Skip to content

Целевая схема данных pm-engine ​

Уровень: target. Источник правды для persistence-слоя pm-engine (engine-state.db). Симуляционные режимы и отдельные dry_run/is_simulated колонки отсутствуют: база хранит только факты реальных live/probe сессий.

Контекст и scope ​

Схема хранит обязательные проекции сквозного EventContext. Любая новая схема сигналов, заявок, наливов и позиций обязана сохранять точную причинную связь с event_id, ревизией события и ветвью стратегии; соединение по близости времени не является допустимой заменой.

Что покрывает. Четыре доменные таблицы pm-engine, INTEGER-кодировку денежных величин, статусы и переходы, single-writer-дисциплину, объём операторского Reset (что стирается, а что переживает), индексы, инварианты, clean-install.

Что не покрывает.

  • Слои/порты движка — см. engine.md.
  • Wire-контракт payload'ов SSE — см. wire-contract.md.
  • Risk-лимиты как бизнес-правила — см. risk-and-emergency.md.
  • Зашифрованную wallets.db и config.db (управляются pm-cli, не pm-engine).

Якорь ​

feedback_no_hidden_logic (никакого is_simulated per-row флага), feedback_single_data_pipeline, project_canonical_vision A10. Источник: openspec spec unified-trading-storage.

Базы данных в системе ​

ФайлНазначениеШифрованиеУправлениеReuse при cutover
engine-state.dbДоменное состояние pm-engine (signals/orders/positions/events)нетpm-engineНЕТ — чистая на cutover
wallets.dbПриватные ключи кошельковAES-GCM, master passphrasepm-cliДА
config.dbКонфиг стратегий, knobsнетpm-cli + UI configuratorДА

При первом запуске pm-engine: если в data-директории найден файл pm-bot-state.db от предыдущей реализации, он переименовывается в pm-bot-state.db.<unix_ts>.bak и не читается. wallets.db и config.db подхватываются как есть.

Режим исполнения и происхождение записей ​

Production runtime имеет только factual режимы live и probe; оба отправляют реальные заявки. Режим публикуется как SessionRunMode в session/SSE metadata. В доменных таблицах НЕТ колонок is_simulated, simulated или эквивалентов.

Runtime simulation и переключение базы в simulated mode удалены. Retired PM_ENGINE_DRY_RUN/ENGINE_SIM_TAKER_FEE_BPS отвергаются при старте и не могут создать строки, выдаваемые за факты торговой сессии.

Четыре доменные таблицы ​

signals — strategy-emitted intent records ​

Ключевые колонки: id (PK), signal_uid (ULID UNIQUE, immutable), strategy, wallet_label, condition_id, token_id, outcome, slug, question, neg_risk, kind ∈ {ENTRY, DCA, EXIT, STOP_LOSS, LATE_ENTRY_REJECTED, SHUTDOWN_CANCEL}, trigger_price_milli, max_price_milli, size_target_shares, budget_micro_usd, context_json (immutable), status ∈ {EMITTED, EXECUTING, COMPLETED, REJECTED, SUPERSEDED}, emitted_at, completed_at, reject_reason, created_at.

Lifecycle: EMITTED → EXECUTING → COMPLETED | REJECTED | SUPERSEDED. Auto-COMPLETED когда все связанные orders в терминальном статусе И все связанные positions в CLOSED|REDEEMED.

orders — orders placed by the engine ​

Колонки — полный перечень по фактической схеме после штатного SqliteStorage::open (CREATE TABLE плюс аддитивные ALTER TABLE … ADD COLUMN; сверено 2026-09-06 с PRAGMA table_info(orders)): id (PK), order_uid (ULID UNIQUE), signal_id (NOT NULL FK), polymarket_order_id (UNIQUE NULLABLE), signing_hash (NULLABLE), wallet_label, strategy, condition_id, token_id, side ∈ {BUY, SELL}, order_type ∈ {LIMIT, MARKET, FOK, FAK}, time_in_force ∈ {GTC, GTD, IOC}, price_pip, size_shares, cost_micro_usd, max_taker_fee_bps (NULL для maker/SELL и legacy-строк), taker_fee_rate_bps_actual (NULLABLE — фактическая taker-ставка, запомненная на успешном submit-ack; NULL означает НЕРАЗРЕШЁННУЮ ставку — legacy-строка до появления колонки, не подтверждённый ноль), filled_shares, filled_value_micro_usd, fees_micro_usd, status ∈ {NEW, PLACED, PARTIAL, AMBIGUOUS_SUBMIT, AMBIGUOUS_CANCEL, FILLED, CANCELLED, EXPIRED, REJECTED}, reject_reason (дословный текст отказа), reject_reason_code (код причины отказа из закрытого словаря; у до-миграционных отказов — маркер легаси, задним числом из текста не выводится), cancel_reason_code (код причины ОТМЕНЫ — своя колонка, не общая с кодом отказа: «заявка не встала» и «вставшая заявка снята» — разные вопросы; NULL — законное «не наблюдалось»), event_slug (слаг события, снятый со справочника рынков в момент сборки строки; NULL — строка старше колонки либо справочник промолчал), exit_role (роль продажи, десять литералов ExitOrderRole::as_db_str — ниже, NULL у покупки), entry_role (ниже), expires_at, placed_at, first_fill_at, terminal_at, created_at, updated_at, deleted_at.

Колонок outcome, slug, question, neg_risk, price_milli в таблице НЕТ: исход и вопрос рынка живут в positions/справочнике рынков, цена хранится в pip (price_pip).

