Files
2026-07-13 12:36:27 +08:00

119 lines
3.8 KiB
Python

from __future__ import annotations
import math
from datetime import datetime, timedelta
from typing import Any
import pandas as pd
import yfinance as yf
def _history(ticker: str, days: int = 260) -> pd.DataFrame:
end = datetime.utcnow()
start = end - timedelta(days=days * 2)
df = yf.Ticker(ticker).history(start=start.strftime("%Y-%m-%d"), end=end.strftime("%Y-%m-%d"))
return df.dropna()
def _annualized_vol(close: pd.Series, window: int = 20) -> float | None:
if len(close) < window + 1:
return None
ret = close.pct_change().dropna()
v = ret.tail(window).std() * math.sqrt(252)
return float(v * 100)
def _ma_status(close: pd.Series) -> str:
if close.empty:
return "N/A"
c = close.iloc[-1]
ma20 = close.tail(20).mean() if len(close) >= 20 else None
ma50 = close.tail(50).mean() if len(close) >= 50 else None
ma200 = close.tail(200).mean() if len(close) >= 200 else None
bits = []
if ma20:
bits.append(f"Price {'>' if c > ma20 else '<='} MA20")
if ma50:
bits.append(f"Price {'>' if c > ma50 else '<='} MA50")
if ma200:
bits.append(f"Price {'>' if c > ma200 else '<='} MA200")
return ", ".join(bits) if bits else "N/A"
def _macd(close: pd.Series) -> tuple[float | None, float | None]:
if len(close) < 35:
return None, None
ema12 = close.ewm(span=12, adjust=False).mean()
ema26 = close.ewm(span=26, adjust=False).mean()
line = ema12 - ema26
signal = line.ewm(span=9, adjust=False).mean()
return float(line.iloc[-1]), float(signal.iloc[-1])
def _kdj(df: pd.DataFrame, n: int = 9) -> tuple[float | None, float | None, float | None]:
if len(df) < n:
return None, None, None
low = df["Low"].rolling(n).min()
high = df["High"].rolling(n).max()
rsv = (df["Close"] - low) / (high - low) * 100
k = rsv.ewm(alpha=1 / 3, adjust=False).mean()
d = k.ewm(alpha=1 / 3, adjust=False).mean()
j = 3 * k - 2 * d
return float(k.iloc[-1]), float(d.iloc[-1]), float(j.iloc[-1])
def _iv_proxy(ticker: str) -> float | None:
# Best-effort: derive from nearest option chain if available.
try:
t = yf.Ticker(ticker)
expirations = t.options
if not expirations:
return None
chain = t.option_chain(expirations[0])
calls = chain.calls
if calls.empty or "impliedVolatility" not in calls:
return None
iv = calls["impliedVolatility"].dropna()
if iv.empty:
return None
return float(iv.median() * 100)
except Exception:
return None
def get_stock_overview_metrics(ticker: str) -> dict[str, Any]:
try:
df = _history(ticker)
if df.empty:
return {"error": f"{ticker} 暂无价格数据。"}
close = df["Close"]
vol = df["Volume"]
hv20 = _annualized_vol(close, 20)
hv30 = _annualized_vol(close, 30)
iv = _iv_proxy(ticker)
macd_line, macd_sig = _macd(close)
k, d, j = _kdj(df)
vol_today = float(vol.iloc[-1]) if not vol.empty else None
vol_prev = float(vol.iloc[-2]) if len(vol) > 1 else None
vol_chg = (
((vol_today - vol_prev) / vol_prev * 100) if vol_prev and vol_today is not None else None
)
return {
"ticker": ticker,
"hv20": hv20,
"hv30": hv30,
"iv": iv,
"volume_today": vol_today,
"volume_prev": vol_prev,
"volume_change_pct": vol_chg,
"ma_status": _ma_status(close),
"macd_line": macd_line,
"macd_signal": macd_sig,
"kdj_k": k,
"kdj_d": d,
"kdj_j": j,
"error": "",
}
except Exception as exc:
return {"error": str(exc)}