Spaces:
Running
fix: realistic AI ranges, Ollama cold-start warmup, price-target prompt
Browse filesBacktest & 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 +65 -29
- app.py +29 -0
- llm_client.py +71 -21
- ollama_client.py +27 -0
- research/backtest.py +1 -1
- research/backtest_watchlist.py +279 -0
|
@@ -321,9 +321,11 @@ def _cache_ttl_for_tf(tf_label: str) -> int:
|
|
| 321 |
|
| 322 |
|
| 323 |
|
| 324 |
-
_JSON_REQUIRED = {"direction", "confidence"
|
| 325 |
-
|
| 326 |
-
#
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 1031 |
-
f"Set
|
| 1032 |
-
f"
|
| 1033 |
-
f"
|
| 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'"
|
| 1046 |
-
f'"
|
| 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:
|
| 1050 |
-
f"- BEARISH:
|
| 1051 |
-
f"- NEUTRAL:
|
| 1052 |
-
f"-
|
|
|
|
| 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(
|
| 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
|
| 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
|
| 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
|
| 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"):
|
|
@@ -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))
|
|
@@ -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=
|
| 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
|
|
|
|
| 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 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -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,
|
|
@@ -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=
|
| 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 |
)
|
|
@@ -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()
|