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,433 @@
1
+ """
2
+ 股票实时行情查询
3
+
4
+ 用法: python stock_quote.py <stock_code> [--detail]
5
+ stock_code: 6位A股代码(如 600519) 或 5位港股代码(如 03888)
6
+ --detail: 获取详细信息(公司概况等)
7
+
8
+ AKShare接口:
9
+ A股主: ak.stock_zh_a_spot() — 新浪实时行情(稳定可用)
10
+ A股备: ak.stock_zh_a_spot_em() — 东方财富实时行情(含PE/PB/市值等)
11
+ A股补: ak.stock_individual_info_em(symbol) — 个股详细信息(行业/市值)
12
+ 港股主: ak.stock_hk_hist_min_em(symbol) — 东方财富分钟线(实时,盘中最快)
13
+ 港股备: ak.stock_hk_daily(symbol) — 新浪港股日线行情(T-1数据,稳定)
14
+ 港股补: ak.stock_hk_financial_indicator_em(symbol) — PE/PB/市值/ROE
15
+ 港股补: ak.stock_hk_company_profile_em(symbol) — 公司名称/行业
16
+ """
17
+
18
+ import sys
19
+ import argparse
20
+ from datetime import datetime
21
+
22
+ sys.path.insert(0, '.')
23
+ from _utils import normalize_code, get_market_prefix, output_json, output_error, check_akshare, safe_float, is_hk_code
24
+
25
+
26
+ def _fetch_em(code):
27
+ """东方财富A股接口:数据最全(含PE/PB/市值/换手率/量比等)"""
28
+ import akshare as ak
29
+
30
+ df = ak.stock_zh_a_spot_em()
31
+ row = df[df['代码'] == code]
32
+
33
+ if row.empty:
34
+ return None
35
+
36
+ r = row.iloc[0]
37
+ return {
38
+ "code": code,
39
+ "name": str(r.get('名称', '')),
40
+ "price": safe_float(r.get('最新价')),
41
+ "change_pct": safe_float(r.get('涨跌幅')),
42
+ "change_amt": safe_float(r.get('涨跌额')),
43
+ "volume": safe_float(r.get('成交量')),
44
+ "amount": safe_float(r.get('成交额')),
45
+ "amplitude": safe_float(r.get('振幅')),
46
+ "high": safe_float(r.get('最高')),
47
+ "low": safe_float(r.get('最低')),
48
+ "open": safe_float(r.get('今开')),
49
+ "prev_close": safe_float(r.get('昨收')),
50
+ "turnover_rate": safe_float(r.get('换手率')),
51
+ "pe_ratio": safe_float(r.get('市盈率-动态')),
52
+ "pb_ratio": safe_float(r.get('市净率')),
53
+ "total_market_cap": safe_float(r.get('总市值')),
54
+ "circulating_market_cap": safe_float(r.get('流通市值')),
55
+ "rise_speed": safe_float(r.get('5分钟涨速')),
56
+ "volume_ratio": safe_float(r.get('量比')),
57
+ "data_source": "eastmoney",
58
+ "data_time": datetime.now().strftime('%Y-%m-%d %H:%M'),
59
+ }
60
+
61
+
62
+ def _fetch_sina(code):
63
+ """新浪A股日线接口:快速稳定(当日数据可用)"""
64
+ import akshare as ak
65
+
66
+ prefix = get_market_prefix(code)
67
+ sina_code = f"{prefix}{code}"
68
+
69
+ # 优先用 daily 获取当日数据(快速)
70
+ try:
71
+ df = ak.stock_zh_a_daily(symbol=sina_code, adjust="qfq")
72
+ if not df.empty and len(df) >= 2:
73
+ r = df.iloc[-1]
74
+ prev_r = df.iloc[-2]
75
+ prev_close = safe_float(prev_r['close'])
76
+ price = safe_float(r['close'])
77
+ change_amt = round(price - prev_close, 3) if prev_close and price else None
78
+ change_pct = round(change_amt / prev_close * 100, 3) if prev_close and change_amt is not None else None
79
+ return {
80
+ "code": code,
81
+ "name": "",
82
+ "price": price,
83
+ "change_pct": change_pct,
84
+ "change_amt": change_amt,
85
+ "volume": safe_float(r.get('volume')),
86
+ "amount": safe_float(r.get('amount')),
87
+ "high": safe_float(r.get('high')),
88
+ "low": safe_float(r.get('low')),
89
+ "open": safe_float(r.get('open')),
90
+ "prev_close": prev_close,
91
+ "date": str(r.get('date', '')),
92
+ "data_source": "sina_daily",
93
+ "data_time": str(r.get('date', '')) + " (收盘)",
94
+ }
95
+ except Exception:
96
+ pass
97
+
98
+ # 备选:全量spot接口(慢,但含名称)
99
+ df = ak.stock_zh_a_spot()
100
+ row = df[df['代码'] == sina_code]
101
+
102
+ if row.empty:
103
+ return None
104
+
105
+ r = row.iloc[0]
106
+ return {
107
+ "code": code,
108
+ "name": str(r.get('名称', '')),
109
+ "price": safe_float(r.get('最新价')),
110
+ "change_pct": safe_float(r.get('涨跌幅')),
111
+ "change_amt": safe_float(r.get('涨跌额')),
112
+ "volume": safe_float(r.get('成交量')),
113
+ "amount": safe_float(r.get('成交额')),
114
+ "high": safe_float(r.get('最高')),
115
+ "low": safe_float(r.get('最低')),
116
+ "open": safe_float(r.get('今开')),
117
+ "prev_close": safe_float(r.get('昨收')),
118
+ "data_source": "sina",
119
+ "data_time": datetime.now().strftime('%Y-%m-%d %H:%M'),
120
+ }
121
+
122
+
123
+ def _fetch_hk_realtime(code):
124
+ """东方财富港股分钟线接口:盘中实时数据(优先使用)"""
125
+ import akshare as ak
126
+
127
+ try:
128
+ df = ak.stock_hk_hist_min_em(symbol=code, period='1')
129
+ if df.empty:
130
+ return None
131
+
132
+ last = df.iloc[-1]
133
+ price = safe_float(last.get('收盘'))
134
+
135
+ # 从分钟线构造今日行情
136
+ today_str = str(last.get('时间', ''))[:10]
137
+ today_data = df[df['时间'].astype(str).str.startswith(today_str)]
138
+
139
+ if today_data.empty:
140
+ return None
141
+
142
+ open_price = safe_float(today_data.iloc[0].get('开盘'))
143
+ high = max(safe_float(r.get('最高')) for _, r in today_data.iterrows() if safe_float(r.get('最高')) is not None)
144
+ low = min(safe_float(r.get('最低')) for _, r in today_data.iterrows() if safe_float(r.get('最低')) is not None)
145
+ volume = sum(safe_float(r.get('成交量')) or 0 for _, r in today_data.iterrows())
146
+ amount = sum(safe_float(r.get('成交额')) or 0 for _, r in today_data.iterrows())
147
+
148
+ # 计算涨跌需要昨日收盘
149
+ prev_close = None
150
+ change_pct = None
151
+ change_amt = None
152
+ try:
153
+ daily_df = ak.stock_hk_daily(symbol=code, adjust="qfq")
154
+ if len(daily_df) >= 2:
155
+ prev_close = safe_float(daily_df.iloc[-2]['close'])
156
+ if prev_close and price:
157
+ change_amt = round(price - prev_close, 3)
158
+ change_pct = round(change_amt / prev_close * 100, 3)
159
+ except Exception:
160
+ pass
161
+
162
+ return {
163
+ "code": code,
164
+ "name": "",
165
+ "price": price,
166
+ "change_pct": change_pct,
167
+ "change_amt": change_amt,
168
+ "volume": volume,
169
+ "amount": amount,
170
+ "high": high,
171
+ "low": low,
172
+ "open": open_price,
173
+ "prev_close": prev_close,
174
+ "date": today_str,
175
+ "data_source": "eastmoney_min",
176
+ "data_time": str(last.get('时间', '')),
177
+ }
178
+ except Exception:
179
+ return None
180
+
181
+
182
+ def _fetch_hk_sina(code):
183
+ """新浪港股接口:通过 stock_hk_daily 取最新数据(T-1,稳定)"""
184
+ import akshare as ak
185
+
186
+ df = ak.stock_hk_daily(symbol=code, adjust="qfq")
187
+ if df.empty:
188
+ return None
189
+
190
+ r = df.iloc[-1]
191
+ prev_r = df.iloc[-2] if len(df) >= 2 else None
192
+ prev_close = safe_float(prev_r['close']) if prev_r is not None else None
193
+ price = safe_float(r['close'])
194
+ change_amt = round(price - prev_close, 3) if prev_close and price else None
195
+ change_pct = round(change_amt / prev_close * 100, 3) if prev_close and change_amt is not None else None
196
+
197
+ return {
198
+ "code": code,
199
+ "name": "",
200
+ "price": price,
201
+ "change_pct": change_pct,
202
+ "change_amt": change_amt,
203
+ "volume": safe_float(r.get('volume')),
204
+ "amount": safe_float(r.get('amount')),
205
+ "high": safe_float(r.get('high')),
206
+ "low": safe_float(r.get('low')),
207
+ "open": safe_float(r.get('open')),
208
+ "prev_close": prev_close,
209
+ "date": str(r.get('date', '')),
210
+ "data_source": "sina_hk",
211
+ "data_time": str(r.get('date', '')) + " (收盘)",
212
+ }
213
+
214
+
215
+ def _fetch_hk_em(code):
216
+ """东方财富港股接口(含名称、振幅、换手率等更多字段)"""
217
+ import akshare as ak
218
+
219
+ df = ak.stock_hk_spot_em()
220
+ row = df[df['代码'] == code]
221
+
222
+ if row.empty:
223
+ return None
224
+
225
+ r = row.iloc[0]
226
+ return {
227
+ "code": code,
228
+ "name": str(r.get('名称', '')),
229
+ "price": safe_float(r.get('最新价')),
230
+ "change_pct": safe_float(r.get('涨跌幅')),
231
+ "change_amt": safe_float(r.get('涨跌额')),
232
+ "volume": safe_float(r.get('成交量')),
233
+ "amount": safe_float(r.get('成交额')),
234
+ "high": safe_float(r.get('最高')),
235
+ "low": safe_float(r.get('最低')),
236
+ "open": safe_float(r.get('今开')),
237
+ "prev_close": safe_float(r.get('昨收')),
238
+ "amplitude": safe_float(r.get('振幅')),
239
+ "turnover_rate": safe_float(r.get('换手率')),
240
+ "data_source": "eastmoney_hk",
241
+ "data_time": datetime.now().strftime('%Y-%m-%d %H:%M'),
242
+ }
243
+
244
+
245
+ def _enrich_hk(data, code):
246
+ """补充港股额外字段:PE/PB/市值/ROE/行业/名称"""
247
+ import akshare as ak
248
+
249
+ # 补充财务指标(PE/PB/市值/ROE等)
250
+ try:
251
+ fi = ak.stock_hk_financial_indicator_em(symbol=code)
252
+ if not fi.empty:
253
+ r = fi.iloc[0]
254
+ data.setdefault("pe_ratio", safe_float(r.get('市盈率')))
255
+ data.setdefault("pb_ratio", safe_float(r.get('市净率')))
256
+ data.setdefault("total_market_cap", safe_float(r.get('总市值(港元)')))
257
+ data["roe"] = safe_float(r.get('股东权益回报率(%)'))
258
+ data["net_profit"] = safe_float(r.get('净利润'))
259
+ data["net_profit_growth"] = safe_float(r.get('净利润滚动环比增长(%)'))
260
+ data["revenue"] = safe_float(r.get('营业总收入'))
261
+ data["revenue_growth"] = safe_float(r.get('营业总收入滚动环比增长(%)'))
262
+ data["net_margin"] = safe_float(r.get('销售净利率(%)'))
263
+ data["eps"] = safe_float(r.get('基本每股收益(元)'))
264
+ data["dividend_yield"] = safe_float(r.get('股息率TTM(%)'))
265
+ else:
266
+ data["financial_indicator_error"] = "东方财富港股指标接口返回空数据"
267
+ except Exception as e:
268
+ data["financial_indicator_error"] = f"获取财务指标失败: {str(e)}"
269
+
270
+ # 补充公司名称和行业
271
+ if not data.get("name") or not data.get("industry"):
272
+ try:
273
+ profile = ak.stock_hk_company_profile_em(symbol=code)
274
+ if not profile.empty:
275
+ p = profile.iloc[0]
276
+ if not data.get("name"):
277
+ data["name"] = str(p.get('公司名称', ''))
278
+ data["industry"] = str(p.get('所属行业', ''))
279
+ data["chairman"] = str(p.get('董事长', ''))
280
+ except Exception:
281
+ pass
282
+
283
+
284
+ def main():
285
+ parser = argparse.ArgumentParser(description='股票实时行情查询')
286
+ parser.add_argument('stock_code', help='股票代码,如 600519(A股) 或 03888(港股)')
287
+ parser.add_argument('--detail', action='store_true', help='获取详细信息')
288
+ args = parser.parse_args()
289
+
290
+ # 检测AKShare
291
+ err = check_akshare()
292
+ if err:
293
+ output_error(err)
294
+ return
295
+
296
+ code = normalize_code(args.stock_code)
297
+
298
+ try:
299
+ import akshare as ak
300
+
301
+ # 港股:优先分钟线实时 → 新浪daily(T-1) → 东方财富spot → 补充丰富字段
302
+ if is_hk_code(code):
303
+ data = None
304
+
305
+ # 1. 优先:东方财富分钟线(盘中实时)
306
+ try:
307
+ data = _fetch_hk_realtime(code)
308
+ except Exception:
309
+ pass
310
+
311
+ # 2. 备选:新浪日线(T-1,稳定)
312
+ if data is None:
313
+ try:
314
+ data = _fetch_hk_sina(code)
315
+ except Exception:
316
+ pass
317
+
318
+ # 3. 兜底:东方财富港股spot
319
+ if data is None:
320
+ try:
321
+ data = _fetch_hk_em(code)
322
+ except Exception as e:
323
+ output_error(f"获取港股行情失败: {str(e)}")
324
+ return
325
+
326
+ if data is None:
327
+ output_error(f"未找到港股代码: {code},请确认代码正确(5位数字如 03888)")
328
+ return
329
+
330
+ # 补充PE/PB/市值/ROE/行业/名称
331
+ _enrich_hk(data, code)
332
+
333
+ # 时效性检查:标注数据是否过时
334
+ from datetime import date
335
+ today = date.today()
336
+ data_date_str = data.get('date', '') or data.get('data_time', '')[:10]
337
+ if data_date_str:
338
+ try:
339
+ data_date = date.fromisoformat(data_date_str[:10])
340
+ days_old = (today - data_date).days
341
+ if days_old > 0:
342
+ data["data_staleness"] = f"数据为{days_old}天前({data_date_str})"
343
+ if data.get("data_source", "").endswith("_daily") or data.get("data_source") == "sina_hk":
344
+ data["data_staleness"] += ",日线接口为T-1数据(最近交易日收盘)"
345
+ elif days_old > 3:
346
+ data["data_staleness"] += ",数据可能过时,请确认是否为非交易日"
347
+ except (ValueError, TypeError):
348
+ pass
349
+
350
+ # 可选:公司详细信息
351
+ if args.detail:
352
+ try:
353
+ profile = ak.stock_hk_company_profile_em(symbol=code)
354
+ if not profile.empty:
355
+ data["detail"] = {col: str(profile.iloc[0][col]) for col in profile.columns}
356
+ except Exception:
357
+ data["detail"] = None
358
+
359
+ output_json(data)
360
+ return
361
+
362
+ # A股:优先新浪(稳定),失败回退东方财富
363
+ data = None
364
+ try:
365
+ data = _fetch_sina(code)
366
+ except Exception:
367
+ pass
368
+
369
+ if data is None:
370
+ try:
371
+ data = _fetch_em(code)
372
+ except Exception as e2:
373
+ output_error(f"获取行情失败: {str(e2)}")
374
+ return
375
+
376
+ if data is None:
377
+ output_error(f"未找到股票代码: {code}")
378
+ return
379
+
380
+ # 非东方财富数据源时,尝试补充个股信息
381
+ if data.get("data_source") != "eastmoney":
382
+ try:
383
+ info_df = ak.stock_individual_info_em(symbol=code)
384
+ for _, row_info in info_df.iterrows():
385
+ key = str(row_info.iloc[0])
386
+ val = str(row_info.iloc[1])
387
+ if key == '总市值':
388
+ data["total_market_cap"] = safe_float(val)
389
+ elif key == '流通市值':
390
+ data["circulating_market_cap"] = safe_float(val)
391
+ elif key == '行业':
392
+ data["industry"] = val
393
+ except Exception:
394
+ pass
395
+
396
+ # 可选:获取详细信息
397
+ if args.detail:
398
+ try:
399
+ info_df = ak.stock_individual_info_em(symbol=code)
400
+ detail = {}
401
+ for _, row_info in info_df.iterrows():
402
+ item_key = str(row_info.iloc[0])
403
+ item_val = str(row_info.iloc[1])
404
+ detail[item_key] = item_val
405
+ data["detail"] = detail
406
+ except Exception:
407
+ data["detail"] = None
408
+
409
+ # A股时效性检查
410
+ from datetime import date as date_cls
411
+ today = date_cls.today()
412
+ data_date_str = data.get('date', '') or data.get('data_time', '')[:10]
413
+ if data_date_str:
414
+ try:
415
+ data_date = date_cls.fromisoformat(data_date_str[:10])
416
+ days_old = (today - data_date).days
417
+ if days_old > 0:
418
+ data["data_staleness"] = f"数据为{days_old}天前({data_date_str})"
419
+ if data.get("data_source") in ("sina_daily", "sina"):
420
+ data["data_staleness"] += ",日线接口为T-1数据(最近交易日收盘)"
421
+ elif days_old > 3:
422
+ data["data_staleness"] += ",数据可能过时,请确认是否为非交易日"
423
+ except (ValueError, TypeError):
424
+ pass
425
+
426
+ output_json(data)
427
+
428
+ except Exception as e:
429
+ output_error(f"获取行情失败: {str(e)}")
430
+
431
+
432
+ if __name__ == '__main__':
433
+ main()