mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 22:44:17 +08:00
Add live fallbacks for history fund flow and price limits
This commit is contained in:
+240
-99
@@ -1,11 +1,13 @@
|
||||
"""高层行情 API:TdxClient(同步)和 AsyncTdxClient(asyncio)。"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import TracebackType
|
||||
from typing import TypeVar
|
||||
|
||||
from .codec.block import parse_block_dat
|
||||
from .codec.industry import parse_tdxhy_cfg
|
||||
from .codec.price_rules import compute_price_limits, get_no_limit_window_days
|
||||
from .commands.base import BaseCommand
|
||||
from .commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd
|
||||
from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd
|
||||
@@ -34,6 +36,83 @@ _DEFAULT_PORT = 7709
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def _record_signature(
|
||||
record: TransactionRecord,
|
||||
) -> tuple[int, int, float, int, int, int]:
|
||||
return (
|
||||
record.hour,
|
||||
record.minute,
|
||||
record.price,
|
||||
record.vol,
|
||||
record.buyorsell,
|
||||
record.unknown_last,
|
||||
)
|
||||
|
||||
|
||||
def _page_signature(
|
||||
records: list[TransactionRecord],
|
||||
) -> tuple[tuple[int, int, float, int, int, int], tuple[int, int, float, int, int, int]]:
|
||||
return (_record_signature(records[0]), _record_signature(records[-1]))
|
||||
|
||||
|
||||
def _classify_fund_flow(records: list[TransactionRecord]) -> FundFlow:
|
||||
stats = {
|
||||
"super_in": 0.0,
|
||||
"large_in": 0.0,
|
||||
"medium_in": 0.0,
|
||||
"small_in": 0.0,
|
||||
"super_out": 0.0,
|
||||
"large_out": 0.0,
|
||||
"medium_out": 0.0,
|
||||
"small_out": 0.0,
|
||||
}
|
||||
|
||||
for record in records:
|
||||
amount = record.price * record.vol * 100.0
|
||||
direction = (
|
||||
"in" if record.buyorsell == 0 else "out" if record.buyorsell == 1 else None
|
||||
)
|
||||
if not direction:
|
||||
continue
|
||||
|
||||
if amount >= 1_000_000:
|
||||
stats[f"super_{direction}"] += amount
|
||||
elif amount >= 200_000:
|
||||
stats[f"large_{direction}"] += amount
|
||||
elif amount >= 40_000:
|
||||
stats[f"medium_{direction}"] += amount
|
||||
else:
|
||||
stats[f"small_{direction}"] += amount
|
||||
|
||||
return FundFlow(**stats)
|
||||
|
||||
|
||||
def _date_from_bar(bar: SecurityBar) -> int:
|
||||
return bar.year * 10000 + bar.month * 100 + bar.day
|
||||
|
||||
|
||||
def _historical_fund_flow_from_records(
|
||||
date: int, records: list[TransactionRecord]
|
||||
) -> HistoricalFundFlow:
|
||||
flow = _classify_fund_flow(records)
|
||||
year = date // 10000
|
||||
month = (date // 100) % 100
|
||||
day = date % 100
|
||||
return HistoricalFundFlow(
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
super_in=flow.super_in,
|
||||
super_out=flow.super_out,
|
||||
large_in=flow.large_in,
|
||||
large_out=flow.large_out,
|
||||
medium_in=flow.medium_in,
|
||||
medium_out=flow.medium_out,
|
||||
small_in=flow.small_in,
|
||||
small_out=flow.small_out,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步客户端
|
||||
# ============================================================
|
||||
@@ -195,6 +274,32 @@ class TdxClient:
|
||||
"""批量获取实时五档行情(最多80只/次)。"""
|
||||
return self._execute(GetSecurityQuotesCmd(stocks))
|
||||
|
||||
def get_price_limits(
|
||||
self, market: Market, code: str, name: str, pre_close: float
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""按当前交易状态计算涨跌停价。
|
||||
|
||||
对上市初期不设涨跌幅限制的标的,会先用日 K 线条数估算已上市交易天数。
|
||||
"""
|
||||
listed_days: int | None = None
|
||||
no_limit_window_days = get_no_limit_window_days(market, code, name)
|
||||
if no_limit_window_days > 0:
|
||||
try:
|
||||
bars = self.get_security_bars(
|
||||
market, code, KlineCategory.DAY, 0, no_limit_window_days + 1
|
||||
)
|
||||
listed_days = len(bars)
|
||||
except Exception:
|
||||
listed_days = None
|
||||
|
||||
return compute_price_limits(
|
||||
market,
|
||||
code,
|
||||
name,
|
||||
pre_close,
|
||||
listed_days=listed_days,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# K 线
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -340,81 +445,84 @@ class TdxClient:
|
||||
total_volume=q.vol,
|
||||
)
|
||||
|
||||
def get_fund_flow(self, market: Market, code: str) -> FundFlow:
|
||||
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
|
||||
# 1. 分页拉取当日分笔并去重
|
||||
def _collect_transaction_records(
|
||||
self,
|
||||
fetch_page: Callable[[int, int], list[TransactionRecord]],
|
||||
page_size: int,
|
||||
max_start: int = 10000,
|
||||
) -> list[TransactionRecord]:
|
||||
all_recs: list[TransactionRecord] = []
|
||||
seen_sig = set()
|
||||
seen_page_sigs = set()
|
||||
seen_sig: set[tuple[int, int, float, int, int, int]] = set()
|
||||
seen_page_sigs: set[
|
||||
tuple[
|
||||
tuple[int, int, float, int, int, int],
|
||||
tuple[int, int, float, int, int, int],
|
||||
]
|
||||
] = set()
|
||||
start = 0
|
||||
|
||||
while start < 10000:
|
||||
recs = self.get_transaction_data(market, code, start, 2000)
|
||||
|
||||
while start < max_start:
|
||||
recs = fetch_page(start, page_size)
|
||||
if not recs:
|
||||
break
|
||||
|
||||
# 页签名判断:首尾记录组合
|
||||
page_sig = (
|
||||
(
|
||||
recs[0].hour, recs[0].minute, recs[0].price,
|
||||
recs[0].vol, recs[0].buyorsell, recs[0].unknown_last
|
||||
),
|
||||
(
|
||||
recs[-1].hour, recs[-1].minute, recs[-1].price,
|
||||
recs[-1].vol, recs[-1].buyorsell, recs[-1].unknown_last
|
||||
),
|
||||
)
|
||||
|
||||
page_sig = _page_signature(recs)
|
||||
if page_sig in seen_page_sigs:
|
||||
break
|
||||
seen_page_sigs.add(page_sig)
|
||||
|
||||
new_count = 0
|
||||
for r in recs:
|
||||
sig = (r.hour, r.minute, r.price, r.vol, r.buyorsell, r.unknown_last)
|
||||
for record in recs:
|
||||
sig = _record_signature(record)
|
||||
if sig not in seen_sig:
|
||||
seen_sig.add(sig)
|
||||
all_recs.append(r)
|
||||
all_recs.append(record)
|
||||
new_count += 1
|
||||
|
||||
|
||||
if new_count == 0:
|
||||
break
|
||||
|
||||
|
||||
start += len(recs)
|
||||
if len(recs) < 100:
|
||||
break
|
||||
|
||||
# 2. 统计逻辑
|
||||
# A 股标准:超大(>100w), 大单(20w-100w), 中单(4w-20w), 小单(<4w)
|
||||
stats = {
|
||||
"super_in": 0.0, "large_in": 0.0, "medium_in": 0.0, "small_in": 0.0,
|
||||
"super_out": 0.0, "large_out": 0.0, "medium_out": 0.0, "small_out": 0.0,
|
||||
}
|
||||
|
||||
for r in all_recs:
|
||||
amount = r.price * r.vol * 100.0 # A股 1手=100股
|
||||
direction = "in" if r.buyorsell == 0 else "out" if r.buyorsell == 1 else None
|
||||
if not direction:
|
||||
continue
|
||||
|
||||
if amount >= 1000000:
|
||||
stats[f"super_{direction}"] += amount
|
||||
elif amount >= 200000:
|
||||
stats[f"large_{direction}"] += amount
|
||||
elif amount >= 40000:
|
||||
stats[f"medium_{direction}"] += amount
|
||||
else:
|
||||
stats[f"small_{direction}"] += amount
|
||||
|
||||
return FundFlow(**stats)
|
||||
|
||||
return all_recs
|
||||
|
||||
def get_fund_flow(self, market: Market, code: str) -> FundFlow:
|
||||
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
|
||||
records = self._collect_transaction_records(
|
||||
lambda start, page_size: self.get_transaction_data(market, code, start, page_size),
|
||||
2000,
|
||||
)
|
||||
return _classify_fund_flow(records)
|
||||
|
||||
def get_history_fund_flow(
|
||||
self, market: Market, code: str, start: int, count: int
|
||||
) -> list[HistoricalFundFlow]:
|
||||
"""获取个股历史日线资金流向序列(Category 22)。
|
||||
"""获取个股历史日线资金流向序列。
|
||||
|
||||
[EXPERIMENTAL] 当前多台公开主机对该请求仍可能返回空列表。
|
||||
优先走 Category 22 直连接口;若服务器返回空列表,则自动回退为
|
||||
“日 K 线取日期 + 历史逐笔成交重算资金流”的兼容实现。
|
||||
"""
|
||||
return self._execute(GetHistoryFundFlowCmd(market, code, start, count))
|
||||
try:
|
||||
direct = self._execute(GetHistoryFundFlowCmd(market, code, start, count))
|
||||
except Exception:
|
||||
direct = []
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
bars = self.get_security_bars(market, code, KlineCategory.DAY, start, count)
|
||||
results: list[HistoricalFundFlow] = []
|
||||
for bar in bars:
|
||||
date = _date_from_bar(bar)
|
||||
records = self._collect_transaction_records(
|
||||
lambda page_start, page_size: self.get_history_transaction_data(
|
||||
market, code, date, page_start, page_size
|
||||
),
|
||||
800,
|
||||
)
|
||||
results.append(_historical_fund_flow_from_records(date, records))
|
||||
return results
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -588,6 +696,29 @@ class AsyncTdxClient:
|
||||
) -> list[SecurityQuote]:
|
||||
return await self._execute(GetSecurityQuotesCmd(stocks))
|
||||
|
||||
async def get_price_limits(
|
||||
self, market: Market, code: str, name: str, pre_close: float
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""按当前交易状态计算涨跌停价。"""
|
||||
listed_days: int | None = None
|
||||
no_limit_window_days = get_no_limit_window_days(market, code, name)
|
||||
if no_limit_window_days > 0:
|
||||
try:
|
||||
bars = await self.get_security_bars(
|
||||
market, code, KlineCategory.DAY, 0, no_limit_window_days + 1
|
||||
)
|
||||
listed_days = len(bars)
|
||||
except Exception:
|
||||
listed_days = None
|
||||
|
||||
return compute_price_limits(
|
||||
market,
|
||||
code,
|
||||
name,
|
||||
pre_close,
|
||||
listed_days=listed_days,
|
||||
)
|
||||
|
||||
async def get_security_bars(
|
||||
self,
|
||||
market: Market,
|
||||
@@ -703,73 +834,83 @@ class AsyncTdxClient:
|
||||
total_volume=q.vol,
|
||||
)
|
||||
|
||||
async def get_fund_flow(self, market: Market, code: str) -> FundFlow:
|
||||
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
|
||||
# 1. 分页拉取当日分笔并去重
|
||||
async def _collect_transaction_records(
|
||||
self,
|
||||
fetch_page: Callable[[int, int], Awaitable[list[TransactionRecord]]],
|
||||
page_size: int,
|
||||
max_start: int = 10000,
|
||||
) -> list[TransactionRecord]:
|
||||
all_recs: list[TransactionRecord] = []
|
||||
seen_sig = set()
|
||||
seen_page_sigs = set()
|
||||
seen_sig: set[tuple[int, int, float, int, int, int]] = set()
|
||||
seen_page_sigs: set[
|
||||
tuple[
|
||||
tuple[int, int, float, int, int, int],
|
||||
tuple[int, int, float, int, int, int],
|
||||
]
|
||||
] = set()
|
||||
start = 0
|
||||
|
||||
while start < 10000:
|
||||
recs = await self.get_transaction_data(market, code, start, 2000)
|
||||
|
||||
while start < max_start:
|
||||
recs = await fetch_page(start, page_size)
|
||||
if not recs:
|
||||
break
|
||||
|
||||
# 页签名判断:首尾记录组合
|
||||
page_sig = (
|
||||
(
|
||||
recs[0].hour, recs[0].minute, recs[0].price,
|
||||
recs[0].vol, recs[0].buyorsell, recs[0].unknown_last
|
||||
),
|
||||
(
|
||||
recs[-1].hour, recs[-1].minute, recs[-1].price,
|
||||
recs[-1].vol, recs[-1].buyorsell, recs[-1].unknown_last
|
||||
),
|
||||
)
|
||||
|
||||
page_sig = _page_signature(recs)
|
||||
if page_sig in seen_page_sigs:
|
||||
break
|
||||
seen_page_sigs.add(page_sig)
|
||||
|
||||
new_count = 0
|
||||
for r in recs:
|
||||
sig = (r.hour, r.minute, r.price, r.vol, r.buyorsell, r.unknown_last)
|
||||
for record in recs:
|
||||
sig = _record_signature(record)
|
||||
if sig not in seen_sig:
|
||||
seen_sig.add(sig)
|
||||
all_recs.append(r)
|
||||
all_recs.append(record)
|
||||
new_count += 1
|
||||
|
||||
|
||||
if new_count == 0:
|
||||
break
|
||||
|
||||
|
||||
start += len(recs)
|
||||
if len(recs) < 100:
|
||||
break
|
||||
|
||||
stats = {
|
||||
"super_in": 0.0, "large_in": 0.0, "medium_in": 0.0, "small_in": 0.0,
|
||||
"super_out": 0.0, "large_out": 0.0, "medium_out": 0.0, "small_out": 0.0,
|
||||
}
|
||||
for r in all_recs:
|
||||
amount = r.price * r.vol * 100.0
|
||||
direction = "in" if r.buyorsell == 0 else "out" if r.buyorsell == 1 else None
|
||||
if not direction:
|
||||
continue
|
||||
if amount >= 1000000:
|
||||
stats[f"super_{direction}"] += amount
|
||||
elif amount >= 200000:
|
||||
stats[f"large_{direction}"] += amount
|
||||
elif amount >= 40000:
|
||||
stats[f"medium_{direction}"] += amount
|
||||
else:
|
||||
stats[f"small_{direction}"] += amount
|
||||
return FundFlow(**stats)
|
||||
|
||||
return all_recs
|
||||
|
||||
async def get_fund_flow(self, market: Market, code: str) -> FundFlow:
|
||||
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
|
||||
records = await self._collect_transaction_records(
|
||||
lambda start, page_size: self.get_transaction_data(
|
||||
market, code, start, page_size
|
||||
),
|
||||
2000,
|
||||
)
|
||||
return _classify_fund_flow(records)
|
||||
|
||||
async def get_history_fund_flow(
|
||||
self, market: Market, code: str, start: int, count: int
|
||||
) -> list[HistoricalFundFlow]:
|
||||
"""获取个股历史日线资金流向序列(Category 22)。
|
||||
"""获取个股历史日线资金流向序列。
|
||||
|
||||
[EXPERIMENTAL] 当前多台公开主机对该请求仍可能返回空列表。
|
||||
优先走 Category 22 直连接口;若服务器返回空列表,则自动回退为
|
||||
“日 K 线取日期 + 历史逐笔成交重算资金流”的兼容实现。
|
||||
"""
|
||||
return await self._execute(GetHistoryFundFlowCmd(market, code, start, count))
|
||||
try:
|
||||
direct = await self._execute(GetHistoryFundFlowCmd(market, code, start, count))
|
||||
except Exception:
|
||||
direct = []
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
bars = await self.get_security_bars(market, code, KlineCategory.DAY, start, count)
|
||||
results: list[HistoricalFundFlow] = []
|
||||
for bar in bars:
|
||||
date = _date_from_bar(bar)
|
||||
records = await self._collect_transaction_records(
|
||||
lambda page_start, page_size: self.get_history_transaction_data(
|
||||
market, code, date, page_start, page_size
|
||||
),
|
||||
800,
|
||||
)
|
||||
results.append(_historical_fund_flow_from_records(date, records))
|
||||
return results
|
||||
|
||||
@@ -4,12 +4,46 @@ from ..models.enums import Market
|
||||
from ..models.finance import FinanceInfo
|
||||
|
||||
|
||||
def get_no_limit_window_days(market: Market, code: str, name: str) -> int:
|
||||
"""返回上市初期不设涨跌幅限制的交易日窗口。
|
||||
|
||||
返回值:
|
||||
0: 默认按常规涨跌幅限制处理
|
||||
1: 北交所上市首日不设涨跌幅限制
|
||||
5: 沪深主板/创业板/科创板上市前 5 个交易日不设涨跌幅限制
|
||||
"""
|
||||
if _is_index_like(market, code, name):
|
||||
return 0
|
||||
|
||||
if code.startswith(("43", "83", "87", "92")):
|
||||
return 1
|
||||
|
||||
if market == Market.SH and code.startswith(("60", "68")):
|
||||
return 5
|
||||
if market == Market.SZ and code.startswith(("00", "30")):
|
||||
return 5
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _is_index_like(market: Market, code: str, name: str) -> bool:
|
||||
"""判断是否为指数/板块类代码。"""
|
||||
if market == Market.SH and code.startswith(
|
||||
("000", "880", "881", "882", "883", "884", "885", "999")
|
||||
):
|
||||
return True
|
||||
if market == Market.SZ and code.startswith(("395", "399")):
|
||||
return True
|
||||
return "指数" in name or "板块" in name
|
||||
|
||||
|
||||
def compute_price_limits(
|
||||
market: Market,
|
||||
code: str,
|
||||
name: str,
|
||||
pre_close: float,
|
||||
finance_info: FinanceInfo | None = None,
|
||||
listed_days: int | None = None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""根据板块规则计算涨跌停价。
|
||||
|
||||
@@ -17,6 +51,11 @@ def compute_price_limits(
|
||||
(limit_up, limit_down)
|
||||
|
||||
无涨跌幅限制或当前规则无法可靠判断时返回 ``(None, None)``。
|
||||
|
||||
Args:
|
||||
listed_days:
|
||||
已上市交易天数(按交易日计,首日=1)。
|
||||
若提供该值,函数会按上市初期无涨跌幅限制规则优先返回 ``(None, None)``。
|
||||
"""
|
||||
if pre_close <= 0:
|
||||
return None, None
|
||||
@@ -24,18 +63,11 @@ def compute_price_limits(
|
||||
upper_name = name.upper()
|
||||
|
||||
# 指数/板块类代码通常无涨跌停。
|
||||
# 这里优先用明确的指数代码段判断,再用名称兜底,避免把真实股票误判成指数。
|
||||
is_index = False
|
||||
if market == Market.SH and code.startswith(
|
||||
("000", "880", "881", "882", "883", "884", "885", "999")
|
||||
):
|
||||
is_index = True
|
||||
elif market == Market.SZ and code.startswith(("395", "399")):
|
||||
is_index = True
|
||||
elif "指数" in name or "板块" in name:
|
||||
is_index = True
|
||||
if _is_index_like(market, code, name):
|
||||
return None, None
|
||||
|
||||
if is_index:
|
||||
no_limit_window_days = get_no_limit_window_days(market, code, name)
|
||||
if listed_days is not None and 0 < listed_days <= no_limit_window_days:
|
||||
return None, None
|
||||
|
||||
limit_pct = 0.10 # 默认 10%
|
||||
@@ -50,7 +82,7 @@ def compute_price_limits(
|
||||
elif code.startswith(("43", "83", "87", "92")):
|
||||
limit_pct = 0.30
|
||||
|
||||
# TODO: 上市前 5 日无涨跌幅限制判断(需要 ipo_date 或更明确的上市状态标识)。
|
||||
# `listed_days` 是更可靠的交易日维度输入;finance_info 仍保留给上层调用方扩展。
|
||||
_ = finance_info
|
||||
|
||||
def _round_price(p: float) -> float:
|
||||
|
||||
Reference in New Issue
Block a user