Artifex/tests/test_qualification_replay_v1.py

381 lines
12 KiB
Python
Raw Permalink Normal View History

from datetime import UTC, datetime, timedelta
import pytest
from control_plane.projects.models import Project
from control_plane.trading_studio.models import (
DataKind,
LiveStrategyRun,
MarketDataset,
MarketDatasetVersion,
Strategy,
StrategyVersion,
TradingProject,
)
from control_plane.trading_studio.qualification import (
Bar,
CalibrationMethod,
CostModel,
QualificationReplayV1,
ScenarioSpec,
calibrate_train_only,
qualification_scenario_catalog,
)
from control_plane.trading_studio.services import TradingStudioService
BASE = datetime(2026, 1, 5, tzinfo=UTC)
def bar(day, open_, high, low, close, signal=None, context=(), reference=""):
return Bar(BASE + timedelta(days=day), open_, high, low, close, signal, context, reference)
def calibrated(bars, *, method=CalibrationMethod.RAW):
return calibrate_train_only(
bars,
method=method,
fold="fold-a",
train_start=BASE,
train_end=BASE + timedelta(days=1),
source_record_ids=("source-1", "source-2"),
source_combo="ohlcv+context",
percentile=50,
)
def run(bars, scenario=None):
return QualificationReplayV1().run(
bars,
strategy="strategy-v1",
dataset="Cohort001",
fold="fold-a",
runner="test-runner",
scenario=scenario or ScenarioSpec("base", 100, 100),
calibration=calibrated(bars),
)
def test_multibar_state_machine_emits_signal_entry_exit_and_ledger_directly():
result = run(
[
bar(0, 100, 101, 99, 100, 1),
bar(1, 100, 101, 99, 100, 2),
bar(2, 100, 102, 99, 101),
bar(3, 101, 103, 100, 102),
]
)
assert [event.event_type for event in result.events] == [
"SIGNAL",
"ENTRY_QUEUED",
"ENTRY",
"EXIT",
]
assert result.ledger[0].holding_bars == 1
assert result.ledger[0].entry_bar == 2
def test_same_bar_tp_sl_ambiguity_is_pessimistic_stop():
result = run(
[bar(0, 100, 101, 99, 100, 1), bar(1, 100, 101, 99, 100, 2), bar(2, 100, 102, 98, 101)]
)
assert result.ledger[0].exit_reason == "SL"
assert result.ledger[0].exit_execution_price == 99
def test_delay_creates_natural_later_entry():
result = run(
[
bar(0, 100, 101, 99, 100, 1),
bar(1, 100, 101, 99, 100, 2),
bar(2, 100, 100, 99, 100),
bar(3, 100, 102, 99, 101),
],
ScenarioSpec("delay", 100, 100, entry_delay_bars=2),
)
assert result.ledger[0].entry_bar == 3
def test_stop_width_changes_later_eligibility():
bars = [
bar(0, 100, 101, 99, 100, 1),
bar(1, 100, 101, 99, 100, 2),
bar(2, 100, 100.5, 98.5, 99),
bar(3, 99, 101, 98, 100, 2),
bar(4, 100, 102, 99, 101),
]
narrow = run(bars, ScenarioSpec("narrow", 100, 100, cooldown_bars=1))
wide = run(bars, ScenarioSpec("wide", 300, 100, cooldown_bars=1, max_holding_bars=3))
assert len(narrow.ledger) == 2
assert len(wide.ledger) == 1
def test_costs_funding_and_ledger_reconcile():
costs = CostModel("v9", 10, 20, 5, 5, 1, 0.5, 0.25)
result = run(
[bar(0, 100, 101, 99, 100, 1), bar(1, 100, 101, 99, 100, 2), bar(2, 100, 102, 99, 101)],
ScenarioSpec("costed", 100, 100, cost_model=costs),
)
row = result.ledger[0]
assert row.net_pnl == pytest.approx(
row.gross_pnl - row.entry_commission - row.exit_commission - row.funding - row.other_cost
)
assert row.entry_slippage > 0 and row.exit_slippage > 0
def test_deq_uses_forward_path_not_trade_pnl():
result = run(
[
bar(0, 100, 101, 99, 100, 1),
bar(1, 100, 101, 99, 100, 2),
bar(2, 100, 100, 99, 100),
bar(3, 100, 110, 99, 109),
]
)
deq = result.ledger[0].deq
assert deq.return_1_bars_bps == 900
assert deq.mfe_bps == 1000
assert deq.time_to_positive_bars == 1
assert deq.time_to_25_bps_bars == 1
assert deq.time_to_50_bps_bars == 1
assert deq.max_adverse_before_positive_bps == -100
def test_deq_is_direction_correct_and_rescue_is_ledger_derived():
result = run(
[
bar(0, 100, 101, 99, 100, 1),
bar(1, 100, 101, 99, 100, -2),
bar(2, 100, 100.5, 98, 99),
bar(3, 99, 99.5, 95, 96),
]
)
deq = result.ledger[0].deq
assert deq.return_1_bars_bps == 400
assert deq.mfe_bps == 500
assert deq.mae_bps == -50
assert result.rescue.trade_count == len(result.ledger)
assert result.rescue.win_count + result.rescue.loss_count == result.rescue.trade_count
assert result.rescue.net_pnl == result.net_pnl
assert result.rescue.commissions == result.commissions
def test_exact_named_scenario_catalog_and_round_trip_costs():
catalog = qualification_scenario_catalog()
assert set(catalog) == {
"BASELINE",
"SLIPPAGE_ADVERSE_2BP",
"TOTAL_COST_6BP",
"TOTAL_COST_10BP",
"TOTAL_COST_15BP",
"PESSIMISTIC_SAME_BAR",
"ENTRY_DELAY_PLUS_ONE_BAR",
"WEEKDAYS_ONLY",
"ALL_DAYS",
"STOP_WIDTH_100",
"STOP_WIDTH_75",
"STOP_WIDTH_50",
"STOP_WIDTH_25",
}
assert catalog["SLIPPAGE_ADVERSE_2BP"].cost_model.nominal_round_trip_bps == 2
assert catalog["TOTAL_COST_15BP"].cost_model.nominal_round_trip_bps == 15
assert catalog["ENTRY_DELAY_PLUS_ONE_BAR"].entry_delay_bars == 2
assert catalog["ALL_DAYS"].allow_weekend_entries is True
def test_batch_protocol_identity_is_an_optional_canary_input_and_persisted_snapshot():
scenario = ScenarioSpec("batch", 100, 100)
identity = {"specimen_report_sha256": "1148d891" + "0" * 56}
assert TradingStudioService.qualification_configuration(scenario) == {
"name": "batch",
"stop_loss_bps": 100,
"take_profit_bps": 100,
"entry_delay_bars": 1,
"cooldown_bars": 0,
"max_holding_bars": 1,
"allow_weekend_entries": False,
"same_bar_policy": "PESSIMISTIC_SL",
"cost_model": {
"version": "qualification-v1",
"entry_commission_bps": 0.0,
"exit_commission_bps": 0.0,
"entry_slippage_bps": 0.0,
"exit_slippage_bps": 0.0,
"funding_bps_per_bar": 0.0,
"entry_other_cost": 0.0,
"exit_other_cost": 0.0,
},
}
snapshot = TradingStudioService.qualification_configuration(
scenario, protocol_identity=identity, require_protocol_identity=True
)
assert snapshot["protocol_identity"] == identity
with pytest.raises(ValueError, match="requires a protocol identity"):
TradingStudioService.qualification_configuration(
scenario, require_protocol_identity=True
)
def test_raw_calibration_has_no_holdout_leak_ab_equivalence():
left = [bar(0, 10, 11, 9, 10, 1, (1,)), bar(1, 10, 11, 9, 10, 100, (100,))]
right = [bar(0, 10, 11, 9, 10, 1, (1,)), bar(1, 10, 11, 9, 10, -999, (-999,))]
assert calibrated(left).threshold == calibrated(right).threshold == 1
def test_cohort_raw_calibration_retains_zero_signal_train_provenance_without_fabrication():
bars = [
bar(0, 100, 101, 99, 100, None, (1.0,), "train-0"),
bar(1, 100, 101, 99, 100, None, (2.0,), "train-1"),
bar(2, 100, 101, 99, 100, None, (3.0,), "replay-0"),
]
calibration = calibrate_train_only(
bars,
method=CalibrationMethod.RAW,
fold="fold-a",
train_start=BASE,
train_end=BASE + timedelta(days=2),
source_record_ids=("frozen-member",),
source_combo="frozen-hs22-combo",
allow_empty_raw=True,
)
result = QualificationReplayV1().run(
bars[2:],
strategy="strategy-v1",
dataset="Cohort001",
fold="fold-a",
runner="cohort_qualification_hyperscalper_001:v1",
scenario=ScenarioSpec("baseline", 100, 100),
calibration=calibration,
)
assert calibration.threshold == 0.0
assert calibration.sample_count == 0
assert calibration.source_combo == "frozen-hs22-combo"
assert calibration.input_hash
assert result.ledger == ()
assert result.rescue.trade_count == 0
assert result.rescue.net_pnl == 0
def test_percentile_calibration_still_rejects_zero_qualifying_train_signals():
with pytest.raises(ValueError, match="Train-only calibration"):
calibrate_train_only(
[bar(0, 100, 101, 99, 100)],
method=CalibrationMethod.PERCENTILE,
fold="fold-a",
train_start=BASE,
train_end=BASE + timedelta(days=1),
source_record_ids=("frozen-member",),
source_combo="frozen-hs22-combo",
percentile=50,
allow_empty_raw=True,
)
def test_percentile_calibration_records_provenance_and_continuous_context():
bars = [bar(0, 10, 11, 9, 10, 1, (0.1, 0.2)), bar(0, 10, 11, 9, 10, 3, (0.3, 0.4))]
value = calibrate_train_only(
bars,
method=CalibrationMethod.PERCENTILE,
fold="fold-a",
train_start=BASE,
train_end=BASE + timedelta(days=1),
source_record_ids=("a",),
source_combo="ohlcv+continuous",
percentile=100,
)
assert value.threshold == 3 and value.input_hash and value.source_combo == "ohlcv+continuous"
def test_reference_mode_is_absent_and_scenario_names_are_required():
assert not hasattr(QualificationReplayV1, "reference_mode")
with pytest.raises(ValueError, match="named"):
ScenarioSpec("", 100, 100)
with pytest.raises(
ValueError,
match="missing materialized strategy_version, dataset_version, bars, calibration",
):
TradingStudioService().run_qualification_canary(
strategy_version=None,
dataset_version=None,
fold="fold-a",
scenario=ScenarioSpec("canary", 100, 100),
runner_name="runner",
bars=[],
calibration=None,
)
def test_materialized_canary_persists_without_creating_live_run():
project = Project.objects.create(name="Canary", goal="Test qualification persistence")
trading_project = TradingProject.objects.create(
project=project,
name="Canary Trading",
slug="canary-trading",
goal="Test qualification persistence",
)
dataset = MarketDataset.objects.create(
trading_project=trading_project,
name="Materialized BTCUSDT",
kind=DataKind.OHLCV,
)
dataset_version = MarketDatasetVersion.objects.create(
dataset=dataset,
version="frozen-v1",
reference="artifact://materialized.csv",
content_hash="a" * 64,
)
strategy = Strategy.objects.create(trading_project=trading_project, name="HS22")
strategy_version = StrategyVersion.objects.create(
strategy=strategy,
version="frozen-v1",
genome={"combo": [1]},
fingerprint="b" * 64,
)
bars = [
bar(0, 100, 101, 99, 100, 1),
bar(1, 100, 101, 99, 100, 2),
bar(2, 100, 102, 99, 101),
bar(3, 101, 103, 100, 102),
]
service = TradingStudioService()
run = service.run_qualification_canary(
strategy_version=strategy_version,
dataset_version=dataset_version,
fold="fold-a",
scenario=ScenarioSpec("baseline", 100, 100),
runner_name="test-runner",
bars=bars,
calibration=calibrated(bars),
)
assert run.replay_mode == "CANARY_ONLY"
assert run.ledger_rows.count() == 1
assert LiveStrategyRun.objects.count() == 0
zero_bars = [bar(0, 100, 101, 99, 100), bar(1, 100, 101, 99, 100)]
zero_run = service.run_qualification_canary(
strategy_version=strategy_version,
dataset_version=dataset_version,
fold="fold-zero",
scenario=ScenarioSpec("zero-trade", 100, 100),
runner_name="test-runner",
bars=zero_bars,
calibration=calibrate_train_only(
zero_bars,
method=CalibrationMethod.RAW,
fold="fold-zero",
train_start=BASE,
train_end=BASE + timedelta(days=1),
source_record_ids=("frozen-member",),
source_combo="frozen-hs22-combo",
allow_empty_raw=True,
),
)
assert zero_run.ledger_rows.count() == 0
assert zero_run.summary["ledger_count"] == 0
assert zero_run.summary["rescue"]["trade_count"] == 0
assert zero_run.summary["rescue"]["net_pnl"] == 0