Files
quant-trader-service/training/tests/test_training_contract.py
T

879 lines
41 KiB
Python
Raw Normal View History

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
2026-06-28 08:33:49 +08:00
from trader_training.conditional_entry_probe import probe_conditional_entry_training
from trader_training.direction_opportunity_dataset import _opportunity_labels
2026-06-28 06:51:39 +08:00
from trader_training.dynamic_exit_search import search_dynamic_exit_plans
2026-06-28 08:21:01 +08:00
from trader_training.entry_condition_pair_screen import screen_entry_condition_pairs
from trader_training.entry_feature_screen import _bucket_edges, _screen_edge_column
2026-06-28 08:28:55 +08:00
from trader_training.entry_mae_label_diagnostic import diagnose_entry_mae_labels
2026-06-28 09:27:59 +08:00
from trader_training.good_trade_structure import _side_frame, _top_fraction_metrics
from trader_training.io_utils import read_json, write_json
2026-06-27 19:57:29 +08:00
from trader_training.labels import ENTRY_LABEL_METHOD, _path_stats_for_group, build_entry_labels
2026-06-28 09:59:36 +08:00
from trader_training.nonlinear_pm_probe import _entry_side_fit_frame, _exit_metrics, _expanded_threshold_candidates
2026-06-28 07:09:34 +08:00
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
2026-06-28 08:40:30 +08:00
from trader_training.training import TARGETS, _head_train_mask
from trader_training.diagnostics import _label_summary
class TrainingContractTest(unittest.TestCase):
def test_feature_order_is_v4_contract_size(self) -> None:
2026-06-27 19:57:29 +08:00
self.assertEqual(54, len(FEATURE_ORDER))
self.assertEqual(len(FEATURE_ORDER), len(set(FEATURE_ORDER)))
self.assertEqual("ret_1m_bps", FEATURE_ORDER[0])
2026-06-27 19:57:29 +08:00
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_nonlinear_pm_probe_expands_low_probability_thresholds(self) -> None:
candidates = _expanded_threshold_candidates()
self.assertIn(
{
"long_open_prob": 0.2,
"short_open_prob": 0.2,
"min_entry_prob": 0.05,
"max_market_risk_prob": 0.45,
"min_expected_edge_bps": -5.0,
"min_direction_margin": 0.0,
},
candidates,
)
2026-06-28 09:59:36 +08:00
self.assertIn(
{
"long_open_prob": 1.01,
"short_open_prob": 0.2,
"min_entry_prob": 0.05,
"max_market_risk_prob": 0.45,
"min_expected_edge_bps": -5.0,
"min_direction_margin": 0.0,
},
candidates,
)
def test_nonlinear_entry_tree_probe_can_use_side_opportunity_rows(self) -> None:
direction = pd.DataFrame(
{
"sample_id": ["s1", "s2", "s3", "s4"],
"long_target": [1, 0, 0, 0],
"short_target": [0, 1, 0, 0],
}
)
entry = pd.DataFrame(
{
"sample_id": ["s1", "s2", "s3", "s4"],
"split_id": ["fit_inner", "fit_inner", "fit_inner", "fit_inner"],
"long_max_achievable_net_edge_bps": [45.0, 10.0, 65.0, 39.0],
"short_max_achievable_net_edge_bps": [8.0, 41.0, 15.0, 70.0],
}
)
long_frame = _entry_side_fit_frame(direction, entry, "LONG", "side_opportunity", 40.0)
short_frame = _entry_side_fit_frame(direction, entry, "SHORT", "side_opportunity", 40.0)
self.assertEqual(["s1", "s3"], long_frame["sample_id"].tolist())
self.assertEqual(["s2", "s4"], short_frame["sample_id"].tolist())
def test_nonlinear_pm_probe_exit_metrics_describe_trade_outcomes(self) -> None:
trades = pd.DataFrame(
{
"target_hit": [1, 0, 0],
"stop_hit": [0, 1, 0],
"time_to_exit_ms": [300_000, 600_000, 2_700_000],
}
)
metrics = _exit_metrics(trades)
self.assertAlmostEqual(1 / 3, metrics["target_hit_rate"])
self.assertAlmostEqual(1 / 3, metrics["stop_hit_rate"])
self.assertAlmostEqual(1 / 3, metrics["timeout_exit_rate"])
self.assertAlmostEqual(20.0, metrics["avg_time_to_exit_min"])
self.assertAlmostEqual(10.0, metrics["p50_time_to_exit_min"])
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"))
2026-06-28 07:29:17 +08:00
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")
2026-06-28 07:26:59 +08:00
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"])
2026-06-28 08:40:30 +08:00
def test_conditional_entry_training_uses_direction_label_rows(self) -> None:
train = pd.DataFrame({"long_target": [1, 0, 1, 0], "short_target": [0, 1, 0, 1]})
long_mask, long_filter = _head_train_mask("ENTRY", "long_entry_prob", train, Namespace(conditional_entry_direction_labels=True))
short_mask, short_filter = _head_train_mask("ENTRY", "short_expected_net_edge_bps", train, Namespace(conditional_entry_direction_labels=True))
default_mask, default_filter = _head_train_mask("ENTRY", "long_entry_prob", train, Namespace(conditional_entry_direction_labels=False))
self.assertEqual("DIRECTION_LABEL_LONG_FIT_ROWS", long_filter)
self.assertEqual([True, False, True, False], long_mask.tolist())
self.assertEqual("DIRECTION_LABEL_SHORT_FIT_ROWS", short_filter)
self.assertEqual([False, True, False, True], short_mask.tolist())
self.assertEqual("ALL_FIT_ROWS", default_filter)
self.assertEqual([True, True, True, True], default_mask.tolist())
2026-06-28 09:27:59 +08:00
def test_conditional_entry_training_can_use_side_opportunity_rows(self) -> None:
train = pd.DataFrame(
{
"long_max_achievable_net_edge_bps": [45.0, 10.0, 60.0, 39.0],
"short_max_achievable_net_edge_bps": [8.0, 42.0, 15.0, 80.0],
}
)
args = Namespace(
conditional_entry_source="side_opportunity",
conditional_entry_direction_labels=False,
conditional_entry_opportunity_bps=40.0,
)
long_mask, long_filter = _head_train_mask("ENTRY", "long_entry_prob", train, args)
short_mask, short_filter = _head_train_mask("ENTRY", "short_expected_net_edge_bps", train, args)
self.assertEqual("SIDE_OPPORTUNITY_LONG_GE_40_BPS_FIT_ROWS", long_filter)
self.assertEqual([True, False, True, False], long_mask.tolist())
self.assertEqual("SIDE_OPPORTUNITY_SHORT_GE_40_BPS_FIT_ROWS", short_filter)
self.assertEqual([False, True, False, True], short_mask.tolist())
def test_direction_opportunity_labels_choose_clear_path_opportunity(self) -> None:
labels = _opportunity_labels(
np.array([45.0, 10.0, 45.0, 42.0, np.nan]),
np.array([20.0, 50.0, 43.0, 48.0, 50.0]),
opportunity_bps=40.0,
min_advantage_bps=5.0,
)
self.assertEqual([1, 0, 0, 0, 0], labels["long_target"].tolist())
self.assertEqual([0, 1, 0, 1, 1], labels["short_target"].tolist())
self.assertEqual([0, 0, 1, 0, 0], labels["neutral_target"].tolist())
def test_diagnostics_reads_actual_training_dataset_labels(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
dataset_dir = root / "dataset"
dataset_dir.mkdir(parents=True)
pd.DataFrame(
{
"sample_id": ["s1", "s2"],
"split_id": ["fit_inner", "fit_inner"],
"long_target": [1, 0],
"short_target": [0, 0],
"neutral_target": [0, 1],
"future_return_bps": [5.0, -1.0],
}
).to_parquet(dataset_dir / "direction_train.parquet", index=False)
pd.DataFrame(
{
"sample_id": ["s1", "s2"],
"split_id": ["fit_inner", "fit_inner"],
"long_entry_target": [1, 0],
"short_entry_target": [0, 1],
"long_actual_plan_net_edge_bps": [8.0, -6.0],
"short_actual_plan_net_edge_bps": [-5.0, 7.0],
}
).to_parquet(dataset_dir / "entry_train.parquet", index=False)
summary = _label_summary(root)
self.assertEqual("dataset/direction_train.parquet", summary["fit_inner"]["direction"]["source"])
self.assertEqual({"LONG": 0.5, "SHORT": 0.0, "NEUTRAL": 0.5}, summary["fit_inner"]["direction"]["label_ratio"])
self.assertEqual("dataset/entry_train.parquet", summary["fit_inner"]["entry"]["source"])
self.assertEqual(0.5, summary["fit_inner"]["entry"]["target_rate_by_side"]["LONG"])
2026-06-28 09:27:59 +08:00
def test_good_trade_structure_builds_side_frame_and_top_metrics(self) -> None:
dataset = pd.DataFrame(
{
"sample_id": ["s1", "s2", "s3"],
"split_id": ["fit_inner", "fit_inner", "fit_inner"],
"long_actual_plan_net_edge_bps": [4.0, -5.0, 1.0],
"short_actual_plan_net_edge_bps": [-5.0, 6.0, -1.0],
**{feature: [0.1, 0.2, 0.3] for feature in FEATURE_ORDER},
}
)
frame = _side_frame(dataset, "LONG", min_good_edge_bps=3.0, bad_edge_bps=-3.0)
metrics = _top_fraction_metrics(frame, np.array([0.9, 0.1, 0.2]), 1 / 3)
self.assertEqual([1, 0, 0], frame["good_trade"].tolist())
self.assertEqual([0, 1, 0], frame["bad_trade"].tolist())
self.assertEqual(1, metrics["rows"])
self.assertEqual(1.0, metrics["good_rate"])
self.assertEqual(4.0, metrics["avg_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])
2026-06-28 08:21:01 +08:00
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)
2026-06-28 08:28:55 +08:00
def test_entry_mae_label_diagnostic_finds_low_drawdown_target(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
data_root = Path(tmp)
run_root = data_root / "trader-v4" / "runs" / "unit-mae-diagnostic"
dataset_path = run_root / "dataset" / "entry_train.parquet"
dataset_path.parent.mkdir(parents=True)
frames = []
row_count = 800
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
good_mask = frame["ret_1m_bps"] > 0.85
frame["long_entry_target"] = good_mask.astype(int)
frame["short_entry_target"] = 0
frame["long_actual_plan_net_edge_bps"] = np.where(good_mask, 9.0, -6.0)
frame["short_actual_plan_net_edge_bps"] = -6.0
frame["long_max_achievable_net_edge_bps"] = np.where(good_mask, 18.0, 2.0)
frame["short_max_achievable_net_edge_bps"] = 2.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)
diagnose_entry_mae_labels(
Namespace(
data_root=data_root,
run_id="unit-mae-diagnostic",
max_mae_bps=(4.0,),
min_opportunity_bps=(12.0,),
model_families=("linear",),
top_fraction=0.10,
max_train_rows=0,
)
)
result = read_json(run_root / "diagnostics" / "entry_mae_label_diagnostic_result.json")
candidates = pd.read_csv(run_root / "diagnostics" / "entry_mae_label_diagnostic_candidates.csv")
self.assertGreater(result["positive_top_edge_candidate_count"], 0)
best = candidates.iloc[0]
self.assertEqual("LONG", best["side"])
self.assertTrue(bool(best["stable_top_edge_positive"]))
2026-06-28 08:33:49 +08:00
def test_conditional_entry_probe_finds_positive_oracle_direction_subset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
data_root = Path(tmp)
run_root = data_root / "trader-v4" / "runs" / "unit-conditional-entry"
dataset_path = run_root / "dataset" / "entry_train.parquet"
dataset_path.parent.mkdir(parents=True)
frames = []
row_count = 900
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
good_mask = frame["ret_1m_bps"] > 0.85
opportunity_mask = frame["ret_1m_bps"] > 0.50
frame["long_actual_plan_net_edge_bps"] = np.where(good_mask, 10.0, -6.0)
frame["short_actual_plan_net_edge_bps"] = -6.0
frame["long_max_achievable_net_edge_bps"] = np.where(opportunity_mask, 40.0, 2.0)
frame["short_max_achievable_net_edge_bps"] = 2.0
frames.append(frame)
pd.concat(frames, ignore_index=True).to_parquet(dataset_path, index=False)
probe_conditional_entry_training(
Namespace(
data_root=data_root,
run_id="unit-conditional-entry",
condition_opportunity_bps=(20.0,),
target_edge_bps=(0.0,),
model_families=("linear",),
top_fractions=(0.10,),
max_train_rows=0,
min_train_rows=50,
min_eval_rows=50,
)
)
result = read_json(run_root / "diagnostics" / "conditional_entry_probe_result.json")
candidates = pd.read_csv(run_root / "diagnostics" / "conditional_entry_probe_candidates.csv")
self.assertGreater(result["stable_positive_count"], 0)
self.assertTrue(candidates.iloc[0]["stable_positive"])
self.assertGreater(float(candidates.iloc[0]["min_top_edge_bps"]), 0.0)
2026-06-28 06:51:39 +08:00
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,),
2026-06-28 06:52:52 +08:00
output_dir_name="dynamic-exit-search",
2026-06-28 06:51:39 +08:00
)
)
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())
2026-06-28 07:09:34 +08:00
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"])
2026-06-27 19:57:29 +08:00
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:
2026-06-27 19:57:29 +08:00
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"])
2026-06-27 19:57:29 +08:00
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)
2026-06-27 19:57:29 +08:00
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",
2026-06-27 19:57:29 +08:00
np.zeros((len(FEATURE_ORDER), 3), dtype=np.float32),
np.array([0.1, 0.2, 0.3], dtype=np.float32),
)
],
2026-06-27 19:57:29 +08:00
feature_count=len(FEATURE_ORDER),
)
session = ort.InferenceSession(str(path))
2026-06-27 19:57:29 +08:00
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()