fromozuzhouzzz commited on
Commit
d47d67f
·
1 Parent(s): e43cdef

Add static 884/886 board catalog + cls red telegraph endpoint

Browse files

缺口1: akshare 不返回 884(THS概念)/886(THS主题) 的 code+name 清单,
/boards/catalog 的 board884/board886 字段恒为空。内置 327 条静态表
(884=230, 886=97, 源自 board_code_map.csv 2026-08 快照) 填充这两段,
脚本无外部依赖即可独立枚举。

缺口2: global_news 把 cls.cn.red_telegraph 排在 5 个 akshare 源之后兜底,
正常取不到。新增 GET /news/cls-red-telegraph 直采 cls.cn 红色电报
(category=red), 供 cls_ai_report 等下游独立获取。

测试: 3 个新单测 (静态表计数/形状, 884/886 填充, cls 路由), 22 passed。

app/api/routes.py CHANGED
@@ -645,6 +645,16 @@ def global_news(limit: int = Query(50, ge=1, le=200)):
645
  return _handle(lambda: market_data_service.global_news(limit))
646
 
647
 
 
 
 
 
 
 
 
 
 
 
648
  @router.get("/stocks/{stock_code}/news")
649
  def stock_news(stock_code: str, limit: int = Query(30, ge=1, le=200)):
650
  return _handle(lambda: market_data_service.stock_news(stock_code, limit))
 
645
  return _handle(lambda: market_data_service.global_news(limit))
646
 
647
 
648
+ @router.get("/news/cls-red-telegraph")
649
+ def cls_red_telegraph(limit: int = Query(50, ge=1, le=200)):
650
+ """财联社红色电报直采端点(category=red 重要级别快讯流)。
651
+
652
+ /news/global 把 cls 源排在 5 个 akshare 源之后兜底,正常取不到;
653
+ cls_ai_report 等下游用本端点独立获取红色电报。
654
+ """
655
+ return _handle(lambda: market_data_service.cls_red_telegraph(limit))
656
+
657
+
658
  @router.get("/stocks/{stock_code}/news")
659
  def stock_news(stock_code: str, limit: int = Query(30, ge=1, le=200)):
660
  return _handle(lambda: market_data_service.stock_news(stock_code, limit))
app/services/market_data.py CHANGED
@@ -31,6 +31,137 @@ UTC = timezone.utc
31
  CN_TZ = timezone(timedelta(hours=8))
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  TTL = {
35
  "stock_quote": 300,
36
  "etf_quote": 300,
@@ -856,6 +987,24 @@ class MarketDataService:
856
  ],
857
  )
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  def stock_news(self, stock_code: str, limit: int = 30) -> dict[str, Any]:
860
  code = normalize_stock_code(stock_code)
861
  limit = max(1, min(int(limit), 200))
@@ -2030,6 +2179,10 @@ class MarketDataService:
2030
  result["industry"] = self._catalog_df_to_records(industry_df, "industry")
2031
  except Exception: # noqa: BLE001
2032
  pass
 
 
 
 
2033
  return result
2034
 
2035
  @staticmethod
 
31
  CN_TZ = timezone(timedelta(hours=8))
32
 
33
 
