aggroot 1.0.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.
@@ -0,0 +1,402 @@
1
+ """
2
+ 股票技术指标分析
3
+
4
+ 用法: python stock_technical.py <stock_code> [--indicator <type>] [--period <days>]
5
+ stock_code: 6位A股代码(如 600519) 或 5位港股代码(如 03888)
6
+ --indicator: 指标类型 ma/macd/rsi/kdj/boll/all,默认all
7
+ --period: 历史天数,默认120
8
+
9
+ AKShare接口:
10
+ 主: ak.stock_zh_a_daily(symbol) — 新浪日线行情(稳定可用,代码需带sh/sz前缀)
11
+ 备: ak.stock_zh_a_hist(symbol) — 东方财富日线行情(当前不稳定)
12
+ 港股主: ak.stock_hk_daily(symbol) — 新浪港股日线(快速)
13
+ 港股备: ak.stock_hk_hist(symbol) — 东方财富港股日线
14
+
15
+ 本地计算: MA(5/10/20/60), MACD(12,26,9), RSI(6/12/24), KDJ(9,3,3), BOLL(20,2)
16
+
17
+ 注意: 新浪接口代码需带市场前缀(如 sh600519),频繁调用会被封IP
18
+ """
19
+
20
+ import sys
21
+ import argparse
22
+ from datetime import datetime, timedelta
23
+
24
+ sys.path.insert(0, '.')
25
+ from _utils import normalize_code, get_market_prefix, output_json, output_error, check_akshare, safe_float, is_hk_code
26
+
27
+
28
+ def calc_ma(closes, periods=[5, 10, 20, 60]):
29
+ """计算移动平均线"""
30
+ result = {}
31
+ for p in periods:
32
+ if len(closes) >= p:
33
+ result[f"ma{p}"] = round(sum(closes[-p:]) / p, 2)
34
+ else:
35
+ result[f"ma{p}"] = None
36
+
37
+ # 判断排列
38
+ vals = [result.get(f"ma{p}") for p in periods if result.get(f"ma{p}") is not None]
39
+ if len(vals) >= 2:
40
+ if all(vals[i] >= vals[i+1] for i in range(len(vals)-1)):
41
+ result["signal"] = "多头排列"
42
+ elif all(vals[i] <= vals[i+1] for i in range(len(vals)-1)):
43
+ result["signal"] = "空头排列"
44
+ else:
45
+ result["signal"] = "交叉排列"
46
+ else:
47
+ result["signal"] = "数据不足"
48
+ return result
49
+
50
+
51
+ def calc_macd(closes, fast=12, slow=26, signal=9):
52
+ """计算MACD"""
53
+ if len(closes) < slow + signal:
54
+ return {"dif": None, "dea": None, "macd_hist": None, "signal": "数据不足"}
55
+
56
+ # EMA计算
57
+ def ema(data, period):
58
+ k = 2 / (period + 1)
59
+ result = data[0]
60
+ for val in data[1:]:
61
+ result = val * k + result * (1 - k)
62
+ return result
63
+
64
+ # 计算DIF序列
65
+ dif_list = []
66
+ for i in range(slow - 1, len(closes)):
67
+ fast_ema = ema(closes[i-fast+1:i+1] if i >= fast-1 else closes[:i+1], fast)
68
+ slow_ema = ema(closes[i-slow+1:i+1], slow)
69
+ dif_list.append(fast_ema - slow_ema)
70
+
71
+ if len(dif_list) < signal:
72
+ return {"dif": None, "dea": None, "macd_hist": None, "signal": "数据不足"}
73
+
74
+ dea = ema(dif_list, signal)
75
+ dif = dif_list[-1]
76
+ hist = 2 * (dif - dea)
77
+
78
+ # 信号判断
79
+ prev_hist = 2 * (dif_list[-2] - ema(dif_list[:-1], signal)) if len(dif_list) >= 2 else 0
80
+ if hist > 0 and prev_hist <= 0:
81
+ sig = "金叉"
82
+ elif hist < 0 and prev_hist >= 0:
83
+ sig = "死叉"
84
+ elif hist > 0:
85
+ sig = "多头"
86
+ else:
87
+ sig = "空头"
88
+
89
+ return {
90
+ "dif": round(dif, 3),
91
+ "dea": round(dea, 3),
92
+ "macd_hist": round(hist, 3),
93
+ "signal": sig,
94
+ }
95
+
96
+
97
+ def calc_rsi(closes, periods=[6, 12, 24]):
98
+ """计算RSI"""
99
+ result = {}
100
+ for p in periods:
101
+ if len(closes) < p + 1:
102
+ result[f"rsi{p}"] = None
103
+ continue
104
+
105
+ gains = []
106
+ losses = []
107
+ for i in range(-p, 0):
108
+ diff = closes[i] - closes[i-1]
109
+ gains.append(max(diff, 0))
110
+ losses.append(max(-diff, 0))
111
+
112
+ avg_gain = sum(gains) / p
113
+ avg_loss = sum(losses) / p
114
+
115
+ if avg_loss == 0:
116
+ rsi = 100
117
+ else:
118
+ rs = avg_gain / avg_loss
119
+ rsi = 100 - (100 / (1 + rs))
120
+
121
+ result[f"rsi{p}"] = round(rsi, 2)
122
+
123
+ # 信号判断
124
+ rsi6 = result.get("rsi6")
125
+ if rsi6 is not None:
126
+ if rsi6 > 80:
127
+ result["signal"] = "超买"
128
+ elif rsi6 > 60:
129
+ result["signal"] = "偏强"
130
+ elif rsi6 > 40:
131
+ result["signal"] = "中性"
132
+ elif rsi6 > 20:
133
+ result["signal"] = "偏弱"
134
+ else:
135
+ result["signal"] = "超卖"
136
+ else:
137
+ result["signal"] = "数据不足"
138
+
139
+ return result
140
+
141
+
142
+ def calc_kdj(highs, lows, closes, n=9, m1=3, m2=3):
143
+ """计算KDJ"""
144
+ if len(closes) < n:
145
+ return {"k": None, "d": None, "j": None, "signal": "数据不足"}
146
+
147
+ # 取最近n天
148
+ recent_highs = highs[-n:]
149
+ recent_lows = lows[-n:]
150
+ close = closes[-1]
151
+
152
+ hn = max(recent_highs)
153
+ ln = min(recent_lows)
154
+
155
+ if hn == ln:
156
+ rsv = 50
157
+ else:
158
+ rsv = (close - ln) / (hn - ln) * 100
159
+
160
+ # 简化计算(使用前一天的K/D值默认50)
161
+ k = (2 / m1) * 50 + (1 / m1) * rsv
162
+ d = (2 / m2) * 50 + (1 / m2) * k
163
+ j = 3 * k - 2 * d
164
+
165
+ # 信号判断
166
+ if j > 100:
167
+ sig = "超买"
168
+ elif k > d and k < 80:
169
+ sig = "偏强"
170
+ elif k < d and k > 20:
171
+ sig = "偏弱"
172
+ elif j < 0:
173
+ sig = "超卖"
174
+ else:
175
+ sig = "中性"
176
+
177
+ return {
178
+ "k": round(k, 2),
179
+ "d": round(d, 2),
180
+ "j": round(j, 2),
181
+ "signal": sig,
182
+ }
183
+
184
+
185
+ def calc_boll(closes, n=20, k=2):
186
+ """计算布林带"""
187
+ if len(closes) < n:
188
+ return {"upper": None, "middle": None, "lower": None, "signal": "数据不足"}
189
+
190
+ recent = closes[-n:]
191
+ middle = sum(recent) / n
192
+ std = (sum((x - middle) ** 2 for x in recent) / n) ** 0.5
193
+ upper = middle + k * std
194
+ lower = middle - k * std
195
+ current = closes[-1]
196
+
197
+ # 位置判断
198
+ if current > upper:
199
+ pos = "上轨上方"
200
+ sig = "超买"
201
+ elif current > middle:
202
+ pos = "中轨上方"
203
+ sig = "中性偏强"
204
+ elif current > lower:
205
+ pos = "中轨下方"
206
+ sig = "中性偏弱"
207
+ else:
208
+ pos = "下轨下方"
209
+ sig = "超卖"
210
+
211
+ return {
212
+ "upper": round(upper, 2),
213
+ "middle": round(middle, 2),
214
+ "lower": round(lower, 2),
215
+ "current": round(current, 2),
216
+ "current_position": pos,
217
+ "signal": sig,
218
+ }
219
+
220
+
221
+ def main():
222
+ parser = argparse.ArgumentParser(description='股票技术指标分析')
223
+ parser.add_argument('stock_code', help='股票代码')
224
+ parser.add_argument('--indicator', default='all',
225
+ choices=['ma', 'macd', 'rsi', 'kdj', 'boll', 'all'],
226
+ help='指标类型(默认all)')
227
+ parser.add_argument('--period', type=int, default=120, help='历史天数(默认120)')
228
+ args = parser.parse_args()
229
+
230
+ err = check_akshare()
231
+ if err:
232
+ output_error(err)
233
+ return
234
+
235
+ code = normalize_code(args.stock_code)
236
+
237
+ try:
238
+ import akshare as ak
239
+
240
+ # 计算日期范围
241
+ end_date = datetime.now().strftime('%Y%m%d')
242
+ start_date = (datetime.now() - timedelta(days=args.period * 2)).strftime('%Y%m%d')
243
+
244
+ df = None
245
+ closes = []
246
+ highs = []
247
+ lows = []
248
+ latest_date = ''
249
+
250
+ # 港股:优先分钟线合成当日K线+新浪daily,失败回退东方财富hist
251
+ if is_hk_code(code):
252
+ # 1. 取新浪日线(T-1,稳定快速)
253
+ daily_df = None
254
+ try:
255
+ daily_df = ak.stock_hk_daily(symbol=code, adjust="qfq")
256
+ if not daily_df.empty:
257
+ daily_df = daily_df.tail(args.period * 2)
258
+ except Exception:
259
+ daily_df = None
260
+
261
+ # 2. 尝试分钟线合成当日K线(盘中实时)
262
+ intraday_appended = False
263
+ try:
264
+ min_df = ak.stock_hk_hist_min_em(symbol=code, period='1')
265
+ if not min_df.empty:
266
+ today_str = str(min_df.iloc[-1].get('时间', ''))[:10]
267
+ today_data = min_df[min_df['时间'].astype(str).str.startswith(today_str)]
268
+ if not today_data.empty and daily_df is not None:
269
+ last_daily_date = str(daily_df.iloc[-1].get('date', ''))
270
+ if today_str > last_daily_date:
271
+ # 当日数据比日线更新,合成当日K线追加
272
+ today_close = safe_float(today_data.iloc[-1].get('收盘'))
273
+ today_high = max(safe_float(r.get('最高')) for _, r in today_data.iterrows() if safe_float(r.get('最高')) is not None)
274
+ today_low = min(safe_float(r.get('最低')) for _, r in today_data.iterrows() if safe_float(r.get('最低')) is not None)
275
+ today_open = safe_float(today_data.iloc[0].get('开盘'))
276
+ if today_close is not None:
277
+ daily_df = daily_df._append({
278
+ 'date': today_str, 'open': today_open,
279
+ 'high': today_high, 'low': today_low,
280
+ 'close': today_close, 'volume': 0, 'amount': 0,
281
+ }, ignore_index=True)
282
+ intraday_appended = True
283
+ except Exception:
284
+ pass
285
+
286
+ if daily_df is not None and not daily_df.empty:
287
+ closes = [safe_float(x) for x in daily_df['close'].tolist() if safe_float(x) is not None]
288
+ highs = [safe_float(x) for x in daily_df['high'].tolist() if safe_float(x) is not None]
289
+ lows = [safe_float(x) for x in daily_df['low'].tolist() if safe_float(x) is not None]
290
+ latest_date = str(daily_df.iloc[-1].get('date', ''))
291
+ df = daily_df # 标记成功
292
+
293
+ # 3. 兜底:东方财富港股日线
294
+ if df is None or df.empty:
295
+ try:
296
+ df = ak.stock_hk_hist(
297
+ symbol=code,
298
+ period="daily",
299
+ start_date=start_date,
300
+ end_date=end_date,
301
+ adjust="qfq"
302
+ )
303
+ if not df.empty:
304
+ closes = [safe_float(x) for x in df['收盘'].tolist() if safe_float(x) is not None]
305
+ highs = [safe_float(x) for x in df['最高'].tolist() if safe_float(x) is not None]
306
+ lows = [safe_float(x) for x in df['最低'].tolist() if safe_float(x) is not None]
307
+ latest_date = str(df.iloc[-1].get('日期', ''))
308
+ except Exception:
309
+ df = None
310
+
311
+ if df is None or df.empty:
312
+ output_error(f"未找到港股 {code} 的历史数据,港股代码为5位数字(如 03888)")
313
+ return
314
+
315
+ else:
316
+ # A股:优先新浪(稳定),失败回退东方财富
317
+ try:
318
+ prefix = get_market_prefix(code)
319
+ sina_code = f"{prefix}{code}"
320
+ df = ak.stock_zh_a_daily(
321
+ symbol=sina_code,
322
+ start_date=start_date,
323
+ end_date=end_date,
324
+ adjust="qfq"
325
+ )
326
+ if not df.empty:
327
+ # 新浪列名: date/open/high/low/close/volume/amount
328
+ closes = [safe_float(x) for x in df['close'].tolist() if safe_float(x) is not None]
329
+ highs = [safe_float(x) for x in df['high'].tolist() if safe_float(x) is not None]
330
+ lows = [safe_float(x) for x in df['low'].tolist() if safe_float(x) is not None]
331
+ latest_date = str(df.iloc[-1].get('date', ''))
332
+ except Exception:
333
+ df = None
334
+
335
+ # 东方财富 fallback
336
+ if df is None or df.empty:
337
+ try:
338
+ df = ak.stock_zh_a_hist(
339
+ symbol=code,
340
+ period="daily",
341
+ start_date=start_date,
342
+ end_date=end_date,
343
+ adjust="qfq"
344
+ )
345
+ if not df.empty:
346
+ closes = [safe_float(x) for x in df['收盘'].tolist() if safe_float(x) is not None]
347
+ highs = [safe_float(x) for x in df['最高'].tolist() if safe_float(x) is not None]
348
+ lows = [safe_float(x) for x in df['最低'].tolist() if safe_float(x) is not None]
349
+ latest_date = str(df.iloc[-1].get('日期', ''))
350
+ except Exception:
351
+ df = None
352
+
353
+ if df is None or df.empty:
354
+ output_error(f"未找到股票 {code} 的历史数据")
355
+ return
356
+
357
+ indicators = {}
358
+ indicator = args.indicator
359
+
360
+ if indicator in ('ma', 'all'):
361
+ indicators["ma"] = calc_ma(closes)
362
+ if indicator in ('macd', 'all'):
363
+ indicators["macd"] = calc_macd(closes)
364
+ if indicator in ('rsi', 'all'):
365
+ indicators["rsi"] = calc_rsi(closes)
366
+ if indicator in ('kdj', 'all'):
367
+ indicators["kdj"] = calc_kdj(highs, lows, closes)
368
+ if indicator in ('boll', 'all'):
369
+ indicators["boll"] = calc_boll(closes)
370
+
371
+ data = {
372
+ "code": code,
373
+ "latest_date": latest_date,
374
+ "data_points": len(closes),
375
+ "data_time": latest_date + (" (盘中实时)" if intraday_appended else " (收盘)"),
376
+ "indicators": indicators,
377
+ }
378
+
379
+ # 时效性检查
380
+ if latest_date:
381
+ try:
382
+ from datetime import date as date_cls
383
+ today = date_cls.today()
384
+ data_date = date_cls.fromisoformat(latest_date[:10])
385
+ days_old = (today - data_date).days
386
+ if days_old > 0:
387
+ data["data_staleness"] = f"最新K线为{days_old}天前({latest_date})"
388
+ if not intraday_appended:
389
+ data["data_staleness"] += ",日线数据为T-1(最近交易日收盘)"
390
+ if days_old > 3:
391
+ data["data_staleness"] += ",数据可能过时,请确认是否为非交易日"
392
+ except (ValueError, TypeError):
393
+ pass
394
+
395
+ output_json(data)
396
+
397
+ except Exception as e:
398
+ output_error(f"获取技术指标失败: {str(e)}")
399
+
400
+
401
+ if __name__ == '__main__':
402
+ main()