Commit Graph
28 Commits
Author SHA1 Message Date
Dmitry 4552efbf5d fix(tinvest): чистка отменённых операций, цена бумаги от брокера, логотипы эмитентов
Отменённые и незавершённые операции удаляются из event по raw_tinvest_operation, а не только из текущего окна синка. Бумага, которую MOEX не котирует (структурные ноты, внебиржевые облигации), получает цену из портфеля брокера. Логотип и цвет бренда копируются в instrument (миграция e8b21f6a90c3).
2026-09-19 21:54:47 +03:00
Dmitry 4236993106 feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
2026-09-19 21:54:42 +03:00
Dmitry 1b521be35e fix(analytics): нереалистичная годовая доходность не валит пересчёт метрик
XIRR за несколько дней истории даёт артефакт солвера за ±100 000 000 %, который переполнял NUMERIC(24,10) и ронял весь refresh. Такое значение теперь считается «нет ответа».
2026-09-19 21:54:36 +03:00
Dmitry 3b3ee4d682 feat(worker): ручной пересчёт метрик через очередь, диагностика шагов и heartbeat
POST /metrics/refresh ставит задачу в sync_job (source=METRICS_JOB) и отвечает 202, пересчёт делает worker; GET /metrics/status отдаёт refreshing и consistent. metric_refresh_log хранит failed_step и step_timings. Источники с needs="tinvest_token" не попадают в расписание без токена, tinvest/moex добавлены в default_schedule. Воркер трогает heartbeat-файл для healthcheck.
2026-09-19 21:54:30 +03:00
Dmitry baac83149f fix(api): Decimal-параметр ребалансировки ломал генерацию Dart-клиента
cash_available был bare Decimal | None в Query(...) — pydantic отдаёт для
Decimal anyOf[number, pattern-string], который openapi-generator не умеет
свернуть в один тип и вместо этого синтезирует пустой класс CashAvailable
без полей (синтаксически битый .dart, компиляция всего пакета падает).
Остальные Decimal-поля в API давно оборачиваются в Money/MoneyOpt
(api/schemas/common.py) ровно за счёт этого — anyOf[string, null] генератор
понимает. cash_available обошёл соглашение, потому что это единственный
Decimal-параметр запроса во всём API, а не тело ответа.
2026-09-19 10:56:33 +03:00
Dmitry 15f5812ea4 feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4
Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
2026-09-19 10:42:50 +03:00
Dmitry ff3b76871d feat(ledger): импорт отчётов в леджер — приём, дедупликация, pending_instrument
Поток: upload -> raw_report_file (sha256 UNIQUE) -> parse -> raw_report_line,
событий в леджере ещё нет -> preview -> POST /imports/{id}/commit ->
ledger/ingest.py резолвит инструмент, считает dedupe_key, пишет event
confirmed | shadow (plan §1.6 B) — сравнивая account.primary_event_source
с источником отчёта, а не гадая. Нерезолвленный инструмент ждёт в
pending_instrument, никогда не угадывается; POST /instruments/pending/{id}/resolve
привязывает и пересобирает лоты.

ledger/dedupe.py — shadow-матчинг случая B двумя проходами (точная дата, затем
±1 рабочий день, жадно 1:1, |price| ±0,5 %). Шаги shadow_dedupe и
report_reconcile зарегистрированы перед quality: оба говорят через FINDINGS.

/instruments/pending регистрируется в app.py ДО routers/instruments.py:
FastAPI сопоставляет маршруты по порядку, и /instruments/{id} с типом int
отвечает 422 на нечисловой сегмент, а не проваливается дальше.

Контракт — docs/ai/import-contract.md, общий для бэкенда и Flutter.
2026-09-19 10:40:04 +03:00
Dmitry 2e742a093b feat(reports): протокол и парсеры отчётов Сбера, ВТБ и Snowball CSV
sources/reports/base.py — контракт ReportParser (плагины: sniff/parse, чистые
функции без БД). registry.py выбирает парсер по содержимому файла, CSV
последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой
формат.

