Definitieve DB-architectuur voor stale-edge-safe live trading

DOC_STATUS: CURRENT
DOC_ROLE: db_architecture_state_first

Onderzoek en classificatie (DEEL A–H). Code-first; dit doc ondersteunt de implementatie.


DEEL A — Epoch / Snapshot / State: contract map

ConceptDefinitieBestanden
EpochEen ingest_epochs-rij: monotone epoch_id per lineage, status valid/degraded/invalid, completed_at, koppeling naar execution_universe_snapshot_id. Bepaal of execution entries mag (valid) of alleen exit (degraded).src/db/ingest_epoch.rs, src/db/read/epoch_queries.rs
SnapshotEen execution_universe_snapshots-rij: bevroren set execution_symbols + pinned_symbols op een tijdstip (as_of, published_at), gekoppeld aan één epoch.src/db/ingest_epoch.rs (insert), src/db/read/epoch_queries.rs (execution_universe_snapshot_by_id)
Staterun_symbol_state: afgeleide tabel, ververst uit raw (ticker_samples, trade_samples, l2_snap_metrics, l3_queue_metrics). Enige bron voor decision-hot reads na refresh. Heeft updated_at per rij; contract: decision-eligible = state uit refresh in deze evaluation-cycle.src/db/run_symbol_state.rs

Relatie: Lineage (1) → Epochs (N) → elk epoch optioneel ExecutionUniverseSnapshot (1). State onafhankelijk: per run_id één refresh die raw → run_symbol_state overschrijft.

Contracts naar consumers (na implementatie)

ConsumerEpochSnapshotState
live_runnerBindt 1 valid/degraded per cyclus; max_epoch_age_secs.Leest snapshot by id voor exec_allowed.Refresh vóór elke evaluation; leest daarna alleen state.
ingest_runnerMaakt epochs, update status, koppelt snapshot.Schrijft snapshots.Zelfde refresh + state-read.
route_selectorNiet direct.Niet direct.Alleen analyze_run_from_state → run_symbol_state.
strategy_pipelineNiet direct.Ontvangt allowed_execution_symbols.Readiness en dataset uit state; l3 uit l3_symbol_stats_for_run_from_state.
executionIndirect via live_runner.positions, execution_orders, fills.

Stale-risico (voor fix)

  • Ingest → raw. State refresh → alleen bij refresh_due (voorheen). Route leest state; readiness las raw (analyze_run). Pipeline las raw (readiness + l3_symbol_stats_for_run). Twee waarheden. Na fix: één refresh per cycle, één state, één readiness-from-state, één pipeline op die state.

DEEL B — Hot / Warm / Cold + Decision-hot / Context-hot

Tabel / dataflowDecision-hotContext-hotWarmColdLive readLive writeDirect execution useRetentionFreshness-eis
run_symbol_stateJa (na refresh)JaAlleen via refreshJaRun-boundupdated_at = refresh in cycle
observation_runsMetadataJaJaNeeLang
ingest_lineageMetadataJaJaNeeLang
ingest_epochsJa (bind)MetadataJaIngestNee (alleen bind)Langcompleted_at binnen max_epoch_age_secs
execution_universe_snapshotsJa (by id)JaIngestJa (exec set)LangGebonden in cycle
positionsJa (exit)JaExecutionJaLang
symbol_safety_stateJa (hard_blocked)JaWS/safetyJa (filter)Lang
execution_ordersJa (exit/context)JaExecutionJaLang
execution_order_eventsWarmJaExecutionNeeLang
fillsWarmJaExecutionNee (ledger)Lang
realized_pnlWarmJaExecutionNeeLang
execution_order_latencyWarmJaExecutionNeeLang
pair_summary_24hWarmNee (live path)IngestNeeRun
shadow_tradesWarmJaPipelineNeeLang
ticker_samplesColdNee (live path)JaNeeRun
trade_samplesColdNeeJaNeeRun
l2_snap_metricsColdNeeJaNeeRun
l3_queue_metricsColdNeeJaNeeRun

