sophhub 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/package.json +1 -1
  2. package/skills/consensus/skill.json +20 -0
  3. package/skills/consensus/src/SKILL.md +93 -0
  4. package/skills/deepwiki/skill.json +20 -0
  5. package/skills/deepwiki/src/SKILL.md +45 -0
  6. package/skills/deepwiki/src/_meta.json +6 -0
  7. package/skills/deepwiki/src/scripts/deepwiki.js +135 -0
  8. package/skills/feishu-bitable/skill.json +20 -0
  9. package/skills/feishu-bitable/src/CHECKLIST.md +150 -0
  10. package/skills/feishu-bitable/src/README.md +178 -0
  11. package/skills/feishu-bitable/src/SKILL.md +113 -0
  12. package/skills/feishu-bitable/src/_meta.json +6 -0
  13. package/skills/feishu-bitable/src/api.js +381 -0
  14. package/skills/feishu-bitable/src/bin/cli.js +284 -0
  15. package/skills/feishu-bitable/src/description.md +143 -0
  16. package/skills/feishu-bitable/src/examples/create-records.json +52 -0
  17. package/skills/feishu-bitable/src/examples/create-table.json +64 -0
  18. package/skills/feishu-bitable/src/package-lock.json +324 -0
  19. package/skills/feishu-bitable/src/package.json +33 -0
  20. package/skills/feishu-bitable/src/publish-config.json +14 -0
  21. package/skills/feishu-bitable/src/test-simple.js +61 -0
  22. package/skills/feishu-bitable/src/utils.js +261 -0
  23. package/skills/google-maps/skill.json +20 -0
  24. package/skills/google-maps/src/SKILL.md +237 -0
  25. package/skills/google-maps/src/_meta.json +6 -0
  26. package/skills/google-maps/src/lib/map_helper.py +912 -0
  27. package/skills/large-task-router/skill.json +20 -0
  28. package/skills/large-task-router/src/SKILL.md +79 -0
  29. package/skills/large-task-router/src/templates/plan.md +74 -0
  30. package/skills/skillhub/skill.json +11 -4
  31. package/skills/skillhub/src/SKILL.md +11 -1
  32. package/skills/sophnet-dailynews/skill.json +20 -0
  33. package/skills/sophnet-dailynews/src/SKILL.md +179 -0
  34. package/skills/sophnet-dailynews/src/cache.json +151 -0
  35. package/skills/sophnet-dailynews/src/sources.json +230 -0
  36. package/skills/sophnet-schedule/skill.json +20 -0
  37. package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -0
  38. package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -0
  39. package/skills/sophnet-schedule/src/SKILL.md +1050 -0
  40. package/skills/sophnet-schedule/src/_meta.json +6 -0
  41. package/skills/sophnet-schedule/src/api/__init__.py +0 -0
  42. package/skills/sophnet-schedule/src/api/models.py +245 -0
  43. package/skills/sophnet-schedule/src/apps/add_event.py +237 -0
  44. package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -0
  45. package/skills/sophnet-schedule/src/apps/check_roc.py +246 -0
  46. package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -0
  47. package/skills/sophnet-schedule/src/apps/import_events.py +216 -0
  48. package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -0
  49. package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -0
  50. package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -0
  51. package/skills/sophnet-schedule/src/compat.py +66 -0
  52. package/skills/sophnet-schedule/src/config/__init__.py +0 -0
  53. package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -0
  54. package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -0
  55. package/skills/sophnet-schedule/src/config/settings.py +133 -0
  56. package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -0
  57. package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -0
  58. package/skills/sophnet-schedule/src/gcal/__init__.py +0 -0
  59. package/skills/sophnet-schedule/src/gcal/client.py +374 -0
  60. package/skills/sophnet-schedule/src/gcal/models.py +91 -0
  61. package/skills/sophnet-schedule/src/requirements.txt +6 -0
  62. package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -0
  63. package/skills/sophnet-schedule/src/server.py +669 -0
  64. package/skills/sophnet-schedule/src/services/__init__.py +0 -0
  65. package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -0
  66. package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -0
  67. package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -0
  68. package/skills/sophnet-schedule/src/services/event_classifier.py +100 -0
  69. package/skills/sophnet-schedule/src/services/event_diff.py +160 -0
  70. package/skills/sophnet-schedule/src/services/google_integration.py +500 -0
  71. package/skills/sophnet-schedule/src/services/job_store.py +100 -0
  72. package/skills/sophnet-schedule/src/services/local_event_store.py +266 -0
  73. package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -0
  74. package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -0
  75. package/skills/sophnet-schedule/src/services/table_parser.py +286 -0
  76. package/skills/sophnet-schedule/src/services/task_builder.py +167 -0
  77. package/skills/sophnet-schedule/src/services/time_window.py +72 -0
  78. package/skills/sophnet-stock/skill.json +20 -0
  79. package/skills/sophnet-stock/src/App-Plan.md +442 -0
  80. package/skills/sophnet-stock/src/README.md +214 -0
  81. package/skills/sophnet-stock/src/SKILL.md +236 -0
  82. package/skills/sophnet-stock/src/TODO.md +394 -0
  83. package/skills/sophnet-stock/src/_meta.json +6 -0
  84. package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -0
  85. package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -0
  86. package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -0
  87. package/skills/sophnet-stock/src/docs/README.md +95 -0
  88. package/skills/sophnet-stock/src/docs/USAGE.md +465 -0
  89. package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -0
  90. package/skills/sophnet-stock/src/scripts/dividends.py +365 -0
  91. package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -0
  92. package/skills/sophnet-stock/src/scripts/portfolio.py +548 -0
  93. package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -0
  94. package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -0
  95. package/skills/sophnet-stock/src/scripts/watchlist.py +336 -0
  96. package/skills/xiaohongshu/skill.json +20 -0
  97. package/skills/xiaohongshu/src/SKILL.md +91 -0
  98. package/skills/xiaohongshu/src/_meta.json +6 -0
  99. package/skills/xiaohongshu/src/assets/card.html +216 -0
  100. package/skills/xiaohongshu/src/assets/cover.html +82 -0
  101. package/skills/xiaohongshu/src/assets/example.md +84 -0
  102. package/skills/xiaohongshu/src/assets/styles.css +318 -0
  103. package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -0
  104. package/skills/xiaohongshu/src/scripts/sign_server.py +158 -0
  105. package/skills/xiaohongshu/src/scripts/stealth.min.js +7 -0
  106. package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -0
  107. package/skills/xiaohongshu/src/workflow.py +185 -0