entry_role — роль заявки на стороне ВХОДА: initial (первичный вход) или dca (добор), литералы EntryOrderRole::as_db_str. Пишется OrderRouter из ТИПИЗИРОВАННОГО намерения (IntentRole::entry_role) той же вставкой, что и сама строка при размещении; имя строки источником не является. Отказ резервации — включая отказ лимитом риска — строки заявки не порождает вовсе (решение оператора 2026-09-06; его наблюдаемость — risk-and-emergency.md §«Где отказ лимитом наблюдаем»). Читается на обоих путях подъёма (recover_from_db, fetch_released_snapshot), поэтому после перезапуска обслуживание стоящей заявки входа (снятие устаревшей, перестановка) видит ту же роль, что видело до него, а обработчики исходов заявки опознают добор по роли, а не по подстроке имени. NULL означает ровно одно: у продажи роли входа нет по построению; у покупки — строка записана до появления колонки и её имя не несло ни одного тега одноразового перевода. Несовместимая пара «сторона × роль намерения» в таблицу не попадает: покупку без роли входа и продажу с ролью входа отвергает единый рубеж допуска (OrderRouter::allocate, отказ авторизации до денежных лимитов — резервация не создаётся), а писатель строки держит второй рубеж перед всеми своими проверками (RouterError::BuyWithoutEntryRole / RouterError::SellWithEntryRole). Покупка, поднятая с NULL, получает снимок без роли (подстановка запрещена — неопознанная покупка может быть добором), не обслуживается контуром входа, не прерывает старт и считается рядом pm_engine_hydrated_buy_without_entry_role_total. Недопустимый литерал в колонке — отказ чтения строки (PersistenceError::Constraint с именем колонки и значением), а не пустая роль: это признак несовместимого писателя. Миграция: идемпотентный ALTER TABLE orders ADD COLUMN entry_role TEXT при старте, затем одноразовый UPDATE … WHERE entry_role IS NULL AND side = 'BUY' с выводом литерала из тегов имени по закрытой таблице (directional-entry-, directional-watch-arm-, paired-merge-bid- → initial; directional-dca- → dca); уже записанное значение не перезаписывается, повторный старт не меняет ни одной строки, строка без опознанного тега остаётся NULL. CHECK на колонке намеренно нет: SQLite не меняет ограничение без пересборки таблицы, а третья роль добавляется кодеком.

exit_role — роль заявки на стороне ВЫХОДА, один из ДЕСЯТИ литералов ExitOrderRole::as_db_str: ordinary_target (обычный целевой выход), sharp_stop/sharp_stop_confirmed (стоп по котировке / подтверждённый лентой), sharp_stop_partial/sharp_stop_confirmed_partial (тот же стоп, но заявка сознательно меньше учтённого остатка — план по видимой книге ужал размер; полный налив ТАКОЙ заявки не закрывает позицию, см. ../strategies/directional-harvester.md §«Стоп-контур pm-engine»), forced/manual (операторская ликвидация), rank_break_even/rank_maker/rank_taker (ранг-выход). Пишется той же вставкой, что и сама строка (insert_order), из ТИПИЗИРОВАННОГО намерения (IntentRole::exit_role), снятого маршрутизатором с интента, — а не разбором order_uid. Разбор по тегу имени (ExitOrderRole::from_order_uid, таблица ORDER_UID_ROLE_TAGS) остаётся только одноразовым переводом исторических строк, записанных до появления колонки; читаются семью загрузчиками, каждый называет колонку явно. Обратный кодек (ExitOrderRole::from_db_str) принимает и три легаси-литерала прежнего имени (dfav_break_even/dfav_maker/dfav_taker) как синонимы текущих rank_* — старые строки не требуют отдельной миграции значения. Недопустимый литерал в колонке — отказ чтения строки (PersistenceError::Constraint с именем колонки и значением), тем же приёмом, что у entry_role ниже. NULL — законная пустота у ПОКУПКИ; у продажи, записанной до появления колонки и не опознанной одноразовым переводом, NULL остаётся. Миграция симметрична entry_role: идемпотентный ALTER TABLE orders ADD COLUMN exit_role TEXT при старте, затем sql_backfill_exit_role() — тот же построитель sql_backfill_role_column, что и у входа, по таблице ORDER_UID_ROLE_TAGS без дополнительного условия по стороне.

cost_micro_usd сохраняет gross-notional заявки. Для нового taker-capable BUY max_taker_fee_bps хранит авторизованный cap, по которому OrderRouter восстанавливает worst-case fee-reserve независимо от текущего strategy config. Фактически уплаченная комиссия по филлам продолжает накапливаться отдельно в fees_micro_usd; cap не выдаётся за факт оплаты.

Учёт исполнения по этим трём колонкам НЕОДНОРОДЕН, и это наблюдаемое расхождение, а не деталь реализации: filled_shares пишется кумулятивным значением из самого события филла и потому идемпотентен к повторной доставке, тогда как filled_value_micro_usd и fees_micro_usd НАКАПЛИВАЮТСЯ сложением. Повторно обработанный кадр завышает стоимость, оставляя доли верными, — расхождение не поднимает ни одной тревоги. Подтверждённый случай и оценка влияния: PM-ENGINE-39 в tech-debt-backlog. Практическое следствие для любого расчёта: источником стоимости входа служит positions.entry_cost_micro_usd (накапливается по идентичности филла), а orders.filled_value_micro_usd — вспомогательный агрегат, который перед употреблением сверяют.

polymarket_order_id заполняется live-адаптером из exchange acknowledgement. NULL допустим при status ∈ {NEW, AMBIGUOUS_SUBMIT}.

signing_hash — EIP-712-отпечаток заявки, записываемый ДО отправки и НИКОГДА не подменяющий polymarket_order_id: у той другой смысл («биржа подтвердила размещение»), на котором стоят сверка при загрузке и probe_cancel_stale. Отпечаток — единственный ключ, которым неопределённая отправка соединяется с книгой и лентой сделок биржи. Историчные строки несут NULL — задним числом отпечаток не появляется.

Status transitions: NEW → PLACED | REJECTED | AMBIGUOUS_SUBMIT; AMBIGUOUS_SUBMIT → PLACED | PARTIAL | FILLED | CANCELLED | EXPIRED | REJECTED (разрешает только ФАКТ — событие жизненного цикла, вердикт сверки или решение оператора); PLACED → PARTIAL | FILLED | CANCELLED | EXPIRED | AMBIGUOUS_CANCEL; PARTIAL → PARTIAL | FILLED | CANCELLED | EXPIRED | AMBIGUOUS_CANCEL; AMBIGUOUS_CANCEL → CANCELLED | FILLED (тот же принцип, что у AMBIGUOUS_SUBMIT — разрешает только ФАКТ: авторитетный ответ биржи, обнаруженное расхождение налива, либо явное операторское разрешение через отдельную поверхность выхода). Терминальные статусы immutable; единственное допустимое изменение терминальной строки — soft-delete через deleted_at. AMBIGUOUS_SUBMIT/AMBIGUOUS_CANCEL терминальными НЕ являются и terminal_at не проставляют.

AMBIGUOUS_CANCEL — заявка получила транспортную команду отмены, но исход этой КОНКРЕТНОЙ отмены (подтверждена/жива/расхождение с наливом) не установлен фактом на момент записи строки. Отдельная append-only таблица ambiguous_cancel_resolutions (ниже) несёт историю операторских проверок этой строки, НЕ заменяя сам статус: строка остаётся AMBIGUOUS_CANCEL, пока факт не установлен, сколько бы раз оператор ни подтверждал «ещё жива».

