import unittest from datetime import date from decimal import Decimal from quant60.broker import SimBroker from quant60.config import ExecutionConfig, FeeConfig from quant60.domain import ( Bar, Fill, Order, OrderStatus, Portfolio, Position, Side, ) from quant60.ledger import HashChainLedger def bar( day, *, symbol="600000.XSHG", price="10", volume=1000, suspended=False, limit_up=None, limit_down=None, ): value = Decimal(price) return Bar( trading_date=day, symbol=symbol, open=value, high=value, low=value, close=value, volume=volume, suspended=suspended, limit_up=Decimal(limit_up) if limit_up else None, limit_down=Decimal(limit_down) if limit_down else None, ) class DomainBrokerTests(unittest.TestCase): def setUp(self): self.portfolio = Portfolio(Decimal("100000")) self.ledger = HashChainLedger() self.broker = SimBroker( self.portfolio, ExecutionConfig( lot_size=100, participation_rate=0.1, slippage_bps=0, ), FeeConfig( commission_rate=0, minimum_commission=0, stamp_duty_rate=0, transfer_fee_rate=0, ), self.ledger, ) def test_duplicate_state_callback_is_idempotent(self): order = Order("O1", "600000.SH", Side.BUY, 100, date(2024, 1, 2)) order.transition(OrderStatus.ACCEPTED) order.transition(OrderStatus.ACCEPTED) self.assertEqual(order.status, OrderStatus.ACCEPTED) order.transition(OrderStatus.UNKNOWN) order.transition(OrderStatus.UNKNOWN) order.transition(OrderStatus.ACCEPTED) self.assertEqual(order.status, OrderStatus.ACCEPTED) def test_late_fill_during_cancel_pending_is_accepted(self): order = Order("O1", "600000.SH", Side.BUY, 200, date(2024, 1, 2)) order.transition(OrderStatus.ACCEPTED) order.transition(OrderStatus.CANCEL_PENDING) order.apply_fill(100, Decimal("10")) self.assertEqual(order.status, OrderStatus.PARTIALLY_FILLED) self.assertEqual(order.filled_quantity, 100) order.transition(OrderStatus.CANCEL_PENDING) order.apply_fill(100, Decimal("10.1")) self.assertEqual(order.status, OrderStatus.FILLED) self.assertEqual(order.filled_quantity, 200) def test_partial_fill_and_t_plus_one(self): submit_day = date(2024, 1, 2) fill_day = date(2024, 1, 3) self.broker.execute_session( {"600000.XSHG": bar(submit_day, volume=2000)} ) self.broker.submit("600000.SH", Side.BUY, 500, submit_day) fills = self.broker.execute_session( {"600000.XSHG": bar(fill_day, volume=100_000)} ) self.assertEqual(sum(item.quantity for item in fills), 200) position = self.portfolio.position("600000.XSHG") self.assertEqual(position.quantity, 200) self.assertEqual(position.sellable_quantity, 0) with self.assertRaises(ValueError): position.sell(100) self.portfolio.start_session() self.assertEqual(position.sellable_quantity, 200) def test_suspension_and_locked_limit_block(self): submit_day = date(2024, 1, 2) next_day = date(2024, 1, 3) self.broker.execute_session( {"600000.XSHG": bar(submit_day, volume=1000)} ) self.broker.submit("600000.SH", Side.BUY, 100, submit_day) self.assertEqual( self.broker.execute_session( {"600000.XSHG": bar(next_day, suspended=True, volume=0)} ), [], ) reasons = [ record.payload["reason"] for record in self.ledger.records if record.event_type == "ORDER_BLOCKED" ] self.assertIn("SUSPENDED", reasons) other = SimBroker( Portfolio(Decimal("100000")), self.broker.execution, self.broker.fees, HashChainLedger(), ) other.execute_session( {"600000.XSHG": bar(submit_day, volume=1000)} ) other.submit("600000.SH", Side.BUY, 100, submit_day) self.assertEqual( other.execute_session( { "600000.XSHG": bar( next_day, price="11", limit_up="11", limit_down="9", ) } ), [], ) def test_open_fill_capacity_uses_only_prior_session_volume(self): submit_day = date(2024, 1, 2) fill_day = date(2024, 1, 3) def executed_quantity(current_day_volume): broker = SimBroker( Portfolio(Decimal("100000")), self.broker.execution, self.broker.fees, HashChainLedger(), ) broker.execute_session( {"600000.XSHG": bar(submit_day, volume=2000)} ) broker.submit("600000.SH", Side.BUY, 500, submit_day) fills = broker.execute_session( { "600000.XSHG": bar( fill_day, volume=current_day_volume, ) } ) return sum(item.quantity for item in fills) self.assertEqual(executed_quantity(0), 200) self.assertEqual(executed_quantity(10_000_000), 200) def test_fill_cash_and_position_conservation(self): fill = Fill( "F1", "O1", date(2024, 1, 3), "600000.SH", Side.BUY, 100, Decimal("10"), Decimal("5"), Decimal("0"), Decimal("0.01"), ) self.portfolio.apply_fill(fill) self.assertEqual(self.portfolio.position("600000.SH").quantity, 100) self.assertEqual(self.portfolio.cash, Decimal("98994.990000")) def test_complete_odd_lot_sell_is_accepted_and_filled(self): submit_day = date(2024, 1, 2) fill_day = date(2024, 1, 3) symbol = "600000.XSHG" self.portfolio.positions[symbol] = Position( symbol, quantity=1050, sellable_quantity=1050, today_bought=0, average_cost=Decimal("9"), ) self.broker.execute_session( {symbol: bar(submit_day, volume=200_000)} ) order = self.broker.submit( symbol, Side.SELL, 1050, submit_day, ) self.assertEqual(order.status, OrderStatus.ACCEPTED) fills = self.broker.execute_session( {symbol: bar(fill_day, volume=200_000)} ) self.assertEqual([item.quantity for item in fills], [1050]) self.assertEqual(order.status, OrderStatus.FILLED) self.assertEqual(self.portfolio.position(symbol).quantity, 0) def test_partial_odd_lot_and_star_subminimum_are_rejected(self): submit_day = date(2024, 1, 2) symbol = "600000.XSHG" self.portfolio.positions[symbol] = Position( symbol, quantity=1050, sellable_quantity=1050, today_bought=0, average_cost=Decimal("9"), ) odd = self.broker.submit(symbol, Side.SELL, 50, submit_day) self.assertEqual(odd.status, OrderStatus.REJECTED) self.assertEqual(odd.status_reason, "NOT_BOARD_LOT") star = self.broker.submit( "688301.XSHG", Side.BUY, 100, submit_day, ) self.assertEqual(star.status, OrderStatus.REJECTED) self.assertEqual(star.status_reason, "BELOW_MARKET_MINIMUM") if __name__ == "__main__": unittest.main()