diff --git a/prod/clean_arch/dita/contracts.py b/prod/clean_arch/dita/contracts.py new file mode 100644 index 0000000..2dbb61f --- /dev/null +++ b/prod/clean_arch/dita/contracts.py @@ -0,0 +1,245 @@ +"""Canonical DITA contracts. + +Decision -> Intent -> Trade -> Account. + +These contracts are intentionally separate from execution, exchange, and +observability. They are also designed to remain BLUE-compatible where the +exchange model allows it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Optional, Tuple + + +class DecisionAction(str, Enum): + """Decision-level action.""" + + ENTER = "ENTER" + HOLD = "HOLD" + EXIT = "EXIT" + FLAT = "FLAT" + # Policy-gate vetoes — ENTER was signalled but blocked by an external gate. + # Each value preserves the gate identity for CH persistence + TUI observability. + # CRITICAL TODO: replace with KernelPolicyGate hook system (see memory: + # project_kernelpolicygate_arch.md) — these values are the interim trace mechanism. + HOLD_DC_CONTRADICTED = "HOLD_DC_CONTRADICTED" # DC gate: rising price in lookback + + +class TradeSide(str, Enum): + """Trade side.""" + + LONG = "LONG" + SHORT = "SHORT" + FLAT = "FLAT" + + +class TradeStage(str, Enum): + """Canonical lifecycle milestones.""" + + DECISION_CREATED = "DECISION_CREATED" + INTENT_CREATED = "INTENT_CREATED" + ORDER_REQUESTED = "ORDER_REQUESTED" + ORDER_SENT = "ORDER_SENT" + ORDER_ACKED = "ORDER_ACKED" + POSITION_OPENED = "POSITION_OPENED" + POSITION_UPDATED = "POSITION_UPDATED" + EXIT_REQUESTED = "EXIT_REQUESTED" + EXIT_SENT = "EXIT_SENT" + EXIT_ACKED = "EXIT_ACKED" + POSITION_PARTIALLY_CLOSED = "POSITION_PARTIALLY_CLOSED" + POSITION_CLOSED = "POSITION_CLOSED" + TRADE_TERMINAL_WRITTEN = "TRADE_TERMINAL_WRITTEN" + + +@dataclass(frozen=True) +class DecisionConfig: + """Pure decision configuration.""" + + vel_div_threshold: float = -0.02 + vel_div_extreme: float = -0.05 + fixed_tp_pct: float = 0.0095 + max_hold_bars: int = 120 + catastrophic_loss_pct: float = 0.05 + capital_fraction: float = 0.20 + max_leverage: float = 5.0 + min_irp_alignment: float = 0.0 + allow_long: bool = False + allow_short: bool = True + exit_leg_ratios: Tuple[float, ...] = (1.0,) + policy_version: str = "clean_arch_dita_v1" + + +@dataclass(frozen=True) +class DecisionContext: + """Minimal state required to make a decision.""" + + capital: float + open_positions: int = 0 + trade_seq: int = 0 + + +@dataclass(frozen=True) +class IntentContext: + """Minimal state required to size and route an intent.""" + + capital: float + open_positions: int = 0 + trade_seq: int = 0 + + +@dataclass(frozen=True) +class Decision: + """Pure decision emitted by the decision layer.""" + + timestamp: datetime + decision_id: str + asset: str + action: DecisionAction + side: TradeSide + reason: str + confidence: float + velocity_divergence: float + irp_alignment: float + reference_price: float + target_size: float = 0.0 + leverage: float = 1.0 + bars_held: int = 0 + stage: TradeStage = TradeStage.DECISION_CREATED + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Intent: + """Executable intent chosen from a decision.""" + + timestamp: datetime + trade_id: str + decision_id: str + asset: str + action: DecisionAction + side: TradeSide + reason: str + target_size: float + leverage: float + reference_price: float + confidence: float + bars_held: int = 0 + stage: TradeStage = TradeStage.INTENT_CREATED + exit_leg_ratios: Tuple[float, ...] = (1.0,) + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class TradePosition: + """Mutable trade-side position state.""" + + trade_id: str + asset: str + side: TradeSide + entry_price: float + entry_time: datetime + size: float + leverage: float + entry_velocity_divergence: float + entry_irp_alignment: float + bars_held: int = 0 + current_price: float = 0.0 + realized_pnl: float = 0.0 + unrealized_pnl: float = 0.0 + exit_price: float = 0.0 + closed: bool = False + close_reason: str = "" + initial_size: float = 0.0 + exit_leg_ratios: Tuple[float, ...] = (1.0,) + exit_leg_index: int = 0 + + def __post_init__(self) -> None: + if self.initial_size <= 0: + self.initial_size = self.size + if self.current_price <= 0: + self.current_price = self.entry_price + + def mark_price(self, price: float) -> None: + """Refresh mark price and unrealized PnL.""" + if price is None or price != price or price in (float("inf"), float("-inf")) or price <= 0: + return + self.current_price = price + if self.entry_price <= 0: + self.unrealized_pnl = 0.0 + return + delta = (price - self.entry_price) / self.entry_price + if self.side == TradeSide.SHORT: + delta = -delta + self.unrealized_pnl = delta * self.size * self.entry_price * self.leverage + + def age_one_bar(self) -> None: + self.bars_held += 1 + + @property + def remaining_size(self) -> float: + return max(0.0, self.size) + + def next_exit_ratio(self) -> float: + if self.exit_leg_index < len(self.exit_leg_ratios): + ratio = self.exit_leg_ratios[self.exit_leg_index] + self.exit_leg_index += 1 + return max(0.0, min(1.0, float(ratio))) + return 1.0 + + +@dataclass(frozen=True) +class TradeEvent: + """Append-only event row for observability.""" + + timestamp: datetime + trade_id: str + decision_id: str + asset: str + stage: TradeStage + action: DecisionAction + side: TradeSide + reason: str + price: float + size: float + leverage: float + capital_before: float + capital_after: float + open_positions_before: int + open_positions_after: int + pnl: float = 0.0 + pnl_pct: float = 0.0 + bars_held: int = 0 + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AccountEvent: + """Canonical account projection row.""" + + timestamp: datetime + runtime_namespace: str + strategy_namespace: str + event_namespace: str + actor_name: str + exec_venue: str + data_venue: str + ledger_authority: str + capital: float + equity: float + open_positions: int + current_open_notional: float + current_account_leverage: float + decision_id: str + trade_id: str + asset: str + side: TradeSide + reason: str + stage: TradeStage + pnl: float = 0.0 + pnl_pct: float = 0.0 + bars_held: int = 0 + metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/prod/clean_arch/runtime/pink_direct.py b/prod/clean_arch/runtime/pink_direct.py index bb3bb30..d835a89 100644 --- a/prod/clean_arch/runtime/pink_direct.py +++ b/prod/clean_arch/runtime/pink_direct.py @@ -849,7 +849,9 @@ class PinkDirectRuntime: decision = self.decision_engine.decide(snapshot, context, legacy_position) if dc_blocked and decision.action == DecisionAction.ENTER: import dataclasses - decision = dataclasses.replace(decision, action=DecisionAction.HOLD, reason="DC_CONTRADICT") + decision = dataclasses.replace(decision, action=DecisionAction.HOLD_DC_CONTRADICTED, reason="DC_CONTRADICT") + self.logger.info("DC CONTRADICT: ENTER blocked (vel_div=%.4f scan=%d symbol=%s)", + self._last_vel_div, self._last_scan_number, getattr(snapshot, "symbol", "?")) self._emit("decision", decision=decision) intent_context = IntentContext(