fix: purge ML validation label leakage
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ml import optimizer
|
||||
|
||||
|
||||
def _row(date, returns=10.0, score_200w=10, score_drawdown=0):
|
||||
row = {
|
||||
"date": date,
|
||||
"price": 100.0,
|
||||
"fwd_365d": returns,
|
||||
"score_puell_multiple": 0,
|
||||
"score_mvrv_zscore": 0,
|
||||
"score_reserve_risk": 0,
|
||||
"score_rhodl_ratio": 0,
|
||||
"score_nupl": 0,
|
||||
"score_fear_greed": 0,
|
||||
"score_drawdown": score_drawdown,
|
||||
"score_pct_above_200w_sma": score_200w,
|
||||
"score_pct_above_lth_rp": 0,
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
def test_purged_time_series_splits_remove_overlapping_forward_label_windows():
|
||||
start = datetime(2020, 1, 1)
|
||||
rows = [_row((start + timedelta(days=i)).strftime("%Y-%m-%d")) for i in range(900)]
|
||||
|
||||
splits = list(
|
||||
optimizer.purged_time_series_splits(
|
||||
rows,
|
||||
n_splits=3,
|
||||
label_horizon_days=365,
|
||||
embargo_days=0,
|
||||
)
|
||||
)
|
||||
|
||||
assert splits, "expected at least one viable split"
|
||||
for train_idx, val_idx in splits:
|
||||
val_start = datetime.strptime(rows[val_idx[0]]["date"], "%Y-%m-%d")
|
||||
latest_allowed_train_date = val_start - timedelta(days=365)
|
||||
assert len(train_idx) > 0, "purging should keep non-overlapping expanding-window training rows"
|
||||
for idx in train_idx:
|
||||
train_date = datetime.strptime(rows[idx]["date"], "%Y-%m-%d")
|
||||
assert train_date <= latest_allowed_train_date
|
||||
|
||||
|
||||
def test_run_out_of_sample_comparison_scores_only_validation_rows_with_fold_weights():
|
||||
rows = [
|
||||
_row("2020-01-01", returns=-10, score_200w=0, score_drawdown=10),
|
||||
_row("2020-01-02", returns=-5, score_200w=0, score_drawdown=10),
|
||||
_row("2020-01-03", returns=100, score_200w=10, score_drawdown=10),
|
||||
_row("2020-01-04", returns=120, score_200w=10, score_drawdown=10),
|
||||
]
|
||||
fold_results = [
|
||||
{
|
||||
"fold": 1,
|
||||
"val_idx": [2, 3],
|
||||
"weights": {"pct_above_200w_sma": 1.0, "drawdown": 0.0},
|
||||
}
|
||||
]
|
||||
|
||||
comparison = optimizer.run_out_of_sample_comparison(rows, fold_results)
|
||||
|
||||
assert comparison["validation_days"] == 2
|
||||
assert comparison["folds"] == 1
|
||||
assert sum(bucket["days"] for bucket in comparison["ml_weighted"]) == 2
|
||||
assert sum(bucket["days"] for bucket in comparison["equal_weight"]) == 2
|
||||
|
||||
extreme_ml = next(bucket for bucket in comparison["ml_weighted"] if bucket["label"] == "Extreme Accumulation")
|
||||
assert extreme_ml["days"] == 2
|
||||
assert extreme_ml["avg_365d"] == 110.0
|
||||
|
||||
caution_equal = next(bucket for bucket in comparison["equal_weight"] if bucket["label"] == "Caution")
|
||||
assert caution_equal["days"] == 2
|
||||
@@ -0,0 +1,63 @@
|
||||
import math
|
||||
|
||||
from scoring import engine
|
||||
|
||||
|
||||
def _complete_metrics():
|
||||
return {
|
||||
"fear_greed": {"value": 10, "classification": "Extreme Fear"},
|
||||
"puell_multiple": {"value": 0.3},
|
||||
"mvrv_zscore": {"value": -0.1},
|
||||
"drawdown": {"value": 60.0, "ath": 250.0},
|
||||
"price": {"price": 100.0},
|
||||
"200w_sma": {"value": 120.0},
|
||||
"reserve_risk": {"value": 0.001},
|
||||
"rhodl_ratio": {"value": 50.0},
|
||||
"nupl": {"value": -0.1},
|
||||
"lth_realized_price": {"value": 120.0},
|
||||
"hash_ribbons": {"buy_signal": False},
|
||||
}
|
||||
|
||||
|
||||
def test_score_all_ml_normalizes_displayed_weights_and_contributions(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"load_ml_weights",
|
||||
lambda: {
|
||||
"fear_greed": 0.40,
|
||||
"puell_multiple": 0.20,
|
||||
"mvrv_zscore": 0.15,
|
||||
"drawdown": 0.10,
|
||||
"pct_above_200w_sma": 0.05,
|
||||
"reserve_risk": 0.04,
|
||||
"rhodl_ratio": 0.03,
|
||||
"nupl": 0.02,
|
||||
"pct_above_lth_rp": 0.01,
|
||||
},
|
||||
)
|
||||
|
||||
scored = engine.score_all_ml(_complete_metrics())
|
||||
|
||||
assert scored["ml_mode"] is True
|
||||
valid_metrics = [m for m in scored["metrics"] if m["score"] is not None]
|
||||
assert scored["ml_weight_total"] == 1.01 # trained weights + small hash-ribbons fallback
|
||||
assert math.isclose(sum(m["ml_weight"] for m in valid_metrics), 1.0, abs_tol=0.001)
|
||||
assert math.isclose(
|
||||
sum(m["ml_contribution"] for m in valid_metrics),
|
||||
scored["composite_score"],
|
||||
abs_tol=0.05,
|
||||
)
|
||||
|
||||
hash_ribbons = next(m for m in valid_metrics if m["key"] == "hash_ribbons")
|
||||
assert hash_ribbons["ml_raw_weight"] == 0.01
|
||||
assert hash_ribbons["ml_weight"] == round(0.01 / 1.01, 4)
|
||||
|
||||
|
||||
def test_score_all_ml_preserves_classic_fallback_when_weights_missing(monkeypatch):
|
||||
monkeypatch.setattr(engine, "load_ml_weights", lambda: {})
|
||||
|
||||
scored = engine.score_all_ml(_complete_metrics())
|
||||
|
||||
assert scored["ml_mode"] is False
|
||||
assert scored["ml_error"] == "ML weights not found — run ml/optimizer.py"
|
||||
assert "classic_score" not in scored
|
||||
Reference in New Issue
Block a user