Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
Крайние подписи оси Y (414.6K поверх 400K) скрыты, числа в русской краткой форме, подписи оси X не обрезаются справа. Добавлена легенда: сплошная линия — стоимость, пунктир — вложено нетто.
Переключатель периода (7д…все и свой диапазон), режимы «Цена ₽ / ₽ + НКД / %», наложение бенчмарков в процентах (у ценового индекса пометка «без дивидендов»), изменение за период, заливка, подписи min/max, курсор с подсказкой и точки покупок и продаж. Ось X по датам, подписи осей без наложений.
У облигаций из ленты операций не было ни даты погашения, ни номинала, а погашение строилось именно от них. moex_payouts дописывает оба поля из паспорта бумаги (только пустые). Аналитика берёт дату погашения из графика номинала, если поля нет, считает сумму по номиналу накануне, а последний шаг амортизации не учитывает дважды.
Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
CRUD портфелей (/portfolios), ручные события (POST /events, DELETE только для manual), PATCH /instruments/{id}, GET /analytics/overview — карточка на каждый скоуп одним запросом. Холдинги отдают logo_url и logo_color. openapi.json обновлён.
TinvestClient.daily_candles отдаёт завершённые дневные свечи, sync добирает ими историю к цене брокера (_backfill_broker_prices). Тесты БД в этой среде не запускались (нет pg_config).
День, выводящий всё, кроме 1 % стоимости (EMPTIED_SHARE), не участвует в цепочке: остаток в пару рублей превращал комиссию или округление в −60 % за день, и несколько таких опустошений обнуляли всю историю.
Миграции вынесены из команды api в one-shot сервис migrate, api и worker стартуют после него. /health отвечает 503 при недоступной БД. Отчёт больше MAX_UPLOAD_BYTES (25 МиБ) получает 413, Caddy режет на 30 МБ раньше. Том uploads убран, секреты env_file передаются сервисам явно. В CI добавлена сборка образа бэкенда без push, test_migrations сверяет модели с историей Alembic.
get_current_user навешивается на роутер целиком, публичными остаются только health и auth (список закреплён tests/api/test_route_auth.py). API не стартует с плейсхолдером или коротким JWT_SECRET без ALLOW_DEV_SECRET=1. Лимитер логинов ограничен по числу ключей. Клиентский IP берётся из последнего хопа X-Forwarded-For.
Валюта, впервые появившаяся в ZenMoney между двумя запусками, раньше показывала «нет курса» до следующего синка. Для валюты без сохранённых курсов история берётся с начала леджера, а не от общего курсора.
Отменённые и незавершённые операции удаляются из event по raw_tinvest_operation, а не только из текущего окна синка. Бумага, которую MOEX не котирует (структурные ноты, внебиржевые облигации), получает цену из портфеля брокера. Логотип и цвет бренда копируются в instrument (миграция e8b21f6a90c3).
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
XIRR за несколько дней истории даёт артефакт солвера за ±100 000 000 %, который переполнял NUMERIC(24,10) и ронял весь refresh. Такое значение теперь считается «нет ответа».
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.
Описывает токены темы, SectionHeader/TileCarousel, NavSidebar и
осознанные побочные эффекты (dataQualityProvider/meProvider watch из
шелла), а также что ещё не редизайнено — health_page.dart и карточки
holdings_tab.dart/goal_card.dart.
Локальные _StatTile/_ChartCard заменены на общие StatTile/SectionCard,
блоки капитала/месяца/инвестиций получили SectionHeader, ряды плиток
переведены с Wrap на TileCarousel. Заодно цвет «ок» в чипе качества
данных переведён с хардкод Colors.green на ChartColors.slot3Aqua —
тот же цвет, что signColor уже использует для положительного знака.
NavSidebar (>=1200): пункты меню сгруппированы под капс-заголовками,
у активного пункта левая акцентная полоска, у «Здоровье» — счётчик
замечаний качества данных из dataQualityProvider, внизу закреплён
профиль (аватар-инициал + email из meProvider). Список направлений
вынесен в nav_destinations.dart — общий для app_shell.dart и
nav_sidebar.dart. На 600–1200 остаётся свёрнутый NavigationRail: в
иконку-без-подписи заголовки групп и профиль всё равно не помещаются.
NavSidebar смонтирован всё время сессии и держит dataQualityProvider
и meProvider живыми — раньше эти запросы уходили только при открытии
Обзора/Здоровья/Настроек.
SectionHeader — заголовок блока с короткой акцентной полоской вместо
разделителя. TileCarousel — горизонтальная прокрутка StatTile вместо
Wrap, не требует общей высоты у детей. Оба без экрана-потребителя пока
не влияют на вид приложения.
ColorScheme в AppTheme.light()/.dark() строится из одного seed вместо
двух параллельных наборов чисел для тёмной и светлой темы — акцент
сведён к ChartColors.slot1Blue, чтобы фирменный цвет интерфейса и
основной цвет графиков совпадали. cardTheme/navigationRailTheme/
navigationBarTheme заданы централизованно и каскадом меняют вид
существующих карточек и навигации на всех экранах.
accounts, cashflow, categories, goals, income, portfolio (+instrument),
rebalance, tax, rules переведены на Cached<T> по контракту
docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider,
categoriesListProvider и т.п.) оставлены как есть — не основной контент
экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён
health-page отдельно, второй — чистые действия без списка для баннера).
analyze+test и flutter build linux/windows в отдельных джобах по образцу
backend. Линуксовая сборка требует libsecret-1-dev/pkg-config (apt) и
подавления -Werror=deprecated-literal-operator в CMake — flutter_secure_storage_linux
вендорит старый nlohmann/json.hpp, который на новых компиляторах иначе
не собирается.
/sync и блок data quality на дашборде сведены в один /health с двумя
вкладками (Источники, Качество данных); дашборд ссылается на неё вместо
собственного bottom sheet. Список находок вынесен в общий виджет
DataQualityList, чтобы не дублировать рендер.
CacheInterceptor кэширует каждый успешный GET по путь+параметры в drift
(sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
провайдер отдаёт Cached<T>, экран показывает баннер «данные на …».
Контракт для остальных экранов — docs/ai/offline-cache.md.
flake.nix: libsecret/pkg-config для линуксовой сборки, jq/curl для
just app-web-assets (сборка sqlite3.wasm + drift_worker.js).
AGENTS.md и docs/ai/plan.md — фаза 2 закрыта (линки, брокерские потоки,
ручные цены), фазы 3 и 4 закрыты и протестированы на живых отчётах и
фикстурах; порядок шагов пересчёта дополнен benchmarks/rebalance/income/tax.
DateTime.tryParse("2026-09-18") в Dart возвращает локальное время, не UTC —
на MSK (UTC+3) это трёхчасовой сдвиг относительно того, что сервер имел
в виду датой без времени. asDate() перетэговывает те же значения полей как
UTC вместо доверия tryParse; настоящий таймстемп с "Z"/смещением проходит
насквозь без изменений.
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, а не тело ответа.
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
Второй источник выплат: 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).
Поток: 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.
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/, когда оно
на месте.
GET /analytics/cashflow-broker читает metric_cash_flow_broker. POST
/instruments/{id}/prices апсертит price_manual по (instrument_id, d);
pricing/prices.py подмешивает его в ту же серию, что price_daily, с тем же
протягиванием и порогом устаревания.
Три утверждения в разделе «Состояние» оказались неверны и стоили агентам времени на
перепроверку.
Пять расхождений позиций описаны как корпоративные действия — на деле корпоративное
там только FIVE→X5, остальные четыре были отменёнными заявками. Причина тонкой истории
цен — не «большинство облигаций куплено в апреле» (у них min(d) совпадает с днём
первого владения), а четыре фонда и переезд с доски TQTF на TQBR. И сектор больше не
пуст у всех инструментов.
Добавлен порядок шагов пересчёта с объяснением, почему matching обязан идти после
classify: это единственное место, где перестановка тихо отменяет результат.
Лента брокерского леджера: фильтры по счёту, типу, датам и поиск, бесконечная
прокрутка с явной кнопкой «Ещё», карточка события в bottom sheet с переходом на
инструмент. Паттерн контроллера и пагинации взят у транзакций один в один — это тот
же список с фильтрами, и второй способ делать одно и то же был бы просто вторым
способом его чинить.
Пункт навигации стоит между «Портфелем» и «Потоками», а не рядом с «Операциями»:
это инвестиционная лента, а в операциях лежит ZenMoney.
eventKindLabels переиспользован из portfolio/labels.dart, где он уже жил ради карточки
инструмента, а не скопирован: два словаря подписей для одного енума разъезжаются на
первом же новом типе события.
Бэкенд не менялся — /events с фильтрами и пагинацией закрывает экран целиком,
клиент не перегенерировался.
Курсор источника был один на все бумаги — дата последнего прогона, — и окно бралось
как 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.
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 без поля не должен молча превращаться в отмену.
Перечитывание окна теперь ещё и удаляет отменённую заявку, импортированную до этой
проверки, — иначе плохая строка осталась бы в леджере навсегда.
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, и внебиржевой структурный продукт.
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.
derive() выводит сплиты, амортизации и погашения из самого леджера и складывает их в
corporate_action; rewrite_splits схлопывает пару безденежных transfer_out/transfer_in
в синтетический split. Без этого перерегистрация бумаги реализовалась бы как фиктивный
round-trip: срок владения обнулился бы, а с ним и право на ЛДВ.
amount_per_unit считается пулом по инструменту и дате: одно действие эмитента приходит
отдельной операцией на каждый счёт, и делить надо на суммарную позицию. Позиция для
деления реплеится внутри модуля, потому что шаг обязан идти ДО лотов — именно лоты
потребляют то, что он пишет. Нет позиции — NULL и находка corporate_action_unpriced,
не подставленное число.
Модуль пишет и удаляет только split, amortization и repayment. Дивиденды и купоны он
не трогает: их календарь придёт из фида эмитента, и чистка снесла бы объявленные.
Живых сплитов и погашений в данных нет — эти ветки покрыты только синтетикой. Из
реального есть девять BOND_REPAYMENT, которые пулятся в семь амортизаций.