524 lines
20 KiB
Python
524 lines
20 KiB
Python
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
|