Skip to content

Commit 6f09b4d

Browse files
committed
docs: document task 16 matching & ledger semantics
- Add §3.4 to mvp-contract capturing SELL-before-BUY ordering, rolling cash checks, lot rounding rules (BUY only), T+1 rejection, fee-free realized_pnl, ROUND_HALF_EVEN quantization, Fill.BUY stamp_tax invariant, target_quantity_for_value(0), and CLI float rejection - Surface the matching/ledger contract in README under task 16 - Tighten CLI config to reject float for initial_cash, aligning with BacktestConfig precision contract (rule 5) - Update changelog with 2026-08-24 task 16 entry
1 parent aff5e59 commit 6f09b4d

11 files changed

Lines changed: 583 additions & 43 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,19 @@ hqbacktest/
149149

150150
性能冒烟测试在 `tests/data/test_task15_performance.py`,50 symbols × 250 days 全量 `history(bar_count=20)` 在 15 秒阈值内完成。
151151

152+
### 撮合与账本口径(任务 16)
153+
154+
- **同日撮合顺序:** 单个 `OPEN_MATCH(today)` batch 内**所有 SELL 先撮合、再撮合 BUY**(A 股「卖出资金当日可用」),同侧内保持策略提交顺序。资金检查用**滚动现金**而非撮合前快照。
155+
- **整手取整:** 仅 BUY 按 100 股整手向下取整;SELL 允许任意正整数股(含零股),静默截到整手违反契约。`order_target(symbol, 0)` 必须能清仓含零股的持仓。
156+
- **T+1 可卖不足:** 整单拒绝(`INSUFFICIENT_SHARES`),不支持部分截断。
157+
- **`realized_pnl` 不含费用**:仅 `(sell_price - avg_cost) × quantity`;费用(commission / stamp_tax / other_fee)只走现金账。
158+
- **金额量化:** 全部 `ROUND_HALF_EVEN`(`quantize_cash` 0.01 元 / `quantize_price` 0.0001 元),与券商「四舍五入到分」存在 1 分级差异。
159+
- **`Fill.BUY` 不携带 `stamp_tax`:** 印花税仅在 SELL 收取;`Fill.__post_init__` 拒绝 BUY 携带非零 stamp_tax 以保证账本与 costs 表一致。
160+
- **`intents.target_quantity_for_value(0)` 返回 0**(flatten),与 docstring 一致。
161+
- **CLI `initial_cash` 拒绝 float**(TOML 字面 `100000.0` 报错),与 `BacktestConfig` 严格度对齐(contract rule 5)。
162+
163+
完整口径见 `docs/design/mvp-contract.md` §3.4。手算回归测试在 `tests/engine/test_task16_matching.py`。
164+
152165
## Python 用法(已实现)
153166

154167
> 下面的代码就是 `examples/moving_average.py` 的简化版,可直接 `python -m hqbacktest run` 跑通(见 §命令行)。完整示例见 `examples/`。

‎docs/design/mvp-contract.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,22 @@
150150
| `Bar.volume` 单位 | **手**(1 手 = 100 股;与 Tushare `hqdata` 适配器口径一致);调用方需要股数时应乘以 `LOT_SIZE`。 |
151151
| 双门户一致性 | `InMemoryDataPortal` 与 `HqDataCsvPortal` 行为完全一致(parity 测试覆盖)。 |
152152

153+
### 3.4 撮合与账本口径(任务 16 固化)
154+
155+
| 维度 | v0.1 默认决定 |
156+
| --- | --- |
157+
| 同日撮合顺序 | 单个 `OPEN_MATCH(today)` batch 内**所有 SELL 先撮合、再撮合 BUY**(A 股「卖出资金当日可用」),同侧内保持策略提交顺序。 |
158+
| 资金检查 | `InsufficientCashRule` 用**滚动现金**(running_cash)逐单检查:SELL 净回款增加、BUY 成本扣除,拒绝后不滚动。 |
159+
| 整手取整 | **仅 BUY** 按 100 股整手向下取整;SELL 允许任意正整数股(含零股),静默截到整手违反契约。 |
160+
| `order_target(symbol, 0)` | 必须能清仓含零股的持仓;`order(sym, -N)` 对任意正整数 N 提交原数量。 |
161+
| T+1 可卖不足 | **整单拒绝**(`RejectReason.INSUFFICIENT_SHARES`),不支持部分截断。 |
162+
| `realized_pnl` | **不含任何费用**(commission / stamp_tax / other_fee),仅 `(sell_price - avg_cost) × quantity`;费用只走现金账。 |
163+
| 金额量化 | 全部使用 `ROUND_HALF_EVEN`(`quantize_cash` 0.01 元 / `quantize_price` 0.0001 元),与券商「四舍五入到分」存在 1 分级差异。 |
164+
| 同日同价「先买后卖」与「先卖后买」 | 提交顺序决定成交时点;`realized_pnl` 在费用外对相同 `(price, avg_cost, quantity)` 相同,**不含费用的现金额**依提交顺序而不同。 |
165+
| BUY 成交 `stamp_tax` | 必须为 0(印花税仅在 SELL 收取),`Fill.__post_init__` 校验拒绝非零。 |
166+
| `target_quantity_for_value(0)` | 返回 `0`(flatten),与 docstring 一致。 |
167+
| CLI `initial_cash` | 拒绝 `float`(TOML 字面 `100000.0` 报错),与引擎层 `BacktestConfig` 严格度对齐。 |
168+
153169
## 6. 不可变规则
154170