Инварианты writer'а (OrderRouter):

  • I-1: signal_id IS NOT NULL.
  • I-2: polymarket_order_id IS NULL ⇒ status ∈ {NEW, AMBIGUOUS_SUBMIT}.
  • I-3: filled_shares ≤ size_shares.
  • I-4: Запрещённые переходы (например, CANCELLED → FILLED) отклоняются с InvalidTransition.

positions — position aggregates ​

Ключевые колонки: id (PK), position_uid (ULID UNIQUE), signal_id (NOT NULL FK), wallet_label, strategy, condition_id, token_id, outcome, slug, question, neg_risk, entry_order_uid (NOT NULL FK → orders), exit_order_uid (NULLABLE FK), avg_entry_price_pip, size_shares, entry_cost_micro_usd, exit_price_pip, exit_value_micro_usd, realized_pnl_micro_usd, realized_pnl_pct_bp, unrealized_pnl_micro_usd, max_drawdown_micro_usd, exit_reason, status ∈ {OPEN, SELLING, CLOSED, REDEEMED}, opened_at, closed_at, duration_secs, created_at, updated_at, deleted_at.

Средняя цена входа выводится только из канонических целочисленных агрегатов entry_cost_micro_usd / size_shares: проекция в pip округляется к ближайшему pip целочисленно (half-up), а не floor. Это не меняет точное количество micro-shares: дробный partial fill биржи сохраняется дословно; биржевой min_size_shares является минимумом заявки, но не шагом fill-количества.

Status transitions: OPEN → SELLING | CLOSED | REDEEMED; SELLING → CLOSED. Инварианты:

  • I-5: Переход в CLOSED запрещён без полного покрытия выхода реальными SELL-филлами: сумма filled_shares по всем ордерам exit-цепочки этого токена = размер входа позиции, а exit_value_micro_usd/realized_pnl_micro_usd = VWAP-агрегат этих филлов (накопители cum_exit_* в position_registry). ВАЖНО: exit_order_uid ссылается на ПЕРВЫЙ (триггерящий) ордер exit-цепочки и НЕ обязан быть FILLED — при stop-re-fire первый ордер может остаться CANCELLED с частичным филлом, а остаток закрывают последующие ордера цепочки (новые ULID, тот же role-tag). Проверка «exit_order_uid → status='FILLED'» НЕ является валидным детектором филла — детектор = покрытие по сумме филлов цепочки.
  • I-6: status = OPEN ⇒ size_shares > 0.

Подтверждённое покрытие (confirmed-coverage-forbids-unsellable-verdict) ​

Две колонки поверх уже существующих coverage_state/coverage_checked_at/ coverage_measured_shares:

  • coverage_booked_shares INTEGER — НЕ живой size_shares этой строки и НЕ её собственный учтённый размер на момент измерения, а СУММА учтённых размеров ВСЕХ активных (OPEN/SELLING) позиций ТОГО ЖЕ wallet_label на ТОМ ЖЕ token_id, против которой измерение было классифицировано (balanceOf цепочки отвечает одним числом на пару кошелёк-токен, а движок может держать несколько активных позиций на одном токене — несколько сигналов одного рынка). У единственной активной позиции на токене эта сумма равна её собственному размеру, и колонка не отличается от прежнего однопозиционного случая. Годность строки (docs/target/risk-and-emergency.md) сравнивает записанную сумму с ТЕКУЩЕЙ суммой учтённых размеров активных позиций того же кошелька на том же токене — не с живым size_shares только этой строки: любой налив или продажа ЛЮБОЙ позиции того же кошелька на токене обесценивает измерения ВСЕХ соседей на этом токене, не только той позиции, что изменилась.
  • coverage_seq INTEGER — порядковый номер измерения, единственный ключ порядка между конкурентными писателями. Часы (coverage_checked_at) для порядка не годятся: их переводят назад, номер — нет.

Обе колонки NULL на строке, которую ещё НИ РАЗУ не измеряли (в т.ч. на всех строках, существовавших до миграции этой карточки) — читается как «измерения не было», а не как правдоподобный 0.

Уникальность номера держит СХЕМА, не прикладной код — частичный индекс:

sql
CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_coverage_seq
  ON positions(coverage_seq) WHERE coverage_seq IS NOT NULL

Частичность обязательна: до-миграционные строки несут coverage_seq IS NULL, а несколько NULL в SQLite UNIQUE-индексе конфликтом не считаются. Повторный номер — гонка писателей либо испорченный вызывающий — отказывает ошибкой записи БД, а не тихо переставляет порядок измерений.

Условие записи по номеру (CAS): PersistencePort::record_coverage_measurement (adapters/storage/sqlite.rs) снимает строку и решает переход (decide_coverage_write, ports/persistence.rs) внутри ОДНОЙ транзакции на пуле с max_connections(1) — второй писатель физически ждёт то же соединение, поэтому снимок не может устареть между чтением и решением. UPDATE повторяет решённое условие вторым рубежом в WHERE coverage_seq IS NULL OR coverage_seq < ?: номер, уже осевший на строке позже, отклоняет запись (MeasurementWrite::SupersededByNewer) — измерение не откатывает строку назад во времени.

Засев счётчика при гидрации: PositionRegistry::hydrate (engine/ position_registry.rs) читает PersistencePort::max_coverage_seq() — SELECT COALESCE(MAX(coverage_seq), 0) FROM positions по ВСЕЙ таблице, включая закрытые строки, не только OPEN/SELLING — и сеет память реестра этим максимумом; следующий выпущенный номер — max + 1. Это делает PositionRegistry не только единственным писателем positions (правило single-writer ниже в этом документе), но и единственным источником номеров измерения: ни один другой код в крейте не конструирует CoverageSeq для записи.

Диагностическое исключение из этого правила — строка events, не positions: coverage.contradiction.diagnostic (engine/coverage_verification.rs) пишет append-only факт при обнаруженном противоречии (биржа отклоняет продажу учтённого инвентаря, хотя покрытие годно) БЕЗ записи в positions. Отказ биржи сам по себе не является оракулом владения — противоречие диагностируется журналом, а не превращается в новое измерение покрытия задним числом.

Redeem tracking (Phase J) ​

