AkShare股票分析
第三方 via ClawHub使用akshare数据,从六个维度分析香港(5位数)和A股(6位数)股票,生成带有置信度分数的买入/持有/卖出信号。
coxlong v1.1.2
---
name: akshare-analysis
description: Stock analysis for Chinese companies: HK stocks (港股) and A-shares (A股). Use when user wants to analyze Chinese stocks by company name (腾讯, 阿里巴巴, 茅台, 比亚迪, 美团, 小米, 宁德时代, 京东, 拼多多, 网易, 百度, 快手) or ticker code (00700, 600519, etc.). Trigger for any request involving 港股, A股, 中概股, or investment/trading analysis of Chinese companies.
version: 1.0.0
commands:
- /akshare - Analyze a HK or A-share stock (e.g., /akshare 00700)
metadata: {"clawdbot":{"emoji":"📊","requires":{"bins":["uv"],"env":[]},"install":[]}}
---
# AKShare Stock Analysis
Analyze Hong Kong and A-share stocks with 6-dimension scoring using akshare as the data source. Works reliably on servers where Yahoo Finance is rate-limited.
## Ticker Format
| Market | Format | Example |
|--------|--------|---------|
| Hong Kong | 5-digit | `00700` (Tencent), `09988` (Alibaba) |
| A-Share | 6-digit | `600519` (Moutai), `000858` (Wuliangye) |
## Quick Commands
```bash
# Single stock analysis
uv run {baseDir}/scripts/analyze.py 00700
# Multiple stocks
uv run {baseDir}/scripts/analyze.py 00700 09988 600519
# JSON output
uv run {baseDir}/scripts/analyze.py 00700 --output json
# Verbose (show per-dimension scores)
uv run {baseDir}/scripts/analyze.py 00700 --verbose
# Generate HTML report
uv run {baseDir}/scripts/analyze.py 00700 --report
uv run {baseDir}/scripts/render_report.py /data/stock-reports/00700/20260329_030822
# Search stock news (supports multiple keywords)
uv run {baseDir}/scripts/news.py 00700 腾讯 港股
uv run {baseDir}/scripts/news.py 600519 茅台 A股 --json
```
## HTML Report Generation
Four-step workflow for generating comprehensive reports:
**Step 1: Analyze and generate report directory**
```bash
uv run {baseDir}/scripts/analyze.py 00700 --report
# Creates: /data/stock-reports/00700/20260329_030822/
# ├── chart_data.json (K线+MA+布林带+RSI+MACD+成交量)
# └── data.json (structured analysis data)
```
**Step 2: Search news**
```bash
# Search by ticker, name, and market keywords
uv run {baseDir}/scripts/news.py 00700 腾讯 港股
uv run {baseDir}/scripts/news.py 600519 茅台 A股
# For HK stocks, use: ticker + name + "港股"
# For A-shares, use: ticker + name + "A股"
```
**Step 3: Generate AI analysis (manual)**
Based on the news from Step 2 and technical data from Step 1, create `ai_analysis.md` in the report directory:
```markdown
---
generated_at: 2026-03-29T20:30:00
---
## 市场情绪
... (summarize key news sentiment)
## 技术面解读
... (combine news + technical indicators from data.json)
## 关键事件影响
... (major news events and their impact)
## 综合展望
... (overall outlook combining fundamentals + technicals + news)
## 风险提示
... (key risks identified from news and technicals)
```
**Step 4: Render HTML report**
```bash
uv run {baseDir}/scripts/render_report.py /data/stock-reports/00700/20260329_030822
# Generates: index.html (self-contained with embedded charts + AI analysis)
```
The HTML report includes: signal badge, dimension scores with visual bars, financial metrics, analyst forecasts (HK only), technical charts, and optional AI analysis section.
## Analysis Dimensions
| Dimension | Weight | HK | A-Share | Data Source |
|-----------|--------|:--:|:-------:|-------------|
| Fundamentals | 25% | ✅ | ✅ | ROE, net margin, profit growth |
| Analyst | 20% | ✅ | ❌ | Target prices, ratings |
| Momentum | 20% | ✅ | ✅ | RSI(14), 52-week position |
| Valuation | 15% | ✅ | ⚠️ | P/E, P/B |
| Trend | 10% | ✅ | ✅ | MA5/MA20 crossover, 20d change |
| Volume | 10% | ✅ | ✅ | 5d vs 60d average volume |
## Signal Logic
- **BUY**: weighted score > 0.33
- **SELL**: weighted score < -0.33
- **HOLD**: otherwise
- Confidence = abs(score) × 100%
## Limitations
- A-shares: no analyst forecast data (akshare doesn't provide per-stock analyst ratings for A-shares)
- A-shares: P/E and P/B may be unavailable depending on market hours
- Data is delayed (not real-time intraday)
## Disclaimer
⚠️ NOT FINANCIAL ADVICE. For informational purposes only.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "akshare>=1.0.0",
# "pandas>=2.0.0",
# "mplfinance>=0.12.0",
# ]
# ///
"""
Stock analysis for HK and A-share markets using akshare data.
Usage:
uv run analyze.py TICKER [TICKER2 ...] [--output text|json] [--verbose]
Ticker format:
5-digit → HK stock (e.g., 00700 for Tencent)
6-digit → A-share (e.g., 600519 for Moutai)
"""
import argparse
import json
import os
import sys
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
from typing import Literal
import akshare as ak
import pandas as pd
# Add scripts dir to path for sibling imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from indicators import (
calc_rsi, calc_kdj, calc_adx, calc_obv, calc_mfi, calc_atr, calc_cci,
calc_bb_bandwidth, calc_hist_volatility, calc_pivot_points, detect_candlestick,
)
from horizons import (
HorizonSignal, MultiHorizonSignal,
analyze_short_term, analyze_medium_term, analyze_long_term,
)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class StockData:
ticker: str
market: Literal["hk", "a"]
name: str
current_price: float
price_history: pd.DataFrame # columns: Date, Open, Close, High, Low, Volume
financials: dict # normalized key metrics
analyst_forecasts: list[dict] | None # HK only
# ---------------------------------------------------------------------------
# Market detection
# ---------------------------------------------------------------------------
def detect_market(ticker: str) -> Literal["hk", "a"]:
t = ticker.strip()
if len(t) == 5 and t.isdigit():
return "hk"
if len(t) == 6 and t.isdigit():
return "a"
raise ValueError(f"Unknown ticker format: {ticker}. Use 5-digit for HK (00700) or 6-digit for A-share (600519)")
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
def fetch_hk_data(ticker: str, verbose: bool = False) -> StockData | None:
try:
if verbose:
print(f"Fetching HK data for {ticker}...", file=sys.stderr)
end = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=730)).strftime("%Y%m%d")
hist = ak.stock_hk_hist(symbol=ticker, period="daily", start_date=start, end_date=end, adjust="qfq")
if hist.empty:
return None
hist = hist.rename(columns={
"日期": "Date", "开盘": "Open", "收盘": "Close",
"最高": "High", "最低": "Low", "成交量": "Volume", "换手率": "Turnover",
})
hist["Date"] = pd.to_datetime(hist["Date"])
hist = hist.set_index("Date")
current_price = float(hist["Close"].iloc[-1])
# Financial indicators via fast valuation API (per-ticker, no full-market crawl)
financials = {}
try:
for indicator, key in [("市盈率(TTM)", "pe"), ("市净率", "pb"), ("股息率(TTM)", "dividend_yield")]:
df = ak.stock_hk_valuation_baidu(symbol=ticker, indicator=indicator, period="近一年")
if df is not None and not df.empty and "value" in df.columns:
financials[key] = _safe_float(df["value"].iloc[-1])
except Exception as e:
if verbose:
print(f" Valuation data failed: {e}", file=sys.stderr)
# ROE, net margin from stock_financial_hk_analysis_indicator_em (per-ticker, faster than financial_indicator_em)
try:
fin = ak.stock_financial_hk_analysis_indicator_em(symbol=ticker)
if not fin.empty:
row = fin.iloc[0]
financials.update({
"roe": _safe_float(row.get("ROE_AVG")),
"net_margin": _safe_float(row.get("NET_PROFIT_RATIO")),
"profit_growth": _safe_float(row.get("HOLDER_PROFIT_YOY")),
"revenue_growth": _safe_float(row.get("OPERATE_INCOME_YOY")),
})
except Exception as e:
if verbose:
print(f" Financial analysis indicators failed: {e}", file=sys.stderr)
# Analyst forecasts (HK only)
forecasts = None
try:
fc = ak.stock_hk_profit_forecast_et(symbol=ticker)
if fc is not None and not fc.empty:
forecasts = []
for _, r in fc.head(10).iterrows():
forecasts.append({
"broker": str(r.get("证券商", "")),
"rating": str(r.get("评级", "")),
"target_price": _safe_float(r.get("目标价")),
"date": str(r.get("更新日期", "")),
})
except Exception as e:
if verbose:
print(f" Analyst forecasts failed: {e}", file=sys.stderr)
# Get name from individual info (fast, per-ticker)
name = ticker
try:
info = ak.stock_individual_basic_info_hk_xq(symbol=ticker)
if info is not None and not info.empty:
info_dict = dict(zip(info["item"], info["value"]))
raw_name = str(info_dict.get("comcnname", ticker))
# Clean up common suffixes
for suffix in ["有限公司", "Limited", "Corporation", "Group", "Holdings", "Investment"]:
raw_name = raw_name.replace(suffix, "").replace(suffix.lower(), "")
name = raw_name.strip()
except Exception:
pass
return StockData(
ticker=ticker, market="hk", name=name,
current_price=current_price, price_history=hist,
financials=financials, analyst_forecasts=forecasts,
)
except Exception as e:
if verbose:
print(f"Failed to fetch HK data for {ticker}: {e}", file=sys.stderr)
return None
def fetch_a_data(ticker: str, verbose: bool = False) -> StockData | None:
try:
if verbose:
print(f"Fetching A-share data for {ticker}...", file=sys.stderr)
end = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=730)).strftime("%Y%m%d")
hist = ak.stock_zh_a_hist(symbol=ticker, period="daily", start_date=start, end_date=end, adjust="qfq")
if hist.empty:
return None
hist = hist.rename(columns={
"日期": "Date", "开盘": "Open", "收盘": "Close",
"最高": "High", "最低": "Low", "成交量": "Volume", "换手率": "Turnover",
})
hist["Date"] = pd.to_datetime(hist["Date"])
hist = hist.set_index("Date")
current_price = float(hist["Close"].iloc[-1])
# Financial indicators from individual info (fast, per-ticker)
financials = {}
name = ticker
try:
info = ak.stock_individual_info_em(symbol=ticker)
if not info.empty:
info_dict = dict(zip(info["item"], info["value"]))
name = str(info_dict.get("股票简称", ticker))
# P/E and P/B not in individual_info_em, get from realtime quote
except Exception as e:
if verbose:
print(f" Individual info failed: {e}", file=sys.stderr)
# Detailed financials (fast, per-ticker, ~2s)
try:
fin = ak.stock_financial_abstract_ths(symbol=ticker, indicator="按年度")
if not fin.empty:
row = fin.iloc[-1] # most recent year
def parse_pct(v) -> float | None:
if v is None or v is False or (isinstance(v, float) and pd.isna(v)):
return None
s = str(v).replace("%", "").strip()
try:
return float(s)
except ValueError:
return None
financials.update({
"net_margin": parse_pct(row.get("销售净利率")),
"gross_margin": parse_pct(row.get("销售毛利率")),
"roe": parse_pct(row.get("净资产收益率")),
"profit_growth": parse_pct(row.get("净利润同比增长率")),
"debt_ratio": parse_pct(row.get("资产负债率")),
})
except Exception as e:
if verbose:
print(f" Financial abstract failed: {e}", file=sys.stderr)
return StockData(
ticker=ticker, market="a", name=name,
current_price=current_price, price_history=hist,
financials=financials, analyst_forecasts=None,
)
except Exception as e:
if verbose:
print(f"Failed to fetch A-share data for {ticker}: {e}", file=sys.stderr)
return None
def fetch_data(ticker: str, verbose: bool = False) -> StockData | None:
market = detect_market(ticker)
if market == "hk":
return fetch_hk_data(ticker, verbose)
return fetch_a_data(ticker, verbose)
def _safe_float(val) -> float | None:
if val is None or (isinstance(val, float) and pd.isna(val)):
return None
try:
return float(val)
except (ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
_REC_LABEL = {"BUY": "买入 ▲", "HOLD": "持有 —", "SELL": "卖出 ▼"}
def format_text(signal: MultiHorizonSignal) -> str:
market_label = "HK" if signal.market == "hk" else "A-Share"
lines = [
"=" * 77,
f"STOCK ANALYSIS: {signal.ticker} ({signal.name}) [{market_label}]",
f"Generated: {signal.timestamp}",
"=" * 77,
"",
]
for hs in [signal.short, signal.medium, signal.long]:
horizon_cn = {"short": "短期 (≤2周)", "medium": "中期 (2周~6月)", "long": "长期 (6月+)"}[hs.horizon]
label = _REC_LABEL[hs.recommendation]
lines.append(f" {horizon_cn:16s} {label} (置信度 {hs.confidence:.0f}%)")
for p in hs.points:
lines.append(f" · {p}")
lines.append("")
lines.append("=" * 77)
lines.append("DISCLAIMER: NOT FINANCIAL ADVICE. For informational purposes only.")
lines.append("=" * 77)
return "\n".join(lines)
def format_json(signal: MultiHorizonSignal) -> str:
d = asdict(signal)
d["disclaimer"] = "NOT FINANCIAL ADVICE. For informational purposes only."
return json.dumps(d, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def analyze_one(ticker: str, verbose: bool = False) -> tuple[MultiHorizonSignal, StockData] | None:
data = fetch_data(ticker, verbose=verbose)
if data is None:
print(f"Error: Failed to fetch data for {ticker}", file=sys.stderr)
return None
short = analyze_short_term(data.current_price, data.price_history)
medium = analyze_medium_term(data.current_price, data.price_history)
long = analyze_long_term(data.current_price, data.price_history, data.financials, data.analyst_forecasts)
if verbose:
for hs in [short, medium, long]:
print(f" {hs.horizon}: {hs.recommendation} ({hs.score:+.2f}) — {'; '.join(hs.points)}", file=sys.stderr)
signal = MultiHorizonSignal(
ticker=data.ticker, name=data.name, market=data.market,
short=short, medium=medium, long=long,
timestamp=datetime.now().isoformat(),
)
return signal, data
# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------
def generate_charts(data: StockData, report_dir: str, verbose: bool = False) -> dict:
"""Generate chart_data.json for lightweight-charts rendering."""
hist = data.price_history.copy()
for col in ["Open", "High", "Low", "Close", "Volume"]:
if col not in hist.columns:
return {}
# Use last 180 trading days
hist = hist.iloc[-180:]
close = hist["Close"]
def _series(s):
return [{"time": t.strftime("%Y-%m-%d"), "value": round(float(v), 4)}
for t, v in s.items() if pd.notna(v)]
def _ohlcv():
rows = []
for t, row in hist.iterrows():
rows.append({
"time": t.strftime("%Y-%m-%d"),
"open": round(float(row["Open"]), 4),
"high": round(float(row["High"]), 4),
"low": round(float(row["Low"]), 4),
"close": round(float(row["Close"]), 4),
"volume": int(row["Volume"]),
})
return rows
# Indicators
ma20 = close.rolling(20).mean()
std20 = close.rolling(20).std()
ema50 = close.ewm(span=50, adjust=False).mean()
ema200 = data.price_history["Close"].ewm(span=200, adjust=False).mean().iloc[-180:]
delta = close.diff()
gains = delta.where(delta > 0, 0).rolling(14).mean()
losses = (-delta.where(delta < 0, 0)).rolling(14).mean()
rsi = 100 - (100 / (1 + gains / losses))
ema12 = close.ewm(span=12, adjust=False).mean()
ema26 = close.ewm(span=26, adjust=False).mean()
macd = ema12 - ema26
sig = macd.ewm(span=9, adjust=False).mean()
macd_hist = macd - sig
low_9 = hist["Low"].rolling(9).min()
high_9 = hist["High"].rolling(9).max()
rsv = (close - low_9) / (high_9 - low_9 + 1e-9) * 100
kdj_k = rsv.ewm(com=2, adjust=False).mean()
kdj_d = kdj_k.ewm(com=2, adjust=False).mean()
kdj_j = 3 * kdj_k - 2 * kdj_d
chart_data = {
"ohlcv": _ohlcv(),
"ma20": _series(ma20),
"bb_upper": _series(ma20 + 2 * std20),
"bb_lower": _series(ma20 - 2 * std20),
"ema50": _series(ema50),
"ema200": _series(ema200),
"rsi": _series(rsi),
"macd": _series(macd),
"macd_signal": _series(sig),
"macd_hist": _series(macd_hist),
"kdj_k": _series(kdj_k),
"kdj_d": _series(kdj_d),
"kdj_j": _series(kdj_j),
}
path = f"{report_dir}/chart_data.json"
with open(path, "w") as f:
json.dump(chart_data, f)
if verbose:
print(f" Chart data saved: {path}", file=sys.stderr)
return {"chart_data": "chart_data.json"}
def generate_report(signal: MultiHorizonSignal, data: StockData, verbose: bool = False) -> str:
"""Create report directory, generate charts, write data.json. Returns report dir path."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
report_dir = f"/data/stock-reports/{signal.ticker}/{ts}"
os.makedirs(report_dir, exist_ok=True)
if verbose:
print(f"Report dir: {report_dir}", file=sys.stderr)
charts = generate_charts(data, report_dir, verbose=verbose)
hist = data.price_history
close = hist["Close"]
ma5 = float(close.rolling(5).mean().iloc[-1]) if len(close) >= 5 else None
ma20 = float(close.rolling(20).mean().iloc[-1]) if len(close) >= 20 else None
ma60 = float(close.rolling(60).mean().iloc[-1]) if len(close) >= 60 else None
ema50 = float(close.ewm(span=50, adjust=False).mean().iloc[-1]) if len(close) >= 50 else None
ema200 = float(close.ewm(span=200, adjust=False).mean().iloc[-1]) if len(close) >= 200 else None
high_52w = float(hist["High"].max())
low_52w = float(hist["Low"].min())
change_1m = float((close.iloc[-1] - close.iloc[-22]) / close.iloc[-22] * 100) if len(close) >= 22 else None
change_3m = float((close.iloc[-1] - close.iloc[-66]) / close.iloc[-66] * 100) if len(close) >= 66 else None
kdj = calc_kdj(hist)
obv_result = calc_obv(hist)
pattern = detect_candlestick(hist)
report_data = {
"ticker": signal.ticker,
"name": signal.name,
"market": signal.market,
"generated_at": signal.timestamp,
"current_price": data.current_price,
"signal": {
"short": asdict(signal.short),
"medium": asdict(signal.medium),
"long": asdict(signal.long),
},
"financials": data.financials,
"technicals": {
"ma5": ma5, "ma20": ma20, "ma60": ma60,
"ema50": ema50, "ema200": ema200,
"high_52w": high_52w, "low_52w": low_52w,
"change_1m_pct": round(change_1m, 2) if change_1m else None,
"change_3m_pct": round(change_3m, 2) if change_3m else None,
"rsi_14": calc_rsi(close),
"adx_14": calc_adx(hist),
"kdj": {"k": kdj[0], "d": kdj[1], "j": kdj[2]} if kdj else None,
"obv_trend": obv_result[1] if obv_result else None,
"mfi_14": calc_mfi(hist),
"cci_20": calc_cci(hist),
"atr_14": calc_atr(hist),
"bb_bandwidth": calc_bb_bandwidth(close),
"hist_volatility_20": calc_hist_volatility(close),
"pivot_points": calc_pivot_points(hist),
"candlestick_pattern": pattern[0] if pattern else None,
},
"analyst_forecasts": data.analyst_forecasts,
"charts": charts,
"ai_analysis": None,
}
data_path = f"{report_dir}/data.json"
with open(data_path, "w", encoding="utf-8") as f:
json.dump(report_data, f, ensure_ascii=False, indent=2, default=str)
print(f"Report saved: {report_dir}", file=sys.stderr)
return report_dir
def main():
parser = argparse.ArgumentParser(description="Analyze HK and A-share stocks using akshare")
parser.add_argument("tickers", nargs="+", help="Stock tickers (5-digit HK, 6-digit A-share)")
parser.add_argument("--output", choices=["text", "json"], default="text")
parser.add_argument("--report", action="store_true", help="Generate report directory with charts and data.json")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
results = []
for ticker in args.tickers:
try:
detect_market(ticker)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(2)
result = analyze_one(ticker, verbose=args.verbose)
if result:
results.append(result)
else:
sys.exit(2)
signals = [r[0] for r in results]
if args.report:
from render_report import render
for signal, data in results:
report_dir = generate_report(signal, data, verbose=args.verbose)
render(report_dir)
if args.output == "json":
if len(signals) == 1:
print(format_json(signals[0]))
else:
print(json.dumps([json.loads(format_json(s)) for s in signals], ensure_ascii=False, indent=2))
else:
for i, s in enumerate(signals):
if i > 0:
print()
print(format_text(s))
if __name__ == "__main__":
main()
"""Three-horizon analysis: short / medium / long term scoring."""
from dataclasses import dataclass
import pandas as pd
from indicators import (
calc_rsi, calc_kdj, calc_cci, calc_adx, calc_obv, calc_mfi,
calc_atr, calc_bb_bandwidth, calc_hist_volatility, calc_pivot_points,
detect_candlestick,
)
@dataclass
class HorizonSignal:
horizon: str
recommendation: str
confidence: float
score: float
points: list[str]
@dataclass
class MultiHorizonSignal:
ticker: str
name: str
market: str
short: HorizonSignal
medium: HorizonSignal
long: HorizonSignal
timestamp: str
def _make_signal(horizon: str, scores: list[float], points: list[str]) -> HorizonSignal:
if not scores:
return HorizonSignal(horizon=horizon, recommendation="HOLD", confidence=0.0, score=0.0, points=["数据不足"])
score = max(-1.0, min(1.0, sum(scores) / len(scores)))
rec = "BUY" if score > 0.25 else ("SELL" if score < -0.25 else "HOLD")
confidence = round(min(100.0, abs(score) * 100 * (1 + len(scores) * 0.05)), 1)
return HorizonSignal(horizon=horizon, recommendation=rec, confidence=confidence, score=round(score, 4), points=points)
def analyze_short_term(current_price: float, hist: pd.DataFrame) -> HorizonSignal:
"""短期 (≤2周): KDJ, RSI(7), CCI, K线形态, 量比, Pivot Points"""
scores, points = [], []
rsi7 = calc_rsi(hist["Close"], period=7)
if rsi7 is not None:
if rsi7 < 25:
scores.append(0.6); points.append(f"RSI(7) {rsi7:.0f} 超卖")
elif rsi7 > 75:
scores.append(-0.6); points.append(f"RSI(7) {rsi7:.0f} 超买")
else:
scores.append((50 - rsi7) / 50 * 0.3)
kdj = calc_kdj(hist)
if kdj:
k, d, j = kdj
if k < 20:
scores.append(0.5); points.append(f"KDJ K={k:.0f} 超卖")
elif k > 80:
scores.append(-0.5); points.append(f"KDJ K={k:.0f} 超买")
elif k > d:
scores.append(0.2); points.append(f"KDJ 金叉 K={k:.0f}")
else:
scores.append(-0.2); points.append(f"KDJ 死叉 K={k:.0f}")
cci = calc_cci(hist)
if cci is not None:
if cci < -100:
scores.append(0.4); points.append(f"CCI {cci:.0f} 超卖")
elif cci > 100:
scores.append(-0.4); points.append(f"CCI {cci:.0f} 超买")
else:
scores.append(0.0)
pattern = detect_candlestick(hist)
if pattern:
name, strength = pattern
scores.append(strength * 0.8)
points.append(f"K线: {name} ({'看涨' if strength > 0 else '看跌'})")
if "Volume" in hist.columns and len(hist) >= 20:
vol5 = hist["Volume"].iloc[-5:].mean()
vol20 = hist["Volume"].iloc[-20:].mean()
if vol20 > 0:
ratio = vol5 / vol20
chg = (hist["Close"].iloc[-1] - hist["Close"].iloc[-5]) / hist["Close"].iloc[-5] * 100
if ratio > 1.5 and chg > 0:
scores.append(0.3); points.append(f"放量上涨 量比{ratio:.1f}x")
elif ratio > 1.5 and chg < 0:
scores.append(-0.3); points.append(f"放量下跌 量比{ratio:.1f}x")
pp = calc_pivot_points(hist)
if pp:
if current_price > pp["r1"]:
scores.append(0.3); points.append(f"突破R1={pp['r1']:.2f}")
elif current_price < pp["s1"]:
scores.append(-0.3); points.append(f"跌破S1={pp['s1']:.2f}")
return _make_signal("short", scores, points)
def analyze_medium_term(current_price: float, hist: pd.DataFrame) -> HorizonSignal:
"""中期 (2周~6月): MACD, RSI(14), ADX, 布林带, OBV, MA排列, MFI"""
close = hist["Close"]
scores, points = [], []
rsi14 = calc_rsi(close, period=14)
if rsi14 is not None:
if rsi14 < 30:
scores.append(0.5); points.append(f"RSI(14) {rsi14:.0f} 超卖")
elif rsi14 > 70:
scores.append(-0.5); points.append(f"RSI(14) {rsi14:.0f} 超买")
else:
scores.append((50 - rsi14) / 100)
# MACD
if len(close) >= 35:
ema12 = close.ewm(span=12, adjust=False).mean()
ema26 = close.ewm(span=26, adjust=False).mean()
macd = ema12 - ema26
sig = macd.ewm(span=9, adjust=False).mean()
mv, sv = float(macd.iloc[-1]), float(sig.iloc[-1])
if mv > sv and mv > 0:
scores.append(0.4); points.append("MACD 金叉(零轴上方)")
elif mv > sv:
scores.append(0.2); points.append("MACD 金叉")
elif mv < sv and mv < 0:
scores.append(-0.4); points.append("MACD 死叉(零轴下方)")
else:
scores.append(-0.2); points.append("MACD 死叉")
adx = calc_adx(hist)
if adx is not None:
points.append(f"ADX {adx:.0f} {'趋势强' if adx > 25 else '震荡市'}")
# MA排列
if len(close) >= 60:
ma5 = float(close.rolling(5).mean().iloc[-1])
ma20 = float(close.rolling(20).mean().iloc[-1])
ma60 = float(close.rolling(60).mean().iloc[-1])
if current_price > ma5 > ma20 > ma60:
scores.append(0.5); points.append("多头排列 P>MA5>MA20>MA60")
elif current_price < ma5 < ma20 < ma60:
scores.append(-0.5); points.append("空头排列 P<MA5<MA20<MA60")
elif current_price > ma20:
scores.append(0.2)
else:
scores.append(-0.2)
# 布林带
if len(close) >= 20:
ma20s = close.rolling(20).mean()
std20 = close.rolling(20).std()
bb_upper = float((ma20s + 2 * std20).iloc[-1])
bb_lower = float((ma20s - 2 * std20).iloc[-1])
bb_range = bb_upper - bb_lower
if bb_range > 0:
bb_pos = (current_price - bb_lower) / bb_range
if bb_pos > 0.95:
scores.append(-0.3); points.append("触及布林上轨")
elif bb_pos < 0.05:
scores.append(0.3); points.append("触及布林下轨")
obv_result = calc_obv(hist)
if obv_result:
_, obv_trend = obv_result
if obv_trend == "up":
scores.append(0.3); points.append("OBV 上升(资金流入)")
elif obv_trend == "down":
scores.append(-0.3); points.append("OBV 下降(资金流出)")
mfi = calc_mfi(hist)
if mfi is not None:
if mfi < 20:
scores.append(0.4); points.append(f"MFI {mfi:.0f} 超卖")
elif mfi > 80:
scores.append(-0.4); points.append(f"MFI {mfi:.0f} 超买")
return _make_signal("medium", scores, points)
def analyze_long_term(current_price: float, hist: pd.DataFrame, financials: dict, analyst_forecasts: list[dict] | None) -> HorizonSignal:
"""长期 (6月+): EMA200, MA120, 基本面, 估值, 分析师, 波动率"""
close = hist["Close"]
scores, points = [], []
if len(close) >= 200:
ema200 = float(close.ewm(span=200, adjust=False).mean().iloc[-1])
pct = (current_price - ema200) / ema200 * 100
if current_price > ema200:
scores.append(0.4); points.append(f"价格在EMA200上方 +{pct:.1f}%")
else:
scores.append(-0.4); points.append(f"价格在EMA200下方 {pct:.1f}%")
if len(close) >= 120:
ma120 = float(close.rolling(120).mean().iloc[-1])
if current_price > ma120:
scores.append(0.3); points.append("价格在MA120上方")
else:
scores.append(-0.3); points.append("价格在MA120下方")
hv = calc_hist_volatility(close)
if hv is not None:
points.append(f"历史波动率(20日) {hv:.0f}%")
# 基本面
roe = financials.get("roe")
if roe is not None:
if roe > 15:
scores.append(0.5); points.append(f"ROE {roe:.1f}% 优秀")
elif roe > 8:
scores.append(0.1)
elif roe > 0:
scores.append(-0.2)
else:
scores.append(-0.5); points.append(f"ROE {roe:.1f}% 亏损")
pg = financials.get("profit_growth")
if pg is not None:
if pg > 20:
scores.append(0.5); points.append(f"净利润增长 {pg:.1f}%")
elif pg > 0:
scores.append(0.1)
elif pg > -10:
scores.append(-0.3)
else:
scores.append(-0.5); points.append(f"净利润下滑 {pg:.1f}%")
pe, pb = financials.get("pe"), financials.get("pb")
if pe is not None and pe > 0:
if pe < 10:
scores.append(0.5); points.append(f"P/E {pe:.1f} 低估")
elif pe < 20:
scores.append(0.2); points.append(f"P/E {pe:.1f}")
elif pe > 35:
scores.append(-0.4); points.append(f"P/E {pe:.1f} 高估")
if pb is not None and pb > 0:
if pb < 1:
scores.append(0.4); points.append(f"P/B {pb:.1f} 破净")
elif pb > 5:
scores.append(-0.2); points.append(f"P/B {pb:.1f}")
if analyst_forecasts:
targets = [f["target_price"] for f in analyst_forecasts if f.get("target_price")]
if targets:
avg_t = sum(targets) / len(targets)
upside = (avg_t - current_price) / current_price * 100
if upside > 20:
scores.append(0.6); points.append(f"分析师目标价上行 {upside:+.1f}%")
elif upside > 10:
scores.append(0.3); points.append(f"分析师目标价上行 {upside:+.1f}%")
elif upside < -10:
scores.append(-0.4); points.append(f"分析师目标价下行 {upside:+.1f}%")
return _make_signal("long", scores, points)
"""Technical indicator calculations. All functions take a DataFrame with OHLCV columns."""
import pandas as pd
def calc_rsi(prices: pd.Series, period: int = 14) -> float | None:
if len(prices) < period + 1:
return None
delta = prices.diff()
gains = delta.where(delta > 0, 0).rolling(window=period).mean()
losses = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gains / losses
rsi = 100 - (100 / (1 + rs))
val = rsi.iloc[-1]
return float(val) if pd.notna(val) else None
def calc_kdj(hist: pd.DataFrame, n: int = 9, m: int = 3) -> tuple[float, float, float] | None:
if len(hist) < n:
return None
low_n = hist["Low"].rolling(n).min()
high_n = hist["High"].rolling(n).max()
rsv = (hist["Close"] - low_n) / (high_n - low_n + 1e-9) * 100
k = rsv.ewm(com=m - 1, adjust=False).mean()
d = k.ewm(com=m - 1, adjust=False).mean()
j = 3 * k - 2 * d
if pd.isna(k.iloc[-1]):
return None
return float(k.iloc[-1]), float(d.iloc[-1]), float(j.iloc[-1])
def calc_adx(hist: pd.DataFrame, period: int = 14) -> float | None:
if len(hist) < period * 2:
return None
high, low, close = hist["High"], hist["Low"], hist["Close"]
tr = pd.concat([high - low, (high - close.shift()).abs(), (low - close.shift()).abs()], axis=1).max(axis=1)
raw_plus = high.diff().clip(lower=0)
raw_minus = (-low.diff()).clip(lower=0)
cond = raw_plus > raw_minus
dm_plus = raw_plus.where(cond, 0.0)
dm_minus = raw_minus.where(~cond, 0.0)
atr = tr.ewm(span=period, adjust=False).mean()
di_plus = 100 * dm_plus.ewm(span=period, adjust=False).mean() / atr
di_minus = 100 * dm_minus.ewm(span=period, adjust=False).mean() / atr
dx = (100 * (di_plus - di_minus).abs() / (di_plus + di_minus + 1e-9)).fillna(0)
adx = dx.ewm(span=period, adjust=False).mean()
val = adx.iloc[-1]
return float(val) if pd.notna(val) else None
def calc_obv(hist: pd.DataFrame) -> tuple[float, str] | None:
if len(hist) < 20:
return None
direction = hist["Close"].diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0))
obv = (direction * hist["Volume"]).cumsum()
recent = obv.iloc[-10:].mean()
prev = obv.iloc[-20:-10].mean()
trend = "up" if recent > prev * 1.02 else ("down" if recent < prev * 0.98 else "flat")
return float(obv.iloc[-1]), trend
def calc_mfi(hist: pd.DataFrame, period: int = 14) -> float | None:
if len(hist) < period + 1:
return None
tp = (hist["High"] + hist["Low"] + hist["Close"]) / 3
mf = tp * hist["Volume"]
pos_mf = mf.where(tp > tp.shift(), 0.0).rolling(period).sum()
neg_mf = mf.where(tp <= tp.shift(), 0.0).rolling(period).sum()
mfi = 100 - 100 / (1 + pos_mf / neg_mf.replace(0, float("nan")))
val = mfi.iloc[-1]
return float(val) if pd.notna(val) else None
def calc_atr(hist: pd.DataFrame, period: int = 14) -> float | None:
if len(hist) < period + 1:
return None
high, low, close = hist["High"], hist["Low"], hist["Close"]
tr = pd.concat([high - low, (high - close.shift()).abs(), (low - close.shift()).abs()], axis=1).max(axis=1)
atr = tr.ewm(span=period, adjust=False).mean()
val = atr.iloc[-1]
return float(val) if pd.notna(val) else None
def calc_cci(hist: pd.DataFrame, period: int = 20) -> float | None:
if len(hist) < period:
return None
tp = (hist["High"] + hist["Low"] + hist["Close"]) / 3
ma = tp.rolling(period).mean()
md = tp.rolling(period).apply(lambda x: abs(x - x.mean()).mean())
cci = (tp - ma) / (0.015 * md + 1e-9)
val = cci.iloc[-1]
return float(val) if pd.notna(val) else None
def calc_bb_bandwidth(close: pd.Series, period: int = 20) -> float | None:
if len(close) < period:
return None
ma = close.rolling(period).mean()
std = close.rolling(period).std()
bw = (4 * std / ma * 100).iloc[-1]
return float(bw) if pd.notna(bw) else None
def calc_hist_volatility(close: pd.Series, period: int = 20) -> float | None:
if len(close) < period + 1:
return None
ret = close.pct_change().dropna()
hv = float(ret.iloc[-period:].std() * (252 ** 0.5) * 100)
return hv
def calc_pivot_points(hist: pd.DataFrame) -> dict | None:
if len(hist) < 2:
return None
prev = hist.iloc[-2]
pp = (prev["High"] + prev["Low"] + prev["Close"]) / 3
r1 = 2 * pp - prev["Low"]
s1 = 2 * pp - prev["High"]
r2 = pp + (prev["High"] - prev["Low"])
s2 = pp - (prev["High"] - prev["Low"])
return {"pp": round(pp, 4), "r1": round(r1, 4), "s1": round(s1, 4), "r2": round(r2, 4), "s2": round(s2, 4)}
def detect_candlestick(hist: pd.DataFrame) -> tuple[str, float] | None:
"""Returns (pattern_name, signal_strength -1~+1) or None."""
if len(hist) < 3:
return None
o2, c2 = hist["Open"].iloc[-3], hist["Close"].iloc[-3]
o1, c1 = hist["Open"].iloc[-2], hist["Close"].iloc[-2]
o0, h0, l0, c0 = hist["Open"].iloc[-1], hist["High"].iloc[-1], hist["Low"].iloc[-1], hist["Close"].iloc[-1]
body0 = abs(c0 - o0)
body1 = abs(c1 - o1)
body2 = abs(c2 - o2)
rng0 = h0 - l0 if h0 != l0 else 1e-9
lower0 = min(o0, c0) - l0
upper0 = h0 - max(o0, c0)
if c1 < o1 and c0 > o0 and c0 > o1 and o0 < c1:
return "bullish_engulfing", 0.6
if c1 > o1 and c0 < o0 and c0 < o1 and o0 > c1:
return "bearish_engulfing", -0.6
if body0 > 0 and lower0 > 2 * body0 and upper0 < body0 * 0.5:
return "hammer", 0.5
if body0 > 0 and upper0 > 2 * body0 and lower0 < body0 * 0.5:
return "shooting_star", -0.5
if c2 < o2 and body2 > 0 and body1 < body2 * 0.3 and c0 > o0 and c0 > (o2 + c2) / 2:
return "morning_star", 0.7
if c2 > o2 and body2 > 0 and body1 < body2 * 0.3 and c0 < o0 and c0 < (o2 + c2) / 2:
return "evening_star", -0.7
return None
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "akshare>=1.0.0",
# ]
# ///
"""
Search stock news by keyword(s).
Usage:
uv run news.py KEYWORD [KEYWORD2 ...]
uv run news.py 00700 腾讯 港股 --json
"""
import argparse
import json
from datetime import datetime
import akshare as ak
def search_news(keywords: list[str], max_per_keyword: int = 10) -> list[dict]:
"""Search news by multiple keywords, merge and dedupe."""
seen = set()
results = []
for kw in keywords:
try:
df = ak.stock_news_em(symbol=kw)
if df is None or df.empty:
continue
for _, row in df.head(max_per_keyword).iterrows():
# Dedupe by title
title = str(row.get("新闻标题", ""))
if title and title not in seen:
seen.add(title)
results.append({
"keyword": kw,
"title": title,
"time": str(row.get("发布时间", "")),
"source": str(row.get("文章来源", "")),
"content": str(row.get("新闻内容", ""))[:500], # truncate long content
})
except Exception as e:
print(f"Warning: Failed to search '{kw}': {e}", flush=True)
# Sort by time (newest first)
results.sort(key=lambda x: x["time"], reverse=True)
return results
def format_text(news: list[dict]) -> str:
if not news:
return "No news found."
lines = ["=" * 60, f"News Search Results ({len(news)} articles)", "=" * 60, ""]
for i, n in enumerate(news, 1):
lines.append(f"[{i}] {n['title']}")
lines.append(f" 来源: {n['source']} | 时间: {n['time']} | 关键词: {n['keyword']}")
if n['content']:
lines.append(f" 摘要: {n['content'][:200]}...")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def format_json(news: list[dict]) -> str:
return json.dumps({
"generated_at": datetime.now().isoformat(),
"keywords": keywords,
"count": len(news),
"news": news,
}, ensure_ascii=False, indent=2)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Search stock news by keywords")
parser.add_argument("keywords", nargs="+", help="Keywords to search (ticker, name, etc.)")
parser.add_argument("--json", action="store_true", help="Output JSON format")
parser.add_argument("--max", type=int, default=10, help="Max articles per keyword")
args = parser.parse_args()
keywords = args.keywords
news = search_news(keywords, max_per_keyword=args.max)
if args.json:
print(format_json(news))
else:
print(format_text(news))
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Render HTML report from a report directory produced by analyze.py --report.
Usage:
uv run render_report.py ~/stock-reports/20260329_030822_00700
"""
import argparse
import json
import os
import re
import sys
def _pct(v, decimals=1):
return "N/A" if v is None else f"{v:.{decimals}f}%"
def _num(v, decimals=2):
return "N/A" if v is None else f"{v:.{decimals}f}"
def render(report_dir: str) -> str:
data_path = os.path.join(report_dir, "data.json")
if not os.path.exists(data_path):
print(f"Error: {data_path} not found", file=sys.stderr)
sys.exit(1)
with open(data_path, encoding="utf-8") as f:
d = json.load(f)
chart_data_path = os.path.join(report_dir, "chart_data.json")
chart_data_json = "{}"
if os.path.exists(chart_data_path):
with open(chart_data_path, encoding="utf-8") as f:
chart_data_json = f.read()
sig = d["signal"]
market_label = "港股" if d["market"] == "hk" else "A股"
generated_at = d["generated_at"][:19].replace("T", " ")
# Three horizon cards
horizon_labels = {"short": "短期 (≤2周)", "medium": "中期 (2周~6月)", "long": "长期 (6月+)"}
rec_labels = {"BUY": "买入 ▲", "HOLD": "持有 —", "SELL": "卖出 ▼"}
rec_colors = {"BUY": "#26a641", "HOLD": "#d29922", "SELL": "#f85149"}
horizons_parts = []
for key in ["short", "medium", "long"]:
hs = sig[key]
rec = hs["recommendation"]
color = rec_colors.get(rec, "#888")
pts = "\n".join(f"<li>{p}</li>" for p in hs.get("points", [])) or "<li>数据不足</li>"
horizons_parts.append(f"""<div class="card">
<div class="horizon-header">
<span class="horizon-label">{horizon_labels[key]}</span>
<span class="badge" style="background:{color}22;color:{color}">{rec_labels.get(rec, rec)}</span>
<span class="confidence">置信度 {hs['confidence']:.0f}%</span>
</div>
<ul class="points">{pts}</ul>
</div>""")
horizons_html = "\n".join(horizons_parts)
# Financials
fin = d.get("financials", {})
fin_items = [
("P/E", _num(fin.get("pe"))),
("P/B", _num(fin.get("pb"))),
("ROE", _pct(fin.get("roe"))),
("净利率", _pct(fin.get("net_margin"))),
("利润增长", _pct(fin.get("profit_growth"))),
("营收增长", _pct(fin.get("revenue_growth"))),
("资产负债率", _pct(fin.get("debt_ratio"))),
("股息率", _pct(fin.get("dividend_yield"))),
]
financials_html = "\n".join(
f'<div class="fin-item"><span class="fin-label">{k}</span><span class="fin-value">{v}</span></div>'
for k, v in fin_items if v != "N/A"
)
# Technicals
tech = d.get("technicals", {})
kdj = tech.get("kdj")
pp = tech.get("pivot_points")
tech_items = [
("MA5", _num(tech.get("ma5"))),
("MA20", _num(tech.get("ma20"))),
("MA60", _num(tech.get("ma60"))),
("EMA50", _num(tech.get("ema50"))),
("EMA200", _num(tech.get("ema200"))),
("RSI(14)", _num(tech.get("rsi_14"), 1)),
("ADX(14)", _num(tech.get("adx_14"), 1)),
("KDJ K/D/J", f"{kdj['k']:.0f}/{kdj['d']:.0f}/{kdj['j']:.0f}" if kdj else "N/A"),
("MFI(14)", _num(tech.get("mfi_14"), 1)),
("CCI(20)", _num(tech.get("cci_20"), 1)),
("ATR(14)", _num(tech.get("atr_14"))),
("BB带宽", _num(tech.get("bb_bandwidth"), 1)),
("波动率(20日)", _pct(tech.get("hist_volatility_20"))),
("OBV趋势", tech.get("obv_trend") or "N/A"),
("K线形态", tech.get("candlestick_pattern") or "无"),
("Pivot PP", _num(pp.get("pp")) if pp else "N/A"),
("近1月涨跌", _pct(tech.get("change_1m_pct"))),
("近3月涨跌", _pct(tech.get("change_3m_pct"))),
("52周高", _num(tech.get("high_52w"))),
("52周低", _num(tech.get("low_52w"))),
]
technicals_html = "\n".join(
f'<div class="fin-item"><span class="fin-label">{k}</span><span class="fin-value">{v}</span></div>'
for k, v in tech_items if v != "N/A"
)
# AI analysis - check ai_analysis.md first, fallback to data.json
ai_md_path = os.path.join(report_dir, "ai_analysis.md")
ai_section = ""
if os.path.exists(ai_md_path):
# Parse ai_analysis.md with frontmatter
try:
with open(ai_md_path, encoding="utf-8") as f:
content = f.read()
# Split by H2 sections
sections = re.split(r'^##\s+', content, flags=re.MULTILINE)
cards = []
for sec in sections[1:]: # Skip first part (frontmatter)
if not sec.strip():
continue
parts = sec.strip().split('\n', 1)
if not parts:
continue
title = parts[0]
body_text = parts[1].strip() if len(parts) > 1 else ""
# Convert markdown links/bold to simple text
body_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', body_text)
body_text = re.sub(r'\*\*([^*]+)\*\*', r'\1', body_text)
cards.append(f"""<div class="ai-card">
<div class="ai-card-title">{title}</div>
<div class="ai-card-body">{body_text}</div>
</div>""")
if cards:
ai_section = f'<div class="ai-section"><h2>AI 分析</h2><div class="ai-grid">' + "".join(cards) + '</div></div>'
except Exception as e:
print(f"Warning: Failed to parse ai_analysis.md: {e}", file=sys.stderr)
else:
ai_analysis = d.get("ai_analysis")
ai_section = f'<div class="ai-box"><h3>AI 综合分析</h3>{ai_analysis}</div>' if ai_analysis else ""
# Analyst forecasts
forecasts = d.get("analyst_forecasts") or []
if forecasts:
rows = "\n".join(
f"<tr><td>{f.get('broker','')}</td><td>{f.get('rating','')}</td>"
f"<td>{f.get('target_price','N/A')}</td><td>{f.get('date','')}</td></tr>"
for f in forecasts
)
analyst_section = f"""<div class="card" style="margin-bottom:16px">
<h3>分析师预测</h3>
<table class="analyst-table">
<thead><tr><th>机构</th><th>评级</th><th>目标价</th><th>更新日期</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>"""
else:
analyst_section = ""
html = f"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{d['ticker']} {d['name']} 分析报告</title>
<script src="https://unpkg.com/[email protected]/dist/lightweight-charts.standalone.production.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fa; color: #333; }}
.container {{ max-width: 1400px; margin: 0 auto; padding: 16px; }}
header {{ display: flex; align-items: center; gap: 12px; margin-bottom: 20px; border-bottom: 1px solid #e0e0e0; padding-bottom: 16px; flex-wrap: wrap; }}
.ticker {{ font-size: 1.5rem; font-weight: 700; color: #1a1a2e; }}
.meta {{ color: #666; font-size: 0.85rem; margin-top: 4px; }}
.grid {{ display: grid; grid-template-columns: 1fr; gap: 12px; margin-bottom: 12px; }}
@media (min-width: 768px) {{ .grid {{ grid-template-columns: 1fr 1fr 1fr; }} }}
@media (min-width: 768px) {{ .grid-2 {{ grid-template-columns: 1fr 1fr; }} }}
.card {{ background: #fff; border-radius: 10px; padding: 14px; border: 1px solid #e0e0e0; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }}
.card h3 {{ font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 10px; }}
.horizon-header {{ display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }}
.horizon-label {{ font-size: 0.88rem; font-weight: 600; color: #1a1a2e; }}
.badge {{ padding: 3px 10px; border-radius: 12px; font-weight: 600; font-size: 0.8rem; }}
.confidence {{ color: #888; font-size: 0.78rem; }}
.points {{ list-style: none; }}
.points li {{ padding: 3px 0; font-size: 0.8rem; border-bottom: 1px solid #f0f0f0; color: #444; }}
.points li::before {{ content: "▸ "; color: #aaa; }}
.fin-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }}
@media (min-width: 480px) {{ .fin-grid {{ grid-template-columns: 1fr 1fr 1fr; }} }}
.fin-item {{ display: flex; justify-content: space-between; font-size: 0.78rem; padding: 4px 0; border-bottom: 1px solid #f0f0f0; }}
.fin-label {{ color: #666; }}
.fin-value {{ font-weight: 600; color: #333; }}
.ai-box {{ background: #fff; border-radius: 10px; padding: 14px; border: 1px solid #e0e0e0; margin-bottom: 12px; white-space: pre-wrap; font-size: 0.83rem; line-height: 1.6; color: #444; }}
.ai-box h3 {{ font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 10px; }}
.ai-section {{ margin-bottom: 16px; }}
.ai-section h2 {{ font-size: 1rem; color: #1a1a2e; margin-bottom: 12px; }}
.ai-grid {{ display: grid; grid-template-columns: 1fr; gap: 10px; }}
@media (min-width: 768px) {{ .ai-grid {{ grid-template-columns: 1fr 1fr; }} }}
.ai-card {{ background: #fff; border-radius: 10px; padding: 14px; border: 1px solid #e0e0e0; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }}
.ai-card-title {{ font-size: 0.85rem; font-weight: 600; color: #1a1a2e; margin-bottom: 8px; border-bottom: 2px solid #58a6ff; padding-bottom: 6px; }}
.ai-card-body {{ font-size: 0.8rem; line-height: 1.5; color: #555; white-space: pre-wrap; }}
.analyst-table {{ width: 100%; border-collapse: collapse; font-size: 0.78rem; }}
.analyst-table th {{ text-align: left; color: #666; font-weight: 500; padding: 6px; border-bottom: 1px solid #e0e0e0; background: #f8f9fa; }}
.analyst-table td {{ padding: 6px; border-bottom: 1px solid #f0f0f0; color: #444; }}
.chart-container {{ background: #fff; border-radius: 10px; border: 1px solid #e0e0e0; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 12px; overflow: hidden; }}
.chart-title {{ padding: 10px 14px 0; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1px; color: #888; }}
.chart-legend {{ padding: 4px 14px 8px; font-size: 0.72rem; color: #888; display: flex; gap: 12px; flex-wrap: wrap; }}
.legend-item {{ display: flex; align-items: center; gap: 4px; }}
.legend-dot {{ width: 8px; height: 2px; border-radius: 1px; }}
footer {{ text-align: center; color: #999; font-size: 0.7rem; margin-top: 24px; padding-top: 16px; border-top: 1px solid #e0e0e0; }}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<div class="ticker">{d['ticker']} <span style="font-size:1.1rem;color:#8b949e">{d['name']}</span></div>
<div class="meta">{market_label} · 当前价: {d['current_price']} · 生成时间: {generated_at}</div>
</div>
</header>
{ai_section}
<div class="grid">
{horizons_html}
</div>
<div class="chart-container">
<div class="chart-title">K线图 · 最近180日</div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot" style="background:#58a6ff"></span>MA20</span>
<span class="legend-item"><span class="legend-dot" style="background:#f0883e"></span>EMA50</span>
<span class="legend-item"><span class="legend-dot" style="background:#ff7b72;border-top:1px dashed #ff7b72;height:0"></span>EMA200</span>
<span class="legend-item"><span class="legend-dot" style="background:#8b949e"></span>BB</span>
</div>
<div id="chart-main" style="height:320px"></div>
<div id="chart-volume" style="height:80px"></div>
</div>
<div class="grid grid-2" style="margin-bottom:12px">
<div class="chart-container" style="margin-bottom:0">
<div class="chart-title">RSI (14)</div>
<div id="chart-rsi" style="height:120px"></div>
</div>
<div class="chart-container" style="margin-bottom:0">
<div class="chart-title">MACD (12,26,9)</div>
<div id="chart-macd" style="height:120px"></div>
</div>
</div>
<div class="chart-container">
<div class="chart-title">KDJ (9,3,3)</div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot" style="background:#58a6ff"></span>K</span>
<span class="legend-item"><span class="legend-dot" style="background:#f0883e"></span>D</span>
<span class="legend-item"><span class="legend-dot" style="background:#3fb950"></span>J</span>
</div>
<div id="chart-kdj" style="height:120px"></div>
</div>
<div class="grid grid-2">
<div class="card">
<h3>财务指标</h3>
<div class="fin-grid">{financials_html}</div>
</div>
<div class="card">
<h3>技术指标</h3>
<div class="fin-grid">{technicals_html}</div>
</div>
</div>
{analyst_section}
<footer>⚠️ 本报告仅供参考,不构成投资建议。</footer>
</div>
<script>
const CD = {chart_data_json};
const OPTS = {{
layout: {{ background: {{ color: '#ffffff' }}, textColor: '#333' }},
grid: {{ vertLines: {{ color: '#f0f0f0' }}, horzLines: {{ color: '#f0f0f0' }} }},
crosshair: {{ mode: 1 }},
timeScale: {{ borderColor: '#e0e0e0', timeVisible: true }},
rightPriceScale: {{ borderColor: '#e0e0e0' }},
}};
// ── Main chart ──
const mainChart = LightweightCharts.createChart(document.getElementById('chart-main'), {{
...OPTS, height: 320,
handleScroll: {{ mouseWheel: true, pressedMouseMove: true }},
handleScale: {{ mouseWheel: true, pinch: true }},
}});
const candleSeries = mainChart.addCandlestickSeries({{
upColor: '#26a641', downColor: '#f85149',
borderUpColor: '#26a641', borderDownColor: '#f85149',
wickUpColor: '#26a641', wickDownColor: '#f85149',
}});
candleSeries.setData(CD.ohlcv);
const ma20s = mainChart.addLineSeries({{ color: '#58a6ff', lineWidth: 1, priceLineVisible: false }});
ma20s.setData(CD.ma20);
const ema50s = mainChart.addLineSeries({{ color: '#f0883e', lineWidth: 1, priceLineVisible: false }});
ema50s.setData(CD.ema50);
const ema200s = mainChart.addLineSeries({{ color: '#ff7b72', lineWidth: 1, lineStyle: 1, priceLineVisible: false }});
ema200s.setData(CD.ema200);
const bbUs = mainChart.addLineSeries({{ color: '#8b949e', lineWidth: 1, lineStyle: 2, priceLineVisible: false }});
bbUs.setData(CD.bb_upper);
const bbLs = mainChart.addLineSeries({{ color: '#8b949e', lineWidth: 1, lineStyle: 2, priceLineVisible: false }});
bbLs.setData(CD.bb_lower);
// ── Volume chart ──
const volChart = LightweightCharts.createChart(document.getElementById('chart-volume'), {{
...OPTS, height: 80,
}});
const volSeries = volChart.addHistogramSeries({{
priceFormat: {{ type: 'volume' }},
priceScaleId: '',
}});
volSeries.priceScale().applyOptions({{ scaleMargins: {{ top: 0.1, bottom: 0 }} }});
volSeries.setData(CD.ohlcv.map(d => ({{
time: d.time,
value: d.volume,
color: d.close >= d.open ? '#26a64166' : '#f8514966',
}})));
// ── RSI chart ──
const rsiChart = LightweightCharts.createChart(document.getElementById('chart-rsi'), {{
...OPTS, height: 120,
}});
const rsiSeries = rsiChart.addLineSeries({{ color: '#d2a8ff', lineWidth: 1, priceLineVisible: false }});
rsiSeries.setData(CD.rsi);
[70, 30].forEach(v => {{
const s = rsiChart.addLineSeries({{ color: v === 70 ? '#f85149' : '#26a641', lineWidth: 1, lineStyle: 2, priceLineVisible: false }});
s.setData(CD.rsi.map(d => ({{ time: d.time, value: v }})));
}});
// ── MACD chart ──
const macdChart = LightweightCharts.createChart(document.getElementById('chart-macd'), {{
...OPTS, height: 120,
}});
const macdHistSeries = macdChart.addHistogramSeries({{ priceLineVisible: false }});
macdHistSeries.setData(CD.macd_hist.map(d => ({{
time: d.time, value: d.value,
color: d.value >= 0 ? '#26a64188' : '#f8514988',
}})));
const macdLine = macdChart.addLineSeries({{ color: '#58a6ff', lineWidth: 1, priceLineVisible: false }});
macdLine.setData(CD.macd);
const sigLine = macdChart.addLineSeries({{ color: '#f0883e', lineWidth: 1, priceLineVisible: false }});
sigLine.setData(CD.macd_signal);
// ── KDJ chart ──
const kdjChart = LightweightCharts.createChart(document.getElementById('chart-kdj'), {{
...OPTS, height: 120,
}});
const kLine = kdjChart.addLineSeries({{ color: '#58a6ff', lineWidth: 1, priceLineVisible: false }});
kLine.setData(CD.kdj_k);
const dLine = kdjChart.addLineSeries({{ color: '#f0883e', lineWidth: 1, priceLineVisible: false }});
dLine.setData(CD.kdj_d);
const jLine = kdjChart.addLineSeries({{ color: '#3fb950', lineWidth: 1, priceLineVisible: false }});
jLine.setData(CD.kdj_j);
[80, 20].forEach(v => {{
const s = kdjChart.addLineSeries({{ color: v === 80 ? '#f85149' : '#26a641', lineWidth: 1, lineStyle: 2, priceLineVisible: false }});
s.setData(CD.kdj_k.map(d => ({{ time: d.time, value: v }})));
}});
// Sync time scales
const charts = [mainChart, volChart, rsiChart, macdChart, kdjChart];
charts.forEach(c => {{
c.timeScale().subscribeVisibleLogicalRangeChange(range => {{
if (range) charts.forEach(o => {{ if (o !== c) o.timeScale().setVisibleLogicalRange(range); }});
}});
}});
mainChart.timeScale().fitContent();
</script>
</body>
</html>"""
out_path = os.path.join(report_dir, "index.html")
with open(out_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"Report rendered: {out_path}")
return out_path
def main():
parser = argparse.ArgumentParser(description="Render HTML report from report directory")
parser.add_argument("report_dir", help="Path to report directory (contains data.json)")
args = parser.parse_args()
render(os.path.expanduser(args.report_dir))
if __name__ == "__main__":
main()