|
1 | 1 | """SimulatedBroker: market-on-open matching with rule set and cost model. |
2 | 2 |
|
3 | | -Rules (task 7 + task 8): |
| 3 | +Rules (task 7 + task 8 + task 16): |
4 | 4 | * Only `OrderType.MARKET` orders are supported; any other type is |
5 | 5 | rejected by `Context` before reaching the broker. |
6 | 6 | * The bar for `today` is read first: `MissingDataError` (suspended / |
7 | 7 | no bar) becomes `bar_available=False` for the rule set, while |
8 | | - `InvalidDataError` and I/O errors propagate and abort the run. |
| 8 | + `InvalidDataError` / I/O errors propagate and abort the run. |
9 | 9 | * Each order then goes through `TradingRuleSet.first_denial`; the |
10 | 10 | first denial short-circuits with a typed `RejectReason`. |
11 | 11 | * Fees come from `CostModel.compute(order, price, quantity)`; the |
|
14 | 14 | * Ledger-side rejections (`InsufficientCashError`, |
15 | 15 | `InsufficientSharesError`) are produced by `Portfolio.apply_fill` |
16 | 16 | and converted by the engine into typed rejections. |
| 17 | +
|
| 18 | +Task 16 batch-matching order (A-share convention): |
| 19 | + * Within a single `OPEN_MATCH(today)` batch, SELL orders match |
| 20 | + **before** BUY orders. This mirrors "卖出资金当日可用": the |
| 21 | + proceeds of a SELL can fund a same-batch BUY, so a rotation |
| 22 | + ("卖旧买新") is not falsely rejected for INSUFFICIENT_CASH. |
| 23 | + * The rule set's `InsufficientCashRule` checks against a rolling |
| 24 | + `running_cash` that starts at `portfolio_cash`, increases by each |
| 25 | + successful SELL's net proceeds, and decreases by each successful |
| 26 | + BUY's cost. The ordering within each side (SELL-only or BUY-only) |
| 27 | + preserves the strategy's submission order. |
17 | 28 | """ |
18 | 29 |
|
19 | 30 | from decimal import Decimal |
|
27 | 38 | from ..domain.bar import Bar |
28 | 39 | from ..domain.enums import EventType, OrderStatus, RejectReason, Side |
29 | 40 | from ..domain.fill import Fill |
30 | | -from ..domain.money import PRICE_QUANT |
| 41 | +from ..domain.money import PRICE_QUANT, quantize_cash |
31 | 42 | from ..domain.order import Order |
32 | 43 | from .cost_model import CostModel, DefaultCostModel |
33 | 44 | from .rule_set import RuleCheckContext, TradingRuleSet |
@@ -58,25 +69,50 @@ def match( |
58 | 69 |
|
59 | 70 | `sellable_quantity_for(symbol)` is a callable returning the current |
60 | 71 | T+1 sellable shares for the symbol (0 if no position). It mirrors |
61 | | - the engine's view of the portfolio so the rule set stays pure. |
62 | | -
|
63 | | - `portfolio_cash` is a snapshot taken before this batch: for later |
64 | | - orders in the same batch the rule-level cash check may be stale. |
65 | | - That is safe because `Portfolio.apply_fill` re-checks every fill |
66 | | - against the live ledger and is the final arbiter. |
| 72 | + the engine's view of the portfolio so the rule set stays pure; |
| 73 | + the value is captured **before** the batch runs (T+1 state from |
| 74 | + the previous day's settlement), and is not re-checked after each |
| 75 | + SELL because a SELL only reduces `sellable_quantity`, so stale |
| 76 | + values only over-estimate availability (fail-safe for the rule). |
| 77 | +
|
| 78 | + Task 16: orders are partitioned into SELLs and BUYs. All SELLs |
| 79 | + match first (in submission order) so that their proceeds are |
| 80 | + available to fund the subsequent BUYs (rolling cash). The |
| 81 | + partition is a stable sort: the relative order within each side |
| 82 | + is preserved, but cross-side order is normalized to |
| 83 | + [SELLs..., BUYs...] as required by A-share convention. |
| 84 | +
|
| 85 | + Results are returned in **matching order** ([SELLs..., BUYs...]), |
| 86 | + NOT submission order: the engine applies fills in the returned |
| 87 | + order, so a BUY must be applied only after its funding SELL has |
| 88 | + already credited the portfolio's cash. Re-assembling into |
| 89 | + submission order would break the rolling-cash guarantee (a BUY |
| 90 | + submitted before its funding SELL would be applied first and |
| 91 | + falsely rejected for INSUFFICIENT_CASH). |
67 | 92 | """ |
| 93 | + sell_orders = [o for o in orders if o.side is Side.SELL] |
| 94 | + buy_orders = [o for o in orders if o.side is Side.BUY] |
| 95 | + # Stable partition: preserve relative submission order within each |
| 96 | + # side (the original `orders` list is already insertion-ordered). |
| 97 | + ordered = sell_orders + buy_orders |
| 98 | + running_cash = quantize_cash(portfolio_cash) |
68 | 99 | results: List[MatchResult] = [] |
69 | | - for order in orders: |
70 | | - results.append( |
71 | | - self._match_one( |
72 | | - order, |
73 | | - portal, |
74 | | - today, |
75 | | - rule_set, |
76 | | - portfolio_cash, |
77 | | - sellable_quantity_for(order.symbol), |
78 | | - ) |
| 100 | + for order in ordered: |
| 101 | + match_result = self._match_one( |
| 102 | + order, |
| 103 | + portal, |
| 104 | + today, |
| 105 | + rule_set, |
| 106 | + running_cash, |
| 107 | + sellable_quantity_for(order.symbol), |
79 | 108 | ) |
| 109 | + results.append(match_result) |
| 110 | + # Roll cash: only successful fills update the running balance. |
| 111 | + # `net_amount()` is already signed (SELL proceeds positive, |
| 112 | + # BUY costs negative), so a single addition covers both sides. |
| 113 | + _, fill, _, _ = match_result |
| 114 | + if fill is not None: |
| 115 | + running_cash = quantize_cash(running_cash + fill.net_amount()) |
80 | 116 | return results |
81 | 117 |
|
82 | 118 | # ------------------------------------------------------------------ # |
|
0 commit comments