Комиссия капитализируется в сделку, отдельным событием не эмитится: и Сбер,
и ВТБ печатают её дважды — колонками в сделках и строками в движении денег,
суммы совпадают, второе прочтение задвоило бы её. Расчётные строки («Сделка
от …», «Сальдо расчетов по сделкам») не эмитятся — это денежные ноги уже
учтённых сделок. У Сбера таблица «Информация о зачислениях на ИИС»
кумулятивна за календарный год и в леджер не идёт, иначе два пересекающихся
отчёта задвоили бы пополнения. CSV Snowball сводный по всем брокерам —
годится как сверка потоков и сделок на уровне портфеля, но не позиций по
счетам; CUSTOM_HOLDING_PRICE не событие, а цена — уходит в meta для
price_manual.

Обезличивание — anonymize.py + scripts/anonymize_reports.py, секреты
собираются по всему корпусу отчётов разом (Snowball цитирует номер договора
Сбера в примечании к переводу). test_fixtures_anonymized.py падает, если
в tests/fixtures/reports/ вне raw/ найдётся ИНН, ФИО или номер счёта — и по
форме (работает в CI без raw/), и по фактическому содержимому raw/, когда оно
на месте.
2026-09-19 10:39:37 +03:00
Dmitry 6b32c79e79 feat(analytics): брокерские потоки по месяцам и ручные цены инструментов
GET /analytics/cashflow-broker читает metric_cash_flow_broker. POST
/instruments/{id}/prices апсертит price_manual по (instrument_id, d);
pricing/prices.py подмешивает его в ту же серию, что price_daily, с тем же
протягиванием и порогом устаревания.
2026-09-19 10:38:59 +03:00
Dmitry ae82df6fa7 feat(ledger): резолв несматченных линков ZenMoney↔брокер
GET /links/unmatched, POST /links, DELETE /links/{id}: ручная привязка того,
что ledger/matching.py не смог сопоставить автоматически.
2026-09-19 10:38:39 +03:00
Dmitry 885137df4f feat(moex): бэкфилл истории цен по инструменту, а не по общему курсору
Курсор источника был один на все бумаги — дата последнего прогона, — и окно бралось
как max(first_held, cursor − OVERLAP). Инструмент, появившийся в леджере после первого
прогона, получал историю не с дня, когда его впервые держали, а с cursor минус пять
дней, и дыра в прошлом не закрывалась уже никогда. Отсюда TBRU@ с 14.09.2026 при
владении с 08.07.2025 и 32 дня, которые пропускал TWR.

price_coverage хранит, с какой даты мы УЖЕ спрашивали ISS. Пока эта дата позже дня
первого владения, бумага разово вычитывается целиком, дальше идёт обычный инкремент —
бэкфилл не превращает каждый прогон в полную перекачку. Инкрементное окно якорится на
max(price_daily.d) самой бумаги, а не на дате прогона, поэтому упавший прогон
самозалечивается вместо того, чтобы оставить за собой новую дыру.

Вторая причина дыр нашлась на живых данных: 22.06.2026 MOEX перевёл фонды T-Bank с
TQTF на TQBR, и история до переезда отвечает только на старой доске, а после — только
на новой. Поэтому выбор доски перестал залипать на умершую, а окно режется на отрезки
по доскам. Девять фондов из-за этого вообще перестали получать цены с 19.06 — они же
сидели в data quality как stale_price.

Записи идут upsert-ом по (instrument_id, d) плюс дедупликация по дню внутри statement:
стык досок иначе даёт два значения на одну дату.

Разрыв длиннее 14 дней внутри истории попадает в warnings, но не перезапрашивается на
каждом прогоне: бумага, которая честно не торговалась месяц, качалась бы вечно. Порог
взят по данным — новогодние каникулы дают около одиннадцати дней.

На живых данных: 31 154 → 33 380 дневных цен, четыре бумаги получили историю с первого
дня владения, TWR пропускает 3 дня вместо 32. Остаток держат SIBN6P4 и
NDM_TBNK-PP-FIXPRCNT-08.25, которых на ISS нет вовсе — им нужен price_manual.
2026-09-18 15:08:01 +03:00
Dmitry 88979869f7 fix(tinvest): не принимать отменённую заявку за сделку
GetOperationsByCursor отдаёт отменённые заявки наравне с исполненными, а отменённая —
это не сделка, которая сорвалась, а сделка, которой не было. Приходит она с
quantity_done = 0, количеством, которое заявка только ПРОСИЛА, в quantity, и без
платежа вообще, — а _operation при нулевом quantity_done откатывался на quantity.
Покупка читалась как бесплатное приобретение: позиция росла, деньги не убывали.