155171
以下 13 条规则在 v0.1 期间**不允许任何代码绕过**;新增能力时若必须突破某条,必须先在本文档登记例外并同步 README。
@@ -199,4 +215,5 @@
199215
| 2026-08-17 | v0.1(已被后续修订取代) | 曾将 `source` 交给 `hqdata` 解析;该 API 驱动的数据边界已在 2026-08-23 被 CSV 快照契约取代 | hqbacktest 维护者 |
200216
| 2026-08-23 | v0.1 | 修正回测运行时数据边界:`hqbacktest` 直接只读 hqdata CLI 落盘 CSV;`data_root` 默认 `~/.hqdata`,不调用 `hqdata.api` 或网络数据源 | hqbacktest 维护者 |
201217
| 2026-08-23 | v0.1 | 重构数据门户:`HqDataPortal` 替换为 `HqDataCsvPortal`,固定布局 `{data_root}/{source}/calendar.csv` + `stock_list|stock_daily|stock_factor/{YYYYMMDD}.csv`;`source` 名称或绝对路径均可,`CacheKey` 加入 `data_root` 防跨目录污染 | hqbacktest 维护者 |
202-
| 2026-08-24 | v0.1 | 任务 14 数据层缺行/停牌/首日语义:钉死 `get_bars` 允许间隙、引入 `SnapshotFileMissingError` 区分整日文件缺失与个股缺行、`current_price` 回看 20 交易日最近有效收盘价、首日哨兵日期不抛异常、删除 `InMemoryDataPortal.get_universe` 向前回退、补双门户 parity 测试、缓存返回防御性拷贝、`.BJ` 股票默认过滤、`Bar.volume` 单位标注为「手」 | hqbacktest 维护者 |
218+
| 2026-08-24 | v0.1 | 任务 14 数据层缺行/停牌/首日语义:钉死 `get_bars` 允许间隙、引入 `SnapshotFileMissingError` 区分整日文件缺失与个股缺行、`current_price` 回看 20 交易日最近有效收盘价、首日哨兵日期不抛异常、删除 `InMemoryDataPortal.get_universe` 向前回退、补双门户 parity 测试、缓存返回防御性拷贝、`.BJ` 股票默认过滤、`Bar.volume` 单位标注为「手」 | hqbacktest 维护者 |
219+
| 2026-08-24 | v0.1 | 任务 16 撮合与账本语义:同批撮合 SELL 先于 BUY(滚动现金)、SELL 不整手取整(`order_target(0)` 可清零股)、`Fill.BUY` 携带非零 stamp_tax 报错、`Order.record_fill` 移除不可达 `ACCEPTED` 分支、`intents.target_quantity_for_value(0)` 按 docstring 返回 0、CLI `initial_cash` 拒绝 float 与引擎对齐、`realized_pnl` 不含费用修正旧注释;登记 §3.4 撮合口径表 | hqbacktest 维护者 |

