Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions docs/paid-ads-launch-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,40 @@ last thirty days) and the landing page:
telling the founder where the event snippet goes; they run the launch again once conversions
record. This is the hard block.
- `needs_billing`, `needs_link`, `account_disabled`, `campaign_exists`,
`landing_page_unreachable`, `not_recommended`: a `SETUP.md` note is published and the run ends
failed with that reason, since a review-pinned run cannot succeed without an approval.
`landing_page_unreachable`, `not_recommended`, `unsupported_currency`: a `SETUP.md` note is
published and the run ends failed with that reason, since a review-pinned run cannot succeed
without an approval.

## Currency

Google reads every amount in the account's own currency (the `amount_micros` fields of
[`CampaignBudget`](https://developers.google.com/google-ads/api/reference/rpc/v25/CampaignBudget)
and the bidding strategies). Tin's figures start in dollars: the assessment's bids come from a
USD account, and the launch's budget and ceiling inputs are in USD. `ads_currency` bridges the
two once:

- `gather` reads the account's `currency_code`. For USD nothing is converted. For any other
currency it reads the day's [ECB reference rates](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html),
derives units per dollar through the euro and receipts the result as `gather:fx`. A failed
read is a retryable error that writes nothing; a retry reuses the receipt once it exists.
- `plan_skeleton()` works out the budget, ceiling, allowable and target cost per customer in
dollars as before, then converts each once and rounds to Google's minimum unit for the
currency (whole yen, won, rupiah; cents elsewhere). The dollar floors (a $1 budget, a $0.05
ceiling) stay floors at the converted rate. The plan carries `currency_code` and `fx` (rate,
source, date), and `PLAN.md` shows every amount in the account's currency with its dollar
equivalent and the rate used. The founder approves the converted figures.
- Nothing converts again. `campaign.json` carries the same `fx`, and the monitor compares the
account's own costs with the converted allowable cost; proposals carry `currency_code` and
the pinned rate, so `budget_body` and `bidding_body` check the unit and floors in the
account's currency.
- Supported currencies are the ones Google Ads bills in and the ECB publishes (`ads_currency.
MINIMUM_UNIT_MICROS`). Any other, or an account that reports none, gates as
`unsupported_currency` before any rate read, model call or write.
- `apply` refuses a plan whose currency is not the account's. A plan drafted before currencies
were pinned (no `fx`) is read as a USD plan, which is what it was.
- The monitor reads the account's currency each run. A campaign planned in one currency in an
account billing in another (only possible for launches from before currencies were pinned)
gets a `currency_mismatch` alert, plain cost reporting and no cost-based pauses or proposals.

## The plan and the one approval

