Khanna, Videh Rakesh Rakesh Claude Sonnet 4.6 commited on
Commit
727c9e5
Β·
1 Parent(s): 2ae3cd4

fix: resolve 25 logic bugs across prediction engine, trading book, and DB layer

Browse files

HIGH:
- ai_forecast: fix off-by-one in positional arg parsing (ml/nifty_ok/macro_ok/vix/news
were all reading wrong args[N] indices β€” every prediction ran with ml={}, vix=15.0 fixed)
- predictor_core: _is_actionable_buy checked "BUY"/"STRONG BUY" (never produced);
fix to "BULLISH"/"SLIGHTLY BULLISH" so entry-price buffer actually fires
- macro_context + predictor_core: macro gate always returned Risk-ON due to shift(1) β†’
today's date missing from index β†’ KeyError swallowed β†’ default True; fix via
build_mask(ffill) + drop NaN warmup rows (bool(NaN)=True was silently passing gate)
- database: close_trade UPDATE had no AND status='OPEN' guard β†’ double-close race
corrupted pnl and duplicated signal_accuracy rows; add guard + rowcount==1 check
- app: _trade_price_diagnostics called fetch_ohlcv with 3 args (takes 2) β†’ silent
TypeError β†’ every postmortem had empty diagnostics; fix to period= kwarg
- database: partial close P&L was computed but never stored; add realized_pnl column
with migration and accumulate on each partial close
- app: auto-close with unavailable live price used entry_price as exit β†’ 0% P&L stored
permanently; now skips close and retries next cycle with WARNING log

MEDIUM:
- ai_forecast: JSON regex {[^{}]*} fails when "reasoning" contains literal braces;
replace with balanced-brace scanner
- app: BEARISH validation actual_return sign-flipped so positive=fell-as-predicted
- app: duplicate _safe_float at line 1708 shadowed the canonical one; removed duplicate
and fixed 3 callers that passed a default= arg
- database: fill_order never updated entry_price on fill; now persists actual fill price
- app: limit order had double 0.1% tolerance dead band (creation + fill check);
fill check now uses live <= limit with no extra margin
- app: _TOP5_COMPUTING check-and-set was non-atomic; protect with threading.Lock
- data_sources: YF 5d daily fallback lacked IST freshness check; could return
yesterday's close on weekends/holidays as "live"

LOW:
- fred_data: >= 12 guard before iloc[-13] β†’ IndexError on 12-item series; fix to >= 13
- database: snapshot dedup used UTC date(); switch to IST (+5:30) offset
- predictor_core: Nifty gate trivially passed with < 3 bars; require len >= 3
- predictor_core: earnings_dates iteration in undocumented order; sort by abs days_away
- predictor_core: new_win stat uncapped; corrupt DB row could inflate all EVs; cap at 20x
- predictor_core: _warn_nifty_unavailable_once check-and-set not thread-safe; add Lock
- predictor_core: add threading import (was missing)
- macro_context: print() β†’ logging.warning() for macro download failures
- app: shares not validated > 0 on trade open; add explicit check
- app: watchlist cache not evicted on ticker delete; add _WATCHLIST_PICK_CACHE.pop()
- app: closing PENDING trade returned 200 OK; now returns 409 Conflict
- database: merge_into_position read+write in same connection to prevent share loss
- trial_run: ADX division by ATR=0 for frozen stocks β†’ inf/NaN; guard with .replace(0, NaN)

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

Files changed (8) hide show
  1. ai_forecast.py +41 -21
  2. app.py +44 -21
  3. data_sources.py +4 -2
  4. database.py +58 -27
  5. fred_data.py +1 -1
  6. macro_context.py +7 -1
  7. predictor_core.py +28 -14
  8. trial_run.py +1 -1
ai_forecast.py CHANGED
@@ -699,22 +699,42 @@ def _parse_json_from_llm(text: str) -> Dict | None:
699
  except Exception:
700
  pass
701
  if parsed is None:
702
- # Try first {...} block β€” handles "thinking aloud" models that prepend reasoning text
703
- m = re.search(r"\{[^{}]*\}", cleaned, re.DOTALL)
704
- if m:
705
- try:
706
- parsed = json.loads(m.group(0))
707
- except Exception:
708
- pass
709
- if parsed is None:
710
- # Broader search: find the last {...} block (some models put JSON at the end)
711
- matches = list(re.finditer(r"\{[^{}]*\}", cleaned, re.DOTALL))
712
- for candidate in reversed(matches):
713
- try:
714
- parsed = json.loads(candidate.group(0))
715
- break
716
- except Exception:
717
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  if parsed is None:
719
  return None
720
  # Reject if any required forecast field is missing β€” prevents 0.0 default corruption
