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
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user