34
+ # ---- Static 884/886 board catalog (THS 概念/主题板块) ----
35
+ # akshare 只提供 industry(881) 和 concept(30xxxx) 的 name 清单,
36
+ # 不返回 884(THS概念)/886(THS主题) 的 code+name,但 /boards/{code}/daily
37
+ # 的 ths.v4 路径能取这 327 个代码的日K。此处内置静态 code+name 清单,
38
+ # 让 boards_catalog 的 board884/board886 字段可独立返回,不依赖本地 sqlite。
39
+ # 数据源: AI_Stock/data/ths_board_index/board_code_map.csv (2026-08 快照)。
40
+ _STATIC_BOARDS_884: tuple[tuple[str, str], ...] = (
41
+ ('884001', '种子生产'), ('884002', '粮食种植'), ('884003', '其他种植业'),
42
+ ('884004', '林业'), ('884005', '海洋捕捞'), ('884006', '水产养殖'),
43
+ ('884009', '果蔬加工'), ('884010', '粮油加工'), ('884011', '其他农产品加工'),
44
+ ('884012', '农业综合'), ('884013', '动物保健'), ('884014', '煤炭开采'),
45
+ ('884015', '焦炭加工'), ('884016', '油气开采'), ('884018', '油服工程'),
46
+ ('884020', '石油加工'), ('884021', '油品石化贸易'), ('884022', '纯碱'),
47
+ ('884023', '氯碱'), ('884024', '无机盐'), ('884025', '其他化学原料'),
48
+ ('884026', '氮肥'), ('884027', '磷肥及磷化工'), ('884028', '农药'),
49
+ ('884030', '涂料油墨'), ('884031', '钾肥'), ('884032', '民爆用品'),
50
+ ('884033', '纺织化学用品'), ('884034', '其他化学制品'), ('884035', '复合肥'),
51
+ ('884036', '氟化工'), ('884039', '聚氨酯'), ('884041', '涤纶'),
52
+ ('884043', '粘胶'), ('884044', '其他纤维'), ('884045', '氨纶'),
53
+ ('884046', '其他塑料制品'), ('884048', '改性塑料'), ('884050', '其他橡胶制品'),
54
+ ('884051', '炭黑'), ('884052', '普钢'), ('884053', '铝'),
55
+ ('884054', '铜'), ('884055', '铅锌'), ('884056', '其他金属新材料'),
56
+ ('884057', '磁性材料'), ('884059', '玻璃玻纤'), ('884060', '水泥'),
57
+ ('884062', '其他建材'), ('884063', '耐火材料'), ('884064', '管材'),
58
+ ('884065', '装饰园林'), ('884066', '房屋建设'), ('884067', '基础建设'),
59
+ ('884068', '专业工程'), ('884069', '机床工具'), ('884071', '磨具磨料'),
60
+ ('884073', '制冷空调设备'), ('884074', '其他通用设备'), ('884075', '金属制品'),
61
+ ('884076', '纺织服装设备'), ('884078', '农用机械'), ('884080', '能源及重型设备'),
62
+ ('884081', '印刷包装机械'), ('884082', '其他专用设备'), ('884083', '楼宇设备'),
63
+ ('884086', '电气自控设备'), ('884088', '输变电设备'), ('884089', '线缆部件及其他'),
64
+ ('884090', '分立器件'), ('884091', '半导体材料'), ('884092', '印制电路板'),
65
+ ('884093', '被动元件'), ('884094', '面板'), ('884095', 'LED'),
66
+ ('884096', '光学元件'), ('884098', '消费电子零部件及组装'), ('884099', '乘用车'),
67
+ ('884100', '商用载货车'), ('884101', '商用载客车'), ('884106', '其他运输设备'),
68
+ ('884107', '汽车服务'), ('884112', '冰洗'), ('884113', '空调'),
69
+ ('884116', '家电零部件'), ('884117', '彩电'), ('884118', '其他黑色家电'),
70
+ ('884119', '其他酒类'), ('884120', '软饮料'), ('884123', '肉制品'),
71
+ ('884124', '调味发酵品'), ('884125', '乳品'), ('884126', '其他食品'),
72
+ ('884128', '棉纺'), ('884130', '印染'), ('884131', '辅料'),
73
+ ('884132', '其他纺织'), ('884136', '鞋帽及其他'), ('884137', '家纺'),
74
+ ('884140', '其他家居用品'), ('884141', '饰品'), ('884142', '文娱用品'),
75
+ ('884143', '原料药'), ('884144', '化学制剂'), ('884145', '医疗设备'),
76
+ ('884146', '火电'), ('884147', '水电'), ('884149', '热力'),
77
+ ('884150', '新能源发电'), ('884153', '港口'), ('884154', '高速公路'),
78
+ ('884155', '铁路运输'), ('884156', '机场'), ('884157', '航空运输'),
79
+ ('884160', '百货零售'), ('884161', '专业连锁'), ('884162', '商业物业经营'),
80
+ ('884163', '人工景点'), ('884164', '自然景点'), ('884165', '旅游综合'),
81
+ ('884167', '酒店'), ('884168', '餐饮'), ('884172', '电视广播'),
82
+ ('884176', '出版'), ('884178', '广告营销'), ('884180', '航天装备'),
83
+ ('884181', '航空装备'), ('884182', '地面兵装'), ('884183', '航海装备'),
84
+ ('884184', '特钢'), ('884186', '其他小金属'), ('884189', '啤酒'),
85
+ ('884191', '航运'), ('884192', '仪器仪表'), ('884199', '医药流通'),
86
+ ('884200', '公交'), ('884202', '住宅开发'), ('884203', '产业地产'),
87
+ ('884207', '其他计算机设备'), ('884209', '钛白粉'), ('884210', '食品及饲料添加剂'),
88
+ ('884211', '有机硅'), ('884212', '合成树脂'), ('884213', '膜材料'),
89
+ ('884214', '冶钢原料'), ('884215', '稀土'), ('884217', '工程咨询服务'),
90
+ ('884218', '机器人'), ('884219', '工控设备'), ('884220', '激光设备'),
91
+ ('884221', '其他自动化设备'), ('884227', '集成电路制造'), ('884228', '集成电路封测'),
92
+ ('884229', '半导体设备'), ('884230', '品牌消费电子'), ('884233', '休闲食品'),
93
+ ('884234', '服装'), ('884235', '印刷'), ('884236', '包装'),
94
+ ('884237', '瓷砖地板'), ('884238', '血液制品'), ('884239', '疫苗'),
95
+ ('884240', '其他生物制品'), ('884242', '医疗耗材'), ('884243', '体外诊断'),
96
+ ('884244', '医疗研发外包'), ('884245', '其他医疗服务'), ('884246', '电能综合服务'),
97
+ ('884247', '商业地产'), ('884248', '房地产服务'), ('884249', '国有大型银行'),
98
+ ('884250', '股份制银行'), ('884251', '城商行'), ('884252', '农商行'),
99
+ ('884254', '旅游零售'), ('884257', '专业服务'), ('884258', '体育'),
100
+ ('884261', '数字媒体'), ('884262', '通信网络设备及器件'), ('884263', '通信线缆及配套'),
101
+ ('884264', '通信终端及配件'), ('884265', '其他通信设备'), ('884267', '大气治理'),
102
+ ('884268', '水务及水治理'), ('884269', '固废治理'), ('884270', '综合环境治理'),
103
+ ('884271', '个护用品'), ('884272', '化妆品'), ('884273', '医疗美容'),
104
+ ('884275', '生猪养殖'), ('884276', '肉鸡养殖'), ('884277', '其他养殖'),
105
+ ('884278', '畜禽饲料'), ('884279', '水产饲料'), ('884280', '宠物食品'),
106
+ ('884281', '煤化工'), ('884282', '钨'), ('884283', '钼'),
107
+ ('884284', '钴'), ('884285', '镍'), ('884286', '锂'),
108
+ ('884287', '数字芯片设计'), ('884288', '模拟芯片设计'), ('884289', '车身附件及饰件'),
109
+ ('884290', '底盘与发动机系统'), ('884291', '轮胎轮毂'), ('884292', '其他汽车零部件'),
110
+ ('884293', '汽车电子电气系统'), ('884294', '安防设备'), ('884295', '垂直应用软件'),
111
+ ('884296', '横向通用软件'), ('884297', '预加工食品'), ('884298', '成品家居'),
112
+ ('884299', '定制家居'), ('884300', '药店'), ('884301', '医院'),
113
+ ('884302', '硅料硅片'), ('884303', '光伏电池组件'), ('884304', '逆变器'),
114
+ ('884305', '光伏辅材'), ('884306', '光伏加工设备'), ('884307', '风电整机'),
115
+ ('884308', '风电零部件'), ('884309', '锂电池'), ('884310', '电池化学品'),
116
+ ('884311', '锂电专用设备'), ('884312', '其他电池'), ('884313', '电信运营商'),
117
+ ('884314', '通信工程及服务'), ('884315', '通信应用增值服务'),
118
+ )
119
+
120
+ _STATIC_BOARDS_886: tuple[tuple[str, str], ...] = (
121
+ ('886000', '一体化压铸'), ('886001', '高压快充'), ('886002', '机器视觉'),
122
+ ('886003', '钒电池'), ('886004', '虚拟电厂'), ('886005', '生物质能发电'),
123
+ ('886006', '钙钛矿电池'), ('886007', 'TOPCON电池'), ('886008', '减速器'),
124
+ ('886009', '先进封装'), ('886010', '空气能热泵'), ('886011', '光热发电'),
125
+ ('886012', '宠物经济'), ('886013', '信创'), ('886015', '创新药'),
126
+ ('886016', '供销社'), ('886017', 'Web3.0'), ('886018', '高压氧舱'),
127
+ ('886019', 'AIGC概念'), ('886020', 'PET铜箔'), ('886021', '国企改革'),
128
+ ('886023', '数据确权'), ('886026', 'POE胶膜'), ('886028', '血氧仪'),
129
+ ('886030', '成飞概念'), ('886031', 'ChatGPT概念'), ('886032', '固态电池'),
130
+ ('886033', '共封装光学(CPO)'), ('886034', '数字水印'), ('886035', '毫米波雷达'),
131
+ ('886036', '时空大数据'), ('886037', '6G概念'), ('886038', '超导概念'),
132
+ ('886039', 'ERP概念'), ('886040', 'MLOps概念'), ('886041', '数据要素'),
133
+ ('886042', '存储芯片'), ('886043', '太赫兹'), ('886044', '液冷服务器'),
134
+ ('886045', '同花顺中特估100'), ('886046', 'MR(混合现实)'), ('886047', '脑机接口'),
135
+ ('886048', '英伟达概念'), ('886049', '空间计算'), ('886050', '算力租赁'),
136
+ ('886051', '减肥药'), ('886052', '核污染防治'), ('886053', 'BC电池'),
137
+ ('886054', '光刻机'), ('886055', '星闪概念'), ('886056', '阿尔茨海默概念'),
138
+ ('886057', '新型工业化'), ('886058', '华为昇腾'), ('886059', '智能座舱'),
139
+ ('886060', '短剧游戏'), ('886061', '长安汽车概念'), ('886062', '多模态AI'),
140
+ ('886063', 'PEEK材料'), ('886064', '小米汽车'), ('886065', '可控核聚变'),
141
+ ('886066', '飞行汽车(eVTOL)'), ('886067', '低空经济'), ('886068', 'AI视频'),
142
+ ('886069', '人形机器人'), ('886070', 'AI手机'), ('886071', 'AI PC'),
143
+ ('886072', '高股息精选'), ('886073', '铜缆高速连接'), ('886074', 'AI语料'),
144
+ ('886075', '同花顺出海50'), ('886076', '军工信息化'), ('886077', '合成生物'),
145
+ ('886078', '商业航天'), ('886080', '财税数字化'), ('886081', '维生素'),
146
+ ('886082', '同花顺果指数'), ('886084', '光纤概念'), ('886085', 'AI眼镜'),
147
+ ('886086', '西部大开发'), ('886087', '房屋检测'), ('886090', '智谱AI'),
148
+ ('886091', '华为手机'), ('886093', '华为数字能源'), ('886094', '华为盘古'),
149
+ ('886095', 'IP经济(谷子经济)'), ('886096', '同花顺新质50'), ('886098', '小红书概念'),
150
+ ('886099', 'AI智能体'), ('886100', 'DeepSeek概念'), ('886101', '兵装重组概念'),
151
+ ('886102', '中国AI 50'), ('886105', '雅下水电概念'), ('886108', 'AI应用'),
152
+ ('886109', '2026一季报预增'), ('886110', '2026中报预增'), ('886111', '玻璃基板'),
153
+ ('886112', 'MLCC概念'),
154
+ )
155
+
156
+
157
+ def _static_board_records(board_type: str, table: tuple[tuple[str, str], ...]) -> list[dict[str, Any]]:
158
+ """Materialise a static (code, name) tuple into catalog record shape."""
159
+ return [
160
+ {"code": code, "name": name, "board_type": board_type, "pinyin": "", "category": board_type}
161
+ for code, name in table
162
+ ]
163
+
164
+
165
  TTL = {
166
  "stock_quote": 300,
167
  "etf_quote": 300,
 
987
  ],
