Khanna, Videh Rakesh Rakesh Claude Sonnet 4.6 commited on
Commit
ecf6e61
Β·
1 Parent(s): 744ba9d

fix: realistic AI ranges, Ollama cold-start warmup, price-target prompt

Browse files

Backtest & prompt:
- Decouple tight_test from _fast_mode so backtest tests AI's own ranges
- Prompt now asks for target_price_lo/hi as β‚Ή absolute prices instead of
percentages β€” eliminates float/%-string ambiguity across all model sizes
- Remove hardcoded % range hints; LLM derives targets from ATR + indicators
- Add _extract_price_targets() helper: tries β‚Ή prices first, falls back to
predicted_return_lo/hi; handles bare float, '+2.0%', 'β‚Ή485.50', etc.
- Fix bare float() calls β†’ _safe_float() in debate synthesis parse path
- Relax _JSON_REQUIRED: accept target_price_lo/hi OR predicted_return_lo/hi
- Add research/backtest_watchlist.py: backtest + live spot-check on watchlist

Ollama reliability:
- Add warmup_ollama() in ollama_client.py: 1-token ping (30s) forces model
load before real inference, catching cold-starts before they hang
- On fresh health check (Space just woke), run warmup first; if warmup times
out β†’ set 5-min backoff immediately instead of burning a 45s real call
- Reduce chat timeout 90s β†’ 45s (_OLLAMA_CHAT_TIMEOUT)
- Add _OLLAMA_INFER_BACKOFF_UNTIL: 5-min backoff after any inference failure,
preventing cascading 45s hangs (was 3 Γ— 90s = 270s worst case)
- Fix UnboundLocalError: _OLLAMA_HEALTH_LAST_CHECK missing global declaration
in fast_fail_on_rate_limit block
- Add _start_ollama_keepalive() in app.py: daemon thread pings Ollama every
10 min to keep HF Space warm during active trading hours

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

ai_forecast.py CHANGED
@@ -321,9 +321,11 @@ def _cache_ttl_for_tf(tf_label: str) -> int:
321
 
322
 
323
 
324
- _JSON_REQUIRED = {"direction", "confidence", "predicted_return_lo", "predicted_return_hi"}
325
- # "reasoning" is intentionally omitted β€” small Ollama models (1B params) frequently
326
- # truncate JSON before closing the reasoning string; rejecting those wastes usable data.
 
 
327
 
328
  # ============================================================================
329
  # CALIBRATED RANGE TABLE (data-verified on NSE 2018-2025, N=828)
@@ -542,28 +544,68 @@ def _parse_json_from_llm(text: str) -> Dict | None:
542
  pass
543
  if parsed is None:
544
  return None
545
- # Reject if any required forecast field is missing β€” prevents 0.0 default corruption
546
  missing = _JSON_REQUIRED - parsed.keys()
547
  if missing:
548
  logger.warning("LLM JSON missing required fields %s β€” rejecting partial parse", missing)
549
  return None
 
 
 
 
550
  return parsed
551
 
552
 
553
  def _safe_float(val, default: float = 0.0) -> float:
554
- """Convert val to float, stripping trailing non-numeric chars from small-model output.
555
- Examples: '3.5-' β†’ 3.5, '+2.0%' β†’ 2.0, None β†’ default."""
556
  if val is None:
557
  return default
558
  if isinstance(val, (int, float)):
559
  return float(val)
560
- s = str(val).strip().rstrip("-%+").lstrip("+")
561
  try:
562
  return float(s)
563
  except (ValueError, TypeError):
564
  return default
565
 
566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  # ============================================================================
568
  # INDICATOR NORMALIZATION
569
  # ============================================================================
@@ -1027,29 +1069,26 @@ def _build_synthesis_prompt(
1027
  f"{signal_rules}\n"
1028
  f"ATR(14): β‚Ή{atr14:.2f} Current price: β‚Ή{current_price:.2f} "
1029
  f"Hard cap: Β±{_cap_pct}% for {holding} horizon.\n\n"
1030
- f"PRICE TARGET GUIDANCE β€” your range is used DIRECTLY in production, not overridden:\n"
1031
- f"Set predicted_return_hi to the REALISTIC MAXIMUM the stock can reach in this timeframe.\n"
1032
- f"Base it on: ATR(14)=β‚Ή{atr14:.2f}, momentum, volume, news, and nearby resistance levels.\n"
1033
- f"{'INTRADAY: typical NSE intraday swing is 0.5–2.0%. BULLISH hi=0.5–2.0%, lo=0.2–0.8%. BEARISH hi=βˆ’0.3%, lo=βˆ’1.5%.' if tf_label == 'INTRADAY' else ''}"
1034
- f"{'1D: typical NSE 1-day move is 1–4%. BULLISH hi=1.5–4.0%, lo=0.5–1.5%. BEARISH hi=βˆ’0.5%, lo=βˆ’3.0%.' if tf_label == '1D' else ''}"
1035
- f"{'3D: typical NSE 3-day move is 2–8%. BULLISH hi=2.0–7.0%, lo=0.8–2.5%. BEARISH hi=βˆ’0.8%, lo=βˆ’5.0%.' if tf_label == '3D' else ''}"
1036
- f"{'5D: typical NSE 5-day move is 3–12%. BULLISH hi=3.0–10.0%, lo=1.0–3.5%. BEARISH hi=βˆ’1.0%, lo=βˆ’8.0%.' if tf_label == '5D' else ''}"
1037
- f" Scale within these bands by conviction: HIGH confidence β†’ upper half, LOW β†’ lower half.\n\n"
1038
  f"should_buy = true if: direction is BULLISH or BEARISH AND risk/reward β‰₯ 1.5Γ— AND no major red flags.\n"
1039
  f"entry_price = recommended β‚Ή entry (current price for market order; slightly below if a pullback entry is better).\n\n"
1040
  f"START YOUR RESPONSE WITH `{{` β€” output ONLY a valid JSON object, zero preamble:\n"
1041
  f'{{"direction": "BULLISH"|"BEARISH"|"NEUTRAL", '
1042
  f'"should_buy": true|false, '
1043
  f'"confidence": "HIGH"|"MEDIUM"|"LOW", '
1044
- f'"entry_price": <β‚Ή recommended entry>, '
1045
- f'"predicted_return_lo": <conservative % β€” positive for bull, negative for bear>, '
1046
- f'"predicted_return_hi": <realistic max % for this timeframe β€” positive for bull, negative for bear>, '
1047
  f'"reasoning": "<2-3 sentences: cite the 3 key signals + what price target means>"}}\n\n'
1048
  f"JSON rules:\n"
1049
- f"- BULLISH: lo > 0, hi > lo\n"
1050
- f"- BEARISH: lo < hi < 0\n"
1051
- f"- NEUTRAL: lo < 0 < hi, should_buy = false\n"
1052
- f"- Absolute values must not exceed {_cap_pct}%\n"
 
1053
  )