Дополнительные колонки на positions, описывающие state machine on-chain CTF redeem:

  • redeem_status TEXT NOT NULL DEFAULT 'NOT_REQUIRED' — состояние ∈ {NOT_REQUIRED, PENDING, SUBMITTED, CONFIRMED, FAILED}.
  • redeem_due_at INTEGER — UnixMillis запланированного выполнения (closure.ts + redeem_backstop_secs).
  • redeem_tx_hash TEXT — заполняется после успешного submit.
  • redeem_submitted_at INTEGER — timestamp submission.
  • redeem_confirmed_at INTEGER — timestamp confirmation.
  • redeem_retry_count INTEGER NOT NULL DEFAULT 0.
  • redeem_last_error TEXT — последнее failure message.

Партиальные индексы:

idx_positions_redeem_due       ON positions(redeem_status, redeem_due_at)
                                  WHERE redeem_status = 'PENDING' AND redeem_due_at IS NOT NULL
idx_positions_redeem_condition ON positions(condition_id, redeem_status)
                                  WHERE redeem_status IN ('PENDING','SUBMITTED','FAILED')

Первый — для быстрого load_due на каждом tick. Второй — для closure lookup при пометке eligible positions.

CAS-переходы (Transaction trait, ADR-029 single-writer-дисциплина):

  • mark_redeem_pending(expected, due_at) — initial scheduling.
  • mark_redeem_submitted(expected = Pending, tx_hash, submitted_at).
  • mark_redeem_confirmed(expected = Submitted, confirmed_at) — также проставляет positions.status = 'REDEEMED'.
  • mark_redeem_failed(expected, retry_count, last_error) — terminal.
  • schedule_redeem_retry(expected = Pending, next_due_at, retry_count, last_error) — backoff reschedule.

Каждый метод проверяет ожидаемый статус через WHERE redeem_status = ?expected; rows_affected != 1 возвращает ConflictingUpdate. Это гарантирует, что параллельные writer'ы не нарушают state machine.

Status transitions для redeem:

NOT_REQUIRED ─► PENDING ─► SUBMITTED ─► CONFIRMED
                   │           │
                   │           └────► FAILED (terminal)
                   │
                   └─► (retry backoff) PENDING (если retry_count < max)
                   │
                   └────► FAILED (если retry_count >= max)

CONFIRMED атомарно тянет positions.status в REDEEMED. Submitted rows после crash не auto-recover'ятся (см. runbook).

events — append-only transactional log ​

Колонки: seq (INTEGER PK AUTOINCREMENT, монотонный, без gaps), event_uid (ULID UNIQUE), ts_ms (wall-clock unix ms), logical_ts (SymbolicClock tick), event_type (TEXT), entity_kind ∈ {signal, order, position, fill, runtime, market}, entity_id, signal_id FK, order_id FK, position_id FK, condition_id (денормализовано для индекса), wallet_label, strategy, payload_json (sonic-rs), created_at.

Таблица insert-only. Запрещены UPDATE и DELETE на любых строках. seq строго монотонный — детерминированный ключ упорядочивания для capture/replay.

event_type маппится 1:1 на варианты enum BotEvent из pm-bot-events — это сохраняет wire-контракт SSE/REST.

unresolved_submits — удержания по неопределённым отправкам ​

Отдельная сущность, а не колонка в orders: блокировка повторного выпуска намерения НЕ снимается оттого, что строка ордера вышла из признака занятости.

Колонки: id (PK), order_uid (UNIQUE), wallet_label, strategy, condition_id, token_id, side, held_micro_usd, signing_hash (NULLABLE — ключ сверки), submitted_at, state ∈ {probing, inconclusive}, resolved_at, resolution ∈ {order_exists, order_filled, no_order}, resolution_source ∈ {lifecycle_event, reconcile, operator}, resolved_by, resolution_note.

Блокировку держит частичный УНИКАЛЬНЫЙ индекс uq_unresolved_submit_intent (wallet_label, token_id, side) WHERE resolved_at IS NULL — по одному живому удержанию на тройку «кошелёк, исход, сторона». Обе стадии (probing / inconclusive) одинаково неразрешённые: деньги удержаны, намерение заблокировано; различие только для оператора. resolution_source отличает разрешение фактом от разрешения человеком — это требование аудита, а не украшение.

ambiguous_cancel_resolutions — аудит ручного разрешения зависшей отмены ​

Зеркальная предыдущей сущность для ДРУГОГО перекрёстка жизни заявки: не отправка, а отмена. Отдельная таблица, а не колонка orders — по двум причинам сразу, каждая из которых сама по себе уже исключает колонку. Во-первых, один из трёх исходов (заявка «ещё жива») НЕ переводит статус строки — колонке orders было бы нечем нести факт «оператор проверял и подтвердил живость» без ложного впечатления, что состояние изменилось. Во-вторых, «append-only» и «одна проверка на ордер» структурно несовместимы: оператор вправе подтверждать живость МНОГОКРАТНО, пока автоматика (периодическая сверка) не сойдётся, и каждая проверка — самостоятельный факт аудита, а не перезапись предыдущей.

Колонки: id (PK), order_uid (БЕЗ уникальности — множественные проверки одной строки легальны), outcome ∈ {CONFIRMED_ZERO, STILL_LIVE}, authority ∈ {RECONCILIATION, LIFECYCLE_EVENT, OPERATOR} (тот же словарь, что у unresolved_submits.resolution_source), resolved_by, note, resolved_at.

Единственный писатель — HTTP-ручка POST /api/local/ambiguous_cancels/{order_uid}/resolve: перед ЛЮБЫМ заявленным оператором исходом ручка сначала выполняет ТУ ЖЕ синхронную проверку, что и периодический работник, разрешающий отмену — заявленный исход применяется только когда эта проверка сама показывает устойчиво деградированное свидетельство. Терминализация исходом «подтверждённый ноль» (CONFIRMED_ZERO) — CAS-переход AMBIGUOUS_CANCEL → CANCELLED (тем же примитивом, что и автоматический путь) в ОДНОЙ транзакции с append-only вставкой сюда; проигранная гонка коммитит транзакцию без вставки — иначе аудит утверждал бы разрешение, которого фактически не произошло. Исход «сработала автоматика или более ранний вызов уже разрешил строку» в эту таблицу НИЧЕГО не пишет — сюда попадают только исходы, применённые ИМЕННО ручным вызовом. Третий заявленный исход, «был налив», в эту таблицу тоже НИЧЕГО не пишет: он структурно отказывает всегда, пока нет отдельного идемпотентного букинга по конкретным подтверждённым сделкам — агрегатная величина денежным оракулом не является, и заявка оператора её продиктовать не может (у тела HTTP-запроса нет поля для суммы).

