chstockdata 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ """chstockdata — free China A-share market data toolkit.
2
+
3
+ Zero third-party data SDKs, no API keys. Direct HTTP/TCP access to public
4
+ quote vendors (Tencent, mootdx/TDX, Eastmoney, Sina, THS, CLS, SSE/SZSE),
5
+ extracted and battle-tested from TradingAgents-astock.
6
+
7
+ Quick start::
8
+
9
+ from chstockdata import configure, get_stock_data, resolve_ticker
10
+
11
+ configure(cache_dir="./cache") # optional
12
+ resolve_ticker("贵州茅台") # -> "600519"
13
+ df = get_stock_data("600519", 365) # daily OHLCV
14
+
15
+ Eastmoney requests are throttled module-wide (``EM_MIN_INTERVAL``, default
16
+ 1.0s) — keep it that way; do not fan out concurrent full-market scans.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .config import (
22
+ configure,
23
+ get_config,
24
+ get_setting,
25
+ reset_config,
26
+ validate_vipdoc_history_config,
27
+ )
28
+ from .vendor_errors import (
29
+ DeadlineExceeded,
30
+ SourceContextDeadlineExceeded,
31
+ VendorError,
32
+ VendorNetworkError,
33
+ VendorNoDataError,
34
+ VendorNotConfiguredError,
35
+ VendorRateLimitError,
36
+ )
37
+ from .utils import safe_ticker_component
38
+
39
+ # ── Core vendor functions (a_stock) ─────────────────────────────────────────
40
+ from .a_stock import (
41
+ ensure_name_code_map_warmup,
42
+ name_code_map_ready,
43
+ resolve_ticker,
44
+ reset_mootdx_client,
45
+ get_realtime_snapshot,
46
+ get_hot_concept_examples,
47
+ get_ohlcv_frame_cached,
48
+ get_stock_data,
49
+ get_fundamentals,
50
+ get_balance_sheet,
51
+ get_cashflow,
52
+ get_income_statement,
53
+ get_free_financial_indicators,
54
+ get_news,
55
+ get_global_news,
56
+ get_insider_transactions,
57
+ get_research_reports,
58
+ get_earnings_forecast,
59
+ get_shareholder_pledge,
60
+ get_corporate_buyback,
61
+ get_margin_trading,
62
+ get_valuation_history,
63
+ get_macro_indicators,
64
+ get_disclosure_schedule,
65
+ get_suspension_info,
66
+ get_delisting_info,
67
+ get_hot_stocks,
68
+ get_stock_monitor,
69
+ get_market_breadth,
70
+ get_corporate_actions,
71
+ get_announcement_index,
72
+ get_block_trades,
73
+ get_northbound_flow,
74
+ get_policy_news,
75
+ get_concept_blocks,
76
+ get_fund_flow,
77
+ get_dragon_tiger_board,
78
+ get_lockup_expiry,
79
+ get_industry_comparison,
80
+ )
81
+
82
+ # ── Adjusted bars / calendar / vipdoc history ───────────────────────────────
83
+ from .adjusted_bars import get_adjusted_bars
84
+ from .trading_calendar import local_is_trading_day, load_trading_calendar
85
+ from .vipdoc_history import (
86
+ load_vipdoc_daily,
87
+ vipdoc_history_dir,
88
+ vipdoc_history_status,
89
+ )
90
+
91
+ # ── Policy news ─────────────────────────────────────────────────────────────
92
+ from .policy_news import get_policy_news_for_context
93
+
94
+ # ── Provenance (evidence envelopes; capability resolver is injectable) ─────
95
+ from .provenance import (
96
+ ATTEMPT_FAILED_AUTH,
97
+ ATTEMPT_FAILED_NETWORK,
98
+ ATTEMPT_FAILED_RATE_LIMIT,
99
+ ATTEMPT_FAILED_STRUCTURE,
100
+ ATTEMPT_NORMAL_EMPTY,
101
+ ATTEMPT_SKIPPED,
102
+ ATTEMPT_SKIPPED_DUE_TO_RUN_CIRCUIT,
103
+ ATTEMPT_SUCCESS,
104
+ COMPLETENESS_FULL,
105
+ COMPLETENESS_MINIMAL,
106
+ COMPLETENESS_PARTIAL,
107
+ EvidenceEnvelope,
108
+ ProviderAttempt,
109
+ make_attempt,
110
+ make_envelope,
111
+ set_capability_resolver,
112
+ validate_envelope,
113
+ )
114
+
115
+ __version__ = "0.1.0"
116
+
117
+ __all__ = [
118
+ # config
119
+ "configure", "get_config", "get_setting", "reset_config",
120
+ "validate_vipdoc_history_config",
121
+ # errors
122
+ "DeadlineExceeded", "SourceContextDeadlineExceeded", "VendorError",
123
+ "VendorNetworkError", "VendorNoDataError", "VendorNotConfiguredError",
124
+ "VendorRateLimitError",
125
+ # ticker safety / name resolution
126
+ "safe_ticker_component", "resolve_ticker", "name_code_map_ready",
127
+ "ensure_name_code_map_warmup", "reset_mootdx_client",
128
+ # quotes / OHLCV
129
+ "get_realtime_snapshot", "get_ohlcv_frame_cached", "get_stock_data",
130
+ "get_adjusted_bars", "get_stock_monitor", "get_hot_concept_examples",
131
+ # fundamentals / financials
132
+ "get_fundamentals", "get_balance_sheet", "get_cashflow",
133
+ "get_income_statement", "get_free_financial_indicators",
134
+ "get_earnings_forecast", "get_research_reports",
135
+ # corporate events / governance
136
+ "get_corporate_actions", "get_announcement_index",
137
+ "get_disclosure_schedule", "get_suspension_info", "get_delisting_info",
138
+ "get_insider_transactions", "get_shareholder_pledge",
139
+ "get_corporate_buyback",
140
+ # money flow / microstructure
141
+ "get_fund_flow", "get_dragon_tiger_board", "get_block_trades",
142
+ "get_northbound_flow", "get_market_breadth", "get_margin_trading",
143
+ "get_valuation_history", "get_industry_comparison", "get_concept_blocks",
144
+ # news / policy
145
+ "get_news", "get_global_news", "get_policy_news", "get_policy_news_for_context",
146
+ "get_hot_stocks", "get_macro_indicators",
147
+ # calendar / vipdoc history
148
+ "local_is_trading_day", "load_trading_calendar",
149
+ "load_vipdoc_daily", "vipdoc_history_dir", "vipdoc_history_status",
150
+ # provenance
151
+ "ATTEMPT_SUCCESS", "ATTEMPT_NORMAL_EMPTY", "ATTEMPT_FAILED_NETWORK",
152
+ "ATTEMPT_FAILED_AUTH", "ATTEMPT_FAILED_RATE_LIMIT",
153
+ "ATTEMPT_FAILED_STRUCTURE", "ATTEMPT_SKIPPED",
154
+ "ATTEMPT_SKIPPED_DUE_TO_RUN_CIRCUIT",
155
+ "COMPLETENESS_FULL", "COMPLETENESS_PARTIAL", "COMPLETENESS_MINIMAL",
156
+ "EvidenceEnvelope", "ProviderAttempt", "make_attempt", "make_envelope",
157
+ "set_capability_resolver", "validate_envelope",
158
+ ]
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """A small JSON bridge for an isolated easy-tdx runtime.
3
+
4
+ The main application intentionally keeps pandas 3.x. easy-tdx 1.20.6
5
+ declares pandas<3, so this program is executed by ``EASY_TDX_PYTHON`` from a
6
+ separate virtual environment rather than imported by the application process.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import os
14
+ import sys
15
+ import time
16
+ from datetime import date, datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ # 全池选优(from_best_host 会并发 ping 全部 ~52 台候选)的限频窗口。issues/023
22
+ # 止血:此前每次桥调用都全池扫描(资金流一次最多 2 轮 ≈ 104 个并发 TCP 连接),
23
+ # 从机房风控视角等同端口扫描。缓存 best_host 连不上时才允许一次选优,且全机
24
+ # (所有桥子进程共享该 stamp 文件)每窗口最多一次。
25
+ _BEST_HOST_REFRESH_INTERVAL_S = float(
26
+ os.environ.get("TDX_BEST_HOST_REFRESH_INTERVAL_S", str(6 * 3600))
27
+ )
28
+
29
+
30
+ def _refresh_stamp_path() -> Path:
31
+ base = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
32
+ return base / "besthost-refresh.stamp"
33
+
34
+
35
+ def _best_host_refresh_allowed() -> bool:
36
+ try:
37
+ return time.time() - _refresh_stamp_path().stat().st_mtime >= (
38
+ _BEST_HOST_REFRESH_INTERVAL_S
39
+ )
40
+ except OSError:
41
+ return True
42
+
43
+
44
+ def _mark_best_host_refresh() -> None:
45
+ try:
46
+ stamp = _refresh_stamp_path()
47
+ stamp.parent.mkdir(parents=True, exist_ok=True)
48
+ stamp.touch()
49
+ except OSError:
50
+ pass
51
+
52
+
53
+ def _load_clients():
54
+ """Return delayed easy-tdx client factories without importing at module load.
55
+
56
+ Each factory accepts ``refresh=False``: 默认返回绑定 config.json 缓存
57
+ best_host 的未连接 client;``refresh=True`` 才做全池选优(from_best_host)。
58
+ """
59
+ from easy_tdx import MacClient, Market, TdxClient
60
+
61
+ def _factory(cls):
62
+ def make(refresh: bool = False):
63
+ return cls.from_best_host() if refresh else cls()
64
+
65
+ return make
66
+
67
+ return _factory(MacClient), _factory(TdxClient), Market
68
+
69
+
70
+ def _market(market_name: str, market_enum):
71
+ name = market_name.upper()
72
+ if name not in {"SH", "SZ", "BJ"}:
73
+ raise ValueError(f"unsupported market: {market_name}")
74
+ return getattr(market_enum, name)
75
+
76
+
77
+ def _records(frame) -> list[dict[str, Any]]:
78
+ """Normalize a pandas DataFrame or record-like payload to JSON scalars."""
79
+ if frame is None:
80
+ return []
81
+ to_dict = getattr(frame, "to_dict", None)
82
+ raw = to_dict("records") if callable(to_dict) else list(frame)
83
+
84
+ def clean(value: Any):
85
+ if value is None or isinstance(value, (str, bool, int)):
86
+ return value
87
+ if isinstance(value, float):
88
+ return value if math.isfinite(value) else None
89
+ if isinstance(value, (datetime, date)):
90
+ return value.isoformat()
91
+ item = getattr(value, "item", None)
92
+ if callable(item):
93
+ return clean(item())
94
+ return str(value)
95
+
96
+ return [{str(key): clean(value) for key, value in row.items()} for row in raw]
97
+
98
+
99
+ def _change_pct(close: Any, pre_close: Any) -> float | None:
100
+ """板块涨跌幅 = (close - pre_close) / pre_close * 100;缺值/零基准返回 None。"""
101
+ try:
102
+ close_value = float(close)
103
+ pre_close_value = float(pre_close)
104
+ except (TypeError, ValueError):
105
+ return None
106
+ if (
107
+ not math.isfinite(close_value)
108
+ or not math.isfinite(pre_close_value)
109
+ or pre_close_value == 0
110
+ ):
111
+ return None
112
+ return round((close_value - pre_close_value) / pre_close_value * 100, 2)
113
+
114
+
115
+ def _board_type_enum():
116
+ """延迟导入 BoardType:本模块只在隔离 easy-tdx 环境中执行。"""
117
+ from easy_tdx import BoardType
118
+
119
+ return BoardType
120
+
121
+
122
+ def _run_on(factory, operation, *, refresh: bool = False):
123
+ client = factory(refresh=refresh)
124
+ client.connect()
125
+ try:
126
+ return operation(client)
127
+ finally:
128
+ try:
129
+ client.close()
130
+ except Exception:
131
+ pass
132
+
133
+
134
+ def _with_client(factory, operation):
135
+ """Run ``operation`` on one connected client, cached best_host first.
136
+
137
+ 缓存 host 上连接或取数失败(含"TCP 通、协议拒"的 issues/023 形态)且选优
138
+ 窗口允许时,才做一次全池选优重试;窗口内失败直接上抛,由主进程熔断器接管。
139
+ """
140
+ try:
141
+ return _run_on(factory, operation)
142
+ except Exception:
143
+ if not _best_host_refresh_allowed():
144
+ raise
145
+ _mark_best_host_refresh()
146
+ return _run_on(factory, operation, refresh=True)
147
+
148
+
149
+ def execute(argv: list[str]) -> dict[str, Any]:
150
+ """Run a bridge command and return the normalized audit payload."""
151
+ if not argv:
152
+ raise ValueError("missing command")
153
+ command = argv[0]
154
+ if command not in {"fund-flow", "industry-ranking", "belong-board", "concept-ranking"}:
155
+ raise ValueError(f"unsupported command: {command}")
156
+ mac_factory, standard_factory, market_enum = _load_clients()
157
+
158
+ if command == "fund-flow":
159
+ if len(argv) != 4:
160
+ raise ValueError("fund-flow requires market, ticker, and history count")
161
+ market_name, code, count_text = argv[1:]
162
+ if not code.isdigit() or len(code) != 6:
163
+ raise ValueError("ticker must be a 6-digit A-share code")
164
+ try:
165
+ history_count = min(max(int(count_text), 0), 20)
166
+ except ValueError as exc:
167
+ raise ValueError("history count must be an integer") from exc
168
+ market = _market(market_name, market_enum)
169
+ current = _with_client(
170
+ mac_factory, lambda client: client.get_capital_flow(market, code)
171
+ )
172
+ history = []
173
+ if history_count:
174
+ history = _with_client(
175
+ standard_factory,
176
+ lambda client: client.get_history_fund_flow(
177
+ market, code, 0, history_count
178
+ ),
179
+ )
180
+ return {
181
+ "source": "tdx",
182
+ "methodology": "tdx_l1_reconstructed",
183
+ "current": _records(current),
184
+ "history": _records(history),
185
+ }
186
+
187
+ if command == "industry-ranking":
188
+ if len(argv) != 3:
189
+ raise ValueError("industry-ranking requires top and bottom counts")
190
+ try:
191
+ top_n = min(max(int(argv[1]), 1), 50)
192
+ bottom_n = min(max(int(argv[2]), 0), 50)
193
+ except ValueError as exc:
194
+ raise ValueError("ranking counts must be integers") from exc
195
+
196
+ # issues/023 止血:top/bottom 共用同一个 mac 连接(原来各连一次)。
197
+ def _both_rankings(client):
198
+ top = client.get_board_ranking(top_n=top_n, ascending=False)
199
+ bottom = (
200
+ client.get_board_ranking(top_n=bottom_n, ascending=True)
201
+ if bottom_n
202
+ else []
203
+ )
204
+ return top, bottom
205
+
206
+ top, bottom = _with_client(mac_factory, _both_rankings)
207
+ return {
208
+ "source": "tdx",
209
+ "methodology": "tdx_board_aggregate",
210
+ "taxonomy": "tdx_industry",
211
+ "top": _records(top),
212
+ "bottom": _records(bottom),
213
+ }
214
+
215
+ if command == "belong-board":
216
+ if len(argv) != 3:
217
+ raise ValueError("belong-board requires market and ticker")
218
+ market_name, code = argv[1:]
219
+ if not code.isdigit() or len(code) != 6:
220
+ raise ValueError("ticker must be a 6-digit A-share code")
221
+ market = _market(market_name, market_enum)
222
+ frame = _with_client(
223
+ mac_factory, lambda client: client.get_belong_board(market, code)
224
+ )
225
+ boards = _records(frame)
226
+ for board in boards:
227
+ board["change_pct"] = _change_pct(
228
+ board.get("close"), board.get("pre_close")
229
+ )
230
+ return {
231
+ "source": "tdx",
232
+ "methodology": "tdx_board_snapshot",
233
+ "taxonomy": "tdx_board",
234
+ "boards": boards,
235
+ }
236
+
237
+ if command == "concept-ranking":
238
+ if len(argv) != 3:
239
+ raise ValueError("concept-ranking requires top and bottom counts")
240
+ try:
241
+ top_n = min(max(int(argv[1]), 1), 50)
242
+ bottom_n = min(max(int(argv[2]), 0), 50)
243
+ except ValueError as exc:
244
+ raise ValueError("ranking counts must be integers") from exc
245
+
246
+ board_type_enum = _board_type_enum()
247
+
248
+ def _concept_changes(client):
249
+ frame = client.get_board_list(board_type_enum.GN)
250
+ rows: list[dict[str, Any]] = []
251
+ for record in _records(frame):
252
+ change = _change_pct(record.get("price"), record.get("pre_close"))
253
+ name = str(record.get("name") or "").strip()
254
+ if change is None or not name:
255
+ continue
256
+ rows.append(
257
+ {
258
+ "code": record.get("code", ""),
259
+ "name": name,
260
+ "change_pct": change,
261
+ }
262
+ )
263
+ rows.sort(key=lambda item: item["change_pct"], reverse=True)
264
+ return rows
265
+
266
+ # 全市场概念板块一次 board_list 全量返回 price/pre_close,本地算涨跌幅;
267
+ # 不做逐板块成分聚合(概念板块 500+,聚合开销远超行业排行)。
268
+ rows = _with_client(mac_factory, _concept_changes)
269
+ return {
270
+ "source": "tdx",
271
+ "methodology": "tdx_board_snapshot",
272
+ "taxonomy": "tdx_concept",
273
+ "top": rows[:top_n],
274
+ "bottom": rows[-bottom_n:][::-1] if bottom_n else [],
275
+ }
276
+
277
+ raise AssertionError("validated command was not handled")
278
+
279
+
280
+ def main() -> int:
281
+ try:
282
+ print(json.dumps(execute(sys.argv[1:]), ensure_ascii=False, allow_nan=False))
283
+ return 0
284
+ except Exception as exc: # noqa: BLE001 - bridge process boundary
285
+ print(
286
+ json.dumps({"error_type": type(exc).__name__}, ensure_ascii=False),
287
+ file=sys.stderr,
288
+ )
289
+ return 2
290
+
291
+
292
+ if __name__ == "__main__":
293
+ raise SystemExit(main())