‎src/hqbacktest/cli/config.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,16 @@ def _require_decimal(
237237
value = section[key]
238238
if isinstance(value, bool):
239239
raise ConfigError(f"[{section_name}].{key} must be a number, not bool")
240-
if isinstance(value, (int, float, str)):
240+
# Task 16: float is forbidden at the CLI layer too, matching the
241+
# engine's contract rule 5. Without this check a TOML like
242+
# `initial_cash = 100000.0` would silently convert to a Decimal via
243+
# `Decimal(str(float))`, masking the precision concern.
244+
if isinstance(value, float):
245+
raise ConfigError(
246+
f"[{section_name}].{key} must be int/str/Decimal; float is "
247+
"forbidden (contract rule 5)"
248+
)
249+
if isinstance(value, (int, str)):
241250
try:
242251
d = Decimal(str(value))
243252
except Exception as exc:

‎src/hqbacktest/domain/fill.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ def __post_init__(self) -> None:
6565
raise ValueError(f"{field_name} must be quantized to 2 decimal places")
6666
if len(self.filled_at) != 8 or not self.filled_at.isdigit():
6767
raise ValueError(f"filled_at must be YYYYMMDD, got {self.filled_at!r}")
68+
# Task 16: 印花税 is charged only on SELL. A BUY fill carrying a
69+
# non-zero stamp_tax would let the cash ledger drift from the
70+
# costs table, so we reject it at construction time.
71+
if self.side is Side.BUY and self.stamp_tax != 0:
72+
raise ValueError(
73+
f"stamp_tax on BUY must be 0 (印花税 only on SELL), got {self.stamp_tax}"
74+
)
6875
gross = cash_for_trade(self.quantity, self.price)
6976
expected_amount = gross if self.side is Side.BUY else -gross
7077
if self.amount != expected_amount:

‎src/hqbacktest/domain/order.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,13 @@ def record_fill(
138138
price: Decimal,
139139
at: str,
140140
) -> None:
141-
"""Record a single fill and update running aggregates."""
141+
"""Record a single fill and update running aggregates.
142+
143+
v0.1 always moves orders through ACCEPTED → PENDING before any
144+
fill arrives, so the ACCEPTED branch in the status guard was
145+
unreachable in practice (task 16).
146+
"""
142147
if self.status not in (
143-
OrderStatus.ACCEPTED,
144148
OrderStatus.PENDING,
145149
OrderStatus.PARTIALLY_FILLED,
146150
):

‎src/hqbacktest/engine/broker.py‎

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""SimulatedBroker: market-on-open matching with rule set and cost model.
22
3-
Rules (task 7 + task 8):
3+
Rules (task 7 + task 8 + task 16):
44
* Only `OrderType.MARKET` orders are supported; any other type is
55
rejected by `Context` before reaching the broker.
66
* The bar for `today` is read first: `MissingDataError` (suspended /
77
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.
99
* Each order then goes through `TradingRuleSet.first_denial`; the
1010
first denial short-circuits with a typed `RejectReason`.
1111
* Fees come from `CostModel.compute(order, price, quantity)`; the
@@ -14,6 +14,17 @@
1414
* Ledger-side rejections (`InsufficientCashError`,
1515
`InsufficientSharesError`) are produced by `Portfolio.apply_fill`
1616
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.
1728
"""
1829

1930
from decimal import Decimal
@@ -27,7 +38,7 @@
2738
from ..domain.bar import Bar
2839
from ..domain.enums import EventType, OrderStatus, RejectReason, Side
2940
from ..domain.fill import Fill
30-
from ..domain.money import PRICE_QUANT
41+
from ..domain.money import PRICE_QUANT, quantize_cash
3142
from ..domain.order import Order
3243
from .cost_model import CostModel, DefaultCostModel
3344
from .rule_set import RuleCheckContext, TradingRuleSet
@@ -58,25 +69,50 @@ def match(
5869
5970
`sellable_quantity_for(symbol)` is a callable returning the current
6071
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).
6792
"""
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)
6899
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),
79108
)
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())
80116
return results
81117

82118
# ------------------------------------------------------------------ #

