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: """单个交易信号的数据源及开仓限制配置。""" # 信号接口相对于 api_host 的路径。 url: str = "" # 允许使用该信号的时间段;"*" 表示不限制时间。 timezone: str = "*" # 当前价格高于信号昨收价时是否仍允许开仓。 gt_last_price_is_open: bool = False @dataclass(slots=True) class GlobalConfig: """所有主机共享的系统配置。""" qmt_base_url: str = "" qmt_token: str = "" api_host: str = "" qmt_data_dir: str = "" # Windows 主机名到对应账户配置文件的映射。 hosts: dict[str, str] = field(default_factory=dict) # 信号名称到信号配置的映射。 signals: dict[str, SignalConfig] = field(default_factory=dict) @dataclass(slots=True) class AccountConfig: """当前主机所使用的账户及交易策略参数。""" account_id: str = "" host_key: str = "" buy_value: float = 0 min_cash_ratio: float = 0 grid_step_pct: float = 1 enable_loss_add_position: bool = False enable_auto_ipo: bool = True signal_allow: list[str] = field(default_factory=list) excluded_codes: list[str] = field(default_factory=list) # ZT 开仓及每次补仓手数(每手 100 股);0 表示不启动。 zt_open_hands: int = 0 zt_sell_ratio: float = 0.5 zt_buy_fall_pct: float = 1.0 zt_max_price: float = 200.0 # 正T/反T 中性带:现价在建仓价 ±N% 内不动手,避免来回摩擦。 zt_t_band_pct: float = 1.0 # 单轮最长持有自然日;超期告警并放弃继续平仓,残量留作隔夜持仓。 zt_max_hold_days: int = 5 # 当前账户启用的策略名称,例如 trend。 strategy: str = "" @dataclass(slots=True) class EtfDefaults: """ETF 网格策略的全局默认参数,来自 ``_etf.yaml`` 的 ``defaults`` 段。 字段顺序与 ``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, EtfConfig]: """加载公共配置、当前主机的账户配置以及 ETF 策略配置。 Args: etc_dir: 配置文件目录,其中必须包含 ``_global.yaml``;为空时 默认使用 py-client 下的 ``etc`` 目录。ETF 策略的 ``_etf.yaml`` 同样在该目录下按固定文件名查找,不存在时 ``etf_config`` 为 None,不视为错误。 hostname: 指定要加载的主机名;为空时使用当前计算机名。 Returns: 由全局配置和账户配置组成的二元组。 Raises: ValueError: 配置缺失、格式错误或策略参数不合法。 """ 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") # 将原始字典转换为带类型的信号配置,方便业务代码使用属性访问。 signals = { key: SignalConfig(**(value or {})) for key, value in (raw.get("signals") or {}).items() } values = { key: raw.get(key, "") for key in ("qmt_base_url", "qmt_token", "api_host", "qmt_data_dir") } current = hostname or socket.gethostname() hosts = raw.get("hosts") or {} account_file = next( ( value for key, value in hosts.items() if key.strip().lower() == current.strip().lower() ), "", ) # QMT 地址、外部 API 地址和数据目录是启动策略的必要参数。 if ( not values["qmt_base_url"] or not values["api_host"] or values["qmt_data_dir"] == "." ): raise ValueError("Global 配置缺少必要参数") if not account_file: raise ValueError(f'_global.yaml 未配置计算机 "{current}"') if not Path(account_file).suffix: account_file += ".yaml" global_config = GlobalConfig(**values, hosts=hosts, signals=signals) # 策略状态文件写入该目录,启动时提前确保目录存在。 Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True) account_config = AccountConfig(**_account_values(root / account_file)) 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: raise ValueError("zt_open_hands 必须为非负整数,0 表示不启动 ZT 策略") if not 0 < account_config.zt_sell_ratio <= 1: raise ValueError("zt_sell_ratio 必须在 (0, 1] 区间") if account_config.zt_buy_fall_pct <= 0 or account_config.zt_max_price <= 0: raise ValueError("zt_buy_fall_pct、zt_max_price 必须大于 0") if account_config.zt_t_band_pct < 0: raise ValueError("zt_t_band_pct 不能为负数") if type(account_config.zt_max_hold_days) is not int or account_config.zt_max_hold_days <= 0: raise ValueError("zt_max_hold_days 必须为正整数") if not account_config.strategy.strip(): raise ValueError("strategy 不能为空") # host_key 统一为小写,避免不同模块比较时受大小写影响。 account_config.host_key = account_config.host_key.lower() account_config.strategy = account_config.strategy.lower() if account_config.strategy == "zt" and account_config.signal_allow != ["dcm"]: raise ValueError("zt 策略的 signal_allow 必须且只能为 [\"dcm\"]") # ETF 参数是固定文件名,缺文件时返回 None,由 ETF 策略自行决定是否必须。 etf_config = _etf_config(root / "_etf.yaml") return global_config, account_config, etf_config def _yaml(path: Path) -> dict: """读取 YAML 文件,并将空文件转换为空字典。""" try: with path.open(encoding="utf-8") as handle: return yaml.safe_load(handle) or {} except (OSError, yaml.YAMLError) as exc: raise ValueError(f"读取或解析配置 {path} 失败: {exc}") from exc def _account_values(path: Path) -> dict: """读取账户配置,并拒绝拼错或已废弃的字段。 以前未知字段会被 ``AccountConfig(**raw)`` 抛成 TypeError,绕开 main() 的异常分支并以裸 traceback 退出;这里改成带文件名的 ValueError。 """ raw = _yaml(path) if not isinstance(raw, dict): raise ValueError(f"账户配置 {path} 的根节点必须是对象") unknown = sorted(set(raw) - {item.name for item in fields(AccountConfig)}) 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