@@ -0,0 +1,2565 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # 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
+ """
13
+ Stock analysis using Yahoo Finance data.
14
+
15
+ Usage:
16
+ uv run analyze_stock.py TICKER [TICKER2 ...] [--output text|json] [--verbose]
17
+ """
18
+
19
+ import argparse
20
+ import asyncio
21
+ import json
22
+ import sys
23
+ import time
24
+ from dataclasses import dataclass, asdict
25
+ from datetime import datetime
26
+ from typing import Literal
27
+
28
+ import pandas as pd
29
+ import yfinance as yf
30
+
31
+
32
+ # Top 20 supported cryptocurrencies
33
+ SUPPORTED_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
+ }
39
+
40
+ # Crypto category mapping for sector-like analysis
41
+ CRYPTO_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
+ }
63
+
64
+ # Common index aliases that users/LLMs often pass without the Yahoo "^" prefix.
65
+ INDEX_TICKER_ALIASES = {
66
+ "IXIC": "^IXIC", # NASDAQ Composite
67
+ "NASDAQ": "^IXIC",
68
+ "GSPC": "^GSPC", # S&P 500
69
+ "SPX": "^GSPC",
70
+ "SP500": "^GSPC",
71
+ "DJI": "^DJI", # Dow Jones Industrial Average
72
+ "DJIA": "^DJI",
73
+ "DOW": "^DJI",
74
+ }
75
+
76
+
77
+ def normalize_ticker(ticker: str) -> str:
78
+ """Normalize user-provided ticker symbols to Yahoo Finance format."""
79
+ ticker_upper = ticker.strip().upper()
80
+ return INDEX_TICKER_ALIASES.get(ticker_upper, ticker_upper)
81
+
82
+
83
+ def format_error_output(message: str, output: Literal["text", "json"] = "text") -> str:
84
+ """Format CLI errors for humans (text) or toolchains (json)."""
85
+ if output == "json":
86
+ return json.dumps({"success": False, "error": message}, indent=2)
87
+ return f"Error: {message}"
88
+
89
+
90
+ def detect_asset_type(ticker: str) -> Literal["stock", "crypto"]:
91
+ """Detect asset type from ticker format."""
92
+ ticker_upper = ticker.upper()
93
+ if ticker_upper.endswith("-USD"):
94
+ base = ticker_upper[:-4]
95
+ if base.isalpha():
96
+ return "crypto"
97
+ return "stock"
98
+
99
+
100
+ @dataclass
101
+ class StockData:
102
+ ticker: str
103
+ info: dict
104
+ earnings_history: pd.DataFrame | None
105
+ analyst_info: dict | None
106
+ price_history: pd.DataFrame | None
107
+ asset_type: Literal["stock", "crypto"] = "stock"
108
+
109
+
110
+ @dataclass
111
+ class CryptoFundamentals:
112
+ """Crypto-specific fundamentals (replaces P/E, margins for crypto)."""
113
+ market_cap: float | None
114
+ market_cap_rank: str # "large", "mid", "small"
115
+ volume_24h: float | None
116
+ circulating_supply: float | None
117
+ category: str | None # "Smart Contract L1", "DeFi", etc.
118
+ btc_correlation: float | None # 30-day correlation to BTC
119
+ score: float
120
+ explanation: str
121
+
122
+
123
+ @dataclass
124
+ class EarningsSurprise:
125
+ score: float
126
+ explanation: str
127
+ actual_eps: float | None = None
128
+ expected_eps: float | None = None
129
+ surprise_pct: float | None = None
130
+
131
+
132
+ @dataclass
133
+ class Fundamentals:
134
+ score: float
135
+ key_metrics: dict
136
+ explanation: str
137
+
138
+
139
+ @dataclass
140
+ class AnalystSentiment:
141
+ score: float | None
142
+ summary: str
143
+ consensus_rating: str | None = None
144
+ price_target: float | None = None
145
+ current_price: float | None = None
146
+ upside_pct: float | None = None
147
+ num_analysts: int | None = None
148
+
149
+
150
+ @dataclass
151
+ class HistoricalPatterns:
152
+ score: float
153
+ pattern_desc: str
154
+ beats_last_4q: int | None = None
155
+ avg_reaction_pct: float | None = None
156
+
157
+
158
+ @dataclass
159
+ class MarketContext:
160
+ vix_level: float
161
+ vix_status: str # "calm", "elevated", "fear"
162
+ spy_trend_10d: float
163
+ qqq_trend_10d: float
164
+ market_regime: str # "bull", "bear", "choppy"
165
+ score: float
166
+ explanation: str
167
+ # Safe-haven indicators (v4.0.0)
168
+ gld_change_5d: float | None = None # Gold ETF % change
169
+ tlt_change_5d: float | None = None # Treasury ETF % change
170
+ uup_change_5d: float | None = None # USD Index ETF % change
171
+ risk_off_detected: bool = False # True if flight to safety detected
172
+
173
+
174
+ @dataclass
175
+ class SectorComparison:
176
+ sector_name: str
177
+ industry_name: str
178
+ stock_return_1m: float
179
+ sector_return_1m: float
180
+ relative_strength: float
181
+ sector_trend: str # "strong uptrend", "downtrend", etc.
182
+ score: float
183
+ explanation: str
184
+
185
+
186
+ @dataclass
187
+ class EarningsTiming:
188
+ days_until_earnings: int | None
189
+ days_since_earnings: int | None
190
+ next_earnings_date: str | None
191
+ last_earnings_date: str | None
192
+ timing_flag: str # "pre_earnings", "post_earnings", "safe"
193
+ price_change_5d: float | None
194
+ confidence_adjustment: float
195
+ caveats: list[str]
196
+
197
+
198
+ @dataclass
199
+ class MomentumAnalysis:
200
+ rsi_14d: float | None
201
+ rsi_status: str # "overbought", "oversold", "neutral"
202
+ price_vs_52w_low: float | None
203
+ price_vs_52w_high: float | None
204
+ near_52w_high: bool
205
+ near_52w_low: bool
206
+ volume_ratio: float | None
207
+ relative_strength_vs_sector: float | None
208
+ score: float
209
+ explanation: str
210
+
211
+
212
+ @dataclass
213
+ class SentimentAnalysis:
214
+ score: float # Overall -1.0 to 1.0
215
+ explanation: str # Human-readable summary
216
+
217
+ # Sub-indicator scores
218
+ fear_greed_score: float | None = None
219
+ short_interest_score: float | None = None
220
+ vix_structure_score: float | None = None
221
+ insider_activity_score: float | None = None
222
+ put_call_score: float | None = None
223
+
224
+ # Raw data
225
+ fear_greed_value: int | None = None # 0-100
226
+ fear_greed_status: str | None = None # "Extreme Fear", etc.
227
+ short_interest_pct: float | None = None
228
+ days_to_cover: float | None = None
229
+ vix_structure: str | None = None # "contango", "backwardation", "flat"
230
+ vix_slope: float | None = None
231
+ insider_net_shares: int | None = None
232
+ insider_net_value: float | None = None # Millions USD
233
+ put_call_ratio: float | None = None
234
+ put_volume: int | None = None
235
+ call_volume: int | None = None
236
+
237
+ # Metadata
238
+ indicators_available: int = 0
239
+ data_freshness_warnings: list[str] | None = None
240
+
241
+
242
+ @dataclass
243
+ class Signal:
244
+ ticker: str
245
+ company_name: str
246
+ recommendation: Literal["BUY", "HOLD", "SELL"]
247
+ confidence: float
248
+ final_score: float
249
+ supporting_points: list[str]
250
+ caveats: list[str]
251
+ timestamp: str
252
+ components: dict
253
+
254
+
255
+ def fetch_stock_data(ticker: str, verbose: bool = False) -> StockData | None:
256
+ """Fetch stock data from Yahoo Finance with retry logic."""
257
+ max_retries = 3
258
+ for attempt in range(max_retries):
259
+ try:
260
+ if verbose:
261
+ print(f"Fetching data for {ticker}... (attempt {attempt + 1}/{max_retries})", file=sys.stderr)
262
+
263
+ stock = yf.Ticker(ticker)
264
+ info = stock.info
265
+
266
+ # Validate ticker
267
+ if not info or "regularMarketPrice" not in info:
268
+ return None
269
+
270
+ # Fetch earnings history
271
+ try:
272
+ earnings_history = stock.earnings_dates
273
+ except Exception:
274
+ earnings_history = None
275
+
276
+ # Fetch analyst info
277
+ try:
278
+ analyst_info = {
279
+ "recommendations": stock.recommendations,
280
+ "analyst_price_targets": stock.analyst_price_targets,
281
+ }
282
+ except Exception:
283
+ analyst_info = None
284
+
285
+ # Fetch price history (1 year for historical patterns)
286
+ try:
287
+ price_history = stock.history(period="1y")
288
+ except Exception:
289
+ price_history = None
290
+
291
+ return StockData(
292
+ ticker=ticker,
293
+ info=info,
294
+ earnings_history=earnings_history,
295
+ analyst_info=analyst_info,
296
+ price_history=price_history,
297
+ asset_type=detect_asset_type(ticker),
298
+ )
299
+
300
+ except Exception as e:
301
+ if attempt < max_retries - 1:
302
+ wait_time = 2 ** attempt # Exponential backoff
303
+ if verbose:
304
+ print(f"Error fetching {ticker}: {e}. Retrying in {wait_time}s...", file=sys.stderr)
305
+ time.sleep(wait_time)
306
+ else:
307
+ if verbose:
308
+ print(f"Failed to fetch {ticker} after {max_retries} attempts", file=sys.stderr)
309
+ return None
310
+
311
+ return None
312
+
313
+
314
+ def analyze_earnings_surprise(data: StockData) -> EarningsSurprise | None:
315
+ """Analyze earnings surprise from most recent quarter."""
316
+ if data.earnings_history is None or data.earnings_history.empty:
317
+ return None
318
+
319
+ try:
320
+ # Get most recent earnings with actual data
321
+ recent = data.earnings_history.sort_index(ascending=False).head(10)
322
+
323
+ for idx, row in recent.iterrows():
324
+ if pd.notna(row.get("Reported EPS")) and pd.notna(row.get("EPS Estimate")):
325
+ actual = float(row["Reported EPS"])
326
+ expected = float(row["EPS Estimate"])
327
+
328
+ if expected == 0:
329
+ continue
330
+
331
+ surprise_pct = ((actual - expected) / abs(expected)) * 100
332
+
333
+ # Score based on surprise percentage
334
+ if surprise_pct > 10:
335
+ score = 1.0
336
+ elif surprise_pct > 5:
337
+ score = 0.7
338
+ elif surprise_pct > 0:
339
+ score = 0.3
340
+ elif surprise_pct > -5:
341
+ score = -0.3
342
+ elif surprise_pct > -10:
343
+ score = -0.7
344
+ else:
345
+ score = -1.0
346
+
347
+ explanation = f"{'Beat' if surprise_pct > 0 else 'Missed'} by {abs(surprise_pct):.1f}%"
348
+
349
+ return EarningsSurprise(
350
+ score=score,
351
+ explanation=explanation,
352
+ actual_eps=actual,
353
+ expected_eps=expected,
354
+ surprise_pct=surprise_pct,
355
+ )
356
+
357
+ return None
358
+
359
+ except Exception:
360
+ return None
361
+
362
+
363
+ def analyze_fundamentals(data: StockData) -> Fundamentals | None:
364
+ """Analyze fundamental metrics."""
365
+ info = data.info
366
+ scores = []
367
+ metrics = {}
368
+ explanations = []
369
+
370
+ try:
371
+ # P/E Ratio (lower is better, but consider growth)
372
+ pe_ratio = info.get("trailingPE") or info.get("forwardPE")
373
+ if pe_ratio and pe_ratio > 0:
374
+ metrics["pe_ratio"] = round(pe_ratio, 2)
375
+ if pe_ratio < 15:
376
+ scores.append(0.5)
377
+ explanations.append(f"Attractive P/E: {pe_ratio:.1f}x")
378
+ elif pe_ratio > 30:
379
+ scores.append(-0.3)
380
+ explanations.append(f"Elevated P/E: {pe_ratio:.1f}x")
381
+ else:
382
+ scores.append(0.1)
383
+
384
+ # Operating Margin
385
+ op_margin = info.get("operatingMargins")
386
+ if op_margin:
387
+ metrics["operating_margin"] = round(op_margin, 3)
388
+ if op_margin > 0.15:
389
+ scores.append(0.5)
390
+ explanations.append(f"Strong margin: {op_margin*100:.1f}%")
391
+ elif op_margin < 0.05:
392
+ scores.append(-0.5)
393
+ explanations.append(f"Weak margin: {op_margin*100:.1f}%")
394
+
395
+ # Revenue Growth
396
+ rev_growth = info.get("revenueGrowth")
397
+ if rev_growth:
398
+ metrics["revenue_growth_yoy"] = round(rev_growth, 3)
399
+ if rev_growth > 0.20:
400
+ scores.append(0.5)
401
+ explanations.append(f"Strong growth: {rev_growth*100:.1f}% YoY")
402
+ elif rev_growth < 0.05:
403
+ scores.append(-0.3)
404
+ explanations.append(f"Slow growth: {rev_growth*100:.1f}% YoY")
405
+ else:
406
+ scores.append(0.2)
407
+
408
+ # Debt to Equity
409
+ debt_equity = info.get("debtToEquity")
410
+ if debt_equity is not None:
411
+ metrics["debt_to_equity"] = round(debt_equity / 100, 2)
412
+ if debt_equity < 50:
413
+ scores.append(0.3)
414
+ elif debt_equity > 200:
415
+ scores.append(-0.5)
416
+ explanations.append(f"High debt: D/E {debt_equity/100:.1f}x")
417
+
418
+ if not scores:
419
+ return None
420
+
421
+ # Average and normalize
422
+ avg_score = sum(scores) / len(scores)
423
+ normalized_score = max(-1.0, min(1.0, avg_score))
424
+
425
+ explanation = "; ".join(explanations) if explanations else "Mixed fundamentals"
426
+
427
+ return Fundamentals(
428
+ score=normalized_score,
429
+ key_metrics=metrics,
430
+ explanation=explanation,
431
+ )
432
+
433
+ except Exception:
434
+ return None
435
+
436
+
437
+ def analyze_crypto_fundamentals(data: StockData, verbose: bool = False) -> CryptoFundamentals | None:
438
+ """Analyze crypto-specific fundamentals (market cap, supply, category)."""
439
+ if data.asset_type != "crypto":
440
+ return None
441
+
442
+ info = data.info
443
+ ticker = data.ticker.upper()
444
+
445
+ try:
446
+ # Market cap analysis
447
+ market_cap = info.get("marketCap")
448
+ if not market_cap:
449
+ return None
450
+
451
+ # Categorize by market cap
452
+ if market_cap >= 10_000_000_000: # $10B+
453
+ market_cap_rank = "large"
454
+ cap_score = 0.3 # Large caps are more stable
455
+ elif market_cap >= 1_000_000_000: # $1B-$10B
456
+ market_cap_rank = "mid"
457
+ cap_score = 0.1
458
+ else:
459
+ market_cap_rank = "small"
460
+ cap_score = -0.2 # Small caps are riskier
461
+
462
+ # Volume analysis
463
+ volume_24h = info.get("volume") or info.get("volume24Hr")
464
+ volume_score = 0.0
465
+ if volume_24h and market_cap:
466
+ volume_to_cap = volume_24h / market_cap
467
+ if volume_to_cap > 0.05: # >5% daily turnover
468
+ volume_score = 0.2 # High liquidity
469
+ elif volume_to_cap < 0.01:
470
+ volume_score = -0.2 # Low liquidity
471
+
472
+ # Circulating supply
473
+ circulating_supply = info.get("circulatingSupply")
474
+
475
+ # Get crypto category
476
+ category = CRYPTO_CATEGORIES.get(ticker, "Unknown")
477
+
478
+ # Calculate BTC correlation (30 days)
479
+ btc_correlation = None
480
+ try:
481
+ if ticker != "BTC-USD" and data.price_history is not None:
482
+ btc = yf.Ticker("BTC-USD")
483
+ btc_hist = btc.history(period="1mo")
484
+ if not btc_hist.empty and len(data.price_history) > 5:
485
+ # Align dates and calculate correlation
486
+ crypto_returns = data.price_history["Close"].pct_change().dropna()
487
+ btc_returns = btc_hist["Close"].pct_change().dropna()
488
+ # Simple correlation on overlapping dates
489
+ common_dates = crypto_returns.index.intersection(btc_returns.index)
490
+ if len(common_dates) > 10:
491
+ btc_correlation = crypto_returns.loc[common_dates].corr(btc_returns.loc[common_dates])
492
+ except Exception:
493
+ pass
494
+
495
+ # BTC correlation scoring (high correlation = less diversification benefit)
496
+ corr_score = 0.0
497
+ if btc_correlation is not None:
498
+ if btc_correlation > 0.8:
499
+ corr_score = -0.1 # Very correlated to BTC
500
+ elif btc_correlation < 0.3:
501
+ corr_score = 0.1 # Good diversification
502
+
503
+ # Total score
504
+ total_score = cap_score + volume_score + corr_score
505
+
506
+ # Build explanation
507
+ explanations = []
508
+ explanations.append(f"Market cap: ${market_cap/1e9:.1f}B ({market_cap_rank})")
509
+ if category != "Unknown":
510
+ explanations.append(f"Category: {category}")
511
+ if btc_correlation is not None:
512
+ explanations.append(f"BTC corr: {btc_correlation:.2f}")
513
+
514
+ return CryptoFundamentals(
515
+ market_cap=market_cap,
516
+ market_cap_rank=market_cap_rank,
517
+ volume_24h=volume_24h,
518
+ circulating_supply=circulating_supply,
519
+ category=category,
520
+ btc_correlation=round(btc_correlation, 2) if btc_correlation else None,
521
+ score=max(-1.0, min(1.0, total_score)),
522
+ explanation="; ".join(explanations),
523
+ )
524
+
525
+ except Exception as e:
526
+ if verbose:
527
+ print(f"Error analyzing crypto fundamentals: {e}", file=sys.stderr)
528
+ return None
529
+
530
+
531
+ def analyze_analyst_sentiment(data: StockData) -> AnalystSentiment | None:
532
+ """Analyze analyst sentiment and price targets."""
533
+ info = data.info
534
+
535
+ try:
536
+ # Get current price
537
+ current_price = info.get("regularMarketPrice") or info.get("currentPrice")
538
+ if not current_price:
539
+ return None
540
+
541
+ # Get target price
542
+ target_price = info.get("targetMeanPrice")
543
+
544
+ # Get number of analysts
545
+ num_analysts = info.get("numberOfAnalystOpinions")
546
+
547
+ # Get recommendation
548
+ recommendation = info.get("recommendationKey")
549
+
550
+ if not target_price or not recommendation:
551
+ return AnalystSentiment(
552
+ score=None,
553
+ summary="No analyst coverage available",
554
+ )
555
+
556
+ # Calculate upside
557
+ upside_pct = ((target_price - current_price) / current_price) * 100
558
+
559
+ # Score based on recommendation and upside
560
+ rec_scores = {
561
+ "strong_buy": 1.0,
562
+ "buy": 0.7,
563
+ "hold": 0.0,
564
+ "sell": -0.7,
565
+ "strong_sell": -1.0,
566
+ }
567
+
568
+ base_score = rec_scores.get(recommendation, 0.0)
569
+
570
+ # Adjust based on upside
571
+ if upside_pct > 20:
572
+ score = min(1.0, base_score + 0.3)
573
+ elif upside_pct > 10:
574
+ score = min(1.0, base_score + 0.15)
575
+ elif upside_pct < -10:
576
+ score = max(-1.0, base_score - 0.3)
577
+ else:
578
+ score = base_score
579
+
580
+ # Format recommendation
581
+ rec_display = recommendation.replace("_", " ").title()
582
+
583
+ summary = f"{rec_display} with {abs(upside_pct):.1f}% {'upside' if upside_pct > 0 else 'downside'}"
584
+ if num_analysts:
585
+ summary += f" ({num_analysts} analysts)"
586
+
587
+ return AnalystSentiment(
588
+ score=score,
589
+ summary=summary,
590
+ consensus_rating=rec_display,
591
+ price_target=target_price,
592
+ current_price=current_price,
593
+ upside_pct=upside_pct,
594
+ num_analysts=num_analysts,
595
+ )
596
+
597
+ except Exception:
598
+ return AnalystSentiment(
599
+ score=None,
600
+ summary="Error analyzing analyst sentiment",
601
+ )
602
+
603
+
604
+ def analyze_historical_patterns(data: StockData) -> HistoricalPatterns | None:
605
+ """Analyze historical earnings patterns."""
606
+ if data.earnings_history is None or data.price_history is None:
607
+ return None
608
+
609
+ if data.earnings_history.empty or data.price_history.empty:
610
+ return None
611
+
612
+ try:
613
+ # Get last 4 quarters earnings dates
614
+ earnings_dates = data.earnings_history.sort_index(ascending=False).head(4)
615
+
616
+ beats = 0
617
+ reactions = []
618
+
619
+ for earnings_date, row in earnings_dates.iterrows():
620
+ if pd.notna(row.get("Reported EPS")) and pd.notna(row.get("EPS Estimate")):
621
+ actual = float(row["Reported EPS"])
622
+ expected = float(row["EPS Estimate"])
623
+
624
+ if actual > expected:
625
+ beats += 1
626
+
627
+ # Try to get price reaction (day of earnings)
628
+ try:
629
+ earnings_day = pd.Timestamp(earnings_date).date()
630
+
631
+ # Find closest trading day
632
+ price_data = data.price_history[data.price_history.index.date == earnings_day]
633
+
634
+ if not price_data.empty:
635
+ day_change = ((price_data["Close"].iloc[0] - price_data["Open"].iloc[0]) / price_data["Open"].iloc[0]) * 100
636
+ reactions.append(day_change)
637
+ except Exception:
638
+ continue
639
+
640
+ total_quarters = len(earnings_dates)
641
+ if total_quarters == 0:
642
+ return None
643
+
644
+ # Score based on beat rate
645
+ beat_rate = beats / total_quarters
646
+
647
+ if beat_rate == 1.0:
648
+ score = 0.8
649
+ elif beat_rate >= 0.75:
650
+ score = 0.5
651
+ elif beat_rate >= 0.5:
652
+ score = 0.0
653
+ elif beat_rate >= 0.25:
654
+ score = -0.5
655
+ else:
656
+ score = -0.8
657
+
658
+ # Pattern description
659
+ pattern_desc = f"{beats}/{total_quarters} quarters beat expectations"
660
+
661
+ if reactions:
662
+ avg_reaction = sum(reactions) / len(reactions)
663
+ pattern_desc += f", avg reaction {avg_reaction:+.1f}%"
664
+ else:
665
+ avg_reaction = None
666
+
667
+ return HistoricalPatterns(
668
+ score=score,
669
+ pattern_desc=pattern_desc,
670
+ beats_last_4q=beats,
671
+ avg_reaction_pct=avg_reaction,
672
+ )
673
+
674
+ except Exception:
675
+ return None
676
+
677
+
678
+ def analyze_market_context(verbose: bool = False) -> MarketContext | None:
679
+ """Analyze overall market conditions using VIX, SPY, QQQ, and safe-havens with 1h cache."""
680
+ # Check cache first
681
+ cached = _get_cached("market_context")
682
+ if cached is not None:
683
+ if verbose:
684
+ print("Using cached market context (< 1h old)", file=sys.stderr)
685
+ return cached
686
+
687
+ try:
688
+ if verbose:
689
+ print("Fetching market indicators (VIX, SPY, QQQ)...", file=sys.stderr)
690
+
691
+ # Fetch market indicators
692
+ vix = yf.Ticker("^VIX")
693
+ spy = yf.Ticker("SPY")
694
+ qqq = yf.Ticker("QQQ")
695
+
696
+ # Get current VIX level
697
+ vix_info = vix.info
698
+ vix_level = vix_info.get("regularMarketPrice") or vix_info.get("currentPrice")
699
+
700
+ if not vix_level:
701
+ return None
702
+
703
+ # Determine VIX status
704
+ if vix_level < 20:
705
+ vix_status = "calm"
706
+ vix_score = 0.2
707
+ elif vix_level < 30:
708
+ vix_status = "elevated"
709
+ vix_score = 0.0
710
+ else:
711
+ vix_status = "fear"
712
+ vix_score = -0.5
713
+
714
+ # Get SPY and QQQ 10-day trends
715
+ spy_hist = spy.history(period="1mo")
716
+ qqq_hist = qqq.history(period="1mo")
717
+
718
+ if spy_hist.empty or qqq_hist.empty:
719
+ return None
720
+
721
+ # Calculate 10-day price changes
722
+ spy_10d_ago = spy_hist["Close"].iloc[-min(10, len(spy_hist))]
723
+ spy_current = spy_hist["Close"].iloc[-1]
724
+ spy_trend_10d = ((spy_current - spy_10d_ago) / spy_10d_ago) * 100
725
+
726
+ qqq_10d_ago = qqq_hist["Close"].iloc[-min(10, len(qqq_hist))]
727
+ qqq_current = qqq_hist["Close"].iloc[-1]
728
+ qqq_trend_10d = ((qqq_current - qqq_10d_ago) / qqq_10d_ago) * 100
729
+
730
+ # Determine market regime
731
+ avg_trend = (spy_trend_10d + qqq_trend_10d) / 2
732
+
733
+ if avg_trend > 3:
734
+ market_regime = "bull"
735
+ regime_score = 0.3
736
+ elif avg_trend < -3:
737
+ market_regime = "bear"
738
+ regime_score = -0.4
739
+ else:
740
+ market_regime = "choppy"
741
+ regime_score = -0.1
742
+
743
+ # Calculate overall score
744
+ overall_score = (vix_score + regime_score) / 2
745
+
746
+ # NEW v4.0.0: Fetch safe-haven indicators (GLD, TLT, UUP)
747
+ gld_change_5d = None
748
+ tlt_change_5d = None
749
+ uup_change_5d = None
750
+ risk_off_detected = False
751
+
752
+ try:
753
+ if verbose:
754
+ print("Fetching safe-haven indicators (GLD, TLT, UUP)...", file=sys.stderr)
755
+
756
+ # Fetch safe-haven ETFs
757
+ gld = yf.Ticker("GLD") # Gold
758
+ tlt = yf.Ticker("TLT") # 20+ Year Treasury
759
+ uup = yf.Ticker("UUP") # USD Index
760
+
761
+ gld_hist = gld.history(period="10d")
762
+ tlt_hist = tlt.history(period="10d")
763
+ uup_hist = uup.history(period="10d")
764
+
765
+ # Calculate 5-day changes
766
+ if not gld_hist.empty and len(gld_hist) >= 5:
767
+ gld_5d_ago = gld_hist["Close"].iloc[-min(5, len(gld_hist))]
768
+ gld_current = gld_hist["Close"].iloc[-1]
769
+ gld_change_5d = ((gld_current - gld_5d_ago) / gld_5d_ago) * 100
770
+
771
+ if not tlt_hist.empty and len(tlt_hist) >= 5:
772
+ tlt_5d_ago = tlt_hist["Close"].iloc[-min(5, len(tlt_hist))]
773
+ tlt_current = tlt_hist["Close"].iloc[-1]
774
+ tlt_change_5d = ((tlt_current - tlt_5d_ago) / tlt_5d_ago) * 100
775
+
776
+ if not uup_hist.empty and len(uup_hist) >= 5:
777
+ uup_5d_ago = uup_hist["Close"].iloc[-min(5, len(uup_hist))]
778
+ uup_current = uup_hist["Close"].iloc[-1]
779
+ uup_change_5d = ((uup_current - uup_5d_ago) / uup_5d_ago) * 100
780
+
781
+ # Risk-off detection: All three safe-havens rising together
782
+ if (gld_change_5d is not None and gld_change_5d >= 2.0 and
783
+ tlt_change_5d is not None and tlt_change_5d >= 1.0 and
784
+ uup_change_5d is not None and uup_change_5d >= 1.0):
785
+ risk_off_detected = True
786
+ overall_score -= 0.5 # Reduce score significantly
787
+ if verbose:
788
+ print(f" 🛡️ RISK-OFF DETECTED: GLD {gld_change_5d:+.1f}%, TLT {tlt_change_5d:+.1f}%, UUP {uup_change_5d:+.1f}%", file=sys.stderr)
789
+
790
+ except Exception as e:
791
+ if verbose:
792
+ print(f" Safe-haven indicators unavailable: {e}", file=sys.stderr)
793
+
794
+ # Build explanation
795
+ explanation = f"VIX {vix_level:.1f} ({vix_status}), Market {market_regime} (SPY {spy_trend_10d:+.1f}%, QQQ {qqq_trend_10d:+.1f}% 10d)"
796
+ if risk_off_detected:
797
+ explanation += " ⚠️ RISK-OFF MODE"
798
+
799
+ result = MarketContext(
800
+ vix_level=vix_level,
801
+ vix_status=vix_status,
802
+ spy_trend_10d=spy_trend_10d,
803
+ qqq_trend_10d=qqq_trend_10d,
804
+ market_regime=market_regime,
805
+ score=overall_score,
806
+ explanation=explanation,
807
+ gld_change_5d=gld_change_5d,
808
+ tlt_change_5d=tlt_change_5d,
809
+ uup_change_5d=uup_change_5d,
810
+ risk_off_detected=risk_off_detected,
811
+ )
812
+
813
+ # Cache the result for 1 hour
814
+ _set_cache("market_context", result)
815
+ return result
816
+
817
+ except Exception as e:
818
+ if verbose:
819
+ print(f"Error analyzing market context: {e}", file=sys.stderr)
820
+ return None
821
+
822
+
823
+ def get_sector_etf_ticker(sector: str) -> str | None:
824
+ """Map sector name to corresponding sector ETF ticker."""
825
+ sector_map = {
826
+ "Financial Services": "XLF",
827
+ "Financials": "XLF",
828
+ "Technology": "XLK",
829
+ "Healthcare": "XLV",
830
+ "Consumer Cyclical": "XLY",
831
+ "Consumer Defensive": "XLP",
832
+ "Utilities": "XLU",
833
+ "Basic Materials": "XLB",
834
+ "Real Estate": "XLRE",
835
+ "Communication Services": "XLC",
836
+ "Industrials": "XLI",
837
+ "Energy": "XLE",
838
+ }
839
+
840
+ return sector_map.get(sector)
841
+
842
+
843
+ # ============================================================================
844
+ # Breaking News Check (v4.0.0)
845
+ # ============================================================================
846
+
847
+ # Crisis keywords by category
848
+ CRISIS_KEYWORDS = {
849
+ "war": ["war", "invasion", "military strike", "attack", "conflict", "combat"],
850
+ "economic": ["recession", "crisis", "collapse", "default", "bankruptcy", "crash"],
851
+ "regulatory": ["sanctions", "embargo", "ban", "investigation", "fraud", "probe"],
852
+ "disaster": ["earthquake", "hurricane", "pandemic", "outbreak", "disaster", "catastrophe"],
853
+ "financial": ["emergency rate", "fed emergency", "bailout", "circuit breaker", "trading halt"],
854
+ }
855
+
856
+ # Geopolitical event → sector mapping (v4.0.0)
857
+ GEOPOLITICAL_RISK_MAP = {
858
+ "taiwan": {
859
+ "keywords": ["taiwan", "tsmc", "strait"],
860
+ "sectors": ["Technology", "Communication Services"],
861
+ "sector_etfs": ["XLK", "XLC"],
862
+ "impact": "Semiconductor supply chain disruption",
863
+ "affected_tickers": ["NVDA", "AMD", "TSM", "INTC", "QCOM", "AVGO", "MU"],
864
+ },
865
+ "china": {
866
+ "keywords": ["china", "beijing", "tariff", "trade war"],
867
+ "sectors": ["Technology", "Consumer Cyclical", "Consumer Defensive"],
868
+ "sector_etfs": ["XLK", "XLY", "XLP"],
869
+ "impact": "Tech supply chain and consumer market exposure",
870
+ "affected_tickers": ["AAPL", "QCOM", "NKE", "SBUX", "MCD", "YUM", "TGT", "WMT"],
871
+ },
872
+ "russia_ukraine": {
873
+ "keywords": ["russia", "ukraine", "putin", "kyiv", "moscow"],
874
+ "sectors": ["Energy", "Materials"],
875
+ "sector_etfs": ["XLE", "XLB"],
876
+ "impact": "Energy and commodity price volatility",
877
+ "affected_tickers": ["XOM", "CVX", "COP", "SLB", "MOS", "CF", "NTR", "ADM"],
878
+ },
879
+ "middle_east": {
880
+ "keywords": ["iran", "israel", "gaza", "saudi", "middle east", "gulf"],
881
+ "sectors": ["Energy", "Industrials"],
882
+ "sector_etfs": ["XLE", "XLI"],
883
+ "impact": "Oil price volatility and defense spending",
884
+ "affected_tickers": ["XOM", "CVX", "COP", "LMT", "RTX", "NOC", "GD", "BA"],
885
+ },
886
+ "banking_crisis": {
887
+ "keywords": ["bank failure", "credit crisis", "liquidity crisis", "bank run"],
888
+ "sectors": ["Financials"],
889
+ "sector_etfs": ["XLF"],
890
+ "impact": "Financial sector contagion risk",
891
+ "affected_tickers": ["JPM", "BAC", "WFC", "C", "GS", "MS", "USB", "PNC"],
892
+ },
893
+ }
894
+
895
+
896
+ def check_breaking_news(verbose: bool = False) -> list[str] | None:
897
+ """
898
+ Check Google News RSS for breaking market/economic crisis events (last 24h).
899
+ Returns list of alert strings or None.
900
+ Uses 1h cache to avoid excessive API calls.
901
+ """
902
+ # Check cache first
903
+ cached = _get_cached("breaking_news")
904
+ if cached is not None:
905
+ return cached
906
+
907
+ alerts = []
908
+
909
+ try:
910
+ import feedparser
911
+ from datetime import datetime, timezone, timedelta
912
+
913
+ if verbose:
914
+ print("Checking breaking news (Google News RSS)...", file=sys.stderr)
915
+
916
+ # Google News RSS feeds for finance/business
917
+ rss_urls = [
918
+ "https://news.google.com/rss/search?q=stock+market+when:24h&hl=en-US&gl=US&ceid=US:en",
919
+ "https://news.google.com/rss/search?q=economy+crisis+when:24h&hl=en-US&gl=US&ceid=US:en",
920
+ ]
921
+
922
+ now = datetime.now(timezone.utc)
923
+ cutoff_time = now - timedelta(hours=24)
924
+
925
+ for url in rss_urls:
926
+ try:
927
+ feed = feedparser.parse(url)
928
+
929
+ for entry in feed.entries[:20]: # Check top 20 headlines
930
+ # Parse publication date
931
+ pub_date = None
932
+ if hasattr(entry, "published_parsed") and entry.published_parsed:
933
+ pub_date = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
934
+
935
+ # Skip if older than 24h
936
+ if pub_date and pub_date < cutoff_time:
937
+ continue
938
+
939
+ title = entry.get("title", "").lower()
940
+ summary = entry.get("summary", "").lower()
941
+ text = f"{title} {summary}"
942
+
943
+ # Check for crisis keywords
944
+ for category, keywords in CRISIS_KEYWORDS.items():
945
+ for keyword in keywords:
946
+ if keyword in text:
947
+ alert_text = entry.get("title", "Unknown alert")
948
+ hours_ago = int((now - pub_date).total_seconds() / 3600) if pub_date else None
949
+ time_str = f"{hours_ago}h ago" if hours_ago is not None else "recent"
950
+
951
+ alert = f"{alert_text} ({time_str})"
952
+ if alert not in alerts: # Deduplicate
953
+ alerts.append(alert)
954
+ if verbose:
955
+ print(f" ⚠️ Alert: {alert}", file=sys.stderr)
956
+ break
957
+ if len(alerts) >= 3: # Limit to 3 alerts
958
+ break
959
+
960
+ if len(alerts) >= 3:
961
+ break
962
+
963
+ except Exception as e:
964
+ if verbose:
965
+ print(f" Failed to fetch {url}: {e}", file=sys.stderr)
966
+ continue
967
+
968
+ # Cache results (even if empty) for 1 hour
969
+ result = alerts if alerts else None
970
+ _set_cache("breaking_news", result)
971
+ return result
972
+
973
+ except Exception as e:
974
+ if verbose:
975
+ print(f" Breaking news check failed: {e}", file=sys.stderr)
976
+ return None
977
+
978
+
979
+ def check_sector_geopolitical_risk(
980
+ ticker: str,
981
+ sector: str | None,
982
+ breaking_news: list[str] | None,
983
+ verbose: bool = False
984
+ ) -> tuple[str | None, float]:
985
+ """
986
+ Check if ticker is exposed to geopolitical risks based on breaking news.
987
+ Returns (warning_message, confidence_penalty).
988
+
989
+ Args:
990
+ ticker: Stock ticker symbol
991
+ sector: Stock sector (from yfinance)
992
+ breaking_news: List of breaking news alerts
993
+ verbose: Print debug info
994
+
995
+ Returns:
996
+ (warning_message, confidence_penalty) where:
997
+ - warning_message: None or string like "⚠️ SECTOR RISK: Taiwan tensions affect semiconductors"
998
+ - confidence_penalty: 0.0 (no risk) to 0.5 (high risk)
999
+ """
1000
+ if not breaking_news:
1001
+ return None, 0.0
1002
+
1003
+ # Combine all breaking news into single text for keyword matching
1004
+ news_text = " ".join(breaking_news).lower()
1005
+
1006
+ # Check each geopolitical event
1007
+ for event_name, event_data in GEOPOLITICAL_RISK_MAP.items():
1008
+ # Check if any keywords from this event appear in breaking news
1009
+ keywords_found = []
1010
+ for keyword in event_data["keywords"]:
1011
+ if keyword in news_text:
1012
+ keywords_found.append(keyword)
1013
+
1014
+ if not keywords_found:
1015
+ continue
1016
+
1017
+ # Check if ticker is in affected list
1018
+ if ticker in event_data["affected_tickers"]:
1019
+ # Direct ticker exposure
1020
+ warning = f"⚠️ SECTOR RISK: {event_data['impact']} (detected: {', '.join(keywords_found)})"
1021
+ penalty = 0.3 # Reduce BUY confidence by 30%
1022
+
1023
+ if verbose:
1024
+ print(f" Geopolitical risk detected: {event_name} affects {ticker}", file=sys.stderr)
1025
+
1026
+ return warning, penalty
1027
+
1028
+ # Check if sector is affected (even if ticker not in list)
1029
+ if sector and sector in event_data["sectors"]:
1030
+ # Sector exposure (weaker signal)
1031
+ warning = f"⚠️ SECTOR RISK: {sector} sector exposed to {event_data['impact']}"
1032
+ penalty = 0.15 # Reduce BUY confidence by 15%
1033
+
1034
+ if verbose:
1035
+ print(f" Sector risk detected: {event_name} affects {sector} sector", file=sys.stderr)
1036
+
1037
+ return warning, penalty
1038
+
1039
+ return None, 0.0
1040
+
1041
+
1042
+ def analyze_sector_performance(data: StockData, verbose: bool = False) -> SectorComparison | None:
1043
+ """Compare stock performance to its sector."""
1044
+ try:
1045
+ sector = data.info.get("sector")
1046
+ industry = data.info.get("industry")
1047
+
1048
+ if not sector:
1049
+ return None
1050
+
1051
+ sector_etf_ticker = get_sector_etf_ticker(sector)
1052
+
1053
+ if not sector_etf_ticker:
1054
+ if verbose:
1055
+ print(f"No sector ETF mapping for {sector}", file=sys.stderr)
1056
+ return None
1057
+
1058
+ if verbose:
1059
+ print(f"Comparing to sector ETF: {sector_etf_ticker}", file=sys.stderr)
1060
+
1061
+ # Fetch sector ETF data
1062
+ sector_etf = yf.Ticker(sector_etf_ticker)
1063
+ sector_hist = sector_etf.history(period="3mo")
1064
+
1065
+ if sector_hist.empty or data.price_history is None or data.price_history.empty:
1066
+ return None
1067
+
1068
+ # Calculate 1-month returns
1069
+ stock_1m_ago = data.price_history["Close"].iloc[-min(22, len(data.price_history))]
1070
+ stock_current = data.price_history["Close"].iloc[-1]
1071
+ stock_return_1m = ((stock_current - stock_1m_ago) / stock_1m_ago) * 100
1072
+
1073
+ sector_1m_ago = sector_hist["Close"].iloc[-min(22, len(sector_hist))]
1074
+ sector_current = sector_hist["Close"].iloc[-1]
1075
+ sector_return_1m = ((sector_current - sector_1m_ago) / sector_1m_ago) * 100
1076
+
1077
+ # Calculate relative strength
1078
+ relative_strength = stock_return_1m / sector_return_1m if sector_return_1m != 0 else 1.0
1079
+
1080
+ # Sector 10-day trend
1081
+ sector_10d_ago = sector_hist["Close"].iloc[-min(10, len(sector_hist))]
1082
+ sector_trend_10d = ((sector_current - sector_10d_ago) / sector_10d_ago) * 100
1083
+
1084
+ if sector_trend_10d > 5:
1085
+ sector_trend = "strong uptrend"
1086
+ elif sector_trend_10d > 2:
1087
+ sector_trend = "uptrend"
1088
+ elif sector_trend_10d < -5:
1089
+ sector_trend = "downtrend"
1090
+ elif sector_trend_10d < -2:
1091
+ sector_trend = "weak"
1092
+ else:
1093
+ sector_trend = "neutral"
1094
+
1095
+ # Calculate score
1096
+ score = 0.0
1097
+
1098
+ # Relative performance score
1099
+ if relative_strength > 1.05: # Outperforming by >5%
1100
+ score += 0.3
1101
+ elif relative_strength < 0.95: # Underperforming by >5%
1102
+ score -= 0.3
1103
+
1104
+ # Sector trend score
1105
+ if sector_trend_10d > 5:
1106
+ score += 0.2
1107
+ elif sector_trend_10d < -5:
1108
+ score -= 0.2
1109
+
1110
+ explanation = f"{sector} sector {sector_trend} ({sector_return_1m:+.1f}% 1m), stock {stock_return_1m:+.1f}% vs sector"
1111
+
1112
+ return SectorComparison(
1113
+ sector_name=sector,
1114
+ industry_name=industry or "Unknown",
1115
+ stock_return_1m=stock_return_1m,
1116
+ sector_return_1m=sector_return_1m,
1117
+ relative_strength=relative_strength,
1118
+ sector_trend=sector_trend,
1119
+ score=score,
1120
+ explanation=explanation,
1121
+ )
1122
+
1123
+ except Exception as e:
1124
+ if verbose:
1125
+ print(f"Error analyzing sector performance: {e}", file=sys.stderr)
1126
+ return None
1127
+
1128
+
1129
+ def analyze_earnings_timing(data: StockData) -> EarningsTiming | None:
1130
+ """Check earnings timing and flag pre/post-earnings periods."""
1131
+ try:
1132
+ from datetime import datetime, timedelta
1133
+
1134
+ if data.earnings_history is None or data.earnings_history.empty:
1135
+ return None
1136
+
1137
+ current_date = datetime.now()
1138
+ earnings_dates = data.earnings_history.sort_index(ascending=False)
1139
+
1140
+ # Find next and last earnings dates
1141
+ next_earnings_date = None
1142
+ last_earnings_date = None
1143
+
1144
+ for earnings_date in earnings_dates.index:
1145
+ earnings_dt = pd.Timestamp(earnings_date).to_pydatetime()
1146
+
1147
+ if earnings_dt > current_date and next_earnings_date is None:
1148
+ next_earnings_date = earnings_dt
1149
+ elif earnings_dt <= current_date and last_earnings_date is None:
1150
+ last_earnings_date = earnings_dt
1151
+ break
1152
+
1153
+ # Calculate days until/since earnings
1154
+ days_until_earnings = None
1155
+ days_since_earnings = None
1156
+
1157
+ if next_earnings_date:
1158
+ days_until_earnings = (next_earnings_date - current_date).days
1159
+
1160
+ if last_earnings_date:
1161
+ days_since_earnings = (current_date - last_earnings_date).days
1162
+
1163
+ # Determine timing flag
1164
+ timing_flag = "safe"
1165
+ confidence_adjustment = 0.0
1166
+ caveats = []
1167
+
1168
+ # Pre-earnings check (< 14 days)
1169
+ if days_until_earnings is not None and days_until_earnings <= 14:
1170
+ timing_flag = "pre_earnings"
1171
+ confidence_adjustment = -0.3
1172
+ caveats.append(f"Earnings in {days_until_earnings} days - high volatility expected")
1173
+
1174
+ # Post-earnings check (< 5 days)
1175
+ price_change_5d = None
1176
+ if days_since_earnings is not None and days_since_earnings <= 5:
1177
+ # Calculate 5-day price change
1178
+ if data.price_history is not None and len(data.price_history) >= 5:
1179
+ price_5d_ago = data.price_history["Close"].iloc[-5]
1180
+ price_current = data.price_history["Close"].iloc[-1]
1181
+ price_change_5d = ((price_current - price_5d_ago) / price_5d_ago) * 100
1182
+
1183
+ if price_change_5d > 15:
1184
+ timing_flag = "post_earnings"
1185
+ confidence_adjustment = -0.2
1186
+ caveats.append(f"Up {price_change_5d:.1f}% in 5 days - gains may be priced in")
1187
+
1188
+ return EarningsTiming(
1189
+ days_until_earnings=days_until_earnings,
1190
+ days_since_earnings=days_since_earnings,
1191
+ next_earnings_date=next_earnings_date.strftime("%Y-%m-%d") if next_earnings_date else None,
1192
+ last_earnings_date=last_earnings_date.strftime("%Y-%m-%d") if last_earnings_date else None,
1193
+ timing_flag=timing_flag,
1194
+ price_change_5d=price_change_5d,
1195
+ confidence_adjustment=confidence_adjustment,
1196
+ caveats=caveats,
1197
+ )
1198
+
1199
+ except Exception:
1200
+ return None
1201
+
1202
+
1203
+ def calculate_rsi(prices: pd.Series, period: int = 14) -> float | None:
1204
+ """Calculate RSI (Relative Strength Index)."""
1205
+ try:
1206
+ if len(prices) < period + 1:
1207
+ return None
1208
+
1209
+ # Calculate price changes
1210
+ delta = prices.diff()
1211
+
1212
+ # Separate gains and losses
1213
+ gains = delta.where(delta > 0, 0)
1214
+ losses = -delta.where(delta < 0, 0)
1215
+
1216
+ # Calculate average gains and losses
1217
+ avg_gain = gains.rolling(window=period).mean()
1218
+ avg_loss = losses.rolling(window=period).mean()
1219
+
1220
+ # Calculate RS
1221
+ rs = avg_gain / avg_loss
1222
+
1223
+ # Calculate RSI
1224
+ rsi = 100 - (100 / (1 + rs))
1225
+
1226
+ return float(rsi.iloc[-1])
1227
+
1228
+ except Exception:
1229
+ return None
1230
+
1231
+
1232
+ def analyze_momentum(data: StockData) -> MomentumAnalysis | None:
1233
+ """Analyze momentum indicators (RSI, 52w range, volume, relative strength)."""
1234
+ try:
1235
+ if data.price_history is None or data.price_history.empty:
1236
+ return None
1237
+
1238
+ # Calculate RSI
1239
+ rsi_14d = calculate_rsi(data.price_history["Close"], period=14)
1240
+
1241
+ if rsi_14d:
1242
+ if rsi_14d > 70:
1243
+ rsi_status = "overbought"
1244
+ elif rsi_14d < 30:
1245
+ rsi_status = "oversold"
1246
+ else:
1247
+ rsi_status = "neutral"
1248
+ else:
1249
+ rsi_status = "unknown"
1250
+
1251
+ # Get 52-week high/low
1252
+ high_52w = data.info.get("fiftyTwoWeekHigh")
1253
+ low_52w = data.info.get("fiftyTwoWeekLow")
1254
+ current_price = data.info.get("regularMarketPrice") or data.info.get("currentPrice")
1255
+
1256
+ price_vs_52w_low = None
1257
+ price_vs_52w_high = None
1258
+ near_52w_high = False
1259
+ near_52w_low = False
1260
+
1261
+ if high_52w and low_52w and current_price:
1262
+ price_range = high_52w - low_52w
1263
+ if price_range > 0:
1264
+ price_vs_52w_low = ((current_price - low_52w) / price_range) * 100
1265
+ price_vs_52w_high = ((high_52w - current_price) / price_range) * 100
1266
+
1267
+ near_52w_high = price_vs_52w_low > 90
1268
+ near_52w_low = price_vs_52w_low < 10
1269
+
1270
+ # Volume analysis
1271
+ volume_ratio = None
1272
+ if "Volume" in data.price_history.columns and len(data.price_history) >= 60:
1273
+ recent_vol = data.price_history["Volume"].iloc[-5:].mean()
1274
+ avg_vol = data.price_history["Volume"].iloc[-60:].mean()
1275
+ volume_ratio = recent_vol / avg_vol if avg_vol > 0 else None
1276
+
1277
+ # Calculate score
1278
+ score = 0.0
1279
+ explanations = []
1280
+
1281
+ if rsi_14d:
1282
+ if rsi_14d > 70:
1283
+ score -= 0.5
1284
+ explanations.append(f"RSI {rsi_14d:.0f} (overbought)")
1285
+ elif rsi_14d < 30:
1286
+ score += 0.5
1287
+ explanations.append(f"RSI {rsi_14d:.0f} (oversold)")
1288
+
1289
+ if near_52w_high:
1290
+ score -= 0.3
1291
+ explanations.append("Near 52w high")
1292
+ elif near_52w_low:
1293
+ score += 0.3
1294
+ explanations.append("Near 52w low")
1295
+
1296
+ if volume_ratio and volume_ratio > 1.5:
1297
+ explanations.append(f"Volume {volume_ratio:.1f}x average")
1298
+
1299
+ explanation = "; ".join(explanations) if explanations else "Momentum indicators neutral"
1300
+
1301
+ return MomentumAnalysis(
1302
+ rsi_14d=rsi_14d,
1303
+ rsi_status=rsi_status,
1304
+ price_vs_52w_low=price_vs_52w_low,
1305
+ price_vs_52w_high=price_vs_52w_high,
1306
+ near_52w_high=near_52w_high,
1307
+ near_52w_low=near_52w_low,
1308
+ volume_ratio=volume_ratio,
1309
+ relative_strength_vs_sector=None, # Could be enhanced with sector comparison
1310
+ score=score,
1311
+ explanation=explanation,
1312
+ )
1313
+
1314
+ except Exception:
1315
+ return None
1316
+
1317
+
1318
+ # ============================================================================
1319
+ # Sentiment Analysis Helper Functions
1320
+ # ============================================================================
1321
+
1322
+ # Simple cache for shared indicators (Fear & Greed, VIX)
1323
+ # Format: {key: (value, timestamp)}
1324
+ _SENTIMENT_CACHE = {}
1325
+ _CACHE_TTL_SECONDS = 3600 # 1 hour
1326
+
1327
+
1328
+ def _get_cached(key: str):
1329
+ """Get cached value if still valid (within TTL)."""
1330
+ if key in _SENTIMENT_CACHE:
1331
+ value, timestamp = _SENTIMENT_CACHE[key]
1332
+ if time.time() - timestamp < _CACHE_TTL_SECONDS:
1333
+ return value
1334
+ return None
1335
+
1336
+
1337
+ def _set_cache(key: str, value):
1338
+ """Set cached value with current timestamp."""
1339
+ _SENTIMENT_CACHE[key] = (value, time.time())
1340
+
1341
+
1342
+ async def get_fear_greed_index() -> tuple[float, int | None, str | None] | None:
1343
+ """
1344
+ Fetch CNN Fear & Greed Index (contrarian indicator) with 1h cache.
1345
+ Returns: (score, value, status) or None on failure.
1346
+ """
1347
+ # Check cache first
1348
+ cached = _get_cached("fear_greed")
1349
+ if cached is not None:
1350
+ return cached
1351
+
1352
+ def _fetch():
1353
+ try:
1354
+ from fear_and_greed import get as get_fear_greed
1355
+ result = get_fear_greed()
1356
+ return result
1357
+ except Exception:
1358
+ return None
1359
+
1360
+ try:
1361
+ result = await asyncio.to_thread(_fetch)
1362
+ if result is None:
1363
+ return None
1364
+
1365
+ value = result.value # 0-100
1366
+ status = result.description # "Extreme Fear", "Fear", etc.
1367
+
1368
+ # Contrarian scoring
1369
+ if value <= 25:
1370
+ score = 0.5 # Extreme fear = buy opportunity
1371
+ elif value <= 45:
1372
+ score = 0.2 # Fear = mild buy signal
1373
+ elif value <= 55:
1374
+ score = 0.0 # Neutral
1375
+ elif value <= 75:
1376
+ score = -0.2 # Greed = caution
1377
+ else:
1378
+ score = -0.5 # Extreme greed = warning
1379
+
1380
+ result_tuple = (score, value, status)
1381
+ _set_cache("fear_greed", result_tuple)
1382
+ return result_tuple
1383
+ except Exception:
1384
+ return None
1385
+
1386
+
1387
+ async def get_short_interest(data: StockData) -> tuple[float, float | None, float | None] | None:
1388
+ """
1389
+ Analyze short interest (from yfinance).
1390
+ Returns: (score, short_interest_pct, days_to_cover) or None.
1391
+ """
1392
+ # This is already synchronous data access (no API call), but make it async for consistency
1393
+ try:
1394
+ short_pct = data.info.get("shortPercentOfFloat")
1395
+ if short_pct is None:
1396
+ return None
1397
+
1398
+ short_pct_float = float(short_pct) * 100 # Convert to percentage
1399
+
1400
+ # Estimate days to cover (simplified - actual calculation needs volume data)
1401
+ short_ratio = data.info.get("shortRatio") # Days to cover
1402
+ days_to_cover = float(short_ratio) if short_ratio else None
1403
+
1404
+ # Scoring logic
1405
+ if short_pct_float > 20:
1406
+ if days_to_cover and days_to_cover > 10:
1407
+ score = 0.4 # High short interest + high days to cover = squeeze potential
1408
+ else:
1409
+ score = -0.3 # High short interest but justified
1410
+ elif short_pct_float < 5:
1411
+ score = 0.2 # Low short interest = bullish sentiment
1412
+ else:
1413
+ score = 0.0 # Normal range
1414
+
1415
+ return (score, short_pct_float, days_to_cover)
1416
+ except Exception:
1417
+ return None
1418
+
1419
+
1420
+ async def get_vix_term_structure() -> tuple[float, str | None, float | None] | None:
1421
+ """
1422
+ Analyze VIX futures term structure (contango vs backwardation) with 1h cache.
1423
+ Returns: (score, structure, slope) or None.
1424
+ """
1425
+ # Check cache first
1426
+ cached = _get_cached("vix_structure")
1427
+ if cached is not None:
1428
+ return cached
1429
+
1430
+ def _fetch():
1431
+ try:
1432
+ import yfinance as yf
1433
+ vix = yf.Ticker("^VIX")
1434
+ vix_data = vix.history(period="5d")
1435
+ if vix_data.empty:
1436
+ return None
1437
+ return vix_data["Close"].iloc[-1]
1438
+ except Exception:
1439
+ return None
1440
+
1441
+ try:
1442
+ vix_spot = await asyncio.to_thread(_fetch)
1443
+ if vix_spot is None:
1444
+ return None
1445
+
1446
+ # Simplified: assume normal contango when VIX < 20, backwardation when VIX > 30
1447
+ if vix_spot < 15:
1448
+ structure = "contango"
1449
+ slope = 10.0 # Steep contango
1450
+ score = 0.3 # Complacency/bullish
1451
+ elif vix_spot < 20:
1452
+ structure = "contango"
1453
+ slope = 5.0
1454
+ score = 0.1
1455
+ elif vix_spot > 30:
1456
+ structure = "backwardation"
1457
+ slope = -5.0
1458
+ score = -0.3 # Stress/bearish
1459
+ else:
1460
+ structure = "flat"
1461
+ slope = 0.0
1462
+ score = 0.0
1463
+
1464
+ result_tuple = (score, structure, slope)
1465
+ _set_cache("vix_structure", result_tuple)
1466
+ return result_tuple
1467
+ except Exception:
1468
+ return None
1469
+
1470
+
1471
+ async def get_insider_activity(ticker: str, period_days: int = 90) -> tuple[float, int | None, float | None] | None:
1472
+ """
1473
+ Analyze insider trading from SEC Form 4 filings using edgartools.
1474
+ Returns: (score, net_shares, net_value_millions) or None.
1475
+
1476
+ Scoring logic:
1477
+ - Strong buying (>100K shares or >$1M): +0.8
1478
+ - Moderate buying (>10K shares or >$0.1M): +0.4
1479
+ - Neutral: 0
1480
+ - Moderate selling: -0.4
1481
+ - Strong selling: -0.8
1482
+
1483
+ Note: SEC EDGAR API requires User-Agent with email.
1484
+ """
1485
+ def _fetch():
1486
+ try:
1487
+ from edgar import Company, set_identity
1488
+ from datetime import datetime, timedelta
1489
+
1490
+ # Set SEC-required identity
1491
+ set_identity("stock-analysis@clawd.bot")
1492
+
1493
+ # Get company and Form 4 filings
1494
+ company = Company(ticker)
1495
+ filings = company.get_filings(form="4")
1496
+
1497
+ if filings is None or len(filings) == 0:
1498
+ return None
1499
+
1500
+ # Calculate cutoff date
1501
+ cutoff_date = datetime.now() - timedelta(days=period_days)
1502
+
1503
+ # Aggregate transactions
1504
+ total_bought_shares = 0
1505
+ total_sold_shares = 0
1506
+ total_bought_value = 0.0
1507
+ total_sold_value = 0.0
1508
+
1509
+ # Process recent filings (iterate, don't slice due to pyarrow compatibility)
1510
+ count = 0
1511
+ for filing in filings:
1512
+ if count >= 50:
1513
+ break
1514
+ count += 1
1515
+
1516
+ try:
1517
+ # Check filing date
1518
+ filing_date = filing.filing_date
1519
+ if hasattr(filing_date, 'to_pydatetime'):
1520
+ filing_date = filing_date.to_pydatetime()
1521
+ elif isinstance(filing_date, str):
1522
+ filing_date = datetime.strptime(filing_date, "%Y-%m-%d")
1523
+
1524
+ # Convert date object to datetime for comparison
1525
+ if hasattr(filing_date, 'year') and not hasattr(filing_date, 'hour'):
1526
+ filing_date = datetime.combine(filing_date, datetime.min.time())
1527
+
1528
+ if filing_date < cutoff_date:
1529
+ continue
1530
+
1531
+ # Get Form 4 object
1532
+ form4 = filing.obj()
1533
+ if form4 is None:
1534
+ continue
1535
+
1536
+ # Process purchases (edgartools returns DataFrames)
1537
+ if hasattr(form4, 'common_stock_purchases'):
1538
+ purchases = form4.common_stock_purchases
1539
+ if isinstance(purchases, pd.DataFrame) and not purchases.empty:
1540
+ if 'Shares' in purchases.columns:
1541
+ total_bought_shares += int(purchases['Shares'].sum())
1542
+ if 'Price' in purchases.columns and 'Shares' in purchases.columns:
1543
+ total_bought_value += float((purchases['Shares'] * purchases['Price']).sum())
1544
+
1545
+ # Process sales
1546
+ if hasattr(form4, 'common_stock_sales'):
1547
+ sales = form4.common_stock_sales
1548
+ if isinstance(sales, pd.DataFrame) and not sales.empty:
1549
+ if 'Shares' in sales.columns:
1550
+ total_sold_shares += int(sales['Shares'].sum())
1551
+ if 'Price' in sales.columns and 'Shares' in sales.columns:
1552
+ total_sold_value += float((sales['Shares'] * sales['Price']).sum())
1553
+
1554
+ except Exception:
1555
+ continue
1556
+
1557
+ # Calculate net values
1558
+ net_shares = total_bought_shares - total_sold_shares
1559
+ net_value = (total_bought_value - total_sold_value) / 1_000_000 # Millions
1560
+
1561
+ # Apply scoring logic
1562
+ if net_shares > 100_000 or net_value > 1.0:
1563
+ score = 0.8 # Strong buying
1564
+ elif net_shares > 10_000 or net_value > 0.1:
1565
+ score = 0.4 # Moderate buying
1566
+ elif net_shares < -100_000 or net_value < -1.0:
1567
+ score = -0.8 # Strong selling
1568
+ elif net_shares < -10_000 or net_value < -0.1:
1569
+ score = -0.4 # Moderate selling
1570
+ else:
1571
+ score = 0.0 # Neutral
1572
+
1573
+ return (score, net_shares, net_value)
1574
+
1575
+ except ImportError:
1576
+ # edgartools not installed
1577
+ return None
1578
+ except Exception:
1579
+ return None
1580
+
1581
+ try:
1582
+ result = await asyncio.to_thread(_fetch)
1583
+ return result
1584
+ except Exception:
1585
+ return None
1586
+
1587
+
1588
+ async def get_put_call_ratio(data: StockData) -> tuple[float, float | None, int | None, int | None] | None:
1589
+ """
1590
+ Calculate put/call ratio from options chain (contrarian indicator).
1591
+ Returns: (score, ratio, put_volume, call_volume) or None.
1592
+ """
1593
+ def _fetch():
1594
+ try:
1595
+ if data.ticker_obj is None:
1596
+ return None
1597
+
1598
+ # Get options chain for nearest expiration
1599
+ expirations = data.ticker_obj.options
1600
+ if not expirations or len(expirations) == 0:
1601
+ return None
1602
+
1603
+ nearest_exp = expirations[0]
1604
+ opt_chain = data.ticker_obj.option_chain(nearest_exp)
1605
+
1606
+ # Calculate total put and call volume
1607
+ put_volume = opt_chain.puts["volume"].sum() if "volume" in opt_chain.puts.columns else 0
1608
+ call_volume = opt_chain.calls["volume"].sum() if "volume" in opt_chain.calls.columns else 0
1609
+
1610
+ if call_volume == 0 or put_volume == 0:
1611
+ return None
1612
+
1613
+ ratio = put_volume / call_volume
1614
+ return (ratio, int(put_volume), int(call_volume))
1615
+ except Exception:
1616
+ return None
1617
+
1618
+ try:
1619
+ result = await asyncio.to_thread(_fetch)
1620
+ if result is None:
1621
+ return None
1622
+
1623
+ ratio, put_volume, call_volume = result
1624
+
1625
+ # Contrarian scoring
1626
+ if ratio > 1.5:
1627
+ score = 0.3 # Excessive fear = bullish
1628
+ elif ratio > 1.0:
1629
+ score = 0.1 # Mild fear
1630
+ elif ratio > 0.7:
1631
+ score = -0.1 # Normal
1632
+ else:
1633
+ score = -0.3 # Complacency = bearish
1634
+
1635
+ return (score, ratio, put_volume, call_volume)
1636
+ except Exception:
1637
+ return None
1638
+
1639
+
1640
+ async def analyze_sentiment(data: StockData, verbose: bool = False, skip_insider: bool = False) -> SentimentAnalysis | None:
1641
+ """
1642
+ Analyze market sentiment using 5 sub-indicators in parallel.
1643
+ Requires at least 2 of 5 indicators for valid sentiment.
1644
+ Returns overall sentiment score (-1.0 to +1.0) with sub-metrics.
1645
+ """
1646
+ scores = []
1647
+ explanations = []
1648
+ warnings = []
1649
+
1650
+ # Initialize all raw data fields
1651
+ fear_greed_score = None
1652
+ fear_greed_value = None
1653
+ fear_greed_status = None
1654
+
1655
+ short_interest_score = None
1656
+ short_interest_pct = None
1657
+ days_to_cover = None
1658
+
1659
+ vix_structure_score = None
1660
+ vix_structure = None
1661
+ vix_slope = None
1662
+
1663
+ insider_activity_score = None
1664
+ insider_net_shares = None
1665
+ insider_net_value = None
1666
+
1667
+ put_call_score = None
1668
+ put_call_ratio = None
1669
+ put_volume = None
1670
+ call_volume = None
1671
+
1672
+ # Fetch all 5 indicators in parallel with 10s timeout per indicator
1673
+ # (or 4 if skip_insider=True for faster analysis)
1674
+ try:
1675
+ tasks = [
1676
+ asyncio.wait_for(get_fear_greed_index(), timeout=10),
1677
+ asyncio.wait_for(get_short_interest(data), timeout=10),
1678
+ asyncio.wait_for(get_vix_term_structure(), timeout=10),
1679
+ ]
1680
+
1681
+ if skip_insider:
1682
+ tasks.append(asyncio.sleep(0)) # Placeholder - returns None
1683
+ if verbose:
1684
+ print(" Skipping insider trading analysis (--no-insider)", file=sys.stderr)
1685
+ else:
1686
+ tasks.append(asyncio.wait_for(get_insider_activity(data.ticker, period_days=90), timeout=10))
1687
+
1688
+ tasks.append(asyncio.wait_for(get_put_call_ratio(data), timeout=10))
1689
+
1690
+ results = await asyncio.gather(*tasks, return_exceptions=True)
1691
+
1692
+ # Process Fear & Greed Index
1693
+ fear_greed_result = results[0]
1694
+ if isinstance(fear_greed_result, tuple) and fear_greed_result is not None:
1695
+ fear_greed_score, fear_greed_value, fear_greed_status = fear_greed_result
1696
+ scores.append(fear_greed_score)
1697
+ explanations.append(f"{fear_greed_status} ({fear_greed_value})")
1698
+ if verbose:
1699
+ print(f" Fear & Greed: {fear_greed_status} ({fear_greed_value}) → score {fear_greed_score:+.2f}", file=sys.stderr)
1700
+ elif verbose and isinstance(fear_greed_result, Exception):
1701
+ print(f" Fear & Greed: Failed ({fear_greed_result})", file=sys.stderr)
1702
+
1703
+ # Process Short Interest
1704
+ short_interest_result = results[1]
1705
+ if isinstance(short_interest_result, tuple) and short_interest_result is not None:
1706
+ short_interest_score, short_interest_pct, days_to_cover = short_interest_result
1707
+ scores.append(short_interest_score)
1708
+ if days_to_cover:
1709
+ explanations.append(f"Short interest {short_interest_pct:.1f}% (days to cover: {days_to_cover:.1f})")
1710
+ else:
1711
+ explanations.append(f"Short interest {short_interest_pct:.1f}%")
1712
+ warnings.append("Short interest data typically ~2 weeks old (FINRA lag)")
1713
+ if verbose:
1714
+ print(f" Short Interest: {short_interest_pct:.1f}% → score {short_interest_score:+.2f}", file=sys.stderr)
1715
+ elif verbose and isinstance(short_interest_result, Exception):
1716
+ print(f" Short Interest: Failed ({short_interest_result})", file=sys.stderr)
1717
+
1718
+ # Process VIX Term Structure
1719
+ vix_result = results[2]
1720
+ if isinstance(vix_result, tuple) and vix_result is not None:
1721
+ vix_structure_score, vix_structure, vix_slope = vix_result
1722
+ scores.append(vix_structure_score)
1723
+ explanations.append(f"VIX {vix_structure}")
1724
+ if verbose:
1725
+ print(f" VIX Structure: {vix_structure} (slope {vix_slope:.1f}%) → score {vix_structure_score:+.2f}", file=sys.stderr)
1726
+ elif verbose and isinstance(vix_result, Exception):
1727
+ print(f" VIX Structure: Failed ({vix_result})", file=sys.stderr)
1728
+
1729
+ # Process Insider Activity
1730
+ insider_result = results[3]
1731
+ if isinstance(insider_result, tuple) and insider_result is not None:
1732
+ insider_activity_score, insider_net_shares, insider_net_value = insider_result
1733
+ scores.append(insider_activity_score)
1734
+ if insider_net_value:
1735
+ explanations.append(f"Insider net: ${insider_net_value:.1f}M")
1736
+ warnings.append("Insider trades may lag filing by 2-3 days")
1737
+ if verbose:
1738
+ print(f" Insider Activity: Net ${insider_net_value:.1f}M → score {insider_activity_score:+.2f}", file=sys.stderr)
1739
+ elif verbose and isinstance(insider_result, Exception):
1740
+ print(f" Insider Activity: Failed ({insider_result})", file=sys.stderr)
1741
+
1742
+ # Process Put/Call Ratio
1743
+ put_call_result = results[4]
1744
+ if isinstance(put_call_result, tuple) and put_call_result is not None:
1745
+ put_call_score, put_call_ratio, put_volume, call_volume = put_call_result
1746
+ scores.append(put_call_score)
1747
+ explanations.append(f"Put/call ratio {put_call_ratio:.2f}")
1748
+ if verbose:
1749
+ print(f" Put/Call Ratio: {put_call_ratio:.2f} → score {put_call_score:+.2f}", file=sys.stderr)
1750
+ elif verbose and isinstance(put_call_result, Exception):
1751
+ print(f" Put/Call Ratio: Failed ({put_call_result})", file=sys.stderr)
1752
+
1753
+ except Exception as e:
1754
+ if verbose:
1755
+ print(f" Sentiment analysis error: {e}", file=sys.stderr)
1756
+ return None
1757
+
1758
+ # Require at least 2 of 5 indicators for valid sentiment
1759
+ indicators_available = len(scores)
1760
+ if indicators_available < 2:
1761
+ if verbose:
1762
+ print(f" Sentiment: Insufficient data ({indicators_available}/5 indicators)", file=sys.stderr)
1763
+ return None
1764
+
1765
+ # Calculate overall score as simple average
1766
+ overall_score = sum(scores) / len(scores)
1767
+ explanation = "; ".join(explanations)
1768
+
1769
+ return SentimentAnalysis(
1770
+ score=overall_score,
1771
+ explanation=explanation,
1772
+ fear_greed_score=fear_greed_score,
1773
+ short_interest_score=short_interest_score,
1774
+ vix_structure_score=vix_structure_score,
1775
+ insider_activity_score=insider_activity_score,
1776
+ put_call_score=put_call_score,
1777
+ fear_greed_value=fear_greed_value,
1778
+ fear_greed_status=fear_greed_status,
1779
+ short_interest_pct=short_interest_pct,
1780
+ days_to_cover=days_to_cover,
1781
+ vix_structure=vix_structure,
1782
+ vix_slope=vix_slope,
1783
+ insider_net_shares=insider_net_shares,
1784
+ insider_net_value=insider_net_value,
1785
+ put_call_ratio=put_call_ratio,
1786
+ put_volume=put_volume,
1787
+ call_volume=call_volume,
1788
+ indicators_available=indicators_available,
1789
+ data_freshness_warnings=warnings if warnings else None,
1790
+ )
1791
+
1792
+
1793
+ def synthesize_signal(
1794
+ ticker: str,
1795
+ company_name: str,
1796
+ earnings: EarningsSurprise | None,
1797
+ fundamentals: Fundamentals | None,
1798
+ analysts: AnalystSentiment | None,
1799
+ historical: HistoricalPatterns | None,
1800
+ market_context: MarketContext | None,
1801
+ sector: SectorComparison | None,
1802
+ earnings_timing: EarningsTiming | None,
1803
+ momentum: MomentumAnalysis | None,
1804
+ sentiment: SentimentAnalysis | None,
1805
+ breaking_news: list[str] | None = None, # NEW v4.0.0
1806
+ geopolitical_risk_warning: str | None = None, # NEW v4.0.0
1807
+ geopolitical_risk_penalty: float = 0.0, # NEW v4.0.0
1808
+ ) -> Signal:
1809
+ """Synthesize all components into a final signal."""
1810
+
1811
+ # Collect available components with weights
1812
+ components = []
1813
+ weights = []
1814
+
1815
+ if earnings:
1816
+ components.append(("earnings", earnings.score))
1817
+ weights.append(0.30) # reduced from 0.35
1818
+
1819
+ if fundamentals:
1820
+ components.append(("fundamentals", fundamentals.score))
1821
+ weights.append(0.20) # reduced from 0.25
1822
+
1823
+ if analysts and analysts.score is not None:
1824
+ components.append(("analysts", analysts.score))
1825
+ weights.append(0.20) # reduced from 0.25
1826
+
1827
+ if historical:
1828
+ components.append(("historical", historical.score))
1829
+ weights.append(0.10) # reduced from 0.15
1830
+
1831
+ # NEW COMPONENTS
1832
+ if market_context:
1833
+ components.append(("market", market_context.score))
1834
+ weights.append(0.10)
1835
+
1836
+ if sector:
1837
+ components.append(("sector", sector.score))
1838
+ weights.append(0.15)
1839
+
1840
+ if momentum:
1841
+ components.append(("momentum", momentum.score))
1842
+ weights.append(0.15)
1843
+
1844
+ if sentiment:
1845
+ components.append(("sentiment", sentiment.score))
1846
+ weights.append(0.10)
1847
+
1848
+ # Require at least 2 components
1849
+ if len(components) < 2:
1850
+ return Signal(
1851
+ ticker=ticker,
1852
+ company_name=company_name,
1853
+ recommendation="HOLD",
1854
+ confidence=0.0,
1855
+ final_score=0.0,
1856
+ supporting_points=["Insufficient data for analysis"],
1857
+ caveats=["Limited data available"],
1858
+ timestamp=datetime.now().isoformat(),
1859
+ components={},
1860
+ )
1861
+
1862
+ # Normalize weights
1863
+ total_weight = sum(weights)
1864
+ normalized_weights = [w / total_weight for w in weights]
1865
+
1866
+ # Calculate weighted score
1867
+ final_score = sum(score * weight for (_, score), weight in zip(components, normalized_weights))
1868
+
1869
+ # Determine recommendation
1870
+ if final_score > 0.33:
1871
+ recommendation = "BUY"
1872
+ elif final_score < -0.33:
1873
+ recommendation = "SELL"
1874
+ else:
1875
+ recommendation = "HOLD"
1876
+
1877
+ confidence = abs(final_score)
1878
+
1879
+ # Apply earnings timing adjustments and overrides
1880
+ if earnings_timing:
1881
+ confidence *= (1.0 + earnings_timing.confidence_adjustment)
1882
+
1883
+ # Override recommendation if needed
1884
+ if earnings_timing.timing_flag == "pre_earnings":
1885
+ if recommendation == "BUY":
1886
+ recommendation = "HOLD"
1887
+
1888
+ elif earnings_timing.timing_flag == "post_earnings":
1889
+ if earnings_timing.price_change_5d and earnings_timing.price_change_5d > 15:
1890
+ if recommendation == "BUY":
1891
+ recommendation = "HOLD"
1892
+
1893
+ # Check overbought + near 52w high
1894
+ if momentum and momentum.rsi_14d and momentum.rsi_14d > 70 and momentum.near_52w_high:
1895
+ if recommendation == "BUY":
1896
+ recommendation = "HOLD"
1897
+ confidence *= 0.7
1898
+
1899
+ # NEW v4.0.0: Risk-off confidence penalty
1900
+ if market_context and market_context.risk_off_detected:
1901
+ if recommendation == "BUY":
1902
+ confidence *= 0.7 # Reduce BUY confidence by 30%
1903
+
1904
+ # NEW v4.0.0: Geopolitical sector risk penalty
1905
+ if geopolitical_risk_penalty > 0:
1906
+ if recommendation == "BUY":
1907
+ confidence *= (1.0 - geopolitical_risk_penalty) # Apply penalty
1908
+
1909
+ # Generate supporting points
1910
+ supporting_points = []
1911
+
1912
+ if earnings and earnings.actual_eps is not None:
1913
+ supporting_points.append(
1914
+ f"{earnings.explanation} - EPS ${earnings.actual_eps:.2f} vs ${earnings.expected_eps:.2f} expected"
1915
+ )
1916
+
1917
+ if fundamentals and fundamentals.explanation:
1918
+ supporting_points.append(fundamentals.explanation)
1919
+
1920
+ if analysts and analysts.summary:
1921
+ supporting_points.append(f"Analyst consensus: {analysts.summary}")
1922
+
1923
+ if historical and historical.pattern_desc:
1924
+ supporting_points.append(f"Historical pattern: {historical.pattern_desc}")
1925
+
1926
+ if market_context and market_context.explanation:
1927
+ supporting_points.append(f"Market: {market_context.explanation}")
1928
+
1929
+ if sector and sector.explanation:
1930
+ supporting_points.append(f"Sector: {sector.explanation}")
1931
+
1932
+ if momentum and momentum.explanation:
1933
+ supporting_points.append(f"Momentum: {momentum.explanation}")
1934
+
1935
+ if sentiment and sentiment.explanation:
1936
+ supporting_points.append(f"Sentiment: {sentiment.explanation}")
1937
+
1938
+ # Generate caveats
1939
+ caveats = []
1940
+
1941
+ # Add earnings timing caveats first (most important)
1942
+ if earnings_timing and earnings_timing.caveats:
1943
+ caveats.extend(earnings_timing.caveats)
1944
+
1945
+ # Add sentiment warnings
1946
+ if sentiment and sentiment.data_freshness_warnings:
1947
+ caveats.extend(sentiment.data_freshness_warnings)
1948
+
1949
+ # Add momentum warnings
1950
+ if momentum and momentum.rsi_14d:
1951
+ if momentum.rsi_14d > 70 and momentum.near_52w_high:
1952
+ caveats.append("Overbought conditions - high risk entry")
1953
+
1954
+ # Add sector warnings
1955
+ if sector and sector.score < -0.2:
1956
+ caveats.append(f"Sector {sector.sector_name} is weak despite stock fundamentals")
1957
+
1958
+ # Add market warnings
1959
+ if market_context and market_context.vix_status == "fear":
1960
+ caveats.append(f"High market volatility (VIX {market_context.vix_level:.0f})")
1961
+
1962
+ # NEW v4.0.0: Risk-off warnings
1963
+ if market_context and market_context.risk_off_detected:
1964
+ caveats.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}%)")
1965
+
1966
+ # NEW v4.0.0: Breaking news alerts
1967
+ if breaking_news:
1968
+ for alert in breaking_news[:2]: # Limit to 2 alerts to avoid overwhelming
1969
+ caveats.append(f"⚠️ BREAKING NEWS: {alert}")
1970
+
1971
+ # NEW v4.0.0: Geopolitical sector risk warnings
1972
+ if geopolitical_risk_warning:
1973
+ caveats.append(geopolitical_risk_warning)
1974
+
1975
+ # Original caveats
1976
+ if not analysts or analysts.score is None:
1977
+ caveats.append("Limited or no analyst coverage")
1978
+
1979
+ if not earnings:
1980
+ caveats.append("No recent earnings data available")
1981
+
1982
+ if len(components) < 4:
1983
+ caveats.append("Analysis based on limited data components")
1984
+
1985
+ if not caveats:
1986
+ caveats.append("Market conditions can change rapidly")
1987
+
1988
+ # Limit to 5 caveats
1989
+ caveats = caveats[:5]
1990
+
1991
+ # Build components dict for output
1992
+ components_dict = {}
1993
+ if earnings:
1994
+ components_dict["earnings_surprise"] = {
1995
+ "score": earnings.score,
1996
+ "actual_eps": earnings.actual_eps,
1997
+ "expected_eps": earnings.expected_eps,
1998
+ "surprise_pct": earnings.surprise_pct,
1999
+ "explanation": earnings.explanation,
2000
+ }
2001
+
2002
+ if fundamentals:
2003
+ components_dict["fundamentals"] = {
2004
+ "score": fundamentals.score,
2005
+ **fundamentals.key_metrics,
2006
+ }
2007
+
2008
+ if analysts:
2009
+ components_dict["analyst_sentiment"] = {
2010
+ "score": analysts.score,
2011
+ "consensus_rating": analysts.consensus_rating,
2012
+ "price_target": analysts.price_target,
2013
+ "current_price": analysts.current_price,
2014
+ "upside_pct": analysts.upside_pct,
2015
+ "num_analysts": analysts.num_analysts,
2016
+ }
2017
+
2018
+ if historical:
2019
+ components_dict["historical_patterns"] = {
2020
+ "score": historical.score,
2021
+ "beats_last_4q": historical.beats_last_4q,
2022
+ "avg_reaction_pct": historical.avg_reaction_pct,
2023
+ }
2024
+
2025
+ if market_context:
2026
+ components_dict["market_context"] = {
2027
+ "score": market_context.score,
2028
+ "vix_level": market_context.vix_level,
2029
+ "vix_status": market_context.vix_status,
2030
+ "spy_trend_10d": market_context.spy_trend_10d,
2031
+ "qqq_trend_10d": market_context.qqq_trend_10d,
2032
+ "market_regime": market_context.market_regime,
2033
+ "gld_change_5d": market_context.gld_change_5d,
2034
+ "tlt_change_5d": market_context.tlt_change_5d,
2035
+ "uup_change_5d": market_context.uup_change_5d,
2036
+ "risk_off_detected": market_context.risk_off_detected,
2037
+ }
2038
+
2039
+ if sector:
2040
+ components_dict["sector_performance"] = {
2041
+ "score": sector.score,
2042
+ "sector_name": sector.sector_name,
2043
+ "stock_return_1m": sector.stock_return_1m,
2044
+ "sector_return_1m": sector.sector_return_1m,
2045
+ "relative_strength": sector.relative_strength,
2046
+ "sector_trend": sector.sector_trend,
2047
+ }
2048
+
2049
+ if earnings_timing:
2050
+ components_dict["earnings_timing"] = {
2051
+ "days_until_earnings": earnings_timing.days_until_earnings,
2052
+ "days_since_earnings": earnings_timing.days_since_earnings,
2053
+ "timing_flag": earnings_timing.timing_flag,
2054
+ "price_change_5d": earnings_timing.price_change_5d,
2055
+ "confidence_adjustment": earnings_timing.confidence_adjustment,
2056
+ }
2057
+
2058
+ if momentum:
2059
+ components_dict["momentum"] = {
2060
+ "score": momentum.score,
2061
+ "rsi_14d": momentum.rsi_14d,
2062
+ "rsi_status": momentum.rsi_status,
2063
+ "near_52w_high": momentum.near_52w_high,
2064
+ "near_52w_low": momentum.near_52w_low,
2065
+ "volume_ratio": momentum.volume_ratio,
2066
+ }
2067
+
2068
+ if sentiment:
2069
+ components_dict["sentiment_analysis"] = {
2070
+ "score": sentiment.score,
2071
+ "indicators_available": sentiment.indicators_available,
2072
+ "fear_greed_value": sentiment.fear_greed_value,
2073
+ "fear_greed_status": sentiment.fear_greed_status,
2074
+ "short_interest_pct": sentiment.short_interest_pct,
2075
+ "days_to_cover": sentiment.days_to_cover,
2076
+ "vix_structure": sentiment.vix_structure,
2077
+ "vix_slope": sentiment.vix_slope,
2078
+ "insider_net_value": sentiment.insider_net_value,
2079
+ "put_call_ratio": sentiment.put_call_ratio,
2080
+ "data_freshness_warnings": sentiment.data_freshness_warnings,
2081
+ }
2082
+
2083
+ return Signal(
2084
+ ticker=ticker,
2085
+ company_name=company_name,
2086
+ recommendation=recommendation,
2087
+ confidence=confidence,
2088
+ final_score=final_score,
2089
+ supporting_points=supporting_points[:5], # Limit to 5
2090
+ caveats=caveats, # Already limited to 5 earlier
2091
+ timestamp=datetime.now().isoformat(),
2092
+ components=components_dict,
2093
+ )
2094
+
2095
+
2096
+ def format_output_text(signal: Signal) -> str:
2097
+ """Format signal as text output."""
2098
+ lines = [
2099
+ "=" * 77,
2100
+ f"STOCK ANALYSIS: {signal.ticker} ({signal.company_name})",
2101
+ f"Generated: {signal.timestamp}",
2102
+ "=" * 77,
2103
+ "",
2104
+ f"RECOMMENDATION: {signal.recommendation} (Confidence: {signal.confidence*100:.0f}%)",
2105
+ "",
2106
+ "SUPPORTING POINTS:",
2107
+ ]
2108
+
2109
+ for point in signal.supporting_points:
2110
+ lines.append(f"• {point}")
2111
+
2112
+ lines.extend([
2113
+ "",
2114
+ "CAVEATS:",
2115
+ ])
2116
+
2117
+ for caveat in signal.caveats:
2118
+ lines.append(f"• {caveat}")
2119
+
2120
+ lines.extend([
2121
+ "",
2122
+ "=" * 77,
2123
+ "DISCLAIMER: This analysis is for informational purposes only and does NOT",
2124
+ "constitute financial advice. Consult a licensed financial advisor before",
2125
+ "making investment decisions. Data provided by Yahoo Finance.",
2126
+ "=" * 77,
2127
+ ])
2128
+
2129
+ return "\n".join(lines)
2130
+
2131
+
2132
+ def format_output_json(signal: Signal) -> str:
2133
+ """Format signal as JSON output."""
2134
+ output = {
2135
+ **asdict(signal),
2136
+ "disclaimer": "NOT FINANCIAL ADVICE. For informational purposes only.",
2137
+ }
2138
+ return json.dumps(output, indent=2)
2139
+
2140
+
2141
+ def main():
2142
+ parser = argparse.ArgumentParser(
2143
+ description="Analyze stocks using Yahoo Finance data"
2144
+ )
2145
+ parser.add_argument(
2146
+ "tickers",
2147
+ nargs="*",
2148
+ help="Stock/crypto ticker(s) to analyze"
2149
+ )
2150
+ parser.add_argument(
2151
+ "--output",
2152
+ choices=["text", "json"],
2153
+ default="text",
2154
+ help="Output format (default: text)"
2155
+ )
2156
+ parser.add_argument(
2157
+ "--verbose",
2158
+ action="store_true",
2159
+ help="Verbose output to stderr"
2160
+ )
2161
+ parser.add_argument(
2162
+ "--portfolio", "-p",
2163
+ type=str,
2164
+ help="Analyze all assets in a portfolio"
2165
+ )
2166
+ parser.add_argument(
2167
+ "--period",
2168
+ choices=["daily", "weekly", "monthly", "quarterly", "yearly"],
2169
+ help="Period for portfolio performance analysis"
2170
+ )
2171
+ parser.add_argument(
2172
+ "--no-insider",
2173
+ action="store_true",
2174
+ help="Skip insider trading analysis (faster, SEC EDGAR is slow)"
2175
+ )
2176
+ parser.add_argument(
2177
+ "--fast",
2178
+ action="store_true",
2179
+ help="Fast mode: skip slow analyses (insider, breaking news)"
2180
+ )
2181
+
2182
+ args = parser.parse_args()
2183
+
2184
+ # Fast mode shortcuts
2185
+ if args.fast:
2186
+ args.no_insider = True
2187
+
2188
+ # Handle portfolio mode
2189
+ portfolio_assets = []
2190
+ portfolio_name = None
2191
+ if args.portfolio:
2192
+ try:
2193
+ from portfolio import PortfolioStore
2194
+ store = PortfolioStore()
2195
+ portfolio = store.get_portfolio(args.portfolio)
2196
+ if not portfolio:
2197
+ # Try to find default portfolio if name not found
2198
+ default_name = store.get_default_portfolio_name()
2199
+ if default_name and args.portfolio.lower() == "default":
2200
+ portfolio = store.get_portfolio(default_name)
2201
+ portfolio_name = default_name
2202
+ else:
2203
+ print(f"Error: Portfolio '{args.portfolio}' not found", file=sys.stderr)
2204
+ sys.exit(1)
2205
+ else:
2206
+ portfolio_name = portfolio.name
2207
+
2208
+ if not portfolio.assets:
2209
+ print(f"Portfolio '{portfolio_name}' has no assets", file=sys.stderr)
2210
+ sys.exit(1)
2211
+
2212
+ portfolio_assets = [(a.ticker, a.quantity, a.cost_basis, a.type) for a in portfolio.assets]
2213
+ args.tickers = [a.ticker for a in portfolio.assets]
2214
+
2215
+ if args.verbose:
2216
+ print(f"Analyzing portfolio: {portfolio_name} ({len(portfolio_assets)} assets)", file=sys.stderr)
2217
+
2218
+ except ImportError:
2219
+ print("Error: portfolio.py not found", file=sys.stderr)
2220
+ sys.exit(1)
2221
+ except Exception as e:
2222
+ print(f"Error loading portfolio: {e}", file=sys.stderr)
2223
+ sys.exit(1)
2224
+
2225
+ if not args.tickers:
2226
+ parser.print_help()
2227
+ sys.exit(1)
2228
+
2229
+ # NEW v4.0.0: Check for breaking news (market-wide, check once before analyzing tickers)
2230
+ # Check breaking news (skip in fast mode)
2231
+ breaking_news = None
2232
+ if not args.fast:
2233
+ if args.verbose:
2234
+ print(f"Checking breaking news (last 24h)...", file=sys.stderr)
2235
+ breaking_news = check_breaking_news(verbose=args.verbose)
2236
+ elif args.verbose:
2237
+ print(f"Skipping breaking news check (--fast mode)", file=sys.stderr)
2238
+ if breaking_news and args.verbose:
2239
+ print(f" Found {len(breaking_news)} breaking news alert(s)\n", file=sys.stderr)
2240
+
2241
+ results = []
2242
+
2243
+ for requested_ticker in args.tickers:
2244
+ requested_ticker = requested_ticker.upper()
2245
+ ticker = normalize_ticker(requested_ticker)
2246
+
2247
+ if args.verbose:
2248
+ if ticker != requested_ticker:
2249
+ print(f"\n=== Analyzing {requested_ticker} (mapped to {ticker}) ===\n", file=sys.stderr)
2250
+ else:
2251
+ print(f"\n=== Analyzing {ticker} ===\n", file=sys.stderr)
2252
+
2253
+ # Fetch data
2254
+ data = fetch_stock_data(ticker, verbose=args.verbose)
2255
+
2256
+ if data is None:
2257
+ error_message = f"Invalid ticker '{requested_ticker}' or data unavailable"
2258
+ if args.output == "json":
2259
+ print(format_error_output(error_message, output="json"))
2260
+ else:
2261
+ print(format_error_output(error_message, output="text"), file=sys.stderr)
2262
+ sys.exit(2)
2263
+
2264
+ # Get company name
2265
+ company_name = data.info.get("longName") or data.info.get("shortName") or ticker
2266
+
2267
+ # Detect asset type (crypto vs stock)
2268
+ is_crypto = data.asset_type == "crypto"
2269
+
2270
+ if args.verbose and is_crypto:
2271
+ print(f" Asset type: CRYPTO (using crypto-specific analysis)", file=sys.stderr)
2272
+
2273
+ # Analyze components (different for crypto vs stock)
2274
+ if is_crypto:
2275
+ # Crypto: Skip stock-specific analyses
2276
+ earnings = None
2277
+ fundamentals = None
2278
+ analysts = None
2279
+ historical = None
2280
+ earnings_timing = None
2281
+ sector = None
2282
+
2283
+ # Crypto fundamentals (market cap, category, BTC correlation)
2284
+ if args.verbose:
2285
+ print(f"Analyzing crypto fundamentals...", file=sys.stderr)
2286
+ crypto_fundamentals = analyze_crypto_fundamentals(data, verbose=args.verbose)
2287
+
2288
+ # Convert crypto fundamentals to regular Fundamentals for synthesize_signal
2289
+ if crypto_fundamentals:
2290
+ fundamentals = Fundamentals(
2291
+ score=crypto_fundamentals.score,
2292
+ key_metrics={
2293
+ "market_cap": crypto_fundamentals.market_cap,
2294
+ "market_cap_rank": crypto_fundamentals.market_cap_rank,
2295
+ "category": crypto_fundamentals.category,
2296
+ "btc_correlation": crypto_fundamentals.btc_correlation,
2297
+ },
2298
+ explanation=crypto_fundamentals.explanation,
2299
+ )
2300
+ else:
2301
+ # Stock: Full analysis
2302
+ earnings = analyze_earnings_surprise(data)
2303
+ fundamentals = analyze_fundamentals(data)
2304
+ analysts = analyze_analyst_sentiment(data)
2305
+ historical = analyze_historical_patterns(data)
2306
+
2307
+ # Analyze earnings timing (stocks only)
2308
+ if args.verbose:
2309
+ print(f"Checking earnings timing...", file=sys.stderr)
2310
+ earnings_timing = analyze_earnings_timing(data)
2311
+
2312
+ # Analyze sector performance (stocks only)
2313
+ if args.verbose:
2314
+ print(f"Analyzing sector performance...", file=sys.stderr)
2315
+ sector = analyze_sector_performance(data, verbose=args.verbose)
2316
+
2317
+ # Market context (both crypto and stock)
2318
+ if args.verbose:
2319
+ print(f"Analyzing market context...", file=sys.stderr)
2320
+ market_context = analyze_market_context(verbose=args.verbose)
2321
+
2322
+ # Momentum (both crypto and stock)
2323
+ if args.verbose:
2324
+ print(f"Analyzing momentum...", file=sys.stderr)
2325
+ momentum = analyze_momentum(data)
2326
+
2327
+ # Sentiment (stocks get full sentiment, crypto gets limited)
2328
+ if args.verbose:
2329
+ print(f"Analyzing market sentiment...", file=sys.stderr)
2330
+ if is_crypto:
2331
+ # Skip insider trading and put/call for crypto
2332
+ sentiment = None
2333
+ else:
2334
+ sentiment = asyncio.run(analyze_sentiment(data, verbose=args.verbose, skip_insider=args.no_insider))
2335
+
2336
+ # Geopolitical risks (stocks only)
2337
+ if is_crypto:
2338
+ geopolitical_risk_warning = None
2339
+ geopolitical_risk_penalty = 0.0
2340
+ else:
2341
+ sector_name = data.info.get("sector")
2342
+ geopolitical_risk_warning, geopolitical_risk_penalty = check_sector_geopolitical_risk(
2343
+ ticker=ticker,
2344
+ sector=sector_name,
2345
+ breaking_news=breaking_news,
2346
+ verbose=args.verbose
2347
+ )
2348
+
2349
+ if args.verbose:
2350
+ print(f"Components analyzed:", file=sys.stderr)
2351
+ if is_crypto:
2352
+ print(f" Crypto Fundamentals: {'✓' if fundamentals else '✗'}", file=sys.stderr)
2353
+ print(f" Market Context: {'✓' if market_context else '✗'}", file=sys.stderr)
2354
+ print(f" Momentum: {'✓' if momentum else '✗'}", file=sys.stderr)
2355
+ print(f" (Earnings, Sector, Sentiment: N/A for crypto)\n", file=sys.stderr)
2356
+ else:
2357
+ print(f" Earnings: {'✓' if earnings else '✗'}", file=sys.stderr)
2358
+ print(f" Fundamentals: {'✓' if fundamentals else '✗'}", file=sys.stderr)
2359
+ print(f" Analysts: {'✓' if analysts and analysts.score else '✗'}", file=sys.stderr)
2360
+ print(f" Historical: {'✓' if historical else '✗'}", file=sys.stderr)
2361
+ print(f" Market Context: {'✓' if market_context else '✗'}", file=sys.stderr)
2362
+ print(f" Sector: {'✓' if sector else '✗'}", file=sys.stderr)
2363
+ print(f" Earnings Timing: {'✓' if earnings_timing else '✗'}", file=sys.stderr)
2364
+ print(f" Momentum: {'✓' if momentum else '✗'}", file=sys.stderr)
2365
+ print(f" Sentiment: {'✓' if sentiment else '✗'}\n", file=sys.stderr)
2366
+
2367
+ # Synthesize signal
2368
+ signal = synthesize_signal(
2369
+ ticker=requested_ticker,
2370
+ company_name=company_name,
2371
+ earnings=earnings,
2372
+ fundamentals=fundamentals,
2373
+ analysts=analysts,
2374
+ historical=historical,
2375
+ market_context=market_context, # NEW
2376
+ sector=sector, # NEW
2377
+ earnings_timing=earnings_timing, # NEW
2378
+ momentum=momentum, # NEW
2379
+ sentiment=sentiment, # NEW
2380
+ breaking_news=breaking_news, # NEW v4.0.0
2381
+ geopolitical_risk_warning=geopolitical_risk_warning, # NEW v4.0.0
2382
+ geopolitical_risk_penalty=geopolitical_risk_penalty, # NEW v4.0.0
2383
+ )
2384
+
2385
+ results.append(signal)
2386
+
2387
+ # Output results
2388
+ if args.output == "json":
2389
+ if len(results) == 1:
2390
+ print(format_output_json(results[0]))
2391
+ else:
2392
+ output_data = [asdict(r) for r in results]
2393
+ # Add portfolio summary if in portfolio mode
2394
+ if portfolio_assets:
2395
+ portfolio_summary = generate_portfolio_summary(
2396
+ results, portfolio_assets, portfolio_name, args.period
2397
+ )
2398
+ output_data = {
2399
+ "portfolio": portfolio_name,
2400
+ "assets": output_data,
2401
+ "summary": portfolio_summary,
2402
+ }
2403
+ print(json.dumps(output_data, indent=2))
2404
+ else:
2405
+ for i, signal in enumerate(results):
2406
+ if i > 0:
2407
+ print("\n")
2408
+ print(format_output_text(signal))
2409
+
2410
+ # Print portfolio summary if in portfolio mode
2411
+ if portfolio_assets:
2412
+ print_portfolio_summary(results, portfolio_assets, portfolio_name, args.period)
2413
+
2414
+
2415
+ def generate_portfolio_summary(
2416
+ results: list,
2417
+ portfolio_assets: list[tuple[str, float, float, str]],
2418
+ portfolio_name: str,
2419
+ period: str | None = None,
2420
+ ) -> dict:
2421
+ """Generate portfolio summary data."""
2422
+ # Map results by ticker
2423
+ result_map = {r.ticker: r for r in results}
2424
+
2425
+ # Calculate portfolio metrics
2426
+ total_cost = 0.0
2427
+ total_value = 0.0
2428
+ asset_values = []
2429
+
2430
+ for ticker, quantity, cost_basis, asset_type in portfolio_assets:
2431
+ cost_total = quantity * cost_basis
2432
+ total_cost += cost_total
2433
+
2434
+ # Get current price from yfinance
2435
+ try:
2436
+ stock = yf.Ticker(ticker)
2437
+ current_price = stock.info.get("regularMarketPrice", 0) or 0
2438
+ current_value = quantity * current_price
2439
+ total_value += current_value
2440
+ asset_values.append((ticker, current_value, cost_total, asset_type))
2441
+ except Exception:
2442
+ asset_values.append((ticker, 0, cost_total, asset_type))
2443
+
2444
+ # Calculate period returns if requested
2445
+ period_return = None
2446
+ if period and total_value > 0:
2447
+ period_days = {
2448
+ "daily": 1,
2449
+ "weekly": 7,
2450
+ "monthly": 30,
2451
+ "quarterly": 90,
2452
+ "yearly": 365,
2453
+ }.get(period, 30)
2454
+
2455
+ period_return = calculate_portfolio_period_return(portfolio_assets, period_days)
2456
+
2457
+ # Concentration analysis
2458
+ concentrations = []
2459
+ if total_value > 0:
2460
+ for ticker, value, _, asset_type in asset_values:
2461
+ if value > 0:
2462
+ pct = value / total_value * 100
2463
+ if pct > 30:
2464
+ concentrations.append(f"{ticker}: {pct:.1f}%")
2465
+
2466
+ # Build summary
2467
+ total_pnl = total_value - total_cost
2468
+ total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else 0
2469
+
2470
+ summary = {
2471
+ "portfolio_name": portfolio_name,
2472
+ "total_cost": total_cost,
2473
+ "total_value": total_value,
2474
+ "total_pnl": total_pnl,
2475
+ "total_pnl_pct": total_pnl_pct,
2476
+ "asset_count": len(portfolio_assets),
2477
+ "concentration_warnings": concentrations if concentrations else None,
2478
+ }
2479
+
2480
+ if period_return is not None:
2481
+ summary["period"] = period
2482
+ summary["period_return_pct"] = period_return
2483
+
2484
+ return summary
2485
+
2486
+
2487
+ def calculate_portfolio_period_return(
2488
+ portfolio_assets: list[tuple[str, float, float, str]],
2489
+ period_days: int,
2490
+ ) -> float | None:
2491
+ """Calculate portfolio return over a period using historical prices."""
2492
+ try:
2493
+ total_start_value = 0.0
2494
+ total_current_value = 0.0
2495
+
2496
+ for ticker, quantity, _, _ in portfolio_assets:
2497
+ stock = yf.Ticker(ticker)
2498
+ hist = stock.history(period=f"{period_days + 5}d")
2499
+
2500
+ if hist.empty or len(hist) < 2:
2501
+ continue
2502
+
2503
+ # Get price at period start and now
2504
+ current_price = hist["Close"].iloc[-1]
2505
+ start_price = hist["Close"].iloc[0]
2506
+
2507
+ total_current_value += quantity * current_price
2508
+ total_start_value += quantity * start_price
2509
+
2510
+ if total_start_value > 0:
2511
+ return (total_current_value - total_start_value) / total_start_value * 100
2512
+
2513
+ except Exception:
2514
+ pass
2515
+
2516
+ return None
2517
+
2518
+
2519
+ def print_portfolio_summary(
2520
+ results: list,
2521
+ portfolio_assets: list[tuple[str, float, float, str]],
2522
+ portfolio_name: str,
2523
+ period: str | None = None,
2524
+ ) -> None:
2525
+ """Print portfolio summary in text format."""
2526
+ summary = generate_portfolio_summary(results, portfolio_assets, portfolio_name, period)
2527
+
2528
+ print("\n" + "=" * 77)
2529
+ print(f"PORTFOLIO SUMMARY: {portfolio_name}")
2530
+ print("=" * 77)
2531
+
2532
+ # Value overview
2533
+ total_cost = summary["total_cost"]
2534
+ total_value = summary["total_value"]
2535
+ total_pnl = summary["total_pnl"]
2536
+ total_pnl_pct = summary["total_pnl_pct"]
2537
+
2538
+ print(f"\nTotal Cost: ${total_cost:,.2f}")
2539
+ print(f"Current Value: ${total_value:,.2f}")
2540
+ pnl_sign = "+" if total_pnl >= 0 else ""
2541
+ print(f"Total P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
2542
+
2543
+ # Period return
2544
+ if "period_return_pct" in summary:
2545
+ period_return = summary["period_return_pct"]
2546
+ period_sign = "+" if period_return >= 0 else ""
2547
+ print(f"{summary['period'].capitalize()} Return: {period_sign}{period_return:.1f}%")
2548
+
2549
+ # Concentration warnings
2550
+ if summary.get("concentration_warnings"):
2551
+ print("\n⚠️ CONCENTRATION WARNINGS:")
2552
+ for warning in summary["concentration_warnings"]:
2553
+ print(f" • {warning} (>30% of portfolio)")
2554
+
2555
+ # Recommendation summary
2556
+ recommendations = {"BUY": 0, "HOLD": 0, "SELL": 0}
2557
+ for r in results:
2558
+ recommendations[r.recommendation] = recommendations.get(r.recommendation, 0) + 1
2559
+
2560
+ print(f"\nRECOMMENDATIONS: {recommendations['BUY']} BUY | {recommendations['HOLD']} HOLD | {recommendations['SELL']} SELL")
2561
+ print("=" * 77)
2562
+
2563
+
2564
+ if __name__ == "__main__":
2565
+ main()