@@ -1236,11 +1256,11 @@ def get_ai_forecast(
1236
  _TF_LABELS = ("INTRADAY", "1D", "3D", "5D")
1237
  company = args[0] if len(args) >= 1 and isinstance(args[0], str) and args[0] not in _TF_LABELS else kwargs.get("company", ticker)
1238
  tf_label = next((a for a in args if a in _TF_LABELS), None) or kwargs.get("tf_label", "3D")
1239
- ml = args[3] if len(args) >= 4 and isinstance(args[3], dict) else kwargs.get("ml", {})
1240
- nifty_ok = bool(args[4]) if len(args) >= 5 else bool(kwargs.get("nifty_ok", True))
1241
- macro_ok = bool(args[5]) if len(args) >= 6 else bool(kwargs.get("macro_ok", True))
1242
- vix_level = float(args[6]) if len(args) >= 7 and isinstance(args[6], (int,float)) else float(kwargs.get("vix_level", 15.0))
1243
- news = args[7] if len(args) >= 8 and isinstance(args[7], dict) else kwargs.get("news", {})
1244
  current_price = float(kwargs.get("current_price", 0.0))
1245
  indicators = _normalize_indicators(kwargs.get("indicators", {}) or {})
1246
  ohlcv_df = kwargs.get("ohlcv_df")
 
699
  except Exception:
700
  pass
701
  if parsed is None:
702
+ # Balanced-brace scan: find the outermost {...} block.
703
+ # {[^{}]*} only matches flat objects and breaks when "reasoning" contains
704
+ # literal braces like "RSI crossed {35}". Walk the string instead.
705
+ def _find_json_object(s: str):
706
+ for start in range(len(s)):
707
+ if s[start] != '{':
708
+ continue
709
+ depth, in_str, i = 0, False, start
710
+ while i < len(s):
711
+ ch = s[i]
712
+ if ch == '"' and (i == 0 or s[i - 1] != '\\'):
713
+ in_str = not in_str
714
+ if not in_str:
715
+ if ch == '{':
716
+ depth += 1
717
+ elif ch == '}':
718
+ depth -= 1
719
+ if depth == 0:
720
+ candidate = s[start:i + 1]
721
+ try:
722
+ return json.loads(candidate)
723
+ except Exception:
724
+ break
725
+ i += 1
726
+ return None
727
+
728
+ parsed = _find_json_object(cleaned)
729
+ if parsed is None:
730
+ # Last-resort: try scanning from the end for the last JSON object
731
+ for start in range(len(cleaned) - 1, -1, -1):
732
+ if cleaned[start] == '{':
733
+ try:
734
+ parsed = json.loads(cleaned[start:])
735
+ break
736
+ except Exception:
737
+ pass
738
  if parsed is None:
739
  return None
740
  # Reject if any required forecast field is missing β€” prevents 0.0 default corruption
 
1256
  _TF_LABELS = ("INTRADAY", "1D", "3D", "5D")
1257
  company = args[0] if len(args) >= 1 and isinstance(args[0], str) and args[0] not in _TF_LABELS else kwargs.get("company", ticker)
1258
  tf_label = next((a for a in args if a in _TF_LABELS), None) or kwargs.get("tf_label", "3D")
1259
+ ml = args[2] if len(args) >= 3 and isinstance(args[2], dict) else kwargs.get("ml", {})
1260
+ nifty_ok = bool(args[3]) if len(args) >= 4 else bool(kwargs.get("nifty_ok", True))
1261
+ macro_ok = bool(args[4]) if len(args) >= 5 else bool(kwargs.get("macro_ok", True))
1262
+ vix_level = float(args[5]) if len(args) >= 6 and isinstance(args[5], (int, float)) else float(kwargs.get("vix_level", 15.0))
1263
+ news = args[6] if len(args) >= 7 and isinstance(args[6], dict) else kwargs.get("news", {})
1264
  current_price = float(kwargs.get("current_price", 0.0))
1265
  indicators = _normalize_indicators(kwargs.get("indicators", {}) or {})
1266
  ohlcv_df = kwargs.get("ohlcv_df")
app.py CHANGED
@@ -69,6 +69,7 @@ db.init_db()
69
  _TOP5_CACHE: dict = {}
70
  _TOP5_CACHE_TTL = 86400 # 24 hours β€” daily reset ensures new rankings based on fresh market data
71
  _TOP5_COMPUTING = False # True while a background computation is in progress
 
72
 
73
  # Watchlist prediction cache β€” avoids re-running 12 LLM debate calls per stock on every tab switch
74
  _WATCHLIST_PICK_CACHE: dict = {}
@@ -137,7 +138,7 @@ def _trade_price_diagnostics(trade: dict) -> dict:
137
  # Include a small buffer around the trade window for context bars.
138
  start = (opened_date - timedelta(days=4)).strftime("%Y-%m-%d")
139
  end = (closed_date + timedelta(days=1)).strftime("%Y-%m-%d")
140
- bars = fetch_ohlcv(ticker, start, end)
141
  if bars is None or getattr(bars, "empty", True):
142
  return {}
143
 
@@ -794,9 +795,11 @@ def _start_top5_background(force_universe_refresh: bool = False) -> None:
794
  except Exception as exc:
795
  app.logger.warning("Background top5 compute failed: %s", exc)
796
  finally:
797
- _TOP5_COMPUTING = False
 
798
 
799
- _TOP5_COMPUTING = True
 
800
  threading.Thread(target=_run, daemon=True, name="top5-compute").start()
801
 
802
 
@@ -825,8 +828,9 @@ def top5():
825
  # Cache is cold β€” kick off background compute and return immediately so the UI
826
  # doesn't hang. The frontend should poll (with ?poll=1) until "computing" is gone.
827
  if not cache_fresh:
828
- if not _TOP5_COMPUTING:
829
- _start_top5_background(force_universe_refresh=force_refresh)
 
830
  # Serve stale result immediately while background recomputes (better UX than spinner)
831
  if entry and not force_refresh:
832
  stale_result = dict(entry["result"])
@@ -885,6 +889,7 @@ def watchlist_remove(ticker: str):
885
  t = _normalise(ticker)
886
  removed = db.remove_from_watchlist(t)
887
  if removed:
 
888
  return jsonify({"removed": t})
889
  return jsonify({"error": "Not found"}), 404
890
 
@@ -1304,6 +1309,13 @@ def trades_open_new():
1304
  if f not in data:
1305
  return jsonify({"error": f"{f} is required"}), 400
1306
 
 
 
 
 
 
 
 
1307
  ticker = _normalise(data["ticker"])
1308
  entry_price = float(data["entry_price"])
1309
  direction = data["direction"].upper()
@@ -1479,13 +1491,16 @@ def orders_check():
1479
  limit = order["entry_price"]
1480
  tol = 0.001
1481
 
 
 
 
1482
  should_fill = (
1483
- (direction == "LONG" and live <= limit * (1 - tol)) or
1484
- (direction == "SHORT" and live >= limit * (1 + tol))
1485
  )
1486
 
1487
  if should_fill:
1488
- filled_trade = db.fill_order(order["id"])
1489
  filled_trade["fill_price"] = live
1490
  filled.append(filled_trade)
1491
  else:
@@ -1573,6 +1588,9 @@ def trades_close(trade_id: int):
1573
  close_shares=int(close_shares) if close_shares else None)
