Spaces:
Running
Running
Commit ·
55298f4
1
Parent(s): e3117db
feat(C12): 1-day horizon; FAILED
Browse files1-day triple barrier labels: dir_acc 18.0% (-23.3pp), up_prec 20.4% (-31.8pp).
5-day horizon is essential — shorter horizon can't overcome next-day noise.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- docs/c12_1day_result.json +112 -0
- docs/improvements_log.md +1 -0
- scripts/backtest_c12.py +157 -0
docs/c12_1day_result.json
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"results": {
|
| 3 |
+
"2330": {
|
| 4 |
+
"5day": {
|
| 5 |
+
"accuracy": 33.3,
|
| 6 |
+
"dir_accuracy": 36.7,
|
| 7 |
+
"up_precision": 52.9,
|
| 8 |
+
"dn_precision": 27.8,
|
| 9 |
+
"n_signals": 196,
|
| 10 |
+
"n_predictions": 231
|
| 11 |
+
},
|
| 12 |
+
"1day": {
|
| 13 |
+
"accuracy": 55.4,
|
| 14 |
+
"dir_accuracy": 24.6,
|
| 15 |
+
"up_precision": 29.4,
|
| 16 |
+
"dn_precision": 19.4,
|
| 17 |
+
"n_signals": 65,
|
| 18 |
+
"n_predictions": 231
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
"0050": {
|
| 22 |
+
"5day": {
|
| 23 |
+
"accuracy": 38.1,
|
| 24 |
+
"dir_accuracy": 43.1,
|
| 25 |
+
"up_precision": 62.8,
|
| 26 |
+
"dn_precision": 29.9,
|
| 27 |
+
"n_signals": 195,
|
| 28 |
+
"n_predictions": 231
|
| 29 |
+
},
|
| 30 |
+
"1day": {
|
| 31 |
+
"accuracy": 56.3,
|
| 32 |
+
"dir_accuracy": 24.6,
|
| 33 |
+
"up_precision": 30.0,
|
| 34 |
+
"dn_precision": 6.7,
|
| 35 |
+
"n_signals": 65,
|
| 36 |
+
"n_predictions": 231
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
"2317": {
|
| 40 |
+
"5day": {
|
| 41 |
+
"accuracy": 39.0,
|
| 42 |
+
"dir_accuracy": 40.5,
|
| 43 |
+
"up_precision": 50.8,
|
| 44 |
+
"dn_precision": 28.4,
|
| 45 |
+
"n_signals": 220,
|
| 46 |
+
"n_predictions": 231
|
| 47 |
+
},
|
| 48 |
+
"1day": {
|
| 49 |
+
"accuracy": 55.4,
|
| 50 |
+
"dir_accuracy": 11.9,
|
| 51 |
+
"up_precision": 8.7,
|
| 52 |
+
"dn_precision": 13.9,
|
| 53 |
+
"n_signals": 59,
|
| 54 |
+
"n_predictions": 231
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"2454": {
|
| 58 |
+
"5day": {
|
| 59 |
+
"accuracy": 39.4,
|
| 60 |
+
"dir_accuracy": 41.6,
|
| 61 |
+
"up_precision": 41.3,
|
| 62 |
+
"dn_precision": 41.8,
|
| 63 |
+
"n_signals": 190,
|
| 64 |
+
"n_predictions": 231
|
| 65 |
+
},
|
| 66 |
+
"1day": {
|
| 67 |
+
"accuracy": 51.5,
|
| 68 |
+
"dir_accuracy": 11.9,
|
| 69 |
+
"up_precision": 16.1,
|
| 70 |
+
"dn_precision": 8.3,
|
| 71 |
+
"n_signals": 67,
|
| 72 |
+
"n_predictions": 231
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"2881": {
|
| 76 |
+
"5day": {
|
| 77 |
+
"accuracy": 40.3,
|
| 78 |
+
"dir_accuracy": 44.8,
|
| 79 |
+
"up_precision": 53.0,
|
| 80 |
+
"dn_precision": 36.9,
|
| 81 |
+
"n_signals": 203,
|
| 82 |
+
"n_predictions": 231
|
| 83 |
+
},
|
| 84 |
+
"1day": {
|
| 85 |
+
"accuracy": 54.5,
|
| 86 |
+
"dir_accuracy": 17.2,
|
| 87 |
+
"up_precision": 17.9,
|
| 88 |
+
"dn_precision": 16.0,
|
| 89 |
+
"n_signals": 64,
|
| 90 |
+
"n_predictions": 231
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
"aggregate": {
|
| 95 |
+
"5day": {
|
| 96 |
+
"accuracy": 38.0,
|
| 97 |
+
"dir_accuracy": 41.3,
|
| 98 |
+
"up_precision": 52.2,
|
| 99 |
+
"dn_precision": 33.0,
|
| 100 |
+
"n_signals": 200.8
|
| 101 |
+
},
|
| 102 |
+
"1day": {
|
| 103 |
+
"accuracy": 54.6,
|
| 104 |
+
"dir_accuracy": 18.0,
|
| 105 |
+
"up_precision": 20.4,
|
| 106 |
+
"dn_precision": 12.9,
|
| 107 |
+
"n_signals": 64.0
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
"passed": false,
|
| 111 |
+
"pass_criterion": "1day dir_accuracy >= 44.0 AND up_precision >= 54.0"
|
| 112 |
+
}
|
docs/improvements_log.md
CHANGED
|
@@ -13,3 +13,4 @@
|
|
| 13 |
| C9 | multi-stock training universe (20 stocks) | dir_acc: 41.3 → 40.4, up_prec: 52.2 → 52.9 | FAILED — cross-stock label noise hurts more than extra data helps |
|
| 14 |
| C10 | asymmetric triple barrier grid (pt_ratio=1.0..3.0, sl fixed 1.0) | up_prec: 52.2 @ pt=1.0, 43.5 @ pt=1.5, 37.2 @ pt=2.0 — monotonically worse | FAILED — wider UP barrier degrades precision; symmetric pt=1.0 remains best |
|
| 15 |
| C11 | full ensemble walk-forward (RF + XGB + LGBM) | dir_acc: 41.3 → 43.1 (+1.8pp), up_prec: 52.2 → 52.2 (flat) | FAILED — dir misses 43.5 threshold; RF already representative; ↑prec flat |
|
|
|
|
|
|
| 13 |
| C9 | multi-stock training universe (20 stocks) | dir_acc: 41.3 → 40.4, up_prec: 52.2 → 52.9 | FAILED — cross-stock label noise hurts more than extra data helps |
|
| 14 |
| C10 | asymmetric triple barrier grid (pt_ratio=1.0..3.0, sl fixed 1.0) | up_prec: 52.2 @ pt=1.0, 43.5 @ pt=1.5, 37.2 @ pt=2.0 — monotonically worse | FAILED — wider UP barrier degrades precision; symmetric pt=1.0 remains best |
|
| 15 |
| C11 | full ensemble walk-forward (RF + XGB + LGBM) | dir_acc: 41.3 → 43.1 (+1.8pp), up_prec: 52.2 → 52.2 (flat) | FAILED — dir misses 43.5 threshold; RF already representative; ↑prec flat |
|
| 16 |
+
| C12 | 1-day label horizon (vs 5-day triple barrier) | dir_acc: 41.3 → 18.0% (-23.3pp), up_prec: 52.2 → 20.4% (-31.8pp) | FAILED — 1-day horizon catastrophically degrades; model can't predict next-day direction; 5-day horizon absorbs noise better |
|
scripts/backtest_c12.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""C12: 1-day vs 5-day label horizon comparison."""
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 7 |
+
sys.path.insert(0, str(ROOT))
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from dotenv import load_dotenv; load_dotenv(ROOT / ".env")
|
| 11 |
+
except ImportError:
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
import warnings; warnings.filterwarnings("ignore")
|
| 15 |
+
import json
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 19 |
+
|
| 20 |
+
from scripts.improvement_harness import (
|
| 21 |
+
fetch_df, build_triple_barrier_labels, walk_forward,
|
| 22 |
+
compute_metrics, BASELINE_FEATURES, DEFAULT_STOCKS,
|
| 23 |
+
RF_PARAMS, MIN_TRAIN, STEP, LABEL_HORIZON,
|
| 24 |
+
)
|
| 25 |
+
from models.predictor import _build_features
|
| 26 |
+
|
| 27 |
+
PASS_DIR = 44.0
|
| 28 |
+
PASS_UP = 54.0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def build_1day_labels(close: np.ndarray, vol_lookback: int = 20, pt_sl_ratio: float = 1.0) -> np.ndarray:
|
| 32 |
+
"""Triple barrier with 1-day vertical barrier instead of 5-day."""
|
| 33 |
+
n = len(close)
|
| 34 |
+
log_ret = np.diff(np.log(close + 1e-9))
|
| 35 |
+
labels = np.full(n, np.nan)
|
| 36 |
+
for i in range(n - 1): # horizon=1
|
| 37 |
+
start_v = max(0, i - vol_lookback)
|
| 38 |
+
window = log_ret[start_v:i]
|
| 39 |
+
vol = float(np.std(window)) if len(window) >= 5 else 0.015
|
| 40 |
+
vol = max(vol, 0.001)
|
| 41 |
+
upper = close[i] * (1.0 + vol * pt_sl_ratio)
|
| 42 |
+
lower = close[i] * (1.0 - vol * pt_sl_ratio)
|
| 43 |
+
c = close[i + 1]
|
| 44 |
+
if c >= upper:
|
| 45 |
+
labels[i] = 1
|
| 46 |
+
elif c <= lower:
|
| 47 |
+
labels[i] = -1
|
| 48 |
+
else:
|
| 49 |
+
labels[i] = 0
|
| 50 |
+
return labels
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def walk_forward_1d(feat_df: pd.DataFrame, label_arr: np.ndarray, cols: list) -> dict:
|
| 54 |
+
LABEL_HORIZON_1D = 1
|
| 55 |
+
avail = [c for c in cols if c in feat_df.columns]
|
| 56 |
+
X_all = feat_df[avail].fillna(0).values
|
| 57 |
+
n = len(feat_df)
|
| 58 |
+
y_true_all, y_pred_all = [], []
|
| 59 |
+
cutoff = MIN_TRAIN
|
| 60 |
+
while cutoff + STEP + LABEL_HORIZON_1D <= n:
|
| 61 |
+
y_tr = label_arr[:cutoff]
|
| 62 |
+
valid = ~np.isnan(y_tr)
|
| 63 |
+
y_v = y_tr[valid]
|
| 64 |
+
if len(y_v) < 10 or len(np.unique(y_v)) < 2:
|
| 65 |
+
cutoff += STEP; continue
|
| 66 |
+
clf = RandomForestClassifier(**RF_PARAMS)
|
| 67 |
+
clf.fit(X_all[:cutoff][valid], y_v.astype(int))
|
| 68 |
+
test_end = min(cutoff + STEP, n - LABEL_HORIZON_1D)
|
| 69 |
+
y_te = label_arr[cutoff:test_end]
|
| 70 |
+
valid_te = ~np.isnan(y_te)
|
| 71 |
+
if valid_te.sum() == 0:
|
| 72 |
+
cutoff += STEP; continue
|
| 73 |
+
y_pred = clf.predict(X_all[cutoff:test_end][valid_te])
|
| 74 |
+
y_true_all.extend(y_te[valid_te].tolist())
|
| 75 |
+
y_pred_all.extend(y_pred.tolist())
|
| 76 |
+
cutoff += STEP
|
| 77 |
+
if not y_true_all:
|
| 78 |
+
return {}
|
| 79 |
+
return compute_metrics(np.array(y_true_all), np.array(y_pred_all))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def main():
|
| 83 |
+
hdr = f"{'Stock':>6} {'5day_dir%':>9} {'1day_dir%':>9} {'Δdir':>5} {'5day_↑prec%':>11} {'1day_↑prec%':>11} {'Δprec':>6} {'1day_sigs':>9}"
|
| 84 |
+
print(f"\n{hdr}\n{'-'*len(hdr)}")
|
| 85 |
+
|
| 86 |
+
per_stock = {}
|
| 87 |
+
rows_5d, rows_1d = [], []
|
| 88 |
+
|
| 89 |
+
for stock_no in DEFAULT_STOCKS:
|
| 90 |
+
print(f" computing {stock_no}...", end="\r", flush=True)
|
| 91 |
+
df = fetch_df(stock_no)
|
| 92 |
+
if df is None or df.empty:
|
| 93 |
+
print(f"{stock_no:>6} no data")
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
feat = _build_features(df)
|
| 97 |
+
close = (df.set_index("date")["close"] if "date" in df.columns else df["close"]).values
|
| 98 |
+
|
| 99 |
+
labels_5d = build_triple_barrier_labels(close)
|
| 100 |
+
r5 = walk_forward(feat, labels_5d, BASELINE_FEATURES)
|
| 101 |
+
|
| 102 |
+
labels_1d = build_1day_labels(close)
|
| 103 |
+
r1 = walk_forward_1d(feat, labels_1d, BASELINE_FEATURES)
|
| 104 |
+
|
| 105 |
+
per_stock[stock_no] = {"5day": r5, "1day": r1}
|
| 106 |
+
if r5:
|
| 107 |
+
rows_5d.append(r5)
|
| 108 |
+
if r1:
|
| 109 |
+
rows_1d.append(r1)
|
| 110 |
+
|
| 111 |
+
d5 = r5.get("dir_accuracy", float("nan"))
|
| 112 |
+
d1 = r1.get("dir_accuracy", float("nan"))
|
| 113 |
+
p5 = r5.get("up_precision", float("nan"))
|
| 114 |
+
p1 = r1.get("up_precision", float("nan"))
|
| 115 |
+
dd = d1 - d5 if not (np.isnan(d1) or np.isnan(d5)) else float("nan")
|
| 116 |
+
dp = p1 - p5 if not (np.isnan(p1) or np.isnan(p5)) else float("nan")
|
| 117 |
+
sigs = r1.get("n_signals", 0)
|
| 118 |
+
print(f"{stock_no:>6} {d5:>9.1f} {d1:>9.1f} {dd:>+5.1f} {p5:>11.1f} {p1:>11.1f} {dp:>+6.1f} {sigs:>9}")
|
| 119 |
+
|
| 120 |
+
def _mean(rows, k):
|
| 121 |
+
vals = [r[k] for r in rows if isinstance(r.get(k), (int, float)) and not np.isnan(r.get(k, float("nan")))]
|
| 122 |
+
return round(sum(vals) / len(vals), 1) if vals else float("nan")
|
| 123 |
+
|
| 124 |
+
agg5 = {k: _mean(rows_5d, k) for k in ("accuracy", "dir_accuracy", "up_precision", "dn_precision", "n_signals")}
|
| 125 |
+
agg1 = {k: _mean(rows_1d, k) for k in ("accuracy", "dir_accuracy", "up_precision", "dn_precision", "n_signals")}
|
| 126 |
+
|
| 127 |
+
dd_agg = agg1["dir_accuracy"] - agg5["dir_accuracy"]
|
| 128 |
+
dp_agg = agg1["up_precision"] - agg5["up_precision"]
|
| 129 |
+
|
| 130 |
+
print(f"\n{'=== AGGREGATE ==='}")
|
| 131 |
+
print(f" 5day: dir={agg5['dir_accuracy']}% ↑prec={agg5['up_precision']}% signals={agg5['n_signals']}")
|
| 132 |
+
print(f" 1day: dir={agg1['dir_accuracy']}% ↑prec={agg1['up_precision']}% signals={agg1['n_signals']}")
|
| 133 |
+
print(f" Δ : dir={dd_agg:+.1f}pp ↑prec={dp_agg:+.1f}pp")
|
| 134 |
+
|
| 135 |
+
passed = bool((agg1["dir_accuracy"] >= PASS_DIR) and (agg1["up_precision"] >= PASS_UP))
|
| 136 |
+
status = "PASSED" if passed else "FAILED"
|
| 137 |
+
print(f"\n Pass criterion: 1day dir ≥ {PASS_DIR} AND ↑prec ≥ {PASS_UP}")
|
| 138 |
+
print(f" C12 {status}")
|
| 139 |
+
|
| 140 |
+
result = {
|
| 141 |
+
"results": per_stock,
|
| 142 |
+
"aggregate": {"5day": agg5, "1day": agg1},
|
| 143 |
+
"passed": passed,
|
| 144 |
+
"pass_criterion": f"1day dir_accuracy >= {PASS_DIR} AND up_precision >= {PASS_UP}",
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
out = ROOT / "docs" / "c12_1day_result.json"
|
| 148 |
+
out.parent.mkdir(exist_ok=True)
|
| 149 |
+
with open(out, "w") as f:
|
| 150 |
+
json.dump(result, f, indent=2)
|
| 151 |
+
print(f"\n Written: {out}")
|
| 152 |
+
|
| 153 |
+
print(f"\nC12 {status} — 1day dir: {agg1['dir_accuracy']}%, ↑prec: {agg1['up_precision']}%, signals/stock: {agg1['n_signals']}")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
main()
|