""" MA Analyzer — Bootstrap historical market periods from price-based MA signals. Fetches 5y daily prices for 5 key underlyings, detects MA ruptures (golden/death cross, MA100 slope changes, MA20 direction changes), enriches candidates with GPT-4o-mini, and persists them via save_market_event(). """ import json import logging import os import time from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd import yfinance as yf from services.database import count_market_events, save_market_event logger = logging.getLogger(__name__) UNDERLYINGS = [ {"ticker": "EURUSD=X", "name": "EUR/USD", "category": "market", "assets": ["EUR/USD", "USD", "EUR"]}, {"ticker": "BZ=F", "name": "Brent Crude", "category": "market", "assets": ["CL", "BZ", "Oil"]}, {"ticker": "GC=F", "name": "Gold", "category": "market", "assets": ["GC", "Gold", "GLD"]}, {"ticker": "^GSPC", "name": "S&P 500", "category": "market", "assets": ["SPX", "SPY", "QQQ"]}, {"ticker": "^TNX", "name": "US 10Y Yield", "category": "market", "assets": ["TNX", "TLT", "Bonds"]}, ] MA_PERIODS = [20, 50, 100, 200] CROSS_CONFIRM_DAYS = 5 SLOPE_WINDOW = 10 DEDUP_DAYS = 30 MAX_CANDIDATES = 80 GPT_BATCH_SIZE = 5 def _fetch_prices() -> Dict[str, pd.Series]: """Download 5y daily close prices for all underlyings. Returns ticker → pd.Series.""" tickers = [u["ticker"] for u in UNDERLYINGS] prices: Dict[str, pd.Series] = {} try: raw = yf.download(tickers, period="5y", interval="1d", progress=False, auto_adjust=True) if raw.empty: raise ValueError("Empty download result") close = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw for ticker in tickers: if ticker in close.columns: s = close[ticker].dropna() if not s.empty: prices[ticker] = s logger.info(f"[MA] {ticker}: {len(s)} bars") except Exception as e: logger.warning(f"[MA] Batch download failed ({e}), falling back to individual") for ticker in tickers: try: df = yf.download(ticker, period="5y", interval="1d", progress=False, auto_adjust=True) if df.empty: continue if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) s = df["Close"].dropna() if not s.empty: prices[ticker] = s logger.info(f"[MA] {ticker}: {len(s)} bars") except Exception as e2: logger.error(f"[MA] Failed to fetch {ticker}: {e2}") return prices def _compute_mas(prices: pd.Series) -> pd.DataFrame: """Return DataFrame with Close + MA20/50/100/200 columns.""" df = prices.to_frame(name="Close") for p in MA_PERIODS: df[f"MA{p}"] = df["Close"].rolling(p).mean() return df def _date_str(ts) -> str: if hasattr(ts, "strftime"): return ts.strftime("%Y-%m-%d") return str(ts)[:10] def _price_move_pct(df: pd.DataFrame, start_idx: int, end_idx: int) -> float: """Absolute % move of Close between two integer iloc positions.""" try: p0 = float(df["Close"].iloc[start_idx]) p1 = float(df["Close"].iloc[end_idx]) if p0 == 0: return 0.0 return abs((p1 - p0) / p0 * 100) except Exception: return 0.0 def _detect_crossovers(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]: """Detect MA50/MA200 golden cross and death cross with 5-day confirmation.""" candidates = [] ma50 = df["MA50"] ma200 = df["MA200"] valid = df.dropna(subset=["MA50", "MA200"]) if len(valid) < CROSS_CONFIRM_DAYS + 2: return candidates idx_list = valid.index.tolist() i = 1 while i < len(idx_list) - CROSS_CONFIRM_DAYS: prev_i = idx_list[i - 1] curr_i = idx_list[i] prev_above = ma50[prev_i] > ma200[prev_i] curr_above = ma50[curr_i] > ma200[curr_i] if prev_above == curr_above: i += 1 continue is_golden = curr_above # MA50 just crossed above MA200 # Confirm: next CROSS_CONFIRM_DAYS all maintain the new relationship confirm_ok = True for k in range(1, CROSS_CONFIRM_DAYS + 1): if i + k >= len(idx_list): confirm_ok = False break future_i = idx_list[i + k] if is_golden: if not (ma50[future_i] > ma200[future_i]): confirm_ok = False break else: if not (ma50[future_i] < ma200[future_i]): confirm_ok = False break if confirm_ok: # start_date = date of the confirming bar (i + CROSS_CONFIRM_DAYS) confirmed_i = idx_list[i + CROSS_CONFIRM_DAYS] start_iloc = valid.index.get_loc(confirmed_i) event_type = "golden_cross" if is_golden else "death_cross" name_prefix = "Golden Cross" if is_golden else "Death Cross" candidates.append({ "_type": event_type, "_underlying": underlying, "_start_iloc": start_iloc, "start_date": _date_str(confirmed_i), "end_date": None, "level": "medium", "category": "market", "_move_magnitude": _price_move_pct(df, max(0, start_iloc - 20), start_iloc), "_name_hint": f"{underlying['name']} {name_prefix} MA50/MA200", }) i += CROSS_CONFIRM_DAYS + 1 else: i += 1 return candidates def _slope(series: pd.Series, window: int = SLOPE_WINDOW) -> pd.Series: """Percentage slope over window: (val[t] - val[t-window]) / val[t-window] * 100.""" return (series - series.shift(window)) / series.shift(window) * 100 def _detect_ma100_slope_changes(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]: """Detect MA100 direction reversals (slope sign flips sustained over 5+ bars).""" candidates = [] valid = df.dropna(subset=["MA100"]) if len(valid) < SLOPE_WINDOW + 10: return candidates slope = _slope(valid["MA100"], SLOPE_WINDOW) sign = np.sign(slope) idx_list = valid.index.tolist() i = 1 while i < len(idx_list) - 5: prev_sign = sign[idx_list[i - 1]] curr_sign = sign[idx_list[i]] if prev_sign == 0 or curr_sign == 0 or prev_sign == curr_sign: i += 1 continue # Check 5-bar confirmation confirm_ok = all( sign[idx_list[i + k]] == curr_sign for k in range(1, 6) if i + k < len(idx_list) ) if not confirm_ok: i += 1 continue start_idx = valid.index.get_loc(idx_list[i]) # Find end: next sign flip end_date = None duration_days = 0 for j in range(i + 1, len(idx_list)): if sign[idx_list[j]] == -curr_sign: end_date = _date_str(idx_list[j]) duration_days = ( datetime.strptime(end_date, "%Y-%m-%d") - datetime.strptime(_date_str(idx_list[i]), "%Y-%m-%d") ).days break direction = "Bullish" if curr_sign > 0 else "Bearish" level = "long" if duration_days > 90 or end_date is None else "medium" candidates.append({ "_type": "ma100_slope_change", "_underlying": underlying, "_start_iloc": start_idx, "start_date": _date_str(idx_list[i]), "end_date": end_date, "level": level, "category": "market", "_move_magnitude": abs(float(slope[idx_list[i]])), "_name_hint": f"{underlying['name']} MA100 {direction} Trend", }) # Skip to next potential flip zone if end_date: while i < len(idx_list) and _date_str(idx_list[i]) < end_date: i += 1 else: break return candidates def _detect_ma20_direction_changes(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]: """Detect strong MA20 direction changes (> 2% in 10 days).""" candidates = [] valid = df.dropna(subset=["MA20"]) if len(valid) < SLOPE_WINDOW + 10: return candidates slope = _slope(valid["MA20"], SLOPE_WINDOW) THRESHOLD = 2.0 idx_list = valid.index.tolist() last_added_date = None for i in range(1, len(idx_list) - 5): prev_slope = float(slope[idx_list[i - 1]]) curr_slope = float(slope[idx_list[i]]) if abs(curr_slope) < THRESHOLD: continue if np.sign(prev_slope) == np.sign(curr_slope): continue start_date = _date_str(idx_list[i]) # Dedup: skip if too close to last added if last_added_date: gap = ( datetime.strptime(start_date, "%Y-%m-%d") - datetime.strptime(last_added_date, "%Y-%m-%d") ).days if gap < DEDUP_DAYS: continue # Find end: slope returns to < 0.5 or reverses end_date = None duration_days = 0 for j in range(i + 1, len(idx_list)): future_slope = float(slope[idx_list[j]]) if abs(future_slope) < 0.5 or np.sign(future_slope) != np.sign(curr_slope): end_date = _date_str(idx_list[j]) duration_days = ( datetime.strptime(end_date, "%Y-%m-%d") - datetime.strptime(start_date, "%Y-%m-%d") ).days break direction = "Bullish" if curr_slope > 0 else "Bearish" level = "short" if (end_date and duration_days < 30) else "medium" candidates.append({ "_type": "ma20_direction_change", "_underlying": underlying, "_start_iloc": valid.index.get_loc(idx_list[i]), "start_date": start_date, "end_date": end_date, "level": level, "category": "market", "_move_magnitude": abs(curr_slope), "_name_hint": f"{underlying['name']} MA20 {direction} Swing", }) last_added_date = start_date return candidates def _dedup_candidates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Remove near-duplicate events (same underlying + type, dates < 30 days apart). Keep the one with the largest _move_magnitude.""" kept: List[Dict[str, Any]] = [] for ev in candidates: ticker = ev["_underlying"]["ticker"] ev_type = ev["_type"] ev_start = ev["start_date"] duplicate_of = None for k in kept: if k["_underlying"]["ticker"] != ticker: continue if k["_type"] != ev_type: continue gap = abs( (datetime.strptime(ev_start, "%Y-%m-%d") - datetime.strptime(k["start_date"], "%Y-%m-%d")).days ) if gap < DEDUP_DAYS: duplicate_of = k break if duplicate_of is None: kept.append(ev) elif ev.get("_move_magnitude", 0) > duplicate_of.get("_move_magnitude", 0): kept.remove(duplicate_of) kept.append(ev) return kept def _assign_end_dates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """For events without an end_date, set it to the next same-type event on same underlying.""" for i, ev in enumerate(candidates): if ev.get("end_date"): continue ticker = ev["_underlying"]["ticker"] ev_type = ev["_type"] ev_start = ev["start_date"] # Find the next event of same type/underlying that comes after next_start = None for j, other in enumerate(candidates): if i == j: continue if other["_underlying"]["ticker"] != ticker: continue if other["_type"] != ev_type: continue if other["start_date"] > ev_start: if next_start is None or other["start_date"] < next_start: next_start = other["start_date"] ev["end_date"] = next_start # None if it's the last (ongoing) return candidates def _build_gpt_prompt(batch: List[Dict[str, Any]], df_map: Dict[str, pd.DataFrame]) -> str: items = [] for i, ev in enumerate(batch): underlying = ev["_underlying"] ticker = underlying["ticker"] df = df_map.get(ticker) start_price = end_price = None if df is not None: try: start_idx = df.index.searchsorted(pd.Timestamp(ev["start_date"])) start_price = float(df["Close"].iloc[min(start_idx, len(df) - 1)]) except Exception: pass if ev.get("end_date"): try: end_idx = df.index.searchsorted(pd.Timestamp(ev["end_date"])) end_price = float(df["Close"].iloc[min(end_idx, len(df) - 1)]) except Exception: pass items.append({ "index": i, "underlying": underlying["name"], "type": ev["_type"], "start_date": ev["start_date"], "end_date": ev.get("end_date"), "level": ev["level"], "name_hint": ev["_name_hint"], "price_start": round(start_price, 4) if start_price else None, "price_end": round(end_price, 4) if end_price else None, }) return f"""You are a senior macro analyst specializing in technical market regimes. Analyze these {len(batch)} market events detected via Moving Average signals. For each, return a JSON object with: - index: (same as input) - name: concise English event name (ex: "S&P 500 Golden Cross Bull Trend") - description: 2-3 sentences explaining market context and significance - market_impact: 1 sentence on trading/volatility impact - affected_assets: list of 3-6 ticker symbols most affected - impact_score: float 0.0-1.0 (0=minor technical, 1=major structural) Events to analyze: {json.dumps(items, indent=2)} Return a JSON array of {len(batch)} objects, one per input event, in the same order. Array only, no wrapping key.""" def _enrich_with_gpt(candidates: List[Dict[str, Any]], df_map: Dict[str, pd.DataFrame]) -> List[Dict[str, Any]]: """Call GPT-4o-mini in batches of GPT_BATCH_SIZE to enrich candidates.""" api_key = os.environ.get("OPENAI_API_KEY", "") if not api_key: logger.warning("[MA] No OPENAI_API_KEY — skipping GPT enrichment") for ev in candidates: ev["name"] = ev["_name_hint"] ev["description"] = f"MA signal detected on {ev['_underlying']['name']}." ev["market_impact"] = "Technical price action signal." ev["affected_assets"] = ev["_underlying"]["assets"] ev["impact_score"] = 0.4 return candidates from openai import OpenAI client = OpenAI(api_key=api_key) for batch_start in range(0, len(candidates), GPT_BATCH_SIZE): batch = candidates[batch_start: batch_start + GPT_BATCH_SIZE] try: prompt = _build_gpt_prompt(batch, df_map) response = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=1200, ) raw = response.choices[0].message.content.strip() # Strip markdown code block if present if raw.startswith("```"): raw = raw.split("```")[1] if raw.startswith("json"): raw = raw[4:] results = json.loads(raw) for item in results: idx = item.get("index") if idx is not None and 0 <= idx < len(batch): ev = batch[idx] ev["name"] = item.get("name", ev["_name_hint"]) ev["description"] = item.get("description", "") ev["market_impact"] = item.get("market_impact", "") ev["affected_assets"] = item.get("affected_assets", ev["_underlying"]["assets"]) ev["impact_score"] = float(item.get("impact_score", 0.4)) except Exception as e: logger.error(f"[MA] GPT batch {batch_start // GPT_BATCH_SIZE + 1} failed: {e}") for ev in batch: if "name" not in ev: ev["name"] = ev["_name_hint"] ev["description"] = f"MA signal on {ev['_underlying']['name']}." ev["market_impact"] = "Technical signal." ev["affected_assets"] = ev["_underlying"]["assets"] ev["impact_score"] = 0.4 time.sleep(1) # Ensure all events have required fields for ev in candidates: if "name" not in ev: ev["name"] = ev["_name_hint"] ev["description"] = f"MA signal on {ev['_underlying']['name']}." ev["market_impact"] = "Technical signal." ev["affected_assets"] = ev["_underlying"]["assets"] ev["impact_score"] = 0.4 return candidates def _to_event_dict(ev: Dict[str, Any]) -> Dict[str, Any]: """Convert internal candidate to the format expected by save_market_event.""" return { "name": ev["name"], "start_date": ev["start_date"], "end_date": ev.get("end_date"), "level": ev["level"], "category": ev.get("category", "market"), "description": ev.get("description", ""), "market_impact": ev.get("market_impact", ""), "affected_assets": ev.get("affected_assets", []), "impact_score": ev.get("impact_score", 0.4), "absorption_pct": None, "relevant_indicators": [], "origin": "bootstrap_ma", } async def bootstrap_ma_events() -> Dict[str, Any]: """ Main entrypoint. Detects MA ruptures, enriches with GPT, saves to DB. Idempotent: skips if DB already has > 100 events. Returns {"detected": N, "saved": N, "skipped": N}. """ existing = count_market_events() if existing > 100: logger.info(f"[MA] DB already has {existing} events — skipping bootstrap") return {"detected": 0, "saved": 0, "skipped": existing} logger.info("[MA] Fetching price data...") prices = _fetch_prices() if not prices: logger.error("[MA] No price data fetched — aborting") return {"detected": 0, "saved": 0, "skipped": 0} df_map: Dict[str, pd.DataFrame] = {} all_candidates: List[Dict[str, Any]] = [] for underlying in UNDERLYINGS: ticker = underlying["ticker"] if ticker not in prices: logger.warning(f"[MA] No data for {ticker}, skipping") continue try: df = _compute_mas(prices[ticker]) df_map[ticker] = df cross_evs = _detect_crossovers(df, underlying) slope_evs = _detect_ma100_slope_changes(df, underlying) swing_evs = _detect_ma20_direction_changes(df, underlying) logger.info( f"[MA] {underlying['name']}: crossovers={len(cross_evs)}, " f"slope={len(slope_evs)}, swing={len(swing_evs)}" ) all_candidates.extend(cross_evs + slope_evs + swing_evs) except Exception as e: logger.error(f"[MA] Detection failed for {underlying['name']}: {e}") logger.info(f"[MA] Raw candidates: {len(all_candidates)}") # Dedup + assign end dates candidates = _dedup_candidates(all_candidates) candidates = _assign_end_dates(candidates) # Sort by start_date ascending for GPT context coherence candidates.sort(key=lambda x: x["start_date"]) # Cap at MAX_CANDIDATES (keep most significant by _move_magnitude) if len(candidates) > MAX_CANDIDATES: candidates.sort(key=lambda x: x.get("_move_magnitude", 0), reverse=True) candidates = candidates[:MAX_CANDIDATES] candidates.sort(key=lambda x: x["start_date"]) detected = len(candidates) logger.info(f"[MA] After dedup/cap: {detected} candidates — enriching with GPT...") candidates = _enrich_with_gpt(candidates, df_map) saved = 0 skipped_save = 0 for ev in candidates: try: save_market_event(_to_event_dict(ev)) saved += 1 except Exception as e: logger.error(f"[MA] Failed to save '{ev.get('name', '?')}': {e}") skipped_save += 1 logger.info(f"[MA] Bootstrap complete: detected={detected} saved={saved} skipped={skipped_save}") return {"detected": detected, "saved": saved, "skipped": skipped_save}