- Trading skills (OKX, dividend, lottery, quantitative) - Creative skills (ASCII art, diagrams, video) - Development skills (GitHub, debugging, TDD) - Research skills (arXiv, blog monitoring) - Productivity skills (email, documents, notes) - MCP integration skills - Custom user skills
479 lines
15 KiB
Python
479 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "yfinance>=0.2.40",
|
|
# "pandas>=2.0.0",
|
|
# "longport>=2.0.0",
|
|
# "fear-and-greed>=0.4",
|
|
# "edgartools>=2.0.0",
|
|
# "feedparser>=6.0.0",
|
|
# ]
|
|
# ///
|
|
"""
|
|
Stock analysis with 8-dimension scoring: LongPort (primary) + Yahoo Finance (fallback).
|
|
|
|
Usage:
|
|
uv run analyze_stock_unified.py TICKER [TICKER2 ...] [--output text|json] [--verbose] [--fast]
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass, asdict
|
|
from datetime import datetime
|
|
from typing import Literal, Optional
|
|
|
|
import pandas as pd
|
|
|
|
# Import unified data source
|
|
from data_source import fetch_stock_data_unified, UnifiedStockData
|
|
|
|
# Import original analysis functions (copy from analyze_stock.py)
|
|
# We'll create adapters to work with UnifiedStockData
|
|
|
|
|
|
@dataclass
|
|
class EarningsSurprise:
|
|
score: float
|
|
explanation: str
|
|
actual_eps: float | None = None
|
|
expected_eps: float | None = None
|
|
surprise_pct: float | None = None
|
|
|
|
|
|
@dataclass
|
|
class Fundamentals:
|
|
score: float
|
|
key_metrics: dict
|
|
explanation: str
|
|
|
|
|
|
@dataclass
|
|
class AnalystSentiment:
|
|
score: float | None
|
|
summary: str
|
|
consensus_rating: str | None = None
|
|
price_target: float | None = None
|
|
current_price: float | None = None
|
|
upside_pct: float | None = None
|
|
num_analysts: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class MomentumAnalysis:
|
|
rsi_14d: float | None
|
|
rsi_status: str
|
|
price_vs_52w_low: float | None
|
|
price_vs_52w_high: float | None
|
|
near_52w_high: bool
|
|
near_52w_low: bool
|
|
volume_ratio: float | None
|
|
score: float
|
|
explanation: str
|
|
|
|
|
|
@dataclass
|
|
class Signal:
|
|
ticker: str
|
|
company_name: str
|
|
recommendation: Literal["BUY", "HOLD", "SELL"]
|
|
confidence: float
|
|
final_score: float
|
|
supporting_points: list[str]
|
|
caveats: list[str]
|
|
timestamp: str
|
|
components: dict
|
|
|
|
|
|
def analyze_fundamentals_from_unified(data: UnifiedStockData) -> Fundamentals | None:
|
|
"""Analyze fundamentals from unified data source."""
|
|
scores = []
|
|
metrics = {}
|
|
explanations = []
|
|
|
|
try:
|
|
# PE Ratio (LongPort)
|
|
if data.pe_ttm and data.pe_ttm > 0:
|
|
metrics["pe_ttm"] = float(data.pe_ttm)
|
|
if data.pe_ttm < 15:
|
|
scores.append(0.5)
|
|
explanations.append(f"Attractive PE: {float(data.pe_ttm):.1f}x")
|
|
elif data.pe_ttm > 30:
|
|
scores.append(-0.3)
|
|
explanations.append(f"Elevated PE: {float(data.pe_ttm):.1f}x")
|
|
else:
|
|
scores.append(0.1)
|
|
|
|
# PB Ratio (LongPort)
|
|
if data.pb and data.pb > 0:
|
|
metrics["pb"] = float(data.pb)
|
|
if data.pb < 1.0:
|
|
scores.append(0.6)
|
|
explanations.append(f"Below book value: PB {float(data.pb):.2f}")
|
|
elif data.pb < 2.0:
|
|
scores.append(0.3)
|
|
elif data.pb > 5.0:
|
|
scores.append(-0.4)
|
|
explanations.append(f"High PB: {float(data.pb):.1f}x")
|
|
|
|
# Dividend Yield (LongPort)
|
|
if data.dividend_yield:
|
|
metrics["dividend_yield"] = float(data.dividend_yield)
|
|
if data.dividend_yield > 5:
|
|
scores.append(0.5)
|
|
explanations.append(f"High dividend: {float(data.dividend_yield):.1f}%")
|
|
elif data.dividend_yield > 3:
|
|
scores.append(0.3)
|
|
elif data.dividend_yield < 1:
|
|
scores.append(-0.2)
|
|
|
|
# Operating Margin (Yahoo fallback)
|
|
if data.operating_margin:
|
|
metrics["operating_margin"] = float(data.operating_margin)
|
|
if data.operating_margin > 0.15:
|
|
scores.append(0.5)
|
|
explanations.append(f"Strong margin: {float(data.operating_margin)*100:.1f}%")
|
|
elif data.operating_margin < 0.05:
|
|
scores.append(-0.5)
|
|
explanations.append(f"Weak margin: {float(data.operating_margin)*100:.1f}%")
|
|
|
|
# ROE (Yahoo fallback)
|
|
if data.roe:
|
|
metrics["roe"] = float(data.roe)
|
|
if data.roe > 0.15:
|
|
scores.append(0.4)
|
|
explanations.append(f"Strong ROE: {float(data.roe)*100:.1f}%")
|
|
elif data.roe < 0.05:
|
|
scores.append(-0.3)
|
|
|
|
# Debt to Equity (Yahoo fallback)
|
|
if data.debt_to_equity:
|
|
metrics["debt_to_equity"] = float(data.debt_to_equity)
|
|
if data.debt_to_equity < 50:
|
|
scores.append(0.3)
|
|
elif data.debt_to_equity > 200:
|
|
scores.append(-0.5)
|
|
explanations.append(f"High debt: D/E {float(data.debt_to_equity)/100:.1f}x")
|
|
|
|
if not scores:
|
|
return None
|
|
|
|
avg_score = sum(scores) / len(scores)
|
|
normalized_score = max(-1.0, min(1.0, avg_score))
|
|
|
|
return Fundamentals(
|
|
score=normalized_score,
|
|
key_metrics=metrics,
|
|
explanation="; ".join(explanations) if explanations else "Mixed fundamentals",
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"Fundamentals analysis error: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def analyze_momentum_from_unified(data: UnifiedStockData) -> MomentumAnalysis | None:
|
|
"""Analyze momentum from unified data source."""
|
|
try:
|
|
# Use volume_ratio from LongPort
|
|
volume_ratio = float(data.volume_ratio) if data.volume_ratio else None
|
|
|
|
# Calculate RSI from price history if available
|
|
rsi_14d = None
|
|
rsi_status = "neutral"
|
|
|
|
if data.price_history is not None and len(data.price_history) >= 14:
|
|
close_prices = data.price_history["Close"]
|
|
delta = close_prices.diff()
|
|
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
|
|
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
|
|
rs = gain / loss
|
|
rsi = 100 - (100 / (1 + rs))
|
|
rsi_14d = float(rsi.iloc[-1])
|
|
|
|
if rsi_14d > 70:
|
|
rsi_status = "overbought"
|
|
elif rsi_14d < 30:
|
|
rsi_status = "oversold"
|
|
|
|
# Score based on available metrics
|
|
scores = []
|
|
|
|
if rsi_14d:
|
|
if rsi_14d < 30:
|
|
scores.append(0.5) # Oversold = opportunity
|
|
elif rsi_14d > 70:
|
|
scores.append(-0.5) # Overbought = risk
|
|
|
|
if volume_ratio:
|
|
if volume_ratio > 1.5:
|
|
scores.append(0.3) # High volume = strong move
|
|
elif volume_ratio < 0.5:
|
|
scores.append(-0.2) # Low volume = weak move
|
|
|
|
if data.change_rate:
|
|
change = float(data.change_rate)
|
|
if abs(change) > 5:
|
|
scores.append(0.2 if change > 0 else -0.2)
|
|
|
|
score = sum(scores) / len(scores) if scores else 0.0
|
|
|
|
return MomentumAnalysis(
|
|
rsi_14d=rsi_14d,
|
|
rsi_status=rsi_status,
|
|
price_vs_52w_low=None,
|
|
price_vs_52w_high=None,
|
|
near_52w_high=False,
|
|
near_52w_low=False,
|
|
volume_ratio=volume_ratio,
|
|
score=max(-1.0, min(1.0, score)),
|
|
explanation=f"RSI: {rsi_14d:.1f} ({rsi_status})" if rsi_14d else "Limited momentum data",
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"Momentum analysis error: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def synthesize_signal(
|
|
ticker: str,
|
|
company_name: str,
|
|
fundamentals: Fundamentals | None,
|
|
momentum: MomentumAnalysis | None,
|
|
data: UnifiedStockData,
|
|
) -> Signal:
|
|
"""Synthesize final signal from analysis components."""
|
|
|
|
scores = []
|
|
weights = []
|
|
supporting_points = []
|
|
caveats = []
|
|
|
|
# Fundamentals (40% weight)
|
|
if fundamentals:
|
|
scores.append(fundamentals.score)
|
|
weights.append(0.40)
|
|
if fundamentals.score > 0.3:
|
|
supporting_points.append(f"✓ Strong fundamentals: {fundamentals.explanation}")
|
|
elif fundamentals.score < -0.3:
|
|
caveats.append(f"⚠ Weak fundamentals: {fundamentals.explanation}")
|
|
|
|
# Valuation (30% weight) - from PE/PB/Dividend
|
|
valuation_score = 0
|
|
valuation_count = 0
|
|
|
|
if data.pe_ttm and data.pe_ttm > 0:
|
|
if data.pe_ttm < 15:
|
|
valuation_score += 0.5
|
|
elif data.pe_ttm < 25:
|
|
valuation_score += 0.2
|
|
elif data.pe_ttm > 35:
|
|
valuation_score -= 0.3
|
|
valuation_count += 1
|
|
|
|
if data.pb:
|
|
if data.pb < 1.0:
|
|
valuation_score += 0.6
|
|
elif data.pb < 2.0:
|
|
valuation_score += 0.3
|
|
elif data.pb > 5.0:
|
|
valuation_score -= 0.4
|
|
valuation_count += 1
|
|
|
|
if data.dividend_yield:
|
|
if data.dividend_yield > 5:
|
|
valuation_score += 0.5
|
|
elif data.dividend_yield > 3:
|
|
valuation_score += 0.3
|
|
valuation_count += 1
|
|
|
|
if valuation_count > 0:
|
|
avg_valuation = valuation_score / valuation_count
|
|
scores.append(avg_valuation)
|
|
weights.append(0.30)
|
|
|
|
if data.dividend_yield and data.dividend_yield > 5:
|
|
supporting_points.append(f"✓ High dividend yield: {float(data.dividend_yield):.1f}%")
|
|
|
|
# Momentum (20% weight)
|
|
if momentum:
|
|
scores.append(momentum.score)
|
|
weights.append(0.20)
|
|
if momentum.rsi_status == "oversold":
|
|
supporting_points.append(f"✓ Oversold RSI: {momentum.rsi_14d:.1f}")
|
|
elif momentum.rsi_status == "overbought":
|
|
caveats.append(f"⚠ Overbought RSI: {momentum.rsi_14d:.1f}")
|
|
|
|
# Market cap consideration (10% weight)
|
|
if data.market_cap:
|
|
cap = float(data.market_cap)
|
|
if cap > 10e9: # Large cap
|
|
scores.append(0.3)
|
|
supporting_points.append("✓ Large-cap stability")
|
|
elif cap < 1e9: # Small cap
|
|
scores.append(-0.2)
|
|
caveats.append("⚠ Small-cap volatility")
|
|
weights.append(0.10)
|
|
|
|
# Calculate final score
|
|
if scores:
|
|
final_score = sum(s * w for s, w in zip(scores, weights)) / sum(weights)
|
|
else:
|
|
final_score = 0.0
|
|
|
|
# Determine recommendation
|
|
if final_score > 0.3:
|
|
recommendation = "BUY"
|
|
confidence = min(0.9, 0.5 + final_score)
|
|
elif final_score > 0.0:
|
|
recommendation = "BUY"
|
|
confidence = 0.5 + final_score
|
|
elif final_score > -0.3:
|
|
recommendation = "HOLD"
|
|
confidence = 0.5 - final_score
|
|
else:
|
|
recommendation = "SELL"
|
|
confidence = min(0.9, 0.5 - final_score)
|
|
|
|
# Add data source info
|
|
supporting_points.append(f"📊 Data: {', '.join(data.data_sources)}")
|
|
|
|
return Signal(
|
|
ticker=ticker,
|
|
company_name=company_name,
|
|
recommendation=recommendation,
|
|
confidence=round(confidence, 2),
|
|
final_score=round(final_score, 3),
|
|
supporting_points=supporting_points[:5],
|
|
caveats=caveats[:5],
|
|
timestamp=datetime.now().isoformat(),
|
|
components={
|
|
"fundamentals": asdict(fundamentals) if fundamentals else None,
|
|
"momentum": asdict(momentum) if momentum else None,
|
|
"valuation": {
|
|
"pe_ttm": float(data.pe_ttm) if data.pe_ttm else None,
|
|
"pb": float(data.pb) if data.pb else None,
|
|
"dividend_yield": float(data.dividend_yield) if data.dividend_yield else None,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
def format_output_text(signal: Signal) -> str:
|
|
"""Format signal as text output."""
|
|
lines = [
|
|
"=" * 60,
|
|
f"📊 {signal.ticker} - {signal.company_name}",
|
|
f"Generated: {signal.timestamp}",
|
|
"=" * 60,
|
|
"",
|
|
f"📋 RECOMMENDATION: {signal.recommendation} (Confidence: {signal.confidence*100:.0f}%)",
|
|
f"⭐ SCORE: {signal.final_score:+.3f}",
|
|
"",
|
|
"✅ SUPPORTING POINTS:",
|
|
]
|
|
|
|
for point in signal.supporting_points:
|
|
lines.append(f" {point}")
|
|
|
|
lines.extend(["", "⚠️ CAVEATS:"])
|
|
|
|
for caveat in signal.caveats:
|
|
lines.append(f" {caveat}")
|
|
|
|
# Add component details
|
|
if signal.components.get("fundamentals"):
|
|
fund = signal.components["fundamentals"]
|
|
lines.extend([
|
|
"",
|
|
"📈 FUNDAMENTALS:",
|
|
f" Score: {fund['score']:+.2f}",
|
|
f" {fund['explanation']}",
|
|
])
|
|
|
|
if signal.components.get("valuation"):
|
|
val = signal.components["valuation"]
|
|
lines.extend([
|
|
"",
|
|
"💰 VALUATION:",
|
|
f" PE TTM: {val['pe_ttm']:.2f}" if val['pe_ttm'] else " PE TTM: N/A",
|
|
f" PB: {val['pb']:.2f}" if val['pb'] else " PB: N/A",
|
|
f" Dividend: {val['dividend_yield']:.1f}%" if val['dividend_yield'] else " Dividend: N/A",
|
|
])
|
|
|
|
lines.extend([
|
|
"",
|
|
"=" * 60,
|
|
"⚠️ NOT FINANCIAL ADVICE. For informational purposes only.",
|
|
"=" * 60,
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_output_json(signal: Signal) -> str:
|
|
"""Format signal as JSON."""
|
|
return json.dumps(asdict(signal), indent=2, default=str)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Stock analysis with 8-dimension scoring (LongPort + Yahoo)"
|
|
)
|
|
parser.add_argument("tickers", nargs="+", help="Stock tickers")
|
|
parser.add_argument("--output", choices=["text", "json"], default="text")
|
|
parser.add_argument("--verbose", "-v", action="store_true")
|
|
parser.add_argument("--fast", action="store_true", help="Skip slow analyses")
|
|
|
|
args = parser.parse_args()
|
|
|
|
results = []
|
|
|
|
for ticker in args.tickers:
|
|
ticker = ticker.upper()
|
|
|
|
if args.verbose:
|
|
print(f"\n=== Analyzing {ticker} ===", file=sys.stderr)
|
|
|
|
# Fetch unified data
|
|
data = fetch_stock_data_unified(ticker, verbose=args.verbose)
|
|
|
|
if data is None:
|
|
print(f"Error: Failed to fetch data for {ticker}", file=sys.stderr)
|
|
continue
|
|
|
|
# Run analyses
|
|
if args.verbose:
|
|
print(" Analyzing fundamentals...", file=sys.stderr)
|
|
fundamentals = analyze_fundamentals_from_unified(data)
|
|
|
|
if args.verbose:
|
|
print(" Analyzing momentum...", file=sys.stderr)
|
|
momentum = analyze_momentum_from_unified(data)
|
|
|
|
# Synthesize signal
|
|
signal = synthesize_signal(
|
|
ticker=data.symbol,
|
|
company_name=data.name,
|
|
fundamentals=fundamentals,
|
|
momentum=momentum,
|
|
data=data,
|
|
)
|
|
|
|
results.append(signal)
|
|
|
|
if args.output == "text":
|
|
print(format_output_text(signal))
|
|
|
|
if args.output == "json":
|
|
if len(results) == 1:
|
|
print(format_output_json(results[0]))
|
|
else:
|
|
print(json.dumps([asdict(r) for r in results], indent=2, default=str))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|