Операторский Reset — что стирается, а что переживает ​

Помимо четырёх доменных таблиц engine-state.db содержит служебные таблицы движка. Legacy Reset (POST /api/local/reset) и выбранная галочка «Сбросить статистику» в POST /api/local/restart выполняют wipe_runtime_data: DELETE FROM по фиксированному списку в ОДНОЙ транзакции, FK-safe порядок, схема не трогается. В restart-workflow wipe допускается только после durable-подтверждения open_orders == 0 && active_positions == 0; без галочки рестарт ради применения config.db таблицы не стирает. Смысл границы: wipe сбрасывает торговое состояние, а не наблюдаемую историю кошелька и не операторские переключатели.

ТаблицаResetПочему
signals / orders / positions / eventsстираетсяторговое состояние текущей сессии
bootstrap_audit / bootstrap_actionsстираетсяаудит реконсиляции привязан к стёртым ордерам/позициям
watch_arm_fak_statesстираетсясостояние стоп-контура по конкретным позициям
selection_signalsстираетсяUNIQUE(condition_id) + INSERT OR IGNORE: пережившая строка заморозила бы СТАРЫЙ исход для повторно торгуемого рынка и разошлась бы с реальностью в ITT-трассе. Единица здесь — РЫНОК, а не решение, и колонка outcome несёт ГРУБЫЙ ITT-класс из четырёх значений (emitted, budget_skipped, duplicate_signal, emit_error — SelectionSignalOutcome), который намеренно НЕ расширяется: его домен читают форвард-тест-выборки. Поимённая причина отказа живёт не здесь, а в строке попытки в аналитическом хранилище
dfav_statesстираетсяdurable FSM защитного выхода привязана к рантайм-позициям
rank_exit_statesстираетсяактуальная durable FSM защиты по probability rank + entry-relative No-ухудшению (threshold_no_price_pip = канонический positions.avg_entry_price_pip, обязателен; last_no_price_pip nullable); legacy dfav_states остаётся только историческим источником
balance_hourlyпереживаетнедельная почасовая серия баланса кошелька; стирание обнуляло бы ровно те данные, ради которых таблица заведена (см. график «Баланс pUSD во времени»)
budget_hourlyпереживаетнедельная почасовая пара базового/effective бюджета по кошельку и стратегии; хранится атомарно и нужна для истории графика после Reset
entry_halt_stateпереживаетDRAIN MODE + EMERGENCY-EXIT — операторский переключатель, а не состояние сессии: он обязан пережить Reset и рестарт, иначе Reset тайно возобновил бы вход в позиции. Снимается POST /api/local/resume либо одноразовым разрешением новой ограниченной итерации, записанным подтверждённым System Restart
unresolved_submitsстирается, но только после разрешенияторговое состояние ⇒ в списке wipe_runtime_data. Молчаливой утраты не возникает: Reset ОТКАЗЫВАЕТ (409, перечень + удержанная сумма), пока есть строка с resolved_at IS NULL, и проверка идёт по САМОЙ таблице, а не по зеркалу в памяти. Не сумели прочитать таблицу ⇒ не стираем (500)
ambiguous_cancel_resolutionsстирается, но только после разрешения ссылающейся строки ordersта же дисциплина, что у unresolved_submits: Reset ОТКАЗЫВАЕТ (409), пока в orders остаётся строка AMBIGUOUS_CANCEL. Это верно для ОБОИХ исходов аудита, включая STILL_LIVE — он статус заявки не меняет, и строка с этим исходом сама по себе не делает ордер терминальным; её переживает та же предусловная проверка, что и любую другую строку того же order_uid, пока ордер не станет терминальным ПОЗЖЕ (автоматикой или последующим CONFIRMED_ZERO). К моменту фактического wipe все ссылающиеся order_uid уже терминальны — оставленная строка была бы аудитом об ордере, которого больше нет
position_iteration_slots / position_iterationsстираетсяоперационная выборка конкретного тестового запуска. Сначала удаляются FK-ссылки слотов, затем заголовок итерации; при restart с reset_statistics=true новый pending_restart записывается только ПОСЛЕ wipe в той же workflow-последовательности

Вне wipe-списка — СЕМЬ таблиц из перечисленных ниже трёх родов. Замер «22 против 15» датирован 2026-08-20 и с тех пор устарел числом (durable- cancel-fill-resolution добавила ambiguous_cancel_resolutions — она СТИРАЕТСЯ, строка выше — и в счёт «вне списка» не входит; сверх этого DDL несёт таблицы economic_ledger_entries/ledger_delivery_intents/ redeem_confirmations, ни разу не названные в этом документе — этот пробел уже задокументирован отдельно, adapters/storage/AGENTS.md §«Сейчас ≠ цель», и его закрытие остаётся за оператором, не за этой карточкой; order_fill_accounting в их числе НЕТ — она в списке удалений wipe_runtime_data присутствует, отдельный, более узкий пробел этого же документа назван в adapters/storage/AGENTS.md §«Инварианты»). Перечень трёх родов ниже остаётся действующим по СОСТАВУ — расхождение только в итоговых цифрах:

  • Накопительные выборки, которые копятся неделями и ради этого заведены — balance_hourly (почасовая серия баланса кошелька: стирание обнуляло бы ровно тот график, ради которого таблица существует), entry_observation_verdicts (журнал вердиктов этапа наблюдения — вся выборка приёмки), stop_breach_trajectory (траектория ожидания на пробое — форвард-замер, который копится неделями; обратный выбор стирал бы весь накопленный материал при каждом сбросе).
  • Реестры и водомеры, которые описывают САМУ выборку, а не торговлю — telemetry_epoch_state (реестр эпохи наблюдений, одна строка id = 1), entry_observation_epochs (эпоха процесса и признак чистого её завершения), entry_observation_verdict_retention (водомер обрезки журнала: сколько строк удалено и до какого момента выборка неполна). Удалённая строка неотличима от никогда не существовавшей — водомер и есть то, что превращает молчание в утверждение.
  • Операторский переключатель — entry_halt_state (DRAIN MODE + EMERGENCY-EXIT). Он обязан пережить Reset и рестарт, иначе Reset тайно возобновил бы вход в позиции; снимается POST /api/local/resume либо одноразовым разрешением новой ограниченной итерации, записанным подтверждённым System Restart.