Expand Down Expand Up @@ -85,7 +117,7 @@ and `campaign.json` (what the monitor consumes) publish under `ads/google/{run}/
## The monitor

`ads.monitor` runs on demand, daily or weekly against a launch whose campaign row is
`live`. It reads the campaign over seven, fourteen and thirty days, the search terms, keywords
`live`. It reads the account's currency, the campaign over seven, fourteen and thirty days, the search terms, keywords
with quality scores, ad policy status, assets, conversion actions and recommendation
subscriptions, each a zero-cost receipt. `paid_ads_monitor.decide()` is deterministic:

Expand Down
200 changes: 200 additions & 0 deletions src/tin_lite/ads_currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""Money in a Google Ads account's own currency.

Tin's campaign figures start in US dollars: the assessment's keyword bids come from a USD
account and the launch inputs are in USD. Google reads every amount in the account's
currency. A launch converts once, at the European Central Bank's reference rate for that day,
rounds to Google's minimum unit for the currency, and pins the rate on the plan the founder
approves. Nothing converts again afterwards: the monitor compares the account's own costs with
the figures pinned at launch, so rate movements never shift a target silently.

Only currencies both Google Ads bills in and the ECB publishes are supported; any other is a
named refusal, never a guessed rate.
"""

from __future__ import annotations

import re
from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal, InvalidOperation

import httpx

ECB_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
ECB_SOURCE = "ECB euro foreign exchange reference rates"
# Google Ads' minimum unit per currency in micros (developers.google.com/google-ads/api/data/
# codes-formats#currency-codes), limited to the currencies the ECB publishes a rate for.
MINIMUM_UNIT_MICROS = {
"AUD": 10_000,
"BRL": 10_000,
"CAD": 10_000,
"CHF": 10_000,
"CNY": 10_000,
"CZK": 10_000,
"DKK": 10_000,
"EUR": 10_000,
"GBP": 10_000,
"HKD": 10_000,
"HUF": 10_000,
"IDR": 1_000_000,
"ILS": 10_000,
"INR": 10_000,
"JPY": 1_000_000,
"KRW": 1_000_000,
"MXN": 10_000,
"MYR": 10_000,
"NOK": 10_000,
"NZD": 10_000,
"PHP": 10_000,
"PLN": 10_000,
"RON": 10_000,
"SEK": 10_000,
"SGD": 10_000,
"THB": 10_000,
"TRY": 10_000,
"USD": 10_000,
"ZAR": 10_000,
}
SYMBOLS = {
"USD": "$",
"EUR": "€",
"GBP": "£",
"JPY": "¥",
"INR": "₹",
"KRW": "₩",
"ILS": "₪",
"AUD": "A$",
"CAD": "CA$",
"NZD": "NZ$",
"HKD": "HK$",
"SGD": "S$",
"MXN": "MX$",
"BRL": "R$",
}
USD_FX = {"currency_code": "USD", "usd_rate": "1", "source": "identity", "date": None}
_MICROS = Decimal(1_000_000)
_CUBE = re.compile(r"<Cube\s+currency=['\"]([A-Z]{3})['\"]\s+rate=['\"]([0-9.]+)['\"]\s*/>")
_TIME = re.compile(r"<Cube\s+time=['\"](\d{4}-\d{2}-\d{2})['\"]")


class RateUnavailable(Exception):
"""The ECB rate could not be read; the code names why, never the provider's text."""

def __init__(self, code: str):
super().__init__(code)
self.code = code


def supported(code) -> bool:
return isinstance(code, str) and code in MINIMUM_UNIT_MICROS


def unit(code: str) -> Decimal:
return Decimal(MINIMUM_UNIT_MICROS[code]) / _MICROS


def unit_micros(code: str) -> int:
return MINIMUM_UNIT_MICROS[code]


def code_of(fx: dict | None) -> str:
return (fx or USD_FX)["currency_code"]


def parse_ecb(text: str) -> dict:
"""The day's euro rates, with EUR itself at one."""
day = _TIME.search(text or "")
rates = {code: rate for code, rate in _CUBE.findall(text or "")}
if not day or "USD" not in rates:
raise RateUnavailable("ecb_feed_unreadable")
try:
parsed = {code: Decimal(rate) for code, rate in rates.items()}
except InvalidOperation:
raise RateUnavailable("ecb_feed_unreadable") from None
if any(not value.is_finite() or value <= 0 for value in parsed.values()):
raise RateUnavailable("ecb_feed_unreadable")
return {"date": day.group(1), "rates": {**rates, "EUR": "1"}}


def fx_for(code: str, feed: dict) -> dict:
"""How many units of `code` one US dollar buys, from one ECB feed (cross rate via EUR)."""
if code == "USD":
return dict(USD_FX)
if not supported(code) or code not in feed["rates"]:
raise RateUnavailable("currency_not_published")
rate = Decimal(feed["rates"][code]) / Decimal(feed["rates"]["USD"])
return {
"currency_code": code,
"usd_rate": str(rate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)),
"source": ECB_SOURCE,
"date": feed["date"],
}


async def read_ecb() -> str:
"""One fixed public read; the caller receipts the parsed rate."""
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client:
response = await client.get(ECB_URL)
except httpx.HTTPError:
raise RateUnavailable("ecb_unreachable") from None
if response.status_code != 200 or len(response.content) > 200_000:
raise RateUnavailable(f"ecb_http_{response.status_code}")
return response.text


def convert(usd, fx: dict | None, *, floor_usd=None) -> float:
"""A USD figure in the account's currency, on Google's minimum unit and never below
`floor_usd`'s equivalent or one unit."""
fx = fx or USD_FX
code = fx["currency_code"]
rate, step = Decimal(fx["usd_rate"]), unit(code)
value = (Decimal(str(usd)) * rate).quantize(step, rounding=ROUND_HALF_UP)
if floor_usd is not None:
floor = (Decimal(str(floor_usd)) * rate).quantize(step, rounding=ROUND_CEILING)
value = max(value, floor, step)
return float(value)


def round_to_unit(amount, code: str) -> float:
return float(Decimal(str(amount)).quantize(unit(code), rounding=ROUND_HALF_UP))


def in_usd(amount, fx: dict | None) -> float:
fx = fx or USD_FX
return float(Decimal(str(amount)) / Decimal(fx["usd_rate"]))


def money(amount, code: str | None, *, cents: bool | None = None) -> str:
"""`$20.00`, `€1,250`, `¥3,000`, `TRY 650.00`; whole units for large or unit-less amounts."""
try:
number = float(amount)
except (TypeError, ValueError):
return "unknown"
if number != number:
return "unknown"
code = code if supported(code) else None
whole = (code is not None and unit(code) >= 1) or (cents is not True and abs(number) >= 100)
text = f"{number:,.0f}" if whole else f"{number:,.2f}"
if code is None:
return f"{text} (account currency)"
symbol = SYMBOLS.get(code)
return f"{symbol}{text}" if symbol else f"{code} {text}"


def with_usd(amount, fx: dict | None) -> str:
"""The account amount, followed by its dollar equivalent when the account is not in USD."""
code = code_of(fx)
shown = money(amount, code)
if code == "USD":
return shown
return f"{shown} (about {money(in_usd(amount, fx), 'USD')})"


def rate_line(fx: dict | None) -> str:
fx = fx or USD_FX
if fx["currency_code"] == "USD":
return "Amounts are in US dollars, the account's currency."
return (
f"Amounts are in {fx['currency_code']}, the account's currency, converted once from the "
f"assessment's US dollar figures at {fx['usd_rate']} {fx['currency_code']} per dollar "
f"({fx['source']}, {fx['date']}). Tin never converts them again."
)
4 changes: 2 additions & 2 deletions src/tin_lite/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -4214,13 +4214,13 @@ def definition_and_files_with_wiki(
"check",
"step",
"Read the assessment and account",
"link, billing, conversions, tag on the page",
"link, billing, currency, conversions, page tag",
),
DiagramNode(
"plan",
"step",
"Set budget, bids and groups",
"code only, up to 5 groups, never broad match",
"code only, account currency at the ECB rate",
),
DiagramNode(
"tracking",
Expand Down
5 changes: 3 additions & 2 deletions src/tin_lite/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import asyncpg

from tin_lite import analytics
from tin_lite import ads_currency, analytics
from tin_lite.domain import (
PROJECT_TASK_WORKFLOW_NAME,
ActivityEvent,
Expand Down Expand Up @@ -8375,9 +8375,10 @@ def _proposal(row: asyncpg.Record) -> dict[str, Any]:
def _proposal_summary(proposal: dict[str, Any]) -> str:
proposed = proposal.get("proposed") or {}
if proposal.get("kind") == "budget_change":
amount = proposed.get("daily_budget", proposed.get("daily_budget_usd"))
return (
f"Proposal {proposal.get('proposal_number')}: change the daily budget to "
f"${proposed.get('daily_budget_usd')}."
f"{ads_currency.money(amount, proposed.get('currency_code') or 'USD', cents=True)}."
)
return (
f"Proposal {proposal.get('proposal_number')}: switch bidding to "
Expand Down
Loading
Loading