Download engine/predict_weekly.py from s-ttp/crucible: direct link, hf CLI and curl.
- Browser
- Download file 47.4 kB
-
https://huggingface.co/spaces/s-ttp/crucible/resolve/main/engine/predict_weekly.py
- Command line
-
hf download hf://spaces/s-ttp/crucible/engine/predict_weekly.py
-
curl -L -o predict_weekly.py https://huggingface.co/spaces/s-ttp/crucible/resolve/main/engine/predict_weekly.py
47.4 kB
| #!/usr/bin/env python3 | |
| """ | |
| 1-Week Stock Price Predictor | |
| ============================ | |
| Technical Analysis + ML Ensemble (~25 features, quantile tail estimates) + Sentiment, | |
| with optional 3-panel LLM debate (annotation only — not blended into return). | |
| Usage: | |
| python3 predict_weekly.py AAPL | |
| python3 predict_weekly.py AAPL --llm | |
| python3 predict_weekly.py AAPL --no-cache | |
| python3 predict_weekly.py AAPL --verbose | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| os.environ.setdefault("PYTHONWARNINGS", "ignore") # suppress joblib/sklearn thread warnings | |
| import warnings | |
| warnings.filterwarnings("ignore") | |
| import re | |
| import sys | |
| import time | |
| from collections import Counter | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| import requests | |
| from sklearn.ensemble import ( | |
| GradientBoostingClassifier, | |
| RandomForestRegressor, | |
| ) | |
| from sklearn.linear_model import ElasticNet | |
| from sklearn.preprocessing import StandardScaler | |
| import sklearn_parallel_hotfix # noqa: F401 | |
| from xgboost import XGBRegressor | |
| # ─── Config ─────────────────────────────────────────────────────────────────── | |
| try: | |
| from config import ( | |
| ALPHA_VANTAGE_API_KEY as API_KEY, | |
| OPENAI_API_KEY, | |
| ANTHROPIC_API_KEY, | |
| KIMI_API_KEY, | |
| GEMINI_API_KEY, | |
| ) | |
| except ImportError: | |
| API_KEY = os.getenv("ALPHA_VANTAGE_API_KEY", "") | |
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") | |
| ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") | |
| KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") | |
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") | |
| BASE_URL = "https://www.alphavantage.co/query" | |
| CACHE_DIR = Path(__file__).parent / "cache" | |
| CACHE_DIR.mkdir(exist_ok=True) | |
| # ─── 4-role debate infrastructure (shared with predict_monthly) ────────────── | |
| try: | |
| from predict_monthly import ( | |
| _call_gemini_3_pro as _call_gemini_weekly, | |
| _call_claude_synthesis as _call_claude_synthesis_weekly, | |
| _NEUTRAL_SYSTEM_PROMPT as _NEUTRAL_SYSTEM_PROMPT_WEEKLY, | |
| _BEAR_SYSTEM_PROMPT as _BEAR_SYSTEM_PROMPT_WEEKLY, | |
| _BULL_SYSTEM_PROMPT as _BULL_SYSTEM_PROMPT_WEEKLY, | |
| _dedup_flags, | |
| ) | |
| _MONTHLY_DEBATE_AVAIL = True | |
| except ImportError: | |
| _MONTHLY_DEBATE_AVAIL = False | |
| _call_gemini_weekly = None | |
| _call_claude_synthesis_weekly = None | |
| _NEUTRAL_SYSTEM_PROMPT_WEEKLY = "" | |
| _BEAR_SYSTEM_PROMPT_WEEKLY = "" | |
| _BULL_SYSTEM_PROMPT_WEEKLY = "" | |
| def _dedup_flags(flags): | |
| seen, out = set(), [] | |
| for f in flags: | |
| k = f.lower().strip() | |
| if k not in seen: | |
| seen.add(k) | |
| out.append(f) | |
| return out | |
| # When universe composite_rank is available (deep scan), nudge ML return slightly. | |
| WEEKLY_XS_ML_ADJ_SCALE = float(os.environ.get("STOCK_REC_XS_ML_ADJ", "0.04")) | |
| # ─── ANSI colours ───────────────────────────────────────────────────────────── | |
| BOLD = "\033[1m" | |
| DIM = "\033[2m" | |
| GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| YELLOW = "\033[93m" | |
| CYAN = "\033[96m" | |
| MAGENTA = "\033[95m" | |
| BLUE = "\033[94m" | |
| WHITE = "\033[97m" | |
| BG_GREEN = "\033[42m" | |
| BG_RED = "\033[41m" | |
| BG_YELLOW= "\033[43m" | |
| RESET = "\033[0m" | |
| # ─── Highest-Return Mode Config (scan_weekly blend reference) ─────────────── | |
| WEEKLY_HR = { | |
| "weights": { | |
| "ml": 0.47, | |
| "tech": 0.38, | |
| "sent": 0.15, | |
| }, | |
| "hard_reject_earnings_days": 4, # hard pass if earnings within N days | |
| "hard_reject_adv_usd": 1_000_000, | |
| "llm_max_effective_weight_hr": 0.12, # legacy HR cap (blend uses LLM_MAX_WEIGHT) | |
| } | |
| # Legacy export for evaluate_weekly_ml.py (tail classifier removed; quantile model used). | |
| WEEKLY_TAIL_PROB_SHRINK = float(os.environ.get("STOCK_REC_TAIL_SHRINK", "0.07")) | |
| # ─── FOMC / Macro Event Calendar ───────────────────────────────────────────── | |
| # Announcement dates (Day 2 of meeting — when Fed decision is released) | |
| _FOMC_ANNOUNCEMENT_DATES = [ | |
| "2025-01-29", "2025-03-19", "2025-05-07", "2025-06-18", | |
| "2025-07-30", "2025-09-17", "2025-10-29", "2025-12-10", | |
| "2026-01-28", "2026-03-18", "2026-05-06", "2026-06-17", | |
| "2026-07-29", "2026-09-16", "2026-10-28", "2026-12-09", | |
| ] | |
| # CPI release dates (Bureau of Labor Statistics published schedule) | |
| _CPI_RELEASE_DATES = [ | |
| "2025-01-15", "2025-02-12", "2025-03-12", "2025-04-10", | |
| "2025-05-13", "2025-06-11", "2025-07-11", "2025-08-12", | |
| "2025-09-10", "2025-10-14", "2025-11-12", "2025-12-10", | |
| "2026-01-14", "2026-02-11", "2026-03-11", "2026-04-10", | |
| "2026-05-12", "2026-06-10", "2026-07-14", "2026-08-12", | |
| "2026-09-10", "2026-10-13", "2026-11-12", "2026-12-10", | |
| ] | |
| # ─── LLM Calibration Tracker ───────────────────────────────────────────────── | |
| _LLM_CAL_DIR = Path(__file__).parent / "cache" | |
| # ─── Rate limiting ──────────────────────────────────────────────────────────── | |
| _call_count = 0 | |
| _call_window = time.time() | |
| def _rate_limit(): | |
| global _call_count, _call_window | |
| _call_count += 1 | |
| elapsed = time.time() - _call_window | |
| if elapsed < 60 and _call_count >= 73: # premium: 75/min, leave 2 margin | |
| wait = 62 - elapsed | |
| print(f" {DIM}(rate limit pause {wait:.0f}s){RESET}", end="", flush=True) | |
| time.sleep(wait) | |
| _call_count = 0 | |
| _call_window = time.time() | |
| elif elapsed >= 60: | |
| _call_count = 1 | |
| _call_window = time.time() | |
| def safe_float(v, default=0.0): | |
| try: | |
| if v is None or str(v).strip() in ("", "None", "N/A", "-"): | |
| return default | |
| return float(str(v).replace(",", "")) | |
| except (ValueError, TypeError): | |
| return default | |
| def api_call(params, label="", cache_key=None, cache_ttl=14400, no_cache=False): | |
| """Alpha Vantage API call with optional file caching.""" | |
| params["apikey"] = API_KEY | |
| if cache_key and not no_cache: | |
| cf = CACHE_DIR / f"{cache_key}.json" | |
| if cf.exists(): | |
| try: | |
| meta_f = CACHE_DIR / f"{cache_key}.meta.json" | |
| if meta_f.exists(): | |
| meta = json.loads(meta_f.read_text()) | |
| age = (datetime.now() - datetime.fromisoformat(meta["cached_at"])).total_seconds() | |
| if age < cache_ttl: | |
| return json.loads(cf.read_text()) | |
| except Exception: | |
| pass | |
| _rate_limit() | |
| try: | |
| resp = requests.get(BASE_URL, params=params, timeout=30) | |
| data = resp.json() | |
| except Exception as e: | |
| print(f" {RED}API error: {e}{RESET}") | |
| return {} | |
| if cache_key: | |
| cf = CACHE_DIR / f"{cache_key}.json" | |
| cf.write_text(json.dumps(data)) | |
| (CACHE_DIR / f"{cache_key}.meta.json").write_text( | |
| json.dumps({"cached_at": datetime.now().isoformat()}) | |
| ) | |
| return data | |
| # ─── Data Fetching ──────────────────────────────────────────────────────────── | |
| def fetch_intraday_volume_profile(ticker, no_cache=False): | |
| """Fetch 15-min bars for last 5 days → compute volume profile features. | |
| Returns dict with: | |
| poc_distance, value_area_position, high_volume_node_support (as hvn_support) | |
| """ | |
| raw = api_call( | |
| { | |
| "function": "TIME_SERIES_INTRADAY", | |
| "symbol": ticker, | |
| "interval": "15min", | |
| "outputsize": "full", | |
| "entitlement": "delayed", | |
| }, | |
| cache_key=f"{ticker}_intraday_15min", | |
| cache_ttl=3600, | |
| no_cache=no_cache, | |
| ) | |
| ts = raw.get("Time Series (15min)", {}) | |
| if not ts or len(ts) < 20: | |
| return None | |
| prices = [] | |
| volumes = [] | |
| for d, v in ts.items(): | |
| typical = (float(v["2. high"]) + float(v["3. low"]) + float(v["4. close"])) / 3 | |
| vol = float(v["5. volume"]) | |
| prices.append(typical) | |
| volumes.append(vol) | |
| if not prices: | |
| return None | |
| prices = np.array(prices) | |
| volumes = np.array(volumes) | |
| current_price = prices[0] | |
| n_bins = 30 | |
| price_min, price_max = prices.min(), prices.max() | |
| if price_max - price_min < 0.01: | |
| return None | |
| bin_edges = np.linspace(price_min, price_max, n_bins + 1) | |
| bin_volumes = np.zeros(n_bins) | |
| bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 | |
| for p, v in zip(prices, volumes): | |
| idx = min(int((p - price_min) / (price_max - price_min) * n_bins), n_bins - 1) | |
| bin_volumes[idx] += v | |
| poc_idx = int(np.argmax(bin_volumes)) | |
| poc_price = float(bin_centers[poc_idx]) | |
| poc_distance = (current_price - poc_price) / (current_price + 1e-12) | |
| total_vol = bin_volumes.sum() | |
| target_vol = total_vol * 0.70 | |
| lo, hi = poc_idx, poc_idx | |
| area_vol = bin_volumes[poc_idx] | |
| while area_vol < target_vol and (lo > 0 or hi < n_bins - 1): | |
| expand_lo = bin_volumes[lo - 1] if lo > 0 else 0 | |
| expand_hi = bin_volumes[hi + 1] if hi < n_bins - 1 else 0 | |
| if expand_lo >= expand_hi and lo > 0: | |
| lo -= 1 | |
| area_vol += bin_volumes[lo] | |
| elif hi < n_bins - 1: | |
| hi += 1 | |
| area_vol += bin_volumes[hi] | |
| else: | |
| if lo > 0: | |
| lo -= 1 | |
| area_vol += bin_volumes[lo] | |
| else: | |
| break | |
| va_low = float(bin_edges[lo]) | |
| va_high = float(bin_edges[hi + 1]) | |
| if current_price > va_high: | |
| va_position = 1.0 | |
| elif current_price < va_low: | |
| va_position = -1.0 | |
| else: | |
| va_position = 0.0 | |
| avg_bin_vol = total_vol / n_bins | |
| hvn_mask = bin_volumes > 1.5 * avg_bin_vol | |
| hvn_centers = bin_centers[hvn_mask] | |
| hvn_support = 0.0 | |
| if len(hvn_centers) > 0: | |
| below = hvn_centers[hvn_centers < current_price] | |
| above = hvn_centers[hvn_centers > current_price] | |
| nearest_below = float(below.max()) if len(below) > 0 else None | |
| nearest_above = float(above.min()) if len(above) > 0 else None | |
| if nearest_below is not None and nearest_above is not None: | |
| dist_below = current_price - nearest_below | |
| dist_above = nearest_above - current_price | |
| hvn_support = 1.0 if dist_below < dist_above else -1.0 | |
| elif nearest_below is not None: | |
| hvn_support = 1.0 | |
| elif nearest_above is not None: | |
| hvn_support = -1.0 | |
| return { | |
| "poc_distance": float(poc_distance), | |
| "value_area_position": float(va_position), | |
| "hvn_support": float(hvn_support), | |
| } | |
| # ─── ENH-2: Options Flow Extraction ───────────────────────────────────────── | |
| # Sector ETF mapping for sector rotation signal | |
| _SECTOR_ETFS = { | |
| "Technology": "XLK", "Information Technology": "XLK", | |
| "Health Care": "XLV", "Healthcare": "XLV", | |
| "Financials": "XLF", "Financial Services": "XLF", | |
| "Consumer Discretionary": "XLY", "Consumer Cyclical": "XLY", | |
| "Consumer Staples": "XLP", "Consumer Defensive": "XLP", | |
| "Energy": "XLE", | |
| "Industrials": "XLI", | |
| "Materials": "XLB", "Basic Materials": "XLB", | |
| "Utilities": "XLU", | |
| "Real Estate": "XLRE", | |
| "Communication Services": "XLC", "Communication": "XLC", | |
| } | |
| # ─── Technical Analysis (Weekly-tuned) ─────────────────────────────────────── | |
| # ─── Market Regime Detection ───────────────────────────────────────────────── | |
| # ─── ML Ensemble (5-day target) ─────────────────────────────────────────────── | |
| def _sigmoid_prob(quantile_value, threshold, scale=25.0, cap=0.80): | |
| """Smooth mapping from quantile prediction to tail probability. | |
| When quantile_value == threshold: output ≈ 0.50 × cap | |
| When quantile_value >> threshold: output approaches cap | |
| When quantile_value << threshold: output approaches 0 | |
| scale controls steepness: higher = sharper transition around threshold. | |
| """ | |
| x = (quantile_value - threshold) * scale | |
| return float(np.clip(cap / (1.0 + np.exp(-x)), 0.0, cap)) | |
| # ─── Sentiment (weekly-focused) ─────────────────────────────────────────────── | |
| # ─── LLM walk-forward logging ────────────────────────────────────────────────── | |
| _LLM_JUDGE_LOG = CACHE_DIR / "llm_judge_log.jsonl" | |
| # ─── Scan outcome tracker ────────────────────────────────────────────────────── | |
| # Logs every Stage-3-validated pick → resolves actual 5d return after 7 days | |
| # → computes per-confidence-bucket calibration stats fed into the Stage 4 selector. | |
| _SCAN_PICK_LOG = CACHE_DIR / "scan_pick_log.jsonl" | |
| # ─── Earnings proximity check ───────────────────────────────────────────────── | |
| def check_earnings_proximity(data): | |
| """Return (days_to_earnings, date_str, warning_str). | |
| Uses EARNINGS_CALENDAR first (reliable), falls back to OVERVIEW.NextEarningsDate.""" | |
| # Primary: EARNINGS_CALENDAR | |
| for row in data.get("earnings_calendar", []): | |
| ed = row.get("reportDate", "") | |
| if not ed: | |
| continue | |
| try: | |
| earn_date = datetime.strptime(ed, "%Y-%m-%d") | |
| days_away = (earn_date - datetime.now()).days | |
| if days_away < 0: | |
| continue # past earnings | |
| warn = (f"⚠ Earnings in {days_away} day{'s' if days_away != 1 else ''} ({ed})" | |
| f" — high uncertainty, confidence bands widened" | |
| if days_away <= 10 else "") | |
| return days_away, ed, warn | |
| except Exception: | |
| continue | |
| # Fallback: OVERVIEW.NextEarningsDate | |
| ed = data.get("overview", {}).get("NextEarningsDate", "") | |
| if ed: | |
| try: | |
| earn_date = datetime.strptime(ed, "%Y-%m-%d") | |
| days_away = (earn_date - datetime.now()).days | |
| if 0 <= days_away: | |
| warn = (f"⚠ Earnings in {days_away} day{'s' if days_away != 1 else ''} ({ed})" | |
| f" — high uncertainty, confidence bands widened" | |
| if days_away <= 10 else "") | |
| return days_away, ed, warn | |
| except Exception: | |
| pass | |
| return None, "unknown", "" | |
| def _is_earnings_season_day(dt): | |
| """Check if a date falls within ±3 trading days of typical earnings season windows. | |
| Earnings seasons: Jan wk2-5, Apr wk2-5, Jul wk2-5, Oct wk2-5.""" | |
| if not hasattr(dt, 'month'): | |
| return False | |
| return dt.month in (1, 4, 7, 10) and 8 <= dt.day <= 31 | |
| def compute_post_earnings_drift_score(overview, daily, days_earn, earnings_estimates=None): | |
| """Score post-earnings drift potential: [-0.05, +0.05] with label. | |
| (a) Stock just reported (1–8 days ago): continuation drift from beat/miss. | |
| (b) Earnings due in 5–12 days: pre-earnings run based on historical surprises. | |
| (c) All other cases: neutral. | |
| Quality filter: only counts moves ≥3% within ±3 trading days of known | |
| earnings season windows (Jan/Apr/Jul/Oct wk2-5). Requires ≥2 qualifying | |
| events; returns neutral with note='insufficient_events' otherwise. | |
| Returns (score: float, label: str, meta: dict). | |
| """ | |
| _neutral = (0.0, "NEUTRAL", {"proxy_quality": "low", "note": ""}) | |
| if daily is None or len(daily) < 10: | |
| return _neutral | |
| close = daily["adj_close"] if "adj_close" in daily.columns else daily.get("close") | |
| if close is None or len(close) < 5: | |
| return _neutral | |
| rets = close.pct_change() | |
| # If actual earnings estimates available, use revision direction (pre-earnings window) | |
| if earnings_estimates and len(earnings_estimates) >= 2: | |
| try: | |
| meta_est = {"proxy_quality": "estimates", "note": ""} | |
| current_est = safe_float( | |
| earnings_estimates[0].get("estimatedEPS", earnings_estimates[0].get("estimate", 0)) | |
| ) | |
| prev_est = safe_float( | |
| earnings_estimates[1].get("estimatedEPS", earnings_estimates[1].get("estimate", 0)) | |
| ) | |
| if prev_est != 0: | |
| revision_pct = (current_est - prev_est) / abs(prev_est) | |
| meta_est["estimate_revision"] = round(revision_pct, 4) | |
| if days_earn is not None and 5 <= days_earn <= 15: | |
| if revision_pct > 0.02: | |
| score = float(np.clip(0.03, -0.05, 0.05)) | |
| meta_est["note"] = f"upward revision {revision_pct:.1%}" | |
| return score, "DRIFT_BUY", meta_est | |
| if revision_pct < -0.02: | |
| score = float(np.clip(-0.03, -0.05, 0.05)) | |
| meta_est["note"] = f"downward revision {revision_pct:.1%}" | |
| return score, "DRIFT_AVOID", meta_est | |
| except Exception: | |
| pass | |
| # Identify qualifying events: |return| >= 3% within earnings season windows | |
| qualifying = [] | |
| for i in range(len(rets)): | |
| r = float(rets.iloc[i]) | |
| if abs(r) < 0.03: | |
| continue | |
| dt = rets.index[i] | |
| # Check if within ±3 trading days of earnings season | |
| in_season = False | |
| for offset in range(-3, 4): | |
| check_idx = i + offset | |
| if 0 <= check_idx < len(rets): | |
| if _is_earnings_season_day(rets.index[check_idx]): | |
| in_season = True | |
| break | |
| if in_season: | |
| qualifying.append((dt, r)) | |
| n_events = len(qualifying) | |
| if n_events < 3: | |
| pq = "low" | |
| elif n_events <= 6: | |
| pq = "medium" | |
| else: | |
| pq = "high" | |
| meta = {"proxy_quality": pq, "note": "", "n_events": n_events} | |
| if n_events < 2: | |
| meta["note"] = "insufficient_events" | |
| return 0.0, "NEUTRAL", meta | |
| score = 0.0 | |
| label = "NEUTRAL" | |
| # (a) Post-report window: stock reported 1-8 days ago | |
| if days_earn is not None and days_earn > 12: | |
| pass | |
| elif days_earn is None or days_earn > 12: | |
| recent_qualifying = [(dt, r) for dt, r in qualifying | |
| if (rets.index[-1] - dt).days <= 12] | |
| if recent_qualifying: | |
| last_r = recent_qualifying[-1][1] | |
| if last_r > 0: | |
| score += 0.03 | |
| label = "DRIFT_BUY" | |
| else: | |
| score -= 0.03 | |
| label = "DRIFT_AVOID" | |
| # (b) Pre-earnings window: 5-12 days out | |
| if days_earn is not None and 5 <= days_earn <= 12: | |
| last_events = qualifying[-min(4, n_events):] | |
| median_surprise = float(np.median([r for _, r in last_events])) | |
| if median_surprise > 0.02: | |
| score += 0.02 | |
| label = "DRIFT_BUY" | |
| elif median_surprise < -0.02: | |
| score -= 0.02 | |
| label = "DRIFT_AVOID" | |
| score = float(np.clip(score, -0.05, 0.05)) | |
| return score, label, meta | |
| # ─── LLM Templates (weekly-tuned) ───────────────────────────────────────────── | |
| _LLM_SYSTEM_PROMPT_WEEKLY = """You are an expert quantitative equity analyst specialising in SHORT-TERM (1-week / 5 trading day) price prediction. You are given technical analysis, ML ensemble, and sentiment inputs for a stock. Your job is to synthesise these signals and predict where the price will be in 5 trading days. | |
| Key principles for 1-week horizon: | |
| - Fundamentals do NOT drive weekly price moves — ignore any fundamental context | |
| - Mean-reversion is STRONGER at 1 week: oversold stocks tend to bounce, overbought tend to pull back | |
| - Volume confirmation is critical: price moves on above-average volume are more reliable | |
| - Gaps tend to fill within 1 week — a gap down is often a near-term buying opportunity | |
| - Support/resistance levels matter more at 1 week than at 1 month | |
| - Earnings within 7 days = high uncertainty — widen your confidence and flag this | |
| - ML provides statistical extrapolation — trust it more when it agrees with technicals | |
| - Be sceptical of extreme weekly moves (>5%). Most stocks don't move that much in 1 week | |
| - Your confidence should be LOW when signals strongly disagree | |
| Recommendation guidelines (1-week specific): | |
| - STRONG BUY: High-conviction upside >3% in 1 week with strong technical alignment | |
| - BUY: Expected upside >1% with reasonable confidence | |
| - HOLD: Expected return -1% to +1%, or high uncertainty / mixed signals | |
| - SELL: Expected downside >1% with reasonable confidence | |
| - STRONG SELL: High-conviction downside >3% with multiple bearish signals confirming | |
| You MUST respond with valid JSON only. No markdown, no explanation outside JSON.""" | |
| _LLM_USER_TEMPLATE_WEEKLY = """Analyse {ticker} (1-WEEK / 5-day forecast) and synthesise these quantitative signals: | |
| CURRENT PRICE: ${current_price:.2f} | |
| DATE: {date} | |
| {signals_text} | |
| Respond with ONLY this JSON (no markdown, no extra text): | |
| {{ | |
| "expected_return_pct": <float: your predicted weekly return in percent, e.g. 1.5 for +1.5%>, | |
| "predicted_price": <float: your 5-day price target, e.g. 175.50>, | |
| "recommendation": "<STRONG BUY|BUY|HOLD|SELL|STRONG SELL>", | |
| "confidence": <float: 0.0 to 1.0>, | |
| "direction": "<BULLISH|BEARISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: which signals you weighted most and why, key risk to your view>", | |
| "risk_flags": ["<list of key short-term risks>"], | |
| "override_ml": <boolean: true if you think ML is significantly wrong for this week> | |
| }}""" | |
| _DEBATE_SYSTEM_PROMPT_WEEKLY = """You are an expert quantitative equity analyst specialising in 1-week (5-day) price prediction. You are participating in a panel debate. You have already made an independent prediction. Now you can see the other analysts' predictions and reasoning. | |
| You must: | |
| 1. Consider whether the other analysts identified signals you underweighted or missed | |
| 2. Either DEFEND your original position with specific data points, or REVISE it | |
| 3. Address the strongest counterargument from the other analysts | |
| 4. Update your price target and recommendation if your view changes | |
| 5. Keep the 1-week horizon in mind — mean-reversion and volume signals matter most | |
| You MUST respond with valid JSON only. No markdown, no explanation outside JSON.""" | |
| _DEBATE_USER_TEMPLATE_WEEKLY = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: 1-WEEK (5 trading days) | |
| YOUR ORIGINAL POSITION: | |
| Return: {my_return:+.2f}% | Price Target: ${my_price:.2f} | Recommendation: {my_recommendation} | |
| Direction: {my_direction} | Confidence: {my_confidence:.0f}% | |
| Reasoning: {my_reasoning} | |
| OTHER ANALYSTS' POSITIONS: | |
| {other_positions} | |
| Based on this debate, provide your REVISED (or defended) position. | |
| Respond with ONLY this JSON (no markdown): | |
| {{ | |
| "expected_return_pct": <float>, | |
| "predicted_price": <float>, | |
| "recommendation": "<STRONG BUY|BUY|HOLD|SELL|STRONG SELL>", | |
| "confidence": <float: 0.0 to 1.0>, | |
| "direction": "<BULLISH|BEARISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: what changed or why you held firm>", | |
| "risk_flags": ["<updated risks>"], | |
| "override_ml": <boolean>, | |
| "changed_view": <boolean: true if you meaningfully revised your position> | |
| }}""" | |
| # ─── 4-role persona templates (1-week horizon) ─────────────────────────────── | |
| _NEUTRAL_USER_TEMPLATE_WEEKLY = """Analyze {ticker} (1-WEEK / 5-day forecast) and provide an objective, unbiased assessment: | |
| CURRENT: ${current_price:.2f} | |
| {signals_text} | |
| As the NEUTRAL ANALYST, report what the data shows without advocacy. | |
| Respond with ONLY this JSON: | |
| {{ | |
| "expected_return_pct": <float: your data-driven return estimate>, | |
| "predicted_price": <float>, | |
| "recommendation": "<STRONG BUY|BUY|HOLD|SELL|STRONG SELL>", | |
| "confidence": <float: 0.0-1.0>, | |
| "direction": "<BULLISH|BEARISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: key bullish signals, key bearish signals, net assessment with specific numbers>", | |
| "risk_flags": ["<top signals on both sides>"], | |
| "override_ml": <boolean> | |
| }}""" | |
| _BEAR_USER_TEMPLATE_WEEKLY = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: 1-WEEK | |
| {signals_text} | |
| As the RISK MANAGER, make the strongest possible bear case. | |
| Respond with ONLY this JSON: | |
| {{ | |
| "expected_return_pct": <float: your bear-case return estimate, typically negative or low positive>, | |
| "predicted_price": <float>, | |
| "recommendation": "<HOLD|SELL|STRONG SELL>", | |
| "confidence": <float: 0.0-1.0>, | |
| "direction": "<BEARISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: core bear thesis with specific data points>", | |
| "risk_flags": ["<top 4-5 specific risks with numbers>"], | |
| "override_ml": <boolean> | |
| }}""" | |
| _BULL_USER_TEMPLATE_WEEKLY = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: 1-WEEK | |
| {signals_text} | |
| As the GROWTH ANALYST, make the strongest possible bull case. | |
| Respond with ONLY this JSON: | |
| {{ | |
| "expected_return_pct": <float: your bull-case return estimate, typically positive>, | |
| "predicted_price": <float>, | |
| "recommendation": "<HOLD|BUY|STRONG BUY>", | |
| "confidence": <float: 0.0-1.0>, | |
| "direction": "<BULLISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: core bull thesis with specific data points>", | |
| "risk_flags": ["<top 4-5 upside catalysts with numbers>"], | |
| "override_ml": <boolean> | |
| }}""" | |
| _BEAR_REBUTTAL_TEMPLATE_WEEKLY = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: 1-WEEK | |
| NEUTRAL ANALYST VIEW (objective baseline): | |
| Return: {neutral_return:+.2f}% | Recommendation: {neutral_rec} | Confidence: {neutral_conf:.0f}% | |
| Reasoning: {neutral_reasoning} | |
| YOUR BEAR POSITION (Round 2): | |
| Return: {my_return:+.2f}% → ${my_price:.2f} | Recommendation: {my_rec} | |
| Thesis: {my_reasoning} | |
| GROWTH ANALYST (Bull) counters with: | |
| Return: {bull_return:+.2f}% → ${bull_price:.2f} | Recommendation: {bull_rec} | Confidence: {bull_conf:.0f}% | |
| Thesis: {bull_reasoning} | |
| Key Catalysts: {bull_catalysts} | |
| Attack the bull's most optimistic assumptions with specific data. Defend your bear thesis against their strongest counterargument. | |
| Respond with ONLY this JSON: | |
| {{ | |
| "expected_return_pct": <float>, | |
| "predicted_price": <float>, | |
| "recommendation": "<HOLD|SELL|STRONG SELL>", | |
| "confidence": <float: 0.0-1.0>, | |
| "direction": "<BEARISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: attack bull thesis + defend bear thesis with specific numbers>", | |
| "risk_flags": ["<key risks>"], | |
| "override_ml": <boolean>, | |
| "changed_view": <boolean> | |
| }}""" | |
| _BULL_REBUTTAL_TEMPLATE_WEEKLY = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: 1-WEEK | |
| NEUTRAL ANALYST VIEW (objective baseline): | |
| Return: {neutral_return:+.2f}% | Recommendation: {neutral_rec} | Confidence: {neutral_conf:.0f}% | |
| Reasoning: {neutral_reasoning} | |
| YOUR BULL POSITION (Round 2): | |
| Return: {my_return:+.2f}% → ${my_price:.2f} | Recommendation: {my_rec} | |
| Thesis: {my_reasoning} | |
| RISK MANAGER (Bear) counters with: | |
| Return: {bear_return:+.2f}% → ${bear_price:.2f} | Recommendation: {bear_rec} | Confidence: {bear_conf:.0f}% | |
| Thesis: {bear_reasoning} | |
| Key Risks: {bear_risks} | |
| Attack the bear's most pessimistic assumptions with specific data. Defend your bull thesis against their strongest counterargument. | |
| Respond with ONLY this JSON: | |
| {{ | |
| "expected_return_pct": <float>, | |
| "predicted_price": <float>, | |
| "recommendation": "<HOLD|BUY|STRONG BUY>", | |
| "confidence": <float: 0.0-1.0>, | |
| "direction": "<BULLISH|NEUTRAL>", | |
| "reasoning": "<2-3 sentences: attack bear thesis + defend bull thesis with specific numbers>", | |
| "risk_flags": ["<key catalysts>"], | |
| "override_ml": <boolean>, | |
| "changed_view": <boolean> | |
| }}""" | |
| _PM_SYNTHESIS_USER_TEMPLATE_WEEKLY = """STOCK: {ticker} | CURRENT: ${current_price:.2f} | HORIZON: 1-WEEK | |
| NEUTRAL ANALYST VIEW (objective baseline — no advocacy): | |
| Return: {neutral_return:+.2f}% | Recommendation: {neutral_rec} | Confidence: {neutral_conf:.0f}% | |
| Reasoning: {neutral_reasoning} | |
| RISK MANAGER (Bear) final position after debate: | |
| Return: {bear_return:+.2f}% → ${bear_price:.2f} | Recommendation: {bear_rec} | Confidence: {bear_conf:.0f}% | |
| Thesis: {bear_reasoning} | |
| Key Risks: {bear_risks} | |
| GROWTH ANALYST (Bull) final position after debate: | |
| Return: {bull_return:+.2f}% → ${bull_price:.2f} | Recommendation: {bull_rec} | Confidence: {bull_conf:.0f}% | |
| Thesis: {bull_reasoning} | |
| Key Catalysts: {bull_catalysts} | |
| Assign probability weights to each scenario. The neutral view is your anchor. | |
| Constraints: p_bear ∈ [0.10, 0.65], p_bull ∈ [0.10, 0.65], p_neutral = 1 - p_bear - p_bull ≥ 0.05 | |
| Respond with ONLY this JSON: | |
| {{ | |
| "p_bear": <float>, | |
| "p_bull": <float>, | |
| "p_neutral": <float>, | |
| "conviction": <float: 0.0-1.0, confidence in these weights>, | |
| "reasoning": "<2-3 sentences: which specific arguments moved your probabilities and why>", | |
| "risk_flags": ["<combined key risks from both cases>"], | |
| "override_ml": <boolean> | |
| }}""" | |
| # ─── Signal formatter for LLM ───────────────────────────────────────────────── | |
| # ─── LLM call functions (reused from monthly pattern) ───────────────────────── | |
| # ─── 3-Panel LLM Debate ─────────────────────────────────────────────────────── | |
| # ─── 3-Panel Signal Judge (scan Stage 4 — PASS/FLAG/REJECT, no price prediction) ─ | |
| _JUDGE_SYSTEM_ROUND1 = """You are an expert equity risk analyst evaluating a QUANTITATIVE weekly trade signal for a human operator. | |
| Your job is NOT to predict returns. You assess whether the quantitative signal is trustworthy and assign a CONFIDENCE ADJUSTMENT — a percentage by which you would modify the quant model's pick confidence. | |
| CRITICAL CALIBRATION FOR 1-WEEK HORIZON: | |
| - Weekly predictions have inherently lower signal-to-noise than monthly or yearly. Do NOT apply monthly/yearly accuracy standards. | |
| - Walk-forward OOS samples are typically 4-6 windows. With 5 samples, dir_acc can only be 0%, 20%, 40%, 60%, 80%, or 100%. The difference between 40% (2/5) and 60% (3/5) is ONE SINGLE PREDICTION. Do NOT treat 40% dir_acc from 5 samples as "poor" — it is statistically indistinguishable from random and from 60%. | |
| - Dir_acc ≥ 40% with 5 OOS samples is ACCEPTABLE for weekly. Only dir_acc = 20% (1/5) or 0% (0/5) warrants concern. | |
| - Signal STABILITY (directional consistency across walk-forward windows) is MORE informative than dir_acc for weekly signals. A stock with 100% stability (all windows predict same direction) has a coherent signal even if dir_acc = 40%. | |
| - Earnings within the forecast window (≤7 days) is a RISK FACTOR that widens uncertainty. It is NOT an automatic rejection. Many profitable weekly trades occur in the 5-10 days before earnings (anticipation drift). Only reject if earnings combine with multiple OTHER severe risks. | |
| - RSI 65-75 is normal momentum, not overbought. RSI 75-85 is elevated. RSI > 85 is extreme. | |
| - A stock selected by a momentum scanner WILL have positive momentum. Don't penalize the trait that selected it — instead check whether that momentum is sustainable or exhausted. | |
| - When blend return and WF-pred AGREE in direction and are within 2x of each other, this is a POSITIVE signal of model consistency. Do NOT penalize for "low OOS samples" if the model shows directional agreement. | |
| - Risk score below 0.40 is LOW RISK. Do not flag stocks with risk < 0.40 unless there are specific structural concerns. | |
| PASS means: the signal is tradeable at full or near-full size. Use PASS when: | |
| - Stability is high (≥60%) and blend is positive | |
| - Dir_acc is ≥40% (with ≤6 OOS samples) or ≥50% (with >6 samples) | |
| - Risk score is below 0.50 | |
| - No structural concerns (fraud, halted, broken data) | |
| FLAG means: trade with reduced size. Use FLAG when: | |
| - Dir_acc is low (20%) OR stability is very low (<40%) | |
| - Earnings within 3 days AND high volatility | |
| - Blend and WF-pred disagree significantly in direction | |
| - Risk score is 0.50-0.75 | |
| REJECT is reserved for: data quality issues, circuit breaker / halted stock, known fraud / SEC investigation, dir_acc = 0% or 20% with high severity, or blend/WF-pred showing strongly conflicting directions with high risk. | |
| Your confidence adjustment should be: | |
| +5 to +15: Signal looks clean — stability high, blend/WF agree, low risk | |
| -5 to -15: Minor risks — slightly elevated risk, or moderate earnings proximity | |
| -15 to -25: Significant risks — WF-pred/blend disagree, OR dir_acc=20%, OR earnings within 3 days with high vol | |
| -25 to -40: Severe risks — multiple major flags combining | |
| Below -40: Critical risks, do not trade | |
| Respond with valid JSON only. No markdown.""" | |
| _JUDGE_USER_ROUND1 = """CANDIDATE PROFILE (1-week horizon): | |
| {ticker} @ ${current_price:.2f} | |
| Blended expected return: {blend_ret_pct:+.2f}% | |
| Walk-forward prediction (OOS mean): {wf_pred_pct:+.2f}% | |
| Direction: {direction} | Confidence: {confidence:.0f}% | |
| Risk score (0-1, higher=worse): {risk_score:.2f} | |
| Pick confidence (walk-forward): {pick_confidence:.1%} | |
| Dir accuracy (historical windows): {dir_accuracy:.1%} | |
| Signal stability: {signal_stability:.1%} | |
| OOS samples: {oos_samples} | |
| P(return>4%): {p4:.1%} | P(return>0%): {ppos:.1%} | |
| Earnings: {earn_str} | |
| History: {history_days} days of price data | |
| Key technicals: RSI {rsi:.0f} | BB%B {bb:.2f} | Mom5d {mom5:+.2f}% | |
| {extra_context} | |
| Respond with ONLY this JSON: | |
| {{ | |
| "confidence_adjustment_pct": <int: -50 to +15, how much to adjust pick_confidence>, | |
| "verdict": "<PASS|FLAG|REJECT>", | |
| "severity": <int: 1-5, where 1=trivial and 5=critical>, | |
| "risk_flags": ["<short flag>", "..."], | |
| "reasoning": "<1-2 sentences>", | |
| "confidence": <float 0-1, your subjective confidence in this assessment> | |
| }}""" | |
| _JUDGE_SYSTEM_DEBATE = """You are the same risk analyst. Other models published their verdicts and confidence adjustments. You may revise your judgment. | |
| WEEKLY CALIBRATION REMINDER: | |
| - With 4-8 OOS samples, dir_acc of 40-50% is NORMAL for weekly signals — do not anchor on this as negative. | |
| - If another analyst flagged "low directional accuracy" but OOS samples are ≤6, consider whether this criticism is statistically meaningful. | |
| - Stability (directional agreement across windows) is MORE informative than dir_acc for weekly. | |
| - Avoid anchoring on the most negative assessment. Check if the harshest critic identified a genuine structural risk or just penalized normal weekly noise. | |
| - It is OK to UPGRADE from FLAG to PASS if the harshest risk flag is "low dir_acc" from few samples. | |
| Focus on: Did other analysts identify risks you missed? Are they being too harsh on normal-for-weekly factors like low OOS count, momentum, or earnings proximity? | |
| JSON only.""" | |
| _JUDGE_USER_DEBATE = """STOCK: {ticker} | |
| YOUR ROUND-1 POSITION: | |
| Verdict: {my_verdict} | Adjustment: {my_adjustment:+d}% | Severity: {my_severity}/5 | |
| Reasoning: {my_reasoning} | |
| OTHER ANALYSTS: | |
| {other_positions} | |
| Respond with ONLY this JSON: | |
| {{ | |
| "confidence_adjustment_pct": <int: -50 to +15>, | |
| "verdict": "<PASS|FLAG|REJECT>", | |
| "severity": <int: 1-5>, | |
| "risk_flags": ["..."], | |
| "reasoning": "<1-2 sentences>", | |
| "confidence": <float 0-1> | |
| }}""" | |
| def _parse_judge_response_weekly(raw, tokens_in, tokens_out, model_name): | |
| if not raw: | |
| return None | |
| cleaned = raw.strip() | |
| if "```" in cleaned: | |
| cleaned = re.sub(r"```(?:json)?\s*", "", cleaned).strip() | |
| cleaned = re.sub(r"```\s*$", "", cleaned).strip() | |
| try: | |
| data = json.loads(cleaned) | |
| except json.JSONDecodeError: | |
| m = re.search(r"\{[^{}]*\"verdict\"[^{}]*\}", cleaned, re.DOTALL) | |
| if not m: | |
| return None | |
| try: | |
| data = json.loads(m.group()) | |
| except Exception: | |
| return None | |
| v = (data.get("verdict") or "FLAG").upper().strip() | |
| if v not in ("PASS", "FLAG", "REJECT"): | |
| v = "FLAG" | |
| try: | |
| adj_raw = data.get("confidence_adjustment_pct", -10) | |
| confidence_adjustment_pct = int( | |
| np.clip(int(round(float(adj_raw if adj_raw is not None else -10))), -50, 15) | |
| ) | |
| except (TypeError, ValueError): | |
| confidence_adjustment_pct = -10 | |
| try: | |
| severity = int(np.clip(int(data.get("severity", 3)), 1, 5)) | |
| except (TypeError, ValueError): | |
| severity = 3 | |
| return { | |
| "verdict": v, | |
| "confidence": float(np.clip(float(data.get("confidence", 0.5)), 0.0, 1.0)), | |
| "confidence_adjustment_pct": confidence_adjustment_pct, | |
| "severity": severity, | |
| "risk_flags": list(data.get("risk_flags") or [])[:8], | |
| "reasoning": str(data.get("reasoning", ""))[:500], | |
| "model": model_name, | |
| "tokens_in": tokens_in, | |
| "tokens_out": tokens_out, | |
| } | |
| def _moonshot_assistant_text(message) -> str: | |
| """Moonshot Kimi K2.6 may return an empty `content` when thinking uses the token budget. | |
| Fall back to `reasoning_content` / `reasoning` so the judge JSON can still be parsed. | |
| See: Moonshot thinking models — JSON may appear after reasoning trace.""" | |
| for attr in ("content", "reasoning_content", "reasoning"): | |
| v = getattr(message, attr, None) | |
| if isinstance(v, str) and v.strip(): | |
| return v.strip() | |
| return "" | |
| def _raw_gpt_judge(user_msg, system, verbose=False): | |
| if not OPENAI_API_KEY: | |
| return None, 0, 0 | |
| from openai import OpenAI | |
| client = OpenAI(api_key=OPENAI_API_KEY) | |
| response = client.chat.completions.create( | |
| model="gpt-5.5", | |
| messages=[ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user_msg}, | |
| ], | |
| temperature=1.0, max_completion_tokens=4000, | |
| ) | |
| raw = (response.choices[0].message.content or "").strip() | |
| ti, to = response.usage.prompt_tokens, response.usage.completion_tokens | |
| if verbose: | |
| print(f" {DIM}GPT judge: {ti} in / {to} out{RESET}") | |
| return raw, ti, to | |
| def _raw_kimi_judge(user_msg, system, verbose=False): | |
| if not KIMI_API_KEY: | |
| return None, 0, 0 | |
| from openai import OpenAI | |
| import time as _time | |
| client = OpenAI(api_key=KIMI_API_KEY, base_url="https://api.moonshot.ai/v1") | |
| def _try_once(extra_body=None, temperature=1.0, max_completion_tokens=4096): | |
| kwargs = dict( | |
| model="kimi-k2.6", | |
| messages=[ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user_msg}, | |
| ], | |
| temperature=temperature, | |
| max_completion_tokens=max_completion_tokens, | |
| ) | |
| if extra_body is not None: | |
| try: | |
| return client.chat.completions.create(**kwargs, extra_body=extra_body) | |
| except TypeError: | |
| pass | |
| except Exception as e: | |
| err = str(e) | |
| if "thinking" in err.lower() or "400" in err: | |
| pass | |
| else: | |
| raise | |
| return client.chat.completions.create(**kwargs) | |
| last_failure_reason = "unknown" | |
| for attempt in range(5): | |
| try: | |
| response = None | |
| last_err = None | |
| for cfg in ( | |
| {"extra_body": {"thinking": {"type": "disabled"}}, "temperature": 0.3, "max_completion_tokens": 4096}, | |
| {"extra_body": None, "temperature": 1.0, "max_completion_tokens": 16384}, | |
| ): | |
| try: | |
| response = _try_once( | |
| extra_body=cfg["extra_body"], | |
| temperature=cfg["temperature"], | |
| max_completion_tokens=cfg["max_completion_tokens"], | |
| ) | |
| break | |
| except Exception as e: | |
| last_err = e | |
| err = str(e) | |
| if "429" in err: | |
| raise | |
| last_failure_reason = f"API error: {err[:60]}" | |
| continue | |
| if response is None: | |
| last_failure_reason = f"all configs failed: {str(last_err)[:60]}" | |
| raise last_err if last_err else RuntimeError("Kimi: no response") | |
| msg = response.choices[0].message | |
| raw = _moonshot_assistant_text(msg) | |
| ti = response.usage.prompt_tokens if response.usage else 0 | |
| to = response.usage.completion_tokens if response.usage else 0 | |
| finish = getattr(response.choices[0], "finish_reason", None) | |
| if verbose: | |
| print( | |
| f" {DIM}Kimi judge: {ti} in / {to} out " | |
| f"(finish:{finish}, len:{len(raw)}){RESET}" | |
| ) | |
| if raw.strip(): | |
| return raw.strip(), ti, to | |
| last_failure_reason = ( | |
| f"empty content (tokens used: {ti}in/{to}out, finish:{finish})" | |
| ) | |
| if verbose: | |
| print(f" {DIM}Kimi judge: empty response — {last_failure_reason}{RESET}") | |
| if attempt < 4: | |
| _time.sleep(5 * (2 ** attempt)) | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err: | |
| last_failure_reason = "rate_limited" | |
| wait = min(10 * (2 ** attempt), 30) | |
| if attempt >= 2: | |
| print( | |
| f" {DIM}Kimi K2.6: rate limited after " | |
| f"{attempt + 1} attempts — skipping{RESET}" | |
| ) | |
| return None, 0, 0 | |
| _time.sleep(wait) | |
| continue | |
| last_failure_reason = f"exception: {err[:60]}" | |
| if attempt < 4: | |
| _time.sleep(min(5 * (2 ** attempt), 20)) | |
| continue | |
| print(f" {DIM}Kimi K2.6: failed — {last_failure_reason}{RESET}") | |
| return None, 0, 0 | |
| print(f" {DIM}Kimi K2.6: exhausted retries — {last_failure_reason}{RESET}") | |
| return None, 0, 0 | |
| def _raw_claude_judge(user_msg, system, verbose=False): | |
| if not ANTHROPIC_API_KEY: | |
| return None, 0, 0 | |
| import anthropic | |
| import time as _time | |
| client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) | |
| last_err = "unknown" | |
| for attempt in range(3): | |
| try: | |
| response = client.messages.create( | |
| model="claude-opus-4-7", | |
| max_tokens=2000, | |
| system=system, | |
| messages=[{"role": "user", "content": user_msg}], | |
| temperature=1.0, | |
| ) | |
| raw = response.content[0].text.strip() | |
| ti, to = response.usage.input_tokens, response.usage.output_tokens | |
| if verbose: | |
| print(f" {DIM}Claude judge: {ti} in / {to} out{RESET}") | |
| if raw: | |
| return raw, ti, to | |
| last_err = "empty response" | |
| if attempt < 2: | |
| _time.sleep(5 * (2 ** attempt)) | |
| except Exception as e: | |
| last_err = str(e)[:60] | |
| err_lower = last_err.lower() | |
| if any(x in err_lower for x in ("overloaded", "529", "503", "502", "rate")): | |
| wait = min(10 * (2 ** attempt), 30) | |
| if attempt >= 2: | |
| print(f" {DIM}Claude judge: failed after 3 attempts — {last_err}{RESET}") | |
| return None, 0, 0 | |
| _time.sleep(wait) | |
| continue | |
| if attempt < 2: | |
| _time.sleep(5) | |
| continue | |
| print(f" {DIM}Claude judge: failed — {last_err}{RESET}") | |
| return None, 0, 0 | |
| print(f" {DIM}Claude judge: exhausted retries — {last_err}{RESET}") | |
| return None, 0, 0 | |
| # ─── Single-call LLM selector (Stage 4 replacement) ─────────────────────────── | |
| _SELECTOR_SYSTEM = """You are an expert portfolio selector evaluating quantitative weekly trade candidates. | |
| Your role is to assess SIGNAL RELIABILITY — not to re-predict price direction (the ML ensemble already does that). | |
| VERDICT CRITERIA: | |
| PASS — signal is reliable; no disqualifying risk flags | |
| FLAG — tradeable but with elevated risk (e.g. earnings proximity, low OOS sample, high RSI); reduce size | |
| REJECT — signal is unreliable or risk is unacceptable | |
| ADJUSTMENT: integer −50 to +20 scaling the pick_confidence. | |
| Negative = penalty (FLAG/REJECT). Positive = modest boost (rare; only high-conviction PASS). | |
| SEVERITY: 1 (minor concern) to 5 (disqualifying) for the worst risk flag. | |
| When calibration data is provided, use it to anchor severity: if the high-confidence bucket | |
| historically under-performs, flag over-confident signals more aggressively. | |
| Respond ONLY with valid JSON. No markdown fences, no explanation outside the JSON.""" | |
| _SELECTOR_USER_TEMPLATE = """SCAN DATE: {scan_date} | |
| {calibration_context} | |
| WEEKLY CANDIDATES ({n} total, ordered by pick_confidence descending): | |
| {candidates_text} | |
| Return JSON exactly: | |
| {{ | |
| "assessments": [ | |
| {{ | |
| "ticker": "<ticker>", | |
| "verdict": "PASS|FLAG|REJECT", | |
| "adjustment": <-50 to +20>, | |
| "severity": <1-5>, | |
| "risk_flags": ["concise flag", ...], | |
| "reasoning": "<one sentence>" | |
| }} | |
| ], | |
| "ranked": ["ticker1", "ticker2", ...], | |
| "best_pick": "<ticker>", | |
| "best_pick_reasoning": "<two sentences max>" | |
| }}""" | |
| # ─── Blend ──────────────────────────────────────────────────────────────────── | |
| # ─── Display ────────────────────────────────────────────────────────────────── | |
| # ─── Main ───────────────────────────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| main() | |