SuperAPI AI Directory

a-stock-data

Data & analytics

View repository

Updated 10-16 08:39

About this skill

📦 Project homepage:https://github.com/simonlin1212/a-stock-data — updates, feedback, support the author

Author: Simon Lin · X @linsizhen · Email:simonlin0423@gmail.com

A-share full-stack data toolkit V3.7.1

Eleven-layer data architecture, 54 endpoints verified usable (51 primary endpoints + 3 official fallbacks, verified 2026-08), covering the Main Board/SME Board/STAR Market/ST. Each data category has an independent fallback listed in the "Backup Source Quick Reference" column, allowing degradation when the primary source is blocked.

V3.7.1 (suffix routing fix, 2026-08-20):get_prefix() recognized explicit prefixes (sh000016) but did not recognize the equivalent suffix form (000016.SH)—while norm_ticker() documentation explicitly supports the suffix form, and em_market_code()/em_secid()(added in V3.7.0) fedthe unnormalized raw stringdirectly to get_prefix():000016.SH strings starting with 0 fell into the default Shenzhen market branch, with secid assembled as 0.000016(Shenzhen Konka A) instead of 1.000016(SSE 50 Index),silently returning data for a different security. Already added a suffix recognition branch at the start of get_prefix() (.sh/.sz/.bj and explicit prefixes are equivalent and passed through), while the number-range inference and Shanghai index whitelist logic remain unchanged. ⚠️ Endpoints that use "prefix + raw string concatenation" (tencent_quote(), Sina financial reports, etc.) still only recognize pure 6-digit or prefix forms—the suffix form will concatenate .SH into the request string,must first go through norm_ticker() before passing; the general principle remains unchanged.

**V3.7.0 (macro layer + chip distribution + adjustment factors + valuation history, 2026-08-19):** Added 1 data layer, 7 endpoints, 4 data sources(baostock / SW / People's Bank of China / National Bureau of Statistics, all with zero registration and zero key). All endpoints were verified by actual runs on 2026-08-19.

  • §4.6 Chip Distribution CYQ — fills the gap where this layer's name did not match its content: Layer 4 is called the "Capital Flows / Chipslayer" but §4.1–§4.5 are all capital flow data, and there had never been true chip distribution.Eastmoney has no public CYQ interface(actual tests of push2/push2his 's cyq/get all returned 404), so this endpoint uses OHLC + turnover ratelocal inference, with zero new data sources. Outputs profit ratio / average cost / 90-70 cost range and concentration / chip peaks. ⚠️ Initial chipsseeded as the entire float on the first day—starting from all zeros would write off existing holdings from before the window (two 1% turnover days would be calculated as 50/50, when the true value should be about 99%/1%).
  • §6.5 Valuation History — §1.2 Tencent only hascurrent-dayvaluation snapshots; this endpoint providesa daily historical series(actual test: Moutai from 2016-01-04, 2581 rows), and at once fills in the four items that were previously completely missing:turnover rate(a required input for chip distribution),suspension status、ST flag(measured 000004 has 276 days with isST=1), historical PE/PB/PS/PCF. ⚠️ baostock does not support the Beijing Stock Exchange, the server reports 10004011, this implementation beforeloginblocks it and throws ValueError。
  • §1.4 Adjustment factors — §1.1 Tongdaxin bars() isunadjusteddata, comparing prices directly across ex-rights dates is bound to be wrong. Sina's qfq/hfq factor sequence is about 1.8KB per HTTP request. ⚠️ At the end of the response hangs a /* base64 */ comment block,you cannot use $ $ anchored regex, you must use raw_decode。
  • §6.6 Listing/delisting dates — the only source that can obtaindelisting dateswith zero authentication, combined with §1.2 is_stale it can exclude zombie tickers at the screening stage.
  • §6.7 History of Shenwan industry changes — §3.7 Eastmoney only hascurrentindustry attribution, using it for historical research islook-ahead bias. This endpoint provides each stock's industry changes (measured 12,893 rows / 5,905 stocks / 38 first-level industries; some tickers have changed history 10 times). ⚠️ Shenwan officially only publishes codes, not Chinese names.
  • §11.1/§11.2 Macro layer (new) — PBoC social financing (monthly, 12 columns) + National Bureau of Statistics PMI. ⚠️ The social financing chain is a triple jump; for months not yet publishedthe entire row is discardedrather than returning NaN (otherwise the caller would treat 12 rows as 12 months of real data); the PMI body is full-width parentheseswith spaces inside, whitespace mustbe deleted entirelyto match.

