Files
Dmitry d2df86ce33 feat(moex): история бумаг с листинга и цены индексов для бенчмарков
Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
2026-09-20 10:46:24 +03:00

19 KiB
Raw Permalink Blame History

AGENTS.md

Контракт для AI-агентов в этом репозитории. Подробности — в docs/ai/; этот файл — индекс, команды и ограничения, не описание архитектуры.

Проект одной фразой

Личная аналитика финансов и инвестиций: ZenMoney (повседневные деньги) + брокеры (T-Invest API, отчёты Сбера и ВТБ) → PostgreSQL → FastAPI → Flutter-клиент. Заменяет Snowball Income. Один пользователь, свой VPS.

Команды

nix develop                 # backend toolchain (uv, python 3.12, postgres 17, just, openapi-generator)
nix develop .#app           # + Flutter
just db-start               # локальный Postgres в ./.pgdata на порту 54329
just migrate                # alembic upgrade head
just api                    # FastAPI с reload, http://127.0.0.1:8000/api/v1/docs
just web                    # то же + Flutter web-сборка с того же origin (WEB_DIR=app/build/web)
just worker                 # планировщик синков
just check                  # ruff + pyright + pytest + проверка дрейфа OpenAPI
just openapi                # экспорт openapi/openapi.json (коммитится)
just gen-client             # Dart-клиент в app/packages/api_client (коммитится)
just revision "msg"         # новая alembic-миграция из изменений моделей

Тесты поднимают собственный Postgres через pg_ctl (pytest-postgresql) — Docker не нужен.

Карта задача → контекст

Задача Что читать
Любое изменение docs/ai/README.md, затем нужный раздел architecture.md
Новый источник данных (backend/src/fintracker/sources/) architecture.md §Источники, sources/base.py, worker/runner.py
Модель данных / миграции (models/, alembic/) architecture.md §Домен, conventions.md
Аналитика / метрики architecture.md §Аналитика, conventions.md §Деньги
API / Flutter-клиент architecture.md §API, openapi/openapi.json
Offline-кэш экрана (фаза 5) offline-cache.md — контракт Cached<T> + CacheInterceptor
Деплой docker-compose.yml, deploy/Caddyfile, ops.md

Обязательные ограничения

  • Деньги — Decimal, в БД NUMERIC(24,10) + колонка валюты рядом. Никаких float в хранении и в API (в JSON — строки). Float допустим только внутри расчёта коэффициентов.
  • Не конвертировать валюту на записи. Конвертация — по курсу на дату операции, в аналитике. Нет курса → NULL и запись в data quality, не подмена.
  • SDK T-Invest (t_tech.invest) импортируется только в sources/tinvest/client.py. Ставится с индекса T-Bank ([tool.uv.index] в backend/pyproject.toml) — не менять на PyPI.
  • Аналитика читает только event.status = confirmed. Snapshot-таблицы — для сверки.
  • Сырые данные (raw_*) append-only и идемпотентны по стабильному id источника.
  • openapi/openapi.json и app/packages/api_client — генерируются, но коммитятся; после изменения роутов запускай just gen-client. В схемах API не выставляй наружу AssetClass: одно из его значений — index, а Dart-енум не может иметь члена с таким именем (конфликт с Enum.index), и сгенерированный клиент перестаёт компилироваться. Стабильные ключи (asset_class, price_status, bucket, period) идут строками.
  • Секреты только в .env.gitignore). Реальные отчёты брокеров — в backend/tests/fixtures/reports/raw/.gitignore); в git только обезличенные фикстуры.
  • Коммиты — Conventional Commits на русском (feat(ledger): …), автор — только пользователь.

Состояние

Фазы 0 и 1 завершены и проверены на живых данных (31 счёт, 5575 транзакций ZenMoney, курсы ЦБ, все экраны работают). Правил классификации пока ноль — переводы и сбережения попадают в расходы.