Read-only decision layer: run_symbol_state (na refresh), execution_universe_snapshots (by id), symbol_safety_state (read), epoch-queries (read). Write-heavy (nooit direct decisioning): ticker_samples, trade_samples, l2_snap_metrics, l3_queue_metrics; execution_orders/fills/events append.


DEEL C — Read/Write boundary

  • Live code mag niet uit write-heavy/raw tabellen lezen voor decisioning. Toegestaan: run_symbol_state, execution_universe_snapshots by id, symbol_safety_state, epoch-queries, positions/execution_orders voor exit/exposure.
  • Derivation: refresh_run_symbol_state maakt van raw → decision-eligible state. Na refresh: alle decision reads uit state + gebonden snapshot.

DEEL D — Freshness contract

Per bron (seconden)

BronMax age decisionMax age contextStaleExecution-eligible cutoff
State (run_symbol_state)Alleen uit refresh in deze cycleBuiten cycleNee buiten cycle
Epoch completed_atmax_epoch_age_secs (config)Buiten windowNee
SnapshotGebonden in cycleAlleen gebonden snapshot
Last message (ticker/trade)LIVE_DATA_MAX_AGE_SECS (60)> 60sNee als stale
L3-derivedVia stateVia state ageVia state
L2-derivedVia stateVia state ageVia state
Safety stateReal-time read

Per route-type (route-specifieke freshness)

Route-typeDecision-hot bronnenMax state/message ageContext-onlyExecution blocked
BreakoutContinuationState, L2/L3, message60s message; state = cycle> 60s messageStale state/message
PullbackContinuationState, L2/L3, message60s message; state = cycle> 60s messageStale state/message
PumpFadeExhaustionState, L2/L3, message60s message; state = cycle> 60s messageStale state/message
PassiveSpreadCaptureState, L2/L3, message60s message; state = cycle> 60s messageStale state/message
Maker unwind / mean-reversionState, L2, message60s message; state = cycle> 60s messageStale state/message

Globale limiet 60s (LIVE_DATA_MAX_AGE_SECS); per route kunnen strengere limieten worden toegevoegd (const in code).


DEEL E — Forensische stale-risk (voor fix)

#FileFunctieBronFreshness-checkKritiek
1strategy_readiness_report.rsrun_readiness_analysis_for_runanalyze_run (raw)Geen state-ageKritiek
2strategy_pipeline.rsrun_strategy_pipeline_v2readiness (raw), l3_symbol_stats_for_run (raw)GeenKritiek
3live_runner.rsevaluation-looprefresh alleen bij refresh_dueState kon oud zijnMiddel
4eligibilityGeen state_updated_at in gateMiddel

Na implementatie: readiness from state, pipeline from state, refresh vóór elke evaluation, state_updated_at in eligibility en logging.


DEEL G — Schaalmodel 50 / 200 / 500 L3-markten

LaagGroeit mee (50→500)Opmerking
Decision-hotState rows ~symbols; snapshot size ~symbolsSchaalbaar
Context-hotIdem
Warmpair_summary_24h, shadow_trades, latency rowsBeperkt door retention
ColdRaw rows (ticker, trade, l2, l3)Eventvolume; geen decision read

Querypaden: decision path mag niet met raw rows meegroeien; refreshkosten mogen met symbol count groeien; refresh mag niet met raw eventvolume in dezelfde transactie blokkeren. Eerste risico: refresh duur (do_refresh CTE op 4 raw tables) bij zeer hoge raw volume; tweede: l3_queue_metrics write load. Conclusie: logische scheiding haalbaar tot 500 L3; bij contention fysieke scheiding onderzoeken.


DEEL H — Logische vs fysieke scheiding

  • Model A (logisch, één PG): Write-heavy raw + read-only decision state + warm + cold in één instance. Winst: eenvoud, geen replica-lag. Risico: contention op zware refresh. Voldoende zolang refresh en decision reads niet geblokkeerd worden door write load.
  • Model B (fysiek): Aparte ingest DB, runtime decision DB. Winst: isolation. Risico: sync/replica-lag, operatie. Niet direct noodzakelijk voor stale-edge; wel optie bij bewezen contention.