wallets.db и config.db — другие файлы, к Reset отношения не имеют. Любая новая таблица движка по умолчанию попадает в wipe_runtime_data; исключение требует строки в этой таблице.

«Вне списка удалений» НЕ означает «очистка её не касается», и разница здесь содержательная. Две таблицы очистка МЕНЯЕТ той же транзакцией, что и стирание:

  • telemetry_epoch_state получает инкремент счётчика сбросов. Смысл ровно обратный стиранию: счётчик входит в личность эпохи, а стереть реестр значило бы после каждого сброса открывать эпоху с нулевым счётчиком и потерять саму возможность сказать, сколько сбросов было. Реестр хранит также водяной знак последней подтверждённой вставки в аналитическое хранилище, отметку завершения запуска и курсор переноса переходов — смысл каждого поля в target/observability.md.
  • entry_observation_verdicts получает linkage_wiped_by_reset = 1 на все строки, где отметки ещё не было. Причина в том, что оракул приёмки соединяется с orders по 'ord-' || client_order_id, а orders только что удалены: без отметки соединение МОЛЧА не находит ничего, и «заявка не налилась» становится неотличимо от «узнать больше нельзя».

Обе правки идут ТОЙ ЖЕ транзакцией, что и удаления. Отметка или счётчик, коммитящиеся отдельно, разъезжаются со сбросом при любом обрыве — и лгут в обе стороны: процесс, упавший между транзакциями, при следующем старте увидит прежнюю пару различителей и ПРОДОЛЖИТ эпоху через сброс, то есть сложит наблюдения до и после стирания позиций в одну выборку.

Наблюдения уходят в отдельное хранилище ​

С 2026-08-18 движок пишет наблюдения (решения входа, кадры стопов, счётчики воронки, витрины позиций и заявок, зеркала переходов) в ClickHouse, а не только в SQLite. Это не отменяет ничего из написанного выше: доменные таблицы остаются единственным источником состояния, а очистка — единственной процедурой его стирания.

Разделение проходит по вопросу: SQLite отвечает «что движок обязан помнить, чтобы продолжить работу», ClickHouse — «что именно произошло с этим рынком, этой заявкой, этой позицией». Правила маршрутизации, смысл пустых значений и границы механизмов — target/observability.md.

Улики решений уехали из SQLite — 2026-08-20 ​

Запись «улик» в engine-state.db отрезана 2026-08-20. Из DDL движка исчезли три таблицы вместе с их миграциями и обслуживающим кодом: decision_liquidity_profiles (строка попытки входа), её водомер обрезки decision_liquidity_profile_retention и stop_dip_contexts (кадр стоп-решения). Строка попытки уходит теперь в admission_attempts, кадр стоп-решения — в stop_frames; обе таблицы живут в аналитическом хранилище.

«Исчезло из DDL» и «исчезло из файла» — РАЗНЫЕ вещи, и разница здесь денежная. Схема заводится исключительно через CREATE TABLE IF NOT EXISTS, а такое объявление СОЗДАЁТ отсутствующее и никогда не трогает существующее. Снять объявление — значит перестать создавать таблицу в новых базах; ни одного DROP TABLE при этом не выполняется, и ни одна строка в уже живущем файле не теряется. Поэтому боевая engine.db продолжает нести все три таблицы со всем накопленным.

Накопленные строки НЕ переливались, и боевые таблицы НЕ удалялись. Это отдельная ЭРА: она читается переключателем FORENSICS_FRAMES_SOURCE=sqlite (scripts/analysis/forensics.sh) и не пополняется. Смешивать две эры в одном выводе запрещено тем же правилом, которым запрещено сравнение эпох: границы записи у них разные, и склеенная выборка выглядит однородной, не будучи ею.

Четыре свойства наблюдения при переезде не изменились и потому названы здесь, а разобраны в target/observability.md:

  • Книга берётся из снимка, КОТОРЫЙ ПОТРЕБИЛО РЕШЕНИЕ (MarketDataSnapshot.book — то же значение, по которому сработали гейты входа), а не перечитывается позже из общего хранилища стаканов. Перечитанная книга принадлежала бы другому моменту, и связь «форма стакана ↔ судьба попытки» стала бы недоказуемой в принципе, а не просто шумной.
  • Единица записи — ПОПЫТКА входа, а не рынок. Повторные попытки по одному рынку дают разные строки: своя книга, свой момент, свой исход. Единица названа значением в самой строке (колонка популяции), а не выводится читателем из пустоты соседних колонок.
  • Эры не склеиваются, и различает их значение config_digest в самой строке, а не память аналитика о дате выката. Строка собирается как отпечаток отбора плюс хвост |liquidity-profile:v5:levels=128; переезд из SQLite и есть граница v4 → v5 (crates/pm-engine/src/strategies/directional/strategy.rs).
  • Глубина лестницы книги стала константой движка DECISION_BOOK_LEVELS = 128 (там же) — ровно то значение, что стояло в боевой конфигурации версий ≤ 46. Настройкой она быть перестала: горизонт удержания ушёл вместе с таблицей, а форма наблюдения — не торговое решение. В config_digest глубина ОСТАЛАСЬ, потому что выборки, снятые при разной глубине, смешивать нельзя.

Отметки «связь оборвана сбросом» у этих строк больше нет, и она им не нужна. Раньше decision_liquidity_profiles несла колонку linkage_wiped_by_reset, которую операторский сброс проставлял той же транзакцией: строки переживали сброс, а signals/orders/positions — нет, и соединение по ним молча читалось как «заявка не налилась». Теперь строки лежат ВНЕ движковой БД, сброс их не касается вовсе, а границу показывает сам ключ эпохи: счётчик сбросов входит в её личность (v{версия}-r{сброс}), поэтому строки по разные стороны сброса лежат в разных эпохах. Колонка при этом не исчезла из системы — тот же механизм и по той же причине продолжает работать на entry_observation_verdicts (см. раздел про Reset выше).

Снятие настройки объявлено переездом контракта, а не молчаливым удалением ключа. Секция directional.liquidity_profile (тип LiquidityProfileConfig) снята целиком; переезд v6 → v7 объявлен в pm_common::strategy_contract::DECLARED_MIGRATIONS, а форма v6 заморожена артефактом crates/pm-engine/schema/history/directional-config.v6.json. Вместе с секцией ушла и обрезка по retention_hours: удерживать локально стало нечего, горизонт удержания — свойство того хранилища, где строки лежат. Включённость слоя определяется теперь наличием приёмника, а не настройкой стратегии: пока секция управляла записью, снятие её из конфигурации гасило и поток в хранилище; такой связи больше нет. Порядок операций (сперва боевая конфигурация, затем код) и его причина — target/observability.md.