Фаза 2 в работе. Готово и прогнано на живых данных:

  • sources/tinvest — 7 счетов, 2756 операций → event, маппер на все 67 OperationType;
  • ledger/lots.py + rebuild.py — FIFO, 804 лота, 672 закрытия, реализовано +1892 ₽; короткие позиции поддержаны, qty_remaining хранится со знаком;
  • sources/moex — 77 инструментов, 33 380 дневных цен, price_daily / price_last; бэкфилл ведётся по инструменту через price_coverage, а не по общему курсору источника, и режет окно по доскам: фонды T-Bank переехали с TQTF на TQBR 22.06.2026, и до переезда история отвечает только на старой доске;
  • analytics/valuation — дневная серия стоимости и холдинги по scope (all, account:<id>, portfolio:<id>), сверка со снапшотами брокера; analytics/returns — XIRR и TWR по периодам (1m 3m 6m ytd 1y 3y all) плюс XIRR на инструмент; pricing/prices.py — цена на дату с протяжкой вперёд и порогом устаревания 10 дн.;
  • analytics/allocation — разрезы по классу актива, сектору, стране и валюте; каждый покрывает один и тот же итог (бумаги + кэш), поэтому веса любого из них дают единицу;
  • ledger/matching.py + flow_link — связывание перевода ZenMoney с брокерским deposit/withdrawal (та же валюта, |Δ| ≤ max(1 ₽, 0,5 %), ≤ 3 рабочих дня, жадно 1:1); связанная ZM-транзакция становится internal_transfer. Кандидаты берутся по mirror_of_account_id, account_link и правилу broker_target;
  • ledger/corporate_actions.py — сплиты, амортизации и погашения выводятся из леджера в corporate_action до пересборки лотов;
  • analytics/cashflow_broker — пополнения и выводы по scope и месяцу; читает event заново, а не дневную сумму external_flow_rub: та неттит потоки внутри дня и прячет 512 550 ₽ выводов;
  • sources/tinvest — сектор берётся из потиповых Shares/Bonds/Etfs (в ответе GetInstrumentBy его нет): 73 из 80 инструментов, аллокация по сектору — 9 бакетов;
  • API /analytics/*, /events, /instruments/{id} и экраны «Портфель» (позиции + аллокация), карточка инструмента и «События».

Фаза 2 закрыта. GET /links/unmatched + POST /links + DELETE /links/{id} и экран несматченных линков; GET /analytics/cashflow-broker; POST /instruments/{id}/prices (апсерт по (instrument_id, d), pricing/prices.py подмешивает price_manual в ту же серию, что и price_daily, с тем же протягиванием и порогом устаревания). Оба инструмента без котировок закрываются одним и тем же механизмом: вручную через этот эндпоинт, либо импортом Snowball CSV (фаза 3) — тот пишет price_manual из CUSTOM_HOLDING_PRICE, но закрывает только SIBN6P4, для NDM_TBNK-PP-FIXPRCNT-08.25 цены нет ни в одной выгрузке. Импорт CSV на живую базу ещё не запускался — это на живых данных не проверено, только на фикстурах.

Фаза 3 завершена и прогнана на живых отчётах:

  • sources/reports/ — протокол ReportParser + три парсера: Сбер (HTML), ВТБ (xlsx), универсальный CSV (экспорт Snowball). registry.py выбирает парсер по содержимому файла, и CSV стоит последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой формат;
  • комиссия капитализируется в сделку, отдельным событием не эмитится. И Сбер, и ВТБ печатают её дважды — колонками в сделках и строками в движении денег; обе суммы совпадают (Сбер 138.03 + 19.54, ВТБ 3.64), и второе прочтение задвоило бы её, потому что ledger/lots.py уже кладёт fee в стоимость лота. Парсеры сверяют обе ипостаси на каждом файле и предупреждают при расхождении;
  • расчётные строки («Сделка от …» у Сбера, «Сальдо расчетов по сделкам» у ВТБ, «Движение ценных бумаг» и «Завершенные сделки» у ВТБ) не эмитятся: это денежные и депозитарные ноги уже учтённых сделок;
  • таблица «Информация о зачислениях на ИИС» у Сбера кумулятивна за год до даты формирования отчёта (8 строк в августовском файле, 9 в полном) — читается, но в леджер не идёт, иначе два пересекающихся отчёта задвоили бы пополнения за полгода;
  • ledger/ingest.pyBrokerEventevent, апсерт по dedupe_key; ledger/dedupe.py — shadow-матчинг случая B двумя проходами (точная дата, затем ±1 рабочий день, жадно 1:1, |price| ±0,5 %); ledger/report_import.py — upload/preview/commit и резолв pending_instrument. Инструмент никогда не угадывается: не резолвится — событие ждёт;
  • шаги shadow_dedupe и report_reconcile зарегистрированы перед quality: оба говорят через FINDINGS, который сбрасывается первым шагом refresh и осушается последним;
  • обезличивание — sources/reports/anonymize.py + scripts/anonymize_reports.py; секреты собираются сразу по всему корпусу отчётов, а не пофайлово (Snowball цитирует номер договора Сбера в примечании к переводу). tests/test_fixtures_anonymized.py падает, если в tests/fixtures/reports/ вне raw/ появится ИНН, ФИО или номер счёта;
  • API /imports и /instruments/pending (контракт — docs/ai/import-contract.md), экраны импорта и резолва. pending_router включается ДО routers/instruments.py: FastAPI сопоставляет маршруты по порядку, и /instruments/{instrument_id} с типом int отвечает 422 на нечисловой сегмент, а не проваливается дальше.

Чего в отчётах нет: у Сбера — дивидендов, налогов и купонной секции; у CSV — закрывающих позиций и остатков денег, а также разбивки по счетам (выгрузка сводная по всем брокерам, поэтому годится как независимая сверка потоков, сделок и выплат по портфелю целиком, но не позиций по счетам). Проверку «pdf и xlsx ВТБ дают одинаковые dedupe_key» выполнить не на чем — pdf-выгрузок нет; контракт для будущего pdf-парсера записан в vtb/common.py.

Фаза 4 завершена (157 backend-тестов на аналитику доходов/ребалансировки/налогов/бенчмарков и источники выплат):

  • sources/tinvest/sync_events.py (GetDividends, GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (bondization + dividends ISS) — второй источник выплат; pricing/payouts.resolve_payouts решает, чей купон/дивиденд считать источником истины, на чтении, а не на записи: corporate_action уникален по (instrument_id, kind, source, source_id), так что строки обоих источников сосуществуют, и приоритет можно поменять без ресинка истории. Амортизация из MOEX идёт не в corporate_action, а в bond_nominal_schedule — этим типом безраздельно владеет ledger/corporate_actions.py; оба источника зарегистрированы (tinvest_events, moex_payouts) и стоят в worker/jobs.default_schedule() вместе с tinvest/moex; источники с needs="tinvest_token" не попадают в расписание, пока токен не задан;
  • analytics/income.pymetric_income_monthly (факт, только confirmed) и metric_income_calendar (прошлое и прогноз) с явным basis (paid / announced / history) на каждой строке — три источника числа никогда не смешиваются в одно;
  • analytics/rebalance.py — предложенные сделки по portfolio_target, пропорционально внутри каждого бакета (никогда не по инструменту — у бакета нет целевого веса на бумагу), округление лотов только вниз, покупки ограничены реальным остатком кэша (blocked_by_cash, не заимствует у ещё не свершившихся продаж);
  • analytics/tax.py — оценка, не замена справки брокера: дивиденды/купоны берутся gross (amount + tax), реализованный результат — из lot_disposal в рублях с переоценкой каждой ноги на свою дату, ЛДВ — по lot.holding_days;
  • analytics/benchmarks.py — TWR индекса на сетке дат портфеля; kind (price vs total_return) выставляется наружу, а не скрывается: сравнение с ценовым IMOEX без дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя, какой индекс сравнивать. Цены индексов тянет sources/moex (_ensure_benchmark_instruments создаёт instrument для активной строки benchmark; доска берётся из ISS — MCFTR на RTSI, IMOEX и RGBITR на SNDX); миграция f3a91c7d5e28 засевает IMOEX, MCFTR (оба is_default) и RGBITR. История бумаг и индексов качается с листинга, а не с первой покупки;
  • analytics/goals.py + api/routers/goals.py — прогресс цели и требуемый ежемесячный взнос по trailing XIRR;
  • четыре новых шага в register_steps: benchmarks после returns (общая сетка дат), rebalance после allocation (переиспользует её веса, не пересчитывает), income и tax после lots (нужен lot_disposal).

Порядок шагов пересчёта (analytics/__init__.py:register_steps): fx → classify → matching → corpactions → lots → valuation → returns → benchmarks → allocation → rebalance → cashflow_broker → income → tax → networth → cashflow → spending → runway → shadow_dedupe → report_reconcile → quality. matching обязан идти после classify: тот пересчитывает flow_type с нуля и затёр бы результат линковки.

Известные расхождения derived vs брокерский снапшот по позициям (5 шт) объяснены. Четыре из пяти — не корпоративные действия, а отменённые заявки: GetOperationsByCursor отдаёт операции с state = OPERATION_STATE_CANCELED, у них quantity_done = 0 и нет платежа, и покупка читалась как бесплатное приобретение (SIBN6P4 +5, HEAD +2, FIVE +2, MGNT 1). sync._is_executed их теперь отсеивает и подчищает при перечитывании; уже импортированные строки уходят только при повторном чтении того же окна. Пятое — редомициляция FIVE→X5: зачисления X5 нет ни в одной операции ленты, лот невыводим из данных и требует ручного transfer_in. Без цен два инструмента — SIBN6P4 и NDM_TBNK-PP-FIXPRCNT-08.25, им нужен price_manual. По кэшу derived выше снапшота на счетах 46, 48 и 52 (+6313, +56447, +146 ₽) — вероятно та же отменённая заявка, отдельно не проверялось: счёт 51 сходится точно. TWR пропускает 3 дня вместо прежних 32 — остаток держат две бумаги без котировок. План фаз — в docs/ai/plan.md.