Это и есть четыре из пяти известных расхождений derived с брокерским снапшотом, а не
корпоративные действия, как записано в AGENTS.md: SIBN6P4 +5, HEAD +2, FIVE +2,
MGNT −1. В сырых операциях ровно десять строк с state = OPERATION_STATE_CANCELED, и
разбивка по бумагам сходится с расхождением ровно. Пятое, FIVE→X5, действительно
редомициляция: зачисления X5 нет ни в одной операции ленты, лот невыводим из данных.

Фильтрует только event. Сырые остаются как есть — это журнал того, что отдал источник,
и их отсутствие было бы собственной загадкой. Отсутствие state вообще считается
исполнением: старый payload без поля не должен молча превращаться в отмену.

Перечитывание окна теперь ещё и удаляет отменённую заявку, импортированную до этой
проверки, — иначе плохая строка осталась бы в леджере навсегда.
2026-09-18 15:07:41 +03:00
Dmitry 582b859e56 feat(tinvest): сектор инструмента из потиповых справочников
GetInstrumentBy отдаёт общую запись, в которой поля sector нет вовсе — из-за этого
сектор не был заполнен ни у одного инструмента, и аллокация по нему показывала один
бакет unknown на все двадцать. Сектор живёт только в Shares/Bonds/Etfs, поэтому
резолв добивает недостающие поля из потипового справочника: по uid, затем figi, затем
ticker+board. Логика разрешения uid не тронута — одна бумага приезжает под несколькими
instrument_uid, и InstrumentAlias с _match_by_identity остаются единственным местом,
которое это разбирает.

Справочник тянется лениво и один раз на экземпляр клиента, и только для встреченных
типов: это 2000-5000 записей на тип и около 14 секунд на все три.

_backfill_sectors нужен отдельно от резолва: у уже известных бумаг uid не попадает в
missing, и без отдельного прохода они никогда бы не переразрешились.

Пустой сектор остаётся NULL, строка "unknown" не пишется никогда — это литерал самой
аллокации, и данные, притворяющиеся ответом, отличить от отсутствия ответа нельзя.
Из 80 инструментов сектор заполнен у 73. Остальные семь честно пустые: валюта, пять
фондов, у которых поле пусто у самого T-Invest, и внебиржевой структурный продукт.
2026-09-18 15:07:18 +03:00
Dmitry 1ceeba2c48 feat(analytics): брокерские потоки по месяцам и порядок шагов пересчёта
cashflow_broker читает event заново по EXTERNAL_FLOW_KINDS, а не агрегирует готовый
external_flow_rub. Дневная серия неттит потоки по (счёт, валюта, день) ДО конвертации,
поэтому пополнение и вывод одного дня схлопываются, и разбивку из неё не восстановить:
на живых данных так спрятано 512 550 ₽ выводов, и все 40 месяцев выглядели бы как
«только пополнения». Правила чтения скопированы из valuation._load_deltas один в один,
поэтому net сходится с external_flow_rub по всем 40 месяцам до последнего знака.

Месяц без потоков строки не порождает: разрежённый ряд позволяет клиенту отличить
«ничего не было» от «вышло в ноль», а дорисовать нули он может сам.

Порядок шагов: fx → classify → matching → corpactions → lots → … → cashflow_broker →
networth → …. matching строго ПОСЛЕ classify, потому что classify пересчитывает
flow_type всех транзакций с нуля из правил и затёр бы internal_transfer, проставленный
линковкой; и строго ДО networth и cashflow, которые этот flow_type читают. corpactions
строго ДО lots: rebuild._split_ratios берёт коэффициенты из corporate_action.
2026-09-18 15:06:19 +03:00
Dmitry eb4eae5beb feat(ledger): корпоративные действия из операций брокера
derive() выводит сплиты, амортизации и погашения из самого леджера и складывает их в
corporate_action; rewrite_splits схлопывает пару безденежных transfer_out/transfer_in
в синтетический split. Без этого перерегистрация бумаги реализовалась бы как фиктивный
round-trip: срок владения обнулился бы, а с ним и право на ЛДВ.

amount_per_unit считается пулом по инструменту и дате: одно действие эмитента приходит
отдельной операцией на каждый счёт, и делить надо на суммарную позицию. Позиция для
деления реплеится внутри модуля, потому что шаг обязан идти ДО лотов — именно лоты
потребляют то, что он пишет. Нет позиции — NULL и находка corporate_action_unpriced,
не подставленное число.

