This commit is contained in:
2026-09-19 19:45:43 +08:00
parent 7183cb45f8
commit 8131b158b4
60 changed files with 7669 additions and 909 deletions

View File

@@ -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、重启防重、峰值恢复、状态损坏、外部日线接口与异常响应。
离线行为验证不等同于历史收益回测或实盘联调。

View File

@@ -1 +0,0 @@
"""A 股场内 ETF均线中轴、ATR 网格与 BOLL 低吸策略。"""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:]]

View File

@@ -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)

View File

@@ -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)

View 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

View 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

View 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

View File

@@ -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()}))