optz
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user