Перестали эмитироваться и потому сняты из описаний метрик шесть счётчиков этого слоя: pm_engine_decision_liquidity_profiles_recorded_total, pm_engine_decision_liquidity_profiles_empty_bid_side_total, pm_engine_decision_liquidity_profiles_pruned_total, pm_engine_decision_liquidity_profile_insert_failed_total, pm_engine_decision_entry_outcome_finalize_failed_total, pm_engine_decision_entry_outcome_finalize_lost_total. Наблюдаемость самого слоя записи переехала вместе со строками — target/observability.md.

Position-limited test iterations ​

position_iterations хранит immutable iteration_id, wallet/strategy identity, положительный position_limit, состояния pending_restart | active | limit_reached | interrupted, временные метки, terminal reason и явный resume_drain_on_start. Частичный unique index допускает не более одной pending/active итерации на кошелёк. position_iteration_slots ключуется (iteration_id, signal_uid) и хранит initial-entry order/position linkage: повтор или reprice одного signal не занимает второй слот, DCA туда не пишется. Резерв слота коммитится с order-row, а превращение в committed position — в той же транзакции, что PositionOpened; terminal zero-fill удаляет только не committed слот. Nullable/отсутствующая итерация означает unlimited runtime, а не скрытый default.

resume_drain_on_start — одноразовое разрешение команды System Restart, а не состояние активной итерации. Оно имеет силу только у строки pending_restart. Переход в active в той же SQLite-транзакции записывает значение 0; повторная активация его не восстанавливает. Поэтому сбой до commit оставляет Drain и разрешение ожидающими следующего запуска, а любой последующий рестарт уже активной итерации сохраняет Drain. Для совместимости строка active со значением 1, оставленная старым бинарём, при чтении не даёт права снять Drain.

Следствие для графиков главной страницы. Ряд графиков ведёт себя при выбранном wipe несимметрично, и это ПОВЕДЕНИЕ: серии бюджета (budget_hourly) и баланса (balance_hourly) сохраняются, а накопительная кривая реализованного PnL обнуляется, потому что выводится SQL-запросом по positions, которые wipe стирает. Рестарт без «Сбросить статистику» сохраняет все три истории. Контракт эндпойнтов — operations/local-dashboard-runbook.md §4.

Денежные величины — INTEGER minor units ​

Все денежные колонки (*_micro_usd): INTEGER, единица = micro-USD (USD × 10^6, совпадает с native precision USDC на блокчейне). Все цены (*_milli): INTEGER, единица = тысячные (price × 1000, Polymarket tick = 0.001). Доли (*_shares): INTEGER.

REAL запрещён для любых денежных, ценовых и количественных колонок. Архитектурный тест сканирует DDL по regex финансовых имён и роняет билд при появлении REAL.

SQL-агрегации (SUM/MIN/MAX/AVG) выполняются на стороне SQLite над INTEGER без потери точности. Конверсия в rust_decimal::Decimal происходит только на границе — Decimal::new(value, 6) для micro-USD, Decimal::new(value, 3) для milli-price.

Пример: SELECT SUM(realized_pnl_micro_usd) FROM positions WHERE strategy = ? возвращает INTEGER; приложение делает одну конверсию для отображения.

Single-writer discipline ​

config.db v3: immutable strategy snapshots ​

strategies.label is a stable identity. Revision state lives only in latest_config_version and published_config_version; version suffixes in a label are not a revision mechanism. Complete authored snapshots (wallet_label + definition) are stored in strategy_config_versions with a positive monotonic version, SHA-256 content identity, typed-contract fingerprint, origin, note and validation state.

Only the version store may update strategies.wallet_label, definition and the derived budget_limit_usd projection. Draft creation advances only latest; publication atomically updates the projection and published but never changes enabled. Direct post-v3 overwrite APIs fail loudly. Retention is the latest 20 numbers plus an older pinned published snapshot and any retained restore source needed for provenance.

The backup-first v2→v3 migration preserves every old row independently as v1 and ignores strategy_audit as a snapshot source. Migration is available only through the engine-owned typed boundary: rows accepted by the exact current startup validator are seeded as valid with its contract fingerprint; disabled/incompatible rows are seeded as inspectable legacy_unvalidated. The storage-only opener refuses a v1/v2 database instead of classifying it without concrete strategy types. Store open verifies published snapshot, projection and stored content hash and refuses a divergent DB.

Operational migration and rollback: strategy-config-v3-migration-runbook.md.

config.db v4: единственность активной строки ​

Схема v4 добавляет РОВНО ОДНО: частичный уникальный индекс

sql
CREATE UNIQUE INDEX idx_strategies_single_active
    ON strategies(COALESCE(wallet_label, '')) WHERE enabled = 1;

Ключ строится по ВЫРАЖЕНИЮ, а не по колонке: SQLite считает NULL-значения различными в уникальном индексе, поэтому индекс по wallet_label напрямую отверг бы вторую активную строку на кошельке live, но ПРИНЯЛ бы две активные строки без кошелька — то есть оставил бы запрещённое состояние достижимым.

Индекс — структурное ограничение, а не проверка в коде: писателей уже двое (pm-cli и HTTP-обработчик дашборда), и проверка в одном из них ничего не гарантирует. Он же делает однозначным ответ на вопрос «какую стратегию запускать» для пары «файл × кошелёк»: выбор рабочей строки живёт в базе, а не в окружении — BOT_STRATEGY_LABEL и --strategy-label удалены.

Миграция v3→v4 backup-first (config.db.pre-v4.bak — точка отката) и ОТКАЗЫВАЕТ при двух и более активных строках на кошельке, перечисляя их метки. Активацию она не снимает сама: какая строка рабочая — знает оператор, а код, выбравший одну из двух автоматически, имел бы шанс один к двум остановить не ту стратегию.

Назначение активной строки — атомарная activate: снятие с прежней и выставление целевой в одной транзакции. Пара «выключить/включить» оставляла бы окно «активной нет», в котором перезапуск инстанса приводит к отказу старта. Активация не создаёт версию конфигурации (enabled не входит в снимок) и пишет одну запись в strategy_audit.