1574
  if not trade:
1575
  return jsonify({"error": "Trade not found"}), 404
 
 
 
1576
 
1577
  # Only generate postmortem on full close (status=CLOSED).
1578
  if trade.get("status") == "CLOSED" and not trade.get("notes"):
@@ -1667,11 +1685,11 @@ def portfolio_review():
1667
 
1668
  lines = []
1669
  for t in closed:
1670
- outcome = "WIN" if _safe_float(t.get("pnl_pct"), 0) >= 0 else "LOSS"
1671
  lines.append(
1672
  f"- {t.get('ticker','?')} | {t.get('direction','?')} | "
1673
- f"Entry β‚Ή{_safe_float(t.get('entry_price'),0):.0f} β†’ Exit β‚Ή{_safe_float(t.get('exit_price') or t.get('current_price'),0):.0f} | "
1674
- f"P&L {_safe_float(t.get('pnl_pct'),0):+.2f}% | {outcome}"
1675
  )
1676
  trade_list = "\n".join(lines)
1677
 
@@ -1705,11 +1723,7 @@ def portfolio_review():
1705
  })
1706
 
1707
 
1708
- def _safe_float(v, default=0.0):
1709
- try:
1710
- return float(v) if v is not None else default
1711
- except Exception:
1712
- return default
1713
 
1714
 
1715
 
@@ -1809,7 +1823,9 @@ def prediction_validation():
1809
  )
1810
  entry_price = snap.get("current_price", 0)
1811
  if entry_price > 0 and actual_price:
1812
- actual_return = round((actual_price - entry_price) / entry_price * 100, 2)
 
 
1813
  target_price_lo = snap.get("target_price_lo") or 0
1814
  target_price_hi = snap.get("target_price_hi") or 0
1815
  hit = _intraday_target_hit(
@@ -2441,7 +2457,14 @@ def _start_trade_monitor():
2441
  # Close after market close (15:31 IST) on the expiry date.
2442
  market_closed = now_ist.hour > 15 or (now_ist.hour == 15 and now_ist.minute >= 31)
2443
  if now_ist.strftime("%Y-%m-%d") >= auto_close_date and market_closed:
2444
- exp_live = _current_price(trade["ticker"]) or float(trade.get("entry_price", 0))
 
 
 
 
 
 
 
2445
  closed = db.close_trade(trade["id"], exp_live)
2446
  if closed and not closed.get("notes"):
2447
  db.save_postmortem(trade["id"], _postmortem(closed))
@@ -2462,9 +2485,9 @@ def _start_trade_monitor():
2462
  direction = order["direction"]
2463
  limit = order["entry_price"]
2464
  tol = 0.001
2465
- if (direction == "LONG" and live <= limit * (1 - tol)) or \
2466
- (direction == "SHORT" and live >= limit * (1 + tol)):
2467
- db.fill_order(order["id"])
2468
  app.logger.info(
2469
  "Auto-filled order #%s %s at β‚Ή%.2f", order["id"], order["ticker"], live
2470
  )
 
69
  _TOP5_CACHE: dict = {}
70
  _TOP5_CACHE_TTL = 86400 # 24 hours β€” daily reset ensures new rankings based on fresh market data
71
  _TOP5_COMPUTING = False # True while a background computation is in progress
72
+ _TOP5_COMPUTING_LOCK = __import__("threading").Lock() # guards the check-and-set on _TOP5_COMPUTING
73
 
74
  # Watchlist prediction cache β€” avoids re-running 12 LLM debate calls per stock on every tab switch
75
  _WATCHLIST_PICK_CACHE: dict = {}
 
138
  # Include a small buffer around the trade window for context bars.
139
  start = (opened_date - timedelta(days=4)).strftime("%Y-%m-%d")
140
  end = (closed_date + timedelta(days=1)).strftime("%Y-%m-%d")
141
+ bars = fetch_ohlcv(ticker, period="2y")
142
  if bars is None or getattr(bars, "empty", True):
143
  return {}
144
 
 
795
  except Exception as exc:
796
  app.logger.warning("Background top5 compute failed: %s", exc)
797
  finally:
798
+ with _TOP5_COMPUTING_LOCK:
799
+ _TOP5_COMPUTING = False
800
 
801
+ with _TOP5_COMPUTING_LOCK:
802
+ _TOP5_COMPUTING = True
803
  threading.Thread(target=_run, daemon=True, name="top5-compute").start()
804
 
805
 
 
828
  # Cache is cold β€” kick off background compute and return immediately so the UI
829
  # doesn't hang. The frontend should poll (with ?poll=1) until "computing" is gone.
830
  if not cache_fresh:
831
+ with _TOP5_COMPUTING_LOCK:
832
+ if not _TOP5_COMPUTING:
833
+ _start_top5_background(force_universe_refresh=force_refresh)
834
  # Serve stale result immediately while background recomputes (better UX than spinner)
835
  if entry and not force_refresh:
836
  stale_result = dict(entry["result"])
 
889
  t = _normalise(ticker)
890
  removed = db.remove_from_watchlist(t)
891
  if removed:
892
+ _WATCHLIST_PICK_CACHE.pop(t, None) # evict stale prediction so re-add gets fresh data
893
  return jsonify({"removed": t})
894
  return jsonify({"error": "Not found"}), 404
895
 
 
1309
  if f not in data:
1310
  return jsonify({"error": f"{f} is required"}), 400
1311
 
1312
+ try:
1313
+ _shares_val = int(data["shares"])
1314
+ except (TypeError, ValueError):
1315
+ return jsonify({"error": "shares must be a positive integer"}), 400
1316
+ if _shares_val <= 0:
1317
+ return jsonify({"error": "shares must be > 0"}), 400
1318
+
1319
  ticker = _normalise(data["ticker"])
1320
  entry_price = float(data["entry_price"])
1321
  direction = data["direction"].upper()
 
1491
  limit = order["entry_price"]
1492
  tol = 0.001
1493
 
1494
+ # Fill when live price reaches the limit β€” no extra tolerance at fill time.
1495
+ # The creation-side tolerance already placed the limit below market for LONG
1496
+ # (entry < live * 0.999), so the fill check just needs live <= entry.
1497
  should_fill = (
1498
+ (direction == "LONG" and live <= limit) or
1499
+ (direction == "SHORT" and live >= limit)
1500
  )
1501
 
1502
  if should_fill:
1503
+ filled_trade = db.fill_order(order["id"], fill_price=live)
1504
  filled_trade["fill_price"] = live
1505
  filled.append(filled_trade)
1506
  else:
 
1588
  close_shares=int(close_shares) if close_shares else None)
