ADR-003: State Model
Status: Accepted
Date: 2026-03-08
Deciders: Assistant + User
Context
Das State Model definiert die Datenstrukturen, Events und Projektionsregeln für Forward V5. Alle State-Änderungen erfolgen ausschließlich über das Event Store – direktes Schreiben in State-Dateien ist verboten.
Decision
1. Entities
1.1 Run (Trading Session)
interface Run {
run_id: string; // UUID, z.B. "FT_2026_03D_R5a"
started_at: string; // ISO 8601
ended_at: string | null; // null = aktiv
symbol: string; // z.B. "BTC-USD"
timeframe: string; // z.B. "1h"
mode: "paper" | "mock" | "live"; // Live erst ab Phase 9
config_version: string; // Ref zur aktiven Config
}
1.2 Position
interface Position {
position_id: string; // UUID
run_id: string; // Referenz
symbol: string;
side: "long" | "short";
entry_price: number; // Durchschnitt bei Multiple Entries
size: number; // Anzahl Kontrakte
realized_pnl: number; // Bereits realisierter PnL
unrealized_pnl: number; // Aktueller unrealisierter PnL
opened_at: string;
closed_at: string | null;
status: "open" | "closed" | "liquidated";
orders: string[]; // Referenzen zu Order-IDs
}
1.3 Order
interface Order {
order_id: string; // Exchange-Order-ID oder UUID für Intents
position_id: string; // Referenz
symbol: string;
side: "buy" | "sell";
type: "market" | "limit";
size: number;
price: number | null; // null für Market Orders
status: "created" | "sent" | "pending" | "filled" | "partial" | "canceled" | "rejected";
filled_size: number;
avg_fill_price: number | null;
created_at: string;
updated_at: string | null;
exchange_response: object | null; // Roher Exchange-Response
}
1.4 Signal (Strategy-Output)
interface Signal {
signal_id: string; // UUID
run_id: string;
timestamp: string; // Wann Signal generiert
tick_timestamp: string; // Referenz zum auslösenden Tick
symbol: string;
action: "enter_long" | "enter_short" | "exit" | "none";
confidence: number; // 0.0 - 1.0
metadata: object; // Strategie-spezifische Daten
strategy_id: string; // z.B. "rsi_regime_filter_v1"
}
1.5 Intent (Interner Order-Vorschlag)
interface Intent {
intent_id: string; // UUID
signal_id: string; // Referenz
run_id: string;
timestamp: string;
symbol: string;
action: "open_long" | "open_short" | "close_position" | "adjust_size";
target_size: number; // Gewünschte Position-Size
target_price: number | null; // null = Market Order
reason: string; // Menschenlesbare Begründung
risk_check_passed: boolean; // Vorab-Check Ergebnis
}
1.6 Event (Event Store Entry)
interface Event {
event_id: string; // UUID (Lexikographisch sortierbar, z.B. ULID)
event_type: EventType; // Siehe unten
occurred_at: string; // ISO 8601
entity_type: string; // "run" | "position" | "order" | "signal" | "intent" | "health" | "config"
entity_id: string; // Referenz zur betroffenen Entity
payload: object; // Event-spezifische Daten
correlation_id: string; // Für Tracing (z.B. Signal → Intent → Order)
causation_id: string | null; // Vorheriges Event in Chain
}
type EventType =
// Run Lifecycle
| "RUN_STARTED" | "RUN_PAUSED" | "RUN_RESUMED" | "RUN_ENDED"
// Signals
| "SIGNAL_GENERATED" | "SIGNAL_REJECTED"
// Intents
| "INTENT_CREATED" | "INTENT_VALIDATED" | "INTENT_REJECTED"
// Orders
| "ORDER_CREATED" | "ORDER_SENT" | "ORDER_ACK" | "ORDER_FILLED" | "ORDER_PARTIAL_FILL"
| "ORDER_CANCELED" | "ORDER_REJECTED" | "ORDER_ERROR"
// Positions
| "POSITION_OPENED" | "POSITION_SIZE_CHANGED" | "POSITION_CLOSED" | "POSITION_LIQUIDATED"
// Health
| "HEALTH_CHECK_PASSED" | "HEALTH_CHECK_FAILED"
// Config
| "CONFIG_LOADED" | "CONFIG_CHANGED"
// Safety/Observability
| "SAFETY_VIOLATED" | "SAFETY_RESOLVED"
| "OBSERVABILITY_WARN" | "OBSERVABILITY_RESOLVED";
1.7 Health (System-Health Snapshot)
interface Health {
health_id: string; // UUID
run_id: string;
timestamp: string;
safety_status: "healthy" | "degraded" | "critical";
observability_status: "healthy" | "degraded" | "offline";
checks: HealthCheck[];
last_tick_at: string | null; // Wann letzter Tick empfangen
last_report_at: string | null; // Wann letzter Report gesendet
}
interface HealthCheck {
check_name: string;
status: "pass" | "fail" | "warn";
message: string | null;
checked_at: string;
}
1.8 Config (Laufzeit-Konfiguration)
interface Config {
config_id: string; // UUID oder Versions-Tag
loaded_at: string;
active: boolean; // Nur eine Config aktiv
// Trading-Parameter
symbol: string;
timeframe: string;
strategy_id: string;
// Risk-Parameter
max_position_size: number;
max_leverage: number;
risk_per_trade: number; // % des Kapitals
min_notional: number; // HL: min $10
max_notional: number;
// Safety-Gates
safety_gates: {
sizing: boolean;
reconcile: boolean;
watchdog: boolean;
market_data_freshness: boolean;
};
// Observability
discord_webhook: string | null;
report_interval_minutes: number;
}
2. Projection-Inhalt: "Aktueller State"
Die State-Projection ist das materialisierte Ergebnis aller Events. Sie ist rekonstruierbar und keine Quelle der Wahrheit.
2.1 Garantierte Felder im State
interface CurrentState {
// Meta
projection_version: string; // ADR-Version
projected_at: string; // Timestamp der Projektion
last_event_id: string; // Bis zu diesem Event aktuell
// Run
current_run: Run | null;
// Trading
open_positions: Position[];
pending_orders: Order[];
recent_signals: Signal[]; // Letzte N Signale (z.B. 100)
recent_intents: Intent[]; // Letzte N Intents
// Health
current_health: Health | null;
// Config
active_config: Config | null;
// Aggregates
stats: {
total_trades_today: number;
total_pnl_today: number;
max_drawdown_today: number;
uptime_seconds: number;
};
// Safety/Observability-Status
safety: {
overall_status: "healthy" | "degraded" | "critical";
active_violations: string[]; // Liste der aktiven SAFETY_VIOLATED Events
last_violation_at: string | null;
};
observability: {
overall_status: "healthy" | "degraded" | "offline";
active_warnings: string[]; // Liste aktiver OBSERVABILITY_WARN Events
last_report_attempt_at: string | null;
last_successful_report_at: string | null;
};
}
2.2 Nicht im State (keine Quelle der Wahrheit)
- Kein "next_trade_id" – wird aus Events berechnet
- Kein "balance" – wird bei Bedarf gefetcht
- Keine historischen Daten (nur via Event Store)
3. Quelle der Wahrheit
EINZIGE Quelle der Wahrheit: Das Event Store (SQLite/Append-only)
┌─────────────────────────────────────────────────────────────┐
│ EVENT STORE │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Event #1 │ → │ Event #2 │ → │ Event #3 │ → ... │
│ │ RUN_STARTED│ │SIGNAL_GEN │ │INTENT_VALID │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ Query / Replay
▼
┌─────────────────────────────────────────────────────────────┐
│ STATE PROJECTION │
│ (in-memory oder SQLite, rekonstruierbar jederzeit) │
└─────────────────────────────────────────────────────────────┘
Regeln:
1. Nur core_engine schreibt Events
2. Alle anderen Komponenten lesen nur Projection
3. State-Dateien sind Cache, keine Quelle
4. Bei Unstimmigkeit: Event Store gewinnt
4. Rebuild-Regeln
4.1 Deterministische Projektion
State = Fold(Events, InitialState, Reducer)
// Pseudo-Code
function rebuildState(events) {
let state = createInitialState();
for (const event of events.sort(byOccurredAt)) {
state = applyEvent(state, event);
state.last_event_id = event.event_id;
}
return state;
}
function applyEvent(state, event) {
switch (event.event_type) {
case "POSITION_OPENED":
state.open_positions.push(event.payload);
break;
case "ORDER_FILLED":
updatePositionFromFill(state, event.payload);
break;
case "SAFETY_VIOLATED":
state.safety.active_violations.push(event.payload.violation_type);
state.safety.overall_status = "critical";
break;
case "SAFETY_RESOLVED":
removeViolation(state.safety, event.payload.violation_type);
break;
// ... weitere Reducer
}
return state;
}
4.2 Idempotenz
- Jedes Event hat UUID (
event_id) - Reducer prüft:
if (alreadyProcessed(event_id)) return state; - Duplikate werden ignoriert
4.3 Ordering
- Events sind strikt nach
occurred_atgeordnet - Bei gleichem Timestamp:
event_id(ULID) als Tiebreaker - Out-of-order Events werden in Queue bis Lücke geschlossen
4.4 Partial Rebuild
// Nicht alles von vorne notwendig
function incrementalRebuild(state, newEvents) {
for (const event of newEvents) {
if (event.event_id > state.last_event_id) {
state = applyEvent(state, event);
}
}
return state;
}
5. Safety vs Observability im State
5.1 Safety-Fields
interface SafetyState {
overall_status: "healthy" | "degraded" | "critical";
active_violations: {
type: "sizing" | "reconcile" | "watchdog" | "market_data";
detected_at: string;
message: string;
severity: "block" | "warn"; // block = trading stop
}[];
last_violation_at: string | null;
block_trading: boolean; // true wenn critical + severity=block
}
5.2 Observability-Fields
interface ObservabilityState {
overall_status: "healthy" | "degraded" | "offline";
active_warnings: {
type: "discord_down" | "report_fail" | "log_fail";
detected_at: string;
message: string;
severity: "warn" | "info"; // NIEMALS block
}[];
last_warning_at: string | null;
metrics: {
reports_attempted: number;
reports_succeeded: number;
reports_failed: number;
};
}
5.3 Entscheidungsmatrix
| Event | Feld | Effekt auf Trading |
|---|---|---|
| SAFETY_VIOLATED (severity=block) | safety.block_trading = true | BLOCK |
| SAFETY_VIOLATED (severity=warn) | safety.overall_status = degraded | WARN |
| SAFETY_RESOLVED | safety.block_trading = false | Resume möglich |
| OBSERVABILITY_WARN | observability.overall_status = degraded | KEIN EFFEKT |
| OBSERVABILITY_RESOLVED | observability.overall_status = healthy | - |
Consequences
Positive
- Klare Trennung von Events (truth) und State (projection)
- Deterministisches Debugging: State zu jedem Zeitpunkt reproduzierbar
- Safety-Observability-Trennung verhindert, dass Discord-Ausfälle das Trading blockieren
Negative
- Event Store braucht Storage (kompensiert durch Rotation)
- Rebuild kann Zeit kosten (kompensiert durch Incremental)
Related
- ADR-001: Target Architecture
- ADR-002: Hyperliquid Integration
- ADR-004: Risk Controls (Safety-Gates)
- MASTERPLAN_PHASES_1-9.md