Files
Hermes-Skills/openclaw-imports/stock-analysis/scripts/analyze_stock_unified.py
T
Hermes Skills Manager 6770bc9b9d Initial commit: Hermes Agent skills collection
- 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
2026-07-05 02:31:15 -04:00

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()