988
  )
989
 
990
+ def cls_red_telegraph(self, limit: int = 50) -> dict[str, Any]:
991
+ """直采财联社红色电报,不经过 global_news 的 akshare 源链。
992
+
993
+ global_news 把 cls.cn.red_telegraph 排在 5 个 akshare 源之后作兜底,
994
+ 只要任一 akshare 源成功就短路,红色电报永远取不到。该端点让 cls_ai_report
995
+ 等下游能独立拉取红色电报(cls.cn 的重要级别快讯流,category=red)。
996
+ """
997
+ limit = max(1, min(int(limit), 200))
998
+ return self._cached(
999
+ "cls_red_telegraph",
1000
+ {"limit": limit},
1001
+ TTL["news"],
1002
+ [
1003
+ ("cls.cn.red_telegraph", lambda: self._cls_red_telegraph_payload(limit)),
1004
+ ],
1005
+ timeout_seconds=20,
1006
+ )
1007
+
1008
  def stock_news(self, stock_code: str, limit: int = 30) -> dict[str, Any]:
1009
  code = normalize_stock_code(stock_code)
1010
  limit = max(1, min(int(limit), 200))
 
2179
  result["industry"] = self._catalog_df_to_records(industry_df, "industry")
2180
  except Exception: # noqa: BLE001
