Spaces:
Sleeping
fix: resolve 25 logic bugs across prediction engine, trading book, and DB layer
Browse filesHIGH:
- 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>
- ai_forecast.py +41 -21
- app.py +44 -21
- data_sources.py +4 -2
- database.py +58 -27
- fred_data.py +1 -1
- macro_context.py +7 -1
- predictor_core.py +28 -14
- trial_run.py +1 -1
|
@@ -699,22 +699,42 @@ def _parse_json_from_llm(text: str) -> Dict | None:
|
|
| 699 |
except Exception:
|
| 700 |
pass
|
| 701 |
if parsed is None:
|
| 702 |
-
#
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 1240 |
-
nifty_ok = bool(args[
|
| 1241 |
-
macro_ok = bool(args[
|
| 1242 |
-
vix_level = float(args[
|
| 1243 |
-
news = args[
|
| 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")
|
|
@@ -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,
|
| 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 |
-
|
|
|
|
| 798 |
|
| 799 |
-
|
|
|
|
| 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 |
-
|
| 829 |
-
|
|
|
|
| 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
|
| 1484 |
-
(direction == "SHORT" and live >= limit
|
| 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")
|
| 1671 |
lines.append(
|
| 1672 |
f"- {t.get('ticker','?')} | {t.get('direction','?')} | "
|
| 1673 |
-
f"Entry βΉ{_safe_float(t.get('entry_price')
|
| 1674 |
-
f"P&L {_safe_float(t.get('pnl_pct')
|
| 1675 |
)
|
| 1676 |
trade_list = "\n".join(lines)
|
| 1677 |
|
|
@@ -1705,11 +1723,7 @@ def portfolio_review():
|
|
| 1705 |
})
|
| 1706 |
|
| 1707 |
|
| 1708 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2466 |
-
(direction == "SHORT" and live >= limit
|
| 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 |
)
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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):
|
|
@@ -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
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
| 436 |
-
|
| 437 |
-
|
| 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,
|
| 510 |
with _conn() as conn:
|
| 511 |
conn.execute(
|
| 512 |
-
"
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 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 |
|
|
@@ -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) >=
|
| 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)
|
|
@@ -33,7 +33,8 @@ class MacroContext:
|
|
| 33 |
close = close.iloc[:, 0]
|
| 34 |
frames[name] = close.rename(name)
|
| 35 |
except Exception as e:
|
| 36 |
-
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
| 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)
|
| 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
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 499 |
-
|
|
|
|
|
|
|
| 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 ("
|
| 1470 |
-
_is_actionable_sell = direction in ("SELL", "BEARISH")
|
| 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 ("
|
| 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:
|
|
@@ -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)
|