feat(cbr): USD и CNY тянутся всегда, новая валюта — с начала леджера
Валюта, впервые появившаяся в ZenMoney между двумя запусками, раньше показывала «нет курса» до следующего синка. Для валюты без сохранённых курсов история берётся с начала леджера, а не от общего курсора.
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
"""The `cbr` source: official RUB rates for every currency the ledger actually uses.
|
||||
|
||||
What to fetch is derived from the data, not configured: the distinct currencies of
|
||||
`account.currency`, `cash_txn.income_currency` and `cash_txn.outcome_currency` (RUB itself
|
||||
is quoted as 1.0 by `pricing/fx.py`, so it is never requested).
|
||||
What to fetch: `ALWAYS_QUOTED` (the dollar and the yuan) whatever the data says, plus every
|
||||
currency the data uses — the distinct currencies of `account.currency`, `cash_txn.income_currency`
|
||||
and `cash_txn.outcome_currency`. RUB itself is quoted as 1.0 by `pricing/fx.py`, so it is never
|
||||
requested. The always-quoted pair exists because a currency that first appears in ZenMoney
|
||||
between two runs would otherwise show «нет курса» until the next run picked it up.
|
||||
|
||||
A currency with no stored rates yet is fetched from the start of the ledger, not from the shared
|
||||
cursor: the cursor only knows what the OTHER currencies already have, and a new one needs the rates
|
||||
for every past transaction it appears in.
|
||||
|
||||
Date range: from the earliest transaction minus 7 days (or today − 30 days when there are
|
||||
no transactions yet) up to today — `analytics.today_local()`, the deployment timezone, the
|
||||
@@ -41,6 +47,8 @@ CURSOR_OVERLAP_DAYS = 3
|
||||
BACKFILL_TOLERANCE_DAYS = 14
|
||||
"""How far the first stored quote may legitimately sit after the wanted start (holidays)."""
|
||||
NO_HISTORY_DAYS = 30
|
||||
ALWAYS_QUOTED = ("USD", "CNY")
|
||||
"""Fetched every run, in use or not: the two foreign currencies the ledger meets first."""
|
||||
CHUNK = 500
|
||||
|
||||
|
||||
@@ -50,16 +58,9 @@ class CbrSource:
|
||||
async def sync(self, ctx: SyncContext) -> SyncResult:
|
||||
session = ctx.session
|
||||
today = today_local()
|
||||
currencies = await currencies_in_use(session)
|
||||
if not currencies:
|
||||
log.info("cbr: only %s in use, nothing to fetch", BASE)
|
||||
return SyncResult(
|
||||
cursor_after=today.isoformat(),
|
||||
counts={"currencies": 0, "rates": 0},
|
||||
changed=False,
|
||||
)
|
||||
|
||||
start = await _start_date(session, ctx.cursor_before, today)
|
||||
currencies = sorted(set(await currencies_in_use(session)) | set(ALWAYS_QUOTED))
|
||||
full_start = await _full_start(session, today)
|
||||
start = await _start_date(session, ctx.cursor_before, today, full_start)
|
||||
warnings: list[str] = []
|
||||
stored = 0
|
||||
async with CbrClient() as client:
|
||||
@@ -70,7 +71,11 @@ class CbrSource:
|
||||
if not cbr_id:
|
||||
warnings.append(f"{ccy} is not quoted by CBR — skipped (crypto or metal?)")
|
||||
continue
|
||||
quotes = await client.quotes(cbr_id, start, today)
|
||||
# a currency the table has never seen needs the whole history, cursor or not
|
||||
have = await session.scalar(
|
||||
select(RawCbrRate.ccy).where(RawCbrRate.ccy == ccy).limit(1)
|
||||
)
|
||||
quotes = await client.quotes(cbr_id, start if have else full_start, today)
|
||||
stored += await _store(session, ccy, quotes)
|
||||
fetched_currencies += 1
|
||||
|
||||
@@ -104,13 +109,19 @@ async def currencies_in_use(session: AsyncSession) -> list[str]:
|
||||
return sorted(code for code in found if code != BASE)
|
||||
|
||||
|
||||
async def _start_date(session: AsyncSession, cursor: str | None, today: date) -> date:
|
||||
async def _full_start(session: AsyncSession, today: date) -> date:
|
||||
"""Where the full history begins: a week before the first transaction, else 30 days back."""
|
||||
first_txn, _ = await ledger_date_range(session)
|
||||
full_start = (
|
||||
return (
|
||||
first_txn - timedelta(days=LOOKBACK_DAYS)
|
||||
if first_txn is not None
|
||||
else today - timedelta(days=NO_HISTORY_DAYS)
|
||||
)
|
||||
|
||||
|
||||
async def _start_date(
|
||||
session: AsyncSession, cursor: str | None, today: date, full_start: date
|
||||
) -> date:
|
||||
cursor_date = _parse_date(cursor)
|
||||
if cursor_date is None:
|
||||
return full_start
|
||||
|
||||
Reference in New Issue
Block a user