-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpullback_picker.py
More file actions
440 lines (365 loc) · 17 KB
/
Copy pathpullback_picker.py
File metadata and controls
440 lines (365 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
#!/usr/bin/env python3
"""
回调到位选股工具 v4 (并发优化版)
核心逻辑:前期有上涨趋势 → 回调到支撑位 → 即将重启上涨 → 不追涨
优化:使用 ThreadPoolExecutor 并发请求,速度提升 20~50 倍
数据源: akshare (stock_zh_a_daily 稳定接口)
"""
import warnings
warnings.filterwarnings('ignore')
import time
import re
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
import akshare as ak
import pandas as pd
import numpy as np
# ─────────────────────────────────────────────
# 数据获取
# ─────────────────────────────────────────────
def get_stock_history(code, adjust='qfq'):
"""获取个股历史K线(最近600条)"""
try:
if code.startswith(('000', '001', '002', '003', '300')):
sym = 'sz' + code
else:
sym = 'sh' + code
df = ak.stock_zh_a_daily(symbol=sym, adjust=adjust)
if df is None or len(df) < 80:
return None
df = df.sort_values('date').tail(600).reset_index(drop=True)
df['pct_chg'] = df['close'].pct_change() * 100
return df
except:
return None
def get_all_stocks():
"""获取全市场A股列表(过滤ST/退市)"""
try:
df = ak.stock_info_a_code_name()
df = df[df['code'].str.match(r'^\d{6}$')]
df = df[~df['name'].str.contains('ST|退市|N |C ', na=False, regex=True)]
return df[['code', 'name']].to_dict('records')
except:
return []
def get_top_stocks(n=300):
"""获取成交额最大的N只股票(优先扫描)"""
try:
df = ak.stock_info_a_code_name()
df = df[df['code'].str.match(r'^\d{6}$')]
df = df[~df['name'].str.contains('ST|退市|N |C ', na=False, regex=True)]
return df.head(n)[['code', 'name']].to_dict('records')
except:
return []
# ─────────────────────────────────────────────
# 技术指标
# ─────────────────────────────────────────────
def compute_indicators(df):
close = df['close'].values.astype(float)
high = df['high'].values.astype(float)
low = df['low'].values.astype(float)
vol = df['volume'].values.astype(float)
ma5 = pd.Series(close).rolling(5, min_periods=1).mean().values
ma10 = pd.Series(close).rolling(10, min_periods=1).mean().values
ma20 = pd.Series(close).rolling(20, min_periods=1).mean().values
ma60 = pd.Series(close).rolling(60, min_periods=1).mean().values
ma120 = pd.Series(close).rolling(120, min_periods=1).mean().values
delta = pd.Series(close).diff()
def _rsi(n):
g = delta.clip(lower=0).rolling(n, min_periods=1).mean().values
l = (-delta.clip(upper=0)).rolling(n, min_periods=1).mean().values
return 100 - 100 / (1 + g / (l + 1e-10))
ema12 = pd.Series(close).ewm(span=12, adjust=False).mean().values
ema26 = pd.Series(close).ewm(span=26, adjust=False).mean().values
macd_l = ema12 - ema26
signal = pd.Series(macd_l).ewm(span=9, adjust=False).mean().values
macd_h = macd_l - signal
low_n = pd.Series(low).rolling(9, min_periods=1).min()
high_n = pd.Series(high).rolling(9, min_periods=1).max()
rsv = (close - low_n.values) / (high_n.values - low_n.values + 1e-10) * 100
k = pd.Series(rsv).ewm(com=2, adjust=False).mean().values
d = pd.Series(k).ewm(com=2, adjust=False).mean().values
j = 3 * k - 2 * d
b_mid = ma20
b_std = pd.Series(close).rolling(20).std().values
b_upper = b_mid + 2 * b_std
b_lower = b_mid - 2 * b_std
tr = np.maximum(high - low,
np.maximum(abs(high - np.roll(close, 1)),
abs(low - np.roll(close, 1))))
tr[0] = high[0] - low[0]
atr14 = pd.Series(tr).rolling(14, min_periods=1).mean().values
vma5 = pd.Series(vol).rolling(5, min_periods=1).mean().values
vma20 = pd.Series(vol).rolling(20, min_periods=1).mean().values
return {
'close': close, 'high': high, 'low': low, 'volume': vol,
'ma5': ma5, 'ma10': ma10, 'ma20': ma20, 'ma60': ma60, 'ma120': ma120,
'rsi6': _rsi(6), 'rsi14': _rsi(14),
'macd': macd_l, 'signal': signal, 'hist': macd_h,
'k': k, 'd': d, 'j': j,
'boll_upper': b_upper, 'boll_lower': b_lower,
'atr14': atr14, 'vma5': vma5, 'vma20': vma20,
'ret5': (close[-1]/close[-6]-1) if len(close) > 5 else 0.0,
'ret10': (close[-1]/close[-11]-1) if len(close) > 10 else 0.0,
'ret20': (close[-1]/close[-21]-1) if len(close) > 20 else 0.0,
'bias5': (close/ma5-1)*100,
'bias20': (close/ma20-1)*100,
}
# ─────────────────────────────────────────────
# 回调到位核心评分
# ─────────────────────────────────────────────
def score_pullback(ind):
"""判定是否处于【回调到位即将重启上涨】,返回 dict 或 None"""
n = -1
def last(arr):
try:
v = float(arr[n])
return None if (np.isnan(v) or np.isinf(v)) else v
except:
return None
def must(arr):
v = last(arr)
if v is None:
raise ValueError('missing')
return v
try:
c = must(ind['close'])
m5 = must(ind['ma5'])
m10 = must(ind['ma10'])
m20 = must(ind['ma20'])
m60 = last(ind['ma60'])
m120 = last(ind['ma120'])
rsi6 = must(ind['rsi6'])
rsi14 = must(ind['rsi14'])
macd_h = must(ind['hist'])
macd_l = must(ind['macd'])
sig = must(ind['signal'])
k_ = must(ind['k'])
d_ = must(ind['d'])
j_ = must(ind['j'])
vol = must(ind['volume'])
vma5 = must(ind['vma5'])
vma20 = must(ind['vma20'])
b_l = last(ind['boll_lower'])
b_u = last(ind['boll_upper'])
atr = must(ind['atr14'])
ret10 = float(ind['ret10']) if not np.isnan(float(ind['ret10'])) else 0.0
ret20 = float(ind['ret20']) if not np.isnan(float(ind['ret20'])) else 0.0
bias5 = must(ind['bias5'])
bias20 = must(ind['bias20'])
rsi6_prev = float(ind['rsi6'][-2]) if len(ind['rsi6']) >= 2 else rsi6
rsi14_prev = float(ind['rsi14'][-2]) if len(ind['rsi14']) >= 2 else rsi14
k_prev = float(ind['k'][-2]) if len(ind['k']) >= 2 else k_
d_prev = float(ind['d'][-2]) if len(ind['d']) >= 2 else d_
except ValueError:
return None
score = 0.0
reasons = []
# ── 前提:前期有上涨趋势 ──
has_uptrend = False
if ret20 > 0.12:
score += 2.0; reasons.append('强势:近20日+{:.0f}%'.format(ret20*100)); has_uptrend = True
elif ret10 > 0.06:
score += 1.5; reasons.append('上涨:近10日+{:.0f}%'.format(ret10*100)); has_uptrend = True
elif ret10 > 0.02:
score += 1.0; reasons.append('小幅涨:近10日+{:.0f}%'.format(ret10*100)); has_uptrend = True
if not has_uptrend:
return None
# ── 回调判定 ──
is_pullback = False
depth = ''
if c < m10 * 0.97 and c >= m20 * 0.92:
score += 3.0
reasons.append('回调至MA10下:{:+.1f}%'.format((c/m10-1)*100))
is_pullback = True; depth = '轻回调'
elif c <= m20 * 1.03 and c >= m20 * 0.90:
score += 2.5
reasons.append('回调至MA20({:.2f}):{:+.1f}%'.format(m20, (c/m20-1)*100))
is_pullback = True; depth = '中回调'
elif m60 and c <= m60 * 1.06 and c >= m60 * 0.87:
score += 1.5
reasons.append('回调至MA60({:.2f})'.format(m60))
is_pullback = True; depth = '深回调'
elif b_l and c >= b_l * 0.96 and c <= b_u:
score += 2.0
reasons.append('布林下轨({:.2f})获撑'.format(b_l))
is_pullback = True; depth = '布林撑'
elif abs(m5 - m10) / m10 < 0.015 and c < m5 * 1.01:
score += 1.5
reasons.append('均线收敛后小回调')
is_pullback = True; depth = '黏合回调'
if not is_pullback:
return None
# ── 即将重启上涨 ──
if macd_h > 0:
score += 2.5; reasons.append('MACD红柱(已启动)')
elif macd_h > -0.03:
score += 2.5; reasons.append('MACD绿柱收窄({:.4f})'.format(macd_h))
elif macd_h > -0.10:
score += 1.5; reasons.append('MACD绿柱({:.4f})'.format(macd_h))
if 30 < rsi14 < 55:
score += 2.0; reasons.append('RSI14={:.0f}(回升区)'.format(rsi14))
elif 25 < rsi14 < 65:
score += 1.0; reasons.append('RSI14={:.0f}(正常区)'.format(rsi14))
if rsi6_prev < rsi14_prev and rsi6 > rsi14:
score += 1.5; reasons.append('RSI6金叉RSI14')
if k_ > d_ and k_ < 70:
score += 1.5; reasons.append('KDJ金叉(K={:.0f})'.format(k_))
elif j_ < 20 or (k_ < 30 and j_ > k_):
score += 1.0; reasons.append('KDJ超卖J={:.0f}'.format(j_))
if -5 < bias20 < 3:
score += 1.0; reasons.append('乖离正常:BIAS20={:.1f}%'.format(bias20))
elif bias20 < -8:
score -= 0.5; reasons.append('超跌:BIAS20={:.1f}%'.format(bias20))
vol_r = vol / vma20 if vma20 > 0 else 0
if 0.4 < vol_r < 2.0:
score += 0.5; reasons.append('量能健康({:.1f}xMA20)'.format(vol_r))
if vol > vma5:
score += 0.5; reasons.append('量开始放大')
if bias5 > 6:
score -= 1.5; reasons.append('偏离MA5过大,追高风险')
if c > m5 * 1.08:
score -= 1.0; reasons.append('突破MA5较多,追涨风险')
if m120 and c < m120 * 0.92:
return None
return {
'score': score, 'reasons': reasons,
'close': c, 'ma5': m5, 'ma10': m10, 'ma20': m20, 'ma60': m60,
'rsi6': rsi6, 'rsi14': rsi14,
'macd_hist': macd_h, 'macd': macd_l, 'signal': sig,
'k': k_, 'd': d_, 'j': j_,
'vol_ratio': vol_r,
'ret10': ret10, 'ret20': ret20,
'pullback_depth': depth,
'atr': atr, 'bias5': bias5, 'bias20': bias20,
}
# ─────────────────────────────────────────────
# 并发扫描(核心优化)
# ─────────────────────────────────────────────
def scan_one(stock):
"""扫描单只股票"""
code = stock['code']
name = stock['name']
df = get_stock_history(code)
if df is None or len(df) < 120:
return None
ind = compute_indicators(df)
result = score_pullback(ind)
if result is not None:
result['code'] = code
result['name'] = name
result['date'] = str(df['date'].iloc[-1])
return result
def scan(stock_list, min_score=5.0, max_count=500, concurrency=20):
"""并发扫描所有股票"""
results = []
total = min(len(stock_list), max_count)
done_count = [0]
found_count = [0]
lock = Lock()
print('并发数: {}'.format(concurrency))
print('扫描中...', flush=True)
with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = {
executor.submit(scan_one, stock): stock
for stock in stock_list[:max_count]
}
for future in as_completed(futures):
done_count[0] += 1
progress = done_count[0] * 100 // total
try:
result = future.result()
if result is not None and result['score'] >= min_score:
found_count[0] += 1
results.append(result)
print(f'\r [已命中 {found_count[0]:3d} 只] {done_count[0]:4d}/{total} ({progress:3d}%)', end='', flush=True)
else:
print(f'\r [扫描中...] {done_count[0]:4d}/{total} ({progress:3d}%)', end='', flush=True)
except Exception:
print(f'\r [错误] {done_count[0]:4d}/{total} ({progress:3d}%)', end='', flush=True)
results.sort(key=lambda x: x['score'], reverse=True)
return results
# ─────────────────────────────────────────────
# 输出
# ─────────────────────────────────────────────
def print_and_save(results):
now = datetime.now().strftime('%Y%m%d_%H%M%S')
fname = '回调到位选股_{}.csv'.format(now)
print('\n')
print('='*95)
print(' 回调到位选股结果 {} 命中: {} 只'.format(datetime.now().strftime('%Y-%m-%d %H:%M'), len(results)))
print('='*95)
if not results:
print('\n今日无符合条件的标的。')
print('可能原因:')
print(' 1. 市场处于普涨阶段(无回调机会)')
print(' 2. 大盘处于下跌趋势(所有股票都在跌)')
print(' 3. 试试调低 --min-score 参数(如 --min-score 4.0)')
return
print('\n{:<10} {:<10} {:>5} {:>6} {:>8} {:>7} {:>7} {:>5} {:>6} {:>7} {:>6} {}'.format(
'代码','名称','得分','回调','收盘','MA10','MA20','RSI6','RSI14','MACD','J值','核心信号'))
print('-'*105)
rows = []
for r in results:
reasons_str = ' | '.join(r['reasons'][:4])
print('{:<10} {:<10} {:>5.1f} {:>6} {:>8.2f} {:>7.2f} {:>7.2f} {:>5.0f} {:>6.0f} {:>7.3f} {:>6.0f} {}'.format(
r['code'], r['name'], r['score'], r['pullback_depth'],
r['close'], r['ma10'], r['ma20'],
r['rsi6'], r['rsi14'], r['macd_hist'], r['j'], reasons_str))
rows.append({
'代码': r['code'], '名称': r['name'], '数据日期': r.get('date',''),
'综合得分': round(r['score'], 1),
'回调深度': r['pullback_depth'],
'收盘价': round(r['close'], 2),
'MA5': round(r['ma5'], 2), 'MA10': round(r['ma10'], 2),
'MA20': round(r['ma20'], 2),
'MA60': round(r['ma60'], 2) if r['ma60'] else None,
'RSI6': round(r['rsi6'], 1), 'RSI14': round(r['rsi14'], 1),
'MACD柱': round(r['macd_hist'], 4),
'KDJ_K': round(r['k'], 1), 'KDJ_D': round(r['d'], 1), 'KDJ_J': round(r['j'], 1),
'量/MA20量': round(r['vol_ratio'], 2),
'近10日涨幅%': round(r['ret10']*100, 1),
'近20日涨幅%': round(r['ret20']*100, 1),
'ATR': round(r['atr'], 2),
'BIAS5': round(r['bias5'], 1),
'详细理由': ' | '.join(r['reasons']),
})
pd.DataFrame(rows).to_csv(fname, index=False, encoding='utf-8-sig')
print('\n已保存: {}'.format(fname))
print('\n判读指南:')
print(' 得分 7+ : 强烈关注,回调到位信号明确')
print(' 得分 5~7: 重点关注,结合大盘判断')
print(' 回调深度: 轻回调 > 布林撑 > 中回调 > 深回调(轻回调风险最小)')
print(' 核心信号: MACD绿柱收窄 + RSI14在30~55 + KDJ金叉 的组合胜率最高')
# ─────────────────────────────────────────────
# 主入口
# ─────────────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(description='回调到位选股工具 v4 (并发优化版)')
parser.add_argument('--mode', choices=['top', 'full'], default='top',
help='top=成交额最大N只(快), full=全市场(较慢)')
parser.add_argument('--min-score', type=float, default=5.0)
parser.add_argument('--max', type=int, default=300)
parser.add_argument('--concurrency', type=int, default=20,
help='并发请求数,默认20,建议值: 10~30')
args = parser.parse_args()
print('='*70)
print(' 回调到位选股工具 v4 (并发优化版)')
print(' 理念: 前期有趋势 → 回调到支撑 → 即将重启上涨 → 不追涨')
print('='*70)
if args.mode == 'top':
print('\n模式: 成交额最大{}只(活跃股优先)'.format(args.max))
stock_list = get_top_stocks(n=args.max)
else:
print('\n模式: 全市场扫描(~5500只)')
stock_list = get_all_stocks()
print('候选股票: {} 只'.format(len(stock_list)))
t0 = time.time()
results = scan(stock_list, min_score=args.min_score, max_count=args.max, concurrency=args.concurrency)
t1 = time.time()
# 原版串行约 50ms/只,20并发理论上提速约 15~20x
print('\n扫描耗时: {:.1f}秒 (提速约 {:.0f}x)'.format(t1-t0, min(args.concurrency, 20)))
print_and_save(results)
if __name__ == '__main__':
main()