from __future__ import annotations import sys import tempfile import unittest from argparse import Namespace from pathlib import Path import numpy as np import pandas as pd TRAINING_ROOT = Path(__file__).resolve().parents[1] if str(TRAINING_ROOT) not in sys.path: sys.path.insert(0, str(TRAINING_ROOT)) from trader_training.onnx_export import LinearHead, export_heads from trader_training.dynamic_exit_search import search_dynamic_exit_plans from trader_training.entry_condition_pair_screen import screen_entry_condition_pairs from trader_training.entry_feature_screen import _bucket_edges, _screen_edge_column from trader_training.io_utils import read_json, write_json from trader_training.labels import ENTRY_LABEL_METHOD, _path_stats_for_group, build_entry_labels from trader_training.ofi_feature_experiment import _load_entry_dataset, l1_snapshot_diff_ofi_quote from trader_training.promote import promote_artifact_bundle from trader_training.replay import build_splits from trader_training.schemas import FEATURE_ORDER, LATEST_STRESS_SPLIT, MODEL_OUTPUTS, OUTPUT_MAPPING, TRAINING_SPLITS, VALIDATION_LOCKED_SPLIT from trader_training.training import TARGETS class TrainingContractTest(unittest.TestCase): def test_feature_order_is_v4_contract_size(self) -> None: self.assertEqual(54, len(FEATURE_ORDER)) self.assertEqual(len(FEATURE_ORDER), len(set(FEATURE_ORDER))) self.assertEqual("ret_1m_bps", FEATURE_ORDER[0]) self.assertEqual("book_pressure_reversal_15m", FEATURE_ORDER[-1]) def test_output_mapping_matches_model_outputs(self) -> None: for model_name, fields in MODEL_OUTPUTS.items(): self.assertEqual(set(fields), set(OUTPUT_MAPPING[model_name])) self.assertEqual([f"prediction[{idx}]" for idx in range(len(fields))], [OUTPUT_MAPPING[model_name][field] for field in fields]) def test_entry_feature_screen_prefers_actual_plan_edge(self) -> None: dataset = pd.DataFrame( { "long_expected_net_edge_bps": [20.0], "short_expected_net_edge_bps": [15.0], "long_actual_plan_net_edge_bps": [-3.0], "short_actual_plan_net_edge_bps": [4.0], } ) self.assertEqual("long_actual_plan_net_edge_bps", _screen_edge_column(dataset, "LONG")) self.assertEqual("short_actual_plan_net_edge_bps", _screen_edge_column(dataset, "SHORT")) def test_entry_feature_screen_requires_actual_plan_edge(self) -> None: dataset = pd.DataFrame({"long_expected_net_edge_bps": [20.0]}) with self.assertRaises(ValueError): _screen_edge_column(dataset, "LONG") def test_entry_regression_heads_train_on_actual_plan_edge(self) -> None: heads = {head[0]: head[2] for head in TARGETS["ENTRY"]["heads"]} self.assertEqual("long_actual_plan_net_edge_bps", heads["long_expected_net_edge_bps"]) self.assertEqual("short_actual_plan_net_edge_bps", heads["short_expected_net_edge_bps"]) def test_entry_feature_screen_keeps_zero_inflated_event_features(self) -> None: values = np.concatenate((np.zeros(5000), np.linspace(1.0, 100.0, 500))) edges = _bucket_edges(values) self.assertGreaterEqual(len(edges), 3) self.assertEqual(-np.inf, edges[0]) self.assertEqual(np.inf, edges[-1]) def test_entry_condition_pair_screen_finds_stable_two_feature_filter(self) -> None: with tempfile.TemporaryDirectory() as tmp: data_root = Path(tmp) run_root = data_root / "trader-v4" / "runs" / "unit-condition-pair" dataset_path = run_root / "dataset" / "entry_train.parquet" dataset_path.parent.mkdir(parents=True) frames = [] row_count = 1200 base_feature_values = np.linspace(0.0, 0.999, row_count) for split_id in TRAINING_SPLITS: frame = pd.DataFrame({feature: 0.0 for feature in FEATURE_ORDER}, index=np.arange(row_count)) frame["split_id"] = split_id frame["ret_1m_bps"] = base_feature_values frame["ret_5m_bps"] = base_feature_values good_mask = (frame["ret_1m_bps"] > 0.9) & (frame["ret_5m_bps"] > 0.9) frame["long_entry_target"] = good_mask.astype(int) frame["short_entry_target"] = 0 frame["long_actual_plan_net_edge_bps"] = np.where(good_mask, 8.0, -6.0) frame["short_actual_plan_net_edge_bps"] = -6.0 frame["long_mae_bps"] = np.where(good_mask, 2.0, 15.0) frame["short_mae_bps"] = 15.0 frames.append(frame) pd.concat(frames, ignore_index=True).to_parquet(dataset_path, index=False) screen_entry_condition_pairs( Namespace( data_root=data_root, run_id="unit-condition-pair", min_seed_rows=50, min_pair_rows=50, max_seed_conditions_per_side=8, max_buckets_per_feature=2, ) ) result = read_json(run_root / "diagnostics" / "entry_condition_pair_screen_result.json") candidates = pd.read_csv(run_root / "diagnostics" / "entry_condition_pair_candidates.csv") self.assertGreater(result["stable_candidate_count"], 0) self.assertTrue(candidates["usable_candidate"].any()) best = candidates.iloc[0] self.assertEqual("LONG", best["side"]) self.assertGreater(float(best["min_eval_edge_bps"]), 0.0) def test_dynamic_exit_search_writes_plan_diagnostics(self) -> None: with tempfile.TemporaryDirectory() as tmp: data_root = Path(tmp) run_root = data_root / "trader-v4" / "runs" / "unit-dynamic-exit" feature_path = run_root / "feature" / "feature_frame.parquet" replay_path = run_root / "replay" / "replay_1m.parquet" config_path = data_root / "label_config.json" feature_path.parent.mkdir(parents=True) replay_path.parent.mkdir(parents=True) times = pd.date_range("2026-01-01", periods=7, freq="min", tz="UTC") pd.DataFrame( { "sample_id": [f"s{i}" for i in range(4)], "symbol": ["BTC-USDT-PERP"] * 4, "event_time": times[:4], "open_time_ms": [0, 60_000, 120_000, 180_000], "split_id": ["tune_inner", "validation_locked", "latest_stress", "fit_inner"], "walk_forward_fold": [0, 0, 0, 0], "data_quality_flag": ["OK", "OK", "OK", "OK"], } ).to_parquet(feature_path, index=False) pd.DataFrame( { "event_time": times, "open_time_ms": [0, 60_000, 120_000, 180_000, 240_000, 300_000, 360_000], "symbol": ["BTC-USDT-PERP"] * 7, "open": [100.0] * 7, "high": [100.0, 100.12, 100.22, 100.24, 100.24, 100.24, 100.24], "low": [100.0, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00], "close": [100.0, 100.10, 100.18, 100.20, 100.20, 100.20, 100.20], "spread_bps": [1.0] * 7, } ).to_parquet(replay_path, index=False) write_json(config_path, {"entry": {"min_expected_net_edge_bps": 3.0}}) search_dynamic_exit_plans( Namespace( data_root=data_root, run_id="unit-dynamic-exit", feature_path=feature_path, replay_path=replay_path, label_config_path=config_path, cost_config_path=None, horizons=(3,), targets=(10.0,), stops=(5.0,), trailing_stops=(4.0,), second_target_multipliers=(2.0,), take1_ratios=(0.5,), take2_ratios=(0.25,), output_dir_name="dynamic-exit-search", ) ) result = read_json(run_root / "dynamic-exit-search" / "dynamic_exit_search_result.json") self.assertEqual("DYNAMIC_TRAILING_V1", result["best_plan"]["plan_method"]) self.assertEqual(1, result["candidate_count"]) self.assertTrue((run_root / "dynamic-exit-search" / "dynamic_exit_search_report.md").is_file()) def test_ofi_entry_dataset_uses_actual_plan_edge(self) -> None: with tempfile.TemporaryDirectory() as tmp: baseline_root = Path(tmp) dataset_path = baseline_root / "dataset" / "entry_train.parquet" dataset_path.parent.mkdir(parents=True) pd.DataFrame( { "sample_id": ["s1"], "long_entry_target": [1], "short_entry_target": [0], "long_actual_plan_net_edge_bps": [4.0], "short_actual_plan_net_edge_bps": [-7.0], } ).to_parquet(dataset_path, index=False) feature = pd.DataFrame( { "sample_id": ["s1"], "symbol": ["BTC-USDT-PERP"], "event_time": pd.to_datetime(["2026-01-01T00:00:00Z"]), "open_time_ms": [0], "split_id": ["fit_inner"], "walk_forward_fold": [0], "data_quality_flag": ["OK"], } ) dataset = _load_entry_dataset(baseline_root, feature) self.assertIn("long_actual_plan_net_edge_bps", dataset.columns) self.assertNotIn("long_expected_net_edge_bps", dataset.columns) self.assertEqual(4.0, float(dataset.loc[0, "long_actual_plan_net_edge_bps"])) def test_split_builder_uses_locked_validation_contract(self) -> None: with tempfile.TemporaryDirectory() as tmp: data_root = Path(tmp) replay_path = data_root / "replay_1m.parquet" frame = pd.DataFrame( { "event_time": pd.date_range("2025-06-20", "2026-06-19", freq="D", tz="UTC"), "symbol": "BTC-USDT-PERP", } ) frame.to_parquet(replay_path, index=False) build_splits( Namespace( data_root=data_root, run_id="unit-split", replay_path=replay_path, fit_inner_start="2025-06-20", fit_inner_end="2026-01-15", tune_inner_start="2026-01-16", tune_inner_end="2026-02-28", validation_locked_start="2026-03-01", validation_locked_end="2026-04-30", latest_stress_start="2026-05-01", latest_stress_end="2026-06-19", gap_minutes=0, fold_count=2, ) ) manifest = read_json(data_root / "trader-v4" / "runs" / "unit-split" / "split" / "split_manifest.json") self.assertEqual(set(TRAINING_SPLITS), {item["split_id"] for item in manifest["splits"]}) self.assertEqual([VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT], manifest["sealed_splits"]) self.assertEqual("FINAL_GATE_ONLY", manifest["latest_stress_policy"]) def test_path_stats_keeps_same_bar_target_stop_as_stop_first(self) -> None: frame = pd.DataFrame( { "event_time": pd.date_range("2026-01-01", periods=6, freq="min", tz="UTC"), "open_time_ms": np.arange(6, dtype=np.int64) * 60_000, "symbol": "BTC-USDT-PERP", "close": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0], "high": [100.0, 100.05, 100.20, 100.0, 100.0, 100.0], "low": [100.0, 99.95, 99.70, 100.0, 100.0, 100.0], "spread_bps": [1.0, 1.1, 1.2, 1.3, 1.4, 1.5], } ) stats = _path_stats_for_group(frame, "LONG", horizon=3, target_bps=10.0, stop_bps=8.0) first = stats.loc[stats["open_time_ms"].eq(0)].iloc[0] self.assertEqual(0, first["target_hit"]) self.assertEqual(1, first["stop_hit"]) self.assertEqual(1, first["ambiguous_hit"]) self.assertEqual(120_000, first["time_to_stop_ms"]) self.assertAlmostEqual(-8.0, first["gross_edge_bps"]) def test_entry_label_uses_price_plan_outcome_not_max_future_edge(self) -> None: with tempfile.TemporaryDirectory() as tmp: data_root = Path(tmp) run_root = data_root / "trader-v4" / "runs" / "unit-entry" feature_path = run_root / "feature" / "feature_frame.parquet" replay_path = run_root / "replay" / "replay_1m.parquet" plan_path = run_root / "label" / "price_plan_context.json" config_path = data_root / "label_config.json" feature_path.parent.mkdir(parents=True) replay_path.parent.mkdir(parents=True) times = pd.date_range("2026-01-01", periods=5, freq="min", tz="UTC") pd.DataFrame( { "sample_id": ["s0", "s1"], "symbol": "BTC-USDT-PERP", "event_time": times[:2], "open_time_ms": [0, 60_000], "split_id": "fit_inner", "walk_forward_fold": 0, "data_quality_flag": "OK", "spread_bps": 1.0, "spread_rank_24h_pct": 0.1, "realized_vol_15m_bps": 2.0, } ).to_parquet(feature_path, index=False) pd.DataFrame( { "event_time": times, "open_time_ms": np.arange(5, dtype=np.int64) * 60_000, "symbol": "BTC-USDT-PERP", "open": [100.0, 100.0, 100.0, 100.0, 100.0], "high": [100.0, 100.05, 100.19, 100.20, 100.0], "low": [100.0, 99.99, 99.98, 99.97, 100.0], "close": [100.0, 100.0, 100.0, 100.0, 100.0], "spread_bps": 1.0, } ).to_parquet(replay_path, index=False) write_json( config_path, { "entry": { "max_hold_minutes": 3, "target_bps": 50.0, "stop_bps": 50.0, "min_expected_net_edge_bps": 3.0, } }, ) write_json( plan_path, { "pricePlanId": "unit-plan", "pricePlanConfigHash": "unit-hash", "targetDistanceBps": 50.0, "stopDistanceBps": 50.0, "maxHoldMinutes": 3, "costBps": 6.5, "entryLabelMethod": ENTRY_LABEL_METHOD, }, ) build_entry_labels( Namespace( data_root=data_root, run_id="unit-entry", feature_path=feature_path, replay_path=replay_path, label_config_path=config_path, cost_config_path=None, price_plan_context_path=plan_path, ) ) labels = pd.read_parquet(run_root / "label" / "entry_labels.parquet") row = labels[labels["sample_id"].eq("s0") & labels["side"].eq("LONG")].iloc[0] self.assertEqual(0, row["target_hit"]) self.assertEqual(0, row["entry_target"]) self.assertEqual(ENTRY_LABEL_METHOD, row["label_method"]) self.assertAlmostEqual(-6.5, row["expected_net_edge_bps"], places=6) self.assertAlmostEqual(row["gross_edge_bps"] - row["cost_bps"], row["expected_net_edge_bps"], places=6) self.assertAlmostEqual(row["mfe_bps"] - row["cost_bps"], row["max_achievable_net_edge_bps"], places=6) def test_entry_opportunity_label_keeps_plan_outcome_for_pm(self) -> None: with tempfile.TemporaryDirectory() as tmp: data_root = Path(tmp) run_root = data_root / "trader-v4" / "runs" / "unit-entry-opportunity" feature_path = run_root / "feature" / "feature_frame.parquet" replay_path = run_root / "replay" / "replay_1m.parquet" plan_path = run_root / "label" / "price_plan_context.json" config_path = data_root / "label_config.json" feature_path.parent.mkdir(parents=True) replay_path.parent.mkdir(parents=True) times = pd.date_range("2026-01-01", periods=5, freq="min", tz="UTC") pd.DataFrame( { "sample_id": ["s0"], "symbol": "BTC-USDT-PERP", "event_time": [times[0]], "open_time_ms": [0], "split_id": "fit_inner", "walk_forward_fold": 0, "data_quality_flag": "OK", "spread_bps": 1.0, "spread_rank_24h_pct": 0.1, "realized_vol_15m_bps": 2.0, } ).to_parquet(feature_path, index=False) pd.DataFrame( { "event_time": times, "open_time_ms": np.arange(5, dtype=np.int64) * 60_000, "symbol": "BTC-USDT-PERP", "open": [100.0] * 5, "high": [100.0, 100.05, 100.19, 100.20, 100.0], "low": [100.0, 99.99, 99.98, 99.97, 100.0], "close": [100.0] * 5, "spread_bps": 1.0, } ).to_parquet(replay_path, index=False) write_json( config_path, { "entry": { "max_hold_minutes": 3, "target_bps": 50.0, "stop_bps": 50.0, "min_expected_net_edge_bps": 3.0, "target_method": "OPPORTUNITY_MFE_V1", } }, ) write_json( plan_path, { "pricePlanId": "unit-plan", "pricePlanConfigHash": "unit-hash", "targetDistanceBps": 50.0, "stopDistanceBps": 50.0, "maxHoldMinutes": 3, "costBps": 6.5, "entryLabelMethod": ENTRY_LABEL_METHOD, "entryTargetMethod": "OPPORTUNITY_MFE_V1", }, ) build_entry_labels( Namespace( data_root=data_root, run_id="unit-entry-opportunity", feature_path=feature_path, replay_path=replay_path, label_config_path=config_path, cost_config_path=None, price_plan_context_path=plan_path, ) ) labels = pd.read_parquet(run_root / "label" / "entry_labels.parquet") row = labels[labels["sample_id"].eq("s0") & labels["side"].eq("LONG")].iloc[0] self.assertEqual(0, row["target_hit"]) self.assertEqual(1, row["entry_target"]) self.assertEqual("OPPORTUNITY_MFE_V1", row["label_method"]) self.assertAlmostEqual(row["mfe_bps"] - row["cost_bps"], row["expected_net_edge_bps"], places=6) self.assertAlmostEqual(-6.5, row["gross_edge_bps"] - row["cost_bps"], places=6) def test_entry_quality_label_rejects_untradable_opportunity(self) -> None: with tempfile.TemporaryDirectory() as tmp: data_root = Path(tmp) run_root = data_root / "trader-v4" / "runs" / "unit-entry-quality" feature_path = run_root / "feature" / "feature_frame.parquet" replay_path = run_root / "replay" / "replay_1m.parquet" plan_path = run_root / "label" / "price_plan_context.json" config_path = data_root / "label_config.json" feature_path.parent.mkdir(parents=True) replay_path.parent.mkdir(parents=True) times = pd.date_range("2026-01-01", periods=5, freq="min", tz="UTC") pd.DataFrame( { "sample_id": ["s0"], "symbol": "BTC-USDT-PERP", "event_time": [times[0]], "open_time_ms": [0], "split_id": "fit_inner", "walk_forward_fold": 0, "data_quality_flag": "OK", "spread_bps": 1.0, "spread_rank_24h_pct": 0.1, "realized_vol_15m_bps": 2.0, } ).to_parquet(feature_path, index=False) pd.DataFrame( { "event_time": times, "open_time_ms": np.arange(5, dtype=np.int64) * 60_000, "symbol": "BTC-USDT-PERP", "open": [100.0] * 5, "high": [100.0, 100.05, 100.19, 100.20, 100.0], "low": [100.0, 99.99, 99.98, 99.97, 100.0], "close": [100.0] * 5, "spread_bps": 1.0, } ).to_parquet(replay_path, index=False) write_json( config_path, { "entry": { "max_hold_minutes": 3, "target_bps": 50.0, "stop_bps": 50.0, "min_expected_net_edge_bps": 3.0, "min_plan_net_edge_bps": 0.0, "max_entry_mae_bps": 12.0, "target_method": "OPPORTUNITY_QUALITY_V1", } }, ) write_json( plan_path, { "pricePlanId": "unit-plan", "pricePlanConfigHash": "unit-hash", "targetDistanceBps": 50.0, "stopDistanceBps": 50.0, "maxHoldMinutes": 3, "costBps": 6.5, "entryLabelMethod": ENTRY_LABEL_METHOD, "entryTargetMethod": "OPPORTUNITY_QUALITY_V1", }, ) build_entry_labels( Namespace( data_root=data_root, run_id="unit-entry-quality", feature_path=feature_path, replay_path=replay_path, label_config_path=config_path, cost_config_path=None, price_plan_context_path=plan_path, ) ) labels = pd.read_parquet(run_root / "label" / "entry_labels.parquet") row = labels[labels["sample_id"].eq("s0") & labels["side"].eq("LONG")].iloc[0] self.assertEqual("OPPORTUNITY_QUALITY_V1", row["label_method"]) self.assertGreater(row["expected_net_edge_bps"], 3.0) self.assertLess(row["actual_plan_net_edge_bps"], 0.0) self.assertEqual(0, row["entry_target"]) def test_l1_snapshot_diff_ofi_uses_quote_notional_and_signed_ask_side(self) -> None: bid_part, ask_part = l1_snapshot_diff_ofi_quote( pd.Series([101.0, 101.0, 100.5]), pd.Series([2.0, 3.0, 4.0]), pd.Series([102.0, 101.5, 102.5]), pd.Series([5.0, 6.0, 7.0]), pd.Series([100.0, 101.0, 101.0]), pd.Series([1.5, 2.0, 3.0]), pd.Series([102.0, 102.0, 101.5]), pd.Series([4.0, 5.0, 6.0]), ) self.assertAlmostEqual(202.0, bid_part.iloc[0]) self.assertAlmostEqual(-102.0, ask_part.iloc[0]) self.assertAlmostEqual(101.0, bid_part.iloc[1]) self.assertAlmostEqual(-609.0, ask_part.iloc[1]) self.assertAlmostEqual(-303.0, bid_part.iloc[2]) self.assertAlmostEqual(609.0, ask_part.iloc[2]) def test_exported_onnx_accepts_java_feature_shape(self) -> None: import onnxruntime as ort with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "direction.onnx" export_heads( path, [ LinearHead( "direction", "softmax", np.zeros((len(FEATURE_ORDER), 3), dtype=np.float32), np.array([0.1, 0.2, 0.3], dtype=np.float32), ) ], feature_count=len(FEATURE_ORDER), ) session = ort.InferenceSession(str(path)) output = session.run(None, {"features": np.zeros((1, len(FEATURE_ORDER)), dtype=np.float32)})[0] self.assertEqual((1, 3), output.shape) self.assertAlmostEqual(1.0, float(output.sum()), places=6) def test_promotion_requires_passed_validation_and_marks_all_manifests_active(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "artifact_bundle" manifest_dir = root / "manifests" manifest_dir.mkdir(parents=True) write_json(root.parent / "artifact_validation_result.json", {"status": "PASS", "release_gate_status": "PASS", "release_gate_reasons": []}) write_json(manifest_dir / "model_bundle_manifest.json", {"status": "CANDIDATE"}) write_json(manifest_dir / "model_manifest.json", [{"model_name": "DIRECTION", "status": "CANDIDATE"}]) write_json(manifest_dir / "calibration_manifest.json", [{"model_name": "DIRECTION", "status": "CANDIDATE"}]) write_json(manifest_dir / "position_manager_manifest.json", {"status": "CANDIDATE"}) write_json(manifest_dir / "training_export_manifest.json", {"status": "CANDIDATE"}) promote_artifact_bundle(Namespace(artifact_root=root, reason="unit test")) self.assertEqual("ACTIVE", read_json(manifest_dir / "model_bundle_manifest.json")["status"]) self.assertEqual("ACTIVE", read_json(manifest_dir / "model_manifest.json")[0]["status"]) self.assertEqual("ACTIVE", read_json(manifest_dir / "calibration_manifest.json")[0]["status"]) self.assertEqual("ACTIVE", read_json(manifest_dir / "position_manager_manifest.json")["status"]) self.assertEqual("ACTIVE", read_json(manifest_dir / "training_export_manifest.json")["status"]) def test_promotion_refuses_failed_validation(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "artifact_bundle" (root / "manifests").mkdir(parents=True) write_json(root.parent / "artifact_validation_result.json", {"status": "FAIL"}) with self.assertRaises(SystemExit): promote_artifact_bundle(Namespace(artifact_root=root, reason="unit test")) result = read_json(root.parent / "artifact_promotion_result.json") self.assertEqual("REFUSED", result["status"]) self.assertEqual("validation result is not PASS", result["message"]) def test_promotion_refuses_failed_release_gate_and_overwrites_stale_result(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "artifact_bundle" (root / "manifests").mkdir(parents=True) write_json(root.parent / "artifact_promotion_result.json", {"status": "ACTIVE"}) write_json( root.parent / "artifact_validation_result.json", { "status": "PASS", "release_gate_status": "REJECTED", "release_gate_reasons": ["backtest_status=REJECTED"], }, ) with self.assertRaises(SystemExit): promote_artifact_bundle(Namespace(artifact_root=root, reason="unit test")) result = read_json(root.parent / "artifact_promotion_result.json") self.assertEqual("REFUSED", result["status"]) self.assertEqual("release gate is not PASS", result["message"]) self.assertEqual(["backtest_status=REJECTED"], result["release_gate_reasons"]) if __name__ == "__main__": unittest.main()