Таблица настроек инстанса (режим прогона) в v4 НЕ входит — она придёт миграцией v4→v5 вместе со своим читателем: настройка, созданная раньше того, кто её читает, остаётся мёртвой, что запрещает RULE-NEW-01. Цена решения названа прямо: две точки отката вместо одной.

engine-state.db: метка прошлого запуска ​

Таблица last_run_identity — одна строка (id = 1) с меткой стратегии, кошельком и временем УСПЕШНОГО старта. Существующего носителя этого значения не было: positions.strategy хранит ТИП ("directional"), поэтому две строки конфигурации одного типа в нём неразличимы — ровно такая пара и лежит в боевой базе; таблицы сессий у движка нет вовсе.

Значение читается на старте: если метка не совпадает с активной строкой И в engine.db есть открытые позиции, движок ОТКАЗЫВАЕТ в старте. Иначе процесс подобрал бы инвентарь прежней стратегии и начал обслуживать его своими порогами — другим стопом, другой целью, другим бюджетом.

Таблица попадает в wipe_runtime_data по умолчанию: очистка удаляет позиции, после чего сравнивать не с чем и предмет проверки исчезает.

ТаблицаЕдинственный writerГейт
signalsSignalRegistryast-grep CI: любой INSERT/UPDATE/DELETE signals вне SignalRegistry блокирует merge
ordersOrderRouterто же, для orders вне OrderRouter
positionsPositionRegistryто же, для positions вне PositionRegistry
eventsappend-only любым writer'ом — но только в той же SQLite-транзакции с domain-mutationпроверка на ручной INSERT events без сопровождающей domain-mutation

Это расширение правил ADR-029 на orders и signals. Outbox-паттерн: domain-row + events-row коммитятся одной транзакцией; EventBus.publish вызывается после COMMIT.

Индексы ​

idx_signals_strategy_emitted    ON signals(strategy, emitted_at DESC)
idx_signals_status              ON signals(status) WHERE status IN ('EMITTED','EXECUTING')
idx_orders_signal               ON orders(signal_id)
idx_orders_status_open          ON orders(status) WHERE status IN ('NEW','PLACED','PARTIAL','AMBIGUOUS_SUBMIT','AMBIGUOUS_CANCEL')
idx_orders_polymarket_id        ON orders(polymarket_order_id) WHERE polymarket_order_id IS NOT NULL
idx_unresolved_submits_open     ON unresolved_submits(resolved_at) WHERE resolved_at IS NULL
uq_unresolved_submit_intent     ON unresolved_submits(wallet_label, token_id, side) WHERE resolved_at IS NULL  -- UNIQUE
idx_ambiguous_cancel_resolutions_order_uid ON ambiguous_cancel_resolutions(order_uid)
idx_positions_signal            ON positions(signal_id)
idx_positions_open              ON positions(status) WHERE status IN ('OPEN','SELLING')
idx_events_signal_seq           ON events(signal_id, seq)
idx_events_order_seq            ON events(order_id, seq)
idx_events_position_seq         ON events(position_id, seq)
idx_events_type_ts              ON events(event_type, ts_ms)

Партиальные индексы используются для bootstrap (открытые ордера/позиции) и горячих SSE-выборок.

Предикат «живого» ордера в SQL НЕ выписывается руками: перечень литералов выводится из того же признака занятости (OrderStatus::occupies_slot), которым пользуются барьеры стратегий, — иначе слой хранения и Rust-код разъезжаются молча. По той же причине перевод схемы ПЕРЕСОБИРАЕТ частичные индексы через DROP + CREATE (idx_orders_status_open, uq_orders_live_immediate_exit, uq_unresolved_submit_intent): CREATE … IF NOT EXISTS с изменившимся предикатом — молчаливая пустая операция, и в боевой базе остался бы СТАРЫЙ предикат.

Trace-запросы ​

Один JOIN по signal_id даёт полную трассировку:

sql
SELECT s.*, o.*, p.*, e.*
FROM signals s
LEFT JOIN orders o ON o.signal_id = s.id
LEFT JOIN positions p ON p.signal_id = s.id
LEFT JOIN events e ON e.signal_id = s.id
WHERE s.signal_uid = ?
ORDER BY e.seq

Используется в pm-cli signal show <signal_uid> и в дашборд-странице /trace/<signal_uid>.

Clean-install policy ​

  • pm-engine при первом старте создаёт engine-state.db со схемой schema_version = 1. Никакого кода миграции с pm-bot-state.db v0/v1/v2.
  • Если в data-директории присутствует pm-bot-state.db от предыдущей реализации, он переименовывается в pm-bot-state.db.<unix_ts>.bak и игнорируется.
  • VPS cutover: backup engine-state.db, удаление, запуск pm-engine. Потеря исторических торговых событий на VPS согласована пользователем (см. proposal unify-orders-positions-signals-events).

Decision rationale (растворённые ADR) ​

  • Per ADR-015 (IMPLEMENTED) — multi-wallet + strategy config: wallets.db (encrypted) и config.db (plaintext, strategy knobs) переиспользуются как есть. pm-engine не управляет ими; чтение через pm-cli API. Принцип «strategy knobs живут только в config.db» (memory feedback_strategy_config_via_ui) сохраняется.
  • Per ADR-029 (ACCEPTED) — Unified PositionRegistry: 4 архитектурных гейт-правила теперь действуют для всех трёх writer'ов (signals/orders/positions), не только positions. Гейт реализуется через ast-grep сканер в CI.
  • Per operator decision 2026-08-22: runtime simulation удалена; журнал orders хранит только реальный lifecycle и является источником factual order trace.
  • Per design D1, D3, D8 (unify-orders-positions-signals-events/design.md): денежные величины как INTEGER micro-USD (TEXT Decimal::to_string нельзя агрегировать SQL'ом); отсутствие per-row simulated-флага сохраняет A10 «no hidden logic»; clean-install без миграции из произвольных входов.
  • Per spec unified-trading-storage (полная DDL и инварианты в openspec change).

Cross-references ​

  • Архитектура движка над этим persistence: engine.md.
  • Wire-контракт (как events.event_type мапится на SSE): wire-contract.md.
  • Где enforce'ятся RISK-LIMITS до записи в orders: risk-and-emergency.md.
  • Openspec spec: openspec/changes/unify-orders-positions-signals-events/specs/unified-trading-storage/spec.md.
  • Memory: feedback_no_hidden_logic, feedback_strategy_config_via_ui.