optz
This commit is contained in:
@@ -35,11 +35,19 @@ py -3.14 -m venv .venv
|
||||
|
||||
## 验证与性能
|
||||
|
||||
测试与基准已统一迁到仓库根的 `labs/`(试验与测试代码集中目录),
|
||||
从**仓库根目录**执行:
|
||||
|
||||
```powershell
|
||||
.venv/Scripts/python.exe -B -m unittest discover -s tests -v
|
||||
.venv/Scripts/python.exe -B benchmarks/hotpaths.py
|
||||
py -3.14 -B labs/run_tests.py # 全部离线测试(统一入口)
|
||||
py -3.14 -B labs/run_tests.py -v # 详细
|
||||
py -3.14 -B labs/benchmarks/hotpaths.py # 微基准
|
||||
```
|
||||
|
||||
`labs/run_tests.py` 会自动把本目录(`py-client`)挂上 `sys.path`,
|
||||
所以不需要先 `cd py-client`;测试模块用 `from tests.zt_harness import ...`
|
||||
这类绝对导入,也由该入口统一处理顶层包名。
|
||||
|
||||
130 项离线测试通过,覆盖 SDK 与 API 字段契约、委托簿、配置校验、IPO 申购状态机、
|
||||
ZT 轮次状态机(正T/反T)、Trend 采集任务与 Python 3.14 回归。
|
||||
测试使用模拟客户端和临时目录,不启动真实交易、不访问真实接口。
|
||||
@@ -64,7 +72,8 @@ ZT 轮次状态机(正T/反T)、Trend 采集任务与 Python 3.14 回归。
|
||||
|
||||
## ETF 自适应网格策略
|
||||
|
||||
入口为 `strategy: etf`,标的和参数见 [`etc/etf.yaml`](etc/etf.yaml),
|
||||
入口为 `strategy: etf`,标的和参数见 [`etc/_etf.yaml`](etc/_etf.yaml)(由
|
||||
`config.load()` 按固定文件名加载,文件不存在时为 `None`),
|
||||
完整规则和启用步骤见 [`strategy/etf/README.md`](strategy/etf/README.md)。
|
||||
|
||||
## ZT 做 T 策略(2026-09 重构)
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import math
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass, field, fields
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# 场内 ETF 代码:沪市 5 开头(510300.SH),深市 15/16/18 开头(159915.SZ);
|
||||
# 必须带交易所后缀,且键与接口返回的 ts_code 完全一致。
|
||||
ETF_CODE_PATTERN = re.compile(r"(?:5[0-9]{5}\.SH|1[568][0-9]{4}\.SZ)")
|
||||
|
||||
# 标的段允许覆盖的全局参数;账户级参数(如 add_pct、commission_rate)不允许覆盖。
|
||||
SYMBOL_OVERRIDE = ("inner_grids", "max_grid_span_pct", "rebound_pct", "max_hold_days")
|
||||
|
||||
# load() 成功后保存已加载的配置,供策略模块直接读取。
|
||||
global_config: GlobalConfig | None = None
|
||||
account_config: AccountConfig | None = None
|
||||
# _etf.yaml 不存在时为 None:只有 ETF 策略需要该文件。
|
||||
etf_config: EtfConfig | None = None
|
||||
|
||||
# QMT 和外部 HTTP 接口的默认请求超时时间,单位为秒。
|
||||
HTTP_TIMEOUT = 5.0
|
||||
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SignalConfig:
|
||||
@@ -60,27 +79,117 @@ class AccountConfig:
|
||||
|
||||
# 当前账户启用的策略名称,例如 trend。
|
||||
strategy: str = ""
|
||||
# 为空时读取 py-client/etc/etf.yaml;非空路径相对于账户配置目录。
|
||||
etf_config_path: str = ""
|
||||
|
||||
|
||||
# load() 成功后保存已加载的配置,供策略模块直接读取。
|
||||
global_config: GlobalConfig | None = None
|
||||
account_config: AccountConfig | None = None
|
||||
@dataclass(slots=True)
|
||||
class EtfDefaults:
|
||||
"""ETF 网格策略的全局默认参数,来自 ``_etf.yaml`` 的 ``defaults`` 段。
|
||||
|
||||
# QMT 和外部 HTTP 接口的默认请求超时时间,单位为秒。
|
||||
HTTP_TIMEOUT = 5.0
|
||||
字段顺序与 ``etc/_etf.yaml`` 保持一致,数值口径见 ``docs/etf.md`` §6。
|
||||
"""
|
||||
|
||||
atr_period: int = 14
|
||||
# 格距 = max(ATR × atr_multiplier, MA60 × min_grid_pct/100, 0.001)。
|
||||
min_grid_pct: float = 0.5
|
||||
# 跨度健康度告警线,超线只告警并建议下调 atr_multiplier,不阻止建网。
|
||||
max_grid_span_pct: float = 40.0
|
||||
# 区间通道回看天数,以及"距区间下沿多少百分比以内算低位"。
|
||||
channel_period: int = 20
|
||||
channel_pct: float = 15.0
|
||||
# 建仓与补仓共用的反弹确认阈值,必须小于 add_pct。
|
||||
rebound_pct: float = 0.5
|
||||
# 补仓触发:自上一档成交价再跌该百分比(需配合反弹确认)。
|
||||
add_pct: float = 3.0
|
||||
# 补仓次数上限,总档数 = max_adds + 1(含底仓)。
|
||||
max_adds: int = 9
|
||||
watch_seconds: int = 600
|
||||
# 主出口:盈亏率 ≥ 该值即整仓清掉。
|
||||
min_profit_pct: float = 1.0
|
||||
# 副出口:峰值至少抬到第 N 格才允许回撤卖出。
|
||||
inner_grids: float = 2.0
|
||||
# 买入日当天不挂卖单;is_t0 为真的标的跳过本条。
|
||||
min_hold_days: int = 1
|
||||
# 0 表示不止损。
|
||||
max_hold_days: int = 0
|
||||
commission_rate: float = 0.0003
|
||||
min_commission: float = 5.0
|
||||
max_tick_age_seconds: int = 90
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EtfSymbolConfig:
|
||||
"""单个标的的参数;未覆盖的字段为 ``None``,由 ``get()`` 继承全局默认。"""
|
||||
|
||||
# 是否支持当日回转;必须显式配置,没有安全默认值。
|
||||
is_t0: bool = False
|
||||
# 每档每次买入股数,100 的整数倍。
|
||||
buy_shares: int = 0
|
||||
# 格距的 ATR 倍数,决定阶梯跨度,必须逐标的标定。
|
||||
atr_multiplier: float = 0.0
|
||||
# 内层格距(盈亏率百分点),决定副出口能否被触发。
|
||||
inner_step: float = 0.0
|
||||
# 单标的总持仓上限;缺省按 max_adds + 1 档计算。
|
||||
max_shares: int | None = None
|
||||
|
||||
inner_grids: float | None = None
|
||||
max_grid_span_pct: float | None = None
|
||||
rebound_pct: float | None = None
|
||||
max_hold_days: int | None = None
|
||||
|
||||
# 同一份 _etf.yaml 的全局默认值,不参与相等性比较。
|
||||
defaults: EtfDefaults = field(default_factory=EtfDefaults, repr=False, compare=False)
|
||||
|
||||
def get(self, name: str):
|
||||
"""返回生效参数:标的覆盖优先,未覆盖时回落到全局默认。"""
|
||||
if name not in SYMBOL_OVERRIDE:
|
||||
raise KeyError(f"标的配置不支持覆盖参数 {name}")
|
||||
value = getattr(self, name)
|
||||
return getattr(self.defaults, name) if value is None else value
|
||||
|
||||
def effective(self) -> dict:
|
||||
"""返回全部生效参数,供日志和自检使用。"""
|
||||
values = {name: getattr(self, name) for name in self.override_names()}
|
||||
values.update({name: self.get(name) for name in SYMBOL_OVERRIDE})
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def override_names(cls) -> tuple[str, ...]:
|
||||
"""标的段允许出现在 YAML 中的字段名。"""
|
||||
return tuple(item.name for item in fields(cls) if item.name != "defaults")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EtfConfig:
|
||||
"""``_etf.yaml`` 的完整内容:全局默认 + 标的白名单。"""
|
||||
|
||||
defaults: EtfDefaults = field(default_factory=EtfDefaults)
|
||||
# 键为证券代码,顺序即资金优先级;每项已绑定全局默认,便于 ``get()`` 回退。
|
||||
symbols: dict[str, EtfSymbolConfig] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def codes(self) -> tuple[str, ...]:
|
||||
"""按配置顺序返回标的代码。"""
|
||||
return tuple(self.symbols)
|
||||
|
||||
def symbol(self, code: str) -> EtfSymbolConfig:
|
||||
"""返回指定标的的配置;不在白名单内时抛 KeyError。"""
|
||||
try:
|
||||
return self.symbols[code]
|
||||
except KeyError:
|
||||
raise KeyError(f"证券 {code} 不在 _etf.yaml 的白名单内") from None
|
||||
|
||||
|
||||
def load(
|
||||
etc_dir: str | Path | None = None,
|
||||
hostname: str | None = None,
|
||||
) -> tuple[GlobalConfig, AccountConfig]:
|
||||
"""加载公共配置以及当前主机对应的账户配置。
|
||||
) -> tuple[GlobalConfig, AccountConfig, EtfConfig]:
|
||||
"""加载公共配置、当前主机的账户配置以及 ETF 策略配置。
|
||||
|
||||
Args:
|
||||
etc_dir: 配置文件目录,其中必须包含 ``_global.yaml``;为空时
|
||||
默认使用 py-client 下的 ``etc`` 目录。
|
||||
默认使用 py-client 下的 ``etc`` 目录。ETF 策略的
|
||||
``_etf.yaml`` 同样在该目录下按固定文件名查找,不存在时
|
||||
``etf_config`` 为 None,不视为错误。
|
||||
hostname: 指定要加载的主机名;为空时使用当前计算机名。
|
||||
|
||||
Returns:
|
||||
@@ -89,7 +198,7 @@ def load(
|
||||
Raises:
|
||||
ValueError: 配置缺失、格式错误或策略参数不合法。
|
||||
"""
|
||||
global global_config, account_config
|
||||
global global_config, account_config, etf_config
|
||||
|
||||
root = Path(etc_dir) if etc_dir is not None else Path(__file__).parent.parent / "etc"
|
||||
raw = _yaml(root / "_global.yaml")
|
||||
@@ -134,11 +243,6 @@ def load(
|
||||
Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
account_config = AccountConfig(**_account_values(root / account_file))
|
||||
if account_config.etf_config_path:
|
||||
etf_path = Path(account_config.etf_config_path)
|
||||
account_config.etf_config_path = str(etf_path if etf_path.is_absolute() else root / etf_path)
|
||||
elif account_config.strategy.strip().lower() == 'etf':
|
||||
account_config.etf_config_path = str(root / 'etf.yaml')
|
||||
if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0:
|
||||
raise ValueError("buy_value、grid_step_pct 必须大于 0")
|
||||
if type(account_config.zt_open_hands) is not int or account_config.zt_open_hands < 0:
|
||||
@@ -159,7 +263,10 @@ def load(
|
||||
account_config.strategy = account_config.strategy.lower()
|
||||
if account_config.strategy == "zt" and account_config.signal_allow != ["dcm"]:
|
||||
raise ValueError("zt 策略的 signal_allow 必须且只能为 [\"dcm\"]")
|
||||
return global_config, account_config
|
||||
|
||||
# ETF 参数是固定文件名,缺文件时返回 None,由 ETF 策略自行决定是否必须。
|
||||
etf_config = _etf_config(root / "_etf.yaml")
|
||||
return global_config, account_config, etf_config
|
||||
|
||||
|
||||
def _yaml(path: Path) -> dict:
|
||||
@@ -184,3 +291,233 @@ def _account_values(path: Path) -> dict:
|
||||
if unknown:
|
||||
raise ValueError(f"账户配置 {path} 存在未知字段: {', '.join(unknown)}")
|
||||
return raw
|
||||
|
||||
|
||||
def _etf_config(path: Path) -> EtfConfig | None:
|
||||
"""读取固定文件名的 ETF 配置;文件不存在时返回 None。
|
||||
|
||||
只有 ETF 策略需要 ``_etf.yaml``,因此缺文件不是错误;文件存在但内容
|
||||
不合法(含未知键)仍然是错误,避免拼错参数被静默忽略。
|
||||
"""
|
||||
if not path.is_file():
|
||||
return None
|
||||
return _parse_etf(_yaml(path), path)
|
||||
|
||||
|
||||
def _parse_etf(raw: dict, path: Path) -> EtfConfig:
|
||||
"""把 ``_etf.yaml`` 的内容转换为带校验的 ``EtfConfig``。"""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"ETF 配置 {path} 的根节点必须是对象")
|
||||
|
||||
unknown = sorted(set(raw) - {"defaults", "symbols"})
|
||||
if unknown:
|
||||
raise ValueError(f"ETF 配置 {path} 存在未知字段: {', '.join(unknown)}")
|
||||
|
||||
defaults = _etf_defaults(raw.get("defaults") or {}, path)
|
||||
raw_symbols = raw.get("symbols") or {}
|
||||
if not isinstance(raw_symbols, dict) or not raw_symbols:
|
||||
raise ValueError(f"ETF 配置 {path} 的 symbols 必须是非空标的映射")
|
||||
|
||||
symbols: dict[str, EtfSymbolConfig] = {}
|
||||
for code, values in raw_symbols.items():
|
||||
symbols[code] = _etf_symbol(code, values or {}, defaults, path)
|
||||
return EtfConfig(defaults=defaults, symbols=symbols)
|
||||
|
||||
|
||||
def _etf_defaults(values: dict, path: Path) -> EtfDefaults:
|
||||
"""校验 ``defaults`` 段并补齐缺省字段,口径见 ``docs/etf.md`` §6。"""
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError(f"ETF 配置 {path} 的 defaults 必须是对象")
|
||||
|
||||
unknown = sorted(set(values) - {item.name for item in fields(EtfDefaults)})
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"ETF 配置 {path} 的 defaults 存在未知字段: {', '.join(unknown)}"
|
||||
)
|
||||
|
||||
section = "defaults"
|
||||
atr_period = _etf_int(values.get("atr_period", 14), "atr_period", path, section)
|
||||
channel_period = _etf_int(
|
||||
values.get("channel_period", 20), "channel_period", path, section
|
||||
)
|
||||
watch_seconds = _etf_int(
|
||||
values.get("watch_seconds", 600), "watch_seconds", path, section
|
||||
)
|
||||
max_tick_age_seconds = _etf_int(
|
||||
values.get("max_tick_age_seconds", 90), "max_tick_age_seconds", path, section
|
||||
)
|
||||
min_hold_days = _etf_int(
|
||||
values.get("min_hold_days", 1), "min_hold_days", path, section
|
||||
)
|
||||
max_hold_days = _etf_int(
|
||||
values.get("max_hold_days", 0), "max_hold_days", path, section, zero_ok=True
|
||||
)
|
||||
max_adds = _etf_int(values.get("max_adds", 9), "max_adds", path, section, zero_ok=True)
|
||||
if max_adds > 9:
|
||||
raise ValueError(f"ETF 配置 {path} 的 max_adds 不能大于 9(总档数 10 档)")
|
||||
|
||||
min_grid_pct = _etf_number(
|
||||
values.get("min_grid_pct", 0.5), "min_grid_pct", path, section
|
||||
)
|
||||
max_grid_span_pct = _etf_number(
|
||||
values.get("max_grid_span_pct", 40.0),
|
||||
"max_grid_span_pct",
|
||||
path,
|
||||
section,
|
||||
high=100.0,
|
||||
)
|
||||
channel_pct = _etf_number(
|
||||
values.get("channel_pct", 15.0), "channel_pct", path, section
|
||||
)
|
||||
rebound_pct = _etf_number(
|
||||
values.get("rebound_pct", 0.5), "rebound_pct", path, section
|
||||
)
|
||||
add_pct = _etf_number(values.get("add_pct", 3.0), "add_pct", path, section)
|
||||
min_profit_pct = _etf_number(
|
||||
values.get("min_profit_pct", 1.0), "min_profit_pct", path, section
|
||||
)
|
||||
inner_grids = _etf_number(
|
||||
values.get("inner_grids", 2.0), "inner_grids", path, section
|
||||
)
|
||||
commission_rate = _etf_number(
|
||||
values.get("commission_rate", 0.0003),
|
||||
"commission_rate",
|
||||
path,
|
||||
section,
|
||||
zero_ok=True,
|
||||
)
|
||||
min_commission = _etf_number(
|
||||
values.get("min_commission", 5.0), "min_commission", path, section, zero_ok=True
|
||||
)
|
||||
|
||||
# 反弹确认必须早于补仓触发,否则确认价回到上一档之上,条件自相矛盾。
|
||||
if rebound_pct >= add_pct:
|
||||
raise ValueError(f"ETF 配置 {path} 的 rebound_pct 必须小于 add_pct")
|
||||
|
||||
return EtfDefaults(
|
||||
atr_period=atr_period,
|
||||
min_grid_pct=min_grid_pct,
|
||||
max_grid_span_pct=max_grid_span_pct,
|
||||
channel_period=channel_period,
|
||||
channel_pct=channel_pct,
|
||||
rebound_pct=rebound_pct,
|
||||
add_pct=add_pct,
|
||||
max_adds=max_adds,
|
||||
watch_seconds=watch_seconds,
|
||||
min_profit_pct=min_profit_pct,
|
||||
inner_grids=inner_grids,
|
||||
min_hold_days=min_hold_days,
|
||||
max_hold_days=max_hold_days,
|
||||
commission_rate=commission_rate,
|
||||
min_commission=min_commission,
|
||||
max_tick_age_seconds=max_tick_age_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _etf_symbol(
|
||||
code: str, values: dict, defaults: EtfDefaults, path: Path
|
||||
) -> EtfSymbolConfig:
|
||||
"""校验单个标的的参数,并绑定全局默认以支持未覆盖字段的继承。"""
|
||||
if not isinstance(code, str) or not ETF_CODE_PATTERN.fullmatch(code):
|
||||
raise ValueError(f"ETF 配置 {path} 的标的 {code!r} 不是合法场内 ETF 代码")
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError(f"ETF 配置 {path} 的标的 {code} 必须是对象")
|
||||
|
||||
unknown = sorted(set(values) - set(EtfSymbolConfig.override_names()))
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"ETF 配置 {path} 的标的 {code} 存在未知字段: {', '.join(unknown)}"
|
||||
)
|
||||
|
||||
section = f"标的 {code}"
|
||||
# is_t0、buy_shares、atr_multiplier、inner_step 分别决定结算制度、佣金轴
|
||||
# 与跨度轴,没有安全的全局默认值,必须逐标的显式给出。
|
||||
missing = sorted({"is_t0", "buy_shares", "atr_multiplier", "inner_step"} - set(values))
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"ETF 配置 {path} 的{section}缺少必填字段: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
is_t0 = values["is_t0"]
|
||||
if type(is_t0) is not bool:
|
||||
raise ValueError(f"ETF 配置 {path} 的{section}的 is_t0 必须是布尔值")
|
||||
|
||||
buy_shares = _etf_int(values["buy_shares"], "buy_shares", path, section)
|
||||
if buy_shares % 100:
|
||||
raise ValueError(f"ETF 配置 {path} 的{section}的 buy_shares 必须是 100 的整数倍")
|
||||
max_shares = _etf_int(
|
||||
values.get("max_shares") or buy_shares * (defaults.max_adds + 1),
|
||||
"max_shares",
|
||||
path,
|
||||
section,
|
||||
)
|
||||
atr_multiplier = _etf_number(
|
||||
values["atr_multiplier"], "atr_multiplier", path, section
|
||||
)
|
||||
inner_step = _etf_number(values["inner_step"], "inner_step", path, section)
|
||||
|
||||
overrides: dict = {}
|
||||
if "inner_grids" in values:
|
||||
overrides["inner_grids"] = _etf_number(
|
||||
values["inner_grids"], "inner_grids", path, section
|
||||
)
|
||||
if "max_grid_span_pct" in values:
|
||||
overrides["max_grid_span_pct"] = _etf_number(
|
||||
values["max_grid_span_pct"], "max_grid_span_pct", path, section, high=100.0
|
||||
)
|
||||
if "rebound_pct" in values:
|
||||
overrides["rebound_pct"] = _etf_number(
|
||||
values["rebound_pct"], "rebound_pct", path, section
|
||||
)
|
||||
if "max_hold_days" in values:
|
||||
overrides["max_hold_days"] = _etf_int(
|
||||
values["max_hold_days"], "max_hold_days", path, section, zero_ok=True
|
||||
)
|
||||
|
||||
return EtfSymbolConfig(
|
||||
is_t0=is_t0,
|
||||
buy_shares=buy_shares,
|
||||
atr_multiplier=atr_multiplier,
|
||||
inner_step=inner_step,
|
||||
max_shares=max_shares,
|
||||
defaults=defaults,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def _etf_number(
|
||||
value,
|
||||
name: str,
|
||||
path: Path,
|
||||
section: str,
|
||||
high: float | None = None,
|
||||
zero_ok: bool = False,
|
||||
) -> float:
|
||||
"""校验有限数值并返回;布尔值、非数值和越界值都抛 ValueError。"""
|
||||
label = f"ETF 配置 {path} 的{section}的 {name}"
|
||||
# bool 是 int 的子类,必须显式排除,否则 True 会被当成 1。
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{label} 必须是数值")
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"{label} 必须是有限数值")
|
||||
|
||||
low = 0 if zero_ok else 0.0
|
||||
if value < low or (value == 0 and not zero_ok) or (high is not None and value > high):
|
||||
if high is None:
|
||||
expect = "大于 0"
|
||||
elif zero_ok:
|
||||
expect = f"在 [0, {high:g}] 区间"
|
||||
else:
|
||||
expect = f"在 (0, {high:g}] 区间"
|
||||
raise ValueError(f"{label} 必须{expect}")
|
||||
return value
|
||||
|
||||
|
||||
def _etf_int(
|
||||
value, name: str, path: Path, section: str, zero_ok: bool = False
|
||||
) -> int:
|
||||
"""校验整数字段并返回;``zero_ok`` 为假时要求大于 0。"""
|
||||
if type(value) is not int or value < 0 or (value == 0 and not zero_ok):
|
||||
expect = "非负整数" if zero_ok else "正整数"
|
||||
raise ValueError(f"ETF 配置 {path} 的{section}的 {name} 必须是{expect}")
|
||||
return value
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
# 标的是示例白名单;仅在账户配置 strategy: etf 时启用。
|
||||
codes:
|
||||
- "588000.SH" # 华夏上证科创板50成份ETF
|
||||
- "510300.SH" # 沪深300ETF华泰柏瑞
|
||||
- "518880.SH" # 安易富黄金ETF
|
||||
# 固定买入手数,每手 100 份;每只 ETF 总持仓硬上限 10 手。
|
||||
buy_hands: 1
|
||||
max_hands: 10
|
||||
# 60 日均线作为中轴;格距 = max(ATR×倍数, MA60×最小格距百分比, 0.001)。
|
||||
atr_period: 14
|
||||
atr_multiplier: 1.0
|
||||
boll_period: 20
|
||||
boll_std: 2.0
|
||||
min_grid_pct: 0.5
|
||||
# 复用 DipWatch:触及低位后,从观察低点反弹 0.61% 才买入。
|
||||
rebound_pct: 0.61
|
||||
watch_seconds: 600
|
||||
# 达到高位和最低利润要求后启动网格回撤止盈。
|
||||
min_profit_pct: 0.5
|
||||
# 用于资金预留及止盈费用门槛,按实际券商佣金调整。
|
||||
commission_rate: 0.0003
|
||||
min_commission: 5.0
|
||||
# 超过此秒数或缺少时间戳的行情不交易。
|
||||
max_tick_age_seconds: 90
|
||||
defaults:
|
||||
atr_period: 14
|
||||
min_grid_pct: 0.5
|
||||
max_grid_span_pct: 40 # 跨度健康度告警线(非闸门)
|
||||
channel_period: 20
|
||||
channel_pct: 15
|
||||
rebound_pct: 0.5 # 反弹确认阈值(建仓与补仓共用)
|
||||
add_pct: 3.0 # 补仓:自上一档再跌该百分比即触发(需配合反弹确认)
|
||||
max_adds: 9 # 补仓次数上限(底仓另计,共 10 档)
|
||||
watch_seconds: 600
|
||||
min_profit_pct: 1.0 # 主出口:盈亏率 >= 该值即整仓清掉
|
||||
inner_grids: 2.0 # 副出口:峰值至少抬到第 N 格才允许回撤卖出
|
||||
min_hold_days: 1
|
||||
max_hold_days: 0 # 0 = 不止损
|
||||
commission_rate: 0.0003
|
||||
min_commission: 5.0
|
||||
max_tick_age_seconds: 90
|
||||
|
||||
# ---------- 标的白名单(键即标的,顺序即资金优先级)----------
|
||||
# 键必须与接口返回的 ts_code 完全一致(588000.SH / 159915.SZ)
|
||||
symbols:
|
||||
"588000.SH":
|
||||
is_t0: false
|
||||
buy_shares: 10000
|
||||
max_shares: 100000 # 10 个价位 × 1000 股
|
||||
atr_multiplier: 0.5 # 该标的 ATR 相对价格偏高,必须收窄
|
||||
inner_step: 0.9
|
||||
|
||||
"510300.SH":
|
||||
is_t0: false
|
||||
buy_shares: 4000
|
||||
max_shares: 20000
|
||||
atr_multiplier: 1.0
|
||||
inner_step: 0.7
|
||||
|
||||
"518880.SH":
|
||||
is_t0: true # 黄金 ETF 支持当日回转
|
||||
buy_shares: 2000
|
||||
max_shares: 10000
|
||||
atr_multiplier: 1.0
|
||||
inner_step: 0.8
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from config import AccountConfig, GlobalConfig, HTTP_TIMEOUT
|
||||
from config import AccountConfig, GlobalConfig,EtfConfig, HTTP_TIMEOUT
|
||||
from sdk import Client
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from libs.http import get_json
|
||||
@@ -19,10 +19,11 @@ class Runtime:
|
||||
client: Client
|
||||
global_cfg: GlobalConfig
|
||||
account_cfg: AccountConfig
|
||||
etf_cfg: EtfConfig
|
||||
orders: OrderBook
|
||||
open_watch: DipWatch
|
||||
add_watch: DipWatch
|
||||
profit_tracker: GridTrailingTracker
|
||||
open_watch: DipWatch | None = None
|
||||
add_watch: DipWatch | None = None
|
||||
profit_tracker: GridTrailingTracker | None = None
|
||||
executor: ThreadPoolExecutor | None = None
|
||||
server_inital: dict[str, list[str]] = field(init=False)
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""策略客户端启动入口:加载配置、拉起后台任务、按 ``strategy`` 分派策略主循环。
|
||||
|
||||
一个进程只跑一个策略(``account_config.strategy``)。IPO 打新、大盘刷新、
|
||||
趋势数据采集由后台调度线程承担,主线程跑策略自己的循环。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import traceback
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import config
|
||||
from dataclasses import dataclass
|
||||
@@ -13,7 +17,6 @@ import yaml
|
||||
import httpx
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
GLOBAL_CONFIG_PATH = os.path.join(PROJECT_ROOT, "etc", "_global.yaml")
|
||||
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, datetime.now().strftime("%Y%m%d.log"))
|
||||
@@ -55,6 +58,14 @@ STRATEGIES = {
|
||||
def require_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def describe_etf_config() -> str:
|
||||
"""启动日志用的 ETF 配置概览:文件缺失时明确说明,不在这里报错。"""
|
||||
etf_cfg = getattr(config, "etf_config", None)
|
||||
if etf_cfg is None:
|
||||
return "未找到 _etf.yaml(仅 etf 策略需要)"
|
||||
return f"标的={len(etf_cfg.symbols)} 只,代码={'/'.join(etf_cfg.codes)}"
|
||||
|
||||
def check_single_instance(project_root: str) -> bool:
|
||||
"""使用 Windows 命名互斥锁保证单实例。"""
|
||||
try:
|
||||
@@ -123,6 +134,19 @@ def main() -> int:
|
||||
config.load()
|
||||
if config.global_config is None or config.account_config is None:
|
||||
raise RuntimeError("配置尚未加载,请先调用 config.load()")
|
||||
strategy = config.account_config.strategy
|
||||
start = STRATEGIES.get(strategy)
|
||||
if start is None:
|
||||
raise ValueError(
|
||||
f"未知策略 strategy={strategy!r},可选: {', '.join(sorted(STRATEGIES))}"
|
||||
)
|
||||
logging.info(
|
||||
"配置已加载:主机=%s,账户=%s,策略=%s,ETF配置=%s",
|
||||
getattr(config.account_config, "host_key", "-"),
|
||||
getattr(config.account_config, "account_id", "-"),
|
||||
strategy,
|
||||
describe_etf_config(),
|
||||
)
|
||||
wait_for_qmt_api()
|
||||
|
||||
# 后台调度不受趋势策略永久循环阻塞;同一时刻最多执行一个实例。
|
||||
@@ -159,14 +183,14 @@ def main() -> int:
|
||||
logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00")
|
||||
logging.info("大盘信号后台刷新已启动:每分钟一次")
|
||||
|
||||
STRATEGIES[config.account_config.strategy].start_strategy()
|
||||
logging.info("%s 策略已结束", config.account_config.strategy)
|
||||
STRATEGIES[strategy].start_strategy()
|
||||
logging.info("%s 策略主循环已结束", strategy)
|
||||
return 0
|
||||
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e:
|
||||
print(f"启动失败: {e}", file=sys.stderr, flush=True)
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
wait_for_any_key()
|
||||
return 1
|
||||
print(f"启动失败: {e}", file=sys.stderr, flush=True)
|
||||
logging.exception("启动失败")
|
||||
wait_for_any_key()
|
||||
return 1
|
||||
finally:
|
||||
if scheduler is not None and scheduler.running:
|
||||
scheduler.shutdown(wait=True)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# A 股 ETF 自适应网格策略
|
||||
|
||||
策略目录为 `py-client/strategy/etf`,入口名称 `etf`,独立配置为
|
||||
`py-client/etc/etf.yaml`。每只 ETF 总持仓上限 10 手(1000 份),默认每次买入 1 手。
|
||||
这是常见均值回归与波动率网格方法的工程组合,尚未完成历史收益回测。
|
||||
|
||||
## 方法分析
|
||||
|
||||
| 方法 | 优点 | 局限 | 本策略选择 |
|
||||
| --- | --- | --- | --- |
|
||||
| 固定价差网格 | 简单直观 | 不适应不同价格与波动率 | ATR 动态格距,设置百分比下限 |
|
||||
| 均线回归 | 提供相对高低位置 | 单边下跌中均线会滞后 | MA60 作中轴,设置仓位上限 |
|
||||
| BOLL 低吸 | 用价格分布寻找相对低位 | 触及下轨不代表跌势结束 | 下轨仅启动观察,反弹后再买 |
|
||||
| 回撤止盈 | 上涨时跟随峰值 | 不能保证最高价退出 | 复用现有 GridTrailingTracker |
|
||||
|
||||
ATR 反映波动幅度,不判断方向;BOLL 为均线加减标准差倍数。定义参考
|
||||
[Fidelity ATR](https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/atr)
|
||||
及 [Fidelity BOLL](https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/bollinger-bands)。
|
||||
默认参数是可调整的起点,不代表已经优化或保证收益。
|
||||
|
||||
## 指标与网格
|
||||
|
||||
历史日线直接读取 `http://go.apinb.com/a/get_daily?code=<证券代码>`,使用响应
|
||||
`details` 中的 `ts_code / trade_date / open / high / low / close`。校验 `code=0`、
|
||||
证券归属、日期和 OHLC 后,按日期排序,排除当天及未来数据,再取最近 120 根。
|
||||
每个标的每天计算一次并缓存,至少需要 60 根;ATR 周期为 60 时至少需要 61 根。
|
||||
接口样本未声明复权口径,策略使用接口原始价格,不自行假定或执行前复权。
|
||||
|
||||
- `M = 最近60根收盘价平均值`,固定 MA60。
|
||||
- `TR = max(最高-最低, abs(最高-前收盘), abs(最低-前收盘))`。
|
||||
- ATR 默认 14 日:前 14 个 TR 平均作初值,后续按
|
||||
`ATR = (前ATR × 13 + 当日TR) / 14` 进行 Wilder 平滑。
|
||||
- BOLL 默认 20 日、2 倍总体标准差:`中轨=MA20`,`上下轨=MA20 ± 2σ`。
|
||||
- 格距 `G = max(ATR × atr_multiplier, M × min_grid_pct / 100, 0.001)`,
|
||||
向上取整至 0.001 元。均线作中轴,ATR 决定每格宽度。
|
||||
|
||||
重复日期、非正数或非有限价格、价格关系异常、日线不足均不交易。
|
||||
最后日线超过 15 个自然日也不交易;该检查只排除明显过期,不能替代交易所日历。
|
||||
接口数据应更新至上一交易日。获取失败的标的每 5 分钟重试。
|
||||
|
||||
## 买入规则
|
||||
|
||||
1. 首仓在 `现价 <= min(BOLL下轨, M-G)` 时开始观察,不直接买入。
|
||||
2. 复用 `DipWatch` 防接飞刀:下跌刷新低点,从低点反弹默认 0.61% 后触发;
|
||||
观察默认 600 秒过期重置。反弹可站回下轨上方,但不能超过 MA60。
|
||||
3. 加仓还须满足 `现价 <= 上次实际买入成交均价-G`,防止同价位连续补满。
|
||||
初次接管已有仓位时,券商成本作为初始加仓基准。
|
||||
4. 每次买入 `buy_hands × 100` 份;若买后超过 `max_hands × 100`,整笔跳过,
|
||||
不临时缩量。配置强制 `1 <= buy_hands <= max_hands <= 10`。
|
||||
5. 买入预算为券商可用资金减账户现金安全线,再减所有本地待确认买单。
|
||||
限价金额加预估佣金占用预算,多标的串行扣减。
|
||||
|
||||
示例:MA60=4.00、G=0.05、下轨=3.90,价格进入 3.90 以下才观察;低点 3.88
|
||||
反弹至 3.904 时超过 0.61%,可以提交固定手数。如果实际均价为 3.904,
|
||||
下一笔最高买价为 3.854,同时仍须满足低位触发和反弹确认。
|
||||
|
||||
## 止盈规则
|
||||
|
||||
1. `现价 >= max(BOLL上轨, M+G, 成本+G)`,且满足 `min_profit_pct`、
|
||||
预估价差收益大于买卖两侧佣金,才启动高位跟踪。
|
||||
2. 启动时冻结格距。盈利格编号为 `floor((现价-成本)/冻结格距)`,复用
|
||||
`GridTrailingTracker` 记录最高格;进入更高格更新峰值,跌回较低格时止盈。
|
||||
这是跌破峰值格边界,不是从最高价回撤完整一个 ATR。
|
||||
3. 启动后,即使回落到 BOLL 上轨或启动价以下,也继续判断回撤;卖出时仍须满足
|
||||
最低利润和费用门槛。止盈启动后暂停补仓,不同时发买卖单。
|
||||
4. **固定手数用于买入;止盈卖出当前全部可用整手份额**,数量为
|
||||
`min(持仓, 券商可卖数量)` 向下取整到 100 份。零股暂不处理。
|
||||
5. T+1 当天不可卖时继续记录峰值,翌日按券商 `can_use_volume` 判断。
|
||||
清仓、实际成交改变仓位、外部数量或成本变化后重建基准;部分卖出后剩余仓位
|
||||
重新等待高位启动,不把旧峰值带入新仓。
|
||||
|
||||
交易单位、0.001 元报价精度及股票 ETF 的 T+1 参考
|
||||
[上交所 ETF 常见问题](https://www.sse.com.cn/assortment/fund/etf/question/)。
|
||||
本策略没有自动止损,单边下跌可能满仓后长期持有;10 手上限只限制数量。
|
||||
佣金参数按券商实际情况调整。费用门槛是估算,不逐笔归集历史买入最低佣金;
|
||||
若券商持仓成本已含费用,该估算会偏保守。
|
||||
|
||||
## 委托、持仓与持久化
|
||||
|
||||
- 管理 `codes` 白名单内的已有持仓;`excluded_codes` 优先排除。配置外证券不买卖。
|
||||
- 以 `ETF-BUY-*` / `ETF-SELL-*` 为本地编号,标签 `etf`,当前价按 0.001 元
|
||||
精度提交限价。复用 OrderBook,只自动撤销超时 120 秒的 ETF 前缀委托。
|
||||
- 同标的全账户买卖在途、未知委托状态或在途份额都会阻止新单。
|
||||
行情缺少 `timetag`、不是当天或超过默认 90 秒,也不交易。
|
||||
- 下单前原子保存意图,HTTP 成功、超时或失败均不会自动解除锁。
|
||||
必须收到终态(53/54/56/57),并且券商持仓与累计成交量相符,才能继续。
|
||||
部分成交按实际数量核对,买入必须取得实际成交均价才推进下一格。
|
||||
- 状态位于 `{qmt_data_dir}/etf/{账户SHA256}/state.json`,保存实际买入基准、
|
||||
止盈峰值、冻结格距和未确认委托;重启恢复,损坏不静默覆盖。
|
||||
- 同一 ETF 不适合同时由人工或其他策略频繁交易。在途期间外部改变持仓,会暂停核对。
|
||||
若跨日后柜台不再返回未确认订单,则持续暂停该标的,需要核对历史订单与持仓后
|
||||
人工处理状态,不按超时自动重发。有未确认委托的标的不能直接从配置移除。
|
||||
|
||||
## 启用
|
||||
|
||||
1. 修改 `etc/etf.yaml` 的标的及参数。示例仅示范格式,请选择实际交易的 A 股股票 ETF;
|
||||
代码形态检查不验证基金投资范围。
|
||||
2. 主机对应的账户 YAML 设置:
|
||||
|
||||
```yaml
|
||||
strategy: etf
|
||||
etf_config_path: etf.yaml
|
||||
enable_auto_ipo: false
|
||||
```
|
||||
|
||||
路径相对账户配置目录,也支持绝对路径;不填默认 `etc/etf.yaml`。
|
||||
`account_id`、`min_cash_ratio` 继续生效。公共校验仍要求 `buy_value`、
|
||||
`grid_step_pct` 为正,但 ETF 不用它们计算数量或格距。
|
||||
关闭 IPO 是此处示例选择;ETF 决策不依赖 IPO 或远端股票信号。
|
||||
3. 无需修改 QMT 服务端或 `sdk/`。历史接口适配完全位于 `strategy/etf/data.py`;
|
||||
实时行情、持仓和交易继续使用已有 QMT SDK。
|
||||
4. 确认外部接口提供配置 ETF 的足量、最新日线。HTTP 错误、业务失败、空列表、
|
||||
证券代码不一致或数据异常都会跳过该标的,不改用其他证券数据。
|
||||
2026-09-17 联通验证中,股票样本 `600584.SH` 成功返回 200 条,示例 ETF
|
||||
`510300.SH` 返回 404;需要数据服务覆盖实际配置的 ETF 后才能正常运行。
|
||||
5. 按现有方式运行 `python main.py`。每 30 秒执行,午休暂停,15:00 退出。
|
||||
|
||||
本次新增不会自动切换已有实盘账户,也没有进行实盘委托。
|
||||
|
||||
## 离线验证
|
||||
|
||||
在 `py-client` 中运行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests -p test_etf.py -v
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
覆盖指标、未收盘日线排除、反弹确认、固定手数、限仓、资金共享、部分成交、拒单、
|
||||
快照延迟、T+1、重启防重、峰值恢复、状态损坏、外部日线接口与异常响应。
|
||||
离线行为验证不等同于历史收益回测或实盘联调。
|
||||
@@ -1 +0,0 @@
|
||||
"""A 股场内 ETF:均线中轴、ATR 网格与 BOLL 低吸策略。"""
|
||||
@@ -1,67 +1,191 @@
|
||||
"""ETF 策略入口:每 30 秒运行,日线指标当天缓存,失败标的单独重试。"""
|
||||
"""趋势策略启动器。
|
||||
|
||||
该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import logging as log
|
||||
from pathlib import Path
|
||||
import time
|
||||
import httpx
|
||||
import logging as log
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
|
||||
import config
|
||||
from libs.calc import trading_time
|
||||
from libs.snapshot import cache_portfolio
|
||||
from libs.market import market_allow_open
|
||||
from libs.overview import Overview
|
||||
from libs.signal import init_signals, SignalItem
|
||||
from sdk import Client
|
||||
|
||||
from .config import load
|
||||
from .data import daily_bars
|
||||
from .engine import Engine
|
||||
from .indicators import calculate
|
||||
from .state import Store
|
||||
|
||||
from libs.snapshot import cache_portfolio
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from libs.order import OrderBook
|
||||
from libs.watch import DipWatch
|
||||
from libs.runtime import Runtime
|
||||
from .open import open_signal
|
||||
from .positions import manage_positions
|
||||
from .signal import gen_signals
|
||||
|
||||
def StartETF() -> None:
|
||||
cfg = load(config.account_config.etf_config_path or None)
|
||||
account = str(config.account_config.account_id).strip()
|
||||
if not account:
|
||||
raise ValueError('ETF 策略缺少账户编号')
|
||||
key = hashlib.sha256(account.encode('utf-8')).hexdigest()
|
||||
store = Store(Path(config.global_config.qmt_data_dir) / 'etf' / key / 'state.json', account)
|
||||
# 独立 HTTP 连接池读取外部日线,不向外部接口发送 QMT 认证信息。
|
||||
with Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT) as client, \
|
||||
httpx.Client(timeout=config.HTTP_TIMEOUT) as history_client:
|
||||
engine = Engine(client, cfg, store, config.account_config.min_cash_ratio,
|
||||
config.account_config.excluded_codes)
|
||||
log.info('[ETF启动] 标的=%s 每次=%d手 每只上限=%d手 状态=%s',
|
||||
cfg.codes, cfg.buy_hands, cfg.max_hands, store.path)
|
||||
log.info('[ETF启动] 管理配置白名单内已有持仓,卖出以券商可用份额为限')
|
||||
indicators, retry_at = {}, {}
|
||||
cached_day = None
|
||||
"""初始化趋势策略,并以 30 秒间隔持续执行。"""
|
||||
client = Client(
|
||||
config.global_config.qmt_base_url,
|
||||
config.global_config.qmt_token,
|
||||
config.HTTP_TIMEOUT,
|
||||
)
|
||||
executor = None
|
||||
try:
|
||||
portfolio = client.portfolio()
|
||||
assets = portfolio.assets
|
||||
positions = list(portfolio.positions.values())
|
||||
cache_portfolio(config.account_config.account_id, assets, positions, client.deals())
|
||||
order_book = OrderBook()
|
||||
order_book.refresh(client, portfolio.orders)
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ETF")
|
||||
run = Runtime(
|
||||
client=client,
|
||||
etf_cfg=config.etf_config,
|
||||
orders=order_book,
|
||||
executor=executor,
|
||||
)
|
||||
|
||||
# 获取本策略的信号开仓数据
|
||||
signals = gen_signals(run)
|
||||
log.info(
|
||||
"[启动] ETF策略已启动,账户=%s,信号=%d,持仓=%d",
|
||||
config.account_config.account_id,
|
||||
len(signals),
|
||||
len(positions),
|
||||
)
|
||||
|
||||
Overview(assets, positions, config.account_config)
|
||||
|
||||
DEFAULT_TICK_INTERVAL = 30
|
||||
while True:
|
||||
now = datetime.now()
|
||||
if now.hour >= 15:
|
||||
log.info('[ETF结束] 已到 15:00')
|
||||
lt = time.localtime()
|
||||
if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0):
|
||||
log.info("[ETF] 已到 15:00,结束趋势策略")
|
||||
return
|
||||
if trading_time(now):
|
||||
try:
|
||||
if cached_day != now.date():
|
||||
indicators, retry_at, cached_day = {}, {}, now.date()
|
||||
for code in cfg.codes:
|
||||
if code in indicators or now < retry_at.get(code, datetime.min):
|
||||
continue
|
||||
try:
|
||||
rows = daily_bars(history_client, code, now.date())
|
||||
indicators[code] = calculate(rows, now.date(), cfg)
|
||||
log.info('[ETF指标] %s %s', code, indicators[code])
|
||||
except Exception:
|
||||
retry_at[code] = now + timedelta(minutes=5)
|
||||
log.exception('[ETF日线] %s 获取或计算失败,5分钟后重试', code)
|
||||
portfolio = client.portfolio()
|
||||
ticks = client.full_tick(list(cfg.codes))
|
||||
engine.run(portfolio, ticks, indicators, datetime.now())
|
||||
try:
|
||||
cache_portfolio(account, portfolio.assets, list(portfolio.positions.values()), client.deals())
|
||||
except Exception:
|
||||
log.exception('[ETF采集] 成交快照读取失败')
|
||||
except Exception:
|
||||
log.exception('[ETF异常] 本轮失败,下一轮继续')
|
||||
time.sleep(30 - datetime.now().second % 30)
|
||||
current_sec = lt.tm_sec
|
||||
|
||||
# 计算距离下一个目标时间点(0秒或30秒)的等待时间
|
||||
if current_sec < DEFAULT_TICK_INTERVAL:
|
||||
wait_seconds = DEFAULT_TICK_INTERVAL - current_sec
|
||||
elif current_sec < 60:
|
||||
wait_seconds = 60 - current_sec
|
||||
else:
|
||||
wait_seconds = DEFAULT_TICK_INTERVAL
|
||||
|
||||
# 等待到目标时间点
|
||||
time.sleep(wait_seconds)
|
||||
|
||||
# 单轮失败不能杀死唯一的交易定时线程。
|
||||
try:
|
||||
RunOnce(run, signals)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"[ETF] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
if executor is not None:
|
||||
executor.shutdown(wait=True)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def RunOnce(run: Runtime, signals: list[SignalItem]) -> None:
|
||||
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
|
||||
if not trading_time(datetime.now()):
|
||||
return
|
||||
|
||||
print(
|
||||
"=" * 40 + f" Ticker {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " + "=" * 40
|
||||
)
|
||||
|
||||
started_at = time.monotonic()
|
||||
|
||||
# 1. 一次获取资产、持仓和订单,并清理过期订单。
|
||||
try:
|
||||
portfolio = run.client.portfolio()
|
||||
assets = portfolio.assets
|
||||
position_codes = list(portfolio.positions)
|
||||
positions = list(portfolio.positions.values())
|
||||
cache_portfolio(run.account_cfg.account_id, assets, positions, run.client.deals())
|
||||
run.orders.refresh(run.client, portfolio.orders)
|
||||
except Exception:
|
||||
log.exception("[Portfolio] 刷新账户快照失败")
|
||||
return
|
||||
|
||||
futures: list[tuple[str, Future]] = []
|
||||
|
||||
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
|
||||
allow_open_by_cash = (
|
||||
assets.available >= assets.total * run.account_cfg.min_cash_ratio
|
||||
)
|
||||
if not allow_open_by_cash:
|
||||
log.info(
|
||||
"[Status] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f",
|
||||
assets.available,
|
||||
assets.total,
|
||||
)
|
||||
|
||||
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
|
||||
market_ok = market_allow_open()
|
||||
|
||||
# 4. 验证有效开仓信号:排除已有持仓。
|
||||
allow_open: list[SignalItem] = []
|
||||
allow_codes: list[str] = []
|
||||
for signal in signals:
|
||||
if signal.code not in portfolio.positions:
|
||||
allow_open.append(signal)
|
||||
allow_codes.append(signal.code)
|
||||
|
||||
if allow_open and not market_ok:
|
||||
log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open))
|
||||
|
||||
# 5. 获取持仓和待开仓证券的实时行情 tick。
|
||||
all_codes = list(dict.fromkeys(position_codes + allow_codes))
|
||||
try:
|
||||
ticks = run.client.full_tick(all_codes)
|
||||
except Exception:
|
||||
log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes))
|
||||
return
|
||||
|
||||
log.info(
|
||||
"[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s",
|
||||
len(positions),
|
||||
len(allow_open),
|
||||
market_ok,
|
||||
allow_open_by_cash,
|
||||
)
|
||||
|
||||
# 启动线程,开始计算
|
||||
# 7. 持仓计算。
|
||||
futures.append(
|
||||
(
|
||||
"持仓计算",
|
||||
run.executor.submit(
|
||||
manage_positions, run, ticks, positions, market_ok, assets.available
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# 8. 开仓计算:必须同时存在有效信号且大盘允许开仓。
|
||||
if allow_open and market_ok and allow_open_by_cash:
|
||||
futures.append(
|
||||
("开仓计算", run.executor.submit(open_signal, run, ticks, allow_open))
|
||||
)
|
||||
|
||||
# 9. 开始执行
|
||||
for name, future in futures:
|
||||
_wait_worker(name, future)
|
||||
log.info(
|
||||
"[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000)
|
||||
)
|
||||
|
||||
|
||||
def _wait_worker(name: str, future: Future) -> None:
|
||||
"""保留单轮继续运行的语义,分别记录工作线程异常。"""
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
log.exception("[运行] %s线程失败", name)
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""独立读取 ETF 参数,不修改现有账户策略的默认行为。"""
|
||||
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
import math
|
||||
import re
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ETFConfig:
|
||||
codes: tuple[str, ...] = ()
|
||||
buy_hands: int = 1
|
||||
max_hands: int = 10
|
||||
atr_period: int = 14
|
||||
atr_multiplier: float = 1.0
|
||||
boll_period: int = 20
|
||||
boll_std: float = 2.0
|
||||
min_grid_pct: float = 0.5
|
||||
rebound_pct: float = 0.61
|
||||
watch_seconds: int = 600
|
||||
min_profit_pct: float = 0.5
|
||||
commission_rate: float = 0.0003
|
||||
min_commission: float = 5.0
|
||||
max_tick_age_seconds: int = 90
|
||||
|
||||
def __post_init__(self):
|
||||
# 限定沪深场内 ETF 代码形态;具体跟踪 A 股的标的由配置白名单决定。
|
||||
if not isinstance(self.codes, (list, tuple)) or not self.codes:
|
||||
raise ValueError('etf.yaml 的 codes 必须是非空 ETF 代码列表')
|
||||
if any(not isinstance(c, str) or not re.fullmatch(r'(?:5[0-9]{5}\.SH|1[58][0-9]{4}\.SZ)', c)
|
||||
for c in self.codes) or len(set(self.codes)) != len(self.codes):
|
||||
raise ValueError('ETF 代码必须唯一,使用完整沪深场内代码,如 510300.SH、159915.SZ')
|
||||
object.__setattr__(self, 'codes', tuple(self.codes))
|
||||
for name in ('buy_hands', 'max_hands', 'atr_period', 'boll_period', 'watch_seconds', 'max_tick_age_seconds'):
|
||||
if type(getattr(self, name)) is not int or getattr(self, name) <= 0:
|
||||
raise ValueError(f'{name} 必须为正整数')
|
||||
if not self.buy_hands <= self.max_hands <= 10:
|
||||
raise ValueError('必须满足 buy_hands <= max_hands <= 10,每手 100 份')
|
||||
if not 2 <= self.atr_period <= 60 or not 2 <= self.boll_period <= 60:
|
||||
raise ValueError('ATR、BOLL 周期必须在 2 到 60 日之间')
|
||||
for name in ('atr_multiplier', 'boll_std', 'min_grid_pct', 'rebound_pct', 'min_profit_pct',
|
||||
'commission_rate', 'min_commission'):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
|
||||
raise ValueError(f'{name} 必须是有限非负数')
|
||||
if name not in ('commission_rate', 'min_commission') and value == 0:
|
||||
raise ValueError(f'{name} 必须大于零')
|
||||
|
||||
|
||||
def load(path: str | Path | None = None) -> ETFConfig:
|
||||
path = Path(path) if path else Path(__file__).resolve().parents[2] / 'etc' / '_etf.yaml'
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding='utf-8'))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise ValueError(f'ETF 配置读取失败:{path}') from exc
|
||||
if not isinstance(raw, dict) or set(raw) - {f.name for f in fields(ETFConfig)}:
|
||||
raise ValueError('ETF 配置必须为对象,且不能包含未知参数')
|
||||
return ETFConfig(**raw)
|
||||
@@ -1,55 +0,0 @@
|
||||
"""ETF 专用历史日线适配,不依赖或修改 QMT SDK。"""
|
||||
|
||||
from datetime import date, datetime
|
||||
import math
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
DAILY_URL = 'http://139.224.247.176:13499/etf/daily'
|
||||
|
||||
|
||||
def daily_bars(client: httpx.Client, code: str, today: date, count: int = 120) -> list[dict]:
|
||||
"""读取指定证券日线;只使用 code 参数,截取历史窗口在本地完成。"""
|
||||
response = client.get(DAILY_URL, params={'code': code})
|
||||
response.raise_for_status()
|
||||
return parse_daily(response.json(), code, today, count)
|
||||
|
||||
|
||||
def parse_daily(payload: dict, code: str, today: date, count: int = 120) -> list[dict]:
|
||||
"""校验业务状态、证券归属和 OHLC,将 trade_date 转为指标需要的 date。"""
|
||||
if type(count) is not int or count <= 0:
|
||||
raise ValueError('日线数量必须为正整数')
|
||||
if not isinstance(payload, dict) or type(payload.get('code')) is not int or payload['code'] != 0:
|
||||
raise ValueError(f'日线接口业务失败:{payload.get("message", "状态无效") if isinstance(payload, dict) else "响应非对象"}')
|
||||
details = payload.get('details')
|
||||
if not isinstance(details, list) or not details:
|
||||
raise ValueError(f'{code} 日线接口未返回有效 details 列表')
|
||||
bars = {}
|
||||
for row in details:
|
||||
if not isinstance(row, dict) or row.get('ts_code') != code:
|
||||
raise ValueError(f'{code} 日线证券代码不一致')
|
||||
stamp = str(row.get('trade_date', ''))
|
||||
if not re.fullmatch(r'[0-9]{8}', stamp):
|
||||
raise ValueError(f'{code} 日线日期无效:{stamp}')
|
||||
day = datetime.strptime(stamp, '%Y%m%d').date()
|
||||
# 当前日及未来日线均不可用于盘中指标,先过滤再截取最近 count 根。
|
||||
if day >= today:
|
||||
continue
|
||||
if stamp in bars:
|
||||
raise ValueError(f'{code} 日线日期重复:{stamp}')
|
||||
values = {}
|
||||
for key in ('open', 'high', 'low', 'close'):
|
||||
value = row.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ValueError(f'{code} 日线 {key} 无效')
|
||||
value = float(value)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f'{code} 日线 {key} 非有限正数')
|
||||
values[key] = value
|
||||
if not (values['low'] <= values['open'] <= values['high']
|
||||
and values['low'] <= values['close'] <= values['high']):
|
||||
raise ValueError(f'{code} 日线 OHLC 关系异常')
|
||||
bars[stamp] = dict(date=stamp, **values)
|
||||
return [bars[stamp] for stamp in sorted(bars)[-count:]]
|
||||
@@ -1,202 +0,0 @@
|
||||
"""串行 ETF 决策:先核对成交,再止盈,最后低吸并预留本轮资金。"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging as log
|
||||
import math
|
||||
|
||||
from libs.grid_take_profit import GridState, GridTrailingTracker
|
||||
from libs.order import OrderBook, PlaceOrderRequest
|
||||
from libs.watch import DipWatch
|
||||
from sdk import OP_BUY, OP_SELL, Portfolio, PositionItem, Tick
|
||||
|
||||
from .config import ETFConfig
|
||||
from .indicators import Indicators
|
||||
from .state import Store, SymbolState
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self, client, cfg: ETFConfig, store: Store, min_cash_ratio: float, excluded=()):
|
||||
if not math.isfinite(min_cash_ratio) or not 0 <= min_cash_ratio <= 1:
|
||||
raise ValueError('ETF min_cash_ratio 必须在 0 到 1 之间')
|
||||
self.client, self.cfg, self.store = client, cfg, store
|
||||
if any(state.pending and code not in cfg.codes for code, state in store.symbols.items()):
|
||||
raise ValueError('存在已从配置移除的 ETF 待确认委托,请保留该标的直到核对完成')
|
||||
self.min_cash_ratio, self.excluded = min_cash_ratio, set(excluded)
|
||||
self.orders = OrderBook(cancel_timeout_sec=120)
|
||||
self.watch = DipWatch(cfg.watch_seconds, cfg.rebound_pct)
|
||||
|
||||
def fee(self, amount: float) -> float:
|
||||
return max(self.cfg.min_commission, amount * self.cfg.commission_rate)
|
||||
|
||||
def reconcile(self, code: str, state: SymbolState, position: PositionItem, orders) -> bool:
|
||||
"""必须同时得到终态委托与匹配的持仓快照,才解除本地待确认锁。"""
|
||||
pending = state.pending
|
||||
# 柜台清仓记录可能保留旧成本;零持仓应统一视为零成本,避免每轮清空低吸观察。
|
||||
current_cost = position.open_price if position.volume > 0 else 0.0
|
||||
if pending:
|
||||
matches = [o for o in orders if o.local_order_id == pending['id'] and o.stock_code == code]
|
||||
if len(matches) != 1:
|
||||
log.warning('[ETF待确认] %s 订单=%s 回报缺失或不唯一,暂停该标的', code, pending['id'])
|
||||
return False
|
||||
order = matches[0]
|
||||
status = str(order.order_status)
|
||||
if status not in {'53', '54', '56', '57'}:
|
||||
log.info('[ETF待确认] %s 订单=%s 状态=%s 成交=%s', code, pending['id'], status, order.volume_traded)
|
||||
return False
|
||||
filled = order.volume_traded
|
||||
if (order.side != pending['side'] or type(filled) is not int or not 0 <= filled <= pending['volume']
|
||||
or (status == '56' and filled != pending['volume'])):
|
||||
log.warning('[ETF待确认] %s 委托方向或成交数量不一致', code)
|
||||
return False
|
||||
expected = pending['base_volume'] + (filled if pending['side'] == 'BUY' else -filled)
|
||||
if position.volume != expected:
|
||||
log.warning('[ETF待确认] %s 持仓=%s 预期=%s,等待快照同步', code, position.volume, expected)
|
||||
return False
|
||||
if filled and pending['side'] == 'BUY':
|
||||
if not math.isfinite(order.traded_price) or order.traded_price <= 0:
|
||||
log.warning('[ETF待确认] %s 缺少实际成交均价', code)
|
||||
return False
|
||||
state.last_buy = order.traded_price
|
||||
if filled:
|
||||
state.reset_profit()
|
||||
log.info('[ETF回报] %s 订单=%s 状态=%s 成交=%s 持仓=%s',
|
||||
code, pending['id'], status, filled, position.volume)
|
||||
state.pending = {}
|
||||
# 终态已被快照证实,可清理共用委托簿的短期方向缓存。
|
||||
self.orders.busy_cache.delete(f"{pending['side']}-{code}")
|
||||
elif position.volume != state.volume or not math.isclose(current_cost, state.cost, abs_tol=1e-8):
|
||||
# 配置内已有仓位一并管理;人工改变仓位时重新建立止盈和加仓基准。
|
||||
state.reset_profit()
|
||||
state.last_buy = position.open_price if position.volume > 0 else 0.0
|
||||
self.watch.forget(code)
|
||||
state.volume, state.cost = position.volume, current_cost
|
||||
if position.volume == 0:
|
||||
state.last_buy = 0.0
|
||||
state.reset_profit()
|
||||
return True
|
||||
|
||||
def run(self, portfolio: Portfolio, ticks: dict[str, Tick], indicators: dict[str, Indicators], now: datetime):
|
||||
# 防重看全账户,自动撤单只针对 ETF 前缀。
|
||||
self.orders.refresh(self.client, portfolio.orders, cancel_prefix='ETF-')
|
||||
assets = portfolio.assets
|
||||
if not all(math.isfinite(v) and v >= 0 for v in (assets.total, assets.available)):
|
||||
raise ValueError('账户资金无效')
|
||||
# 先扣除所有未确认买单,不能等遍历到后面的标的才预留。
|
||||
pending_cash = sum(s.pending['reserved'] for s in self.store.symbols.values()
|
||||
if s.pending.get('side') == 'BUY')
|
||||
cash = max(0.0, assets.available - assets.total * self.min_cash_ratio - pending_cash)
|
||||
for code in self.cfg.codes:
|
||||
state = self.store.get(code)
|
||||
had_pending = bool(state.pending)
|
||||
position = portfolio.positions.get(code, PositionItem(stock_code=code))
|
||||
try:
|
||||
if (type(position.volume) is not int or position.volume < 0
|
||||
or type(position.can_use_volume) is not int or position.can_use_volume < 0
|
||||
or type(position.on_road_volume) is not int or position.on_road_volume < 0
|
||||
or not math.isfinite(position.open_price)):
|
||||
raise ValueError('持仓数量或成本无效')
|
||||
if not self.reconcile(code, state, position, portfolio.orders):
|
||||
continue
|
||||
self.store.save()
|
||||
if code in self.excluded:
|
||||
self.watch.forget(code)
|
||||
continue
|
||||
if self.orders.busy(code, 'BUY') or self.orders.busy(code, 'SELL'):
|
||||
continue
|
||||
if position.on_road_volume > 0 or any(
|
||||
o.stock_code == code and str(o.order_status) not in {'53', '54', '56', '57'}
|
||||
for o in portfolio.orders
|
||||
):
|
||||
log.info('[ETF跳过] %s 存在在途份额或未知委托状态', code)
|
||||
continue
|
||||
tick, ind = ticks.get(code), indicators.get(code)
|
||||
if ind is None or not self.fresh_tick(tick, now):
|
||||
self.watch.forget(code)
|
||||
log.info('[ETF跳过] %s 日线或实时行情无效/过期', code)
|
||||
continue
|
||||
price = round(tick.last_price, 3)
|
||||
if position.volume > 0 and position.open_price <= 0:
|
||||
raise ValueError('非空持仓缺少有效成本')
|
||||
if self.sell(code, state, position, price, ind):
|
||||
continue
|
||||
cash -= self.buy(code, state, position, price, ind, cash, now)
|
||||
except Exception:
|
||||
# 异常后不允许其他标的重复使用可能已提交的资金。
|
||||
if not had_pending and state.pending.get('side') == 'BUY':
|
||||
cash = max(0, cash - state.pending['reserved'])
|
||||
log.exception('[ETF异常] %s 本轮跳过', code)
|
||||
|
||||
def fresh_tick(self, tick: Tick | None, now: datetime) -> bool:
|
||||
if tick is None or not math.isfinite(tick.last_price) or tick.last_price <= 0:
|
||||
return False
|
||||
try:
|
||||
stamp = datetime.strptime(tick.raw['timetag'], '%Y%m%d %H:%M:%S')
|
||||
return stamp.date() == now.date() and 0 <= (now - stamp).total_seconds() <= self.cfg.max_tick_age_seconds
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
def sell(self, code: str, state: SymbolState, position: PositionItem, price: float, ind: Indicators) -> bool:
|
||||
if position.volume <= 0:
|
||||
return False
|
||||
cost = position.open_price
|
||||
volume = min(position.volume, position.can_use_volume)
|
||||
volume = volume // 100 * 100
|
||||
# 即使 T+1 当天不可卖,也持续记录高位与峰值;翌日可卖时继续判断。
|
||||
estimate_volume = volume or position.volume
|
||||
profit = (price - cost) * estimate_volume
|
||||
enough_profit = ((price - cost) / cost * 100 >= self.cfg.min_profit_pct
|
||||
and profit > self.fee(cost * estimate_volume) + self.fee(price * estimate_volume))
|
||||
if not state.armed:
|
||||
if price < max(ind.upper, ind.ma60 + ind.grid, cost + ind.grid) or not enough_profit:
|
||||
return False
|
||||
state.armed, state.sell_grid = True, ind.grid
|
||||
state.peak = math.floor((price - cost) / state.sell_grid)
|
||||
self.store.save()
|
||||
log.info('[ETF止盈] %s 高位启动,峰值格=%d 格距=%.3f', code, state.peak, state.sell_grid)
|
||||
return True
|
||||
# 通过公开 observe 接口恢复跨日峰值,复用现有网格回撤算法。
|
||||
tracker = GridTrailingTracker(1.0)
|
||||
tracker.observe(code, state.peak)
|
||||
observation = tracker.observe(code, (price - cost) / state.sell_grid)
|
||||
state.peak = observation.peak_grid
|
||||
self.store.save()
|
||||
if observation.state == GridState.RETREAT and enough_profit and volume > 0:
|
||||
self.submit(code, state, position, 'SELL', volume, price, 0.0)
|
||||
# 止盈已启动时不同时补仓,避免同一轮买卖冲突。
|
||||
return True
|
||||
|
||||
def buy(self, code: str, state: SymbolState, position: PositionItem, price: float,
|
||||
ind: Indicators, cash: float, now: datetime) -> float:
|
||||
volume = self.cfg.buy_hands * 100
|
||||
if position.volume + volume > self.cfg.max_hands * 100:
|
||||
self.watch.forget(code)
|
||||
return 0.0
|
||||
# 首次进入 BOLL 下轨且低于均线一格;加仓须比上次实际买入再低至少一格。
|
||||
ceiling = min(ind.ma60, state.last_buy - ind.grid) if state.last_buy else ind.ma60
|
||||
entry = min(ind.lower, ind.ma60 - ind.grid, ceiling)
|
||||
if price > ceiling:
|
||||
self.watch.forget(code)
|
||||
return 0.0
|
||||
if code not in self.watch.data and price > entry:
|
||||
return 0.0
|
||||
amount = round(price, 3) * volume
|
||||
reserved = amount + self.fee(amount)
|
||||
if reserved > cash:
|
||||
return 0.0
|
||||
if not self.watch.triggered('ETF低吸', code, price, now):
|
||||
return 0.0
|
||||
self.submit(code, state, position, 'BUY', volume, price, reserved)
|
||||
return reserved
|
||||
|
||||
def submit(self, code: str, state: SymbolState, position: PositionItem, side: str,
|
||||
volume: int, price: float, reserved: float):
|
||||
order_id = self.orders.new_order_id('ETF', side)
|
||||
# 先持久化再提交;超时、异常、进程重启均不会丢失未确认的意图。
|
||||
state.pending = dict(id=order_id, side=side, volume=volume,
|
||||
base_volume=position.volume, reserved=reserved)
|
||||
self.store.save()
|
||||
request = PlaceOrderRequest(OP_BUY if side == 'BUY' else OP_SELL,
|
||||
code, volume, order_id, 'etf', price=round(price, 3))
|
||||
accepted = self.orders.place(self.client, request)
|
||||
log.info('[ETF委托] %s %s 数量=%d 限价=%.3f 接口返回=%s 订单=%s,等待柜台核对',
|
||||
code, side, volume, price, accepted, order_id)
|
||||
@@ -1,55 +0,0 @@
|
||||
"""仅用已收盘日线计算指标,避免把盘中未完成的日线混入信号。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, ROUND_CEILING
|
||||
import math
|
||||
from statistics import fmean, pstdev
|
||||
|
||||
from .config import ETFConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Indicators:
|
||||
day: str
|
||||
ma60: float
|
||||
atr: float
|
||||
lower: float
|
||||
middle: float
|
||||
upper: float
|
||||
grid: float
|
||||
|
||||
|
||||
def calculate(rows: list[dict], today: date, cfg: ETFConfig) -> Indicators:
|
||||
"""MA60 + Wilder ATR + BOLL(总体标准差),格距向上取整到 0.001 元。"""
|
||||
bars = {}
|
||||
for row in rows:
|
||||
day = datetime.strptime(str(row['date']), '%Y%m%d').date()
|
||||
if day >= today:
|
||||
continue
|
||||
if day in bars:
|
||||
raise ValueError('日线包含重复日期')
|
||||
high, low, close = (float(row[key]) for key in ('high', 'low', 'close'))
|
||||
if not all(math.isfinite(v) and v > 0 for v in (high, low, close)) or not low <= close <= high:
|
||||
raise ValueError('日线价格无效')
|
||||
bars[day] = (high, low, close)
|
||||
days = sorted(bars)
|
||||
if len(days) < max(60, cfg.atr_period + 1, cfg.boll_period):
|
||||
raise ValueError('已收盘日线不足,至少需要 60 根且能计算 ATR')
|
||||
# 长期停牌或历史缓存未补齐时不使用过期信号;春节等长假允许 15 个自然日。
|
||||
if (today - days[-1]).days > 15:
|
||||
raise ValueError('最近日线超过 15 个自然日,需补齐行情')
|
||||
values = [bars[d] for d in days]
|
||||
closes = [v[2] for v in values]
|
||||
tr = [max(h - l, abs(h - closes[i - 1]), abs(l - closes[i - 1]))
|
||||
for i, (h, l, _) in enumerate(values) if i > 0]
|
||||
n = cfg.atr_period
|
||||
atr = fmean(tr[:n])
|
||||
for value in tr[n:]:
|
||||
atr = (atr * (n - 1) + value) / n
|
||||
ma = fmean(closes[-60:])
|
||||
window = closes[-cfg.boll_period:]
|
||||
middle, width = fmean(window), cfg.boll_std * pstdev(window)
|
||||
raw_grid = max(atr * cfg.atr_multiplier, ma * cfg.min_grid_pct / 100, 0.001)
|
||||
grid = float(Decimal(str(raw_grid)).quantize(Decimal('0.001'), rounding=ROUND_CEILING))
|
||||
return Indicators(days[-1].strftime('%Y%m%d'), ma, atr, middle - width, middle, middle + width, grid)
|
||||
250
py-client/strategy/etf/open.py
Normal file
250
py-client/strategy/etf/open.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""ETF 网格策略开仓:观察 → 反弹确认 → 底仓挂单。
|
||||
|
||||
信号由 ``strategy/etf/signal.py`` 的 ``gen_signals`` 生成:白名单里的每个标的
|
||||
一条信号,``tech_indicator`` 里带着已收盘指标(``etf_entry``、``etf_price`` 等)。
|
||||
本模块只负责"能不能建网 / 按哪个价挂底仓",补仓与卖出见 ``positions.py``。
|
||||
|
||||
底仓规则(``docs/etf.md`` §2、§3.5):
|
||||
|
||||
1. 现价必须落在入场门槛以内(``min(区间下沿 + 通道幅度×channel_pct%, MA60)``);
|
||||
2. 用 ``rt.open_watch``(``DipWatch``)确认从观察低点反弹 ``rebound_pct%``;
|
||||
3. 反弹确认价就是锚点,按该价挂限价单买一档 ``buy_shares`` 股;
|
||||
4. 资金不足或挂单失败时撤销锚点,下一轮重新触发,不留"死锚点"。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging as log
|
||||
import math
|
||||
from typing import Any, Mapping
|
||||
|
||||
from libs.calc import trading_time
|
||||
from libs.order import PlaceOrderRequest
|
||||
from libs.runtime import Runtime
|
||||
from libs.signal import SignalItem
|
||||
from sdk import OP_BUY
|
||||
|
||||
from .signal import IND_ENTRY, IND_PRICE
|
||||
|
||||
|
||||
def entry_prices(item: SignalItem) -> tuple[float, float]:
|
||||
"""返回 (入场门槛, 最近收盘价);缺失时对应项为 0。"""
|
||||
values = getattr(item, "tech_indicator", None)
|
||||
if not isinstance(values, Mapping):
|
||||
values = {}
|
||||
entry = _positive(values.get(IND_ENTRY) or values.get("entry"))
|
||||
price = _positive(values.get(IND_PRICE) or getattr(item, "last_close", 0.0))
|
||||
return entry, price
|
||||
|
||||
|
||||
def classify_entry(item: SignalItem, runtime: Runtime, price: float) -> tuple[bool, str]:
|
||||
"""判定现价是否处于入场区,并维护 ``open_watch`` 的观察状态。
|
||||
|
||||
Returns:
|
||||
(是否已确认可建网, 说明)。价格在入场区之上时清除观察点,
|
||||
防止用"陈旧低点 + 现价"拼出虚假反弹。
|
||||
"""
|
||||
entry, _ = entry_prices(item)
|
||||
if entry <= 0:
|
||||
return False, "缺少入场门槛指标"
|
||||
|
||||
if price > entry:
|
||||
# 价格回到入场区上方:旧观察低点作废,必须重新形成低点。
|
||||
runtime.open_watch.forget(item.code)
|
||||
return False, f"未进入入场区(现价{price:.3f}>门槛{entry:.3f})"
|
||||
|
||||
if not runtime.open_watch.triggered("建网", item.code, price):
|
||||
return False, f"入场区内等待反弹确认(门槛{entry:.3f})"
|
||||
return True, f"反弹已确认,锚点={price:.3f}"
|
||||
|
||||
|
||||
def open_signal(run: Runtime, ticks, open_signals) -> None:
|
||||
"""逐个验证开仓信号,按锚点价挂出底仓限价单。"""
|
||||
if not trading_time(datetime.now()):
|
||||
return
|
||||
|
||||
for item in open_signals:
|
||||
code = item.code
|
||||
try:
|
||||
symbol = _symbol(run, code)
|
||||
if symbol is None:
|
||||
log.info("[ETF开仓] %s 跳过:不在 _etf.yaml 白名单内", code)
|
||||
continue
|
||||
if code in (getattr(run.account_cfg, "excluded_codes", None) or []):
|
||||
log.info("[ETF开仓] %s 跳过:已配置为排除证券", code)
|
||||
continue
|
||||
|
||||
price = _tick_price(run, code, (ticks or {}).get(code))
|
||||
if price <= 0:
|
||||
continue
|
||||
if run.orders.busy(code, "BUY"):
|
||||
log.info("[ETF开仓] %s 跳过:买入委托处理中", code)
|
||||
continue
|
||||
|
||||
confirmed, reason = classify_entry(item, run, price)
|
||||
if not confirmed:
|
||||
log.info("[ETF开仓] %s 跳过:%s", code, reason)
|
||||
continue
|
||||
|
||||
volume = _entry_volume(run, code)
|
||||
if volume <= 0:
|
||||
run.open_watch.forget(code) # 不留挂不出单的死锚点
|
||||
continue
|
||||
if not _budget_ok(run, price * volume):
|
||||
run.open_watch.forget(code)
|
||||
log.info(
|
||||
"[ETF开仓] %s 跳过:本轮预算不足,锚点作废,现价=%.3f,需要=%.2f",
|
||||
code,
|
||||
price,
|
||||
price * volume,
|
||||
)
|
||||
continue
|
||||
|
||||
do_open(run, code, volume, price, reason)
|
||||
except Exception as exc:
|
||||
log.exception("[ETF开仓] %s 处理异常:%s", code, exc)
|
||||
|
||||
|
||||
def do_open(run: Runtime, code: str, volume: int, price: float, reason: str = "") -> bool:
|
||||
"""按锚点价挂底仓买入委托;成功返回 True。"""
|
||||
request = PlaceOrderRequest(
|
||||
op=OP_BUY,
|
||||
code=code,
|
||||
volume=int(volume),
|
||||
order_id=run.orders.new_order_id("ETF", "BUY"),
|
||||
strategy_name=strategy_name(run),
|
||||
kind="base",
|
||||
price=price,
|
||||
)
|
||||
if not run.orders.place(run.client, request):
|
||||
run.open_watch.forget(code)
|
||||
log.warning("[ETF开仓] %s 底仓挂单失败,撤销锚点:%s", code, reason)
|
||||
return False
|
||||
|
||||
run.open_watch.forget(code)
|
||||
log.info(
|
||||
"[ETF开仓] %s 建网底仓 %d 股,锚点=%.3f,%s", code, request.volume, price, reason
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def strategy_name(run: Runtime) -> str:
|
||||
"""委托上的策略名:与账户 ``strategy`` 一致,便于按策略过滤委托与日志。"""
|
||||
return str(getattr(run.account_cfg, "strategy", "") or "etf").strip().lower() or "etf"
|
||||
|
||||
|
||||
def _symbol(run: Runtime, code: str) -> Any | None:
|
||||
"""取标的配置;不在白名单内返回 None。"""
|
||||
symbols = getattr(getattr(run, "etf_cfg", None), "symbols", None)
|
||||
if not isinstance(symbols, Mapping):
|
||||
return None
|
||||
return symbols.get(code)
|
||||
|
||||
|
||||
def _tick_price(run: Runtime, code: str, tick) -> float:
|
||||
"""校验实时行情:有限正数、时间戳为当天且未超过 ``max_tick_age_seconds``。"""
|
||||
price = _positive(getattr(tick, "last_price", 0.0)) if tick is not None else 0.0
|
||||
if price <= 0:
|
||||
log.info("[ETF开仓] %s 跳过:价格无效", code)
|
||||
return 0.0
|
||||
|
||||
now = datetime.now()
|
||||
stamp = _tick_stamp(getattr(tick, "raw", None))
|
||||
if stamp is None:
|
||||
log.info("[ETF开仓] %s 跳过:行情时间戳缺失", code)
|
||||
return 0.0
|
||||
if stamp.date() != now.date():
|
||||
log.info("[ETF开仓] %s 跳过:行情时间戳非当天(%s)", code, stamp)
|
||||
return 0.0
|
||||
|
||||
limit = _max_tick_age(run)
|
||||
age = (now - stamp).total_seconds()
|
||||
if age > limit:
|
||||
log.info("[ETF开仓] %s 跳过:行情已过期 %.0f 秒>%d 秒", code, age, limit)
|
||||
return 0.0
|
||||
return price
|
||||
|
||||
|
||||
def _tick_stamp(raw: Any) -> datetime | None:
|
||||
"""解析行情时间戳(``20260916103000`` / ``2026-09-16 10:30:00``)。"""
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
text = str(raw.get("timetag") or raw.get("time") or raw.get("stime") or "")
|
||||
digits = "".join(char for char in text if char.isdigit())
|
||||
if len(digits) < 14:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(digits[:14], "%Y%m%d%H%M%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _max_tick_age(run: Runtime) -> int:
|
||||
defaults = getattr(getattr(run, "etf_cfg", None), "defaults", None)
|
||||
value = getattr(defaults, "max_tick_age_seconds", 0)
|
||||
return value if type(value) is int and value > 0 else 90
|
||||
|
||||
|
||||
def _entry_volume(run: Runtime, code: str) -> int:
|
||||
"""底仓股数:配置的 ``buy_shares``,按整手与单标的上限裁剪。"""
|
||||
volume = getattr(_symbol(run, code), "buy_shares", 0)
|
||||
if type(volume) is not int or volume <= 0:
|
||||
log.info("[ETF开仓] %s 跳过:buy_shares 配置无效", code)
|
||||
return 0
|
||||
volume -= volume % 100
|
||||
if volume <= 0:
|
||||
return 0
|
||||
|
||||
max_shares = getattr(_symbol(run, code), "max_shares", None)
|
||||
if type(max_shares) is int and max_shares > 0:
|
||||
volume = min(volume, max_shares - max_shares % 100)
|
||||
return volume
|
||||
|
||||
|
||||
def _budget_ok(run: Runtime, amount: float) -> bool:
|
||||
"""本轮可用预算 = 券商可用资金 − 现金安全线 − 所有在途买单预留。"""
|
||||
assets = _latest_assets(run)
|
||||
available = getattr(assets, "available", None)
|
||||
if isinstance(available, bool) or not isinstance(available, (int, float)):
|
||||
# 拿不到资金快照时不阻拦,最终由柜台与在途委托锁把关。
|
||||
return True
|
||||
|
||||
total = _positive(getattr(assets, "total", 0.0))
|
||||
ratio = getattr(run.account_cfg, "min_cash_ratio", 0.0)
|
||||
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
|
||||
ratio = 0.0
|
||||
budget = float(available) - total * float(ratio) - pending_buy_amount(run)
|
||||
return amount <= max(0.0, budget)
|
||||
|
||||
|
||||
def pending_buy_amount(run: Runtime) -> float:
|
||||
"""所有未确认买单的预留金额(不是只算当前标的)。"""
|
||||
reserved = 0.0
|
||||
for order in getattr(run.orders, "data", None) or []:
|
||||
if getattr(order, "side", "") != "BUY":
|
||||
continue
|
||||
remaining = getattr(order, "volume_total_original", 0) - getattr(
|
||||
order, "volume_traded", 0
|
||||
)
|
||||
price = getattr(order, "limit_price", 0.0) or getattr(order, "traded_price", 0.0)
|
||||
if remaining > 0 and _positive(price) > 0:
|
||||
reserved += float(remaining) * float(price)
|
||||
return reserved
|
||||
|
||||
|
||||
def _latest_assets(run: Runtime) -> Any:
|
||||
"""读取最新资金快照:优先用 Runtime 上缓存的,其次问一次客户端。"""
|
||||
cached = getattr(run, "assets", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
return run.client.assets()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _positive(value: Any) -> float:
|
||||
"""把配置/指标值转成有限正浮点数;不合法时返回 0。"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return 0.0
|
||||
value = float(value)
|
||||
return value if math.isfinite(value) and value > 0 else 0.0
|
||||
490
py-client/strategy/etf/positions.py
Normal file
490
py-client/strategy/etf/positions.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""ETF 网格策略持仓管理:整仓止盈、单档止盈、百分比补仓。
|
||||
|
||||
规则见 ``docs/etf.md`` §4、§5,参数全部取自 ``rt.etf_cfg``:
|
||||
|
||||
- 持仓数量与可用份额**只以券商快照为准**,本地不重算持仓;
|
||||
- 档位由"持仓股数 ÷ 每档 ``buy_shares``"推出,是唯一能跨轮次存活的档位依据;
|
||||
- 上一档成交价优先用券商成本价 ``open_price``,没有可用成本时回落到本模块
|
||||
记录的上次成交价;
|
||||
- 主出口:盈亏率 ≥ ``min_profit_pct`` 整仓卖出(受 T+1 与 ``min_hold_days`` 限制);
|
||||
- 副出口:单档盈利从峰值回撤(``inner_step`` 网格、峰值已抬到 ``inner_grids``)只卖该档;
|
||||
- 补仓:自上一档再跌 ``add_pct`` 且 ``add_watch`` 反弹确认,最多 ``max_adds`` 次;
|
||||
- 超过 ``max_hold_days`` 只告警不强制平仓,残量留作隔夜持仓。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import logging as log
|
||||
import math
|
||||
from threading import Lock
|
||||
from typing import Any, Mapping
|
||||
|
||||
from libs.calc import trading_time
|
||||
from libs.grid_take_profit import GridState, GridTrailingTracker
|
||||
from libs.order import PlaceOrderRequest
|
||||
from libs.runtime import Runtime
|
||||
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
|
||||
|
||||
from .open import pending_buy_amount, strategy_name
|
||||
|
||||
# 单次卖出/补仓委托被拒后的冷却时间,避免同一 tick 反复重试。
|
||||
REJECT_COOLDOWN_SECONDS = 30
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TradeDecision:
|
||||
"""一次止盈或补仓判断的统一结果。"""
|
||||
|
||||
submitted: bool
|
||||
message: str = ""
|
||||
reserved_cash: float = 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SymbolProgress:
|
||||
"""单标的的进程内进度:补仓次数、上次成交价与冷却时刻。"""
|
||||
|
||||
adds: int = 0
|
||||
last_buy_price: float = 0.0
|
||||
last_add_day: int = 0
|
||||
last_sell_at: datetime | None = None
|
||||
warned_hold_days: int = 0
|
||||
# 主出口当日重试次数:挂单失败或状态未回报时不每轮重试。
|
||||
failed_sell_day: int = 0
|
||||
|
||||
|
||||
_progress: dict[str, SymbolProgress] = {}
|
||||
_trackers: dict[str, GridTrailingTracker] = {}
|
||||
_state_lock = Lock()
|
||||
|
||||
|
||||
def manage_positions(
|
||||
runtime: Runtime,
|
||||
ticks: Mapping[str, Tick],
|
||||
positions: list[PositionItem],
|
||||
market_ok: bool,
|
||||
available: float,
|
||||
) -> None:
|
||||
"""逐只核对持仓并执行卖出与补仓。"""
|
||||
if not trading_time(datetime.now()):
|
||||
return
|
||||
|
||||
# 所有标的分预算:先扣除全部在途买单,避免轮到后面才发现钱不够。
|
||||
budget = _available_budget(runtime, available, market_ok)
|
||||
|
||||
for position in positions:
|
||||
code = position.stock_code
|
||||
try:
|
||||
excluded = getattr(runtime.account_cfg, "excluded_codes", None) or []
|
||||
if code in excluded:
|
||||
log.info("[ETF持仓] %s 跳过:已配置为排除证券", code)
|
||||
continue
|
||||
symbol = _symbol(runtime, code)
|
||||
if symbol is None:
|
||||
log.info("[ETF持仓] %s 跳过:不在 _etf.yaml 白名单内", code)
|
||||
continue
|
||||
|
||||
tick = ticks.get(code)
|
||||
price = _tick_price(runtime, code, tick)
|
||||
if price <= 0 or position.volume <= 0:
|
||||
log.warning(
|
||||
"[ETF持仓] %s 跳过:持仓或行情无效,持仓=%d,现价=%.3f",
|
||||
code,
|
||||
position.volume,
|
||||
price,
|
||||
)
|
||||
continue
|
||||
|
||||
# 盈亏率口径与主出口一致:以券商成本价为分母。
|
||||
cost = _positive(position.open_price)
|
||||
pnl_rate = (price - cost) / cost * 100 if cost > 0 else 0.0
|
||||
level = position_level(runtime, position)
|
||||
progress = _get_progress(code, level)
|
||||
sellable = sellable_volume(symbol, position)
|
||||
|
||||
# 1. 主出口:整仓止盈,一次清空网格。
|
||||
exit_decision = handle_exit(
|
||||
runtime, symbol, position, tick, pnl_rate, sellable
|
||||
)
|
||||
action = exit_decision.message or "未触发"
|
||||
if exit_decision.submitted:
|
||||
_log_position(code, position, price, pnl_rate, action, "已停止")
|
||||
continue
|
||||
|
||||
# 2. 副出口:单档峰值回撤,只处理当前档。
|
||||
if not runtime.orders.busy(code, "SELL"):
|
||||
per_level = handle_level_exit(
|
||||
runtime, symbol, position, tick, pnl_rate, level
|
||||
)
|
||||
action = per_level.message or action
|
||||
|
||||
# 3. 时间退出:超期只告警,残量留作隔夜持仓。
|
||||
hold_decision = handle_max_hold(runtime, code, progress, level)
|
||||
add_action = "未启用"
|
||||
if hold_decision.submitted:
|
||||
add_action = hold_decision.message
|
||||
|
||||
# 4. 补仓:自上一档再跌 add_pct,且反弹确认后才买。
|
||||
if market_ok:
|
||||
add_decision = handle_add(
|
||||
runtime, symbol, position, tick, price, budget, level
|
||||
)
|
||||
budget = max(0.0, budget - add_decision.reserved_cash)
|
||||
add_action = add_decision.message or "未触发"
|
||||
else:
|
||||
add_action = "大盘信号不允许"
|
||||
|
||||
_log_position(code, position, price, pnl_rate, action, add_action)
|
||||
except Exception as exc:
|
||||
log.exception("[ETF持仓] %s 处理异常:%s", code, exc)
|
||||
|
||||
|
||||
def handle_exit(
|
||||
runtime: Runtime,
|
||||
symbol: Any,
|
||||
position: PositionItem,
|
||||
tick: Tick,
|
||||
pnl_rate: float,
|
||||
sellable: int,
|
||||
) -> TradeDecision:
|
||||
"""主出口:盈亏率 ≥ ``min_profit_pct`` 时整仓卖出。"""
|
||||
target = _default(runtime, "min_profit_pct", 1.0)
|
||||
minimum = _positive(target)
|
||||
if minimum <= 0 or pnl_rate < minimum:
|
||||
return TradeDecision(False, f"持有中 PNL={pnl_rate:.2f}%(目标{minimum:.2f}%)")
|
||||
|
||||
code = position.stock_code
|
||||
volume = min(max(0, int(sellable)) - int(sellable) % 100, position.volume)
|
||||
if volume <= 0:
|
||||
return TradeDecision(False, f"无当日可卖整手(可用={position.can_use_volume})")
|
||||
if runtime.orders.busy(code, "SELL"):
|
||||
return TradeDecision(False, "卖出委托处理中")
|
||||
|
||||
progress = _get_progress(code, position_level(runtime, position))
|
||||
now = datetime.now()
|
||||
if progress.last_sell_at is not None and (
|
||||
now - progress.last_sell_at
|
||||
).total_seconds() < REJECT_COOLDOWN_SECONDS:
|
||||
return TradeDecision(False, "卖出冷却中")
|
||||
if progress.failed_sell_day == now.date().toordinal():
|
||||
# 当日挂单失败过:等收盘或等仓位变化,避免每轮重复下单。
|
||||
return TradeDecision(False, "当日整仓止盈挂单未成功,暂停重试")
|
||||
|
||||
request = PlaceOrderRequest(
|
||||
op=OP_SELL,
|
||||
code=code,
|
||||
volume=volume,
|
||||
order_id=runtime.orders.new_order_id("ETF", "SELL"),
|
||||
strategy_name=strategy_name(runtime),
|
||||
kind="exit",
|
||||
price=_tick_price_or(position.last_price, tick.last_price),
|
||||
)
|
||||
submitted = runtime.orders.place(runtime.client, request)
|
||||
progress.last_sell_at = now
|
||||
if not submitted:
|
||||
progress.failed_sell_day = now.date().toordinal()
|
||||
return TradeDecision(False, "整仓止盈委托失败")
|
||||
|
||||
return TradeDecision(True, f"[主出口] 盈亏率={pnl_rate:.2f}% 整仓卖出 {volume} 股")
|
||||
|
||||
|
||||
def handle_level_exit(
|
||||
runtime: Runtime,
|
||||
symbol: Any,
|
||||
position: PositionItem,
|
||||
tick: Tick,
|
||||
pnl_rate: float,
|
||||
level: int,
|
||||
) -> TradeDecision:
|
||||
"""副出口:单档盈利从峰值回撤且峰值已抬到 ``inner_grids`` 格时只卖该档。"""
|
||||
code = position.stock_code
|
||||
observation = _tracker(code, symbol).observe(f"etf:{code}:level:{level}", pnl_rate)
|
||||
if observation.state is not GridState.RETREAT:
|
||||
return TradeDecision(
|
||||
False, f"单档网格={observation.current_grid}/峰值={observation.peak_grid}"
|
||||
)
|
||||
|
||||
required = _positive(_symbol_value(symbol, "inner_grids", runtime, "inner_grids", 2.0))
|
||||
if observation.peak_grid < required:
|
||||
return TradeDecision(
|
||||
False,
|
||||
f"峰值未达 {required:g} 格(当前峰值={observation.peak_grid})",
|
||||
)
|
||||
|
||||
volume = min(max(0, int(position.can_use_volume)) - int(position.can_use_volume) % 100,
|
||||
position.volume)
|
||||
if volume <= 0:
|
||||
return TradeDecision(False, "该档无当日可卖整仓")
|
||||
if runtime.orders.busy(code, "SELL"):
|
||||
return TradeDecision(False, "卖出委托处理中")
|
||||
|
||||
request = PlaceOrderRequest(
|
||||
op=OP_SELL,
|
||||
code=code,
|
||||
volume=volume,
|
||||
order_id=runtime.orders.new_order_id("ETF", "SELL"),
|
||||
strategy_name=strategy_name(runtime),
|
||||
kind="profit",
|
||||
price=_tick_price_or(position.last_price, tick.last_price),
|
||||
)
|
||||
if not runtime.orders.place(runtime.client, request):
|
||||
# 下单失败或撤单时必须保留峰值,等下一轮再试。
|
||||
return TradeDecision(False, "单档止盈委托失败")
|
||||
|
||||
# 峰值只能在卖出成功后清除。
|
||||
_tracker(code, symbol).clear(f"etf:{code}:level:{level}")
|
||||
return TradeDecision(True, f"[副出口] 第{level}档 盈亏率={pnl_rate:.2f}% 卖出 {volume} 股")
|
||||
|
||||
|
||||
def handle_max_hold(
|
||||
runtime: Runtime, code: str, progress: SymbolProgress, level: int
|
||||
) -> TradeDecision:
|
||||
"""超过 ``max_hold_days`` 的轮次只告警,不强制平仓(残量留作隔夜持仓)。"""
|
||||
limit = _default(runtime, "max_hold_days", 0)
|
||||
if type(limit) is not int or limit <= 0:
|
||||
return TradeDecision(False)
|
||||
|
||||
# 本地不记录真实买入日:用"档位 + 当日首见/本次加档"推算持有自然日,
|
||||
# 只为触发一次告警,不参与下单决策。没有记录时退化为"档位 ≈ 已持有天数"。
|
||||
today = datetime.now().date().toordinal()
|
||||
started = progress.last_buy_day or (today - level)
|
||||
if progress.warned_hold_days == today or today < started + limit:
|
||||
return TradeDecision(False)
|
||||
|
||||
progress.warned_hold_days = today
|
||||
return TradeDecision(True, f"[超期] 已持有{max(0, today - started)}天,超过 max_hold_days={limit},仅告警不平仓")
|
||||
|
||||
|
||||
def handle_add(
|
||||
runtime: Runtime,
|
||||
symbol: Any,
|
||||
position: PositionItem,
|
||||
tick: Tick,
|
||||
price: float,
|
||||
budget: float,
|
||||
level: int,
|
||||
) -> TradeDecision:
|
||||
"""补仓:自上一档再跌 ``add_pct`` 且反弹确认后按现价买入一档。"""
|
||||
code = position.stock_code
|
||||
progress = _get_progress(code, level)
|
||||
max_adds = _default(runtime, "max_adds", 9)
|
||||
if type(max_adds) is not int or max_adds < 0:
|
||||
return TradeDecision(False, "max_adds 配置无效")
|
||||
if progress.adds >= max_adds:
|
||||
return TradeDecision(False, f"已满 {max_adds + 1} 档,只等主出口")
|
||||
|
||||
add_pct = _positive(_default(runtime, "add_pct", 3.0))
|
||||
last_price = last_buy_price(symbol, position, progress)
|
||||
if add_pct <= 0 or last_price <= 0:
|
||||
return TradeDecision(False, "缺少上一档成交价")
|
||||
|
||||
drop = (last_price - price) / last_price * 100
|
||||
if drop < add_pct:
|
||||
# 跌幅未达门槛时不观察,避免把"没到位的低点"记成观察起点。
|
||||
runtime.add_watch.forget(code)
|
||||
return TradeDecision(False, f"自上一档跌幅={drop:.2f}%<{add_pct:.2f}%")
|
||||
|
||||
if progress.last_add_day == datetime.now().date().toordinal():
|
||||
# 同一交易日每档最多补一次:避免同一个低点被反复确认成多笔加仓。
|
||||
return TradeDecision(False, "本档当日已补仓,等待下一档")
|
||||
|
||||
buy_shares = _symbol_value(symbol, "buy_shares", runtime, "buy_shares", 0)
|
||||
if type(buy_shares) is not int or buy_shares <= 0:
|
||||
return TradeDecision(False, "buy_shares 配置无效")
|
||||
volume = buy_shares - buy_shares % 100
|
||||
max_shares = _symbol_value(symbol, "max_shares", runtime, "max_shares", 0)
|
||||
if type(max_shares) is int and max_shares > 0:
|
||||
room = max_shares - max_shares % 100 - position.volume
|
||||
volume = min(volume, room)
|
||||
if volume <= 0:
|
||||
return TradeDecision(False, "已达单标的上限")
|
||||
|
||||
amount = price * volume
|
||||
if runtime.orders.busy(code, "BUY"):
|
||||
return TradeDecision(False, "买入委托处理中")
|
||||
# 预算不足时不消耗观察状态:等资金腾出来仍可用同一个观察低点确认。
|
||||
if amount > budget:
|
||||
return TradeDecision(False, f"本轮预算不足(需要{amount:.2f}>可用{budget:.2f})")
|
||||
if not runtime.add_watch.triggered("补仓", code, price):
|
||||
return TradeDecision(False, "等待价格反弹确认")
|
||||
|
||||
request = PlaceOrderRequest(
|
||||
op=OP_BUY,
|
||||
code=code,
|
||||
volume=volume,
|
||||
order_id=runtime.orders.new_order_id("ETF", "BUY"),
|
||||
strategy_name=strategy_name(runtime),
|
||||
kind="add",
|
||||
price=price,
|
||||
)
|
||||
if not runtime.orders.place(runtime.client, request):
|
||||
return TradeDecision(False, "补仓委托失败")
|
||||
|
||||
progress.adds += 1
|
||||
progress.last_buy_price = price
|
||||
progress.last_add_day = datetime.now().date().toordinal()
|
||||
runtime.add_watch.forget(code)
|
||||
return TradeDecision(
|
||||
True, f"[补仓] 第{level + 1}档 {volume} 股,跌幅={drop:.2f}%", amount
|
||||
)
|
||||
|
||||
|
||||
def position_level(runtime: Runtime, position: PositionItem) -> int:
|
||||
"""由持仓股数推出档位:1 = 只有底仓,2 = 底仓 + 一档补仓……"""
|
||||
buy_shares = _symbol_value(
|
||||
_symbol(runtime, position.stock_code),
|
||||
"buy_shares",
|
||||
runtime,
|
||||
"buy_shares",
|
||||
0,
|
||||
)
|
||||
if type(buy_shares) is not int or buy_shares <= 0:
|
||||
return 1
|
||||
return max(1, -(-int(position.volume) // buy_shares))
|
||||
|
||||
|
||||
def last_buy_price(
|
||||
symbol: Any, position: PositionItem, progress: SymbolProgress
|
||||
) -> float:
|
||||
"""上一档成交价:优先券商成本价,其次本模块记录的上次成交价。"""
|
||||
if progress.last_buy_price > 0:
|
||||
return progress.last_buy_price
|
||||
return _positive(position.open_price)
|
||||
|
||||
|
||||
def sellable_volume(symbol: Any, position: PositionItem) -> int:
|
||||
"""当日可卖股数:受 T+1 与 ``min_hold_days`` 限制,整手向下取整。"""
|
||||
if _is_t0(symbol) or position.yesterday_volume > 0:
|
||||
# T+0 标的,或已有隔夜持仓:券商可用份额就是上限。
|
||||
return max(0, int(position.can_use_volume))
|
||||
return 0
|
||||
|
||||
|
||||
def _available_budget(runtime: Runtime, available: float, market_ok: bool) -> float:
|
||||
"""补仓预算 = 调用方传入的可用资金 − 现金安全线 − 全部在途买单预留。
|
||||
|
||||
调用方只给 ``assets.available``(见 ``boot.RunOnce``),因此现金安全线按
|
||||
"可用资金"比例扣除:``available × min_cash_ratio`` 是本模块能保守估计的
|
||||
安全垫,不会把预留资金算成可加仓的额度。
|
||||
"""
|
||||
if isinstance(available, bool) or not isinstance(available, (int, float)):
|
||||
return 0.0
|
||||
ratio = getattr(runtime.account_cfg, "min_cash_ratio", 0.0)
|
||||
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
|
||||
ratio = 0.0
|
||||
budget = float(available) - abs(float(available)) * float(ratio) - pending_buy_amount(runtime)
|
||||
return max(0.0, budget)
|
||||
|
||||
|
||||
def _tick_price(runtime: Runtime, code: str, tick: Tick | None) -> float:
|
||||
"""校验实时行情:有限正数、当天且未超过 ``max_tick_age_seconds``。"""
|
||||
price = _positive(getattr(tick, "last_price", 0.0)) if tick is not None else 0.0
|
||||
if price <= 0:
|
||||
return 0.0
|
||||
stamp = _tick_stamp(getattr(tick, "raw", None))
|
||||
if stamp is None or stamp.date() != datetime.now().date():
|
||||
return 0.0
|
||||
limit = _default(runtime, "max_tick_age_seconds", 90)
|
||||
if type(limit) is not int or limit <= 0:
|
||||
limit = 90
|
||||
if (datetime.now() - stamp).total_seconds() > limit:
|
||||
return 0.0
|
||||
return price
|
||||
|
||||
|
||||
def _tick_stamp(raw: Any) -> datetime | None:
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
text = str(raw.get("timetag") or raw.get("time") or raw.get("stime") or "")
|
||||
digits = "".join(char for char in text if char.isdigit())
|
||||
if len(digits) < 14:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(digits[:14], "%Y%m%d%H%M%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _tick_price_or(fallback: Any, price: Any) -> float:
|
||||
"""限价:优先现价,缺失时用持仓快照的最新价。"""
|
||||
return _positive(price) or _positive(fallback)
|
||||
|
||||
|
||||
def _symbol(runtime: Runtime, code: str) -> Any | None:
|
||||
symbols = getattr(getattr(runtime, "etf_cfg", None), "symbols", None)
|
||||
if not isinstance(symbols, Mapping):
|
||||
return None
|
||||
return symbols.get(code)
|
||||
|
||||
|
||||
def _symbol_value(
|
||||
symbol: Any, attr: str, runtime: Runtime, defaults_attr: str, fallback: Any
|
||||
) -> Any:
|
||||
"""标的覆盖优先,其次全局默认:标的为 None 时按未覆盖处理。"""
|
||||
value = getattr(symbol, attr, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return _default(runtime, defaults_attr, fallback)
|
||||
|
||||
|
||||
def _default(runtime: Runtime, name: str, fallback: Any) -> Any:
|
||||
"""读取 ``_etf.yaml`` 的全局默认参数。"""
|
||||
defaults = getattr(getattr(runtime, "etf_cfg", None), "defaults", None)
|
||||
value = getattr(defaults, name, None)
|
||||
return fallback if value is None else value
|
||||
|
||||
|
||||
def _is_t0(symbol: Any) -> bool:
|
||||
return getattr(symbol, "is_t0", False) is True
|
||||
|
||||
|
||||
def _tracker(code: str, symbol: Any) -> GridTrailingTracker:
|
||||
"""按标的缓存峰值跟踪器:内层格距是逐标的参数。"""
|
||||
with _state_lock:
|
||||
tracker = _trackers.get(code)
|
||||
if tracker is None:
|
||||
step = _positive(getattr(symbol, "inner_step", 0.0)) or 0.5
|
||||
tracker = GridTrailingTracker(step)
|
||||
_trackers[code] = tracker
|
||||
return tracker
|
||||
|
||||
|
||||
def _get_progress(code: str, level: int) -> SymbolProgress:
|
||||
"""取标的进度;首次见到时用券商推出来的档位补齐补仓次数。"""
|
||||
with _state_lock:
|
||||
progress = _progress.get(code)
|
||||
if progress is None:
|
||||
# 档位 N 意味着已经补过 N-1 次,重启后仍能对上 max_adds 上限。
|
||||
progress = SymbolProgress(adds=max(0, level - 1))
|
||||
_progress[code] = progress
|
||||
return progress
|
||||
|
||||
|
||||
def _log_position(
|
||||
code: str,
|
||||
position: PositionItem,
|
||||
price: float,
|
||||
pnl_rate: float,
|
||||
exit_action: str,
|
||||
add_action: str,
|
||||
) -> None:
|
||||
log.info(
|
||||
"[ETF持仓] %s %s,现价=%.3f,成本=%.3f,盈亏=%.2f%%,持有=%d,可用=%d,止盈=%s,补仓=%s",
|
||||
code,
|
||||
position.stock_name or "-",
|
||||
price,
|
||||
position.open_price,
|
||||
pnl_rate,
|
||||
position.volume,
|
||||
position.can_use_volume,
|
||||
exit_action,
|
||||
add_action,
|
||||
)
|
||||
|
||||
|
||||
def _positive(value: Any) -> float:
|
||||
"""把配置/行情值转成有限正浮点数;不合法时返回 0。"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return 0.0
|
||||
value = float(value)
|
||||
return value if math.isfinite(value) and value > 0 else 0.0
|
||||
317
py-client/strategy/etf/signal.py
Normal file
317
py-client/strategy/etf/signal.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""ETF 信号层:把 ``_etf.yaml`` 的白名单展开为可交易的信号列表。
|
||||
|
||||
一个信号就是一个标的:入场判定所需的指标全部固化在 ``SignalItem.tech_indicator``
|
||||
里,引擎不再自己取数、算指标。数据来自外部日线接口(``docs/etf.md`` §7.4),
|
||||
只使用已收盘日线,见 ``calculate`` 的校验。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import ROUND_CEILING, Decimal
|
||||
import logging as log
|
||||
import math
|
||||
import re
|
||||
from statistics import fmean
|
||||
|
||||
import httpx
|
||||
|
||||
from config import HTTP_TIMEOUT, EtfSymbolConfig
|
||||
from libs.runtime import Runtime
|
||||
from libs.signal import SignalItem
|
||||
|
||||
# 外部日线接口:只接受单个 code,无复权价;不发送 QMT 认证信息。
|
||||
DAILY_PATH = "/etf/daily"
|
||||
DAILY_URL = "http://139.224.247.176:13499/etf/daily"
|
||||
|
||||
# 取数失败后的重试间隔,避免接口故障时每轮都打满请求。
|
||||
RETRY_SECONDS = 300
|
||||
# 只保留最近的样本;需要 60 根 MA60、61 根 ATR14。
|
||||
BAR_COUNT = 120
|
||||
# ATR 需要前一根收盘价,MA60 需要 60 根,取两者的较大值作为样本下限。
|
||||
MIN_BARS = 61
|
||||
# MA60 是网的顶部上限,周期固定为 60 个交易日。
|
||||
MA_PERIOD = 60
|
||||
# 长期停牌或缓存未补齐时不使用过期指标;春节等长假允许 15 个自然日。
|
||||
MAX_BAR_AGE_DAYS = 15
|
||||
|
||||
# tech_indicator 的键:带 etf_ 前缀,避免与其它策略的信号字段混用。
|
||||
IND_MA60 = "etf_ma60"
|
||||
IND_ATR = "etf_atr"
|
||||
IND_CHANNEL_LOW = "etf_channel_low"
|
||||
IND_CHANNEL_HIGH = "etf_channel_high"
|
||||
IND_ENTRY = "etf_entry"
|
||||
IND_GRID = "etf_grid"
|
||||
IND_GRID_PCT = "etf_grid_pct"
|
||||
IND_ADD_PRICE = "etf_add_price"
|
||||
IND_PRICE = "etf_price"
|
||||
|
||||
_STAMP_PATTERN = re.compile(r"[0-9]{8}")
|
||||
_PRICE_FIELDS = ("open", "high", "low", "close")
|
||||
|
||||
# 行情客户端按需创建:模块只做信号生成,不持有 QMT 客户端。
|
||||
_history_client: httpx.Client | None = None
|
||||
# 每标的每日只取一次;失败标的按 RETRY_SECONDS 重试。
|
||||
_daily_cache: dict[str, list[dict]] = {}
|
||||
_fetched: dict[str, date] = {}
|
||||
_retry_at: dict[str, datetime] = {}
|
||||
|
||||
|
||||
def gen_signals(rt: Runtime) -> list[SignalItem]:
|
||||
"""从 ``_etf.yaml`` 白名单生成信号列表,顺序即资金优先级。
|
||||
|
||||
每个标的独立取日线并计算指标;取数或计算失败的标的本轮直接跳过,
|
||||
不允许退化使用旧数据,也不允许替换成别的证券。返回的
|
||||
``SignalItem.tech_indicator`` 携带引擎需要的全部已收盘指标。
|
||||
"""
|
||||
etf_cfg = getattr(rt, "etf_cfg", None)
|
||||
if etf_cfg is None:
|
||||
log.error("[ETF信号] 缺少 _etf.yaml 配置,本轮无可交易标的")
|
||||
return []
|
||||
|
||||
today = datetime.now().date()
|
||||
_reset_daily(today)
|
||||
endpoint = _api_endpoint(rt)
|
||||
cfg = etf_cfg.defaults
|
||||
signals: list[SignalItem] = []
|
||||
|
||||
for code in etf_cfg.codes:
|
||||
try:
|
||||
symbol = etf_cfg.symbols[code]
|
||||
bars = _daily_bars(code, today, endpoint)
|
||||
if not bars:
|
||||
continue
|
||||
indicators = calculate(bars, symbol, cfg, today)
|
||||
except Exception as exc:
|
||||
log.warning("[ETF信号] %s 跳过:%s", code, exc)
|
||||
continue
|
||||
|
||||
signals.append(
|
||||
SignalItem(
|
||||
signal_key=etf_cfg_key(rt),
|
||||
code=code,
|
||||
name=code,
|
||||
desc=f"ETF网格 档位={symbol.buy_shares}股 上限={symbol.max_shares}股",
|
||||
last_close=indicators[IND_PRICE],
|
||||
tech_indicator=indicators,
|
||||
)
|
||||
)
|
||||
|
||||
if signals:
|
||||
log.info(
|
||||
"[ETF信号] 生成完毕,可交易=%d/%d,来源=%s",
|
||||
len(signals),
|
||||
len(etf_cfg.codes),
|
||||
endpoint,
|
||||
)
|
||||
else:
|
||||
log.warning("[ETF信号] 本轮没有可用信号,白名单=%d", len(etf_cfg.codes))
|
||||
return signals
|
||||
|
||||
|
||||
def etf_cfg_key(rt: Runtime) -> str:
|
||||
"""信号的品种标识:ETF 全部标的共用 ``etf``,便于按策略名过滤委托与日志。"""
|
||||
strategy = str(getattr(rt.account_cfg, "strategy", "") or "").strip().lower()
|
||||
return strategy or "etf"
|
||||
|
||||
|
||||
def calculate(
|
||||
bars: list[dict], symbol: EtfSymbolConfig, defaults, today: date
|
||||
) -> dict[str, float]:
|
||||
"""用已收盘日线算出引擎需要的全部指标。
|
||||
|
||||
ATR 走 Wilder 平滑;格距向上取整到 0.001 元(ETF 最小报价单位)。
|
||||
样本不足或日线过期时抛 ValueError,由调用方放弃该标的当轮交易。
|
||||
"""
|
||||
if len(bars) < MIN_BARS:
|
||||
raise ValueError(f"已收盘日线不足 {MIN_BARS} 根")
|
||||
|
||||
ordered = sorted(bars, key=lambda bar: bar["date"])
|
||||
last_day = datetime.strptime(ordered[-1]["date"], "%Y%m%d").date()
|
||||
# 长期停牌或历史缓存未补齐时不使用过期数据;春节等长假允许 15 个自然日。
|
||||
if (today - last_day).days > MAX_BAR_AGE_DAYS:
|
||||
raise ValueError(
|
||||
f"最近日线 {ordered[-1]['date']} 超过 {MAX_BAR_AGE_DAYS} 个自然日"
|
||||
)
|
||||
|
||||
period = defaults.atr_period
|
||||
if type(period) is not int or period < 2:
|
||||
raise ValueError("atr_period 必须是大于 1 的整数")
|
||||
|
||||
closes = [float(bar["close"]) for bar in ordered]
|
||||
highs = [float(bar["high"]) for bar in ordered]
|
||||
lows = [float(bar["low"]) for bar in ordered]
|
||||
ranges = [
|
||||
max(high - low, abs(high - closes[index - 1]), abs(low - closes[index - 1]))
|
||||
for index, (high, low) in enumerate(zip(highs, lows))
|
||||
if index > 0
|
||||
]
|
||||
if len(ranges) < period:
|
||||
raise ValueError(f"日线不足 {period + 1} 根,无法计算 ATR")
|
||||
|
||||
atr = fmean(ranges[:period])
|
||||
for value in ranges[period:]:
|
||||
atr = (atr * (period - 1) + value) / period
|
||||
|
||||
window = int(defaults.channel_period)
|
||||
if len(highs) < max(window, MA_PERIOD):
|
||||
raise ValueError(f"日线不足 {max(window, MA_PERIOD)} 根,无法计算通道或 MA60")
|
||||
ma60 = fmean(closes[-MA_PERIOD:])
|
||||
channel_low = min(lows[-window:])
|
||||
channel_high = max(highs[-window:])
|
||||
# 入场门槛 = min(距区间下沿 channel_pct% 的价位, MA60):不在均线上方建网。
|
||||
entry = min(
|
||||
channel_low + (channel_high - channel_low) * defaults.channel_pct / 100, ma60
|
||||
)
|
||||
# 格距 = max(ATR × 倍数, MA60 × 格距下限百分比, 0.001),向上取整到 0.001 元。
|
||||
raw_grid = max(atr * symbol.atr_multiplier, ma60 * defaults.min_grid_pct / 100, 0.001)
|
||||
grid = float(Decimal(str(raw_grid)).quantize(Decimal("0.001"), rounding=ROUND_CEILING))
|
||||
|
||||
values = (ma60, atr, channel_low, channel_high, entry, grid, closes[-1])
|
||||
if not all(math.isfinite(value) and value > 0 for value in values):
|
||||
raise ValueError("指标存在非有限正数")
|
||||
if grid <= 0:
|
||||
raise ValueError("格距非正数")
|
||||
|
||||
return {
|
||||
IND_MA60: ma60,
|
||||
IND_ATR: atr,
|
||||
IND_CHANNEL_LOW: channel_low,
|
||||
IND_CHANNEL_HIGH: channel_high,
|
||||
IND_ENTRY: entry,
|
||||
IND_GRID: grid,
|
||||
IND_GRID_PCT: grid / closes[-1] * 100,
|
||||
IND_ADD_PRICE: closes[-1] * (1 - defaults.add_pct / 100),
|
||||
IND_PRICE: closes[-1],
|
||||
}
|
||||
|
||||
|
||||
def daily_bars(
|
||||
client: httpx.Client,
|
||||
code: str,
|
||||
today: date,
|
||||
count: int = BAR_COUNT,
|
||||
endpoint: str = DAILY_URL,
|
||||
) -> list[dict]:
|
||||
"""读取指定证券日线;窗口截取在本地完成(接口只支持单 code)。"""
|
||||
response = client.get(endpoint, params={"code": code})
|
||||
response.raise_for_status()
|
||||
return parse_daily(response.json(), code, today, count)
|
||||
|
||||
|
||||
def parse_daily(
|
||||
payload: object, code: str, today: date, count: int = BAR_COUNT
|
||||
) -> list[dict]:
|
||||
"""校验业务状态、证券归属、OHLC 与日期,返回按日期升序的最近若干根。
|
||||
|
||||
线上接口直接返回一维数组(倒序),旧版是 ``{code, message, details}`` 包装,
|
||||
两种形式都支持。任一校验不通过即抛 ValueError,调用方放弃该标的当轮交易。
|
||||
"""
|
||||
if type(count) is not int or count <= 0:
|
||||
raise ValueError("日线数量必须为正整数")
|
||||
if isinstance(payload, list):
|
||||
rows = payload
|
||||
elif isinstance(payload, dict):
|
||||
if type(payload.get("code")) is not int or payload["code"] != 0:
|
||||
raise ValueError(f"日线接口业务失败:{payload.get('message', '状态无效')}")
|
||||
rows = payload.get("details")
|
||||
else:
|
||||
rows = None
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise ValueError(f"{code} 日线接口未返回有效数据列表")
|
||||
|
||||
bars: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or row.get("ts_code") != code:
|
||||
raise ValueError(f"{code} 日线证券代码不一致")
|
||||
stamp = str(row.get("trade_date", ""))
|
||||
if not _STAMP_PATTERN.fullmatch(stamp):
|
||||
raise ValueError(f"{code} 日线日期无效:{stamp}")
|
||||
day = datetime.strptime(stamp, "%Y%m%d").date()
|
||||
# 当前日及未来日线不得混入盘中指标,先过滤再截取最近 count 根。
|
||||
if day >= today:
|
||||
continue
|
||||
if stamp in bars:
|
||||
raise ValueError(f"{code} 日线日期重复:{stamp}")
|
||||
|
||||
values = {}
|
||||
for name in _PRICE_FIELDS:
|
||||
value = row.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ValueError(f"{code} 日线 {name} 无效")
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{code} 日线 {name} 无效") from exc
|
||||
if not math.isfinite(number) or number <= 0:
|
||||
raise ValueError(f"{code} 日线 {name} 非有限正数")
|
||||
values[name] = number
|
||||
if not (
|
||||
values["low"] <= values["open"] <= values["high"]
|
||||
and values["low"] <= values["close"] <= values["high"]
|
||||
):
|
||||
raise ValueError(f"{code} 日线 OHLC 关系异常")
|
||||
bars[stamp] = dict(date=stamp, **values)
|
||||
return [bars[stamp] for stamp in sorted(bars)[-count:]]
|
||||
|
||||
|
||||
def _api_endpoint(rt: Runtime) -> str:
|
||||
"""日线接口地址:拼接全局 api_host,未配置时用接口默认地址。"""
|
||||
global_cfg = getattr(rt, "global_cfg", None)
|
||||
host = str(getattr(global_cfg, "api_host", "") or "").strip().rstrip("/")
|
||||
return f"{host}{DAILY_PATH}" if host else DAILY_URL
|
||||
|
||||
|
||||
def _reset_daily(today: date) -> None:
|
||||
"""跨交易日清空日线缓存,保证指标只基于当天可见的已收盘日线。
|
||||
|
||||
按标的逐个判断取数日期:失败重试记录带着自己的日期,即使还有标的当天
|
||||
尚未取数成功也不会被清掉,重试窗口因此始终有效。
|
||||
"""
|
||||
for code in [code for code, day in _fetched.items() if day != today]:
|
||||
_daily_cache.pop(code, None)
|
||||
_fetched.pop(code, None)
|
||||
for code in [
|
||||
code for code, retry_at in _retry_at.items() if retry_at.date() != today
|
||||
]:
|
||||
_retry_at.pop(code, None)
|
||||
|
||||
|
||||
def _daily_bars(code: str, today: date, endpoint: str) -> list[dict] | None:
|
||||
"""取某个标的的日线:当日成功过就直接复用,失败则等重试间隔。"""
|
||||
if _fetched.get(code) == today:
|
||||
return _daily_cache.get(code)
|
||||
|
||||
now = datetime.now()
|
||||
# 重试时刻在同一天内才生效;跨日后必须先重新取数。
|
||||
retry_at = _retry_at.get(code)
|
||||
if retry_at is not None and retry_at.date() == today and now < retry_at:
|
||||
return None
|
||||
|
||||
try:
|
||||
bars = daily_bars(_history_client_get(), code, today, endpoint=endpoint)
|
||||
except (httpx.HTTPError, ValueError, OSError) as exc:
|
||||
_retry_at[code] = now + timedelta(seconds=RETRY_SECONDS)
|
||||
log.warning(
|
||||
"[ETF日线] %s 获取失败,%d 秒后重试:%s", code, RETRY_SECONDS, exc
|
||||
)
|
||||
return None
|
||||
|
||||
_daily_cache[code] = bars
|
||||
_fetched[code] = today
|
||||
_retry_at.pop(code, None)
|
||||
return bars
|
||||
|
||||
|
||||
def _history_client_get() -> httpx.Client:
|
||||
"""复用外部日线连接池;模块首次取数时才创建。"""
|
||||
global _history_client
|
||||
if _history_client is None:
|
||||
_history_client = httpx.Client(timeout=HTTP_TIMEOUT)
|
||||
return _history_client
|
||||
|
||||
|
||||
def reset_history_client() -> None:
|
||||
"""关闭并清空外部日线客户端,供进程退出或测试收尾调用。"""
|
||||
global _history_client
|
||||
if _history_client is not None:
|
||||
_history_client.close()
|
||||
_history_client = None
|
||||
@@ -1,65 +0,0 @@
|
||||
"""保存交易意图与网格基准;实际持仓始终以券商快照为准。"""
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
import json
|
||||
import math
|
||||
|
||||
from libs.lockfile import replace_json
|
||||
|
||||
|
||||
@dataclass
|
||||
class SymbolState:
|
||||
volume: int = 0
|
||||
cost: float = 0.0
|
||||
last_buy: float = 0.0
|
||||
armed: bool = False
|
||||
sell_grid: float = 0.0
|
||||
peak: int = 0
|
||||
pending: dict = field(default_factory=dict)
|
||||
|
||||
def reset_profit(self):
|
||||
"""持仓成本或数量改变后,不沿用上轮止盈峰值。"""
|
||||
self.armed = False
|
||||
self.sell_grid = 0.0
|
||||
self.peak = 0
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: Path, account: str):
|
||||
self.path, self.account = path, account
|
||||
self.symbols: dict[str, SymbolState] = {}
|
||||
if path.exists():
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding='utf-8'))
|
||||
if raw['version'] != 1 or raw['account'] != account:
|
||||
raise ValueError('版本或账户不一致')
|
||||
for code, value in raw['symbols'].items():
|
||||
state = SymbolState(**value)
|
||||
if type(state.volume) is not int or state.volume < 0 or type(state.peak) is not int:
|
||||
raise ValueError('状态数量或峰值无效')
|
||||
if any(not math.isfinite(v) or v < 0 for v in (state.cost, state.last_buy, state.sell_grid)):
|
||||
raise ValueError('状态价格无效')
|
||||
if type(state.armed) is not bool or (state.armed and state.sell_grid <= 0):
|
||||
raise ValueError('止盈状态无效')
|
||||
if not isinstance(state.pending, dict):
|
||||
raise ValueError('委托状态无效')
|
||||
if state.pending:
|
||||
p = state.pending
|
||||
if (p['side'] not in ('BUY', 'SELL') or not p['id'].startswith('ETF-')
|
||||
or type(p['volume']) is not int or p['volume'] <= 0
|
||||
or (p['side'] == 'BUY' and p['volume'] > 1000)
|
||||
or type(p['base_volume']) is not int or p['base_volume'] < 0
|
||||
or not math.isfinite(p['reserved']) or p['reserved'] < 0):
|
||||
raise ValueError('待确认委托无效')
|
||||
self.symbols[code] = state
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||
raise ValueError(f'ETF 状态损坏,禁止自动重建:{path}') from exc
|
||||
|
||||
def get(self, code: str) -> SymbolState:
|
||||
return self.symbols.setdefault(code, SymbolState())
|
||||
|
||||
def save(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
replace_json(self.path, dict(version=1, account=self.account,
|
||||
symbols={k: asdict(v) for k, v in self.symbols.items()}))
|
||||
Reference in New Issue
Block a user