Модуль пишет и удаляет только split, amortization и repayment. Дивиденды и купоны он
не трогает: их календарь придёт из фида эмитента, и чистка снесла бы объявленные.

Живых сплитов и погашений в данных нет — эти ветки покрыты только синтетикой. Из
реального есть девять BOND_REPAYMENT, которые пулятся в семь амортизаций.
2026-09-18 15:06:06 +03:00
Dmitry ff4d63e829 feat(ledger): связь перевода ZenMoney с брокерским пополнением
Случай C из плана §1.6: без него одни и те же деньги считаются дважды, а перевод на
брокерский счёт выглядит расходом.

match_flows — чистая функция над двумя последовательностями кандидатов, как apply() в
lots.py; rebuild_flow_links её обвязка с сессией. Скоринг: та же валюта, тот же счёт,
|Δ| ≤ max(1 ₽, 0,5 %), разрыв ≤ 3 РАБОЧИХ дня. Рабочих, а не календарных: деньги на
брокерский счёт в субботу не приходят, и календарное окно систематически теряло бы
пятничные переводы. Праздники не моделируются — лишний праздник делает матчер строже,
а не наглее.

Направление выбрано по данным, а не по тексту плана. План говорит «outcome на
зеркальный счёт = пополнение», но зеркальный ZM-счёт это отражение брокерского кэша:
деньги идут «карта → зеркало» (income на зеркале), а на брокере в тот же день deposit.
На живых данных income-соглашение даёт 525 пар, обратное — 4. Для маршрута через
правило broker_target, где брокера в ZenMoney нет вовсе, направление остаётся как в
плане: outcome ↔ deposit.

Жадность 1:1 по (дельта суммы, разрыв в днях): пара берётся, только если свободны обе
стороны. Ручные линки не пересобираются — обе их стороны исключаются из пулов, иначе
автоматика молча переписывала бы решение человека.

Связанная транзакция получает flow_type = internal_transfer здесь, а не в classify:
классификатор не может знать о линках, которых на момент его работы ещё нет.
2026-09-18 15:05:53 +03:00
Dmitry e974ea9ffa feat(db): flow_link, metric_cash_flow_broker и price_coverage
Три таблицы одной миграцией, а не тремя: автогенерация из трёх параллельных веток
дала бы три ревизии с общим down_revision, то есть ручную разборку ветвления вместо
экономии.

price_coverage засевается прямо в миграции из price_daily: она отвечает на вопрос «с
какой даты мы УЖЕ спрашивали ISS», и без засева первый же прогон moex перекачал бы
историю всех 77 бумаг целиком.

flow_link_kind снимается на откате явно. DROP TABLE оставляет тип в базе, и следующий
upgrade упал бы на CREATE TYPE — то же, что уже сделано для остальных енумов домена.
2026-09-18 15:05:36 +03:00
Dmitry 3727419506 feat(api): аналитика инвестиций, события и карточка инструмента
/analytics/{scopes,summary,value-series,holdings,returns,allocation}, /events с
фильтрами и /instruments/{id}. На запрос ничего не считается — это чтение metric_*,
благодаря чему экраны читаются быстро и показывают одно и то же число.

scope (all | account:<id> | portfolio:<id>) резолвится через ту же функцию, что его
построила, valuation.account_scopes: scope, для которого метрик нет, отдаёт 404, а не
пустой график, который читался бы как пустой портфель.

asset_class уходит наружу строкой, а не енумом. Одно из его значений — index, а
Dart-енум не может назвать член index: он конфликтует с Enum.index, и сгенерированный
клиент перестаёт компилироваться. flutter analyze это пропускает, flutter test ловит.

Фильтр /events?external_flow=false сравнивает meta через is_not_distinct_from, а не
через равенство: у события без meta сравнение даёт NULL, NOT NULL это тоже NULL, и
равенство выбрасывало бы такие события из ОБЕИХ половин фильтра.
2026-09-18 14:20:50 +03:00
Dmitry b58ffb3aac feat(analytics): аллокация портфеля по классу, сектору, стране и валюте
Каждое измерение покрывает ОДИН И ТОТ ЖЕ итог — бумаги плюс кэш. Четыре диаграммы
одного портфеля обязаны быть одного размера, иначе экраны противоречат друг другу,
поэтому кэш это бакет в каждом разрезе, а не то, что выброшено из тех, куда он
неочевидно ложится. Исключение — валютный разрез: деньги в рубле лежат вместе с
рублёвыми бумагами, потому что вопрос к этой диаграмме именно такой.

