Files
big-qmt/labs/tests/test_etf.py
2026-09-19 19:45:43 +08:00

556 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 GridLot, Store, SymbolState
CODE, OTHER = '510300.SH', '159915.SZ'
NOW = datetime(2026, 9, 16, 10)
# ma60=10、grid=0.2,指标入场门槛 9.5;引擎再叠加 ma60-1格 = 9.8,最终入场价 9.5
IND = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2, entry_band=9.5,
donchian_lo=9.1, donchian_hi=10.9)
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)
def held(lots: dict, anchor: float, opened_days_ago=5):
"""构造一个已持仓的网格lots = {档位: (数量, 成本)}。"""
state = SymbolState(anchor=anchor)
day = NOW.date() - timedelta(days=opened_days_ago)
for level, (volume, cost) in lots.items():
state.lots[str(level)] = GridLot(volume=volume, cost=cost, bought=day, buys=1)
state.last_buy = max(cost for _, cost in lots.values())
return state
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'}
# 测试用零佣金、零金额门槛、每格 1 手、3 档:真实默认值为最低佣金 5 元设计。
self.cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3,
min_commission=0, commission_rate=0, min_order_value=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, engine=None):
portfolio = Portfolio(Assets(total=10000, available=cash),
{CODE: pos or position()}, list(orders))
(engine or self.engine).run(portfolio, {CODE: tick(price, now)}, {CODE: ind}, now)
def keep(self, low, high):
"""让 DipWatch 从 low 反弹到 high反弹幅度必须 ≥ 0.61%)。"""
self.run_price(low)
self.run_price(round(low * 1.007, 3))
self.run_price(high)
def anchor_grid(self, low=9.3, high=9.38):
"""跌到 low 后反弹到 high≥0.61%)确认,锚点取确认价 high挂单挂在 low。"""
self.run_price(low)
self.run_price(high)
return self.newest()
def ack(self, pending, pos, price=None, side=None, cost=None, status=56, filled=None):
"""把一笔委托做成终态回报,并给出成交后的持仓快照。"""
volume = pending['volume'] if filled is None else filled
side = side or pending['side']
fill_price = pending['price'] if price is None else price
report = OrderItem(stock_code=CODE, remark=pending['id'] + '|etf',
offset_flag={'BUY': 23, 'SELL': 24}[side],
volume_traded=volume, volume_total_original=pending['volume'],
traded_price=fill_price, order_status=status)
after = PositionItem(stock_code=CODE,
volume=pos.volume + (volume if side == 'BUY' else -volume),
open_price=cost if cost is not None else pos.open_price,
can_use_volume=max(0, pos.can_use_volume - volume)
if side == 'SELL' else pos.can_use_volume)
return report, after
def fills(self, volume, cost, available=None):
return {level: (volume, cost, available) for level in self.store.get(CODE).lots}
def newest(self, side='BUY'):
pending = self.store.get(CODE).pending
rows = [p for p in pending.values() if p['side'] == side]
return max(rows, key=lambda p: p['id'])
def report(self, pending, status=56, filled=None, price=None, side=None):
volume = pending['volume'] if filled is None else filled
return OrderItem(stock_code=CODE, remark=pending['id'] + '|etf',
offset_flag={'BUY': 23, 'SELL': 24}[side or pending['side']],
volume_traded=volume, volume_total_original=pending['volume'],
traded_price=pending['price'] if price is None else price,
order_status=status)
# ------------------------------------------------------------- 入场
def test_entry_band_requires_rebound_then_ladder_order(self):
"""进入入场区只是开始观察;反弹确认后以 t0 价为锚点挂出锚点档。"""
self.run_price(9.8) # 未进入入场区
self.run_price(9.4) # 进入入场区,开始观察
self.run_price(9.3) # 刷新低点 t0=9.3
self.run_price(9.35) # 反弹不足 0.61%
self.client.passorder.assert_not_called()
self.run_price(9.38) # 反弹 (9.38-9.3)/9.3 = 0.86%,锚点取确认价
request = self.client.passorder.call_args.kwargs
self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.38, 11))
self.assertEqual(request['strategy_name'], 'etf')
self.assertEqual(self.store.get(CODE).anchor, 9.38)
self.assertEqual(set(self.store.get(CODE).pending), {'0'})
def test_leaving_entry_zone_restarts_observation(self):
"""价格弹回入场区上方后,旧低点作废,必须重新形成低点再确认。"""
self.run_price(9.4)
self.run_price(9.3) # t0=9.3
self.run_price(9.8) # 离开入场区,观察点作废
self.run_price(9.38) # 反弹不再基于 9.3
self.client.passorder.assert_not_called()
def test_ladder_places_one_order_per_level_below_anchor(self):
"""锚点 9.4、格距 0.2:价格每跌一格补一档;同一时刻只允许一笔在途委托。"""
cash = 50000
pending = self.anchor_grid(low=9.3, high=9.4) # 锚点 = 9.4
self.assertEqual((pending['level'], pending['price']), (0, 9.4))
report, after = self.ack(pending, position(), price=9.4)
self.run_price(9.4, after, [report], cash=cash) # 锚点档成交
self.assertEqual(self.store.get(CODE).lots['0'].volume, 100)
self.run_price(9.2, position(100, 9.4, 0), cash=cash) # 跌到第 1 档
self.assertEqual(self.store.get(CODE).pending['1']['price'], 9.2) # 锚点-1格
self.run_price(9.2, position(100, 9.4, 0), cash=cash) # 同档不重复挂单
self.assertEqual(sorted(self.store.get(CODE).pending), ['1'])
pending = self.store.get(CODE).pending['1']
report, after = self.ack(pending, position(100, 9.4, 0), price=9.2)
self.run_price(9.0, after, [report], cash=cash) # 第 1 档成交
self.assertEqual(self.store.get(CODE).lots['1'].volume, 100)
self.run_price(9.0, position(200, 9.3, 0), cash=cash) # 跌到第 2 档
self.assertEqual(self.store.get(CODE).pending['2']['price'], 9.0) # 锚点-2格
def test_per_level_fill_cost_and_target(self):
pending = self.anchor_grid()
report, after = self.ack(pending, position(), price=9.28)
self.run_price(9.28, after, [report])
state = self.store.get(CODE)
self.assertEqual(state.lots['0'].volume, 100)
self.assertAlmostEqual(state.lots['0'].cost, 9.28)
self.assertEqual(state.lots['0'].bought, NOW.date())
# 目标价 = 成本 + 格距×2 = 9.28 + 0.4
self.assertAlmostEqual(self.engine.sell_target(9.28, IND), 9.68)
# ------------------------------------------------------------- 卖出
def test_each_level_sells_independently_at_own_target(self):
"""两档成本不同,各自到价才卖,且只卖该档。"""
state = held({0: (100, 9.0), 1: (100, 10.0)}, anchor=10.0)
pos = position(200, 9.5, 200)
self.run_price(9.4, pos, engine=self.engine_with(state))
request = self.client.passorder.call_args.kwargs
self.assertEqual((request['op_type'], request['volume']), (24, 100))
self.assertEqual(self.store.get(CODE).pending['0']['price'], 9.4)
self.assertNotIn('1', self.store.get(CODE).pending)
def engine_with(self, state):
"""把预置状态挂到引擎上,跳过与券商快照的首次核对。"""
self.store.symbols[CODE] = state
engine = Engine(self.client, self.cfg, self.store, 0.1)
return engine
def test_sell_waits_for_target_then_clears_only_that_level(self):
state = held({0: (100, 9.0)}, anchor=9.0)
engine = self.engine_with(state)
pos = position(100, 9.0, 100)
target = engine.sell_target(9.0, IND)
self.run_price(target - 0.001, pos, engine=engine)
self.client.passorder.assert_not_called()
self.run_price(target, pos, engine=engine)
request = self.client.passorder.call_args.kwargs
self.assertEqual((request['op_type'], request['volume']), (24, 100))
pending = self.store.get(CODE).pending['0']
report, after = self.ack(pending, pos)
self.run_price(target, after, [report], engine=engine)
state = self.store.get(CODE)
self.assertEqual(state.lots, {})
self.assertEqual(state.pending, {})
def test_sold_level_is_rebought_when_price_returns(self):
state = held({0: (100, 9.0)}, anchor=9.0)
engine = self.engine_with(state)
pos = position(100, 9.0, 100)
target = engine.sell_target(9.0, IND)
self.run_price(target, pos, engine=engine)
report, after = self.ack(self.store.get(CODE).pending['0'], pos)
self.run_price(target, after, [report], engine=engine)
self.assertEqual(self.store.get(CODE).lots, {})
# 价格回到锚点下方:同一档重新挂买单
self.run_price(9.0, position(), engine=engine)
pending = self.store.get(CODE).pending
self.assertIn('0', pending)
self.assertEqual(pending['0']['side'], 'BUY')
def test_t_plus_one_lot_is_not_sold_same_day(self):
state = held({0: (100, 9.0)}, anchor=9.0, opened_days_ago=0)
self.run_price(10.7, position(100, 9.0, 100), engine=self.engine_with(state))
self.client.passorder.assert_not_called()
def test_partial_available_volume_limits_sell_to_whole_lots(self):
state = held({0: (300, 9.0)}, anchor=9.0)
self.run_price(10.7, position(300, 9.0, 250), engine=self.engine_with(state))
request = self.client.passorder.call_args.kwargs
self.assertEqual((request['op_type'], request['volume']), (24, 200))
def test_max_hold_days_clears_only_stale_level(self):
cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3,
min_commission=0, commission_rate=0, min_order_value=0,
max_hold_days=3)
self.engine = Engine(self.client, cfg, self.store, 0.1)
state = held({0: (100, 9.0)}, anchor=9.0)
self.store.symbols[CODE] = state
with self.assertLogs(level='WARNING'):
self.run_price(9.0, position(100, 9.0, 100))
request = self.client.passorder.call_args.kwargs
self.assertEqual((request['op_type'], request['volume']), (24, 100))
# ------------------------------------------------------------- 核对
def test_fill_waits_for_position_snapshot(self):
pending = self.anchor_grid()
report = self.report(pending)
with self.assertLogs(level='WARNING'):
self.run_price(9.28, orders=[report])
self.assertIn('0', self.store.get(CODE).pending)
report, after = self.ack(pending, position(), price=9.28)
self.run_price(9.28, after, [report])
state = self.store.get(CODE)
self.assertEqual(state.pending, {})
self.assertEqual(state.lots['0'].volume, 100)
self.assertAlmostEqual(state.last_buy, 9.28)
self.client.passorder.assert_called_once()
def test_pending_written_before_network_and_retained_after_timeout(self):
"""提交前先落盘意图;网络异常不解除锁,重启后仍保留待确认状态。"""
def submit(**kwargs):
saved = Store(self.path, 'test').get(CODE).pending
self.assertEqual({p['id'] for p in saved.values()}, {kwargs['order_id']})
raise TimeoutError('unknown result')
self.client.passorder.side_effect = submit
engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
engine.orders.busy_cache.clear()
self.run_price(9.3, engine=engine) # 进入入场区
with self.assertLogs(level='ERROR'):
self.run_price(9.38, engine=engine) # 反弹确认,提交时网络异常
self.assertTrue(Store(self.path, 'test').get(CODE).pending)
engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
engine.orders.busy_cache.clear()
with self.assertLogs(level='WARNING'):
self.run_price(9.3, engine=engine, now=NOW + timedelta(minutes=10))
self.client.passorder.assert_called_once()
def test_rejected_order_releases_level_and_does_not_advance(self):
pending = self.anchor_grid()
report = self.report(pending, status=57, filled=0)
self.run_price(9.39, orders=[report]) # 价格已回到锚点上方,不会重挂
state = self.store.get(CODE)
self.assertEqual(state.pending, {})
self.assertEqual(state.lots, {})
self.assertEqual(state.last_buy, 0.0)
# 网格仍保留锚点,价格回到锚点档可重新挂单
self.run_price(9.38)
self.assertIn('0', self.store.get(CODE).pending)
def test_external_position_change_rebuilds_grid_from_broker_snapshot(self):
self.run_price(10.0, position(500, 9.9, 500))
state = self.store.get(CODE)
self.assertEqual(state.volume, 500)
self.assertEqual(state.anchor, 9.9)
self.assertEqual(state.lots['0'].volume, 500)
def test_full_position_blocks_further_levels(self):
state = self.store.get(CODE)
state.adopt(300, 9.0, NOW.date() - timedelta(days=5))
self.run_price(8.0, position(300, 9.0, 300))
self.client.passorder.assert_not_called()
def test_cash_reserve_and_fixed_lot_no_downsizing(self):
self.run_price(9.4, cash=1, now=NOW)
self.run_price(9.3, cash=1, now=NOW)
self.client.passorder.assert_not_called()
def test_min_order_value_skips_small_level_orders(self):
cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3,
min_commission=5.0, commission_rate=0.0003, min_order_value=2000)
self.engine = Engine(self.client, cfg, self.store, 0.1)
with self.assertLogs(level='INFO'):
self.run_price(9.4)
self.run_price(9.3)
self.client.passorder.assert_not_called()
def test_multiple_symbols_share_one_cash_budget(self):
cfg = ETFConfig(codes=(CODE, OTHER), buy_hands=1, grid_levels=2, max_hands=3,
min_commission=0, commission_rate=0, min_order_value=0)
self.engine = Engine(self.client, cfg, self.store, 0.1)
portfolio = Portfolio(Assets(total=10000, available=2000), {}, [])
for price in (9.3, 9.38):
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()
self.assertIn('0', self.store.get(CODE).pending)
self.assertFalse(self.store.get(OTHER).pending)
def test_pending_later_symbol_reserves_cash_before_first_symbol(self):
cfg = ETFConfig(codes=(CODE, OTHER), buy_hands=1, grid_levels=2, max_hands=3,
min_commission=0, commission_rate=0, min_order_value=0)
self.store.get(OTHER).pending['0'] = dict(id='ETF-BUY-pending', side='BUY', volume=100,
base_volume=0, reserved=950, level=0,
price=9.5)
self.engine = Engine(self.client, cfg, self.store, 0.1)
portfolio = Portfolio(Assets(total=10000, available=2000), {}, [])
with self.assertLogs(level='WARNING'):
for price in (9.4, 9.3):
self.engine.run(portfolio, {CODE: tick(price)}, {CODE: IND}, NOW)
self.client.passorder.assert_not_called()
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.3, orders=[report])
self.client.passorder.assert_not_called()
self.client.cancel_by_id.assert_not_called()
def test_on_road_or_unknown_order_never_opens_grid(self):
pos = position()
pos.on_road_volume = 100
self.run_price(9.4, pos)
self.run_price(9.3, pos)
unknown = OrderItem(stock_code=CODE, order_status=255)
self.run_price(9.4, orders=[unknown])
self.run_price(9.3, 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.3)
self.run_price(10.7, position(100, 9.0, 100))
self.client.passorder.assert_not_called()
def test_entry_gate_uses_configured_band_not_boll_lower(self):
"""入场门槛取自 Indicators.entry_bandband_type 决定它由谁计算。"""
donchian_ind = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2,
entry_band=9.0, donchian_lo=8.8, donchian_hi=11.0)
self.run_price(9.4, ind=donchian_ind)
self.run_price(9.35, ind=donchian_ind) # 高于 Donchian 门槛 9.0
self.client.passorder.assert_not_called()
self.run_price(8.95, ind=donchian_ind) # 进入入场区
self.run_price(8.90, ind=donchian_ind) # 刷新低点
self.run_price(8.96, ind=donchian_ind) # 反弹 0.67% 确认
self.client.passorder.assert_called_once()
self.assertEqual(self.client.passorder.call_args.kwargs['price'], 8.96)
def test_band_type_switch_changes_entry_band(self):
rows = IndicatorTests().bars()
don = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='donchian',
donchian_period=20, donchian_pct=15.0))
boll = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='boll'))
self.assertEqual((don.donchian_lo, don.donchian_hi), (9.0, 11.0))
# 常数序列下 ma60=10、grid=2两条通道都被 ma60-1格 压到 8.0
# 因此这里验证通道本身确实换了,而不是只看最终门槛。
self.assertEqual(don.lower, boll.lower)
self.assertAlmostEqual(boll.entry_band, min(boll.lower, boll.ma60 - boll.grid))
widened = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='donchian',
donchian_period=20, donchian_pct=5.0))
self.assertAlmostEqual(widened.entry_band, min(9.0 + (11.0 - 9.0) * 0.05,
widened.ma60 - widened.grid))
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_state_roundtrip_keeps_lots_and_pending(self):
state = self.store.get(CODE)
state.anchor = 9.3
state.last_buy = 9.28
state.lot(0).volume = 200
state.lot(0).cost = 9.28
state.lot(0).bought = NOW.date()
state.lot(0).buys = 1
state.pending['1'] = dict(id='ETF-BUY-abc', side='BUY', volume=100, base_volume=200,
reserved=913.0, level=1, price=9.1)
self.store.save()
again = Store(self.path, 'test').get(CODE)
self.assertEqual(again.anchor, 9.3)
self.assertEqual(again.volume, 200)
self.assertEqual(again.lots['0'].bought, NOW.date())
self.assertEqual(again.pending['1']['level'], 1)
def test_v1_state_is_migrated_to_anchor_lot(self):
self.path.write_text(
'{"version": 1, "account": "test", "symbols": {"510300.SH": '
'{"volume": 200, "cost": 9.9, "last_buy": 9.8, "armed": true, "sell_grid": 0.2, '
'"peak": 3, "hold_days": 4, "pending": {}}}}', encoding='utf-8')
state = Store(self.path, 'test').get(CODE)
self.assertEqual(state.volume, 200)
self.assertEqual(state.anchor, 9.9)
self.assertEqual(state.lots['0'].volume, 200)
self.assertEqual(state.pending, {})
def test_corrupt_state_does_not_silently_start_empty(self):
for payload in ('{', '{"version": 9, "account": "test", "symbols": {}}',
'{"version": 2, "account": "other", "symbols": {}}',
'{"version": 2, "account": "test", "symbols": {"510300.SH": '
'{"anchor": 9.0, "lots": {"0": {"volume": 100, "cost": 0}}, "pending": {}}}}',
'{"version": 2, "account": "test", "symbols": {"510300.SH": '
'{"anchor": 0, "lots": {"0": {"volume": 100, "cost": 9.0}}, "pending": {}}}}',
'{"version": 2, "account": "test", "symbols": {"510300.SH": '
'{"anchor": 9.0, "lots": {"0": {"volume": 50, "cost": 9.0}}, "pending": {}}}}'):
with self.subTest(payload=payload):
self.path.write_text(payload, encoding='utf-8')
with self.assertRaises(ValueError):
Store(self.path, 'test')
def test_engine_rejects_symbol_removed_with_pending_order(self):
state = self.store.get(CODE)
state.pending['0'] = dict(id='ETF-BUY-x', side='BUY', volume=100, base_volume=0,
reserved=950.0, level=0, price=9.5)
with self.assertRaises(ValueError):
Engine(self.client, ETFConfig(codes=(OTHER,)), self.store, 0.1)
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))
self.assertAlmostEqual(ind.entry_band, min(ind.lower, ind.ma60 - ind.grid))
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': ()}, {'min_hold_days': -1},
{'max_hold_days': -1}, {'grid_levels': 0}, {'grid_levels': 10},
{'band_type': 'ma'}, {'donchian_pct': 0}):
with self.assertRaises(ValueError):
ETFConfig(**dict({'codes': (CODE,)}, **kwargs))
def test_default_file_loads(self):
cfg = load()
self.assertEqual(cfg.band_type, 'boll')
self.assertGreaterEqual(cfg.grid_levels, 1)
self.assertGreater(cfg.sell_grid_mult, 0)
self.assertGreaterEqual(cfg.min_order_value, 1000)
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_bare_list_response_is_supported(self):
"""线上 /etf/daily 直接返回一维数组(倒序),旧版是 {code, details} 包装。"""
def respond(request):
self.assertEqual(str(request.url), DAILY_URL + '?code=' + CODE)
self.assertNotIn('x-token', request.headers)
return httpx.Response(200, json=[self.row(20260915)])
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_legacy_envelope_response_still_supported(self):
payload = {'code': 0, 'message': '', 'details': [self.row(20260915)]}
self.assertEqual([b['date'] for b in parse_daily(payload, CODE, NOW.date())], ['20260915'])
def test_sort_filter_then_limit_and_numeric_strings(self):
payload = [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},
[self.row(20260916)]):
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(rows, CODE, NOW.date())
def test_external_history_flows_into_real_indicators(self):
rows = parse_daily([self.row(int(row['date'])) for row in IndicatorTests().bars()],
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()