Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Fetch daily multi-ticker stock data with RSI, Bollinger Bands, Stochastic, and BUY/HOLD/SELL signals.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/analyze_stock.py
1#!/usr/bin/env python32# /// script3# requires-python = ">=3.10"4# dependencies = [5# "yfinance>=0.2.40",6# "pandas>=2.0.0",7# "fear-and-greed>=0.4",8# "edgartools>=2.0.0",9# "feedparser>=6.0.0",10# ]11# ///12"""13Stock analysis using Yahoo Finance data.1415Usage:16uv run analyze_stock.py TICKER [TICKER2 ...] [--output text|json] [--verbose]17"""1819import argparse20import asyncio21import json22import sys23import time24from dataclasses import dataclass, asdict25from datetime import datetime26from typing import Literal2728import pandas as pd29import yfinance as yf303132# Top 20 supported cryptocurrencies33SUPPORTED_CRYPTOS = {34"BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD", "XRP-USD",35"ADA-USD", "DOGE-USD", "AVAX-USD", "DOT-USD", "MATIC-USD",36"LINK-USD", "ATOM-USD", "UNI-USD", "LTC-USD", "BCH-USD",37"XLM-USD", "ALGO-USD", "VET-USD", "FIL-USD", "NEAR-USD",38}3940# Crypto category mapping for sector-like analysis41CRYPTO_CATEGORIES = {42"BTC-USD": "Store of Value",43"ETH-USD": "Smart Contract L1",44"BNB-USD": "Exchange Token",45"SOL-USD": "Smart Contract L1",46"XRP-USD": "Payment",47"ADA-USD": "Smart Contract L1",48"DOGE-USD": "Meme",49"AVAX-USD": "Smart Contract L1",50"DOT-USD": "Interoperability",51"MATIC-USD": "Layer 2",52"LINK-USD": "Oracle",53"ATOM-USD": "Interoperability",54"UNI-USD": "DeFi",55"LTC-USD": "Payment",56"BCH-USD": "Payment",57"XLM-USD": "Payment",58"ALGO-USD": "Smart Contract L1",59"VET-USD": "Enterprise",60"FIL-USD": "Storage",61"NEAR-USD": "Smart Contract L1",62}636465def detect_asset_type(ticker: str) -> Literal["stock", "crypto"]:66"""Detect asset type from ticker format."""67ticker_upper = ticker.upper()68if ticker_upper.endswith("-USD"):69base = ticker_upper[:-4]70if base.isalpha():71return "crypto"72return "stock"737475@dataclass76class StockData:77ticker: str78info: dict79earnings_history: pd.DataFrame | None80analyst_info: dict | None81price_history: pd.DataFrame | None82asset_type: Literal["stock", "crypto"] = "stock"838485@dataclass86class CryptoFundamentals:87"""Crypto-specific fundamentals (replaces P/E, margins for crypto)."""88market_cap: float | None89market_cap_rank: str # "large", "mid", "small"90volume_24h: float | None91circulating_supply: float | None92category: str | None # "Smart Contract L1", "DeFi", etc.93btc_correlation: float | None # 30-day correlation to BTC94score: float95explanation: str969798@dataclass99class EarningsSurprise:100score: float101explanation: str102actual_eps: float | None = None103expected_eps: float | None = None104surprise_pct: float | None = None105106107@dataclass108class Fundamentals:109score: float110key_metrics: dict111explanation: str112113114@dataclass115class AnalystSentiment:116score: float | None117summary: str118consensus_rating: str | None = None119price_target: float | None = None120current_price: float | None = None121upside_pct: float | None = None122num_analysts: int | None = None123124125@dataclass126class HistoricalPatterns:127score: float128pattern_desc: str129beats_last_4q: int | None = None130avg_reaction_pct: float | None = None131132133@dataclass134class MarketContext:135vix_level: float136vix_status: str # "calm", "elevated", "fear"137spy_trend_10d: float138qqq_trend_10d: float139market_regime: str # "bull", "bear", "choppy"140score: float141explanation: str142# Safe-haven indicators (v4.0.0)143gld_change_5d: float | None = None # Gold ETF % change144tlt_change_5d: float | None = None # Treasury ETF % change145uup_change_5d: float | None = None # USD Index ETF % change146risk_off_detected: bool = False # True if flight to safety detected147148149@dataclass150class SectorComparison:151sector_name: str152industry_name: str153stock_return_1m: float154sector_return_1m: float155relative_strength: float156sector_trend: str # "strong uptrend", "downtrend", etc.157score: float158explanation: str159160161@dataclass162class EarningsTiming:163days_until_earnings: int | None164days_since_earnings: int | None165next_earnings_date: str | None166last_earnings_date: str | None167timing_flag: str # "pre_earnings", "post_earnings", "safe"168price_change_5d: float | None169confidence_adjustment: float170caveats: list[str]171172173@dataclass174class MomentumAnalysis:175rsi_14d: float | None176rsi_status: str # "overbought", "oversold", "neutral"177price_vs_52w_low: float | None178price_vs_52w_high: float | None179near_52w_high: bool180near_52w_low: bool181volume_ratio: float | None182relative_strength_vs_sector: float | None183score: float184explanation: str185186187@dataclass188class SentimentAnalysis:189score: float # Overall -1.0 to 1.0190explanation: str # Human-readable summary191192# Sub-indicator scores193fear_greed_score: float | None = None194short_interest_score: float | None = None195vix_structure_score: float | None = None196insider_activity_score: float | None = None197put_call_score: float | None = None198199# Raw data200fear_greed_value: int | None = None # 0-100201fear_greed_status: str | None = None # "Extreme Fear", etc.202short_interest_pct: float | None = None203days_to_cover: float | None = None204vix_structure: str | None = None # "contango", "backwardation", "flat"205vix_slope: float | None = None206insider_net_shares: int | None = None207insider_net_value: float | None = None # Millions USD208put_call_ratio: float | None = None209put_volume: int | None = None210call_volume: int | None = None211212# Metadata213indicators_available: int = 0214data_freshness_warnings: list[str] | None = None215216217@dataclass218class Signal:219ticker: str220company_name: str221recommendation: Literal["BUY", "HOLD", "SELL"]222confidence: float223final_score: float224supporting_points: list[str]225caveats: list[str]226timestamp: str227components: dict228229230def fetch_stock_data(ticker: str, verbose: bool = False) -> StockData | None:231"""Fetch stock data from Yahoo Finance with retry logic."""232max_retries = 3233for attempt in range(max_retries):234try:235if verbose:236print(f"Fetching data for {ticker}... (attempt {attempt + 1}/{max_retries})", file=sys.stderr)237238stock = yf.Ticker(ticker)239info = stock.info240241# Validate ticker242if not info or "regularMarketPrice" not in info:243return None244245# Fetch earnings history246try:247earnings_history = stock.earnings_dates248except Exception:249earnings_history = None250251# Fetch analyst info252try:253analyst_info = {254"recommendations": stock.recommendations,255"analyst_price_targets": stock.analyst_price_targets,256}257except Exception:258analyst_info = None259260# Fetch price history (1 year for historical patterns)261try:262price_history = stock.history(period="1y")263except Exception:264price_history = None265266return StockData(267ticker=ticker,268info=info,269earnings_history=earnings_history,270analyst_info=analyst_info,271price_history=price_history,272asset_type=detect_asset_type(ticker),273)274275except Exception as e:276if attempt < max_retries - 1:277wait_time = 2 ** attempt # Exponential backoff278if verbose:279print(f"Error fetching {ticker}: {e}. Retrying in {wait_time}s...", file=sys.stderr)280time.sleep(wait_time)281else:282if verbose:283print(f"Failed to fetch {ticker} after {max_retries} attempts", file=sys.stderr)284return None285286return None287288289def analyze_earnings_surprise(data: StockData) -> EarningsSurprise | None:290"""Analyze earnings surprise from most recent quarter."""291if data.earnings_history is None or data.earnings_history.empty:292return None293294try:295# Get most recent earnings with actual data296recent = data.earnings_history.sort_index(ascending=False).head(10)297298for idx, row in recent.iterrows():299if pd.notna(row.get("Reported EPS")) and pd.notna(row.get("EPS Estimate")):300actual = float(row["Reported EPS"])301expected = float(row["EPS Estimate"])302303if expected == 0:304continue305306surprise_pct = ((actual - expected) / abs(expected)) * 100307308# Score based on surprise percentage309if surprise_pct > 10:310score = 1.0311elif surprise_pct > 5:312score = 0.7313elif surprise_pct > 0:314score = 0.3315elif surprise_pct > -5:316score = -0.3317elif surprise_pct > -10:318score = -0.7319else:320score = -1.0321322explanation = f"{'Beat' if surprise_pct > 0 else 'Missed'} by {abs(surprise_pct):.1f}%"323324return EarningsSurprise(325score=score,326explanation=explanation,327actual_eps=actual,328expected_eps=expected,329surprise_pct=surprise_pct,330)331332return None333334except Exception:335return None336337338def analyze_fundamentals(data: StockData) -> Fundamentals | None:339"""Analyze fundamental metrics."""340info = data.info341scores = []342metrics = {}343explanations = []344345try:346# P/E Ratio (lower is better, but consider growth)347pe_ratio = info.get("trailingPE") or info.get("forwardPE")348if pe_ratio and pe_ratio > 0:349metrics["pe_ratio"] = round(pe_ratio, 2)350if pe_ratio < 15:351scores.append(0.5)352explanations.append(f"Attractive P/E: {pe_ratio:.1f}x")353elif pe_ratio > 30:354scores.append(-0.3)355explanations.append(f"Elevated P/E: {pe_ratio:.1f}x")356else:357scores.append(0.1)358359# Operating Margin360op_margin = info.get("operatingMargins")361if op_margin:362metrics["operating_margin"] = round(op_margin, 3)363if op_margin > 0.15:364scores.append(0.5)365explanations.append(f"Strong margin: {op_margin*100:.1f}%")366elif op_margin < 0.05:367scores.append(-0.5)368explanations.append(f"Weak margin: {op_margin*100:.1f}%")369370# Revenue Growth371rev_growth = info.get("revenueGrowth")372if rev_growth:373metrics["revenue_growth_yoy"] = round(rev_growth, 3)374if rev_growth > 0.20:375scores.append(0.5)376explanations.append(f"Strong growth: {rev_growth*100:.1f}% YoY")377elif rev_growth < 0.05:378scores.append(-0.3)379explanations.append(f"Slow growth: {rev_growth*100:.1f}% YoY")380else:381scores.append(0.2)382383# Debt to Equity384debt_equity = info.get("debtToEquity")385if debt_equity is not None:386metrics["debt_to_equity"] = round(debt_equity / 100, 2)387if debt_equity < 50:388scores.append(0.3)389elif debt_equity > 200:390scores.append(-0.5)391explanations.append(f"High debt: D/E {debt_equity/100:.1f}x")392393if not scores:394return None395396# Average and normalize397avg_score = sum(scores) / len(scores)398normalized_score = max(-1.0, min(1.0, avg_score))399400explanation = "; ".join(explanations) if explanations else "Mixed fundamentals"401402return Fundamentals(403score=normalized_score,404key_metrics=metrics,405explanation=explanation,406)407408except Exception:409return None410411412def analyze_crypto_fundamentals(data: StockData, verbose: bool = False) -> CryptoFundamentals | None:413"""Analyze crypto-specific fundamentals (market cap, supply, category)."""414if data.asset_type != "crypto":415return None416417info = data.info418ticker = data.ticker.upper()419420try:421# Market cap analysis422market_cap = info.get("marketCap")423if not market_cap:424return None425426# Categorize by market cap427if market_cap >= 10_000_000_000: # $10B+428market_cap_rank = "large"429cap_score = 0.3 # Large caps are more stable430elif market_cap >= 1_000_000_000: # $1B-$10B431market_cap_rank = "mid"432cap_score = 0.1433else:434market_cap_rank = "small"435cap_score = -0.2 # Small caps are riskier436437# Volume analysis438volume_24h = info.get("volume") or info.get("volume24Hr")439volume_score = 0.0440if volume_24h and market_cap:441volume_to_cap = volume_24h / market_cap442if volume_to_cap > 0.05: # >5% daily turnover443volume_score = 0.2 # High liquidity444elif volume_to_cap < 0.01:445volume_score = -0.2 # Low liquidity446447# Circulating supply448circulating_supply = info.get("circulatingSupply")449450# Get crypto category451category = CRYPTO_CATEGORIES.get(ticker, "Unknown")452453# Calculate BTC correlation (30 days)454btc_correlation = None455try:456if ticker != "BTC-USD" and data.price_history is not None:457btc = yf.Ticker("BTC-USD")458btc_hist = btc.history(period="1mo")459if not btc_hist.empty and len(data.price_history) > 5:460# Align dates and calculate correlation461crypto_returns = data.price_history["Close"].pct_change().dropna()462btc_returns = btc_hist["Close"].pct_change().dropna()463# Simple correlation on overlapping dates464common_dates = crypto_returns.index.intersection(btc_returns.index)465if len(common_dates) > 10:466btc_correlation = crypto_returns.loc[common_dates].corr(btc_returns.loc[common_dates])467except Exception:468pass469470# BTC correlation scoring (high correlation = less diversification benefit)471corr_score = 0.0472if btc_correlation is not None:473if btc_correlation > 0.8:474corr_score = -0.1 # Very correlated to BTC475elif btc_correlation < 0.3:476corr_score = 0.1 # Good diversification477478# Total score479total_score = cap_score + volume_score + corr_score480481# Build explanation482explanations = []483explanations.append(f"Market cap: ${market_cap/1e9:.1f}B ({market_cap_rank})")484if category != "Unknown":485explanations.append(f"Category: {category}")486if btc_correlation is not None:487explanations.append(f"BTC corr: {btc_correlation:.2f}")488489return CryptoFundamentals(490market_cap=market_cap,491market_cap_rank=market_cap_rank,492volume_24h=volume_24h,493circulating_supply=circulating_supply,494category=category,495btc_correlation=round(btc_correlation, 2) if btc_correlation else None,496score=max(-1.0, min(1.0, total_score)),497explanation="; ".join(explanations),498)499500except Exception as e:501if verbose:502print(f"Error analyzing crypto fundamentals: {e}", file=sys.stderr)503return None504505506def analyze_analyst_sentiment(data: StockData) -> AnalystSentiment | None:507"""Analyze analyst sentiment and price targets."""508info = data.info509510try:511# Get current price512current_price = info.get("regularMarketPrice") or info.get("currentPrice")513if not current_price:514return None515516# Get target price517target_price = info.get("targetMeanPrice")518519# Get number of analysts520num_analysts = info.get("numberOfAnalystOpinions")521522# Get recommendation523recommendation = info.get("recommendationKey")524525if not target_price or not recommendation:526return AnalystSentiment(527score=None,528summary="No analyst coverage available",529)530531# Calculate upside532upside_pct = ((target_price - current_price) / current_price) * 100533534# Score based on recommendation and upside535rec_scores = {536"strong_buy": 1.0,537"buy": 0.7,538"hold": 0.0,539"sell": -0.7,540"strong_sell": -1.0,541}542543base_score = rec_scores.get(recommendation, 0.0)544545# Adjust based on upside546if upside_pct > 20:547score = min(1.0, base_score + 0.3)548elif upside_pct > 10:549score = min(1.0, base_score + 0.15)550elif upside_pct < -10:551score = max(-1.0, base_score - 0.3)552else:553score = base_score554555# Format recommendation556rec_display = recommendation.replace("_", " ").title()557558summary = f"{rec_display} with {abs(upside_pct):.1f}% {'upside' if upside_pct > 0 else 'downside'}"559if num_analysts:560summary += f" ({num_analysts} analysts)"561562return AnalystSentiment(563score=score,564summary=summary,565consensus_rating=rec_display,566price_target=target_price,567current_price=current_price,568upside_pct=upside_pct,569num_analysts=num_analysts,570)571572except Exception:573return AnalystSentiment(574score=None,575summary="Error analyzing analyst sentiment",576)577578579def analyze_historical_patterns(data: StockData) -> HistoricalPatterns | None:580"""Analyze historical earnings patterns."""581if data.earnings_history is None or data.price_history is None:582return None583584if data.earnings_history.empty or data.price_history.empty:585return None586587try:588# Get last 4 quarters earnings dates589earnings_dates = data.earnings_history.sort_index(ascending=False).head(4)590591beats = 0592reactions = []593594for earnings_date, row in earnings_dates.iterrows():595if pd.notna(row.get("Reported EPS")) and pd.notna(row.get("EPS Estimate")):596actual = float(row["Reported EPS"])597expected = float(row["EPS Estimate"])598599if actual > expected:600beats += 1601602# Try to get price reaction (day of earnings)603try:604earnings_day = pd.Timestamp(earnings_date).date()605606# Find closest trading day607price_data = data.price_history[data.price_history.index.date == earnings_day]608609if not price_data.empty:610day_change = ((price_data["Close"].iloc[0] - price_data["Open"].iloc[0]) / price_data["Open"].iloc[0]) * 100611reactions.append(day_change)612except Exception:613continue614615total_quarters = len(earnings_dates)616if total_quarters == 0:617return None618619# Score based on beat rate620beat_rate = beats / total_quarters621622if beat_rate == 1.0:623score = 0.8624elif beat_rate >= 0.75:625score = 0.5626elif beat_rate >= 0.5:627score = 0.0628elif beat_rate >= 0.25:629score = -0.5630else:631score = -0.8632633# Pattern description634pattern_desc = f"{beats}/{total_quarters} quarters beat expectations"635636if reactions:637avg_reaction = sum(reactions) / len(reactions)638pattern_desc += f", avg reaction {avg_reaction:+.1f}%"639else:640avg_reaction = None641642return HistoricalPatterns(643score=score,644pattern_desc=pattern_desc,645beats_last_4q=beats,646avg_reaction_pct=avg_reaction,647)648649except Exception:650return None651652653def analyze_market_context(verbose: bool = False) -> MarketContext | None:654"""Analyze overall market conditions using VIX, SPY, QQQ, and safe-havens with 1h cache."""655# Check cache first656cached = _get_cached("market_context")657if cached is not None:658if verbose:659print("Using cached market context (< 1h old)", file=sys.stderr)660return cached661662try:663if verbose:664print("Fetching market indicators (VIX, SPY, QQQ)...", file=sys.stderr)665666# Fetch market indicators667vix = yf.Ticker("^VIX")668spy = yf.Ticker("SPY")669qqq = yf.Ticker("QQQ")670671# Get current VIX level672vix_info = vix.info673vix_level = vix_info.get("regularMarketPrice") or vix_info.get("currentPrice")674675if not vix_level:676return None677678# Determine VIX status679if vix_level < 20:680vix_status = "calm"681vix_score = 0.2682elif vix_level < 30:683vix_status = "elevated"684vix_score = 0.0685else:686vix_status = "fear"687vix_score = -0.5688689# Get SPY and QQQ 10-day trends690spy_hist = spy.history(period="1mo")691qqq_hist = qqq.history(period="1mo")692693if spy_hist.empty or qqq_hist.empty:694return None695696# Calculate 10-day price changes697spy_10d_ago = spy_hist["Close"].iloc[-min(10, len(spy_hist))]698spy_current = spy_hist["Close"].iloc[-1]699spy_trend_10d = ((spy_current - spy_10d_ago) / spy_10d_ago) * 100700701qqq_10d_ago = qqq_hist["Close"].iloc[-min(10, len(qqq_hist))]702qqq_current = qqq_hist["Close"].iloc[-1]703qqq_trend_10d = ((qqq_current - qqq_10d_ago) / qqq_10d_ago) * 100704705# Determine market regime706avg_trend = (spy_trend_10d + qqq_trend_10d) / 2707708if avg_trend > 3:709market_regime = "bull"710regime_score = 0.3711elif avg_trend < -3:712market_regime = "bear"713regime_score = -0.4714else:715market_regime = "choppy"716regime_score = -0.1717718# Calculate overall score719overall_score = (vix_score + regime_score) / 2720721# NEW v4.0.0: Fetch safe-haven indicators (GLD, TLT, UUP)722gld_change_5d = None723tlt_change_5d = None724uup_change_5d = None725risk_off_detected = False726727try:728if verbose:729print("Fetching safe-haven indicators (GLD, TLT, UUP)...", file=sys.stderr)730731# Fetch safe-haven ETFs732gld = yf.Ticker("GLD") # Gold733tlt = yf.Ticker("TLT") # 20+ Year Treasury734uup = yf.Ticker("UUP") # USD Index735736gld_hist = gld.history(period="10d")737tlt_hist = tlt.history(period="10d")738uup_hist = uup.history(period="10d")739740# Calculate 5-day changes741if not gld_hist.empty and len(gld_hist) >= 5:742gld_5d_ago = gld_hist["Close"].iloc[-min(5, len(gld_hist))]743gld_current = gld_hist["Close"].iloc[-1]744gld_change_5d = ((gld_current - gld_5d_ago) / gld_5d_ago) * 100745746if not tlt_hist.empty and len(tlt_hist) >= 5:747tlt_5d_ago = tlt_hist["Close"].iloc[-min(5, len(tlt_hist))]748tlt_current = tlt_hist["Close"].iloc[-1]749tlt_change_5d = ((tlt_current - tlt_5d_ago) / tlt_5d_ago) * 100750751if not uup_hist.empty and len(uup_hist) >= 5:752uup_5d_ago = uup_hist["Close"].iloc[-min(5, len(uup_hist))]753uup_current = uup_hist["Close"].iloc[-1]754uup_change_5d = ((uup_current - uup_5d_ago) / uup_5d_ago) * 100755756# Risk-off detection: All three safe-havens rising together757if (gld_change_5d is not None and gld_change_5d >= 2.0 and758tlt_change_5d is not None and tlt_change_5d >= 1.0 and759uup_change_5d is not None and uup_change_5d >= 1.0):760risk_off_detected = True761overall_score -= 0.5 # Reduce score significantly762if verbose:763print(f" 🛡️ RISK-OFF DETECTED: GLD {gld_change_5d:+.1f}%, TLT {tlt_change_5d:+.1f}%, UUP {uup_change_5d:+.1f}%", file=sys.stderr)764765except Exception as e:766if verbose:767print(f" Safe-haven indicators unavailable: {e}", file=sys.stderr)768769# Build explanation770explanation = f"VIX {vix_level:.1f} ({vix_status}), Market {market_regime} (SPY {spy_trend_10d:+.1f}%, QQQ {qqq_trend_10d:+.1f}% 10d)"771if risk_off_detected:772explanation += " ⚠️ RISK-OFF MODE"773774result = MarketContext(775vix_level=vix_level,776vix_status=vix_status,777spy_trend_10d=spy_trend_10d,778qqq_trend_10d=qqq_trend_10d,779market_regime=market_regime,780score=overall_score,781explanation=explanation,782gld_change_5d=gld_change_5d,783tlt_change_5d=tlt_change_5d,784uup_change_5d=uup_change_5d,785risk_off_detected=risk_off_detected,786)787788# Cache the result for 1 hour789_set_cache("market_context", result)790return result791792except Exception as e:793if verbose:794print(f"Error analyzing market context: {e}", file=sys.stderr)795return None796797798def get_sector_etf_ticker(sector: str) -> str | None:799"""Map sector name to corresponding sector ETF ticker."""800sector_map = {801"Financial Services": "XLF",802"Financials": "XLF",803"Technology": "XLK",804"Healthcare": "XLV",805"Consumer Cyclical": "XLY",806"Consumer Defensive": "XLP",807"Utilities": "XLU",808"Basic Materials": "XLB",809"Real Estate": "XLRE",810"Communication Services": "XLC",811"Industrials": "XLI",812"Energy": "XLE",813}814815return sector_map.get(sector)816817818# ============================================================================819# Breaking News Check (v4.0.0)820# ============================================================================821822# Crisis keywords by category823CRISIS_KEYWORDS = {824"war": ["war", "invasion", "military strike", "attack", "conflict", "combat"],825"economic": ["recession", "crisis", "collapse", "default", "bankruptcy", "crash"],826"regulatory": ["sanctions", "embargo", "ban", "investigation", "fraud", "probe"],827"disaster": ["earthquake", "hurricane", "pandemic", "outbreak", "disaster", "catastrophe"],828"financial": ["emergency rate", "fed emergency", "bailout", "circuit breaker", "trading halt"],829}830831# Geopolitical event → sector mapping (v4.0.0)832GEOPOLITICAL_RISK_MAP = {833"taiwan": {834"keywords": ["taiwan", "tsmc", "strait"],835"sectors": ["Technology", "Communication Services"],836"sector_etfs": ["XLK", "XLC"],837"impact": "Semiconductor supply chain disruption",838"affected_tickers": ["NVDA", "AMD", "TSM", "INTC", "QCOM", "AVGO", "MU"],839},840"china": {841"keywords": ["china", "beijing", "tariff", "trade war"],842"sectors": ["Technology", "Consumer Cyclical", "Consumer Defensive"],843"sector_etfs": ["XLK", "XLY", "XLP"],844"impact": "Tech supply chain and consumer market exposure",845"affected_tickers": ["AAPL", "QCOM", "NKE", "SBUX", "MCD", "YUM", "TGT", "WMT"],846},847"russia_ukraine": {848"keywords": ["russia", "ukraine", "putin", "kyiv", "moscow"],849"sectors": ["Energy", "Materials"],850"sector_etfs": ["XLE", "XLB"],851"impact": "Energy and commodity price volatility",852"affected_tickers": ["XOM", "CVX", "COP", "SLB", "MOS", "CF", "NTR", "ADM"],853},854"middle_east": {855"keywords": ["iran", "israel", "gaza", "saudi", "middle east", "gulf"],856"sectors": ["Energy", "Industrials"],857"sector_etfs": ["XLE", "XLI"],858"impact": "Oil price volatility and defense spending",859"affected_tickers": ["XOM", "CVX", "COP", "LMT", "RTX", "NOC", "GD", "BA"],860},861"banking_crisis": {862"keywords": ["bank failure", "credit crisis", "liquidity crisis", "bank run"],863"sectors": ["Financials"],864"sector_etfs": ["XLF"],865"impact": "Financial sector contagion risk",866"affected_tickers": ["JPM", "BAC", "WFC", "C", "GS", "MS", "USB", "PNC"],867},868}869870871def check_breaking_news(verbose: bool = False) -> list[str] | None:872"""873Check Google News RSS for breaking market/economic crisis events (last 24h).874Returns list of alert strings or None.875Uses 1h cache to avoid excessive API calls.876"""877# Check cache first878cached = _get_cached("breaking_news")879if cached is not None:880return cached881882alerts = []883884try:885import feedparser886from datetime import datetime, timezone, timedelta887888if verbose:889print("Checking breaking news (Google News RSS)...", file=sys.stderr)890891# Google News RSS feeds for finance/business892rss_urls = [893"https://news.google.com/rss/search?q=stock+market+when:24h&hl=en-US&gl=US&ceid=US:en",894"https://news.google.com/rss/search?q=economy+crisis+when:24h&hl=en-US&gl=US&ceid=US:en",895]896897now = datetime.now(timezone.utc)898cutoff_time = now - timedelta(hours=24)899900for url in rss_urls:901try:902feed = feedparser.parse(url)903904for entry in feed.entries[:20]: # Check top 20 headlines905# Parse publication date906pub_date = None907if hasattr(entry, "published_parsed") and entry.published_parsed:908pub_date = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)909910# Skip if older than 24h911if pub_date and pub_date < cutoff_time:912continue913914title = entry.get("title", "").lower()915summary = entry.get("summary", "").lower()916text = f"{title} {summary}"917918# Check for crisis keywords919for category, keywords in CRISIS_KEYWORDS.items():920for keyword in keywords:921if keyword in text:922alert_text = entry.get("title", "Unknown alert")923hours_ago = int((now - pub_date).total_seconds() / 3600) if pub_date else None924time_str = f"{hours_ago}h ago" if hours_ago is not None else "recent"925926alert = f"{alert_text} ({time_str})"927if alert not in alerts: # Deduplicate928alerts.append(alert)929if verbose:930print(f" ⚠️ Alert: {alert}", file=sys.stderr)931break932if len(alerts) >= 3: # Limit to 3 alerts933break934935if len(alerts) >= 3:936break937938except Exception as e:939if verbose:940print(f" Failed to fetch {url}: {e}", file=sys.stderr)941continue942943# Cache results (even if empty) for 1 hour944result = alerts if alerts else None945_set_cache("breaking_news", result)946return result947948except Exception as e:949if verbose:950print(f" Breaking news check failed: {e}", file=sys.stderr)951return None952953954def check_sector_geopolitical_risk(955ticker: str,956sector: str | None,957breaking_news: list[str] | None,958verbose: bool = False959) -> tuple[str | None, float]:960"""961Check if ticker is exposed to geopolitical risks based on breaking news.962Returns (warning_message, confidence_penalty).963964Args:965ticker: Stock ticker symbol966sector: Stock sector (from yfinance)967breaking_news: List of breaking news alerts968verbose: Print debug info969970Returns:971(warning_message, confidence_penalty) where:972- warning_message: None or string like "⚠️ SECTOR RISK: Taiwan tensions affect semiconductors"973- confidence_penalty: 0.0 (no risk) to 0.5 (high risk)974"""975if not breaking_news:976return None, 0.0977978# Combine all breaking news into single text for keyword matching979news_text = " ".join(breaking_news).lower()980981# Check each geopolitical event982for event_name, event_data in GEOPOLITICAL_RISK_MAP.items():983# Check if any keywords from this event appear in breaking news984keywords_found = []985for keyword in event_data["keywords"]:986if keyword in news_text:987keywords_found.append(keyword)988989if not keywords_found:990continue991992# Check if ticker is in affected list993if ticker in event_data["affected_tickers"]:994# Direct ticker exposure995warning = f"⚠️ SECTOR RISK: {event_data['impact']} (detected: {', '.join(keywords_found)})"996penalty = 0.3 # Reduce BUY confidence by 30%997998if verbose:999print(f" Geopolitical risk detected: {event_name} affects {ticker}", file=sys.stderr)10001001return warning, penalty10021003# Check if sector is affected (even if ticker not in list)1004if sector and sector in event_data["sectors"]:1005# Sector exposure (weaker signal)1006warning = f"⚠️ SECTOR RISK: {sector} sector exposed to {event_data['impact']}"1007penalty = 0.15 # Reduce BUY confidence by 15%10081009if verbose:1010print(f" Sector risk detected: {event_name} affects {sector} sector", file=sys.stderr)10111012return warning, penalty10131014return None, 0.0101510161017def analyze_sector_performance(data: StockData, verbose: bool = False) -> SectorComparison | None:1018"""Compare stock performance to its sector."""1019try:1020sector = data.info.get("sector")1021industry = data.info.get("industry")10221023if not sector:1024return None10251026sector_etf_ticker = get_sector_etf_ticker(sector)10271028if not sector_etf_ticker:1029if verbose:1030print(f"No sector ETF mapping for {sector}", file=sys.stderr)1031return None10321033if verbose:1034print(f"Comparing to sector ETF: {sector_etf_ticker}", file=sys.stderr)10351036# Fetch sector ETF data1037sector_etf = yf.Ticker(sector_etf_ticker)1038sector_hist = sector_etf.history(period="3mo")10391040if sector_hist.empty or data.price_history is None or data.price_history.empty:1041return None10421043# Calculate 1-month returns1044stock_1m_ago = data.price_history["Close"].iloc[-min(22, len(data.price_history))]1045stock_current = data.price_history["Close"].iloc[-1]1046stock_return_1m = ((stock_current - stock_1m_ago) / stock_1m_ago) * 10010471048sector_1m_ago = sector_hist["Close"].iloc[-min(22, len(sector_hist))]1049sector_current = sector_hist["Close"].iloc[-1]1050sector_return_1m = ((sector_current - sector_1m_ago) / sector_1m_ago) * 10010511052# Calculate relative strength1053relative_strength = stock_return_1m / sector_return_1m if sector_return_1m != 0 else 1.010541055# Sector 10-day trend1056sector_10d_ago = sector_hist["Close"].iloc[-min(10, len(sector_hist))]1057sector_trend_10d = ((sector_current - sector_10d_ago) / sector_10d_ago) * 10010581059if sector_trend_10d > 5:1060sector_trend = "strong uptrend"1061elif sector_trend_10d > 2:1062sector_trend = "uptrend"1063elif sector_trend_10d < -5:1064sector_trend = "downtrend"1065elif sector_trend_10d < -2:1066sector_trend = "weak"1067else:1068sector_trend = "neutral"10691070# Calculate score1071score = 0.010721073# Relative performance score1074if relative_strength > 1.05: # Outperforming by >5%1075score += 0.31076elif relative_strength < 0.95: # Underperforming by >5%1077score -= 0.310781079# Sector trend score1080if sector_trend_10d > 5:1081score += 0.21082elif sector_trend_10d < -5:1083score -= 0.210841085explanation = f"{sector} sector {sector_trend} ({sector_return_1m:+.1f}% 1m), stock {stock_return_1m:+.1f}% vs sector"10861087return SectorComparison(1088sector_name=sector,1089industry_name=industry or "Unknown",1090stock_return_1m=stock_return_1m,1091sector_return_1m=sector_return_1m,1092relative_strength=relative_strength,1093sector_trend=sector_trend,1094score=score,1095explanation=explanation,1096)10971098except Exception as e:1099if verbose:1100print(f"Error analyzing sector performance: {e}", file=sys.stderr)1101return None110211031104def analyze_earnings_timing(data: StockData) -> EarningsTiming | None:1105"""Check earnings timing and flag pre/post-earnings periods."""1106try:1107from datetime import datetime, timedelta11081109if data.earnings_history is None or data.earnings_history.empty:1110return None11111112current_date = datetime.now()1113earnings_dates = data.earnings_history.sort_index(ascending=False)11141115# Find next and last earnings dates1116next_earnings_date = None1117last_earnings_date = None11181119for earnings_date in earnings_dates.index:1120earnings_dt = pd.Timestamp(earnings_date).to_pydatetime()11211122if earnings_dt > current_date and next_earnings_date is None:1123next_earnings_date = earnings_dt1124elif earnings_dt <= current_date and last_earnings_date is None:1125last_earnings_date = earnings_dt1126break11271128# Calculate days until/since earnings1129days_until_earnings = None1130days_since_earnings = None11311132if next_earnings_date:1133days_until_earnings = (next_earnings_date - current_date).days11341135if last_earnings_date:1136days_since_earnings = (current_date - last_earnings_date).days11371138# Determine timing flag1139timing_flag = "safe"1140confidence_adjustment = 0.01141caveats = []11421143# Pre-earnings check (< 14 days)1144if days_until_earnings is not None and days_until_earnings <= 14:1145timing_flag = "pre_earnings"1146confidence_adjustment = -0.31147caveats.append(f"Earnings in {days_until_earnings} days - high volatility expected")11481149# Post-earnings check (< 5 days)1150price_change_5d = None1151if days_since_earnings is not None and days_since_earnings <= 5:1152# Calculate 5-day price change1153if data.price_history is not None and len(data.price_history) >= 5:1154price_5d_ago = data.price_history["Close"].iloc[-5]1155price_current = data.price_history["Close"].iloc[-1]1156price_change_5d = ((price_current - price_5d_ago) / price_5d_ago) * 10011571158if price_change_5d > 15:1159timing_flag = "post_earnings"1160confidence_adjustment = -0.21161caveats.append(f"Up {price_change_5d:.1f}% in 5 days - gains may be priced in")11621163return EarningsTiming(1164days_until_earnings=days_until_earnings,1165days_since_earnings=days_since_earnings,1166next_earnings_date=next_earnings_date.strftime("%Y-%m-%d") if next_earnings_date else None,1167last_earnings_date=last_earnings_date.strftime("%Y-%m-%d") if last_earnings_date else None,1168timing_flag=timing_flag,1169price_change_5d=price_change_5d,1170confidence_adjustment=confidence_adjustment,1171caveats=caveats,1172)11731174except Exception:1175return None117611771178def calculate_rsi(prices: pd.Series, period: int = 14) -> float | None:1179"""Calculate RSI (Relative Strength Index)."""1180try:1181if len(prices) < period + 1:1182return None11831184# Calculate price changes1185delta = prices.diff()11861187# Separate gains and losses1188gains = delta.where(delta > 0, 0)1189losses = -delta.where(delta < 0, 0)11901191# Calculate average gains and losses1192avg_gain = gains.rolling(window=period).mean()1193avg_loss = losses.rolling(window=period).mean()11941195# Calculate RS1196rs = avg_gain / avg_loss11971198# Calculate RSI1199rsi = 100 - (100 / (1 + rs))12001201return float(rsi.iloc[-1])12021203except Exception:1204return None120512061207def analyze_momentum(data: StockData) -> MomentumAnalysis | None:1208"""Analyze momentum indicators (RSI, 52w range, volume, relative strength)."""1209try:1210if data.price_history is None or data.price_history.empty:1211return None12121213# Calculate RSI1214rsi_14d = calculate_rsi(data.price_history["Close"], period=14)12151216if rsi_14d:1217if rsi_14d > 70:1218rsi_status = "overbought"1219elif rsi_14d < 30:1220rsi_status = "oversold"1221else:1222rsi_status = "neutral"1223else:1224rsi_status = "unknown"12251226# Get 52-week high/low1227high_52w = data.info.get("fiftyTwoWeekHigh")1228low_52w = data.info.get("fiftyTwoWeekLow")1229current_price = data.info.get("regularMarketPrice") or data.info.get("currentPrice")12301231price_vs_52w_low = None1232price_vs_52w_high = None1233near_52w_high = False1234near_52w_low = False12351236if high_52w and low_52w and current_price:1237price_range = high_52w - low_52w1238if price_range > 0:1239price_vs_52w_low = ((current_price - low_52w) / price_range) * 1001240price_vs_52w_high = ((high_52w - current_price) / price_range) * 10012411242near_52w_high = price_vs_52w_low > 901243near_52w_low = price_vs_52w_low < 1012441245# Volume analysis1246volume_ratio = None1247if "Volume" in data.price_history.columns and len(data.price_history) >= 60:1248recent_vol = data.price_history["Volume"].iloc[-5:].mean()1249avg_vol = data.price_history["Volume"].iloc[-60:].mean()1250volume_ratio = recent_vol / avg_vol if avg_vol > 0 else None12511252# Calculate score1253score = 0.01254explanations = []12551256if rsi_14d:1257if rsi_14d > 70:1258score -= 0.51259explanations.append(f"RSI {rsi_14d:.0f} (overbought)")1260elif rsi_14d < 30:1261score += 0.51262explanations.append(f"RSI {rsi_14d:.0f} (oversold)")12631264if near_52w_high:1265score -= 0.31266explanations.append("Near 52w high")1267elif near_52w_low:1268score += 0.31269explanations.append("Near 52w low")12701271if volume_ratio and volume_ratio > 1.5:1272explanations.append(f"Volume {volume_ratio:.1f}x average")12731274explanation = "; ".join(explanations) if explanations else "Momentum indicators neutral"12751276return MomentumAnalysis(1277rsi_14d=rsi_14d,1278rsi_status=rsi_status,1279price_vs_52w_low=price_vs_52w_low,1280price_vs_52w_high=price_vs_52w_high,1281near_52w_high=near_52w_high,1282near_52w_low=near_52w_low,1283volume_ratio=volume_ratio,1284relative_strength_vs_sector=None, # Could be enhanced with sector comparison1285score=score,1286explanation=explanation,1287)12881289except Exception:1290return None129112921293# ============================================================================1294# Sentiment Analysis Helper Functions1295# ============================================================================12961297# Simple cache for shared indicators (Fear & Greed, VIX)1298# Format: {key: (value, timestamp)}1299_SENTIMENT_CACHE = {}1300_CACHE_TTL_SECONDS = 3600 # 1 hour130113021303def _get_cached(key: str):1304"""Get cached value if still valid (within TTL)."""1305if key in _SENTIMENT_CACHE:1306value, timestamp = _SENTIMENT_CACHE[key]1307if time.time() - timestamp < _CACHE_TTL_SECONDS:1308return value1309return None131013111312def _set_cache(key: str, value):1313"""Set cached value with current timestamp."""1314_SENTIMENT_CACHE[key] = (value, time.time())131513161317async def get_fear_greed_index() -> tuple[float, int | None, str | None] | None:1318"""1319Fetch CNN Fear & Greed Index (contrarian indicator) with 1h cache.1320Returns: (score, value, status) or None on failure.1321"""1322# Check cache first1323cached = _get_cached("fear_greed")1324if cached is not None:1325return cached13261327def _fetch():1328try:1329from fear_and_greed import get as get_fear_greed1330result = get_fear_greed()1331return result1332except Exception:1333return None13341335try:1336result = await asyncio.to_thread(_fetch)1337if result is None:1338return None13391340value = result.value # 0-1001341status = result.description # "Extreme Fear", "Fear", etc.13421343# Contrarian scoring1344if value <= 25:1345score = 0.5 # Extreme fear = buy opportunity1346elif value <= 45:1347score = 0.2 # Fear = mild buy signal1348elif value <= 55:1349score = 0.0 # Neutral1350elif value <= 75:1351score = -0.2 # Greed = caution1352else:1353score = -0.5 # Extreme greed = warning13541355result_tuple = (score, value, status)1356_set_cache("fear_greed", result_tuple)1357return result_tuple1358except Exception:1359return None136013611362async def get_short_interest(data: StockData) -> tuple[float, float | None, float | None] | None:1363"""1364Analyze short interest (from yfinance).1365Returns: (score, short_interest_pct, days_to_cover) or None.1366"""1367# This is already synchronous data access (no API call), but make it async for consistency1368try:1369short_pct = data.info.get("shortPercentOfFloat")1370if short_pct is None:1371return None13721373short_pct_float = float(short_pct) * 100 # Convert to percentage13741375# Estimate days to cover (simplified - actual calculation needs volume data)1376short_ratio = data.info.get("shortRatio") # Days to cover1377days_to_cover = float(short_ratio) if short_ratio else None13781379# Scoring logic1380if short_pct_float > 20:1381if days_to_cover and days_to_cover > 10:1382score = 0.4 # High short interest + high days to cover = squeeze potential1383else:1384score = -0.3 # High short interest but justified1385elif short_pct_float < 5:1386score = 0.2 # Low short interest = bullish sentiment1387else:1388score = 0.0 # Normal range13891390return (score, short_pct_float, days_to_cover)1391except Exception:1392return None139313941395async def get_vix_term_structure() -> tuple[float, str | None, float | None] | None:1396"""1397Analyze VIX futures term structure (contango vs backwardation) with 1h cache.1398Returns: (score, structure, slope) or None.1399"""1400# Check cache first1401cached = _get_cached("vix_structure")1402if cached is not None:1403return cached14041405def _fetch():1406try:1407import yfinance as yf1408vix = yf.Ticker("^VIX")1409vix_data = vix.history(period="5d")1410if vix_data.empty:1411return None1412return vix_data["Close"].iloc[-1]1413except Exception:1414return None14151416try:1417vix_spot = await asyncio.to_thread(_fetch)1418if vix_spot is None:1419return None14201421# Simplified: assume normal contango when VIX < 20, backwardation when VIX > 301422if vix_spot < 15:1423structure = "contango"1424slope = 10.0 # Steep contango1425score = 0.3 # Complacency/bullish1426elif vix_spot < 20:1427structure = "contango"1428slope = 5.01429score = 0.11430elif vix_spot > 30:1431structure = "backwardation"1432slope = -5.01433score = -0.3 # Stress/bearish1434else:1435structure = "flat"1436slope = 0.01437score = 0.014381439result_tuple = (score, structure, slope)1440_set_cache("vix_structure", result_tuple)1441return result_tuple1442except Exception:1443return None144414451446async def get_insider_activity(ticker: str, period_days: int = 90) -> tuple[float, int | None, float | None] | None:1447"""1448Analyze insider trading from SEC Form 4 filings using edgartools.1449Returns: (score, net_shares, net_value_millions) or None.14501451Scoring logic:1452- Strong buying (>100K shares or >$1M): +0.81453- Moderate buying (>10K shares or >$0.1M): +0.41454- Neutral: 01455- Moderate selling: -0.41456- Strong selling: -0.814571458Note: SEC EDGAR API requires User-Agent with email.1459"""1460def _fetch():1461try:1462from edgar import Company, set_identity1463from datetime import datetime, timedelta14641465# Set SEC-required identity1466set_identity("[email protected]")14671468# Get company and Form 4 filings1469company = Company(ticker)1470filings = company.get_filings(form="4")14711472if filings is None or len(filings) == 0:1473return None14741475# Calculate cutoff date1476cutoff_date = datetime.now() - timedelta(days=period_days)14771478# Aggregate transactions1479total_bought_shares = 01480total_sold_shares = 01481total_bought_value = 0.01482total_sold_value = 0.014831484# Process recent filings (iterate, don't slice due to pyarrow compatibility)1485count = 01486for filing in filings:1487if count >= 50:1488break1489count += 114901491try:1492# Check filing date1493filing_date = filing.filing_date1494if hasattr(filing_date, 'to_pydatetime'):1495filing_date = filing_date.to_pydatetime()1496elif isinstance(filing_date, str):1497filing_date = datetime.strptime(filing_date, "%Y-%m-%d")14981499# Convert date object to datetime for comparison1500if hasattr(filing_date, 'year') and not hasattr(filing_date, 'hour'):1501filing_date = datetime.combine(filing_date, datetime.min.time())15021503if filing_date < cutoff_date:1504continue15051506# Get Form 4 object1507form4 = filing.obj()1508if form4 is None:1509continue15101511# Process purchases (edgartools returns DataFrames)1512if hasattr(form4, 'common_stock_purchases'):1513purchases = form4.common_stock_purchases1514if isinstance(purchases, pd.DataFrame) and not purchases.empty:1515if 'Shares' in purchases.columns:1516total_bought_shares += int(purchases['Shares'].sum())1517if 'Price' in purchases.columns and 'Shares' in purchases.columns:1518total_bought_value += float((purchases['Shares'] * purchases['Price']).sum())15191520# Process sales1521if hasattr(form4, 'common_stock_sales'):1522sales = form4.common_stock_sales1523if isinstance(sales, pd.DataFrame) and not sales.empty:1524if 'Shares' in sales.columns:1525total_sold_shares += int(sales['Shares'].sum())1526if 'Price' in sales.columns and 'Shares' in sales.columns:1527total_sold_value += float((sales['Shares'] * sales['Price']).sum())15281529except Exception:1530continue15311532# Calculate net values1533net_shares = total_bought_shares - total_sold_shares1534net_value = (total_bought_value - total_sold_value) / 1_000_000 # Millions15351536# Apply scoring logic1537if net_shares > 100_000 or net_value > 1.0:1538score = 0.8 # Strong buying1539elif net_shares > 10_000 or net_value > 0.1:1540score = 0.4 # Moderate buying1541elif net_shares < -100_000 or net_value < -1.0:1542score = -0.8 # Strong selling1543elif net_shares < -10_000 or net_value < -0.1:1544score = -0.4 # Moderate selling1545else:1546score = 0.0 # Neutral15471548return (score, net_shares, net_value)15491550except ImportError:1551# edgartools not installed1552return None1553except Exception:1554return None15551556try:1557result = await asyncio.to_thread(_fetch)1558return result1559except Exception:1560return None156115621563async def get_put_call_ratio(data: StockData) -> tuple[float, float | None, int | None, int | None] | None:1564"""1565Calculate put/call ratio from options chain (contrarian indicator).1566Returns: (score, ratio, put_volume, call_volume) or None.1567"""1568def _fetch():1569try:1570if data.ticker_obj is None:1571return None15721573# Get options chain for nearest expiration1574expirations = data.ticker_obj.options1575if not expirations or len(expirations) == 0:1576return None15771578nearest_exp = expirations[0]1579opt_chain = data.ticker_obj.option_chain(nearest_exp)15801581# Calculate total put and call volume1582put_volume = opt_chain.puts["volume"].sum() if "volume" in opt_chain.puts.columns else 01583call_volume = opt_chain.calls["volume"].sum() if "volume" in opt_chain.calls.columns else 015841585if call_volume == 0 or put_volume == 0:1586return None15871588ratio = put_volume / call_volume1589return (ratio, int(put_volume), int(call_volume))1590except Exception:1591return None15921593try:1594result = await asyncio.to_thread(_fetch)1595if result is None:1596return None15971598ratio, put_volume, call_volume = result15991600# Contrarian scoring1601if ratio > 1.5:1602score = 0.3 # Excessive fear = bullish1603elif ratio > 1.0:1604score = 0.1 # Mild fear1605elif ratio > 0.7:1606score = -0.1 # Normal1607else:1608score = -0.3 # Complacency = bearish16091610return (score, ratio, put_volume, call_volume)1611except Exception:1612return None161316141615async def analyze_sentiment(data: StockData, verbose: bool = False) -> SentimentAnalysis | None:1616"""1617Analyze market sentiment using 5 sub-indicators in parallel.1618Requires at least 2 of 5 indicators for valid sentiment.1619Returns overall sentiment score (-1.0 to +1.0) with sub-metrics.1620"""1621scores = []1622explanations = []1623warnings = []16241625# Initialize all raw data fields1626fear_greed_score = None1627fear_greed_value = None1628fear_greed_status = None16291630short_interest_score = None1631short_interest_pct = None1632days_to_cover = None16331634vix_structure_score = None1635vix_structure = None1636vix_slope = None16371638insider_activity_score = None1639insider_net_shares = None1640insider_net_value = None16411642put_call_score = None1643put_call_ratio = None1644put_volume = None1645call_volume = None16461647# Fetch all 5 indicators in parallel with 10s timeout per indicator1648try:1649results = await asyncio.gather(1650asyncio.wait_for(get_fear_greed_index(), timeout=10),1651asyncio.wait_for(get_short_interest(data), timeout=10),1652asyncio.wait_for(get_vix_term_structure(), timeout=10),1653asyncio.wait_for(get_insider_activity(data.ticker, period_days=90), timeout=10),1654asyncio.wait_for(get_put_call_ratio(data), timeout=10),1655return_exceptions=True # Don't fail if one indicator fails1656)16571658# Process Fear & Greed Index1659fear_greed_result = results[0]1660if isinstance(fear_greed_result, tuple) and fear_greed_result is not None:1661fear_greed_score, fear_greed_value, fear_greed_status = fear_greed_result1662scores.append(fear_greed_score)1663explanations.append(f"{fear_greed_status} ({fear_greed_value})")1664if verbose:1665print(f" Fear & Greed: {fear_greed_status} ({fear_greed_value}) → score {fear_greed_score:+.2f}", file=sys.stderr)1666elif verbose and isinstance(fear_greed_result, Exception):1667print(f" Fear & Greed: Failed ({fear_greed_result})", file=sys.stderr)16681669# Process Short Interest1670short_interest_result = results[1]1671if isinstance(short_interest_result, tuple) and short_interest_result is not None:1672short_interest_score, short_interest_pct, days_to_cover = short_interest_result1673scores.append(short_interest_score)1674if days_to_cover:1675explanations.append(f"Short interest {short_interest_pct:.1f}% (days to cover: {days_to_cover:.1f})")1676else:1677explanations.append(f"Short interest {short_interest_pct:.1f}%")1678warnings.append("Short interest data typically ~2 weeks old (FINRA lag)")1679if verbose:1680print(f" Short Interest: {short_interest_pct:.1f}% → score {short_interest_score:+.2f}", file=sys.stderr)1681elif verbose and isinstance(short_interest_result, Exception):1682print(f" Short Interest: Failed ({short_interest_result})", file=sys.stderr)16831684# Process VIX Term Structure1685vix_result = results[2]1686if isinstance(vix_result, tuple) and vix_result is not None:1687vix_structure_score, vix_structure, vix_slope = vix_result1688scores.append(vix_structure_score)1689explanations.append(f"VIX {vix_structure}")1690if verbose:1691print(f" VIX Structure: {vix_structure} (slope {vix_slope:.1f}%) → score {vix_structure_score:+.2f}", file=sys.stderr)1692elif verbose and isinstance(vix_result, Exception):1693print(f" VIX Structure: Failed ({vix_result})", file=sys.stderr)16941695# Process Insider Activity1696insider_result = results[3]1697if isinstance(insider_result, tuple) and insider_result is not None:1698insider_activity_score, insider_net_shares, insider_net_value = insider_result1699scores.append(insider_activity_score)1700if insider_net_value:1701explanations.append(f"Insider net: ${insider_net_value:.1f}M")1702warnings.append("Insider trades may lag filing by 2-3 days")1703if verbose:1704print(f" Insider Activity: Net ${insider_net_value:.1f}M → score {insider_activity_score:+.2f}", file=sys.stderr)1705elif verbose and isinstance(insider_result, Exception):1706print(f" Insider Activity: Failed ({insider_result})", file=sys.stderr)17071708# Process Put/Call Ratio1709put_call_result = results[4]1710if isinstance(put_call_result, tuple) and put_call_result is not None:1711put_call_score, put_call_ratio, put_volume, call_volume = put_call_result1712scores.append(put_call_score)1713explanations.append(f"Put/call ratio {put_call_ratio:.2f}")1714if verbose:1715print(f" Put/Call Ratio: {put_call_ratio:.2f} → score {put_call_score:+.2f}", file=sys.stderr)1716elif verbose and isinstance(put_call_result, Exception):1717print(f" Put/Call Ratio: Failed ({put_call_result})", file=sys.stderr)17181719except Exception as e:1720if verbose:1721print(f" Sentiment analysis error: {e}", file=sys.stderr)1722return None17231724# Require at least 2 of 5 indicators for valid sentiment1725indicators_available = len(scores)1726if indicators_available < 2:1727if verbose:1728print(f" Sentiment: Insufficient data ({indicators_available}/5 indicators)", file=sys.stderr)1729return None17301731# Calculate overall score as simple average1732overall_score = sum(scores) / len(scores)1733explanation = "; ".join(explanations)17341735return SentimentAnalysis(1736score=overall_score,1737explanation=explanation,1738fear_greed_score=fear_greed_score,1739short_interest_score=short_interest_score,1740vix_structure_score=vix_structure_score,1741insider_activity_score=insider_activity_score,1742put_call_score=put_call_score,1743fear_greed_value=fear_greed_value,1744fear_greed_status=fear_greed_status,1745short_interest_pct=short_interest_pct,1746days_to_cover=days_to_cover,1747vix_structure=vix_structure,1748vix_slope=vix_slope,1749insider_net_shares=insider_net_shares,1750insider_net_value=insider_net_value,1751put_call_ratio=put_call_ratio,1752put_volume=put_volume,1753call_volume=call_volume,1754indicators_available=indicators_available,1755data_freshness_warnings=warnings if warnings else None,1756)175717581759def synthesize_signal(1760ticker: str,1761company_name: str,1762earnings: EarningsSurprise | None,1763fundamentals: Fundamentals | None,1764analysts: AnalystSentiment | None,1765historical: HistoricalPatterns | None,1766market_context: MarketContext | None,1767sector: SectorComparison | None,1768earnings_timing: EarningsTiming | None,1769momentum: MomentumAnalysis | None,1770sentiment: SentimentAnalysis | None,1771breaking_news: list[str] | None = None, # NEW v4.0.01772geopolitical_risk_warning: str | None = None, # NEW v4.0.01773geopolitical_risk_penalty: float = 0.0, # NEW v4.0.01774) -> Signal:1775"""Synthesize all components into a final signal."""17761777# Collect available components with weights1778components = []1779weights = []17801781if earnings:1782components.append(("earnings", earnings.score))1783weights.append(0.30) # reduced from 0.3517841785if fundamentals:1786components.append(("fundamentals", fundamentals.score))1787weights.append(0.20) # reduced from 0.2517881789if analysts and analysts.score is not None:1790components.append(("analysts", analysts.score))1791weights.append(0.20) # reduced from 0.2517921793if historical:1794components.append(("historical", historical.score))1795weights.append(0.10) # reduced from 0.1517961797# NEW COMPONENTS1798if market_context:1799components.append(("market", market_context.score))1800weights.append(0.10)18011802if sector:1803components.append(("sector", sector.score))1804weights.append(0.15)18051806if momentum:1807components.append(("momentum", momentum.score))1808weights.append(0.15)18091810if sentiment:1811components.append(("sentiment", sentiment.score))1812weights.append(0.10)18131814# Require at least 2 components1815if len(components) < 2:1816return Signal(1817ticker=ticker,1818company_name=company_name,1819recommendation="HOLD",1820confidence=0.0,1821final_score=0.0,1822supporting_points=["Insufficient data for analysis"],1823caveats=["Limited data available"],1824timestamp=datetime.now().isoformat(),1825components={},1826)18271828# Normalize weights1829total_weight = sum(weights)1830normalized_weights = [w / total_weight for w in weights]18311832# Calculate weighted score1833final_score = sum(score * weight for (_, score), weight in zip(components, normalized_weights))18341835# Determine recommendation1836if final_score > 0.33:1837recommendation = "BUY"1838elif final_score < -0.33:1839recommendation = "SELL"1840else:1841recommendation = "HOLD"18421843confidence = abs(final_score)18441845# Apply earnings timing adjustments and overrides1846if earnings_timing:1847confidence *= (1.0 + earnings_timing.confidence_adjustment)18481849# Override recommendation if needed1850if earnings_timing.timing_flag == "pre_earnings":1851if recommendation == "BUY":1852recommendation = "HOLD"18531854elif earnings_timing.timing_flag == "post_earnings":1855if earnings_timing.price_change_5d and earnings_timing.price_change_5d > 15:1856if recommendation == "BUY":1857recommendation = "HOLD"18581859# Check overbought + near 52w high1860if momentum and momentum.rsi_14d and momentum.rsi_14d > 70 and momentum.near_52w_high:1861if recommendation == "BUY":1862recommendation = "HOLD"1863confidence *= 0.718641865# NEW v4.0.0: Risk-off confidence penalty1866if market_context and market_context.risk_off_detected:1867if recommendation == "BUY":1868confidence *= 0.7 # Reduce BUY confidence by 30%18691870# NEW v4.0.0: Geopolitical sector risk penalty1871if geopolitical_risk_penalty > 0:1872if recommendation == "BUY":1873confidence *= (1.0 - geopolitical_risk_penalty) # Apply penalty18741875# Generate supporting points1876supporting_points = []18771878if earnings and earnings.actual_eps is not None:1879supporting_points.append(1880f"{earnings.explanation} - EPS ${earnings.actual_eps:.2f} vs ${earnings.expected_eps:.2f} expected"1881)18821883if fundamentals and fundamentals.explanation:1884supporting_points.append(fundamentals.explanation)18851886if analysts and analysts.summary:1887supporting_points.append(f"Analyst consensus: {analysts.summary}")18881889if historical and historical.pattern_desc:1890supporting_points.append(f"Historical pattern: {historical.pattern_desc}")18911892if market_context and market_context.explanation:1893supporting_points.append(f"Market: {market_context.explanation}")18941895if sector and sector.explanation:1896supporting_points.append(f"Sector: {sector.explanation}")18971898if momentum and momentum.explanation:1899supporting_points.append(f"Momentum: {momentum.explanation}")19001901if sentiment and sentiment.explanation:1902supporting_points.append(f"Sentiment: {sentiment.explanation}")19031904# Generate caveats1905caveats = []19061907# Add earnings timing caveats first (most important)1908if earnings_timing and earnings_timing.caveats:1909caveats.extend(earnings_timing.caveats)19101911# Add sentiment warnings1912if sentiment and sentiment.data_freshness_warnings:1913caveats.extend(sentiment.data_freshness_warnings)19141915# Add momentum warnings1916if momentum and momentum.rsi_14d:1917if momentum.rsi_14d > 70 and momentum.near_52w_high:1918caveats.append("Overbought conditions - high risk entry")19191920# Add sector warnings1921if sector and sector.score < -0.2:1922caveats.append(f"Sector {sector.sector_name} is weak despite stock fundamentals")19231924# Add market warnings1925if market_context and market_context.vix_status == "fear":1926caveats.append(f"High market volatility (VIX {market_context.vix_level:.0f})")19271928# NEW v4.0.0: Risk-off warnings1929if market_context and market_context.risk_off_detected:1930caveats.append(f"🛡️ RISK-OFF MODE: Flight to safety detected (GLD {market_context.gld_change_5d:+.1f}%, TLT {market_context.tlt_change_5d:+.1f}%, UUP {market_context.uup_change_5d:+.1f}%)")19311932# NEW v4.0.0: Breaking news alerts1933if breaking_news:1934for alert in breaking_news[:2]: # Limit to 2 alerts to avoid overwhelming1935caveats.append(f"⚠️ BREAKING NEWS: {alert}")19361937# NEW v4.0.0: Geopolitical sector risk warnings1938if geopolitical_risk_warning:1939caveats.append(geopolitical_risk_warning)19401941# Original caveats1942if not analysts or analysts.score is None:1943caveats.append("Limited or no analyst coverage")19441945if not earnings:1946caveats.append("No recent earnings data available")19471948if len(components) < 4:1949caveats.append("Analysis based on limited data components")19501951if not caveats:1952caveats.append("Market conditions can change rapidly")19531954# Limit to 5 caveats1955caveats = caveats[:5]19561957# Build components dict for output1958components_dict = {}1959if earnings:1960components_dict["earnings_surprise"] = {1961"score": earnings.score,1962"actual_eps": earnings.actual_eps,1963"expected_eps": earnings.expected_eps,1964"surprise_pct": earnings.surprise_pct,1965"explanation": earnings.explanation,1966}19671968if fundamentals:1969components_dict["fundamentals"] = {1970"score": fundamentals.score,1971**fundamentals.key_metrics,1972}19731974if analysts:1975components_dict["analyst_sentiment"] = {1976"score": analysts.score,1977"consensus_rating": analysts.consensus_rating,1978"price_target": analysts.price_target,1979"current_price": analysts.current_price,1980"upside_pct": analysts.upside_pct,1981"num_analysts": analysts.num_analysts,1982}19831984if historical:1985components_dict["historical_patterns"] = {1986"score": historical.score,1987"beats_last_4q": historical.beats_last_4q,1988"avg_reaction_pct": historical.avg_reaction_pct,1989}19901991if market_context:1992components_dict["market_context"] = {1993"score": market_context.score,1994"vix_level": market_context.vix_level,1995"vix_status": market_context.vix_status,1996"spy_trend_10d": market_context.spy_trend_10d,1997"qqq_trend_10d": market_context.qqq_trend_10d,1998"market_regime": market_context.market_regime,1999"gld_change_5d": market_context.gld_change_5d,2000"tlt_change_5d": market_context.tlt_change_5d,2001"uup_change_5d": market_context.uup_change_5d,2002"risk_off_detected": market_context.risk_off_detected,2003}20042005if sector:2006components_dict["sector_performance"] = {2007"score": sector.score,2008"sector_name": sector.sector_name,2009"stock_return_1m": sector.stock_return_1m,2010"sector_return_1m": sector.sector_return_1m,2011"relative_strength": sector.relative_strength,2012"sector_trend": sector.sector_trend,2013}20142015if earnings_timing:2016components_dict["earnings_timing"] = {2017"days_until_earnings": earnings_timing.days_until_earnings,2018"days_since_earnings": earnings_timing.days_since_earnings,2019"timing_flag": earnings_timing.timing_flag,2020"price_change_5d": earnings_timing.price_change_5d,2021"confidence_adjustment": earnings_timing.confidence_adjustment,2022}20232024if momentum:2025components_dict["momentum"] = {2026"score": momentum.score,2027"rsi_14d": momentum.rsi_14d,2028"rsi_status": momentum.rsi_status,2029"near_52w_high": momentum.near_52w_high,2030"near_52w_low": momentum.near_52w_low,2031"volume_ratio": momentum.volume_ratio,2032}20332034if sentiment:2035components_dict["sentiment_analysis"] = {2036"score": sentiment.score,2037"indicators_available": sentiment.indicators_available,2038"fear_greed_value": sentiment.fear_greed_value,2039"fear_greed_status": sentiment.fear_greed_status,2040"short_interest_pct": sentiment.short_interest_pct,2041"days_to_cover": sentiment.days_to_cover,2042"vix_structure": sentiment.vix_structure,2043"vix_slope": sentiment.vix_slope,2044"insider_net_value": sentiment.insider_net_value,2045"put_call_ratio": sentiment.put_call_ratio,2046"data_freshness_warnings": sentiment.data_freshness_warnings,2047}20482049return Signal(2050ticker=ticker,2051company_name=company_name,2052recommendation=recommendation,2053confidence=confidence,2054final_score=final_score,2055supporting_points=supporting_points[:5], # Limit to 52056caveats=caveats, # Already limited to 5 earlier2057timestamp=datetime.now().isoformat(),2058components=components_dict,2059)206020612062def format_output_text(signal: Signal) -> str:2063"""Format signal as text output."""2064lines = [2065"=" * 77,2066f"STOCK ANALYSIS: {signal.ticker} ({signal.company_name})",2067f"Generated: {signal.timestamp}",2068"=" * 77,2069"",2070f"RECOMMENDATION: {signal.recommendation} (Confidence: {signal.confidence*100:.0f}%)",2071"",2072"SUPPORTING POINTS:",2073]20742075for point in signal.supporting_points:2076lines.append(f"• {point}")20772078lines.extend([2079"",2080"CAVEATS:",2081])20822083for caveat in signal.caveats:2084lines.append(f"• {caveat}")20852086lines.extend([2087"",2088"=" * 77,2089"DISCLAIMER: This analysis is for informational purposes only and does NOT",2090"constitute financial advice. Consult a licensed financial advisor before",2091"making investment decisions. Data provided by Yahoo Finance.",2092"=" * 77,2093])20942095return "\n".join(lines)209620972098def format_output_json(signal: Signal) -> str:2099"""Format signal as JSON output."""2100output = {2101**asdict(signal),2102"disclaimer": "NOT FINANCIAL ADVICE. For informational purposes only.",2103}2104return json.dumps(output, indent=2)210521062107def main():2108parser = argparse.ArgumentParser(2109description="Analyze stocks using Yahoo Finance data"2110)2111parser.add_argument(2112"tickers",2113nargs="*",2114help="Stock/crypto ticker(s) to analyze"2115)2116parser.add_argument(2117"--output",2118choices=["text", "json"],2119default="text",2120help="Output format (default: text)"2121)2122parser.add_argument(2123"--verbose",2124action="store_true",2125help="Verbose output to stderr"2126)2127parser.add_argument(2128"--portfolio", "-p",2129type=str,2130help="Analyze all assets in a portfolio"2131)2132parser.add_argument(2133"--period",2134choices=["daily", "weekly", "monthly", "quarterly", "yearly"],2135help="Period for portfolio performance analysis"2136)21372138args = parser.parse_args()21392140# Handle portfolio mode2141portfolio_assets = []2142portfolio_name = None2143if args.portfolio:2144try:2145from portfolio import PortfolioStore2146store = PortfolioStore()2147portfolio = store.get_portfolio(args.portfolio)2148if not portfolio:2149# Try to find default portfolio if name not found2150default_name = store.get_default_portfolio_name()2151if default_name and args.portfolio.lower() == "default":2152portfolio = store.get_portfolio(default_name)2153portfolio_name = default_name2154else:2155print(f"Error: Portfolio '{args.portfolio}' not found", file=sys.stderr)2156sys.exit(1)2157else:2158portfolio_name = portfolio.name21592160if not portfolio.assets:2161print(f"Portfolio '{portfolio_name}' has no assets", file=sys.stderr)2162sys.exit(1)21632164portfolio_assets = [(a.ticker, a.quantity, a.cost_basis, a.type) for a in portfolio.assets]2165args.tickers = [a.ticker for a in portfolio.assets]21662167if args.verbose:2168print(f"Analyzing portfolio: {portfolio_name} ({len(portfolio_assets)} assets)", file=sys.stderr)21692170except ImportError:2171print("Error: portfolio.py not found", file=sys.stderr)2172sys.exit(1)2173except Exception as e:2174print(f"Error loading portfolio: {e}", file=sys.stderr)2175sys.exit(1)21762177if not args.tickers:2178parser.print_help()2179sys.exit(1)21802181# NEW v4.0.0: Check for breaking news (market-wide, check once before analyzing tickers)2182if args.verbose:2183print(f"Checking breaking news (last 24h)...", file=sys.stderr)2184breaking_news = check_breaking_news(verbose=args.verbose)2185if breaking_news and args.verbose:2186print(f" Found {len(breaking_news)} breaking news alert(s)\n", file=sys.stderr)21872188results = []21892190for ticker in args.tickers:2191ticker = ticker.upper()21922193if args.verbose:2194print(f"\n=== Analyzing {ticker} ===\n", file=sys.stderr)21952196# Fetch data2197data = fetch_stock_data(ticker, verbose=args.verbose)21982199if data is None:2200print(f"Error: Invalid ticker '{ticker}' or data unavailable", file=sys.stderr)2201sys.exit(2)22022203# Get company name2204company_name = data.info.get("longName") or data.info.get("shortName") or ticker22052206# Detect asset type (crypto vs stock)2207is_crypto = data.asset_type == "crypto"22082209if args.verbose and is_crypto:2210print(f" Asset type: CRYPTO (using crypto-specific analysis)", file=sys.stderr)22112212# Analyze components (different for crypto vs stock)2213if is_crypto:2214# Crypto: Skip stock-specific analyses2215earnings = None2216fundamentals = None2217analysts = None2218historical = None2219earnings_timing = None2220sector = None22212222# Crypto fundamentals (market cap, category, BTC correlation)2223if args.verbose:2224print(f"Analyzing crypto fundamentals...", file=sys.stderr)2225crypto_fundamentals = analyze_crypto_fundamentals(data, verbose=args.verbose)22262227# Convert crypto fundamentals to regular Fundamentals for synthesize_signal2228if crypto_fundamentals:2229fundamentals = Fundamentals(2230score=crypto_fundamentals.score,2231key_metrics={2232"market_cap": crypto_fundamentals.market_cap,2233"market_cap_rank": crypto_fundamentals.market_cap_rank,2234"category": crypto_fundamentals.category,2235"btc_correlation": crypto_fundamentals.btc_correlation,2236},2237explanation=crypto_fundamentals.explanation,2238)2239else:2240# Stock: Full analysis2241earnings = analyze_earnings_surprise(data)2242fundamentals = analyze_fundamentals(data)2243analysts = analyze_analyst_sentiment(data)2244historical = analyze_historical_patterns(data)22452246# Analyze earnings timing (stocks only)2247if args.verbose:2248print(f"Checking earnings timing...", file=sys.stderr)2249earnings_timing = analyze_earnings_timing(data)22502251# Analyze sector performance (stocks only)2252if args.verbose:2253print(f"Analyzing sector performance...", file=sys.stderr)2254sector = analyze_sector_performance(data, verbose=args.verbose)22552256# Market context (both crypto and stock)2257if args.verbose:2258print(f"Analyzing market context...", file=sys.stderr)2259market_context = analyze_market_context(verbose=args.verbose)22602261# Momentum (both crypto and stock)2262if args.verbose:2263print(f"Analyzing momentum...", file=sys.stderr)2264momentum = analyze_momentum(data)22652266# Sentiment (stocks get full sentiment, crypto gets limited)2267if args.verbose:2268print(f"Analyzing market sentiment...", file=sys.stderr)2269if is_crypto:2270# Skip insider trading and put/call for crypto2271sentiment = None2272else:2273sentiment = asyncio.run(analyze_sentiment(data, verbose=args.verbose))22742275# Geopolitical risks (stocks only)2276if is_crypto:2277geopolitical_risk_warning = None2278geopolitical_risk_penalty = 0.02279else:2280sector_name = data.info.get("sector")2281geopolitical_risk_warning, geopolitical_risk_penalty = check_sector_geopolitical_risk(2282ticker=ticker,2283sector=sector_name,2284breaking_news=breaking_news,2285verbose=args.verbose2286)22872288if args.verbose:2289print(f"Components analyzed:", file=sys.stderr)2290if is_crypto:2291print(f" Crypto Fundamentals: {'✓' if fundamentals else '✗'}", file=sys.stderr)2292print(f" Market Context: {'✓' if market_context else '✗'}", file=sys.stderr)2293print(f" Momentum: {'✓' if momentum else '✗'}", file=sys.stderr)2294print(f" (Earnings, Sector, Sentiment: N/A for crypto)\n", file=sys.stderr)2295else:2296print(f" Earnings: {'✓' if earnings else '✗'}", file=sys.stderr)2297print(f" Fundamentals: {'✓' if fundamentals else '✗'}", file=sys.stderr)2298print(f" Analysts: {'✓' if analysts and analysts.score else '✗'}", file=sys.stderr)2299print(f" Historical: {'✓' if historical else '✗'}", file=sys.stderr)2300print(f" Market Context: {'✓' if market_context else '✗'}", file=sys.stderr)2301print(f" Sector: {'✓' if sector else '✗'}", file=sys.stderr)2302print(f" Earnings Timing: {'✓' if earnings_timing else '✗'}", file=sys.stderr)2303print(f" Momentum: {'✓' if momentum else '✗'}", file=sys.stderr)2304print(f" Sentiment: {'✓' if sentiment else '✗'}\n", file=sys.stderr)23052306# Synthesize signal2307signal = synthesize_signal(2308ticker=ticker,2309company_name=company_name,2310earnings=earnings,2311fundamentals=fundamentals,2312analysts=analysts,2313historical=historical,2314market_context=market_context, # NEW2315sector=sector, # NEW2316earnings_timing=earnings_timing, # NEW2317momentum=momentum, # NEW2318sentiment=sentiment, # NEW2319breaking_news=breaking_news, # NEW v4.0.02320geopolitical_risk_warning=geopolitical_risk_warning, # NEW v4.0.02321geopolitical_risk_penalty=geopolitical_risk_penalty, # NEW v4.0.02322)23232324results.append(signal)23252326# Output results2327if args.output == "json":2328if len(results) == 1:2329print(format_output_json(results[0]))2330else:2331output_data = [asdict(r) for r in results]2332# Add portfolio summary if in portfolio mode2333if portfolio_assets:2334portfolio_summary = generate_portfolio_summary(2335results, portfolio_assets, portfolio_name, args.period2336)2337output_data = {2338"portfolio": portfolio_name,2339"assets": output_data,2340"summary": portfolio_summary,2341}2342print(json.dumps(output_data, indent=2))2343else:2344for i, signal in enumerate(results):2345if i > 0:2346print("\n")2347print(format_output_text(signal))23482349# Print portfolio summary if in portfolio mode2350if portfolio_assets:2351print_portfolio_summary(results, portfolio_assets, portfolio_name, args.period)235223532354def generate_portfolio_summary(2355results: list,2356portfolio_assets: list[tuple[str, float, float, str]],2357portfolio_name: str,2358period: str | None = None,2359) -> dict:2360"""Generate portfolio summary data."""2361# Map results by ticker2362result_map = {r.ticker: r for r in results}23632364# Calculate portfolio metrics2365total_cost = 0.02366total_value = 0.02367asset_values = []23682369for ticker, quantity, cost_basis, asset_type in portfolio_assets:2370cost_total = quantity * cost_basis2371total_cost += cost_total23722373# Get current price from yfinance2374try:2375stock = yf.Ticker(ticker)2376current_price = stock.info.get("regularMarketPrice", 0) or 02377current_value = quantity * current_price2378total_value += current_value2379asset_values.append((ticker, current_value, cost_total, asset_type))2380except Exception:2381asset_values.append((ticker, 0, cost_total, asset_type))23822383# Calculate period returns if requested2384period_return = None2385if period and total_value > 0:2386period_days = {2387"daily": 1,2388"weekly": 7,2389"monthly": 30,2390"quarterly": 90,2391"yearly": 365,2392}.get(period, 30)23932394period_return = calculate_portfolio_period_return(portfolio_assets, period_days)23952396# Concentration analysis2397concentrations = []2398if total_value > 0:2399for ticker, value, _, asset_type in asset_values:2400if value > 0:2401pct = value / total_value * 1002402if pct > 30:2403concentrations.append(f"{ticker}: {pct:.1f}%")24042405# Build summary2406total_pnl = total_value - total_cost2407total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else 024082409summary = {2410"portfolio_name": portfolio_name,2411"total_cost": total_cost,2412"total_value": total_value,2413"total_pnl": total_pnl,2414"total_pnl_pct": total_pnl_pct,2415"asset_count": len(portfolio_assets),2416"concentration_warnings": concentrations if concentrations else None,2417}24182419if period_return is not None:2420summary["period"] = period2421summary["period_return_pct"] = period_return24222423return summary242424252426def calculate_portfolio_period_return(2427portfolio_assets: list[tuple[str, float, float, str]],2428period_days: int,2429) -> float | None:2430"""Calculate portfolio return over a period using historical prices."""2431try:2432total_start_value = 0.02433total_current_value = 0.024342435for ticker, quantity, _, _ in portfolio_assets:2436stock = yf.Ticker(ticker)2437hist = stock.history(period=f"{period_days + 5}d")24382439if hist.empty or len(hist) < 2:2440continue24412442# Get price at period start and now2443current_price = hist["Close"].iloc[-1]2444start_price = hist["Close"].iloc[0]24452446total_current_value += quantity * current_price2447total_start_value += quantity * start_price24482449if total_start_value > 0:2450return (total_current_value - total_start_value) / total_start_value * 10024512452except Exception:2453pass24542455return None245624572458def print_portfolio_summary(2459results: list,2460portfolio_assets: list[tuple[str, float, float, str]],2461portfolio_name: str,2462period: str | None = None,2463) -> None:2464"""Print portfolio summary in text format."""2465summary = generate_portfolio_summary(results, portfolio_assets, portfolio_name, period)24662467print("\n" + "=" * 77)2468print(f"PORTFOLIO SUMMARY: {portfolio_name}")2469print("=" * 77)24702471# Value overview2472total_cost = summary["total_cost"]2473total_value = summary["total_value"]2474total_pnl = summary["total_pnl"]2475total_pnl_pct = summary["total_pnl_pct"]24762477print(f"\nTotal Cost: ${total_cost:,.2f}")2478print(f"Current Value: ${total_value:,.2f}")2479pnl_sign = "+" if total_pnl >= 0 else ""2480print(f"Total P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")24812482# Period return2483if "period_return_pct" in summary:2484period_return = summary["period_return_pct"]2485period_sign = "+" if period_return >= 0 else ""2486print(f"{summary['period'].capitalize()} Return: {period_sign}{period_return:.1f}%")24872488# Concentration warnings2489if summary.get("concentration_warnings"):2490print("\n⚠️ CONCENTRATION WARNINGS:")2491for warning in summary["concentration_warnings"]:2492print(f" • {warning} (>30% of portfolio)")24932494# Recommendation summary2495recommendations = {"BUY": 0, "HOLD": 0, "SELL": 0}2496for r in results:2497recommendations[r.recommendation] = recommendations.get(r.recommendation, 0) + 124982499print(f"\nRECOMMENDATIONS: {recommendations['BUY']} BUY | {recommendations['HOLD']} HOLD | {recommendations['SELL']} SELL")2500print("=" * 77)250125022503if __name__ == "__main__":2504main()2505