Бакет хранится ключом, а не подписью: класс актива как есть, сектор и страна как их
пишет источник, плюс два литерала — cash и unknown. Язык живёт в клиенте; зашивать
его в данные значит зашивать один язык навсегда.

Позиция без цены исключается, а не считается нулём: ноль тихо ужал бы все остальные
веса. Короткая позиция сохраняет свою величину, но не уменьшает знаменатель — иначе
длинная сторона вылезла бы за 100 %, что на круговой диаграмме не значит ничего.

Derived-кэш вынесен в valuation.cash_balances(): одно определение «нашего кэша» для
сверки со снапшотом брокера и для аллокации, с одним и тем же исключением покупок с
карты, деньги которых баланс счёта никогда не видел.
2026-09-18 14:20:37 +03:00
Dmitry c203ae65fc feat(analytics): оценка позиций, XIRR и TWR
pricing/prices.py — цена на дату: последний close тянется вперёд, после 10 дней
считается протухшей, но всё ещё используется. Назад не тянется никогда — цена из
будущего это выдумка, а не оценка.

valuation берёт два разных источника намеренно. Дневная серия — реплей event
(только он отвечает, сколько стоило в марте), текущие холдинги — из lot, где
есть себестоимость и учтены сплиты. Расхождение между ними на последний день
становится находкой, а не поводом выбрать одно из двух. Отчётная единица —
scope: all, account:<id>, portfolio:<id>.

returns читает только metric_portfolio_value_daily. XIRR — по внешним потокам и
терминальной стоимости; TWR — цепочкой V_t / (V_{t-1} + F_t). План пишет формулу
как (V_t - F_t) / V_{t-1}, то есть с потоком в конце дня; поток в начале даёт то
же число при нулевом потоке, не требует особого случая на первый день и относит
движение рынка к деньгам, которые в этот день уже работали.

Покупка бумаги без цены трактуется как вывод из оцениваемого портфеля
(unvalued_flow_rub): иначе деньги уходят из оценки, а бумага в неё не попадает,
и день читается как обвал — именно это фонд денежного рынка без фида MOEX
устроил серии 2024 года. Пропускается только день, в который меняется ЧИСЛО
неоценённых позиций, и их счётчик уходит в metric_data_quality.

Нет цены или курса — NULL и замечание, не ноль: SIBN6P4 в холдингах именно так и
выглядит. Валютные «позиции» из сверки исключены, их двойник — cash_snapshot, а
не лот; после этого расхождений с брокером ровно пять известных.

Проверка из плана закрыта тестами: взнос 100 и 110 через год дают XIRR 10,0 % и
TWR 10,0 %, второй взнос двигает XIRR и не трогает TWR подпериодов.
2026-09-18 13:44:50 +03:00
Dmitry 53096c207e feat(moex): источник MOEX — справочник инструментов и дневные цены
ISS без ключа: история по доске, текущие котировки, метаданные бумаг. Облигации
приходят в процентах от номинала, поэтому price_daily хранит и price_pct как
опубликовано, и close как денежную величину, плюс НКД рядом.
2026-09-18 13:44:31 +03:00
Dmitry 1adb1c16df feat(tinvest): источник T-Invest — операции, снапшоты и справочник
SDK t_tech.invest импортируется ровно в client.py: остальной код видит обычные
Decimal и dataclass'ы. Операции читаются по курсору GetOperationsByCursor,
снапшоты GetPortfolio/GetPositions складываются в position_snapshot и
cash_snapshot — только для сверки, аналитика их не читает.

mapper.py — явный словарь OperationType -> EventKind на все значения enum SDK,
покрытый тестом: новый тип должен ломать тест, а не молча уезжать в other.

Покупка с привязанной карты помечается meta.card_funded: деньги пришли снаружи,
баланс счёта их не видел, и для доходности это внешний поток, а не внутреннее
движение.
2026-09-18 13:44:31 +03:00
Dmitry 012a40981f feat(ledger): единый леджер событий и FIFO-лоты
Одна таблица event, куда маппится каждый брокерский источник: quantity знаковый
по эффекту на позицию, amount — по эффекту на кэш, dedupe_key UNIQUE делает
повторный импорт пустой операцией. Аналитика читает только confirmed.

