Artifex/control_plane/trading_studio/qualification.py

685 lines
23 KiB
Python
Raw Normal View History

"""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
},
)