2181
  pass
2182
+ # akshare 不返回 884/886 的 name 清单,用内置静态表填充。
2183
+ # 这两个段是 board_daily 的 ths.v4 路径覆盖的核心,必须始终可枚举。
2184
+ result["board884"] = _static_board_records("board884", _STATIC_BOARDS_884)
2185
+ result["board886"] = _static_board_records("board886", _STATIC_BOARDS_886)
2186
  return result
2187
 
2188
  @staticmethod
tests/test_routes.py CHANGED
@@ -285,3 +285,81 @@ def test_index_daily_rejects_garbage(client, auth_headers):
285
  response = client.get("/api/v1/indices/zzz/daily", headers=auth_headers)
286
  assert response.status_code == 400
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  response = client.get("/api/v1/indices/zzz/daily", headers=auth_headers)
286
  assert response.status_code == 400
287
 
288
+
289
+ def test_cls_red_telegraph_route_is_wired(client, auth_headers, monkeypatch):
290
+ """GET /news/cls-red-telegraph routes to MarketDataService.cls_red_telegraph
291
+ and returns the standard envelope shape."""
292
+ from app.api import routes
293
+
294
+ captured: dict = {}
295
+
296
+ def fake(limit):
297
+ captured["limit"] = limit
298
+ return {
299
+ "ok": True,
300
+ "data": {"count": 1, "news": [{"标题": "x", "id": 1}]},
301
+ "meta": {"source": "test"},
302
+ }
303
+
304
+ monkeypatch.setattr(routes.market_data_service, "cls_red_telegraph", fake)
305
+
306
+ response = client.get("/api/v1/news/cls-red-telegraph?limit=10", headers=auth_headers)
307
+ assert response.status_code == 200, (response.status_code, response.text)
308
+ body = response.json()
309
+ assert body["ok"] is True
310
+ assert body["data"]["count"] == 1
311
+ assert captured["limit"] == 10
312
+
313
+
314
+ def test_static_boards_catalog_covers_884_and_886():
315
+ """akshare does not return 884/886 board names; the service ships a static
316
+ catalog for these segments so boards_catalog is independently usable."""
317
+ from app.services.market_data import (
318
+ _STATIC_BOARDS_884,
319
+ _STATIC_BOARDS_886,
320
+ _static_board_records,
321
+ )
322
+
323
+ assert len(_STATIC_BOARDS_884) == 230
324
+ assert len(_STATIC_BOARDS_886) == 97
325
+
326
+ all_codes = [c for c, _ in _STATIC_BOARDS_884] + [c for c, _ in _STATIC_BOARDS_886]
327
+ assert all(len(c) == 6 and c.isdigit() for c in all_codes)
328
+ assert len(set(all_codes)) == len(all_codes) # no duplicates
329
+ # segment prefixes
330
+ assert all(c.startswith("884") for c, _ in _STATIC_BOARDS_884)
331
+ assert all(c.startswith("886") for c, _ in _STATIC_BOARDS_886)
332
+
333
+ records = _static_board_records("board884", _STATIC_BOARDS_884)
334
+ assert records[0] == {
335
+ "code": "884001",
336
+ "name": "种子生产",
337
+ "board_type": "board884",
338
+ "pinyin": "",
339
+ "category": "board884",
340
+ }
341
+
342
+
343
+ def test_boards_catalog_includes_static_884_886_segments(client, auth_headers, monkeypatch):
344
+ """When akshare returns industry/concept (or even fails), the 884/886
345
+ fields must still be populated from the static table — the real-world
346
+ gap akshare does not cover."""
347
+ from app.services import market_data as md
348
+ import pandas as pd
349
+
350
+ # Stub akshare name lookups so no network is hit; industry/concept come
351
+ # back empty (as if akshare provided nothing), exercising the static fill.
352
+ monkeypatch.setattr(md.ak, "stock_board_concept_name_ths", lambda: pd.DataFrame())
353
+ monkeypatch.setattr(md.ak, "stock_board_industry_name_ths", lambda: pd.DataFrame())
354
+
355
+ svc = md.MarketDataService()
356
+ result = svc._boards_catalog_from_akshare(fallback_only=False)
357
+
358
+ assert len(result["board884"]) == 230
359
+ assert len(result["board886"]) == 97
360
+ assert result["board884"][0]["code"] == "884001"
361
+ assert result["board886"][0]["code"] == "886000"
362
+ # every record carries the catalog shape
363
+ for rec in result["board884"] + result["board886"]:
364
+ assert set(rec) == {"code", "name", "board_type", "pinyin", "category"}
365
+