add etf
This commit is contained in:
131
py-client/strategy/etf/README.md
Normal file
131
py-client/strategy/etf/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 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、重启防重、峰值恢复、状态损坏、外部日线接口与异常响应。
|
||||
离线行为验证不等同于历史收益回测或实盘联调。
|
||||
1
py-client/strategy/etf/__init__.py
Normal file
1
py-client/strategy/etf/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""A 股场内 ETF:均线中轴、ATR 网格与 BOLL 低吸策略。"""
|
||||
67
py-client/strategy/etf/boot.py
Normal file
67
py-client/strategy/etf/boot.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""ETF 策略入口:每 30 秒运行,日线指标当天缓存,失败标的单独重试。"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import logging as log
|
||||
from pathlib import Path
|
||||
import time
|
||||
import httpx
|
||||
|
||||
import config
|
||||
from libs.calc import trading_time
|
||||
from libs.snapshot import cache_portfolio
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
while True:
|
||||
now = datetime.now()
|
||||
if now.hour >= 15:
|
||||
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)
|
||||
59
py-client/strategy/etf/config.py
Normal file
59
py-client/strategy/etf/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""独立读取 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)
|
||||
55
py-client/strategy/etf/data.py
Normal file
55
py-client/strategy/etf/data.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""ETF 专用历史日线适配,不依赖或修改 QMT SDK。"""
|
||||
|
||||
from datetime import date, datetime
|
||||
import math
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
DAILY_URL = 'http://go.apinb.com/a/get_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:]]
|
||||
202
py-client/strategy/etf/engine.py
Normal file
202
py-client/strategy/etf/engine.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""串行 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)
|
||||
55
py-client/strategy/etf/indicators.py
Normal file
55
py-client/strategy/etf/indicators.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""仅用已收盘日线计算指标,避免把盘中未完成的日线混入信号。"""
|
||||
|
||||
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)
|
||||
65
py-client/strategy/etf/state.py
Normal file
65
py-client/strategy/etf/state.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""保存交易意图与网格基准;实际持仓始终以券商快照为准。"""
|
||||
|
||||
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()}))
|
||||
Reference in New Issue
Block a user