"""Deterministic QualificationReplayV1 with no external or reference-data dependency.""" from __future__ import annotations from collections.abc import Iterable from dataclasses import asdict, dataclass from datetime import datetime from enum import StrEnum from hashlib import sha256 from json import dumps class ReplayMode(StrEnum): CANARY_ONLY = "CANARY_ONLY" class CalibrationMethod(StrEnum): RAW = "RAW" PERCENTILE = "PERCENTILE" class SameBarPolicy(StrEnum): PESSIMISTIC_SL = "PESSIMISTIC_SL" class EventType(StrEnum): SIGNAL = "SIGNAL" ENTRY_QUEUED = "ENTRY_QUEUED" ENTRY = "ENTRY" EXIT = "EXIT" COOLDOWN_START = "COOLDOWN_START" COOLDOWN_END = "COOLDOWN_END" ENTRY_SKIPPED = "ENTRY_SKIPPED" class ReplayState(StrEnum): FLAT = "FLAT" ENTRY_QUEUED = "ENTRY_QUEUED" OPEN = "OPEN" COOLDOWN = "COOLDOWN" @dataclass(frozen=True) class CostModel: version: str entry_commission_bps: float = 0.0 exit_commission_bps: float = 0.0 entry_slippage_bps: float = 0.0 exit_slippage_bps: float = 0.0 funding_bps_per_bar: float = 0.0 entry_other_cost: float = 0.0 exit_other_cost: float = 0.0 def __post_init__(self) -> None: if not self.version: raise ValueError("Cost model version is required.") if ( min( self.entry_commission_bps, self.exit_commission_bps, self.entry_slippage_bps, self.exit_slippage_bps, self.entry_other_cost, self.exit_other_cost, ) < 0 ): raise ValueError("Commission, slippage, and other costs cannot be negative.") @property def nominal_round_trip_bps(self) -> float: """Known entry/exit friction, excluding time-dependent funding and fixed costs.""" return ( self.entry_commission_bps + self.exit_commission_bps + self.entry_slippage_bps + self.exit_slippage_bps ) @dataclass(frozen=True) class ScenarioSpec: name: str stop_loss_bps: float take_profit_bps: float entry_delay_bars: int = 1 cooldown_bars: int = 0 max_holding_bars: int = 1 allow_weekend_entries: bool = False same_bar_policy: SameBarPolicy = SameBarPolicy.PESSIMISTIC_SL cost_model: CostModel = CostModel(version="qualification-v1") def __post_init__(self) -> None: if not self.name: raise ValueError("Every qualification scenario must be named.") if min(self.stop_loss_bps, self.take_profit_bps) <= 0: raise ValueError("TP and SL widths must be positive.") if self.entry_delay_bars < 1 or self.cooldown_bars < 0 or self.max_holding_bars < 1: raise ValueError("Invalid entry delay, cooldown, or holding period.") def qualification_scenario_catalog() -> dict[str, ScenarioSpec]: """The fixed, named QualificationReplayV1 scenario catalog.""" def total_cost(name: str, total_bps: float) -> ScenarioSpec: component = total_bps / 4 return ScenarioSpec( name, 100, 100, cost_model=CostModel( version=f"{name.lower()}-v1", entry_commission_bps=component, exit_commission_bps=component, entry_slippage_bps=component, exit_slippage_bps=component, ), ) baseline = ScenarioSpec("BASELINE", 100, 100) return { "BASELINE": baseline, "SLIPPAGE_ADVERSE_2BP": ScenarioSpec( "SLIPPAGE_ADVERSE_2BP", 100, 100, cost_model=CostModel( version="slippage-adverse-2bp-v1", entry_slippage_bps=1, exit_slippage_bps=1, ), ), "TOTAL_COST_6BP": total_cost("TOTAL_COST_6BP", 6), "TOTAL_COST_10BP": total_cost("TOTAL_COST_10BP", 10), "TOTAL_COST_15BP": total_cost("TOTAL_COST_15BP", 15), "PESSIMISTIC_SAME_BAR": ScenarioSpec("PESSIMISTIC_SAME_BAR", 100, 100), "ENTRY_DELAY_PLUS_ONE_BAR": ScenarioSpec( "ENTRY_DELAY_PLUS_ONE_BAR", 100, 100, entry_delay_bars=2 ), "WEEKDAYS_ONLY": ScenarioSpec("WEEKDAYS_ONLY", 100, 100, allow_weekend_entries=False), "ALL_DAYS": ScenarioSpec("ALL_DAYS", 100, 100, allow_weekend_entries=True), "STOP_WIDTH_100": ScenarioSpec("STOP_WIDTH_100", 100, 100), "STOP_WIDTH_75": ScenarioSpec("STOP_WIDTH_75", 75, 100), "STOP_WIDTH_50": ScenarioSpec("STOP_WIDTH_50", 50, 100), "STOP_WIDTH_25": ScenarioSpec("STOP_WIDTH_25", 25, 100), } @dataclass(frozen=True) class Bar: at: datetime open: float high: float low: float close: float signal: float | None = None context: tuple[float, ...] = () reference: str = "" def __post_init__(self) -> None: if min(self.open, self.high, self.low, self.close) <= 0: raise ValueError("OHLC prices must be positive.") if self.high < max(self.open, self.close) or self.low > min(self.open, self.close): raise ValueError("OHLC values are inconsistent.") @dataclass(frozen=True) class Calibration: method: CalibrationMethod threshold: float percentile: float | None source_record_ids: tuple[str, ...] source_combo: str fold: str input_hash: str train_start: datetime train_end: datetime sample_count: int @dataclass(frozen=True) class ReplayEvent: sequence: int event_type: EventType state: ReplayState at: datetime bar_index: int detail: str = "" @dataclass(frozen=True) class DEQPrimitives: return_1_bars_bps: float | None return_2_bars_bps: float | None return_3_bars_bps: float | None return_5_bars_bps: float | None return_10_bars_bps: float | None mfe_bps: float mae_bps: float time_to_positive_bars: int | None time_to_25_bps_bars: int | None time_to_50_bps_bars: int | None max_adverse_before_positive_bps: float winner_negative_first: bool recovery_bars: int | None @dataclass(frozen=True) class RescuePrimitives: activated: bool trigger_drawdown: float max_drawdown: float max_drawdown_bps: float peak_equity: float final_equity: float trade_count: int win_count: int loss_count: int win_rate: float gross_profit: float gross_loss: float profit_factor: float | None gross_pnl: float commissions: float slippage: float funding: float other_cost: float net_pnl: float average_trade_pnl: float max_losing_streak: int recovered: bool recovery_bars: int | None RescueMetrics = RescuePrimitives @dataclass(frozen=True) class LedgerRow: identity: str strategy: str dataset: str fold: str scenario: str runner: str signal_at: datetime signal_bar: int signal_reference: str entry_at: datetime entry_bar: int entry_reference_price: float entry_execution_price: float exit_at: datetime exit_bar: int exit_reference_price: float exit_execution_price: float take_profit_price: float stop_loss_price: float quantity: float gross_pnl: float entry_commission: float exit_commission: float entry_slippage: float exit_slippage: float funding: float other_cost: float net_pnl: float holding_bars: int cooldown_bars: int same_bar_policy: SameBarPolicy exit_reason: str deq: DEQPrimitives @dataclass(frozen=True) class ReplayAggregate: mode: ReplayMode strategy: str dataset: str fold: str scenario: ScenarioSpec runner: str calibration: Calibration events: tuple[ReplayEvent, ...] ledger: tuple[LedgerRow, ...] gross_pnl: float commissions: float slippage: float funding: float other_cost: float net_pnl: float rescue: RescueMetrics def calibrate_train_only( bars: Iterable[Bar], *, method: CalibrationMethod, fold: str, train_start: datetime, train_end: datetime, source_record_ids: Iterable[str], source_combo: str, percentile: float | None = None, allow_empty_raw: bool = False, ) -> Calibration: """Calibrate from continuous signal/context records strictly inside the train window.""" records = tuple(bars) train_records = [bar for bar in records if train_start <= bar.at < train_end] train = [bar for bar in records if train_start <= bar.at < train_end and bar.signal is not None] source_ids = tuple(source_record_ids) raw_empty_allowed = method is CalibrationMethod.RAW and allow_empty_raw if ( not train_records or (not train and not raw_empty_allowed) or not source_ids or not fold or not source_combo ): raise ValueError( "Train-only calibration requires train records, sources, fold, and source combo." ) values = sorted(abs(bar.signal) for bar in train if bar.signal is not None) if method is CalibrationMethod.RAW: threshold = sum(values) / len(values) if values else 0.0 selected_percentile = None else: if percentile is None or not 0 <= percentile <= 100: raise ValueError("PERCENTILE calibration requires a percentile from 0 through 100.") position = round((len(values) - 1) * percentile / 100) threshold = values[position] selected_percentile = percentile payload = [ { "at": bar.at.isoformat(), "signal": bar.signal, "context": bar.context, "reference": bar.reference, } for bar in (train_records if raw_empty_allowed else train) ] input_hash = sha256(dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() return Calibration( method, threshold, selected_percentile, source_ids, source_combo, fold, input_hash, train_start, train_end, len(train), ) class QualificationReplayV1: mode = ReplayMode.CANARY_ONLY def run( self, bars: Iterable[Bar], *, strategy: str, dataset: str, fold: str, scenario: ScenarioSpec, runner: str, calibration: Calibration, initial_equity: float = 1.0, ) -> ReplayAggregate: sequence = tuple(bars) if not strategy or not dataset or not fold or not runner: raise ValueError("Strategy, dataset, fold, and runner are required.") if calibration.fold != fold or initial_equity <= 0: raise ValueError("Calibration fold must match and initial equity must be positive.") if any(left.at >= right.at for left, right in zip(sequence, sequence[1:], strict=False)): raise ValueError("Bars must be strictly chronological.") events: list[ReplayEvent] = [] ledger: list[LedgerRow] = [] state = ReplayState.FLAT queued: tuple[Bar, int] | None = None position: tuple[Bar, int, float, float, float] | None = None cooldown_remaining = 0 def emit( event_type: EventType, state_value: ReplayState, index: int, detail: str = "" ) -> None: events.append( ReplayEvent(len(events), event_type, state_value, sequence[index].at, index, detail) ) for index, bar in enumerate(sequence): if state is ReplayState.COOLDOWN: cooldown_remaining -= 1 if cooldown_remaining == 0: state = ReplayState.FLAT emit(EventType.COOLDOWN_END, state, index) else: continue if state is ReplayState.ENTRY_QUEUED and queued is not None and index == queued[1]: signal_bar, _ = queued if not scenario.allow_weekend_entries and bar.at.weekday() >= 5: emit(EventType.ENTRY_SKIPPED, ReplayState.FLAT, index, "weekend") queued = None state = ReplayState.FLAT else: direction = 1 if signal_bar.signal and signal_bar.signal > 0 else -1 entry_reference = bar.open entry_execution = self._apply_slippage( entry_reference, direction, scenario.cost_model.entry_slippage_bps ) stop = entry_execution * (1 - direction * scenario.stop_loss_bps / 10_000) target = entry_execution * (1 + direction * scenario.take_profit_bps / 10_000) position = (signal_bar, index, entry_execution, stop, target) queued = None state = ReplayState.OPEN emit(EventType.ENTRY, state, index) if state is ReplayState.OPEN and position is not None: signal_bar, entry_index, entry_execution, stop, target = position exit_reason, exit_reference = self._exit_for_bar( bar, entry_execution, stop, target, index - entry_index + 1, scenario ) if exit_reason: direction = 1 if signal_bar.signal and signal_bar.signal > 0 else -1 row = self._ledger_row( sequence, strategy, dataset, fold, scenario, runner, signal_bar, entry_index, index, entry_execution, stop, target, exit_reference, direction, ) ledger.append(row) emit(EventType.EXIT, ReplayState.FLAT, index, exit_reason) position = None if scenario.cooldown_bars: state = ReplayState.COOLDOWN cooldown_remaining = scenario.cooldown_bars emit(EventType.COOLDOWN_START, state, index) else: state = ReplayState.FLAT if ( state is ReplayState.FLAT and bar.at >= calibration.train_end and bar.signal is not None ): if abs( bar.signal ) >= calibration.threshold and index + scenario.entry_delay_bars < len(sequence): emit(EventType.SIGNAL, state, index) queued = (bar, index + scenario.entry_delay_bars) state = ReplayState.ENTRY_QUEUED emit(EventType.ENTRY_QUEUED, state, index, f"bar={queued[1]}") commissions = sum(row.entry_commission + row.exit_commission for row in ledger) slippage = sum(row.entry_slippage + row.exit_slippage for row in ledger) funding = sum(row.funding for row in ledger) other = sum(row.other_cost for row in ledger) gross = sum(row.gross_pnl for row in ledger) net = sum(row.net_pnl for row in ledger) rescue = self._rescue(tuple(ledger), initial_equity) return ReplayAggregate( self.mode, strategy, dataset, fold, scenario, runner, calibration, tuple(events), tuple(ledger), gross, commissions, slippage, funding, other, net, rescue, ) @staticmethod def _apply_slippage(price: float, direction: int, bps: float) -> float: return price * (1 + direction * bps / 10_000) @staticmethod def _exit_for_bar( bar: Bar, entry: float, stop: float, target: float, held: int, scenario: ScenarioSpec ) -> tuple[str, float]: long = target > entry stopped = bar.low <= stop if long else bar.high >= stop target_hit = bar.high >= target if long else bar.low <= target if stopped and (scenario.same_bar_policy is SameBarPolicy.PESSIMISTIC_SL or not target_hit): return "SL", min(bar.open, stop) if long else max(bar.open, stop) if target_hit: return "TP", target if held >= scenario.max_holding_bars: return "TIME", bar.close return "", 0.0 def _ledger_row( self, bars: tuple[Bar, ...], strategy: str, dataset: str, fold: str, scenario: ScenarioSpec, runner: str, signal: Bar, entry_index: int, exit_index: int, entry: float, stop: float, target: float, exit_reference: float, direction: int, ) -> LedgerRow: cost = scenario.cost_model exit_execution = self._apply_slippage(exit_reference, -direction, cost.exit_slippage_bps) entry_commission = entry * cost.entry_commission_bps / 10_000 exit_commission = exit_execution * cost.exit_commission_bps / 10_000 entry_slippage = abs(entry - bars[entry_index].open) exit_slippage = abs(exit_execution - exit_reference) holding = exit_index - entry_index + 1 funding = entry * holding * cost.funding_bps_per_bar / 10_000 other = cost.entry_other_cost + cost.exit_other_cost gross = (exit_execution - entry) * direction net = gross - entry_commission - exit_commission - funding - other deq = self._deq(bars, entry_index, entry, direction) identity = f"{strategy}:{dataset}:{fold}:{scenario.name}:{runner}:{signal.at.isoformat()}" return LedgerRow( identity, strategy, dataset, fold, scenario.name, runner, signal.at, bars.index(signal), signal.reference, bars[entry_index].at, entry_index, bars[entry_index].open, entry, bars[exit_index].at, exit_index, exit_reference, exit_execution, target, stop, 1.0, gross, entry_commission, exit_commission, entry_slippage, exit_slippage, funding, other, net, holding, scenario.cooldown_bars, scenario.same_bar_policy, self._exit_for_bar(bars[exit_index], entry, stop, target, holding, scenario)[0], deq, ) @staticmethod def _deq( bars: tuple[Bar, ...], entry_index: int, entry: float, direction: int, ) -> DEQPrimitives: path = bars[entry_index:] highs = [direction * (bar.high - entry) / entry * 10_000 for bar in path] lows = [direction * (bar.low - entry) / entry * 10_000 for bar in path] favorable = highs if direction > 0 else lows adverse = lows if direction > 0 else highs positive_at = next((index for index, value in enumerate(favorable) if value > 0), None) before_positive = adverse[:positive_at] if positive_at is not None else adverse recovery = next( (index for index, bar in enumerate(path) if direction * (bar.close - entry) >= 0), None ) def forward(horizon: int) -> float | None: if entry_index + horizon >= len(bars): return None return direction * (bars[entry_index + horizon].close - entry) / entry * 10_000 return DEQPrimitives( forward(1), forward(2), forward(3), forward(5), forward(10), max(favorable), min(adverse), positive_at, next((index for index, value in enumerate(favorable) if value >= 25), None), next((index for index, value in enumerate(favorable) if value >= 50), None), min(before_positive, default=0.0), path[-1].close * direction > entry * direction and min(adverse) < 0, recovery, ) @staticmethod def _rescue(ledger: tuple[LedgerRow, ...], initial_equity: float) -> RescuePrimitives: equity = initial_equity peak = equity max_drawdown = 0.0 max_losing_streak = losing_streak = 0 recovery_bars: int | None = 0 recovery_target = initial_equity gross_profit = gross_loss = 0.0 for index, row in enumerate(ledger, start=1): equity += row.net_pnl gross_profit += max(row.net_pnl, 0) gross_loss += min(row.net_pnl, 0) losing_streak = losing_streak + 1 if row.net_pnl < 0 else 0 max_losing_streak = max(max_losing_streak, losing_streak) peak = max(peak, equity) drawdown = peak - equity if drawdown > max_drawdown: max_drawdown = drawdown recovery_target = peak recovery_bars = None elif recovery_bars is None and equity >= recovery_target: recovery_bars = index wins = sum(row.net_pnl > 0 for row in ledger) losses = sum(row.net_pnl < 0 for row in ledger) commissions = sum(row.entry_commission + row.exit_commission for row in ledger) slippage = sum(row.entry_slippage + row.exit_slippage for row in ledger) funding = sum(row.funding for row in ledger) other_cost = sum(row.other_cost for row in ledger) gross_pnl = sum(row.gross_pnl for row in ledger) net_pnl = sum(row.net_pnl for row in ledger) return RescuePrimitives( activated=equity <= 0, trigger_drawdown=initial_equity, max_drawdown=max_drawdown, max_drawdown_bps=max_drawdown / initial_equity * 10_000, peak_equity=peak, final_equity=equity, trade_count=len(ledger), win_count=wins, loss_count=losses, win_rate=wins / len(ledger) if ledger else 0, gross_profit=gross_profit, gross_loss=gross_loss, profit_factor=gross_profit / abs(gross_loss) if gross_loss else None, gross_pnl=gross_pnl, commissions=commissions, slippage=slippage, funding=funding, other_cost=other_cost, net_pnl=net_pnl, average_trade_pnl=net_pnl / len(ledger) if ledger else 0.0, max_losing_streak=max_losing_streak, recovered=recovery_bars is not None, recovery_bars=recovery_bars, ) def ledger_payload(row: LedgerRow) -> dict[str, object]: """JSON-safe immutable representation for the Django ledger model.""" return asdict( row, dict_factory=lambda values: { key: value.isoformat() if isinstance(value, datetime) else value for key, value in values }, )