PaperTrade / research /loop_backtest.py
Khanna, Videh Rakesh Rakesh
Real direction-accuracy/P&L measurement + BULLISH trigger fixes
5342112
Raw
History Blame Contribute Delete
28.6 kB
#!/usr/bin/env python3
"""
research/loop_backtest.py β€” Continuous prompt optimization loop (AI-only, no strategies).
Runs backtest β†’ analyzes accuracy β†’ applies targeted prompt fix β†’ repeats
indefinitely until target price range predictions achieve β‰₯90% accuracy on all
timeframes (1D, 3D, 5D), matching actual NSE price movements within predicted ranges.
Features:
- No hard iteration ceiling: runs until target is met or Ctrl+C
- Auto-detects model rate limits and waits for cooldown before retrying
- AI predictions only (no strategy signals, matched_strategy=null)
- Backs up CSV and prompt on every iteration
Usage:
python research/loop_backtest.py
python research/loop_backtest.py --reset # clear CSV and start fresh
"""
from __future__ import annotations
import sys, os, re, shutil, subprocess, time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import numpy as np
import pandas as pd
CSV_PATH = os.path.join(os.path.dirname(__file__), "ai_prompt_accuracy.csv")
FORECAST_PY = os.path.join(os.path.dirname(__file__), "..", "ai_forecast.py")
TARGET = 90.0
BACKTEST_CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache")
ITERATION_BACKUP_RE = re.compile(r"ai_prompt_accuracy_iter(\d+)\.csv$")
# Hold-out boundary β€” must match backtest.py HOLDOUT_START.
# Training window for loop optimization = rolling 18 months ending the day before this.
HOLDOUT_START = "2025-01-01"
# ── ANALYSIS ──────────────────────────────────────────────────────────────────
def analyze(csv_path: str) -> dict | None:
if not os.path.exists(csv_path):
return None
df = pd.read_csv(csv_path)
if "timeframe" not in df.columns:
print(" [WARN] CSV has no timeframe column β€” old format"); return None
res = {}
if "intraday_hit_for_tf" not in df.columns:
print(" [WARN] CSV missing intraday_hit_for_tf column β€” re-run backtest with updated script")
return None
# Exclude non-LLM rows β€” only real provider/model predictions count toward targets.
n_total = len(df)
if "source" in df.columns:
excluded_sources = {"heuristic", "ai_unavailable", "failed"}
df = df[~df["source"].isin(excluded_sources)].copy()
n_llm = len(df)
n_heuristic = n_total - n_llm
if n_heuristic > 0:
print(f" [FILTER] Dropped {n_heuristic}/{n_total} non-LLM rows β€” only {n_llm} real LLM predictions counted")
if n_llm == 0:
print(" [WARN] Zero LLM predictions in CSV β€” all rows were non-LLM (heuristic/ai_unavailable/failed).")
return None
llm_pct = n_llm / n_total * 100
if llm_pct < 50:
print(f" [WARN] Only {llm_pct:.0f}% of predictions came from LLM β€” results may not be representative")
# Scope: INTRADAY + 1D only. 3D/5D are retired from every production path (CLAUDE.md,
# "No 3D/5D on prod API") β€” this loop should never spend iterations re-tuning a timeframe
# nothing in prod ever calls.
for tf in ["INTRADAY", "1D"]:
sub = df[df["timeframe"] == tf]
if sub.empty:
continue
directional = sub[sub["direction"].isin(["BULLISH", "BEARISH"])].copy()
bull = directional[directional["direction"] == "BULLISH"]
bear = directional[directional["direction"] == "BEARISH"]
# PRIMARY METRIC: target_hit (actual price range predictions)
tgt_acc = directional["target_hit_for_tf"].mean() * 100 if len(directional) >= 3 else float("nan")
tgt_bull = bull["target_hit_for_tf"].mean() * 100 if len(bull) >= 3 else float("nan")
tgt_bear = bear["target_hit_for_tf"].mean() * 100 if len(bear) >= 3 else float("nan")
# SECONDARY: band-touch "directional" hit (for diagnostics β€” NOT real direction accuracy,
# see real_dir_acc/real_pnl below for that).
dir_acc = directional["intraday_hit_for_tf"].mean() * 100 if len(directional) >= 3 else float("nan")
b_acc = bull["intraday_hit_for_tf"].mean() * 100 if len(bull) >= 3 else float("nan")
e_acc = bear["intraday_hit_for_tf"].mean() * 100 if len(bear) >= 3 else float("nan")
# REAL direction accuracy + net P&L β€” did the stock actually move the predicted way,
# and would trading it have made money net of NSE round-trip costs. This is the metric
# target_hit/dir_acc above are blind to (a low, easily-touched near-bound can "hit" a
# band on either metric regardless of whether the direction call has any real edge).
real_dir_acc = real_pnl = real_pnl_bull = real_pnl_bear = float("nan")
try:
from costs import cost_pct_for_timeframe
if tf == "INTRADAY":
real_move = pd.to_numeric(directional.get("ret_intraday_real"), errors="coerce")
else:
real_move = pd.to_numeric(directional.get("ret_for_tf"), errors="coerce")
valid = directional[real_move.notna()].copy()
valid["real_move"] = real_move[real_move.notna()]
if len(valid) >= 3:
is_bull = valid["direction"] == "BULLISH"
correct = np.where(is_bull, valid["real_move"] > 0, valid["real_move"] < 0)
gross = np.where(is_bull, valid["real_move"], -valid["real_move"])
net = gross - cost_pct_for_timeframe(tf)
real_dir_acc = correct.mean() * 100
real_pnl = net.mean()
if is_bull.sum() >= 3:
real_pnl_bull = net[is_bull.values].mean()
if (~is_bull).sum() >= 3:
real_pnl_bear = net[(~is_bull).values].mean()
except Exception:
pass
res[tf] = {
"target_acc": tgt_acc,
"target_bull": tgt_bull,
"target_bear": tgt_bear,
"dir_acc": dir_acc,
"bull_acc": b_acc,
"bear_acc": e_acc,
"real_dir_acc": real_dir_acc,
"real_pnl": real_pnl,
"real_pnl_bull": real_pnl_bull,
"real_pnl_bear": real_pnl_bear,
"n_bull": len(bull),
"n_bear": len(bear),
"n_dir": len(directional),
"n_total": len(sub),
"bear_ratio": (directional["direction"] == "BEARISH").mean() * 100 if len(directional) else 0,
"med_bull_acc": sub[(sub["confidence"] == "MEDIUM") & (sub["direction"] == "BULLISH")]["intraday_hit_for_tf"].mean() * 100
if len(sub[(sub["confidence"] == "MEDIUM") & (sub["direction"] == "BULLISH")]) >= 3
else float("nan"),
}
return res
MIN_LLM_PREDICTIONS = 30 # require at least 30 real LLM rows per TF before declaring target met
def target_met(res: dict) -> bool:
for tf, r in res.items():
if r.get("n_dir", 0) < 5:
return False
if r.get("n_dir", 0) < MIN_LLM_PREDICTIONS:
print(f" [GATE] {tf}: only {r.get('n_dir',0)} predictions (need {MIN_LLM_PREDICTIONS}) β€” target not met yet")
return False
# PRIMARY: target_hit accuracy must reach 90%
if np.isnan(r.get("target_acc", float("nan"))) or r.get("target_acc", 0) < TARGET:
return False
return True
def next_backup_iteration(csv_path: str) -> int:
"""Return the next monotonically increasing iterN suffix for CSV backups."""
directory = os.path.dirname(csv_path)
highest = -1
for name in os.listdir(directory):
match = ITERATION_BACKUP_RE.match(name)
if match:
highest = max(highest, int(match.group(1)))
return highest + 1
def print_summary(res: dict, iteration: int):
print(f"\n{'='*70}")
print(f" ITERATION {iteration} RESULTS")
print(f"{'='*70}")
print(f" {'TF':<9} {'TgtHit':>10} {'TgtBull':>10} {'TgtBear':>10} {'DirHit':>9} {'N_DIR':>8} {'BearRat':>9}")
print(f" {'-'*66}")
for tf in ["INTRADAY", "1D"]:
r = res.get(tf, {})
ta = f"{r.get('target_acc',float('nan')):.1f}%" if not np.isnan(r.get('target_acc', float('nan'))) else "n/a"
tb = f"{r.get('target_bull',float('nan')):.1f}%" if not np.isnan(r.get('target_bull', float('nan'))) else "n/a"
te = f"{r.get('target_bear',float('nan')):.1f}%" if not np.isnan(r.get('target_bear', float('nan'))) else "n/a"
da = f"{r.get('dir_acc',float('nan')):.1f}%" if not np.isnan(r.get('dir_acc', float('nan'))) else "n/a"
br = f"{r.get('bear_ratio',0):.1f}%"
nd = r.get('n_dir', 0)
tgt_ok = not np.isnan(r.get('target_acc', float('nan'))) and r.get('target_acc', 0) >= TARGET
status = "βœ“" if tgt_ok else "βœ—"
print(f" {tf:<9} {ta:>10} {status} {tb:>10} {te:>10} {da:>8} {nd:>6} {br:>8}")
# Real direction-accuracy + net P&L β€” the metric TgtHit/DirHit above are blind to. Printed
# separately so it's impossible to miss even though it isn't (yet) a pass/fail gate.
print(f"\n {'TF':<9} {'RealDirAcc':>11} {'RealP&L':>9} {'P&L(Bull)':>10} {'P&L(Bear)':>10}")
print(f" {'-'*54}")
for tf in ["INTRADAY", "1D"]:
r = res.get(tf, {})
rda = f"{r.get('real_dir_acc',float('nan')):.1f}%" if not np.isnan(r.get('real_dir_acc', float('nan'))) else "n/a"
rp = f"{r.get('real_pnl',float('nan')):+.3f}%" if not np.isnan(r.get('real_pnl', float('nan'))) else "n/a"
rpb = f"{r.get('real_pnl_bull',float('nan')):+.3f}%" if not np.isnan(r.get('real_pnl_bull', float('nan'))) else "n/a"
rpe = f"{r.get('real_pnl_bear',float('nan')):+.3f}%" if not np.isnan(r.get('real_pnl_bear', float('nan'))) else "n/a"
print(f" {tf:<9} {rda:>11} {rp:>9} {rpb:>10} {rpe:>10}")
print()
# ── PROMPT FIXES ──────────────────────────────────────────────────────────────
def read_forecast(path: str) -> str:
with open(path, "r") as f:
return f.read()
def write_forecast(path: str, content: str):
with open(path, "w") as f:
f.write(content)
def _replace_once(src: str, old: str, new: str) -> tuple[str, bool]:
if old in src:
return src.replace(old, new, 1), True
return src, False
def _find_in_synthesis(src: str, target: str) -> bool:
"""Check if target string exists inside _build_synthesis_prompt function."""
start = src.find("def _build_synthesis_prompt(")
end = src.find("def _downgrade_confidence(", start)
if start == -1 or end == -1:
return False
return target in src[start:end]
def _replace_in_synthesis(src: str, old: str, new: str) -> tuple[str, bool]:
"""Replace string inside _build_synthesis_prompt only."""
start = src.find("def _build_synthesis_prompt(")
end = src.find("def _downgrade_confidence(", start)
if start == -1 or end == -1:
return src, False
block = src[start:end]
if old not in block:
return src, False
block = block.replace(old, new, 1)
return src[:start] + block + src[end:], True
# ── PROMPT FIX FUNCTIONS ──────────────────────────────────────────────────────
# All fixes target the signal alignment rules in _build_synthesis_prompt.
# Strategy: the key lever for >90% accuracy is direction accuracy.
# We do this by tightening the criteria that must be met before LLM calls directional.
def fix_tighten_1d_bullish_rsi(src: str, iteration: int) -> tuple[str, str]:
"""Tighten 1D BULLISH RSI threshold β€” require stronger oversold to call BULLISH."""
pairs = [
("- BULLISH when: RSI < 40 (oversold bounce) OR (above EMA50 AND MACD > 0 AND volume high)\n",
f"- BULLISH when: RSI < 35 (deep oversold) OR (above EMA50 AND MACD > 0 AND volume > 1.3x avg) [v{iteration}]\n"),
(f"- BULLISH when: RSI < 35 (deep oversold) OR (above EMA50 AND MACD > 0 AND volume > 1.3x avg) [v{iteration-1}]\n",
f"- BULLISH when: RSI < 32 (extreme oversold) OR (above EMA50 AND MACD > 0 AND volume > 1.5x avg) [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, f" [FIX] 1D BULLISH RSI tightened to {32 if 'extreme' in new else 35}"
return src, " [FIX] 1D BULLISH RSI anchor not found β€” skipped"
def fix_tighten_1d_bearish_rsi(src: str, iteration: int) -> tuple[str, str]:
"""Tighten 1D BEARISH RSI threshold β€” require more overbought to call BEARISH."""
pairs = [
("- BEARISH when: RSI > 68 AND below EMA50 AND MACD < 0 AND volume confirms\n",
f"- BEARISH when: RSI > 72 AND below EMA50 AND MACD < 0 AND volume confirms downside [v{iteration}]\n"),
(f"- BEARISH when: RSI > 72 AND below EMA50 AND MACD < 0 AND volume confirms downside [v{iteration-1}]\n",
f"- BEARISH when: RSI > 75 AND below BOTH EMA50 AND EMA200 AND MACD < 0 AND 90D return negative [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, f" [FIX] 1D BEARISH RSI threshold raised"
return src, " [FIX] 1D BEARISH RSI anchor not found β€” skipped"
def fix_tighten_3d_signal_count(src: str, iteration: int) -> tuple[str, str]:
"""Tighten 3D direction criteria β€” require more evidence for directional calls."""
pairs = [
("- BEARISH when: below EMA50 AND (RSI > 58 OR MACD < 0) AND macro/sector headwinds\n",
f"- BEARISH when: below EMA50 AND RSI > 58 AND MACD < 0 AND macro/sector headwinds [v{iteration}]\n"),
(f"- BEARISH when: below EMA50 AND RSI > 58 AND MACD < 0 AND macro/sector headwinds [v{iteration-1}]\n",
f"- BEARISH when: below BOTH EMA50 AND EMA200 AND RSI > 60 AND MACD < 0 AND 90D return negative [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, " [FIX] 3D BEARISH now requires both EMAs below and MACD confirmation"
return src, " [FIX] 3D BEARISH anchor not found β€” skipped"
def fix_tighten_5d_direction(src: str, iteration: int) -> tuple[str, str]:
"""Tighten 5D direction thresholds β€” only call directional when trend is clear."""
pairs = [
("- NEUTRAL when: between EMAs, or any major signal is conflicting β€” prefer NEUTRAL over a weak guess\n",
f"- NEUTRAL when: between EMAs, OR RSI 40-62, OR MACD near zero, OR FII flows mixed β€” STRONGLY prefer NEUTRAL [v{iteration}]\n"),
(f"- NEUTRAL when: between EMAs, OR RSI 40-62, OR MACD near zero, OR FII flows mixed β€” STRONGLY prefer NEUTRAL [v{iteration-1}]\n",
f"- NEUTRAL when: any ambiguity at all in EMA position, RSI direction, or macro regime β€” NEUTRAL is correct answer [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, " [FIX] 5D NEUTRAL threshold strengthened β€” more NEUTRAL calls"
return src, " [FIX] 5D NEUTRAL anchor not found β€” skipped"
def fix_raise_vix_threshold(src: str, iteration: int) -> tuple[str, str]:
"""Lower VIX bar for reducing BULLISH β€” now 18 instead of 20."""
pairs = [
("- When VIX > 20 or macro is risk-off: require 4+ signals for BULLISH\n",
f"- When VIX > 18 or macro is risk-off: require 4+ signals for BULLISH; prefer NEUTRAL [v{iteration}]\n"),
(f"- When VIX > 18 or macro is risk-off: require 4+ signals for BULLISH; prefer NEUTRAL [v{iteration-1}]\n",
f"- When VIX > 16 or macro is risk-off: prefer NEUTRAL; only BULLISH with 5+ clear signals [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, " [FIX] VIX BULLISH threshold lowered (18/16)"
return src, " [FIX] VIX threshold anchor not found β€” skipped"
def fix_increase_signal_count(src: str, iteration: int) -> tuple[str, str]:
"""Require more signals to align before calling directional."""
pairs = [
("- Require β‰₯3 of these to align before calling BULLISH or BEARISH:\n",
f"- Require β‰₯4 of these to align before calling BULLISH or BEARISH (β‰₯3 is not enough): [v{iteration}]\n"),
(f"- Require β‰₯4 of these to align before calling BULLISH or BEARISH (β‰₯3 is not enough): [v{iteration-1}]\n",
f"- Require β‰₯5 of these to clearly align before calling BULLISH or BEARISH: [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, " [FIX] Required signal alignment count raised to 4/5"
return src, " [FIX] signal count anchor not found β€” skipped"
def fix_strengthen_neutral_preference(src: str, iteration: int) -> tuple[str, str]:
"""Make NEUTRAL the strong default when signals conflict."""
pairs = [
("- In genuine signal conflict: always choose NEUTRAL over a low-conviction directional call\n",
f"- RULE: When in doubt, output NEUTRAL. A wrong directional call is worse than NEUTRAL. [v{iteration}]\n"),
(f"- RULE: When in doubt, output NEUTRAL. A wrong directional call is worse than NEUTRAL. [v{iteration-1}]\n",
f"- RULE: NEUTRAL is the safe default. Only override to directional when evidence is overwhelming and specific. [v{iteration}]\n"),
]
for old, new in pairs:
new_src, ok = _replace_in_synthesis(src, old, new)
if ok:
return new_src, " [FIX] NEUTRAL preference strengthened in synthesis prompt"
return src, " [FIX] NEUTRAL anchor not found β€” skipped"
# Fix sequence β€” ordered for target-hit accuracy improvement
# Each fix targets direction accuracy (the root cause of <90% target-hit)
FIX_SEQUENCE = [
("tighten_5d_direction", fix_tighten_5d_direction),
("tighten_3d_signal_count", fix_tighten_3d_signal_count),
("tighten_1d_bullish_rsi", fix_tighten_1d_bullish_rsi),
("tighten_1d_bearish_rsi", fix_tighten_1d_bearish_rsi),
("raise_vix_threshold", fix_raise_vix_threshold),
("increase_signal_count", fix_increase_signal_count),
("strengthen_neutral_preference", fix_strengthen_neutral_preference),
]
def choose_fix(res: dict, iteration: int) -> tuple[str, callable]:
"""Pick fix based on which timeframe and direction has worst target-hit accuracy."""
target_accs = [res[tf]["target_acc"] for tf in res if not np.isnan(res[tf].get("target_acc", float("nan")))]
avg_target = np.mean(target_accs) if target_accs else float("nan")
r1d = res.get("1D", {})
r3d = res.get("3D", {})
r5d = res.get("5D", {})
t1 = r1d.get("target_acc", float("nan"))
t3 = r3d.get("target_acc", float("nan"))
t5 = r5d.get("target_acc", float("nan"))
b1 = r1d.get("dir_acc", float("nan")) # direction accuracy 1D
b3 = r3d.get("dir_acc", float("nan")) # direction accuracy 3D
b5 = r5d.get("dir_acc", float("nan")) # direction accuracy 5D
print(f" Diagnosis: target_acc={avg_target:.1f}% 1D={t1:.1f}% (dir={b1:.1f}%) 3D={t3:.1f}% (dir={b3:.1f}%) 5D={t5:.1f}% (dir={b5:.1f}%)")
# Identify weakest TF by target accuracy
weakest = min(
[("1D", t1), ("3D", t3), ("5D", t5)],
key=lambda x: x[1] if not np.isnan(x[1]) else 999,
)[0]
# 5D is hardest β€” fix its direction criteria first
if weakest == "5D" and not np.isnan(t5) and t5 < TARGET:
return FIX_SEQUENCE[0] # tighten_5d_direction
if weakest == "3D" and not np.isnan(t3) and t3 < TARGET:
return FIX_SEQUENCE[1] # tighten_3d_signal_count
if weakest == "1D" and not np.isnan(t1) and t1 < TARGET:
# Sub-diagnose: is BULLISH or BEARISH worse for 1D?
bull1 = r1d.get("target_bull", float("nan"))
bear1 = r1d.get("target_bear", float("nan"))
if not np.isnan(bull1) and not np.isnan(bear1) and bull1 < bear1:
return FIX_SEQUENCE[2] # tighten_1d_bullish_rsi
return FIX_SEQUENCE[3] # tighten_1d_bearish_rsi
# If all TFs present but still below target β€” try global fixes
if not np.isnan(avg_target) and avg_target < TARGET - 10:
return FIX_SEQUENCE[4] # raise_vix_threshold
if not np.isnan(avg_target) and avg_target < TARGET - 5:
return FIX_SEQUENCE[5] # increase_signal_count
if not np.isnan(avg_target) and avg_target < TARGET:
return FIX_SEQUENCE[6] # strengthen_neutral_preference
return FIX_SEQUENCE[iteration % len(FIX_SEQUENCE)]
# ── MODEL STATUS ──────────────────────────────────────────────────────────────
def _model_status() -> str:
"""Return a one-line string showing which LLM providers are available."""
try:
import ai_forecast as _aif
or_ready = bool(os.environ.get("OPENROUTER_API_KEY", ""))
groq_ready = bool(os.environ.get("GROQ_API_KEY", ""))
hf_ready = bool(os.environ.get("HF_TOKEN", ""))
parts = []
if or_ready:
model = os.environ.get("OPENROUTER_BEST_FREE_MODEL", "openai/gpt-oss-120b:free")
parts.append(f"OpenRouter ({model}): ready")
if groq_ready:
parts.append("Groq: ready")
if hf_ready:
parts.append("HuggingFace: ready")
if not parts:
parts.append("No LLM provider configured (set OPENROUTER_API_KEY, GROQ_API_KEY, or HF_TOKEN)")
return " Models: " + " | ".join(parts)
except Exception as e:
return f" Models: (status check failed: {e})"
def _all_models_cooled_down() -> tuple[bool, int]:
"""Return (all_cooled, max_wait_seconds) β€” always False unless we can detect cooldowns."""
return False, 0
# ── MAIN LOOP ─────────────────────────────────────────────────────────────────
def main():
import signal
_stop = [False]
def _sig_handler(sig, frame):
print("\n\n [STOP] Ctrl+C received β€” finishing current iteration then exiting...")
_stop[0] = True
signal.signal(signal.SIGINT, _sig_handler)
# Optional: --reset to clear CSV and start fresh
if "--reset" in sys.argv:
if os.path.exists(CSV_PATH):
os.remove(CSV_PATH)
print(" CSV cleared β€” starting fresh")
print("=" * 70)
print(" CONTINUOUS AI PREDICTION OPTIMIZATION LOOP")
print(" Target: Target price range accuracy >=90% on 1D, 3D, 5D")
print(" AI-only mode: no strategy signals (matched_strategy=null)")
print(" Model chain: gpt-4.1-mini -> gpt-4o -> gpt-4o-mini (auto-failover)")
print(" Press Ctrl+C to stop gracefully after current iteration")
print("=" * 70)
iteration = 0
backup_iteration = next_backup_iteration(CSV_PATH)
if backup_iteration > 0:
print(f" Resuming backup numbering from ai_prompt_accuracy_iter{backup_iteration}.csv")
while not _stop[0]:
iteration += 1
# If all GitHub models are rate-limited, continue running so OpenRouter
# (if configured) can still provide LLM rows while cooldowns recover.
all_cooled, max_wait = _all_models_cooled_down()
if all_cooled:
print(f"\n [COOLDOWN] All models are rate-limited ({max_wait}s remaining).")
print(" Continuing with other LLM providers (if configured) so the loop stays live.")
print(_model_status())
print(f"\n{'─'*70}")
print(f" ITERATION {iteration}")
print(_model_status())
print(f"{'─'*70}")
# Back up CSV from previous run
if os.path.exists(CSV_PATH):
backup = CSV_PATH.replace(".csv", f"_iter{backup_iteration}.csv")
shutil.copy(CSV_PATH, backup)
print(f" Backed up previous results -> {os.path.basename(backup)}")
backup_iteration += 1
# Rolling 18-month training window: optimize on recent data only, never touch hold-out.
# holdout_dt is 2025-01-01; rolling window ends 2024-12-31, starts 18 months earlier.
from datetime import datetime as _dt, timedelta as _td
_holdout_dt = _dt.strptime(HOLDOUT_START, "%Y-%m-%d")
_train_end_dt = _holdout_dt - _td(days=1) # 2024-12-31
_rolling_days = 18 * 30 # ~18 months
_rolling_start_dt = _train_end_dt - _td(days=_rolling_days)
_rolling_start = _rolling_start_dt.strftime("%Y-%m-%d")
_rolling_end = _train_end_dt.strftime("%Y-%m-%d")
print(f" Rolling window: {_rolling_start} β†’ {_rolling_end} (18-month training set, hold-out locked)")
# Run backtest β€” explicit file handles so subprocess doesn't inherit
# nohup's broken fds (avoids "Bad file descriptor" crash on macOS)
print(f"\n Running backtest (fresh historical data + prompt-only calibration)...")
t0 = time.time()
bt_log = "/tmp/backtest_live.log"
bt_err = "/tmp/backtest_err.log"
with open(bt_log, "w") as bt_out_f, open(bt_err, "w") as bt_err_f:
proc = subprocess.run(
[
sys.executable,
os.path.join(os.path.dirname(__file__), "backtest.py"),
"--start", _rolling_start,
"--end", _rolling_end,
],
cwd=os.path.dirname(__file__) + "/..",
stdin=subprocess.DEVNULL,
stdout=bt_out_f,
stderr=bt_err_f,
)
elapsed = time.time() - t0
# Stream backtest output to our log
try:
with open(bt_log) as f:
for line in f:
print(line, end="")
except Exception:
pass
print(f"\n Backtest finished in {elapsed/60:.1f} min (exit code {proc.returncode})")
# Detect rate-limit failure
if proc.returncode != 0:
all_cooled, max_wait = _all_models_cooled_down()
if all_cooled:
print(f" [RATE LIMIT] Backtest failed due to model rate limits. "
f"Will retry after cooldown.")
iteration -= 1
continue
# Analyze
res = analyze(CSV_PATH)
if not res:
print(" [WARN] Analysis returned no results (all non-LLM or missing columns) β€” skipping prompt fix, waiting for models")
if not _stop[0]:
print(f" Pausing 120s for model rate limits to recover...")
for _ in range(24):
if _stop[0]: break
time.sleep(5)
continue
print_summary(res, iteration)
# Check target
if target_met(res):
print(" *** TARGET MET -- target price range accuracy >=90% on 1D, 3D, 5D ***")
subprocess.run([sys.executable,
os.path.join(os.path.dirname(__file__), "backtest.py"),
"--print-only"])
break
if _stop[0]:
print(" [STOP] Stopped by user.")
break
# Pick and apply fix
fix_name, fix_fn = choose_fix(res, iteration)
print(f"\n Applying fix: {fix_name}")
src = read_forecast(FORECAST_PY)
new_src, msg = fix_fn(src, iteration)
if new_src == src:
print(f" {msg} -- trying next fix in sequence")
idx = next((i for i, f in enumerate(FIX_SEQUENCE) if f[0] == fix_name), 0)
fix_name2, fix_fn2 = FIX_SEQUENCE[(idx + 1) % len(FIX_SEQUENCE)]
new_src, msg2 = fix_fn2(src, iteration)
msg = msg2
write_forecast(FORECAST_PY, new_src)
print(msg)
# Short pause between iterations
if not _stop[0]:
print(f"\n Pausing 30s before next iteration...")
for _ in range(6):
if _stop[0]:
break
time.sleep(5)
print("\n Loop complete.")
if __name__ == "__main__":
main()