Repository navigation
fix(trade): scale BUY fills down to available cash instead of rejecting - #86
Conversation
SimBroker rejected a whole market BUY when its cost exceeded available cash at fill time. Orders are sized from the signal bar's close but fill at a later price, and TopNRanking targets sum to 1.0, so fully invested rotations routinely dropped their last BUY leg and left the target position empty until the next rebalance (17 of 87 orders on a production 3-ETF rotation spec). - SimBroker(insufficient_cash_policy="scale_down" | "reject"), default scale_down: fill the largest whole-lot quantity the cash covers, including fees; reject only when not even one lot is affordable. - Engine passes its lot size to the broker via set_lot_size(). - OrderBook.fill(shares=...): the managed order keeps the requested quantity, the Fill carries the filled one; scaled fills are marked status_reason=scaled_to_available_cash. - Spec: execution.insufficient_cash_policy, validated and recorded in compiled_plan.json, execution_assumptions.json and strategy.py. - Reproducibility: the default is left out of compute_hash() and back-filled when normalizing older compiled plans, so run directories written before this change still audit clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@codex review |
|
审查结论:建议 Request changes。发现 1 个阻塞问题和 1 个兼容性缺口。 [P1] 旧产物被错误解释为新的 位置:
旧版本缺少该字段时,实际行为是
实际复现结果: 即相同 spec、相同 hash、审计可通过,但成交结果不同。这破坏了 建议将旧计划缺失字段解释为 [P2] 工具入口无法恢复旧的 位置:
建议为相关工具参数、broker factory 和测试补齐该字段。 验证结果:
|
Linked Issue
No issue filed; found while reconciling a production XQuant spec replica against its legacy implementation.
Linked Docs / Plans
agent/skills/configure-trade-execution/SKILL.md: documentsexecution.insufficient_cash_policydocs/architecture.md: spec example updatedRequirement Background
SimBroker.fill_market_ordersrejected a market BUY outright whenever its cost exceeded available cash at fill time.Orders are sized from the signal bar's close but fill at a later price (
next_open,next_hl2, ...).TopNRankingtargets sum to 1.0 with no cash buffer. So on a fully invested rotation, the last BUY leg is often a few units short of cash and the whole order is dropped. The target position then stays empty until the next rebalance, and the next attempt must buy the full weight in one order, so it tends to fail again.Observed on a production spec (3-ETF rotation, 10-day rebalance,
next_hl2, lot size 100, 2025-01-02 to 2026-09-28): 17 of 87 orders were rejected withinsufficient_cash, several in a row for the same symbol. Signals were identical to a legacy implementation that scales buys to available cash, yet cumulative return was +65.3% vs +80.4%.Design
SimBroker(insufficient_cash_policy=...), default"scale_down": when a BUY costs more than available cash, fill the largest whole-lot quantity the cash covers, fees included (binary search over lot counts; cost is monotonic in shares). Reject withinsufficient_cashonly when not even one lot is affordable."reject"keeps the previous all-or-nothing behavior.Enginecallsbroker.set_lot_size(self._lot_size)each bar, next toset_current_date, so there is one source of truth and every broker construction path (compile_run,tools/engine) picks it up.OrderBook.fill(..., shares=None): theManagedOrderkeeps the requested quantity; the returnedFillcarries the filled quantity. Soorders.csvshowsshares(requested) andfilled_shares(actual), and scaled orders are markedstatus_reason=scaled_to_available_cash._apply_fillalready readsfill.order.shares, so portfolio and broker cash stay consistent.execution.insufficient_cash_policy, validated byinsufficient_cash_policy_invalidand recorded incompiled_plan.json,execution_assumptions.json, and the generatedstrategy.py.Design Constraints
audit_reproducibilityfailspec_hashandcompiled_plan_consistencyfor runs written before this change. Following the existing precedents (defaultmetricsis dropped from the hash;suspension_policy/lag_barsare back-filled in plan normalization):compute_hash()omits the field when it is the defaultscale_down. An explicitrejectstill changes the hash._normalize_compiled_plan_material_fieldsback-fillsexecution.insufficient_cash_policy = "scale_down"for older plans.Data Flow
strategy_spec.yaml(execution.insufficient_cash_policy) →_parse_execution→validate→compile_run→SimBroker(insufficient_cash_policy=...);Engine.step→broker.set_lot_size;fill_market_orders→_affordable_buy_shares→OrderBook.fill(shares=...)→Fill→_apply_fill→orders.csv/trades.csv/ equity curve.Non-goals
lot_size_config.by_symbolstays non-executable).Compatibility
execution.insufficient_cash_policy: rejectto keep the old fills.6af7fa77with this branch:pass(it failed both hash checks before the compatibility fix).SimBrokerconstructor: new keyword argument with a default; existing callers are unaffected.OrderBook.fill: new optional keyword argument; existing callers are unaffected.Verification
strategy.py, legacy hash and legacy plan compatibility). Every test asserting new behavior was run red before its implementation. The guard tests (valid values accepted, explicitrejectchanges the hash, a legacy plan does not match arejectplan) already held and are there to lock the boundary. The existing gap-up rejection test now passesinsufficient_cash_policy="reject"explicitly.tests/spec tests/trade tests/core tests/audit: 792 passed.tests/operators/test_installed_certification.py::test_installed_wheel_certifies_without_source_checkout, also fails onmainin the same environment, so it predates this change. (tests/agent/test_round_21_monitor_contracts.pyshells out touv run oxqand passes whenuvresolves the test environment.)ruff check src testsandmypyon the touched modules: no findings beyond those already onmain.6af7fa77)Target weights are byte-identical before and after. On the window where the legacy implementation uses the same market-value NAV method (2026-03-02 onward), the median absolute daily return difference is 0.0008% (max 0.04%). The remaining gap before that window comes from a legacy-side NAV formula change that was never back-filled, not from this engine.
🤖 Generated with Claude Code