
Trading Quant
- 748 installs
- 9 repo stars
- Updated March 4, 2026
- lanyasheng/trading-quant
trading-quant is a Python CLI skill that pulls real-time A-share, US, Hong Kong, and commodity quotes with a 5-dimension scoring model and capital-flow data for quantitative trading analysis.
About
trading-quant is a Python 3.12 CLI skill that aggregates market data from Tencent, Sina, East Money, and Tonghuashun sources through a unified quant.py entry point. It delivers a 5-dimension scoring system spanning technical, capital, and fundamental factors, plus A-share limit-up pools, northbound capital flows, minute-level fund flows, and global market snapshots. Developers reach for trading-quant when agents must query live stock quotes, analyze intraday anomalies, compare US or HK symbols, or inspect commodity prices inside automated research workflows. Commands like stock_analysis, intraday_snapshot, us_stock, hk_stock, and commodity expose discrete tools from one script. The skill targets quantitative developers monitoring multi-market positions rather than discretionary chart reading alone.
- Unified CLI entrypoint for 15+ quant commands across A-share, US, HK, and commodity markets
- 5-dimension scoring engine combining technical, capital, and fundamental factors with explicit weights
- Real-time intraday snapshots, northbound capital flow tracking, and top capital inflow lists
- Multi-source data aggregation from Tencent, Sina, East Money, and Tonghuashun
- Maintenance commands for warming kline caches and daily data persistence
Trading Quant by the numbers
- 748 all-time installs (skills.sh)
- +5 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #187 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lanyasheng/trading-quant --skill trading-quantAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 748 |
|---|---|
| repo stars | ★ 9 |
| Security audit | 2 / 3 scanners passed |
| Last updated | March 4, 2026 |
| Repository | lanyasheng/trading-quant ↗ |
How do you pull multi-market stock quotes in Python?
Pull real-time stock quotes, multi-factor scores, capital flows, and market anomaly data for quantitative trading strategies.
Who is it for?
Quantitative developers and data engineers needing multi-source A-share, US, HK, and commodity market data with scoring inside agent or CLI workflows.
Skip if: Developers seeking trade execution, brokerage integration, or regulated investment advice rather than programmatic market data retrieval.
When should I use this skill?
A developer asks for real-time stock quotes, A-share anomaly screens, northbound capital flows, or multi-factor quant scores across CN, US, or HK markets.
What you get
Real-time quote tables, 5-dimension factor scores, capital-flow snapshots, limit-up pools, and intraday market anomaly summaries.
- quote tables
- factor scores
- capital-flow reports
By the numbers
- Uses a 5-dimension scoring system for stock analysis
- Aggregates data from 4 providers: Tencent, Sina, East Money, and Tonghuashun
Files
量化交易数据分析
通过腾讯/新浪/东财/同花顺多数据源获取实时行情,提供5维评分体系。
工具列表
所有工具统一入口:
python3.12 {baseDir}/scripts/quant.py <tool> [args...]A股分析
python3.12 {baseDir}/scripts/quant.py stock_analysis [codes]
python3.12 {baseDir}/scripts/quant.py intraday_snapshot全球市场
python3.12 {baseDir}/scripts/quant.py us_stock [symbols]
python3.12 {baseDir}/scripts/quant.py hk_stock [codes]
python3.12 {baseDir}/scripts/quant.py commodity [codes]
python3.12 {baseDir}/scripts/quant.py global_overview市场数据
python3.12 {baseDir}/scripts/quant.py market_anomaly
python3.12 {baseDir}/scripts/quant.py market_scan
python3.12 {baseDir}/scripts/quant.py top_amount [N]
python3.12 {baseDir}/scripts/quant.py capital_flow [codes]
python3.12 {baseDir}/scripts/quant.py northbound_flow
python3.12 {baseDir}/scripts/quant.py gold_analysis
python3.12 {baseDir}/scripts/quant.py margin_data [code]
python3.12 {baseDir}/scripts/quant.py lhb [date]
python3.12 {baseDir}/scripts/quant.py main_flow [codes]维护
python3.12 {baseDir}/scripts/quant.py warm_klines
python3.12 {baseDir}/scripts/quant.py save_daily
python3.12 {baseDir}/scripts/quant.py system_health评分体系
| 维度 | 权重 | 指标 |
|---|---|---|
| 技术面 | 25% | MACD/RSI/KDJ/均线/布林 |
| 资金面 | 30% | 量比/换手率/量价/主力资金 |
| 基本面 | 10% | PE/PB/市值 |
| 消息面 | 20% | LLM 根据新闻原文判断 |
| 情绪面 | 15% | LLM 根据市场数据判断 |
信号等级
STRONG_BUY(>=80) > BUY(>=65) > WATCH(>=50) > HOLD(>=35) > SELL(>=20) > STRONG_SELL(<20)
数据源
| 市场 | 主源 | 降级链 |
|---|---|---|
| A股 | 腾讯 | 新浪→东财→同花顺 |
| 美股 | 腾讯 | yfinance |
| 港股 | 腾讯 | - |
| 商品 | 新浪期货 | - |
规则
1. 必须使用工具获取数据,禁止凭记忆回答行情 2. PE>100 或 PB<0.8 时必须标注风险 3. 涨停>30只时提示市场情绪亢奋 4. 北向净流出>50亿时提示外资撤离
{
"ownerId": "local",
"slug": "trading-quant",
"version": "1.0.0",
"publishedAt": 1772117606781
}__pycache__/
*.py[cod]
.DS_Store
*.log
venv/
*.bak.*
cache/
pandas>=2.0.0
pandas-ta>=0.3.14b
httpx>=0.27.0
aiohttp>=3.9.0
pyyaml>=6.0
python-dateutil>=2.9.0
#!/bin/bash
# Parallel batch data fetching for cron tasks
# Usage: bash batch_fetch.sh [profile]
# Profiles: closing (收盘), daily (日报), weekly (周报), morning (晨报), macro
PYTHON=/opt/homebrew/bin/python3.12
QUANT=/Users/study/.openclaw/workspace-trading/skills/trading-quant/scripts/quant.py
OUT=/tmp/quant_batch_$$
mkdir -p "$OUT"
profile=${1:-closing}
case "$profile" in
closing)
$PYTHON "$QUANT" stock_analysis > "$OUT/stock.json" 2>/dev/null &
$PYTHON "$QUANT" northbound_flow > "$OUT/northbound.json" 2>/dev/null &
$PYTHON "$QUANT" market_anomaly > "$OUT/anomaly.json" 2>/dev/null &
$PYTHON "$QUANT" news_sentiment > "$OUT/news.json" 2>/dev/null &
$PYTHON "$QUANT" gold_analysis > "$OUT/gold.json" 2>/dev/null &
$PYTHON "$QUANT" margin_data > "$OUT/margin.json" 2>/dev/null &
$PYTHON "$QUANT" lhb > "$OUT/lhb.json" 2>/dev/null &
$PYTHON "$QUANT" top_amount > "$OUT/top_amount.json" 2>/dev/null &
$PYTHON "$QUANT" save_daily > "$OUT/save.json" 2>/dev/null &
;;
daily)
$PYTHON "$QUANT" stock_analysis > "$OUT/stock.json" 2>/dev/null &
$PYTHON "$QUANT" northbound_flow > "$OUT/northbound.json" 2>/dev/null &
$PYTHON "$QUANT" global_overview > "$OUT/global.json" 2>/dev/null &
;;
weekly)
$PYTHON "$QUANT" stock_analysis > "$OUT/stock.json" 2>/dev/null &
$PYTHON "$QUANT" global_overview > "$OUT/global.json" 2>/dev/null &
;;
morning)
$PYTHON "$QUANT" global_overview > "$OUT/global.json" 2>/dev/null &
$PYTHON "$QUANT" northbound_flow > "$OUT/northbound.json" 2>/dev/null &
;;
macro)
$PYTHON "$QUANT" global_overview > "$OUT/global.json" 2>/dev/null &
$PYTHON "$QUANT" northbound_flow > "$OUT/northbound.json" 2>/dev/null &
;;
esac
wait
echo "{"
first=1
for f in "$OUT"/*.json; do
name=$(basename "$f" .json)
[ $first -eq 0 ] && echo ","
echo "\"$name\": $(cat "$f")"
first=0
done
echo "}"
rm -rf "$OUT"
"""Capital flow analysis — volume/turnover/bid-ask based scoring."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from data_sources.base import QuoteData
logger = logging.getLogger(__name__)
@dataclass
class CapitalSignal:
"""Capital flow analysis result."""
score: float = 50.0
signals: list[str] = field(default_factory=list)
metrics: dict = field(default_factory=dict)
def compute_capital(quote: QuoteData, avg_volume: float = 0, avg_amount: float = 0, main_force_data: dict = None) -> CapitalSignal:
"""Compute capital flow score from real-time quote data.
Uses volume ratio, turnover rate, bid-ask spread, and amount anomaly.
avg_volume/avg_amount: 5-day average for comparison.
Args:
main_force_data: 主力资金数据 (来自主力接口)
{
"main_net_inflow_wan": -10653, # 主力净流入 (万)
"super_big_net_wan": -15104, # 超大单
"big_net_wan": 4451, # 大单
"signal": "主力流出"
}
"""
score = 50.0
signals = []
metrics = {}
chg_pct = quote.change_pct if quote.change_pct is not None else 0.0
# Volume ratio (量比)
vr = quote.volume_ratio if quote.volume_ratio is not None else 0
if vr > 0:
metrics["volume_ratio"] = round(vr, 2)
if vr > 5:
score += 8
signals.append(f"量比{vr:.1f}极度放量+8")
elif vr > 3:
score += 5
signals.append(f"量比{vr:.1f}显著放量+5")
elif vr > 1.5:
score += 2
signals.append(f"量比{vr:.1f}温和放量+2")
elif vr < 0.5:
score -= 3
signals.append(f"量比{vr:.1f}缩量-3")
# Turnover rate (换手率)
tr = quote.turnover_rate if quote.turnover_rate is not None else 0
if tr > 0:
metrics["turnover_rate"] = round(tr, 2)
if tr > 15:
score += 3
signals.append(f"换手率{tr:.1f}%高度活跃+3")
elif tr > 8:
score += 1
signals.append(f"换手率{tr:.1f}%活跃+1")
elif tr < 1:
score -= 2
signals.append(f"换手率{tr:.1f}%低迷-2")
# Amount comparison (成交额对比)
if avg_amount > 0 and quote.amount > 0:
amount_ratio = quote.amount / avg_amount
metrics["amount_ratio"] = round(amount_ratio, 2)
if amount_ratio > 3:
score += 5
signals.append(f"成交额{amount_ratio:.1f}倍均值+5")
elif amount_ratio > 1.5:
score += 2
signals.append(f"成交额{amount_ratio:.1f}倍均值+2")
elif amount_ratio < 0.5:
score -= 2
signals.append(f"成交额仅{amount_ratio:.1f}倍均值-2")
# Bid-ask pressure (买卖压力)
if quote.bid1 > 0 and quote.ask1 > 0:
spread = (quote.ask1 - quote.bid1) / quote.bid1 * 100
metrics["spread_pct"] = round(spread, 3)
if spread < 0.05:
score += 2
signals.append("买卖价差极窄 (流动性好)+2")
# Price-volume divergence: price up but volume down => warning
if chg_pct > 2 and vr > 0 and vr < 0.8:
score -= 4
signals.append(f"涨{chg_pct:.1f}%但缩量 (量价背离)-4")
elif chg_pct < -2 and vr > 3:
score -= 3
signals.append(f"跌{chg_pct:.1f}%且放量 (资金出逃)-3")
# 量比 + 方向修正:放量上涨谨慎,放量下跌警惕
if vr > 2:
if chg_pct > 3:
score -= 3
signals.append(f"放量大涨 (量比{vr:.1f})-3(追高风险)")
elif chg_pct < -3:
score -= 5
signals.append(f"放量大跌 (量比{vr:.1f})-5(出逃信号)")
elif chg_pct > 1:
signals.append(f"放量上涨 (量比{vr:.1f},温和)")
# 主力资金净流入 (新增)
if main_force_data and "error" not in main_force_data:
main_net = main_force_data.get("main_net_inflow_wan", 0)
super_big = main_force_data.get("super_big_net_wan", 0)
big = main_force_data.get("big_net_wan", 0)
signal = main_force_data.get("signal", "")
source = main_force_data.get("source", "unknown")
metrics["main_net_inflow_wan"] = round(main_net, 2)
metrics["super_big_net_wan"] = round(super_big, 2)
metrics["big_net_wan"] = round(big, 2)
metrics["main_force_source"] = source
# 主力净流入评分
if main_net > 50000: # >5 亿
score += 15
signals.append(f"主力巨额流入+{main_net/10000:.2f}亿+15[{source}]")
elif main_net > 5000: # >5000 万
score += 10
signals.append(f"主力大幅流入+{main_net/10000:.2f}亿+10[{source}]")
elif main_net > 1000: # >1000 万
score += 5
signals.append(f"主力流入+{main_net/10000:.2f}亿+5[{source}]")
elif main_net < -50000:
score -= 15
signals.append(f"主力巨额流出{main_net/10000:.2f}亿-15[{source}]")
elif main_net < -5000:
score -= 10
signals.append(f"主力大幅流出{main_net/10000:.2f}亿-10[{source}]")
elif main_net < -1000:
score -= 5
signals.append(f"主力流出{main_net/10000:.2f}亿-5[{source}]")
else:
signals.append(f"主力中性 ({main_net/10000:.2f}亿)[{source}]")
# 超大单修正
if super_big > 3000:
score += 3
signals.append(f"超大单流入+{super_big/10000:.2f}亿+3[{source}]")
elif super_big < -3000:
score -= 3
signals.append(f"超大单流出{super_big/10000:.2f}亿-3[{source}]")
# 腾讯内外盘差作为主力替代信号
elif hasattr(quote, 'outer_vol') and hasattr(quote, 'inner_vol'):
outer_vol = getattr(quote, 'outer_vol', 0) or 0
inner_vol = getattr(quote, 'inner_vol', 0) or 0
if outer_vol > 0 or inner_vol > 0:
outer_inner_diff = outer_vol - inner_vol # 手
metrics["outer_vol"] = int(outer_vol)
metrics["inner_vol"] = int(inner_vol)
metrics["outer_inner_diff"] = int(outer_inner_diff)
metrics["main_force_source"] = "tencent_outer_inner"
# 内外盘差评分映射 (考虑量比修正)
base_score = 0
if outer_inner_diff > 100000: # >10 万手
base_score = 8
signals.append(f"外盘强势 +{outer_inner_diff/10000:.1f}万手+8[腾讯]")
elif outer_inner_diff > 50000: # >5 万手
base_score = 5
signals.append(f"外盘偏强 +{outer_inner_diff/10000:.1f}万手+5[腾讯]")
elif outer_inner_diff < -100000:
base_score = -8
signals.append(f"内盘强势 {outer_inner_diff/10000:.1f}万手 -8[腾讯]")
elif outer_inner_diff < -50000:
base_score = -5
signals.append(f"内盘偏强 {outer_inner_diff/10000:.1f}万手 -5[腾讯]")
else:
signals.append(f"内外盘平衡 (差{outer_inner_diff/10000:.1f}万手)[腾讯]")
# 量比修正 (放量更可信)
if vr > 3:
base_score = int(base_score * 1.3)
signals.append(f"显著放量,可信度 +30%")
elif vr > 1.5:
base_score = int(base_score * 1.1)
signals.append(f"温和放量,可信度 +10%")
elif vr < 0.8:
base_score = int(base_score * 0.7)
signals.append(f"缩量,可信度 -30%")
score += base_score
else:
metrics["main_force_source"] = "missing"
signals.append("主力资金数据缺失 (东方财富不稳定,AKShare 未安装)")
else:
# 主力数据缺失,标注数据来源状态
metrics["main_force_source"] = "missing"
signals.append("主力资金数据缺失 (东方财富不稳定,AKShare 未安装)")
return CapitalSignal(
score=max(0, min(100, score)),
signals=signals,
metrics=metrics,
)
"""Capital flow analysis — volume/turnover/bid-ask based scoring."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from data_sources.base import QuoteData
logger = logging.getLogger(__name__)
@dataclass
class CapitalSignal:
"""Capital flow analysis result."""
score: float = 50.0
signals: list[str] = field(default_factory=list)
metrics: dict = field(default_factory=dict)
def compute_capital(quote: QuoteData, avg_volume: float = 0, avg_amount: float = 0, main_force_data: dict = None) -> CapitalSignal:
"""Compute capital flow score from real-time quote data.
Uses volume ratio, turnover rate, bid-ask spread, and amount anomaly.
avg_volume/avg_amount: 5-day average for comparison.
Args:
main_force_data: 主力资金数据 (来自主力接口)
{
"main_net_inflow_wan": -10653, # 主力净流入 (万)
"super_big_net_wan": -15104, # 超大单
"big_net_wan": 4451, # 大单
"signal": "主力流出"
}
"""
score = 50.0
signals = []
metrics = {}
chg_pct = quote.change_pct if quote.change_pct is not None else 0.0
# Volume ratio (量比)
vr = quote.volume_ratio if quote.volume_ratio is not None else 0
if vr > 0:
metrics["volume_ratio"] = round(vr, 2)
if vr > 5:
score += 8
signals.append(f"量比{vr:.1f}极度放量+8")
elif vr > 3:
score += 5
signals.append(f"量比{vr:.1f}显著放量+5")
elif vr > 1.5:
score += 2
signals.append(f"量比{vr:.1f}温和放量+2")
elif vr < 0.5:
score -= 3
signals.append(f"量比{vr:.1f}缩量-3")
# Turnover rate (换手率)
tr = quote.turnover_rate
if tr > 0:
metrics["turnover_rate"] = round(tr, 2)
if tr > 15:
score += 3
signals.append(f"换手率{tr:.1f}%高度活跃+3")
elif tr > 8:
score += 1
signals.append(f"换手率{tr:.1f}%活跃+1")
elif tr < 1:
score -= 2
signals.append(f"换手率{tr:.1f}%低迷-2")
# Amount comparison (成交额对比)
if avg_amount > 0 and quote.amount > 0:
amount_ratio = quote.amount / avg_amount
metrics["amount_ratio"] = round(amount_ratio, 2)
if amount_ratio > 3:
score += 5
signals.append(f"成交额{amount_ratio:.1f}倍均值+5")
elif amount_ratio > 1.5:
score += 2
signals.append(f"成交额{amount_ratio:.1f}倍均值+2")
elif amount_ratio < 0.5:
score -= 2
signals.append(f"成交额仅{amount_ratio:.1f}倍均值-2")
# Bid-ask pressure (买卖压力)
if quote.bid1 > 0 and quote.ask1 > 0:
spread = (quote.ask1 - quote.bid1) / quote.bid1 * 100
metrics["spread_pct"] = round(spread, 3)
if spread < 0.05:
score += 2
signals.append("买卖价差极窄 (流动性好)+2")
# Price-volume divergence: price up but volume down => warning
if chg_pct > 2 and vr > 0 and vr < 0.8:
score -= 4
signals.append(f"涨{chg_pct:.1f}%但缩量 (量价背离)-4")
elif chg_pct < -2 and vr > 3:
score -= 3
signals.append(f"跌{chg_pct:.1f}%且放量 (资金出逃)-3")
# 量比 + 方向修正:放量上涨谨慎,放量下跌警惕
if quote.volume_ratio > 2:
if chg_pct > 3:
score -= 3
signals.append(f"放量大涨 (量比{quote.volume_ratio:.1f})-3(追高风险)")
elif chg_pct < -3:
score -= 5
signals.append(f"放量大跌 (量比{quote.volume_ratio:.1f})-5(出逃信号)")
elif chg_pct > 1:
signals.append(f"放量上涨 (量比{quote.volume_ratio:.1f},温和)")
# 主力资金净流入 (新增)
if main_force_data and "error" not in main_force_data:
main_net = main_force_data.get("main_net_inflow_wan", 0)
super_big = main_force_data.get("super_big_net_wan", 0)
big = main_force_data.get("big_net_wan", 0)
signal = main_force_data.get("signal", "")
source = main_force_data.get("source", "unknown")
metrics["main_net_inflow_wan"] = round(main_net, 2)
metrics["super_big_net_wan"] = round(super_big, 2)
metrics["big_net_wan"] = round(big, 2)
metrics["main_force_source"] = source
# 主力净流入评分
if main_net > 50000: # >5 亿
score += 15
signals.append(f"主力巨额流入+{main_net/10000:.2f}亿+15[{source}]")
elif main_net > 5000: # >5000 万
score += 10
signals.append(f"主力大幅流入+{main_net/10000:.2f}亿+10[{source}]")
elif main_net > 1000: # >1000 万
score += 5
signals.append(f"主力流入+{main_net/10000:.2f}亿+5[{source}]")
elif main_net < -50000:
score -= 15
signals.append(f"主力巨额流出{main_net/10000:.2f}亿-15[{source}]")
elif main_net < -5000:
score -= 10
signals.append(f"主力大幅流出{main_net/10000:.2f}亿-10[{source}]")
elif main_net < -1000:
score -= 5
signals.append(f"主力流出{main_net/10000:.2f}亿-5[{source}]")
else:
signals.append(f"主力中性 ({main_net/10000:.2f}亿)[{source}]")
# 超大单修正
if super_big > 3000:
score += 3
signals.append(f"超大单流入+{super_big/10000:.2f}亿+3[{source}]")
elif super_big < -3000:
score -= 3
signals.append(f"超大单流出{super_big/10000:.2f}亿-3[{source}]")
# 腾讯内外盘差作为主力替代信号
elif hasattr(quote, 'outer_vol') and hasattr(quote, 'inner_vol'):
outer_vol = getattr(quote, 'outer_vol', 0) or 0
inner_vol = getattr(quote, 'inner_vol', 0) or 0
if outer_vol > 0 or inner_vol > 0:
outer_inner_diff = outer_vol - inner_vol # 手
metrics["outer_vol"] = int(outer_vol)
metrics["inner_vol"] = int(inner_vol)
metrics["outer_inner_diff"] = int(outer_inner_diff)
metrics["main_force_source"] = "tencent_outer_inner"
# 内外盘差评分映射 (考虑量比修正)
base_score = 0
if outer_inner_diff > 100000: # >10 万手
base_score = 8
signals.append(f"外盘强势 +{outer_inner_diff/10000:.1f}万手+8[腾讯]")
elif outer_inner_diff > 50000: # >5 万手
base_score = 5
signals.append(f"外盘偏强 +{outer_inner_diff/10000:.1f}万手+5[腾讯]")
elif outer_inner_diff < -100000:
base_score = -8
signals.append(f"内盘强势 {outer_inner_diff/10000:.1f}万手 -8[腾讯]")
elif outer_inner_diff < -50000:
base_score = -5
signals.append(f"内盘偏强 {outer_inner_diff/10000:.1f}万手 -5[腾讯]")
else:
signals.append(f"内外盘平衡 (差{outer_inner_diff/10000:.1f}万手)[腾讯]")
# 量比修正 (放量更可信)
if quote.volume_ratio > 3:
base_score = int(base_score * 1.3)
signals.append(f"显著放量,可信度 +30%")
elif quote.volume_ratio > 1.5:
base_score = int(base_score * 1.1)
signals.append(f"温和放量,可信度 +10%")
elif quote.volume_ratio < 0.8:
base_score = int(base_score * 0.7)
signals.append(f"缩量,可信度 -30%")
score += base_score
else:
metrics["main_force_source"] = "missing"
signals.append("主力资金数据缺失 (东方财富不稳定,AKShare 未安装)")
else:
# 主力数据缺失,标注数据来源状态
metrics["main_force_source"] = "missing"
signals.append("主力资金数据缺失 (东方财富不稳定,AKShare 未安装)")
return CapitalSignal(
score=max(0, min(100, score)),
signals=signals,
metrics=metrics,
)
"""Capital flow analysis — volume/turnover/bid-ask based scoring."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from data_sources.base import QuoteData
logger = logging.getLogger(__name__)
@dataclass
class CapitalSignal:
"""Capital flow analysis result."""
score: float = 50.0
signals: list[str] = field(default_factory=list)
metrics: dict = field(default_factory=dict)
def compute_capital(quote: QuoteData, avg_volume: float = 0, avg_amount: float = 0, main_force_data: dict = None) -> CapitalSignal:
"""Compute capital flow score from real-time quote data.
Uses volume ratio, turnover rate, bid-ask spread, and amount anomaly.
avg_volume/avg_amount: 5-day average for comparison.
Args:
main_force_data: 主力资金数据 (来自主力接口)
{
"main_net_inflow_wan": -10653, # 主力净流入 (万)
"super_big_net_wan": -15104, # 超大单
"big_net_wan": 4451, # 大单
"signal": "主力流出"
}
"""
score = 50.0
signals = []
metrics = {}
chg_pct = quote.change_pct if quote.change_pct is not None else 0.0
# Volume ratio (量比)
vr = quote.volume_ratio if quote.volume_ratio is not None else 0
if vr > 0:
metrics["volume_ratio"] = round(vr, 2)
if vr > 5:
score += 8
signals.append(f"量比{vr:.1f}极度放量+8")
elif vr > 3:
score += 5
signals.append(f"量比{vr:.1f}显著放量+5")
elif vr > 1.5:
score += 2
signals.append(f"量比{vr:.1f}温和放量+2")
elif vr < 0.5:
score -= 3
signals.append(f"量比{vr:.1f}缩量-3")
# Turnover rate (换手率)
tr = quote.turnover_rate if quote.turnover_rate is not None else 0
if tr > 0:
metrics["turnover_rate"] = round(tr, 2)
if tr > 15:
score += 3
signals.append(f"换手率{tr:.1f}%高度活跃+3")
elif tr > 8:
score += 1
signals.append(f"换手率{tr:.1f}%活跃+1")
elif tr < 1:
score -= 2
signals.append(f"换手率{tr:.1f}%低迷-2")
# Amount comparison (成交额对比)
if avg_amount > 0 and quote.amount > 0:
amount_ratio = quote.amount / avg_amount
metrics["amount_ratio"] = round(amount_ratio, 2)
if amount_ratio > 3:
score += 5
signals.append(f"成交额{amount_ratio:.1f}倍均值+5")
elif amount_ratio > 1.5:
score += 2
signals.append(f"成交额{amount_ratio:.1f}倍均值+2")
elif amount_ratio < 0.5:
score -= 2
signals.append(f"成交额仅{amount_ratio:.1f}倍均值-2")
# Bid-ask pressure (买卖压力)
if quote.bid1 > 0 and quote.ask1 > 0:
spread = (quote.ask1 - quote.bid1) / quote.bid1 * 100
metrics["spread_pct"] = round(spread, 3)
if spread < 0.05:
score += 2
signals.append("买卖价差极窄 (流动性好)+2")
# Price-volume divergence: price up but volume down => warning
if chg_pct > 2 and vr > 0 and vr < 0.8:
score -= 4
signals.append(f"涨{chg_pct:.1f}%但缩量 (量价背离)-4")
elif chg_pct < -2 and vr > 3:
score -= 3
signals.append(f"跌{chg_pct:.1f}%且放量 (资金出逃)-3")
# 量比 + 方向修正:放量上涨谨慎,放量下跌警惕
if quote.volume_ratio > 2:
if chg_pct > 3:
score -= 3
signals.append(f"放量大涨 (量比{quote.volume_ratio:.1f})-3(追高风险)")
elif chg_pct < -3:
score -= 5
signals.append(f"放量大跌 (量比{quote.volume_ratio:.1f})-5(出逃信号)")
elif chg_pct > 1:
signals.append(f"放量上涨 (量比{quote.volume_ratio:.1f},温和)")
# 主力资金净流入 (新增)
if main_force_data and "error" not in main_force_data:
main_net = main_force_data.get("main_net_inflow_wan", 0)
super_big = main_force_data.get("super_big_net_wan", 0)
big = main_force_data.get("big_net_wan", 0)
signal = main_force_data.get("signal", "")
source = main_force_data.get("source", "unknown")
metrics["main_net_inflow_wan"] = round(main_net, 2)
metrics["super_big_net_wan"] = round(super_big, 2)
metrics["big_net_wan"] = round(big, 2)
metrics["main_force_source"] = source
# 主力净流入评分
if main_net > 50000: # >5 亿
score += 15
signals.append(f"主力巨额流入+{main_net/10000:.2f}亿+15[{source}]")
elif main_net > 5000: # >5000 万
score += 10
signals.append(f"主力大幅流入+{main_net/10000:.2f}亿+10[{source}]")
elif main_net > 1000: # >1000 万
score += 5
signals.append(f"主力流入+{main_net/10000:.2f}亿+5[{source}]")
elif main_net < -50000:
score -= 15
signals.append(f"主力巨额流出{main_net/10000:.2f}亿-15[{source}]")
elif main_net < -5000:
score -= 10
signals.append(f"主力大幅流出{main_net/10000:.2f}亿-10[{source}]")
elif main_net < -1000:
score -= 5
signals.append(f"主力流出{main_net/10000:.2f}亿-5[{source}]")
else:
signals.append(f"主力中性 ({main_net/10000:.2f}亿)[{source}]")
# 超大单修正
if super_big > 3000:
score += 3
signals.append(f"超大单流入+{super_big/10000:.2f}亿+3[{source}]")
elif super_big < -3000:
score -= 3
signals.append(f"超大单流出{super_big/10000:.2f}亿-3[{source}]")
# 腾讯内外盘差作为主力替代信号
elif hasattr(quote, 'outer_vol') and hasattr(quote, 'inner_vol'):
outer_vol = getattr(quote, 'outer_vol', 0) or 0
inner_vol = getattr(quote, 'inner_vol', 0) or 0
if outer_vol > 0 or inner_vol > 0:
outer_inner_diff = outer_vol - inner_vol # 手
metrics["outer_vol"] = int(outer_vol)
metrics["inner_vol"] = int(inner_vol)
metrics["outer_inner_diff"] = int(outer_inner_diff)
metrics["main_force_source"] = "tencent_outer_inner"
# 内外盘差评分映射 (考虑量比修正)
base_score = 0
if outer_inner_diff > 100000: # >10 万手
base_score = 8
signals.append(f"外盘强势 +{outer_inner_diff/10000:.1f}万手+8[腾讯]")
elif outer_inner_diff > 50000: # >5 万手
base_score = 5
signals.append(f"外盘偏强 +{outer_inner_diff/10000:.1f}万手+5[腾讯]")
elif outer_inner_diff < -100000:
base_score = -8
signals.append(f"内盘强势 {outer_inner_diff/10000:.1f}万手 -8[腾讯]")
elif outer_inner_diff < -50000:
base_score = -5
signals.append(f"内盘偏强 {outer_inner_diff/10000:.1f}万手 -5[腾讯]")
else:
signals.append(f"内外盘平衡 (差{outer_inner_diff/10000:.1f}万手)[腾讯]")
# 量比修正 (放量更可信)
if quote.volume_ratio > 3:
base_score = int(base_score * 1.3)
signals.append(f"显著放量,可信度 +30%")
elif quote.volume_ratio > 1.5:
base_score = int(base_score * 1.1)
signals.append(f"温和放量,可信度 +10%")
elif quote.volume_ratio < 0.8:
base_score = int(base_score * 0.7)
signals.append(f"缩量,可信度 -30%")
score += base_score
else:
metrics["main_force_source"] = "missing"
signals.append("主力资金数据缺失 (东方财富不稳定,AKShare 未安装)")
else:
# 主力数据缺失,标注数据来源状态
metrics["main_force_source"] = "missing"
signals.append("主力资金数据缺失 (东方财富不稳定,AKShare 未安装)")
return CapitalSignal(
score=max(0, min(100, score)),
signals=signals,
metrics=metrics,
)
"""行业分类模块 - 完全依赖券商接口获取准确数据."""
import json
import logging
from typing import Optional, Dict
logger = logging.getLogger(__name__)
# 持久化缓存行业数据
_CACHE_FILE = "/tmp/quant_industry_cache.json"
_industry_cache: Dict[str, str] = {}
_cache_loaded = False
def _load_file_cache():
global _industry_cache, _cache_loaded
if _cache_loaded:
return
try:
import os
if os.path.exists(_CACHE_FILE):
with open(_CACHE_FILE) as f:
_industry_cache = json.load(f)
except Exception:
pass
if not _industry_cache:
_prefill_from_watchlist()
_cache_loaded = True
def _prefill_from_watchlist():
"""从 watchlist.json 预填充行业分类(自选股行业不变,无需网络请求)"""
global _industry_cache
try:
import os
wl_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..", "knowledge", "watchlist.json")
wl_path = os.path.normpath(wl_path)
if not os.path.exists(wl_path):
return
with open(wl_path) as f:
wl = json.load(f)
for cat in ("priority", "observe", "research"):
for item in wl.get(cat, []):
if isinstance(item, dict):
code = item.get("code", "")
sector = item.get("sector", "")
if code and sector:
_industry_cache[code] = _normalize_industry_name(sector.split("/")[0])
if _industry_cache:
_save_file_cache()
logger.info(f"Pre-filled {len(_industry_cache)} industries from watchlist")
except Exception as e:
logger.debug(f"Watchlist prefill failed: {e}")
def _save_file_cache():
try:
import json as _json
with open(_CACHE_FILE, "w") as f:
_json.dump(_industry_cache, f, ensure_ascii=False)
except Exception:
pass
def classify_industry(code: str) -> str:
"""
使用券商/数据接口获取准确的行业分类.
优先级:
1. 东方财富接口 (eastmoney) - 最可靠
2. akshare 股票基本信息
3. 同花顺接口 (ths)
如果所有接口都失败,返回 "unknown",不进行猜测.
Args:
code: 股票代码
Returns:
行业分类名称,失败返回 "unknown"
"""
_load_file_cache()
if code in _industry_cache:
return _industry_cache[code]
industry = None
# 尝试 1: 东方财富接口 (akshare)
try:
industry = _get_industry_from_eastmoney(code)
if industry and industry != "unknown":
_industry_cache[code] = industry
_save_file_cache()
logger.debug(f"Got industry for {code} from EastMoney: {industry}")
return industry
except Exception as e:
logger.debug(f"EastMoney industry fetch failed for {code}: {e}")
# 尝试 2: akshare 股票基本信息(拉取全市场数据,性能差,降级跳过)
# _get_industry_from_akshare 会调用 stock_zh_a_spot_em() 拉取 5000+ 股票
# 东方财富个股接口已足够,无需全市场查询
# 尝试 3: 同花顺接口(非常慢,遍历所有行业板块,降级跳过)
# 注释掉以避免性能问题:对每只股票遍历所有行业板块是O(N*M)复杂度
# try:
# industry = _get_industry_from_ths(code)
# ...
# 所有接口都失败,返回 unknown
logger.warning(f"Failed to get industry for {code} from all sources")
_industry_cache[code] = "unknown"
_save_file_cache()
return "unknown"
def _get_industry_from_eastmoney(code: str) -> Optional[str]:
"""从东方财富获取行业分类(通过akshare)."""
try:
import akshare as ak
# 使用东方财富个股信息接口
df = ak.stock_individual_info_em(symbol=code)
if df is not None and not df.empty:
# 查找行业信息
industry_row = df[df['item'] == '行业']
if not industry_row.empty:
industry = industry_row['value'].values[0]
if industry and industry != '-' and industry != 'None':
return _normalize_industry_name(industry)
return "unknown"
except Exception as e:
logger.debug(f"Failed to get EastMoney industry for {code}: {e}")
return "unknown"
def _get_industry_from_akshare(code: str) -> Optional[str]:
"""从 akshare 获取行业分类."""
try:
import akshare as ak
# 使用股票基本信息接口
df = ak.stock_zh_a_spot_em()
if df is not None and not df.empty:
stock_row = df[df['代码'] == code]
if not stock_row.empty:
# 尝试获取行业列
if '行业' in stock_row.columns:
industry = stock_row['行业'].values[0]
if industry and industry != '-' and pd.notna(industry):
return _normalize_industry_name(industry)
return "unknown"
except Exception as e:
logger.debug(f"Failed to get AKShare industry for {code}: {e}")
return "unknown"
def _get_industry_from_ths(code: str) -> Optional[str]:
"""从同花顺获取行业分类."""
try:
import akshare as ak
# 使用同花顺行业数据接口
# 获取所有行业板块
df = ak.stock_board_industry_name_ths()
if df is not None and not df.empty:
# 遍历行业,查找成分股
for _, row in df.iterrows():
industry_name = row.get('名称', '')
if not industry_name:
continue
try:
# 获取该行业的成分股
stocks_df = ak.stock_board_industry_cons_ths(symbol=industry_name)
if stocks_df is not None and not stocks_df.empty:
# 检查目标股票是否在该行业
if '代码' in stocks_df.columns:
if code in stocks_df['代码'].values:
return _normalize_industry_name(industry_name)
elif 'symbol' in stocks_df.columns:
if code in stocks_df['symbol'].values:
return _normalize_industry_name(industry_name)
except Exception:
continue
return "unknown"
except Exception as e:
logger.debug(f"Failed to get THS industry for {code}: {e}")
return "unknown"
def _normalize_industry_name(industry: str) -> str:
"""
标准化行业名称到内部分类.
将各种数据源返回的行业名称统一为内部标准名称.
"""
if not industry or industry == '-':
return "unknown"
industry = str(industry).strip()
# 直接映射表
industry_map = {
# 银行
"银行": "银行", "商业银行": "银行", "股份制银行": "银行", "城商行": "银行",
# 地产
"房地产": "地产", "房地产开发": "地产", "物业管理": "地产", "商业地产": "地产",
# 钢铁
"钢铁": "钢铁", "普钢": "钢铁", "特钢": "钢铁", "钢铁制品": "钢铁",
# 煤炭
"煤炭": "煤炭", "煤炭开采": "煤炭", "焦炭": "煤炭", "煤化工": "煤炭",
# 石油
"石油": "石油", "石油化工": "石油", "油气开采": "石油", "炼油": "石油",
# 电力
"电力": "电力", "火电": "电力", "水电": "电力", "核电": "电力",
"风电": "新能源", "光伏": "新能源", "电力设备": "新能源",
# 有色
"有色金属": "有色", "贵金属": "有色", "工业金属": "有色", "稀有金属": "有色",
"铜": "有色", "铝": "有色", "锌": "有色", "镍": "有色", "钴": "有色", "锂": "有色",
# 化工
"化工": "化工", "化学制品": "化工", "化学原料": "化工", "化肥": "化工",
"农药": "化工", "化纤": "化工", "塑料": "化工", "橡胶": "化工",
# 消费
"食品加工": "消费", "饮料制造": "消费", "休闲食品": "消费", "调味品": "消费",
"乳业": "消费", "肉制品": "消费", "啤酒": "消费", "食品饮料": "消费",
# 白酒
"白酒": "白酒", "白酒Ⅱ": "白酒", "白酒Ⅲ": "白酒", "酿酒": "白酒",
# 医药
"医药": "医药", "生物医药": "医药", "化学制药": "医药", "中药": "医药",
"医疗器械": "医药", "医疗服务": "医药", "疫苗": "医药", "生物制品": "医药",
# 科技
"计算机": "科技", "软件": "科技", "IT服务": "科技", "互联网": "科技",
"通信": "科技", "通信设备": "科技", "传媒": "科技", "电子": "半导体",
# 半导体
"半导体": "半导体", "集成电路": "半导体", "芯片": "半导体",
"元件": "半导体", "光学光电子": "半导体",
# 军工
"国防军工": "军工", "军工": "军工", "航天": "军工", "航空": "军工",
"船舶": "军工", "兵器": "军工",
# 新能源
"新能源": "新能源", "电池": "新能源", "锂电池": "新能源", "储能": "新能源",
"光伏设备": "新能源", "风电设备": "新能源", "新能源汽车": "新能源",
"电网设备": "新能源",
# ETF
"ETF": "ETF",
}
# 直接映射
if industry in industry_map:
return industry_map[industry]
# 部分匹配
for key, value in industry_map.items():
if key in industry:
return value
# 无法识别的行业,返回原始值(小写处理)
logger.debug(f"Unrecognized industry name: {industry}, using as-is")
return industry
def clear_industry_cache():
"""清除行业分类缓存."""
global _industry_cache
_industry_cache.clear()
def get_cached_industries() -> Dict[str, str]:
"""获取已缓存的行业分类."""
return _industry_cache.copy()
# 兼容性别名
classify_industry_by_api = classify_industry"""TradingScore V2 — multi-dimensional stock scoring engine."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from config import get_config
from data_sources.base import QuoteData
from .technical import TechnicalSignal, compute_technical
from .capital_flow import CapitalSignal, compute_capital
from .industry_classifier import classify_industry_by_api, classify_industry
logger = logging.getLogger(__name__)
@dataclass
class StockScore:
"""Complete multi-dimensional score for a stock."""
code: str
name: str
price: float
change_pct: float
total_score: float = 50.0
technical: dict = field(default_factory=dict)
capital: dict = field(default_factory=dict)
sentiment: dict = field(default_factory=dict)
fundamental: dict = field(default_factory=dict)
market: dict = field(default_factory=dict)
signal: str = "WATCH"
confidence: str = "中"
risk_alerts: list[str] = field(default_factory=list)
data_freshness: str = "fresh"
def to_dict(self) -> dict:
return {
"code": self.code,
"name": self.name,
"price": self.price,
"change_pct": self.change_pct,
"score": {
"total": round(self.total_score, 1),
"technical": self.technical,
"capital": self.capital,
"fundamental": self.fundamental,
"sentiment": self.sentiment,
"market": self.market,
},
"signal": self.signal,
"confidence": self.confidence,
"risk_alerts": self.risk_alerts,
"data_freshness": self.data_freshness,
}
def _get_signal(score: float) -> tuple[str, str]:
"""Map score to signal level and confidence."""
cfg = get_config().get("scoring", {}).get("signals", {})
if score >= cfg.get("strong_buy", 80):
return "STRONG_BUY", "高"
elif score >= cfg.get("buy", 65):
return "BUY", "中高"
elif score >= cfg.get("watch", 50):
return "WATCH", "中"
elif score >= cfg.get("hold", 35):
return "HOLD", "中低"
elif score >= cfg.get("sell", 22):
return "SELL", "低"
elif score >= cfg.get("strong_sell", 18):
return "STRONG_SELL", "极低"
else:
return "STRONG_SELL", "极低"
INDUSTRY_PE_RANGES = {
"银行": {"undervalued": 5, "fair": 8, "high": 12, "extreme": 20},
"地产": {"undervalued": 5, "fair": 10, "high": 15, "extreme": 25},
"钢铁": {"undervalued": 5, "fair": 10, "high": 15, "extreme": 25},
"煤炭": {"undervalued": 5, "fair": 8, "high": 12, "extreme": 20},
"石油": {"undervalued": 6, "fair": 12, "high": 18, "extreme": 30},
"电力": {"undervalued": 8, "fair": 15, "high": 25, "extreme": 40},
"有色": {"undervalued": 8, "fair": 15, "high": 25, "extreme": 45},
"化工": {"undervalued": 8, "fair": 15, "high": 25, "extreme": 40},
"消费": {"undervalued": 15, "fair": 25, "high": 40, "extreme": 60},
"白酒": {"undervalued": 15, "fair": 30, "high": 45, "extreme": 70},
"医药": {"undervalued": 15, "fair": 30, "high": 50, "extreme": 80},
"科技": {"undervalued": 15, "fair": 35, "high": 60, "extreme": 100},
"半导体": {"undervalued": 15, "fair": 40, "high": 70, "extreme": 120},
"军工": {"undervalued": 20, "fair": 40, "high": 70, "extreme": 100},
"新能源": {"undervalued": 15, "fair": 30, "high": 50, "extreme": 80},
"ETF": {"undervalued": 0, "fair": 999, "high": 999, "extreme": 999},
"default": {"undervalued": 10, "fair": 20, "high": 40, "extreme": 80},
}
INDUSTRY_KEYWORDS = {
"银行": ["银行", "Bank"],
"地产": ["地产", "置业", "置地"],
"钢铁": ["钢铁", "钢材", "不锈钢"],
"煤炭": ["煤炭", "煤业", "能源"],
"石油": ["石油", "石化", "中海油"],
"电力": ["电力", "电网", "发电"],
"有色": ["有色", "铜", "铝", "锌", "镍", "锡", "铅", "银锡", "矿业", "紫金"],
"化工": ["化工", "化学"],
"消费": ["食品", "饮料", "乳业", "调味"],
"白酒": ["茅台", "五粮液", "洋河", "泸州", "汾酒", "酒"],
"医药": ["医药", "生物", "制药", "疫苗"],
"科技": ["科技", "软件", "信息", "数据", "AI", "智能"],
"半导体": ["芯片", "半导体", "存储", "光刻"],
"军工": ["军工", "航天", "航空", "兵器", "船舶", "导弹", "国防"],
"新能源": ["光伏", "风电", "锂电", "新能源", "电池", "储能", "金风"],
"ETF": ["ETF"],
}
# 保留旧函数别名以兼容现有代码,但标记为废弃
def _classify_industry(name: str) -> str:
"""
[废弃] 根据股票名称推断行业分类.
请使用 classify_industry_by_api(code, name) 替代.
"""
from .industry_classifier import _classify_industry_by_name
return _classify_industry_by_name(name)
def _get_pe_ranges(industry: str) -> dict:
"""获取行业PE合理区间."""
return INDUSTRY_PE_RANGES.get(industry, INDUSTRY_PE_RANGES["default"])
def _compute_fundamental(quote: QuoteData) -> dict:
"""Compute fundamental score from PE/PB data."""
score = 50.0
signals = []
pe = quote.pe if quote.pe is not None else 0
pb = quote.pb if quote.pb is not None else 0
if pe <= 0:
signals.append("PE数据缺失(中性)")
return {"score": score, "signals": signals, "pe": pe, "pb": pb}
# PE scoring with industry classification
industry = classify_industry(quote.code)
pe_ranges = _get_pe_ranges(industry)
if pe < pe_ranges["undervalued"]:
score += 15
signals.append(f"PE={pe:.1f}(低估值,{industry})")
elif pe < pe_ranges["fair"]:
score += 8
signals.append(f"PE={pe:.1f}(合理,{industry})")
elif pe < pe_ranges["high"]:
signals.append(f"PE={pe:.1f}(偏高,{industry})")
elif pe < pe_ranges["extreme"]:
score -= 8
signals.append(f"PE={pe:.1f}(高估,{industry})")
else:
score -= 15
signals.append(f"PE={pe:.1f}(极高估,{industry})")
# PB scoring
if pb > 0:
if pb < 1:
score += 10
signals.append(f"PB={pb:.2f}(破净)")
elif pb < 2:
score += 5
signals.append(f"PB={pb:.2f}(低PB)")
elif pb > 10:
score -= 5
signals.append(f"PB={pb:.2f}(高PB)")
score = max(0, min(100, score))
return {"score": round(score, 1), "signals": signals, "pe": pe, "pb": pb, "market_cap": quote.market_cap}
def compute_stock_score(
quote: QuoteData,
daily_df: Any,
avg_volume: float = 0,
avg_amount: float = 0,
extra: dict = None,
capital_flow_data: dict = None, # 新增:主力资金数据
) -> StockScore:
"""Compute TradingScore V2 for a single stock.
Combines technical (30%), capital (35%), sentiment (20%), market (15%).
Sentiment and market scores default to neutral when data unavailable.
Args:
capital_flow_data: 主力资金数据 (来自主力接口或龙虎榜)
"""
if extra is None:
extra = {}
change_pct = quote.change_pct if quote.change_pct is not None else 0.0
cfg = get_config().get("scoring", {}).get("weights", {})
w_tech = cfg.get("technical", 0.25)
w_cap = cfg.get("capital", 0.30)
w_fund = cfg.get("fundamental", 0.10)
w_sent = cfg.get("sentiment", 0.20)
w_mkt = cfg.get("market", 0.15)
w_total = w_tech + w_cap + w_fund + w_sent + w_mkt
if abs(w_total - 1.0) > 0.001:
w_tech /= w_total
w_cap /= w_total
w_fund /= w_total
w_sent /= w_total
w_mkt /= w_total
tech = compute_technical(daily_df)
cap = compute_capital(quote, avg_volume=avg_volume, avg_amount=avg_amount, main_force_data=capital_flow_data)
fund = _compute_fundamental(quote)
sent_score = 50.0
sent_signals = []
news_sentiment = extra.get("news_sentiment", None)
if news_sentiment is not None:
news_count = extra.get("news_count", 0)
# Map news sentiment (-5 to +5) to score (30 to 70)
sent_score = 50.0 + news_sentiment * 4.0
sent_score = max(30.0, min(70.0, sent_score))
if news_sentiment > 0.5:
sent_signals.append(f"新闻偏多({news_count}条,情绪+{news_sentiment})")
elif news_sentiment < -0.5:
sent_signals.append(f"新闻偏空({news_count}条,情绪{news_sentiment})")
else:
sent_signals.append(f"新闻中性({news_count}条)")
top_news = extra.get("top_news", [])
for t in top_news[:1]:
sent_signals.append(f"热点:{t[:20]}")
else:
sent_signals.append("消息面暂无数据(中性)")
mkt_data = extra.get("market_sentiment", None)
if mkt_data and isinstance(mkt_data, dict):
mkt_score = float(mkt_data.get("score", 50.0))
mkt_signals = mkt_data.get("signals", ["市场情绪数据加载中"])
# 大盘连涨天数惩罚(从extra中获取)
consecutive_up_days = extra.get("consecutive_up_days", 0)
if consecutive_up_days >= 5:
mkt_score -= 10
mkt_signals.append(f"大盘连涨{consecutive_up_days}天(过热风险)-10")
elif consecutive_up_days >= 3:
mkt_score -= 5
mkt_signals.append(f"大盘连涨{consecutive_up_days}天(谨慎)-5")
consecutive_down_days = extra.get("consecutive_down_days", 0)
if consecutive_down_days >= 5:
mkt_score += 8
mkt_signals.append(f"大盘连跌{consecutive_down_days}天(超跌反弹概率)+8")
elif consecutive_down_days >= 3:
mkt_score += 4
mkt_signals.append(f"大盘连跌{consecutive_down_days}天(关注底部)+4")
# 北向资金连续净流出
nb_consecutive_outflow = extra.get("nb_consecutive_outflow_days", 0)
if nb_consecutive_outflow >= 5:
mkt_score -= 8
mkt_signals.append(f"北向连续净流出{nb_consecutive_outflow}天(外资撤退)-8")
elif nb_consecutive_outflow >= 3:
mkt_score -= 4
mkt_signals.append(f"北向连续净流出{nb_consecutive_outflow}天(外资谨慎)-4")
mkt_score = max(15, min(85, mkt_score))
else:
mkt_score = 50.0
mkt_signals = ["市场情绪暂无数据(中性)"]
total = (
tech.score * w_tech
+ cap.score * w_cap
+ fund["score"] * w_fund
+ sent_score * w_sent
+ mkt_score * w_mkt
)
# 防追涨杀跌: 动量惩罚 (涨幅越大, 越可能是追涨)
momentum_penalty = 0
chg = abs(change_pct)
if change_pct >= 9.5:
momentum_penalty = 12
elif change_pct >= 7:
momentum_penalty = 6
elif change_pct >= 5:
momentum_penalty = 3
elif change_pct <= -9.5:
momentum_penalty = -8 # 跌停时逆向加分(超跌反弹概率)
elif change_pct <= -7:
momentum_penalty = -4
if momentum_penalty != 0:
total -= momentum_penalty # 涨多扣分, 跌多加分(逆向)
total = max(10, min(90, total))
risk_alerts = _detect_risks(quote, tech, cap, change_pct)
signal, confidence = _get_signal(total)
# RSI超买时限制最高信号为WATCH
rsi_val = tech.indicators.get("rsi", 50)
if rsi_val > 80 and signal in ("STRONG_BUY", "BUY"):
signal = "WATCH"
confidence = "中"
risk_alerts.append(f"RSI={rsi_val:.0f}超买,信号降级为WATCH")
# RSI超卖时保底信号为WATCH
if rsi_val < 20 and signal in ("STRONG_SELL", "SELL"):
signal = "WATCH"
confidence = "中"
risk_alerts.append(f"RSI={rsi_val:.0f}超卖,可能超跌反弹")
data_freshness = "fresh"
if quote.timestamp:
data_freshness = "fresh"
elif daily_df is not None and not daily_df.empty:
data_freshness = "stale"
else:
data_freshness = "cached"
return StockScore(
code=quote.code,
name=quote.name,
price=quote.price,
change_pct=change_pct,
total_score=round(total, 1),
technical={"score": round(tech.score, 1), "signals": tech.signals, "indicators": tech.indicators},
capital={"score": round(cap.score, 1), "signals": cap.signals, "metrics": cap.metrics},
fundamental={"score": fund["score"], "signals": fund["signals"], "pe": fund.get("pe", 0), "pb": fund.get("pb", 0)},
sentiment={"score": sent_score, "signals": sent_signals},
market={"score": mkt_score, "signals": mkt_signals},
signal=signal,
confidence=confidence,
risk_alerts=risk_alerts,
data_freshness=data_freshness,
)
def _detect_risks(quote: QuoteData, tech: TechnicalSignal, cap: CapitalSignal, change_pct: float = 0.0) -> list[str]:
"""Detect risk conditions from combined analysis."""
alerts = []
if change_pct >= 9.5:
alerts.append(f"涨停{change_pct:.1f}%(追高风险)")
elif change_pct >= 7:
alerts.append(f"大涨{change_pct:.1f}%(短期回调风险)")
elif change_pct <= -9.5:
alerts.append(f"跌停{change_pct:.1f}%(恐慌信号)")
elif change_pct <= -5:
alerts.append(f"大跌{change_pct:.1f}%(止损关注)")
rsi = tech.indicators.get("rsi", 50)
if rsi > 80:
alerts.append(f"RSI={rsi:.0f}(极度超买)")
elif rsi < 20:
alerts.append(f"RSI={rsi:.0f}(极度超卖)")
vr = cap.metrics.get("volume_ratio", 1)
if vr > 5 and change_pct > 5:
alerts.append(f"量比{vr:.1f}+涨幅{change_pct:.1f}%(游资炒作可能)")
if hasattr(quote, "pe") and (quote.pe or 0) > 100:
alerts.append(f"PE={quote.pe:.0f}(极高估值风险)")
if hasattr(quote, "pb") and quote.pb is not None and 0 < quote.pb < 0.8:
alerts.append(f"PB={quote.pb:.2f}(破净,注意基本面)")
return alerts
"""金融情感分析模块 - 基于 FinBERT.
该模块提供基于 FinBERT 的金融文本情感分析功能。
支持单条和批量分析,包含延迟加载和 LLM fallback 机制。
"""
import os
import json
from typing import List, Dict, Union, Optional
from dataclasses import dataclass, asdict
from enum import Enum
import logging
logger = logging.getLogger(__name__)
class SentimentLabel(str, Enum):
"""情感标签枚举"""
POSITIVE = "positive"
NEGATIVE = "negative"
NEUTRAL = "neutral"
ERROR = "error"
EMPTY = "empty"
@dataclass
class SentimentResult:
"""情感分析结果.
Attributes:
score: 情感分数,范围 -1.0(极度负面)到 1.0(极度正面)
label: 情感标签(positive/negative/neutral/error/empty)
confidence: 模型置信度,范围 0.0 到 1.0
raw_label: 原始模型输出标签
method: 分析方法(finbert/llm_fallback/rule_based/empty)
"""
score: float = 0.0
label: str = "neutral"
confidence: float = 0.0
raw_label: str = ""
method: str = "unknown"
def to_dict(self) -> Dict:
"""转换为字典."""
return asdict(self)
def is_bullish(self, threshold: float = 0.1) -> bool:
"""判断是否看涨.
Args:
threshold: 看涨阈值,默认 0.1
Returns:
如果情感分数大于阈值返回 True
"""
return self.score > threshold
def is_bearish(self, threshold: float = -0.1) -> bool:
"""判断是否看跌.
Args:
threshold: 看跌阈值,默认 -0.1
Returns:
如果情感分数小于阈值返回 True
"""
return self.score < threshold
class FinBERTSentimentAnalyzer:
"""FinBERT 情感分析器.
使用中文金融新闻 FinBERT 模型进行情感分析。
支持延迟加载、批量处理和多种 fallback 策略。
Example:
>>> analyzer = FinBERTSentimentAnalyzer()
>>> result = analyzer.analyze("央行降息,股市上涨")
>>> print(result.score, result.label)
"""
# 中文金融新闻 FinBERT 模型
DEFAULT_MODEL = "uer/roberta-base-finetuned-chinanews-chinese"
# 备用模型(按优先级排序)
FALLBACK_MODELS = [
"uer/roberta-base-finetuned-jd-binary-chinese",
"uer/roberta-base-finetuned-dianping-chinese",
"uer/roberta-base-finetuned-weibo-chinese",
]
# 情感关键词映射(rule-based fallback)
BULLISH_KEYWORDS = [
"上涨", "涨停", "利好", "增长", "盈利", "突破", "买入", "增持",
"反弹", "创新高", "超预期", "降息", "降准", "刺激", "支持",
"利好", "景气", "扩张", "回升", "改善", "复苏", "牛市",
"rise", "surge", "rally", "bull", "gain", "profit", "growth",
"upgrade", "buy", "outperform", "beat", "positive"
]
BEARISH_KEYWORDS = [
"下跌", "跌停", "利空", "亏损", "跌破", "卖出", "减持",
"回调", "创新低", "不及预期", "加息", "收紧", "风险", "危机",
"衰退", "萎缩", "下滑", "恶化", "熊市", "panic", "crash",
"fall", "drop", "decline", "bear", "loss", "sell", "downgrade",
"underperform", "miss", "negative", "warning"
]
def __init__(
self,
model_name: Optional[str] = None,
device: str = "cpu",
use_mps: bool = False,
cache_dir: Optional[str] = None
):
"""初始化情感分析器.
Args:
model_name: 模型名称,默认使用中文 FinBERT
device: 运行设备,"cpu"、"cuda" 或 "mps"
use_mps: 是否尝试使用 Apple Silicon MPS
cache_dir: HuggingFace 缓存目录
"""
self.model_name = model_name or self.DEFAULT_MODEL
self.device = self._select_device(device, use_mps)
self.cache_dir = cache_dir or os.path.expanduser("~/.cache/huggingface")
self._pipeline = None
self._model_loaded = False
self._model_info = {
"model_name": self.model_name,
"loaded": False,
"device": self.device,
"cache_dir": self.cache_dir,
"fallback_used": False
}
def _select_device(self, device: str, use_mps: bool) -> str:
"""选择最佳可用设备.
Args:
device: 用户指定的设备
use_mps: 是否允许使用 MPS
Returns:
实际使用的设备名称
"""
if device != "auto":
return device
try:
import torch
if torch.cuda.is_available():
return "cuda"
if use_mps and torch.backends.mps.is_available():
return "mps"
except ImportError:
pass
return "cpu"
def _load_model(self) -> bool:
"""延迟加载模型.
Returns:
是否成功加载模型
"""
if self._model_loaded:
return True
try:
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification
logger.info(f"Loading FinBERT model: {self.model_name}")
# 尝试加载主模型
loaded = self._try_load_model(
self.model_name,
AutoTokenizer,
AutoModelForSequenceClassification,
pipeline
)
# 主模型失败,尝试备用模型
if not loaded:
for fallback_model in self.FALLBACK_MODELS:
logger.warning(f"Trying fallback model: {fallback_model}")
loaded = self._try_load_model(
fallback_model,
AutoTokenizer,
AutoModelForSequenceClassification,
pipeline
)
if loaded:
self._model_info["model_name"] = fallback_model
self._model_info["fallback_used"] = True
break
if loaded:
self._model_loaded = True
self._model_info["loaded"] = True
logger.info(f"Model loaded successfully on {self.device}")
return True
else:
logger.error("All models failed to load")
return False
except ImportError as e:
logger.warning(f"transformers not installed: {e}")
return False
except Exception as e:
logger.error(f"Failed to load model: {e}")
return False
def _try_load_model(
self,
model_name: str,
AutoTokenizer,
AutoModelForSequenceClassification,
pipeline
) -> bool:
"""尝试加载单个模型.
Args:
model_name: 模型名称
AutoTokenizer: Tokenizer 类
AutoModelForSequenceClassification: 模型类
Returns:
是否成功加载
"""
try:
# 尝试从本地缓存加载
try:
tokenizer = AutoTokenizer.from_pretrained(
model_name,
local_files_only=True,
cache_dir=self.cache_dir
)
model = AutoModelForSequenceClassification.from_pretrained(
model_name,
local_files_only=True,
cache_dir=self.cache_dir
)
logger.info(f"Model loaded from local cache: {model_name}")
except (OSError, ValueError):
# 从 HuggingFace 下载
logger.info(f"Downloading model: {model_name}")
tokenizer = AutoTokenizer.from_pretrained(
model_name,
cache_dir=self.cache_dir
)
model = AutoModelForSequenceClassification.from_pretrained(
model_name,
cache_dir=self.cache_dir
)
# 确定设备映射
device_id = -1 # CPU default
if self.device == "mps":
import torch
device_id = 0 if torch.backends.mps.is_available() else -1
elif self.device == "cuda":
import torch
device_id = 0 if torch.cuda.is_available() else -1
self._pipeline = pipeline(
"sentiment-analysis",
model=model,
tokenizer=tokenizer,
device=device_id,
truncation=True,
max_length=512
)
return True
except Exception as e:
logger.warning(f"Failed to load {model_name}: {e}")
return False
def analyze(self, text: str) -> SentimentResult:
"""分析单条文本情感.
Args:
text: 待分析文本(新闻标题、摘要等)
Returns:
SentimentResult 包含标准化后的情感分数
"""
if not text or not text.strip():
return SentimentResult(
score=0.0,
label=SentimentLabel.EMPTY,
confidence=0.0,
raw_label="",
method="empty"
)
if not self._load_model():
# 模型加载失败,使用 rule-based fallback
return self._rule_based_fallback(text)
try:
result = self._pipeline(text[:512])
raw = result[0]
label = raw['label'].lower()
confidence = raw['score']
# 映射到 -1.0 ~ 1.0
if 'positive' in label or label == 'positive':
score = confidence
normalized_label = SentimentLabel.POSITIVE
elif 'negative' in label or label == 'negative':
score = -confidence
normalized_label = SentimentLabel.NEGATIVE
else:
score = 0.0
normalized_label = SentimentLabel.NEUTRAL
return SentimentResult(
score=round(score, 3),
label=normalized_label,
confidence=round(confidence, 3),
raw_label=label,
method="finbert"
)
except Exception as e:
logger.error(f"FinBERT analysis failed: {e}")
return self._rule_based_fallback(text)
def analyze_batch(self, texts: List[str]) -> List[SentimentResult]:
"""批量分析文本情感(效率更高).
Args:
texts: 待分析文本列表
Returns:
SentimentResult 列表,与输入顺序一致
"""
if not texts:
return []
if not self._load_model():
return [self._rule_based_fallback(t) for t in texts]
try:
# 记录原始索引,过滤空文本
valid_indices = []
valid_texts = []
for i, t in enumerate(texts):
if t and t.strip():
valid_indices.append(i)
valid_texts.append(t[:512])
if not valid_texts:
return [
SentimentResult(0.0, SentimentLabel.EMPTY, 0.0, "", "empty")
for _ in texts
]
results = self._pipeline(valid_texts)
# 构建完整结果列表
processed = [
SentimentResult(0.0, SentimentLabel.EMPTY, 0.0, "", "empty")
for _ in texts
]
for idx, raw in zip(valid_indices, results):
label = raw['label'].lower()
confidence = raw['score']
if 'positive' in label:
score = confidence
norm_label = SentimentLabel.POSITIVE
elif 'negative' in label:
score = -confidence
norm_label = SentimentLabel.NEGATIVE
else:
score = 0.0
norm_label = SentimentLabel.NEUTRAL
processed[idx] = SentimentResult(
score=round(score, 3),
label=norm_label,
confidence=round(confidence, 3),
raw_label=label,
method="finbert"
)
return processed
except Exception as e:
logger.error(f"Batch analysis failed: {e}")
return [self._rule_based_fallback(t) for t in texts]
def _rule_based_fallback(self, text: str) -> SentimentResult:
"""基于规则的情感分析 fallback.
当 FinBERT 不可用时,使用关键词匹配进行简单情感判断。
Args:
text: 待分析文本
Returns:
SentimentResult
"""
if not text:
return SentimentResult(0.0, SentimentLabel.EMPTY, 0.0, "", "empty")
text_lower = text.lower()
bullish_count = sum(1 for kw in self.BULLISH_KEYWORDS if kw in text_lower)
bearish_count = sum(1 for kw in self.BEARISH_KEYWORDS if kw in text_lower)
total = bullish_count + bearish_count
if total == 0:
return SentimentResult(0.0, SentimentLabel.NEUTRAL, 0.5, "neutral", "rule_based")
# 计算情感分数
score = (bullish_count - bearish_count) / max(total, 3)
score = max(-1.0, min(1.0, score)) # 限制在 -1 到 1
if score > 0.1:
label = SentimentLabel.POSITIVE
elif score < -0.1:
label = SentimentLabel.NEGATIVE
else:
label = SentimentLabel.NEUTRAL
confidence = min(0.7, total / 10) # 关键词越多置信度越高,最高 0.7
return SentimentResult(
score=round(score, 3),
label=label,
confidence=round(confidence, 3),
raw_label="keyword_match",
method="rule_based"
)
def get_model_info(self) -> Dict:
"""获取模型信息.
Returns:
包含模型状态信息的字典
"""
return self._model_info.copy()
def warmup(self) -> bool:
"""预热模型(预加载并执行一次推理).
Returns:
是否成功预热
"""
success = self._load_model()
if success:
# 执行一次虚拟推理确保模型完全加载
_ = self.analyze("测试")
return success
# 单例模式 - 全局复用
_sentiment_analyzer: Optional[FinBERTSentimentAnalyzer] = None
def get_sentiment_analyzer(
model_name: Optional[str] = None,
device: str = "auto"
) -> FinBERTSentimentAnalyzer:
"""获取全局情感分析器实例(单例模式).
Args:
model_name: 模型名称,None 使用默认
device: 运行设备
Returns:
FinBERTSentimentAnalyzer 实例
"""
global _sentiment_analyzer
if _sentiment_analyzer is None:
_sentiment_analyzer = FinBERTSentimentAnalyzer(
model_name=model_name,
device=device
)
return _sentiment_analyzer
def reset_sentiment_analyzer():
"""重置全局分析器(用于测试)."""
global _sentiment_analyzer
_sentiment_analyzer = None
async def analyze_news_sentiment(
title: str,
summary: str = "",
title_weight: float = 0.7
) -> SentimentResult:
"""分析新闻情感(供数据源调用).
优先使用标题,如果有摘要则加权合并分析。
Args:
title: 新闻标题
summary: 新闻摘要
title_weight: 标题权重(0-1),默认 0.7
Returns:
SentimentResult
"""
analyzer = get_sentiment_analyzer()
# 只有标题
if not summary or len(summary) < 10:
return analyzer.analyze(title)
# 标题 + 摘要
title_result = analyzer.analyze(title)
summary_result = analyzer.analyze(summary[:300])
# 加权合并
combined_score = (
title_result.score * title_weight +
summary_result.score * (1 - title_weight)
)
# 确定最终标签
if combined_score > 0.1:
label = SentimentLabel.POSITIVE
elif combined_score < -0.1:
label = SentimentLabel.NEGATIVE
else:
label = SentimentLabel.NEUTRAL
# 置信度取平均
confidence = (title_result.confidence + summary_result.confidence) / 2
return SentimentResult(
score=round(combined_score, 3),
label=label,
confidence=round(confidence, 3),
raw_label=f"title:{title_result.raw_label},summary:{summary_result.raw_label}",
method="finbert_weighted"
)
def analyze_news_batch(
news_items: List[Dict],
text_key: str = "title",
summary_key: str = "summary"
) -> List[Dict]:
"""批量分析新闻列表.
Args:
news_items: 新闻列表,每项是包含 title 和可选 summary 的字典
text_key: 标题字段名
summary_key: 摘要字段名
Returns:
添加 sentiment 相关字段的 news_items
"""
analyzer = get_sentiment_analyzer()
# 构建待分析文本
texts = []
for item in news_items:
title = item.get(text_key, "")
summary = item.get(summary_key, "")
if summary and len(summary) > 10:
text = f"{title}。{summary[:200]}"
else:
text = title
texts.append(text)
results = analyzer.analyze_batch(texts)
# 添加结果到原数据
for item, result in zip(news_items, results):
item["sentiment"] = result.score
item["sentiment_label"] = result.label
item["sentiment_confidence"] = result.confidence
item["sentiment_method"] = result.method
return news_items
def calculate_aggregate_sentiment(
results: List[SentimentResult],
method: str = "mean"
) -> Dict:
"""计算聚合情感.
Args:
results: 情感结果列表
method: 聚合方法,"mean" 或 "weighted"
Returns:
聚合结果字典
"""
if not results:
return {
"score": 0.0,
"label": "neutral",
"confidence": 0.0,
"count": 0
}
if method == "weighted":
# 按置信度加权
total_weight = sum(r.confidence for r in results)
if total_weight == 0:
score = sum(r.score for r in results) / len(results)
else:
score = sum(r.score * r.confidence for r in results) / total_weight
else:
score = sum(r.score for r in results) / len(results)
# 确定聚合标签
if score > 0.1:
label = "bullish"
elif score < -0.1:
label = "bearish"
else:
label = "neutral"
return {
"score": round(score, 3),
"label": label,
"confidence": round(sum(r.confidence for r in results) / len(results), 3),
"count": len(results),
"positive_count": sum(1 for r in results if r.label == SentimentLabel.POSITIVE),
"negative_count": sum(1 for r in results if r.label == SentimentLabel.NEGATIVE),
"neutral_count": sum(1 for r in results if r.label == SentimentLabel.NEUTRAL),
}
"""Technical indicator calculations using pandas-ta."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import pandas as pd
logger = logging.getLogger(__name__)
@dataclass
class TechnicalSignal:
"""Result of technical analysis for a single stock."""
score: float = 0.0
signals: list[str] = field(default_factory=list)
indicators: dict = field(default_factory=dict)
def compute_technical(df: pd.DataFrame) -> TechnicalSignal:
"""Compute technical indicators and generate score from daily K-line data.
Expects columns: date, open, high, low, close, volume.
Returns: TechnicalSignal with score (0-100), signal descriptions, raw indicators.
"""
if df is None or len(df) < 20:
return TechnicalSignal(score=50.0, signals=["数据不足,使用中性评分"])
# 按 date 去重,避免重复数据影响所有指标(MA、RSI、MACD等)
if "date" in df.columns:
df = df.sort_values("date").drop_duplicates(subset=["date"], keep="last").reset_index(drop=True)
if len(df) < 20:
return TechnicalSignal(score=50.0, signals=["数据不足,使用中性评分"])
try:
import pandas_ta as ta
except ImportError:
logger.warning("pandas-ta not installed, using basic calculations")
return _compute_basic(df)
result = TechnicalSignal()
close = df["close"].astype(float)
score = 50.0
# MACD
try:
macd = ta.macd(close)
if macd is not None and not macd.empty:
macd_val = macd.iloc[-1].get("MACD_12_26_9", 0)
macd_signal = macd.iloc[-1].get("MACDs_12_26_9", 0)
macd_hist = macd.iloc[-1].get("MACDh_12_26_9", 0)
result.indicators["macd"] = round(float(macd_val), 3)
result.indicators["macd_signal"] = round(float(macd_signal), 3)
if macd_hist > 0 and (len(macd) < 2 or macd.iloc[-2].get("MACDh_12_26_9", 0) <= 0):
score += 6
result.signals.append(f"MACD金叉+6")
elif macd_hist < 0 and (len(macd) < 2 or macd.iloc[-2].get("MACDh_12_26_9", 0) >= 0):
score -= 6
result.signals.append(f"MACD死叉-6")
elif macd_hist > 0:
score += 2
result.signals.append(f"MACD多头+2")
elif macd_hist < 0:
score -= 2
result.signals.append(f"MACD空头-2")
except Exception as e:
logger.debug(f"MACD calculation error: {e}")
# RSI
try:
rsi = ta.rsi(close, length=14)
if rsi is not None and not rsi.empty:
rsi_val = float(rsi.iloc[-1])
result.indicators["rsi"] = round(rsi_val, 1)
if rsi_val > 80:
score -= 4
result.signals.append(f"RSI={rsi_val:.0f}超买-4")
elif rsi_val > 70:
score -= 2
result.signals.append(f"RSI={rsi_val:.0f}偏高-2")
elif rsi_val < 20:
score += 4
result.signals.append(f"RSI={rsi_val:.0f}超卖+4")
elif rsi_val < 30:
score += 2
result.signals.append(f"RSI={rsi_val:.0f}偏低+2")
else:
result.signals.append(f"RSI={rsi_val:.0f}中性")
rsi6 = ta.rsi(close, length=6)
if rsi6 is not None and not rsi6.empty:
rsi6_val = float(rsi6.iloc[-1])
result.indicators["rsi6"] = round(rsi6_val, 1)
if rsi6_val > 85:
score -= 2
result.signals.append(f"RSI6={rsi6_val:.0f}短线极度超买-2")
elif rsi6_val < 15:
score += 2
result.signals.append(f"RSI6={rsi6_val:.0f}短线极度超卖+2")
except Exception as e:
logger.debug(f"RSI calculation error: {e}")
# KDJ
try:
stoch = ta.stoch(df["high"].astype(float), df["low"].astype(float), close)
if stoch is not None and not stoch.empty:
k = float(stoch.iloc[-1].get("STOCHk_14_3_3", 50))
d = float(stoch.iloc[-1].get("STOCHd_14_3_3", 50))
result.indicators["kdj_k"] = round(k, 1)
result.indicators["kdj_d"] = round(d, 1)
if k > d and len(stoch) >= 2:
prev_k = float(stoch.iloc[-2].get("STOCHk_14_3_3", 50))
prev_d = float(stoch.iloc[-2].get("STOCHd_14_3_3", 50))
if prev_k <= prev_d:
if k > 80:
score -= 1
result.signals.append(f"KDJ高位金叉(K={k:.0f}>80,钝化)-1")
else:
score += 4
result.signals.append(f"KDJ金叉+4")
else:
if k > 85:
result.signals.append(f"KDJ高位钝化(K={k:.0f})")
else:
score += 1
elif k < d:
if k < 20:
score += 2
result.signals.append(f"KDJ低位死叉(超卖区)+2")
else:
score -= 2
result.signals.append(f"KDJ空头-2")
except Exception as e:
logger.debug(f"KDJ calculation error: {e}")
# Moving averages alignment
try:
ma5 = close.rolling(5).mean().iloc[-1]
ma10 = close.rolling(10).mean().iloc[-1]
ma20 = close.rolling(20).mean().iloc[-1]
ma60 = close.rolling(60).mean().iloc[-1] if len(close) >= 60 else None
current = float(close.iloc[-1])
result.indicators["ma5"] = round(float(ma5), 2)
result.indicators["ma20"] = round(float(ma20), 2)
if current > ma5 > ma10 > ma20:
score += 4
result.signals.append("均线多头排列+4")
elif current < ma5 < ma10 < ma20:
score -= 4
result.signals.append("均线空头排列-4")
if current > ma20 and float(close.iloc[-2]) <= float(close.rolling(20).mean().iloc[-2]):
score += 3
result.signals.append("突破MA20+3")
if ma60 is not None:
result.indicators["ma60"] = round(float(ma60), 2)
if current > ma60:
score += 2
result.signals.append("站上MA60+2")
else:
score -= 1
result.signals.append("低于MA60-1")
ma120 = close.rolling(120).mean().iloc[-1] if len(close) >= 120 else None
if ma120 is not None:
result.indicators["ma120"] = round(float(ma120), 2)
if current > ma120:
score += 1
result.signals.append("站上MA120+1(中长期趋势向好)")
else:
score -= 1
result.signals.append("低于MA120-1(中长期趋势偏弱)")
except Exception as e:
logger.debug(f"MA calculation error: {e}")
# Bollinger Bands position
try:
bbands = ta.bbands(close, length=20)
if bbands is not None and not bbands.empty:
upper = float(bbands.iloc[-1].get("BBU_20_2.0", 0))
lower = float(bbands.iloc[-1].get("BBL_20_2.0", 0))
mid = float(bbands.iloc[-1].get("BBM_20_2.0", 0))
current = float(close.iloc[-1])
if upper > lower:
bb_pos = (current - lower) / (upper - lower)
result.indicators["bb_position"] = round(bb_pos, 2)
if bb_pos > 0.95:
score -= 3
result.signals.append(f"布林上轨压力-3")
elif bb_pos < 0.05:
score += 3
result.signals.append(f"布林下轨支撑+3")
except Exception as e:
logger.debug(f"Bollinger calculation error: {e}")
# Volume-Price Pattern Analysis
try:
volume = df["volume"].astype(float)
if len(volume) >= 10:
avg_vol_10 = volume.rolling(10).mean().iloc[-1]
current_vol = float(volume.iloc[-1])
vol_ratio = current_vol / avg_vol_10 if avg_vol_10 > 0 else 1.0
result.indicators["vol_ratio_10d"] = round(vol_ratio, 2)
# 近5日价格位置判断
recent_high = float(df["high"].tail(60).max()) if len(df) >= 60 else float(df["high"].max())
recent_low = float(df["low"].tail(60).min()) if len(df) >= 60 else float(df["low"].min())
price_range = recent_high - recent_low
if price_range > 0:
price_position = (current - recent_low) / price_range
result.indicators["price_position_60d"] = round(price_position, 2)
# 底部放量(价格在60日低位20%以内 + 成交量>1.5倍均量)
if price_position < 0.2 and vol_ratio > 1.5:
score += 5
result.signals.append(f"底部放量(位置{price_position:.0%},量比{vol_ratio:.1f})+5")
# 顶部放量(价格在60日高位80%以上 + 成交量>2倍均量)
elif price_position > 0.8 and vol_ratio > 2.0:
score -= 5
result.signals.append(f"顶部放量(位置{price_position:.0%},量比{vol_ratio:.1f})-5(出货风险)")
# 顶部缩量(价格高位但成交量萎缩)
elif price_position > 0.8 and vol_ratio < 0.5:
score -= 2
result.signals.append(f"顶部缩量(位置{price_position:.0%},量比{vol_ratio:.1f})-2(上涨乏力)")
# 缩量回调(连续3天缩量 + 价格小幅回调 < 5%)
if len(volume) >= 5:
recent_3vol = volume.tail(3).values
prev_3vol = volume.tail(6).head(3).values
avg_recent = sum(recent_3vol) / 3
avg_prev = sum(prev_3vol) / 3
recent_change = float(close.pct_change().tail(3).sum())
if avg_prev > 0 and avg_recent / avg_prev < 0.6 and -0.05 < recent_change < 0:
score += 3
result.signals.append(f"缩量回调(量缩{avg_recent/avg_prev:.0%},跌{recent_change:.1%})+3(洗盘)")
except Exception as e:
logger.debug(f"Volume-price analysis error: {e}")
# Consecutive candle pattern
try:
if len(df) >= 5:
opens = df["open"].astype(float).tail(5).values
closes = close.tail(5).values
up_count = sum(1 for o, c in zip(opens, closes) if c > o)
down_count = sum(1 for o, c in zip(opens, closes) if c < o)
result.indicators["consecutive_up_candles_5d"] = up_count
result.indicators["consecutive_down_candles_5d"] = down_count
if up_count >= 4:
score += 2
result.signals.append(f"近5日{up_count}阳线+2(强势)")
elif down_count >= 4:
score -= 1
result.signals.append(f"近5日{down_count}阴线-1(弱势)")
except Exception as e:
logger.debug(f"Candle pattern error: {e}")
# Breakout detection
try:
if len(df) >= 20:
prev_20_high = float(df["high"].tail(21).head(20).max())
prev_20_low = float(df["low"].tail(21).head(20).min())
if current > prev_20_high:
score += 3
result.signals.append(f"突破20日高点{prev_20_high:.2f}+3")
elif current < prev_20_low:
score -= 3
result.signals.append(f"跌破20日低点{prev_20_low:.2f}-3")
except Exception as e:
logger.debug(f"Breakout detection error: {e}")
# Gap detection
try:
if len(df) >= 2:
today_low = float(df["low"].iloc[-1])
today_high = float(df["high"].iloc[-1])
yesterday_high = float(df["high"].iloc[-2])
yesterday_low = float(df["low"].iloc[-2])
if today_low > yesterday_high:
gap_pct = (today_low - yesterday_high) / yesterday_high * 100
if gap_pct > 1:
score += 2
result.signals.append(f"向上跳空{gap_pct:.1f}%+2")
elif today_high < yesterday_low:
gap_pct = (yesterday_low - today_high) / yesterday_low * 100
if gap_pct > 1:
score -= 2
result.signals.append(f"向下跳空{gap_pct:.1f}%-2")
except Exception as e:
logger.debug(f"Gap detection error: {e}")
# Fibonacci 0.618 retracement support/resistance
try:
if len(df) >= 60:
high_60 = float(df["high"].tail(60).max())
low_60 = float(df["low"].tail(60).min())
fib_range = high_60 - low_60
if fib_range > 0:
fib_382 = high_60 - fib_range * 0.382
fib_500 = high_60 - fib_range * 0.500
fib_618 = high_60 - fib_range * 0.618
result.indicators["fib_618"] = round(fib_618, 2)
result.indicators["fib_382"] = round(fib_382, 2)
tolerance = fib_range * 0.02
# Price near 0.618 support (golden ratio retracement)
if abs(current - fib_618) < tolerance and current > fib_618:
rsi_val = result.indicators.get("rsi", 50)
if rsi_val < 45:
score += 6
result.signals.append(f"黄金分割0.618支撑({fib_618:.2f})+RSI低位+6(高胜率)")
else:
score += 4
result.signals.append(f"黄金分割0.618支撑位({fib_618:.2f})+4")
elif abs(current - fib_382) < tolerance and current < fib_382:
score -= 3
result.signals.append(f"0.382压力位({fib_382:.2f})-3")
# Fibonacci extension target
if current > high_60 * 0.98:
fib_1618 = low_60 + fib_range * 1.618
result.indicators["fib_1618_target"] = round(fib_1618, 2)
except Exception as e:
logger.debug(f"Fibonacci analysis error: {e}")
# MACD-Price Divergence (top/bottom divergence, very high win rate)
try:
if len(df) >= 30:
macd_full = ta.macd(close)
if macd_full is not None and len(macd_full) >= 20:
macd_hist_series = macd_full["MACDh_12_26_9"].astype(float)
close_10 = close.tail(10).values
macd_10 = macd_hist_series.tail(10).values
price_making_low = close_10[-1] < min(close_10[:5])
macd_making_high = macd_10[-1] > min(macd_10[:5])
if price_making_low and macd_making_high:
score += 5
result.signals.append("MACD底背离+5(高胜率反转)")
price_making_high = close_10[-1] > max(close_10[:5])
macd_making_low = macd_10[-1] < max(macd_10[:5])
if price_making_high and macd_making_low:
score -= 5
result.signals.append("MACD顶背离-5(高胜率见顶)")
except Exception as e:
logger.debug(f"MACD divergence error: {e}")
# Bollinger Band squeeze (low volatility -> breakout imminent)
try:
if len(df) >= 20:
bbands2 = ta.bbands(close, length=20)
if bbands2 is not None and not bbands2.empty:
bbu = bbands2["BBU_20_2.0"].astype(float)
bbl = bbands2["BBL_20_2.0"].astype(float)
bandwidth = (bbu - bbl) / ((bbu + bbl) / 2) * 100
bw_current = float(bandwidth.iloc[-1])
bw_avg = float(bandwidth.tail(20).mean())
result.indicators["bb_bandwidth"] = round(bw_current, 2)
if bw_current < bw_avg * 0.5:
score += 3
result.signals.append(f"布林收口(带宽{bw_current:.1f}%<均值{bw_avg:.1f}%的50%)+3(变盘信号)")
except Exception as e:
logger.debug(f"Bollinger squeeze error: {e}")
# RSI-Price Divergence
try:
rsi_full = ta.rsi(close, length=14)
if rsi_full is not None and len(rsi_full) >= 10:
rsi_10 = rsi_full.tail(10).values
close_10_vals = close.tail(10).values
if close_10_vals[-1] < min(close_10_vals[:5]) and rsi_10[-1] > min(rsi_10[:5]):
score += 4
result.signals.append("RSI底背离+4(超卖反转)")
elif close_10_vals[-1] > max(close_10_vals[:5]) and rsi_10[-1] < max(rsi_10[:5]):
score -= 4
result.signals.append("RSI顶背离-4(超买见顶)")
except Exception as e:
logger.debug(f"RSI divergence error: {e}")
# MA golden/death cross (MA5 x MA20)
try:
if len(close) >= 22:
ma5_series = close.rolling(5).mean()
ma20_series = close.rolling(20).mean()
if len(ma5_series) >= 2 and len(ma20_series) >= 2:
today_5 = float(ma5_series.iloc[-1])
today_20 = float(ma20_series.iloc[-1])
yest_5 = float(ma5_series.iloc[-2])
yest_20 = float(ma20_series.iloc[-2])
if yest_5 <= yest_20 and today_5 > today_20:
score += 4
result.signals.append("MA5/MA20金叉+4")
elif yest_5 >= yest_20 and today_5 < today_20:
score -= 4
result.signals.append("MA5/MA20死叉-4")
except Exception as e:
logger.debug(f"MA cross error: {e}")
# Volume-price confirmation (rising price + rising volume = healthy trend)
try:
if len(df) >= 5:
vol_5 = df["volume"].astype(float).tail(5)
close_5 = close.tail(5)
price_up = float(close_5.iloc[-1]) > float(close_5.iloc[0])
vol_up = float(vol_5.iloc[-1]) > float(vol_5.mean())
if price_up and vol_up:
score += 2
result.signals.append("量价齐升+2(健康上涨)")
elif price_up and not vol_up:
score -= 1
result.signals.append("价升量缩-1(上涨动能不足)")
elif not price_up and vol_up:
score -= 2
result.signals.append("价跌量增-2(恐慌抛售)")
except Exception as e:
logger.debug(f"Volume-price confirmation error: {e}")
# W-bottom pattern (double bottom near same level)
try:
if len(df) >= 30:
lows = df["low"].astype(float).tail(30).values
mid_idx = len(lows) // 2
first_low = min(lows[:mid_idx])
second_low = min(lows[mid_idx:])
mid_high = max(df["high"].astype(float).tail(30).values[mid_idx-3:mid_idx+3])
neckline_dist = mid_high - max(first_low, second_low)
tolerance_w = neckline_dist * 0.05 if neckline_dist > 0 else 0
if tolerance_w > 0 and abs(first_low - second_low) < neckline_dist * 0.03:
if current > mid_high:
score += 5
result.signals.append(f"W底突破颈线{mid_high:.2f}+5(反转)")
elif current > second_low and current < mid_high:
score += 2
result.signals.append(f"疑似W底形成中(底{second_low:.2f}颈{mid_high:.2f})+2")
except Exception as e:
logger.debug(f"W-bottom detection error: {e}")
# M-top pattern (double top)
try:
if len(df) >= 30:
highs = df["high"].astype(float).tail(30).values
mid_idx_m = len(highs) // 2
first_high = max(highs[:mid_idx_m])
second_high = max(highs[mid_idx_m:])
mid_low = min(df["low"].astype(float).tail(30).values[mid_idx_m-3:mid_idx_m+3])
neckline_dist_m = min(first_high, second_high) - mid_low
if neckline_dist_m > 0 and abs(first_high - second_high) < neckline_dist_m * 0.03:
if current < mid_low:
score -= 5
result.signals.append(f"M顶跌破颈线{mid_low:.2f}-5(反转下跌)")
elif current < second_high and current > mid_low:
score -= 2
result.signals.append(f"疑似M顶形成中(顶{second_high:.2f}颈{mid_low:.2f})-2")
except Exception as e:
logger.debug(f"M-top detection error: {e}")
# Vegas Channel (EMA 144/169 - weekly trend channel on daily chart)
try:
if len(close) >= 170:
ema_144 = ta.ema(close, length=144)
ema_169 = ta.ema(close, length=169)
if ema_144 is not None and ema_169 is not None:
ema144_val = float(ema_144.iloc[-1])
ema169_val = float(ema_169.iloc[-1])
result.indicators["vegas_ema144"] = round(ema144_val, 2)
result.indicators["vegas_ema169"] = round(ema169_val, 2)
channel_width = abs(ema144_val - ema169_val) / ema169_val * 100
if current > max(ema144_val, ema169_val):
score += 3
result.signals.append(f"Vegas通道上方+3(中长期多头)")
elif current < min(ema144_val, ema169_val):
score -= 3
result.signals.append(f"Vegas通道下方-3(中长期空头)")
elif min(ema144_val, ema169_val) <= current <= max(ema144_val, ema169_val):
result.signals.append(f"Vegas通道内(通道宽{channel_width:.1f}%,观望)")
except Exception as e:
logger.debug(f"Vegas channel error: {e}")
# ATR Volatility (Average True Range)
try:
atr = ta.atr(df["high"].astype(float), df["low"].astype(float), close, length=14)
if atr is not None and not atr.empty:
atr_val = float(atr.iloc[-1])
atr_pct = atr_val / float(close.iloc[-1]) * 100
result.indicators["atr_14"] = round(atr_val, 2)
result.indicators["atr_pct"] = round(atr_pct, 2)
atr_avg = float(atr.tail(20).mean())
if atr_val > atr_avg * 1.5:
result.signals.append(f"ATR放大({atr_pct:.1f}%,波动加剧)")
elif atr_val < atr_avg * 0.6:
result.signals.append(f"ATR收窄({atr_pct:.1f}%,变盘临近)")
except Exception as e:
logger.debug(f"ATR calculation error: {e}")
# Ichimoku Cloud (simplified - tenkan/kijun cross + cloud position)
try:
ichimoku = ta.ichimoku(df["high"].astype(float), df["low"].astype(float), close)
if ichimoku is not None and isinstance(ichimoku, tuple) and len(ichimoku) >= 1:
ich = ichimoku[0]
if ich is not None and not ich.empty:
tenkan = ich.iloc[-1].get("ITS_9", None)
kijun = ich.iloc[-1].get("IKS_26", None)
span_a = ich.iloc[-1].get("ISA_9", None)
span_b = ich.iloc[-1].get("ISB_26", None)
if all(v is not None for v in [tenkan, kijun, span_a, span_b]):
tenkan = float(tenkan)
kijun = float(kijun)
span_a = float(span_a)
span_b = float(span_b)
cloud_top = max(span_a, span_b)
cloud_bottom = min(span_a, span_b)
result.indicators["ichimoku_tenkan"] = round(tenkan, 2)
result.indicators["ichimoku_kijun"] = round(kijun, 2)
if current > cloud_top and tenkan > kijun:
score += 3
result.signals.append("一目均衡:云上+转换>基准+3(强势)")
elif current < cloud_bottom and tenkan < kijun:
score -= 3
result.signals.append("一目均衡:云下+转换<基准-3(弱势)")
elif cloud_bottom <= current <= cloud_top:
result.signals.append("一目均衡:云中(方向不明)")
except Exception as e:
logger.debug(f"Ichimoku calculation error: {e}")
result.score = max(0, min(100, score))
return result
def _compute_basic(df: pd.DataFrame) -> TechnicalSignal:
"""Fallback: basic calculations without pandas-ta."""
close = df["close"].astype(float)
score = 50.0
signals = []
ma5 = close.rolling(5).mean().iloc[-1]
ma20 = close.rolling(20).mean().iloc[-1]
current = float(close.iloc[-1])
if current > ma5 > ma20:
score += 5
signals.append("均线多头+5")
elif current < ma5 < ma20:
score -= 5
signals.append("均线空头-5")
changes = close.pct_change().dropna()
if len(changes) >= 14:
gains = changes[changes > 0].rolling(14).mean().iloc[-1] if len(changes[changes > 0]) >= 14 else 0
losses = abs(changes[changes < 0].rolling(14).mean().iloc[-1]) if len(changes[changes < 0]) >= 14 else 1
rsi = 100 - 100 / (1 + gains / max(losses, 0.001)) if losses else 50
if rsi > 70:
score -= 3
signals.append(f"RSI={rsi:.0f}偏高-3")
elif rsi < 30:
score += 3
signals.append(f"RSI={rsi:.0f}偏低+3")
# Consecutive candle pattern (same as main path)
result = TechnicalSignal(score=max(0, min(100, score)), signals=signals)
if len(df) >= 5:
opens = df["open"].astype(float).tail(5).values
closes = df["close"].astype(float).tail(5).values
up_count = sum(1 for o, c in zip(opens, closes) if c > o)
down_count = sum(1 for o, c in zip(opens, closes) if c < o)
result.indicators["consecutive_up_candles_5d"] = up_count
result.indicators["consecutive_down_candles_5d"] = down_count
return result
import os
import yaml
from pathlib import Path
_config = None
def get_config() -> dict:
global _config
if _config is None:
config_path = Path(__file__).parent / "settings.yaml"
with open(config_path, "r") as f:
_config = yaml.safe_load(f)
return _config
def reload_config():
global _config
_config = None
return get_config()
def get_workspace_root() -> Path:
return Path(os.environ.get(
"TRADING_WORKSPACE",
os.path.expanduser("~/.openclaw/workspace-trading")
))
# TradingScore V2 权重配置
scoring:
weights:
technical: 0.25
capital: 0.30
fundamental: 0.10
sentiment: 0.20
market: 0.15
signals:
strong_buy: 78
buy: 63
watch: 50
hold: 35
sell: 22
strong_sell: 18
# 板块轮动配置
sector:
hot_streak_days: 3
new_hot_top_n: 3
cooling_from_top: 5
cooling_to_rank: 20
# 数据源限流配置
rate_limits:
sina:
max_per_minute: 60
delay_range: [0.5, 1.5]
zhitu:
max_per_minute: 200
delay_range: [0.1, 0.3]
eastmoney:
max_per_minute: 30
delay_range: [1.0, 3.0]
alpha_vantage:
max_per_minute: 5
delay_range: [12.0, 15.0]
baostock:
max_per_minute: 120
delay_range: [0.3, 0.8]
# 缓存 TTL (秒)
cache_ttl:
realtime: 30
daily_kline: 86400
minute_kline: 7200
sector: 3600
fundamental: 604800
news: 1800
us_daily: 43200
# MCP 工具超时 (秒)
tool_timeout:
stock_analysis: 30
sector_rotation: 20
market_anomaly: 45
morning_brief: 60
closing_summary: 60
us_market: 30
"""Data sources package â unified access to all market data."""
from .base import QuoteData, FallbackChain, RealtimeSource, HistorySource
from .sina import SinaRealtimeSource
from .tencent import TencentRealtimeSource
from .eastmoney import EastMoneyRealtimeSource
from .manager import DataManager
__all__ = [
"QuoteData",
"FallbackChain",
"RealtimeSource",
"HistorySource",
"SinaRealtimeSource",
"TencentRealtimeSource",
"EastMoneyRealtimeSource",
"DataManager",
]
"""Data source abstraction layer with fallback chain and rate limiting."""
from __future__ import annotations
import asyncio
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
import pandas as pd
logger = logging.getLogger(__name__)
@dataclass
class QuoteData:
"""Real-time quote snapshot for a single stock."""
code: str
name: str
price: float
change_pct: float
open: float
high: float
low: float
pre_close: float
volume: float # shares
amount: float # CNY
turnover_rate: float = 0.0
volume_ratio: float = 0.0
bid1: float = 0.0
ask1: float = 0.0
pe: float = 0.0
pb: float = 0.0
market_cap: float = 0.0
timestamp: str = ""
source: str = ""
# 主力相关字段
outer_vol: float = 0.0 # 外盘成交量 (手)
inner_vol: float = 0.0 # 内盘成交量 (手)
@dataclass
class SourceHealth:
"""Tracks health metrics for a single data source."""
name: str
success_count: int = 0
fail_count: int = 0
total_latency: float = 0.0
last_success: float = 0.0
last_fail: float = 0.0
circuit_open: bool = False
circuit_open_until: float = 0.0
CIRCUIT_FAIL_THRESHOLD = 3
CIRCUIT_RECOVER_SECONDS = 300
consecutive_failures: int = 0
def record_success(self, latency: float):
self.success_count += 1
self.total_latency += latency
self.last_success = time.time()
self.consecutive_failures = 0
self.circuit_open = False
def record_failure(self):
self.fail_count += 1
self.consecutive_failures += 1
self.last_fail = time.time()
if self.consecutive_failures >= self.CIRCUIT_FAIL_THRESHOLD:
self.circuit_open = True
self.circuit_open_until = time.time() + self.CIRCUIT_RECOVER_SECONDS
logger.warning(f"Circuit breaker OPEN for {self.name}, recover at +{self.CIRCUIT_RECOVER_SECONDS}s")
def is_available(self) -> bool:
if not self.circuit_open:
return True
if time.time() >= self.circuit_open_until:
self.circuit_open = False
logger.info(f"Circuit breaker CLOSED (recovery) for {self.name}")
return True
return False
@property
def avg_latency(self) -> float:
return self.total_latency / max(self.success_count, 1)
class RealtimeSource(ABC):
"""Abstract base for real-time quote providers."""
name: str = "unknown"
@abstractmethod
async def fetch_quotes(self, codes: list[str]) -> list[QuoteData]:
"""Fetch real-time quotes for given stock codes."""
...
async def health_check(self) -> bool:
try:
result = await self.fetch_quotes(["000001"])
return len(result) > 0
except Exception:
return False
class HistorySource(ABC):
"""Abstract base for historical data providers."""
name: str = "unknown"
@abstractmethod
async def fetch_daily(self, code: str, start: str, end: str, adjust: str = "") -> pd.DataFrame:
...
@dataclass
class FallbackChain:
"""Manages ordered data source fallback with circuit breakers."""
sources: list[RealtimeSource] = field(default_factory=list)
health: dict[str, SourceHealth] = field(default_factory=dict)
def add_source(self, source: RealtimeSource):
self.sources.append(source)
self.health[source.name] = SourceHealth(name=source.name)
async def fetch_quotes(self, codes: list[str]) -> list[QuoteData]:
last_error = None
for source in self.sources:
h = self.health[source.name]
if not h.is_available():
logger.debug(f"Skipping {source.name} (circuit open)")
continue
try:
t0 = time.time()
result = await source.fetch_quotes(codes)
h.record_success(time.time() - t0)
if result:
return result
except Exception as e:
h.record_failure()
last_error = e
logger.warning(f"{source.name} failed: {e}")
continue
logger.error(f"All sources exhausted for {codes[:3]}..., last error: {last_error}")
return []
def health_report(self) -> dict:
return {
name: {
"success": h.success_count,
"fail": h.fail_count,
"avg_latency_ms": round(h.avg_latency * 1000, 1),
"circuit_open": h.circuit_open,
}
for name, h in self.health.items()
}
"""资金流数据源管理器 - 多级降级链路.
降级顺序:
1. 分钟级资金流:同花顺 → 东方财富 → 腾讯 (外盘/内盘)
2. 主力资金净流入:东方财富 → AKShare(T+1) → 腾讯 (内外盘差)
"""
from __future__ import annotations
import logging
import asyncio
from typing import Optional
logger = logging.getLogger(__name__)
class CapitalFlowManager:
"""资金流数据管理器 - 自动降级."""
def __init__(self):
self._ths = None
self._em = None
self._tencent = None
self._ak = None
def _get_ths(self):
if self._ths is None:
from .ths_market import THSMarketScanner
self._ths = THSMarketScanner()
return self._ths
def _get_em(self):
if self._em is None:
from .eastmoney_market import EastMoneyMarketData
self._em = EastMoneyMarketData()
return self._em
def _get_tencent(self):
if self._tencent is None:
from .tencent import TencentRealtimeSource
self._tencent = TencentRealtimeSource()
return self._tencent
def _get_ak(self):
"""懒加载 AKShare."""
if self._ak is None:
try:
import akshare as ak
self._ak = ak
except ImportError:
logger.warning("AKShare not installed")
self._ak = None
return self._ak
async def _retry_request(self, func, max_retries: int = 2, base_delay: float = 1.0):
"""带指数退避的重试机制."""
last_error = None
for attempt in range(max_retries + 1):
try:
return await func()
except Exception as e:
last_error = e
if attempt < max_retries:
delay = base_delay * (2 ** attempt) # 1s, 2s, 4s
logger.warning(f"Request failed, retrying in {delay}s (attempt {attempt+1}/{max_retries}): {e}")
await asyncio.sleep(delay)
else:
logger.error(f"All {max_retries+1} attempts failed: {e}")
return {"error": str(last_error), "retries": max_retries}
async def get_capital_flows_batch(self, codes: list[str]) -> dict[str, dict]:
"""批量获取资金流数据 (高效版).
Args:
codes: 股票代码列表
Returns:
{code: flow_data, ...}
"""
results = {}
# 1. 先尝试同花顺批量 (最快)
ths_codes = codes[:10] # 同花顺限制每次最多 10 只
for code in ths_codes:
try:
flow = await self._get_ths().get_capital_flow(code)
if "error" not in flow and flow.get("data_points", 0) > 0:
results[code] = flow
results[code]["source"] = "ths"
except Exception as e:
logger.warning(f"THS flow failed for {code}: {e}")
# 2. 对其余股票,仅获取主力资金 (东方财富/AKShare)
remaining = [c for c in codes if c not in results]
for code in remaining[:5]: # 限制最多 5 只,防超时
try:
# 东方财富主力资金 (带重试)
async def fetch_em():
em = await self._get_em().get_main_flow(code)
return em if em and "error" not in em else None
em_main = await self._retry_request(fetch_em, max_retries=1, base_delay=0.5)
if em_main:
results[code] = {"main_force": em_main, "source": "em_main"}
continue
# AKShare 历史数据
ak = self._get_ak()
if ak:
try:
df = ak.stock_individual_fund_flow(stock=code, market='sz' if code.startswith(('0', '3')) else 'sh')
if len(df) > 0:
latest = df.iloc[-1]
results[code] = {
"main_force": {
"main_net_inflow_wan": round(latest.get("主力净流入 - 净额", 0) / 1e4, 2),
"super_big_net_wan": round(latest.get("超大单净流入 - 净额", 0) / 1e4, 2),
"big_net_wan": round(latest.get("大单净流入 - 净额", 0) / 1e4, 2),
"signal": "主力流入" if latest.get("主力净流入 - 净额", 0) > 0 else "主力流出",
"source": "akshare_T+1",
"date": latest.get("日期", ""),
},
"source": "akshare"
}
except Exception as e:
logger.warning(f"AKShare failed for {code}: {e}")
except Exception as e:
logger.warning(f"Main force failed for {code}: {e}")
# 3. 剩余股票标记为缺失
for code in codes:
if code not in results:
results[code] = {"main_force": None, "source": "missing"}
return results
async def get_capital_flow(self, code: str) -> dict:
"""获取资金流数据 (带降级).
降级链路:
1. 同花顺分钟级资金流 (最详细)
2. 东方财富分钟级资金流 (备用)
3. 腾讯内外盘差 (简化版)
4. AKShare 历史资金流 (T+1, 盘后参考)
Returns:
{
"code": "002202",
"source": "ths" | "em" | "tencent" | "akshare",
"name": "金风科技",
"total_amount": 53.58 亿,
"data_points": 121,
"amount_surge_last_10min": false,
"last_10min_flow": [...],
"main_force": { # 主力资金
"main_net_inflow_wan": -10653,
"super_big_net_wan": -15104,
"big_net_wan": 4451,
"signal": "主力流出"
},
}
"""
code = code.strip().zfill(6)
result = {"code": code, "source": "unknown"}
# 1. 尝试同花顺分钟级资金流 (主数据源)
try:
ths_flow = await self._get_ths().get_capital_flow(code)
if "error" not in ths_flow and ths_flow.get("data_points", 0) > 0:
result.update(ths_flow)
result["source"] = "ths"
logger.info(f"Capital flow from THS for {code}")
except Exception as e:
logger.warning(f"THS capital flow failed for {code}: {e}")
# 2. 获取东方财富主力资金 (带重试)
async def fetch_em_main():
em_main = await self._get_em().get_main_flow(code)
if em_main and "error" not in em_main:
return em_main
return None
try:
em_main = await self._retry_request(fetch_em_main, max_retries=2, base_delay=1.0)
if em_main and "error" not in em_main:
result["main_force"] = em_main
if result.get("source") == "unknown":
result["source"] = "em_main"
logger.info(f"Main force from EM for {code}")
except Exception as e:
logger.warning(f"EM main force failed for {code}: {e}")
# 3. 如果东方财富失败,尝试 AKShare 历史数据 (T+1)
if result.get("main_force") is None:
ak = self._get_ak()
if ak:
try:
# AKShare 获取历史资金流
df = ak.stock_individual_fund_flow(stock=code, market='sz' if code.startswith(('0', '3')) else 'sh')
if len(df) > 0:
# 取最新数据 (最后一行)
latest = df.iloc[-1]
result["main_force"] = {
"main_net_inflow_wan": round(latest.get("主力净流入 - 净额", 0) / 1e4, 2),
"super_big_net_wan": round(latest.get("超大单净流入 - 净额", 0) / 1e4, 2),
"big_net_wan": round(latest.get("大单净流入 - 净额", 0) / 1e4, 2),
"signal": "主力流入" if latest.get("主力净流入 - 净额", 0) > 0 else "主力流出",
"source": "akshare_T+1",
"date": latest.get("日期", ""),
}
if result.get("source") == "unknown":
result["source"] = "akshare"
logger.info(f"Main force from AKShare for {code} (T+1)")
except Exception as e:
logger.warning(f"AKShare main force failed for {code}: {e}")
# 4. 如果都失败,尝试东方财富分钟级资金流
if result.get("source") == "unknown" or result.get("data_points", 0) == 0:
try:
em_flow = await self._get_em().get_minute_flow(code)
if "error" not in em_flow and em_flow.get("data_points", 0) > 0:
# 保留已有的 main_force
main_force = result.get("main_force")
result.update(em_flow)
result["main_force"] = main_force or result.get("main_force")
result["source"] = "em"
logger.info(f"Capital flow from EM for {code}")
except Exception as e:
logger.warning(f"EM capital flow failed for {code}: {e}")
# 5. 如果都失败,尝试腾讯内外盘差作为简化版
if result.get("source") == "unknown":
try:
tencent_quote = await self._get_tencent().fetch_quotes([code])
if tencent_quote and len(tencent_quote) > 0:
q = tencent_quote[0]
outer_vol = getattr(q, 'outer_vol', 0) if hasattr(q, 'outer_vol') else 0
inner_vol = getattr(q, 'inner_vol', 0) if hasattr(q, 'inner_vol') else 0
result.update({
"code": code,
"name": q.name,
"total_amount": q.amount,
"data_points": 1,
"last_price": q.price,
"change_pct": q.change_pct,
"outer_vol": int(outer_vol),
"inner_vol": int(inner_vol),
"outer_inner_diff": int(outer_vol - inner_vol),
"source": "tencent",
"note": "腾讯简化版 (内外盘差)",
})
logger.info(f"Capital flow from Tencent for {code}")
except Exception as e:
logger.warning(f"Tencent capital flow failed for {code}: {e}")
# 6. 如果全部失败,返回错误
if result.get("source") == "unknown":
result["error"] = "all capital flow sources failed"
logger.error(f"All capital flow sources failed for {code}")
return result
async def get_capital_flows_batch(self, codes: list[str]) -> dict[str, dict]:
"""批量获取资金流数据 (高效版).
Args:
codes: 股票代码列表
Returns:
{code: flow_data, ...}
"""
results = {}
# 1. 先尝试同花顺批量 (最快)
ths_codes = codes[:10] # 同花顺限制每次最多 10 只
for code in ths_codes:
try:
flow = await self._get_ths().get_capital_flow(code)
if "error" not in flow and flow.get("data_points", 0) > 0:
results[code] = flow
results[code]["source"] = "ths"
except Exception as e:
logger.warning(f"THS flow failed for {code}: {e}")
# 2. 对其余股票,仅获取主力资金 (东方财富/AKShare)
remaining = [c for c in codes if c not in results]
for code in remaining[:5]: # 限制最多 5 只,防超时
try:
# 东方财富主力资金 (带重试)
async def fetch_em():
em = await self._get_em().get_main_flow(code)
return em if em and "error" not in em else None
em_main = await self._retry_request(fetch_em, max_retries=1, base_delay=0.5)
if em_main:
results[code] = {"main_force": em_main, "source": "em_main"}
continue
# AKShare 历史数据
ak = self._get_ak()
if ak:
try:
df = ak.stock_individual_fund_flow(stock=code, market='sz' if code.startswith(('0', '3')) else 'sh')
if len(df) > 0:
latest = df.iloc[-1]
results[code] = {
"main_force": {
"main_net_inflow_wan": round(latest.get("主力净流入 - 净额", 0) / 1e4, 2),
"super_big_net_wan": round(latest.get("超大单净流入 - 净额", 0) / 1e4, 2),
"big_net_wan": round(latest.get("大单净流入 - 净额", 0) / 1e4, 2),
"signal": "主力流入" if latest.get("主力净流入 - 净额", 0) > 0 else "主力流出",
"source": "akshare_T+1",
"date": latest.get("日期", ""),
},
"source": "akshare"
}
except Exception as e:
logger.warning(f"AKShare failed for {code}: {e}")
except Exception as e:
logger.warning(f"Main force failed for {code}: {e}")
# 3. 剩余股票标记为缺失
for code in codes:
if code not in results:
results[code] = {"main_force": None, "source": "missing"}
return results
async def close(self):
"""关闭连接."""
if self._tencent:
await self._tencent.close()
if self._ths:
if hasattr(self._ths, 'close'):
await self._ths.close()
if self._em:
if hasattr(self._em, 'close'):
await self._em.close()
"""东方财富北向资金(沪深港通)实时数据."""
from __future__ import annotations
import re
import json
import logging
import httpx
logger = logging.getLogger(__name__)
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Referer": "https://data.eastmoney.com",
}
class NorthboundFlowSource:
"""东方财富北向资金实时流入/流出数据."""
URL = "https://push2.eastmoney.com/api/qt/kamt.rtmin/get"
async def get_realtime_flow(self) -> dict:
"""获取今日北向资金分钟级流入数据."""
params = {
"fields1": "f1,f2,f3,f4",
"fields2": "f51,f52,f53,f54,f55,f56",
}
async with httpx.AsyncClient(timeout=10, headers=HEADERS) as client:
resp = await client.get(self.URL, params=params)
resp.raise_for_status()
text = resp.text
m = re.search(r'\{.*\}', text, re.DOTALL)
if not m:
return {"error": "parse failed"}
data = json.loads(m.group(0))
if data.get("rc") != 0:
return {"error": "API error", "rc": data.get("rc")}
s2n = data.get("data", {}).get("s2n", [])
if not s2n:
return {"note": "no data (non-trading hours or holiday)"}
flow_points = []
for point in s2n:
parts = point.split(",")
if len(parts) >= 6:
time_str = parts[0]
# Handle '-' or empty strings (non-trading hours)
def safe_float(s, default=0):
try:
return float(s) if s and s.strip() and s.strip() != '-' else default
except (ValueError, TypeError):
return default
sh_net = safe_float(parts[1])
sh_buy = safe_float(parts[2])
sz_net = safe_float(parts[3])
sz_buy = safe_float(parts[4])
total_net = safe_float(parts[5])
flow_points.append({
"time": time_str,
"sh_net": sh_net,
"sz_net": sz_net,
"total_net": total_net,
})
last = flow_points[-1] if flow_points else {}
total_net = last.get("total_net", 0)
sh_net = last.get("sh_net", 0)
sz_net = last.get("sz_net", 0)
max_inflow = max((p["total_net"] for p in flow_points), default=0)
min_inflow = min((p["total_net"] for p in flow_points), default=0)
sentiment = "neutral"
if total_net > 200_000_000:
sentiment = "very_bullish"
elif total_net > 50_000_000:
sentiment = "bullish"
elif total_net < -200_000_000:
sentiment = "very_bearish"
elif total_net < -50_000_000:
sentiment = "bearish"
return {
"sh_net_flow": round(sh_net / 1e8, 2),
"sz_net_flow": round(sz_net / 1e8, 2),
"total_net_flow": round(total_net / 1e8, 2),
"total_net_flow_raw": total_net,
"max_inflow": round(max_inflow / 1e8, 2),
"min_inflow": round(min_inflow / 1e8, 2),
"data_points": len(flow_points),
"sentiment": sentiment,
"unit": "亿元",
}
"""EastMoney real-time quote source via push2.eastmoney.com API."""
from __future__ import annotations
import logging
from datetime import datetime
import httpx
from .base import QuoteData, RealtimeSource
logger = logging.getLogger(__name__)
_EM_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
def _code_to_secid(code: str) -> str:
market = 1 if code.startswith(("5", "6", "9")) else 0
return f"{market}.{code}"
class EastMoneyRealtimeSource(RealtimeSource):
"""Fetches real-time quotes from EastMoney push API.
Uses the public push2 endpoint; moderate rate limit tolerance.
"""
name = "eastmoney"
def __init__(self):
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=15.0,
headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"},
)
return self._client
async def fetch_quotes(self, codes: list[str]) -> list[QuoteData]:
client = await self._get_client()
secids = ",".join(_code_to_secid(c) for c in codes)
params = {
"fltt": "2",
"fields": "f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f14,f15,f16,f17,f18",
"secids": secids,
}
resp = await client.get(_EM_URL, params=params)
resp.raise_for_status()
data = resp.json()
results = []
raw_data = data.get("data")
if raw_data is None:
return results
for item in raw_data.get("diff", []) or []:
def _ef(val, default=0.0):
if val is None or val == "-" or val == "":
return default
try:
return float(val)
except (ValueError, TypeError):
return default
try:
code = str(item.get("f12", ""))
price = _ef(item.get("f2"))
if not code or price == 0:
continue
results.append(QuoteData(
code=code,
name=str(item.get("f14", "")),
price=price,
change_pct=_ef(item.get("f3")),
open=_ef(item.get("f17"), price),
high=_ef(item.get("f15"), price),
low=_ef(item.get("f16"), price),
pre_close=_ef(item.get("f18")),
volume=_ef(item.get("f5")) * 100,
amount=_ef(item.get("f6")),
turnover_rate=_ef(item.get("f8")),
volume_ratio=_ef(item.get("f10")),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
source="eastmoney",
))
except Exception as e:
logger.debug(f"Parse error for eastmoney item: {e}")
continue
return results
async def close(self):
if self._client and not self._client.is_closed:
await self._client.aclose()
"""Hong Kong stock real-time quotes via Tencent API (qt.gtimg.cn)."""
from __future__ import annotations
import re
from dataclasses import dataclass
import httpx
from data_sources.base import QuoteData, RealtimeSource
class TencentHKRealtimeSource(RealtimeSource):
"""Tencent HK stock quotes. Code format: 00700, 09988, etc."""
name = "tencent_hk"
BASE = "https://qt.gtimg.cn/q="
def _build_codes(self, codes: list[str]) -> str:
parts = []
for c in codes:
c = c.strip().zfill(5)
parts.append(f"r_hk{c}")
return ",".join(parts)
async def fetch_quotes(self, codes: list[str]) -> list[QuoteData | None]:
url = self.BASE + self._build_codes(codes)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
resp.raise_for_status()
text = resp.text
results: list[QuoteData | None] = []
for code in codes:
code5 = code.strip().zfill(5)
pattern = f"v_r_hk{code5}=\"(.*?)\";"
m = re.search(pattern, text)
if not m or not m.group(1).strip():
results.append(None)
continue
try:
def _hf(fields, idx, default=0.0):
try:
return float(fields[idx]) if idx < len(fields) and fields[idx] and fields[idx].strip() else default
except (ValueError, TypeError):
return default
fields = m.group(1).split("~")
if len(fields) < 35:
results.append(None)
continue
price = _hf(fields, 3)
if price == 0:
results.append(None)
continue
results.append(QuoteData(
code=code5,
name=fields[1].strip() if len(fields) > 1 else "",
price=price,
pre_close=_hf(fields, 4),
open=_hf(fields, 5, price),
high=_hf(fields, 33, price),
low=_hf(fields, 34, price),
volume=_hf(fields, 6),
amount=_hf(fields, 37),
change_pct=_hf(fields, 32),
turnover_rate=_hf(fields, 38),
volume_ratio=0,
pe=_hf(fields, 39),
pb=_hf(fields, 46),
market_cap=_hf(fields, 44),
source="tencent_hk",
))
except Exception:
results.append(None)
return results
"""Multi-source stock data framework MVP."""
from .manager import StockDataManager
__all__ = ["StockDataManager"]
Related skills
FAQ
What markets does trading-quant support?
trading-quant supports A-shares, US stocks, Hong Kong stocks, and commodities including precious metals. It also provides global market overview and intraday snapshot commands through quant.py.
How does trading-quant score stocks?
trading-quant applies a 5-dimension scoring system combining technical, capital, and fundamental factors. Scores are fetched alongside real-time quotes from multiple Chinese financial data providers.
Is Trading Quant safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.