Files
big-qmt/py-client/config/__init__.py
2026-09-19 19:45:43 +08:00

524 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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