lots.apply() — чистая функция без сессии, rebuild.py её обвязка с БД. FIFO по
(счёт, инструмент), как требует ст. 214.1 НК; комиссии капитализируются в
покупку и вычитаются из продажи, НКД в себестоимость не входит — это деньги,
авансированные продавцу и возвращаемые купоном.

Короткие продажи — тоже лоты: продажа без остатка открывает короткий лот,
покупка его закрывает, прибыль равна падению цены. В данных такое есть (TATN
продан 22.08 и выкуплен 29.08); считать это ошибкой значило бы оставить
фантомный длинный лот навсегда и потерять реализованную прибыль.

Всё пересобирается с нуля на каждом refresh: объёмы личные, это секунды, зато
исчезает целый класс багов расхождения инкремента с леджером.
2026-09-18 13:44:31 +03:00
Dmitry b9c12fa1a1 feat(analytics): метрики фазы 1 — классификация, net worth, потоки, расходы, runway
fx_rate_daily получает строку на каждый календарный день: котировки ЦБ тянутся
вперёд (и назад до первой), is_carried это помечает, RUB = 1.0 всегда. Дальше
любая сумма конвертируется по курсу СВОЕЙ даты, а не сегодняшнему.

Net worth восстанавливается назад от текущего account.balance по транзакциям —
ZenMoney отдаёт остаток, а не историю; поэтому сегодняшняя строка совпадает с
тем, что показывает ZenMoney, а каждая прошлая с ней согласована.

Нет курса — не подстановка, а NULL и строка в metric_data_quality. Туда же
попадает то, что шаги заметили по дороге: правило без совпадений, счёт без
баланса, перевод через границу net worth.
2026-09-18 13:44:09 +03:00
Dmitry c55fe19e48 feat(sources): контракт источников, worker и синк ZenMoney + ЦБ
Source.sync(ctx) -> SyncResult пишет только raw_* и возвращает курсор; локи,
журнал, ошибки и продвижение курсора берёт на себя worker/runner.

ZenMoney читается единственным доступным способом — POST /v8/diff/ по
serverTimestamp; токен живёт сутки, поэтому worker ротирует refresh_token через
source_credential. Маппер всегда пересобирает core из полных raw_*, так что
удаление в ZenMoney исчезает и у нас.

ЦБ ходит мимо прокси (trust_env=False) и отдаёт cp1251 с делением на Nominal.
Курсы только по рабочим дням — протяжку по календарю делает аналитика.

Планировщик — APScheduler в отдельном процессе, на источник advisory-лок
sync:<name>, чтобы ручной запуск не пересёкся с плановым.
2026-09-18 13:43:49 +03:00
Dmitry 3fc7a954b9 feat(api): FastAPI — auth, RFC 7807 и роуты фазы 1
Префикс /api/v1, operationId = "<tag>_<name>", чтобы Dart-клиент получил методы
вроде accountsList, а не list_accounts_api_v1_accounts_get. Ошибки —
application/problem+json. Auth: access-JWT на час + refresh на 30 дней, который
хранится хэшем и ротируется, логин ограничен по частоте в памяти.

Деньги в JSON — всегда позиционные строки (api/schemas/common.py): float в
проводе потерял бы копейки, которые NUMERIC(24,10) бережёт.

Тестовый harness поднимает свой Postgres через pg_ctl (pytest-postgresql),
мигрирует его один раз на сессию и усекает таблицы после каждого теста.
2026-09-18 13:43:49 +03:00
Dmitry 295438914d feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция
SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде
NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят
значения, а не имена, чтобы база читалась так же, как API.

Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job.
metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные
пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою
таблицу целиком, два одновременных прогона затёрли бы друг друга.
2026-09-18 13:43:31 +03:00
Dmitry 563308a08b chore: монорепо, dev-окружение и деплой
Nix flake на два шелла (backend и + Flutter), justfile как единая точка входа,
локальный Postgres в ./.pgdata на порту 54329, docker-compose из пяти сервисов
(db, api, worker, caddy, pg-backup) и CI на ruff/pyright/pytest + дрейф OpenAPI.

Российские корневые сертификаты лежат в репозитории: они публичные, но нужны и
Caddy, и gRPC-каналу T-Invest, а глобальный gitignore отбрасывает *.pem.
2026-09-18 13:43:17 +03:00