This commit is contained in:
2026-09-17 00:56:05 +08:00
parent 33693adb66
commit c7d39938a2
14 changed files with 1005 additions and 0 deletions

View File

@@ -62,6 +62,11 @@ ZT 轮次状态机正T/反T、Trend 采集任务与 Python 3.14 回归。
修改前 18 项测试中 12 项失败,原因是模型仅有 `get_local_order_id` 属性,调用处却使用缺失的 `local_order_id`,存储层还将属性当方法调用。
本次增加同一属性的兼容别名,并统一存储层属性访问,保留原属性名和 API 数据字段;这些是使既有撤单、成交对账测试恢复的接口修复。
## ETF 自适应网格策略
入口为 `strategy: etf`,标的和参数见 [`etc/etf.yaml`](etc/etf.yaml)
完整规则和启用步骤见 [`strategy/etf/README.md`](strategy/etf/README.md)。
## ZT 做 T 策略2026-09 重构)
ZT 已从"本地 SQLite 重算持仓 + base/added 分桶归档"改为**正T/反T 轮次状态机**

View File

@@ -60,6 +60,8 @@ class AccountConfig:
# 当前账户启用的策略名称,例如 trend。
strategy: str = ""
# 为空时读取 py-client/etc/etf.yaml非空路径相对于账户配置目录。
etf_config_path: str = ""
# load() 成功后保存已加载的配置,供策略模块直接读取。
@@ -132,6 +134,11 @@ def load(
Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True)
account_config = AccountConfig(**_account_values(root / account_file))
if account_config.etf_config_path:
etf_path = Path(account_config.etf_config_path)
account_config.etf_config_path = str(etf_path if etf_path.is_absolute() else root / etf_path)
elif account_config.strategy.strip().lower() == 'etf':
account_config.etf_config_path = str(root / 'etf.yaml')
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:

23
py-client/etc/etf.yaml Normal file
View File

@@ -0,0 +1,23 @@
# 标的是示例白名单;仅在账户配置 strategy: etf 时启用。
codes:
- "510300.SH"
- "159915.SZ"
# 固定买入手数,每手 100 份;每只 ETF 总持仓硬上限 10 手。
buy_hands: 1
max_hands: 10
# 60 日均线作为中轴;格距 = max(ATR×倍数, MA60×最小格距百分比, 0.001)。
atr_period: 14
atr_multiplier: 1.0
boll_period: 20
boll_std: 2.0
min_grid_pct: 0.5
# 复用 DipWatch触及低位后从观察低点反弹 0.61% 才买入。
rebound_pct: 0.61
watch_seconds: 600
# 达到高位和最低利润要求后启动网格回撤止盈。
min_profit_pct: 0.5
# 用于资金预留及止盈费用门槛,按实际券商佣金调整。
commission_rate: 0.0003
min_commission: 5.0
# 超过此秒数或缺少时间戳的行情不交易。
max_tick_age_seconds: 90

View File

