diff --git a/docs/adr/0001-kernel-adapter-boundary.md b/docs/adr/0001-kernel-adapter-boundary.md index 92fcbf997..9a948410c 100644 --- a/docs/adr/0001-kernel-adapter-boundary.md +++ b/docs/adr/0001-kernel-adapter-boundary.md @@ -213,8 +213,8 @@ kernel and own these quirks, each at its site: - **Seams the adapter overrides.** The kernel is driven, not patched. `PineStrategyHost` overrides `resolve_execution_terms` (`pine_strategy_host.cpp:1179`) for TV sizing and fill spelling; `validate_execution_precommit` (`pine_strategy_host.cpp:1204`), whose - `validate_precommit` (`pine_adapter.cpp:16988`) returns `AdmitWithHostMargin` - (`pine_adapter.cpp:17223`) to own the opening margin decision; `on_native_tick` + `validate_precommit` (`pine_adapter.cpp:17004`) returns `AdmitWithHostMargin` + (`pine_adapter.cpp:17239`) to own the opening margin decision; `on_native_tick` (`pine_strategy_host.cpp:898`) / `on_native_applied` (`pine_strategy_host.cpp:970`) for `calc_on_order_fills` re-entry. It does not own lot excursions: since the adapter state consolidation it keeps the kernel's `owns_lot_excursions()` default, so the kernel's sampler books every Pine lot's @@ -260,8 +260,8 @@ kernel and own these quirks, each at its site: (`select_reservation_growth_sources` src/compat/pine/reservation_expansion.cpp:9). - **Margin.** The kernel owns the margin *mechanism* — the level solve, the check points, the kernel request, its re-pricing, the receipt — and the adapter answers its three policy hooks - with TradingView's: `margin_check_allowed` (`pine_adapter.cpp:18449`) for the scheduling, - `resolve_margin_requirement` (`pine_adapter.cpp:18518`) for the ten-significant-digit money, + with TradingView's: `margin_check_allowed` (`pine_adapter.cpp:18465`) for the scheduling, + `resolve_margin_requirement` (`pine_adapter.cpp:18534`) for the ten-significant-digit money, and `resolve_margin_call_units` for the lot-floored 4x restore. That hook answers every call on purpose (the margin and cancellation comparison): TradingView floors the restore onto the lot grid before the multiple, so on a gridded tape the kernel's own `ShortfallMultiple 4` books 5 lots where TradingView @@ -272,7 +272,7 @@ kernel and own these quirks, each at its site: margin since F7), while the roll point measures at the remaining path's adverse mark (MG-FX: 0.4116 @ 97 there against TradingView's 0.3996 @ 100). What has no kernel check point at all stays adapter-side: the `process_orders_on_close` chronology exception - (`non_pooc_commissioned_short` `pine_adapter.cpp:24835`) and + (`non_pooc_commissioned_short` `pine_adapter.cpp:24851`) and the 1x-long money call — plus the TV admission scopes (`explicit_pair_scope` `src/compat/pine/market_admission.cpp:34-47`) and the review fold they feed (`awaits_pair_review` `:68-73`, `fold_admission_history` `:83-140`). @@ -715,8 +715,8 @@ Every line number in this section names its symbol on this tree, which | `NativeRunSpec` field | ruling | what the adapter runs instead | measurement and ruling of record | executed native consumers | |---|---|---|---|---| | `price_grid`, `grid_rounding` | **native-only** | TradingView's per-order-kind tick rules on top of `None`: `source_trigger_threshold` (`pine_adapter.cpp:471`), `source_level_on_price_grid` (`:454`), `nearest_tick` (`:361`) / `source_bar_fill_tick` (`:382`) / `directional_tick` (`:414`), behind the terms seam | R5-3 and design risk E7 ruled `None` for the adapter before the lane ran; lanes R7 and N13 measured the alternative anyway (raw levels submitted, `QuantizeFillsAndTriggers` with `HalfUp` declared). L8b closed the first blocker (no run aborts). The second has no remedy on either side: TradingView quantizes per order kind (stop and limit legs and a trail's activation on the quantized bar; the trail stop, the running best, stop-limit entries and the `calc_on_order_fills` cursors raw), the grid is one rule for the run, and the old N13 trial moved 30 pinned checks in 4 units; this is historical evidence, not a current-head result; a per-kind mask would spell that inconsistency into the kernel. The corpus cannot arbitrate (every probe runs a 0.01 tick on an on-grid feed; 5 of 312 differ in an engine-only column). Permanent witness: `tests/test_adapter_grid_relower.cpp`, whose section 5 pins the trail stop the grid fires a bar early. Design row PG and §3.6 | `examples/native/native_price_grid_strategy.cpp`, `examples/native/native_price_grid_c.c`, the private test `test_native_price_grid` | -| `risk` | **native-only** | all of `strategy.risk.*` but the direction: `update_risk_state` (`pine_adapter.cpp:18102`), `SourceDayLedger`, `submit_intraday_loss_close` (`pine_adapter.cpp:21479`), `chart_day_key` (`pine_adapter.cpp:17999`), `compat::pine::IntradayCap` with `IntradayOrderBudget` (337 lines) | Structural first: Pine's risk calls are per-bar statements, so a limit reaches the adapter on script bar 0, after `project()` (`pine_strategy_host.cpp:678`) and the kernel's `configure` (`pine_strategy_host.cpp:694`) have fixed and digested the spec. In substance (the risk-lowering comparison, `tests/test_adapter_risk_relower.cpp`, 62 checks over nine paired scenarios): the drawdown latch samples at the close only and still admits a reversal; the loss-day streak counts trades, not days; the intraday loss closes at the path's adverse extreme, refuses every placement and withdraws the book; the fill cap charges slots, transfers quota and closes at the bar's better extreme on the chart timezone's day. With the kernel seeded on the corpus, 3 of 4 cap probes diverge (3840 of 3916, 312 of 604, 2370 of 2384 rows; re-derived by `scripts/check_seeded_risk_experiment.sh` since the kernel/adapter differential study). `strategy.risk.allow_entry_in` is the one rule that is the kernel's already (`allowed_open_directions`). Design §3.6 | `examples/native/native_risk_limits_strategy.cpp`, `examples/native/native_trail_risk_strategy.cpp`, `tests/test_native_risk_limits.cpp`, `tests/test_native_c_api.c` | -| `max_abs_units` | **adapter-policy** | `strategy.risk.max_position_size` as a gate on the LIVE book before the fill (`max_position_size` `pine_adapter.cpp:17108`): an entry is refused once the book already holds the limit | design row MG2 (R5-1): the resulting-book cap is the generic one. Measured by N12's scenario `PS` in `tests/test_adapter_risk_relower.cpp`: two-unit entries against a limit of 3 leave the adapter at 4 and the kernel cap at 2 | `tests/test_native_resting_matching_contract.cpp`, `tests/test_native_run_spec.cpp` | +| `risk` | **native-only** | all of `strategy.risk.*` but the direction: `update_risk_state` (`pine_adapter.cpp:18118`), `SourceDayLedger`, `submit_intraday_loss_close` (`pine_adapter.cpp:21495`), `chart_day_key` (`pine_adapter.cpp:18015`), `compat::pine::IntradayCap` with `IntradayOrderBudget` (337 lines) | Structural first: Pine's risk calls are per-bar statements, so a limit reaches the adapter on script bar 0, after `project()` (`pine_strategy_host.cpp:678`) and the kernel's `configure` (`pine_strategy_host.cpp:694`) have fixed and digested the spec. In substance (the risk-lowering comparison, `tests/test_adapter_risk_relower.cpp`, 62 checks over nine paired scenarios): the drawdown latch samples at the close only and still admits a reversal; the loss-day streak counts trades, not days; the intraday loss closes at the path's adverse extreme, refuses every placement and withdraws the book; the fill cap charges slots, transfers quota and closes at the bar's better extreme on the chart timezone's day. With the kernel seeded on the corpus, 3 of 4 cap probes diverge (3840 of 3916, 312 of 604, 2370 of 2384 rows; re-derived by `scripts/check_seeded_risk_experiment.sh` since the kernel/adapter differential study). `strategy.risk.allow_entry_in` is the one rule that is the kernel's already (`allowed_open_directions`). Design §3.6 | `examples/native/native_risk_limits_strategy.cpp`, `examples/native/native_trail_risk_strategy.cpp`, `tests/test_native_risk_limits.cpp`, `tests/test_native_c_api.c` | +| `max_abs_units` | **adapter-policy** | `strategy.risk.max_position_size` as a gate on the LIVE book before the fill (`max_position_size` `pine_adapter.cpp:17124`): an entry is refused once the book already holds the limit | design row MG2 (R5-1): the resulting-book cap is the generic one. Measured by N12's scenario `PS` in `tests/test_adapter_risk_relower.cpp`: two-unit entries against a limit of 3 leave the adapter at 4 and the kernel cap at 2 | `tests/test_native_resting_matching_contract.cpp`, `tests/test_native_run_spec.cpp` | | `max_open_lots` | **adapter-policy** | Pine pyramiding is a per-cycle entry count in the adapter's command policy; a resting source entry must not consume a physical-lot cap before it fills, so `project()` leaves the cap unset (the `max_open_lots` setting spelling is at `pine_adapter.cpp:2835`) | design row MG3 (R5-1); the contract comment in `project()`. Measured against TradingView by the kernel/adapter differential study (the private test `test_pyramiding_count_differential`, 15 scenarios on three `lab tv` tapes): TradingView checks an entry once, at its first eligible point, against the trades then open, and does not check a resting entry again at its fill; the kernel's cap books 14 of the 15 as TradingView does, the adapter's per-cycle count 13 (it keeps a slot a close or another entry's exit drained; since the resting-entry signal-state correction a resting LIMIT entry no longer counts, and one the count admitted fills over the cap, and since the residual-margin correction's SB the later market entries of a flat bar are refused at the fill once the book holds the cap, as on TradingView's tapes). A recorded divergence: the retention stands until a re-lowering decision. The adapter state consolidation tried the lowering and kept the count: on the kernel's cap four corpus probes moved away from TradingView, for want of the first-eligible-point rule (the kernel/adapter differential study's P4b); it fixed the reservation divergence measured beside it (P10), so a default `strategy.exit(from_entry)` under FIFO now reserves its entry's own quantity, the oldest lots first, as TradingView does | `tests/test_native_resting_matching_contract.cpp`, `tests/test_native_margin_model.cpp` | | `initial_margin_fraction` | **adapter-policy** | TradingView's ten-significant-digit money admission against the signal-time tuple, answered as `AdmitWithHostMargin`; the `margin` model the adapter does declare is maintenance-only (`NativeMarginModel` `pine_adapter.cpp:3074`, `margin.maintenance_long` `pine_adapter.cpp:3089`) | design row MG4 and the wave-4 ruling recorded in `project()`: a positive initial requirement would decline openings TradingView takes. Measured both ways by the kernel/adapter differential study (the private test `test_adapter_margin_schedule_differential`, M11): the kernel's gate declines a 1x long whose entry fee the adapter admits (`InitialMargin`), and admits a gap-up add the adapter refuses against its signal-time equity; `ab9714be` books the adapter's answer both times | `tests/test_native_precommit_view.cpp`, `tests/test_native_margin_model.cpp` | | `report_open_position_at_end` | **adapter-policy** | The Pine host owns TradingView's range-end shape: batch re-marks the stored final point and re-folds extremes, while REALTIME retains ordinary marks and `PineStrategyHost::present_report` re-marks only the caller-owned copy before generic metric derivation; the stream hash excludes that terminal extreme fold (`scheduler_record_range_end` pine_strategy_host.cpp:2261): report shape, not a mark-to-market row (`KernelRecordedAtHostMarks` pine_adapter.cpp:3044) | design row RP5; the kernel reads the field under `KernelRecorded` only, which `scripts/check_adapter_spec_shadowing.py` gates | `examples/native/native_sized_report_strategy.cpp`, `tests/test_native_report_truth.cpp` | diff --git a/docs/design/arena-bound.md b/docs/design/arena-bound.md index 16e12230b..40ae84dda 100644 --- a/docs/design/arena-bound.md +++ b/docs/design/arena-bound.md @@ -344,7 +344,7 @@ indexPeak(k) = 24 * slots(k) # k > 32 | Entry-rank AVL | (60+L)*J + 1[J>0] | `exit_rank_keys_` at `src/source/pine_adapter.cpp:6773-6774`; `Links` at `include/pineforge/detail/disk_id_table.hpp:165-182`, `end_` at `:348` | | Erased minima data | (40+L)*D + (40+2*L)*Q | `erased_minima_` at `include/pineforge/source/pine_adapter.hpp:960`; 40-byte header at `include/pineforge/detail/id_pair_store.hpp:69` | | Erased minima index | indexPeak(D+Q) | 16-byte slot at `include/pineforge/detail/id_pair_store.hpp:69`; replacement at `:642` | -| Consumed partial-cycle data | (41+2*L)*Q | `include/pineforge/source/pine_adapter.hpp:2834`; joined key at `src/source/pine_adapter.cpp:25477` | +| Consumed partial-cycle data | (41+2*L)*Q | `include/pineforge/source/pine_adapter.hpp:2834`; joined key at `src/source/pine_adapter.cpp:25493` | | Consumed partial-cycle index | indexPeak(Q) | `include/pineforge/detail/id_pair_store.hpp:642` | | Ordered consumed-cycle fold | 136*leaves + 2088*branches + 2096*savedPath | `include/pineforge/detail/id_pair_ordered_fold.hpp:161`, `:165`, `:172`, size assertion for `Leaf`, `Branch` and `Snapshot` at `:183` | | Successor-root rows | 16*R | `src/native_order.cpp:8220`, row assertion for `Row` at `:8141`, 64-row tail bound `kTailRows` at `:8146` | @@ -381,7 +381,7 @@ root only when a newly issued definition has one. The 64-row RAM tail makes charging every issued row conservative. Partial closes create one trade row per affected lot: `src/engine_execution.cpp:1241`. Bracket definitions have limit, stop and trailing families; per-entry expansion is visible at -`src/source/pine_adapter.cpp:23440`. A closed-trade count cannot bound +`src/source/pine_adapter.cpp:23456`. A closed-trade count cannot bound unfilled placement history or the replacement-root history. The ordered fold retains distinct-ever keys, including erased keys. diff --git a/docs/design/native-feature-parity.md b/docs/design/native-feature-parity.md index 51670ce6a..2242e4750 100644 --- a/docs/design/native-feature-parity.md +++ b/docs/design/native-feature-parity.md @@ -58,8 +58,8 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa | OL9 | Birth eligibility: an order accepted at bar N cannot fill on N's already-delivered points | **yes** — `point_eligible` native_order.hpp:880-884, the generic birth record `OrderBirth` include/pineforge/order_birth.hpp:59, contract `docs/pages/native-engine.md` ("Acceptance is not a fill") | K+A | — | F:A13 O:A10 S:R4 | **Disagree:** F yes / O partial / S "no, TV quirk". R5-1: the generic core (birth cursor) is native; TV's bar-granular first-bar rule is the A half → OL14. | | OL10 | TP/SL bracket per entry, armed before the entry fills, OCA between legs (`exit()` pine_adapter.cpp:11527; lowering `submit_one` pine_adapter.cpp:12443, `BindCohort` pine_adapter.cpp:12906, `submit_leg` pine_adapter.cpp:12199; lifecycle `select_exit_suspension` exit_lifecycle.cpp:10-66) | **partial** — every primitive is native (`WaitForApplied{parent}` native_order.hpp:457-459, `Reduce{OwnerOpenedUnits}` native_order.hpp:241-245, `Member{…, Cancel}` native_order.hpp:527-534, `waiting_children` native_order.hpp:2173); no builder **Closed:** the builder is `native_toolkit::submit_bracket(host, BracketSpec)` — take-profit, stop-loss and trail as one parent's `WaitForApplied` children in one OCA group, with `anchor_rounding` and `visibility` knobs — and it adds no kernel behaviour (`tests/test_native_toolkit_bracket.cpp`). | K (toolkit) | L7 | F:A10 O:A8 S:O6 | **Disagree:** F "yes (primitives)" + toolkit / S partial + new kernel bracket object, lane size L and prerequisite for everything. R5-2: builder over the existing primitives, size M. | | OL11 | Bracket levels relative to the parent's fill (profit / loss ticks; derived `profit_ticks` pine_adapter.cpp:11792; `materialize_relative_exits` pine_adapter.cpp:14528, pine_adapter.hpp:2523) | **partial** — composable: the host computes levels in `on_native_applied` and submits; suffix eligibility makes that same-bar correct `born_on_remaining_path` native_execution_consumer.cpp:6893. `Trigger` itself is absolute-only native_order.hpp:317 **Closed:** `Request::anchor = FromOwnerFill{offset, ticks, rounding}` (`TriggerAnchor` native_order.hpp:357) defers the level to the owner's fill, the kernel materializes it once at the arm under `NativeAnchorRounding`, and `resolve_anchored_level` native_host.hpp:1032 is where a source language's own level arithmetic lives (`tests/test_native_anchored_legs.cpp`, `tests/test_native_arm_options.cpp`). | K | L7 | F:A11 O:A11 S:O6 | **Disagree:** F partial (toolkit is enough) / O no (kernel `TriggerAnchor`). R5-2: L7 delivers relative anchors; kernel anchor primary, toolkit materialization is the zero-kernel fallback. | -| OL12 | Working-order view for the host and outside observers (`strategy_pending_order_*`; `PendingIntentView` pine_adapter.hpp:1615-1631; the POD is filled only by the adapter `pine_birth_reach` pine_adapter.cpp:27809) | **no** — base returns 0 / -1 `observe_last_bar_dual_entry_path_v1` engine_consumer.cpp:196-205; the POD is TV-shaped (`tv_carry_qty` pending_order_mirror.hpp:73, `pine_birth_reach` pending_order_mirror.hpp:135) **Closed for a bare host:** `native_working_requests()` native_host.hpp:1283 returns one owning `NativeWorkingRequest` native_host.hpp:701 per live request — definition, remaining, trigger state — with the C spelling `strategy_native_working_len_v1` / `_get_v1`. The TV-shaped POD stays frozen and stays the source layer's projection (`tests/test_native_limit_fill_through.cpp`, `tests/test_native_arm_options.cpp`, `tests/test_native_c_api.c`). | K+A | L7 (C++), L13 (C) | F:J5 O:J6 S:X4 | **Disagree:** O "partial + TV-shaped" / F, S no. A bare host reads zeros → no. R5-1: neutral view → kernel; POD shape and Pine journal retention stay A (POD frozen, §2.ii j). | -| OL13 | *(quirk)* Same-bar command batching and the seven deferred queues (`pending_bracket_legs_` pine_adapter.hpp:2799); KI-62 open-order priority (`select` order_priority.cpp:13-60, selected `update_l4c_priority` pine_adapter.cpp:1518); per-bar priced-entry throttle, BUY-before-SELL batch, short-seed plan / collision (`activate_short_seed_plan_at_open` pine_adapter.cpp:4676, `materialize_snapshot` pine_adapter.cpp:14502), reversal-gap bracket policy (`ShortSeedPlan` pine_adapter.hpp:1504-1520, `DeferredOpenMarketableSell` pine_adapter.hpp:2106, `CloseCallsiteState` pine_adapter.hpp:2244; `apply_reversal_gap_bracket_policy` pine_adapter.cpp:22239, `reaccept_gapped_bracket_behind_same_id_add` pine_adapter.cpp:22473) | n/a — native priority is acceptance / incarnation order `native_events` native-engine.md:676-678 | A | — | F:A15 O:A15 S:O9 S:R3 | R5-1 names KI-62. S: the kernel exposes deterministic insertion order only. | +| OL12 | Working-order view for the host and outside observers (`strategy_pending_order_*`; `PendingIntentView` pine_adapter.hpp:1615-1631; the POD is filled only by the adapter `pine_birth_reach` pine_adapter.cpp:27825) | **no** — base returns 0 / -1 `observe_last_bar_dual_entry_path_v1` engine_consumer.cpp:196-205; the POD is TV-shaped (`tv_carry_qty` pending_order_mirror.hpp:73, `pine_birth_reach` pending_order_mirror.hpp:135) **Closed for a bare host:** `native_working_requests()` native_host.hpp:1283 returns one owning `NativeWorkingRequest` native_host.hpp:701 per live request — definition, remaining, trigger state — with the C spelling `strategy_native_working_len_v1` / `_get_v1`. The TV-shaped POD stays frozen and stays the source layer's projection (`tests/test_native_limit_fill_through.cpp`, `tests/test_native_arm_options.cpp`, `tests/test_native_c_api.c`). | K+A | L7 (C++), L13 (C) | F:J5 O:J6 S:X4 | **Disagree:** O "partial + TV-shaped" / F, S no. A bare host reads zeros → no. R5-1: neutral view → kernel; POD shape and Pine journal retention stay A (POD frozen, §2.ii j). | +| OL13 | *(quirk)* Same-bar command batching and the seven deferred queues (`pending_bracket_legs_` pine_adapter.hpp:2799); KI-62 open-order priority (`select` order_priority.cpp:13-60, selected `update_l4c_priority` pine_adapter.cpp:1518); per-bar priced-entry throttle, BUY-before-SELL batch, short-seed plan / collision (`activate_short_seed_plan_at_open` pine_adapter.cpp:4676, `materialize_snapshot` pine_adapter.cpp:14502), reversal-gap bracket policy (`ShortSeedPlan` pine_adapter.hpp:1504-1520, `DeferredOpenMarketableSell` pine_adapter.hpp:2106, `CloseCallsiteState` pine_adapter.hpp:2244; `apply_reversal_gap_bracket_policy` pine_adapter.cpp:22255, `reaccept_gapped_bracket_behind_same_id_add` pine_adapter.cpp:22489) | n/a — native priority is acceptance / incarnation order `native_events` native-engine.md:676-678 | A | — | F:A15 O:A15 S:O9 S:R3 | R5-1 names KI-62. S: the kernel exposes deterministic insertion order only. | | OL14 | *(quirk)* Exit activation / suspension / revival barriers, historical birth reach (`select_exit_activation` exit_activation.cpp:44-90, enforced `leg_activation` pine_adapter.cpp:1415; `select_exit_activation` exit_activation.cpp:20-90, `select_historical_birth_reach` order_birth.cpp:5-14, `capture_order_birth` pine_adapter.cpp:1234) | n/a | A | — | S:R4 O:A10 | TV half of OL9. **Measured (the execution-rate and clock correction, the cancelled-origin retirement):** a margin-call revival never restores an exit the script cancelled: `strategy.cancel` / `cancel_all` retire every leg of each exit row they name — the live ones they take off the book (caused at the Cancelled event, after anything the same callback did to the leg), the dormant ones, and the ones the adapter took off the book itself — and drop a gapped stop a declined reversal parked for the next margin-call slice (`retire_cancelled_exits` pine_adapter.cpp:2286); the revival skips a withdrawn exit (`revive_brackets_after_margin` pine_adapter.cpp:2377). `tests/test_adapter_margin_revival_cancel.cpp` replays seven lab tv tapes on NYSE:F 15m; these numbers illustrate the sequences synthetically: badapter-v19dp1-control (no cancel: a 20-share margin call, then 980 closed through X) and v19fix-gapped-stop-control (X's stop at 25.15, gapped by a later open and parked: the same close), and badapter-v19dp1-cancel, v19fix-cancel-before-pair, v19fix-cancel-at-1000, v19fix-gapped-stop-cancel and v19fix-gapped-stop-cancel-after-pair (a cancel while X is dormant, before the reversal pair or while X is live, and before and after the pair while X is parked; synthetic cancel book: margin calls 20 @25.39 and 80 @25.57, 900 open). Untaped, the same row holds the tapes' trades for `strategy.cancel_all` on the later bar and before the pair and for `strategy.cancel` after the pair, and on the same bars with no quantity grid — where the pair holds X in the cancel's own callback — every spelling books what the cancel on the dormant exit books | ### 1.2 Trailing @@ -86,7 +86,7 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa | SZ8 | Fractional reduce of the position / a cohort (`quantize_close_units` pine_adapter.cpp:5571, `compute_exit_reservation` pine_adapter.cpp:5611) | **no** — sizes are `ExplicitUnits` / `OwnerOpenedUnits` native_order.hpp:212-218 / `HostSized` native_order.hpp:94-97 only **Closed:** `Reduce{ScopeFraction{fraction, claim, basis}}` takes a fraction in `(0, 1]` of the scope the reduce is bound to — the book, one opening, or a cohort roster (`tests/test_native_sizing_bases.cpp`). | K | L3 | F:A7 O:A12 O:C4 S:S5 | F's "partial" counts the units-reduce of OL2. R5-4 → L3. | | SZ9 | Exit-quantity reservation against sibling exits (`compute_exit_reservation` pine_adapter.cpp:5611, `reserved_exit_qty` pine_adapter.cpp:12162) | **partial** — `PointBudget` native_order.hpp:400-403 and group `Reduce` native_order.hpp:241 give a generic reservation; no net-of-siblings claim for fraction sizes **Closed:** `ScopeClaim::NetOfSiblings` subtracts the units already claimed by live sibling reduces bound to the same scope, and `ScopeBasis::{AtMatch, AtAcceptance}` chooses when that scope is measured (`tests/test_native_sizing_bases.cpp`). | K+A | L3 | F:A14 S:S5 S:O10 O:A12 | F proposed nothing. R5-4: generic half → L3; the Pine ledger → SZ11, SZ12. | | SZ10 | *(quirk)* Ten-significant-digit money rule (`source_money_round` pine_adapter.cpp:645, `source_money_floor_lot` pine_adapter.cpp:745; twin `tv_money_round` pine_policy_support.hpp:9-15); exact percent-exit rounding (`quantize_percent_exit_units` pine_adapter.cpp:5593) | n/a — kernel money is plain binary64 `marked_equity` engine_execution.cpp:1753 | A | — | O:C8 F:C3 S:M4 S:S5 | R5-1 | -| SZ10a | *(quirk, measured retention)* The money band's affordable price, `sig10(sig10(E) / notional per unit of price)` against the sizing price — at placement (`entry`, `affordable_price` pine_adapter.cpp:8951), in the paired-reversal whole drop (`close`, `affordable_price` pine_adapter.cpp:10494), at the terms boundary (`resolve_terms`, `affordable_price` pine_adapter.cpp:16794), in the precommit money scopes (`validate_precommit`, `affordable_price` pine_adapter.cpp:17364, pine_adapter.cpp:17417) and in the carried-money check (`apply_reversal_gap_bracket_policy`, `affordable_price` pine_adapter.cpp:22287) | n/a — not a units conversion. Every money-to-units conversion in the adapter is the kernel's `native_sized_units` under the source's floors since the execution-rate and clock correction: the default at placement and at the fill (`default_sizing_units` pine_adapter.cpp:3404), the typed per-call quantity at placement, at the fill and in the probe (`typed_quantity_units` pine_adapter.cpp:3423, `typed_entry_units` pine_adapter.cpp:3441), and the queued-order fill-quantity probe (`probe_fill_qty` pine_adapter.cpp:27388) | A | — | the affordable-price review | **Measured:** the quotient is TradingView's whole-drop PRICE, pinned by 445 famr3 tapes and 202 famr tapes (two tests that live in the private fixtures); on a seven-digit tie (E 9998.99999996, 10 000 lots at tick(close) 0.9999) the rule admits while the kernel's conversion buys 9999.99999996 lots and binary64 gives E/Q 0.999899999996 (`tests/test_adapter_fill_qty_probe.cpp`, section 2). | +| SZ10a | *(quirk, measured retention)* The money band's affordable price, `sig10(sig10(E) / notional per unit of price)` against the sizing price — at placement (`entry`, `affordable_price` pine_adapter.cpp:8951), in the paired-reversal whole drop (`close`, `affordable_price` pine_adapter.cpp:10494), at the terms boundary (`resolve_terms`, `affordable_price` pine_adapter.cpp:16810), in the precommit money scopes (`validate_precommit`, `affordable_price` pine_adapter.cpp:17380, pine_adapter.cpp:17433) and in the carried-money check (`apply_reversal_gap_bracket_policy`, `affordable_price` pine_adapter.cpp:22303) | n/a — not a units conversion. Every money-to-units conversion in the adapter is the kernel's `native_sized_units` under the source's floors since the execution-rate and clock correction: the default at placement and at the fill (`default_sizing_units` pine_adapter.cpp:3404), the typed per-call quantity at placement, at the fill and in the probe (`typed_quantity_units` pine_adapter.cpp:3423, `typed_entry_units` pine_adapter.cpp:3441), and the queued-order fill-quantity probe (`probe_fill_qty` pine_adapter.cpp:27404) | A | — | the affordable-price review | **Measured:** the quotient is TradingView's whole-drop PRICE, pinned by 445 famr3 tapes and 202 famr tapes (two tests that live in the private fixtures); on a seven-digit tie (E 9998.99999996, 10 000 lots at tick(close) 0.9999) the rule admits while the kernel's conversion buys 9999.99999996 lots and binary64 gives E/Q 0.999899999996 (`tests/test_adapter_fill_qty_probe.cpp`, section 2). | | SZ11 | *(quirk)* Same-bar `strategy.close` callsite batching, two-call provenance, entry-id ledger (`enqueue_pooc_fifo_close` pine_adapter.cpp:9616; ledger `close_batch_bar_` pine_adapter.hpp:2872; the kernel's comment-only residue was deleted by the detached-comment and mask cleanup and the dead-path cleanup) | n/a | A | — | S:O8 F:A15 O:A15 | **Disagree:** S "generic → kernel" (its the generic replacement design `ReplaceByKey` / `Fifo`). R5-4: adapter. | | SZ12 | *(quirk)* POOC reservation-growth market-entry population predicate (`select_reservation_growth_sources` src/compat/pine/reservation_expansion.cpp:9-34, called from the adapter, `select_reservation_growth_sources` pine_adapter.cpp:1476) | n/a | A | — | S:O10 | (single-source) R5-4 confirms S's own split: concept → SZ9, predicate A. | @@ -95,21 +95,21 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa | ID | Feature (adapter mechanism) | Native today | Class | Lane | Sources | Notes | |---|---|---|---|---|---|---| | MG1 | Allowed opening directions (`allowed_open_directions` pine_adapter.cpp:3048) | **yes** — `allowed_open_directions` native_run_spec.hpp:665 → `MatchRejectReason::OpeningDirection` native_execution_consumer.cpp:3939 | K | — | F:D4 O:D7 S:M1 | the one risk rule the adapter delegates (O) | -| MG2 | Max absolute position size | **yes** — `max_abs_units` native_run_spec.hpp:663 tests the resulting book native_execution_consumer.cpp:3946 | K+A | — | F:D5 O:D5 S:M1 S:M5 | **Disagree:** O, S:M5 partial. R5-1: the generic cap is native; the adapter's live-position `>= held` variant (`max_position_size` pine_adapter.cpp:17108) is TV. | +| MG2 | Max absolute position size | **yes** — `max_abs_units` native_run_spec.hpp:663 tests the resulting book native_execution_consumer.cpp:3946 | K+A | — | F:D5 O:D5 S:M1 S:M5 | **Disagree:** O, S:M5 partial. R5-1: the generic cap is native; the adapter's live-position `>= held` variant (`max_position_size` pine_adapter.cpp:17124) is TV. | | MG3 | Open-lot cap (pyramiding) | **yes** — `max_open_lots` native_run_spec.hpp:664 → `MaxOpenLots` native_execution_consumer.cpp:3951 | K+A | — | F:A5 O:A13 O:D6 S:M1 | **Disagree:** F, O:A13 partial / O:D6, S yes. R5-1: Pine's per-cycle entry count (`accepted_in_cycle` pine_adapter.cpp:8137, cap left unset; setting spelling `max_open_lots` pine_adapter.cpp:2835) is TV. **Measured (the kernel/adapter differential study), and not TradingView's rule:** on 15 scenarios of three `lab tv` tapes (the private test `test_pyramiding_count_differential`) TradingView checks an entry once, at its first eligible point, against the trades then open; the kernel's `max_open_lots` books 14 as TradingView does, the adapter's count 13 (it keeps a slot a close or another entry's exit drained; since the residual-margin correction's SB the later market entries of a flat bar are refused at the fill once the book holds the cap). The fifteenth, a resting entry accepted below the cap and filled at it, the kernel's cap refuses; the adapter books it since the resting-entry signal-state correction, which also stopped the count from counting a resting LIMIT entry (a queued LIMIT entry does not count at a later call, and one the cap admitted fills over it: the private fixture set `pyramiding_open_order`). A recorded divergence, retained until a re-lowering decision. **Re-lowering tried by the adapter state consolidation and not taken:** on the kernel's cap four corpus probes moved away from TradingView, for want of that first-eligible-point rule, so the count stays. The change fixed the reservation divergence the kernel/adapter differential study measured beside it (P10): a default `strategy.exit(from_entry)` under FIFO reserved the whole book (200 in the adapter, 100 on TradingView's tape); it now reserves the entry's own quantity, the oldest lots first (`fifo_named_entry` pine_adapter.cpp:12152). | -| MG4 | Initial-margin opening gate (adapter answers `AdmitWithHostMargin` from frozen signal-time equity pine_adapter.cpp:17925 and leaves the kernel gate unset pine_adapter.cpp:17944) | **yes** — `initial_margin_fraction` native_run_spec.hpp:666; the requirement `resulting_abs_notional * fraction` native_execution_consumer.cpp:3969 against marked equity. **Conversion instant, the execution-clock follow-up:** both sites of the gate convert at their own point's rate — the candidate at its cursor, the placement gate at the acceptance point where a `Sized{SizeTime::AtAcceptance}` fixes its units — for the notional, the equity and a percent ticket, and the snapshot's equity basis with them. The rate is threaded, never the clock: `make_command_context` native_execution_consumer.cpp:3815 hands the point's rate to the snapshot, to `marked_equity_at` and to the engine's rate-explicit settlement inspection `inspect_native_settlement_scoped_at` engine.hpp:943, and the engine's presented clock is never written (`tests/test_native_margin_fx_clock.cpp` sections 8, 10 and 11) | K | — | F:D1 O:D1 S:M2 | **Disagree:** F, O partial (they fold per-side in) / S yes. Row split → MG5. **Measured (the kernel/adapter differential study, M11):** the two gates part both ways on the same bars — the kernel's declines a 1× long whose entry fee the adapter admits, and admits a gap-up add the adapter refuses against its signal-time equity; `ab9714be` books the adapter's answer both times (the private test `test_adapter_margin_schedule_differential`). **Since the placement and recalculation correction** an explicit-quantity market buy from flat is costed at its signal close's tick plus the slippage ticks its fill carries, off `process_orders_on_close` (the private test `test_explicit_market_admission_tapes`), and an infinite `strategy.entry` quantity trades the default quantity, as na does (`tests/test_nonfinite_entry_qty_tape.cpp`). | -| MG5 | Per-side initial margin (`margin_pct` pine_adapter.cpp:17240, `margin_pct` pine_adapter.cpp:17680) | **no** — one `std::optional` `initial_margin_fraction` native_run_spec.hpp:666 **Closed:** `NativeMarginModel::initial_long` / `initial_short` native_run_spec.hpp:241-242, opt-in and mutually exclusive with `initial_margin_fraction`; `initial_ == 0.0` is the maintenance-only spelling that leaves opening admission to the host at both of its gates (`tests/test_native_margin_model.cpp`). | K | L4 | F:D1 O:D2 | R5-1 | -| MG6 | Maintenance margin + forced liquidation (`submit_margin_call_slice` pine_adapter.cpp:18563, `submit_margin_call_units` pine_adapter.cpp:18593, `schedule_margin_call_path` pine_adapter.cpp:20845) | **no** — "no maintenance liquidation" `initial_margin_fraction` native_run_spec.hpp:666-667; margin is checked only when `inspect.would_open` native_execution_consumer.cpp:3928 **Closed:** `maintenance_long` / `maintenance_short` with `NativeLiquidationCheck::{PathAdverseExtreme, PathAdverseExtremeMark, CalculationOnly}`; the kernel solves the level, rests its own `Reduce`/`Flatten` bound to the live book, re-prices it with `CancelReason::Superseded` and books a `MarginCallEvent` (`tests/test_native_margin_model.cpp`, `tests/test_native_margin_hooks.cpp`). | K+A | L4 | F:D2 O:D3 S:M3 | TV parts → MG15 | -| MG7 | Liquidation slice sizing (grid floor, ×4 restore, one-contract fallback `source_margin_units` pine_adapter.cpp:18222) | **no** **Closed:** `NativeLiquidationSizing::{RestoreMinimum, ShortfallMultiple, Flatten}` with `shortfall_multiple` and `liquidation_min_units`, capped at the held quantity and floored onto `quantity_grid`; `resolve_margin_call_units` native_host.hpp:1015 gives a host the last word, clamped into `(0, held]` (`tests/test_native_margin_model.cpp`, `tests/test_native_margin_hooks.cpp`). | K+A | L4 | O:D4 F:D2 S:M4 | **Disagree:** F generic knob `ShortfallMultiple` / O "×4 is A" / S quirk, yet a 4.0 default inside its generic model. R5-1: policy knob in the kernel, plain default (restore-minimum). **Decided by the adapter margin review (the slice-sizing question):** the adapter selects NO kernel knob — `project()` leaves `sizing` / `shortfall_multiple` / `liquidation_min_units` unset and its `resolve_margin_call_units` answers every call with TradingView's slice (the restore floored onto the lot grid BEFORE the ×4, floored again, the one-contract band, the dust gate). Measured: on a one-unit grid, 20 long at 100 on 1000 at 50 % reaching 93.9, TradingView books 4 lots and the kernel's own `ShortfallMultiple 4` books 5 (`tests/test_native_margin_hooks_twin.cpp` MG-F3, an executed run, no longer a comment); without a grid the two agree bit for bit (MG-F/H, 4.2105263157894735), but declaring the knob for those runs alone folds `NativeMarginModel::sizing` into every margin run's identity — an epoch decision that review does not take. Since the pre-open margin sizing update the pre-open admission slice (`schedule_preopen_margin_slice`) sizes through the same `source_margin_units`, where it kept its own copy of the ×4 without the band or the dust gates (the audit finding on pre-open margin sizing; the private test `test_adapter_margin_schedule_differential` M10: band and fudge now 1 and 1, the kernel's and `ab9714be`'s). A four-times call on a whole-unit grid whose book's oldest lot is one unit is followed by one more unit at the next check point (`whole_unit_follow_up_due` pine_adapter.cpp:19752; the margin-residual update, the private fixture set `margin_residual` w5b-es-*, w5b-sb-es-*, w5b-f-q1x12). With slippage the one-contract band takes its unit only where that unit restores the book at its own fill: the position's requirement at the liquidation's slipped print, less the equity at the mark, under one unit's margin (`source_margin_units` pine_adapter.cpp:18222; the slipped-call restore rule, the private fixture set `slipped_short`). A one-unit call that leaves the book short at its own fill, by more than one unit's margin and two slippage steps with the book re-marked there, is followed by one more unit at the bar's next path point, where that point's own check calls nothing and its print is inside the call's fill (the slipped-call follow-up rule). Under a percent commission a call of any size is followed so, on a fractional lot grid too: while the book the call left, re-marked at its fill, is short by more than two slippage steps for each unit called and the exit fees of those units and of one more, the next path point inside that fill takes four times the shortfall's lot floor over the fill, or one unit where that floors to none (`margin_follow_up_units` pine_adapter.cpp:19794, `follow_margin_call` pine_adapter.cpp:19843; the commission follow-up update, the private fixture set `margin_open_print`). | +| MG4 | Initial-margin opening gate (adapter answers `AdmitWithHostMargin` from frozen signal-time equity pine_adapter.cpp:17941 and leaves the kernel gate unset pine_adapter.cpp:17960) | **yes** — `initial_margin_fraction` native_run_spec.hpp:666; the requirement `resulting_abs_notional * fraction` native_execution_consumer.cpp:3969 against marked equity. **Conversion instant, the execution-clock follow-up:** both sites of the gate convert at their own point's rate — the candidate at its cursor, the placement gate at the acceptance point where a `Sized{SizeTime::AtAcceptance}` fixes its units — for the notional, the equity and a percent ticket, and the snapshot's equity basis with them. The rate is threaded, never the clock: `make_command_context` native_execution_consumer.cpp:3815 hands the point's rate to the snapshot, to `marked_equity_at` and to the engine's rate-explicit settlement inspection `inspect_native_settlement_scoped_at` engine.hpp:943, and the engine's presented clock is never written (`tests/test_native_margin_fx_clock.cpp` sections 8, 10 and 11) | K | — | F:D1 O:D1 S:M2 | **Disagree:** F, O partial (they fold per-side in) / S yes. Row split → MG5. **Measured (the kernel/adapter differential study, M11):** the two gates part both ways on the same bars — the kernel's declines a 1× long whose entry fee the adapter admits, and admits a gap-up add the adapter refuses against its signal-time equity; `ab9714be` books the adapter's answer both times (the private test `test_adapter_margin_schedule_differential`). **Since the placement and recalculation correction** an explicit-quantity market buy from flat is costed at its signal close's tick plus the slippage ticks its fill carries, off `process_orders_on_close` (the private test `test_explicit_market_admission_tapes`), and an infinite `strategy.entry` quantity trades the default quantity, as na does (`tests/test_nonfinite_entry_qty_tape.cpp`). | +| MG5 | Per-side initial margin (`margin_pct` pine_adapter.cpp:17256, `margin_pct` pine_adapter.cpp:17696) | **no** — one `std::optional` `initial_margin_fraction` native_run_spec.hpp:666 **Closed:** `NativeMarginModel::initial_long` / `initial_short` native_run_spec.hpp:241-242, opt-in and mutually exclusive with `initial_margin_fraction`; `initial_ == 0.0` is the maintenance-only spelling that leaves opening admission to the host at both of its gates (`tests/test_native_margin_model.cpp`). | K | L4 | F:D1 O:D2 | R5-1 | +| MG6 | Maintenance margin + forced liquidation (`submit_margin_call_slice` pine_adapter.cpp:18579, `submit_margin_call_units` pine_adapter.cpp:18609, `schedule_margin_call_path` pine_adapter.cpp:20861) | **no** — "no maintenance liquidation" `initial_margin_fraction` native_run_spec.hpp:666-667; margin is checked only when `inspect.would_open` native_execution_consumer.cpp:3928 **Closed:** `maintenance_long` / `maintenance_short` with `NativeLiquidationCheck::{PathAdverseExtreme, PathAdverseExtremeMark, CalculationOnly}`; the kernel solves the level, rests its own `Reduce`/`Flatten` bound to the live book, re-prices it with `CancelReason::Superseded` and books a `MarginCallEvent` (`tests/test_native_margin_model.cpp`, `tests/test_native_margin_hooks.cpp`). | K+A | L4 | F:D2 O:D3 S:M3 | TV parts → MG15 | +| MG7 | Liquidation slice sizing (grid floor, ×4 restore, one-contract fallback `source_margin_units` pine_adapter.cpp:18238) | **no** **Closed:** `NativeLiquidationSizing::{RestoreMinimum, ShortfallMultiple, Flatten}` with `shortfall_multiple` and `liquidation_min_units`, capped at the held quantity and floored onto `quantity_grid`; `resolve_margin_call_units` native_host.hpp:1015 gives a host the last word, clamped into `(0, held]` (`tests/test_native_margin_model.cpp`, `tests/test_native_margin_hooks.cpp`). | K+A | L4 | O:D4 F:D2 S:M4 | **Disagree:** F generic knob `ShortfallMultiple` / O "×4 is A" / S quirk, yet a 4.0 default inside its generic model. R5-1: policy knob in the kernel, plain default (restore-minimum). **Decided by the adapter margin review (the slice-sizing question):** the adapter selects NO kernel knob — `project()` leaves `sizing` / `shortfall_multiple` / `liquidation_min_units` unset and its `resolve_margin_call_units` answers every call with TradingView's slice (the restore floored onto the lot grid BEFORE the ×4, floored again, the one-contract band, the dust gate). Measured: on a one-unit grid, 20 long at 100 on 1000 at 50 % reaching 93.9, TradingView books 4 lots and the kernel's own `ShortfallMultiple 4` books 5 (`tests/test_native_margin_hooks_twin.cpp` MG-F3, an executed run, no longer a comment); without a grid the two agree bit for bit (MG-F/H, 4.2105263157894735), but declaring the knob for those runs alone folds `NativeMarginModel::sizing` into every margin run's identity — an epoch decision that review does not take. Since the pre-open margin sizing update the pre-open admission slice (`schedule_preopen_margin_slice`) sizes through the same `source_margin_units`, where it kept its own copy of the ×4 without the band or the dust gates (the audit finding on pre-open margin sizing; the private test `test_adapter_margin_schedule_differential` M10: band and fudge now 1 and 1, the kernel's and `ab9714be`'s). A four-times call on a whole-unit grid whose book's oldest lot is one unit is followed by one more unit at the next check point (`whole_unit_follow_up_due` pine_adapter.cpp:19768; the margin-residual update, the private fixture set `margin_residual` w5b-es-*, w5b-sb-es-*, w5b-f-q1x12). With slippage the one-contract band takes its unit only where that unit restores the book at its own fill: the position's requirement at the liquidation's slipped print, less the equity at the mark, under one unit's margin (`source_margin_units` pine_adapter.cpp:18238; the slipped-call restore rule, the private fixture set `slipped_short`). A one-unit call that leaves the book short at its own fill, by more than one unit's margin and two slippage steps with the book re-marked there, is followed by one more unit at the bar's next path point, where that point's own check calls nothing and its print is inside the call's fill (the slipped-call follow-up rule). Under a percent commission a call of any size is followed so, on a fractional lot grid too: while the book the call left, re-marked at its fill, is short by more than two slippage steps for each unit called and the exit fees of those units and of one more, the next path point inside that fill takes four times the shortfall's lot floor over the fill, or one unit where that floors to none (`margin_follow_up_units` pine_adapter.cpp:19810, `follow_margin_call` pine_adapter.cpp:19859; the commission follow-up update, the private fixture set `margin_open_print`). | | MG8 | Liquidation price query (`margin_liquidation_price` pine_strategy_host.cpp:481-497) | **no** **Closed:** `native_liquidation_price()` native_host.hpp:1350 answers the exact solved level, `nullopt` where none exists; `NativeLiquidationLevelBase` chooses whether the solve answers for the open entries' fees (`tests/test_native_margin_model.cpp`, `tests/test_native_open_lots.cpp`). | K+A | Margin | F:D3 S:R5 | **Disagree:** F generic / S adapter-only. R5-1: liquidation price is generic; the Pine spelling stays A. **Lowered by the adapter state consolidation:** the Pine host's second solve (`compute_liquidation_price`) is gone; `strategy.margin_liquidation_price` reads `native_liquidation_price()`, which the adapter configures with `NativeLiquidationLevelBase::RealizedOnly` (the Pine formula's closed-money solve, bit for bit at every bar of the measured 1,021 seeded margin-call runs), and keeps only TradingView's tick spelling: floored for a long, ceiled for a short. A run whose margin calls are switched off declares no margin model, so it reads na. | | MG9 | FX revaluation of margin (`apply_fx_open_margin_slice` pine_adapter.cpp:6038, `apply_fx_opening_margin_slice` pine_adapter.cpp:6050) | **no** — staged FX curve `configure_native_fx_curve` native_host.hpp:1222 but no margin re-check. **Closed by the FX integration:** `NativeMarginCheckKind::FxRoll` — a step of the declared curve is a check point of its own, measured at the unchanged price before the first point the new rate converts (`tests/test_native_margin_fx_roll.cpp`). The adapter refuses the point by kind, for a measured reason since the margin and cancellation comparison: TradingView's rollover is a broker-open checkpoint at the open price, now at any positive margin (`apply_fx_open_margin_slice`, on the one TradingView money / units rule every other checkpoint uses), while the roll point measures at the remaining path's adverse mark and rests the slice there — a carried 1x long on a step bar O 100 L 97 books 0.4116 @ 97 on the adapter's run shape and 0.4036 @ 99, measured a bar early, on a strict calendar, against TradingView's 0.3996 @ 100 (`tests/test_native_margin_hooks_twin.cpp` MG-FX). Both N6 follow-ups are closed (§4.4). **Completed by the execution-clock follow-up:** the conversion INSTANT is now generic too — every check kind converts at its own cursor's rate (`account_currency_fx_at(cursor.point.effective_time_ms)`) for the requirement, the equity and the level, in batch and stream, so a step landing between a fill's cursor and the clock the engine presents no longer breaches at the fill (`tests/test_native_margin_fx_clock.cpp`; N6's local clock swap inside `fx_roll_margin_check` is removed as redundant). **Extended by the execution-clock follow-up** to the opening gate's placement site (MG4), and the rate-explicit equity E3 restated beside the engine's accessor is now that accessor's one implementation, `BacktestEngine::marked_equity_at` engine.hpp:1023 | K | Margin → N6 → E3 → E21 | O:D8 | (single-source) | -| MG10 | Max drawdown halt (`update_risk_state` pine_adapter.cpp:18102) | **no** — observation field only `drawdown_limit` include/pineforge/source/market_admission.hpp:55; composable today from `native_marked_equity` native_host.hpp:1331 **Closed:** `NativeRiskLimits::max_drawdown` native_run_spec.hpp:311, a `NativeLossLimit{value, percent}` measured against the running peak equity at three points of every script bar, blocking openings for the rest of the run (`tests/test_native_risk_limits.cpp`). | K | L9 | F:D6 O:E1 S:M6 | R5-9 | -| MG11 | Max consecutive losing days (`SourceDayLedger` pine_adapter.hpp:1548-1559; gate `max_cons_loss_days` pine_adapter.cpp:17102) | **no** — observation only `loss_days_limit` include/pineforge/source/market_admission.hpp:54 **Closed:** `NativeRiskLimits::max_consecutive_loss_days`, settled from each day's realized result at the next day's open (`tests/test_native_risk_limits.cpp`). | K | L9 | F:D6 O:E2 S:M6 | R5-9 | -| MG12 | Max intraday loss → flatten + block (`intraday_loss_breached` pine_adapter.cpp:18135, forced flatten `submit_intraday_loss_close` pine_adapter.cpp:21479) | **no** — observation only `intraday_loss_limit` include/pineforge/source/market_admission.hpp:56 **Closed:** `NativeRiskLimits::max_intraday_loss` with `NativeRiskAction::FlattenAndBlock`, under which the kernel issues one `Flatten` of its own at the breach point (`RequestOrigin::KernelRisk`) and latches the block before that flatten settles (`tests/test_native_risk_limits.cpp`). | K | L9 | F:D7 O:E3 S:M6 | R5-9 | -| MG13 | Max filled orders per day (`IntradayCap` intraday_cap.hpp:84-263, `IntradayOrderBudget` intraday_order_budget.hpp:27-92; `execute_due_cap_close` pine_adapter.cpp:21522) | **no** **Closed:** `NativeRiskLimits::max_fills_per_day` counts every applied execution of the day as it settles, whatever issued it (`tests/test_native_risk_limits.cpp`). | K+A | L9 | F:D8 O:E4 S:M7 | Count cap K; quota transfer, POOC deferral, TV close price A | -| MG14 | Risk-day boundary (`chart_day_key` pine_adapter.cpp:17999) | **no** — the calendar exists `session_day_ordinal` native_calendar.hpp:416-419, there is no risk-day ledger **Closed:** `NativeRiskDay::{SessionDay, CalendarDayInTimezone}` keys the risk day, and `native_risk_state()` native_host.hpp:1359 reads the whole ledger back — block and reason, day, fills counted, loss-day streak, peak equity, day-opening equity (`tests/test_native_risk_limits.cpp`). | K+A | L9 | O:E5 S:M7 | The chart-day shortcut stays A; the kernel keys on session / calendar day | -| MG15 | *(quirk)* TV margin call: money rounding `source_margin_money` pine_adapter.cpp:18167, 4× shortfall `4.0 * minimum` pine_adapter.cpp:18256, POOC chronology exception `resting_order` pine_adapter.cpp:20877 (the commissioned carried short; the competing-entry and rounded-money exceptions retired by the residual-margin correction, the private fixture set `margin_residual` w5b-pc-*), 1×-long money call `submit_tv_money_long_margin_call` pine_adapter.cpp:19162, fill pricing (the slice books at its waypoint's mark, `submit_tv_money_long_margin_call` pine_adapter.cpp:19162) | n/a | A | — | F:D2 F:G4 O:D4 S:M4 | R5-1. **Measured (the kernel/adapter differential study)** against the kernel's own points on the same bars and orders, the kernel's hooks answering TradingView's money and slice (the private test `test_adapter_margin_schedule_differential`): a gap-open breach executes at the open, sized on the open's money, where the kernel's mark check rests at the path's adverse extreme and `CalculationOnly` executes at the calculation (M8); the pre-open slice parts from the kernel's `AfterApplied` point, and from `ab9714be`, on the one-contract band and the restore's `+1e-6` floor (M10; the frozen units it also parted on are TradingView's book since the margin-schedule correction froze a default-percent stop above 100 % as eight `lab tv` tapes, the private tapes `pm-m10-*` of `margin_entry_bar`, show TradingView does); the entry bar of a leveraged opening, where four `lab tv` tapes (the private fixture set `margin_entry_bar`) and the kernel's `AfterApplied` point book the margin call at that bar's low, is admitted by `on_applied` the margin-schedule correction on the run shapes `ab9714be` checked it on (the pre-open slice's own opening keeps that slice's verdict), and the adapter books the tapes' rows (M7; the kernel/adapter differential study had recorded it as a divergence); the intrabar margin correction extended it to limit openings, `process_orders_on_close`, `calc_on_order_fills` and adds to a carried book, and made the kernel's post-fill point reach the low a limit was filled on the way down to, or a short's high on its way up (twenty-eight `lab tv` tapes, the private tapes `pm2-*` of `margin_entry_bar`). Under the magnifier the kernel's `IntrabarSample` point (the margin-schedule correction) is admitted on a leveraged long, plain, under `process_orders_on_close` or `calc_on_order_fills`, and on a short outside `process_orders_on_close` (the intrabar margin correction), where TradingView books its call at the first lower-timeframe low that crosses -- at its own 2-minute intrabars on a 15-minute chart, which a model reproduces on all 20 magnified tapes (the private fixture set `intrabar_margin`); six tapes part on the one-minute samples the host feeds, a recorded divergence of the magnifier's sampling. A margin call books the nearest tick of its print, as TradingView's market fills do (12.105 books 12.11; the private fixture set `half_tick_rounding`, the intrabar margin correction). | -| MG16 | *(quirk)* Historical market-admission review / retention journal (`explicit_pair_scope` src/compat/pine/market_admission.cpp:34 and the review fold it feeds (`original_pair_call` src/compat/pine/market_admission.cpp:48); `admission_allocation` pine_adapter.cpp:4881, `record_market_review` pine_adapter.cpp:21778) | n/a | A | — | S:R2 | (single-source) | +| MG10 | Max drawdown halt (`update_risk_state` pine_adapter.cpp:18118) | **no** — observation field only `drawdown_limit` include/pineforge/source/market_admission.hpp:55; composable today from `native_marked_equity` native_host.hpp:1331 **Closed:** `NativeRiskLimits::max_drawdown` native_run_spec.hpp:311, a `NativeLossLimit{value, percent}` measured against the running peak equity at three points of every script bar, blocking openings for the rest of the run (`tests/test_native_risk_limits.cpp`). | K | L9 | F:D6 O:E1 S:M6 | R5-9 | +| MG11 | Max consecutive losing days (`SourceDayLedger` pine_adapter.hpp:1548-1559; gate `max_cons_loss_days` pine_adapter.cpp:17118) | **no** — observation only `loss_days_limit` include/pineforge/source/market_admission.hpp:54 **Closed:** `NativeRiskLimits::max_consecutive_loss_days`, settled from each day's realized result at the next day's open (`tests/test_native_risk_limits.cpp`). | K | L9 | F:D6 O:E2 S:M6 | R5-9 | +| MG12 | Max intraday loss → flatten + block (`intraday_loss_breached` pine_adapter.cpp:18151, forced flatten `submit_intraday_loss_close` pine_adapter.cpp:21495) | **no** — observation only `intraday_loss_limit` include/pineforge/source/market_admission.hpp:56 **Closed:** `NativeRiskLimits::max_intraday_loss` with `NativeRiskAction::FlattenAndBlock`, under which the kernel issues one `Flatten` of its own at the breach point (`RequestOrigin::KernelRisk`) and latches the block before that flatten settles (`tests/test_native_risk_limits.cpp`). | K | L9 | F:D7 O:E3 S:M6 | R5-9 | +| MG13 | Max filled orders per day (`IntradayCap` intraday_cap.hpp:84-263, `IntradayOrderBudget` intraday_order_budget.hpp:27-92; `execute_due_cap_close` pine_adapter.cpp:21538) | **no** **Closed:** `NativeRiskLimits::max_fills_per_day` counts every applied execution of the day as it settles, whatever issued it (`tests/test_native_risk_limits.cpp`). | K+A | L9 | F:D8 O:E4 S:M7 | Count cap K; quota transfer, POOC deferral, TV close price A | +| MG14 | Risk-day boundary (`chart_day_key` pine_adapter.cpp:18015) | **no** — the calendar exists `session_day_ordinal` native_calendar.hpp:416-419, there is no risk-day ledger **Closed:** `NativeRiskDay::{SessionDay, CalendarDayInTimezone}` keys the risk day, and `native_risk_state()` native_host.hpp:1359 reads the whole ledger back — block and reason, day, fills counted, loss-day streak, peak equity, day-opening equity (`tests/test_native_risk_limits.cpp`). | K+A | L9 | O:E5 S:M7 | The chart-day shortcut stays A; the kernel keys on session / calendar day | +| MG15 | *(quirk)* TV margin call: money rounding `source_margin_money` pine_adapter.cpp:18183, 4× shortfall `4.0 * minimum` pine_adapter.cpp:18272, POOC chronology exception `resting_order` pine_adapter.cpp:20893 (the commissioned carried short; the competing-entry and rounded-money exceptions retired by the residual-margin correction, the private fixture set `margin_residual` w5b-pc-*), 1×-long money call `submit_tv_money_long_margin_call` pine_adapter.cpp:19178, fill pricing (the slice books at its waypoint's mark, `submit_tv_money_long_margin_call` pine_adapter.cpp:19178) | n/a | A | — | F:D2 F:G4 O:D4 S:M4 | R5-1. **Measured (the kernel/adapter differential study)** against the kernel's own points on the same bars and orders, the kernel's hooks answering TradingView's money and slice (the private test `test_adapter_margin_schedule_differential`): a gap-open breach executes at the open, sized on the open's money, where the kernel's mark check rests at the path's adverse extreme and `CalculationOnly` executes at the calculation (M8); the pre-open slice parts from the kernel's `AfterApplied` point, and from `ab9714be`, on the one-contract band and the restore's `+1e-6` floor (M10; the frozen units it also parted on are TradingView's book since the margin-schedule correction froze a default-percent stop above 100 % as eight `lab tv` tapes, the private tapes `pm-m10-*` of `margin_entry_bar`, show TradingView does); the entry bar of a leveraged opening, where four `lab tv` tapes (the private fixture set `margin_entry_bar`) and the kernel's `AfterApplied` point book the margin call at that bar's low, is admitted by `on_applied` the margin-schedule correction on the run shapes `ab9714be` checked it on (the pre-open slice's own opening keeps that slice's verdict), and the adapter books the tapes' rows (M7; the kernel/adapter differential study had recorded it as a divergence); the intrabar margin correction extended it to limit openings, `process_orders_on_close`, `calc_on_order_fills` and adds to a carried book, and made the kernel's post-fill point reach the low a limit was filled on the way down to, or a short's high on its way up (twenty-eight `lab tv` tapes, the private tapes `pm2-*` of `margin_entry_bar`). Under the magnifier the kernel's `IntrabarSample` point (the margin-schedule correction) is admitted on a leveraged long, plain, under `process_orders_on_close` or `calc_on_order_fills`, and on a short outside `process_orders_on_close` (the intrabar margin correction), where TradingView books its call at the first lower-timeframe low that crosses -- at its own 2-minute intrabars on a 15-minute chart, which a model reproduces on all 20 magnified tapes (the private fixture set `intrabar_margin`); six tapes part on the one-minute samples the host feeds, a recorded divergence of the magnifier's sampling. A margin call books the nearest tick of its print, as TradingView's market fills do (12.105 books 12.11; the private fixture set `half_tick_rounding`, the intrabar margin correction). | +| MG16 | *(quirk)* Historical market-admission review / retention journal (`explicit_pair_scope` src/compat/pine/market_admission.cpp:34 and the review fold it feeds (`original_pair_call` src/compat/pine/market_admission.cpp:48); `admission_allocation` pine_adapter.cpp:4881, `record_market_review` pine_adapter.cpp:21794) | n/a | A | — | S:R2 | (single-source) | ### 1.5 Calculation timing @@ -117,7 +117,7 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa |---|---|---|---|---|---|---| | CT1 | Calculate at script-bar close | **yes** — `on_native_bar` native_host.hpp:913, dispatched by `invoke_callback` native_execution_consumer.cpp:8634 (call site `on_native_recalculate` native_execution_consumer.cpp:8662) | K | — | F:E1 O:F1 | — | | CT2 | Process orders on close (`close_execution` pine_adapter.cpp:3005) | **yes** — `NativeCloseExecution::AfterCalculation` native_run_spec.hpp:35-38; `AfterCalculation` native_execution_consumer.cpp:9078 | K | — | F:E2 O:F6 S:C1 | — | -| CT3 | Pre-match hook at bar open (`on_bar_open` pine_adapter.cpp:22874) | **yes** — `on_native_bar_open` native_host.hpp:909 runs before the open match native_execution_consumer.cpp:8508; `execute_current` legal native_host.hpp:1124 | K | — | F:E3 O:F2 | hazard → CT4 | +| CT3 | Pre-match hook at bar open (`on_bar_open` pine_adapter.cpp:22890) | **yes** — `on_native_bar_open` native_host.hpp:909 runs before the open match native_execution_consumer.cpp:8508; `execute_current` legal native_host.hpp:1124 | K | — | F:E3 O:F2 | hazard → CT4 | | CT4 | Partial-bar view / open-bar lookahead guard | **no** — the open hook receives the *complete* script bar: `invoke_bar_open_callback(engine, bar, point)` native_execution_consumer.cpp:8451 with `engine.current_bar_ = open_view` native_execution_consumer.cpp:8502 (the complete bar unless `OpenOnly`) **Closed:** `current_partial_bar()` native_host.hpp:1093 is the lookahead-free bar so far, through the last path point consumed (a fill inside a segment reads it at the segment's origin), and `NativeOpenBarView::OpenOnly` native_run_spec.hpp:109 masks `on_native_bar_open`'s bar to `H = L = C = open` as presentation only — the complete bar is restored before the open match (`tests/test_native_calc_timing.cpp`). | K | L5 | F:E3 | (single-source) The adapter needs the full bar, so the guard is opt-in. | | CT5 | Raw input observation before aggregation | **yes** — `on_native_input` native_host.hpp:895, `NativeInputContext` native_host.hpp:793-798 | K | — | O:F3 | (single-source row) | | CT6 | Calculate on order fills (scheduler `PineScheduler::recalculate` pine_scheduler_native.cpp:767, `coof_recalculation_due` pine_scheduler_native.cpp:733, the first-open chain `kFirstOpenLoopGuard` pine_scheduler_native.cpp:888; `begin_coof_recalc` pine_adapter.cpp:7121, `flush_coof_tail` pine_adapter.cpp:7543, `end_coof_recalc` pine_adapter.cpp:7178) | **partial** — `on_native_applied` native_host.hpp:958 fires mid-path after each fill native_execution_consumer.cpp:8351, native_execution_consumer.cpp:8351; commands are legal there `commands_allowed` native_execution_consumer.cpp:1980; a request born there is eligible on the unconsumed rest of the bar `born_on_remaining_path` native_execution_consumer.cpp:6893, `remaining_path_delivery` native_order.cpp:4224-4232. Missing: calculation re-entry, a documented chronology, a bar-so-far view **Closed:** `NativeCalculationTrigger::BarCloseAndFills` native_run_spec.hpp:96 drives one `OrderFill` recalculation per applied execution at that event's own cursor, delivered as `on_native_recalculate(..., OrderFill, cause)` and bounded by `max_recalculations_per_point` (`tests/test_native_calc_timing.cpp`). | K+A | Calculation timing | F:E4 O:F5 S:C2 | **Disagree:** F partial / O, S no; F, S v1 / O deferrable. R5-5: v1, sub-bar hook + chronology contract, adapter path unchanged. TV parts → CT11. | @@ -149,7 +149,7 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa | FP5 | Trigger tested against the tick-quantized bar | **no** — the kernel tests raw prices against raw levels **Closed:** `NativePriceGrid::QuantizeFillsAndTriggers` additionally tests and re-validates every activation against the tick-quantized path, one rule for every trigger kind (`tests/test_native_trail_best_seed.cpp` puts an off-ladder trail seed on the ladder; `tests/test_native_acid_composite.cpp` runs a trail's best and its exit level on it; the private test `test_native_price_grid` re-ran the pair under the overlay). | K+A | L8 | O:H5 F:G4 S:F3 | R5-3: the optional quantized trigger test is K; TV's exact half-tick rule (`source_trigger_threshold` pine_adapter.cpp:471) is A on top. | | PG | The Pine adapter on the kernel price grid: raw levels submitted, `project()` declaring `QuantizeFillsAndTriggers` + `HalfUp`, `source_trigger_threshold` deleted (lane R7; re-tested after L8b by gap lane N13) | **measured-infeasible for the Pine adapter** — B1 (the sub-tick cursor-print abort) is closed by L8b's `ActivationGrid`: 0 aborted runs under the trial (R7: 9 zero-offset-trail tapes + 6 POOC panels; now `test_zero_offset_trail_rides_l4c` 449/450, `test_pooc_short_close_tick_l4d` 183/183). B2 (TradingView quantizes per order kind, the grid is one run-wide rule) moved 30 pinned checks in 4 units when N13 last ran the trial, before lanes E14 and E16, with no adapter-side remedy: `test_coof_market_limit_recross_l4c` 24, `test_stop_tick_rounding_l4d` 3, `test_adapter_grid_relower` 2, `test_zero_offset_trail_rides_l4c` 1; 12 more checks in 3 units have an adapter-side cause (`test_trail_fill_snap_l4c` 2, `test_native_margin_hooks` 2, `test_adapter_brackets_relower` 8 book digests). Corpus: 5/312 probes differ, in the engine-only entry-incarnation column only (every probe runs a 0.01 tick on an on-grid feed, so it cannot arbitrate) | A | waived (R7, N13) | — | **Waiver.** No class of triggers is byte-identical on its own: the grid is a run-wide switch and a per-kind mask would spell TradingView's inconsistency into the kernel (not generic, R5-3). The adapter stays on `NativePriceGrid::None` and keeps `source_trigger_threshold` and its call sites; native-engine.md "The Pine adapter's grid re-lowering is waived". **Native-only by ruling (audit lane P6): §3.6.2**, with `examples/native/native_price_grid_strategy.cpp` and its C twin as the feature's hosts. | | FP6 | Account-currency FX | **partial** — scalar `account_fx` + immutable `NativeFxCurve` native_host.hpp:1222 in batch; the stream then refused a non-empty curve (today only tick-driven input is refused under one, `refuse_fx_curve_tick_input` native_execution_consumer.cpp:10439). **Closed by the FX integration:** the declared curve is the run's immutable FX epoch and a confirmed-bar stream runs under it exactly as a batch does (stream twin in `tests/test_native_margin_fx_roll.cpp`); no realtime rate ingestion and no tick-driven input under a curve are stated as permanent limits with their reasons (native-engine.md, "Stream FX"). **Re-examined by the execution-clock follow-up and KEPT:** the tick refusal was never only about the margin check — it guards the SCRIPT-VISIBLE conversions (`open_trade_profit()`, `marked_equity()`, the equity curve), which read `active_account_currency_fx()`, i.e. The presented clock the observation hook has not yet moved (re-measured: a step at T+90s left the T+100s and T+110s prints converting at the pre-step rate). E3's cursor rule reaches the margin model only, so the refusal stands and is pinned in `tests/test_native_margin_fx_clock.cpp` | K | unassigned → N6 (E3: refusal kept) | F:D9 S:the C policy-hook integration | **Disagree:** F out of scope ("O7 clock") / S its P10, a stream-safe FX epoch. No planned implementation → §4 U4 (answered by N6: S's "same curve in batch and stream", without a new clock or callback). | -| FP7 | *(quirk)* TV tick conventions: half-tick threshold `source_bar_fill_tick` pine_adapter.cpp:382 (used `source_trigger_threshold` pine_adapter.cpp:471, `exit_limit_trigger` pine_adapter.cpp:14918), `source_bar_fill_tick` pine_adapter.cpp:382, raw-vs-booked spelling `source_same_point` pine_adapter.cpp:400, margin-call fill pricing `source_margin_fill_price` pine_adapter.cpp:18391 | n/a — already isolated behind the terms seam (adapter `resolve_terms` pine_adapter.cpp:15428) | A | — | F:G4 O:H5 S:F3 | R5-1, R5-3 | +| FP7 | *(quirk)* TV tick conventions: half-tick threshold `source_bar_fill_tick` pine_adapter.cpp:382 (used `source_trigger_threshold` pine_adapter.cpp:471, `exit_limit_trigger` pine_adapter.cpp:14918), `source_bar_fill_tick` pine_adapter.cpp:382, raw-vs-booked spelling `source_same_point` pine_adapter.cpp:400, margin-call fill pricing `source_margin_fill_price` pine_adapter.cpp:18407 | n/a — already isolated behind the terms seam (adapter `resolve_terms` pine_adapter.cpp:15428) | A | — | F:G4 O:H5 S:F3 | R5-1, R5-3 | ### 1.8 Excursion, metrics, report, hash @@ -223,7 +223,7 @@ spans, including nullable Calmar. | f | Dead legacy stream path | **Done.** `stream_feed_input_bar` and `dispatch_source_stream_script_bar` are both gone — neither name is in `src/` or `include/` — and the virtual left with the epoch that carried it. `stream_state_hash` engine.hpp:2143 stays, and the declaration/definition pairing is still enforced by `scripts/test_native_source_guard.py` | NEUTRAL (removing the virtual changes the vtable → shared bump) | Delete both | O; F asked (its Q6) | | g | Legacy path resolver | **Done, and then deleted.** `src/engine_path_resolve.cpp` is 118 lines with five functions: `bar_path_uses_high_first`, both `first_touch_position` overloads, `fill_bar_path_points_ordered`, and `BacktestEngine::declare_opened_lot_entry_bar_mask`. Ten test files still mention `resolve_exit_path_fill` in historical comments; the resolver body and test-only oracle are gone. | NEUTRAL (pure move; verify with the link gate) | Keep the generic path helpers in the kernel TU; the old source resolver is not a live counterpart | F (single-source) | | | h | `pine_*`-named generic API | `timeframe_time` session_time.hpp:35-211 (live, e.g. `session_in_market`, which generated code reaches through `pine_session_ismarket` session_time.hpp:263), `str_utils.hpp`, `deterministic_random` math.hpp:9, `tz_util` timezone.hpp:8-30; the guard bans `pine_*` only inside `IDENTIFIER_ROOTS` (test_native_source_guard.py:34, test_native_source_guard.py:34-42) | NEUTRAL, M | Neutral aliases; `pine_*` kept as deprecated inlines for codegen. Large blast radius in generated code. **Status (L11 + N14): done** — session_time / str_utils / math / timezone in L11; N14 applied the same treatment to the two public matrix types: `PineMatrix` → `NumericMatrix`, `PineGenericMatrix` → `GenericMatrix`, old spellings kept as exact aliases (no version script pins either name) | O (single-source) | -| i | `pf_pending_order_v1_t` Pine-named fields | `tv_carry_qty` pending_order_mirror.hpp:73, `pine_birth_reach` pending_order_mirror.hpp:135, `pine_exit_activation_owner_cycle_at_birth` pending_order_mirror.hpp:147-156, `pine_frozen_market_instruction_kind` pending_order_mirror.hpp:164-169; enumerated by `PF_PO_FIELD` pending_order_mirror.cpp:144-178; written only by `pine_birth_reach` pine_adapter.cpp:27809 | no change | Freeze (ABI). The neutral view arrives beside it (L7 / L13) | O (single-source) | +| i | `pf_pending_order_v1_t` Pine-named fields | `tv_carry_qty` pending_order_mirror.hpp:73, `pine_birth_reach` pending_order_mirror.hpp:135, `pine_exit_activation_owner_cycle_at_birth` pending_order_mirror.hpp:147-156, `pine_frozen_market_instruction_kind` pending_order_mirror.hpp:164-169; enumerated by `PF_PO_FIELD` pending_order_mirror.cpp:144-178; written only by `pine_birth_reach` pine_adapter.cpp:27825 | no change | Freeze (ABI). The neutral view arrives beside it (L7 / L13) | O (single-source) | | j | Pine-shaped lot flags | **Mostly done.** `market_pyramid_add` moved to the adapter side table the row asked for — `market_pyramid_adds_` src/source/pine_state_hash.cpp:771, whose own comment records that it was `PyramidEntry::market_pyramid_add` — and `ordinary_market_open`, `pooc_terminal_market_entry`, `ordinary_stop_open`, `bracket_slot_shadowed` and `entry_path_position` are all deleted. What is left on the lot is the intrabar-fill excursion mask, `skip_entry_bar_high` / `skip_entry_bar_low` engine.hpp:167-168, hashed (`skip_entry_bar_high` engine_state_hash.cpp:80) and, since the detached-comment and mask cleanup, written by no host at all: a host that owns lot excursions declares where its fill sat (`declare_opened_lot_entry_bar_mask` engine_path_resolve.cpp:95; the Pine host did until the adapter state consolidation left its excursions to the kernel's sampler) and the kernel derives the pair | HASH-VISIBLE | Move the live flag to an adapter side table keyed by `entry_incarnation` (the adapter already keeps `PlacementTable` pine_adapter.hpp:591-968) and fold it through `hash_source_extension`; delete the dead ones; update the hash-coverage waivers. Whether a kernel TU beyond the hash reads them is unverified (O) | F O S | | k | `margin_call_enabled_` | **Done.** The generic kernel model is enabled by `NativeRunSpec::margin` native_run_spec.hpp:672. TradingView-specific toggling remains `source::PineStrategyHost::set_margin_call_enabled` pine_strategy_host.hpp:539; the frozen C symbol remains source-owned. | HASH-VISIBLE (R5-8) | Keep the source setter in the adapter; the kernel toggle is the presence of `spec.margin` | F O | | l | Kernel decodes adapter strings | **Done.** `closed_trade_close_cause` engine_trade_accessors.cpp:166 branches on no string at all: it reads `Trade::open_at_end`, then the typed `execution::CloseCause` the closer recorded on the row, then `exit_from_bracket`. A kernel-originated liquidation or risk flatten states its own cause through the settling fill, so a bare host reads `3` / `4` where the retired string derivation answered `1`. The public C contract it implements is `strategy_closed_trade_close_cause` pineforge.h:1530 | HASH-VISIBLE (adds durable state) | Kernel `enum class CloseCause` recorded on the `Trade` at settlement (the kernel sets `MarginCall` when L4 liquidates; a source virtual covers adapter causes). C numbers unchanged. After L4 | O (single-source) | @@ -537,11 +537,11 @@ line numbers are re-anchored to this tree (the measurement ran on 683a82f); kern | Pine rule | adapter mechanism | kernel counterpart | class | measured divergence (test scenario) | ruling | |---|---|---|---|---|---| | `strategy.risk.allow_entry_in` | `risk_.direction`; `project()` already declares `spec.allowed_open_directions` (pine_adapter.cpp:3048); the forbidden opposite entry is reshaped to `CloseOpposite` in the adapter's own terms branch | per-opening cap `allowed_open_directions` | (i)+(ii), **already re-lowered** | none to measure: the cap is the kernel's, the close-only reshaping is the retained TV policy | done before N12; nothing to move | -| `strategy.risk.max_position_size` | precommit refuses a flat/same-side ENTRY at its fill when the LIVE book already holds ≥ the limit (`max_position_size` pine_adapter.cpp:17108; the retired legacy rule is `git show ab9714be:src/source/pine_risk.cpp`, lines 115 and 2531) | `max_abs_units`: refuses a fill whose RESULTING book would exceed it | (iv) | `PS`: two-unit entries against 3 — adapter admits the second (live 2 < 3, book 4) and refuses the third; kernel refuses the second (`MaxAbsUnits` at bars 1, 2), book 2 | retained: a pre-fill-book gate is a TV emulation fact, not a generic cap | -| `strategy.risk.max_drawdown` | `update_risk_state(bar.close)` once per script bar at the close mark (`update_risk_state(bar.close)` pine_adapter.cpp:24787, `update_risk_state` pine_adapter.cpp:18102): peak / running max drawdown at close marks, `>=` exact, percent of the current peak; the latch `risk_.halted` gates flat/same-side entries at precommit (`risk_.halted` pine_adapter.cpp:17101), never an opposite entry | `max_drawdown`: current drawdown vs percent-of-peak, at all three points; `BlockOpenings` refuses every opening | (ii) cadence + (iv) scope | `MG10-a`: bar 1 opens 600 under the entry and closes 100 under — kernel breaches at the open mark and refuses bar 2's add (book 100), adapter admits it (book 200). `MG10-b`: latched at bar 1's close, the adapter still reverses twice (`L→S` −600, `S→L`), the kernel refuses both reversals (`RiskLimit` at bars 2, 3) and holds. Corpus: `ta-closedtrades-risk-introspection-01` (20 % of peak) identical 1502/1502 rows with the kernel seeded — the rule never fires there | retained: the latched-reversal exemption cannot be composed on a kernel block; a close-only cadence knob alone would not close it | -| `strategy.risk.max_cons_loss_days` | `SourceDayLedger` (pine_adapter.hpp:1548-1559): +1 per losing TRADE on a new chart day, reset to 0 by any winning trade at its fill (`consecutive_loss_days` pine_adapter.cpp:26436-26438); gated immediately at precommit and latched at the close | `max_consecutive_loss_days`: a day's NET realized result settles the streak when the next day opens | (iv) | `MG11-a` (loss; win then loss; loss): adapter latches at day 3's first loss (4 rows), kernel's netted streak never reaches 2 (6 rows, no event). `MG11-b` (loss then smaller win, twice): adapter resets daily (5 rows), kernel blocks at day 3's open (4 rows, one event). No corpus probe declares the rule | retained: an order-dependent per-trade streak is not a generic day result | -| `strategy.risk.max_intraday_loss` | day-open equity at the day's first bar (`intraday_start_equity` pine_adapter.cpp:23114); checked at the bar open (`submit_intraday_loss_close(bar.open, context, true)` pine_adapter.cpp:23514), along the bar's PATH at its adverse extreme with a resting `Stop` flatten (`schedule_intraday_loss_path` pine_adapter.cpp:21511, `submit_intraday_loss_close` pine_adapter.cpp:21519), and at a closing fill with that fill's own P&L unbooked (`preclose_intraday_loss` pine_adapter.cpp:25368); epsilon `1e-9·max(1,|limit|)`; ticket `"Close Position (Max intraday Loss)"` → `CloseCause::RiskLimit`; the block refuses ALL placements for the chart day (`entry`/`order`/`close`/`close_all`/`exit`, `intraday_loss_orders_blocked` pine_adapter.cpp:7706, `:15196`, `:10085`, `:11003`, `:11534`) and `cancel_all()` withdraws the working book (`cancel_all()` pine_adapter.cpp:15160) | `max_intraday_loss` at the three points; `FlattenAndBlock` closes `AsPresented` under `"__kernel_risk__"` / `"Risk limit"`, blocks openings only, leaves working orders | (ii) ticket, day basis + (iv) path check, placement scope, unbooked fill, epsilon | `MG12-a`: day 2's first bar 100→85→95 — adapter closes at the extreme (85, −1500, cause 4) and refuses the day's later entry, kernel measures 0 at the open and 500 at the close, never breaches, ends with 300 units. `MG12-b`: breach at a bar open inside the day — same money on both sides (100 @ 88, −1200) under different tickets; the adapter withdraws a resting limit entry, the kernel leaves it working and it fills on day 3. Both sides mark the day's opening equity at its first bar: a gap there is not an intraday loss for either | retained: the path-extreme check is the rule's substance and the kernel has no path evaluation point; a risk ticket field and a `PathAdverseExtreme` check kind would be generic additions (mirroring `NativeMarginModel`) but would still leave the placement scope, the unbooked fill and the epsilon | -| `strategy.risk.max_intraday_filled_orders` | `compat::pine::IntradayCap` + `IntradayOrderBudget` (243 + 94 lines): charged slots per risk day, quota transfer, POOC deferral, noop-market skip, the close at the fill price or the bar's better extreme (`post_dispatch`), latch → all placements denied (`cap_placement_denied` pine_adapter.cpp:18093); ticket `"Close Position (Max number of filled orders in one day)"` → `CloseCause::FillCap`; day = `session_trading_day_index` for a `HHMM-HHMM` session, else the chart day (`chart_day_key` pine_adapter.cpp:17999, `mday·100+month` in the CHART timezone) | `max_fills_per_day`: applied fills counted as they settle, evaluated at the three points; `FlattenAndBlock` at the drain's cursor; `SessionDay` / `CalendarDayInTimezone` on the SPEC timezone | (i) count + (ii) ticket + (iv) close price, charged-slot budget, placement scope, chart-day key | `MG13`: the second fill reaches the cap on a bar that closes above its open — adapter closes at that bar's HIGH (103) under its ticket, kernel at the close (101) under its own. `MG14`: chart timezone `America/New_York` over a UTC symbol — bar 4 (04:00 UTC) is a new chart day for the adapter and the same UTC day for the kernel. Corpus, kernel seeded with the declared cap: `cap-risk-gates-allow-max-intraday-01` identical 1464/1464 (the third fill of each day flattens the book, so block and latch coincide); `cap-max-intraday-filled-orders-isolate-01` 3840 of 3916 rows differ from row 77; `cap-gatekeeper-intraday-risk-01` 312 of 604 differ from row 293; `composite-bracket-cap-range-pending-stop-01` 2370 of 2384 differ from row 17, two extra rows | retained: the count alone is expressible, but the kernel's block would refuse fills the transfer admits and the close price is TV's; a report-only action would give the adapter a count it cannot use (charged slots ≠ applied fills) | +| `strategy.risk.max_position_size` | precommit refuses a flat/same-side ENTRY at its fill when the LIVE book already holds ≥ the limit (`max_position_size` pine_adapter.cpp:17124; the retired legacy rule is `git show ab9714be:src/source/pine_risk.cpp`, lines 115 and 2531) | `max_abs_units`: refuses a fill whose RESULTING book would exceed it | (iv) | `PS`: two-unit entries against 3 — adapter admits the second (live 2 < 3, book 4) and refuses the third; kernel refuses the second (`MaxAbsUnits` at bars 1, 2), book 2 | retained: a pre-fill-book gate is a TV emulation fact, not a generic cap | +| `strategy.risk.max_drawdown` | `update_risk_state(bar.close)` once per script bar at the close mark (`update_risk_state(bar.close)` pine_adapter.cpp:24803, `update_risk_state` pine_adapter.cpp:18118): peak / running max drawdown at close marks, `>=` exact, percent of the current peak; the latch `risk_.halted` gates flat/same-side entries at precommit (`risk_.halted` pine_adapter.cpp:17117), never an opposite entry | `max_drawdown`: current drawdown vs percent-of-peak, at all three points; `BlockOpenings` refuses every opening | (ii) cadence + (iv) scope | `MG10-a`: bar 1 opens 600 under the entry and closes 100 under — kernel breaches at the open mark and refuses bar 2's add (book 100), adapter admits it (book 200). `MG10-b`: latched at bar 1's close, the adapter still reverses twice (`L→S` −600, `S→L`), the kernel refuses both reversals (`RiskLimit` at bars 2, 3) and holds. Corpus: `ta-closedtrades-risk-introspection-01` (20 % of peak) identical 1502/1502 rows with the kernel seeded — the rule never fires there | retained: the latched-reversal exemption cannot be composed on a kernel block; a close-only cadence knob alone would not close it | +| `strategy.risk.max_cons_loss_days` | `SourceDayLedger` (pine_adapter.hpp:1548-1559): +1 per losing TRADE on a new chart day, reset to 0 by any winning trade at its fill (`consecutive_loss_days` pine_adapter.cpp:26452-26454); gated immediately at precommit and latched at the close | `max_consecutive_loss_days`: a day's NET realized result settles the streak when the next day opens | (iv) | `MG11-a` (loss; win then loss; loss): adapter latches at day 3's first loss (4 rows), kernel's netted streak never reaches 2 (6 rows, no event). `MG11-b` (loss then smaller win, twice): adapter resets daily (5 rows), kernel blocks at day 3's open (4 rows, one event). No corpus probe declares the rule | retained: an order-dependent per-trade streak is not a generic day result | +| `strategy.risk.max_intraday_loss` | day-open equity at the day's first bar (`intraday_start_equity` pine_adapter.cpp:23130); checked at the bar open (`submit_intraday_loss_close(bar.open, context, true)` pine_adapter.cpp:23530), along the bar's PATH at its adverse extreme with a resting `Stop` flatten (`schedule_intraday_loss_path` pine_adapter.cpp:21527, `submit_intraday_loss_close` pine_adapter.cpp:21535), and at a closing fill with that fill's own P&L unbooked (`preclose_intraday_loss` pine_adapter.cpp:25384); epsilon `1e-9·max(1,|limit|)`; ticket `"Close Position (Max intraday Loss)"` → `CloseCause::RiskLimit`; the block refuses ALL placements for the chart day (`entry`/`order`/`close`/`close_all`/`exit`, `intraday_loss_orders_blocked` pine_adapter.cpp:7706, `:15196`, `:10085`, `:11003`, `:11534`) and `cancel_all()` withdraws the working book (`cancel_all()` pine_adapter.cpp:15160) | `max_intraday_loss` at the three points; `FlattenAndBlock` closes `AsPresented` under `"__kernel_risk__"` / `"Risk limit"`, blocks openings only, leaves working orders | (ii) ticket, day basis + (iv) path check, placement scope, unbooked fill, epsilon | `MG12-a`: day 2's first bar 100→85→95 — adapter closes at the extreme (85, −1500, cause 4) and refuses the day's later entry, kernel measures 0 at the open and 500 at the close, never breaches, ends with 300 units. `MG12-b`: breach at a bar open inside the day — same money on both sides (100 @ 88, −1200) under different tickets; the adapter withdraws a resting limit entry, the kernel leaves it working and it fills on day 3. Both sides mark the day's opening equity at its first bar: a gap there is not an intraday loss for either | retained: the path-extreme check is the rule's substance and the kernel has no path evaluation point; a risk ticket field and a `PathAdverseExtreme` check kind would be generic additions (mirroring `NativeMarginModel`) but would still leave the placement scope, the unbooked fill and the epsilon | +| `strategy.risk.max_intraday_filled_orders` | `compat::pine::IntradayCap` + `IntradayOrderBudget` (243 + 94 lines): charged slots per risk day, quota transfer, POOC deferral, noop-market skip, the close at the fill price or the bar's better extreme (`post_dispatch`), latch → all placements denied (`cap_placement_denied` pine_adapter.cpp:18109); ticket `"Close Position (Max number of filled orders in one day)"` → `CloseCause::FillCap`; day = `session_trading_day_index` for a `HHMM-HHMM` session, else the chart day (`chart_day_key` pine_adapter.cpp:18015, `mday·100+month` in the CHART timezone) | `max_fills_per_day`: applied fills counted as they settle, evaluated at the three points; `FlattenAndBlock` at the drain's cursor; `SessionDay` / `CalendarDayInTimezone` on the SPEC timezone | (i) count + (ii) ticket + (iv) close price, charged-slot budget, placement scope, chart-day key | `MG13`: the second fill reaches the cap on a bar that closes above its open — adapter closes at that bar's HIGH (103) under its ticket, kernel at the close (101) under its own. `MG14`: chart timezone `America/New_York` over a UTC symbol — bar 4 (04:00 UTC) is a new chart day for the adapter and the same UTC day for the kernel. Corpus, kernel seeded with the declared cap: `cap-risk-gates-allow-max-intraday-01` identical 1464/1464 (the third fill of each day flattens the book, so block and latch coincide); `cap-max-intraday-filled-orders-isolate-01` 3840 of 3916 rows differ from row 77; `cap-gatekeeper-intraday-risk-01` 312 of 604 differ from row 293; `composite-bracket-cap-range-pending-stop-01` 2370 of 2384 differ from row 17, two extra rows | retained: the count alone is expressible, but the kernel's block would refuse fills the transfer admits and the close price is TV's; a report-only action would give the adapter a count it cannot use (charged slots ≠ applied fills) | **Proposed generic additions (not implemented — no consumer today).** A risk ticket (`label` / `comment` on `NativeRiskLimits`, folded only when set, @@ -1648,10 +1648,10 @@ the rule gives 9.9 + 9.89). | # | Risk | Why it bites | Containment | Src | |---|---|---|---|---| | E1 | **Spec hashing.** `hash_spec` folded every field unconditionally (native_execution_consumer.cpp:257-360); every block added since folds only when set | Any new field, even defaulted, changes every continuation / broker-state hash, adapter runs included | Fold new blocks only when set, as a tagged extension (precedent `precommit_digest_` native_execution_consumer.hpp:2118); test that a default spec hashes to the pre-change constant | F | -| E2 | **Margin.** The TV margin call is built host-side from kernel primitives (a Stop-triggered `Reduce` bound to openings, or `execute_current`, which survives in `submit_margin_call_slice` pine_adapter.cpp:18563); its chronology lives at `schedule_margin_call_path` pine_adapter.cpp:20845 and its excursion sample differed by chronology (the Pine host's own excursion model, retired by the adapter state consolidation: the kernel's sampler books it) | A kernel model under an adapter run would liquidate twice or reorder settlement; a checkpoint that reduces mid-path changes lot state exactly where the adapter's chronology lives | Superseded by R5: the adapter declares `spec.margin` — maintenance-only, so no opening is decided twice (`AdmitWithHostMargin`, MG4) — and the kernel owns the mechanism (check points, resting liquidation, receipt) while the adapter's hooks answer TradingView's scheduling, money and slice (ADR-0001 "Margin"). What stays host-side is named there: the chronology exceptions, the pre-open slice, the 1x-long money call and the broker-open FX rollover, whose kernel `FxRoll` point measures elsewhere (MG9). Corrected by the margin and cancellation comparison | F O S | +| E2 | **Margin.** The TV margin call is built host-side from kernel primitives (a Stop-triggered `Reduce` bound to openings, or `execute_current`, which survives in `submit_margin_call_slice` pine_adapter.cpp:18579); its chronology lives at `schedule_margin_call_path` pine_adapter.cpp:20861 and its excursion sample differed by chronology (the Pine host's own excursion model, retired by the adapter state consolidation: the kernel's sampler books it) | A kernel model under an adapter run would liquidate twice or reorder settlement; a checkpoint that reduces mid-path changes lot state exactly where the adapter's chronology lives | Superseded by R5: the adapter declares `spec.margin` — maintenance-only, so no opening is decided twice (`AdmitWithHostMargin`, MG4) — and the kernel owns the mechanism (check points, resting liquidation, receipt) while the adapter's hooks answer TradingView's scheduling, money and slice (ADR-0001 "Margin"). What stays host-side is named there: the chronology exceptions, the pre-open slice, the 1x-long money call and the broker-open FX rollover, whose kernel `FxRoll` point measures elsewhere (MG9). Corrected by the margin and cancellation comparison | F O S | | E3 | **Sizing.** TV sizes from the tick-rounded signal close, ten-significant-digit-rounded percent-of-equity money (`source_money_round` pine_adapter.cpp:3336, applied when the quantity grid is staged) and fee-reserved cash, frozen at signal, with fill-time exceptions in COOF paths (`default_sizing_shape` pine_adapter.cpp:3387, the COOF `at_fill` exception pine_adapter.cpp:8835) | One "generic" percent sizing serving both would break parity or import TV rounding into the kernel; the last ulp differs on lot-stepped instruments | Two paths on purpose: `Sized` (kernel, plain arithmetic, timing as a first-class field) vs `HostSized` + the adapter resolver. Never "simplify" the adapter onto the kernel basis. What the two paths share is the conversion: since the kernel/source split and the margin and cancellation comparison the division by price × point value × FX and the percentage fee reserve exist once, in the kernel (`native_sized_units`), for a default AND a typed (`qty_type` cash / percent) quantity. The adapter keeps what is measured TradingView: the money basis (signal-time equity, TradingView's margin equity at a typed fill, the hypothetical Flatten's balance for a typed reversal), the snapshot, the fill-time exceptions and both lot floors (`tests/test_adapter_sizing_relower.cpp` cases 8, 9, 11: kernel 0.0392 against the percent floor's 0.0391 on a 0.0001 lot), and a staged FX series keeps `Sized` wherever the acceptance coordinate converts at the source's rate (case 12) | F O S | | E4 | **Calc on fill.** Kernel suffix eligibility is continuous along the segment; TV refills only at O/H/L/C waypoints -- on a magnified path at each intrabar's open, twice, and its two extremes, never its close (the TradingView intrabar import) (`PineScheduler::recalculate` pine_scheduler_native.cpp:767, `coof_next_waypoint` pine_adapter.cpp:7392) | Changing `born_on_remaining_path` or the notification order shifts adapter fills | Keep fixed `born_on_remaining_path` native_execution_consumer.cpp:6893 and `drain_applied_notifications` (native_execution_consumer.cpp:7661); new behaviour only behind new opt-ins | F | -| E5 | **Open-bar lookahead.** The adapter reads the full bar in `on_bar_open` (margin path scheduling uses H/L, pine_adapter.cpp:22874) | The complete bar cannot be masked globally | Opt-in `OpenOnly` view (A.4); loud doc warning in L0 | F | +| E5 | **Open-bar lookahead.** The adapter reads the full bar in `on_bar_open` (margin path scheduling uses H/L, pine_adapter.cpp:22890) | The complete bar cannot be masked globally | Opt-in `OpenOnly` view (A.4); loud doc warning in L0 | F | | E6 | **Double equity recording.** The source host already records per bar (history: only its cadence remains, `scheduler_mark_report_point` pine_strategy_host.cpp:3301) | Kernel recording would duplicate points and shift drawdown | Report policy / ownership (A.1); the adapter declares `KernelRecordedAtHostMarks` (R1): one recorder, at the adapter's own marks | F O | | E7 | **Price grid.** TV does not merely quantize: it tests triggers against a quantized bar while booking the fill on the *unrounded* level, so the adapter shifts the threshold (`source_trigger_threshold` pine_adapter.cpp:471) and books elsewhere | A kernel grid that quantizes both sides is correct and not TV; moving rounding into the kernel would change both native results and the adapter | `NativePriceGrid::None` for the adapter permanently; never "unify". Before L8: a byte-level experiment that the default price spelling on decimal ticks (one-ULP representations) does not move (S) | O S | | E8 | **HTF.** The evaluator machinery carries TV publication semantics (lookahead, boundary deferral, early close, `syminfo.type` branching engine_security.cpp:67-73). The feed store is TV-calibrated too: "W" / "M" from native daily bars, native stamps as the period partition (`set_native_security_feed` `sha256:3ada9f7e0bde989376f15e4e27c1fa3e77494750f2568ee721bd2a649e7354ef` engine_aux_security.cpp:52-84, *fresh*). Two aggregators exist: script buckets use `native_calendar`, `request.security` uses `TimeframeAggregator`, whose completion rules are TV-pinned (timeframe.hpp:296-338) | Reuse risks changing the *order* of the Pine scheduler's pumps; a bare host inherits TV partition rules; native HTF may differ from Pine HTF on early-close sessions | Registration-gated pump; no change to `scheduler_feed_security_input` pine_strategy_host.cpp:2085; full corpus diff; supplied-bar provenance preserved, never silently replaced by chart aggregation (S); differences on early-close sessions are recorded, not "fixed" (F). The Pine scheduler keeps one more `TimeframeAggregator` pass, before the run (`PineScheduler::run_begin`, audit M26): the script-bar count it yields (`terminal_source_bar`, the probe-tail suppression) and the per-input completion flags it hands `on_native_input` are needed before the first input arrives, while the kernel seals script intervals only as inputs arrive and offers no pre-run interval query (`NativeExecutionConsumer::script_interval_at` is consumer-private). Retained by the margin and cancellation comparison: replacing it needs that kernel capability | F O S | @@ -1671,7 +1671,7 @@ the rule gives 9.9 + 9.89). | P5 | **Test asymmetry:** 430 adapter test files vs 14 native when this plan was written (on this tree 486 `tests/*.cpp` name `source::PineStrategyHost`, 128 `NativeStrategyHost`) | native-only tests in every lane (§3.1 b) | O | | P6 | **Stream vs batch.** The stream refuses monthly input, mixed modes, in-session gaps, security feeds and source setters, and tick input under an FX curve (any non-empty curve until the FX stream audit) (`native-engine.md`, "Stream limitations (current)") | L5 and L6 must state which refusals are generic and which are deliberate source limits | S | | P7 | **C ABI variant growth** | §2.iii hardening rules | S | -| P8 | **Unverified in the inputs:** whether `execution::LifecycleEffects` (execution.hpp:136-139) is ever non-empty from the Pine path, else `PlacementSnapshot::legs` (pine_adapter.hpp:384) is the only live lifecycle (O); the `strategy.risk.allow_entry_in` setter chain beyond `set_pine_risk_direction` pine_strategy_host.cpp:1238 → `set_risk_direction` pine_adapter.cpp:27195 (O); what the ABI receipts pin (F); the probe corpus itself lives outside this tree (F) | verify inside the lane that touches each | F O | +| P8 | **Unverified in the inputs:** whether `execution::LifecycleEffects` (execution.hpp:136-139) is ever non-empty from the Pine path, else `PlacementSnapshot::legs` (pine_adapter.hpp:384) is the only live lifecycle (O); the `strategy.risk.allow_entry_in` setter chain beyond `set_pine_risk_direction` pine_strategy_host.cpp:1238 → `set_risk_direction` pine_adapter.cpp:27211 (O); what the ABI receipts pin (F); the probe corpus itself lives outside this tree (F) | verify inside the lane that touches each | F O | ### 4.3 Open design questions (supervisor level) @@ -1774,7 +1774,7 @@ virtual std::optional resolve_margin_call_units(const NativeMarginCallVi virtual void on_native_margin_call(const native_order::MarginCallEvent&) {} // O, S ``` -- Mechanism (F): a **kernel-originated request**. At each script-bar open and after each applied fill the kernel computes the liquidation level and rests a `Reduce` / `Flatten` with `Stop{level}` bound to the live openings — the shape the adapter builds host-side today (pine_adapter.cpp:20832). New hashed `RequestDefinition::origin { Host, KernelLiquidation, KernelRisk }`, new `CancelReason::Superseded` on re-price, `MarginCallEvent` joins `CommandEvent` (17 → 18, native_order.hpp:1400). O's alternative, a checkpoint on the excursion walk (`apply_excursion` native_execution_consumer.cpp:3598), is not chosen: a reduction mid-path is the entanglement O itself flags (E2). +- Mechanism (F): a **kernel-originated request**. At each script-bar open and after each applied fill the kernel computes the liquidation level and rests a `Reduce` / `Flatten` with `Stop{level}` bound to the live openings — the shape the adapter builds host-side today (pine_adapter.cpp:20848). New hashed `RequestDefinition::origin { Host, KernelLiquidation, KernelRisk }`, new `CancelReason::Superseded` on re-price, `MarginCallEvent` joins `CommandEvent` (17 → 18, native_order.hpp:1400). O's alternative, a checkpoint on the excursion walk (`apply_excursion` native_execution_consumer.cpp:3598), is not chosen: a reduction mid-path is the entanglement O itself flags (E2). - FX revaluation (MG9) falls out once `native_fx_curve` drives the mark (`native_fx_curve.cpp`). - TUs: `native_run_spec.{hpp,cpp}`, `native_execution_consumer.cpp` (`admit_opening_inspect` native_execution_consumer.cpp:3922 per side; scheduling next to `invoke_bar_open_callback` / `drain_after_applied`), `native_order.*`, `engine_state_hash.cpp` + the coverage checker, `c_abi.cpp` + `pineforge.h` (`pf_native_run_spec_v2` or an extension struct; `CLAUDE.md` SOP). @@ -1884,7 +1884,7 @@ std::optional risk; // NativeRunSpec - Breach: `MatchRejectReason::RiskLimit` for openings (O; F: `RiskHalted`), an optional kernel-issued flatten through A.3's kernel-originated request (`origin = KernelRisk`, F), and a `NativeRiskEvent` in `native_events` (native_host.hpp:1365) (O) or a callback `on_native_risk_event` (S — a virtual, so only inside an epoch batch). The day key uses the existing calendar (`session_day_ordinal` native_calendar.hpp:416), not a chart clock. - S's `directions` / `max_units` / `max_position_units` are existing spec fields (MG1-MG3), not duplicated. -- Interim (F): a C++ host can build all four today from `native_marked_equity` + `on_native_applied`, which is what the adapter does (`update_risk_state` pine_adapter.cpp:18102; `on_applied` pine_adapter.cpp:25053; the `opening_margin_checkpoint` block pine_adapter.cpp:17934); a toolkit helper is an acceptable stand-in until L9. +- Interim (F): a C++ host can build all four today from `native_marked_equity` + `on_native_applied`, which is what the adapter does (`update_risk_state` pine_adapter.cpp:18118; `on_applied` pine_adapter.cpp:25069; the `opening_margin_checkpoint` block pine_adapter.cpp:17950); a toolkit helper is an acceptable stand-in until L9. - TUs: `native_run_spec.{hpp,cpp}`, `native_execution_consumer.cpp` (`admit_opening_inspect` native_execution_consumer.cpp:3922 and the per-point walk), `native_order.hpp`, the state hash. ### A.9 Single-source proposals not adopted diff --git a/docs/native-refactor-progress.md b/docs/native-refactor-progress.md index 7ad1a2302..0afb99218 100644 --- a/docs/native-refactor-progress.md +++ b/docs/native-refactor-progress.md @@ -183,7 +183,7 @@ them for the record only. "Linux aarch64" is the maintainers' Cortex-A725 host. | Compact journal records and a history ring buffer (request-core profiles) | Smaller command records and a ring buffer for the request core's history. Since the bounded-journal change, `WorkingRequestCore::history` (`include/pineforge/native_order.hpp:1884`) returns a `JournalView` over `journal_` (`include/pineforge/native_order.hpp:2647`) instead of exposing the former `history_` vector. Compact records and a ring remain the deferred request-core profiling proposal | Request-core profiles, Linux aarch64 (Cortex-A725), `perf` call graphs, inclusive shares: at most 3.68 % of bracket, 11.99 % of re-issue, 4.26 % of market and 3.42 % of the 14 probes (retiring the history alone: 1.64, 4.51, 2.53 and 1.00 %); only part is realisable, since records must still be written | The accepted request-core profiling report (2026-09-24), ruling 1: deferred to the structural performance plan; the deferred-work review, §D: "compact journal records" | The request-core profiling code, merged in #285; its profiles are unpublished | A `native_order` layout epoch | | A checked-values handoff for executions | `execution_values` runs twice, in `check_execution` (`src/native_order.cpp:7128`) and again in `apply_execution` (`src/native_order.cpp:7152`); handing the checked values across needs a public type, an API addition with no layout change | As measured above: 2.21 % of bracket for the two together, about 1.1 % recoverable | Deferral ruling 2: "a public checked-values handoff for executions (~1.1% bracket) -> DEFERRED" | As above | A maintainer decision on the API addition | | A chunked `trades_` store | Closed trades in chunks rather than one vector; `trades_` (`include/pineforge/engine.hpp:717`) is protected `BacktestEngine` state, so script ABI | As measured above: on bracket, vector growth 0.64 %, `Trade` moves 0.74 % and `record_close_trade` 0.43 % (at most about 1.4 %); at most 0.35 % on the 14 probes | Deferral ruling 4: "chunked trades_ store (ABI) -> DEFERRED" | As above | An `engine_script_run` epoch that changes the engine's protected layout | -| A zoned chart-day memo (finding 4 of the UTC memo study) | On a chart with a timezone, `libc_chart_day_key` (`src/source/pine_adapter.cpp:17972`) takes the process timezone lock through `ScopedTimezone` and calls `localtime_r` on every bar, because `on_bar_open` (`src/source/pine_adapter.cpp:22874`) reads `chart_day_key` (`src/source/pine_adapter.cpp:23111`) unconditionally. The non-UTC monthly Sharpe/Sortino walk, `month_key_local` (`src/engine_metrics.cpp:55`), pays one `localtime_r` per equity point. UTC charts use the UTC chart-day memo's memo, `civil_chart_day_key` (`src/source/pine_adapter.cpp:17956`) | Not measured on a zoned chart. The UTC path cost 134 instructions a bar before the UTC chart-day memo's memo and 68 after (the UTC chart-day memo, Linux aarch64, callgrind on the floor). The timezone lock profiling: `ScopedTimezone` copies the zone name, which allocates for names of 16 or more characters under libstdc++ | the deferred-work review §D: "the UTC chart-day memo finding 4 (the timezone `localtime_r` memo)", under the maintainer's cap. the UTC memo report: an exact zoned memo "would need the zone's transition table, which belongs in `timezone.cpp`" | None; the UTC memo's witness is `tests/test_chart_day_memo.cpp` | A change to `src/timezone.cpp` that builds an exact zone-transition memo (the timezone lock profiling's Sitka case shows why a shortcut is not exact), byte-identical, with the zoned keys of `test_chart_day_memo` still equal to the computed ones | +| A zoned chart-day memo (finding 4 of the UTC memo study) | On a chart with a timezone, `libc_chart_day_key` (`src/source/pine_adapter.cpp:17988`) takes the process timezone lock through `ScopedTimezone` and calls `localtime_r` on every bar, because `on_bar_open` (`src/source/pine_adapter.cpp:22890`) reads `chart_day_key` (`src/source/pine_adapter.cpp:23127`) unconditionally. The non-UTC monthly Sharpe/Sortino walk, `month_key_local` (`src/engine_metrics.cpp:55`), pays one `localtime_r` per equity point. UTC charts use the UTC chart-day memo's memo, `civil_chart_day_key` (`src/source/pine_adapter.cpp:17972`) | Not measured on a zoned chart. The UTC path cost 134 instructions a bar before the UTC chart-day memo's memo and 68 after (the UTC chart-day memo, Linux aarch64, callgrind on the floor). The timezone lock profiling: `ScopedTimezone` copies the zone name, which allocates for names of 16 or more characters under libstdc++ | the deferred-work review §D: "the UTC chart-day memo finding 4 (the timezone `localtime_r` memo)", under the maintainer's cap. the UTC memo report: an exact zoned memo "would need the zone's transition table, which belongs in `timezone.cpp`" | None; the UTC memo's witness is `tests/test_chart_day_memo.cpp` | A change to `src/timezone.cpp` that builds an exact zone-transition memo (the timezone lock profiling's Sitka case shows why a shortcut is not exact), byte-identical, with the zoned keys of `test_chart_day_memo` still equal to the computed ones | | Thread-local reads that remain per call | The per-pump state cache moved the three scopes' writes onto the pump's block, `pump_ambient` (`src/native_execution_consumer.hpp:597`). A reader still reaches the block through `tl_runtime_ambient` (`src/ta_extremes_volume.cpp:30`): `bar_context` (`src/ta_extremes_volume.cpp:83`), read by every `ExtremeRing::update` (`src/ta_extremes_volume.cpp:123`), which backs `ta.highest`, `ta.lowest`, `ta.highestbars` and `ta.lowestbars` and, through an embedded `Highest` and `Lowest`, `ta.stoch`, `ta.wpr` and `ta.range`; and `matching_day_partition` (`src/timeframe.cpp:628`), read by the default-anchored `ta.vwap` through `session_day_index`, by `crosses_boundary` on a DAY period (`timeframe.change("D")`) and by the session-period helpers. `decompose_ms_local` (`src/session_time.cpp:250`) reads two thread-locals per call on a zoned clock. The prepared order storage now caches one idle block per token type through `free_block` (`src/native_order.cpp:957`), replacing the former `free_blocks` vector; prepared allocation and deallocation still reach that thread-local cache. `ema_na_warmup_flag` is read once per EMA, at its first compute | One thread-local access per reader call. The fourth review, macOS arm64, a dlopen'd tutorial-MACD probe, counted by `_tlv_get_addr` breakpoints and by descriptor patching: 1.000 a bar for one `ta::Highest` and for one default `ta::VWAP`, 1.999 for one `pine_hour` on a New York clock, 0.000 on the no-order floor and with orders. On Linux a TLS-descriptor call costs about 20 instructions: the per-pump state cache's 13 → 4 calls a bar was 260 → 80 instructions (Linux aarch64, GCC 13, callgrind). The accepted integration: 2.00 a bar on the zoned probe 064, 0.08 on probe 008 | None for the TA and day-partition readers: the integration acceptance (ledger, 2026-09-24 23:30 UTC) reads "dlopen TLS 13->0/bar", and the accepted integration's report names only the timezone and order-core residuals. This row is their record | Unpublished (the fourth review's scratch) | A byte-identical change that hands these readers the pump's block without a thread-local read. `ta::bar_context()` is script ABI, so its public declaration stays as it is | | "audit-off mode", "LTO", "data-oriented layout" | Named in "Maintainer decision 2026-09-25 00:20 UTC+08:00" among the post-release structural items: "S1 bulk quiet bars, S2 per-strategy specialization, audit-off mode, LTO+PGO, data-oriented layout". No engine or campaign document defines, designs or measures the three | None | The maintainer decision | None | Struck from this inventory until a design note defines one of them | diff --git a/src/source/pine_adapter.cpp b/src/source/pine_adapter.cpp index b0e0e0e03..2c82b6ea5 100644 --- a/src/source/pine_adapter.cpp +++ b/src/source/pine_adapter.cpp @@ -15924,8 +15924,24 @@ native_order::ExecutionTerms PineExecutionAdapter::resolve_source_terms( const bool armed_pure_limit_entry = source.family == PineOrderFamily::Entry && !finite_positive(source.exit_levels.stop); if (std::holds_alternative(trigger) && !armed_pure_limit_entry) { - return directional_tick(facts.raw_price, staged_.syminfo.mintick, - !facts.is_buy); + const double projected = directional_tick(facts.raw_price, staged_.syminfo.mintick, + !facts.is_buy); + // The repair the limit below makes: this projection can sit one + // binary64 ULP past the immutable limit (a buy limit at 99.99 on + // a 0.01 grid projects to 9999 * 0.01 = 99.990000000000009), + // which the generic kernel refuses. Only there, and only for a + // print on the chart grid, the fill books that print's grid + // spelling; an admitted projection is kept bit for bit. + const double limit = std::get(trigger).limit; + if (facts.is_buy ? projected > limit : projected < limit) { + const double print_on_grid = source_level_on_price_grid( + facts.raw_price, staged_.syminfo.mintick); + if (source_bar_fill_tick(print_on_grid, staged_.syminfo.mintick) + == print_on_grid) { + return print_on_grid; + } + } + return projected; } const double level = finite_positive(source.exit_levels.limit) ? source.exit_levels.limit diff --git a/tests/cr150.d/tick-fractional-stop-limit.cmake b/tests/cr150.d/tick-fractional-stop-limit.cmake new file mode 100644 index 000000000..10b040fe2 --- /dev/null +++ b/tests/cr150.d/tick-fractional-stop-limit.cmake @@ -0,0 +1,4 @@ +if(PINEFORGE_BUILD_SOURCE_LAYER) + list(APPEND TEST_SOURCES test_tick_fractional_stop_limit) + pineforge_add_unit_test(test_tick_fractional_stop_limit) +endif() diff --git a/tests/cr150.d/tick-grid-canonical-controls.cmake b/tests/cr150.d/tick-grid-canonical-controls.cmake new file mode 100644 index 000000000..92e143d63 --- /dev/null +++ b/tests/cr150.d/tick-grid-canonical-controls.cmake @@ -0,0 +1,4 @@ +if(PINEFORGE_BUILD_SOURCE_LAYER) + list(APPEND TEST_SOURCES test_tick_grid_canonical_controls) + pineforge_add_unit_test(test_tick_grid_canonical_controls) +endif() diff --git a/tests/test_tick_fractional_stop_limit.cpp b/tests/test_tick_fractional_stop_limit.cpp new file mode 100644 index 000000000..bf447fef8 --- /dev/null +++ b/tests/test_tick_fractional_stop_limit.cpp @@ -0,0 +1,306 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// A stop-limit entry on observed ticks at tick 0.01. The stop is printed at +// its own level, beyond the limit, so the order activates there and cannot +// fill; a later print reaches the limit and one unit enters at that print on +// that bar. The fill projects the print onto its grid index n (floor for a +// buy, ceil for a sell) and books the binary64 product RN(n * tick) whenever +// the kernel admits it against the order's immutable limit (a buy at or +// below it, a sell at or above it). That product is booked as is by design: +// TradingView evaluates its equity and margin thresholds with it, bit for +// bit. For the long limits 99.99, 100.07 and 1.15 the product lies one ULP +// above the limit, so the kernel refused the fill as invalid terms and the +// order never traded, with no error. There the print is on the grid, and the +// fill must book its grid spelling, the double of n / 100: the print itself. +// +// RN is binary64 round to nearest, ties to even; a price in [2^e, 2^(e+1)) +// has ulp 2^(e-52). 0.01 * 2^59 = 5764607523034234.88, so the tick is +// RN(0.01) = 5764607523034235 * 2^-59 = 0.01 + 0.12 * 2^-59 and, exactly, +// n * RN(0.01) = n / 100 + d ulp with d = 0.12 * n * 2^(-7-e). With +// F = (n / 100) / ulp, RN(n / 100) = round(F) and RN(n * tick) = round(F + d): +// +// n e F d F + d RN(n * tick) +// 9999 6 7036170730324623.36 0.1465 ...623.5065 RN(99.99) + 1 ulp +// 10007 6 7041800229858836.48 0.1466 ...836.6266 RN(100.07) + 1 ulp +// 115 0 5179139571476070.4 0.1078 ...070.5078 RN(1.15) + 1 ulp +// 10001 6 7037578105208176.64 0.1465 ...176.7865 RN(100.01) +// 9900 6 6966505673588736 0.1450 ...736.1450 99 +// 10100 6 7107243161944064 0.1479 ...064.1479 101 +// 7 -4 5044031582654955.52 0.1050 ...955.6250 RN(0.07) +// 29 -2 5224175567749775.36 0.1088 ...775.4688 RN(0.29) +// 20000 7 7036874417766400 0.1465 ...400.1465 200 +// +// The 115 row in integers: 115 * 5764607523034235 = 662929865148937025, and +// 662929865148937025 / 2^7 = 5179139571476070.5078125. So the buy rows at +// 99.99 (0x1.8ff5c28f5c28fp+6), 100.07 (0x1.9047ae147ae14p+6) and 1.15 +// (0x1.2666666666666p+0) project one ULP above their limits, to +// 0x1.8ff5c28f5c290p+6, 0x1.9047ae147ae15p+6 and 0x1.2666666666667p+0; a sell +// at 1.15 admits 0x1.2666666666667p+0 = 1.1500000000000001; every other +// product equals its limit. Each print p below is RN(n / 100), and +// p / RN(0.01) lies within 1e-11 of n (three roundings of relative size at +// most 2^-53, n <= 20000), so floor(p / tick + 1e-9) and ceil(p / tick - 1e-9) +// both select n, and n / 100 rounds back to p. +// +// Before the fix the three refused rows print, for each of touch, +// delayed-touch (entry_bar=3), double-quantity and earlier-close: +// FAIL long-fraction-99.99/touch: expected trades=1 entry_bar=2 +// entry_price=99.989999999999995 (0x1.8ff5c28f5c28fp+6); actual trades=0 +// entry_bar=-1 entry_price=nan (nan); position=0 pending=0 +// and likewise with 100.06999999999999 (0x1.9047ae147ae14p+6) and +// 1.1499999999999999 (0x1.2666666666666p+0): the refusal is terminal, so +// nothing stays pending. Their better-print input passes there too (one tick, +// 1 * RN(0.01), is admitted by every buy limit), as do unreached-stop and +// untouched-limit, which never enter. Every other row passes before and after. + +namespace { +using namespace pineforge; +constexpr std::int64_t kHour = 60 * 60 * 1000; +constexpr double kTick = 0.01; +constexpr double kNa = std::numeric_limits::quiet_NaN(); + +struct Case { + const char* name; + bool is_long; + double stop; + double limit; + double print; // the print that reaches the limit after activation + double index; // its grid index n, derived above + double product; // RN(n * tick), derived above + double expected; // the booked entry: the product, or n / 100 if refused +}; + +enum class Input { + Touch, DelayedTouch, BetterPrint, UnreachedStop, UntouchedLimit, DoubleQuantity, EarlierClose +}; + +const char* input_name(Input input) { + switch (input) { + case Input::Touch: return "touch"; + case Input::DelayedTouch: return "delayed-touch"; + case Input::BetterPrint: return "better-print"; + case Input::UnreachedStop: return "unreached-stop"; + case Input::UntouchedLimit: return "untouched-limit"; + case Input::DoubleQuantity: return "double-quantity"; + case Input::EarlierClose: return "earlier-close"; + } + throw std::logic_error("unknown input"); +} + +// The derivation above, checked on its own arithmetic before any order runs: +// a slip in it fails here, whatever the engine does, not as an order row. +bool geometry(const Case& row) { + const double scaled = row.print / kTick; + const double index = row.is_long ? std::floor(scaled + 1e-9) : std::ceil(scaled - 1e-9); + const double product = index * kTick; + const double decimal = index / 100; + const bool ok = index == row.index && product == row.product && decimal == row.print; + std::printf("%s geometry %s: index=%.17g (derived %.17g) product=%a (derived %a) " + "decimal=%a (print %a)\n", + ok ? "PASS" : "FAIL", row.name, index, row.index, product, row.product, + decimal, row.print); + return ok; +} + +class Probe final : public source::PineStrategyHost { +public: + Probe(const Case& row, Input input) : row_(row), input_(input) { + source::PineStrategyConfig config; + config.initial_capital = 1000000; + config.default_qty_type = static_cast(QtyType::FIXED); + config.default_qty_value = 1; + config.process_orders_on_close = true; + config.calc_on_order_fills = false; + config.commission_value = 0; + config.slippage = 0; + configure_pine_strategy(config); + set_syminfo_mintick(kTick); + set_syminfo_timezone("UTC"); + set_syminfo_session("24x7"); + } + + void on_source_bar(const Bar&) override { + // Index 0 is historical warmup. Submit once at streamed close 1, + // before the activation prints in 2. Never resubmit a rejected order. + if (bar_index_ == 1) + strategy_entry("Entry", row_.is_long, row_.limit, row_.stop, + input_ == Input::DoubleQuantity ? 2 * 1.0 : 1.0); + if (bar_index_ == (input_ == Input::EarlierClose ? 3 : 4)) { + strategy_cancel("Entry"); + if (signed_position_size() != 0) strategy_close("Entry"); + } + } + +private: + Case row_; + Input input_; +}; + +void require_stream(bool ok, const Probe& probe, const char* operation) { + if (!ok) throw std::runtime_error(std::string(operation) + ": " + probe.last_error()); +} + +void drive(Probe& probe, const Case& row, Input input) { + // Prices begin strictly on the untriggered side: buy < stop, sell > stop. + // The first streamed slot starts one hour after non-negative warmup 0. + const double before = row.limit + (row.is_long ? -kTick : kTick); + const Bar warmup{before, before, before, before, 1, 0}; + require_stream(probe.stream_begin(&warmup, 1, "60", "60"), probe, "begin"); + std::uint64_t sequence = 0; + const int touch_bar = input == Input::DelayedTouch ? 3 : 2; + // Input mutants use exact, strictly better grid prints: 1 cent < every + // buy limit, 200 > every sell limit. These falsify the entry-price bound. + const double reached = input == Input::BetterPrint + ? (row.is_long ? 0.01 : 200.0) : row.print; + for (int bar = 1; bar <= 5; ++bar) { + std::array prices{{before, before, before, before}}; + if (bar >= 2) { + prices.fill(reached); + if (input == Input::UnreachedStop) { + prices.fill(row.print); + } else if (input == Input::UntouchedLimit || bar < touch_bar) { + prices.fill(row.stop); + } else if (bar == touch_bar) { + // Distinct observed prints, not an interpolated OHLC path: + // reach stop at equality, then reach the limit. + prices = {{row.stop, row.stop, reached, reached}}; + } + } + for (std::size_t i = 0; i < prices.size(); ++i) { + const TradeTick tick{bar * kHour + static_cast(i) * (kHour / 4), + ++sequence, prices[i], 1.0 / 4}; + require_stream(probe.stream_push_tick(tick), probe, "push tick"); + } + require_stream(probe.stream_advance_time((bar + 1) * kHour), probe, "seal close"); + } + require_stream(probe.stream_end(false), probe, "end"); + if (!probe.last_error().empty() || probe.last_run_status() != 0) + throw std::runtime_error("run status " + std::to_string(probe.last_run_status()) + + ": " + probe.last_error()); +} + +bool check(const Case& row, Input input) { + // Stop equality in bar 2 activates the order, but cannot fill it: + // buy stop > limit, sell stop < limit. The first later eligible print + // reaches the limit, so one unit enters at that print on bar 2. + // Delaying the return until bar 3 changes only the expected entry bar. + // A close submitted on bar 4 sees its first eligible print at open 5; + // five streamed bars therefore produce exactly one CLOSED trade. + // Removing either activation or every limit touch instead gives zero. + // Two further input mutants double the requested unit or move the close + // from 4 to 3: quantity becomes 2*1, or exit becomes 3+1 instead of 4+1. + // BetterPrint books 1 * RN(0.01) = 0.01 for a buy and 200 for a sell + // (n = 20000 above), each admitted by every limit here. + const bool fills = input != Input::UnreachedStop && input != Input::UntouchedLimit; + const int expected_count = fills ? 1 : 0; + const int expected_bar = fills ? (input == Input::DelayedTouch ? 3 : 2) : -1; + const double expected_price = fills + ? (input == Input::BetterPrint ? (row.is_long ? 0.01 : 200.0) : row.expected) : kNa; + const double expected_qty = input == Input::DoubleQuantity ? 2 * 1.0 : 1.0; + const int expected_exit = input == Input::EarlierClose ? 3 + 1 : 4 + 1; + Probe probe(row, input); + std::string error; + try { + drive(probe, row, input); + } catch (const std::exception& exception) { + error = exception.what(); + } + const int actual_count = probe.trade_count(); + int actual_bar = -1; + double actual_price = kNa; + bool trade_ok = true; + if (actual_count > 0) { + const auto trade = probe.get_trade(0); + actual_bar = trade.entry_bar_index; + actual_price = trade.entry_price; + const bool within_limit = row.is_long + ? actual_price <= row.limit : actual_price >= row.limit; + trade_ok = fills && actual_bar == expected_bar + && std::isfinite(actual_price) && actual_price == expected_price + && within_limit && trade.qty == expected_qty + && !trade.open_at_end && trade.exit_bar_index == expected_exit; + } + const bool ok = error.empty() && actual_count == expected_count && trade_ok + && probe.live_position_size() == 0 && probe.pending_order_count() == 0; + std::printf("%s %s/%s: expected trades=%d entry_bar=%d entry_price=%.17g (%a); " + "actual trades=%d entry_bar=%d entry_price=%.17g (%a); " + "position=%.17g pending=%d%s%s\n", + ok ? "PASS" : "FAIL", row.name, input_name(input), + expected_count, expected_bar, expected_price, expected_price, + actual_count, actual_bar, actual_price, actual_price, + probe.live_position_size(), probe.pending_order_count(), + error.empty() ? "" : "; error=", error.c_str()); + if (!ok && actual_count > 0) { + const auto trade = probe.get_trade(0); + std::printf(" expected qty=%.17g closed=true exit_bar=%d; " + "actual qty=%.17g closed=%s exit_bar=%d\n", + expected_qty, expected_exit, trade.qty, + trade.open_at_end ? "false" : "true", trade.exit_bar_index); + } + return ok; +} +} // namespace + +int main() { + // Every quote is a decimal literal, the double nearest its cents, never + // built as n * mintick. Whole-price controls share stop 100 with their + // fractional counterparts. For small quotes, adjacent decimal cents keep + // the stop strictly outside the limit; all stops are actually printed + // after the submission close. + const Case cases[] = { + // Refused before the fix: the buy product is one ULP above the limit. + // The fill books the print's grid spelling, n / 100 = the limit. + {"long-fraction-99.99", true, 100.00, 99.99, 99.99, 9999, + 0x1.8ff5c28f5c290p+6, 99.99}, + {"long-fraction-100.07", true, 100.08, 100.07, 100.07, 10007, + 0x1.9047ae147ae15p+6, 100.07}, + {"long-fraction-1.15", true, 1.16, 1.15, 1.15, 115, + 0x1.2666666666667p+0, 1.15}, + // The same product admitted by a sell limit at 1.15 is booked as is: + // 1.1500000000000001, one ULP above the limit and the print. + {"short-fraction-1.15", false, 1.14, 1.15, 1.15, 115, + 0x1.2666666666667p+0, 0x1.2666666666667p+0}, + // Products equal to their limits, admitted and booked as is. + {"short-fraction-100.01", false, 100.00, 100.01, 100.01, 10001, 100.01, 100.01}, + {"long-whole-99", true, 100.00, 99.00, 99.00, 9900, 99.00, 99.00}, + {"short-whole-101", false, 100.00, 101.00, 101.00, 10100, 101.00, 101.00}, + {"long-fraction-0.07", true, 0.08, 0.07, 0.07, 7, 0.07, 0.07}, + {"short-fraction-0.07", false, 0.06, 0.07, 0.07, 7, 0.07, 0.07}, + {"long-fraction-0.29", true, 0.30, 0.29, 0.29, 29, 0.29, 0.29}, + {"short-fraction-0.29", false, 0.28, 0.29, 0.29, 29, 0.29, 0.29}, + // Off the grid: the limit 99.995 lies half a tick between grid + // points, past the entry's 0.0001-point snap, so the kernel's limit + // is RN(99.995) = 7036522574045512 * 2^-46 (F = 7036522574045511.68). + // The on-grid print 99.99 reaches it and projects to n = 9999, whose + // product 0x1.8ff5c28f5c290p+6 = 99.990000000000009 is below that + // limit: admitted, and booked as is, one ULP above the print. + {"long-offgrid-limit-99.995", true, 100.01, 99.995, 99.99, 9999, + 0x1.8ff5c28f5c290p+6, 0x1.8ff5c28f5c290p+6}, + }; + int failures = 0; + int geometry_count = 0; + for (const auto& row : cases) { + ++geometry_count; + if (!geometry(row)) ++failures; + } + int count = 0; + for (const auto& row : cases) { + for (Input input : {Input::Touch, Input::DelayedTouch, Input::BetterPrint, + Input::UnreachedStop, Input::UntouchedLimit, + Input::DoubleQuantity, Input::EarlierClose}) { + ++count; + if (!check(row, input)) ++failures; + } + } + std::printf("%s observed tick stop-limit: %d geometry checks, %d cases, %d failures\n", + failures ? "FAIL" : "PASS", geometry_count, count, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_tick_grid_canonical_controls.cpp b/tests/test_tick_grid_canonical_controls.cpp new file mode 100644 index 000000000..c08538b0c --- /dev/null +++ b/tests/test_tick_grid_canonical_controls.cpp @@ -0,0 +1,259 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Controls for the observed-tick stop-limit fill on ticks other than 0.01, in +// the same shape: the stop prints at its level beyond the limit and activates +// the order, a later print at the limit enters one unit on that bar, and the +// fill books RN(n * tick), the binary64 product of the print's grid index n +// (floor for a buy, ceil for a sell). The kernel admits every product here +// against its limit, so each row books that product, bit for bit, before and +// after the stop-limit repair; a change to that booking turns a row red. +// +// RN is binary64 round to nearest, ties to even; a price in [2^e, 2^(e+1)) +// has ulp 2^(e-52). +// - 0.05 * 2^56 = 3602879701896396.8, so RN(0.05) = 3602879701896397 * 2^-56 +// = 0.05 * (1 + 2^-54), and n * RN(0.05) = (n / 20) * (1 + 2^-54) exactly. +// n = 20: 1 + 2^-54, a quarter ulp above 1, rounds to 1. +// n = 23: 1.15 * 2^52 = 5179139571476070.4, so RN(1.15) = ...070 * 2^-52 +// = 0x1.2666666666666p+0, and the product is ...070.4 + 1.15 * 2^-2 = +// ...070.6875 ulp (in integers 23 * 3602879701896397 = 82866233143617131 +// = 16 * 5179139571476070 + 11), which rounds to ...071: +// 0x1.2666666666667p+0 = 1.1500000000000001, one ULP above the sell limit. +// A sell admits it, and the fill books it as is: the booked price keeps the +// binary product by design, since TradingView evaluates its equity and +// margin thresholds with that product. +// n = 4000 (the better sell print 200): 200 + 200 * 2^-54 = 200 + 0.39 ulp, +// which rounds to 200. +// - 0.25 is exact, and so are 5 * 0.25 = 1.25 and 800 * 0.25 = 200. +// - 0.0004 * 2^64 = 7378697629483820.6464, so RN(0.0004) = 7378697629483821 +// * 2^-64 = 0.0004 + 0.3536 * 2^-64. n = 2500: 1 + 884 * 2^-64 = 1 + 0.2158 +// ulp, which rounds to 1. n = 500000 (the better sell print 200): 200 + +// 176800 * 2^-64 = 200 + 0.3372 ulp, which rounds to 200. +// - The better buy print is one tick, n = 1: the product is the tick itself. +// Each print p is the decimal literal of its grid point, and p / tick lies +// within 2e-10 of n (three roundings of relative size at most 2^-53, +// n <= 500000), so floor(p / tick + 1e-9) and ceil(p / tick - 1e-9) both +// select n. Every row passes before and after the repair. + +namespace { +using namespace pineforge; +constexpr std::int64_t kHour = 60 * 60 * 1000; +constexpr double kNa = std::numeric_limits::quiet_NaN(); + +struct Case { + const char* name; + bool is_long; + double tick; + double stop; + double limit; // also the print that reaches it after activation + double index; // its grid index n, derived above + double product; // RN(n * tick), derived above: the booked entry price +}; + +enum class Input { + Touch, DelayedTouch, BetterPrint, UnreachedStop, UntouchedLimit, DoubleQuantity, EarlierClose +}; + +const char* input_name(Input input) { + switch (input) { + case Input::Touch: return "touch"; + case Input::DelayedTouch: return "delayed-touch"; + case Input::BetterPrint: return "better-print"; + case Input::UnreachedStop: return "unreached-stop"; + case Input::UntouchedLimit: return "untouched-limit"; + case Input::DoubleQuantity: return "double-quantity"; + case Input::EarlierClose: return "earlier-close"; + } + throw std::logic_error("unknown input"); +} + +// The derivation above, checked on its own arithmetic before any order runs: +// a slip in it fails here, whatever the engine does, not as an order row. +bool geometry(const Case& row) { + const double scaled = row.limit / row.tick; + const double index = row.is_long ? std::floor(scaled + 1e-9) : std::ceil(scaled - 1e-9); + const double product = index * row.tick; + const bool admitted = row.is_long ? product <= row.limit : product >= row.limit; + const bool ok = index == row.index && product == row.product && admitted; + std::printf("%s geometry %s: tick=%.17g index=%.17g (derived %.17g) " + "product=%a (derived %a) admitted=%s\n", + ok ? "PASS" : "FAIL", row.name, row.tick, index, row.index, product, + row.product, admitted ? "true" : "false"); + return ok; +} + +class Probe final : public source::PineStrategyHost { +public: + Probe(const Case& row, Input input) : row_(row), input_(input) { + source::PineStrategyConfig config; + config.initial_capital = 1000000; + config.default_qty_type = static_cast(QtyType::FIXED); + config.default_qty_value = 1; + config.process_orders_on_close = true; + config.calc_on_order_fills = false; + config.commission_value = 0; + config.slippage = 0; + configure_pine_strategy(config); + set_syminfo_mintick(row.tick); + set_syminfo_timezone("UTC"); + set_syminfo_session("24x7"); + } + + void on_source_bar(const Bar&) override { + // Warm up at 0, submit at streamed close 1, activate in 2. + if (bar_index_ == 1) + strategy_entry("Entry", row_.is_long, row_.limit, row_.stop, + input_ == Input::DoubleQuantity ? 2 * 1.0 : 1.0); + if (bar_index_ == (input_ == Input::EarlierClose ? 3 : 4)) { + strategy_cancel("Entry"); + if (signed_position_size() != 0) strategy_close("Entry"); + } + } + +private: + Case row_; + Input input_; +}; + +void require_stream(bool ok, const Probe& probe, const char* operation) { + if (!ok) throw std::runtime_error(std::string(operation) + ": " + probe.last_error()); +} + +void drive(Probe& probe, const Case& row, Input input) { + const double before = row.limit + (row.is_long ? -row.tick : row.tick); + const Bar warmup{before, before, before, before, 1, 0}; + require_stream(probe.stream_begin(&warmup, 1, "60", "60"), probe, "begin"); + std::uint64_t sequence = 0; + const int touch_bar = input == Input::DelayedTouch ? 3 : 2; + // One tick is strictly below every buy limit, and 200 is strictly above + // every sell limit; both are on every grid here (n derived above). These + // mutants must change the entry-price bound. + const double reached = input == Input::BetterPrint + ? (row.is_long ? row.tick : 200.0) : row.limit; + for (int bar = 1; bar <= 5; ++bar) { + std::array prices{{before, before, before, before}}; + if (bar >= 2) { + prices.fill(reached); + if (input == Input::UnreachedStop) { + prices.fill(row.limit); + } else if (input == Input::UntouchedLimit || bar < touch_bar) { + prices.fill(row.stop); + } else if (bar == touch_bar) { + prices = {{row.stop, row.stop, reached, reached}}; + } + } + for (std::size_t i = 0; i < prices.size(); ++i) { + const TradeTick tick{bar * kHour + static_cast(i) * (kHour / 4), + ++sequence, prices[i], 1.0 / 4}; + require_stream(probe.stream_push_tick(tick), probe, "push tick"); + } + require_stream(probe.stream_advance_time((bar + 1) * kHour), probe, "seal close"); + } + require_stream(probe.stream_end(false), probe, "end"); + if (!probe.last_error().empty() || probe.last_run_status() != 0) + throw std::runtime_error("run status " + std::to_string(probe.last_run_status()) + + ": " + probe.last_error()); +} + +bool check(const Case& row, Input input) { + // At bar 2 the stop print activates, but is worse than the limit and + // cannot fill. The next print is exactly the written limit: entry is + // bar 2 at its product, one unit. DelayedTouch moves that print to bar 3. + // UnreachedStop removes activation; UntouchedLimit removes every + // eligible print: either gives zero trades. BetterPrint falsifies price, + // DoubleQuantity falsifies qty=1, EarlierClose falsifies exit_bar=4+1. + // Closing at close 4 first executes on open 5, so the trade is closed. + const bool fills = input != Input::UnreachedStop && input != Input::UntouchedLimit; + const int expected_count = fills ? 1 : 0; + const int expected_bar = fills ? (input == Input::DelayedTouch ? 3 : 2) : -1; + const double expected_price = fills + ? (input == Input::BetterPrint ? (row.is_long ? row.tick : 200.0) : row.product) + : kNa; + const double expected_qty = input == Input::DoubleQuantity ? 2 * 1.0 : 1.0; + const int expected_exit = input == Input::EarlierClose ? 3 + 1 : 4 + 1; + Probe probe(row, input); + std::string error; + try { + drive(probe, row, input); + } catch (const std::exception& exception) { + error = exception.what(); + } + const int actual_count = probe.trade_count(); + int actual_bar = -1; + double actual_price = kNa; + bool trade_ok = true; + if (actual_count > 0) { + const auto trade = probe.get_trade(0); + actual_bar = trade.entry_bar_index; + actual_price = trade.entry_price; + const bool within_limit = row.is_long + ? actual_price <= row.limit : actual_price >= row.limit; + trade_ok = fills && actual_bar == expected_bar + && std::isfinite(actual_price) && actual_price == expected_price + && within_limit && trade.qty == expected_qty + && !trade.open_at_end && trade.exit_bar_index == expected_exit; + } + const bool ok = error.empty() && actual_count == expected_count && trade_ok + && probe.live_position_size() == 0 && probe.pending_order_count() == 0; + std::printf("%s %s/%s tick=%.17g limit=%.17g: " + "expected trades=%d entry_bar=%d entry_price=%.17g (%a); " + "actual trades=%d entry_bar=%d entry_price=%.17g (%a); " + "position=%.17g pending=%d%s%s\n", + ok ? "PASS" : "FAIL", row.name, input_name(input), row.tick, row.limit, + expected_count, expected_bar, expected_price, expected_price, + actual_count, actual_bar, actual_price, actual_price, + probe.live_position_size(), probe.pending_order_count(), + error.empty() ? "" : "; error=", error.c_str()); + if (!ok && actual_count > 0) { + const auto trade = probe.get_trade(0); + std::printf(" expected qty=%.17g closed=true exit_bar=%d; " + "actual qty=%.17g closed=%s exit_bar=%d\n", + expected_qty, expected_exit, trade.qty, + trade.open_at_end ? "false" : "true", trade.exit_bar_index); + } + return ok; +} +} // namespace + +int main() { + const Case cases[] = { + // 20 * RN(0.05) = 1; the stops lie one tick beyond the limit. + {"long-nickel", true, 0.05, 1.05, 1.00, 20, 1.00}, + {"short-nickel", false, 0.05, 0.95, 1.00, 20, 1.00}, + // 23 * RN(0.05) = 1.1500000000000001, one ULP above the sell limit + // 1.15 and admitted by it: booked as is, the binary product. + {"short-nickel-1.15", false, 0.05, 1.10, 1.15, 23, 0x1.2666666666667p+0}, + // 5 * 0.25 = 1.25, exact. + {"long-quarter", true, 0.25, 1.50, 1.25, 5, 1.25}, + {"short-quarter", false, 0.25, 1.00, 1.25, 5, 1.25}, + // 2500 * RN(0.0004) = 1; the stops differ by one tick from 1. + {"long-small-tick", true, 0.0004, 1.0004, 1.0000, 2500, 1.0000}, + {"short-small-tick", false, 0.0004, 0.9996, 1.0000, 2500, 1.0000}, + }; + int failures = 0; + int geometry_count = 0; + for (const auto& row : cases) { + ++geometry_count; + if (!geometry(row)) ++failures; + } + int count = 0; + for (const auto& row : cases) { + for (Input input : {Input::Touch, Input::DelayedTouch, Input::BetterPrint, + Input::UnreachedStop, Input::UntouchedLimit, + Input::DoubleQuantity, Input::EarlierClose}) { + ++count; + if (!check(row, input)) ++failures; + } + } + std::printf("%s stop-limit tick grid controls: %d geometry checks, %d cases, " + "%d failures\n", failures ? "FAIL" : "PASS", geometry_count, count, failures); + return failures == 0 ? 0 : 1; +}