stock-analyzer-skill 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +21 -0
- package/CHANGELOG.md +93 -0
- package/CONTRIBUTING.md +331 -0
- package/README.md +259 -0
- package/experts/README.md +119 -0
- package/experts/buffett.md +91 -0
- package/experts/chaogu_yangjia.md +125 -0
- package/experts/decide.md +212 -0
- package/experts/duan_yongping.md +106 -0
- package/experts/lynch.md +127 -0
- package/experts/soros.md +89 -0
- package/experts/xu_xiang.md +107 -0
- package/experts/zhao_laoge.md +143 -0
- package/experts/zuoshou_xinyi.md +144 -0
- package/install-plugin.js +69 -0
- package/methodology.md +455 -0
- package/package.json +43 -0
- package/scripts/__pycache__/announcements.cpython-314.pyc +0 -0
- package/scripts/__pycache__/backtest.cpython-314.pyc +0 -0
- package/scripts/__pycache__/chan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/classifier.cpython-314.pyc +0 -0
- package/scripts/__pycache__/common.cpython-314.pyc +0 -0
- package/scripts/__pycache__/finance.cpython-314.pyc +0 -0
- package/scripts/__pycache__/init_pool.cpython-314.pyc +0 -0
- package/scripts/__pycache__/kline.cpython-314.pyc +0 -0
- package/scripts/__pycache__/patterns_local.cpython-314.pyc +0 -0
- package/scripts/__pycache__/quote.cpython-314.pyc +0 -0
- package/scripts/__pycache__/refresh_pool.cpython-314.pyc +0 -0
- package/scripts/__pycache__/screener.cpython-314.pyc +0 -0
- package/scripts/__pycache__/technical.cpython-314.pyc +0 -0
- package/scripts/announcements.py +118 -0
- package/scripts/backtest.py +528 -0
- package/scripts/chan.py +591 -0
- package/scripts/classifier.py +302 -0
- package/scripts/common.py +507 -0
- package/scripts/data/__init__.py +208 -0
- package/scripts/data/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/cache.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/config.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/types.cpython-314.pyc +0 -0
- package/scripts/data/cache.py +99 -0
- package/scripts/data/config.py +49 -0
- package/scripts/data/industry_thresholds.json +199 -0
- package/scripts/data/portfolio_example.json +14 -0
- package/scripts/data/sector_etf.csv +14 -0
- package/scripts/data/sector_mapping.json +64 -0
- package/scripts/data/sector_stocks.json +135 -0
- package/scripts/data/types.py +66 -0
- package/scripts/fetchers/__init__.py +130 -0
- package/scripts/fetchers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_finance.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/baostock_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_finance.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_finance.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/pytdx_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/sina_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/sina_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tencent_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tencent_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tushare_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tushare_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/yfinance_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/akshare_finance.py +35 -0
- package/scripts/fetchers/akshare_kline.py +59 -0
- package/scripts/fetchers/akshare_quote.py +52 -0
- package/scripts/fetchers/baostock_kline.py +64 -0
- package/scripts/fetchers/eastmoney_finance.py +29 -0
- package/scripts/fetchers/eastmoney_kline.py +48 -0
- package/scripts/fetchers/eastmoney_quote.py +68 -0
- package/scripts/fetchers/efinance_finance.py +32 -0
- package/scripts/fetchers/efinance_kline.py +46 -0
- package/scripts/fetchers/efinance_quote.py +53 -0
- package/scripts/fetchers/pytdx_kline.py +70 -0
- package/scripts/fetchers/pytdx_quote.py +78 -0
- package/scripts/fetchers/sina_kline.py +30 -0
- package/scripts/fetchers/sina_quote.py +35 -0
- package/scripts/fetchers/tencent_kline.py +52 -0
- package/scripts/fetchers/tencent_quote.py +29 -0
- package/scripts/fetchers/tushare_kline.py +62 -0
- package/scripts/fetchers/tushare_quote.py +62 -0
- package/scripts/fetchers/yfinance_kline.py +66 -0
- package/scripts/finance.py +92 -0
- package/scripts/init_pool.py +105 -0
- package/scripts/kline.py +62 -0
- package/scripts/monitor.py +107 -0
- package/scripts/patterns_local.py +599 -0
- package/scripts/quote.py +69 -0
- package/scripts/refresh_pool.py +328 -0
- package/scripts/screener.py +434 -0
- package/scripts/strategies/__init__.py +11 -0
- package/scripts/strategies/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/strategies/__pycache__/registry.cpython-314.pyc +0 -0
- package/scripts/strategies/__pycache__/thresholds.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__init__.py +8 -0
- package/scripts/strategies/factors/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/liquidity.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/momentum.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/quality.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/valuation.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/volatility.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/liquidity.py +49 -0
- package/scripts/strategies/factors/momentum.py +45 -0
- package/scripts/strategies/factors/quality.py +54 -0
- package/scripts/strategies/factors/valuation.py +76 -0
- package/scripts/strategies/factors/volatility.py +89 -0
- package/scripts/strategies/registry.py +87 -0
- package/scripts/strategies/thresholds.py +28 -0
- package/scripts/technical/__init__.py +116 -0
- package/scripts/technical/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/astock.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/boll.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/candlestick.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/core.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/kdj.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/macd.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/moving_average.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/report.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/rsi.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/scoring.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/signals.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/trend.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/volume.cpython-314.pyc +0 -0
- package/scripts/technical/astock.py +98 -0
- package/scripts/technical/boll.py +49 -0
- package/scripts/technical/candlestick.py +151 -0
- package/scripts/technical/core.py +92 -0
- package/scripts/technical/kdj.py +68 -0
- package/scripts/technical/macd.py +97 -0
- package/scripts/technical/moving_average.py +59 -0
- package/scripts/technical/report.py +221 -0
- package/scripts/technical/rsi.py +37 -0
- package/scripts/technical/scoring.py +392 -0
- package/scripts/technical/signals.py +70 -0
- package/scripts/technical/trend.py +143 -0
- package/scripts/technical/volume.py +113 -0
- package/scripts/technical.py +215 -0
- package/skills/financial-analyst/SKILL.md +141 -0
- package/skills/help/SKILL.md +188 -0
- package/skills/init/SKILL.md +66 -0
- package/skills/investment-researcher/SKILL.md +152 -0
- package/skills/market/SKILL.md +99 -0
- package/skills/portfolio/SKILL.md +96 -0
- package/skills/screener/SKILL.md +128 -0
- package/skills/sector/SKILL.md +102 -0
- package/skills/stock/SKILL.md +148 -0
- package/skills/technical/SKILL.md +168 -0
- package/workflow.md +91 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
A 股多因子选股器。
|
|
4
|
+
用法:
|
|
5
|
+
screener.py # 内置核心标的池,均衡策略
|
|
6
|
+
screener.py --sector 资源 --top 5
|
|
7
|
+
screener.py --strategy growth_momentum --json
|
|
8
|
+
screener.py --codes sh600989,sz000807,300476
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import statistics
|
|
13
|
+
import sys
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from common import (
|
|
18
|
+
DATA_DIR,
|
|
19
|
+
board_type,
|
|
20
|
+
clamp,
|
|
21
|
+
normalize_finance_code,
|
|
22
|
+
normalize_quote_code,
|
|
23
|
+
plain_code,
|
|
24
|
+
to_float,
|
|
25
|
+
)
|
|
26
|
+
from data import get_quote, get_quotes, get_kline, get_finance
|
|
27
|
+
from classifier import infer_industry
|
|
28
|
+
from strategies import STRATEGIES, quality_score, valuation_score, momentum_score, liquidity_score, volatility_from_closes
|
|
29
|
+
from strategies.thresholds import get_industry_threshold, load_industry_thresholds
|
|
30
|
+
from technical.core import ema
|
|
31
|
+
from technical.macd import macd_full as macd_features
|
|
32
|
+
from technical.rsi import rsi_features
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- 数据层适配函数 ----------
|
|
36
|
+
|
|
37
|
+
def _fetch_quote_dict(code: str) -> dict:
|
|
38
|
+
"""获取单只行情,返回 dict(兼容旧接口)。"""
|
|
39
|
+
q = get_quote(normalize_quote_code(code))
|
|
40
|
+
return q.to_dict() if q else {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fetch_batch_dicts(codes: list) -> list:
|
|
44
|
+
"""批量获取行情,返回 dict 列表。"""
|
|
45
|
+
quotes = get_quotes(codes)
|
|
46
|
+
return [q.to_dict() for q in quotes]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fetch_kline_dicts(code: str, limit: int = 240, scale: int = 30) -> list:
|
|
50
|
+
"""获取 K 线,返回 dict 列表。"""
|
|
51
|
+
bars = get_kline(normalize_quote_code(code), scale=scale, datalen=limit)
|
|
52
|
+
return [b.to_dict() for b in bars]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fetch_finance_dicts(code: str) -> list:
|
|
56
|
+
"""获取财务数据,返回 dict 列表。"""
|
|
57
|
+
records = get_finance(normalize_finance_code(code))
|
|
58
|
+
return [r.to_dict() for r in records]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_universe(sector=None, codes=None):
|
|
62
|
+
if codes:
|
|
63
|
+
return sorted({normalize_quote_code(c) for c in codes})
|
|
64
|
+
|
|
65
|
+
path = DATA_DIR / "sector_stocks.json"
|
|
66
|
+
sectors = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
if sector:
|
|
68
|
+
matched = []
|
|
69
|
+
for name, items in sectors.items():
|
|
70
|
+
if sector.lower() in name.lower():
|
|
71
|
+
matched.extend(items)
|
|
72
|
+
if not matched:
|
|
73
|
+
# 尝试从 sector_mapping.json 查找 BK 代码,动态拉取
|
|
74
|
+
matched = _try_fetch_from_mapping(sector)
|
|
75
|
+
if not matched:
|
|
76
|
+
raise SystemExit(f"未在内置标的库找到板块: {sector}")
|
|
77
|
+
return sorted({normalize_quote_code(c) for c in matched})
|
|
78
|
+
|
|
79
|
+
all_codes = []
|
|
80
|
+
for items in sectors.values():
|
|
81
|
+
all_codes.extend(items)
|
|
82
|
+
return sorted({normalize_quote_code(c) for c in all_codes})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _try_fetch_from_mapping(sector: str) -> list[str]:
|
|
86
|
+
"""从 sector_mapping.json 查找板块的 BK 代码,动态拉取成分股"""
|
|
87
|
+
mapping_path = DATA_DIR / "sector_mapping.json"
|
|
88
|
+
if not mapping_path.exists():
|
|
89
|
+
return []
|
|
90
|
+
try:
|
|
91
|
+
from refresh_pool import fetch_multiple_boards, build_sector_pool
|
|
92
|
+
mapping = json.loads(mapping_path.read_text(encoding="utf-8"))
|
|
93
|
+
# 模糊匹配板块名
|
|
94
|
+
for name, cfg in mapping.items():
|
|
95
|
+
if name.startswith("_"):
|
|
96
|
+
continue
|
|
97
|
+
if sector.lower() in name.lower():
|
|
98
|
+
bk_codes = cfg.get("bk_codes", [])
|
|
99
|
+
if not bk_codes:
|
|
100
|
+
continue
|
|
101
|
+
print(f"📡 动态获取板块 '{name}' ({', '.join(bk_codes)})...", flush=True)
|
|
102
|
+
stocks = fetch_multiple_boards(bk_codes)
|
|
103
|
+
if stocks:
|
|
104
|
+
pool = build_sector_pool(stocks, top_n=30)
|
|
105
|
+
print(f" 获取到 {len(pool)} 只标的")
|
|
106
|
+
return pool
|
|
107
|
+
return []
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f" ⚠ 动态获取失败: {e}", file=sys.stderr)
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def latest_finance(code):
|
|
114
|
+
records = _fetch_finance_dicts(code)
|
|
115
|
+
return records[0] if records else {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def volume_price_features(closes, volumes):
|
|
119
|
+
"""量价关系分析。返回 (vol_price_signal, description)。
|
|
120
|
+
signal: 1=配合良好, 0=中性, -1=背离警报。"""
|
|
121
|
+
if len(closes) < 6 or len(volumes) < 6:
|
|
122
|
+
return {"signal": 0, "desc": "数据不足"}
|
|
123
|
+
|
|
124
|
+
# 近 5 日对比前 5 日
|
|
125
|
+
mid = len(closes) // 2
|
|
126
|
+
recent_close = closes[-mid:]
|
|
127
|
+
prev_close = closes[:mid]
|
|
128
|
+
recent_vol = volumes[-mid:]
|
|
129
|
+
prev_vol = volumes[:mid]
|
|
130
|
+
|
|
131
|
+
price_chg = statistics.mean(recent_close) / max(statistics.mean(prev_close), 0.01) - 1
|
|
132
|
+
vol_chg = statistics.mean(recent_vol) / max(statistics.mean(prev_vol), 0.01) - 1
|
|
133
|
+
|
|
134
|
+
# 近 3 日 vs 整体
|
|
135
|
+
last3_close = closes[-3:]
|
|
136
|
+
last3_vol = volumes[-3:]
|
|
137
|
+
avg_close = statistics.mean(closes)
|
|
138
|
+
avg_vol = statistics.mean(volumes)
|
|
139
|
+
|
|
140
|
+
price_up = statistics.mean(last3_close) > avg_close
|
|
141
|
+
vol_up = statistics.mean(last3_vol) > avg_vol
|
|
142
|
+
|
|
143
|
+
if price_up and vol_up:
|
|
144
|
+
return {"signal": 1, "desc": "放量上涨-资金介入"}
|
|
145
|
+
elif not price_up and not vol_up:
|
|
146
|
+
return {"signal": 1, "desc": "缩量下跌-抛压减轻"}
|
|
147
|
+
elif price_up and not vol_up:
|
|
148
|
+
return {"signal": -1, "desc": "缩量上涨-量价背离"}
|
|
149
|
+
elif not price_up and vol_up:
|
|
150
|
+
return {"signal": -1, "desc": "放量下跌-主力出货"}
|
|
151
|
+
return {"signal": 0, "desc": "量价中性"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def daily_features(code):
|
|
155
|
+
records = _fetch_kline_dicts(code, 240, 30)
|
|
156
|
+
closes = [to_float(r.get("close")) for r in records if to_float(r.get("close")) > 0]
|
|
157
|
+
volumes = [to_float(r.get("volume")) for r in records if to_float(r.get("volume")) > 0]
|
|
158
|
+
if len(closes) < 10:
|
|
159
|
+
return {
|
|
160
|
+
"trend": 0, "ret20": 0, "ma10": 0, "ma20": 0, "volume_ratio": 1,
|
|
161
|
+
"macd_signal": 0, "rsi": 50, "rsi_signal": 0, "vol_price_signal": 0,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
last = closes[-1]
|
|
165
|
+
ma10 = statistics.mean(closes[-10:])
|
|
166
|
+
ma20 = statistics.mean(closes[-20:]) if len(closes) >= 20 else statistics.mean(closes)
|
|
167
|
+
base = closes[-21] if len(closes) >= 21 else closes[0]
|
|
168
|
+
ret20 = (last / base - 1) * 100 if base else 0
|
|
169
|
+
recent_vol = statistics.mean(volumes[-5:]) if len(volumes) >= 5 else 0
|
|
170
|
+
base_vol = statistics.mean(volumes[-20:-5]) if len(volumes) >= 20 else recent_vol
|
|
171
|
+
volume_ratio = recent_vol / base_vol if base_vol else 1
|
|
172
|
+
trend = 0
|
|
173
|
+
if last > ma10 > ma20:
|
|
174
|
+
trend = 1
|
|
175
|
+
elif last < ma10 < ma20:
|
|
176
|
+
trend = -1
|
|
177
|
+
|
|
178
|
+
# MACD
|
|
179
|
+
macd = macd_features(closes) or {"signal": 0}
|
|
180
|
+
macd_signal = macd.get("signal", 0)
|
|
181
|
+
|
|
182
|
+
# RSI
|
|
183
|
+
rsi = rsi_features(closes)
|
|
184
|
+
rsi_val = rsi["rsi"]
|
|
185
|
+
rsi_signal = rsi["signal"]
|
|
186
|
+
|
|
187
|
+
# 量价关系
|
|
188
|
+
vp = volume_price_features(closes, volumes)
|
|
189
|
+
vol_price_signal = vp["signal"]
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"trend": trend,
|
|
193
|
+
"ret20": ret20,
|
|
194
|
+
"ma10": ma10,
|
|
195
|
+
"ma20": ma20,
|
|
196
|
+
"volume_ratio": volume_ratio,
|
|
197
|
+
"macd_signal": macd_signal,
|
|
198
|
+
"rsi": round(rsi_val, 1),
|
|
199
|
+
"rsi_signal": rsi_signal,
|
|
200
|
+
"vol_price_signal": vol_price_signal,
|
|
201
|
+
"closes": closes, # 用于波动率因子计算
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def hard_filter(quote, fin, args):
|
|
206
|
+
reasons = []
|
|
207
|
+
name = quote.get("name", "")
|
|
208
|
+
code = quote.get("code", "")
|
|
209
|
+
bd = board_type(code)
|
|
210
|
+
|
|
211
|
+
# ST 检测:A 股 ST 标记在名称开头,用前缀匹配而非子串匹配
|
|
212
|
+
upper_name = name.upper()
|
|
213
|
+
if upper_name.startswith("ST") or upper_name.startswith("*ST"):
|
|
214
|
+
reasons.append("ST风险")
|
|
215
|
+
|
|
216
|
+
# 退市风险:市值过小(主板<3亿、创业板/科创板<2亿)
|
|
217
|
+
min_survival_cap = {"主板": 3, "创业板": 2, "科创板": 2, "北交所": 1}.get(bd, 3)
|
|
218
|
+
if 0 < to_float(quote.get("total_cap")) < min_survival_cap:
|
|
219
|
+
reasons.append(f"市值<{min_survival_cap}亿(退市风险)")
|
|
220
|
+
|
|
221
|
+
# 连续亏损检测(EPS 连续为负)
|
|
222
|
+
if to_float(fin.get("EPSJB")) < 0:
|
|
223
|
+
reasons.append("EPS<0(亏损)")
|
|
224
|
+
|
|
225
|
+
# 商誉减值风险(可选字段,无数据时跳过)
|
|
226
|
+
goodwill_ratio = to_float(fin.get("GOODWILL_RATIO", 0))
|
|
227
|
+
if goodwill_ratio > 30:
|
|
228
|
+
reasons.append(f"商誉/总资产>{goodwill_ratio:.0f}%(减值风险)")
|
|
229
|
+
|
|
230
|
+
# 股权质押率过高(可选字段,无数据时跳过)
|
|
231
|
+
pledge_ratio = to_float(fin.get("PLEDGE_RATIO", 0))
|
|
232
|
+
if pledge_ratio > 70:
|
|
233
|
+
reasons.append(f"质押率>{pledge_ratio:.0f}%(爆仓风险)")
|
|
234
|
+
|
|
235
|
+
# 板块差异化阈值:主板 10%,科创/创业板 20%,北交所 30% 涨跌停
|
|
236
|
+
board_min_amount = {
|
|
237
|
+
"主板": args.min_amount,
|
|
238
|
+
"创业板": args.min_amount * 0.7,
|
|
239
|
+
"科创板": args.min_amount * 0.7,
|
|
240
|
+
"北交所": args.min_amount * 1.5,
|
|
241
|
+
}
|
|
242
|
+
board_min_cap = {
|
|
243
|
+
"主板": args.min_cap,
|
|
244
|
+
"创业板": args.min_cap * 0.6,
|
|
245
|
+
"科创板": args.min_cap * 0.6,
|
|
246
|
+
"北交所": args.min_cap * 0.4,
|
|
247
|
+
}
|
|
248
|
+
min_amt = board_min_amount.get(bd, args.min_amount)
|
|
249
|
+
min_cap = board_min_cap.get(bd, args.min_cap)
|
|
250
|
+
|
|
251
|
+
if to_float(quote.get("amount")) < min_amt:
|
|
252
|
+
reasons.append(f"成交额<{min_amt:.0f}万")
|
|
253
|
+
if to_float(quote.get("total_cap")) < min_cap:
|
|
254
|
+
reasons.append(f"市值<{min_cap:.0f}亿")
|
|
255
|
+
|
|
256
|
+
change_pct = abs(to_float(quote.get("change_pct")))
|
|
257
|
+
# 涨跌停过滤:T+1 下当日无法交易
|
|
258
|
+
limit_ratio = {"主板": 9.5, "创业板": 19.5, "科创板": 19.5, "北交所": 29.5}
|
|
259
|
+
limit = limit_ratio.get(bd, 9.5)
|
|
260
|
+
if change_pct >= limit:
|
|
261
|
+
reasons.append("涨跌停限制")
|
|
262
|
+
|
|
263
|
+
if args.exclude_loss and to_float(fin.get("EPSJB")) <= 0:
|
|
264
|
+
reasons.append("EPS<=0")
|
|
265
|
+
return reasons
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def prefetch_finance_all(codes):
|
|
269
|
+
"""并发拉取所有股票的财务数据。"""
|
|
270
|
+
results = {}
|
|
271
|
+
|
|
272
|
+
def _fetch_one(code):
|
|
273
|
+
# data 层已有零值缓存校验,自动跳过无效缓存
|
|
274
|
+
from data import get_finance
|
|
275
|
+
records = get_finance(normalize_finance_code(code))
|
|
276
|
+
return code, [r.to_dict() for r in records]
|
|
277
|
+
|
|
278
|
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
279
|
+
futures = {ex.submit(_fetch_one, c): c for c in codes}
|
|
280
|
+
for future in as_completed(futures):
|
|
281
|
+
try:
|
|
282
|
+
code, data = future.result()
|
|
283
|
+
results[code] = data
|
|
284
|
+
except Exception:
|
|
285
|
+
results[futures[future]] = []
|
|
286
|
+
return results
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def analyze_code(quote, strategy, args, finance_cache=None):
|
|
290
|
+
code = quote["code"]
|
|
291
|
+
quote_code = normalize_quote_code(code)
|
|
292
|
+
if finance_cache is not None:
|
|
293
|
+
records = finance_cache.get(quote_code, [])
|
|
294
|
+
fin = records[0] if records else {}
|
|
295
|
+
else:
|
|
296
|
+
fin = latest_finance(quote_code)
|
|
297
|
+
features = daily_features(quote_code)
|
|
298
|
+
rejected = hard_filter(quote, fin, args)
|
|
299
|
+
|
|
300
|
+
# 推断行业,获取行业差异化阈值
|
|
301
|
+
industry = infer_industry(quote.get("name", ""), quote_code)
|
|
302
|
+
|
|
303
|
+
parts = {
|
|
304
|
+
"quality": quality_score(fin, industry),
|
|
305
|
+
"valuation": valuation_score(quote, fin, industry),
|
|
306
|
+
"momentum": momentum_score(features, quote),
|
|
307
|
+
"liquidity": liquidity_score(quote),
|
|
308
|
+
"volatility": volatility_from_closes(features.get("closes", []), industry),
|
|
309
|
+
}
|
|
310
|
+
weights = STRATEGIES[strategy]
|
|
311
|
+
total = sum(parts.get(k, 0) * weights.get(k, 0) for k in set(parts) | set(weights) if k != "label")
|
|
312
|
+
return {
|
|
313
|
+
"code": quote_code,
|
|
314
|
+
"name": quote.get("name", ""),
|
|
315
|
+
"board": board_type(quote_code),
|
|
316
|
+
"industry": industry,
|
|
317
|
+
"score": round(total, 1),
|
|
318
|
+
"quality": round(parts["quality"], 1),
|
|
319
|
+
"valuation": round(parts["valuation"], 1),
|
|
320
|
+
"momentum": round(parts["momentum"], 1),
|
|
321
|
+
"liquidity": round(parts["liquidity"], 1),
|
|
322
|
+
"volatility": round(parts["volatility"], 1),
|
|
323
|
+
"price": quote.get("price"),
|
|
324
|
+
"change_pct": quote.get("change_pct"),
|
|
325
|
+
"pe": quote.get("pe"),
|
|
326
|
+
"pb": quote.get("pb"),
|
|
327
|
+
"roe": fin.get("roe", fin.get("ROEJQ", "-")),
|
|
328
|
+
"profit_growth": fin.get("net_profit_yoy", fin.get("PARENTNETPROFITTZ", "-")),
|
|
329
|
+
"ret20": round(features["ret20"], 1),
|
|
330
|
+
"trend": "上升" if features["trend"] > 0 else "下降" if features["trend"] < 0 else "震荡",
|
|
331
|
+
"rsi": features.get("rsi", 50),
|
|
332
|
+
"macd_signal": features.get("macd_signal", 0),
|
|
333
|
+
"vol_price": "配合" if features.get("vol_price_signal", 0) > 0 else "背离" if features.get("vol_price_signal", 0) < 0 else "中性",
|
|
334
|
+
"rejected": rejected,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def apply_portfolio_constraints(rows: list, sector_cap: float = 0.30,
|
|
339
|
+
trend_penalty: float = 0.70) -> list:
|
|
340
|
+
"""应用组合层面约束。
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
rows: 已排序的候选股票列表
|
|
344
|
+
sector_cap: 单板块最高占比(默认 30%)
|
|
345
|
+
trend_penalty: 趋势下降标的得分乘数(默认 0.70)
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
应用约束后的列表
|
|
349
|
+
"""
|
|
350
|
+
if not rows:
|
|
351
|
+
return rows
|
|
352
|
+
|
|
353
|
+
max_per_sector = max(1, int(len(rows) * sector_cap))
|
|
354
|
+
sector_count = {}
|
|
355
|
+
result = []
|
|
356
|
+
|
|
357
|
+
for stock in rows:
|
|
358
|
+
industry = stock.get("industry", "默认")
|
|
359
|
+
|
|
360
|
+
# 板块集中度约束
|
|
361
|
+
if sector_count.get(industry, 0) >= max_per_sector:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
# 趋势下降降权
|
|
365
|
+
if stock.get("trend") == "下降":
|
|
366
|
+
stock["score"] = round(stock["score"] * trend_penalty, 1)
|
|
367
|
+
|
|
368
|
+
sector_count[industry] = sector_count.get(industry, 0) + 1
|
|
369
|
+
result.append(stock)
|
|
370
|
+
|
|
371
|
+
# 重新排序(降权后排名可能变化)
|
|
372
|
+
result.sort(key=lambda r: r["score"], reverse=True)
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def render(rows, strategy, top):
|
|
377
|
+
accepted = [r for r in rows if not r["rejected"]]
|
|
378
|
+
rejected = [r for r in rows if r["rejected"]]
|
|
379
|
+
accepted.sort(key=lambda r: r["score"], reverse=True)
|
|
380
|
+
|
|
381
|
+
print(f"策略: {STRATEGIES[strategy]['label']} ({strategy})")
|
|
382
|
+
print(f"入选: {len(accepted)} | 剔除: {len(rejected)}")
|
|
383
|
+
print()
|
|
384
|
+
header = "排名 | 代码 | 名称 | 行业 | 板块 | 总分 | 质量 | 估值 | 动量 | 流动性 | PE | ROE | RSI | 20日% | 趋势 | 量价"
|
|
385
|
+
print(header)
|
|
386
|
+
print("-" * len(header))
|
|
387
|
+
for idx, r in enumerate(accepted[:top], 1):
|
|
388
|
+
macd_icon = "↑" if r.get("macd_signal", 0) > 0 else "↓" if r.get("macd_signal", 0) < 0 else "→"
|
|
389
|
+
print(
|
|
390
|
+
f"{idx:>2} | {r['code']:<8} | {r['name']:<8} | {r.get('industry', '默认'):<4} | {r['board']:<4} | "
|
|
391
|
+
f"{r['score']:>5} | {r['quality']:>5} | {r['valuation']:>5} | "
|
|
392
|
+
f"{r['momentum']:>5} | {r['liquidity']:>6} | {r['pe']:>6} | "
|
|
393
|
+
f"{str(r['roe'])[:6]:>6} | {r.get('rsi', 50):>4} | {r['ret20']:>5} | {r['trend']}{macd_icon} | {r.get('vol_price', '?')}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if rejected:
|
|
397
|
+
print()
|
|
398
|
+
print("剔除样本:")
|
|
399
|
+
for r in rejected[:10]:
|
|
400
|
+
print(f"- {r['code']} {r['name']}: {', '.join(r['rejected'])}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main():
|
|
404
|
+
parser = argparse.ArgumentParser(description="A 股多因子选股器")
|
|
405
|
+
parser.add_argument("--strategy", choices=STRATEGIES.keys(), default="balanced")
|
|
406
|
+
parser.add_argument("--sector", help="内置板块名称,支持模糊匹配")
|
|
407
|
+
parser.add_argument("--codes", help="逗号分隔代码列表,优先于 --sector")
|
|
408
|
+
parser.add_argument("--top", type=int, default=10)
|
|
409
|
+
parser.add_argument("--min-amount", type=float, default=5000, help="最低成交额,单位万元")
|
|
410
|
+
parser.add_argument("--min-cap", type=float, default=40, help="最低总市值,单位亿元")
|
|
411
|
+
parser.add_argument("--exclude-loss", action="store_true", help="剔除 EPS<=0 标的")
|
|
412
|
+
parser.add_argument("--no-constraints", action="store_true", help="禁用组合约束")
|
|
413
|
+
parser.add_argument("--sector-cap", type=float, default=0.30, help="单板块最高占比")
|
|
414
|
+
parser.add_argument("-j", "--json", action="store_true")
|
|
415
|
+
args = parser.parse_args()
|
|
416
|
+
|
|
417
|
+
codes = load_universe(args.sector, args.codes.split(",") if args.codes else None)
|
|
418
|
+
quotes = _fetch_batch_dicts(codes)
|
|
419
|
+
finance_cache = prefetch_finance_all(codes)
|
|
420
|
+
rows = [analyze_code(q, args.strategy, args, finance_cache) for q in quotes]
|
|
421
|
+
rows.sort(key=lambda r: r["score"], reverse=True)
|
|
422
|
+
|
|
423
|
+
# 应用组合约束(除非禁用)
|
|
424
|
+
if not args.no_constraints:
|
|
425
|
+
rows = apply_portfolio_constraints(rows, sector_cap=args.sector_cap)
|
|
426
|
+
|
|
427
|
+
if args.json:
|
|
428
|
+
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
|
429
|
+
else:
|
|
430
|
+
render(rows, args.strategy, args.top)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
if __name__ == "__main__":
|
|
434
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
策略引擎层:因子注册、策略配置、评分管道。
|
|
3
|
+
"""
|
|
4
|
+
from .registry import (
|
|
5
|
+
STRATEGIES,
|
|
6
|
+
get_strategy,
|
|
7
|
+
register_strategy,
|
|
8
|
+
list_strategies,
|
|
9
|
+
)
|
|
10
|
+
from .factors import quality_score, valuation_score, momentum_score, liquidity_score, volatility_score
|
|
11
|
+
from .factors.volatility import volatility_from_closes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
因子实现:quality / valuation / momentum / liquidity / volatility。
|
|
3
|
+
"""
|
|
4
|
+
from .quality import quality_score
|
|
5
|
+
from .valuation import valuation_score
|
|
6
|
+
from .momentum import momentum_score
|
|
7
|
+
from .liquidity import liquidity_score
|
|
8
|
+
from .volatility import volatility_score
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
流动性因子评分:成交额、总市值、换手率(板块差异化)。
|
|
3
|
+
"""
|
|
4
|
+
from common import to_float, clamp, board_type
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def liquidity_score(quote: dict) -> float:
|
|
8
|
+
"""流动性因子评分(板块差异化)。满分 100。"""
|
|
9
|
+
amount = to_float(quote.get("amount")) # 成交额(万元)
|
|
10
|
+
cap = to_float(quote.get("total_cap")) # 总市值(亿元)
|
|
11
|
+
turnover = to_float(quote.get("turnover"))
|
|
12
|
+
bd = board_type(quote.get("code", ""))
|
|
13
|
+
|
|
14
|
+
# 板块差异化满分阈值
|
|
15
|
+
# 主板:成交额 5 亿满分,市值 150 亿满分
|
|
16
|
+
# 创业板/科创板:成交额 2 亿满分,市值 60 亿满分
|
|
17
|
+
# 北交所:成交额 0.5 亿满分,市值 20 亿满分
|
|
18
|
+
amount_max = {"主板": 50000, "创业板": 20000, "科创板": 20000, "北交所": 5000}.get(bd, 50000)
|
|
19
|
+
cap_max = {"主板": 150, "创业板": 60, "科创板": 60, "北交所": 20}.get(bd, 150)
|
|
20
|
+
|
|
21
|
+
score = 0
|
|
22
|
+
score += clamp(amount / amount_max * 42)
|
|
23
|
+
score += clamp(cap / cap_max * 28)
|
|
24
|
+
# 换手率阈值按市值分层:大盘股正常换手率远低于小盘股
|
|
25
|
+
if cap > 500:
|
|
26
|
+
# 大盘股(>500亿):换手率 0.1-5% 为正常
|
|
27
|
+
if 0.1 <= turnover <= 5:
|
|
28
|
+
score += 24
|
|
29
|
+
elif 5 < turnover <= 10:
|
|
30
|
+
score += 14
|
|
31
|
+
else:
|
|
32
|
+
score += 6
|
|
33
|
+
elif cap > 100:
|
|
34
|
+
# 中盘股(100-500亿):换手率 0.3-8% 为正常
|
|
35
|
+
if 0.3 <= turnover <= 8:
|
|
36
|
+
score += 24
|
|
37
|
+
elif 8 < turnover <= 15:
|
|
38
|
+
score += 14
|
|
39
|
+
else:
|
|
40
|
+
score += 6
|
|
41
|
+
else:
|
|
42
|
+
# 小盘股(<100亿):换手率 0.5-12% 为正常
|
|
43
|
+
if 0.5 <= turnover <= 12:
|
|
44
|
+
score += 24
|
|
45
|
+
elif 12 < turnover <= 20:
|
|
46
|
+
score += 14
|
|
47
|
+
else:
|
|
48
|
+
score += 6
|
|
49
|
+
return clamp(score)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
动量因子评分:趋势、20日收益、量能比、MACD、RSI、量价配合。
|
|
3
|
+
MACD 和 RSI 均为趋势确认指标,同向时降权以避免信息冗余。
|
|
4
|
+
"""
|
|
5
|
+
from common import to_float, clamp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def momentum_score(features: dict, quote: dict) -> float:
|
|
9
|
+
"""动量因子评分。满分 100。"""
|
|
10
|
+
ret20 = features["ret20"]
|
|
11
|
+
volume_ratio = features["volume_ratio"]
|
|
12
|
+
turnover = to_float(quote.get("turnover"))
|
|
13
|
+
|
|
14
|
+
# 趋势基础分:缩小上升/下降差距,避免过度敏感
|
|
15
|
+
score = 40 if features["trend"] > 0 else 20 if features["trend"] == 0 else 12
|
|
16
|
+
score += clamp((ret20 + 8) / 25 * 22)
|
|
17
|
+
score += clamp((volume_ratio - 0.6) / 1.4 * 12)
|
|
18
|
+
score += clamp(turnover / 6 * 6)
|
|
19
|
+
|
|
20
|
+
# MACD 金叉加分,死叉扣分
|
|
21
|
+
macd_signal = features.get("macd_signal", 0)
|
|
22
|
+
if macd_signal > 0:
|
|
23
|
+
score += 10
|
|
24
|
+
elif macd_signal < 0:
|
|
25
|
+
score -= 8
|
|
26
|
+
|
|
27
|
+
# RSI 合理区间加分,过度区域扣分
|
|
28
|
+
# 与 MACD 同向时降权(避免趋势类信息重复计算)
|
|
29
|
+
rsi = features.get("rsi", 50)
|
|
30
|
+
rsi_weight = 0.6 if (macd_signal > 0 and rsi > 50) or (macd_signal < 0 and rsi < 50) else 1.0
|
|
31
|
+
if 30 <= rsi <= 70:
|
|
32
|
+
score += int(5 * rsi_weight)
|
|
33
|
+
elif rsi > 80:
|
|
34
|
+
score -= int(6 * rsi_weight)
|
|
35
|
+
elif rsi < 20:
|
|
36
|
+
score -= int(4 * rsi_weight)
|
|
37
|
+
|
|
38
|
+
# 量价配合加分
|
|
39
|
+
vol_price_signal = features.get("vol_price_signal", 0)
|
|
40
|
+
if vol_price_signal > 0:
|
|
41
|
+
score += 8
|
|
42
|
+
elif vol_price_signal < 0:
|
|
43
|
+
score -= 10
|
|
44
|
+
|
|
45
|
+
return clamp(score)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
质量因子评分:ROE、净利增速、营收增速、毛利率、负债率、经营现金流。
|
|
3
|
+
支持多期财务数据的 ROE 趋势判断。
|
|
4
|
+
"""
|
|
5
|
+
from common import to_float, clamp
|
|
6
|
+
from strategies.thresholds import get_industry_threshold
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def quality_score(fin: dict, industry: str = "默认") -> float:
|
|
10
|
+
"""质量因子评分(行业差异化)。满分 100。
|
|
11
|
+
fin 可包含多期数据(roe_trend 列表),用于 ROE 趋势判断。
|
|
12
|
+
"""
|
|
13
|
+
# 支持标准化字段名(data层)和原始东财字段名(兼容)
|
|
14
|
+
roe = to_float(fin.get("roe", fin.get("ROEJQ")))
|
|
15
|
+
profit_growth = to_float(fin.get("net_profit_yoy", fin.get("PARENTNETPROFITTZ")))
|
|
16
|
+
revenue_growth = to_float(fin.get("revenue_yoy", fin.get("TOTALOPERATEREVETZ")))
|
|
17
|
+
gross_margin = to_float(fin.get("gross_margin", fin.get("XSMLL")))
|
|
18
|
+
debt = to_float(fin.get("debt_ratio", fin.get("ZCFZL")))
|
|
19
|
+
eps = to_float(fin.get("eps", fin.get("EPSJB")))
|
|
20
|
+
cashflow = to_float(fin.get("ocf_per_share", fin.get("MGJYXJJE")))
|
|
21
|
+
|
|
22
|
+
# 行业差异化 ROE 基准
|
|
23
|
+
roe_excellent = get_industry_threshold(industry, "roe_excellent", 20)
|
|
24
|
+
gross_margin_min = get_industry_threshold(industry, "gross_margin_min", 20)
|
|
25
|
+
debt_max = get_industry_threshold(industry, "debt_ratio_max", 60)
|
|
26
|
+
|
|
27
|
+
score = 0
|
|
28
|
+
# ROE:相对于行业优秀值评分
|
|
29
|
+
score += clamp(roe / roe_excellent * 28)
|
|
30
|
+
|
|
31
|
+
# ROE 趋势:连续下降扣分,连续上升加分
|
|
32
|
+
roe_trend = fin.get("roe_trend", [])
|
|
33
|
+
if len(roe_trend) >= 2:
|
|
34
|
+
declining = all(roe_trend[i] < roe_trend[i - 1] for i in range(1, len(roe_trend)))
|
|
35
|
+
rising = all(roe_trend[i] > roe_trend[i - 1] for i in range(1, len(roe_trend)))
|
|
36
|
+
if declining:
|
|
37
|
+
score -= 8 # ROE 连续下降,基本面恶化信号
|
|
38
|
+
elif rising:
|
|
39
|
+
score += 5 # ROE 连续上升,基本面改善
|
|
40
|
+
|
|
41
|
+
profit_growth_base = get_industry_threshold(industry, "profit_growth_excellent", 40)
|
|
42
|
+
score += clamp(profit_growth / profit_growth_base * 22) if profit_growth_base > 0 else 0
|
|
43
|
+
revenue_growth_base = get_industry_threshold(industry, "revenue_growth_excellent", 30)
|
|
44
|
+
score += clamp(revenue_growth / revenue_growth_base * 16) if revenue_growth_base > 0 else 0
|
|
45
|
+
# 毛利率:相对于行业最低值评分
|
|
46
|
+
if gross_margin_min > 0:
|
|
47
|
+
score += clamp(gross_margin / (gross_margin_min * 2) * 16)
|
|
48
|
+
else:
|
|
49
|
+
score += clamp(gross_margin / 40 * 16)
|
|
50
|
+
# 负债率:相对于行业上限评分
|
|
51
|
+
score += clamp((debt_max + 10 - debt) / (debt_max + 10) * 12)
|
|
52
|
+
if eps > 0 and cashflow > 0:
|
|
53
|
+
score += clamp((cashflow / eps) * 6, 0, 6)
|
|
54
|
+
return clamp(score)
|