476 lines
20 KiB
Python
476 lines
20 KiB
Python
"""ZT 新路径端到端:建仓、正T、反T、T+1 隔夜、每日一轮、资金、启动与撤单范围。"""
|
||
|
||
import logging
|
||
import tempfile
|
||
import unittest
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from types import SimpleNamespace as NS
|
||
from unittest.mock import Mock, patch
|
||
|
||
from libs.signal import SignalItem
|
||
from libs.snapshot import get_collector_snapshot
|
||
from sdk import OP_BUY, OP_SELL, Assets, OrderItem, PositionItem
|
||
from strategy.zt import boot
|
||
from strategy.zt.rounds import Round, RoundStore, start_round
|
||
from tests.zt_harness import Fixture
|
||
|
||
CODE = '600000.SH'
|
||
OTHER = '600001.SH'
|
||
TODAY = '2026-09-15'
|
||
SIGNAL = [SignalItem(signal_key='dcm', code=CODE, last_close=10.0)]
|
||
|
||
|
||
class ZTBaseTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fx = Fixture()
|
||
self.addCleanup(self.fx.cleanup)
|
||
logging.disable(logging.CRITICAL)
|
||
self.addCleanup(logging.disable, logging.NOTSET)
|
||
|
||
def test_existing_account_positions_are_never_taken_over(self):
|
||
self.fx.hold(CODE, volume=1000, price=37.72)
|
||
self.fx.quote(CODE, 37.72)
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.base_qty, 0) # 不写基准
|
||
self.assertEqual(item.phase, 'IDLE')
|
||
self.assertEqual(self.fx.placed, [])
|
||
# 也不纳入管理,账户已有持仓原样保留
|
||
self.assertFalse(self.fx.store.rounds)
|
||
|
||
def test_unmanaged_positions_are_listed_each_tick(self):
|
||
self.fx.hold(CODE, volume=1000, price=37.72)
|
||
self.fx.quote(CODE, 37.72)
|
||
logging.disable(logging.NOTSET)
|
||
with self.assertLogs(level='INFO') as captured:
|
||
self.fx.tick()
|
||
text = '\n'.join(captured.output)
|
||
self.assertIn('[ZT跳过]', text)
|
||
self.assertIn('未接管持仓 1 只', text)
|
||
self.assertIn(CODE, text)
|
||
|
||
def test_decision_and_summary_lines_are_logged(self):
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
self.fx.quote(CODE, 10.0)
|
||
logging.disable(logging.NOTSET)
|
||
with self.assertLogs(level='INFO') as captured:
|
||
self.fx.tick()
|
||
text = '\n'.join(captured.output)
|
||
self.assertIn('[ZT汇总]', text)
|
||
self.assertIn('未接管=1 新委托=0', text)
|
||
|
||
def test_open_base_needs_signal_and_rebound_then_uses_the_fill_price(self):
|
||
self.fx.quote(CODE, 10.0)
|
||
self.fx.tick(SIGNAL) # 第一次观察,不追
|
||
self.assertEqual(self.fx.placed, [])
|
||
|
||
self.fx.tick(SIGNAL) # 同一价位即满足反弹确认
|
||
self.assertEqual(len(self.fx.placed), 1)
|
||
order = self.fx.placed[0]
|
||
self.assertEqual((order['op_type'], order['volume']), (OP_BUY, 100))
|
||
self.assertTrue(order['order_id'].startswith('zt-base-'))
|
||
|
||
self.fx.deals = [self.fx.deal(order['order_id'], 100, 10.25, sys_id='b1')]
|
||
self.fx.tick(SIGNAL)
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'CLOSED')
|
||
self.assertEqual(item.outcome, 'base')
|
||
self.assertEqual((item.base_qty, item.base_cost), (100, 10.25))
|
||
self.assertEqual(item.base_source, 'opened')
|
||
|
||
def test_open_base_is_skipped_without_a_signal(self):
|
||
self.fx.quote(CODE, 10.0)
|
||
self.fx.prime(self.fx.run.open_watch, CODE, 10.0)
|
||
self.fx.tick([])
|
||
self.assertEqual(self.fx.placed, [])
|
||
|
||
def test_decision_and_summary_lines_are_logged(self):
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
self.fx.quote(CODE, 10.0)
|
||
self.fx.open_round() # 本策略自有基准 -> 纳入管理
|
||
logging.disable(logging.NOTSET)
|
||
with self.assertLogs(level='INFO') as captured:
|
||
self.fx.tick()
|
||
text = '\n'.join(captured.output)
|
||
self.assertIn('[ZT决策]', text)
|
||
self.assertIn('[ZT汇总]', text)
|
||
self.assertIn('中性带内不做', text)
|
||
self.assertIn('未接管=0', text)
|
||
|
||
|
||
class ZTShortTTests(unittest.TestCase):
|
||
"""反T:高抛后低吸买回。"""
|
||
|
||
def setUp(self):
|
||
self.fx = Fixture()
|
||
self.addCleanup(self.fx.cleanup)
|
||
logging.disable(logging.CRITICAL)
|
||
self.addCleanup(logging.disable, logging.NOTSET)
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
self.fx.own_base(CODE, 1000, 10.0)
|
||
|
||
def enter(self):
|
||
self.fx.quote(CODE, 11.0)
|
||
self.fx.tick() # 网格首次观察
|
||
self.fx.quote(CODE, 10.5)
|
||
self.fx.tick() # 网格回撤 -> 高抛
|
||
|
||
def test_sell_high_then_buy_back(self):
|
||
self.enter()
|
||
self.assertEqual(len(self.fx.placed), 1)
|
||
entry = self.fx.placed[0]
|
||
self.assertEqual((entry['op_type'], entry['volume']), (OP_SELL, 500))
|
||
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
|
||
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'OPENING')
|
||
self.assertEqual((item.kind, item.base_qty), ('SHORT_T', 1000))
|
||
|
||
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1')]
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'OPEN')
|
||
self.assertEqual(item.residual_qty, 500)
|
||
|
||
# 买回需要"较卖均价回落 + 反弹确认":上一轮 tick 已在上方建立观察点。
|
||
self.fx.quote(CODE, 10.8)
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'CLOSING')
|
||
exit_order = self.fx.placed[-1]
|
||
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_BUY, 500))
|
||
self.assertTrue(exit_order['order_id'].startswith('zt-exit-'))
|
||
|
||
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1'),
|
||
self.fx.deal(exit_order['order_id'], 500, 10.8, sys_id='b1')]
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'CLOSED')
|
||
self.assertEqual(item.outcome, 'normal')
|
||
self.assertEqual(item.residual_qty, 0)
|
||
self.assertEqual(item.base_qty, 1000) # 成本基准数量不变
|
||
self.assertAlmostEqual(item.realized_amount, 100.0)
|
||
|
||
def test_no_sell_inside_the_neutral_band(self):
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
self.fx.quote(CODE, 10.05)
|
||
self.fx.tick()
|
||
self.fx.tick()
|
||
self.assertEqual(self.fx.placed, [])
|
||
|
||
def test_sell_only_round_is_counted_in_the_tick_summary(self):
|
||
logging.disable(logging.NOTSET)
|
||
self.fx.quote(CODE, 11.0)
|
||
self.fx.tick() # 首次观察,不下单
|
||
self.fx.quote(CODE, 10.5)
|
||
with self.assertLogs(level='INFO') as captured:
|
||
self.fx.tick() # 网格回撤 -> 高抛
|
||
text = '\n'.join(captured.output)
|
||
self.assertIn('[ZT下单]', text)
|
||
self.assertIn('[ZT决策]', text)
|
||
# 卖出腿不预留资金,仍必须计入"新委托",否则日志会漏报卖出。
|
||
self.assertIn('新委托=1', text)
|
||
|
||
def test_price_above_the_cap_never_starts_a_round(self):
|
||
self.fx.hold(CODE, volume=1000, price=190.0)
|
||
self.fx.quote(CODE, 200.5)
|
||
self.fx.tick()
|
||
self.fx.tick()
|
||
self.assertEqual(self.fx.placed, [])
|
||
|
||
def test_position_not_sellable_cannot_open_a_short_t(self):
|
||
self.fx.hold(CODE, volume=1000, price=10.0, can_use=0)
|
||
self.enter()
|
||
self.assertEqual(self.fx.placed, [])
|
||
|
||
|
||
class ZTLongTTests(unittest.TestCase):
|
||
"""正T:低吸后高抛;当天买入受 T+1 限制。"""
|
||
|
||
def setUp(self):
|
||
self.fx = Fixture()
|
||
self.addCleanup(self.fx.cleanup)
|
||
logging.disable(logging.CRITICAL)
|
||
self.addCleanup(logging.disable, logging.NOTSET)
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
self.fx.own_base(CODE, 1000, 10.0)
|
||
|
||
def enter(self, price=9.0):
|
||
self.fx.quote(CODE, price)
|
||
self.fx.prime(self.fx.run.open_watch, CODE, price)
|
||
self.fx.tick()
|
||
|
||
def test_buy_the_dip_then_wait_for_t_plus_1(self):
|
||
self.enter()
|
||
self.assertEqual(len(self.fx.placed), 1)
|
||
entry = self.fx.placed[0]
|
||
self.assertEqual((entry['op_type'], entry['volume']), (OP_BUY, 100))
|
||
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
|
||
|
||
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual((item.kind, item.phase), ('LONG_T', 'OPEN'))
|
||
self.assertEqual(item.residual_qty, 100)
|
||
|
||
# 当天买入不可卖:可卖库存仍为 0,只能隔夜。
|
||
self.fx.hold(CODE, volume=1100, price=10.0, can_use=0)
|
||
self.fx.quote(CODE, 9.5)
|
||
self.fx.tick()
|
||
self.assertEqual(len(self.fx.placed), 1) # 没有新的卖单
|
||
self.assertEqual(self.fx.store.get(CODE).phase, 'OPEN')
|
||
|
||
# 可卖恢复后才能高抛平仓。
|
||
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'CLOSING')
|
||
exit_order = self.fx.placed[-1]
|
||
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_SELL, 100))
|
||
|
||
def test_only_one_round_per_stock_per_day(self):
|
||
self.enter()
|
||
entry = self.fx.placed[0]
|
||
exit_order_id = None
|
||
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
|
||
self.fx.tick()
|
||
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
|
||
self.fx.quote(CODE, 9.5)
|
||
self.fx.tick()
|
||
exit_order_id = self.fx.placed[-1]['order_id']
|
||
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1'),
|
||
self.fx.deal(exit_order_id, 100, 9.5, sys_id='s1')]
|
||
self.fx.tick()
|
||
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
|
||
placed_after_close = len(self.fx.placed)
|
||
|
||
# 同一天价格再次满足低吸,也不允许开新轮。
|
||
self.fx.quote(CODE, 8.8)
|
||
self.fx.prime(self.fx.run.open_watch, CODE, 8.8)
|
||
self.fx.tick()
|
||
self.assertEqual(len(self.fx.placed), placed_after_close)
|
||
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
|
||
|
||
|
||
class ZTRiskTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fx = Fixture()
|
||
self.addCleanup(self.fx.cleanup)
|
||
logging.disable(logging.CRITICAL)
|
||
self.addCleanup(logging.disable, logging.NOTSET)
|
||
|
||
def test_cash_budget_is_shared_between_codes_in_one_tick(self):
|
||
self.fx.account_cfg.min_cash_ratio = 0.0
|
||
for code in (CODE, OTHER):
|
||
self.fx.hold(code, volume=1000, price=10.0)
|
||
self.fx.own_base(code, 1000, 10.0)
|
||
self.fx.quote(code, 9.0)
|
||
self.fx.prime(self.fx.run.open_watch, code, 9.0)
|
||
self.fx.assets.total = 1500.0
|
||
self.fx.assets.available = 1500.0
|
||
self.fx.tick()
|
||
self.assertEqual(len(self.fx.placed), 1) # 只够一手的钱
|
||
self.assertEqual(self.fx.placed[0]['stock_code'], CODE)
|
||
|
||
def test_failed_place_self_heals_on_the_next_tick(self):
|
||
from sdk import APIError
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
self.fx.own_base(CODE, 1000, 10.0)
|
||
self.fx.quote(CODE, 9.0)
|
||
self.fx.prime(self.fx.run.open_watch, CODE, 9.0)
|
||
self.fx.client.passorder.side_effect = APIError(400, 'rejected')
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'OPENING') # 意图先落盘,请求被拒
|
||
|
||
self.fx.client.passorder.side_effect = None
|
||
self.fx.client.passorder.return_value = {'status': 'success'}
|
||
self.fx.tick() # 未受理且无成交 -> 判为作废
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'CLOSED')
|
||
self.assertEqual(item.outcome, 'aborted')
|
||
self.assertEqual(item.base_qty, 1000)
|
||
|
||
def test_only_zt_orders_are_cancelled(self):
|
||
stamp = datetime.now() - timedelta(minutes=30)
|
||
self.fx.orders = [
|
||
self._order('sys-tren', 'TREN-BUY-1|trend', stamp),
|
||
self._order('sys-zt', 'zt-entry-1', stamp),
|
||
self._order('sys-ipo', 'IPO-abc', stamp),
|
||
self._order('sys-manual', '', stamp),
|
||
]
|
||
self.fx.quote(CODE, 10.0)
|
||
self.fx.tick()
|
||
cancelled = [call.args[0] for call in self.fx.client.cancel_by_id.call_args_list]
|
||
self.assertEqual(cancelled, ['sys-zt'])
|
||
|
||
@staticmethod
|
||
def _order(sys_id, remark, stamp):
|
||
return OrderItem(stock_code=CODE, order_sys_id=sys_id, remark=remark,
|
||
order_status=50, offset_flag=23,
|
||
insert_date=stamp.strftime('%Y%m%d'),
|
||
insert_time=stamp.strftime('%H%M%S'))
|
||
|
||
def test_expired_round_folds_its_exposure_into_the_base(self):
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
item = self.fx.own_base(CODE, 1000, 10.0)
|
||
start_round(item, 'SHORT_T', '2020-01-01')
|
||
item.entry_order_id = 'zt-entry-old'
|
||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||
item.phase = 'OPEN'
|
||
self.fx.store.put(item)
|
||
self.fx.store.save()
|
||
|
||
self.fx.quote(CODE, 11.0)
|
||
self.fx.tick()
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.outcome, 'expired')
|
||
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
|
||
self.assertEqual(item.base_cost, 10.0) # 成本仍是建仓价
|
||
|
||
def test_stale_order_id_disappearing_returns_the_round_to_open(self):
|
||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||
item = self.fx.own_base(CODE, 1000, 10.0)
|
||
start_round(item, 'SHORT_T', TODAY)
|
||
item.entry_order_id = 'zt-entry-1'
|
||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||
item.exit_order_id = 'zt-exit-1'
|
||
item.phase = 'CLOSING'
|
||
self.fx.store.put(item)
|
||
self.fx.store.save()
|
||
|
||
self.fx.quote(CODE, 10.0)
|
||
self.fx.tick() # 平仓腿已不在途且无成交
|
||
item = self.fx.store.get(CODE)
|
||
self.assertEqual(item.phase, 'OPEN')
|
||
self.assertEqual(item.residual_qty, 500)
|
||
|
||
|
||
class ZTForeignBaseCleanupTests(unittest.TestCase):
|
||
"""升级清理:旧版本留下的"接管"基准不得继续参与做 T。"""
|
||
|
||
def setUp(self):
|
||
self.fx = Fixture()
|
||
self.addCleanup(self.fx.cleanup)
|
||
logging.disable(logging.CRITICAL)
|
||
self.addCleanup(logging.disable, logging.NOTSET)
|
||
|
||
def open_store(self):
|
||
with patch.object(boot.config, 'global_config',
|
||
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
|
||
qmt_token='')), \
|
||
patch.object(boot.config, 'account_config', self.fx.account_cfg):
|
||
return boot._open_store()
|
||
|
||
def test_stale_takeover_record_is_dropped(self):
|
||
self.fx.store.put(Round(code=CODE, base_qty=1000, base_cost=37.72,
|
||
base_source='adopted', phase='CLOSED'))
|
||
self.fx.store.save()
|
||
self.fx.own_base(OTHER, 500, 9.5)
|
||
store = self.open_store()
|
||
self.assertNotIn(CODE, store.rounds)
|
||
self.assertIn(OTHER, store.rounds)
|
||
|
||
def test_unclosed_round_is_kept_so_its_exposure_can_be_finished(self):
|
||
item = Round(code=CODE, base_qty=1000, base_cost=37.72, base_source='adopted')
|
||
start_round(item, 'SHORT_T', TODAY)
|
||
item.entry_order_id = 'zt-entry-1'
|
||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||
item.phase = 'OPEN'
|
||
self.fx.store.put(item)
|
||
self.fx.store.save()
|
||
self.assertIn(CODE, self.open_store().rounds)
|
||
|
||
def test_owned_and_empty_records_are_untouched(self):
|
||
self.fx.own_base(CODE, 500, 9.5)
|
||
self.fx.store.put(Round(code=OTHER)) # 无基准的空记录
|
||
self.fx.store.save()
|
||
store = self.open_store()
|
||
self.assertIn(CODE, store.rounds)
|
||
self.assertIn(OTHER, store.rounds)
|
||
|
||
|
||
class ZTStartTests(unittest.TestCase):
|
||
"""启动路径:使用新轮次文件、不碰旧账本、跨重启恢复未平轮次。"""
|
||
|
||
def start(self, client, directory):
|
||
account = NS(account_id='test', strategy='zt', grid_step_pct=1.0,
|
||
signal_allow=[], zt_open_hands=1, zt_max_hold_days=5,
|
||
zt_t_band_pct=1.0, zt_sell_ratio=0.5, zt_buy_fall_pct=1.0,
|
||
zt_max_price=200.0, excluded_codes=[],
|
||
min_cash_ratio=0.1)
|
||
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
|
||
with patch.object(boot, 'Client', return_value=client), \
|
||
patch.object(boot.config, 'global_config', global_cfg), \
|
||
patch.object(boot.config, 'account_config', account), \
|
||
patch.object(boot, 'init_signals', return_value=[]), \
|
||
patch.object(boot.time, 'localtime',
|
||
return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
|
||
boot.StartZT()
|
||
return account
|
||
|
||
def test_uses_the_rounds_store_and_never_touches_the_old_ledger(self):
|
||
client = Mock()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
self.start(client, tmp)
|
||
self.assertFalse((Path(tmp) / 'zt_test_state.db').exists())
|
||
self.assertEqual(client.deals.call_count, 0) # 15:00 直接退出,没跑 tick
|
||
self.assertEqual(client.portfolio.call_count, 0) # 也不再为接管读持仓
|
||
client.close.assert_called_once()
|
||
with patch.object(boot.config, 'global_config',
|
||
NS(qmt_data_dir=tmp, qmt_base_url='u', qmt_token='')), \
|
||
patch.object(boot.config, 'account_config', NS(account_id='test')):
|
||
store = boot._open_store()
|
||
self.assertEqual(store.path.name, 'zt_test_rounds.json')
|
||
self.assertEqual(store.rounds, {})
|
||
|
||
def test_start_does_not_read_positions_at_all(self):
|
||
# 不接管持仓,启动阶段不需要账户快照,第一次读盘发生在第一个 tick。
|
||
client = Mock()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
self.start(client, tmp)
|
||
self.assertEqual(client.portfolio.call_count, 0)
|
||
self.assertEqual(client.deals.call_count, 0)
|
||
|
||
def test_restores_an_unclosed_round_across_restart(self):
|
||
client = Mock()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store = RoundStore(Path(tmp) / 'zt_test_rounds.json')
|
||
item = Round(code=CODE, base_qty=1000, base_cost=10.0)
|
||
start_round(item, 'SHORT_T', '2026-09-14')
|
||
item.entry_order_id = 'zt-entry-1'
|
||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||
item.phase = 'OPEN'
|
||
store.put(item)
|
||
store.save()
|
||
|
||
self.start(client, tmp)
|
||
|
||
restored = RoundStore(Path(tmp) / 'zt_test_rounds.json').get(CODE)
|
||
self.assertEqual(restored.phase, 'OPEN')
|
||
self.assertEqual(restored.residual_qty, 500)
|
||
self.assertEqual(restored.entry_avg_price, 11.0)
|
||
|
||
|
||
class ZTCollectorTests(unittest.TestCase):
|
||
def test_snapshot_is_cached_even_when_market_fetch_fails(self):
|
||
fx = Fixture()
|
||
self.addCleanup(fx.cleanup)
|
||
fx.assets = Assets(total=20000, available=10000)
|
||
fx.hold(CODE, volume=100, price=10.0)
|
||
fx.quote(CODE, 10.0)
|
||
fx.client.full_tick.side_effect = RuntimeError('no market data')
|
||
with patch.object(boot, 'trading_time', return_value=True), \
|
||
patch.object(boot, 'market_allow_open', return_value=True):
|
||
boot.RunOnce(fx.run, fx.store, [])
|
||
snapshot = get_collector_snapshot()
|
||
self.assertEqual(snapshot[0], 'zt-test')
|
||
self.assertEqual(snapshot[1].total, 20000)
|
||
self.assertEqual([p.stock_code for p in snapshot[2]], [CODE])
|
||
self.assertEqual(fx.placed, [])
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|