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,4 +1,4 @@
"""ETF 离线回归:指标、真实防飞刀/网格算法、限仓、回报持久化。"""
"""ETF 离线回归:指标、分格网格(每格独立买卖)、费用门槛、限仓、回报持久化。"""
from datetime import date, datetime, timedelta
import httpx
@@ -12,12 +12,14 @@ 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
from strategy.etf.state import GridLot, Store, SymbolState
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)
# 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):
@@ -29,201 +31,414 @@ def position(volume=0, cost=0, available=None):
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'}
self.cfg = ETFConfig(codes=(CODE,), min_commission=0, commission_rate=0)
# 测试用零佣金、零金额门槛、每格 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):
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 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 buy(self):
self.run_price(9.4)
self.run_price(9.46)
self.client.passorder.assert_called_once()
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 report(self, status=56, filled=100, side=23, price=9.46):
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
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)
rows = [p for p in pending.values() if p['side'] == side]
return max(rows, key=lambda p: p['id'])
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)
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.36)
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.36, 11))
self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.38, 11))
self.assertEqual(request['strategy_name'], 'etf')
self.assertTrue(self.store.get(CODE).pending)
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(saved['id'], kwargs['order_id'])
self.assertEqual({p['id'] for p in saved.values()}, {kwargs['order_id']})
raise TimeoutError('unknown result')
self.client.passorder.side_effect = submit
self.run_price(9.4)
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.46)
self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
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, now=NOW + timedelta(minutes=10))
self.run_price(9.3, engine=engine, 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_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_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_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_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))
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=1900)
self.run_price(9.46, cash=1900)
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), min_commission=0, commission_rate=0)
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=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)
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.46, 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_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):
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.46, 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.46, 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.46)
self.run_price(10.7, position(100, 10))
self.run_price(10.55, position(100, 10))
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):
self.path.write_text('{', encoding='utf-8')
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):
Store(self.path, 'test')
Engine(self.client, ETFConfig(codes=(OTHER,)), self.store, 0.1)
class IndicatorTests(unittest.TestCase):
@@ -241,6 +456,7 @@ class IndicatorTests(unittest.TestCase):
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()
@@ -267,32 +483,41 @@ 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': ()}):
{'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.buy_hands, cfg.max_hands), (1, 10))
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_request_and_sample_shape(self):
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={'code': 0, 'message': '', 'details': [self.row()]})
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 = dict(code=0, details=[self.row(20260916), self.row(20260915, close='10.5'),
self.row(20260914), self.row(20260917)])
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)
@@ -307,7 +532,8 @@ class DailyDataTests(unittest.TestCase):
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}):
{'code': 0, 'details': {}}, {'code': 0, 'details': None},
[self.row(20260916)]):
with self.subTest(payload=payload), self.assertRaises(ValueError):
parse_daily(payload, CODE, NOW.date())
@@ -316,11 +542,11 @@ class DailyDataTests(unittest.TestCase):
[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())
parse_daily(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())
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))