Aanbeveling: Start met logische scheiding; alle live decision reads uit state + snapshot; refresh vóór elke evaluation. Fysieke scheiding alleen indien metingen (lock wait, refresh duration) dat rechtvaardigen.


DEEL I — Uitvoeringsregel

Direct noodzakelijk (uitgevoerd)

  • State-first readiness: run_readiness_analysis_for_run_from_state in strategy_readiness_report.rs; live_runner gebruikt alleen deze voor readiness (geen raw analyze_run in live path).
  • State-first pipeline: l3_symbol_stats_for_run_from_state in strategy_pipeline.rs; pipeline leest alleen state.
  • Refresh vóór elke evaluation: In beide evaluation-loops in live_runner.rs: refresh_run_symbol_state(bound_run_id) / refresh_run_symbol_state(run_id) vóór readiness; bij refresh-fout wordt de evaluation overgeslagen.
  • run_symbol_state updated_at-contract: state_updated_at(pool, run_id) in db/run_symbol_state.rs; data_stale in from_state = (now - state_updated_at) > 60s.
  • Route-specifieke freshness: RouteFreshnessLimit en LIVE_DATA_MAX_AGE_SECS in execution/decision_eligibility.rs; globale 60s; per-route aanscherping mogelijk via default impl.
  • DecisionEligibility struct: execution/decision_eligibility.rs; gevuld in live_runner na readiness; doorgegeven aan logging.
  • Logging: DATA_FRESHNESS_EVALUATED (run_id, evaluation_index, state_updated_at, state_age_secs, data_stale, execution_eligible) en ROUTE_EXECUTION_BLOCKED_STALE_DATA (bij data_stale && !execution_eligible) in beide evaluation-loops.

Uitgevoerd (vervolg — freshness/safety/500 L3)

ItemUitvoering
Route-specifieke seconden per route-typeroute_freshness_limit(RouteType) in decision_eligibility.rs: Breakout/PumpFade/DumpReversal 30s, Pullback/PassiveSpread 45s. apply_route_freshness_filter filtert exec_allowed op route-limiet.
ROUTE_FRESHNESS_ per symbol/route*Per evaluation: ROUTE_FRESHNESS_OK / ROUTE_FRESHNESS_STALE / ROUTE_FRESHNESS_CONTEXT en ROUTE_EXECUTION_BLOCKED_STALE_DATA per (symbol, route_type, horizon) in apply_route_freshness_filter.
Fysieke DB-scheidingBeoordeling: Logische scheiding alleen is niet voldoende voor 500 L3. Uitgevoerd: DbPools, DECISION_DATABASE_URL, create_pools, refresh op ingest, sync_run_symbol_state_to_decision, epoch/snapshot dual-write, live path leest alleen decision. Definitie: Fysieke scheiding = tweede PostgreSQL-cluster/instance (eigen poort, datadir); twee DBs/schemas/pools op dezelfde instance tellen niet. Zie DUAL_DB_SECOND_INSTANCE_PLAN.md.
Partitioning raw (500 L3)Cutover uitgevoerd. 20260313120000: L3 partitioned tabel. 20260313130000: L3 cutover (rename). 20260313140000: ticker_samples, trade_samples, l2_snap_metrics partitioned + cutover. App gebruikt nu overal de gepartitioneerde tabellen onder de oorspronkelijke namen; retention blijft DELETE WHERE run_id.
Refresh generation contract20260313150000: sequence + generation_id op run_symbol_state. Elke refresh krijgt één generation_id; sync kopieert die naar decision. Live: INGEST_DECISION_SYNC_VISIBLE log; execution gate: alleen als state_generation_id(decision) = cycle generation_id, anders EXECUTION_BLOCKED_GENERATION_MISMATCH en exec_allowed geleegd.
Refresh-complexiteitBewijs: refresh is O(rows) (CTE scant alle rijen per run_id). Zie docs/REFRESH_COMPLEXITY_AND_GENERATION.md. Meet duration_ms; bij groei incrementeel/watermark overwegen.