1054
 
1055
 
@@ -1169,7 +1208,7 @@ def get_ai_forecast(
1169
  logger.debug("AI forecast cache hit for %s %s", ticker, tf_label)
1170
  return _cached["result"]
1171
 
1172
- tight_test = bool(_fast_mode or kwargs.get("_tight_test_ranges", False))
1173
  vol_pctile = _volatility_percentile(ohlcv_df, tf_label)
1174
  move_anchor = _realized_move_anchor(ohlcv_df, tf_label, vol_pctile)
1175
  atr14 = float(indicators.get("atr14") or move_anchor * (current_price / 100) or 10.0)
@@ -1258,8 +1297,7 @@ def get_ai_forecast(
1258
  reasoning = str(parsed.get("reasoning", ""))
1259
  should_buy = bool(parsed.get("should_buy", direction in ("BULLISH", "BEARISH")))
1260
  ai_entry_price = _safe_float(parsed.get("entry_price"), None)
1261
- ret_lo = _safe_float(parsed.get("predicted_return_lo"), 0.0)
1262
- ret_hi = _safe_float(parsed.get("predicted_return_hi"), 0.0)
1263
 
1264
  # Guard: normalize invalid direction/confidence enum values
1265
  if direction not in ("BULLISH", "BEARISH", "NEUTRAL"):
@@ -1374,8 +1412,7 @@ def get_ai_forecast(
1374
  direction = str(parsed.get("direction", "NEUTRAL")).upper()
1375
  confidence = str(parsed.get("confidence", "MEDIUM")).upper()
1376
  reasoning = str(parsed.get("reasoning", ""))
1377
- ret_lo = _safe_float(parsed.get("predicted_return_lo"), 0.0)
1378
- ret_hi = _safe_float(parsed.get("predicted_return_hi"), 0.0)
1379
  if direction not in ("BULLISH", "BEARISH", "NEUTRAL"):
1380
  direction = "NEUTRAL"
1381
  if confidence not in ("HIGH", "MEDIUM", "LOW"):
@@ -1495,8 +1532,7 @@ def get_ai_forecast(
1495
  reasoning = str(parsed.get("reasoning", ""))
1496
  should_buy = bool(parsed.get("should_buy", direction in ("BULLISH", "BEARISH")))
1497
  ai_entry_price = _safe_float(parsed.get("entry_price"), None)
1498
- ret_lo = float(parsed.get("predicted_return_lo", 0.0))
1499
- ret_hi = float(parsed.get("predicted_return_hi", 0.0))
1500
 
1501
  # Guard: normalize invalid direction/confidence enum values
1502
  if direction not in ("BULLISH", "BEARISH", "NEUTRAL"):
 
321
 
322
 
323
 
324
+ _JSON_REQUIRED = {"direction", "confidence"}
325
+ _JSON_PRICE_FIELDS = frozenset({"target_price_lo", "target_price_hi", "predicted_return_lo", "predicted_return_hi"})
326
+ # Prompt now asks for target_price_lo/hi (β‚Ή); older/smaller models may still return
327
+ # predicted_return_lo/hi (%). We require at least one pair β€” validated in _extract_price_targets.
328
+ # "reasoning" intentionally omitted β€” small Ollama models frequently truncate before closing it.
329
 
330
  # ============================================================================
331
  # CALIBRATED RANGE TABLE (data-verified on NSE 2018-2025, N=828)
 
544
  pass
545
  if parsed is None:
546
  return None
547
+ # Reject if direction/confidence missing β€” prevents 0.0 default corruption
548
  missing = _JSON_REQUIRED - parsed.keys()
549
  if missing:
550
  logger.warning("LLM JSON missing required fields %s β€” rejecting partial parse", missing)
551
  return None
552
+ # Must have at least one price pair (target_price or predicted_return)
553
+ if not (_JSON_PRICE_FIELDS & parsed.keys()):
554
+ logger.warning("LLM JSON missing all price/return fields β€” rejecting")
555
+ return None
556
  return parsed
557
 
558
 
559
  def _safe_float(val, default: float = 0.0) -> float:
560
+ """Convert val to float. Handles bare numbers, '+2.0', '-0.6%', '3.5-', None β†’ default."""
 
561
  if val is None:
562
  return default
563
  if isinstance(val, (int, float)):
564
  return float(val)
565
+ s = str(val).strip().lstrip("β‚Ή$").replace(",", "").rstrip("-%+").lstrip("+")
566
  try:
567
  return float(s)
568
  except (ValueError, TypeError):
569
  return default
570
 
571
 
572
+ def _extract_price_targets(
573
+ parsed: dict, current_price: float
574
+ ) -> tuple:
575
+ """
576
+ Return (ret_lo, ret_hi) as plain percentage returns from LLM parsed dict.
577
+
578
+ Priority:
579
+ 1. target_price_lo/hi (β‚Ή absolute) β€” models reason better in prices, no % ambiguity
580
+ 2. predicted_return_lo/hi (%, possibly with % sign or bare float)
581
+ Returns (0.0, 0.0) when nothing valid is found; caller falls back to heuristic.
582
+ """
583
+ def _to_price(v):
584
+ if v is None:
585
+ return None
586
+ if isinstance(v, (int, float)):
587
+ return float(v) if float(v) > 0 else None
588
+ s = str(v).strip().lstrip("β‚Ή$").replace(",", "")
589
+ try:
590
+ f = float(s)
591
+ return f if f > 0 else None
592
+ except (ValueError, TypeError):
593
+ return None
594
+
595
+ tp_lo = _to_price(parsed.get("target_price_lo"))
596
+ tp_hi = _to_price(parsed.get("target_price_hi"))
597
+ if tp_lo is not None and tp_hi is not None and current_price > 0:
598
+ return (
599
+ round((tp_lo / current_price - 1) * 100, 3),
600
+ round((tp_hi / current_price - 1) * 100, 3),
601
+ )
602
+
603
+ return (
604
+ _safe_float(parsed.get("predicted_return_lo"), 0.0),
605
+ _safe_float(parsed.get("predicted_return_hi"), 0.0),
606
+ )
607
+
608
+
609
  # ============================================================================
610
  # INDICATOR NORMALIZATION
611
  # ============================================================================
 
1069
  f"{signal_rules}\n"
1070
  f"ATR(14): β‚Ή{atr14:.2f} Current price: β‚Ή{current_price:.2f} "
1071
  f"Hard cap: Β±{_cap_pct}% for {holding} horizon.\n\n"
1072
+ f"PRICE TARGET GUIDANCE:\n"
1073
+ f"Set target_price_lo/hi based on the indicators above β€” ATR(14)=β‚Ή{atr14:.2f} is your primary"
1074
+ f" volatility anchor. Use resistance/support levels, Bollinger Bands, and momentum to set realistic"
1075
+ f" bounds. Do NOT use generic %-range assumptions; derive targets from THIS stock's actual data.\n\n"
 
 
 
 
1076
  f"should_buy = true if: direction is BULLISH or BEARISH AND risk/reward β‰₯ 1.5Γ— AND no major red flags.\n"
1077
  f"entry_price = recommended β‚Ή entry (current price for market order; slightly below if a pullback entry is better).\n\n"
1078
  f"START YOUR RESPONSE WITH `{{` β€” output ONLY a valid JSON object, zero preamble:\n"
1079
  f'{{"direction": "BULLISH"|"BEARISH"|"NEUTRAL", '
1080
  f'"should_buy": true|false, '
1081
  f'"confidence": "HIGH"|"MEDIUM"|"LOW", '
1082
+ f'"entry_price": <β‚Ή recommended entry β€” plain number, e.g. {current_price:.2f}>, '
1083
+ f'"target_price_lo": <conservative β‚Ή price target β€” plain number, e.g. {current_price * 1.01:.2f}>, '
1084
+ f'"target_price_hi": <optimistic β‚Ή price target β€” plain number, e.g. {current_price * 1.03:.2f}>, '
1085
  f'"reasoning": "<2-3 sentences: cite the 3 key signals + what price target means>"}}\n\n'
1086
  f"JSON rules:\n"
1087
+ f"- BULLISH: target_price_lo > entry_price, target_price_hi > target_price_lo\n"
1088
+ f"- BEARISH: target_price_hi < entry_price, target_price_lo < target_price_hi\n"
1089
+ f"- NEUTRAL: target_price_lo < entry_price < target_price_hi\n"
1090
+ f"- All prices must be bare numbers (no β‚Ή symbol, no commas)\n"
1091
+ f"- Price range must not exceed Β±{_cap_pct}% from current price β‚Ή{current_price:.2f}\n"
1092
  )
1093
 
1094
 
 
1208
  logger.debug("AI forecast cache hit for %s %s", ticker, tf_label)
1209
  return _cached["result"]
1210
 
1211
+ tight_test = bool(kwargs.get("_tight_test_ranges", False))
1212
  vol_pctile = _volatility_percentile(ohlcv_df, tf_label)
1213
  move_anchor = _realized_move_anchor(ohlcv_df, tf_label, vol_pctile)
1214
  atr14 = float(indicators.get("atr14") or move_anchor * (current_price / 100) or 10.0)
 
1297
  reasoning = str(parsed.get("reasoning", ""))
1298
  should_buy = bool(parsed.get("should_buy", direction in ("BULLISH", "BEARISH")))
1299
  ai_entry_price = _safe_float(parsed.get("entry_price"), None)
1300
+ ret_lo, ret_hi = _extract_price_targets(parsed, current_price)
 
1301
 
1302
  # Guard: normalize invalid direction/confidence enum values
1303
  if direction not in ("BULLISH", "BEARISH", "NEUTRAL"):
 
1412
  direction = str(parsed.get("direction", "NEUTRAL")).upper()
1413
  confidence = str(parsed.get("confidence", "MEDIUM")).upper()
1414
  reasoning = str(parsed.get("reasoning", ""))
1415
+ ret_lo, ret_hi = _extract_price_targets(parsed, current_price)
 
1416
  if direction not in ("BULLISH", "BEARISH", "NEUTRAL"):
1417
  direction = "NEUTRAL"
1418
  if confidence not in ("HIGH", "MEDIUM", "LOW"):
 
1532
  reasoning = str(parsed.get("reasoning", ""))
1533
  should_buy = bool(parsed.get("should_buy", direction in ("BULLISH", "BEARISH")))
1534
  ai_entry_price = _safe_float(parsed.get("entry_price"), None)
1535
+ ret_lo, ret_hi = _extract_price_targets(parsed, current_price)
 
1536
 
1537
  # Guard: normalize invalid direction/confidence enum values
1538
  if direction not in ("BULLISH", "BEARISH", "NEUTRAL"):
app.py CHANGED
@@ -3649,12 +3649,41 @@ def _start_intraday_refresh_scheduler():
3649
  threading.Thread(target=_run, daemon=True, name="intraday-refresh").start()
3650
 
3651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3652
  # Start background services at import time so both `python app.py` and
3653
  # WSGI servers (gunicorn) warm the top5 cache and run the trade monitor.
3654
  _prewarm_top5()
3655
  _start_trade_monitor()
3656
  _start_validation_scheduler()
3657
  _start_intraday_refresh_scheduler()
 
3658
 
3659
  if __name__ == "__main__":
3660
  port = int(os.environ.get("PORT", 7860))
 
3649
  threading.Thread(target=_run, daemon=True, name="intraday-refresh").start()
3650
 
3651
 
3652
+ def _start_ollama_keepalive():
3653
+ """Ping Ollama every 10 min to prevent HF Space cold-start hangs during inference."""
3654
+ _ep = os.environ.get("OLLAMA_ENDPOINT", "").strip()
3655
+ if not _ep:
3656
+ return
3657
+
3658
+ import threading, time as _time
3659
+ _INTERVAL = 600 # 10 minutes β€” HF free tier sleeps after ~15 min
3660
+
3661
+ def _run():
3662
+ _time.sleep(30) # give app startup a moment first
3663
+ while True:
3664
+ try:
3665
+ from ollama_client import warmup_ollama, get_ollama_model
3666
+ _m = get_ollama_model(_ep)
3667
+ _ok = warmup_ollama(_ep, model=_m, timeout=30)
3668
+ if _ok:
3669
+ app.logger.debug("Ollama keepalive ping succeeded (%s)", _m)
3670
+ else:
3671
+ app.logger.info("Ollama keepalive: Space not responding (cold/busy) β€” will retry in %ds", _INTERVAL)
3672
+ except Exception as _e:
3673
+ app.logger.debug("Ollama keepalive error: %s", _e)
3674
+ _time.sleep(_INTERVAL)
3675
+
3676
+ threading.Thread(target=_run, daemon=True, name="ollama-keepalive").start()
3677
+ app.logger.info("Ollama keepalive started (interval=%ds, endpoint=%s)", _INTERVAL, _ep)
3678
+
3679
+
3680
  # Start background services at import time so both `python app.py` and
3681
  # WSGI servers (gunicorn) warm the top5 cache and run the trade monitor.
3682
  _prewarm_top5()
3683
  _start_trade_monitor()
3684
  _start_validation_scheduler()
3685
  _start_intraday_refresh_scheduler()
3686
+ _start_ollama_keepalive()
3687
 
3688
  if __name__ == "__main__":
3689
  port = int(os.environ.get("PORT", 7860))
llm_client.py CHANGED
@@ -82,6 +82,12 @@ _OLLAMA_HEALTH_LAST_CHECK: float = 0.0
82
  _OLLAMA_HEALTH_RESULT: bool = False
83
  _OLLAMA_HEALTH_TTL: int = 60
84
 
 
 
 
 
 
 
85
  # Monotonically increasing debate counter β€” kept for backward compat with racing calls.
86
  _AI_TASK_COUNTER: int = 0
87
 
@@ -437,11 +443,16 @@ def make_chat_call(
437
  _ep = os.environ.get("OLLAMA_ENDPOINT", "").strip()
438
  if not _ep:
439
  return None
440
- global _OLLAMA_HEALTH_LAST_CHECK, _OLLAMA_HEALTH_RESULT
441
  _now_h = time.time()
 
442
  with _LLM_LOCK:
 
 
 
443
  _cv = (_now_h - _OLLAMA_HEALTH_LAST_CHECK) < _OLLAMA_HEALTH_TTL
444
  _ch = _OLLAMA_HEALTH_RESULT if _cv else None
 
445
  if _ch is None:
446
  _ch = check_ollama_health(_ep, timeout=8)
447
  with _LLM_LOCK:
@@ -450,11 +461,28 @@ def make_chat_call(
450
  if not _ch:
451
  return None
452
  _m = get_ollama_model(_ep)
 
 
 
 
 
 
 
 
 
 
 
 
453
  with _OLLAMA_SEMAPHORE:
454
- _r = ollama_chat(messages, endpoint=_ep, model=_m, timeout=90)
455
  if _r:
456
  logger.info("LLM: Ollama succeeded")
457
  return _r, "ollama", _m
 
 
 
 
 
458
  except Exception as _e:
459
  logger.debug("Ollama call failed: %s", _e)
460
  return None
@@ -514,30 +542,52 @@ def make_chat_call(
514
 
515
  if fast_fail_on_rate_limit:
516
  # Cloud providers all failed. Ollama has no rate limits β€” always try it last.
517
- # Use the shared health cache so a 150-stock scan doesn't burn 25s per stock.
 
518
  _ep_check = os.environ.get("OLLAMA_ENDPOINT", "").strip()
519
  if _ep_check:
520
  from ollama_client import ollama_chat, get_ollama_model, check_ollama_health
521
- # Check cached health result before doing a fresh 25s probe
522
  with _LLM_LOCK:
523
  _now_lr = time.time()
524
- _cached_valid = (_now_lr - _OLLAMA_HEALTH_LAST_CHECK) < _OLLAMA_HEALTH_TTL
525
- _ch = _OLLAMA_HEALTH_RESULT if _cached_valid else None
526
- if _ch is None:
527
- # No recent result β€” do the longer probe (HF Space may be cold-starting)
528
- _ch = check_ollama_health(_ep_check, timeout=25)
529
- with _LLM_LOCK:
530
- _OLLAMA_HEALTH_LAST_CHECK = time.time()
531
- _OLLAMA_HEALTH_RESULT = _ch # cache both True and False
532
- if _ch:
533
- _m = get_ollama_model(_ep_check)
534
- with _OLLAMA_SEMAPHORE:
535
- _r = ollama_chat(messages, endpoint=_ep_check, model=_m, timeout=90)
536
- if _r:
537
- logger.info("LLM: Ollama (last-resort fast_fail path) succeeded")
538
- return _r, "ollama", _m
539
- else:
540
- logger.debug("LLM: Ollama last-resort skipped (cached unhealthy)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  raise RuntimeError("All LLM providers unavailable β€” all rate-limited or unconfigured")
542
 
543
  # ── Retry loop ────────────────────────────────────────────────────────────
 
82
  _OLLAMA_HEALTH_RESULT: bool = False
83
  _OLLAMA_HEALTH_TTL: int = 60
84
 
85
+ # After a chat timeout or inference failure, back off for this many seconds before retrying.
86
+ # Prevents cascading 45s timeouts when the HF Space is unresponsive.
87
+ _OLLAMA_INFER_BACKOFF_UNTIL: float = 0.0
88
+ _OLLAMA_INFER_BACKOFF_SECS: int = 300 # 5 minutes
89
+ _OLLAMA_CHAT_TIMEOUT: int = 45 # was 90s; Ollama either responds in <10s or hangs
90
+
91
  # Monotonically increasing debate counter β€” kept for backward compat with racing calls.
92
  _AI_TASK_COUNTER: int = 0
93
 
 
443
  _ep = os.environ.get("OLLAMA_ENDPOINT", "").strip()
444
  if not _ep:
445
  return None
446
+ global _OLLAMA_HEALTH_LAST_CHECK, _OLLAMA_HEALTH_RESULT, _OLLAMA_INFER_BACKOFF_UNTIL
447
  _now_h = time.time()
448
+ # Skip entirely if a recent inference timed out β€” don't burn another 45s
449
  with _LLM_LOCK:
450
+ if _now_h < _OLLAMA_INFER_BACKOFF_UNTIL:
451
+ logger.debug("Ollama skipped β€” inference backoff active for %.0fs", _OLLAMA_INFER_BACKOFF_UNTIL - _now_h)
452
+ return None
453
  _cv = (_now_h - _OLLAMA_HEALTH_LAST_CHECK) < _OLLAMA_HEALTH_TTL
454
  _ch = _OLLAMA_HEALTH_RESULT if _cv else None
455
+ _fresh_check = _ch is None
456
  if _ch is None:
457
  _ch = check_ollama_health(_ep, timeout=8)
458
  with _LLM_LOCK:
 
461
  if not _ch:
462
  return None
463
  _m = get_ollama_model(_ep)
464
+ # Fresh health check means Space just woke from sleep β€” warmup before real call
465
+ # so the model is loaded and the real inference doesn't hang.
466
+ if _fresh_check:
467
+ from ollama_client import warmup_ollama
468
+ _warm = warmup_ollama(_ep, model=_m, timeout=30)
469
+ if not _warm:
470
+ with _LLM_LOCK:
471
+ _OLLAMA_INFER_BACKOFF_UNTIL = time.time() + _OLLAMA_INFER_BACKOFF_SECS
472
+ _OLLAMA_HEALTH_RESULT = False
473
+ logger.warning("Ollama warmup failed β€” model still loading, backing off %ds", _OLLAMA_INFER_BACKOFF_SECS)
474
+ return None
475
+ logger.info("Ollama warmup succeeded β€” model warm, proceeding with inference")
476
  with _OLLAMA_SEMAPHORE:
477
+ _r = ollama_chat(messages, endpoint=_ep, model=_m, timeout=_OLLAMA_CHAT_TIMEOUT)
478
  if _r:
479
  logger.info("LLM: Ollama succeeded")
480
  return _r, "ollama", _m
481
+ # Inference returned None (timeout or empty) β€” set backoff so we don't retry immediately
482
+ with _LLM_LOCK:
483
+ _OLLAMA_INFER_BACKOFF_UNTIL = time.time() + _OLLAMA_INFER_BACKOFF_SECS
484
+ _OLLAMA_HEALTH_RESULT = False # also invalidate health so fast path re-checks later
485
+ logger.warning("Ollama inference failed β€” backing off for %ds", _OLLAMA_INFER_BACKOFF_SECS)
486
  except Exception as _e:
487
  logger.debug("Ollama call failed: %s", _e)
488
  return None
 
542
 
543
  if fast_fail_on_rate_limit:
544
  # Cloud providers all failed. Ollama has no rate limits β€” always try it last.
545
+ # Use the shared health cache so a 150-stock scan doesn't burn _OLLAMA_CHAT_TIMEOUT per stock.
546
+ global _OLLAMA_HEALTH_LAST_CHECK, _OLLAMA_HEALTH_RESULT, _OLLAMA_INFER_BACKOFF_UNTIL
547
  _ep_check = os.environ.get("OLLAMA_ENDPOINT", "").strip()
548
  if _ep_check:
549
  from ollama_client import ollama_chat, get_ollama_model, check_ollama_health
 
550
  with _LLM_LOCK:
551
  _now_lr = time.time()
552
+ # Skip if a recent inference timed out
553
+ if _now_lr < _OLLAMA_INFER_BACKOFF_UNTIL:
554
+ logger.debug("Ollama last-resort skipped β€” backoff active for %.0fs", _OLLAMA_INFER_BACKOFF_UNTIL - _now_lr)
555
+ else:
556
+ _cached_valid = (_now_lr - _OLLAMA_HEALTH_LAST_CHECK) < _OLLAMA_HEALTH_TTL
557
+ _ch = _OLLAMA_HEALTH_RESULT if _cached_valid else None
558
+ _now_lr = None # signal: proceed
559
+ if _now_lr is None: # not in backoff
560
+ _fresh_lr = _ch is None
561
+ if _ch is None:
562
+ # No recent result β€” do the longer probe (HF Space may be cold-starting)
563
+ _ch = check_ollama_health(_ep_check, timeout=25)
564
+ with _LLM_LOCK:
565
+ _OLLAMA_HEALTH_LAST_CHECK = time.time()
566
+ _OLLAMA_HEALTH_RESULT = _ch
567
+ if _ch:
568
+ _m = get_ollama_model(_ep_check)
569
+ # Warmup on fresh health check β€” prevents 45s hang on cold-start model loading
570
+ if _fresh_lr:
571
+ from ollama_client import warmup_ollama
572
+ if not warmup_ollama(_ep_check, model=_m, timeout=30):
573
+ with _LLM_LOCK:
574
+ _OLLAMA_INFER_BACKOFF_UNTIL = time.time() + _OLLAMA_INFER_BACKOFF_SECS
575
+ _OLLAMA_HEALTH_RESULT = False
576
+ logger.warning("Ollama last-resort warmup failed β€” backing off %ds", _OLLAMA_INFER_BACKOFF_SECS)
577
+ raise RuntimeError("All LLM providers unavailable β€” Ollama cold-start backoff")
578
+ logger.info("Ollama last-resort warmup succeeded")
579
+ with _OLLAMA_SEMAPHORE:
580
+ _r = ollama_chat(messages, endpoint=_ep_check, model=_m, timeout=_OLLAMA_CHAT_TIMEOUT)
581
+ if _r:
582
+ logger.info("LLM: Ollama (last-resort fast_fail path) succeeded")
583
+ return _r, "ollama", _m
584
+ # Inference failed β€” backoff so next call doesn't wait again
585
+ with _LLM_LOCK:
586
+ _OLLAMA_INFER_BACKOFF_UNTIL = time.time() + _OLLAMA_INFER_BACKOFF_SECS
587
+ _OLLAMA_HEALTH_RESULT = False
588
+ logger.warning("Ollama last-resort inference failed β€” backing off %ds", _OLLAMA_INFER_BACKOFF_SECS)
589
+ else:
590
+ logger.debug("LLM: Ollama last-resort skipped (cached unhealthy)")
591
  raise RuntimeError("All LLM providers unavailable β€” all rate-limited or unconfigured")
592
 
593
  # ── Retry loop ────────────────────────────────────────────────────────────
ollama_client.py CHANGED
@@ -80,6 +80,33 @@ def ollama_chat(
80
  return None
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def ollama_generate(
84
  prompt: str,
85
  endpoint: str,
 
80
  return None
81
 
82
 
83
+ def warmup_ollama(endpoint: str, model: Optional[str] = None, timeout: int = 30) -> bool:
84
+ """Send a 1-token inference call to force model loading before a real call.
85
+ Returns True if model responded (warm), False if cold-start timed out.
86
+ Use after a fresh health check to avoid hanging 45s on the real inference."""
87
+ if model is None:
88
+ model = get_ollama_model(endpoint)
89
+ try:
90
+ payload = {
91
+ "model": model,
92
+ "messages": [{"role": "user", "content": "Hi"}],
93
+ "stream": False,
94
+ "options": {"num_predict": 1},
95
+ }
96
+ resp = requests.post(f"{endpoint}/api/chat", json=payload, timeout=timeout)
97
+ if resp.status_code == 200:
98
+ logger.debug("Ollama warmup (%s) succeeded", model)
99
+ return True
100
+ logger.debug("Ollama warmup (%s) status %d", model, resp.status_code)
101
+ return False
102
+ except requests.exceptions.Timeout:
103
+ logger.warning("Ollama warmup (%s) timed out after %ds β€” model still cold", model, timeout)
104
+ return False
105
+ except Exception as e:
106
+ logger.debug("Ollama warmup failed: %s", e)
107
+ return False
108
+
109
+
110
  def ollama_generate(
111
  prompt: str,
112
  endpoint: str,
research/backtest.py CHANGED
@@ -684,7 +684,7 @@ def run_backtest(work_items: list[dict], csv_path: str | None = None, limit_work
684
  current_price=w["price"], indicators=w["inds"], ohlcv_df=w["ohlcv"],
685
  vix_declining=w["vix_decl"],
686
  _fast_mode=True, # single call β€” no 3-call debate
687
- _tight_test_ranges=True, # keep target ranges as tight as possible during validation
688
  _fast_fail_on_rate_limit=True, # skip to next model immediately on 429
689
  _enable_backtest_openrouter=True,
690
  )
 
684
  current_price=w["price"], indicators=w["inds"], ohlcv_df=w["ohlcv"],
685
  vix_declining=w["vix_decl"],
686
  _fast_mode=True, # single call β€” no 3-call debate
687
+ _tight_test_ranges=False, # use AI's own predicted ranges (realistic)
688
  _fast_fail_on_rate_limit=True, # skip to next model immediately on 429
689
  _enable_backtest_openrouter=True,
690
  )
research/backtest_watchlist.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ research/backtest_watchlist.py β€” Backtest AI prompts on current watchlist stocks.
4
+
5
+ Uses realistic AI-owned ranges (tight_test=False) so results match what the UI shows.
6
+ Also runs a live spot-check prediction for each stock to verify UI output.
7
+
8
+ Run:
9
+ python research/backtest_watchlist.py # historical backtest + live spot-check
10
+ python research/backtest_watchlist.py --live-only # live spot-check only (fast)
11
+ """
12
+ from __future__ import annotations
13
+ import sys, os, argparse
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15
+ sys.path.insert(0, os.path.dirname(__file__))
16
+
17
+ import warnings
18
+ import threading
19
+ import time
20
+ import pandas as pd
21
+ import yfinance as yf
22
+
23
+ warnings.filterwarnings("ignore")
24
+
25
+ from backtest import (
26
+ fetch_data, _compute_indicators, _fwd_intraday_moves, _fwd_returns,
27
+ _vix_nifty_series, _simple_ml_prob, run_backtest, print_results,
28
+ TIMEFRAMES, NIFTY, VIX_TK,
29
+ )
30
+
31
+ # ── CONFIG ──────────────────────────────────────────────────────────────────
32
+ # Test last ~3 months with a step of 20 trading days β†’ ~5 dates
33
+ DATA_START = "2025-01-01"
34
+ DATA_END = "2026-07-17"
35
+ TEST_START = "2026-04-01" # only run from this date forward
36
+ STEP = 20 # every 20 trading days (~4 weeks)
37
+
38
+ _PACE_SECS = int(os.environ.get("BACKTEST_LLM_PACE_SECS", 12))
39
+
40
+
41
+ def _get_watchlist_tickers():
42
+ try:
43
+ import database as db
44
+ wl = db.get_watchlist()
45
+ tickers = [w["ticker"] for w in wl]
46
+ if not tickers:
47
+ print("WARNING: Watchlist is empty β€” using fallback set")
48
+ return ["TATASTEEL.NS", "AXISCADES.NS", "HINDZINC.NS"]
49
+ return tickers
50
+ except Exception as e:
51
+ print(f"WARNING: Could not read watchlist ({e}) β€” using fallback set")
52
+ return ["TATASTEEL.NS", "AXISCADES.NS", "HINDZINC.NS"]
53
+
54
+
55
+ def _snap_to_trading_day(idx, ts):
56
+ prior = idx[idx <= ts]
57
+ return prior[-1] if not prior.empty else None
58
+
59
+
60
+ def _build_work_items(tickers, dates, sc, sh, sl, sv, nc, vc, nifty_ema200, vix_slope):
61
+ company_names = {t: t.replace(".NS", "") for t in tickers}
62
+ work_items = []
63
+ skipped = 0
64
+
65
+ for date in dates:
66
+ for ticker in tickers:
67
+ if ticker not in sc.columns:
68
+ continue
69
+ snapped = _snap_to_trading_day(sc[ticker].dropna().index, date)
70
+ if snapped is None or snapped != date:
71
+ continue
72
+ try:
73
+ vix_level = float(vc.loc[:date].dropna().iloc[-1])
74
+ nifty_v = float(nc.loc[:date].dropna().iloc[-1])
75
+ nifty_ema = float(nifty_ema200.loc[:date].dropna().iloc[-1])
76
+ nifty_ok = nifty_v > nifty_ema
77
+ vix_decl = float(vix_slope.loc[:date].dropna().iloc[-1]) < 0
78
+ macro_ok = nifty_ok and vix_level < 20
79
+ except Exception:
80
+ skipped += 1
81
+ continue
82
+
83
+ r1, r3, r5 = _fwd_returns(sc, date, ticker)
84
+ up0, dn0, up1, dn1, up3, dn3, up5, dn5 = _fwd_intraday_moves(sc, sh, sl, date, ticker)
85
+
86
+ price = float(sc[ticker].dropna().loc[:date].iloc[-1])
87
+ inds = _compute_indicators(sc[ticker], sh[ticker], sl[ticker], sv[ticker], date)
88
+ ml_prob = _simple_ml_prob(sc[ticker], sv[ticker], date)
89
+
90
+ idx2 = sc[ticker].dropna().index.searchsorted(date, side="right")
91
+ try:
92
+ ohlcv = pd.DataFrame({
93
+ "High": sh[ticker].iloc[max(0, idx2 - 20):idx2].values,
94
+ "Low": sl[ticker].iloc[max(0, idx2 - 20):idx2].values,
95
+ "Close": sc[ticker].iloc[max(0, idx2 - 20):idx2].values,
96
+ "Volume": sv[ticker].iloc[max(0, idx2 - 20):idx2].values,
97
+ }).dropna()
98
+ except Exception:
99
+ ohlcv = None
100
+
101
+ for tf in ["INTRADAY", "1D", "3D"]: # 5D retired from UI
102
+ ret_for_tf = {"INTRADAY": 0.0, "1D": r1, "3D": r3}[tf]
103
+ if pd.isna(ret_for_tf) and tf != "INTRADAY":
104
+ continue
105
+ work_items.append(dict(
106
+ date=date, ticker=ticker, tf=tf,
107
+ price=price, ml_prob=ml_prob, inds=inds,
108
+ company=company_names[ticker], ohlcv=ohlcv,
109
+ nifty_ok=nifty_ok, macro_ok=macro_ok,
110
+ vix_level=vix_level, vix_decl=vix_decl,
111
+ r1=r1, r3=r3, r5=r5,
112
+ up0=up0, dn0=dn0,
113
+ up1=up1, dn1=dn1, up3=up3, dn3=dn3, up5=up5, dn5=dn5,
114
+ ))
115
+
116
+ if skipped:
117
+ print(f" ({skipped} (ticker,date) pairs skipped β€” missing macro data)")
118
+ return work_items
119
+
120
+
121
+ def _print_summary(df, tickers):
122
+ print("\n" + "=" * 70)
123
+ print("Accuracy by Timeframe (tight_test=False β†’ AI's own ranges)")
124
+ print("=" * 70)
125
+ for tf in ["INTRADAY", "1D", "3D"]:
126
+ sub = df[df["timeframe"] == tf]
127
+ if sub.empty:
128
+ continue
129
+ n = len(sub)
130
+ tgt_hits = int(sub["target_hit_for_tf"].sum())
131
+ dir_hits = int(sub["intraday_hit_for_tf"].sum())
132
+ bullish = int((sub["direction"] == "BULLISH").sum())
133
+ bearish = int((sub["direction"] == "BEARISH").sum())
134
+ neutral = int((sub["direction"] == "NEUTRAL").sum())
135
+ avg_lo = sub["target_price_lo"].mean() if "target_price_lo" in sub.columns else float("nan")
136
+ avg_hi = sub["target_price_hi"].mean() if "target_price_hi" in sub.columns else float("nan")
137
+ avg_range_pct = sub.apply(
138
+ lambda r: abs(r.get("target_price_hi", 0) - r.get("target_price_lo", 0)) /
139
+ r.get("entry_price", 1) * 100 if r.get("entry_price", 0) > 0 else 0,
140
+ axis=1
141
+ ).mean() if "target_price_hi" in sub.columns else float("nan")
142
+ print(f" {tf:>10}: target_hit={tgt_hits}/{n} ({tgt_hits/n*100:.0f}%) "
143
+ f"dir={dir_hits/n*100:.0f}% "
144
+ f"[B:{bullish} Bear:{bearish} N:{neutral}] "
145
+ f"avg_range={avg_range_pct:.1f}%")
146
+
147
+ total = len(df)
148
+ tgt_total = int(df["target_hit_for_tf"].sum())
149
+ dir_total = int(df["intraday_hit_for_tf"].sum())
150
+ print(f"\n Overall: {tgt_total}/{total} = {tgt_total/total*100:.0f}% target_hit "
151
+ f"| {dir_total}/{total} = {dir_total/total*100:.0f}% direction")
152
+
153
+ print("\n" + "=" * 70)
154
+ print("Accuracy by Ticker")
155
+ print("=" * 70)
156
+ print(f" {'Ticker':<18} {'N':>4} {'Target%':>8} {'Dir%':>6} {'AvgRange%':>10}")
157
+ print(" " + "-" * 52)
158
+ for t in tickers:
159
+ sub = df[df["ticker"] == t]
160
+ if sub.empty:
161
+ continue
162
+ n = len(sub)
163
+ tgt_hits = int(sub["target_hit_for_tf"].sum())
164
+ dir_hits = int(sub["intraday_hit_for_tf"].sum())
165
+ avg_range_pct = sub.apply(
166
+ lambda r: abs(r.get("target_price_hi", 0) - r.get("target_price_lo", 0)) /
167
+ r.get("entry_price", 1) * 100 if r.get("entry_price", 0) > 0 else 0,
168
+ axis=1
169
+ ).mean() if "target_price_hi" in sub.columns else float("nan")
170
+ print(f" {t:<18} {n:>4} {tgt_hits/n*100:>7.0f}% {dir_hits/n*100:>6.0f}% {avg_range_pct:>9.1f}%")
171
+
172
+
173
+ def _live_spot_check(tickers):
174
+ """Predict each watchlist stock right now and show what the UI would display."""
175
+ print("\n" + "=" * 70)
176
+ print("LIVE SPOT-CHECK β€” what the UI shows right now")
177
+ print("=" * 70)
178
+ print(f" {'Ticker':<18} {'TF':>10} {'Dir':>10} {'Conf':>7} {'Lo%':>7} {'Hi%':>7} "
179
+ f"{'Range%':>8} {'BUY?':>6} {'Source'}")
180
+ print(" " + "-" * 85)
181
+
182
+ from predictor_core import predict_stock_v2, timeframe_to_dates
183
+
184
+ results = []
185
+ for ticker in tickers:
186
+ for tf in ["INTRADAY", "1D", "3D"]:
187
+ try:
188
+ start, end = timeframe_to_dates(tf)
189
+ pred = predict_stock_v2(
190
+ ticker=ticker, start_date=start, end_date=end,
191
+ _run_ai_forecast=True,
192
+ )
193
+ af = pred.get("ai_forecast") or {}
194
+ direction = af.get("direction", pred.get("direction", "β€”"))
195
+ confidence = af.get("confidence", pred.get("confidence", "β€”"))
196
+ ret_lo = af.get("predicted_return_lo") or pred.get("predicted_return_lo", 0)
197
+ ret_hi = af.get("predicted_return_hi") or pred.get("predicted_return_hi", 0)
198
+ should_buy = af.get("should_buy")
199
+ source = af.get("source", "β€”")
200
+ entry_px = af.get("entry_price", 0)
201
+ range_pct = abs((ret_hi or 0) - (ret_lo or 0))
202
+ buy_str = "BUY" if should_buy is True else ("SKIP" if should_buy is False else "β€”")
203
+ print(f" {ticker:<18} {tf:>10} {direction:>10} {confidence:>7} "
204
+ f"{(ret_lo or 0):>+7.2f} {(ret_hi or 0):>+7.2f} {range_pct:>7.2f}% "
205
+ f"{buy_str:>6} {source}")
206
+ results.append({
207
+ "ticker": ticker, "tf": tf,
208
+ "direction": direction, "confidence": confidence,
209
+ "ret_lo": ret_lo, "ret_hi": ret_hi, "range_pct": range_pct,
210
+ "should_buy": should_buy, "source": source, "entry_px": entry_px,
211
+ })
212
+ except Exception as e:
213
+ print(f" {ticker:<18} {tf:>10} ERROR: {e}")
214
+ time.sleep(2) # light throttle between live calls
215
+
216
+ # Range realism summary
217
+ if results:
218
+ import statistics
219
+ all_ranges = [r["range_pct"] for r in results if r.get("range_pct")]
220
+ print(f"\n Range stats: min={min(all_ranges):.2f}% "
221
+ f"avg={statistics.mean(all_ranges):.2f}% "
222
+ f"max={max(all_ranges):.2f}%")
223
+ print(f" Expected realistic ranges: INTRADAY ~0.5-2%, 1D ~1-4%, 3D ~2-7%")
224
+ tiny = [r for r in results if r.get("range_pct", 99) < 0.5]
225
+ if tiny:
226
+ print(f" WARNING: {len(tiny)} predictions have suspiciously tiny ranges (<0.5%):")
227
+ for r in tiny:
228
+ print(f" {r['ticker']} {r['tf']} lo={r['ret_lo']:+.3f}% hi={r['ret_hi']:+.3f}%")
229
+ else:
230
+ print(f" OK: All ranges are β‰₯0.5% β€” looks realistic.")
231
+
232
+
233
+ def main():
234
+ parser = argparse.ArgumentParser()
235
+ parser.add_argument("--live-only", action="store_true",
236
+ help="Skip historical backtest; only run live spot-check")
237
+ args = parser.parse_args()
238
+
239
+ tickers = _get_watchlist_tickers()
240
+
241
+ print("=" * 70)
242
+ print("Watchlist Backtest β€” realistic AI ranges (tight_test=False)")
243
+ print("=" * 70)
244
+ print(f"Tickers : {', '.join(tickers)}")
245
+
246
+ if not args.live_only:
247
+ print(f"Period : {TEST_START} β†’ {DATA_END} step={STEP} trading days")
248
+
249
+ # Download data
250
+ sc, sh, sl, sv, nc, vc = fetch_data(tickers, DATA_START, DATA_END)
251
+ nifty_ema200, vix_slope = _vix_nifty_series(nc, vc)
252
+
253
+ # Build date list
254
+ all_nifty_days = nc.dropna().index
255
+ dates = all_nifty_days[all_nifty_days >= pd.Timestamp(TEST_START)][::STEP]
256
+ print(f"Dates : {len(dates)} ({[str(d.date()) for d in dates]})")
257
+ work_items = _build_work_items(tickers, dates, sc, sh, sl, sv, nc, vc, nifty_ema200, vix_slope)
258
+ n_tfs = 3
259
+ print(f"Items : {len(work_items)} LLM calls (~{len(work_items) * _PACE_SECS // 60} min at {_PACE_SECS}s/call)\n")
260
+
261
+ if not work_items:
262
+ print("ERROR: No valid work items.")
263
+ else:
264
+ csv_out = os.path.join(os.path.dirname(__file__), "ai_prompt_accuracy_watchlist.csv")
265
+ df = run_backtest(work_items, csv_path=csv_out)
266
+ if df is not None and not df.empty:
267
+ _print_summary(df, tickers)
268
+ print(f"\nSaved β†’ {csv_out}")
269
+ print("\n" + "=" * 70)
270
+ print("FULL BREAKDOWN")
271
+ print_results(df)
272
+ else:
273
+ print("ERROR: run_backtest returned no results.")
274
+
275
+ _live_spot_check(tickers)
276
+
277
+
278
+ if __name__ == "__main__":
279
+ main()