Spaces:
Running
Running
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 +10 -0
- app/services/market_data.py +153 -0
- tests/test_routes.py +78 -0
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 |
+
|