Spaces:
Running
Running
| """ | |
| Per-ticker auto-configuration + trust report. | |
| Instead of guessing lookback/T, this searches a small grid by *walk-forward evidence* on | |
| the ticker's own recent history, rejects configs whose fed window violates Kronos' | |
| normalisation assumption (wide price range), and picks the one that best beats a | |
| random-walk baseline. It writes a profile JSON the dashboard consumes so every served | |
| forecast carries a validated track record, a calibrated band, and an honest verdict. | |
| Example | |
| ------- | |
| python us/autoconfig.py --ticker COHR --pred_len 20 --n_windows 6 --n_paths 15 | |
| python us/autoconfig.py --ticker AAPL # defaults | |
| Note: this is an *offline* step (grid x windows x paths predictions ~ minutes on CPU). | |
| The result is cached; serving then loads it instantly. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| import time | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import numpy as np | |
| from predict_us import fetch_ohlcv | |
| from engine import get_predictor, _RANGE_THRESH | |
| import validation | |
| def profile_path(outdir: Path, ticker: str, interval: str) -> Path: | |
| return outdir / f"{ticker.upper()}_{interval}.json" | |
| def _num(x, default): | |
| """None/NaN -> a sentinel so it sorts as the worst option.""" | |
| return x if isinstance(x, (int, float)) and x == x else default | |
| def select_best(results: list, objective: str = "blend") -> dict: | |
| """ | |
| Pick a config (all objectives reject out-of-distribution windows first): | |
| * blend : (default) among in-distribution configs that beat a random walk | |
| (skill < 1.1), take the lowest MAPE — MAPE-driven but guarded against | |
| persistence-mimics / normalisation-broken windows. | |
| * mape : lowest MAPE (naive: a 'tomorrow = today' mimic also scores low). | |
| * skill : lowest skill_vs_rw (best at genuinely beating persistence). | |
| * ic : highest Information Coefficient (predicted-vs-realised return correlation) | |
| — how the Kronos paper judges skill; direction/ranking, not price level. | |
| * dir : highest directional accuracy. | |
| """ | |
| def key(r): | |
| ind = 0 if r["_in_distribution"] else 1 | |
| m = r["metrics"] | |
| mape = _num(m.get("MAPE%"), 1e9) | |
| skill = _num(m.get("skill_vs_rw"), 1e9) | |
| ic = _num(m.get("IC"), -1e9) | |
| da = _num(m.get("DirAcc%"), -1e9) | |
| if objective == "mape": | |
| return (ind, mape) | |
| if objective == "skill": | |
| return (ind, skill, mape) | |
| if objective == "ic": | |
| return (ind, -ic) | |
| if objective == "dir": | |
| return (ind, -da) | |
| competitive = 0 if skill < 1.1 else 1 | |
| return (ind, competitive, mape) | |
| return sorted(results, key=key)[0] | |
| def _grade(sel: dict) -> dict: | |
| """Turn the selected config's evidence into an honest verdict.""" | |
| m = sel["metrics"] | |
| in_dist = sel["_in_distribution"] | |
| skill = _num(m.get("skill_vs_rw"), 1e9) | |
| da = _num(m.get("DirAcc%"), 0.0) | |
| ic = _num(m.get("IC"), float("nan")) | |
| reasons = [] | |
| if not in_dist: | |
| reasons.append(f"視窗價格範圍 {sel['validity']['range_ratio_mean']}× 超出 Kronos " | |
| f"正規化可靠區(≥{_RANGE_THRESH}×),預測可能失真") | |
| return {"grade": "D", "trust": "unreliable-ood", "reasons": reasons} | |
| reasons.append(f"vs 隨機漫步 skill={skill:.2f}(<1 才算贏過『明天=今天』)") | |
| reasons.append(f"方向命中 {da:.0f}%(50% 為亂猜)" | |
| + (f" · IC={ic:.2f}" if ic == ic else "")) | |
| if skill < 0.97 and da >= 55: | |
| grade, trust = "A", "usable" | |
| elif skill < 1.0: | |
| grade, trust = "B", "marginal" | |
| elif skill <= 1.05: | |
| grade, trust = "C", "no-edge" | |
| reasons.append("點預測未能勝過隨機漫步 → 別當方向訊號用,只看校準後的區間") | |
| else: | |
| grade, trust = "D", "worse-than-naive" | |
| reasons.append("比隨機漫步還差 → 不建議使用此預測") | |
| return {"grade": grade, "trust": trust, "reasons": reasons} | |
| def run_ticker(predictor, df, ticker, interval, period, pred_len, lookbacks, temps, | |
| top_p, n_paths, n_windows, n_outer, target_coverage, objective, seed, | |
| verbose=False) -> dict: | |
| """ | |
| Nested walk-forward for one ticker → JSON trust profile. | |
| Rolling origins are split in time: the earlier *inner* windows drive config selection | |
| and band-scale calibration; the most recent *outer* windows — which the selection never | |
| saw — are used ONLY to report the honest track record. This removes the selection-bias | |
| optimism you'd get from grading a config on the same windows you picked it with. | |
| """ | |
| max_lb = max(lookbacks) | |
| windows = validation.make_windows(len(df), max_lb, pred_len, n_windows) | |
| if len(windows) < n_outer + 2: | |
| raise ValueError(f"need >= {n_outer + 2} windows to nest; got {len(windows)} " | |
| f"(raise --n_windows or --period)") | |
| n_inner = len(windows) - n_outer | |
| configs = [] | |
| for lb in lookbacks: | |
| if len(df) < max_lb + pred_len * 2: | |
| continue | |
| for T in temps: | |
| recs = validation.run_windows(predictor, df, lb, pred_len, T=T, top_p=top_p, | |
| n_paths=n_paths, windows=windows, seed=seed) | |
| if len(recs) < len(windows): | |
| continue | |
| inner, outer = recs[:n_inner], recs[n_inner:] | |
| r_in = validation.aggregate(inner, target_coverage) # fits band_scale on inner | |
| item = {"config": {"lookback": lb, "pred_len": pred_len, "T": T, "top_p": top_p, | |
| "top_k": 0, "n_paths": n_paths}, | |
| "metrics": r_in["metrics"], "calibration": r_in["calibration"], | |
| "validity": r_in["validity"], "n_windows": r_in["n_windows"], | |
| "_in_distribution": r_in["validity"]["range_ratio_mean"] < _RANGE_THRESH, | |
| "_outer": outer} | |
| configs.append(item) | |
| if verbose: | |
| m = r_in["metrics"] | |
| print(f" [inner] lb={lb:>3} T={T} MAPE={m['MAPE%']}% skill={m['skill_vs_rw']} " | |
| f"IC={m['IC']} Dir={m['DirAcc%']}% range={item['validity']['range_ratio_mean']}x" | |
| f"{'' if item['_in_distribution'] else ' ⚠OOD'}") | |
| if not configs: | |
| raise ValueError("no runnable configs (need more history / smaller pred_len)") | |
| sel = select_best(configs, objective) | |
| # honest report: apply the inner-fitted band scale to the untouched outer windows | |
| r_out = validation.aggregate(sel["_outer"], target_coverage, | |
| band_scale=sel["calibration"]["band_scale"]) | |
| verdict = _grade({"metrics": r_out["metrics"], "_in_distribution": sel["_in_distribution"], | |
| "validity": r_out["validity"]}) | |
| return { | |
| "ticker": ticker.upper(), "interval": interval, "period": period, | |
| "generated": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "objective": objective, | |
| "validation": {"scheme": "nested", "n_windows": len(windows), | |
| "n_inner": n_inner, "n_outer": n_outer}, | |
| "recommended": {"lookback": sel["config"]["lookback"], "pred_len": pred_len, | |
| "T": sel["config"]["T"], "top_p": sel["config"]["top_p"], | |
| "top_k": 0, "n_paths": n_paths}, | |
| "calibration": {"target_coverage": target_coverage, | |
| "band_scale": sel["calibration"]["band_scale"], | |
| "coverage_raw": r_out["calibration"]["coverage_raw"], | |
| "coverage_calibrated": r_out["calibration"]["coverage_calibrated"]}, | |
| "track_record": {**r_out["metrics"], "n_windows": n_outer, | |
| "note": "honest: outer windows the selection never saw"}, | |
| "selection_metrics": {**sel["metrics"], "n_windows": n_inner}, | |
| "validity": {**sel["validity"], "in_distribution": sel["_in_distribution"], | |
| "range_thresh": _RANGE_THRESH}, | |
| "verdict": verdict, | |
| "all_configs": [ | |
| {"lookback": c["config"]["lookback"], "T": c["config"]["T"], | |
| **c["metrics"], **c["calibration"], | |
| "range_ratio_mean": c["validity"]["range_ratio_mean"], | |
| "in_distribution": c["_in_distribution"]} | |
| for c in configs], | |
| } | |
| def main(): | |
| try: # Windows consoles default to cp950/utf-16; make Chinese output readable | |
| sys.stdout.reconfigure(encoding="utf-8") | |
| except Exception: | |
| pass | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--ticker", default="AAPL") | |
| ap.add_argument("--interval", default="1d") | |
| ap.add_argument("--period", default="5y") | |
| ap.add_argument("--pred_len", type=int, default=20) | |
| ap.add_argument("--n_windows", type=int, default=8, help="total rolling origins (inner+outer)") | |
| ap.add_argument("--n_outer", type=int, default=3, help="most-recent windows held out for the honest report") | |
| ap.add_argument("--n_paths", type=int, default=15, help="ensemble size (also used for serving, keeps calibration valid)") | |
| ap.add_argument("--lookbacks", default="60,120,240,400") | |
| ap.add_argument("--temps", default="0.7,1.0") | |
| ap.add_argument("--top_p", type=float, default=0.9) | |
| ap.add_argument("--target_coverage", type=float, default=0.8) | |
| ap.add_argument("--objective", choices=["blend", "mape", "skill", "ic", "dir"], default="blend", | |
| help="blend=lowest MAPE among in-dist configs that beat RW; ic=max Information Coefficient; dir=max direction") | |
| ap.add_argument("--seed", type=int, default=123) | |
| ap.add_argument("--outdir", default=str(Path(__file__).resolve().parent / "out" / "profiles")) | |
| args = ap.parse_args() | |
| lookbacks = [int(x) for x in args.lookbacks.split(",") if x.strip()] | |
| temps = [float(x) for x in args.temps.split(",") if x.strip()] | |
| df = fetch_ohlcv(args.ticker, args.interval, args.period) | |
| print(f"[data] {args.ticker} {args.interval}: {len(df)} bars -> {df['timestamps'].iloc[-1].date()}") | |
| predictor = get_predictor() | |
| grid = len(lookbacks) * len(temps) | |
| print(f"[search] {grid} configs x {args.n_windows} windows x {args.n_paths} paths " | |
| f"(nested: {args.n_windows-args.n_outer} inner / {args.n_outer} outer)\n") | |
| t0 = time.time() | |
| profile = run_ticker(predictor, df, args.ticker, args.interval, args.period, | |
| args.pred_len, lookbacks, temps, args.top_p, args.n_paths, | |
| args.n_windows, args.n_outer, args.target_coverage, | |
| args.objective, args.seed, verbose=True) | |
| outdir = Path(args.outdir); outdir.mkdir(parents=True, exist_ok=True) | |
| pth = profile_path(outdir, args.ticker, args.interval) | |
| pth.write_text(json.dumps(profile, indent=2, ensure_ascii=False), encoding="utf-8") | |
| rec = profile["recommended"]; v = profile["verdict"] | |
| tr = profile["track_record"]; sm = profile["selection_metrics"]; c = profile["calibration"] | |
| print(f"\n=== TRUST REPORT · {profile['ticker']} (objective={args.objective}) ===") | |
| print(f"grade {v['grade']} ({v['trust']})") | |
| for r in v["reasons"]: | |
| print(" -", r) | |
| print(f"recommended: lookback={rec['lookback']} T={rec['T']} top_p={rec['top_p']} " | |
| f"pred_len={rec['pred_len']} n_paths={rec['n_paths']}") | |
| print(f"HONEST (outer {tr['n_windows']} win, unseen): MAPE={tr['MAPE%']}% " | |
| f"skill={tr['skill_vs_rw']} IC={tr['IC']} Dir={tr['DirAcc%']}% " | |
| f"cover={c['coverage_calibrated']*100:.0f}%") | |
| print(f"selection (inner {sm['n_windows']} win, optimistic): MAPE={sm['MAPE%']}% " | |
| f"skill={sm['skill_vs_rw']} IC={sm['IC']}") | |
| print(f"[done] {time.time()-t0:.0f}s profile -> {pth}") | |
| if __name__ == "__main__": | |
| main() | |