V3.6.1 (fix for crash on empty dragon-tiger list window, 2026-08-09 · #45):dragon_tiger_board() throws when there are no listing records within the lookback window UnboundLocalError— while large-cap / low-turnover tickers (such as Kweichow Moutai) normally have no listings, which amounts tocrashing on call, and the caller cannot distinguish "no data" from "interface exception". Fixed; empty window returns an empty structure with consistent semantics.

V3.6.0 (silent failure fix + key monitoring pool/intraday anomalies, 2026-07-31 · #15):

  • 🔴 Old Beijing Stock Exchange code segments (43/83/87) return zombie data without reporting an error: measured out of 342 listed, 336 have migrated to 920xxx(Jinbo Bio 832982→920982, BTR 835185→920185), old codes silently return in Eastmoney research reports 0 articles, and in Tencent quotes returnfrozen quotes(volume 0, price spread 17%~100%+) yet still HTTP 200. Added a global warning section;tencent_quote() added is_stale/stale_reason flags (measured old codes hit 2/2, normal tickers no false positives 4/4);eastmoney_reports() when encountering old codesthrows ValueError instead of returning empty。
  • 🔴 §2.1/§2.2 Research report layer ticker not normalized (silent empty):eastmoney_reports("SH600519") / "600519.SH" always return 0 articles(reportapi only recognizes pure 6 digits), while the documentation "Ticker format normalization" explicitly promises all interfaces support prefixed notation——the promise does not match the implementation, and the failure mode is silent empty, and callers misread it as "this ticker has no research coverage." Added norm_ticker() implementation (raises ValueError on parse failure, never returns an empty string), andeastmoney_reports() / ths_eps_forecast() integration.
  • §8.4 em_stock_monitor() Eastmoney key-monitoring pool added (#15): exchange risk warnings / key-monitoring list + effective time window, zero-auth static JSON.
  • §8.5 em_price_anomaly() / em_price_anomaly_count() Intraday abnormal-movement pool added (#15): exchange "severe abnormal fluctuation" scope movement details and per-ticker aggregate statistics, including full explanations of all 12 abnormal-movement rule codes. ⚠️ Must include team=h5 and other fixed parameters; missing them returns unknow team——now result!=0 bubbles up instead of silently returning empty.
  • §2.1 Industry research reports remove hardcoded dates:begin defaults from fixed 2024-01-01 to "two years back from today," avoiding an increasingly stale time window.
  • Endpoints 44 → 47. Measured 24/24 pass (including 4/4 prefix format, old number-segment interception, real data for new endpoints, and abnormal-movement API rejection bubbling up).

V3.5.0 (sector fund flows, 2026-07-23 · #37):

  • §3.8 board_fund_flow() Sector fund flows added: fills in the previously missingsector-level fund flows——industry/concept/region three sector types × today/5-day/10-day three periods, main-force net inflow amount/net ratio + super-large/large/medium/small order four-tier details + leading stocks. Same source and same API as §3.7 sector rankingssame source, same API(Eastmoney push2 clist); previously only price/advance-decline counts fields were requested; this version simply additionally requests the f62/f184/f66... fund flow fields for coverage. Goes through em_get rate limiting to prevent blocking. Endpoints 43 → 44.
  • Measured (2026-07-23): industry today 100 sectors by main-force net amount descending (Power Equipment 6.466 billion = super-large 4.355 billion + large 2.111 billion), concept 5-day, region 10-day all return real data; parameter validation rejects illegal board_type/period.

V3.4.1 (prefix routing + mootdx liveness-check fix, 2026-07-23):

  • §1.2/§Market prefix rules prefix routing fix (#40 #41):5 Shanghai ETFs starting with (510300/588200 etc.), Shanghai-Shenzhen indices (000300/000016 etc.) previously fell into else → sz, Tencent API returned emptyor wrong ticker(000016 was misjudged as sz000016 *ST Kangjia A, silently returning data for an unrelated ticker, which is more dangerous than returning empty).get_prefix() and tencent_quote() both fixed in sync:5x→sh, Shanghai index whitelist, support explicit prefix (sh000001/sz000001) passthrough to resolve 000001(Shanghai Composite Index vs Ping An Bank) ambiguity.
  • §1.1 tdx_client() Real data-fetch liveness check (#43):_probe() only performs a TCP handshake; a successful handshake ≠ can fetch data——a bad server can pass the handshake yet return a 2-byte empty body, causingsilently returning an empty DataFrame or connection crashand never reaching fallback. Added _validate(): each candidate server must actually successfully pull one K-line before being adopted, and factory() connection exceptions are skipped with try/except; only if all fail is a clear error raised.
  • Backup source quick-reference K-line rows add Tencent m5 minute K-lines (#43): Tonghuashun K-line backup only has 30/60 minute; once mootdx is down there is no 5-minute source. Added Tencent ifzq.gtimg.cn minute K (m1/m5/m15/m30/m60, zero-auth and no IP blocking). ⚠️ The 7th field isTurnover rate base pointNot turnover amount (off by 3 orders of magnitude); turnover amount must be calculated yourself.

V3.4.0 (interface quality + backup source resilience, 2026-07-11):

  • §5.2 Cailianshe flash news revived: After the old nodeapi went offline in 2026-05, switch to the official v1/roll/get_roll_list + local signature (sign=md5(sha1(排序query)), zero key), the whole-market telegraph capability removed in V3.2 is restored, and it serves as an independent backup for Eastmoney 7×24. Measured errno=0.
  • Added the "Backup Source Quick Reference & Degradation Strategy" section: Ten-layer primary source → independent backup quick reference table (different domains/different risk-control surfaces) + 3 measured backup functions——dragon_tiger_backup()(official Shanghai and Shenzhen Stock Exchange dragon-tiger list, including brokerage branch seats),fund_flow_backup()(Sina daily capital flow),announcements_backup()(official Shenzhen Stock Exchange for Shenzhen market / Eastmoney announcements + PDF for Shanghai market). Endpoints 40 → 43, data sources 13 → 15 (newly added official Shanghai and Shenzhen Stock Exchanges).
  • §3.6 Unlock field fix: Eastmoney RPT_LIFT_STAGE changed column names causing type/shares to be always empty → changed to FREE_SHARES_TYPE/FREE_SHARES, and added able_shares(actual tradable share count, closer to real selling pressure).
  • §3.7 Industry ranking sort fix: clist request added fid=f3,top/bottom now truly sorted by change percentage (previously missing the sort field, so sliced results were not in change order).
  • §3.2 Shenzhen Stock Connect labeling: After intraday northbound disclosure was tightened, the sgt minute series is unreliable (hgt is available); for authoritative northbound data use HKEX official daily statistics (see Backup Source Quick Reference).
  • Experience: Added an "Endpoint Routing Quick Reference" master table at the top (§→function→purpose→source, can be read locally as needed); FAQ added countermeasures for Eastmoney being blocked / Cailianshe revival / mootdx library abandonment explanation.

V3.2.3 (industry research reports added):

  • §2.1 Eastmoney industry research reports eastmoney_industry_reports(): The research report layer now includes the industry research report endpoint (previously only individual stock research reports). Same endpoint as individual stock research reportssame endpoint reportapi.eastmoney.com/report/list, only qType=1;industry_code="*" pulls all industries, pass the Eastmoney industry code (e.g. 1238=IT Services II) for precise filtering, PDF reuses download_pdf(), goes through em_get rate limiting. Endpoint count 27 → 28.
  • Measured (2026-06-20): all industries hits=47928, filtered by industry code 1238 filtering hits=1863, first PDF H3_{infoCode}_1.pdf downloaded successfully (2.5MB,%PDF header); industry code table endpoint (bxpa etc.) 404 does not exist, use "*" to fetch and then reverse-look up the industry code from the results.

V3.2.2 (failed interface replacement + hidden bug fixes):

  • §3.3 Concept sector attribution (#18): Baidu PAE getrelatedblock failed (ResultCode 10003 + empty array) → switched to Eastmoney slist(spt=3)eastmoney_concept_blocks(), one request retrieves all sectors the stock belongs to (industry/concept/region + BK code + change percentage + leading stock), zero authentication goes through em_get rate limiting.
  • §7.1 Cninfo announcement orgId (#19): Hardcoded gssx0{code} caused many 601xxx stocks totalAnnouncement=0 → added _cninfo_orgid() dynamic lookup of the official mapping table szse_stock.json(6198 stocks, module-level cache), hardcoding downgraded to fallback.
  • Comprehensive example fix: The example still calls the v3.1-deleted baidu_fund_flow_history → change to eastmoney_fund_flow_minute。
  • §4.5/§5.1 risk control notes: some mainland residential IPs are intermittently risk-controlled by Eastmoney (HTTP 000/empty) is not a code bug; add retry/switch-network hints.
  • The new code run as-is via exec smoke test, actual results: sector attribution Moutai 27/Wuliangye 28/Leaderdrive 21; announcements Ping An 601318/ICBC 601398 previously failed stocks restored.

V3.2.1 (Bug fix): Fixed the parsing logic of two embedded functions (pre-existing, not introduced by V3.2)——

  • §5.1 Eastmoney individual stock news: in Eastmoney's actual return, result.cmsArticleWebOld is directly the article list; the old approach called .get("list") on the list, triggering AttributeError / returning empty → changed to iterate over the cmsArticleWebOld list itself.
  • §6.4 Sina financial statements three tables: Sina's actual structure is result.data.report_list(a dict keyed by reporting period, where each period's data is the row-item list); the old approach took result.data.{lrb} and permanently returned empty → changed to iterate over report_list periods, and from each period's data extract by item_title .
  • Both functions were tested with Moutai 600519 public API (zero key), returning non-empty and correct fields.

V3.2 (anti-ban + failure fix):

  • Data source priority + Eastmoney anti-ban: clarify the principle of "prefer Tongdaxin (mootdx)/Tencent which don't ban IPs; use Eastmoney only for its unique data"; add a unified throttling entry em_get(), all Eastmoney interfaces have built-in serial rate limiting (interval ≥1s + random jitter) + session reuse, so AI copying the code comes with anti-ban built in. See the "Data Source Priority & Eastmoney Anti-Ban" section for details.
  • Cailianshe flash news discontinued (#14):cls.cn old API fully 404, marked deprecated and switched to Eastmoney global news.

V3.1 fix: replaced 4 failed interfaces (Baidu PAE fund flow→Eastmoney push2, block trade RPT report name updated, institutional seats changed to BUY/SELL detail filtering) + fixed Eastmoney global news req_trace parameter + fixed Juchao announcement orgId format.

V3.0 Breaking Change: completely remove the akshare dependency, all data sources changed to direct HTTP API connections (only mootdx retains TCP). ⚠️ Starting from V3.7, §6.5/§6.6 additionally require a third-party client baostock(TCP), so "zero third-party wrapper dependencies" now applies only to the remaining endpoints.

Usage: place this file into ~/.claude/skills/a-stock-data/SKILL.md, Claude Code will automatically recognize it and activate it in A-share related conversations.

行情层(实时,不封IP)
├── mootdx        → K线 + 五档盘口 + 逐笔成交 (TCP 7709)
├── 腾讯财经 API   → PE/PB/市值/换手率/涨跌停/指数/ETF (HTTP)
├── 百度股市通     → K线带MA5/10/20 (V3.0 新增,HTTP)
└── 新浪复权因子   → qfq/hfq 因子序列 + 套用到不复权K线 (V3.7 新增)

研报层
├── 东财 reportapi → 个股研报 + 行业研报 + PDF下载 + 评级 + 三年EPS
├── 同花顺 THS     → 一致预期EPS (直连 basic.10jqka.com.cn)
└── iwencai        → NL语义搜索研报 (唯一能力,需X-Claw)

信号层
├── 同花顺热点     → 当日强势股 + 题材归因 reason tags (零鉴权 73ms)
├── 同花顺北向     → hgt/sgt 分钟资金流向 + 本地自缓存历史
├── 东财 slist     → 个股所属板块/概念归属 (V3.2.2 替换百度PAE)
├── 东财 push2     → 个股资金流向 分钟级 (V3.1 替换百度PAE)
├── 龙虎榜席位     → 上榜记录 + 买卖席位 TOP5 + 机构动向 (datacenter-web)
├── 全市场龙虎榜   → 每日全市场上榜股票 + 净买额排名 (datacenter-web)
├── 限售解禁日历   → 历史解禁 + 未来90天待解禁 (datacenter-web)
├── 行业板块排名   → 东财行业涨跌/上涨下跌家数 (V3.0 替换同花顺)
└── 板块资金流向   → 行业/概念/地域 × 今日/5日/10日 主力+超大/大/中/小四档 (push2, V3.5)

资金面 / 筹码层
├── 融资融券明细   → 日级融资余额/买入/偿还 + 融券 (datacenter-web)
├── 大宗交易       → 成交价/量 + 买卖方营业部 (datacenter-web)
├── 股东户数变化   → 季度股东户数 + 环比变化 (datacenter-web)
├── 分红送转       → 历史每股派息/送股/转增 (datacenter-web)
├── 个股资金流120日 → 主力/大单/中单/小单 日级净流入 (push2his)
└── 筹码分布 CYQ   → 获利比例/平均成本/90-70成本区间/筹码峰 (本地计算,V3.7 新增)

新闻层
├── 东财个股新闻   → 个股相关新闻 (search-api-web JSONP)
├── 财联社快讯     → 全市场实时电报 (v1 API+本地签名零key,✅V3.4 复活)
└── 东财全球资讯   → 7×24 财经快讯 (np-weblist,与财联社互备)

基础数据层
├── mootdx finance → 季报快照 (37字段, EPS/ROE/净利)
├── mootdx F10     → 公司资料 (9大类文本)
├── 东财个股信息   → 行业/总股本/流通股/市值/上市日期 (push2)
├── 新浪财报三表   → 资产负债表/利润表/现金流量表 (quotes.sina.cn)
├── baostock 估值历史 → 日频 PE/PB/PS/PCF + 换手率 + 停牌 + ST (不支持北交所,V3.7 新增)
├── baostock 标的信息 → 上市日/退市日/状态 (V3.7 新增)
└── 申万行业分类   → 行业归属变迁史,消除前视偏差 (仅代码无中文名,V3.7 新增)

公告层
├── 巨潮 cninfo    → 公告全文检索+下载 (cninfo.com.cn)
└── mootdx F10     → 最新公告摘要

打板层 (V3.3 新增)
├── 东财涨停池     → 连板数/几天几板/封板资金/炸板次数/行业 (push2ex)
├── 东财炸板池     → 涨停后开板 + 振幅/涨速 (push2ex)
├── 东财跌停池     → 封单资金/连续跌停/开板次数 (push2ex)
├── 东财昨涨停池   → 昨涨停今表现,算晋级率/赚钱效应 (push2ex)
└── 同花顺涨停揭秘 → 涨停原因题材/封板成功率/板型 (10jqka)

ETF期权层 (V3.3 新增)
├── 合约清单       → 50ETF/300ETF/科创50/500ETF 各月认购认沽 (新浪)
├── T型报价        → 买卖五档/持仓量/行权价/最新价 (新浪)
└── 希腊字母+IV    → Delta/Gamma/Theta/Vega/隐含波动率 (新浪)

舆情互动层 (V3.3 新增)
├── 互动易问答     → 投资者提问+公司回复 (巨潮,AI问答独家)
├── 同花顺热榜     → 人气值/概念标签/排名变化 (10jqka)
├── 东财人气榜     → 排名+排名变化+名称价格 (emappdata)
└── 东财概念命中   → 个股被归到哪些概念在炒+热度 (emappdata)

宏观层 (V3.7 新增)
├── 人民银行社融   → 社会融资规模增量 月度12列 (pbc.gov.cn,三级跳取 xls 附件)
└── 国家统计局PMI  → 制造业/非制造业/综合 + 大中小型企业分档 (stats.gov.cn)

Endpoint routing quick reference (locate as needed, no need to read the whole document)

When you need only one type of data, locate the section (§) in the table below and read it locally. All are zero-key except iwencai which requires an API Key.

§ Function What it gets Source
Prerequisite norm_ticker(code) Any format→pure 6 digits (SH600519/600519.SH both work; parsing failure throws an error, does not return empty) Local
1.1 tdx_client() → .bars() / .quotes() / .transaction() K-line (multi-period, unadjusted) / five-level order book / tick-by-tick trades Tongdaxin
1.2 tencent_quote(codes) Real-time price/PE/PB/market cap/turnover/limit up-down/index/ETF (with is_stale zombie quote flag) Tencent
1.3 baidu_kline_with_ma(code) Daily K-line with MA5/10/20 Baidu
1.4 sina_adjust_factor(code, kind) / apply_adjust(bars, factors) Adjustment factor qfq/hfq + apply to unadjusted K-line Sina
2.1 eastmoney_reports(code) / download_pdf(rec) Individual stock research reports + ratings + three-year EPS / research report PDF Eastmoney
2.1 eastmoney_industry_reports(industry_code) Industry research reports Eastmoney
2.2 ths_eps_forecast(code) Institutional consensus EPS Hithink
2.3 iwencai_search(query) / iwencai_query(query) NL semantic research report/search stock selection (Key required) iwencai
3.1 ths_hot_reason() Today's strong stocks + theme attribution Hithink
3.2 hsgt_realtime() Northbound minute-level flows (hgt available / sgt for reference only) Hithink
3.3 eastmoney_concept_blocks(code) Sector/concept attribution of individual stocks Eastmoney
3.4 eastmoney_fund_flow_minute(code) Individual stock capital flow (minute-level) Eastmoney
3.5 dragon_tiger_board(code, date) Individual stock Dragon-Tiger List + top 5 buy/sell seats Eastmoney
3.6 lockup_expiry(code, date) Share unlock history + unlocks pending in the next 90 days Eastmoney
3.7 industry_comparison() Industry sector gain/loss ranking Eastmoney
3.8 board_fund_flow(board_type, period) Sector capital flows (industry/concept/region × today/5-day/10-day, main force + four tiers) Eastmoney
3.9 daily_dragon_tiger(date) Whole-market Dragon-Tiger List + net buy amount ranking Eastmoney
4.1 margin_trading(code) Margin trading and securities lending details Eastmoney
4.2 block_trade(code) Block trades + business departments Eastmoney
4.3 holder_num_change(code) Changes in number of shareholders Eastmoney
4.4 dividend_history(code) Dividend and bonus share history Eastmoney
4.5 stock_fund_flow_120d(code) Individual stock capital flow (120-day, daily level) Eastmoney
4.6 chip_distribution(df) Chip distribution (profit ratio/average cost/90-70 cost range/chip peak) Local calculation
5.1 eastmoney_stock_news(code) Individual stock news Eastmoney
5.2 cls_telegraph() Cailianshe telegraph (7×24, local signature zero key) Cailianshe
5.3 eastmoney_global_news() Global news (7×24) Eastmoney
6.1 client.finance(symbol) Quarterly report snapshot 37 fields TDX
6.2 client.F10(symbol, name) Company information 9 major categories of text TDX
6.3 eastmoney_stock_info(code) Industry/share capital/market cap/listing date Eastmoney
6.4 sina_financial_report(code, type) Three financial statements Sina
6.5 baostock_valuation_history(code, s, e) Valuation history PE/PB/PS/PCF + turnover rate + suspension + ST (Beijing Stock Exchange not supported) baostock
6.6 baostock_stock_basic(code) Listing date / Delisting date / Status baostock
6.7 sw_industry_history() / sw_industry_as_of(df, code, d) Shenwan industryChange history(eliminates look-ahead bias, code only without Chinese name) Shenwan
7.1 cninfo_announcements(code) Announcement search + PDF download CNINFO
7.2 client.F10(symbol, name='最新提示') Latest announcement summary TDX
8.1 em_zt_pool / em_zb_pool / em_dt_pool / em_yzt_pool Limit-up/broken limit/down limit/previous-day limit-up four pools Eastmoney
8.2 ths_limit_up_pool(date) Limit-up reason theme + sealing success rate + board type Hithink
8.3 limit_up_sentiment(date) Broken limit rate/consecutive limit-up height/consecutive limit-up echelon Eastmoney (four-pool combination)
8.4 em_stock_monitor() Key monitoring pool (risk warning list + effective time window) Eastmoney
8.5 em_price_anomaly() / em_price_anomaly_count() Intraday unusual movement details / unusual movement statistics aggregated by security (severe unusual fluctuation) Eastmoney
9.1 sina_option_codes / sina_option_tquote / sina_option_greeks ETF option contract list / T-shaped quote / Greeks + IV Sina
10.1 cninfo_irm(code) Interactive Y问答 (questions + company replies) Juchao
10.2 ths_hot_list() / em_hot_rank() / em_hot_concept(code) Hot list/popularity list/concept hits Tonghuashun + Eastmoney
11.1 pboc_social_financing(year) Increment of aggregate financing to the real economy (monthly, 12 columns) People's Bank of China
11.2 nbs_pmi() Manufacturing/non-manufacturing/composite PMI + large, medium, and small enterprises National Bureau of Statistics
Backup source quick reference dragon_tiger_backup / fund_flow_backup / announcements_backup Dragon-Tiger List/capital flow/announcement official backup (downgrade when the primary source is blocked) Exchange official + Sina + Eastmoney (Shanghai announcements)
Valuation formula forward_pe / pe_digestion / calc_peg / full_valuation(code) Forward PE / PE digestion time / PEG / single-stock valuation panorama Local calculation

Data source priority & Eastmoney anti-blocking (important, read first)

Priority principle: if Tongdaxin/Tencent can be used, don't use Eastmoney

Priority Data source Protocol IP blocking risk Coverage
1 (first choice) mootdx (Tongdaxin) TCP 7709 binary No IP blocking K-line, five-level order book, tick-by-tick trades, financial snapshot, F10
2 Tencent Finance HTTP GBK No IP blocking Real-time price, PE/PB/market cap/turnover rate/price limit, indices, ETF
3 Sina / Juchao / Tonghuashun HTTP Low Financial statements, announcements, consensus expectations/hot topics
4 (only use when data is unique) Eastmoney eastmoney HTTP Has risk control, will block IP See below

Whenever quotes / K-line / real-time price / market cap / financial statements can be obtained from mootdx or Tencent, always use them——TCP protocol and Tencent interfaces have been tested and do not block IP, so high-frequency calls can be made with confidence.

Eastmoney is only used for data that is "unique to it and unavailable elsewhere"

The following dataOnly Eastmoney has, Tongdaxin/Tencent/Sina do not have it, Eastmoney must be used (but rate limiting is required):

Dragon-Tiger List seats · market-wide Dragon-Tiger List · restricted share unlocking calendar · margin financing and securities lending · block trades · number of shareholders · dividends and transfers · individual stock capital flow (minute/daily level) · industry sector rankings · research report list/PDF · individual stock news · global news

Eastmoney risk control thresholds (community-tested, 2026-05)

Behavior Threshold that triggers a ban Risk
Requests per second > 5 times/second High
Concurrent connections per IP ≥ 10 High
Total requests per minute ≥ 200 times Medium-high
Total requests per 5 minutes ≥ 300 times Trigger ban
User-Agent Empty UA / no browser characteristics Medium

Signs of being blocked: after continuous requests 403 / 429 / connection timeout / empty data returned. Temporary bans usually last from a few minutes to a few hours.

⚠️ Tested ban case (2026-06-30, first-hand data, thanks @luodada99 issue #36)

The table above is the community version; the following entry isactually hit an IP-level bancomplete record, which is more valuable for reference than the threshold table:

  • Trigger method: stock screening script with 10 concurrent threads,completely bypassing the em_get() rate limiting, sent 45,000+ requests within 1 hour (three versions of the script running the entire market of 5,208 stocks simultaneously)
  • Consequence:push2 / push2his the entire series RemoteDisconnected,IP-level ban lasting 20+ hours—not the temporary rate limiting of "a few minutes to a few hours"
  • Key observation one:datacenter-web.eastmoney.com unaffected—different Eastmoney subdomains go through different WAFs,push2 being banned does not mean the entire Eastmoney site is unusable
  • Key observation two:Tencent K-line (web.ifzq.gtimg.cn) returns empty after 5,000+ consecutive calls, but this israte limiting, not an IP ban, and it recovers by slowing down or switching to Sina
  • Fallback test: when Eastmoney is banned, the first stock takes 10.9s to complete "detect ban + fallback", after which each stock takes 0.4s via Tencent, with accurate data

This case is precisely hard evidence that "rate limiting is an iron rule":em_get() 's default interval (1s + jitter, serial), at most about 3,000 requests per hour, an order of magnitude less than the 45,000 of the person who hit the pitfall.

Fallback path after being banned(for spare options at each layer, see the "Spare Source Quick Reference" section):

Banned endpoint Alternative Difference
push2/clist/get(stock list) datacenter-web + Tencent quotes batch Industry fields come from datacenter's BOARD_NAME
push2his/kline/get(K-line) Tencent fqkline/get(forward-adjusted) → Sina getKLineData(unadjusted) Tencent has forward adjustment, Sina does not
push2/stock/get(individual stock) Tencent qt.gtimg.cn Tencent has no industry/concept fields

Anti-ban iron rules (must be followed when calling Eastmoney)

  1. Serial, not concurrent—never issue multithreaded/coroutine concurrent requests to Eastmoney
  2. Interval of ≥ 1 second each time + random jitter(QPS ≤ 2); for batch screening, increase to 1.5~2 seconds
  3. Reuse the HTTP session(Keep-Alive), do not create a new connection each time
  4. Carry a normal UA + Referer(already configured for each endpoint in this SKILL)
  5. In batch scenarios, sleep between each stock—AI running batch loops (e.g., screening 100 stocks and pulling dragon-tiger list/capital flow one by one) is the number one culprit for being banned

Rate limiting is already built in: all Eastmoney requests go through em_get()

This SKILL provides a unified throttling entry point em_get()(defined in "Eastmoney Data Center Unified Query (shared helper)" below), which automatically achieves: serial rate limiting (minimum interval EM_MIN_INTERVAL=1.0s + random jitter) + reuse EM_SESSION(Keep-Alive) + default UA.All eastmoney.com endpoint code blocks have been switched to use em_get instead of bare requests.get, so AI can copy the code directly and get anti-ban built in. For batch tasks, increase EM_MIN_INTERVAL to further reduce speed.

Note:em_get / EM_SESSION / EM_MIN_INTERVAL is a shared prerequisite definition for all Eastmoney code blocks; before using any Eastmoney endpoint, you must first execute the "shared helper" code block.


When to Activate

  • The user wants to check A-share individual stock valuation (consensus estimates / PE / PEG / PE digestion)
  • Users want to pull real-time quotes (price / five-level order book / K-line / price limits)
  • Users want to search research reports (by topic / by symbol / by industry / download PDF)
  • Users want to seetoday's strong stocks / theme attribution / concept hotspots
  • Users want to seeNorthbound capital flows(Shanghai Stock Connect/Shenzhen Stock Connect minute-level flows)
  • Users want to seeconcept sector classification(industry/concept/region)
  • Users want to seeindividual stock capital flows(main force/retail/super-large orders/large orders minute-level)
  • Users want to seeDragon-Tiger List seats(business departments + institutional buying and selling)
  • Users want to seewhole-market Dragon-Tiger List(all stocks on the list that day + net buying amount ranking)
  • Users want to seerestricted share unlock calendar(historical unlocks + future pending unlocks)
  • Users want to doindustry horizontal comparison(gain/loss ranking / capital inflow / leading stocks)
  • Users want to seemargin financing and securities lending / margin trading data(margin financing balance + securities lending balance)
  • Users want to seeblock trades(transaction price/volume + buyer and seller business departments)
  • Users want to seechanges in number of shareholders(chip concentration)
  • Users want to seedividend and bonus share history(dividend per share + bonus shares + capitalization of reserves)
  • Users want to seeindex/ETF quotes(Shanghai Composite Index / CSI 300 / ChiNext Index / ETF)
  • Users want to seelimit-up / limit-up board sentiment(limit-up pool / consecutive limit-up tiers / limit-up failure rate / limit-down / reasons and themes for limit-up)
  • Users want to seeETF options(T-shaped quote / Greeks Delta·Gamma·Theta·Vega / implied volatility IV)
  • Users want to seeinvestor interactive Q&A(how a company responds to a rumor/positive news · Hudongyi)
  • Users want to seemarket heat / popularity ranking(Tonghuashun hot list / East Money popularity ranking / individual stock concept hits)
  • Users want to see news and information (individual stock news / Cailianshe flash news / global news)
  • Users want to check announcements (full text of Cninfo announcements)
  • Users want to do industry chain research / batch horizontal comparison
  • Keywords: valuation, consensus expectations, institutional forecasts, P/E ratio, PEG, market capitalization, research reports, industry chain, industry research, K-line, order book, announcements, news,strong stocks, themes, hotspots, concept attribution, Northbound capital, Shanghai Stock Connect, Shenzhen Stock Connect, concept sectors, capital flows, main force, Dragon-Tiger List, seats, business departments, whole-market Dragon-Tiger List, net buying, unlock, restricted shares, industry comparison, industry rotation, margin financing and securities lending, margin trading, block trades, number of shareholders, chip concentration, dividends, dividend distribution, bonus shares, index, ETF, limit-up, limit-up board, consecutive limit-up, limit-up failure, limit-down, reasons for limit-up, sealing the board, promotion rate, ETF options, Greeks, implied volatility, Hudongyi, investor relations, hot list, popularity ranking, market heat

Prerequisites

pip install mootdx requests pandas stockstats numpy baostock xlrd openpyxl
Dependencies Version requirements Purpose
mootdx >= 0.10 TCP quotes + financials + F10 (one of the non-HTTP dependencies); for 0.11.x use tdx_client() to work around the BESTIP bug, see previous section
requests any All HTTP APIs are direct connections
pandas any Data processing + HTML table parsing
stockstats any Technical indicator calculation (RSI/MACD/BOLL, etc.)
numpy any §4.6 Grid calculation of chip distribution
baostock >= 0.8 §6.5/§6.6 Valuation history · turnover rate · suspension · ST · delisting date (TCP, no registration or key required;Beijing Stock Exchange not supported)
xlrd >= 2.0 Read .xls(§6.7 Shenwan industry classification)
openpyxl any Read .xlsx(§11.1 PBOC social financing)

Architecture: Except for mootdx and baostock (both TCP client libraries), all data sources are direct HTTP APIs, without third-party data wrappers. The underlying URL/parameters of every HTTP endpoint are fully exposed, making debugging and customization easy.

iwencai API Key (only required for semantic search)

# 环境变量方式
export IWENCAI_API_KEY="your_key_here"
export IWENCAI_BASE_URL="https://openapi.iwencai.com"

# 申请地址: https://www.iwencai.com/skillhub
# 注册后安装 SkillHub CLI,再安装 report-search 技能即可获得 Key

All other data sources (mootdx / Tencent / Eastmoney / Tonghuashun / Baidu Stock / Sina / Juchao / baostock / Shenwan / People's Bank of China / National Bureau of Statistics) are completely free and require no key.

mootdx client (required reading, to work around the 0.11.x BESTIP empty string bug)

Known bug (mootdx 0.11.x): After a fresh installation, Quotes.factory(market='std') a bare call may throw ValueError: not enough values to unpack (expected 2, got 0). Root cause:~/.mootdx/config.json the BESTIP.HQ is initially an empty string ""(not a missing key); mootdx uses dict.get(key, default) cannot get the default, and unpacking fails.Existing users (whose config has previously been populated with an IP) will not trigger this, so it is easy to miss in testing. Do not solve it by pinning the version: pinning mootdx==0.10.12 in some environments (such as a clean Python 3.9) import mootdx will crash directly due to numpy/pandas binary incompatibility. The correct approach is to use the following tdx_client()—explicitly pass server to bypass BESTIP, which works for both 0.10 / 0.11.

Use the following helper to create the client consistently (all mootdx calls go through it):

import socket
from mootdx.quotes import Quotes

# 实测可用的备选服务器(按延迟排序,2026-06 验证)
_TDX_SERVERS = [
    ('119.97.185.59', 7709), ('124.70.133.119', 7709), ('116.205.183.150', 7709),
    ('123.60.73.44', 7709),  ('116.205.163.254', 7709), ('121.36.225.169', 7709),
    ('123.60.70.228', 7709), ('124.71.9.153', 7709),    ('110.41.147.114', 7709),
    ('124.71.187.122', 7709),
]

def _probe(ip, port, timeout=2.0):
    """TCP 握手探测(快速粗筛)。注意:握手成功 ≠ 能取数,必须再经 _validate 验活。"""
    try:
        with socket.create_connection((ip, port), timeout=timeout):
            return True
    except Exception:
        return False

def _validate(client, market: str = 'std') -> bool:
    """真实取数验活:坏服务器可 TCP 握手通过却回 2 字节空 body → 静默空表。用一次真实 K 线请求兜底。

    验活样本 '000001' 是 A 股代码,只对 market='std' 有意义。其它市场(如扩展行情 'ext')
    用它必然取不到数,会把所有正常服务器都判死、误报「全部不可达」,故非 std 时跳过验活。
    """
    if market != 'std':
        return True
    try:
        df = client.bars(symbol='000001', frequency=9, offset=1)
        return df is not None and not df.empty
    except Exception:
        return False

def tdx_client(market='std'):
    """
    创建 mootdx 客户端,规避 0.11.x BESTIP.HQ 空串 bug + 坏服务器静默空表(#43)。
    每个候选都必须「真实取数验活」通过才采用(_probe TCP 握手是假阳性来源):
      1) 顺序探测 _TDX_SERVERS,对 probe 通过者再 _validate 真实取数,取第一个验活成功的;
      2) 全部失败 → 回退 mootdx 自带 bestip 测速选优(同样验活);
      3) 再回退裸 factory(老用户 config 已有可用 BESTIP 时成立);
      4) 仍失败 → 抛 RuntimeError,明确报错而非静默返回空表 / 崩溃。
    """
    for ip, port in _TDX_SERVERS:
        if not _probe(ip, port):
            continue
        try:
            c = Quotes.factory(market=market, server=(ip, port))
            if _validate(c, market):
                return c
        except Exception:
            continue                                        # 握手过但取数崩 → 跳过下一台
    for kwargs in ({'bestip': True}, {}):                   # fallback: bestip 测速 / 裸 factory
        try:
            c = Quotes.factory(market=market, **kwargs)
            if _validate(c, market):
                return c
        except Exception:
            continue
    raise RuntimeError(
        "所有 mootdx 服务器均无法取到数据(TCP 可达但返回空 / 被 reset)。"
        "海外网络通常全部超时(TCP 7709),请走国内代理或更新 _TDX_SERVERS 列表。"
    )

# 用法:client = tdx_client()   # 替代所有 Quotes.factory(market='std')

Overseas IP users: mootdx uses TDX TCP 7709; in overseas environments it usually times out entirely.tdx_client() will fail quickly with a clear error message instead of waiting forever.

Market prefix rules (global common)

# 沪市指数白名单:与深市 000xxx 个股同段,需白名单区分(沪深300/上证50/中证500/科创50/中证1000/上证180)
SH_INDEX = {"000300", "000905", "000016", "000688", "000852", "000010"}

def get_prefix(code: str) -> str:
    """6位代码 → 市场前缀(sh/sz/bj)。支持显式前缀/后缀(sh000016 / 000016.SH)透传以解决歧义。"""
    c = code.lower().strip()
    if c.endswith((".sh", ".sz", ".bj")):    # 后缀写法与前缀等价:000016.SH ≡ sh000016。
        return c[-2:]                        # 不认后缀会让 000016.SH 落到默认深市 → 静默查成深康佳A
    if c.startswith(("sh", "sz", "bj")):     # 显式前缀透传(如 sh000001=上证指数 vs sz000001=平安银行)
        return c[:2]
    if c.startswith("92"):                   # 北交所 2024-10 起的新股号段,必须先于下面的 9x 判断
        return "bj"
    if c.startswith(("5", "6", "9")):        # 5x=沪 ETF/LOF,6/9=沪个股(900xxx=沪 B 股)
        return "sh"
    if c.startswith(("4", "8")):             # 4x/8x=北交所【老号段,多数已迁 920,见下方警告】
        return "bj"
    if c in SH_INDEX:                         # 沪深300/上证50 等沪指数(000xxx)
        return "sh"
    return "sz"                              # 深市个股/ETF(00/30/15x/16x/159 等),深指数 399xxx 亦走 sz

Ambiguity note: 000001 by default it is treated as an individual stock →sz000001(Ping An Bank); for the Shanghai Composite Index, explicitly pass sh000001。000016 by default it is treated as the Shanghai index → SSE 50; for Shenzhen Kangjia A, pass sz000016。

⚠️ Old Beijing Stock Exchange code segments (43/83/87) are basically obsolete — you will get zombie data without any error

Measured on 2026-07-31: Among the 342 listed Beijing Stock Exchange stocks on Eastmoney, 336 are already in the 920xxx code segment, with only 3 old codes remaining and all suspended. Existing company codes have been migrated overall (e.g., Jinbo Biology 832982→920982, BTR 835185→920185)。

The danger is that old codes do not throw an error; instead they return seemingly normal dirty data:

Endpoint Pass old code 832982 Pass new code 920982
Tencent quotes Returns 112.60, volume 0(frozen at the migration date) 131.74, normal trading ✅
Tencent quote (BTR) 835185 → 45.91, volume 0 920185 → 21.05 ✅
Eastmoney research reports 0 reports(silent empty) 79 reports ✅

Old-code quote prices can differ from real prices by 17% to 100%+, and using them directly for valuation will lead to completely wrong conclusions.

Identify zombie quotes: 成交量 == 0 且 最新价 == 昨收 → very likely a migrated obsolete code (genuinely suspended stocks also satisfy this, and neither should be used for valuation).tencent_quote() This detection is already built in and sets the is_stale flag, see §1.2.

Getting a new code: use the push2 BSE full list fs=m:0+t:81+s:2048 to reverse-look up the current code by name.

Ticker format normalization

norm_ticker() Normalize the following formats into pure 6-digit numbers:

⚠️ Not all endpoints auto-normalize(before V3.6.0 this said "all interfaces support this uniformly", which did not match the implementation and has been corrected).

  • Normalization is already built in:§2.1 eastmoney_reports()、§2.2 ths_eps_forecast()。
  • Only pure 6-digit or explicit sh/sz/bj prefix is recognized(other endpoints):tencent_quote() etc. go through the get_prefix() routed functions support 600519 and sh600519, butdo not recognize suffix style 600519.SH— in testing it gets concatenated into sh600519.SH and returns an empty payload (silent failure).
  • Conclusion: when you get a user-input code,run it through norm_ticker() first before passing it to any endpoint, which is the easiest and safest approach.
Input Normalization result
688017 688017
SH688017 / sh688017 688017
688017.SH / 688017.sh 688017
SZ000001 000001
BJ920982 920982
import re

# 整串锚定匹配,只认下表列出的写法;市场标识前缀、后缀**二选一,不能同时出现**。
# ⚠️ 两个坑都会造成「静默拿到另一只股票的数据」,比报错危险得多:
#   ① 别用 re.search(r"\d{6}") 从任意串里"捞"6 位:"6005190"/"foo600519bar" 会被截成 600519。
#   ② 别让前后缀同时可选:`SH000001.SZ` 这种自相矛盾的写法会被照单全收,
#      而 000001 恰是歧义码(sh000001=上证指数 / sz000001=平安银行),静默丢掉市场信息=选错标的。
# 捕获组:1=前缀市场 2=前缀式代码 | 3=后缀式代码 4=后缀市场
# 市场标识要**同时**从前缀和后缀取——只认 startswith("sh") 会漏掉 `000001.SH` 这种后缀写法。
_TICKER_RE = re.compile(
    r"^(?:(sh|sz|bj)(\d{6})|(\d{6})(?:\.(sh|sz|bj))?)$", re.IGNORECASE)

def _natural_market(digits: str) -> str:
    """6 位码的自然归属市场。仅用于校验显式前缀是否自相矛盾。
    注意 000xxx 是沪指数/深个股共用的歧义段,由调用处单独处理,不走这里。"""
    if digits.startswith("92") or digits[:2] in ("43", "83", "87"):
        return "bj"                      # 北交所(920 现行 / 43·83·87 老号段)
    if digits[0] in ("5", "6", "9"):
        return "sh"                      # 5x 沪 ETF/LOF,6xx 沪个股,9xx 沪 B 股
    return "sz"                          # 00x/30x/15x/16x/39x 等

def norm_ticker(code: str, stock_only: bool = False) -> str:
    """任意受支持写法 → 纯 6 位数字代码。

    支持 600519 / SH600519 / sh600519 / 600519.SH / BJ920982 等。
    stock_only=True:个股专用接口(研报、一致预期等)传这个,会拒绝显式指数写法。
    ⚠️ 不匹配时**抛 ValueError,绝不静默返回空串或猜一个代码**——
    否则调用方会把「代码格式写错」误读成「这只票没有数据」,
    或者更糟:拿到另一只股票的数据还以为是对的。
    """
    raw = str(code).strip()
    m = _TICKER_RE.match(raw)
    if not m:
        raise ValueError(
            f"无法把 {code!r} 解析为 6 位股票代码;"
            f"支持格式:600519 / SH600519 / sh600519 / 600519.SH"
            f"(前缀与后缀二选一,不能同时写)"
        )
    digits = m.group(2) or m.group(3)
    market = (m.group(1) or m.group(4) or "").lower()      # 前缀式与后缀式都要认
    # 归一化会丢掉市场标识,若标识与号段矛盾就会静默落到另一只票上,必须在这里拦。
    if market:
        if digits.startswith("000"):
            # 000xxx 是**沪市指数 / 深市个股共用**的歧义段,显式标识在这里是「消歧」不是「矛盾」:
            #   sh000001=上证指数 vs sz000001=平安银行;sh000016=上证50 vs sz000016=深康佳A。
            if market == "bj":
                raise ValueError(f"{code!r} 市场标识与号段矛盾:000xxx 不属北交所。")
            # 沪市个股只有 600/601/603/605/688/689(B 股 900),**不存在 000xxx 沪市个股**,
            # 所以「显式 sh + 000 段」必然是指数。实测不拦的话:sh000001→平安银行研报 100 篇、
            # sh000016→深康佳A、sh000039→中集集团 84 篇,全是别人的数据。
            if stock_only and market == "sh":
                raise ValueError(
                    f"{code!r} 指向沪市指数而非个股(沪市无 000xxx 个股),本接口只服务个股。"
                    f"要查同号段的深市个股请显式传 sz{digits}。"
                )
        else:
            nat = _natural_market(digits)
            if market != nat:
                raise ValueError(
                    f"{code!r} 的市场标识与号段矛盾:{digits} 属 {nat} 市,而不是 {market} 市。"
                    f"(改用 {nat}{digits} 或去掉市场标识)"
                )
    return digits

# 用法
norm_ticker("SH600519")      # '600519'
norm_ticker("600519.SH")     # '600519'
norm_ticker("bj920982")      # '920982'
norm_ticker("6005190")       # ValueError(7 位,不会被截成 600519)
norm_ticker("茅台")           # ValueError
norm_ticker("SH000001.SZ")   # ValueError(前后缀矛盾,不猜市场)
norm_ticker("SH000001", stock_only=True)     # ValueError(上证指数,不是平安银行)
norm_ticker("000001.SH", stock_only=True)    # ValueError(后缀写法同样拦下)
norm_ticker("SZ600519")                      # ValueError(600519 是沪市,标识矛盾)
norm_ticker("sz000016")                      # '000016'(深康佳A,000 段的显式消歧,合法)


def em_market_code(code: str) -> int:
    """东财 secid 的市场号:**沪=1,深/北=0**(V3.7.0 新增 · #46)。

    ⚠️ 绝不要用 `code.startswith("6")` 判市场 —— 那会把**沪市 ETF(51x)**、
    **科创板 ETF(588x)**、**沪 B 股(900x)** 全部错判成深市,接口返回 `data: null`。
    2026-08-19 实测:510300 / 588000 / 600519 / 688112 / 900901 → m=1;
    300750 / 159915 / 920982 / 832982 → m=0(北交所与深市共用 m=0)。
    """
    return 1 if get_prefix(code) == "sh" else 0


def em_secid(code: str) -> str:
    """东财 push2/push2his 的 secid,如 `1.600519` / `0.300750`。"""
    return f"{em_market_code(code)}.{norm_ticker(code)}"

Eastmoney data center unified query (shared helper)

Dragon-Tiger list/share lockup release/margin trading/block trades/number of shareholders/dividends share the same base URL:

import time
import random
import requests

UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
DATACENTER_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get"

# ── 东财防封:全局节流 + 会话复用 ────────────────────────────────────
# 东财系 HTTP 接口(push2 / datacenter / reportapi / search / np-weblist)有风控:
#   每秒 >5 次 / 单 IP 并发 ≥10 / 1 分钟 ≥200 次  →  临时封 IP。
# 所有 eastmoney.com 请求一律走 em_get():串行限流(最小间隔 + 随机抖动)+ 复用
# Keep-Alive 会话,批量调用时自动降速,避免被封。详见「数据源优先级 & 东财防封」章节。
EM_SESSION = requests.Session()
EM_SESSION.headers.update({"User-Agent": UA})
# 连接级自动重试:瞬态连接错误 / 429 / 5xx 指数退避重试(住宅IP偶发风控更稳)。
# 注意:403 不重试(东财风控信号,重试无益反而加重;按下方 EM_MIN_INTERVAL 降频应对)。
try:
    from requests.adapters import HTTPAdapter
    from urllib3.util.retry import Retry
    _em_adapter = HTTPAdapter(max_retries=Retry(
        total=3, connect=3, backoff_factor=0.6,
        status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"]))
    EM_SESSION.mount("https://", _em_adapter)
    EM_SESSION.mount("http://", _em_adapter)
except Exception:
    pass  # 老版本 urllib3 缺 allowed_methods 等参数时降级为无重试,不影响主流程
from typing import Optional     # 3.9 兼容:不能写 `dict | None`(那是 3.10+ 语法)

EM_MIN_INTERVAL = 1.0          # 两次东财请求最小间隔(秒);批量筛选建议调大到 1.5~2
_em_last_call = [0.0]          # 模块级上次请求时间戳

def em_get(url: str, params: Optional[dict] = None, headers: Optional[dict] = None,
           timeout: int = 15, **kwargs):
    """东财统一请求入口:自动节流 + 复用 session + 默认 UA。
    所有 eastmoney.com 接口都应通过它请求,避免高频被封 IP。"""
    wait = EM_MIN_INTERVAL - (time.time() - _em_last_call[0])
    if wait > 0:
        time.sleep(wait + random.uniform(0.1, 0.5))
    try:
        return EM_SESSION.get(url, params=params, headers=headers, timeout=timeout, **kwargs)
    finally:
        _em_last_call[0] = time.time()

def eastmoney_datacenter(report_name: str, columns: str = "ALL",
                          filter_str: str = "", page_size: int = 50,
                          sort_columns: str = "", sort_types: str = "-1") -> list[dict]:
    """东财数据中心统一查询 — 龙虎榜/解禁/融资融券/大宗交易/股东户数/分红 共用(已内置限流)"""
    params = {
        "reportName": report_name, "columns": columns,
        "filter": filter_str, "pageNumber": "1", "pageSize": str(page_size),
        "sortColumns": sort_columns, "sortTypes": sort_types,
        "source": "WEB", "client": "WEB",
    }
    r = em_get(DATACENTER_URL, params=params, timeout=15)
    d = r.json()
    if d.get("result") and d["result"].get("data"):
        return d["result"]["data"]
    return []

Layer 1: Quotes layer (real-time, no IP blocking)

1.1 mootdx — K-lines + five-level order book + tick-by-tick trades

TCP binary protocol, connects to Tongdaxin servers (7709), no registration required, no IP blocking.

from mootdx.quotes import Quotes

client = tdx_client()  # 见 Prerequisites 的 tdx_client() helper(规避 0.11.x BESTIP bug;等价 Quotes.factory(market='std'))

# === K线数据 ===
# ⚠️ 参数名是 frequency(不是 category!传 category 会被 **kwargs 静默吞掉,
#    永远退化成默认 frequency=9 日线,拿不到分钟数据)。
# mootdx 0.11.7 实测频率值表:
#   0=5分钟  1=15分钟  2=30分钟  3=60分钟(1小时)  4=日线  5=周线  6=月线
#   8=1分钟  9=日线(默认)  10=季线  11=年线        (7=1分钟除权口径,少用)
klines = client.bars(symbol='688017', frequency=9, offset=10)    # 日线
min1   = client.bars(symbol='688017', frequency=8, offset=240)   # 1分钟(一个交易日≈240根)
min5   = client.bars(symbol='688017', frequency=0, offset=48)    # 5分钟
# 返回: open, close, high, low, vol, amount, datetime
# ⚠️ 复权:bars 返回【不复权】原始价(通达信原始数据,无 adjust 参数)。
#    跨除权除息日做估值/回测前需自行复权,或改用带前复权的日K数据源(腾讯财经)。

# === 实时报价 ===
quotes = client.quotes(symbol=['688017', '300476'])
# 返回 46 个字段:
#   price(现价), open, high, low, last_close(昨收)
#   bid1~bid5, ask1~ask5, bid_vol1~bid_vol5, ask_vol1~ask_vol5
#   vol(成交量), amount(成交额), servertime

# === 逐笔成交(非交易时间返回空)===
trades = client.transaction(symbol='688017', date='20260502')
# 返回: time, price, vol, num, buyorsell(0买/1卖/2中性)

mootdx does not provide PE / PB / market cap / turnover rate / limit-up and limit-down prices — these go through Tencent Finance.

1.2 Tencent Finance API — PE/PB/market cap/turnover rate/limit-up and limit-down/index/ETF

HTTP GET, GBK encoding,~ separates 88 fields, no IP blocking.

import urllib.request

def tencent_quote(codes: list[str]) -> dict[str, dict]:
    """
    批量拉取腾讯财经实时行情。
    codes: ["688017", "300476", "002463"]
    也支持指数: ["000001", "000300", "399006"]
    也支持ETF: ["510050", "510300"]
    返回: {code: {name, price, pe_ttm, pb, mcap, ...}}
    """
    # 前缀路由:与全局 get_prefix() 一致。5x 沪ETF / 000300 等沪指数不能落到 sz(会返回空或错票)。
    SH_INDEX = {"000300", "000905", "000016", "000688", "000852", "000010"}   # 沪指数白名单
    prefixed = []
    key_of = {}          # 带前缀的查询键 → 调用方原始写法,保证结果键与入参一一对应
    for c in codes:
        low = c.lower()
        if low.startswith(("sh", "sz", "bj")):        # 显式前缀透传,解决 000001 等歧义
            p = low
        elif c.startswith("92"):                      # 北交所 920 号段须先于 9x 判断
            p = f"bj{c}"
        elif c in SH_INDEX or c.startswith(("5", "6", "9")):
            p = f"sh{c}"
        elif c.startswith(("4", "8")):
            p = f"bj{c}"
        else:
            p = f"sz{c}"
        prefixed.append(p)
        key_of[p] = c    # 显式前缀入参原样返回,裸代码返回裸代码

    url = "https://qt.gtimg.cn/q=" + ",".join(prefixed)
    req = urllib.request.Request(url)
    req.add_header("User-Agent", "Mozilla/5.0")
    resp = urllib.request.urlopen(req, timeout=10)
    data = resp.read().decode("gbk")

    result = {}
    for line in data.strip().split(";"):
        if not line.strip() or "=" not in line or '"' not in line:
            continue
        key = line.split("=")[0].split("_")[-1]
        vals = line.split('"')[1].split("~")
        if len(vals) < 53:
            continue
        # 用入参原样做键:批量里同时传 sh000001 与 sz000001 时,若都退回裸 6 位码
        # 会撞成同一个键、后者静默覆盖前者,显式前缀这个特性就白做了。
        code = key_of.get(key, key[2:])
        result[code] = {
            "name":         vals[1],
            "price":        float(vals[3]) if vals[3] else 0,
            "last_close":   float(vals[4]) if vals[4] else 0,
            "open":         float(vals[5]) if vals[5] else 0,
            "change_amt":   float(vals[31]) if vals[31] else 0,
            "change_pct":   float(vals[32]) if vals[32] else 0,
            "high":         float(vals[33]) if vals[33] else 0,
            "low":          float(vals[34]) if vals[34] else 0,
            "amount_wan":   float(vals[37]) if vals[37] else 0,
            "turnover_pct": float(vals[38]) if vals[38] else 0,
            "pe_ttm":       float(vals[39]) if vals[39] else 0,
            "amplitude_pct":float(vals[43]) if vals[43] else 0,
            # ⚠️ 44=流通市值、45=总市值(曾标反)。总股本≠流通股本时差数倍,见上方踩坑提醒二
            "float_mcap_yi":float(vals[44]) if vals[44] else 0,
            "mcap_yi":      float(vals[45]) if vals[45] else 0,
            "pb":           float(vals[46]) if vals[46] else 0,
            "limit_up":     float(vals[47]) if vals[47] else 0,
            "limit_down":   float(vals[48]) if vals[48] else 0,
            "vol_ratio":    float(vals[49]) if vals[49] else 0,
            "pe_static":    float(vals[52]) if vals[52] else 0,
        }
        # 僵尸报价检测:腾讯对「已迁移的北交所老码 / 长期停牌股」照样返回 HTTP 200 +
        # 一份定格在最后交易日的报价(成交量 0、最新价==昨收),不报任何错。
        # 直接拿去算估值会得出完全错误的结论(实测 bj832982 报 112.60,真实新码 920982 为 131.74)。
        q = result[code]
        q["is_stale"] = (q["amount_wan"] == 0 and q["price"] == q["last_close"] and q["price"] > 0)
        if q["is_stale"] and key[2:4] in ("43", "83", "87"):
            q["stale_reason"] = "北交所老号段,多数已迁至 920xxx,请按名称反查现行代码"
        elif q["is_stale"]:
            q["stale_reason"] = "成交量为 0(停牌 / 未开盘 / 废码),报价非当日真实成交"
    return result

# 用法: 个股
quotes = tencent_quote(["688017", "300476", "002463"])
for code, q in quotes.items():
    print(f"{q['name']}({code}): {q['price']}元 PE={q['pe_ttm']} PB={q['pb']} 市值={q['mcap_yi']}亿")

# 用法: 指数 — sh000001=上证指数, sh000300=沪深300, sz399006=创业板指
index_quotes = tencent_quote(["000001", "000300", "399006"])

# 用法: ETF — sh510050=上证50ETF, sh510300=沪深300ETF
etf_quotes = tencent_quote(["510050", "510300"])

Tencent Finance field index quick reference (tested and calibrated 2026-05-03)

Index Meaning Example
1 Name Leaderdrive
3 Current price 224.12
4 Previous close 215.01
5 Today's open 214.10
9-18 Bid 1~Bid 5 (price+volume)
19-28 Ask 1~Ask 5 (price+volume)
31 Change amount 9.11
32 Change % 4.24
33 High 229.62
34 Low 214.10
37 Turnover (10k) 187040
38 Turnover rate % 4.55
39 PE(TTM) 300.45
43 Amplitude % (not PB!) 7.22
44 Float market cap (100M) 410.88
45 Total market cap (100M) 410.88
46 PB (price-to-book ratio) 11.51
47 Limit-up price 258.01
48 Limit-down price 172.01
49 Volume ratio 1.20
52 PE (static) 314.76

Pitfall reminder one: Many tutorials online write index 43 as PB, but in practice it's amplitude%. PB is at index 46.

Pitfall reminder two (corrected on 2026-07-26): 44 is circulating market value, 45 is total market value, previously this table had them reversed. For most stocks the two are equal, so you can't tell; butstocks where total shares ≠ circulating shares (STAR Market/new stocks/restricted shares) can differ by several times. In testing, China Shipbuilding Special Gas (688146):f[44]=356.15亿(circulating shares 145 million),f[45]=1300.61亿(total shares 529 million),a 3.65x difference. When using market value for screening, picking the wrong one will misjudge large-cap companies as small caps. You can use f[45] ÷ 现价 to back out the total shares and cross-check (consistent with East Money f84 ). Reference: East Money push2's f116=total market value / f117=circulating market value; the direction is opposite to Tencent, confirmed by testing, do not mix them up.

1.3 Baidu Stock K-line — with MA5/MA10/MA20 (new in V3.0)

Core value: it comes with moving average data on return, no local calculation needed.

import requests

def baidu_kline_with_ma(code: str, start_time: str = "") -> dict:
    """百度股市通K线 — 独有能力: 返回时自带 ma5/ma10/ma20 均价"""
    url = "https://finance.pae.baidu.com/selfselect/getstockquotation"
    params = {
        "all": "1", "isIndex": "false", "isBk": "false", "isBlock": "false",
        "isFutures": "false", "isStock": "true", "newFormat": "1",
        "group": "quotation_kline_ab", "finClientType": "pc",
        "code": code, "start_time": start_time, "ktype": "1",
    }
    headers = {
        "User-Agent": "Mozilla/5.0",
        "Accept": "application/vnd.finance-web.v1+json",
        "Origin": "https://gushitong.baidu.com",
        "Referer": "https://gushitong.baidu.com/",
    }
    r = requests.get(url, params=params, headers=headers, timeout=10)
    d = r.json()
    result = d.get("Result", {})
    md = result.get("newMarketData", {})
    keys = md.get("keys", [])  # includes: ma5avgprice, ma10avgprice, ma20avgprice
    rows = md.get("marketData", "").split(";")
    return {"keys": keys, "rows": rows}

# 用法
data = baidu_kline_with_ma("600519")
print("字段:", data["keys"][:10])
print("最近5根K线:", data["rows"][-5:])
# keys 包含: time, open, close, high, low, volume, amount, ma5avgprice, ma10avgprice, ma20avgprice 等

1.4 Sina adjustment factor — qfq / hfq (new in V3.7.0)

Core value: §1.1 tdx_client().bars() what is returned isunadjusteddata; directly comparing prices across ex-rights dates will inevitably go wrong. This endpoint provides the adjustment factor series, one HTTP request, about 1.8KB, zero authentication.

import json
import re

import requests


def sina_adjust_factor(code: str, kind: str = "qfq") -> list:
    """新浪复权因子序列 — kind='qfq'(前复权) | 'hfq'(后复权),按日期倒序(最新在前)"""
    if kind not in ("qfq", "hfq"):
        raise ValueError(f"kind 只能是 'qfq' 或 'hfq',收到 {kind!r}")
    # 数字位用 norm_ticker() 剥掉前后缀(否则 "sz000016" 会拼成 "szsz000016" ——
    # zfill(6) 对 8 字符输入不做任何事)。
    raw = str(code).strip()
    digits = norm_ticker(raw)
    # 市场:**显式写法优先**——前缀或 `.SH` 后缀直接采信(V3.7.1 起 get_prefix() 也认后缀,
    # 本地显式匹配保留,语义不变)。都没写显式市场时,才用 get_prefix() 按号段推断
    # (它已处理 92 必须先于 9x)。
    m = re.match(r"^(sh|sz|bj)", raw, re.I) or re.search(r"\.(sh|sz|bj)$", raw, re.I)
    prefix = m.group(1).lower() if m else get_prefix(digits)
    symbol = f"{prefix}{digits}"
    url = f"https://finance.sina.com.cn/realstock/company/{symbol}/{kind}.js"
    r = requests.get(url, headers={"User-Agent": "Mozilla/5.0",
                                   "Referer": "https://finance.sina.com.cn/"}, timeout=10)
    r.raise_for_status()
    # 🔴 响应形如 `var sh600519qfq={...}` 且**末尾挂着 /* base64 */ 注释块**,
    #    不能用 $ 锚定正则。从第一个 { 起用 raw_decode,让解析器自己在 JSON 结束处停下。
    text = r.text
    brace = text.find("{")
    if brace < 0:
        raise RuntimeError(f"新浪复权因子响应无 JSON({symbol}/{kind}): {text[:120]}")
    try:
        data, _ = json.JSONDecoder().raw_decode(text[brace:])
    except json.JSONDecodeError as e:
        raise RuntimeError(f"新浪复权因子 JSON 解析失败({symbol}/{kind}): {e}") from e
    return [{"date": it["d"], "factor": float(it["f"])} for it in data.get("data", [])]


def apply_adjust(bars, factors: list, kind: str = "qfq",
                 price_keys=("open", "high", "low", "close")):
    """把复权因子套到不复权 K 线上。

    `bars` 接受两种形态:
      - **§1.1 `tdx_client().bars()` 的 DataFrame**(日期列名是 `datetime`)→ 返回 DataFrame
      - list[dict](需含 `date` 键)→ 返回 list[dict]

    🔴 **qfq 与 hfq 的运算方向相反,必须传对 kind**:
      - `qfq`(前复权)因子是**除数**:`前复权价 = 不复权价 ÷ factor`
      - `hfq`(后复权)因子是**乘数**:`后复权价 = 不复权价 × factor`
    传错方向不会报错,只会把历史价格放大/缩小几倍(见下方实测对照表)。

    因子表是「生效日 → 因子」的阶梯,每根 K 线取**不晚于它**的最近一个因子。
    """
    if kind not in ("qfq", "hfq"):
        raise ValueError(f"kind 只能是 'qfq' 或 'hfq',收到 {kind!r}")
    # 🔴 因子为空时绝不能「原样返回」—— 那会把不复权价当成复权价交出去,
    #    调用方拿到的数字看着正常却是错的(新浪对不支持的标的就返回空 data)。
    if not factors:
        raise ValueError(
            "复权因子列表为空,无法复权。请先确认 sina_adjust_factor() 是否取到数据"
            "(新浪对不支持的标的会返回空 data),不要用未复权价继续计算。"
        )

    is_df = hasattr(bars, "columns") and hasattr(bars, "to_dict")
    if is_df:
        # mootdx bars() 的日期列叫 datetime,且可能带时分秒,统一截成 YYYY-MM-DD
        date_col = next((c for c in ("date", "datetime") if c in bars.columns), None)
        if date_col is None:
            raise ValueError(f"DataFrame 需含 date 或 datetime 列,实际列={list(bars.columns)}")
        rows = bars.to_dict("records")
        for r in rows:
            r["date"] = str(r[date_col])[:10]
    else:
        rows = [dict(b) for b in bars]
        for r in rows:
            if "date" not in r:
                raise ValueError(f"每根 K 线需含 'date' 键,实际键={sorted(r)}")
            r["date"] = str(r["date"])[:10]

    fac = sorted(factors, key=lambda x: x["date"])
    out, i, cur = [], 0, None
    for bar in sorted(rows, key=lambda b: b["date"]):
        while i < len(fac) and fac[i]["date"] <= bar["date"]:
            cur = fac[i]["factor"]
            i += 1
        # 🔴 早于最早因子日的 K 线不能原样放行 —— 那会让一份结果里混着「已复权」和
        #    「未复权」两种价格且无从分辨。新浪的因子表通常带 1900-01-01 哨兵
        #    (实测 600519/000001/300750/688981/000004/601398 六只均是),
        #    真出现未覆盖行,说明因子表异常,必须显式失败。
        if cur is None:
            raise RuntimeError(
                f"K 线日期 {bar['date']} 早于因子序列最早日 {fac[0]['date']},"
                "无法复权;不返回未复权价以免与已复权行混淆。"
            )
        if cur == 0:
            raise RuntimeError(f"复权因子为 0({bar['date']}),无法换算")
        nb = dict(bar)
        for k in price_keys:
            if k in nb and nb[k] is not None:
                v = float(nb[k])
                nb[k] = round(v / cur if kind == "qfq" else v * cur, 4)
        nb["adj_factor"] = cur
        out.append(nb)
    if is_df:
        import pandas as pd
        res = pd.DataFrame(out)
        # mootdx 的 bars() 带 DatetimeIndex,重建 DataFrame 会退化成 RangeIndex,
        # 下游按时间切片 / resample / 时间对齐 join 都会失效。按排序后的顺序还原索引。
        if getattr(bars, "index", None) is not None and not isinstance(
            bars.index, pd.RangeIndex
        ):
            order = sorted(range(len(bars)), key=lambda n: str(bars.iloc[n][date_col])[:10])
            res.index = bars.index[order]
            res.index.name = bars.index.name
        return res
    return out


# 用法
qfq = sina_adjust_factor("600519", "qfq")
hfq = sina_adjust_factor("600519", "hfq")
print(len(qfq), "条 | 最新", qfq[0], "| 最早", qfq[-1])
# 实测 2026-08-19:33 条
#   qfq 最新 {'date': '2026-06-26', 'factor': 1.0}          ← 前复权以最新为基准
#   hfq 最早 {'date': '1900-01-01', 'factor': 1.0}          ← 后复权以最早为基准

bars = [{"date": "2015-01-05", "close": 202.52}]         # 茅台当日不复权收盘价
print(apply_adjust(bars, qfq, kind="qfq"))                # → 143.46(前复权,除法)
print(apply_adjust(bars, hfq, kind="hfq"))                # → 1274.28(后复权,乘法)

Direction tested comparison (2026-08-19, cross-validated against baostock adjustflag as baseline)

Date Unadjusted baostock forward-adjusted raw × qfq raw ÷ qfq
2015-01-05 202.52 143.46 285.90 ❌ 143.46 ✅
2026-08-14 1341.99 1341.99 1341.99 ✅ 1341.99 ✅

qfq the factor is always ≥ 1 and larger the further back in history,multiplying it in would inflate historical prices, so you must divide. The 2026 row works with both algorithms because the latest-day factor happens to be 1.0 —— verifying with only the most recent date will miss this bug。

⚠️ hfq's baseline differs from baostock's: Sina raw × hfq and baostock's backward-adjusted price differ by aconstant multiple (tested 1.1582, consistent at two points, 2015 and 2026). Scaling the entire backward-adjusted series does not affect returns or shape, butdo not directly compare Sina's backward-adjusted price numerically with other sources' backward-adjusted prices。

⚠️ Beijing Stock Exchange has no adjustment factor: tested bj920982 returns 404(Sina does not provide qfq/hfq files for the Beijing Stock Exchange), this function will throw HTTPError. For Beijing Stock Exchange symbols, use §1.1 Tongdaxin unadjusted prices instead, and derive adjustments yourself based on dividends and stock splits.

Self-check criteria (calibrated by testing on 2026-08-19):

  • qfq serieslatestone factor is always 1.0;hfq seriesearliestone is always 1.0。
  • on the same date qfq(d) × hfq(d) is always equal to a constant(the symbol's full-period total adjustment coefficient, Moutai tested 8.882513). ⚠️ The twoare not reciprocals(the product is not 1), and the ratio hfq/qfq alsois not constant(For Moutai, 33 dates have 32 distinct values) — Two normalized series with different bases, only their product is conserved. If any of the above is not satisfied, it means the response was truncated or the ticker symbol was written incorrectly.

Layer 2: Research Report Layer

2.1 Eastmoney Research Report API — Research Report List + PDF Download (Primary)

Grade A interface (public JSON API), reportapi.eastmoney.com, free with no key.

import requests
import re
import time
from datetime import date, timedelta   # 注意用 date/timedelta,不要 import datetime 模块:
                                       # 本 SKILL 多处有 `from datetime import datetime`,会把模块名遮蔽掉
from pathlib import Path
from typing import Optional            # 3.9 兼容:不能写 `str | None`(那是 3.10+ 语法)

REPORT_API = "https://reportapi.eastmoney.com/report/list"
PDF_TPL = "https://pdf.dfcfw.com/pdf/H3_{info_code}_1.pdf"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"

def eastmoney_reports(code: str, max_pages: int = 5) -> list[dict]:
    """拉取指定股票的研报列表。

    code 支持 600519 / SH600519 / 600519.SH 等写法(内部归一化为纯 6 位)。
    ⚠️ reportapi 只认纯 6 位数字:传 "SH600519" 会返回 hits=0,
       看起来像「这只票没研报」,实际是格式没归一化——务必先过 norm_ticker()。
    返回 [] 仅表示东财确无该标的研报覆盖(格式错误已在上游抛 ValueError)。
    """
    code = norm_ticker(code, stock_only=True)   # 格式错/显式指数码直接抛错,不静默返回 []
    all_records = []
    for page in range(1, max_pages + 1):
        params = {
            "industryCode": "*", "pageSize": "100", "industry": "*",
            "rating": "*", "ratingChange": "*",
            "beginTime": "2000-01-01", "endTime": "2030-01-01",
            "pageNo": str(page), "fields": "", "qType": "0",
            "orgCode": "", "code": code, "rcode": "",
            "p": str(page), "pageNum": str(page), "pageNumber": str(page),
        }
        r = em_get(REPORT_API, params=params,
                   headers={"Referer": "https://data.eastmoney.com/"}, timeout=30)  # 已内置限流
        d = r.json()
        rows = d.get("data") or []
        if not rows:
            break
        all_records.extend(rows)
        if page >= (d.get("TotalPage", 1) or 1):
            break
    # 正向识别「查无结果」的真实原因,不把废码静默当成「无研报覆盖」
    if not all_records and code[:2] in ("43", "83", "87"):
        raise ValueError(
            f"{code} 属北交所老号段(43/83/87),东财研报库已不再按老码索引。"
            f"北交所存量标的已基本迁至 920xxx(如 832982→920982);"
            f"请按股票名称反查现行 920 代码后重试。详见「北交所老号段」警告。"
        )
    return all_records

def download_pdf(record: dict, target_dir: str = "./reports") -> Optional[str]:
    """下载单份研报PDF,返回保存路径或None"""
    info_code = record.get("infoCode", "")
    if not info_code:
        return None
    date = (record.get("publishDate") or "")[:10]
    org = re.sub(r'[\\/:*?"<>|]', "_", record.get("orgSName") or "未知")[:40]
    title = re.sub(r'[\\/:*?"<>|]', "_", record.get("title", ""))[:80]
    fname = f"{date}_{org}_{title}.pdf"
    target = Path(target_dir) / fname
    if target.exists():
        return str(target)
    url = PDF_TPL.format(info_code=info_code)
    r = em_get(url, headers={"Referer": "https://data.eastmoney.com/"}, timeout=60)
    if r.status_code == 200 and len(r.content) >= 1024:
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(r.content)
        return str(target)
    return None

# 用法
reports = eastmoney_reports("688017")
print(f"共 {len(reports)} 篇研报")
for r in reports[:5]:
    print(f"  {r.get('publishDate','')[:10]} | {r.get('orgSName')} | {r.get('title','')[:60]}")

Key fields of a research report record

Field Meaning
title Research report title
publishDate Publish date
orgSName Institution abbreviation
infoCode Used to construct the PDF URL
predictThisYearEps This year's EPS forecast
predictNextYearEps Next year's EPS forecast
predictNextTwoYearEps The year after next's EPS forecast
emRatingName Rating (Buy/Overweight/...)
indvInduName Industry classification

Industry research report list (qType=1)

With individual stock research reportsSame endpoint(reportapi.eastmoney.com/report/list), only qType differs:qType=0 individual stock research reports,qType=1 industry research reports. The returned record can be fed directly to the above download_pdf()(the PDF template is universal).

from datetime import date, timedelta   # 本块单独拷贝也能跑;勿写成 import datetime(会被 §3+ 的
                                       # `from datetime import datetime` 遮蔽掉模块名)

def eastmoney_industry_reports(industry_code: str = "*", max_pages: int = 5,
                               begin: str = "") -> list[dict]:
    """拉取行业研报列表(qType=1)。
    industry_code="*" = 全行业;传东财行业码(如 "1238"=IT服务Ⅱ)= 单行业。
    行业名 / 行业码在每条 record 的 industryName / industryCode 字段。
    begin 留空 = 近两年(相对今天算,避免硬编码日期越用越旧)。"""
    if not begin:
        begin = (date.today() - timedelta(days=730)).isoformat()
    all_records = []
    for page in range(1, max_pages + 1):
        params = {
            "industryCode": industry_code, "pageSize": "100", "industry": "*",
            "rating": "*", "ratingChange": "*",
            "beginTime": begin, "endTime": "2030-01-01",
            "pageNo": str(page), "fields": "", "qType": "1",
        }
        r = em_get(REPORT_API, params=params,
                   headers={"Referer": "https://data.eastmoney.com/"}, timeout=30)  # 已内置限流
        d = r.json()
        rows = d.get("data") or []
        if not rows:
            break
        all_records.extend(rows)
        if page >= (d.get("TotalPage", 1) or 1):
            break
    return all_records

# 用法
# 1) 全行业最新研报
reports = eastmoney_industry_reports("*", max_pages=2)
print(f"共 {len(reports)} 篇行业研报")
for r in reports[:5]:
    print(f"  {r.get('publishDate','')[:10]} | {r.get('industryName')} | {r.get('orgSName')} | {r.get('title','')[:50]}")

# 2) 单行业(IT服务Ⅱ,行业码 1238)+ 下载首篇 PDF(复用 2.1 的 download_pdf)
it = eastmoney_industry_reports("1238", max_pages=1)
if it:
    download_pdf(it[0])

Fields specific to/common for industry research reports (other fields same as 2.1 individual stock research reports):

Field Meaning
industryName Industry name (e.g., IT Services II, Wind Power Equipment, Photovoltaic Equipment)
industryCode Eastmoney industry code (used for industry_code precise filtering)
emRatingName Industry rating (Buy/Overweight/Neutral/...)
reportType Report type
attachPages / attachSize PDF page count / size (KB)
infoCode Feed to download_pdf() to construct the PDF URL

How to get the industry code: The Eastmoney industry code is not a universal remembered code, and there is no public code table endpoint (bxpa etc. are already 404). Common approach: first use industry_code="*" to pull a batch, from the results' industryName/industryCode find the code of the target industry, then use that code for precise filtering.

2.2 Tonghuashun consensus expected EPS (direct connection to basic.10jqka.com.cn)

import requests
import pandas as pd
from io import StringIO

def ths_eps_forecast(code: str) -> pd.DataFrame:
    """
    同花顺机构一致预期EPS。
    直连 basic.10jqka.com.cn,解析HTML表格。
    code 支持 688017 / SH688017 / 688017.SH 等写法(内部归一化为纯 6 位)。
    返回 DataFrame: 年度, 预测机构数, 最小值, 均值, 最大值
    "均值" = 机构一致预期EPS
    """
    code = norm_ticker(code, stock_only=True)   # URL 路径只认纯 6 位,带前缀会 404 到空表
    url = f"https://basic.10jqka.com.cn/new/{code}/worth.html"
    headers = {
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
        "Referer": "https://basic.10jqka.com.cn/",
    }
    r = requests.get(url, headers=headers, timeout=15)
    r.encoding = "gbk"
    dfs = pd.read_html(StringIO(r.text))
    # 找含"每股收益"的表格
    for df in dfs:
        cols = [str(c) for c in df.columns]
        if any("每股收益" in c or "均值" in c for c in cols):
            return df
    # fallback: 返回第一个表
    return dfs[0] if dfs else pd.DataFrame()

# 用法
df = ths_eps_forecast("688017")
print(df)
# "预测机构数" < 3 的要谨慎

2.3 iwencai — NL semantic search for research reports (unique capability)

Requires an API Key + X-Claw Headers (mandatory requirement of SkillHub 2.0).

import os
import json
import secrets
import requests

IWENCAI_BASE = os.environ.get("IWENCAI_BASE_URL", "https://openapi.iwencai.com")
IWENCAI_KEY = os.environ.get("IWENCAI_API_KEY", "")

def _claw_headers(call_type: str = "normal") -> dict:
    """SkillHub 2.0 必须的 X-Claw 鉴权头"""
    return {
        "X-Claw-Call-Type": call_type,
        "X-Claw-Skill-Id": "report-search",
        "X-Claw-Skill-Version": "2.0.0",
        "X-Claw-Plugin-Id": "none",
        "X-Claw-Plugin-Version": "none",
        "X-Claw-Trace-Id": secrets.token_hex(32),
    }

def iwencai_search(query: str, channel: str = "report", size: int = 50) -> list[dict]:
    """
    iwencai 语义搜索。
    channel: "report"(研报) / "announcement"(公告) / "news"(新闻)
    size: 默认10, 实测可调到50(隐藏参数)
    """
    headers = {
        "Authorization": f"Bearer {IWENCAI_KEY}",
        "Content-Type": "application/json",
        **_claw_headers(),
    }
    payload = {
        "channels": [channel],
        "app_id": "AIME_SKILL",
        "query": query,
        "size": size,
    }
    r = requests.post(
        f"{IWENCAI_BASE}/v1/comprehensive/search",
        json=payload, headers=headers, timeout=30,
    )
    if r.status_code != 200:
        raise RuntimeError(f"iwencai HTTP {r.status_code}: {r.text[:200]}")
    data = r.json()
    if data.get("status_code", 0) != 0:
        raise RuntimeError(f"iwencai error: {data.get('status_msg', '')}")
    return data.get("data") or []

def iwencai_query(query: str, page: int = 1, limit: int = 50) -> list[dict]:
    """
    iwencai NL数据查询(结构化字段)。
    例: "贵州茅台 ROE" → DataFrame-like rows
    """
    headers = {
        "Authorization": f"Bearer {IWENCAI_KEY}",
        "Content-Type": "application/json",
        **_claw_headers(),
    }
    payload = {
        "query": query,
        "page": str(page),
        "limit": str(limit),
        "is_cache": "1",
        "expand_index": "true",
    }
    r = requests.post(
        f"{IWENCAI_BASE}/v1/query2data",
        json=payload, headers=headers, timeout=30,
    )
    if r.status_code != 200:
        raise RuntimeError(f"iwencai HTTP {r.status_code}: {r.text[:200]}")
    data = r.json()
    if data.get("status_code", 0) != 0:
        raise RuntimeError(f"iwencai error: {data.get('status_msg', '')}")
    return data.get("datas") or []

def dedup_articles(articles: list[dict]) -> list[dict]:
    """同一uid仅保留score最高的段落"""
    best = {}
    for a in articles:
        uid = a.get("uid", "") or f"{a.get('title','')}|{a.get('publish_date','')}"
        score = float(a.get("score", 0))
        if uid not in best or score > float(best[uid].get("score", 0)):
            best[uid] = a
    return sorted(best.values(), key=lambda x: x.get("publish_date", ""), reverse=True)

# 用法: NL语义搜索研报
articles = iwencai_search("人形机器人 行星滚柱丝杠 2026", channel="report", size=50)
articles = dedup_articles(articles)
for a in articles[:5]:
    extra = a.get("extra") or {}
    if isinstance(extra, str):
        extra = json.loads(extra)
    print(f"{a.get('publish_date','')[:10]} | {extra.get('organization','')} | {a.get('title','')[:60]}")

The only value of iwencai: NL topic search. Cross-topic retrieval like "humanoid robot planetary roller screw" can only be done by iwencai. For searching research reports by ticker, going through the Eastmoney reportapi is more stable.


Layer 3: Signal Layer

3.1 Tonghuashun hotspots — same-day strong stocks + theme attribution reason tags (exclusive)

Core value: It not only tells you "which ones are strong" but also tells you **"why they are strong"** —— theme tags manually operated by the Tonghuashun editorial team.

import requests
import pandas as pd

def ths_hot_reason(date: str = None) -> pd.DataFrame:
    """
    同花顺当日强势股归因。
    date: 'YYYY-MM-DD' 格式,None=今天
    返回 DataFrame,含每只股票的题材标签 (reason)。

    实测: 73ms 拿到 ~125 只 + 完整字段
    """
    from datetime import date as _date
    if date is None:
        date = _date.today().strftime("%Y-%m-%d")

    url = (
        f"http://zx.10jqka.com.cn/event/api/getharden/"
        f"date/{date}/orderby/date/orderway/desc/charset/GBK/"
    )
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "Chrome/117.0.0.0 Safari/537.36"
        )
    }
    r = requests.get(url, headers=headers, timeout=10)
    data = r.json()
    if data.get("errocode", 0) != 0:
        raise RuntimeError(f"同花顺热点错误: {data.get('errormsg', '')}")

    rows = data.get("data") or []
    df = pd.DataFrame(rows)
    if df.empty:
        return df

    # 字段重命名(中文友好)
    rename_map = {
        "name": "名称", "code": "代码", "reason": "题材归因",
        "close": "收盘价", "zhangdie": "涨跌额", "zhangfu": "涨幅%",
        "huanshou": "换手率%", "chengjiaoe": "成交额",
        "chengjiaoliang": "成交量", "ddejingliang": "大单净量",
        "market": "市场",
    }
    df = df.rename(columns=rename_map)
    return df

# 用法
df = ths_hot_reason("2026-05-09")
print(f"当日强势股: {len(df)} 只")
print(df[["代码", "名称", "涨幅%", "题材归因"]].head(10))

Tonghuashun hotspots field quick reference

Original field Chinese Description
code Code 6-digit stock code
name Name Abbreviation
reason Theme attribution Core field, manually operated tags, e.g., "computing power leasing+Token factory+AI government affairs"
zhangfu Change % Same-day change
huanshou Turnover rate % Same-day turnover
chengjiaoe Trading amount Yuan
chengjiaoliang Trading volume Shares
ddejingliang Large Order Net Volume Main Force Net Inflow Indicator
close Closing Price CNY
zhangdie Change Amount CNY
market Market SH/SZ/BJ

3.2 Tonghuashun Northbound Capital — hsgtApi Real-time Minute Flow + Local Self-cache History

⚠️ Shenzhen Connect real-time flow recently unreliable (tested 2026-07): The Shanghai Connect (hgt) minute series is complete, but Shenzhen Connect (sgt) often returns only a few scattered points, with anomalous magnitude in the last value. The root cause is that northbound intraday real-time disclosure has been tightened since 2024-08; it is not a problem with this code. Conclusion:hgt can be used for same-day sentiment, sgt is for reference only; for authoritative northbound data use HKEX official daily statistics (hkex.com.hk/chi/csm/DailyStat/data_tab_daily_YYYYMMDDc.js, see "Backup Source Quick Reference" at the end).

Known industry-wide issue: The net buy amount field of all eastmoney northbound data has returned NaN/0 since 2024-08, which is an upstream supply cutoff. Changed tolocal CSV self-cache mode— after each real-time data pull, it is automatically written to a local CSV; the history becomes richer the more it runs.

import requests
import pandas as pd
from pathlib import Path

HSGT_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "Chrome/117.0.0.0 Safari/537.36"
    ),
    "Host": "data.hexin.cn",
    "Referer": "https://data.hexin.cn/",
}

def hsgt_realtime() -> pd.DataFrame:
    """
    沪深股通当日实时分钟流向(含集合竞价 09:10–15:00,262 个时间点)。
    返回字段: time, hgt(沪股通累计净买入), sgt(深股通累计净买入)
    单位: 亿元
    """
    url = "https://data.hexin.cn/market/hsgtApi/method/dayChart/"
    r = requests.get(url, headers=HSGT_HEADERS, timeout=10)
    d = r.json()
    times = d.get("time", [])
    hgt = d.get("hgt", [])
    sgt = d.get("sgt", [])

    n = len(times)
    return pd.DataFrame({
        "time": times,
        "hgt_yi": hgt[:n] + [None] * (n - len(hgt)),
        "sgt_yi": sgt[:n] + [None] * (n - len(sgt)),
    })

# === 自缓存辅助函数 ===

def _northbound_cache_path() -> Path:
    """北向资金本地 CSV 缓存路径"""
    p = Path.home() / ".tradingagents" / "cache" / "northbound_daily.csv"
    p.parent.mkdir(parents=True, exist_ok=True)
    return p

def _save_northbound_snapshot(date: str, hgt: float, sgt: float):
    """写入/更新当天北向收盘数据到 CSV"""
    path = _northbound_cache_path()
    rows = {}
    if path.exists():
        for line in path.read_text().strip().split("\n")[1:]:
            parts = line.split(",")
            if len(parts) == 3:
                rows[parts[0]] = line
    rows[date] = f"{date},{hgt},{sgt}"
    with open(path, "w") as f:
        f.write("date,hgt,sgt\n")
        for d in sorted(rows.keys()):
            f.write(rows[d] + "\n")

def _load_northbound_history(n: int = 20) -> pd.DataFrame:
    """读取最近 N 天北向历史"""
    path = _northbound_cache_path()
    if not path.exists():
        return pd.DataFrame()
    df = pd.read_csv(path)
    return df.tail(n)

# 用法 1: 实时分钟流向
df = hsgt_realtime()
print(f"分钟点数: {len(df)}")
print(df.tail(5))

# 用法 2: 自动缓存今日收盘数据
if not df.empty:
    last = df.dropna().iloc[-1]
    _save_northbound_snapshot("2026-05-17", last["hgt_yi"], last["sgt_yi"])

# 用法 3: 读取历史
hist = _load_northbound_history(20)
print(hist)

3.3 Eastmoney slist — Sector/Concept Attribution of Individual Stocks (V3.2.2 Replaces Baidu)

Core value: Get all sectors an individual stock belongs to (industry + concept + region mixed) in one call, including sector code (BK code), same-day change percentage, and sector leading stock. Essential for theme attribution and sector linkage analysis.

V3.2.2 Replacement Note: Baidu PAE getrelatedblock interface is no longer valid (tested returns ResultCode 10003 + empty array, #18), switched to Eastmoney slist individual stock sector interface (spt=3, get everything in one request, zero authentication). Eastmoney mixes industry/concept/region inone listand returns them; the sector name itself is self-explanatory (e.g. "Food & Beverage" is an industry, "Guizhou Sector" is a region, "Brewing Concept" is a concept), and AI can directly use the sector name for theme attribution.

def eastmoney_concept_blocks(code: str) -> dict:
    """
    个股所属板块/概念归属(东财 slist,一次请求拿全,已内置限流)。
    返回: {total, boards: [{name, code(BK码), change_pct, lead_stock}], concept_tags: [板块名...]}
    boards 混合 行业/概念/地域,板块名自解释;concept_tags 是所有板块名的便捷列表。
    """
    market_code = em_market_code(code)      # #46:不能用 startswith("6"),见 helper 注释
    params = {
        "fltt": "2", "invt": "2",
        "secid": f"{market_code}.{code}",
        "spt": "3", "pi": "0", "pz": "200", "po": "1",
        "fields": "f12,f14,f3,f128",
    }
    headers = {"User-Agent": UA, "Referer": "https://quote.eastmoney.com/"}
    try:
        r = em_get("https://push2.eastmoney.com/api/qt/slist/get",
                   params=params, headers=headers, timeout=15)
        d = r.json()
    except Exception as e:
        print(f"[WARN] 东财板块归属请求失败: {e}")
        return {"total": 0, "boards": [], "concept_tags": []}

    diff = (d.get("data") or {}).get("diff") or {}
    items = diff.values() if isinstance(diff, dict) else diff
    boards = []
    for it in items:
        boards.append({
            "name": it.get("f14", ""),         # 板块名
            "code": it.get("f12", ""),         # BK 板块代码
            "change_pct": it.get("f3", ""),    # 板块当日涨跌幅
            "lead_stock": it.get("f128", ""),  # 板块龙头股
        })
    return {
        "total": len(boards),
        "boards": boards,
        "concept_tags": [b["name"] for b in boards],
    }

# 用法
blocks = eastmoney_concept_blocks("600519")
print(f"共 {blocks['total']} 个板块")
print("板块归属:", blocks["concept_tags"])
# → ['食品饮料', '白酒Ⅲ', '白酒Ⅱ', '贵州板块', '酿酒概念', 'HS300_', ...]

Note: Eastmoney does not distinguish industry/concept/region type (mixed in one list returned). If precise classification is needed, judge by sector name, or separately query the full-market sector list (clist + m:90+t:1/2/3) — but the latter requires extra requests each time, and large pages easily trigger risk control, so it is not recommended in batch scenarios.

3.4 Eastmoney push2 — Individual Stock Capital Flow (Minute-level)

Intraday real-time minute-level capital flow (main force/large order/medium order/small order/super-large order net inflow).

V3.1 Replacement Note: Baidu PAE fundflow and fundsortlist interfaces were taken offline in 2026-05 (return null), switched to Eastmoney push2 capital flow API. For daily-level capital flow see Layer 4.5 stock_fund_flow_120d()。

import requests

def eastmoney_fund_flow_minute(code: str) -> list[dict]:
    """
    个股资金流向(分钟级,当日盘中)。
    code: 6位股票代码
    返回: [{time, main_net, small_net, mid_net, large_net, super_net}, ...]
    单位: 元
    """
    # #46:secid 必须走 em_secid()。旧的 `startswith("6")` 会把沪市 ETF(51x)、
    # 科创 ETF(588x)、沪 B(900x) 错判成深市 → 接口返回 `data: null`。
    # ⚠️ 但要分清两件事:**路由修好 ≠ ETF 就有资金流**。2026-08-19 同批次实测,
    #    600519/300750 各返回 100 条,而 510300/588000 即便 secid 正确仍为 0 条 ——
    #    东财这个**个股**资金流接口本身不覆盖 ETF。ETF 资金流请另找端点。
    secid = em_secid(code)
    url = "https://push2.eastmoney.com/api/qt/stock/fflow/kline/get"
    params = {
        "secid": secid, "klt": 1,
        "fields1": "f1,f2,f3,f7",
        "fields2": "f51,f52,f53,f54,f55,f56,f57",
    }
    headers = {
        "User-Agent": UA,
        "Referer": "https://quote.eastmoney.com/",
        "Origin": "https://quote.eastmoney.com",
    }
    try:
        r = em_get(url, params=params, headers=headers, timeout=10)
        d = r.json()
    except Exception as e:
        print(f"[WARN] push2 资金流请求失败: {e}")
        return []

    rows = []
    # #46:接口对不存在/路由错误的 secid 返回 `"data": null`,
    # `.get("data", {})` 拿到的是 None 而非 {},直接 .get() 会 AttributeError。
    for line in (d.get("data") or {}).get("klines") or []:
        parts = line.split(",")
        if len(parts) >= 6:
            rows.append({
                "time": parts[0],
                "main_net": float(parts[1]),
                "small_net": float(parts[2]),
                "mid_net": float(parts[3]),
                "large_net": float(parts[4]),
                "super_net": float(parts[5]),
            })
    return rows

# 用法: 分钟级实时资金流
realtime = eastmoney_fund_flow_minute("000858")
if realtime:
    last = realtime[-1]
    signal = "bullish" if last["main_net"] > 0 else "bearish"
    print(f"主力净流入: {last['main_net']:.0f}元 → {signal}")
    # 统计全天主力净流入
    total = sum(r["main_net"] for r in realtime)
    print(f"全天主力累计: {total/1e4:.0f}万元")

Note: push2 capital flow amount unit isCNY(not ten-thousand CNY), pay attention to conversion when using.klt=1 minute-level,klt=101 daily-level.

3.5 Dragon-Tiger List Seats — Individual Stock Listing Records + Top 5 Buy/Sell Seats + Institutional Activity

Directly connects to Eastmoney datacenter API, does not rely on third-party wrappers.

import requests
from datetime import datetime, timedelta

def dragon_tiger_board(code: str, trade_date: str, look_back: int = 30) -> dict:
    """
    龙虎榜数据聚合。
    trade_date: YYYY-MM-DD
    look_back: 回看天数
    返回: {records: [...], seats: {buy: [...], sell: [...]}, institution: {...}}
    """
    start = datetime.strptime(trade_date, "%Y-%m-%d") - timedelta(days=look_back)
    start_str = start.strftime("%Y-%m-%d")

    # 1. 上榜记录
    records = []
    data = eastmoney_datacenter(
        "RPT_DAILYBILLBOARD_DETAILSNEW",
        filter_str=f"(TRADE_DATE>='{start_str}')(TRADE_DATE<='{trade_date}')(SECURITY_CODE=\"{code}\")",
        page_size=50,
        sort_columns="TRADE_DATE", sort_types="-1",
    )
    for row in data:
        records.append({
            "date": str(row.get("TRADE_DATE", ""))[:10],
            "reason": row.get("EXPLANATION", ""),
            "net_buy": round((row.get("BILLBOARD_NET_AMT") or 0) / 10000, 1),
            "turnover": round(float(row.get("TURNOVERRATE") or 0), 2),
        })

    # 2. 最近上榜的买卖席位
    # ⚠️ buy_data/sell_data 必须在 if 之前初始化:第 3 步无条件遍历它们,
    # 而回看窗口内无上榜记录(大市值 / 低换手率标的的常态)时 if 分支不执行,
    # 变量从未绑定 → UnboundLocalError,调用方拿到的是崩溃而不是"空结果"(#45)。
    buy_data, sell_data = [], []
    seats = {"buy": [], "sell": []}
    if records:
        latest_date = records[0]["date"]
        # 买入席位
        buy_data = eastmoney_datacenter(
            "RPT_BILLBOARD_DAILYDETAILSBUY",
            filter_str=f"(TRADE_DATE='{latest_date}')(SECURITY_CODE=\"{code}\")",
            page_size=10,
            sort_columns="BUY", sort_types="-1",
        )
        for row in buy_data[:5]:
            seats["buy"].append({
                "name": row.get("OPERATEDEPT_NAME", ""),
                "buy_amt": round((row.get("BUY") or 0) / 10000, 1),
                "sell_amt": round((row.get("SELL") or 0) / 10000, 1),
                "net": round((row.get("NET") or 0) / 10000, 1),
            })
        # 卖出席位
        sell_data = eastmoney_datacenter(
            "RPT_BILLBOARD_DAILYDETAILSSELL",
            filter_str=f"(TRADE_DATE='{latest_date}')(SECURITY_CODE=\"{code}\")",
            page_size=10,
            sort_columns="SELL", sort_types="-1",
        )
        for row in sell_data[:5]:
            seats["sell"].append({
                "name": row.get("OPERATEDEPT_NAME", ""),
                "buy_amt": round((row.get("BUY") or 0) / 10000, 1),
                "sell_amt": round((row.get("SELL") or 0) / 10000, 1),
                "net": round((row.get("NET") or 0) / 10000, 1),
            })

    # 3. 机构买卖统计(从买卖席位明细中筛选 OPERATEDEPT_CODE="0" 即机构专用席位)
    institution = {"buy_amt": 0, "sell_amt": 0, "net_amt": 0}
    for detail_data, side in [(buy_data, "buy"), (sell_data, "sell")]:
        for row in detail_data:
            if str(row.get("OPERATEDEPT_CODE", "")) == "0":
                amt = (row.get("BUY") or 0) if side == "buy" else (row.get("SELL") or 0)
                if side == "buy":
                    institution["buy_amt"] += amt
                else:
                    institution["sell_amt"] += amt
    institution["buy_amt"] = round(institution["buy_amt"] / 10000, 1)
    institution["sell_amt"] = round(institution["sell_amt"] / 10000, 1)
    institution["net_amt"] = round(institution["buy_amt"] - institution["sell_amt"], 1)

    return {"records": records, "seats": seats, "institution": institution}

# 用法
data = dragon_tiger_board("002475", "2026-05-17")
print(f"近30日上榜 {len(data['records'])} 次")
for r in data["records"]:
    print(f"  {r['date']}: {r['reason']}")
if data["seats"]["buy"]:
    print("买入席位 TOP5:")
    for s in data["seats"]["buy"]:
        print(f"  {s['name']}: 买{s['buy_amt']}万 卖{s['sell_amt']}万 净{s['net']}万")

ST stock note: The 5% price limit is more likely to trigger the Dragon-Tiger List ("cumulative deviation value reaches 12% over three consecutive days"), while the STAR Market 20% price limit triggers it less often.

3.6 Restricted Share Unlock Calendar — Historical Unlocks + Unlocks Due in the Next 90 Days

from datetime import datetime, timedelta

def lockup_expiry(code: str, trade_date: str, forward_days: int = 90) -> dict:
    """
    限售解禁日历。
    返回: {history: [...], upcoming: [...]}
    """
    # 1. 历史解禁记录
    history_data = eastmoney_datacenter(
        "RPT_LIFT_STAGE",
        filter_str=f"(SECURITY_CODE=\"{code}\")",
        page_size=15,
        sort_columns="FREE_DATE", sort_types="-1",
    )
    history = []
    for row in history_data:
        history.append({
            "date": str(row.get("FREE_DATE", ""))[:10],
            "type": row.get("FREE_SHARES_TYPE", ""),        # 解禁类型(东财2026改列名, 旧LIMITED_STOCK_TYPE已废)
            "shares": row.get("FREE_SHARES", 0),             # 本次解禁股数(万股)
            "able_shares": row.get("ABLE_FREE_SHARES", 0),   # 实际可流通股数(万股, 更贴近真实抛压)
            "ratio": row.get("FREE_RATIO", 0),               # 占总股本比(小数, ×100 得百分比)
        })

    # 2. 未来待解禁
    end_date = datetime.strptime(trade_date, "%Y-%m-%d") + timedelta(days=forward_days)
    end_str = end_date.strftime("%Y-%m-%d")
    upcoming_data = eastmoney_datacenter(
        "RPT_LIFT_STAGE",
        filter_str=f"(SECURITY_CODE=\"{code}\")(FREE_DATE>='{trade_date}')(FREE_DATE<='{end_str}')",
        page_size=20,
        sort_columns="FREE_DATE", sort_types="1",
    )
    upcoming = []
    for row in upcoming_data:
        upcoming.append({
            "date": str(row.get("FREE_DATE", ""))[:10],
            "type": row.get("FREE_SHARES_TYPE", ""),        # 解禁类型(东财2026改列名, 旧LIMITED_STOCK_TYPE已废)
            "shares": row.get("FREE_SHARES", 0),             # 本次解禁股数(万股)
            "able_shares": row.get("ABLE_FREE_SHARES", 0),   # 实际可流通股数(万股, 更贴近真实抛压)
            "ratio": row.get("FREE_RATIO", 0),               # 占总股本比(小数, ×100 得百分比)
        })

    return {"history": history, "upcoming": upcoming}

# 用法
data = lockup_expiry("002475", "2026-05-17")
print(f"历史解禁 {len(data['history'])} 批")
for h in data["history"][:5]:
    print(f"  {h['date']}: {h['type']} 数量={h['shares']}")
if data["upcoming"]:
    print(f"未来90天待解禁 {len(data['upcoming'])} 批")
else:
    print("未来90天无待解禁")

Reference for types of restricted shares:

  • Restricted shares held by original shareholders at IPO (1-3 years after IPO)
  • Restricted shares placed with institutions at IPO (IPO strategic placement)
  • Restricted shares placed with institutions in private placements (6-18 months)
  • Restricted shares from equity incentives

3.7 Industry Sector Ranking (V3.0 switched to Eastmoney — Tonghuashun added anti-scraping 401)

Eastmoney industry sector gain/loss ranking; see industry rotation across the whole market in one call.

import requests

def industry_comparison(top_n: int = 20) -> dict:
    """
    全行业涨跌幅排名(东财行业板块,~100 个行业)。
    返回: {top: [...], bottom: [...], total: int}
    """
    url = "https://push2.eastmoney.com/api/qt/clist/get"
    params = {
        "pn": "1", "pz": "100", "po": "1", "np": "1",
        "fltt": "2", "invt": "2", "fid": "f3",   # fid=f3 + po=1:按涨跌幅降序(缺 fid 时 top/bottom 切片非按涨幅排)
        "fs": "m:90+t:2",
        "fields": "f2,f3,f4,f12,f13,f14,f104,f105,f128,f136,f140,f141,f207",
    }
    headers = {"User-Agent": UA}
    r = em_get(url, params=params, headers=headers, timeout=15)
    d = r.json()
    items = (d.get("data") or {}).get("diff") or []        # #46 同因
    if not items:
        return {"top": [], "bottom": [], "total": 0}

    rows = []
    for i, item in enumerate(items):
        rows.append({
            "rank": i + 1,
            "name": item.get("f14", ""),
            "change_pct": item.get("f3", 0),
            "code": item.get("f12", ""),
            "up_count": item.get("f104", 0),
            "down_count": item.get("f105", 0),
            "leader": item.get("f140", ""),
            "leader_change": item.get("f136", 0),
        })

    return {
        "top": rows[:top_n],
        "bottom": rows[-top_n:],
        "total": len(rows),
    }

# 用法
data = industry_comparison(20)
print(f"共 {data['total']} 个行业")
print("\nTOP 10 涨幅:")
for r in data["top"][:10]:
    print(f"  {r['rank']}. {r['name']}: {r['change_pct']}% 涨{r['up_count']}跌{r['down_count']} 领涨{r['leader']}")
print("\nBOTTOM 5 跌幅:")
for r in data["bottom"][-5:]:
    print(f"  {r['rank']}. {r['name']}: {r['change_pct']}%")

3.8 Sector Capital Flows (Industry/Concept/Region × Today/5-Day/10-Day)

Eastmoney sector capital flows — main-force net inflow amount/net ratio + four tiers of extra-large/large/medium/small orders, covering three types of sectors (industry, concept, region) and three periods (today/5-day/10-day). Same source and same interface as the §3.7 sector rankingsame source and same interface(push2 clist), except that the capital flow fields were additionally requested (f62/f184/f66...). Uses em_get rate limiting to prevent blocking.

import requests

# 板块类型 → 东财 fs 参数
_BOARD_FS = {"industry": "m:90+t:2", "concept": "m:90+t:3", "region": "m:90+t:1"}
# 周期 → (排序fid, 主力净额, 主力净占比, 涨跌幅, 领涨股name);四档明细仅今日
_BOARD_PERIOD = {
    "today": ("f62",  "f62",  "f184", "f3",   "f204"),
    "5d":    ("f164", "f164", "f165", "f109", "f257"),
    "10d":   ("f174", "f174", "f175", "f160", None),   # 10日领涨股名称字段不稳定,省略
}

def board_fund_flow(board_type: str = "industry", period: str = "today",
                    top_n: int = 20) -> dict:
    """
    板块资金流向排名(按主力净流入降序)。
    board_type: industry(行业) / concept(概念) / region(地域)
    period:     today(今日) / 5d(5日) / 10d(10日)
    返回: {board_type, period, total, rows:[{rank, name, code, change_pct,
           main_net(主力净额,元), main_pct(主力净占比,%), leader(领涨股),
           # 仅 today:super_large_net/large_net/medium_net/small_net(超大/大/中/小单净额,元)}]}
    注:板块级只有 今日/5日/10日(无 3日,个股级才有)。主力净额 = 超大单 + 大单。
    """
    if board_type not in _BOARD_FS:
        raise ValueError(f"board_type 须为 {list(_BOARD_FS)}")
    if period not in _BOARD_PERIOD:
        raise ValueError(f"period 须为 {list(_BOARD_PERIOD)}")
    fid, f_main, f_pct, f_chg, f_leader = _BOARD_PERIOD[period]

    fields = ["f12", "f14", f_chg, f_main, f_pct]
    if f_leader:
        fields.append(f_leader)
    if period == "today":
        fields += ["f66", "f72", "f78", "f84"]   # 超大/大/中/小单净额

    url = "https://push2.eastmoney.com/api/qt/clist/get"
    base = {
        "pz": "200", "po": "1", "np": "1",
        "fltt": "2", "invt": "2", "fid": fid,       # fid + po=1:按该周期主力净额降序
        "fs": _BOARD_FS[board_type],
        "fields": ",".join(dict.fromkeys(fields)),  # 去重保序
    }
    # ⚠️ 板块数超过单页上限:实测行业 496 个、概念 495 个,写死 pz=200 会把两者都截断,
    # total 也会误报成 200。先取第一页拿真实 total,需要更多才翻页(多数调用 top_n≤200,
    # 只发一次请求;em_get 有限流,不无谓翻页)。
    def _page(pn: int):
        r = em_get(url, params={**base, "pn": str(pn)},
                   headers={"User-Agent": UA}, timeout=15)
        d = r.json().get("data") or {}
        return (d.get("diff") or []), int(d.get("total") or 0)

    _PAGE = 200
    items, total = _page(1)
    pn = 2
    while len(items) < top_n:
        if total and len(items) >= total:
            break                      # 已取满接口声明的总数
        more, _ = _page(pn)
        if not more:
            break                      # 防御:API 提前返空则停止,避免死循环
        items += more
        pn += 1
        if len(more) < _PAGE:
            break                      # 不足一页=已到末页(total 缺失时的收敛条件)
    total = max(total, len(items))

    rows = []
    for i, it in enumerate(items):
        row = {
            "rank": i + 1,
            "name": it.get("f14", ""),
            "code": it.get("f12", ""),
            "change_pct": it.get(f_chg, 0),
            "main_net": it.get(f_main, 0),          # 主力净流入净额(元)
            "main_pct": it.get(f_pct, 0),           # 主力净流入净占比(%)
            "leader": it.get(f_leader, "") if f_leader else "",
        }
        if period == "today":
            row.update({
                "super_large_net": it.get("f66", 0),
                "large_net":       it.get("f72", 0),
                "medium_net":      it.get("f78", 0),
                "small_net":       it.get("f84", 0),
            })
        rows.append(row)

    return {"board_type": board_type, "period": period,
            "total": total, "rows": rows[:top_n]}

# 用法
d = board_fund_flow("industry", "today", 10)
print(f"行业板块今日主力净流入 TOP{len(d['rows'])}(共 {d['total']} 个):")
for r in d["rows"]:
    print(f"  {r['rank']}. {r['name']}: 主力 {r['main_net']/1e8:.2f}亿 ({r['main_pct']}%) "
          f"涨跌{r['change_pct']}% 超大{r['super_large_net']/1e8:.2f}亿 领涨{r['leader']}")

# 概念板块 5 日资金流
concept_5d = board_fund_flow("concept", "5d", 10)
# 地域板块 10 日资金流
region_10d = board_fund_flow("region", "10d", 10)

3.9 Whole-Market Dragon-Tiger List

Daily whole-market Dragon-Tiger List summary — all stocks that triggered the Dragon-Tiger List that day + reason for listing + net buy/sell amount + turnover rate.

from datetime import datetime

def daily_dragon_tiger(trade_date: str = None, min_net_buy: float = None) -> dict:
    """
    全市场龙虎榜。
    trade_date: YYYY-MM-DD(默认当日)
    min_net_buy: 净买入下限(万元),None 不过滤
    返回: {date, total_records, stocks: [{code, name, reason, close, change_pct,
           net_buy_wan, buy_wan, sell_wan, turnover_pct}]}
    """
    if trade_date is None:
        trade_date = datetime.now().strftime("%Y-%m-%d")

    data = eastmoney_datacenter(
        "RPT_DAILYBILLBOARD_DETAILSNEW",
        filter_str=f"(TRADE_DATE>='{trade_date}')(TRADE_DATE<='{trade_date}')",
        page_size=500,
        sort_columns="BILLBOARD_NET_AMT", sort_types="-1",
    )
    if not data:
        return {"date": trade_date, "total_records": 0, "stocks": [],
                "note": "无数据(非交易日或盘后未更新)"}

    actual_date = str(data[0].get("TRADE_DATE", ""))[:10] if data else trade_date
    stocks = []
    for row in data:
        net_buy = (row.get("BILLBOARD_NET_AMT") or 0) / 10000
        if min_net_buy is not None and net_buy < min_net_buy:
            continue
        stocks.append({
            "code": row.get("SECURITY_CODE", ""),
            "name": row.get("SECURITY_NAME_ABBR", ""),
            "reason": row.get("EXPLANATION", ""),
            "close": row.get("CLOSE_PRICE") or 0,
            "change_pct": round(float(row.get("CHANGE_RATE") or 0), 2),
            "net_buy_wan": round(net_buy, 1),
            "buy_wan": round((row.get("BILLBOARD_BUY_AMT") or 0) / 10000, 1),
            "sell_wan": round((row.get("BILLBOARD_SELL_AMT") or 0) / 10000, 1),
            "turnover_pct": round(float(row.get("TURNOVERRATE") or 0), 2),
        })
    return {"date": actual_date, "total_records": len(stocks), "stocks": stocks}

# 用法
data = daily_dragon_tiger("2026-05-16")
print(f"{data['date']} 龙虎榜共 {data['total_records']} 条记录")
for s in data["stocks"][:10]:
    print(f"  {s['code']} {s['name']}: {s['reason']} | 净买{s['net_buy_wan']}万 涨跌{s['change_pct']}%")

# 只看净买入 > 5000 万的
data = daily_dragon_tiger("2026-05-16", min_net_buy=5000)
print(f"\n净买入 > 5000万: {data['total_records']} 条")

3.10 Combined Use of the Signal Layer: Theme Heat + Capital Verification

# 拉当日强势股 reason
df_hot = ths_hot_reason()

# 词频统计 reason 列里的题材关键词
from collections import Counter
all_tags = []
for r in df_hot["题材归因"].dropna():
    tags = [t.strip() for t in str(r).split("+") if t.strip()]
    all_tags.extend(tags)

cnt = Counter(all_tags)
print("当日 TOP 10 题材热度:")
for tag, n in cnt.most_common(10):
    print(f"  {tag}: {n} 只")

# 同时拉北向当日流向,看资金流方向是否对应题材
df_north = hsgt_realtime()
hgt_close = df_north["hgt_yi"].dropna().iloc[-1] if not df_north.empty else 0
sgt_close = df_north["sgt_yi"].dropna().iloc[-1] if not df_north.empty else 0
print(f"\n北向收盘累计: 沪股通 {hgt_close} 亿 / 深股通 {sgt_close} 亿")

# V3.0: 叠加行业对比,看哪些行业资金在流入
comp = industry_comparison(10)
print("\n行业涨幅 TOP 5:")
for r in comp["top"][:5]:
    print(f"  {r['name']}: {r['change_pct']}% 涨{r['up_count']}跌{r['down_count']}")

Layer 4: Capital Side / Chip Layer (New in V3.0)

4.1 Margin Trading and Securities Lending Details

def margin_trading(code: str, page_size: int = 30) -> list[dict]:
    """
    融资融券明细(日级)。
    返回: [{date, rzye(融资余额), rzmre(融资买入), rqye(融券余额), ...}]
    """
    data = eastmoney_datacenter(
        "RPTA_WEB_RZRQ_GGMX",
        filter_str=f'(SCODE="{code}")',
        page_size=page_size,
        sort_columns="DATE", sort_types="-1",
    )
    rows = []
    for row in data:
        rows.append({
            "date": str(row.get("DATE", ""))[:10],
            "rzye": row.get("RZYE", 0),       # 融资余额(元)
            "rzmre": row.get("RZMRE", 0),      # 融资买入额
            "rzche": row.get("RZCHE", 0),      # 融资偿还额
            "rqye": row.get("RQYE", 0),        # 融券余额(元)
            "rqmcl": row.get("RQMCL", 0),      # 融券卖出量
            "rqchl": row.get("RQCHL", 0),      # 融券偿还量
            "rzrqye": row.get("RZRQYE", 0),    # 融资融券余额合计
        })
    return rows

# 用法
data = margin_trading("600519")
for d in data[:5]:
    print(f"{d['date']}: 融资余额={d['rzye']/1e8:.2f}亿 融券余额={d['rqye']/1e8:.2f}亿")

4.2 Block Trades

def block_trade(code: str, page_size: int = 20) -> list[dict]:
    """
    大宗交易记录。
    返回: [{date, price, vol, amount, buyer, seller, premium_pct}]
    """
    data = eastmoney_datacenter(
        "RPT_DATA_BLOCKTRADE",
        filter_str=f'(SECURITY_CODE="{code}")',
        page_size=page_size,
        sort_columns="TRADE_DATE", sort_types="-1",
    )
    rows = []
    for row in data:
        close = row.get("CLOSE_PRICE") or 0
        deal_price = row.get("DEAL_PRICE") or 0
        premium = ((deal_price / close - 1) * 100) if close else 0
        rows.append({
            "date": str(row.get("TRADE_DATE", ""))[:10],
            "price": deal_price,
            "close": close,
            "premium_pct": round(premium, 2),
            "vol": row.get("DEAL_VOLUME", 0),
            "amount": row.get("DEAL_AMT", 0),
            "buyer": row.get("BUYER_NAME", ""),
            "seller": row.get("SELLER_NAME", ""),
        })
    return rows

# 用法
data = block_trade("600519")
for d in data[:5]:
    print(f"{d['date']}: 价格={d['price']} 溢价={d['premium_pct']}% 买方={d['buyer']}")

4.3 Changes in Number of Shareholders

def holder_num_change(code: str, page_size: int = 10) -> list[dict]:
    """
    股东户数变化(季度级)。
    返回: [{date, holder_num, change_num, change_ratio, avg_shares}]
    """
    data = eastmoney_datacenter(
        "RPT_HOLDERNUMLATEST",
        filter_str=f'(SECURITY_CODE="{code}")',
        page_size=page_size,
        sort_columns="END_DATE", sort_types="-1",
    )
    rows = []
    for row in data:
        rows.append({
            "date": str(row.get("END_DATE", ""))[:10],
            "holder_num": row.get("HOLDER_NUM", 0),
            "change_num": row.get("HOLDER_NUM_CHANGE", 0),
            "change_ratio": row.get("HOLDER_NUM_RATIO", 0),  # 环比%
            "avg_shares": row.get("AVG_FREE_SHARES", 0),     # 户均持股
        })
    return rows

# 用法
data = holder_num_change("600519")
for d in data[:5]:
    print(f"{d['date']}: 股东数={d['holder_num']} 变化={d['change_ratio']}% 户均={d['avg_shares']}")
# 股东户数持续减少 = 筹码集中 = 主力吸筹信号

4.4 Dividend and Bonus Share History

def dividend_history(code: str, page_size: int = 20) -> list[dict]:
    """
    分红送转历史。
    返回: [{date, bonus_rmb(每股派息), transfer_ratio(转增比例), bonus_ratio(送股比例)}]
    """
    data = eastmoney_datacenter(
        "RPT_SHAREBONUS_DET",
        filter_str=f'(SECURITY_CODE="{code}")',
        page_size=page_size,
        sort_columns="EX_DIVIDEND_DATE", sort_types="-1",
    )
    rows = []
    for row in data:
        rows.append({
            "date": str(row.get("EX_DIVIDEND_DATE", ""))[:10],
            "bonus_rmb": row.get("PRETAX_BONUS_RMB", 0),    # 每股派息(税前)
            "transfer_ratio": row.get("TRANSFER_RATIO", 0),  # 每10股转增
            "bonus_ratio": row.get("BONUS_RATIO", 0),        # 每10股送股
            "plan": row.get("ASSIGN_PROGRESS", ""),           # 进度
        })
    return rows

# 用法
data = dividend_history("600519")
for d in data[:5]:
    print(f"{d['date']}: 每股派息={d['bonus_rmb']}元 转增={d['transfer_ratio']} 送={d['bonus_ratio']}")

4.5 Individual Stock Capital Flows (120 days, daily)

import requests

def stock_fund_flow_120d(code: str) -> list[dict]:
    """
    个股资金流(日级,最近120个交易日)。
    返回: [{date, main_net(主力净流入), small_net, mid_net, large_net, super_net}]
    单位: 元
    """
    market_code = em_market_code(code)      # #46
    url = "https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get"
    params = {
        "secid": f"{market_code}.{code}",
        "fields1": "f1,f2,f3,f7",
        "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65",
        "lmt": "120",
    }
    headers = {
        "User-Agent": UA,
        "Referer": "https://quote.eastmoney.com/",
        "Origin": "https://quote.eastmoney.com",
    }
    try:
        r = em_get(url, params=params, headers=headers, timeout=15)
        d = r.json()
    except Exception as e:
        print(f"[WARN] push2 资金流请求失败: {e}")
        return []
    klines = (d.get("data") or {}).get("klines") or []     # #46 同因

    rows = []
    for line in klines:
        parts = line.split(",")
        if len(parts) >= 7:
            rows.append({
                "date": parts[0],
                "main_net": float(parts[1]) if parts[1] != "-" else 0,
                "small_net": float(parts[2]) if parts[2] != "-" else 0,
                "mid_net": float(parts[3]) if parts[3] != "-" else 0,
                "large_net": float(parts[4]) if parts[4] != "-" else 0,
                "super_net": float(parts[5]) if parts[5] != "-" else 0,
            })
    return rows

# 用法
data = stock_fund_flow_120d("600519")
for d in data[-5:]:
    print(f"{d['date']}: 主力净流入={d['main_net']/1e4:.0f}万 超大单={d['super_net']/1e4:.0f}万")

# 统计近20日主力净流入
recent_20 = data[-20:]
total_main = sum(d["main_net"] for d in recent_20)
print(f"\n近20日主力累计净流入: {total_main/1e8:.2f}亿")

⚠️ Intermittent blocking of mainland residential IPs (#18): the push2/push2his series hassome mainland residential broadband IPs connection-level risk control, manifesting as occasional HTTP 000(connection refused/timeout) or empty returns —this is not a code issue(the same code has been tested as working normally on other networks/time periods). When encountered: ① retry after a few minutes; ② switch network environment (e.g., mobile hotspot); ③ reduce request frequency (increase EM_MIN_INTERVAL). Pragmatic alternative for daily-level capital flows: you can still use mootdx to calculate volume and price, or retry in a different time period.


4.6 Chip Distribution CYQ — Profit Ratio / Average Cost / Cost Range (New in V3.7.0)

Core value: This layer is called the "Capital Side / ChipLayer", but §4.1~§4.5 are all margin trading, block trades, number of shareholders and other such Capital Sidedata, and it has always lacked truechip distribution. This endpoint fills that gap.

🔴 Eastmoney has no public CYQ interface(tested on 2026-08-19, push2/api/qt/stock/cyq/get and push2his both written formsboth 404). The common industry practice islocal inference: historical chips decay according to turnover rate, and the day's trading volume is distributed by triangular distribution into the [low, high] range. Zero new data sources — OHLC uses §1.1 Tongdaxin, turnover rate uses §6.5 baostock.

import numpy as np
import pandas as pd


def _triangular_weights(grid: np.ndarray, low: float, high: float, avg: float) -> np.ndarray:
    """当日筹码在价格网格上的三角分布权重(峰值在均价,面积归一)"""
    w = np.zeros_like(grid)
    if not np.isfinite([low, high, avg]).all() or high < low:
        return w
    if high - low < 1e-9:                       # 一字板:全部堆在一个价位
        w[np.argmin(np.abs(grid - low))] = 1.0
        return w
    avg = min(max(avg, low), high)              # 均价必须落在当日区间内
    left = (grid >= low) & (grid <= avg)
    right = (grid > avg) & (grid <= high)
    if avg - low > 1e-9:
        w[left] = (grid[left] - low) / (avg - low)
    else:
        w[left] = 1.0
    if high - avg > 1e-9:
        w[right] = (high - grid[right]) / (high - avg)
    else:
        w[right] = 1.0
    total = w.sum()
    if total > 0:
        return w / total
    # 🔴 兜底:当日振幅窄于网格步长时,可能一个网格点都没落进 [low, high],
    #    权重会全为 0。若就此跳过该日,连它的换手衰减也会一并丢失 ——
    #    低波动标的(银行股等)+ 长窗口下这会累积成很大的偏差。映射到最近网格点。
    w[np.argmin(np.abs(grid - avg))] = 1.0
    return w


def chip_distribution(df: pd.DataFrame, grid_size: int = 300, decay: float = 1.0) -> dict:
    """筹码分布 — df 需含 high/low/close/turn(turn 为百分数,0.31 表示 0.31%)

    decay: 换手衰减系数。1.0=按真实换手率换手;同花顺口径常用 1.5~2.0 加快历史筹码消散。
    """
    # 🔴 必须带 date 并按时间升序:换手衰减是有方向的时序递推,
    #    若传入常见的「最新在前」倒序,衰减会反向推、且 close.iloc[-1] 会把最老的
    #    收盘价当成现价 —— 结果完全错却不会报错。这里强制要求 date 并自行排序。
    need = {"date", "high", "low", "close", "turn"}
    missing = need - set(df.columns)
    if missing:
        raise ValueError(f"chip_distribution 缺少列: {sorted(missing)}(date 用于强制时间升序)")
    d = df.dropna(subset=["high", "low", "close", "turn"]).copy()
    d = d[d["high"] > 0]
    if d.empty:
        raise ValueError("chip_distribution: 有效行数为 0(检查是否全是停牌日,或字段类型不对)")
    d = d.sort_values("date").reset_index(drop=True)

    lo, hi = float(d["low"].min()), float(d["high"].max())
    pad = (hi - lo) * 0.02 or max(lo * 0.02, 0.01)
    grid = np.linspace(lo - pad, hi + pad, grid_size)

    # 🔴 初始筹码必须播种成「首日全部流通盘」,不能从全零开始。
    #    从零起步等于假设窗口之前没有任何持仓,再把窗口内的少量换手归一化成 100%:
    #    两个 1% 换手日(价 10 和 100)会被算成约 50/50,而真实情况是约 99% 仍在 10 附近。
    chips = None
    for row in d.itertuples(index=False):
        t = float(row.turn) / 100.0 * decay
        t = min(max(t, 0.0), 1.0)               # 换手率兜到 [0,1],防异常值把筹码一次清零
        avg = (float(row.high) + float(row.low) + float(row.close)) / 3.0
        w = _triangular_weights(grid, float(row.low), float(row.high), avg)
        if w.sum() <= 0:
            continue
        if chips is None:
            chips = w.copy()                    # 首日分布 = 期初全部流通筹码
            continue
        chips = chips * (1.0 - t) + w * t
    if chips is None:
        raise RuntimeError("chip_distribution: 所有交易日的价格区间都无效,无法构建分布")

    total = chips.sum()
    if total <= 0:
        raise RuntimeError("chip_distribution: 筹码总量为 0,无法计算指标")
    chips = chips / total

    price = float(d["close"].iloc[-1])
    cum = np.cumsum(chips)

    def price_at(q: float) -> float:
        return float(np.interp(q, cum, grid))

    p05, p15, p85, p95 = (price_at(q) for q in (0.05, 0.15, 0.85, 0.95))
    peak_i = int(np.argmax(chips))
    return {
        "price": price,
        "profit_ratio": float(chips[grid <= price].sum()),      # 获利比例
        "avg_cost": float((grid * chips).sum()),                # 平均成本
        "cost_90": (p05, p95),
        "cost_70": (p15, p85),
        "concentration_90": float((p95 - p05) / (p95 + p05)) if p95 + p05 else None,
        "concentration_70": float((p85 - p15) / (p85 + p15)) if p85 + p15 else None,
        "peak_price": float(grid[peak_i]),                      # 筹码峰
        "histogram": [(float(pp), float(cc)) for pp, cc in zip(grid, chips) if cc > 1e-6],
    }


# 用法 — 输入用 §6.5 baostock(一次拿齐 OHLC + 换手率)
import baostock as bs

bs_code = _bs_code("600519")
with bs_session():
    rs = bs.query_history_k_data_plus(
        bs_code, "date,open,high,low,close,turn,tradestatus",
        start_date="2026-02-01", end_date="2026-08-18", frequency="d", adjustflag="2",
    )                                            # 2=前复权,筹码成本必须用复权价
    k = _rs_to_df(rs)
for c in ("open", "high", "low", "close", "turn"):
    k[c] = pd.to_numeric(k[c], errors="coerce")
k = k[k["tradestatus"] == "1"]                   # 停牌日不参与换手衰减

r = chip_distribution(k)
print(f"现价 {r['price']:.2f} | 获利比例 {r['profit_ratio']*100:.2f}% | 平均成本 {r['avg_cost']:.2f}")
print(f"90%成本区间 {r['cost_90'][0]:.2f}~{r['cost_90'][1]:.2f} 集中度 {r['concentration_90']*100:.2f}%")
print(f"筹码峰 {r['peak_price']:.2f}")
# 实测 2026-08-19(131 个交易日,窗口累计换手 46.5%):
#   现价 1297.99 | 获利比例 15.44% | 平均成本 1371.31
#   90%成本区间 1207.89~1425.16 集中度 8.25% | 筹码峰 1398.99
#   ← 窗口累计换手不足 100%,多数筹码仍是期初高位持仓,故均成本高于现价、获利盘偏低

Reading and self-check

Indicator Meaning Nature
profit_ratio current pricebelowthe proportion of holdings = unrealized profit positions Hard constraint: must be in [0,1]
avg_cost weighted average holding cost Hard constraint: must fall between the grid minimum and maximum
cost_90 / cost_70 5%–95% / 15%–85% percentile price range Hard constraint:cost_90 must include cost_70
concentration_* (高-低)/(高+低), the smaller the more concentrated Hard constraint: 90% concentration must be greater than 70%
peak_price The price level with the densest chips (trapped/support zone) Heuristic: usually falls within cost_90 within

⚠️ The following two are heuristics, not invariants; do not use them as assertions to reject results:

  • 「price < avg_cost ⇔ profit_ratio < 50%" undersymmetricdistribution holds, butright-skeweddistribution, the mean is pulled up by the right tail, and the current price may be both below the mean and above the median — in this case it is normal for the two to point in opposite directions.
  • 「peak_price falls within cost_90 " holds the vast majority of the time, but anarrow and high spikeif it happens to lie outside the 5% percentile, the peak will fall outside the range, which is still a valid result.

⚠️ This is an inference, not actual holdings. Brokerage software differs in decay coefficients and distribution models, so the values will not be completely identical; what to look at isthe shape and relative changes(whether the profitable positions are increasing or decreasing, whether the chip peak is above or below), not absolute value alignment. The input must useforward-adjustedprices (adjustflag="2"). Using unadjusted prices across ex-rights dates will compute cost incorrectly.


Layer 5: News Layer

5.1 Eastmoney individual stock news (direct connection to search-api-web)

import requests
import re
import json

def eastmoney_stock_news(code: str, page_size: int = 20) -> list[dict]:
    """
    东财个股新闻(JSONP 接口)。
    返回: [{title, content, time, source, url}]
    """
    # 构造 JSONP 参数
    cb = "jQuery_news"
    url = "https://search-api-web.eastmoney.com/search/jsonp"
    inner_params = json.dumps({
        "uid": "",
        "keyword": code,
        "type": ["cmsArticleWebOld"],
        "client": "web",
        "clientType": "web",
        "clientVersion": "curr",
        "param": {"cmsArticleWebOld": {"searchScope": "default", "sort": "default",
                  "pageIndex": 1, "pageSize": page_size, "preTag": "", "postTag": ""}},
    }, separators=(',', ':'))
    params = {"cb": cb, "param": inner_params}
    headers = {"User-Agent": UA, "Referer": "https://so.eastmoney.com/"}
    r = em_get(url, params=params, headers=headers, timeout=15)

    # 解析 JSONP
    text = r.text
    json_str = text[text.index("(") + 1 : text.rindex(")")]
    d = json.loads(json_str)

    rows = []
    # 东财实际返回里 result.cmsArticleWebOld 直接就是文章列表(非 {list:[...]} 嵌套)
    articles = d.get("result", {}).get("cmsArticleWebOld", []) or []
    for a in articles:
        rows.append({
            "title": re.sub(r'<[^>]+>', '', a.get("title", "")),
            "content": re.sub(r'<[^>]+>', '', a.get("content", ""))[:200],
            "time": a.get("date", ""),
            "source": a.get("mediaName", ""),
            "url": a.get("url", ""),
        })
    return rows

# 用法
news = eastmoney_stock_news("688017")
for n in news[:5]:
    print(f"  {n['time']} | {n['source']} | {n['title']}")

⚠️ Intermittently returns empty (#18): Some mainland residential IPs calling this API will only get passportWeb(stockholder data) and not cmsArticleWebOld(article list) — this is Eastmoney's intermittent risk control on that IP, not a code issue. The code already safely returns on empty results []; when this happens, wait a few minutes or switch networks and retry.

5.2 Cailianshe flash news (direct connection to cls.cn, v1 API + local signature) ✅ Revived (2026-07)

✅ Revived 2026-07: the old API cls.cn/nodeapi/telegraphList went offline in 2026-05 (the site switched to Next.js, the old address returns HTML instead of JSON, #14). Now uses the new version cls.cn/v1/roll/get_roll_list— it mandatorily validates sign, but the signatureis computed purely locally, no key required:sign = md5(sha1(按 key 字典序 拼接的 query 串)). Cailianshe flash news leans toward A-share finance and is highly time-sensitive, serving as an independent backup with §5.3 Eastmoney 7×24 mutually independent backups (two different sources, different risk control surfaces; if one is blocked the other still works). Tested on 2026-07-11, errno=0 returns normally.

import requests
import hashlib
from datetime import datetime

def cls_telegraph(page_size: int = 50) -> list[dict]:
    """
    财联社电报(全市场实时快讯)。v1 API + 本地签名,零 key。
    返回: [{title, content, time}]  time 已转为 'YYYY-MM-DD HH:MM:SS'
    """
    params = {"appName": "CailianpressWeb", "os": "web", "sv": "7.7.5",
              "last_time": "", "refresh_type": "1", "rn": str(page_size)}
    # 签名:md5(sha1(按 key 字典序拼接的 query 串)),纯本地算、无需 key
    qs = "&".join(f"{k}={params[k]}" for k in sorted(params))
    sign = hashlib.md5(hashlib.sha1(qs.encode()).hexdigest().encode()).hexdigest()
    url = f"https://www.cls.cn/v1/roll/get_roll_list?{qs}&sign={sign}"
    headers = {"User-Agent": UA, "Referer": "https://www.cls.cn/"}
    r = requests.get(url, headers=headers, timeout=10)
    d = r.json()

    rows = []
    for item in (d.get("data") or {}).get("roll_data") or []:   # #46 同因
        ts = item.get("ctime")
        t = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
        rows.append({
            "title": item.get("title", "") or item.get("brief", ""),
            "content": item.get("content", "") or item.get("brief", ""),
            "time": t,
        })
    return rows

# 用法
news = cls_telegraph()
for n in news[:10]:
    print(f"  {n['time']} | {n['title'][:60]}")

5.3 Eastmoney global news (7x24)

import requests

import uuid

def eastmoney_global_news(page_size: int = 50) -> list[dict]:
    """
    东方财富全球财经资讯(7x24 滚动)。
    返回: [{title, summary, time}]
    """
    url = "https://np-weblist.eastmoney.com/comm/web/getFastNewsList"
    params = {
        "client": "web", "biz": "web_724",
        "fastColumn": "102", "sortEnd": "",
        "pageSize": str(page_size),
        "req_trace": str(uuid.uuid4()),
    }
    headers = {"User-Agent": UA, "Referer": "https://kuaixun.eastmoney.com/"}
    r = em_get(url, params=params, headers=headers, timeout=10)
    d = r.json()

    rows = []
    for item in (d.get("data") or {}).get("fastNewsList") or []:  # #46 同因
        rows.append({
            "title": item.get("title", ""),
            "summary": item.get("summary", "")[:200],
            "time": item.get("showTime", ""),
        })
    return rows

# 用法
news = eastmoney_global_news()
for n in news[:10]:
    print(f"  {n['time']} | {n['title']}")

Layer 6: Basic Data Layer

6.1 mootdx financial snapshot (37-field quarterly report data)

from mootdx.quotes import Quotes

client = tdx_client()  # 见 Prerequisites 的 tdx_client() helper(规避 0.11.x BESTIP bug;等价 Quotes.factory(market='std'))

# market: 0=深圳, 1=上海
fin = client.finance(symbol='688017')
# 返回 37 个字段的季报快照:
#   liutongguben(流通股本), zongguben(总股本)
#   eps(每股收益), bvps(每股净资产), roe(净资产收益率%)
#   profit(净利润), income(主营收入)
#   meigujingzichan(每股净资产), meigugongjijin(每股公积金)
#   meiguweifeipeili(每股未分配利润)
#   等37个季报财务字段

6.2 mootdx F10 (company text materials)

from mootdx.quotes import Quotes

client = tdx_client()  # 见 Prerequisites 的 tdx_client() helper(规避 0.11.x BESTIP bug;等价 Quotes.factory(market='std'))

# 9 大类文本数据:
categories = [
    "最新提示", "公司概况", "财务分析",
    "股东研究", "股本结构", "资本运作",
    "业内点评", "行业分析", "公司大事",
]
for cat in categories:
    text = client.F10(symbol='688017', name=cat)
    print(f"=== {cat} ===")
    print(text[:200] if text else "(空)")

Optimization hint: The 【4. Shareholder Changes】 section in "Shareholder Research" contains a large amount of historical top ten shareholder lists, measured at 16000+ chars. It is recommended to keep only the latest period (-70% tokens).

6.3 Eastmoney individual stock fundamentals (direct connection to push2 API)

import requests

def eastmoney_stock_info(code: str) -> dict:
    """
    东财个股基本面信息。
    返回: {code, name, industry, total_shares, float_shares, mcap, float_mcap, list_date}
    """
    market_code = em_market_code(code)      # #46
    url = "https://push2.eastmoney.com/api/qt/stock/get"
    params = {
        "fltt": "2", "invt": "2",
        "fields": "f57,f58,f84,f85,f127,f116,f117,f189,f43",
        "secid": f"{market_code}.{code}",
    }
    headers = {"User-Agent": UA}
    r = em_get(url, params=params, headers=headers, timeout=10)
    d = r.json().get("data", {})
    return {
        "code": d.get("f57", ""),
        "name": d.get("f58", ""),
        "industry": d.get("f127", ""),
        "total_shares": d.get("f84", 0),     # 总股本(股)
        "float_shares": d.get("f85", 0),     # 流通股(股)
        "mcap": d.get("f116", 0),            # 总市值(元)
        "float_mcap": d.get("f117", 0),      # 流通市值(元)
        "list_date": str(d.get("f189", "")), # 上市日期 YYYYMMDD
        "price": d.get("f43", 0),
    }

# 用法
info = eastmoney_stock_info("688017")
print(f"{info['name']}({info['code']}): 行业={info['industry']} 总市值={info['mcap']/1e8:.0f}亿 上市={info['list_date']}")

6.4 Sina financial three statements (balance sheet/income statement/cash flow statement)

import requests

def sina_financial_report(code: str, report_type: str = "lrb", num: int = 8) -> list[dict]:
    """
    新浪财报三表。
    code: 6位代码
    report_type: "fzb"(资产负债表) / "lrb"(利润表) / "llb"(现金流量表)
    num: 取最近 N 期(默认 8 期)
    返回: 按报告期倒序的记录列表,每期一条 dict:
          {"报告期": "2026-03-31", "<科目>": "<值>", "<科目>_同比": <同比>, ...}
          (item_value 为新浪原始字符串数值,仅在有同比时附 "_同比" 键)
    """
    prefix = get_prefix(code)               # #46:51x/588x/900x 也是沪市
    paper_code = f"{prefix}{code}"
    url = "https://quotes.sina.cn/cn/api/openapi.php/CompanyFinanceService.getFinanceReport2022"
    params = {
        "paperCode": paper_code,
        "source": report_type,
        "type": "0",
        "page": "1",
        "num": str(num),
    }
    headers = {"User-Agent": UA}
    r = requests.get(url, params=params, headers=headers, timeout=15)
    # 新浪实际结构: result.data.report_list 是「按报告期(如 '20260331')为键」的 dict,
    # 每期对象的 data 字段才是行项列表 [{item_title, item_value, item_tongbi}]。
    # #46 同因:任一层为 null 时 `.get(k, {})` 返回 None,链式 .get 会 AttributeError
    _j = r.json() or {}
    report_list = ((_j.get("result") or {}).get("data") or {}).get("report_list") or {}

    rows = []
    for period in sorted(report_list.keys(), reverse=True)[:num]:
        obj = report_list[period]
        rec = {"报告期": f"{period[:4]}-{period[4:6]}-{period[6:8]}"}
        for it in obj.get("data", []) or []:
            title = it.get("item_title", "")
            if not title or it.get("item_value") is None:
                continue
            rec[title] = it.get("item_value")
            tongbi = it.get("item_tongbi")
            if tongbi not in (None, ""):
                rec[title + "_同比"] = tongbi
        rows.append(rec)
    return rows

# 用法: 利润表
lrb = sina_financial_report("600519", "lrb")
for item in lrb[:3]:
    print(f"报告期: {item.get('报告期', '')} 净利润: {item.get('净利润', '')}")

# 用法: 资产负债表
fzb = sina_financial_report("600519", "fzb")

# 用法: 现金流量表
llb = sina_financial_report("600519", "llb")

6.5 baostock Valuation History — PE/PB/PS/PCF + Turnover Rate + Suspension + ST (New in V3.7.0)

Core value: §1.2 Tencent only providescurrent-dayvaluation snapshot; this endpoint providesdaily-frequency historical series(traceable back to 2016), and a single call retrieves four fields we previously did not have at all:turnover rate(an essential input for chip distribution), suspension status、ST flag、historical valuation。

🔴 Beijing Stock Exchange not supported: the baostock server directly rejects the 4/8/92/920 code segments, reporting 10004011 股票代码未标识sh或sz(tested on 2026-08-19). This implementation, inbefore loginblocks it and throws ValueError, without wasting a session, and will not silently return an empty table.

from contextlib import contextmanager

import baostock as bs
import pandas as pd


@contextmanager
def bs_session():
    """baostock 登录会话 — 必须用上下文管理器,异常路径也保证 logout"""
    lg = bs.login()
    if lg.error_code != "0":
        raise RuntimeError(f"baostock 登录失败: {lg.error_code} {lg.error_msg}")
    try:
        yield
    finally:
        bs.logout()


def _rs_to_df(rs) -> pd.DataFrame:
    """baostock ResultData → DataFrame;错误码转异常,绝不静默返回空表"""
    if rs.error_code != "0":
        raise RuntimeError(f"baostock 查询失败: {rs.error_code} {rs.error_msg}")
    rows = []
    while rs.next():
        rows.append(rs.get_row_data())
    return pd.DataFrame(rows, columns=rs.fields)


def _bs_code(code: str) -> str:
    """6位代码 → baostock 格式;北交所在登录前就拦掉"""
    code = str(code).zfill(6)
    if code[:2] in ("60", "68", "90"):
        return f"sh.{code}"
    if code[:2] in ("00", "30", "20"):
        return f"sz.{code}"
    raise ValueError(
        f"baostock 不支持该代码: {code}(北交所 4/8/92/920 号段会被服务端拒绝,"
        f"报 10004011 股票代码未标识sh或sz)。北交所估值请改用 §1.2 腾讯当日快照。"
    )


def baostock_valuation_history(code: str, start_date: str, end_date: str) -> pd.DataFrame:
    """估值历史序列 — PE/PB/PS/PCF + 换手率 + 停牌 + ST,日频"""
    bs_code = _bs_code(code)          # 先校验,失败就不必登录
    fields = "date,code,close,peTTM,pbMRQ,psTTM,pcfNcfTTM,turn,tradestatus,isST"
    with bs_session():
        rs = bs.query_history_k_data_plus(
            bs_code, fields, start_date=start_date, end_date=end_date,
            frequency="d", adjustflag="3",     # 3=不复权,与 §1.1 通达信口径一致
        )
        df = _rs_to_df(rs)
    for c in ("close", "peTTM", "pbMRQ", "psTTM", "pcfNcfTTM", "turn"):
        df[c] = pd.to_numeric(df[c], errors="coerce")
    return df


# 用法
df = baostock_valuation_history("600519", "2016-01-04", "2026-08-18")
print(len(df), "行", df.iloc[0]["date"], "→", df.iloc[-1]["date"])
print(df.tail(2)[["date", "close", "peTTM", "pbMRQ", "psTTM", "turn", "isST"]].to_string(index=False))
# 实测 2026-08-19:2581 行;2026-08-18 peTTM=19.93 pbMRQ=6.46 psTTM=9.37 turn=0.3098

# ST 标记实测有效:000004 在 2024-01 至今的 610 个交易日里有 276 天 isST=1
st = baostock_valuation_history("000004", "2024-01-01", "2026-08-18")
print("isST 分布:", st["isST"].value_counts().to_dict())     # {'0': 334, '1': 276}

# 停牌:tradestatus == "0"
print("停牌天数:", (df["tradestatus"] == "0").sum())

Field description

Field Meaning Remarks
peTTM pbMRQ psTTM pcfNcfTTM PE TTM / PB MRQ / PS TTM / PCF TTM negative values indicate a loss; do not sort directly
turn turnover rate (percentage,0.31 = 0.31%) §4.6 essential input for chip distribution
tradestatus 1= normal trading 0= suspended suspended days should be filtered out before calculating indicators
isST 1=ST/*ST 0= normal marked day by day historically, allowing reconstruction of "whether it was ST at the time"

6.6 baostock Basic Security Information — Listing Date / Delisting Date / Status (New in V3.7.0)

Core value: the only zero-authentication source that can obtaindelisting date. Combined with the is_stale zombie quote flag from §1.2, delisted securities can be directly excluded during backtesting/screening.

def baostock_stock_basic(code: str) -> dict:
    """标的基本信息 — ipoDate(上市日) / outDate(退市日,在市为空) / status(1=上市 0=退市)"""
    bs_code = _bs_code(code)
    with bs_session():
        df = _rs_to_df(bs.query_stock_basic(code=bs_code))
    return df.iloc[0].to_dict() if not df.empty else {}


# 用法
print(baostock_stock_basic("600519"))
# 实测:{'code': 'sh.600519', 'code_name': '贵州茅台', 'ipoDate': '2001-08-27',
#        'outDate': '', 'type': '1', 'status': '1'}   ← outDate 为空 = 仍在市

🔴 Ex-rights and ex-dividend adjustments for delisted securities are missing from all three sources(cross-validated in 2026-08): Tongdaxin xdxr() even when passed correctly market=2 also returns 0 records, Eastmoney historical snapshots likewise have none, and baostock directly rejects Beijing Stock Exchange codes. Delisted securities (especially Beijing Stock Exchange ones)will inevitably fail to match when performing adjustment, which is not a defect of this toolkit but a retention policy on the source side.


6.7 SW Industry Classification History — Eliminating Industry Look-Ahead Bias (New in V3.7.0)

Core value: §3.7 industry_comparison() uses Eastmoney,only current affiliation. When doing historical research, applying today's industry classification to the past is a typical case oflook-ahead bias. This endpoint provides each stock'sindustry change history。

⚠️ SW officially publishes onlycodesnot Chinese names (the name table is a separate, unpublished document). Eastmoney/Tongdaxin industry namescannotbe applied directly—the classification systems differ and the codes are not interchangeable.

import io

from typing import Optional

import pandas as pd
import requests

SW_URL = "https://www.swsresearch.com/swindex/pdf/SwClass2021/StockClassifyUse_stock.xls"


def sw_industry_history() -> pd.DataFrame:
    """申万行业归属变迁史 — 每只股票每次行业调整一行"""
    try:
        r = requests.get(SW_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=60)
        r.raise_for_status()
    except requests.exceptions.SSLError as e:
        # 2026-08-19 实测:纯 certifi 环境握手正常,证书链完整,无需手动补中间证书。
        # 保留此分支是为了在站点证书回归时给出可操作的提示,而不是吞掉异常。
        raise RuntimeError(
            "申万站点 SSL 握手失败。2026-08 实测其证书链正常,若你遇到此错误,"
            "多半是本机 CA 包过旧或中间人代理:先试 `pip install -U certifi`。"
            f"原始错误: {e}"
        ) from e
    df = pd.read_excel(io.BytesIO(r.content))
    df = df.rename(columns={"股票代码": "code", "计入日期": "start_date",
                            "行业代码": "industry_code", "更新日期": "update_date"})
    missing = {"code", "start_date", "industry_code"} - set(df.columns)
    if missing:
        raise RuntimeError(f"申万表结构变了,缺列 {sorted(missing)};实际列={list(df.columns)}")
    df["code"] = df["code"].astype(str).str.zfill(6)
    df["industry_code"] = df["industry_code"].astype(str).str.zfill(6)
    # 层级码要补成规范的 6 位(申万官方一级是 480000、二级是 480300),
    # 直接截断成 "48"/"4803" 无法与官方指数/名称表 join。
    df["l1_code"] = df["industry_code"].str[:2] + "0000"    # 一级,如 480000
    df["l2_code"] = df["industry_code"].str[:4] + "00"      # 二级,如 480300
    df["start_date"] = pd.to_datetime(df["start_date"], errors="coerce")
    return df.sort_values(["code", "start_date"]).reset_index(drop=True)


def sw_industry_as_of(df: pd.DataFrame, code: str, as_of: str) -> Optional[dict]:
    """某只股票在 as_of 日所属的申万行业(取不晚于该日的最后一次调整)"""
    code = str(code).zfill(6)
    sub = df[(df["code"] == code) & (df["start_date"] <= pd.Timestamp(as_of))]
    if sub.empty:
        return None                  # 该日尚未上市 / 无归属记录
    row = sub.iloc[-1]
    return {"code": code, "as_of": as_of,
            "industry_code": row["industry_code"],
            "l1_code": row["l1_code"], "l2_code": row["l2_code"],
            "since": row["start_date"].strftime("%Y-%m-%d")}


# 用法
sw = sw_industry_history()
print(len(sw), "行 |", sw["code"].nunique(), "只标的 |",
      sw["l1_code"].nunique(), "个一级行业")
# 实测 2026-08-19:12893 行 | 5905 只 | 38 个一级 / 194 个二级 / 553 个三级

# 前视偏差验证:平安银行在不同时点属于不同行业
for d in ("2013-01-01", "2016-01-01", "2026-08-18"):
    print(d, sw_industry_as_of(sw, "000001", d))
# 2013-01-01 → 440101(一级 440000,自 1991-04-03)
# 2016-01-01 → 480101(一级 480000,自 2014-02-21)
# 2026-08-18 → 480301(一级 480000 / 二级 480300,自 2021-07-30)

Typical usage: when backtesting industry rotation, call sw_industry_as_of() on each rebalancing day to getthe affiliation at that time, rather than using a single current classification table throughout. In testing, some securities changed 10 timesin industry historically.


Layer 7: Announcement Layer

7.1 Cninfo Announcements (direct connection to cninfo.com.cn)

import requests
from datetime import datetime

def _cninfo_ts_to_date(ts):
    """巨潮 announcementTime 返回 Unix 毫秒整数,需转换为日期字符串。"""
    if isinstance(ts, (int, float)):
        return datetime.fromtimestamp(ts / 1000).strftime("%Y-%m-%d")
    return str(ts)[:10] if ts else ""

# 巨潮 股票→orgId 映射(模块级缓存,首次调用时拉取一次,全程复用)
_CNINFO_ORGID_MAP = {}

def _cninfo_orgid(code: str) -> str:
    """查股票真实 orgId。巨潮 orgId 并非统一 `gssx0{code}` 格式(如 601318→9900002221、
    601398→jjxt0000019、688017→9900041602),硬编码会导致大量股票(尤其 601xxx 段)
    返回 totalAnnouncement=0、查不到公告(#19)。优先动态查官方映射表,查不到再回退硬编码。"""
    global _CNINFO_ORGID_MAP
    if not _CNINFO_ORGID_MAP:
        try:
            r = requests.get("http://www.cninfo.com.cn/new/data/szse_stock.json",
                             headers={"User-Agent": UA}, timeout=15)
            _CNINFO_ORGID_MAP = {s["code"]: s["orgId"]
                                 for s in r.json().get("stockList", [])}
        except Exception as e:
            print(f"[WARN] 巨潮 orgId 映射表拉取失败,回退硬编码规则: {e}")
    org = _CNINFO_ORGID_MAP.get(code)
    if org:
        return org
    # fallback:老格式(仅部分老股票如 600519/600036 适用)
    # #46:改用 get_prefix(),原先只认 6/8/4,会把 51x 沪 ETF、900x 沪 B、920x 北交所判错
    return f"gs{get_prefix(code)}0{code}"

def cninfo_announcements(code: str, page_size: int = 30) -> list[dict]:
    """
    巨潮公告全文检索。
    返回: [{title, type, date, url}]
    """
    url = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
    org_id = _cninfo_orgid(code)   # 动态查真实 orgId(#19 修复,自带硬编码 fallback)

    payload = {
        "stock": f"{code},{org_id}",
        "tabName": "fulltext",
        "pageSize": str(page_size),
        "pageNum": "1",
        "column": "",
        "category": "",
        "plate": "",
        "seDate": "",
        "searchkey": "",
        "secid": "",
        "sortName": "",
        "sortType": "",
        "isHLtitle": "true",
    }
    headers = {
        "User-Agent": UA,
        "Content-Type": "application/x-www-form-urlencoded",
        "Referer": "https://www.cninfo.com.cn/new/disclosure",
        "Origin": "https://www.cninfo.com.cn",
    }
    r = requests.post(url, data=payload, headers=headers, timeout=15)
    d = r.json()

    rows = []
    for item in d.get("announcements", []) or []:
        rows.append({
            "title": item.get("announcementTitle", ""),
            "type": item.get("announcementTypeName", ""),
            "date": _cninfo_ts_to_date(item.get("announcementTime")),
            "url": f"https://www.cninfo.com.cn/new/disclosure/detail?annoId={item.get('announcementId', '')}",
        })
    return rows

# 用法
anns = cninfo_announcements("688017")
for a in anns[:10]:
    print(f"  {a['date']} | {a['type']} | {a['title']}")

7.2 mootdx F10 Announcement Summary

from mootdx.quotes import Quotes
client = tdx_client()  # 见 Prerequisites 的 tdx_client() helper(规避 0.11.x BESTIP bug;等价 Quotes.factory(market='std'))
text = client.F10(symbol='688017', name='最新提示')
# 包含最近的公告/分红/股东大会决议等摘要

Layer 8: Limit-up Trading Layer (limit-up / failed limit-up / limit-down / thematic sentiment, new in V3.3.0)

Consecutive limit-up tiers, failed limit-up rate, promotion rate, limit-up reason themes—high-frequency needs for limit-up trading and theme tracking (#23 / #15). Eastmoney's four pools use push2ex.eastmoney.com(same source as the existing push2, already included in the em_get() rate limit); limit-up reason theme enhancement uses Tonghuashun.All require no login and zero authentication.

8.1 Eastmoney Limit-Up Pool — limit-up / failed limit-up / limit-down / yesterday's limit-up

import requests

ZTB_UT = "7eea3edcaed734bea9cbfc24409ed989"

def _fmt_zt_time(t) -> str:
    """涨停板时间整数 → HH:MM:SS(92500 → 09:25:00)。"""
    s = str(t).zfill(6)
    return f"{s[0:2]}:{s[2:4]}:{s[4:6]}"

def _em_zt_api(endpoint: str, sort: str, date: str) -> list[dict]:
    """东财涨停板行情中心通用请求(push2ex,走 em_get 限流)。
    endpoint: getTopicZTPool / getTopicZBPool / getTopicDTPool / getYesterdayZTPool
    返回 data.pool 原始列表(data 为 null = 非交易日 / 参数错)。"""
    url = f"https://push2ex.eastmoney.com/{endpoint}"
    params = {"ut": ZTB_UT, "dpt": "wz.ztzt", "Pageindex": 0,
              "pagesize": 10000, "sort": sort, "date": date}
    headers = {"User-Agent": UA, "Referer": "https://quote.eastmoney.com/"}
    try:
        r = em_get(url, params=params, headers=headers, timeout=10)
        return (r.json().get("data") or {}).get("pool") or []
    except Exception as e:
        print(f"[WARN] 涨停板池 {endpoint} 请求失败: {e}")
        return []

def em_zt_pool(date: str) -> list[dict]:
    """涨停池。date=YYYYMMDD(交易日)。
    返回每只: code/name/price/pct/amount/float_cap/turnover/limit_days(连板数)/
    first_seal/last_seal(封板时间)/seal_fund(封板资金,元)/break_times(炸板次数)/
    industry/zt_stat(N天M板)"""
    out = []
    for p in _em_zt_api("getTopicZTPool", "fbt:asc", date):
        out.append({"code": p["c"], "name": p["n"], "price": p["p"] / 1000,
            "pct": round(p["zdp"], 2), "amount": p["amount"], "float_cap": p["ltsz"],
            "turnover": round(p["hs"], 2), "limit_days": p["lbc"],
            "first_seal": _fmt_zt_time(p["fbt"]), "last_seal": _fmt_zt_time(p["lbt"]),
            "seal_fund": p["fund"], "break_times": p["zbc"], "industry": p.get("hybk", ""),
            "zt_stat": f'{(p.get("zttj") or {}).get("days","?")}天{(p.get("zttj") or {}).get("ct","?")}板'})
    return out

def em_zb_pool(date: str) -> list[dict]:
    """炸板池(涨停后开板)。返回 code/name/price/limit_price(涨停价)/pct/turnover/
    first_seal/break_times/amplitude(振幅)/speed(涨速)/industry/zt_stat"""
    out = []
    for p in _em_zt_api("getTopicZBPool", "fbt:asc", date):
        out.append({"code": p["c"], "name": p["n"], "price": p["p"] / 1000,
            "limit_price": p["ztp"] / 1000, "pct": round(p["zdp"], 2),
            "turnover": round(p["hs"], 2), "first_seal": _fmt_zt_time(p["fbt"]),
            "break_times": p["zbc"], "amplitude": round(p["zf"], 2),
            "speed": round(p["zs"], 2), "industry": p.get("hybk", ""),
            "zt_stat": f'{(p.get("zttj") or {}).get("days","?")}天{(p.get("zttj") or {}).get("ct","?")}板'})
    return out

def em_dt_pool(date: str) -> list[dict]:
    """跌停池。返回 code/name/price/pct/turnover/pe/seal_fund(封单资金)/last_seal/
    board_amount(板上成交额)/dt_days(连续跌停)/open_times(开板次数)/industry"""
    out = []
    for p in _em_zt_api("getTopicDTPool", "fund:asc", date):
        out.append({"code": p["c"], "name": p["n"], "price": p["p"] / 1000,
            "pct": round(p["zdp"], 2), "turnover": round(p["hs"], 2), "pe": p.get("pe"),
            "seal_fund": p["fund"], "last_seal": _fmt_zt_time(p["lbt"]),
            "board_amount": p.get("fba"), "dt_days": p.get("days"),
            "open_times": p.get("oc"), "industry": p.get("hybk", "")})
    return out

def em_yzt_pool(date: str) -> list[dict]:
    """昨日涨停池(昨涨停今表现,算晋级率/赚钱效应)。返回 code/name/price/
    pct(今日涨幅)/turnover/amplitude/speed/y_first_seal(昨封板时间)/
    y_limit_days(昨连板)/industry/zt_stat"""
    out = []
    for p in _em_zt_api("getYesterdayZTPool", "zs:desc", date):
        out.append({"code": p["c"], "name": p["n"], "price": p["p"] / 1000,
            "pct": round(p["zdp"], 2), "turnover": round(p["hs"], 2),
            "amplitude": round(p["zf"], 2), "speed": round(p["zs"], 2),
            "y_first_seal": _fmt_zt_time(p["yfbt"]), "y_limit_days": p["ylbc"],
            "industry": p.get("hybk", ""), "zt_stat": f'{(p.get("zttj") or {}).get("days","?")}天{(p.get("zttj") or {}).get("ct","?")}板'})
    return out

# 用法
zt = em_zt_pool("20260626")
print(f"今日涨停 {len(zt)} 只")
for s in zt[:3]:
    print(f"  {s['name']} {s['zt_stat']} 封板{s['seal_fund']/1e8:.2f}亿 {s['industry']}")

Pitfalls: ① Price fields price/limit_price are already ÷1000 (raw values are ×1000 integers). ② The four pools differ only in sort (limit-up/failed limit-up=fbt:asc, limit-down=fund:asc, yesterday's limit-up=zs:desc),dpt all are wz.ztzt。③ date must be passed a trading day; on non-trading days data returns null. ④ Amount units are allyuan。

8.2 Tonghuashun Limit-Up Revelation — limit-up reason themes + limit-up success rate + limit-up type

from datetime import datetime

def ths_limit_up_pool(date: str) -> list[dict]:
    """同花顺涨停揭秘(涨停原因 + 封板质量增强源)。date=YYYYMMDD。
    返回每只: code/name/price/pct/reason(涨停原因题材)/board_type(换手板/一字板/T字板)/
    seal_rate(封板成功率,0~1)/break_times(炸板次数)/seal_amount(封单额,元)/
    high_days(几天几板)/first_time(首次涨停时间)/is_again(是否回封 0/1)"""
    url = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
    params = {"page": 1, "limit": 200,
              "field": "199112,10,9001,330323,330324,330325,9002,330329,133971,133970,1968584,3475914,9003,9004",
              "filter": "HS,GEM2STAR", "order_field": "330324", "order_type": "0", "date": date}
    try:
        r = requests.get(url, params=params, headers={"User-Agent": UA}, timeout=10)
        info = (r.json().get("data") or {}).get("info", [])
    except Exception as e:
        print(f"[WARN] 同花顺涨停揭秘请求失败: {e}")
        return []
    out = []
    for it in info:
        ft = it.get("first_limit_up_time")
        out.append({"code": it.get("code"), "name": it.get("name"),
            "price": it.get("latest"), "pct": it.get("change_rate"),
            "reason": it.get("reason_type", ""), "board_type": it.get("limit_up_type", ""),
            "seal_rate": it.get("limit_up_suc_rate"), "break_times": it.get("open_num") or 0,
            "seal_amount": it.get("order_amount"), "high_days": it.get("high_days", ""),
            "first_time": datetime.fromtimestamp(int(ft)).strftime("%H:%M:%S") if ft else "",
            "is_again": it.get("is_again_limit")})
    return out

# 用法: 涨停原因题材归因
for s in ths_limit_up_pool("20260626")[:5]:
    print(f"  {s['name']} {s['high_days']} | {s['reason']} | 封板率{s['seal_rate']}")

Pitfalls: first_limit_up_time is a Unix seconds timestamp(requires datetime.fromtimestamp), not HHMMSS.field That string is a Tonghuashun internal field ID; just copy it as is.filter=HS,GEM2STAR controls the board scope (Shanghai/Shenzhen main board + ChiNext + STAR Market).

8.3 Limit-Up Trading Sentiment Quick Calculation — failed limit-up rate / consecutive limit-up height / consecutive limit-up tiers

def limit_up_sentiment(date: str) -> dict:
    """打板情绪温度计:连板梯队 + 炸板率 + 涨跌停对比。"""
    zt, zb, dt = em_zt_pool(date), em_zb_pool(date), em_dt_pool(date)
    ladder = {}
    for s in zt:
        ladder[s["limit_days"]] = ladder.get(s["limit_days"], 0) + 1
    zt_n, zb_n = len(zt), len(zb)
    return {"date": date, "zt_count": zt_n, "zb_count": zb_n, "dt_count": len(dt),
        "break_rate": round(zb_n / (zt_n + zb_n) * 100, 1) if (zt_n + zb_n) else 0,  # 炸板率%
        "max_height": max((s["limit_days"] for s in zt), default=0),                 # 最高连板
        "ladder": dict(sorted(ladder.items()))}                                       # 连板梯队 {板数:家数}

# 用法
s = limit_up_sentiment("20260626")
print(f"涨停{s['zt_count']} 炸板{s['zb_count']}(炸板率{s['break_rate']}%) "
      f"跌停{s['dt_count']} 最高{s['max_height']}连板")
print(f"连板梯队: {s['ladder']}")

Promotion rate (yesterday's limit-ups that are still limit-up today / total of yesterday's limit-ups) can be calculated manually using em_yzt_pool() 's pct >= 9.8 count divided by the total.

8.4 Eastmoney Key Monitoring Pool (new in V3.6.0 · #15)

Eastmoney App "Key Monitoring" list: securities under exchange risk warnings / key monitoring and theireffective time window. Zero-authentication static JSON, full return without pagination.

from datetime import datetime, timedelta, timezone   # 不要 import datetime 模块:§8.2 的 `from datetime import datetime` 会遮蔽它

# A 股的"今天"按北京时间算。用本机 date.today() 在海外时区会错开一天
# (如新西兰比北京早 4~5 小时,北京傍晚时本机已跨到次日),
# 监控窗口首日/末日会因此提前纳入或提前剔除。
CN_TZ = timezone(timedelta(hours=8))

def cn_today() -> str:
    """北京时间的今天(YYYY-MM-DD)。"""
    return datetime.now(CN_TZ).date().isoformat()

MONITOR_URL = "https://mobappconfig.securities.eastmoney.com/emcfg/stock_monitor.json"

# ⚠️ MARKET 是三值且**含字母 "B"**(北交所),不是 0/1 二值。
# 写成 `"SH" if MARKET=="1" else "SZ"` 会把北交所标的整片错标成 SZ——
# 实测 2026-07-31 全量 16 只里就有 3 只 MARKET="B"(*ST康乐 920575 等)。
_MONITOR_MARKET = {"1": "SH", "0": "SZ", "B": "BJ"}

def em_stock_monitor(only_active: bool = True) -> list[dict]:
    """东财重点监控池。
    only_active=True 只留今天仍在监控窗口内的(按 VALIDATESTARTDATE~VALIDATEENDDATE 过滤)。
    返回: [{code, name, market, start, end, link}]
    """
    r = em_get(MONITOR_URL, headers={"Referer": "https://vipmoney.eastmoney.com/"}, timeout=20)
    rows = r.json() or []
    today = cn_today()
    out = []
    for x in rows:
        start, end = x.get("VALIDATESTARTDATE", ""), x.get("VALIDATEENDDATE", "")
        if only_active and not (start <= today <= end):
            continue
        raw_mkt = str(x.get("MARKET", "")).upper()
        out.append({
            "code":   x.get("STKCODE", ""),
            "name":   x.get("STKNAME", ""),
            # 未知取值不猜市场,原样带出(`?<原值>`),避免静默标错
            "market": _MONITOR_MARKET.get(raw_mkt, f"?{raw_mkt}"),
            "start":  start, "end": end,
            "link":   x.get("LINK_URL", ""),
        })
    return out

# 用法
pool = em_stock_monitor()
print(f"当前重点监控 {len(pool)} 只")
for s in pool[:5]:
    print(f"  {s['code']} {s['name']}({s['market']}) 监控期 {s['start']}~{s['end']}")
Field Meaning
STKCODE / STKNAME Code / Name
MARKET "1"=Shanghai market,"0"=Shenzhen market,"B"=Beijing Stock Exchange(three values and containing letters; do not treat as 0/1 binary)
VALIDATESTARTDATE / VALIDATEENDDATE Monitoring effective start / end date (usually a 14-day window)
LINK_URL Detail page within the Eastmoney App (may be empty)

8.5 Eastmoney Intraday Anomaly Pool — severe abnormal fluctuations (new in V3.6.0 · #15)

Anomaly securities under the exchange's "severe abnormal fluctuation" criteria: consecutive N-day same-direction anomalies, cumulative deviation triggering thresholds, etc. The two endpoints share the same source,zero authentication, but must carry team=h5 and other fixed parameters, otherwise returns {"result":1001,"msg":"unknow team"}。

ANOMALY_BASE = "https://dycalchis.eastmoney.com/price-anomaly"
# 东财 H5 固定公共参数,缺 team 会被拒(unknow team)
HQ_PARAMS = {"team": "h5", "product": "EastMoney", "client": "WAP",
             "version": "9001", "name": "WAP", "user": "123"}

# 异动规则码(e 字段)→ 文字说明;s==6 且 e∈{4,5,6,7} 时按 e*10 取更严阈值那档
ANOMALY_RULES = {
    1:  "主板连续10个交易日内4次出现同向异常波动",
    2:  "创业板连续10个交易日内3次出现同向异常波动",
    3:  "科创板连续10个交易日内3次出现同向异常波动",
    4:  "连续十个交易日内日收盘价涨跌幅偏离值累计达到+100%",
    5:  "连续十个交易日内日收盘价涨跌幅偏离值累计达到-50%",
    6:  "连续三十个交易日内日收盘价涨跌幅偏离值累计达到+200%",
    7:  "连续三十个交易日内日收盘价涨跌幅偏离值累计达到-70%",
    8:  "北交所连续10个交易日内3次出现同向异常波动",
    40: "连续十个交易日内日收盘价涨跌幅偏离值累计达到+150%",
    50: "连续十个交易日内日收盘价涨跌幅偏离值累计达到-60%",
    60: "连续30个交易日内日收盘价涨跌幅偏离值累计达到+300%",
    70: "连续30个交易日内日收盘价涨跌幅偏离值累计达到-75%",
}

def _anomaly_market(code, m, board=None) -> str:
    """异动记录 → 交易所。
    ⚠️ 不能只看 m:东财体系里**北交所与深市同为 m=0**(拉北交所清单用的就是 `m:0+t:81`),
       只按 `m==1 else "SZ"` 会把北交所标的错标成 SZ——而异动规则码 8 正是北交所专用,
       说明北交所记录确实会出现在本接口。代码号段无歧义,优先用它判。
    """
    c = str(code or "")
    if c.startswith("920") or c[:2] in ("43", "83", "87") or board == 8:
        return "BJ"
    return "SH" if m == 1 else "SZ"

def _anomaly_get(path: str, page_size: int, page_no: int, **extra) -> dict:
    params = {**HQ_PARAMS, "pageSize": str(page_size), "pageNo": str(page_no), **extra}
    r = em_get(f"{ANOMALY_BASE}/{path}", params=params,
               headers={"Referer": "https://vipmoney.eastmoney.com/"}, timeout=20)
    d = r.json()
    if d.get("result") != 0:
        # 正向识别:接口用 result!=0 表达拒绝,不能当成「今天没异动」静默吞掉
        raise RuntimeError(f"东财异动接口拒绝: result={d.get('result')} msg={d.get('msg')!r}")
    return d

def em_price_anomaly(page_size: int = 200, page_no: int = 1) -> dict:
    """日内异动明细(price-anomaly/list)。返回 {date, items:[...]}"""
    d = _anomaly_get("list", page_size, page_no)
    items = []
    for x in d.get("data") or []:
        e = x.get("e")
        key = e * 10 if (x.get("s") == 6 and e in (4, 5, 6, 7)) else e
        items.append({
            "code": x.get("c"), "name": x.get("n"),
            "market": _anomaly_market(x.get("c"), x.get("m"), x.get("s")),
            "change_pct": x.get("a"),          # 当日涨跌幅%
            "deviation": x.get("x"),           # 累计偏离值%
            "days": x.get("d"),                # 统计窗口天数
            "board": x.get("s"),               # 板块码:1=主板 4=创业板 6=科创板(阈值加严) 8=北交所
            "rule_code": key,
            "rule": ANOMALY_RULES.get(key, f"未知规则码 {key}"),
            "is_today": x.get("o") != 2,
        })
    return {"date": str(d.get("date", "")), "pages": d.get("pages", 0), "items": items}

def em_price_anomaly_count(page_size: int = 50, page_no: int = 1,
                           sort_key: str = "", sort_dir: str = "") -> dict:
    """异动统计(price-anomaly/count):按标的聚合的异动次数 + 现价。"""
    d = _anomaly_get("count", page_size, page_no, sortKey=sort_key, sortDir=sort_dir)
    items = [{
        "code": x.get("c"), "name": x.get("n"),
        "market": _anomaly_market(x.get("c"), x.get("m"), x.get("s")),
        "price": x.get("p"),                 # 最新价(已核对腾讯行情,3/3 一致)
        "change_pct": x.get("a"),            # 涨跌幅%(已核对腾讯行情,3/3 一致)
        "times": x.get("t"),                 # 窗口内异动次数
        "deviation": x.get("x"),             # 累计偏离值%
        "days": x.get("d"),                  # 统计窗口天数
        "board": x.get("s"),
    } for x in d.get("data") or []]
    return {"date": str(d.get("date", "")), "pages": d.get("pages", 0), "items": items}

# 用法
a = em_price_anomaly(page_size=200)
print(f"{a['date']} 日内异动 {len(a['items'])} 条")
for s in a["items"][:5]:
    print(f"  {s['code']} {s['name']} {s['change_pct']}% 偏离{s['deviation']}%/{s['days']}日 | {s['rule']}")

c = em_price_anomaly_count(page_size=50)
for s in c["items"][:5]:
    print(f"  {s['code']} {s['name']} {s['price']}元 {s['change_pct']}% 异动{s['times']}次")

# 与重点监控池交叉:异动 且 已在监控名单 = 最高风险
monitor_codes = {x["code"] for x in em_stock_monitor()}
hot = [s for s in a["items"] if s["code"] in monitor_codes]
print(f"异动且在监控池: {[(s['code'], s['name']) for s in hot]}")

Field source notes: p/a(latest price, change percentage) has been checked item by item against Tencent quotes (3/3 fully consistent);m/c/n/e/x/d/o 's semantics are taken from the Eastmoney frontend formatNewData field mapping (x→DEVUATION_VALUE、d→MAX_DAYS、a→CHANGE_RATE、o→IS_HAPPEN)。

The same letter has different meanings across the two endpoints: list 's t is the change percentage target value (float),count 's t is the anomaly count (integer)—do not reuse parsing logic across endpoints.

open fieldis the order book open/close flag;date is the trading day (YYYYMMDD). Returning the previous trading day's data during non-trading hours is normal.


Layer 9: ETF Options Layer (T-shaped quotes + Greeks + IV, new in V3.3.0)

50ETF / 300ETF / STAR 50ETF / 500ETF options (#13). Uses the Sina source——T-shaped quote board, Greeks, and implied volatility are all pre-calculated by the exchange/Sina, no need to calculate BSM locally. Free direct connection, the only thing to note is the Referer。

9.1 Contract list + T-shaped quote board + Greeks

import requests

SINA_OPT_HDR = {"Referer": "https://stock.finance.sina.com.cn/", "User-Agent": UA}

def _opt_f(x):
    try: return float(x)
    except Exception: return x

def _sina_opt_list(param: str) -> list:
    """新浪 hq.sinajs.cn 取值(GBK,逗号分隔,去 var hq_str_XXX="..." 壳)。"""
    r = requests.get(f"https://hq.sinajs.cn/list={param}", headers=SINA_OPT_HDR, timeout=10)
    r.encoding = "gbk"
    t = r.text
    return t.split('"')[1].split(",") if '"' in t else []

def sina_option_codes(underlying: str = "510050", call: bool = True) -> dict:
    """ETF期权合约清单。underlying: 510050/510300/588000/510500。call=True认购/False认沽。
    返回 {月份YYMM: [合约代码,...]},第一个 key 即近月。"""
    cate = {"510050": "50ETF", "510300": "300ETF",
            "588000": "科创50ETF", "510500": "500ETF"}.get(underlying, "50ETF")
    url = ("https://stock.finance.sina.com.cn/futures/api/openapi.php/"
           f"StockOptionService.getStockName?exchange=null&cate={cate}")
    try:
        months = requests.get(url, headers=SINA_OPT_HDR, timeout=10).json()["result"]["data"]["contractMonth"]
    except Exception as e:
        print(f"[WARN] 期权月份获取失败: {e}")
        return {}
    months = [m.replace("-", "")[2:] for m in months[1:]]  # 丢首个,转 YYMM
    flag = "OP_UP_" if call else "OP_DOWN_"
    out = {}
    for m in months:
        codes = [c.replace("CON_OP_", "") for c in _sina_opt_list(f"{flag}{underlying}{m}")
                 if c.startswith("CON_OP_")]
        if codes:
            out[m] = codes
    return out

def sina_option_tquote(code: str) -> dict:
    """期权T型报价。返回 bid_vol/bid/last/ask/ask_vol/open_interest(持仓量)/pct/
    strike(行权价)/prev_close/open/limit_up/limit_down/name/amplitude/high/low/volume/amount。"""
    v = _sina_opt_list(f"CON_OP_{code}")
    if len(v) < 43:
        return {}
    return {"bid_vol": _opt_f(v[0]), "bid": _opt_f(v[1]), "last": _opt_f(v[2]),
        "ask": _opt_f(v[3]), "ask_vol": _opt_f(v[4]), "open_interest": _opt_f(v[5]),
        "pct": _opt_f(v[6]), "strike": _opt_f(v[7]), "prev_close": _opt_f(v[8]),
        "open": _opt_f(v[9]), "limit_up": _opt_f(v[10]), "limit_down": _opt_f(v[11]),
        "name": v[37], "amplitude": _opt_f(v[38]), "high": _opt_f(v[39]),
        "low": _opt_f(v[40]), "volume": _opt_f(v[41]), "amount": _opt_f(v[42])}

def sina_option_greeks(code: str) -> dict:
    """期权希腊字母 + 隐含波动率。返回 name/volume/delta/gamma/theta/vega/
    iv(隐含波动率,小数)/high/low/trade_code/strike/last/theory(理论价值)。"""
    raw = _sina_opt_list(f"CON_SO_{code}")
    if len(raw) < 16:
        return {}
    v = [raw[0]] + raw[4:]  # ⚠️ raw[1:4] 是 3 个空串,必须跳过否则字段错位
    return {"name": v[0], "volume": _opt_f(v[1]), "delta": _opt_f(v[2]),
        "gamma": _opt_f(v[3]), "theta": _opt_f(v[4]), "vega": _opt_f(v[5]),
        "iv": _opt_f(v[6]), "high": _opt_f(v[7]), "low": _opt_f(v[8]),
        "trade_code": v[9], "strike": _opt_f(v[10]), "last": _opt_f(v[11]), "theory": _opt_f(v[12])}

# 用法: 取 50ETF 近月平值附近一档的 T型报价 + 希腊字母
codes = sina_option_codes("510050", call=True)
near = list(codes)[0]                       # 近月
c = codes[near][len(codes[near]) // 2]      # 中间档≈平值附近
q, g = sina_option_tquote(c), sina_option_greeks(c)
print(f"{q['name']} 行权价{q['strike']} 最新{q['last']} 持仓{q['open_interest']:.0f}")
print(f"  Delta={g['delta']} Gamma={g['gamma']} Theta={g['theta']} Vega={g['vega']} IV={g['iv']:.2%}")

pitfalls: ① Sina source GBK encoding、comma-separated, need to strip the var hq_str_XXX="..." shell. ② Must include Referer: https://stock.finance.sina.com.cn/, otherwise 403. ③ Greeks parsing [raw[0]] + raw[4:]——raw[1:4] is 3 empty strings; if not skipped, Delta/IV are all misaligned. ④ iv is a decimal (0.1735 = 17.35%). ⑤ 300ETF(510300), STAR 50ETF(588000) are the same, just replace underlying and it works.


Layer 10: Sentiment & Interaction Layer (Investor Interaction Q&A + Hot List + Popularity List, new in V3.3.0)

Investor interaction Q&A + market heat——an exclusive source for AI Q&A and topic selection.Investor Interaction(CNINFO) can answer "how does the company respond to a certain rumor/positive news", which is unavailable elsewhere;THS Hot List / Eastmoney Popularity Listgive "the hottest stocks right now + what concept they're being hyped under". All require no login and zero authentication.

10.1 Investor Interaction Q&A (CNINFO — investor questions + company replies)

import requests
from datetime import datetime

def cninfo_irm(code: str, page_size: int = 30, page_num: int = 1) -> list[dict]:
    """互动易问答(深沪统一走巨潮)。code: 6位代码。
    返回每条: code/company/question(投资者提问)/answer(公司回复,None=未回复)/
    answerer(回答方)/ask_time。"""
    try:
        r1 = requests.post("https://irm.cninfo.com.cn/newircs/index/queryKeyboardInfo",
            data={"keyWord": code}, headers={"User-Agent": UA}, timeout=10)
        d1 = r1.json().get("data") or []
        if not d1:
            return []
        org_id = d1[0].get("secid")
        # ⚠️ 第二步参数必须放 query string(POST 但 body 空),否则 HTTP 400
        params = {"_t": 1, "stockcode": code, "orgId": org_id, "pageSize": page_size,
                  "pageNum": page_num, "keyWord": "", "startDay": "", "endDay": ""}
        r2 = requests.post("https://irm.cninfo.com.cn/newircs/company/question",
            params=params, headers={"User-Agent": UA}, timeout=10)
        rows = r2.json().get("rows") or []
    except Exception as e:
        print(f"[WARN] 互动易请求失败: {e}")
        return []
    out = []
    for it in rows:
        pd = it.get("pubDate")
        out.append({"code": it.get("stockCode"), "company": it.get("companyShortName"),
            "question": it.get("mainContent"), "answer": it.get("attachedContent"),
            "answerer": it.get("attachedAuthor"),
            "ask_time": datetime.fromtimestamp(pd / 1000).strftime("%Y-%m-%d %H:%M") if pd else ""})
    return out

# 用法: 看公司怎么回应投资者关切
for q in cninfo_irm("002594", page_size=30):
    if q["answer"]:
        print(f"  Q: {q['question'][:30]}\n  A[{q['answerer']}]: {q['answer'][:50]}")

Pitfalls: ① Step 2 parameters go in query string(not the body), otherwise 400. ② orgId is taken from the secidof step 1 (even if the prefix is gshk, filtering by stockcode still gets A-share Q&A). ③ The latest questions are often unanswered (answer=None), and the reply rate varies by company (measured: Luxshare Precision 002475 replies a lot, BOE 000725 almost never replies). ④ Time is a millisecond timestamp.

10.2 THS Hot List + Eastmoney Popularity List (market heat + concept hits)

EM_HOT_BODY = {"appId": "appId01", "globalId": "786e4c21-70dc-435a-93bb-38"}

def ths_hot_list(period: str = "hour") -> list[dict]:
    """同花顺热榜(单接口拿名称+人气+概念标签+排名变化)。period: hour/day。
    返回每只: rank/code/name/heat(人气值)/pct/rank_chg(排名变化)/concepts(概念标签)/tag。"""
    try:
        r = requests.get("https://dq.10jqka.com.cn/fuyao/hot_list_data/out/hot_list/v1/stock",
            params={"stock_type": "a", "type": period, "list_type": "normal"},
            headers={"User-Agent": UA}, timeout=10)
        lst = (r.json().get("data") or {}).get("stock_list") or []
    except Exception as e:
        print(f"[WARN] 同花顺热榜失败: {e}")
        return []
    out = []
    for it in lst:
        tag = it.get("tag") or {}
        out.append({"rank": it.get("order"), "code": it.get("code"), "name": it.get("name"),
            "heat": it.get("rate"), "pct": it.get("rise_and_fall"), "rank_chg": it.get("hot_rank_chg"),
            "concepts": tag.get("concept_tag") or [], "tag": tag.get("popularity_tag", "")})
    return out

def em_hot_rank(top: int = 50) -> list[dict]:
    """东财人气榜(排名 + 排名变化 + 名称/价格)。返回 rank/code/name/price/pct/rank_chg。"""
    try:
        r = requests.post("https://emappdata.eastmoney.com/stockrank/getAllCurrentList",
            json={**EM_HOT_BODY, "marketType": "", "pageNo": 1, "pageSize": top},
            headers={"User-Agent": UA}, timeout=10)
        data = r.json().get("data") or []
        if not data:
            return []
        # 人气榜只给带前缀代码,用 push2 ulist.np 批量补名称/价格
        secids = [("0." if it["sc"].startswith("SZ") else "1.") + it["sc"][2:] for it in data]
        u = requests.get("https://push2.eastmoney.com/api/qt/ulist.np/get",
            params={"ut": "f057cbcbce2a86e2866ab8877db1d059", "fltt": 2, "invt": 2,
                    "fields": "f14,f3,f12,f2", "secids": ",".join(secids)},
            headers={"User-Agent": UA, "Referer": "https://quote.eastmoney.com/"}, timeout=10)
        diff = (u.json().get("data") or {}).get("diff") or []
        if isinstance(diff, dict):                       # push2 的 diff 有时是 dict
            diff = list(diff.values())
        nm = {x["f12"]: (x.get("f14"), x.get("f2"), x.get("f3")) for x in diff}
    except Exception as e:
        print(f"[WARN] 东财人气榜失败: {e}")
        return []
    out = []
    for it in data:
        code = it["sc"][2:]
        name, price, pct = nm.get(code, ("", None, None))
        out.append({"rank": it["rk"], "code": code, "name": name,
            "price": price, "pct": pct, "rank_chg": it.get("hisRc")})
    return out

def em_hot_concept(code: str) -> list[dict]:
    """东财个股热门概念命中(这只票当下被市场归到哪些概念在炒)。
    返回 [{concept, bk, hit(命中热度)}, ...],按热度降序。"""
    try:
        prefix = get_prefix(code).upper()   # #46;emappdata 用大写 SH/SZ/BJ
        r = requests.post("https://emappdata.eastmoney.com/stockrank/getHotStockRankList",
            json={**EM_HOT_BODY, "srcSecurityCode": prefix + code},
            headers={"User-Agent": UA}, timeout=10)
        data = r.json().get("data") or []
    except Exception as e:
        print(f"[WARN] 东财个股概念失败: {e}")
        return []
    return [{"concept": x.get("conceptName"), "bk": x.get("conceptId"),
             "hit": x.get("hitCount")} for x in data]

# 用法
for s in ths_hot_list()[:5]:
    print(f"  #{s['rank']} {s['name']} 热度{s['heat']} {s['concepts']} {s['tag']}")
hot = em_hot_rank(10)        # 东财人气榜 TOP10
print("人气第一:", hot[0]["name"], "概念命中:", em_hot_concept(hot[0]["code"])[:3])

Pitfalls: ① Eastmoney Popularity List getAllCurrentList only returns prefixed codes (SZ/SH); names need to be filled in via ulist.np (SZ→0.、SH→1.)。② ulist.np 's diff is occasionally a dict (keyed by index); list(values()) normalization has been done. ③ THS Hot List type optional hour/day。


Layer 11: Macro Layer (Social Financing + PMI, new in V3.7.0)

A-shares are a liquidity-driven market; social financing is aleading indicator, PMI is acoincident indicator. Both are released directly by official sources, with zero authentication. ⚠️ This layer ismonthlydata; do not use it as a daily-frequency signal; release dates are fixed (social financing in mid-next-month, PMI at month-end), for months not yet releasedwill notappear in the return (see the fail-fast note below).

Social financing supported range: from 2021(tested on 2026-08-19, all six years 2021~2026 parseable, 12 rows per year and no cross-year). 2020 and earlier are the old format—the header and item names are merged into one cell, and the attached table has a historical section "since 2017", passing in these years willthrow an error rather than return suspicious data。

11.1 People's Bank of China — Increment of Social Financing Scale

Core value: the official measure of society-wide liquidity injection, the most important medium-term macro variable for A-shares. 12 columns in both Chinese and English.

The chain is a triple jump(index → year page → topic page → xls attachment), any structural change at any level will fail-fast and throw an error, rather than silently returning empty.

import io
import re
from typing import Optional

import pandas as pd
import requests

_UA = {"User-Agent": "Mozilla/5.0"}
PBC_BASE = "https://www.pbc.gov.cn"
PBC_INDEX = f"{PBC_BASE}/diaochatongjisi/116219/116319/index.html"


def _macro_get(url: str, timeout: int = 30) -> str:
    r = requests.get(url, headers=_UA, timeout=timeout)
    r.raise_for_status()
    r.encoding = r.apparent_encoding or "utf-8"
    return r.text


def _abs_pbc(href: str) -> str:
    return href if href.startswith("http") else PBC_BASE + href


def pboc_social_financing(year: Optional[int] = None) -> pd.DataFrame:
    """人民银行「社会融资规模增量统计表」— 月度,单位亿元;year=None 取最新年"""
    idx = _macro_get(PBC_INDEX)
    years = re.findall(r"""href=["']([^"']+)["'][^>]*>\s*(\d{4})年统计数据\s*</a>""", idx)
    if not years:
        raise RuntimeError("人民银行索引页未找到「XXXX年统计数据」链接,页面结构可能已变更")
    table = {int(y): href for href, y in years}
    target = max(table) if year is None else year
    if target not in table:
        raise ValueError(f"人民银行无 {target} 年数据,可选年份: {sorted(table, reverse=True)[:8]}")

    ypage = _macro_get(_abs_pbc(table[target]))
    topics = re.findall(r"""href=["']([^"']+)["'][^>]*>\s*(社会融资规模)\s*</a>""", ypage)
    if not topics:
        raise RuntimeError(f"{target} 年页未找到「社会融资规模」专题链接")

    tpage = _macro_get(_abs_pbc(topics[0][0]))
    books = re.findall(r"""href=["']([^"']+\.xlsx?)["']""", tpage)
    if not books:
        raise RuntimeError(f"{target} 年社融专题页未找到 xls/xlsx 附件")

    content = requests.get(_abs_pbc(books[0]), headers=_UA, timeout=60).content
    raw = pd.read_excel(io.BytesIO(content), header=None)

    start = None                      # 表头是中英双行 + 单位说明,用「月份」列定位数据起点
    for i in range(len(raw)):
        if str(raw.iloc[i, 0]).strip() == "月份":
            start = i
            break
    if start is None:
        raise RuntimeError(
            f"{target} 年社融表没有独立的「月份」表头单元格。"
            "**2020 及更早采用旧版式**(表头与项目名合并在同一单元格,且附表含 2017 年以来的历史区),"
            "本端点仅支持 **2021 年起**(2026-08-19 实测 2021~2026 全部可解析)。"
        )

    cols = ["month", "afre_total", "rmb_loans", "fx_loans", "entrusted_loans",
            "trust_loans", "undiscounted_bankers_acceptance", "corporate_bonds",
            "government_bonds", "equity_financing", "abs_by_depository", "loans_written_off"]
    df = raw.iloc[start + 3:].copy().iloc[:, :len(cols)]
    df.columns = cols
    df = df[df["month"].astype(str).str.match(r"^\d{4}\.\d{1,2}$", na=False)].copy()
    for c in cols[1:]:
        df[c] = pd.to_numeric(df[c], errors="coerce")

    def _month_label(v):
        """`2026.01` → 2026-01;`2026.1` → 2026-10。

        Excel 把 `2026.10` 的尾零吃掉读成浮点 `2026.1`,与 1 月的 `2026.01` 撞车。
        1 月在表里始终写作两位 `.01`,因此**单个小数位必然是被吃了尾零的 x0 月**。
        按单元格逐行解析(而不是按行序编号),跨年工作簿也不会错位。
        """
        m = re.match(r"^(\d{4})\.(\d{1,2})$", str(v).strip())
        if not m:
            return None
        year_s, mon_s = m.group(1), m.group(2)
        if len(mon_s) == 1:
            mon_s += "0"
        return f"{year_s}-{int(mon_s):02d}"

    df["month"] = [_month_label(v) for v in df["month"]]
    df = df[df["month"].notna()]
    # 旧工作簿底部会附「表1:2017年以来各月…」的历史区,只保留目标年,防跨年污染
    df = df[df["month"].str.startswith(f"{target}-")].reset_index(drop=True)

    # 未发布月份整行为空 —— 必须丢掉,否则调用方会把 12 行当成 12 个月的真数据。
    df = df.dropna(subset=["afre_total"]).reset_index(drop=True)
    if df.empty:
        raise RuntimeError(f"社融表解析后无有效月份({target} 年),格式可能已变更")
    return df


# 用法
df = pboc_social_financing()          # 最新年(只含已发布月份)
print(df[["month", "afre_total", "rmb_loans", "government_bonds"]].to_string(index=False))
# 实测 2026-08-19:返回 7 行(2026-01 ~ 2026-07),2026-01 社融增量 72,185 亿
# 全部 12 列:month / afre_total(社融增量) / rmb_loans(人民币贷款) / fx_loans(外币贷款) /
#   entrusted_loans(委托贷款) / trust_loans(信托贷款) /
#   undiscounted_bankers_acceptance(未贴现银行承兑汇票) / corporate_bonds(企业债券) /
#   government_bonds(政府债券) / equity_financing(非金融企业境内股票融资) /
#   abs_by_depository(存款类金融机构ABS) / loans_written_off(贷款核销)

hist = pboc_social_financing(2024)    # 指定年份
print(len(hist), "个月, 全年社融增量", f"{hist['afre_total'].sum():,.0f}", "亿元")
# 实测:12 个月, 322,588 亿元

11.2 National Bureau of Statistics — Purchasing Managers' Index PMI

Core value: A coincident indicator of manufacturing prosperity; 50 is the boom-or-bust line. Released at month-end, reflecting business conditions a quarter earlier than listed company financial reports.

import re

import requests

NBS_INDEX = "https://www.stats.gov.cn/sj/zxfb/"
_UA = {"User-Agent": "Mozilla/5.0"}


def _macro_get(url: str, timeout: int = 30) -> str:
    """与 §11.1 同名同实现 —— 本块按「端点路由速查」单独取用时也能独立跑,
    不必先执行 §11.1。两处同时执行时后定义覆盖前者,行为一致,无副作用。"""
    r = requests.get(url, headers=_UA, timeout=timeout)
    r.raise_for_status()
    r.encoding = r.apparent_encoding or "utf-8"
    return r.text


def nbs_pmi() -> dict:
    """国家统计局最新 PMI — 制造业 / 非制造业商务活动 / 综合产出 + 大中小型企业"""
    idx = _macro_get(NBS_INDEX)
    links = re.findall(r'<a[^>]+href="([^"]+)"[^>]*>\s*([^<]{6,80}?)\s*</a>', idx)
    hit = next(((u, t) for u, t in links if "采购经理指数" in t), None)
    if not hit:
        raise RuntimeError("国家统计局最新发布页未找到「采购经理指数」条目")
    href, title = hit
    url = href if href.startswith("http") else NBS_INDEX + href.lstrip("./")

    html = _macro_get(url)
    text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.S)
    text = re.sub(r"<[^>]+>", "", text)
    # 🔴 正文是全角括号且**括号内带空格**(`( PMI )为 49.2%`)。
    #    必须把空白**整个删掉**;只做「压成单个空格」会一条都匹配不到。
    text = re.sub(r"[\s\u3000\xa0]+", "", text)

    def grab(pat):
        m = re.search(pat, text)
        return float(m.group(1)) if m else None

    ym = re.search(r"(\d{4})年(\d{1,2})月", title)

    # 分档措辞统计局用过三种版式,逐层回退;解析不到留 None(属可选字段)。
    #   ① 全合并:大、中、小型企业PMI分别为 A%、B%和C%
    #   ② 半拆:  大型企业PMI为 A%…;中、小型企业PMI分别为 B%和C%
    #   ③ 全拆:  大型企业PMI为 A%…;中型企业PMI为 B%…;小型企业PMI为 C%
    # 注:③ 的单条正则要求「…企业PMI为」,不会误匹配 ①② 里的「…企业PMI分别为」。
    large = medium = small = None
    combined = re.search(r"大、中、小型企业PMI分别为([\d.]+)%、([\d.]+)%和([\d.]+)%", text)
    if combined:
        large, medium, small = (float(x) for x in combined.groups())
    else:
        m_ms = re.search(r"中、小型企业PMI分别为([\d.]+)%和([\d.]+)%", text)
        if m_ms:                            # ② 中小型合并一句
            medium, small = (float(x) for x in m_ms.groups())
        for _name, _pat in (("large", r"大型企业PMI为([\d.]+)%"),
                            ("medium", r"中型企业PMI为([\d.]+)%"),
                            ("small", r"小型企业PMI为([\d.]+)%")):
            _m = re.search(_pat, text)      # ③ 各自单独成句
            if _m:
                _v = float(_m.group(1))
                if _name == "large":
                    large = _v
                elif _name == "medium" and medium is None:
                    medium = _v
                elif _name == "small" and small is None:
                    small = _v

    result = {
        "title": title.strip(),
        "period": f"{ym.group(1)}-{int(ym.group(2)):02d}" if ym else None,
        "manufacturing_pmi": grab(r"(?<!非)制造业采购经理指数(PMI)为([\d.]+)%"),
        "non_manufacturing_pmi": grab(r"非制造业商务活动指数为([\d.]+)%"),
        "composite_pmi": grab(r"综合PMI产出指数为([\d.]+)%"),
        "pmi_large": large,
        "pmi_medium": medium,
        "pmi_small": small,
        "source_url": url,
    }
    # 三个主指标是本端点的承诺输出,解析不到必须 fail-fast ——
    # 统计局改一次措辞就静默返回一串 None,调用方会当成「本月没数据」。
    core = ("manufacturing_pmi", "non_manufacturing_pmi", "composite_pmi")
    absent = [k for k in core if result[k] is None]
    if absent:
        raise RuntimeError(
            f"PMI 正文措辞可能已变更,无法解析 {absent};请核对页面:{url}"
        )
    return result


# 用法
p = nbs_pmi()
print(p["period"], "制造业", p["manufacturing_pmi"], "非制造业", p["non_manufacturing_pmi"])
# 实测 2026-08-19:2026-07 制造业 49.2 / 非制造业 49.0 / 综合 49.3
#                  大型 49.5 / 中型 49.7 / 小型 47.4(均在荣枯线下)

Interpretation standard: PMI > 50 expansion, < 50 contraction; only two consecutive months in the same direction counts as a trend. The large/medium/small enterprise tiers reveal structural divergence — small enterprises being below large ones for a long time is normal; what matters is thechange in the differencenot the absolute value.


Valuation calculation formula

Forward PE

def forward_pe(price: float, eps_forecast: float) -> float:
    """前向PE = 当前股价 / 未来年度一致预期EPS"""
    if eps_forecast <= 0:
        return float("inf")
    return price / eps_forecast

PE digestion time

import math

def pe_digestion(current_pe: float, cagr: float, target_pe: float = 30) -> float:
    """
    当前PE消化到目标PE需要多少年。
    target_pe 固定30x(A股成长股合理估值锚点)。
    cagr: 用 下一年EPS / 当年EPS - 1
    """
    if current_pe <= target_pe:
        return 0.0
    if cagr <= 0:
        return float("inf")
    return math.log(current_pe / target_pe) / math.log(1 + cagr)

PEG

def calc_peg(pe: float, cagr: float) -> float:
    """
    PEG = 前向PE / (CAGR * 100)
    PEG < 1   → 便宜
    PEG 1-1.5 → 合理
    PEG > 1.5 → 贵
    """
    if cagr <= 0:
        return float("inf")
    return pe / (cagr * 100)

Investment framework quick reference

壁垒 → 增速 → PE消化 → PEG校验

1. 有壁垒吗?(tech_moat / capacity_moat) → 没有则排除
2. 增速多少?(CAGR > 30% 才有意义)
3. PE多久消化到30x?(< 2年合理, > 4年太贵)
4. PEG多少?(< 1 便宜, 1-1.5 合理, > 1.5 贵)

30x PE 锚点: A股成长股的合理估值重力线,所有行业统一用30x。
期权定价例外: PEG > 3 但壁垒极深时,本质是看涨期权,不适用PEG框架。

Complete research workflow

Workflow A: Full valuation of a single stock (30 seconds)

import requests
import urllib.request
import math
import pandas as pd

def full_valuation(code: str) -> dict:
    """单票完整估值分析"""
    # 1. 腾讯实时行情
    # 92 必须先判:北交所 2024-10 起启用 920xxx 号段,裸 startswith("9") 会误判成沪市,
    # 腾讯对 sh920xxx 返回空载荷(静默失败)。900xxx 沪市 B 股仍走 sh。
    prefix = ("bj" if code.startswith(("92", "8"))
              else "sh" if code.startswith(("6", "9")) else "sz")
    url = f"https://qt.gtimg.cn/q={prefix}{code}"
    req = urllib.request.Request(url)
    req.add_header("User-Agent", "Mozilla/5.0")
    resp = urllib.request.urlopen(req, timeout=10)
    data = resp.read().decode("gbk")
    vals = data.split('"')[1].split("~")
    price = float(vals[3])
    mcap = float(vals[45])   # 45=总市值(44 是流通市值,见「腾讯行情字段」踩坑提醒二)
    pe_ttm = float(vals[39]) if vals[39] else 0
    pb = float(vals[46]) if vals[46] else 0

    # 2. 机构一致预期(直连同花顺)
    df = ths_eps_forecast(code)
    eps_cur = eps_next = None
    analyst_count = 0
    if not df.empty and len(df.columns) >= 3:
        # 按列名取「均值」=机构一致预期EPS(见 ths_eps_forecast 文档)。
        # 不按 iloc 位置取——同花顺表格列序会变;且旧版误用 iloc[2]=「最小值」
        # 当成一致预期,导致 pe_forward/PEG 系统性偏差,此处一并修正。
        def _pick(row, name):
            for c in df.columns:
                if name in str(c):
                    return row.get(c)
            return None
        try:
            r0 = df.iloc[0]
            v = _pick(r0, "均值");          eps_cur = float(v) if pd.notna(v) else None
            cnt = _pick(r0, "预测机构数");  analyst_count = int(cnt) if pd.notna(cnt) else 0
            if len(df) >= 2:
                vn = _pick(df.iloc[1], "均值"); eps_next = float(vn) if pd.notna(vn) else None
        except (ValueError, TypeError) as e:
            print(f"[WARN] full_valuation EPS 解析失败({e}),估值可能不完整")

    # 3. 估值指标
    pe_fwd = price / eps_cur if eps_cur else float("inf")
    cagr = (eps_next / eps_cur - 1) if (eps_cur and eps_next) else 0
    peg = pe_fwd / (cagr * 100) if cagr > 0 else float("inf")
    digest = (
        math.log(pe_fwd / 30) / math.log(1 + cagr)
        if pe_fwd > 30 and cagr > 0 else 0
    )

    return {
        "name": vals[1],
        "price": price,
        "mcap_yi": mcap,
        "pe_ttm": pe_ttm,
        "pb": pb,
        "eps_cur": eps_cur,
        "eps_next": eps_next,
        "pe_fwd": round(pe_fwd, 1) if eps_cur else None,
        "cagr_pct": round(cagr * 100, 0) if cagr else None,
        "peg": round(peg, 2) if peg != float("inf") else None,
        "digest_years": round(digest, 1),
        "analyst_count": analyst_count,
    }

# 用法
result = full_valuation("688017")
print(result)

Workflow B: Batch valuation comparison

stocks = ["688017", "300308", "300476", "002463"]
for code in stocks:
    try:
        r = full_valuation(code)
        print(f"{r['name']}({code}): PE_fwd={r['pe_fwd']}x PEG={r['peg']} 消化={r['digest_years']}年 覆盖={r['analyst_count']}家")
    except Exception as e:
        print(f"{code}: 失败 - {e}")

Workflow C: Batch search of thematic research reports

# Step 1: iwencai 多 query 语义搜索
queries = [
    "人形机器人产业链深度 2026",
    "人形机器人减速器 丝杠",
    "特斯拉Optimus 国产供应链",
]
seen_uids = set()
all_articles = []
for q in queries:
    arts = iwencai_search(q, channel="report", size=50)
    for a in arts:
        uid = a.get("uid", "")
        if uid not in seen_uids:
            seen_uids.add(uid)
            all_articles.append(a)
print(f"共 {len(all_articles)} 篇去重后研报")

# Step 2: 东财补充同标的研报 + PDF
for a in all_articles[:10]:
    stocks = a.get("stock_infos") or []
    for s in stocks:
        stock_code = s.get("code", "")
        if stock_code:
            em = eastmoney_reports(stock_code, max_pages=1)
            print(f"  {stock_code}: 东财 {len(em)} 篇")

Workflow D: Rapid research on a new target (V3.0 enhanced version)

code = "688017"

# 1. 有无机构覆盖?
forecast = ths_eps_forecast(code)
print(f"机构覆盖: {'有' if not forecast.empty else '无'}")

# 2. 实时估值
quotes = tencent_quote([code])
q = quotes[code]
print(f"PE={q['pe_ttm']} PB={q['pb']} 市值={q['mcap_yi']}亿")

# 3. PE消化 → 用 full_valuation()
# 4. PEG校验

# 5. 概念板块归属
blocks = eastmoney_concept_blocks(code)
print(f"板块: {', '.join(blocks['concept_tags'][:10])}")

# 6. 资金流向(分钟级,当日盘中)
flow = eastmoney_fund_flow_minute(code)
if flow:
    total = sum(f["main_net"] for f in flow)
    print(f"当日主力累计净流入: {total/1e4:.0f}万")

# 7. 资金流向(东财120日)
flow_120 = stock_fund_flow_120d(code)
if flow_120:
    total = sum(d["main_net"] for d in flow_120[-20:])
    print(f"近20日主力累计净流入: {total/1e8:.2f}亿")

# 8. 龙虎榜
dtb = dragon_tiger_board(code, "2026-05-17")
print(f"近30日上龙虎榜: {len(dtb['records'])} 次")

# 9. 解禁预警
lockup = lockup_expiry(code, "2026-05-17")
print(f"未来90天待解禁: {len(lockup['upcoming'])} 批")

# 10. 融资融券
margin = margin_trading(code, page_size=5)
if margin:
    print(f"最新融资余额: {margin[0]['rzye']/1e8:.2f}亿")

# 11. 股东户数
holders = holder_num_change(code)
if holders:
    print(f"最新股东数: {holders[0]['holder_num']} 环比{holders[0]['change_ratio']}%")

Data source priority

Priority Data source Purpose Reliability IP blocking risk
1 mootdx (TCP) K-line + five-level order book + tick-by-tick trades + financial snapshot + F10 Extremely stable Extremely low
2 Tencent Finance (HTTP) Real-time PE/PB/market cap/turnover rate/price limit up-down/index/ETF Stable Low
3 Eastmoney datacenter (HTTP) Dragon-Tiger List/share unlock/margin trading/block trades/number of shareholders/dividends/stock information Stable Low
4 Eastmoney push2/push2his (HTTP) Industry sector/individual stock capital flow minute-level + 120 days Stable Low
5 iwencai (OpenAPI) NL thematic search research reports (unique capability) Requires X-Claw Header Low
6 Eastmoney reportapi/PDF (HTTP) Complete research report charts, ratings Stable Low
7 Tonghuashun hot topics (HTTP) Intraday strong stocks + theme attribution reason tags Stable 73ms Extremely low (zero authentication)
8 Tonghuashun hsgtApi (HTTP) Northbound capital minute-level + self-cached history Stable Extremely low (zero authentication)
9 Baidu Stock (HTTP) Concept sectors + K-line with MA Stable Extremely low (zero authentication)
10 Sina Finance (HTTP) Balance sheet/income statement/cash flow statement Stable Low
11 Tonghuashun basic (HTTP) Consensus EPS Stable (requires UA) Low
12 Cailianshe (HTTP) Whole-market real-time newsflash Stable Low
13 Cninfo (HTTP) Full-text announcement search + download Stable Low
14 Shanghai Stock Exchange official (HTTP, backup) Dragon-Tiger List full text/real-time five-level quotes (use when the primary source is blocked) Stable (first-hand authoritative) Extremely low (zero authentication)
15 Shenzhen Stock Exchange official (HTTP, backup) Dragon-Tiger List/announcements+PDF/real-time five-level quotes (use when the primary source is blocked) Stable (first-hand authoritative) Extremely low (zero authentication)
16 baostock (TCP,V3.7) Historical valuation PE/PB/PS/PCF+turnover rate+suspension+ST+listing/delisting date Stable (no registration required) Extremely low;Beijing Stock Exchange not supported
17 Shenwan Research (HTTP,V3.7) History of industry classification changes (public XLS) Stable Extremely low (public document)
18 People's Bank of China (HTTP,V3.7) Increment of aggregate financing to the real economy (monthly, since 2021) Stable (official site) Extremely low
19 National Bureau of Statistics (HTTP,V3.7) PMI manufacturing/non-manufacturing/composite+large, medium and small enterprises Stable (official site) Extremely low

Principles: Quotes go through mootdx+Tencent (no IP blocking), research reports go through Eastmoney+iwencai, capital flows go through Eastmoney datacenter+push2,the signal layer goes through Tonghuashun+Baidu+Eastmoney direct connection interfaces. Except for the two TCP clients mootdx / baostock, all are direct HTTP connections.

Fallback: When any primary source is blocked/fails, first check the "Backup Source Quick Reference & Fallback Strategy" below—each type of data has onedifferent domain, different risk-control surfaceindependent backup (exchange official/Sina/Tonghuashun); when Eastmoney is blocked, they are not affected.


Backup Source Quick Reference & Fallback Strategy (use when Eastmoney/primary source is blocked)

When to use: When the primary source reports a 403/connection reset (Eastmoney IP-level risk control), returns empty, or when authoritative first-hand data is needed for cross-validation.Eastmoney interfaces share the same risk-control surface; if one residential IP is blocked, they will all lose connection in bulk—the table below givesten categories of core dataeach with one independent backup (different domain, different risk-control surface; the limit-up trading/options/public sentiment three layers currently have no independent backup). All endpoints in the table were tested alive on 2026-07-11, usable with zero authentication, among which 3 backup functions were also fully run through with real data.

Data type Primary source (this skill) Independent backup Backup endpoint / description
Real-time quotes+five-level quotes mootdx/Tencent Exchange official Shanghai yunhq.sse.com.cn:32041/v1/sh1/snap/{code}, Shenzhen szse.cn/api/market/ssjjhq/getTimeData?marketId=1&code={code}(first-hand five-level quotes)
K-line (full history) mootdx/Baidu/Tencent Tonghuashun d.10jqka.com.cn/v6/line/hs_{code}/01/last.js(01-day/11-week/21-month/30/60-minute; from 2001 to now; JSONP unwrapping)
K-line (minute) mootdx Tencent ifzq.gtimg.cn/appstock/app/kline/mkline?param={pre}{code},m5,,320(m1/m5/m15/m30/m60, ≤320 bars, requires header Referer: https://gu.qq.com/; the only 5-minute source when mootdx goes down)
Dragon-Tiger List Eastmoney datacenter Shanghai and Shenzhen Exchange official dragon_tiger_backup()(see below, includes brokerage branch seats)
Individual stock capital flow Eastmoney push2 Sina fund_flow_backup()(see below, daily four-tier net order amount)
Announcements Juchao Shenzhen Stock Exchange official/Eastmoney announcements_backup()(see below, Shenzhen market SZSE+PDF, Shanghai market Eastmoney+PDF)
Three financial statements Sina/mootdx Tonghuashun F10 basic.10jqka.com.cn/api/stock/finance/{code}_debt.json(_benefitProfit/_cashcash flow; UA only, 5 consecutive requests not blocked)
Individual stock news Eastmoney search Sina 7x24 zhibo.sina.com.cn/api/zhibo/feed?zhibo_id=152&page_size=20&dire=f(ext.stocks filterable by stock association)
News flash Eastmoney 7x24 (Section 5.3) Cailianshe (Section 5.2) the two already back each other up; add Jin10 jin10.com/flash_newest.js
Brokerage ratings + target price Tonghuashun consensus estimates Juchao webapi p_sysapi1089?tdate=YYYY-MM-DD, requires header Accept-Enckey=base64(AES-128-CBC(unix seconds, key=iv=1234567887654321))
Northbound (authoritative) Tonghuashun hexin HKEX official hkex.com.hk/chi/csm/DailyStat/data_tab_daily_{YYYYMMDD}c.js(turnover/quota/top ten active stocks)

⛔ dead, do not use(tested 2026-07): NetEase Finance (126.net entire site offline), Hexun, Phoenix quotes, Tencent capital flow (ff_ dead), Xueqiu deep data without login (requires token). mootdx libraryabandoned (last update 2024) butthe Tongdaxin TCP protocol itself works as usual—keep using it; if it won't install, use tdx_client()。

⚠️ Tencent minute K-line field pitfall: returns an array [时间, 开, 收, 高, 低, 量(手), {}, 换手率基点]—the 7th fieldis not turnover, it is turnover rate in basis points(sum each bar of the day ÷100 = daily turnover rate %). Read as turnover it is three orders of magnitude too small; turnover must be calculated manually 量(手) × 100 × 均价。

import json, urllib.request, ssl
_ctx = ssl.create_default_context(); _ctx.check_hostname = False; _ctx.verify_mode = ssl.CERT_NONE

def dragon_tiger_backup(trade_date: str) -> dict:
    """龙虎榜官方备用源(东财被封时用):上交所+深交所官方,零鉴权权威一手,含营业部席位。"""
    out = {"date": trade_date, "sse_raw": "", "szse": []}
    su = (f"https://www.szse.cn/api/report/ShowReport/data?SHOWTYPE=JSON"
          f"&CATALOGID=1842_xxpl&TABKEY=tab1&txtStart={trade_date}&txtEnd={trade_date}&random=0.9")
    req = urllib.request.Request(su, headers={"User-Agent": UA,
          "Referer": "https://www.szse.cn/disclosure/supervision/dealinfo/index.html"})
    with urllib.request.urlopen(req, timeout=15, context=_ctx) as r:
        d = json.loads(r.read())
    for row in d[0].get("data", []):
        out["szse"].append({"code": row.get("zqdm"), "name": row.get("zqjc"),
                            "amount": row.get("cjje"), "reason": row.get("plyy")})
    eu = (f"https://query.sse.com.cn/infodisplay/showTradePublicFile.do?"
          f"jsonCallBack=cb&isPagination=false&dateTx={trade_date}")
    req = urllib.request.Request(eu, headers={"User-Agent": UA,
          "Referer": "https://www.sse.com.cn/disclosure/diclosure/public/"})
    with urllib.request.urlopen(req, timeout=15) as r:
        t = r.read().decode("utf-8", "ignore")
    out["sse_raw"] = "\n".join(json.loads(t[t.index("(")+1:t.rindex(")")]).get("fileContents", []))
    return out

def fund_flow_backup(code: str, days: int = 60) -> list:
    """个股资金流备用源(东财被封时用):新浪,日度四档单净额。"""
    # 92 先判:920xxx 是北交所,误判成 sh/sz 时新浪返回空数组(实测 bj920002 有数据、sh/sz 为 [])
    pre = ("bj" if code.startswith(("92", "8"))
           else "sh" if code.startswith(("6", "9")) else "sz") + code
    u = (f"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
         f"MoneyFlow.ssl_qsfx_zjlrqs?page=1&num={days}&sort=opendate&asc=0&daima={pre}")
    req = urllib.request.Request(u, headers={"User-Agent": UA, "Referer": "https://finance.sina.com.cn/"})
    with urllib.request.urlopen(req, timeout=15) as r:
        t = r.read().decode("utf-8", "ignore")
    arr = json.loads(t[t.index("["):t.rindex("]")+1])
    return [{"date": x.get("opendate"), "close": x.get("trade"),
             "net_amount": x.get("netamount"), "turnover": x.get("turnover")} for x in arr]

def announcements_backup(code: str, page_size: int = 20) -> list:
    """公告备用源(巨潮被封时用):深市走深交所官方,沪市走东财,均带 PDF 直链。"""
    if code.startswith(("0", "3")):
        body = json.dumps({"channelCode": ["listedNotice_disc"], "pageSize": page_size,
                           "pageNum": 1, "stock": [code]}).encode()
        req = urllib.request.Request("https://www.szse.cn/api/disc/announcement/annList", data=body,
              headers={"User-Agent": UA, "Content-Type": "application/json",
                       "Referer": "https://www.szse.cn/disclosure/listed/notice/index.html"})
        with urllib.request.urlopen(req, timeout=15, context=_ctx) as r:
            d = json.loads(r.read())
        return [{"title": a.get("title"), "time": a.get("publishTime", "")[:10],
                 "pdf": "https://disc.static.szse.cn/download" + a.get("attachPath", "")}
                for a in d.get("data", [])]
    u = (f"https://np-anotice-stock.eastmoney.com/api/security/ann?sr=-1&page_size={page_size}"
         f"&page_index=1&ann_type=A&client_source=web&stock_list={code}&f_node=0&s_node=0")
    req = urllib.request.Request(u, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=15) as r:
        d = json.loads(r.read())
    return [{"title": a.get("title"), "time": a.get("notice_date", "")[:10],
             "pdf": f"https://pdf.dfcfw.com/pdf/H2_{a.get('art_code','')}_1.pdf"}
            for a in (d.get("data") or {}).get("list") or []]     # #46 同因

# 用法(主源失败时降级)
lhb = dragon_tiger_backup("2026-07-10")   # 深市结构化 + 沪市全文(含营业部)
flow = fund_flow_backup("600519", 60)     # 近60日资金流
anns = announcements_backup("000858")     # 深市走深交所, 沪市走东财

FAQ

Q: Eastmoney API 403 / connection reset, am I blocked, what should I do?

A: Eastmoney APIs (datacenter/push2/push2ex/reportapi/search/np-weblist) share the same risk control; if the IP is blocked, they all go offline together. Three steps: ① stop requests and wait 30-60 minutes (IP-level temporary blocks usually lift automatically), or switch networks (phone hotspot) to recover immediately; ② for long batch tasks confirm all go through em_get(), and increase EM_MIN_INTERVAL; ③ if the data cannot wait → use the independent fallbacks from the “Backup Source Quick Reference & Degradation Strategy” above (exchange official/Sina/Tonghuashun, different risk control surfaces, unaffected when Eastmoney is blocked).

Q: Wasn't Cailianshe news flash marked offline in V3.2?

A: It has revived (V3.4.0, see Section 5.2). What died in 2026-05 was the old nodeapi API; the official new v1/roll/get_roll_list has always been available, it just enforces sign verification—and the sign can be calculated entirely locally (md5(sha1(按 key 字典序拼接的 query 串))), zero key. It and the Eastmoney 7×24 in Section 5.3 back each other up independently.

Q: I heard the mootdx library stopped updating, is it still usable?

A: The library is indeed abandoned (last commit 2024-07, official site offline, no official fix for the BESTIP bug), butthe Tongdaxin TCP protocol itself runs as usual—what is abandoned is the wrapper library, not the data source. This skill's tdx_client() already has built-in IP probing to bypass the BESTIP bug, so it's fine to keep using. If mootdx cannot be installed in the future, the active community alternative is easy_tdx (same protocol, actively maintained).

Q: What is the difference between mootdx and Tencent?

A: Complementary relationship. mootdx = trading layer (price + order book + K-line), Tencent = valuation layer (PE/PB/market cap/turnover rate/limit up-down price). Neither blocks IPs.

Q: Why was akshare removed in V3.0?

A: akshare is essentially a wrapper around public APIs from Eastmoney/Tonghuashun/Sina, and the middle layer adds failure points (version compatibility bugs, pandas 3.0 ArrowInvalid, etc.). V3.0 connects directly to the underlying HTTP APIs, with zero intermediate dependencies, making it more stable and controllable.

Q: iwencai returns 401

A: Check two things: (1) whether the API Key is valid (2) whether the X-Claw-* Headers are included. After SkillHub 2.0, X-Claw Headers must be included, otherwise it is always 401.

Q: Tonghuashun consensus estimate ths_eps_forecast returns empty

A: This stock has no institutional coverage. Common for small-cap/newly listed/ST stocks. You can fall back to the predictThisYearEps field in Eastmoney reportapi.

Q: Eastmoney PDF download 403

A: Must include Referer: https://data.eastmoney.com/ header。

Q: Tencent API returns garbled text

A: The encoding is GBK, must decode("gbk")。

Q: Is Tencent API field 43 PB?

A: No! 43=amplitude %, 46=PB. Many tutorials online get this wrong; this is the actual measured and calibrated result.

Q: iwencai search returns too few results

A: size The parameter defaults to 10, adjust it to 50. Hidden parameter, not documented but verified to work.

Q: Which data sources require an API Key?

A: Only iwencai requires one. mootdx / Tencent / Eastmoney / Tonghuashun / Baidu Stock / Sina / Cninfo / Cailianshe are all free with no key.

Q: Does the Tonghuashun hot topics interface require a cookie?

A: No need. Only User-Agent is required; with zero authentication, 73ms to get ~125 strong stocks of the day. Butdo not hit the iwencai NL stock selection interface at search.10jqka.com.cn — that one has hexin-v cookie JS signature authentication, a completely different matter from the hot topics interface.

Q: Baidu Stock ResultCode is sometimes 0 and sometimes "0"?

A: Known pitfall.ResultCode The return type is unstable—sometimes int, sometimes string. In code you must use str(d.get("ResultCode", -1)) != "0" for unified comparison.

Q: Why does Northbound capital historical data only cover the last few days?

A: Local self-caching mode. Eastmoney's entire Northbound data line has been cut off since 2024-08 (net buy amount field returns NaN/0). Each call to the real-time API automatically writes to a local CSV, so history grows richer the more it runs.

Q: Why switch industry sectors from Tonghuashun to Eastmoney?

A: Tonghuashun stock_board_industry_summary_ths interface added anti-scraping 401 in early 2026 (requires login state). Eastmoney push2 industry sector data (m:90+t:2) is a perfect replacement, with zero authentication and richer fields.

Q: Running on an overseas server, mootdx interface times out?

A: mootdx uses TCP to connect directly to Tongdaxin market data servers, and needs a domestic IP to be stable. In overseas environments, using a proxy is recommended. Tencent Finance and Baidu Stock are unaffected.

Q: Can it be used without Claude Code?

A: Yes. SKILL.md is essentially Markdown + embedded Python code. Codex, OpenClaw, or any AI programming assistant can read it. You can also directly copy the Python code sections and run them in your own scripts.


Installation instructions

# 1. 创建 skill 目录
mkdir -p ~/.claude/skills/a-stock-data

# 2. 将本文件复制为 SKILL.md
cp SKILL.md ~/.claude/skills/a-stock-data/SKILL.md

# 3. 安装 Python 依赖
pip install mootdx requests pandas stockstats numpy baostock xlrd openpyxl

# 4. (可选) 配置 iwencai API Key
export IWENCAI_API_KEY="your_key_here"

# 5. 启动 Claude Code,说"查一下688017的估值"即可自动激活

📦 https://github.com/simonlin1212/a-stock-data — Star ⭐ is the best support