684 lines
23 KiB
Python
684 lines
23 KiB
Python
"""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
|
|
},
|
|
)
|