Files
quant-os/tests/test_domain_broker.py

248 lines
7.9 KiB
Python

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