‎src/hqbacktest/engine/context.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -440,18 +440,27 @@ def _create_order(
440440
validate_symbol(symbol)
441441
if not is_positive(Decimal(quantity)):
442442
raise StrategyLifecycleError(f"quantity must be positive, got {quantity}")
443-
lot_aligned = round_lot(quantity, lot_size=LOT_SIZE)
444-
if lot_aligned == 0:
445-
raise StrategyLifecycleError(
446-
f"quantity {quantity} is below one lot of {LOT_SIZE} shares"
447-
)
443+
# Task 16: lot-alignment applies to BUY only. A-share rules allow
444+
# odd-lot SELLs so positions holding non-lot quantities can be
445+
# fully closed; round_lot() on a SELL would silently shrink
446+
# the order (e.g. 150 -> 100), violating the contract that the
447+
# broker sees the exact share count the strategy submitted.
448+
if side is Side.BUY:
449+
lot_aligned = round_lot(quantity, lot_size=LOT_SIZE)
450+
if lot_aligned == 0:
451+
raise StrategyLifecycleError(
452+
f"quantity {quantity} is below one lot of {LOT_SIZE} shares"
453+
)
454+
final_quantity = lot_aligned
455+
else:
456+
final_quantity = quantity
448457
# Guaranteed by _require_orderable in every public order method.
449458
created_session = self._phase
450459
order = Order(
451460
order_id=self._next_order_id(),
452461
symbol=symbol,
453462
side=side,
454-
quantity=lot_aligned,
463+
quantity=final_quantity,
455464
order_type=order_type,
456465
created_at=self._current_date,
457466
created_session=created_session,
@@ -468,7 +477,7 @@ def _create_order(
468477
phase=EventType.ORDER_CREATED,
469478
order_id=order.order_id,
470479
detail=(
471-
f"{side.name} {lot_aligned} {symbol} "
480+
f"{side.name} {final_quantity} {symbol} "
472481
f"(session={created_session.name})"
473482
),
474483
)

‎src/hqbacktest/engine/intents.py‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,26 +51,35 @@ def side_from_quantity(quantity: int) -> Optional[Side]:
5151

5252

5353
def signed_diff_to_lots(target: int, current: int, lot_size: int = 100) -> int:
54-
"""Compute the signed delta between `target` and `current`, lot-aligned.
54+
"""Compute the signed delta between `target` and `current`.
5555
56-
Used by `order_target*` helpers: the resulting value is the share count
56+
BUY deltas (target > current) are floored to the nearest lot (100
57+
shares). SELL deltas (target < current) preserve the requested
58+
share count exactly — A-share rules allow odd-lot SELLs so a
59+
position holding a non-lot multiple can still be flattened. Used
60+
by `order_target*` helpers: the resulting value is the share count
5761
to send through the broker (positive => BUY, negative => SELL).
5862
"""
5963
diff = target - current
6064
if diff == 0:
6165
return 0
62-
sign = 1 if diff > 0 else -1
63-
abs_diff = abs(diff)
64-
lots = abs_diff // lot_size # floor; contract: lot-aligned BUY/SELL
65-
return sign * lots * lot_size
66+
if diff < 0:
67+
# SELL: any positive integer is legal; do NOT lot-round.
68+
return diff
69+
# BUY: floor to nearest lot to avoid requesting fractional shares.
70+
lots = diff // lot_size
71+
return lots * lot_size
6672

6773

6874
def target_quantity_for_value(
6975
target_value: Decimal, price: Decimal, current_quantity: int, lot_size: int = 100
7076
) -> int:
7177
"""Compute the desired `quantity` for a `target_value` position.
7278
73-
`target_value` may be `Decimal("0")` to flatten the position.
79+
`target_value` may be `Decimal("0")` to flatten the position; in
80+
that case the function returns `0` (the caller, e.g.
81+
`Context.order_target_value`, will translate this into a full
82+
flatten via `order_target(symbol, 0)`).
7483
"""
7584
if price <= 0:
7685
raise StrategyLifecycleError(f"price must be positive, got {price}")
@@ -79,7 +88,7 @@ def target_quantity_for_value(
7988
f"target_value must be non-negative, got {target_value}"
8089
)
8190
if target_value == 0:
82-
return current_quantity # flat → no change
91+
return 0 # flatten (per docstring)
8392
target_qty_signed = quantity_from_value(target_value, price, lot_size)
8493
# quantity_from_value already returns a lot-aligned signed count.
8594
# For a target_value (positive), the signed count is positive.

‎tests/engine/test_broker.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,10 @@ def on_bar(self, context, data):
418418
assert portfolio.cash + portfolio.market_value(
419419
{"600000.SH": Decimal("10.0000")}
420420
) == Decimal("99984.00")
421-
# Fees live in `realized_pnl` via the broker + portfolio.apply_fill path.
421+
# Fees do NOT live in `realized_pnl`: per contract §3.1 / rule 8,
422+
# realized_pnl is the gross (sell_price - avg_cost) * quantity and
423+
# stays independent of commission / stamp_tax / other_fee. Fees flow
424+
# through `cash` only.
422425
fills = [e for e in engine.event_log.all() if e.phase is EventType.ORDER_FILLED]
423426
assert len(fills) == 3
424427

‎tests/engine/test_intents.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ def test_signed_diff_to_lots_buy():
5252

5353

5454
def test_signed_diff_to_lots_sell():
55-
# Need to go from 300 to 50; diff = -250, lot-rounded = -200.
56-
assert signed_diff_to_lots(50, 300) == -200
55+
# Need to go from 300 to 50; diff = -250. Task 16: SELL preserves
56+
# the requested share count (odd-lot SELLs allowed per A-share rules),
57+
# so the result is -250, not lot-rounded -200.
58+
assert signed_diff_to_lots(50, 300) == -250
5759

5860

5961
def test_signed_diff_to_lots_no_change():
@@ -70,10 +72,12 @@ def test_target_quantity_for_value_positive():
7072
)
7173

7274

73-
def test_target_quantity_for_value_zero_returns_current():
75+
def test_target_quantity_for_value_zero_returns_zero_per_docstring():
76+
# Task 16: per the function's docstring ("may be 0 to flatten"), a
77+
# zero target returns 0 — the caller flattens via order_target().
7478
assert (
7579
target_quantity_for_value(Decimal("0"), Decimal("12.50"), current_quantity=300)
76-
== 300
80+
== 0
7781
)
7882

7983

0 commit comments

Comments
 (0)