@@ -27,6 +27,8 @@ class PlaceOrderRequest:
order_id: str
strategy_name: str
kind: str = ""
# ETF 使用 0.001 元精度的限价;未指定时保留原策略的最新价委托。
price: float | None = None
class OrderBook:
@@ -129,12 +131,14 @@ class OrderBook:
self.busy_cache.set(key, True, timeout=self.lock_timeout_sec)
try:
price_args = {} if request.price is None else {"pr_type": 11, "price": request.price}
result = client.passorder(
op_type=request.op,
stock_code=request.code,
volume=request.volume,
strategy_name=request.strategy_name,
order_id=request.order_id,
**price_args,
)
except APIError as exc:
logging.exception(

View File

@@ -37,6 +37,7 @@ from libs.market import refresh_market
from libs.collector import submit_trend_data
from strategy.trend.boot import StartTrend
from strategy.zt.boot import StartZT
from strategy.etf.boot import StartETF
from strategy.ipo import AutoBuyIpo
@dataclass(slots=True)
@@ -48,6 +49,7 @@ class StrategyDefinition:
STRATEGIES = {
"trend": StrategyDefinition("Trend", StartTrend),
"zt": StrategyDefinition("ZT", StartZT),
"etf": StrategyDefinition("ETF", StartETF),
}
def require_windows() -> bool:

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

View File

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

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

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

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

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

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

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

329
py-client/tests/test_etf.py Normal file
View File

@@ -0,0 +1,329 @@
"""ETF 离线回归:指标、真实防飞刀/网格算法、限仓、回报和持久化。"""
from datetime import date, datetime, timedelta
import httpx
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock
from sdk import Assets, OrderItem, Portfolio, PositionItem, Tick
from strategy.etf.config import ETFConfig, load
from strategy.etf.data import DAILY_URL, daily_bars, parse_daily
from strategy.etf.engine import Engine
from strategy.etf.indicators import Indicators, calculate
from strategy.etf.state import Store
CODE, OTHER = '510300.SH', '159915.SZ'
NOW = datetime(2026, 9, 16, 10)
IND = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2)
def tick(price, now=NOW):
return Tick(price, raw={'timetag': now.strftime('%Y%m%d %H:%M:%S')})
def position(volume=0, cost=0, available=None):
return PositionItem(stock_code=CODE, volume=volume, open_price=cost,
can_use_volume=volume if available is None else available)
class ETFTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.path = Path(temp.name) / 'state.json'
self.client = Mock()
self.client.passorder.return_value = {'status': 'success'}
self.cfg = ETFConfig(codes=(CODE,), min_commission=0, commission_rate=0)
self.store = Store(self.path, 'test')
self.engine = Engine(self.client, self.cfg, self.store, 0.1)
def run_price(self, price, pos=None, orders=(), cash=10000, now=NOW, ind=IND):
portfolio = Portfolio(Assets(total=10000, available=cash), {CODE: pos or position()}, list(orders))
self.engine.run(portfolio, {CODE: tick(price, now)}, {CODE: ind}, now)
def buy(self):
self.run_price(9.4)
self.run_price(9.46)
self.client.passorder.assert_called_once()
def report(self, status=56, filled=100, side=23, price=9.46):
pending = self.store.get(CODE).pending
return OrderItem(stock_code=CODE, remark=pending['id'] + '|etf', offset_flag=side,
volume_traded=filled, volume_total_original=pending['volume'],
traded_price=price, order_status=status)
def test_boll_lower_requires_rebound_and_uses_fixed_limit_order(self):
self.run_price(9.8)
self.run_price(9.4)
self.run_price(9.3)
self.run_price(9.35)
self.client.passorder.assert_not_called()
self.run_price(9.36)
request = self.client.passorder.call_args.kwargs
self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.36, 11))
self.assertEqual(request['strategy_name'], 'etf')
self.assertTrue(self.store.get(CODE).pending)
def test_pending_written_before_network_and_retained_after_timeout(self):
def submit(**kwargs):
saved = Store(self.path, 'test').get(CODE).pending
self.assertEqual(saved['id'], kwargs['order_id'])
raise TimeoutError('unknown result')
self.client.passorder.side_effect = submit
self.run_price(9.4)
with self.assertLogs(level='ERROR'):
self.run_price(9.46)
self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
with self.assertLogs(level='WARNING'):
self.run_price(9.3, now=NOW + timedelta(minutes=10))
self.client.passorder.assert_called_once()
def test_filled_order_waits_for_position_snapshot(self):
self.buy()
report = self.report()
with self.assertLogs(level='WARNING'):
self.run_price(9.2, orders=[report])
self.assertTrue(self.store.get(CODE).pending)
self.run_price(9.2, position(100, 9.46, 0), [report])
self.assertFalse(self.store.get(CODE).pending)
self.assertEqual(self.store.get(CODE).last_buy, 9.46)
self.client.passorder.assert_called_once()
def test_add_requires_another_grid_below_actual_fill(self):
self.buy()
report = self.report()
pos = position(100, 9.46, 0)
self.run_price(9.4, pos, [report])
self.run_price(9.46, pos)
self.client.passorder.assert_called_once()
self.run_price(9.1, pos)
self.run_price(9.16, pos)
self.assertEqual(self.client.passorder.call_count, 2)
def test_partial_cancel_records_actual_fill_and_never_exceeds_cap(self):
self.cfg = ETFConfig(codes=(CODE,), buy_hands=2, min_commission=0, commission_rate=0)
self.engine = Engine(self.client, self.cfg, self.store, 0.1)
pos = position(800, 10)
self.run_price(9.4, pos)
self.run_price(9.46, pos)
report = self.report(status=53, filled=100)
self.run_price(9.1, position(900, 9.94), [report])
self.run_price(9.16, position(900, 9.94))
self.client.passorder.assert_called_once()
self.assertFalse(self.store.get(CODE).pending)
def test_full_position_blocks_buy_and_zero_position_is_not_a_warning(self):
self.run_price(9.4, position(1000, 10))
self.run_price(9.46, position(1000, 10))
self.client.passorder.assert_not_called()
with self.assertNoLogs(level='WARNING'):
self.run_price(9.8, position())
def test_zero_position_with_retained_broker_cost_can_reopen(self):
self.run_price(9.4, position(0, 10))
self.run_price(9.46, position(0, 10))
self.client.passorder.assert_called_once()
def test_rejected_order_is_logged_and_does_not_advance_anchor(self):
self.buy()
report = self.report(status=57, filled=0)
self.run_price(9.8, orders=[report])
self.assertEqual(self.store.get(CODE).last_buy, 0)
self.assertFalse(self.store.get(CODE).pending)
def test_t_plus_one_tracks_peak_but_only_sells_available_whole_lots(self):
self.run_price(10.7, position(200, 10, 0))
self.run_price(10.55, position(200, 10, 0))
self.client.passorder.assert_not_called()
self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
self.run_price(10.55, position(200, 10, 100))
order = self.client.passorder.call_args.kwargs
self.assertEqual((order['op_type'], order['volume']), (24, 100))
def test_drop_below_activation_price_still_triggers_profitable_retreat(self):
self.run_price(10.7, position(100, 10))
self.run_price(10.4, position(100, 10))
self.assertEqual(self.client.passorder.call_args.kwargs['op_type'], 24)
def test_cost_change_and_flat_position_reset_peak(self):
self.run_price(10.7, position(100, 10))
self.assertTrue(self.store.get(CODE).armed)
self.run_price(10.5, position(200, 10.4))
self.assertFalse(self.store.get(CODE).armed)
self.run_price(9.8, position())
self.assertEqual(self.store.get(CODE).last_buy, 0)
self.client.passorder.assert_not_called()
def test_fee_floor_prevents_loss_after_commission(self):
cfg = ETFConfig(codes=(CODE,), min_commission=50, commission_rate=0)
self.engine = Engine(self.client, cfg, self.store, 0.1)
self.run_price(10.7, position(100, 10))
self.run_price(10.55, position(100, 10))
self.client.passorder.assert_not_called()
def test_cash_reserve_and_fixed_lot_no_downsizing(self):
self.run_price(9.4, cash=1900)
self.run_price(9.46, cash=1900)
self.client.passorder.assert_not_called()
def test_multiple_symbols_share_one_cash_budget(self):
cfg = ETFConfig(codes=(CODE, OTHER), min_commission=0, commission_rate=0)
self.engine = Engine(self.client, cfg, self.store, 0.1)
portfolio = Portfolio(Assets(total=10000, available=2500), {}, [])
for price in (9.4, 9.46):
self.engine.run(portfolio, {c: tick(price) for c in cfg.codes}, {c: IND for c in cfg.codes}, NOW)
self.client.passorder.assert_called_once()
def test_other_strategy_order_blocks_same_symbol_without_cancel(self):
report = OrderItem(stock_code=CODE, remark='TREN-BUY-other', offset_flag=23,
order_status=50, insert_date='20260916', insert_time='093000')
self.run_price(9.4, orders=[report])
self.run_price(9.46, orders=[report])
self.client.passorder.assert_not_called()
self.client.cancel_by_id.assert_not_called()
def test_pending_later_symbol_reserves_cash_before_first_symbol(self):
cfg = ETFConfig(codes=(CODE, OTHER), min_commission=0, commission_rate=0)
self.store.get(OTHER).pending = dict(id='ETF-BUY-pending', side='BUY', volume=100,
base_volume=0, reserved=950)
self.engine = Engine(self.client, cfg, self.store, 0.1)
portfolio = Portfolio(Assets(total=10000, available=2500), {}, [])
with self.assertLogs(level='WARNING'):
for price in (9.4, 9.46):
self.engine.run(portfolio, {CODE: tick(price)}, {CODE: IND}, NOW)
self.client.passorder.assert_not_called()
def test_on_road_or_unknown_order_never_opens_another_buy(self):
pos = position()
pos.on_road_volume = 100
self.run_price(9.4, pos)
self.run_price(9.46, pos)
unknown = OrderItem(stock_code=CODE, order_status=255)
self.run_price(9.4, orders=[unknown])
self.run_price(9.46, orders=[unknown])
self.client.passorder.assert_not_called()
def test_excluded_symbol_is_neither_bought_nor_sold(self):
self.engine.excluded.add(CODE)
self.run_price(9.4)
self.run_price(9.46)
self.run_price(10.7, position(100, 10))
self.run_price(10.55, position(100, 10))
self.client.passorder.assert_not_called()
def test_invalid_or_stale_tick_cannot_trade(self):
for t in (None, Tick(10), tick(float('nan')), tick(9.4, NOW - timedelta(days=1)),
tick(9.4, NOW - timedelta(seconds=91))):
self.assertFalse(self.engine.fresh_tick(t, NOW))
self.assertTrue(self.engine.fresh_tick(tick(9.4), NOW))
def test_corrupt_state_does_not_silently_start_empty(self):
self.path.write_text('{', encoding='utf-8')
with self.assertRaises(ValueError):
Store(self.path, 'test')
class IndicatorTests(unittest.TestCase):
def bars(self):
days = []
day = date(2026, 9, 15)
while len(days) < 80:
if day.weekday() < 5:
days.append(day)
day -= timedelta(days=1)
return [dict(date=d.strftime('%Y%m%d'), high=11, low=9, close=10) for d in reversed(days)]
def test_known_constant_series_and_exclusion_of_unfinished_day(self):
cfg = ETFConfig(codes=(CODE,))
rows = self.bars() + [dict(date='20260916', high=999, low=1, close=999)]
ind = calculate(rows, NOW.date(), cfg)
self.assertEqual((ind.ma60, ind.atr, ind.lower, ind.upper, ind.grid), (10, 2, 10, 10, 2))
def test_atr_accounts_for_gap_and_uses_wilder_smoothing(self):
rows = self.bars()
rows[-1].update(high=14, low=12, close=13)
ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,)))
self.assertAlmostEqual(ind.atr, (2 * 13 + 4) / 14)
self.assertAlmostEqual(ind.ma60, 10.05)
self.assertGreater(ind.upper, ind.middle)
def test_bad_or_insufficient_history_is_rejected(self):
cfg = ETFConfig(codes=(CODE,))
for rows in (self.bars()[:59], self.bars() + [self.bars()[-1]],
self.bars()[:-1] + [dict(self.bars()[-1], close=float('nan'))]):
with self.assertRaises(ValueError):
calculate(rows, NOW.date(), cfg)
def test_grid_floor_and_tick_rounding(self):
rows = [dict(row, high=10.001, low=9.999) for row in self.bars()]
ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), min_grid_pct=0.501))
self.assertEqual(ind.grid, 0.051)
class ConfigAndDataTests(unittest.TestCase):
def test_config_rejects_excess_hands_and_invalid_codes(self):
for kwargs in ({'max_hands': 11}, {'buy_hands': 11}, {'buy_hands': True},
{'atr_multiplier': float('nan')}, {'codes': ('920202.BJ',)},
{'codes': (CODE, CODE)}, {'codes': ()}):
with self.assertRaises(ValueError):
ETFConfig(**dict({'codes': (CODE,)}, **kwargs))
def test_default_file_loads(self):
cfg = load()
self.assertEqual((cfg.buy_hands, cfg.max_hands), (1, 10))
class DailyDataTests(unittest.TestCase):
def row(self, day=20260915, **changes):
return dict(dict(ts_code=CODE, trade_date=day, open=10, high=11, low=9, close=10), **changes)
def test_request_and_sample_shape(self):
def respond(request):
self.assertEqual(str(request.url), DAILY_URL + '?code=' + CODE)
self.assertNotIn('x-token', request.headers)
return httpx.Response(200, json={'code': 0, 'message': '', 'details': [self.row()]})
with httpx.Client(transport=httpx.MockTransport(respond)) as client:
self.assertEqual(daily_bars(client, CODE, NOW.date()),
[dict(date='20260915', open=10.0, high=11.0, low=9.0, close=10.0)])
def test_sort_filter_then_limit_and_numeric_strings(self):
payload = dict(code=0, details=[self.row(20260916), self.row(20260915, close='10.5'),
self.row(20260914), self.row(20260917)])
bars = parse_daily(payload, CODE, NOW.date(), count=1)
self.assertEqual([b['date'] for b in bars], ['20260915'])
self.assertEqual(bars[0]['close'], 10.5)
def test_http_error_and_invalid_json_propagate(self):
for status, content in ((404, '{}'), (200, '<html>error</html>')):
with httpx.Client(transport=httpx.MockTransport(
lambda r: httpx.Response(status, text=content))) as client:
with self.assertRaises((httpx.HTTPStatusError, ValueError)):
daily_bars(client, CODE, NOW.date())
def test_bad_business_response_is_rejected(self):
for payload in (None, [], {}, {'code': False, 'details': [self.row()]},
{'code': 1, 'message': 'failed'}, {'code': 0, 'details': []},
{'code': 0, 'details': {}}, {'code': 0, 'details': None}):
with self.subTest(payload=payload), self.assertRaises(ValueError):
parse_daily(payload, CODE, NOW.date())
def test_wrong_symbol_duplicate_dates_and_invalid_ohlc_are_rejected(self):
for rows in ([self.row(ts_code=OTHER)], [self.row(), self.row()],
[self.row(20260230)], [self.row(close=float('nan'))],
[self.row(open=True)], [self.row(low=12)], [self.row(close=None)]):
with self.subTest(rows=rows), self.assertRaises(ValueError):
parse_daily(dict(code=0, details=rows), CODE, NOW.date())
def test_external_history_flows_into_real_indicators(self):
details = [self.row(int(row['date'])) for row in IndicatorTests().bars()]
rows = parse_daily(dict(code=0, details=details), CODE, NOW.date())
ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,)))
self.assertEqual((ind.ma60, ind.atr, ind.grid), (10, 2, 2))
if __name__ == '__main__':
unittest.main()