1589
  if not trade:
1590
  return jsonify({"error": "Trade not found"}), 404
1591
+ if trade.get("status") not in ("CLOSED", "OPEN"):
1592
+ # close_trade returned the trade unchanged β€” it was PENDING/CANCELLED, not OPEN
1593
+ return jsonify({"error": f"Cannot close trade in status '{trade.get('status')}'"}), 409
1594
 
1595
  # Only generate postmortem on full close (status=CLOSED).
1596
  if trade.get("status") == "CLOSED" and not trade.get("notes"):
 
1685
 
1686
  lines = []
1687
  for t in closed:
1688
+ outcome = "WIN" if (_safe_float(t.get("pnl_pct")) or 0) >= 0 else "LOSS"
1689
  lines.append(
1690
  f"- {t.get('ticker','?')} | {t.get('direction','?')} | "
1691
+ f"Entry β‚Ή{(_safe_float(t.get('entry_price')) or 0):.0f} β†’ Exit β‚Ή{(_safe_float(t.get('exit_price') or t.get('current_price')) or 0):.0f} | "
1692
+ f"P&L {(_safe_float(t.get('pnl_pct')) or 0):+.2f}% | {outcome}"
1693
  )
1694
  trade_list = "\n".join(lines)
1695
 
 
1723
  })
1724
 
1725
 
1726
+ # _safe_float defined above (line ~387) β€” this duplicate removed.
 
 
 
 
1727
 
1728
 
1729
 
 
1823
  )
1824
  entry_price = snap.get("current_price", 0)
1825
  if entry_price > 0 and actual_price:
1826
+ raw_return = (actual_price - entry_price) / entry_price * 100
1827
+ # For BEARISH predictions sign-flip so positive = stock fell as predicted.
1828
+ actual_return = round(-raw_return if direction == "BEARISH" else raw_return, 2)
1829
  target_price_lo = snap.get("target_price_lo") or 0
1830
  target_price_hi = snap.get("target_price_hi") or 0
1831
  hit = _intraday_target_hit(
 
2457
  # Close after market close (15:31 IST) on the expiry date.
2458
  market_closed = now_ist.hour > 15 or (now_ist.hour == 15 and now_ist.minute >= 31)
2459
  if now_ist.strftime("%Y-%m-%d") >= auto_close_date and market_closed:
2460
+ exp_live = _current_price(trade["ticker"])
2461
+ if not exp_live:
2462
+ app.logger.warning(
2463
+ "Skipping auto-close for trade #%s %s β€” live price unavailable; "
2464
+ "will retry next cycle",
2465
+ trade["id"], trade["ticker"],
2466
+ )
2467
+ continue
2468
  closed = db.close_trade(trade["id"], exp_live)
2469
  if closed and not closed.get("notes"):
2470
  db.save_postmortem(trade["id"], _postmortem(closed))
 
2485
  direction = order["direction"]
2486
  limit = order["entry_price"]
2487
  tol = 0.001
2488
+ if (direction == "LONG" and live <= limit) or \
2489
+ (direction == "SHORT" and live >= limit):
2490
+ db.fill_order(order["id"], fill_price=live)
2491
  app.logger.info(
2492
  "Auto-filled order #%s %s at β‚Ή%.2f", order["id"], order["ticker"], live
2493
  )
data_sources.py CHANGED
@@ -609,7 +609,9 @@ def fetch_live_price(ticker_ns: str, allow_delayed: bool = True) -> Optional[flo
609
  if isinstance(hist.columns, pd.MultiIndex):
610
  hist.columns = hist.columns.get_level_values(0)
611
  closes = hist["Close"].dropna() if "Close" in hist.columns else pd.Series(dtype=float)
612
- if not closes.empty:
 
 
613
  return round(float(closes.iloc[-1]), 2)
614
  except Exception as e:
615
  if _is_yf_crumb_error(e):
@@ -640,7 +642,7 @@ def fetch_live_price(ticker_ns: str, allow_delayed: bool = True) -> Optional[flo
640
  if isinstance(hist.columns, pd.MultiIndex):
641
  hist.columns = hist.columns.get_level_values(0)
642
  closes = hist["Close"].dropna() if "Close" in hist.columns else pd.Series(dtype=float)
643
- if not closes.empty:
644
  return round(float(closes.iloc[-1]), 2)
645
  except Exception as e:
646
  if _is_yf_crumb_error(e):
 
609
  if isinstance(hist.columns, pd.MultiIndex):
610
  hist.columns = hist.columns.get_level_values(0)
611
  closes = hist["Close"].dropna() if "Close" in hist.columns else pd.Series(dtype=float)
612
+ # Require today's date (IST) so we don't serve yesterday's close on
613
+ # weekends, holidays, or after intraday bars are unavailable.
614
+ if not closes.empty and _is_today_ist(pd.Timestamp(closes.index[-1])):
615
  return round(float(closes.iloc[-1]), 2)
616
  except Exception as e:
617
  if _is_yf_crumb_error(e):
 
642
  if isinstance(hist.columns, pd.MultiIndex):
643
  hist.columns = hist.columns.get_level_values(0)
644
  closes = hist["Close"].dropna() if "Close" in hist.columns else pd.Series(dtype=float)
645
+ if not closes.empty and _is_today_ist(pd.Timestamp(closes.index[-1])):
646
  return round(float(closes.iloc[-1]), 2)
647
  except Exception as e:
648
  if _is_yf_crumb_error(e):
database.py CHANGED
@@ -245,14 +245,17 @@ def _migrate() -> None:
245
  pass # Columns may already exist
246
 
247
  # Remove same-day duplicate snapshots (same ticker/TF/direction/target_date created
248
- # on the same UTC day). Keep the lowest id. Safe to run repeatedly β€” idempotent.
 
 
249
  try:
250
  conn.execute("""
251
  DELETE FROM prediction_snapshots
252
  WHERE id NOT IN (
253
  SELECT MIN(id)
254
  FROM prediction_snapshots
255
- GROUP BY ticker, timeframe, direction, validation_target_date, date(created_at)
 
256
  )
257
  """)
258
  except Exception:
@@ -309,6 +312,8 @@ def _migrate() -> None:
309
  conn.execute("ALTER TABLE trades ADD COLUMN snapshot_id INTEGER")
310
  if "auto_close_date" not in cols:
311
  conn.execute("ALTER TABLE trades ADD COLUMN auto_close_date TEXT")
 
 
312
 
313
  if "order_type" not in cols:
314
  # Rebuild trades table to add order_type and extend the status CHECK.
@@ -428,14 +433,26 @@ def get_trade(trade_id: int) -> dict:
428
  return dict(row) if row else {}
429
 
430
 
431
- def fill_order(trade_id: int) -> dict:
432
- """Transition a PENDING limit order to OPEN (filled)."""
 
 
 
 
 
433
  filled_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
434
  with _conn() as conn:
435
- conn.execute(
436
- "UPDATE trades SET status = 'OPEN', opened_at = ? WHERE id = ? AND status = 'PENDING'",
437
- (filled_at, trade_id),
438
- )
 
 
 
 
 
 
 
439
  return get_trade(trade_id)
440
 
441
 
@@ -461,18 +478,24 @@ def merge_into_position(trade_id: int, add_shares: int, add_price: float) -> dic
461
  """Average a new buy into an existing OPEN position.
462
 
463
  Recalculates weighted-average entry price and increments share count.
 
 
464
  Returns the updated trade dict.
465
  """
466
- trade = get_trade(trade_id)
467
- if not trade or trade["status"] != "OPEN":
468
- return trade
469
- old_shares = trade["shares"]
470
- old_price = trade["entry_price"]
471
- total = old_shares + add_shares
472
- avg_price = round((old_shares * old_price + add_shares * add_price) / total, 2)
473
  with _conn() as conn:
 
 
 
 
 
 
 
 
 
 
 
474
  conn.execute(
475
- "UPDATE trades SET shares = ?, entry_price = ? WHERE id = ?",
476
  (total, avg_price, trade_id),
477
  )
478
  return get_trade(trade_id)
@@ -506,15 +529,22 @@ def close_trade(trade_id: int, exit_price: float, close_shares: int | None = Non
506
  remaining = total_sh - close_sh
507
 
508
  if remaining > 0:
509
- # Partial close β€” reduce shares, keep position OPEN, no status change.
510
  with _conn() as conn:
511
  conn.execute(
512
- "UPDATE trades SET shares = ? WHERE id = ?",
513
- (remaining, trade_id),
 
 
 
 
 
514
  )
515
  return get_trade(trade_id)
516
 
517
- # Full close.
 
 
518
  closed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
519
  strategy = trade.get("strategy", "")
520
  timeframe = trade.get("timeframe", "")
@@ -522,20 +552,21 @@ def close_trade(trade_id: int, exit_price: float, close_shares: int | None = Non
522
  signals = [s.strip() for s in strategy.split(",") if s.strip()] if strategy else ["Manual"]
523
 
524
  with _conn() as conn:
525
- conn.execute(
526
  """
527
  UPDATE trades
528
  SET exit_price = ?, closed_at = ?, status = 'CLOSED',
529
  pnl = ?, pnl_pct = ?
530
- WHERE id = ?
531
  """,
532
  (exit_price, closed_at, round(pnl, 2), round(pnl_pct, 2), trade_id),
533
  )
534
- for sig in signals:
535
- conn.execute(
536
- "INSERT INTO signal_accuracy (signal, timeframe, won, pnl_pct) VALUES (?, ?, ?, ?)",
537
- (sig, timeframe, won, round(pnl_pct, 2)),
538
- )
 
539
 
540
  return get_trade(trade_id)
541
 
 
245
  pass # Columns may already exist
246
 
247
  # Remove same-day duplicate snapshots (same ticker/TF/direction/target_date created
248
+ # on the same IST day). Use IST offset (+5:30) so predictions before/after UTC midnight
249
+ # but on the same IST trading day are correctly deduplicated.
250
+ # Keep the lowest id. Safe to run repeatedly β€” idempotent.
251
  try:
252
  conn.execute("""
253
  DELETE FROM prediction_snapshots
254
  WHERE id NOT IN (
255
  SELECT MIN(id)
256
  FROM prediction_snapshots
257
+ GROUP BY ticker, timeframe, direction, validation_target_date,
258
+ date(created_at, '+5 hours', '+30 minutes')
259
  )
260
  """)
261
  except Exception:
 
312
  conn.execute("ALTER TABLE trades ADD COLUMN snapshot_id INTEGER")
313
  if "auto_close_date" not in cols:
314
  conn.execute("ALTER TABLE trades ADD COLUMN auto_close_date TEXT")
315
+ if "realized_pnl" not in cols:
316
+ conn.execute("ALTER TABLE trades ADD COLUMN realized_pnl REAL DEFAULT 0.0")
317
 
318
  if "order_type" not in cols:
319
  # Rebuild trades table to add order_type and extend the status CHECK.
 
433
  return dict(row) if row else {}
434
 
435
 
436
+ def fill_order(trade_id: int, fill_price: float | None = None) -> dict:
437
+ """Transition a PENDING limit order to OPEN (filled).
438
+
439
+ If fill_price is provided (the actual market price at fill time), it
440
+ overwrites entry_price so that P&L calculations use the real fill price
441
+ rather than the original limit price when the market gapped past it.
442
+ """
443
  filled_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
444
  with _conn() as conn:
445
+ if fill_price is not None:
446
+ conn.execute(
447
+ "UPDATE trades SET status = 'OPEN', opened_at = ?, entry_price = ? "
448
+ "WHERE id = ? AND status = 'PENDING'",
449
+ (filled_at, round(fill_price, 2), trade_id),
450
+ )
451
+ else:
452
+ conn.execute(
453
+ "UPDATE trades SET status = 'OPEN', opened_at = ? WHERE id = ? AND status = 'PENDING'",
454
+ (filled_at, trade_id),
455
+ )
456
  return get_trade(trade_id)
457
 
458
 
 
478
  """Average a new buy into an existing OPEN position.
479
 
480
  Recalculates weighted-average entry price and increments share count.
481
+ The SELECT and UPDATE are in the same connection/transaction so SQLite's
482
+ WAL serialization prevents concurrent adds from losing shares.
483
  Returns the updated trade dict.
484
  """
 
 
 
 
 
 
 
485
  with _conn() as conn:
486
+ row = conn.execute(
487
+ "SELECT shares, entry_price, status FROM trades WHERE id = ?", (trade_id,)
488
+ ).fetchone()
489
+ if not row or row["status"] != "OPEN":
490
+ return get_trade(trade_id)
491
+ old_shares = row["shares"]
492
+ old_price = row["entry_price"]
493
+ total = old_shares + add_shares
494
+ if total <= 0:
495
+ return get_trade(trade_id)
496
+ avg_price = round((old_shares * old_price + add_shares * add_price) / total, 2)
497
  conn.execute(
498
+ "UPDATE trades SET shares = ?, entry_price = ? WHERE id = ? AND status = 'OPEN'",
499
  (total, avg_price, trade_id),
500
  )
501
  return get_trade(trade_id)
 
529
  remaining = total_sh - close_sh
530
 
531
  if remaining > 0:
532
+ # Partial close β€” reduce shares, accumulate realized P&L, keep OPEN.
533
  with _conn() as conn:
534
  conn.execute(
535
+ """
536
+ UPDATE trades
537
+ SET shares = ?,
538
+ realized_pnl = COALESCE(realized_pnl, 0.0) + ?
539
+ WHERE id = ? AND status = 'OPEN'
540
+ """,
541
+ (remaining, round(pnl, 2), trade_id),
542
  )
543
  return get_trade(trade_id)
544
 
545
+ # Full close β€” guard against double-close race condition by requiring
546
+ # status = 'OPEN' in the UPDATE predicate. rowcount == 0 means another
547
+ # thread already closed this trade; skip the signal_accuracy insert.
548
  closed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
549
  strategy = trade.get("strategy", "")
550
  timeframe = trade.get("timeframe", "")
 
552
  signals = [s.strip() for s in strategy.split(",") if s.strip()] if strategy else ["Manual"]
553
 
554
  with _conn() as conn:
555
+ cur = conn.execute(
556
  """
557
  UPDATE trades
558
  SET exit_price = ?, closed_at = ?, status = 'CLOSED',
559
  pnl = ?, pnl_pct = ?
560
+ WHERE id = ? AND status = 'OPEN'
561
  """,
562
  (exit_price, closed_at, round(pnl, 2), round(pnl_pct, 2), trade_id),
563
  )
564
+ if cur.rowcount == 1:
565
+ for sig in signals:
566
+ conn.execute(
567
+ "INSERT INTO signal_accuracy (signal, timeframe, won, pnl_pct) VALUES (?, ?, ?, ?)",
568
+ (sig, timeframe, won, round(pnl_pct, 2)),
569
+ )
570
 
571
  return get_trade(trade_id)
572
 
fred_data.py CHANGED
@@ -156,7 +156,7 @@ def _fetch_via_fredapi() -> dict | None:
156
  observation_start=end - timedelta(days=400),
157
  observation_end=end,
158
  ).dropna()
159
- if len(cpi_series) >= 12:
160
  latest_cpi = float(cpi_series.iloc[-1])
161
  year_ago_cpi = float(cpi_series.iloc[-13])
162
  cpi_yoy = round((latest_cpi / year_ago_cpi - 1) * 100, 2)
 
156
  observation_start=end - timedelta(days=400),
157
  observation_end=end,
158
  ).dropna()
159
+ if len(cpi_series) >= 13:
160
  latest_cpi = float(cpi_series.iloc[-1])
161
  year_ago_cpi = float(cpi_series.iloc[-13])
162
  cpi_yoy = round((latest_cpi / year_ago_cpi - 1) * 100, 2)
macro_context.py CHANGED
@@ -33,7 +33,8 @@ class MacroContext:
33
  close = close.iloc[:, 0]
34
  frames[name] = close.rename(name)
35
  except Exception as e:
36
- print(f" [macro_context] Warning: could not download {ytk}: {e}")
 
37
  frames[name] = None
38
 
39
  available = {k: v for k, v in frames.items() if v is not None}
@@ -81,6 +82,11 @@ class MacroContext:
81
  ~feat["crude_spike"]
82
  )
83
 
 
 
 
 
 
84
  # Lag all features by 1 trading day (use T-1 data to predict T direction)
85
  self._features = feat.shift(1)
86
 
 
33
  close = close.iloc[:, 0]
34
  frames[name] = close.rename(name)
35
  except Exception as e:
36
+ import logging as _log
37
+ _log.getLogger(__name__).warning("macro_context: could not download %s: %s", ytk, e)
38
  frames[name] = None
39
 
40
  available = {k: v for k, v in frames.items() if v is not None}
 
82
  ~feat["crude_spike"]
83
  )
84
 
85
+ # Drop warmup rows where indicators are NaN (rolling/pct_change warmup period).
86
+ # bool(NaN) == True in Python, so keeping these rows would cause the gate to
87
+ # silently pass as Risk-ON during the first ~20 bars of data.
88
+ feat = feat.dropna(subset=["sp500_trend", "usdinr_stable", "crude_spike"])
89
+
90
  # Lag all features by 1 trading day (use T-1 data to predict T direction)
91
  self._features = feat.shift(1)
92
 
predictor_core.py CHANGED
@@ -17,7 +17,7 @@ Public API:
17
  """
18
 
19
  from __future__ import annotations
20
- import sys, os, warnings, math, time, logging
21
  warnings.filterwarnings("ignore")
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -303,7 +303,7 @@ def _load_live_strategy_stats() -> dict:
303
  # avg_pnl = avg_win * (wr - lr / rr) β†’ solve for avg_win
304
  denom = win_rate - (lr / old_rr)
305
  if abs(denom) > 1e-6:
306
- new_win = max(0.5, avg_pnl / denom)
307
  new_loss = max(0.1, new_win / old_rr)
308
  stats[sig] = (round(win_rate, 3), round(new_win, 2), round(new_loss, 2))
309
  except Exception:
@@ -358,14 +358,16 @@ def clear_runtime_caches() -> dict:
358
  # ── LOG THROTTLE (avoid per-ticker warning spam) ────────────────────────────
359
  _LAST_NIFTY_WARN_TS = 0.0
360
  _NIFTY_WARN_TTL = 300 # seconds
 
361
 
362
 
363
  def _warn_nifty_unavailable_once() -> None:
364
  global _LAST_NIFTY_WARN_TS
365
  now = time.time()
366
- if now - _LAST_NIFTY_WARN_TS >= _NIFTY_WARN_TTL:
367
- logging.warning("Nifty market data unavailable β€” Nifty-dependent signals skipped (throttled)")
368
- _LAST_NIFTY_WARN_TS = now
 
369
 
370
 
371
  def _get_market_cache(period: str = "1y"):
@@ -447,7 +449,7 @@ def _get_nifty_gate() -> tuple[bool, str]:
447
  making the current EMA200 level accurate. Requires 3 of the last 5 closes
448
  below the warm EMA200 to trigger CAUTION β€” majority-of-week rule filters noise."""
449
  nifty_c, _ = _get_market_cache("2y") # 2y β†’ EMA200 is fully warmed up
450
- if nifty_c is None or len(nifty_c) == 0:
451
  return True, "Nifty data unavailable β€” assuming OK"
452
  ema200_s = nifty_c.ewm(span=200).mean()
453
  ema200 = float(ema200_s.iloc[-1])
@@ -471,9 +473,19 @@ def _get_macro_gate() -> tuple[bool, str]:
471
  start = (datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d")
472
  mc = MacroContext()
473
  mc.load(start, end)
474
- today = pd.Timestamp.now().normalize()
475
- feat = mc.get(today)
476
- ok = feat.get("global_risk_on", True)
 
 
 
 
 
 
 
 
 
 
477
  parts = []
478
  if not feat.get("sp500_trend", True):
479
  parts.append("S&P500 weak")
@@ -495,8 +507,10 @@ def get_earnings_status(ticker: str, window: int = 5) -> dict:
495
  return {"next_date": None, "days_away": None, "in_blackout": False, "warning": None}
496
 
497
  today = datetime.now().date()
498
- # Look at upcoming earnings (within next 30 days) AND recent past (within 5 days)
499
- for edate in ed.index:
 
 
500
  edate_d = edate.date() if hasattr(edate, "date") else pd.Timestamp(edate).date()
501
  days_away = (edate_d - today).days
502
  if -window <= days_away <= 30:
@@ -1466,8 +1480,8 @@ def predict_stock_v2(ticker: str, start_date: str, end_date: str,
1466
  elif _ml_score < 50:
1467
  _entry_buffer = _entry_buffer * 1.2 # 20% more conservative
1468
 
1469
- _is_actionable_buy = direction in ("BUY", "STRONG BUY") and no_trade_reason is None
1470
- _is_actionable_sell = direction in ("SELL", "BEARISH") and no_trade_reason is None
1471
  if _is_actionable_buy:
1472
  entry_price = round(price * (1 + _entry_buffer), 2)
1473
  entry_basis = "est_open_conservative" if _ml_score < 50 else "est_open"
@@ -1518,7 +1532,7 @@ def predict_stock_v2(ticker: str, start_date: str, end_date: str,
1518
  # ── Phase 6: Price targets (Camarilla / ATR / PDH) ──────────────────────────
1519
  try:
1520
  from price_targets import get_price_targets
1521
- _strategy_bias = "BULLISH" if direction in ("BUY", "STRONG BUY", "BULLISH") else (
1522
  "BEARISH" if direction in ("SELL", "BEARISH") else "NEUTRAL")
1523
  price_targets_dict = get_price_targets(ticker, sc, sh, sl, _strategy_bias, confidence)
1524
  except Exception as _pt_err:
 
17
  """
18
 
19
  from __future__ import annotations
20
+ import sys, os, warnings, math, time, logging, threading
21
  warnings.filterwarnings("ignore")
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
303
  # avg_pnl = avg_win * (wr - lr / rr) β†’ solve for avg_win
304
  denom = win_rate - (lr / old_rr)
305
  if abs(denom) > 1e-6:
306
+ new_win = max(0.5, min(avg_pnl / denom, 20.0)) # cap at 20Γ— so corrupt rows can't inflate EV
307
  new_loss = max(0.1, new_win / old_rr)
308
  stats[sig] = (round(win_rate, 3), round(new_win, 2), round(new_loss, 2))
309
  except Exception:
 
358
  # ── LOG THROTTLE (avoid per-ticker warning spam) ────────────────────────────
359
  _LAST_NIFTY_WARN_TS = 0.0
360
  _NIFTY_WARN_TTL = 300 # seconds
361
+ _NIFTY_WARN_LOCK = threading.Lock()
362
 
363
 
364
  def _warn_nifty_unavailable_once() -> None:
365
  global _LAST_NIFTY_WARN_TS
366
  now = time.time()
367
+ with _NIFTY_WARN_LOCK:
368
+ if now - _LAST_NIFTY_WARN_TS >= _NIFTY_WARN_TTL:
369
+ logging.warning("Nifty market data unavailable β€” Nifty-dependent signals skipped (throttled)")
370
+ _LAST_NIFTY_WARN_TS = now
371
 
372
 
373
  def _get_market_cache(period: str = "1y"):
 
449
  making the current EMA200 level accurate. Requires 3 of the last 5 closes
450
  below the warm EMA200 to trigger CAUTION β€” majority-of-week rule filters noise."""
451
  nifty_c, _ = _get_market_cache("2y") # 2y β†’ EMA200 is fully warmed up
452
+ if nifty_c is None or len(nifty_c) < 3:
453
  return True, "Nifty data unavailable β€” assuming OK"
454
  ema200_s = nifty_c.ewm(span=200).mean()
455
  ema200 = float(ema200_s.iloc[-1])
 
473
  start = (datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d")
474
  mc = MacroContext()
475
  mc.load(start, end)
476
+ # _features is shifted by 1 day so today's date is never in the index.
477
+ # Use build_mask with ffill to get the most recent available T-1 row.
478
+ _today_ts = pd.Timestamp.now().normalize()
479
+ _mask = mc.build_mask(pd.DatetimeIndex([_today_ts]))
480
+ ok = bool(_mask.iloc[0]) if not _mask.empty else True
481
+ # Re-fetch the full feature row for the description.
482
+ feat = mc.get(_today_ts - pd.Timedelta(days=1))
483
+ if not feat:
484
+ # Fallback: scan backwards up to 5 days for a valid row.
485
+ for _d in range(1, 6):
486
+ feat = mc.get(_today_ts - pd.Timedelta(days=_d))
487
+ if feat:
488
+ break
489
  parts = []
490
  if not feat.get("sp500_trend", True):
491
  parts.append("S&P500 weak")
 
507
  return {"next_date": None, "days_away": None, "in_blackout": False, "warning": None}
508
 
509
  today = datetime.now().date()
510
+ # Sort descending so the nearest upcoming date is encountered first.
511
+ # yfinance returns earnings_dates in undocumented order β€” sorting avoids
512
+ # returning a distant date when a near one is also in the window.
513
+ for edate in sorted(ed.index, key=lambda d: abs((d.date() - today).days) if hasattr(d, "date") else abs((pd.Timestamp(d).date() - today).days)):
514
  edate_d = edate.date() if hasattr(edate, "date") else pd.Timestamp(edate).date()
515
  days_away = (edate_d - today).days
516
  if -window <= days_away <= 30:
 
1480
  elif _ml_score < 50:
1481
  _entry_buffer = _entry_buffer * 1.2 # 20% more conservative
1482
 
1483
+ _is_actionable_buy = direction in ("BULLISH", "SLIGHTLY BULLISH") and no_trade_reason is None
1484
+ _is_actionable_sell = direction in ("SELL", "BEARISH") and no_trade_reason is None
1485
  if _is_actionable_buy:
1486
  entry_price = round(price * (1 + _entry_buffer), 2)
1487
  entry_basis = "est_open_conservative" if _ml_score < 50 else "est_open"
 
1532
  # ── Phase 6: Price targets (Camarilla / ATR / PDH) ──────────────────────────
1533
  try:
1534
  from price_targets import get_price_targets
1535
+ _strategy_bias = "BULLISH" if direction in ("BULLISH", "SLIGHTLY BULLISH") else (
1536
  "BEARISH" if direction in ("SELL", "BEARISH") else "NEUTRAL")
1537
  price_targets_dict = get_price_targets(ticker, sc, sh, sl, _strategy_bias, confidence)
1538
  except Exception as _pt_err:
trial_run.py CHANGED
@@ -63,7 +63,7 @@ def adx_s(h, l, c, n=14):
63
  up = h.diff(); dn = -l.diff()
64
  pdm = up.where((up > dn) & (up > 0), 0.0)
65
  ndm = dn.where((dn > up) & (dn > 0), 0.0)
66
- at = atr(h, l, c, n)
67
  pdi = 100 * pdm.ewm(com=n-1).mean() / at
68
  ndi = 100 * ndm.ewm(com=n-1).mean() / at
69
  dx = 100 * (pdi - ndi).abs() / (pdi + ndi).replace(0, np.nan)
 
63
  up = h.diff(); dn = -l.diff()
64
  pdm = up.where((up > dn) & (up > 0), 0.0)
65
  ndm = dn.where((dn > up) & (dn > 0), 0.0)
66
+ at = atr(h, l, c, n).replace(0, np.nan) # guard: frozen stocks have ATR=0 β†’ inf/NaN in ADX
67
  pdi = 100 * pdm.ewm(com=n-1).mean() / at
68
  ndi = 100 * ndm.ewm(com=n-1).mean() / at
69
  dx = 100 * (pdi - ndi).abs() / (pdi + ndi).replace(0, np.nan)