diff --git a/docs/pages/abi-stability.md b/docs/pages/abi-stability.md index 9877967a..d573b75c 100644 --- a/docs/pages/abi-stability.md +++ b/docs/pages/abi-stability.md @@ -119,14 +119,24 @@ Rebuild generated and native C++ objects against matching engine headers and runtime. R4-D L1 advanced `BacktestEngine`, `NativeStrategyHost`, and the private consumer to `engine_script_run_v17`; R5 L6 advances the same three to `engine_script_run_v18` for the native higher-timeframe host surface -(`on_native_timeframe_bar`, `native_series_bar`), and the host capability macro +(`on_native_timeframe_bar`, `native_series_bar`), joined by R5 L4's margin +surface (`resolve_margin_call_units`, `on_native_margin_call`, +`native_liquidation_price`) and R5 L5's calculation-timing surface +(`on_native_recalculate`, `on_native_sub_bar`, `current_partial_bar`), and the +host capability macro is `PINEFORGE_HAS_NATIVE_STRATEGY_HOST_V18`. L3b removes the source compatibility order type; `pineforge-source-adapter/v2` hashes adapter and scheduler state instead. Native request/core/event values are `native_order_v6`, the private consumer identity is `native-consumer/v7`, driver types are `native_driver_v5`, and run specs are `native_run_spec_v3` (R5 L6 adds `NativeRunSpec::subscriptions`, folded into -the continuation hash only when it is non-empty). +the continuation hash only when it is non-empty; R5 L4 adds +`NativeRunSpec::margin`, folded only when it is set; R5 L5 adds `calculation`, +`max_recalculations_per_point` and `open_bar_view`, folded only once the +trigger or the open-bar view is non-default). R5 L4 also adds +`RequestDefinition::origin` and the `MarginCallEvent` alternative to +`native_order_v6`; `RequestOrigin::Host` — every host request — folds nothing, +so no established continuation hash moves. | Matrix role | Internal identity | | --- | --- | diff --git a/docs/pages/native-engine.md b/docs/pages/native-engine.md index 5beacad6..45e14a98 100644 --- a/docs/pages/native-engine.md +++ b/docs/pages/native-engine.md @@ -139,6 +139,10 @@ Always set, with documented defaults in the header: - `report_policy`: `HostRecorded` (default) or `KernelRecorded`; `report_open_position_at_end`: `false` (default). See *Reporting for native hosts* below. +- `calculation`: `BarClose` (default), `BarCloseAndFills` or + `EveryModeledPoint`; `max_recalculations_per_point`: `8` (default, any + value including 0 is legal); `open_bar_view`: `Complete` (default) or + `OpenOnly`. See *Calculation timing* below. Optional, absent unless set: @@ -147,6 +151,9 @@ Optional, absent unless set: - `max_open_lots`: positive; surviving + new lots - `initial_margin_fraction`: finite > 0 as a fraction, not a percent. Opening admission only; no maintenance liquidation. +- `margin`: the generic per-side broker margin model. Mutually exclusive with + `initial_margin_fraction` (setting both is `MarginModelConflict`). See + *Margin and liquidation* below. - `subscriptions`: declared higher-timeframe series of the run's own symbol. Empty is the whole default surface; see "Higher-timeframe series for native hosts" below. @@ -483,6 +490,220 @@ already presented open/high/low/close. close point may match, still obeying birth ordinal/floor. It is not a replay of observed prints. +## Margin and liquidation + +`NativeRunSpec::margin` is the whole generic broker margin model, and it is +opt-in. A spec that leaves it unset keeps `initial_margin_fraction`'s +one-scalar opening gate, never enters a liquidation path, and folds nothing +new into the continuation digest. The two spellings are mutually exclusive: +declaring both fails validation with `MarginModelConflict`. + +```cpp +NativeMarginModel margin; +margin.initial_long = 0.5; // fractions, not percents; both > 0 +margin.initial_short = 0.5; +margin.maintenance_long = 0.25; // absent = that side never liquidates +margin.maintenance_short = 0.25; +margin.sizing = NativeLiquidationSizing::RestoreMinimum; +margin.shortfall_multiple = 1.0; // used by ShortfallMultiple +margin.liquidation_min_units = 1.0; // optional broker minimum trade +margin.check = NativeLiquidationCheck::PathAdverseExtreme; +spec.margin = margin; +``` + +**Opening admission.** With a model set, `initial_long` / `initial_short` +replace the single scalar for that run: an opening is refused with +`MatchRejectReason::InitialMargin` when +`resulting_abs_notional × initial_ > marked_equity − ticket`. A host +that answers `AdmitWithHostMargin` from `validate_execution_precommit` still +takes that one check over, exactly as before. + +**The liquidation level.** With a maintenance fraction for the live side, the +kernel solves the one price where the marked equity meets the maintenance +requirement: + +``` +equity(P) = capital + realized − open entry fees + dir × (P × Q − Σ qty×price) × pv × fx +required(P) = Q × P × pv × fx × maintenance +L : equity(L) == required(L) +``` + +Both sides are affine in `P`, so `L` is unique unless the slopes coincide — +`maintenance == 1.0` on a LONG, where equity and requirement move together and +no price solves the breach. `NativeStrategyHost::native_liquidation_price()` +answers `L`, or `nullopt` when the run declares no model, the side has no +maintenance fraction, the book is flat, or no finite price solves it. It is +the exact level: no tick rounding, which is a source-layer spelling. + +**Arming (`PathAdverseExtreme`, the default).** At every script-bar open and +after every applied fill, the kernel measures the requirement against the most +adverse price the modeled script path still reaches after the current +waypoint — the same sizing mark a whole-bar broker check would use. If that +mark breaches, the kernel rests its own `Reduce` (or `Flatten`) with +`Stop{L}`, bound to the live book. The reduction therefore *fills at the +liquidation level*, where the account actually runs out of margin, while it is +*sized at* the adverse mark. With a declared `IntrabarPath` there is no +whole-bar waypoint model: the kernel re-evaluates at each delivered sample. + +The units come from `sizing`: + +| `sizing` | units | +| --- | --- | +| `RestoreMinimum` | `(required(mark) − equity(mark)) / (mark × pv × fx × maintenance)` — the fewest units that restore the requirement | +| `ShortfallMultiple` | that restore × `shortfall_multiple` | +| `Flatten` | the whole position | + +The result is capped at the held quantity, floored onto `quantity_grid` when +one is configured, and replaced by a full flatten when `liquidation_min_units` +is set and the computed slice falls below it. A host override of +`resolve_margin_call_units(const NativeMarginCallView&)` has the last word and +is clamped into `(0, held]`. + +**Re-pricing.** Exactly one kernel liquidation is live at a time. When the +level or the units move — typically after a host fill changes the book — the +previous one is withdrawn with `CancelReason::Superseded` before the new one +is accepted. A book that is flat or no longer breached withdraws it outright. + +**`CalculationOnly`.** The kernel rests nothing and tests the requirement only +at a script calculation point, against that bar's close. A breach there is +liquidated immediately as a current execution. Nothing fills mid-path. + +**Events and hooks.** A kernel-issued request carries +`RequestDefinition::origin == RequestOrigin::KernelLiquidation`; every host +request is `RequestOrigin::Host`. When it fills, a `MarginCallEvent` joins the +command history directly after its own `ExecutionAppliedEvent`, carrying the +mark, the marked equity and requirement at that mark, the solved liquidation +price, the filled units and the signed book on either side. The host sees +`on_native_applied` first and `on_native_margin_call` immediately after, with +the same cursor. + +The TradingView margin call is **not** this model: its rounded-money rules, +its 4× shortfall default, its one-contract long money call and its +adverse-extreme fill pricing stay in the Pine adapter, which never sets +`margin`. + +## Calculation timing + +`NativeRunSpec::calculation` decides when the kernel asks the host to +calculate. It is `NativeCalculationTrigger::BarClose` by default, which is the +established surface exactly: one calculation per script bar, at its close. The +source layer never sets this field, so Pine-compatible runs are unchanged, and +the spec folds the block into the continuation hash only once the trigger or +the open-bar view is non-default. + +Every calculation — including the script bar's own — is delivered through + +```cpp +virtual void on_native_recalculate(const Bar& bar, const NativeDecisionContext& ctx, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause); +``` + +whose default forwards to `on_native_bar(bar, ctx)`. A host that implements +only `on_native_bar` therefore sees precisely what it saw before this field +existed. `NativeCalculationReason` is `BarClose` (the script bar's own +calculation, `cause == nullptr`), `OrderFill` (one recalculation at an applied +execution's cursor, `cause` being that event, valid only for the call), +`Tick` (one recalculation at a modeled point or an observed print) and +`SubBar`, which is reserved: a lower-timeframe sub-bar has its own hook and is +never delivered through `on_native_recalculate`. + +The triggers are a strict superset chain, so opting in never removes a +calculation: + +- `BarClose` (default): the script bar's calculation only. +- `BarCloseAndFills`: additionally one `OrderFill` recalculation per applied + execution (Pine's `calc_on_order_fills`, without its TradingView specifics). +- `EveryModeledPoint`: additionally one `Tick` recalculation at every modeled + point of the delivered path — each confirmed OHLC waypoint, each intrabar + sample — and at every observed print, in batch and in a stream alike + (Pine's `calc_on_every_tick`, generically). + +### Chronology contract + +At **one** point, in this order and no other: + +1. Match and settle. +2. `on_native_applied` for each applied event, FIFO, through the existing + notification drain under its re-entrancy guard. +3. With `BarCloseAndFills` or `EveryModeledPoint`, one `OrderFill` + recalculation at that event's own cursor, immediately after its + `on_native_applied`, driven from the same drain. It is bounded by + `max_recalculations_per_point` (default 8) **per matching point**: + executions a callback drives through `execute_current` land at the same + cursor and spend the same budget, so a host that refills on its own fill + cannot cascade without end. Beyond the bound the execution is still + applied and still delivered to `on_native_applied`; only the calculation + it would have driven is dropped. `native_recalculation_count()` and + `native_recalculations_skipped()` report both totals. +4. With `EveryModeledPoint`, one `Tick` recalculation after the point's + matching is finished. `on_native_tick` stays the observation hook and + still runs **before** the print is matched. + +A request born in any of these callbacks follows the existing birth rule +unchanged: it is eligible on the unconsumed rest of the bar, which for a +market request means the next discrete matching point. Nothing about the +drain order, the birth rule or language-state rollback moves — the kernel +never attempts rollback; that stays a source-layer concern. + +A recalculation records **no** report point. Under +`NativeReportPolicy::KernelRecorded` the equity curve still has exactly one +point per script bar whatever the trigger is. + +### Sub-bars + +```cpp +virtual void on_native_sub_bar(const Bar& sub, const NativeDecisionContext& ctx); +``` + +fires once after each retained lower-timeframe sub-bar's whole matching path, +before the next sub-bar's. It is not a cadence: it fires whatever +`calculation` is. It requires a real lower feed +(`IntrabarPath::lower_tf`) — a synthesized path and a plain confirmed bar have +no sub-bars of their own, so it never fires for them. The decision point is +the sub-bar's last modeled point, so commands and `execute_current` are legal +and a request born there follows the ordinary birth rule. + +### The bar so far, and the open-bar view + +```cpp +std::optional current_partial_bar() const; // non-virtual +``` + +answers the lookahead-free bar so far at the current cursor: the open of this +script bar's first modeled point, the running high/low, and the close at the +cursor. `volume` accrues only activity actually consumed — the completed +lower-timeframe sub-bars of an intrabar path, or the observed prints of a +stream — and stays 0 for a modeled path that carries no intrabar volume of its +own. It is valid in the bar-open, applied, tick, sub-bar and recalculation +callbacks, and is `nullopt` outside a path walk, including in the bar's own +close calculation, where the host already holds the complete bar. + +This matters because the mid-bar callbacks are handed the **complete** script +bar: `on_native_bar_open` receives the whole bar by default, and so does every +`OrderFill` / batch `Tick` recalculation. That is deliberate — a host that +schedules against the bar's own high/low needs it, and the adapter relies on +it — but it is lookahead. `current_partial_bar()` is the answer for a host +that must not see it. + +`NativeRunSpec::open_bar_view` masks exactly one callback: +`NativeOpenBarView::OpenOnly` hands `on_native_bar_open` (and `current_bar_` +while it runs) `H = L = C = open` and volume 0. `Complete` (default) is +unchanged. The mask is presentation only: the complete bar is restored before +the open match, so matching, fills, excursions and every later callback are +byte-for-byte what `Complete` books. + +### What stays in the source layer + +TradingView's COOF specifics are **not** reproduced here (design ruling R5-5): +the waypoint-only refill, the two-fills-at-open rule, the script-state +snapshot/restore and the adapter's own cascade guard and deferral queue all +remain in `src/source`. A native `BarCloseAndFills` host running the adapter's +refill rule reaches the same book with the same order ids in the same order, +but the coordinates a request born in a recalculation fills at differ: the +adapter re-presents it at the chart bar's next waypoint, the kernel at the +next discrete matching point of the delivered path. + ## One physical book There is one engine lot/account book. Native matching inspects settlement, @@ -661,10 +882,44 @@ TradingView-calibrated, and a host that supplies them inherits its rules: Declare no `authoritative_bars` and the buckets are a plain aggregation of the run's own input, with no calibration to inherit. -**Limits.** Subscriptions are a batch-run feature: a `stream_begin` with a -non-empty `subscriptions` is refused (`Contract`) because a series is resolved -over the run's whole input. A series finer than the input is refused at -configure, not emulated. Only the run's own symbol is addressable; there is no +**Streams.** `stream_begin` accepts a non-empty `subscriptions`, so a +forward-execution host reads the same series a backtest of the same bars +reads. The warmup resolves the series exactly as a `run()` over those same +warmup bars does — the same buckets, at the same delivery points, in the same +callback order — and each pushed live bar then extends the bucket the warmup +left open, delivering it before the calculation of the bar that completed it. +Live specifics: + +- `lookahead = true` changes the **warmup** only. Pine's lookahead is a + historical-resolution mode, and a realtime bar has no future to resolve + over, so a live bucket is delivered when it completes under both modes. +- A calendar (`D` / `W` / `M`) bucket closes on its period end or its session + close, and a stream reaches both on its own: a session-clipped daily series + over a warmup that stops mid-session is the batch's series bucket for + bucket. The one completion rule a stream cannot use is the fallback that + closes a period on its last input because the *next* input's stamp is + already known — a batch has that stamp for every bar but its last, and a + stream never has it for the bar it has just received. A period whose input + simply stops early therefore waits for the next period's first pushed bar + and arrives as `LazyComplete`. +- `authoritative_bars` are installed once, at begin, and therefore cover the + buckets the warmup completes. A live bucket with no authoritative bar of its + own aggregates the pushed input and is counted by + `native_security_misses()`. +- A bucket still open when the stream ends is not delivered by `stream_end`, + exactly as a batch never delivers the input's trailing partial bucket. +- A stream that declares a series takes **confirmed bars only**: tick input + and `stream_advance_time` are refused by name. An observed-tick slot is + finalized after its own matching pass and a quiet-carried slot is a + synthesized flat bar, so neither has a batch counterpart a bucket could be + built from. + +A spec that declares no series is untouched by all of this: its stream event +sequence, continuation hash and `stream_state_hash()` are the pre-subscription +ones. + +**Limits.** A series finer than the input is refused at configure, not +emulated. Only the run's own symbol is addressable; there is no auxiliary-symbol feed and no chart-slice mapping. ## Batch OHLCV vs ticks vs quiet @@ -672,7 +927,9 @@ auxiliary-symbol feed and no chart-slice mapping. Two driver models only: confirmed OHLCV and observed ticks. Mixing them on one stream is refused. The script-bar **calculation** callback is `on_native_bar`, one per completed script bucket; the opening, tick and -post-fill hooks fire in addition to it, not instead of it. +post-fill hooks fire in addition to it, not instead of it. Calculation is +**close-only** unless the spec asks otherwise; see *Calculation timing* above +for `BarCloseAndFills` and `EveryModeledPoint`. Confirmed OHLC retains the modeled **Opening**, high/low in the existing AUTO order, close, calculation and optional AfterCalculation close sequence. @@ -714,16 +971,18 @@ These are existing refusals, not implied future features: - Source `calc_on_every_tick` / `calc_on_order_fills` enabled (the runner rejects an explicit true override, and the Pine host refuses a stream begin with `calc_on_order_fills`, `pine_strategy_host.cpp:266-269`). This is a - limit on the *source* calculation policies, not on the native hooks: - `on_native_tick` and `on_native_applied` are delivered on a stream. + **source-route** refusal, not a limit on the native hooks: `on_native_tick` + and `on_native_applied` are delivered on a stream, and a native host's own + `NativeRunSpec::calculation` is accepted there, where `EveryModeledPoint` + recalculates per observed print - A nonempty staged native FX curve on `stream_begin`; batch runs may use one. - Auxiliary/native security feeds, source magnifier/tail/probe/hash/trace setters, `set_input`, and Pine entry/exit/cancel commands — native hosts latch `Failed` (`UnsupportedSource`) before mutation -- A non-empty `subscriptions` on `stream_begin` (`Contract`): a declared - higher-timeframe series is resolved over the run's whole input; batch runs - may declare them +- Tick input (`stream_push_tick` / `stream_push_ticks` / + `stream_advance_time`) on a stream whose spec declares `subscriptions`; + confirmed bars carry those series - C-level native request submit/replace/cancel Rebuild strategy libraries against this engine. An ABI-v4 module without the diff --git a/include/pineforge/native_host.hpp b/include/pineforge/native_host.hpp index dda41221..7b02c8ef 100644 --- a/include/pineforge/native_host.hpp +++ b/include/pineforge/native_host.hpp @@ -321,6 +321,19 @@ enum class NativePrecommitVerdict : std::uint8_t { AdmitWithHostMargin = 2, }; +// Ephemeral factual view of one kernel-issued liquidation before its units +// are fixed. `position` is the physical book being liquidated, `mark` the +// sizing price the kernel measured the breach at, `equity` the marked equity +// there and `required` the maintenance requirement of the whole position at +// that same mark. A host that answers with a value owns the slice quantity. +struct NativeMarginCallView { + NativePhysicalPosition position; + double mark = 0.0; + double equity = 0.0; + double required = 0.0; + native_order::MatchCursor cursor; +}; + struct NativeCurrentPointView { NativeDecisionContext decision; double price = 0.0; @@ -447,6 +460,24 @@ struct NativeTimeframeBarContext { std::int64_t delivered_at_ms = 0; }; +// Why the kernel is asking the host to calculate. BarClose is the script +// bar's own calculation and is delivered for every run, whatever the spec's +// NativeCalculationTrigger is: every calculation is routed through +// on_native_recalculate, whose default forwards to on_native_bar, so a host +// that only implements on_native_bar sees exactly what it saw before. +// OrderFill is one recalculation at the cursor of an applied execution +// (NativeCalculationTrigger::BarCloseAndFills and above) and carries that +// event as its cause. Tick is one recalculation at a modeled path point or +// an observed print (NativeCalculationTrigger::EveryModeledPoint). SubBar is +// reserved: a lower-timeframe sub-bar has its own hook, on_native_sub_bar, +// and is never delivered through on_native_recalculate. +enum class NativeCalculationReason : std::uint8_t { + BarClose = 0, + OrderFill = 1, + Tick = 2, + SubBar = 3, +}; + // Most-derived native strategy host. Binds NativeExecutionConsumer in the // protected engine constructor. Noncopyable and nonmovable. Lives in the // same inline engine epoch as BacktestEngine so old-header/new-library @@ -485,6 +516,43 @@ class NativeStrategyHost : public BacktestEngine { // returns, before the consumer advances beyond this calculation point. virtual void on_native_bar(const Bar& bar, const NativeDecisionContext& context) = 0; + // EVERY calculation of the run arrives here first, including the script + // bar's own close calculation (reason BarClose, cause nullptr), whose + // default forwarding keeps on_native_bar the complete contract for a host + // that never opts into another cadence. + // + // reason OrderFill: one recalculation at an applied execution's cursor, + // driven from the applied-notification drain after that event's + // on_native_applied and bounded by + // NativeRunSpec::max_recalculations_per_point. `cause` is that event and + // is valid only for this call. reason Tick: one recalculation at a + // modeled path point or an observed print, with a null cause. + // + // `bar` is the bar the calculation is about: the script bar under + // delivery in batch, the print's value bar for a stream Tick. It is the + // COMPLETE script bar even mid-path; current_partial_bar() is the + // lookahead-free bar so far at this cursor. Commands and + // execute_current are legal here exactly as in on_native_applied. + virtual void on_native_recalculate(const Bar& bar, const NativeDecisionContext& ctx, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause) { + (void)reason; + (void)cause; + on_native_bar(bar, ctx); + } + + // One completed lower-timeframe sub-bar of an IntrabarPath::lower_tf + // path, delivered after that sub-bar's whole matching path and before the + // next sub-bar's. Never called for a run without a retained lower feed: + // a synthesized path and a plain confirmed bar have no sub-bars of their + // own. The decision point is the sub-bar's last modeled point, so + // commands and execute_current are legal and a request born here follows + // the ordinary birth rule. + virtual void on_native_sub_bar(const Bar& sub, const NativeDecisionContext& ctx) { + (void)sub; + (void)ctx; + } + virtual void on_native_applied(const native_order::ExecutionAppliedEvent&, const NativeDecisionContext&) {} @@ -498,6 +566,17 @@ class NativeStrategyHost : public BacktestEngine { return NativePrecommitVerdict::Admit; } + // The kernel's own liquidation sizing, offered to the host before the + // reduction rests. Returning nullopt keeps the run spec's sizing policy; + // a returned value is clamped into (0, held] and wins over it. + virtual std::optional resolve_margin_call_units( + const NativeMarginCallView&) const { + return std::nullopt; + } + // A kernel-issued liquidation that filled. It is delivered after the + // ordinary on_native_applied for the same fill, with the same cursor. + virtual void on_native_margin_call(const native_order::MarginCallEvent&) {} + // RULING A48 — the ONE generic per-lot excursion capability. A host that // returns true here takes ownership of every open lot's favorable/adverse // excursion: the consumer stops sampling excursion at matched trigger @@ -510,6 +589,22 @@ class NativeStrategyHost : public BacktestEngine { return {}; } + // The bar so far at the current cursor, folded from the modeled points + // this script bar has already presented: open of its first point, + // running high/low, close at the cursor. Volume is the activity actually + // consumed so far — the completed lower-timeframe sub-bars of an + // intrabar path, or the prints of an observed stream — and stays 0 for a + // modeled path with no intrabar volume of its own. Valid in the bar-open, + // applied, tick, sub-bar and recalculation callbacks; nullopt outside a + // path walk, including in the bar's own close calculation, where the host + // already holds the complete bar. + std::optional current_partial_bar() const; + // How many recalculations the kernel has driven this run, and how many it + // suppressed because a point had already spent its + // max_recalculations_per_point budget. Observation only. + std::uint64_t native_recalculation_count() const; + std::uint64_t native_recalculations_skipped() const; + std::optional current_execution_point() const; std::optional trail_state( const native_order::RequestHandle& target) const; @@ -548,6 +643,11 @@ class NativeStrategyHost : public BacktestEngine { NativePhysicalPosition physical_position() const; double native_marked_equity(double mark) const; + // The price at which the marked equity falls below the run's maintenance + // requirement for the live position's side. nullopt when the run declares + // no margin model, the side has no maintenance fraction, the book is flat, + // or no finite price solves the breach (a long at full maintenance). + std::optional native_liquidation_price() const; // Owning snapshots copied at query time. Later commands/reset do not // invalidate already returned values. std::vector native_events(uint64_t after_ordinal) const; diff --git a/include/pineforge/native_order.hpp b/include/pineforge/native_order.hpp index 0fd03738..53ff52fd 100644 --- a/include/pineforge/native_order.hpp +++ b/include/pineforge/native_order.hpp @@ -416,11 +416,23 @@ struct TargetObservation { std::vector openings; }; +// Who authored a request. Host is every request a host submits, replaces or +// cancels, and it is the whole existing population: it folds nothing into the +// continuation digest, so no established hash moves. KernelLiquidation marks +// the margin model's own Reduce; KernelRisk is reserved for the risk lane. +enum class RequestOrigin : std::uint8_t { + Host = 0, + KernelLiquidation = 1, + KernelRisk = 2, +}; + struct RequestDefinition { RequestHandle handle; Request request; Birth birth; std::optional predecessor; + // Appended last so every existing aggregate initializer keeps its meaning. + RequestOrigin origin = RequestOrigin::Host; }; using DefinitionRef = std::shared_ptr; @@ -554,6 +566,9 @@ enum class CancelReason : std::uint8_t { Group = 1, OwnerGone = 2, UnsupportedRelation = 3, + // A kernel-originated request the kernel itself withdrew: the liquidation + // level or its units moved, or the requirement is no longer breached. + Superseded = 4, }; enum class AppliedTerminalReason : std::uint8_t { @@ -799,6 +814,30 @@ struct ArmedEvent { std::optional quantity_resolution; }; +// A kernel-issued liquidation that actually filled. It carries the margin +// facts of that fill, so a host reconstructs the outcome without recomputing +// the account: `mark` is the booked resolved price, `equity` and `required` +// are the marked equity and the maintenance requirement of the SURVIVING book +// at that price, `liquidation_price` is the level re-solved for what is left, +// and `position_before` / `position_after` are the signed book on either side +// of the reduction. `applied` names the ExecutionAppliedEvent that booked it. +struct MarginCallEvent { + uint64_t ordinal = 0; + DefinitionRef definition; + EventId applied; + MatchCursor cursor{}; + Side side = Side::Long; + double mark = 0.0; + double equity = 0.0; + double required = 0.0; + double liquidation_price = 0.0; + double units = 0.0; + double position_before = 0.0; + double position_after = 0.0; + const RequestHandle& handle() const noexcept { return definition->handle; } + const Request& request() const noexcept { return definition->request; } +}; + using CommandEvent = std::variant; + TermsResolvedEvent, + MarginCallEvent>; // Almost every prepared command yields one history event. Keep that ordinary // transactional payload inline; the overflow vector preserves the existing @@ -1132,14 +1172,18 @@ class WorkingRequestCore { PreparedSubmit prepare_submit(const Request& request, const CommandContext& context, uint64_t& next_order_incarnation, - uint64_t& next_timeline_ordinal); + uint64_t& next_timeline_ordinal, + RequestOrigin origin = RequestOrigin::Host); PreparedReplace prepare_replace(const RequestHandle& target, const Request& request, const CommandContext& context, uint64_t& next_order_incarnation, uint64_t& next_timeline_ordinal, ReplaceOptions options = {}); - PreparedCancel prepare_cancel(const RequestHandle& target, uint64_t& next_timeline_ordinal); + // `reason` lets the kernel withdraw its own request under the durable + // Superseded receipt. Host cancels keep the default User reason. + PreparedCancel prepare_cancel(const RequestHandle& target, uint64_t& next_timeline_ordinal, + CancelReason reason = CancelReason::User); InstalledCommand install_submit(PreparedSubmit&& prepared) noexcept; InstalledCommand install_replace(PreparedReplace&& prepared) noexcept; @@ -1179,6 +1223,12 @@ class WorkingRequestCore { const TermsResolvedInput& input, uint64_t& next_timeline_ordinal); + // Append one MarginCallEvent to the immutable history. The target is the + // kernel-originated request that filled, which is already terminal by the + // time its margin facts are recorded, so no live row moves. + Preparation prepare_margin_call(const MarginCallEvent& event, + uint64_t& next_timeline_ordinal); + // The allowance that prepare_evaluation would install for this point. static Allowance evaluated_allowance(const LiveRequest& live, uint64_t point) noexcept; // Consumer-only no-event form of the ordinary allowance @@ -1439,7 +1489,7 @@ static_assert(std::variant_size_v == 2); static_assert(std::variant_size_v == 5); static_assert(std::variant_size_v == 5); static_assert(std::variant_size_v == 4); -static_assert(std::variant_size_v == 17); +static_assert(std::variant_size_v == 18); static_assert(std::variant_size_v == 4); static_assert(std::variant_size_v == 3); static_assert(std::variant_size_v == 9); diff --git a/include/pineforge/native_run_spec.hpp b/include/pineforge/native_run_spec.hpp index f000178c..f3cb6dfb 100644 --- a/include/pineforge/native_run_spec.hpp +++ b/include/pineforge/native_run_spec.hpp @@ -54,6 +54,39 @@ enum class NativeOpenDirections : std::uint32_t { Both = 3, }; +// When the kernel asks the host to calculate. BarClose is the whole default +// surface: exactly one calculation per script bar, at its close, which is +// what every host that drives its own cadence already gets. The other two +// are a strict superset of the one before them, so a host never loses the +// close calculation by opting in. +// +// BarCloseAndFills additionally recalculates once at the cursor of each +// applied execution, from the existing applied-notification drain and +// bounded by max_recalculations_per_point. EveryModeledPoint additionally +// recalculates at every modeled point of the delivered path (each confirmed +// OHLC waypoint, each intrabar sample) and at every observed print. +// +// This is a generic cadence, not a source-language policy: TradingView's +// waypoint-only COOF refill, its two-fills-at-open rule and its script-state +// rollback stay in the source layer, which never sets this field. +enum class NativeCalculationTrigger : std::uint32_t { + BarClose = 0, + BarCloseAndFills = 1, + EveryModeledPoint = 2, +}; + +// What a bar-open callback is handed. Complete keeps the established view: +// on_native_bar_open receives the whole script bar, which is what a host that +// schedules against the bar's own high/low needs. OpenOnly masks that +// lookahead for hosts that must decide at the open with open-only +// information: H = L = C = open and volume 0. It changes no matching, no +// fill and no other callback; mid-bar callbacks answer current_partial_bar() +// for the lookahead-free bar so far. +enum class NativeOpenBarView : std::uint32_t { + Complete = 0, + OpenOnly = 1, +}; + // A generic instrument price grid. The kernel otherwise treats price_tick as // the slippage multiplier only, so an unset grid leaves every booked price // exactly as the path presented it. QuantizeFills books the fill on the tick @@ -75,6 +108,58 @@ enum class NativeGridRounding : std::uint32_t { Directional = 1, }; +// Which units a kernel-issued liquidation reduces (L4). RestoreMinimum is +// the fewest units that restore the marked equity to the maintenance +// requirement at the sizing mark; ShortfallMultiple books that same restore +// scaled by `shortfall_multiple` (TradingView's 4.0 is the adapter's choice, +// never the default here); Flatten closes the whole position. +enum class NativeLiquidationSizing : std::uint32_t { + RestoreMinimum = 0, + ShortfallMultiple = 1, + Flatten = 2, +}; + +// When the kernel tests the maintenance requirement. PathAdverseExtreme +// evaluates it against the most adverse price the remaining modeled script +// path still reaches and rests the reduction at the liquidation level, so the +// fill lands where the account actually runs out of margin. CalculationOnly +// tests the mark only at a script calculation point and rests nothing. +enum class NativeLiquidationCheck : std::uint32_t { + PathAdverseExtreme = 0, + CalculationOnly = 1, +}; + +// A generic per-side broker margin model (L4). It is entirely opt-in: a spec +// that leaves `NativeRunSpec::margin` unset keeps the one-scalar +// `initial_margin_fraction` gate and has no liquidation path at all. +// +// `initial_long` / `initial_short` are the opening-admission fractions (not +// percents) applied to the resulting absolute notional of an opening, per +// side. Both must be finite and positive. +// +// `maintenance_long` / `maintenance_short` are the liquidation fractions. +// Absent means that side never liquidates. Present means the kernel solves +// for the liquidation level -- the price at which the marked equity falls +// below the maintenance requirement -- and rests a kernel-originated Reduce +// with Stop{level} while the requirement is breached on the modeled path. +// A maintenance fraction equal to 1.0 has no finite level for a LONG: at +// full maintenance a long's equity and requirement move together, so the +// breach is a constant and no price solves it. Source-language money rules +// for that case are source-layer policy, never spelled here. +// +// `liquidation_min_units` is the broker's minimum liquidation trade: a +// computed reduction below it flattens the position instead. +struct NativeMarginModel { + double initial_long = 0.0; + double initial_short = 0.0; + std::optional maintenance_long; + std::optional maintenance_short; + NativeLiquidationSizing sizing = NativeLiquidationSizing::RestoreMinimum; + double shortfall_multiple = 1.0; + std::optional liquidation_min_units; + NativeLiquidationCheck check = NativeLiquidationCheck::PathAdverseExtreme; +}; + // Native hosts normally require every confirmed bar to name a canonical input // slot. A host that deliberately reproduces a legacy batch route can retain // the caller's strictly-increasing timestamps as its decision labels instead. @@ -236,12 +321,30 @@ struct NativeRunSpec { NativeOpenDirections allowed_open_directions = NativeOpenDirections::Both; std::optional initial_margin_fraction; // Positive fraction, not percent; // no maintenance liquidation. + // Opt-in generic margin model. Mutually exclusive with + // initial_margin_fraction, which remains the one-scalar spelling. Folded + // into the continuation digest only when it is present, so a spec that + // declares none keeps its pre-margin-model identity byte for byte. + std::optional margin; NativeReportPolicy report_policy = NativeReportPolicy::HostRecorded; // Report a position still open at run end as a mark-to-market closed row // at the last close. KernelRecorded only, and reporting only: the live // book, the realized sums and every hash are left exactly as the run left // them. Inert under HostRecorded, whose host owns the whole report series. bool report_open_position_at_end = false; + // Calculation timing. BarClose is the established cadence and the whole + // default surface; the other triggers only add calculations, never move + // or remove one. max_recalculations_per_point bounds the fill cascade at + // one matching point: further executions at that point are still applied + // and still delivered to on_native_applied, they just stop driving a new + // calculation. Zero is a legal bound and means "deliver, never + // recalculate". All three fold into the continuation hash only once the + // trigger or the open-bar view is non-default, so a spec that leaves the + // cadence alone keeps the continuation identity it had before these + // fields existed. + NativeCalculationTrigger calculation = NativeCalculationTrigger::BarClose; + std::uint32_t max_recalculations_per_point = 8; + NativeOpenBarView open_bar_view = NativeOpenBarView::Complete; IntrabarPath intrabar{}; // Declared higher-timeframe series. Empty is the whole default surface: // no evaluator is registered, no feed is prepared, and the run spec's @@ -265,6 +368,9 @@ enum class NativeRunSpecField : std::uint8_t { ReportPolicy, PriceGrid, GridRounding, SubscriptionTimeframe, SubscriptionBars, + MarginModel, MarginInitial, MarginMaintenance, MarginSizing, + MarginShortfallMultiple, MarginMinUnits, MarginCheck, + Calculation, OpenBarView, }; enum class NativeRunSpecError : std::uint8_t { @@ -310,6 +416,14 @@ enum class NativeRunSpecError : std::uint8_t { UnorderedSubscriptionBars, // Subscriptions declared with no detected input timeframe to pair with. SubscriptionWithoutTimeframe, + // Both the generic margin model and the one-scalar initial-margin gate + // were set. They are two spellings of the same admission authority. + MarginModelConflict, + UnknownLiquidationSizing, + UnknownLiquidationCheck, + // A calculation trigger / open-bar view outside its enumeration. + UnknownCalculationTrigger, + UnknownOpenBarView, }; // Allocation-free facts suitable for the host's durable failure variant. @@ -374,6 +488,13 @@ std::uint64_t native_timeframe_subscriptions_digest( // digest is portable enough to pin as a constant. std::uint64_t native_run_spec_digest(const NativeRunSpec& spec) noexcept; +// Exact FNV-1a content digest for the generic margin model. It includes every +// per-side fraction, the liquidation policy and its parameters, so two runs +// that differ only in their margin model cannot share a continuation identity. +// Callers fold it only when `margin` is present, keeping the default spec's +// continuation identity unchanged. +std::uint64_t native_margin_model_digest(const NativeMarginModel& margin) noexcept; + static_assert(std::is_trivially_copyable_v); static_assert(std::is_nothrow_move_constructible_v); static_assert(std::is_nothrow_move_assignable_v); diff --git a/scripts/check_native_cpp_abi.py b/scripts/check_native_cpp_abi.py index e124c387..b8c752db 100644 --- a/scripts/check_native_cpp_abi.py +++ b/scripts/check_native_cpp_abi.py @@ -53,7 +53,7 @@ CURRENT_TERMS_SURFACE_READY = True CURRENT_RESULT_DIAGNOSTIC = "R4B_CURRENT_RESULT_ALTERNATIVES" -CURRENT_ORDER_VARIANT = 17 +CURRENT_ORDER_VARIANT = 18 CURRENT_ORDER_INTENT_VARIANT = 6 CURRENT_CALENDAR_INTERVAL = 40 CURRENT_COORDINATE = 80 diff --git a/scripts/check_native_cpp_versions.py b/scripts/check_native_cpp_versions.py index daa1375e..f201c68c 100644 --- a/scripts/check_native_cpp_versions.py +++ b/scripts/check_native_cpp_versions.py @@ -262,6 +262,8 @@ def check_texts(files): "CashValue", "EquityFraction", "SizeTime", "Sized", "ScopeClaim", "ScopeFraction"), "native_order_v6", r'\b(?:enum\s+class|class|struct)\s+NAME\s*(?::[^;{]+)?\{') + require(order, ("RequestOrigin", "MarginCallEvent"), + "native_order_v6", r'\b(?:enum\s+class|class|struct)\s+NAME\s*(?::[^;{]+)?\{') require(order, ("CommandEvent", "ExecutionPlan", "OrderIntent", "Remaining", "RemainingProjection", "Allowance", "ReductionSize", "SizeBasis"), "native_order_v6", r'\busing\s+NAME\s*=') @@ -349,17 +351,21 @@ def check_texts(files): "NativeRunSpecField", "IntrabarPath", "SampleEligibility", "synthesized", "NativeSlotLabelPolicy", "NativePathOrder", "NativeLegacyTolerance", "NativeReportPolicy", - "NativeTimeframeSubscription"), + "NativeTimeframeSubscription", "NativeMarginModel", + "NativeLiquidationSizing", "NativeLiquidationCheck", + "NativeCalculationTrigger", "NativeOpenBarView"), "native_run_spec_v3", r'\b(?:enum\s+class|struct)\s+NAME\s*(?::[^;{]+)?\{') require_namespace_functions( spec, ("validate_native_run_spec", "normalize_native_run_spec", - "native_intrabar_path_digest", "native_timeframe_subscriptions_digest"), + "native_intrabar_path_digest", "native_timeframe_subscriptions_digest", + "native_margin_model_digest"), "native_run_spec_v3") spec_src = versioned(files[FILES[5]], "pineforge", "native_run_spec_v3") require_namespace_functions( spec_src, ("validate_native_run_spec", "normalize_native_run_spec", - "native_intrabar_path_digest", "native_timeframe_subscriptions_digest"), + "native_intrabar_path_digest", "native_timeframe_subscriptions_digest", + "native_margin_model_digest"), "native_run_spec_v3") run_spec = body(spec, r'struct\s+NativeRunSpec\s*\{', 'native run spec') if ('std::stringinput_tf;std::stringscript_tf;booltimeframe_undetected=false;' @@ -375,7 +381,11 @@ def check_texts(files): 'NativeAbortReportingabort_reporting=NativeAbortReporting::Error;', 'NativeReportPolicyreport_policy=NativeReportPolicy::HostRecorded;', 'boolreport_open_position_at_end=false;', - 'std::vectorsubscriptions;'): + 'std::vectorsubscriptions;', + 'std::optionalmargin;', + 'NativeCalculationTriggercalculation=NativeCalculationTrigger::BarClose;', + 'std::uint32_tmax_recalculations_per_point=8;', + 'NativeOpenBarViewopen_bar_view=NativeOpenBarView::Complete;'): if member not in compact_spec: raise ValueError('native_run_spec_v3 omits required policy member: ' + member) subscription = body(spec, r'struct\s+NativeTimeframeSubscription\s*\{', @@ -383,10 +393,21 @@ def check_texts(files): if (re.sub(r'\s+', '', subscription) != 'std::stringtf;std::vectorauthoritative_bars;boollookahead=false;'): raise ValueError('native timeframe subscription must preserve its member order and shape') + margin = body(spec, r'struct\s+NativeMarginModel\s*\{', 'native margin model') + if (re.sub(r'\s+', '', margin) + != 'doubleinitial_long=0.0;doubleinitial_short=0.0;' + 'std::optionalmaintenance_long;std::optionalmaintenance_short;' + 'NativeLiquidationSizingsizing=NativeLiquidationSizing::RestoreMinimum;' + 'doubleshortfall_multiple=1.0;std::optionalliquidation_min_units;' + 'NativeLiquidationCheckcheck=NativeLiquidationCheck::PathAdverseExtreme;'): + raise ValueError('native margin model must preserve its member order and shape') fields = body(spec, r'enum\s+class\s+NativeRunSpecField\s*:\s*std::uint8_t\s*\{', 'native run spec fields') for field in ('TimeframeUndetected', 'SlotLabelPolicy', 'LegacyTolerance', 'AbortReporting', - 'PathOrder', 'ReportPolicy', 'SubscriptionTimeframe', 'SubscriptionBars'): + 'PathOrder', 'ReportPolicy', 'SubscriptionTimeframe', 'SubscriptionBars', + 'MarginModel', 'MarginInitial', 'MarginMaintenance', 'MarginSizing', + 'MarginShortfallMultiple', 'MarginMinUnits', 'MarginCheck', + 'Calculation', 'OpenBarView'): if not re.search(r'\b' + field + r'\b', fields): raise ValueError('native_run_spec_v3 omits the field tag: ' + field) errors = body(spec, r'enum\s+class\s+NativeRunSpecError\s*:\s*std::uint8_t\s*\{', @@ -397,7 +418,9 @@ def check_texts(files): 'UnknownReportPolicy', 'InvalidSubscriptionTimeframe', 'SubscriptionFinerThanInput', 'DuplicateSubscriptionTimeframe', 'UnorderedSubscriptionBars', - 'SubscriptionWithoutTimeframe'): + 'SubscriptionWithoutTimeframe', 'MarginModelConflict', + 'UnknownLiquidationSizing', 'UnknownLiquidationCheck', + 'UnknownCalculationTrigger', 'UnknownOpenBarView'): if not re.search(r'\b' + error + r'\b', errors): raise ValueError('native_run_spec_v3 omits the validation error: ' + error) if ('spec.timeframe_undetected' not in spec_src @@ -408,7 +431,11 @@ def check_texts(files): or 'spec.abort_reporting' not in spec_src or 'spec.report_policy' not in spec_src or 'spec.subscriptions' not in spec_src + or 'spec.calculation' not in spec_src + or 'spec.open_bar_view' not in spec_src or 'SubscriptionFinerThanInput' not in spec_src + or 'spec.margin' not in spec_src + or 'MarginModelConflict' not in spec_src or 'lower->sample_eligibility' not in spec_src): raise ValueError('native run-spec validation omits an explicit compatibility rule') intrabar = body(spec, r'struct\s+IntrabarPath\s*\{', 'intrabar path') @@ -497,6 +524,8 @@ def check_texts(files): raise ValueError('native driver omits legacy-compatible preflight token: ' + token) consumer_src = consumer_epoch_source(files) + if 'native_margin_model_digest(*spec.margin)' not in consumer_src: + raise ValueError('native consumer omits the conditional margin-model digest fold') for fold in ('f.u(static_cast(spec.slot_label_policy));', 'f.u(static_cast(spec.legacy_tolerance));', 'f.u(static_cast(spec.abort_reporting));', @@ -515,7 +544,13 @@ def check_texts(files): 'input_callback_context_', 'hash_input_context', 'tick_callback_context_', 'hash_tick_context', 'invoke_tick_callback(engine, tick_bar, tick_context)', - 'staged_ingress_fx_', 'if (failed() && !recoverable_abort())'): + 'staged_ingress_fx_', 'if (failed() && !recoverable_abort())', + 'bool calc_timing_on(const NativeRunSpec& spec) noexcept {', + 'if (calc_timing_on(spec)) {', + 'NativeCalculationReason::BarClose', + 'NativeCalculationReason::OrderFill', + 'host->on_native_sub_bar(sub, presented);', + 'NativeOpenBarView::OpenOnly'): if token not in consumer_src: raise ValueError('native consumer omits staged/intrabar policy token: ' + token) @@ -530,7 +565,8 @@ def check_texts(files): "NativeExecutionTermsFacts", "NativePrecommitView", "NativePrecommitVerdict", "NativeFxCurveSetupResult", "NativeBeginArgs", "NativeInputContext", "NativeTickContext", - "NativeTimeframeBarContext"), + "NativeTimeframeBarContext", "NativeMarginCallView", + "NativeCalculationReason"), "engine_script_run_v18", r'\b(?:enum\s+class|class|struct)\s+NAME\s*(?::[^;{]+)?\{') begin_args = body(host, r'struct\s+NativeBeginArgs\s*\{', 'native begin args') @@ -621,6 +657,20 @@ def check_texts(files): "on_native_timeframe_bar"), (r'\bstd::optional\s*<\s*Bar\s*>\s+native_series_bar\s*\(' r'\s*std::size_t\s+\w+\s*\)\s*const\s*;', "native_series_bar"), + (r'\bvirtual\s+std::optional\s*<\s*double\s*>\s+resolve_margin_call_units\s*\(' + r'\s*const\s+NativeMarginCallView\s*&', "resolve_margin_call_units"), + (r'\bvirtual\s+void\s+on_native_margin_call\s*\(' + r'\s*const\s+native_order::MarginCallEvent\s*&', "on_native_margin_call"), + (r'\bstd::optional\s*<\s*double\s*>\s+native_liquidation_price\s*\(' + r'\s*\)\s*const\s*;', "native_liquidation_price"), + (r'\bvirtual\s+void\s+on_native_recalculate\s*\(' + r'\s*const\s+Bar\s*&\s*\w*\s*,\s*const\s+NativeDecisionContext\s*&', + "on_native_recalculate"), + (r'\bvirtual\s+void\s+on_native_sub_bar\s*\(' + r'\s*const\s+Bar\s*&\s*\w*\s*,\s*const\s+NativeDecisionContext\s*&', + "on_native_sub_bar"), + (r'\bstd::optional\s*<\s*Bar\s*>\s+current_partial_bar\s*\(\s*\)\s*const\s*;', + "current_partial_bar"), ) for pattern, name in required_host_methods: if len(re.findall(pattern, host)) != 1: @@ -650,7 +700,11 @@ def check_texts(files): "NativeStrategyHost::configure_native_fx_curve", "NativeStrategyHost::cohort_open", "NativeStrategyHost::cohort_add", "NativeStrategyHost::cohort_remove", "NativeStrategyHost::trail_state", - "NativeStrategyHost::native_series_bar"), + "NativeStrategyHost::native_series_bar", + "NativeStrategyHost::native_liquidation_price", + "NativeStrategyHost::current_partial_bar", + "NativeStrategyHost::native_recalculation_count", + "NativeStrategyHost::native_recalculations_skipped"), "engine_script_run_v18", r'\bNAME\s*\(') diff --git a/scripts/check_settlement_cpp_abi.py b/scripts/check_settlement_cpp_abi.py index ecd41a42..36f0d431 100644 --- a/scripts/check_settlement_cpp_abi.py +++ b/scripts/check_settlement_cpp_abi.py @@ -50,7 +50,8 @@ def verify(include: Path) -> dict: raise RuntimeError("retired engine seams remain: " + ", ".join(present)) required_virtuals = { "prepare_native_begin", "on_native_bar_open", "on_native_input", - "on_native_tick", "on_native_timeframe_bar", + "on_native_tick", "on_native_timeframe_bar", "on_native_margin_call", + "on_native_recalculate", "on_native_sub_bar", } if not required_virtuals.issubset(set(manifest.get("addedVirtuals", []))): raise RuntimeError("relocation manifest omits a native hook") diff --git a/src/native_execution_consumer.cpp b/src/native_execution_consumer.cpp index 9a5a0100..29c697bd 100644 --- a/src/native_execution_consumer.cpp +++ b/src/native_execution_consumer.cpp @@ -94,6 +94,15 @@ native_matching::GridThreshold grid_threshold(const NativeRunSpec& spec) noexcep return grid; } +// L5 calculation timing. A spec that leaves both the trigger and the +// open-bar view at their defaults is exactly the pre-lane surface: no +// recalculation is driven, no bar is masked, and nothing of this block is +// folded into the continuation digest. +bool calc_timing_on(const NativeRunSpec& spec) noexcept { + return spec.calculation != NativeCalculationTrigger::BarClose + || spec.open_bar_view != NativeOpenBarView::Complete; +} + void hash_spec(Fnv& f, const NativeRunSpec& spec) noexcept { f.s(spec.identity.session_key); f.u(spec.identity.run_number - f.run_base); f.s(spec.input_tf); f.s(spec.script_tf); @@ -136,6 +145,21 @@ void hash_spec(Fnv& f, const NativeRunSpec& spec) noexcept { if (!spec.subscriptions.empty()) { f.u(native_timeframe_subscriptions_digest(spec.subscriptions)); } + // L4: the generic margin model folds only where a host declared one. An + // absent model folds nothing, so every continuation hash established + // before it existed survives this spec extension unchanged. + if (spec.margin) { + f.u(native_margin_model_digest(*spec.margin)); + } + // L5: the calculation cadence folds only where a host actually moved it + // off BarClose/Complete. A defaulted cadence folds nothing, including its + // inert recalculation bound, so every established continuation hash + // survives this spec extension unchanged. + if (calc_timing_on(spec)) { + f.u(static_cast(spec.calculation)); + f.u(spec.max_recalculations_per_point); + f.u(static_cast(spec.open_bar_view)); + } } void hash_handle(Fnv& f, const native_order::RequestHandle& handle) noexcept { @@ -641,6 +665,12 @@ void hash_definition(Fnv& f, const native_order::DefinitionRef& definition) noex hash_request(f, definition->request); hash_birth(f, definition->birth); hash_optional_handle(f, definition->predecessor); + // Every host request is RequestOrigin::Host, so the authorship of the + // established population folds nothing. Only a kernel-originated request + // moves the digest, and only a run with a margin model has one. + if (definition->origin != native_order::RequestOrigin::Host) { + f.u(static_cast(definition->origin)); + } } void hash_scope(Fnv& f, const native_order::ExecutionScope& scope) noexcept { @@ -903,6 +933,19 @@ void hash_command(Fnv& f, const native_order::CommandEvent& event) noexcept { } f.b(payload.quantity_resolution.has_value()); if (payload.quantity_resolution) hash_event_id(f, *payload.quantity_resolution); + } else if constexpr (std::is_same_v) { + f.u(18); + hash_definition(f, payload.definition); + hash_event_id(f, payload.applied); + hash_cursor(f, payload.cursor); + f.u(static_cast(payload.side)); + f.d(payload.mark); + f.d(payload.equity); + f.d(payload.required); + f.d(payload.liquidation_price); + f.d(payload.units); + f.d(payload.position_before); + f.d(payload.position_after); } else if constexpr (std::is_same_v) { f.u(17); hash_definition(f, payload.definition); @@ -960,6 +1003,12 @@ CommissionType fee_to_commission(NativeFeeKind kind) { return CommissionType::PERCENT; } +// The kernel's own liquidation is a generic request, not a source signal: +// its label and comment are engine-owned literals no host can collide with +// through the public surface, because a host request is never KernelLiquidation. +constexpr char kNativeLiquidationLabel[] = "__kernel_liquidation__"; +constexpr char kNativeLiquidationComment[] = "Margin liquidation"; + uint64_t command_ordinal(const native_order::CommandEvent& event) { return std::visit([](const auto& payload) { return payload.ordinal; }, event); } @@ -1287,11 +1336,51 @@ uint64_t NativeExecutionConsumer::continuation_hash() const noexcept { f.u(notification.history_index); f.u(notification.ordinal); hash_current_point(f, notification.point); + // Conditional: only a kernel-originated fill carries a margin receipt, + // so a host-only queue folds exactly what it folded before L4. + if (notification.margin_call_index) { + f.u(1); + f.u(*notification.margin_call_index); + } } f.b(processing_input_); f.u(static_cast(input_mode_)); f.i(next_interval_index_); - if (const auto* spec = spec_ptr()) hash_spec(f, *spec); + // Declared higher-timeframe series carry their own delivery cursors, and + // a stream's warmup boundary is where its live phase starts -- neither is + // recoverable from the input count alone. Folded only for a run that + // declares a series, so every spec without one keeps the pre-subscription + // continuation identity (the same rule hash_spec's digest follows). + if (!subscriptions_.empty()) { + f.i(subscription_warmup_inputs_); + for (const auto& subscription : subscriptions_) { + f.u(subscription.index); + f.b(subscription.lookahead); + f.b(subscription.latest.has_value()); + if (subscription.latest) hash_bar(f, *subscription.latest); + f.i(subscription.bucket_first_index); + f.i(subscription.bucket_first_ms); + f.u(subscription.projected_bars.size()); + f.u(subscription.projected_cursor); + } + } + if (const auto* spec = spec_ptr()) { + hash_spec(f, *spec); + // L5: the recalculation cadence is durable decision state only for a + // spec that opted into it. Folding it conditionally keeps a default + // run's continuation identity byte-identical to the pre-lane tree. + if (calc_timing_on(*spec)) { + f.u(recalc_epoch_); + f.u(recalc_epoch_count_); + f.u(recalculations_); + f.u(recalculations_skipped_); + f.b(partial_has_); + if (partial_has_) { + hash_bar(f, partial_); + f.i(partial_script_open_ms_); + } + } + } f.b(staged_ingress_fx_); f.b(staged_fx_curve_.has_value()); if (staged_fx_curve_) f.u(native_fx_curve_digest(*staged_fx_curve_)); @@ -1362,6 +1451,23 @@ uint64_t NativeExecutionConsumer::continuation_hash() const noexcept { f.u(precommit_digest_.count); f.u(precommit_digest_.h); } + // L4 durable liquidation state. It exists only under a declared margin + // model, and folds only there, so no pre-L4 continuation identity moves. + if (margin_model() != nullptr) { + f.b(margin_liquidation_.has_value()); + if (margin_liquidation_) { + hash_handle(f, margin_liquidation_->handle); + f.d(margin_liquidation_->level); + f.d(margin_liquidation_->units); + } + f.b(has_margin_path_); + if (has_margin_path_) { + hash_bar(f, margin_path_bar_); + f.b(margin_path_high_first_); + } + f.u(margin_point_ordinal_); + f.u(margin_point_calls_); + } return f.h; } @@ -1729,8 +1835,22 @@ bool NativeExecutionConsumer::begin_ready(BacktestEngine& engine, NativeRunPhase driver_digest_.reset(); account_digest_.reset(); precommit_digest_.reset(); + margin_liquidation_.reset(); + has_margin_path_ = false; + margin_path_bar_ = Bar{}; + margin_path_high_first_ = false; + margin_point_ordinal_ = 0; + margin_point_calls_ = 0; driver_statistics_ = NativeDriverStatistics{}; driver_statistics_.intrabar_path_enabled = !spec.intrabar.is_none(); + clear_partial(); + calculating_bar_ = Bar{}; + calculating_bar_has_ = false; + point_epoch_ = 0; + recalc_epoch_ = 0; + recalc_epoch_count_ = 0; + recalculations_ = 0; + recalculations_skipped_ = 0; callback_context_ = NativeDecisionContext{}; callback_context_.driver_statistics = driver_statistics_; input_callback_context_.reset(); @@ -2229,9 +2349,15 @@ bool NativeExecutionConsumer::admit_opening_inspect( if (reason) *reason = native_order::MatchRejectReason::MaxOpenLots; return false; } - if (!skip_initial_margin && spec->initial_margin_fraction) { + // L4: a declared margin model replaces the one-scalar gate for this run + // with its own per-side initial fraction. The two spellings are mutually + // exclusive by configure, so exactly one of these branches can apply. + const double fraction = spec->margin + ? (inspect.incoming_short ? spec->margin->initial_short : spec->margin->initial_long) + : (spec->initial_margin_fraction ? *spec->initial_margin_fraction : 0.0); + if (!skip_initial_margin && fraction > 0.0) { const double equity = engine.marked_equity(resolved_price) - inspect.current_ticket; - const double required = inspect.resulting_abs_notional * *spec->initial_margin_fraction; + const double required = inspect.resulting_abs_notional * fraction; if (!std::isfinite(equity) || !std::isfinite(required) || required > equity) { if (reason) *reason = native_order::MatchRejectReason::InitialMargin; return false; @@ -2240,6 +2366,393 @@ bool NativeExecutionConsumer::admit_opening_inspect( return true; } +const NativeMarginModel* NativeExecutionConsumer::margin_model() const noexcept { + const auto* spec = spec_ptr(); + return spec && spec->margin ? &*spec->margin : nullptr; +} + +std::optional NativeExecutionConsumer::maintenance_fraction( + bool short_side) const noexcept { + const auto* margin = margin_model(); + if (!margin) return std::nullopt; + return short_side ? margin->maintenance_short : margin->maintenance_long; +} + +// equity(P) = base + dir * (P * Q - W) * pv * fx and requirement(P) = +// Q * P * pv * fx * m are both affine in P, so the breach has exactly one +// solution unless their slopes coincide: (m - dir) == 0, which is a LONG at +// full maintenance. That case has no liquidation price at all and is reported +// as such rather than as a very large or negative one. +std::optional NativeExecutionConsumer::liquidation_level( + const BacktestEngine& engine) const { + if (engine.position_side_ == PositionSide::FLAT || engine.pyramid_entries_.empty()) { + return std::nullopt; + } + const bool short_side = engine.position_side_ == PositionSide::SHORT; + const auto fraction = maintenance_fraction(short_side); + if (!fraction) return std::nullopt; + const double point_value = engine.syminfo_.pointvalue; + const double fx = engine.active_account_currency_fx(); + if (!std::isfinite(point_value) || !(point_value > 0.0) + || !std::isfinite(fx) || !(fx > 0.0)) { + return std::nullopt; + } + double units = 0.0; + double cost = 0.0; + double commissions = 0.0; + for (const auto& lot : engine.pyramid_entries_) { + if (!std::isfinite(lot.qty) || lot.qty <= 0.0 || !std::isfinite(lot.price)) { + return std::nullopt; + } + units += lot.qty; + cost += lot.price * lot.qty; + commissions += engine.open_entry_commission(lot); + } + if (!(units > 0.0) || !std::isfinite(commissions)) return std::nullopt; + const double direction = short_side ? -1.0 : 1.0; + const double slope = *fraction - direction; + if (!std::isfinite(slope) || std::abs(slope) < 1e-12) return std::nullopt; + const double base = engine.initial_capital_ + engine.net_profit_sum_ - commissions; + const double level = (base - direction * cost * point_value * fx) + / (units * point_value * fx * slope); + if (!std::isfinite(level)) return std::nullopt; + return level; +} + +// The most adverse price the modeled script path still reaches after `phase`, +// including the cursor price itself so a point with no remaining waypoint +// still has a finite mark. A run with an intrabar path has no whole-bar +// waypoint model here: it re-evaluates at each delivered sample instead. +double NativeExecutionConsumer::margin_sizing_price( + bool short_side, NativePathPhase phase, double fallback) const noexcept { + if (!has_margin_path_) return fallback; + const NativePathPhase order[4] = { + NativePathPhase::Open, + margin_path_high_first_ ? NativePathPhase::High : NativePathPhase::Low, + margin_path_high_first_ ? NativePathPhase::Low : NativePathPhase::High, + NativePathPhase::Close, + }; + const double prices[4] = { + margin_path_bar_.open, + margin_path_high_first_ ? margin_path_bar_.high : margin_path_bar_.low, + margin_path_high_first_ ? margin_path_bar_.low : margin_path_bar_.high, + margin_path_bar_.close, + }; + int current = -1; + for (int index = 0; index < 4; ++index) { + if (order[index] == phase) { + current = index; + break; + } + } + if (current < 0) return fallback; + double adverse = fallback; + for (int index = current + 1; index < 4; ++index) { + const double price = prices[index]; + if (!std::isfinite(price) || !(price > 0.0)) continue; + if (!std::isfinite(adverse) || (short_side ? price > adverse : price < adverse)) { + adverse = price; + } + } + return adverse; +} + +std::optional NativeExecutionConsumer::margin_call_units( + const BacktestEngine& engine, double mark, const native_order::MatchCursor& cursor, + double* out_equity, double* out_required) const { + const auto* margin = margin_model(); + if (!margin || engine.position_side_ == PositionSide::FLAT) return std::nullopt; + const bool short_side = engine.position_side_ == PositionSide::SHORT; + const auto fraction = maintenance_fraction(short_side); + if (!fraction) return std::nullopt; + const auto book = position(engine); + const double held = std::abs(book.signed_units); + const double point_value = engine.syminfo_.pointvalue; + const double fx = engine.active_account_currency_fx(); + if (!(held > 0.0) || !std::isfinite(mark) || !(mark > 0.0) + || !std::isfinite(point_value) || !(point_value > 0.0) + || !std::isfinite(fx) || !(fx > 0.0)) { + return std::nullopt; + } + const double unit_margin = mark * point_value * fx * *fraction; + const double equity = engine.marked_equity(mark); + const double required = held * unit_margin; + if (out_equity) *out_equity = equity; + if (out_required) *out_required = required; + if (!std::isfinite(unit_margin) || !(unit_margin > 0.0) + || !std::isfinite(equity) || !std::isfinite(required) || !(required > equity)) { + return std::nullopt; + } + const double restore = (required - equity) / unit_margin; + double units = 0.0; + switch (margin->sizing) { + case NativeLiquidationSizing::RestoreMinimum: + units = restore; + break; + case NativeLiquidationSizing::ShortfallMultiple: + units = restore * margin->shortfall_multiple; + break; + case NativeLiquidationSizing::Flatten: + units = held; + break; + } + if (!std::isfinite(units) || !(units > 0.0)) return std::nullopt; + units = std::min(units, held); + // A restore below the broker's minimum trade is not a broker action: the + // position is closed instead of nibbled. + if (margin->liquidation_min_units && units < *margin->liquidation_min_units) { + units = held; + } + // The host sees the kernel's own facts and has the last word on the size. + const auto* host = dynamic_cast(&engine); + if (host) { + NativeMarginCallView view; + view.position = book; + view.mark = mark; + view.equity = equity; + view.required = required; + view.cursor = cursor; + if (const auto override_units = host->resolve_margin_call_units(view)) { + if (!std::isfinite(*override_units) || !(*override_units > 0.0)) return std::nullopt; + units = std::min(*override_units, held); + } + } + if (!std::isfinite(units) || !(units > 0.0)) return std::nullopt; + return units; +} + +void NativeExecutionConsumer::withdraw_margin_liquidation(BacktestEngine& engine) { + if (!margin_liquidation_) return; + const auto handle = margin_liquidation_->handle; + margin_liquidation_.reset(); + if (!requests_.find_live(handle)) return; + auto prepared = requests_.prepare_cancel(handle, next_timeline_ordinal_, + native_order::CancelReason::Superseded); + if (!prepared) { + fail(engine, NativeFailure{NativeFailureCode::Contract, + NativeFailureOperation::Settlement}); + render(engine, "native liquidation withdrawal produced no preparation"); + return; + } + const auto predicted = prepared.predicted_event_id(); + const auto status = prepared.predicted().status; + auto installed = requests_.install_cancel(std::move(prepared)); + if (const auto* error = std::get_if(&installed)) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Settlement, + predicted.ordinal, static_cast(*error)}); + render(engine, "native liquidation withdrawal install failed"); + return; + } + auto& ok = std::get>(installed); + note_terminal_events(ok.events); + clear_cohort_target_cache(); + catch_up_timeline(); + if (status == native_order::CancelStatus::Cancelled) { + drain_parent_terminal(engine, predicted, handle, NativeFailureOperation::Settlement); + } +} + +// A kernel-originated reduction. A finite positive `level` rests it as a +// Stop; a nonpositive one makes it a market command the caller executes at the +// current point. A slice that would take the whole book becomes a Flatten, so +// a quantity grid can never refuse the broker's own liquidation. +bool NativeExecutionConsumer::kernel_submit_liquidation( + BacktestEngine& engine, double level, double units, + std::int64_t decision_time_ms, native_order::RequestHandle* out_handle) { + const auto* spec = spec_ptr(); + if (!spec) return false; + const bool resting = std::isfinite(level) && level > 0.0; + const auto book = position(engine); + const double held = std::abs(book.signed_units); + native_order::Request request; + const double slack = std::max(1e-12, held * 1e-12); + if (units >= held - slack) { + request.intent = native_order::Flatten{}; + } else { + double sized = units; + if (spec->quantity_grid && *spec->quantity_grid > 0.0) { + sized = std::floor(units / *spec->quantity_grid) * *spec->quantity_grid; + if (!native_order::quantity_on_grid(sized, *spec->quantity_grid)) return false; + } + if (!std::isfinite(sized) || !(sized > 0.0)) return false; + request.intent = native_order::Reduce{native_order::ExplicitUnits{sized}}; + } + request.label = kNativeLiquidationLabel; + request.comment = kNativeLiquidationComment; + if (resting) request.trigger = native_order::Stop{level}; + // The kernel is born AT the point it decided on, exactly as a request + // submitted from the pre-open callback is. It never inherits the input + // path's already-raised future decision floor, which would make it + // ineligible for the very bar it was armed for. + native_order::CommandContext ctx; + ctx.decision_time_ms = decision_time_ms; + ctx.quantity_grid = spec->quantity_grid; + ctx.surface = native_order::CommandSurface::General; + native_order::PreparedSubmit prepared; + try { + prepared = requests_.prepare_submit(request, ctx, engine.next_order_incarnation_, + next_timeline_ordinal_, + native_order::RequestOrigin::KernelLiquidation); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Allocation, + NativeFailureOperation::Settlement}); + render(engine, e.what()); + return false; + } + if (!prepared) { + fail(engine, NativeFailure{NativeFailureCode::Contract, + NativeFailureOperation::Settlement}); + render(engine, "native liquidation submit produced no preparation"); + return false; + } + auto installed = requests_.install_submit(std::move(prepared)); + if (const auto* error = std::get_if(&installed)) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Settlement, + 0, static_cast(*error)}); + render(engine, "native liquidation submit install failed"); + return false; + } + auto& ok = std::get>(installed); + note_terminal_events(ok.events); + clear_cohort_target_cache(); + catch_up_timeline(); + if (ok.result.status != native_order::SubmitStatus::Accepted || !ok.result.handle) { + return false; + } + ++engine.next_order_incarnation_; + record_pre_open_birth(request, *ok.result.handle); + if (resting) { + margin_liquidation_ = MarginLiquidation{*ok.result.handle, level, units}; + } + if (out_handle) *out_handle = *ok.result.handle; + return true; +} + +void NativeExecutionConsumer::maintain_margin_liquidation( + BacktestEngine& engine, const native_order::MatchCursor& cursor, + NativePathPhase phase, double fallback_price) { + const auto* margin = margin_model(); + if (!margin || failed() || consuming_request_) return; + if (margin->check != NativeLiquidationCheck::PathAdverseExtreme) return; + try { + if (engine.position_side_ == PositionSide::FLAT) { + withdraw_margin_liquidation(engine); + return; + } + const bool short_side = engine.position_side_ == PositionSide::SHORT; + const auto level = liquidation_level(engine); + if (!level || !std::isfinite(*level) || !(*level > 0.0)) { + withdraw_margin_liquidation(engine); + return; + } + const double mark = margin_sizing_price(short_side, phase, fallback_price); + const auto units = margin_call_units(engine, mark, cursor, nullptr, nullptr); + if (!units) { + withdraw_margin_liquidation(engine); + return; + } + // Bound the re-arm chain at one driver point (see the member note). + if (cursor.point.ordinal == margin_point_ordinal_ && margin_point_calls_ >= 8) { + withdraw_margin_liquidation(engine); + return; + } + if (margin_liquidation_ && requests_.find_live(margin_liquidation_->handle) != nullptr + && native_matching::double_bits(margin_liquidation_->level) + == native_matching::double_bits(*level) + && native_matching::double_bits(margin_liquidation_->units) + == native_matching::double_bits(*units)) { + return; + } + withdraw_margin_liquidation(engine); + if (failed()) return; + kernel_submit_liquidation(engine, *level, *units, cursor.point.effective_time_ms); + } catch (const std::exception& e) { + if (!failed()) { + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Settlement, + cursor.point.ordinal}); + render(engine, e.what()); + } + } catch (...) { + if (!failed()) { + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Settlement, + cursor.point.ordinal}); + render(engine, "native margin maintenance callback exception"); + } + } +} + +void NativeExecutionConsumer::calculation_margin_check( + BacktestEngine& engine, const NativeCoordinate& calc, double mark) { + const auto* margin = margin_model(); + if (!margin || failed() || consuming_request_) return; + if (margin->check != NativeLiquidationCheck::CalculationOnly) return; + if (engine.position_side_ == PositionSide::FLAT) return; + const bool short_side = engine.position_side_ == PositionSide::SHORT; + if (!maintenance_fraction(short_side)) return; + native_order::MatchCursor cursor; + cursor.point = calc; + std::optional units; + try { + units = margin_call_units(engine, mark, cursor, nullptr, nullptr); + } catch (const std::exception& e) { + if (!failed()) { + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Settlement, calc.ordinal}); + render(engine, e.what()); + } + return; + } + if (!units) return; + native_order::RequestHandle target; + if (!kernel_submit_liquidation(engine, 0.0, *units, calc.effective_time_ms, &target)) return; + (void)execute_current(engine, {target, NativeCurrentPriceRule::AsPresented}); +} + +std::optional NativeExecutionConsumer::record_margin_call( + BacktestEngine& engine, const native_order::ExecutionAppliedEvent& applied, + const native_order::DefinitionRef& definition, double position_before, + double position_after) { + if (!definition || definition->origin != native_order::RequestOrigin::KernelLiquidation) { + return std::nullopt; + } + native_order::MarginCallEvent event; + event.definition = definition; + event.applied = native_order::EventId{applied.handle().run, applied.ordinal}; + event.cursor = applied.cursor; + event.side = position_before < 0.0 ? native_order::Side::Short : native_order::Side::Long; + event.mark = applied.resolved_price; + event.equity = engine.marked_equity(applied.resolved_price); + const auto fraction = maintenance_fraction(position_before < 0.0); + event.required = fraction + ? std::abs(position_after) * applied.resolved_price * engine.syminfo_.pointvalue + * engine.active_account_currency_fx() * *fraction + : 0.0; + if (const auto level = liquidation_level(engine)) event.liquidation_price = *level; + event.units = applied.closed_units; + event.position_before = position_before; + event.position_after = position_after; + if (applied.cursor.point.ordinal == margin_point_ordinal_) { + ++margin_point_calls_; + } else { + margin_point_ordinal_ = applied.cursor.point.ordinal; + margin_point_calls_ = 1; + } + const std::size_t index = requests_.history().size(); + auto prepared = requests_.prepare_margin_call(event, next_timeline_ordinal_); + if (const auto* error = std::get_if(&prepared)) { + fail_preparation(engine, *error, NativeFailureOperation::Settlement); + return std::nullopt; + } + auto* mutation = std::get_if(&prepared); + if (!mutation || !install_mutation(engine, std::move(*mutation), + NativeFailureOperation::Settlement, applied.ordinal)) { + return std::nullopt; + } + return index; +} + void NativeExecutionConsumer::fail_preparation( BacktestEngine& engine, const native_order::PreparationError& error, NativeFailureOperation operation) { @@ -3247,6 +3760,7 @@ std::optional NativeExecutionConsumer::consume_mat proposal.inspected_opened_units = inspect.opened_units; proposal.inspected_current_ticket = *candidate.fill.commission_account; const int64_t cycle_before = engine.position_cycle_seq_; + const double signed_units_before = position(engine).signed_units; const native_order::EventId applied_id{handle.run, next_timeline_ordinal_}; auto prepared = requests_.prepare_execution(handle, proposal, next_timeline_ordinal_); if (const auto* error = std::get_if(&prepared)) { @@ -3309,7 +3823,8 @@ std::optional NativeExecutionConsumer::consume_mat } if (verdict == NativePrecommitVerdict::AdmitWithHostMargin) { const auto* admitted_spec = spec_ptr(); - if (admitted_spec && admitted_spec->initial_margin_fraction) { + if (admitted_spec + && (admitted_spec->initial_margin_fraction || admitted_spec->margin)) { Fnv digest; digest.run_base = requests_.identity().run_number; digest.h = precommit_digest_.h; @@ -3381,6 +3896,20 @@ std::optional NativeExecutionConsumer::consume_mat account_log_.push_back(observation); fold_account_digest(observation); engine.bar_index_ = ctx.interval_index; + // L4: the margin receipt of a kernel-issued liquidation follows its + // own fill directly, before any dependency mutation of that fill. The + // owning event is read from the outcome copy because recording it + // appends to the history the borrowed reference points into. + { + const auto& booked = std::get(outcome); + if (booked.definition + && booked.definition->origin != native_order::RequestOrigin::Host) { + notification.margin_call_index = record_margin_call( + engine, booked, booked.definition, signed_units_before, + observation.signed_units); + if (failed()) return std::nullopt; + } + } drain_after_applied(engine, applied_id, handle); if (failed()) return std::nullopt; enqueue_applied_notification(std::move(notification)); @@ -3401,6 +3930,8 @@ void NativeExecutionConsumer::match_path( BacktestEngine& engine, const NativeDriverPoint& point, bool continuous, double from_price, double to_price) { if (failed()) return; + // Every matching cursor is its own recalculation budget (L5). + open_point_epoch(); if (point.coordinate.provenance == NativePriceProvenance::CurrentExecution) { fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Settlement, point.coordinate.ordinal}); @@ -4416,6 +4947,197 @@ NativeCurrentExecutionResult NativeExecutionConsumer::execute_current( } } +// --------------------------------------------------------------------------- +// L5 calculation timing +// +// The chronology at one point is the established one with exactly one +// addition. Match and settle, then drain the applied notifications FIFO under +// the existing re-entrancy guard, delivering on_native_applied for each; a +// spec that asked for BarCloseAndFills (or above) then drives ONE +// recalculation at that event's own cursor, bounded by +// max_recalculations_per_point for the point. A request born in any of these +// callbacks keeps the existing birth rule: born_on_remaining_path and the +// drain order are untouched. A recalculation never records a report point — +// only the script calculation does. +// --------------------------------------------------------------------------- + +NativeCalculationTrigger NativeExecutionConsumer::calculation_trigger() const noexcept { + const auto* spec = spec_ptr(); + return spec ? spec->calculation : NativeCalculationTrigger::BarClose; +} + +bool NativeExecutionConsumer::recalculates_on_fills() const noexcept { + const auto trigger = calculation_trigger(); + return trigger == NativeCalculationTrigger::BarCloseAndFills + || trigger == NativeCalculationTrigger::EveryModeledPoint; +} + +void NativeExecutionConsumer::open_point_epoch() noexcept { ++point_epoch_; } + +bool NativeExecutionConsumer::claim_recalculation() noexcept { + const auto* spec = spec_ptr(); + const uint32_t budget = spec ? spec->max_recalculations_per_point : 0; + if (recalc_epoch_ != point_epoch_) { + recalc_epoch_ = point_epoch_; + recalc_epoch_count_ = 0; + } + if (recalc_epoch_count_ >= budget) { + ++recalculations_skipped_; + return false; + } + ++recalc_epoch_count_; + return true; +} + +void NativeExecutionConsumer::note_partial_point( + int64_t script_open_ms, double price, double volume_delta) { + if (!std::isfinite(price)) return; + if (!partial_has_ || partial_script_open_ms_ != script_open_ms) { + partial_ = Bar{price, price, price, price, 0.0, script_open_ms}; + partial_script_open_ms_ = script_open_ms; + partial_has_ = true; + } else { + if (price > partial_.high) partial_.high = price; + if (price < partial_.low) partial_.low = price; + partial_.close = price; + } + if (volume_delta > 0.0 && std::isfinite(volume_delta)) partial_.volume += volume_delta; +} + +void NativeExecutionConsumer::clear_partial() noexcept { + partial_has_ = false; + partial_ = Bar{}; + partial_script_open_ms_ = 0; +} + +std::optional NativeExecutionConsumer::partial_bar() const { + if (!partial_has_) return std::nullopt; + return partial_; +} + +NativeCurrentPointView NativeExecutionConsumer::point_frame_view( + const NativeDriverPoint& point) const { + NativeCurrentPointView view; + // The delivery loop already owns this bar's sub-bar labels and driver + // statistics; only the cursor's own facts are replaced here. + view.decision = callback_context_; + view.decision.coordinate = point.coordinate; + view.decision.decision_floor_ms = decision_floor(); + if (auto input = input_interval_at(point.coordinate.open_ms)) + view.decision.input_interval = *input; + if (auto script = script_interval_at(point.coordinate.open_ms)) + view.decision.script_interval = *script; + view.price = point.raw_price; + view.quote_kind = NativeCurrentQuoteKind::MarketDecision; + view.quote_origin_ordinal = point.coordinate.ordinal; + return view; +} + +void NativeExecutionConsumer::enter_point_frame( + BacktestEngine& engine, const NativeCurrentPointView& point, CallbackPhase phase) { + current_frame_ = CurrentExecutionFrame{point, next_timeline_ordinal_ - 1}; + callback_context_ = point.decision; + callback_context_.decision_floor_ms = decision_floor(); + current_frame_->point.decision.decision_floor_ms = decision_floor(); + engine.current_bar_.timestamp = std::max( + point.decision.coordinate.effective_time_ms, decision_floor()); + in_callback_ = true; + callback_phase_ = phase; +} + +void NativeExecutionConsumer::invoke_recalculation( + BacktestEngine& engine, const Bar& bar, NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause) { + auto* host = dynamic_cast(&engine); + if (!host) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + return; + } + const uint64_t ordinal = callback_context_.coordinate.ordinal; + ++recalculations_; + try { + const NativeDecisionContext presented = callback_context_; + host->on_native_recalculate(bar, presented, reason, cause); + } catch (const std::bad_alloc& e) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + fail(engine, NativeFailure{NativeFailureCode::Allocation, + NativeFailureOperation::Callback, ordinal}); + render(engine, e.what()); + return; + } catch (const std::exception& e) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + if (!failed()) fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback, ordinal}); + render(engine, e.what()); + return; + } catch (...) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + if (!failed()) fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback, ordinal}); + return; + } + finish_callback(engine, ordinal); +} + +const Bar& NativeExecutionConsumer::calculating_bar(const BacktestEngine& engine) const noexcept { + return calculating_bar_has_ ? calculating_bar_ : engine.current_bar_; +} + +void NativeExecutionConsumer::recalculate_at_point( + BacktestEngine& engine, const Bar& bar, const NativeDriverPoint& point) { + if (failed()) return; + if (calculation_trigger() != NativeCalculationTrigger::EveryModeledPoint) return; + if (in_callback_ || consuming_request_ || draining_notifications_) return; + enter_point_frame(engine, point_frame_view(point), CallbackPhase::Tick); + invoke_recalculation(engine, bar, NativeCalculationReason::Tick, nullptr); +} + +void NativeExecutionConsumer::invoke_sub_bar_callback( + BacktestEngine& engine, const Bar& sub, const NativeDriverPoint& point) { + if (failed()) return; + if (in_callback_ || consuming_request_ || draining_notifications_) return; + auto* host = dynamic_cast(&engine); + if (!host) return; + enter_point_frame(engine, point_frame_view(point), CallbackPhase::Tick); + const uint64_t ordinal = callback_context_.coordinate.ordinal; + try { + const NativeDecisionContext presented = callback_context_; + host->on_native_sub_bar(sub, presented); + } catch (const std::bad_alloc& e) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + fail(engine, NativeFailure{NativeFailureCode::Allocation, + NativeFailureOperation::Callback, ordinal}); + render(engine, e.what()); + return; + } catch (const std::exception& e) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + if (!failed()) fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback, ordinal}); + render(engine, e.what()); + return; + } catch (...) { + in_callback_ = false; + callback_phase_ = CallbackPhase::None; + current_frame_.reset(); + if (!failed()) fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback, ordinal}); + return; + } + finish_callback(engine, ordinal); +} + void NativeExecutionConsumer::enqueue_applied_notification(AppliedNotification notification) { applied_notifications_.push_back(std::move(notification)); } @@ -4449,6 +5171,17 @@ void NativeExecutionConsumer::invoke_applied_callback( callback_phase_ = CallbackPhase::Applied; const auto presented = callback_context_; host->on_native_applied(applied, presented); + // L4: the margin receipt of this same fill, after the ordinary applied + // notification and with the same cursor. + if (notification.margin_call_index + && *notification.margin_call_index < requests_.history().size()) { + const auto* margin_call = std::get_if( + &requests_.history().at(*notification.margin_call_index)); + if (margin_call) { + const auto receipt = *margin_call; + host->on_native_margin_call(receipt); + } + } finish_callback(engine, notification.ordinal); } catch (const std::bad_alloc& e) { in_callback_ = false; @@ -4480,11 +5213,34 @@ void NativeExecutionConsumer::drain_applied_notifications(BacktestEngine& engine const auto notification = applied_notifications_[notification_head_++]; raise_floor(notification.point.decision.coordinate.effective_time_ms); invoke_applied_callback(engine, notification); + if (failed() || !recalculates_on_fills()) continue; + // The event is delivered whatever the budget says; only the + // recalculation it would drive is dropped once this point has spent + // max_recalculations_per_point. Executions the callback drives + // through execute_current land at the same cursor, so they spend the + // same point's budget and the cascade is bounded. + if (!claim_recalculation()) continue; + const auto applied = std::get( + requests_.history().at(notification.history_index)); + enter_point_frame(engine, notification.point, CallbackPhase::Applied); + invoke_recalculation(engine, calculating_bar(engine), + NativeCalculationReason::OrderFill, &applied); } draining_notifications_ = false; if (!failed()) { + const bool drained = !applied_notifications_.empty(); + const auto last = drained ? applied_notifications_.back() : AppliedNotification{}; applied_notifications_.clear(); notification_head_ = 0; + // L4: every applied fill re-arms the margin model against the book it + // left behind. Inert for a run that declares no margin model. + if (drained && margin_model() != nullptr) { + native_order::MatchCursor cursor; + cursor.point = last.point.decision.coordinate; + maintain_margin_liquidation(engine, cursor, + last.point.decision.coordinate.path_phase, + last.point.price); + } } } @@ -4514,13 +5270,24 @@ void NativeExecutionConsumer::invoke_bar_open_callback( current.quote_kind = NativeCurrentQuoteKind::MarketDecision; current.quote_origin_ordinal = point.coordinate.ordinal; current_frame_ = CurrentExecutionFrame{current, next_timeline_ordinal_ - 1}; - engine.current_bar_ = bar; + open_point_epoch(); + // L5 open-bar view. OpenOnly masks this one callback's lookahead: the + // host sees H = L = C = open and no volume, and so does current_bar_ + // while the callback runs. Nothing else moves — the complete bar is + // restored before the open match, so matching, fills and every later + // callback are exactly what Complete presents. + const auto* view_spec = spec_ptr(); + const bool open_only = view_spec + && view_spec->open_bar_view == NativeOpenBarView::OpenOnly; + const Bar open_view = open_only + ? Bar{bar.open, bar.open, bar.open, bar.open, 0.0, bar.timestamp} : bar; + engine.current_bar_ = open_view; engine.current_bar_.timestamp = point.coordinate.effective_time_ms; in_callback_ = true; callback_phase_ = CallbackPhase::PreOpen; try { const NativeDecisionContext presented = callback_context_; - host->on_native_bar_open(bar, presented); + host->on_native_bar_open(open_view, presented); } catch (const std::exception& e) { in_callback_ = false; callback_phase_ = CallbackPhase::None; @@ -4544,6 +5311,11 @@ void NativeExecutionConsumer::invoke_bar_open_callback( } return; } + if (open_only) { + const int64_t stamp = engine.current_bar_.timestamp; + engine.current_bar_ = bar; + engine.current_bar_.timestamp = stamp; + } finish_callback(engine, point.coordinate.ordinal); } @@ -4665,9 +5437,14 @@ void NativeExecutionConsumer::invoke_callback(BacktestEngine& engine, const Bar& current_frame_ = CurrentExecutionFrame{point, next_timeline_ordinal_ - 1}; in_callback_ = true; callback_phase_ = CallbackPhase::Bar; + open_point_epoch(); try { const NativeDecisionContext presented = callback_context_; - host->on_native_bar(bar, presented); + // L5: every calculation of the run is routed here. The default + // on_native_recalculate forwards to on_native_bar, so a host that + // never opted into another cadence sees exactly its own contract. + host->on_native_recalculate(bar, presented, NativeCalculationReason::BarClose, + nullptr); } catch (const std::exception& e) { in_callback_ = false; callback_phase_ = CallbackPhase::None; @@ -4692,14 +5469,22 @@ void NativeExecutionConsumer::invoke_callback(BacktestEngine& engine, const Bar& return; } ++engine.diag_script_bars_processed_; + calculation_margin_check(engine, coordinate, bar.close); finish_callback(engine, coordinate.ordinal); } void NativeExecutionConsumer::deliver_confirmed_script(BacktestEngine& engine, const Bar& bar, const NativeCoordinate& base) { const auto* spec = spec_ptr(); + calculating_bar_ = bar; + calculating_bar_has_ = true; const bool high_first = path_uses_high_first( bar, spec ? spec->path_order : NativePathOrder::Auto); + // L4 publishes this script bar's modeled waypoints so the margin model can + // measure a breach against the adverse price the path still reaches. + margin_path_bar_ = bar; + margin_path_high_first_ = high_first; + has_margin_path_ = margin_model() != nullptr; callback_context_ = NativeDecisionContext{}; callback_context_.sub_index = 0; callback_context_.sub_count = 1; @@ -4722,11 +5507,18 @@ void NativeExecutionConsumer::deliver_confirmed_script(BacktestEngine& engine, c point.matching = matching; record_driver(point); if (phase == NativePathPhase::Open) { + // A discrete point IS its own cursor, so the bar so far is folded + // before the callbacks that decide at it. + note_partial_point(base.open_ms, price, 0.0); invoke_bar_open_callback(engine, bar, point); if (failed()) return; + maintain_margin_liquidation(engine, make_cursor(point, 0.0), phase, price); + if (failed()) return; } match_discrete(engine, point); raise_floor(time); + if (provenance != NativePriceProvenance::AfterCalculationClose) + recalculate_at_point(engine, bar, point); }; auto emit_segment = [&](double from, double to, int64_t time, NativePathPhase phase) { NativeDriverPoint point; @@ -4741,7 +5533,11 @@ void NativeExecutionConsumer::deliver_confirmed_script(BacktestEngine& engine, c point.excursion = true; record_driver(point); match_segment(engine, point, from); + // A segment's destination is only reached once the segment has been + // consumed, so the bar so far never runs ahead of the cursor. + note_partial_point(base.open_ms, to, 0.0); raise_floor(time); + recalculate_at_point(engine, bar, point); }; const int64_t open_time = script_.first_source_time_ms != 0 ? script_.first_source_time_ms @@ -4767,6 +5563,9 @@ void NativeExecutionConsumer::deliver_confirmed_script(BacktestEngine& engine, c if (failed()) return; emit_segment(prev, bar.close, close_time, NativePathPhase::Close); if (failed()) return; + // The modeled path is consumed: from the calculation on there is no + // remaining waypoint for the margin model to measure a breach against. + has_margin_path_ = false; NativeCoordinate calc = base; calc.ordinal = take_ordinal(engine); calc.effective_time_ms = close_time; @@ -4776,6 +5575,7 @@ void NativeExecutionConsumer::deliver_confirmed_script(BacktestEngine& engine, c engine.current_bar_ = bar; engine.bar_index_ = calc.interval_index; engine.current_bar_.timestamp = close_time; + clear_partial(); invoke_callback(engine, bar, calc); if (failed()) return; if (spec && spec->close_execution == NativeCloseExecution::AfterCalculation) { @@ -4796,6 +5596,9 @@ void NativeExecutionConsumer::deliver_intrabar_script( return; } + // A sampled intrabar path re-evaluates the margin model at each delivered + // sample instead of at the containing bar's remaining waypoints. + has_margin_path_ = false; std::vector sub_bars; if (lower) { const int64_t begin = base.open_ms; @@ -4820,6 +5623,8 @@ void NativeExecutionConsumer::deliver_intrabar_script( Bar script_bar = bar; script_bar.timestamp = base.open_ms; + calculating_bar_ = script_bar; + calculating_bar_has_ = true; const int sample_count = lower ? lower->samples : synthesized->samples; const auto distribution = lower ? lower->distribution : synthesized->distribution; const bool volume_weighted = lower ? lower->volume_weighted : synthesized->volume_weighted; @@ -4844,6 +5649,7 @@ void NativeExecutionConsumer::deliver_intrabar_script( const bool distribution_samples = synthesized || lower->sample_eligibility == IntrabarPath::SampleEligibility::DistributionSamples; + NativeDriverPoint sub_last_point{}; for (std::size_t sub_index = 0; sub_index < sub_bars.size(); ++sub_index) { const Bar& sub = *sub_bars[sub_index]; callback_context_.sub_index = static_cast(sub_index); @@ -4914,20 +5720,42 @@ void NativeExecutionConsumer::deliver_intrabar_script( point.matching = distribution_samples || sample_index == 0; point.excursion = sample_index != 0; record_driver(point); + // Same rule as the confirmed path: a discrete sample is its own + // cursor and folds before its callbacks, a segment folds after it + // has been consumed. + if (distribution_samples || sample_index == 0) { + note_partial_point(base.open_ms, price, 0.0); + } if (sub_index == 0 && sample_index == 0) { invoke_bar_open_callback(engine, script_bar, point); if (failed()) return; + has_margin_path_ = false; + maintain_margin_liquidation( + engine, make_cursor(point, 0.0), point.coordinate.path_phase, price); + if (failed()) return; } if (distribution_samples || sample_index == 0) { match_discrete(engine, point); if (!failed()) apply_excursion(engine, price); } else { match_segment(engine, point, previous); + if (!failed()) note_partial_point(base.open_ms, price, 0.0); } if (failed()) return; raise_floor(sub.timestamp); + recalculate_at_point(engine, script_bar, point); + if (failed()) return; + sub_last_point = point; previous = price; } + // CT8/HT3: one hook per completed lower-timeframe sub-bar, after its + // whole path. A synthesized path has no lower bars of its own — its + // single "sub-bar" IS the script bar — so it never fires here. + if (lower) { + note_partial_point(base.open_ms, previous, sub.volume); + invoke_sub_bar_callback(engine, sub, sub_last_point); + if (failed()) return; + } } NativeCoordinate calculation = base; @@ -4944,6 +5772,7 @@ void NativeExecutionConsumer::deliver_intrabar_script( engine.current_bar_ = script_bar; engine.bar_index_ = calculation.interval_index; engine.current_bar_.timestamp = calculation.effective_time_ms; + clear_partial(); invoke_callback(engine, script_bar, calculation); if (failed()) return; if (spec->close_execution == NativeCloseExecution::AfterCalculation) { @@ -5015,6 +5844,8 @@ void NativeExecutionConsumer::record_open_position_report_rows(BacktestEngine& e void NativeExecutionConsumer::deliver_aggregate_calculation( BacktestEngine& engine, const Bar& bar, const NativeCoordinate& base) { + calculating_bar_ = bar; + calculating_bar_has_ = true; callback_context_ = NativeDecisionContext{}; callback_context_.sub_index = 0; callback_context_.sub_count = 1; @@ -5032,6 +5863,7 @@ void NativeExecutionConsumer::deliver_aggregate_calculation( engine.current_bar_ = bar; engine.bar_index_ = calc.interval_index; engine.current_bar_.timestamp = close_time; + clear_partial(); invoke_callback(engine, bar, calc); if (failed()) return; const auto* spec = spec_ptr(); @@ -5168,6 +6000,7 @@ bool NativeExecutionConsumer::contribute_input( // trade date's bar (src/engine_aux_security.cpp, docs/pages/native-engine.md). void NativeExecutionConsumer::clear_timeframe_subscriptions(BacktestEngine& engine) { + subscription_warmup_inputs_ = -1; if (subscriptions_.empty()) return; subscriptions_.clear(); input_next_ms_.clear(); @@ -5190,17 +6023,15 @@ bool NativeExecutionConsumer::begin_timeframe_subscriptions( render(engine, "native timeframe subscriptions require a native strategy host"); return false; } - if (is_stream) { - // A subscription is resolved over the run's whole input: the calendar - // aggregators need each bar's successor to close a period on its - // actual last bar, and lookahead_on needs the completed bucket before - // its first bar. Neither is available to a stream. - fail(engine, NativeFailure{NativeFailureCode::Contract, - NativeFailureOperation::Begin}); - render(engine, "native timeframe subscriptions require a batch run"); - return false; - } if (input_bars == nullptr || n_input < 0) n_input = 0; + // A stream resolves its series over the warmup input exactly as a batch of + // those same bars does -- including the last one, whose successor a batch + // does not know either -- and then continues the same aggregators live. + // This is where that phase change happens: a live input has no successor + // and no future, so it extends the current bucket and delivers it at + // completion under both publication modes (Pine's lookahead is a + // historical-resolution mode; the realtime bar has nothing to look into). + subscription_warmup_inputs_ = is_stream ? n_input : -1; try { input_next_ms_.assign(static_cast(n_input), 0); for (int i = 0; i + 1 < n_input; ++i) { @@ -5273,10 +6104,13 @@ bool NativeExecutionConsumer::begin_timeframe_subscriptions( return true; } -// barmerge.lookahead_on: resolve the whole series over the batch input now, so -// each completed bucket's FINAL values can be delivered at the input bar that -// opened it. This pass IS the subscription's only feed; its live pump is -// skipped, so the substitution/miss diagnostics count each bucket exactly once. +// barmerge.lookahead_on: resolve the whole series over the historical input +// now, so each completed bucket's FINAL values can be delivered at the input +// bar that opened it. This pass IS the subscription's only feed for those +// inputs; the pump is skipped over them, so the substitution/miss diagnostics +// count each bucket exactly once. A stream's historical input is its warmup, +// and the aggregator is left holding whatever bucket the warmup left open -- +// the live phase continues that very bucket through the aggregating pump. bool NativeExecutionConsumer::project_timeframe_subscription( BacktestEngine& engine, TimeframeSubscription& subscription, const Bar* input_bars, int n_input) { @@ -5314,6 +6148,14 @@ bool NativeExecutionConsumer::project_timeframe_subscription( } } engine.security_next_input_ms_ = 0; + // The bucket the projected input left open, published as the aggregating + // pump's own cursor. Inert for a batch run, where a lookahead_on series + // never reaches that pump. For a stream it keeps the documented anchor: + // the delivered context's interval is the span of the bucket's FIRST + // contributing input bar (native_host.hpp), which for a bucket the warmup + // opened is a warmup bar, not the first live one that continues it. + subscription.bucket_first_index = first_index; + subscription.bucket_first_ms = first_ms; return !failed(); } @@ -5333,7 +6175,13 @@ bool NativeExecutionConsumer::pump_timeframe_subscriptions( return false; } } - continue; + // Historical inputs are served entirely by that projection. A + // stream's live inputs are not in it and have no future to be + // resolved over, so they fall through to the aggregating pump + // below: the same buckets, delivered when they complete. + if (subscription_warmup_inputs_ < 0 || index < subscription_warmup_inputs_) { + continue; + } } auto& state = engine.security_eval_states_[static_cast(subscription.sec_id)]; @@ -5370,6 +6218,20 @@ bool NativeExecutionConsumer::pump_timeframe_subscriptions( return true; } +// A declared series is a function of the accepted CONFIRMED input, which is +// the whole input a batch of the same bars has. The tick driver's other two +// contributions have no batch counterpart to reproduce: an observed-tick slot +// is finalized after its own matching pass, and a quiet-carried slot is a +// synthesized flat bar a batch feed would simply not contain. Rather than fold +// either into a bucket and silently answer with a series no batch could +// produce, a stream that declares a series takes confirmed bars only. +bool NativeExecutionConsumer::refuse_subscription_tick_input(BacktestEngine& engine) { + if (subscriptions_.empty()) return false; + present_refusal(engine, + "native timeframe subscriptions require confirmed-bar stream input"); + return true; +} + bool NativeExecutionConsumer::deliver_timeframe_bar( BacktestEngine& engine, TimeframeSubscription& subscription, const Bar& bucket, std::int64_t first_contributing_ms, std::int64_t delivered_at_ms, @@ -5819,6 +6681,7 @@ bool NativeExecutionConsumer::preflight_ticks(BacktestEngine& engine, const Trad return false; } if (refuse_mixed_input_mode(engine, InputMode::ObservedTicks)) return false; + if (refuse_subscription_tick_input(engine)) return false; if (n == 0) return true; uint64_t prev_sequence = last_tick_sequence_; bool prev_has_sequence = has_tick_sequence_; @@ -5934,6 +6797,9 @@ bool NativeExecutionConsumer::finalize_observed_tick_slot( calc.ordinal = take_ordinal(engine); raise_floor(calc.effective_time_ms); engine.current_bar_ = forming_; + calculating_bar_ = forming_; + calculating_bar_has_ = true; + clear_partial(); invoke_callback(engine, forming_, calc); if (failed()) return false; const auto* spec = spec_ptr(); @@ -6039,6 +6905,11 @@ bool NativeExecutionConsumer::deliver_tick(BacktestEngine& engine, const TradeTi tick_context.sequence = tick.sequence; const Bar tick_bar{tick.price, tick.price, tick.price, tick.price, tick.quantity, tick.timestamp}; + calculating_bar_ = tick_bar; + calculating_bar_has_ = true; + // The print is the cursor, and it is real traded activity: the bar so far + // folds its price and its quantity before the observation hook runs. + note_partial_point(tick_context.decision.script_bar_open_ms, tick.price, tick.quantity); if (!invoke_tick_callback(engine, tick_bar, tick_context)) { processing_input_ = false; return false; @@ -6046,6 +6917,11 @@ bool NativeExecutionConsumer::deliver_tick(BacktestEngine& engine, const TradeTi match_point(engine, point); apply_excursion(engine, tick.price); raise_floor(tick.timestamp); + recalculate_at_point(engine, tick_bar, point); + if (failed()) { + processing_input_ = false; + return false; + } last_price_ = tick.price; has_last_price_ = true; last_print_time_ms_ = tick.timestamp; @@ -6115,6 +6991,7 @@ bool NativeExecutionConsumer::stream_advance_time(BacktestEngine& engine, int64_ return false; } if (refuse_mixed_input_mode(engine, InputMode::ObservedTicks)) return false; + if (refuse_subscription_tick_input(engine)) return false; if (has_floor_ && timestamp_ms < decision_floor_ms_) { present_refusal(engine, "native time advance regresses the decision floor"); return false; @@ -6648,6 +7525,21 @@ std::optional NativeStrategyHost::native_series_bar(std::size_t subscriptio .series_bar(subscription); } +std::optional NativeStrategyHost::current_partial_bar() const { + return as_native_consumer(const_cast(execution_consumer())) + .partial_bar(); +} + +std::uint64_t NativeStrategyHost::native_recalculation_count() const { + return as_native_consumer(const_cast(execution_consumer())) + .recalculation_count(); +} + +std::uint64_t NativeStrategyHost::native_recalculations_skipped() const { + return as_native_consumer(const_cast(execution_consumer())) + .recalculations_skipped(); +} + NativeStateView NativeStrategyHost::native_state() const { return as_native_consumer(const_cast(execution_consumer())).view(); } @@ -6732,10 +7624,20 @@ NativePhysicalPosition NativeStrategyHost::physical_position() const { return as_native_consumer(const_cast(execution_consumer())).position(*this); } +std::optional NativeExecutionConsumer::host_liquidation_price( + const BacktestEngine& engine) const { + return liquidation_level(engine); +} + double NativeStrategyHost::native_marked_equity(double mark) const { return as_native_consumer(const_cast(execution_consumer())).marked(*this, mark); } +std::optional NativeStrategyHost::native_liquidation_price() const { + return as_native_consumer(const_cast(execution_consumer())) + .host_liquidation_price(*this); +} + std::vector NativeStrategyHost::native_events(uint64_t after_ordinal) const { return as_native_consumer(const_cast(execution_consumer())) .events_after(after_ordinal); diff --git a/src/native_execution_consumer.hpp b/src/native_execution_consumer.hpp index 818ab56b..12dc3ce5 100644 --- a/src/native_execution_consumer.hpp +++ b/src/native_execution_consumer.hpp @@ -84,6 +84,7 @@ class NativeExecutionConsumer final : public IExecutionConsumer { BacktestEngine& engine, const NativeCurrentExecution& command); NativePhysicalPosition position(const BacktestEngine& engine) const; double marked(const BacktestEngine& engine, double price) const; + std::optional host_liquidation_price(const BacktestEngine& engine) const; std::vector events_after(uint64_t after_ordinal) const; uint64_t event_high_water() const noexcept; uint64_t terminal_receipt_high_water() const noexcept { @@ -96,6 +97,12 @@ class NativeExecutionConsumer final : public IExecutionConsumer { uint64_t high_water() const noexcept { return consumed_high_water_; } void reject_inherited_on_bar(BacktestEngine& engine); std::optional series_bar(std::size_t subscription) const; + // L5 calculation timing readbacks. The partial bar is the lookahead-free + // bar so far at the current cursor; the two counters are observation of + // the recalculation cadence, never matching state. + std::optional partial_bar() const; + uint64_t recalculation_count() const noexcept { return recalculations_; } + uint64_t recalculations_skipped() const noexcept { return recalculations_skipped_; } private: struct CurrentExecutionFrame { @@ -107,6 +114,19 @@ class NativeExecutionConsumer final : public IExecutionConsumer { std::size_t history_index = 0; uint64_t ordinal = 0; NativeCurrentPointView point; + // History index of the MarginCallEvent this fill recorded, when the + // filled request was the kernel's own liquidation. Absent for every + // host request, which is the whole population of a run without a + // margin model. + std::optional margin_call_index; + }; + // The live kernel-issued liquidation of the current position, if any. + // At most one rests at a time: a moved level or moved units withdraw the + // previous one under CancelReason::Superseded before the new one is born. + struct MarginLiquidation { + native_order::RequestHandle handle{}; + double level = 0.0; + double units = 0.0; }; struct ResolvedCandidate { native_order::ExecutionPlan physical = execution::Flatten{}; @@ -193,8 +213,9 @@ class NativeExecutionConsumer final : public IExecutionConsumer { // opens one. int bucket_first_index = -1; std::int64_t bucket_first_ms = 0; - // lookahead_on: the whole series, resolved over the batch input at - // begin, each bucket keyed to the input index it is delivered on. + // lookahead_on: the whole series, resolved at begin over the + // historical input (a stream's is its warmup), each bucket keyed to + // the input index it is delivered on. std::vector projected_bars; std::vector projected_first_index; std::vector projected_first_ms; @@ -296,6 +317,10 @@ class NativeExecutionConsumer final : public IExecutionConsumer { TimeframeSubscription& subscription, const Bar* input_bars, int n_input); bool pump_timeframe_subscriptions(BacktestEngine& engine, const Bar& bar, int index); + // A declared series is fed by accepted CONFIRMED input only, because that + // is the only input a batch of the same bars also has. True (refused) + // exactly when a stream that declares one is asked for tick-driven input. + bool refuse_subscription_tick_input(BacktestEngine& engine); bool deliver_timeframe_bar(BacktestEngine& engine, TimeframeSubscription& subscription, const Bar& bucket, std::int64_t first_contributing_ms, std::int64_t delivered_at_ms, NativeCompletionKind completion); @@ -319,6 +344,41 @@ class NativeExecutionConsumer final : public IExecutionConsumer { void apply_excursion(BacktestEngine& engine, double price); void invoke_bar_open_callback(BacktestEngine& engine, const Bar& bar, const NativeDriverPoint& point); + // L5 calculation timing. Every calculation of the run is routed through + // invoke_recalculation, whose BarClose reason is the script bar's own + // calculation; the default host forwarding keeps on_native_bar exactly + // what it was. A recalculation never records a report point: only the + // script calculation does (record_script_report_point). + void invoke_recalculation(BacktestEngine& engine, const Bar& bar, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause); + // One Tick recalculation at a modeled path point / observed print, for a + // spec that asked for EveryModeledPoint. Inert for every other spec. + void recalculate_at_point(BacktestEngine& engine, const Bar& bar, + const NativeDriverPoint& point); + void invoke_sub_bar_callback(BacktestEngine& engine, const Bar& sub, + const NativeDriverPoint& point); + // Frame bookkeeping shared by the mid-path callbacks above and by + // invoke_applied_callback: one owning current point, one decision + // context, and the engine's callback timestamp. + void enter_point_frame(BacktestEngine& engine, const NativeCurrentPointView& point, + CallbackPhase phase); + NativeCurrentPointView point_frame_view(const NativeDriverPoint& point) const; + const Bar& calculating_bar(const BacktestEngine& engine) const noexcept; + NativeCalculationTrigger calculation_trigger() const noexcept; + bool recalculates_on_fills() const noexcept; + // The lookahead-free bar so far. Folded from the modeled points as they + // are presented, keyed by the script bar's own open so a new script bar + // always restarts it; `volume` accrues only activity actually consumed + // (completed lower sub-bars, observed prints). + void note_partial_point(int64_t script_open_ms, double price, double volume_delta); + void clear_partial() noexcept; + // One matching point's recalculation budget. Points are identified by a + // monotone epoch raised whenever the consumer establishes a new cursor, + // so executions a callback drives through execute_current at that same + // cursor spend the same budget as the matched fill that started them. + void open_point_epoch() noexcept; + bool claim_recalculation() noexcept; bool invoke_input_callback(BacktestEngine& engine, const Bar& bar, const NativeInputContext& context); bool invoke_tick_callback(BacktestEngine& engine, const Bar& bar, @@ -353,6 +413,34 @@ class NativeExecutionConsumer final : public IExecutionConsumer { const execution::SettlementInspection& inspect, bool skip_initial_margin, native_order::MatchRejectReason* reason) const; + // L4 generic margin model. Every one of these is inert for a spec that + // leaves `margin` unset, which is every source-projected spec. + const NativeMarginModel* margin_model() const noexcept; + std::optional maintenance_fraction(bool short_side) const noexcept; + // Solve equity(P) == maintenance requirement(P) for the live book. + std::optional liquidation_level(const BacktestEngine& engine) const; + // The price the breach is measured at: the most adverse price the modeled + // script path still reaches after `phase`, or `fallback` when the point + // has no remaining modeled path of its own. + double margin_sizing_price(bool short_side, NativePathPhase phase, + double fallback) const noexcept; + // Kernel sizing then the host override. nullopt means no liquidation. + std::optional margin_call_units( + const BacktestEngine& engine, double mark, const native_order::MatchCursor& cursor, + double* out_equity, double* out_required) const; + void withdraw_margin_liquidation(BacktestEngine& engine); + void maintain_margin_liquidation(BacktestEngine& engine, + const native_order::MatchCursor& cursor, + NativePathPhase phase, double fallback_price); + void calculation_margin_check(BacktestEngine& engine, const NativeCoordinate& calc, + double mark); + bool kernel_submit_liquidation(BacktestEngine& engine, double level, double units, + std::int64_t decision_time_ms, + native_order::RequestHandle* out_handle = nullptr); + std::optional record_margin_call( + BacktestEngine& engine, const native_order::ExecutionAppliedEvent& applied, + const native_order::DefinitionRef& definition, double position_before, + double position_after); void fail_preparation(BacktestEngine& engine, const native_order::PreparationError& error, NativeFailureOperation operation); void catch_up_timeline() noexcept; @@ -445,11 +533,16 @@ class NativeExecutionConsumer final : public IExecutionConsumer { // Declared higher-timeframe series: empty for every spec that declares // none, which is the whole source-projected population. std::vector subscriptions_{}; - // Owned copy of the batch input's forward look, input_next_ms_[i] being - // input bar i+1's timestamp (0 for the last). The calendar aggregators + // Owned copy of the historical input's forward look, input_next_ms_[i] + // being input bar i+1's timestamp (0 for the last, and for every live + // stream input, whose successor nobody has yet). The calendar aggregators // need it to complete a D/W/M bucket on the period's actual last bar; // allocated only for a run that declares a subscription. std::vector input_next_ms_{}; + // How many of the inputs the declared series were resolved over at begin + // are warmup, i.e. where a stream's historical phase ends and its live + // phase starts. -1 for a batch run, whose every input is historical. + int subscription_warmup_inputs_ = -1; // Borrowed begin arguments, valid only inside one public begin call. const Bar* begin_bars_ = nullptr; int begin_n_ = 0; @@ -471,6 +564,18 @@ class NativeExecutionConsumer final : public IExecutionConsumer { // Derived receipt cursor: it can be reconstructed from the immutable // command history and only lets source projections skip empty polls. uint64_t terminal_receipt_high_water_ = 0; + // L4: the resting kernel liquidation and the modeled script path it is + // sized against. Both are folded into the continuation digest only when + // the run spec declares a margin model. + std::optional margin_liquidation_; + // Kernel liquidations already booked at one driver point. A kernel-sized + // slice always restores or flattens, so it re-arms at most once per fill; + // this bounds a host override that keeps answering with a smaller slice. + uint64_t margin_point_ordinal_ = 0; + std::uint32_t margin_point_calls_ = 0; + Bar margin_path_bar_{}; + bool has_margin_path_ = false; + bool margin_path_high_first_ = false; std::array cohort_target_cache_{}; std::size_t cohort_target_cache_size_ = 0; // Derived calendar lookup cache, cleared at staged ingress (L10c). @@ -483,6 +588,26 @@ class NativeExecutionConsumer final : public IExecutionConsumer { // not set that gate, preserving their established fingerprint while the // new generic authority remains hash-visible for native hosts. mutable AppendDigest precommit_digest_{}; + // L5 calculation timing. All of this is derived observation over the + // already hashed driver/notification state: the partial bar is the fold + // of points the driver log already holds, the epoch is a cursor counter, + // and the two totals are readbacks. They fold into the continuation + // digest only for a spec that actually opted into a non-default cadence, + // so a default spec keeps the continuation identity it had before this + // lane (the precommit_digest_ precedent). + Bar partial_{}; + bool partial_has_ = false; + // The bar a mid-path callback is calculating: the script bar under + // delivery in batch, the print's value bar in a stream. Borrowed nowhere: + // it is an owning copy taken when delivery begins. + Bar calculating_bar_{}; + bool calculating_bar_has_ = false; + int64_t partial_script_open_ms_ = 0; + uint64_t point_epoch_ = 0; + uint64_t recalc_epoch_ = 0; + uint32_t recalc_epoch_count_ = 0; + uint64_t recalculations_ = 0; + uint64_t recalculations_skipped_ = 0; }; inline NativeExecutionConsumer& as_native_consumer(IExecutionConsumer& consumer) { diff --git a/src/native_order.cpp b/src/native_order.cpp index 32de27cd..0f72488d 100644 --- a/src/native_order.cpp +++ b/src/native_order.cpp @@ -1666,7 +1666,8 @@ std::vector WorkingRequestCore::group_recipients(const EventId& a PreparedSubmit WorkingRequestCore::prepare_submit(const Request& request, const CommandContext& context, uint64_t& next_order_incarnation, - uint64_t& next_timeline_ordinal) { + uint64_t& next_timeline_ordinal, + RequestOrigin origin) { require_identity(identity_); require_distinct_counters(next_order_incarnation, next_timeline_ordinal); Request staged = request; @@ -1689,7 +1690,7 @@ PreparedSubmit WorkingRequestCore::prepare_submit(const Request& request, RequestHandle handle{identity_, incarnation}; Birth birth{ordinal, context.decision_time_ms}; auto definition = std::make_shared( - RequestDefinition{handle, std::move(staged), birth, std::nullopt}); + RequestDefinition{handle, std::move(staged), birth, std::nullopt, origin}); LiveRequest live = make_live(definition, context, EventId{identity_, ordinal}); AcceptedEvent accepted; accepted.ordinal = ordinal; @@ -1800,7 +1801,8 @@ PreparedReplace WorkingRequestCore::prepare_replace(const RequestHandle& target, } PreparedCancel WorkingRequestCore::prepare_cancel(const RequestHandle& target, - uint64_t& next_timeline_ordinal) { + uint64_t& next_timeline_ordinal, + CancelReason reason) { require_identity(identity_); RequestHandle staged_target = target; std::size_t live_index = 0; @@ -1825,7 +1827,7 @@ PreparedCancel WorkingRequestCore::prepare_cancel(const RequestHandle& target, return PreparedCancel(std::move(impl)); } impl->result = CancelResult{CancelStatus::Cancelled, ordinal}; - plan.events.emplace_back(make_cancelled(ordinal, live_[live_index], CancelReason::User, + plan.events.emplace_back(make_cancelled(ordinal, live_[live_index], reason, EventId{identity_, ordinal})); plan.live_change = kLiveErase; plan.live_index = live_index; @@ -2366,6 +2368,25 @@ Preparation WorkingRequestCore::prepare_match_rejected( return finish_mutation(std::move(plan)); } +Preparation WorkingRequestCore::prepare_margin_call( + const MarginCallEvent& event, uint64_t& next_timeline_ordinal) { + require_identity(identity_); + if (!event.definition || event.definition->handle.run != identity_) { + return PreparationError{CoreFailure::InvalidProposal, event.applied, + event.definition ? event.definition->handle : RequestHandle{}}; + } + if (event.definition->origin == RequestOrigin::Host) { + return PreparationError{CoreFailure::InvalidProposal, event.applied, + event.definition->handle}; + } + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + MutationPlan plan = begin_plan(); + MarginCallEvent receipt = event; + receipt.ordinal = ordinal; + plan.events.emplace_back(std::move(receipt)); + return finish_mutation(std::move(plan)); +} + Preparation WorkingRequestCore::prepare_terms( const RequestHandle& target, const EvaluationContext& context, diff --git a/src/native_run_spec.cpp b/src/native_run_spec.cpp index af44f2d4..18778f19 100644 --- a/src/native_run_spec.cpp +++ b/src/native_run_spec.cpp @@ -99,6 +99,25 @@ bool valid_report_policy(NativeReportPolicy policy) noexcept { return false; } +bool valid_calculation_trigger(NativeCalculationTrigger trigger) noexcept { + switch (trigger) { + case NativeCalculationTrigger::BarClose: + case NativeCalculationTrigger::BarCloseAndFills: + case NativeCalculationTrigger::EveryModeledPoint: + return true; + } + return false; +} + +bool valid_open_bar_view(NativeOpenBarView view) noexcept { + switch (view) { + case NativeOpenBarView::Complete: + case NativeOpenBarView::OpenOnly: + return true; + } + return false; +} + bool valid_path_order(NativePathOrder order) noexcept { switch (order) { case NativePathOrder::Auto: @@ -152,6 +171,58 @@ std::int64_t subscription_period_key(const native_calendar::Timeframe& tf) noexc return count * unit_seconds; } +bool valid_liquidation_sizing(NativeLiquidationSizing sizing) noexcept { + switch (sizing) { + case NativeLiquidationSizing::RestoreMinimum: + case NativeLiquidationSizing::ShortfallMultiple: + case NativeLiquidationSizing::Flatten: + return true; + } + return false; +} + +bool valid_liquidation_check(NativeLiquidationCheck check) noexcept { + switch (check) { + case NativeLiquidationCheck::PathAdverseExtreme: + case NativeLiquidationCheck::CalculationOnly: + return true; + } + return false; +} + +// The generic margin model is the whole admission authority of the run that +// declares it. Both spellings at once is a configuration conflict, never a +// silent precedence rule. +Result validate_margin(const NativeRunSpec& spec) noexcept { + if (!spec.margin) return {}; + if (spec.initial_margin_fraction) { + return {Error::MarginModelConflict, Field::MarginModel}; + } + const auto& margin = *spec.margin; + if (!positive(margin.initial_long) || !positive(margin.initial_short)) { + return {Error::NotFinitePositive, Field::MarginInitial}; + } + if (margin.maintenance_long && !positive(*margin.maintenance_long)) { + return {Error::NotFinitePositive, Field::MarginMaintenance}; + } + if (margin.maintenance_short && !positive(*margin.maintenance_short)) { + return {Error::NotFinitePositive, Field::MarginMaintenance}; + } + if (!valid_liquidation_sizing(margin.sizing)) { + return {Error::UnknownLiquidationSizing, Field::MarginSizing}; + } + if (!positive(margin.shortfall_multiple)) { + return {Error::NotFinitePositive, Field::MarginShortfallMultiple}; + } + if (margin.liquidation_min_units && !positive(*margin.liquidation_min_units)) { + return {Error::NotFinitePositive, Field::MarginMinUnits}; + } + if (!valid_liquidation_check(margin.check)) { + return {Error::UnknownLiquidationCheck, Field::MarginCheck}; + } + return {}; +} + bool valid_legacy_tolerance(NativeLegacyTolerance tolerance) noexcept { constexpr std::uint32_t kKnown = static_cast(NativeLegacyTolerance::BatchStructuralBars) @@ -264,8 +335,15 @@ Result validate_values(const NativeRunSpec& spec) noexcept { } if (spec.initial_margin_fraction && !positive(*spec.initial_margin_fraction)) return {Error::NotFinitePositive, Field::InitialMarginFraction}; + if (const auto margin = validate_margin(spec); !margin) return margin; if (!valid_report_policy(spec.report_policy)) return {Error::UnknownReportPolicy, Field::ReportPolicy}; + // L5 calculation timing. Every bound is legal, including zero: a host may + // ask for the fills to be delivered without ever driving a recalculation. + if (!valid_calculation_trigger(spec.calculation)) + return {Error::UnknownCalculationTrigger, Field::Calculation}; + if (!valid_open_bar_view(spec.open_bar_view)) + return {Error::UnknownOpenBarView, Field::OpenBarView}; if (!spec.subscriptions.empty() && spec.timeframe_undetected) { return {Error::SubscriptionWithoutTimeframe, Field::SubscriptionTimeframe}; } @@ -459,6 +537,32 @@ std::uint64_t native_intrabar_path_digest(const IntrabarPath& path) noexcept { return state; } +std::uint64_t native_margin_model_digest(const NativeMarginModel& margin) noexcept { + std::uint64_t state = 1469598103934665603ULL; + const auto bytes = [&state](const void* data, std::size_t count) noexcept { + const auto* values = static_cast(data); + for (std::size_t i = 0; i < count; ++i) { + state ^= values[i]; + state *= 1099511628211ULL; + } + }; + const auto u = [&bytes](std::uint64_t value) noexcept { bytes(&value, sizeof value); }; + const auto d = [&bytes](double value) noexcept { bytes(&value, sizeof value); }; + const auto o = [&u, &d](const std::optional& value) noexcept { + u(value.has_value() ? 1u : 0u); + if (value) d(*value); + }; + d(margin.initial_long); + d(margin.initial_short); + o(margin.maintenance_long); + o(margin.maintenance_short); + u(static_cast(margin.sizing)); + d(margin.shortfall_multiple); + o(margin.liquidation_min_units); + u(static_cast(margin.check)); + return state; +} + std::uint64_t native_timeframe_subscriptions_digest( const std::vector& subscriptions) noexcept { std::uint64_t state = 1469598103934665603ULL; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 08c66bd2..4bbd857b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ set(TEST_SOURCES test_native_order_terms_core test_native_execution_terms test_native_report_truth + test_native_margin_model test_native_queued_reverse_to test_native_precommit_view test_native_fx_activation @@ -108,6 +109,8 @@ set(TEST_SOURCES test_native_market_vertical_contract test_native_calendar_driver_contract test_native_htf_subscriptions + test_native_calc_timing + test_native_htf_subscriptions_stream test_native_host_repairs test_l8c_short_seed_guards test_l8c_adapter_ordering diff --git a/tests/fixtures/native_cpp_abi/host-ab9714b/README.md b/tests/fixtures/native_cpp_abi/host-ab9714b/README.md index 4054f3a9..d6d6ced4 100644 --- a/tests/fixtures/native_cpp_abi/host-ab9714b/README.md +++ b/tests/fixtures/native_cpp_abi/host-ab9714b/README.md @@ -16,5 +16,6 @@ archive. The ABI matrices require v16↔v18 rejection in both directions while retaining historical v13/v14/v15 controls. Its sibling `relocation-manifest-v16-v18.json` pins the added `NativeStrategyHost` virtuals (`prepare_native_begin`, `on_native_bar_open`, `on_native_input`, -`on_native_tick`, `on_native_timeframe_bar`) +`on_native_tick`, `on_native_timeframe_bar`, `resolve_margin_call_units`, +`on_native_margin_call`) and the additive v18 value members, with no engine storage relocation. diff --git a/tests/fixtures/native_cpp_abi/host-ab9714b/relocation-manifest-v16-v18.json b/tests/fixtures/native_cpp_abi/host-ab9714b/relocation-manifest-v16-v18.json index fe09416f..5139a3aa 100644 --- a/tests/fixtures/native_cpp_abi/host-ab9714b/relocation-manifest-v16-v18.json +++ b/tests/fixtures/native_cpp_abi/host-ab9714b/relocation-manifest-v16-v18.json @@ -27,14 +27,29 @@ "IntrabarPath::synthesized", "NativeDecisionContext::driver_statistics", "NativeInputContext", - "NativeTickContext" + "NativeTickContext", + "NativeRunSpec::margin", + "NativeMarginModel", + "NativeMarginCallView", + "RequestDefinition::origin", + "MarginCallEvent", + "NativeRunSpec::calculation", + "NativeRunSpec::max_recalculations_per_point", + "NativeRunSpec::open_bar_view", + "NativeCalculationTrigger", + "NativeOpenBarView", + "NativeCalculationReason" ], "addedVirtuals": [ "prepare_native_begin", "on_native_bar_open", "on_native_input", "on_native_tick", - "on_native_timeframe_bar" + "on_native_timeframe_bar", + "resolve_margin_call_units", + "on_native_margin_call", + "on_native_recalculate", + "on_native_sub_bar" ], "removedVirtuals": [ "legacy_run_simple", @@ -52,7 +67,13 @@ "apply_source_pending_removals" ], "rejectionPairs": [ - ["v16-frozen", "v18-current"], - ["v18-current", "v16-frozen"] + [ + "v16-frozen", + "v18-current" + ], + [ + "v18-current", + "v16-frozen" + ] ] } diff --git a/tests/test_native_calc_timing.cpp b/tests/test_native_calc_timing.cpp new file mode 100644 index 00000000..adaf5630 --- /dev/null +++ b/tests/test_native_calc_timing.cpp @@ -0,0 +1,888 @@ +// Native calculation timing for a bare NativeStrategyHost: the +// NativeCalculationTrigger cadence, the OrderFill cascade and its per-point +// bound, the EveryModeledPoint tick cadence, the lower-timeframe sub-bar +// hook, the OpenOnly bar-open view and the partial-bar accessor. +// +// Witnesses, all independent of the feature under test: +// 1. the BarClose default delivers exactly the callback sequence, the fills +// and the continuation hash a clean b01ef03 build delivers, and drives +// zero recalculations; +// 2. BarCloseAndFills: a host that refills on its own fill produces the +// cascade entry -> applied -> recalculate -> execute -> applied ... in +// that order, and max_recalculations_per_point = 2 stops it after two +// recalculations while the third fill is still applied and delivered; +// 3. EveryModeledPoint: one Tick recalculation per modeled point in batch +// (each confirmed waypoint, each intrabar sample) and one per observed +// print in a stream, in order, with current_partial_bar() folding the +// bar so far up to that cursor; +// 4. on_native_sub_bar fires once per retained lower-timeframe sub-bar and +// never for a plain confirmed or synthesized path; +// 5. OpenOnly hands the bar-open callback H = L = C = open and volume 0 +// while the applied and close callbacks keep the complete bar, and the +// fills are exactly the ones Complete books; +// 6. recalculations record no report point: under KernelRecorded the curve +// still has one point per script bar; +// 7. a default spec hashes to the pre-L5 continuation constant; +// 8. TWIN: the adapter's calc_on_order_fills probe from +// tests/test_native_l4c_coof_literals.cpp, re-expressed natively. + +#include +#include + +#include +#include +#include +#include +#include + +namespace { +using namespace pineforge; + +int checks = 0; +int failures = 0; +const char* scenario = "initialization"; + +#define CHECK(expression) \ + do { \ + ++checks; \ + if (!(expression)) { \ + ++failures; \ + std::printf("FAIL [%s] line %d: %s\n", scenario, __LINE__, \ + #expression); \ + } \ + } while (false) + +bool same(double a, double b) { + return std::isfinite(a) && std::isfinite(b) && std::abs(a - b) <= 1e-9; +} + +std::string fmt(double value) { + char buffer[64]; + std::snprintf(buffer, sizeof buffer, "%.4f", value); + return buffer; +} + +std::string stamp(std::int64_t value) { + return std::to_string(static_cast(value)); +} + +void report_log(const std::vector& got, + const std::vector& want) { + if (got == want) return; + std::printf(" log mismatch (%zu rows, wanted %zu)\n", got.size(), want.size()); + for (std::size_t i = 0; i < got.size() || i < want.size(); ++i) { + const char* g = i < got.size() ? got[i].c_str() : ""; + const char* w = i < want.size() ? want[i].c_str() : ""; + std::printf(" %2zu %-52s | %s\n", i, g, w); + } +} + +NativeRunSpec base_spec(const char* session_key, const char* script = "1") { + NativeRunSpec spec; + spec.identity = {session_key, 1}; + spec.input_tf = "1"; + spec.script_tf = script; + spec.ticker = "CALC"; + spec.tickerid = "TEST:CALC"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = ""; + spec.volumetype = ""; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = ""; + spec.initial_capital = 100000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.slippage_ticks = 0; + spec.fee_kind = NativeFeeKind::CashPerExecution; + spec.fee_value = 0.0; + return spec; +} + +// open = 100 + i, high = open + 2, low = open - 1, close = open + 1. +std::vector minute_bars(int n) { + std::vector bars; + for (int i = 0; i < n; ++i) { + const double open = 100.0 + i; + bars.push_back({open, open + 2.0, open - 1.0, open + 1.0, 10.0 + i, + static_cast(i) * 60000}); + } + return bars; +} + +std::string partial_of(const NativeStrategyHost& host) { + const auto partial = host.current_partial_bar(); + if (!partial) return "none"; + return fmt(partial->open) + "/" + fmt(partial->high) + "/" + fmt(partial->low) + + "/" + fmt(partial->close) + " v=" + fmt(partial->volume); +} + +// ---- 1. the BarClose default surface -------------------------------------- + +// The cadence folds into the run-spec fold only once the trigger or the +// open-bar view is non-default, so a BarClose spec folds exactly the pre-L5 +// fields. That is pinned as native_run_spec_digest() and not as a continuation +// hash: the continuation identity also folds the machine's resolved timezone +// resources (zoneinfo root and zone file paths), so a raw constant passes here +// and fails on CI. Observed on THIS tree for base_spec("native-calc-timing"); +// it guards the fold's field list and order, while the neutrality claim is the +// equalities in test_bar_close_default — including the callback log, the book +// and the recalculation counters, which are the pre-L5 contract itself. +constexpr std::uint64_t kDefaultSpecDigest = 12300031127478902007ull; + +class DefaultHost final : public NativeStrategyHost { +public: + std::vector log; + int bars = 0; + + void on_native_bar_open(const Bar& bar, const NativeDecisionContext&) override { + log.push_back("open@" + stamp(bar.timestamp) + " " + fmt(bar.open) + "/" + + fmt(bar.high) + "/" + fmt(bar.low) + "/" + fmt(bar.close)); + } + void on_native_applied(const native_order::ExecutionAppliedEvent& applied, + const NativeDecisionContext&) override { + log.push_back("applied#" + std::to_string(static_cast(applied.ordinal)) + + " px=" + fmt(applied.resolved_price) + + " t=" + stamp(applied.effective_time_ms())); + } + void on_native_bar(const Bar& bar, const NativeDecisionContext&) override { + ++bars; + log.push_back("bar@" + stamp(bar.timestamp)); + if (bars == 1) submit_market({order_action::Transact{1.0}, "e", ""}); + if (bars == 4) submit_market({execution::Flatten{}, "x", ""}); + } +}; + +void test_bar_close_default() { + scenario = "BarClose default"; + const auto bars = minute_bars(6); + DefaultHost host; + CHECK(host.configure_native(base_spec("native-calc-timing")).status + == NativeSetupStatus::Applied); + host.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + + const std::vector want = { + "open@0 100.0000/102.0000/99.0000/101.0000", + "bar@0", + "open@60000 101.0000/103.0000/100.0000/102.0000", + "applied#8 px=101.0000 t=60000", + "bar@60000", + "open@120000 102.0000/104.0000/101.0000/103.0000", + "bar@120000", + "open@180000 103.0000/105.0000/102.0000/104.0000", + "bar@180000", + "open@240000 104.0000/106.0000/103.0000/105.0000", + "applied#26 px=104.0000 t=240000", + "bar@240000", + "open@300000 105.0000/107.0000/104.0000/106.0000", + "bar@300000", + }; + report_log(host.log, want); + CHECK(host.log == want); + CHECK(host.physical_position().lot_count == 0); + CHECK(host.closed_trade_count() == 1); + // The default cadence drives no recalculation at all: every calculation + // of the run is the script bar's own. + CHECK(host.native_recalculation_count() == 0); + CHECK(host.native_recalculations_skipped() == 0); + // 7. hash neutrality against the pre-L5 tip, stated portably. + const auto base = base_spec("native-calc-timing"); + const auto digest = native_run_spec_digest(base); + if (digest != kDefaultSpecDigest) { + std::printf(" spec digest %llu, pinned %llu\n", + static_cast(digest), + static_cast(kDefaultSpecDigest)); + } + CHECK(digest == kDefaultSpecDigest); + + // Spelling all three cadence fields out at their defaults folds nothing, + // and the recalculation bound is inert while the cadence is BarClose. + auto stated = base; + stated.calculation = NativeCalculationTrigger::BarClose; + stated.max_recalculations_per_point = 8; + stated.open_bar_view = NativeOpenBarView::Complete; + CHECK(native_run_spec_digest(stated) == kDefaultSpecDigest); + auto bound_only = base; + bound_only.max_recalculations_per_point = 3; + CHECK(native_run_spec_digest(bound_only) == kDefaultSpecDigest); + + // Moving the trigger, or the open-bar view on its own, is what moves it. + auto moved = base; + moved.calculation = NativeCalculationTrigger::BarCloseAndFills; + CHECK(native_run_spec_digest(moved) != kDefaultSpecDigest); + auto open_only = base; + open_only.open_bar_view = NativeOpenBarView::OpenOnly; + CHECK(native_run_spec_digest(open_only) != kDefaultSpecDigest); + + // The same three facts at run level, compared between runs in this process + // so no continuation constant is needed: the restated defaults reproduce + // this run's callback log and identity, and the opted-in spec cannot share + // that identity. + DefaultHost restated; + CHECK(restated.configure_native(stated).status == NativeSetupStatus::Applied); + restated.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(restated.last_error().empty()); + CHECK(restated.log == want); + CHECK(restated.native_recalculation_count() == 0); + CHECK(restated.native_recalculations_skipped() == 0); + CHECK(restated.native_continuation_hash() == host.native_continuation_hash()); + + DefaultHost opted; + CHECK(opted.configure_native(moved).status == NativeSetupStatus::Applied); + opted.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(opted.last_error().empty()); + CHECK(opted.native_continuation_hash() != host.native_continuation_hash()); +} + +// The BarClose calculation reaches on_native_recalculate too, and its default +// forwarding is what a host that never overrode it already sees. +class RoutedHost final : public NativeStrategyHost { +public: + std::vector log; + void on_native_recalculate(const Bar& bar, const NativeDecisionContext& ctx, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause) override { + log.push_back("recalc reason=" + std::to_string(static_cast(reason)) + + " cause=" + (cause ? "yes" : "no") + + " t=" + stamp(ctx.coordinate.effective_time_ms)); + NativeStrategyHost::on_native_recalculate(bar, ctx, reason, cause); + } + int forwarded = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++forwarded; } +}; + +void test_every_calculation_is_routed() { + scenario = "calculation routing"; + const auto bars = minute_bars(3); + RoutedHost host; + CHECK(host.configure_native(base_spec("native-calc-routing")).status + == NativeSetupStatus::Applied); + host.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + const std::vector want = { + "recalc reason=0 cause=no t=60000", + "recalc reason=0 cause=no t=120000", + "recalc reason=0 cause=no t=180000", + }; + report_log(host.log, want); + CHECK(host.log == want); + CHECK(host.forwarded == 3); +} + +// ---- 2. the fill cascade and its per-point bound --------------------------- + +class CascadeHost final : public NativeStrategyHost { +public: + std::vector log; + int bars = 0; + double target_units = 4.0; + + void on_native_applied(const native_order::ExecutionAppliedEvent& applied, + const NativeDecisionContext&) override { + log.push_back("applied#" + std::to_string(static_cast(applied.ordinal)) + + " px=" + fmt(applied.resolved_price)); + } + void on_native_recalculate(const Bar&, const NativeDecisionContext&, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause) override { + if (reason == NativeCalculationReason::BarClose) { + if (++bars == 1) submit_market({order_action::Transact{1.0}, "seed", ""}); + return; + } + CHECK(reason == NativeCalculationReason::OrderFill); + CHECK(cause != nullptr); + log.push_back("recalc cause#" + + std::to_string(cause ? static_cast(cause->ordinal) : 0ull) + + " units=" + fmt(physical_position().signed_units)); + if (std::abs(physical_position().signed_units) >= target_units) return; + const auto submitted = submit_market({order_action::Transact{1.0}, "refill", ""}); + CHECK(submitted.handle.has_value()); + if (!submitted.handle) return; + const auto outcome = execute_current({*submitted.handle, + NativeCurrentPriceRule::AsPresented}); + CHECK(std::holds_alternative(outcome)); + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +void test_fill_cascade() { + scenario = "BarCloseAndFills cascade"; + const auto bars = minute_bars(4); + auto spec = base_spec("native-calc-cascade"); + spec.calculation = NativeCalculationTrigger::BarCloseAndFills; + + // (a) a budget the host's own rule never reaches. + CascadeHost wide; + CHECK(wide.configure_native(spec).status == NativeSetupStatus::Applied); + wide.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(wide.last_error().empty()); + const std::vector want_wide = { + "applied#8 px=101.0000", + "recalc cause#8 units=1.0000", + "applied#11 px=101.0000", + "recalc cause#11 units=2.0000", + "applied#14 px=101.0000", + "recalc cause#14 units=3.0000", + "applied#17 px=101.0000", + "recalc cause#17 units=4.0000", + }; + report_log(wide.log, want_wide); + CHECK(wide.log == want_wide); + CHECK(same(wide.physical_position().signed_units, 4.0)); + CHECK(wide.native_recalculation_count() == 4); + CHECK(wide.native_recalculations_skipped() == 0); + + // (b) the same rule against a two-recalculation budget: the third fill is + // still applied and still delivered, it just drives no calculation. + auto bounded_spec = spec; + bounded_spec.max_recalculations_per_point = 2; + CascadeHost bounded; + CHECK(bounded.configure_native(bounded_spec).status == NativeSetupStatus::Applied); + bounded.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(bounded.last_error().empty()); + const std::vector want_bounded = { + "applied#8 px=101.0000", + "recalc cause#8 units=1.0000", + "applied#11 px=101.0000", + "recalc cause#11 units=2.0000", + "applied#14 px=101.0000", + }; + report_log(bounded.log, want_bounded); + CHECK(bounded.log == want_bounded); + CHECK(same(bounded.physical_position().signed_units, 3.0)); + CHECK(bounded.physical_position().lot_count == 3); + CHECK(bounded.native_recalculation_count() == 2); + CHECK(bounded.native_recalculations_skipped() == 1); + + // (c) the same host under the default trigger: the fills the seed order + // books are delivered, and not one of them recalculates. + CascadeHost closed; + CHECK(closed.configure_native(base_spec("native-calc-cascade-off")).status + == NativeSetupStatus::Applied); + closed.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(closed.last_error().empty()); + CHECK(closed.log == std::vector{"applied#8 px=101.0000"}); + CHECK(same(closed.physical_position().signed_units, 1.0)); + CHECK(closed.native_recalculation_count() == 0); + CHECK(closed.native_recalculations_skipped() == 0); +} + +// ---- 3/4. every modeled point, and the sub-bar hook ------------------------ + +class PointHost final : public NativeStrategyHost { +public: + std::vector log; + int ticks = 0; + int sub_bars = 0; + + void on_native_recalculate(const Bar& bar, const NativeDecisionContext& ctx, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent*) override { + if (reason == NativeCalculationReason::Tick) ++ticks; + log.push_back(std::string(reason == NativeCalculationReason::BarClose ? "close" : "tick") + + " t=" + stamp(ctx.coordinate.effective_time_ms) + + " bar.c=" + fmt(bar.close) + " partial=" + partial_of(*this)); + } + void on_native_sub_bar(const Bar& sub, const NativeDecisionContext& ctx) override { + ++sub_bars; + log.push_back("sub t=" + stamp(sub.timestamp) + " idx=" + std::to_string(ctx.sub_index) + + " partial=" + partial_of(*this)); + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +void test_every_modeled_point_confirmed() { + scenario = "EveryModeledPoint, confirmed path"; + const auto bars = minute_bars(2); + auto spec = base_spec("native-calc-points-plain"); + spec.calculation = NativeCalculationTrigger::EveryModeledPoint; + PointHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + host.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + + // Four modeled points per confirmed bar (opening, low leg, high leg, + // close leg) and then the bar's own calculation, whose partial is gone + // because the host already holds the complete bar. + const std::vector want = { + "tick t=0 bar.c=101.0000 partial=100.0000/100.0000/100.0000/100.0000 v=0.0000", + "tick t=0 bar.c=101.0000 partial=100.0000/100.0000/99.0000/99.0000 v=0.0000", + "tick t=0 bar.c=101.0000 partial=100.0000/102.0000/99.0000/102.0000 v=0.0000", + "tick t=60000 bar.c=101.0000 partial=100.0000/102.0000/99.0000/101.0000 v=0.0000", + "close t=60000 bar.c=101.0000 partial=none", + "tick t=60000 bar.c=102.0000 partial=101.0000/101.0000/101.0000/101.0000 v=0.0000", + "tick t=60000 bar.c=102.0000 partial=101.0000/101.0000/100.0000/100.0000 v=0.0000", + "tick t=60000 bar.c=102.0000 partial=101.0000/103.0000/100.0000/103.0000 v=0.0000", + "tick t=120000 bar.c=102.0000 partial=101.0000/103.0000/100.0000/102.0000 v=0.0000", + "close t=120000 bar.c=102.0000 partial=none", + }; + report_log(host.log, want); + CHECK(host.log == want); + CHECK(host.ticks == 8); + CHECK(host.native_recalculation_count() == 8); + // 4. no retained lower feed, so the sub-bar hook never fires. + CHECK(host.sub_bars == 0); +} + +void test_every_modeled_point_intrabar() { + scenario = "EveryModeledPoint, lower-timeframe path"; + const auto lower = minute_bars(6); + auto spec = base_spec("native-calc-points-lower", "3"); + spec.calculation = NativeCalculationTrigger::EveryModeledPoint; + IntrabarPath::lower_tf path; + path.bars = lower; + path.tf = "1"; + path.samples = 4; + spec.intrabar.value = path; + PointHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + host.run(lower.data(), static_cast(lower.size()), "1", "3", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + + // Two three-minute script bars, three retained sub-bars each, four + // sampled points per sub-bar: 24 Tick recalculations and 6 sub-bar hooks. + CHECK(host.ticks == 24); + CHECK(host.native_recalculation_count() == 24); + CHECK(host.sub_bars == 6); + + // The first sub-bar's own walk, in order, with the bar so far following + // the cursor and its volume accruing only once the sub-bar completes. + const std::vector head = { + "tick t=0 bar.c=103.0000 partial=100.0000/100.0000/100.0000/100.0000 v=0.0000", + "tick t=0 bar.c=103.0000 partial=100.0000/100.0000/99.0000/99.0000 v=0.0000", + "tick t=0 bar.c=103.0000 partial=100.0000/102.0000/99.0000/102.0000 v=0.0000", + "tick t=0 bar.c=103.0000 partial=100.0000/102.0000/99.0000/101.0000 v=0.0000", + "sub t=0 idx=0 partial=100.0000/102.0000/99.0000/101.0000 v=10.0000", + "tick t=60000 bar.c=103.0000 partial=100.0000/102.0000/99.0000/101.0000 v=10.0000", + }; + std::vector got(host.log.begin(), + host.log.begin() + static_cast(head.size())); + report_log(got, head); + CHECK(got == head); + // Each script bar's calculation still happens exactly once, after its + // last sub-bar, with no partial of its own. + CHECK(host.log[14] == "sub t=120000 idx=2 partial=100.0000/104.0000/99.0000/103.0000 v=33.0000"); + CHECK(host.log[15] == "close t=180000 bar.c=103.0000 partial=none"); + CHECK(host.log.size() == 32); + CHECK(host.log[31] == "close t=360000 bar.c=106.0000 partial=none"); +} + +void test_sub_bar_hook_needs_a_lower_feed() { + scenario = "sub-bar hook"; + const auto bars = minute_bars(6); + // A synthesized path samples the script bar's own OHLC: it retains no + // lower bars, so it has no sub-bars of its own to deliver. + auto spec = base_spec("native-calc-synth", "3"); + IntrabarPath::synthesized synthesized; + synthesized.samples = 4; + spec.intrabar.value = synthesized; + PointHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + host.run(bars.data(), static_cast(bars.size()), "1", "3", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + CHECK(host.sub_bars == 0); + // The cadence is untouched by the hook: BarClose still calculates once + // per script bar and drives no recalculation. + CHECK(host.ticks == 0); + CHECK(host.native_recalculation_count() == 0); + + // The same lower feed, at the default trigger: the hook is not a cadence + // and fires for every retained sub-bar anyway. + auto lower_spec = base_spec("native-calc-lower-default", "3"); + IntrabarPath::lower_tf path; + path.bars = bars; + path.tf = "1"; + path.samples = 4; + lower_spec.intrabar.value = path; + PointHost lower_host; + CHECK(lower_host.configure_native(lower_spec).status == NativeSetupStatus::Applied); + lower_host.run(bars.data(), static_cast(bars.size()), "1", "3", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(lower_host.last_error().empty()); + CHECK(lower_host.sub_bars == 6); + CHECK(lower_host.ticks == 0); + CHECK(lower_host.native_recalculation_count() == 0); +} + +// ---- 3 (stream half). one Tick recalculation per observed print ------------ + +class StreamHost final : public NativeStrategyHost { +public: + std::vector log; + void on_native_tick(const Bar& bar, const NativeTickContext&) override { + log.push_back("observed " + fmt(bar.close) + " partial=" + partial_of(*this)); + } + void on_native_recalculate(const Bar& bar, const NativeDecisionContext&, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent*) override { + if (reason == NativeCalculationReason::BarClose) { + log.push_back("close " + fmt(bar.close)); + return; + } + log.push_back("tick " + fmt(bar.close) + " partial=" + partial_of(*this)); + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +void test_every_modeled_point_stream() { + scenario = "EveryModeledPoint, observed prints"; + auto spec = base_spec("native-calc-stream"); + spec.calculation = NativeCalculationTrigger::EveryModeledPoint; + StreamHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + const Bar warmup{100.0, 100.0, 100.0, 100.0, 1.0, -60000}; + CHECK(host.stream_begin(&warmup, 1, "1", "1")); + const std::size_t after_warmup = host.log.size(); + const TradeTick ticks[] = {{0, 1, 100.5, 2.0}, {10000, 2, 101.5, 3.0}, + {20000, 3, 99.5, 1.0}, {60000, 4, 102.0, 4.0}}; + for (const auto& tick : ticks) CHECK(host.stream_push_tick(tick)); + CHECK(host.stream_end(true)); + CHECK(host.last_error().empty()); + + // The observation hook stays ahead of the recalculation: each print is + // observed, matched, then recalculated once, with the bar so far carrying + // that print's price and its traded quantity. + const std::vector want = { + "observed 100.5000 partial=100.5000/100.5000/100.5000/100.5000 v=2.0000", + "tick 100.5000 partial=100.5000/100.5000/100.5000/100.5000 v=2.0000", + "observed 101.5000 partial=100.5000/101.5000/100.5000/101.5000 v=5.0000", + "tick 101.5000 partial=100.5000/101.5000/100.5000/101.5000 v=5.0000", + "observed 99.5000 partial=100.5000/101.5000/99.5000/99.5000 v=6.0000", + "tick 99.5000 partial=100.5000/101.5000/99.5000/99.5000 v=6.0000", + "close 99.5000", + "observed 102.0000 partial=102.0000/102.0000/102.0000/102.0000 v=4.0000", + "tick 102.0000 partial=102.0000/102.0000/102.0000/102.0000 v=4.0000", + "close 102.0000", + }; + std::vector got(host.log.begin() + static_cast(after_warmup), + host.log.end()); + report_log(got, want); + CHECK(got == want); +} + +// ---- 5. the open-bar view -------------------------------------------------- + +class ViewHost final : public NativeStrategyHost { +public: + std::vector log; + std::vector fills; + int bars = 0; + + static std::string shape(const Bar& bar) { + return fmt(bar.open) + "/" + fmt(bar.high) + "/" + fmt(bar.low) + "/" + + fmt(bar.close) + " v=" + fmt(bar.volume); + } + void on_native_bar_open(const Bar& bar, const NativeDecisionContext&) override { + log.push_back("open " + shape(bar) + " partial=" + partial_of(*this)); + } + void on_native_applied(const native_order::ExecutionAppliedEvent& applied, + const NativeDecisionContext&) override { + fills.push_back(applied.resolved_price); + } + void on_native_recalculate(const Bar& bar, const NativeDecisionContext& ctx, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent* cause) override { + if (reason != NativeCalculationReason::BarClose) { + log.push_back("fill-recalc " + shape(bar)); + return; + } + log.push_back("bar " + shape(bar)); + if (++bars == 1) submit_market({order_action::Transact{1.0}, "e", ""}); + (void)ctx; + (void)cause; + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +void test_open_bar_view() { + scenario = "open-bar view"; + const auto bars = minute_bars(3); + auto complete_spec = base_spec("native-calc-view"); + complete_spec.calculation = NativeCalculationTrigger::BarCloseAndFills; + ViewHost complete; + CHECK(complete.configure_native(complete_spec).status == NativeSetupStatus::Applied); + complete.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(complete.last_error().empty()); + + auto open_only_spec = complete_spec; + open_only_spec.open_bar_view = NativeOpenBarView::OpenOnly; + ViewHost open_only; + CHECK(open_only.configure_native(open_only_spec).status == NativeSetupStatus::Applied); + open_only.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(open_only.last_error().empty()); + + const std::vector want_complete = { + "open 100.0000/102.0000/99.0000/101.0000 v=10.0000 partial=100.0000/100.0000/100.0000/100.0000 v=0.0000", + "bar 100.0000/102.0000/99.0000/101.0000 v=10.0000", + "open 101.0000/103.0000/100.0000/102.0000 v=11.0000 partial=101.0000/101.0000/101.0000/101.0000 v=0.0000", + "fill-recalc 101.0000/103.0000/100.0000/102.0000 v=11.0000", + "bar 101.0000/103.0000/100.0000/102.0000 v=11.0000", + "open 102.0000/104.0000/101.0000/103.0000 v=12.0000 partial=102.0000/102.0000/102.0000/102.0000 v=0.0000", + "bar 102.0000/104.0000/101.0000/103.0000 v=12.0000", + }; + report_log(complete.log, want_complete); + CHECK(complete.log == want_complete); + + // OpenOnly masks exactly the bar-open callback: H = L = C = open and no + // volume. The applied recalculation and the close calculation keep the + // complete bar, and current_partial_bar() is the same either way. + const std::vector want_open_only = { + "open 100.0000/100.0000/100.0000/100.0000 v=0.0000 partial=100.0000/100.0000/100.0000/100.0000 v=0.0000", + "bar 100.0000/102.0000/99.0000/101.0000 v=10.0000", + "open 101.0000/101.0000/101.0000/101.0000 v=0.0000 partial=101.0000/101.0000/101.0000/101.0000 v=0.0000", + "fill-recalc 101.0000/103.0000/100.0000/102.0000 v=11.0000", + "bar 101.0000/103.0000/100.0000/102.0000 v=11.0000", + "open 102.0000/102.0000/102.0000/102.0000 v=0.0000 partial=102.0000/102.0000/102.0000/102.0000 v=0.0000", + "bar 102.0000/104.0000/101.0000/103.0000 v=12.0000", + }; + report_log(open_only.log, want_open_only); + CHECK(open_only.log == want_open_only); + + // Masking a view books no different trade. + CHECK(complete.fills == open_only.fills); + CHECK(complete.fills.size() == 1); + CHECK(same(complete.physical_position().signed_units, + open_only.physical_position().signed_units)); +} + +// ---- 6. recalculations record no report point ------------------------------ + +class ReportHost final : public NativeStrategyHost { +public: + int bars = 0; + void on_native_recalculate(const Bar&, const NativeDecisionContext&, + NativeCalculationReason reason, + const native_order::ExecutionAppliedEvent*) override { + if (reason == NativeCalculationReason::BarClose) { + if (++bars == 1) submit_market({order_action::Transact{1.0}, "seed", ""}); + return; + } + if (std::abs(physical_position().signed_units) >= 4.0) return; + const auto submitted = submit_market({order_action::Transact{1.0}, "refill", ""}); + if (!submitted.handle) return; + (void)execute_current({*submitted.handle, NativeCurrentPriceRule::AsPresented}); + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +struct Report { + ReportC c{}; + explicit Report(const BacktestEngine& engine) { engine.fill_report(&c); } + ~Report() { BacktestEngine::free_report(&c); } + Report(const Report&) = delete; + Report& operator=(const Report&) = delete; +}; + +void test_recalculations_add_no_report_points() { + scenario = "kernel-recorded report"; + const auto bars = minute_bars(6); + auto spec = base_spec("native-calc-report"); + spec.calculation = NativeCalculationTrigger::EveryModeledPoint; + spec.report_policy = NativeReportPolicy::KernelRecorded; + ReportHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + host.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + CHECK(host.native_recalculation_count() > 6); + const Report report(host); + CHECK(report.c.script_bars_processed == 6); + CHECK(report.c.equity_curve_len == 6); + CHECK(report.c.equity_curve_len == report.c.script_bars_processed); +} + +// ---- 8. TWIN against the adapter's calc_on_order_fills probe --------------- + +// The adapter fixture of tests/test_native_l4c_coof_literals.cpp, verbatim. +std::vector coof_lower_bars() { + std::vector lower; + for (int i = 0; i < 30; ++i) { + const double open = i < 15 ? 100.0 : 100.0 + (i - 15) * 0.1; + lower.push_back({open, open + 1.0, open - 1.0, open + 0.25, 500.0, + static_cast(i) * 60000}); + } + return lower; +} + +class AdapterProbe : public source::PineNativeHost { +public: + AdapterProbe() { + source::PineStrategyConfig config; + config.calc_on_order_fills = true; + config.initial_capital = 100000.0; + config.default_qty_type = static_cast(QtyType::FIXED); + config.default_qty_value = 1.0; + config.pyramiding = 10; + config.commission_value = 0.0; + configure_pine_strategy(config); + } + std::string lot_id(int index) const { return open_trade_entry_id(index); } + double lot_price(int index) const { return open_trade_entry_price(index); } + std::int64_t lot_time(int index) const { return open_trade_entry_time(index); } +}; + +class AdapterRefill final : public AdapterProbe { +public: + void on_source_bar(const Bar&) override { + if (bar_index_ <= 1 && std::abs(physical_position().signed_units) < 6.0) + strategy_entry("L" + std::to_string(physical_position().lot_count), true); + } +}; + +class AdapterSingleEntry final : public AdapterProbe { +public: + void on_source_bar(const Bar&) override { + if (bar_index_ == 0 && physical_position().lot_count == 0) + strategy_entry("once", true); + } +}; + +class NativeTwin : public NativeStrategyHost { +public: + int script_index = -1; + void on_native_bar_open(const Bar&, const NativeDecisionContext&) override { + ++script_index; + } + std::string lot_id(int index) const { return open_trade_entry_id(index); } + double lot_price(int index) const { return open_trade_entry_price(index); } + std::int64_t lot_time(int index) const { return open_trade_entry_time(index); } + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +// The adapter's RefillProbe rule, expressed against the kernel's own cadence: +// it runs on every calculation, exactly as on_source_bar does under +// calc_on_order_fills. +class NativeRefill final : public NativeTwin { +public: + void on_native_recalculate(const Bar&, const NativeDecisionContext&, + NativeCalculationReason, + const native_order::ExecutionAppliedEvent*) override { + if (script_index > 1) return; + if (std::abs(physical_position().signed_units) >= 6.0) return; + submit_market({order_action::Transact{1.0}, + "L" + std::to_string(physical_position().lot_count), ""}); + } +}; + +class NativeSingleEntry final : public NativeTwin { +public: + void on_native_recalculate(const Bar&, const NativeDecisionContext&, + NativeCalculationReason, + const native_order::ExecutionAppliedEvent*) override { + if (script_index == 0 && physical_position().lot_count == 0) + submit_market({order_action::Transact{1.0}, "once", ""}); + } +}; + +NativeRunSpec twin_spec(const char* key) { + auto spec = base_spec(key, "15"); + spec.calculation = NativeCalculationTrigger::BarCloseAndFills; + IntrabarPath::lower_tf path; + path.bars = coof_lower_bars(); + path.tf = "1"; + path.samples = 4; + spec.intrabar.value = path; + return spec; +} + +void test_twin_against_the_adapter() { + scenario = "twin: adapter calc_on_order_fills"; + const auto lower = coof_lower_bars(); + + // (a) The TV waypoint rule is inactive: the recalculation the fill drives + // places no order, so nothing is deferred to a chart waypoint. The two + // routes must book the identical entry. + AdapterSingleEntry adapter_single; + adapter_single.run(lower.data(), static_cast(lower.size()), "1", "15", true, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(adapter_single.last_error().empty()); + NativeSingleEntry native_single; + CHECK(native_single.configure_native(twin_spec("native-calc-twin-single")).status + == NativeSetupStatus::Applied); + native_single.run(lower.data(), static_cast(lower.size()), "1", "15", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(native_single.last_error().empty()); + CHECK(adapter_single.physical_position().lot_count == 1); + CHECK(native_single.physical_position().lot_count + == adapter_single.physical_position().lot_count); + CHECK(native_single.lot_id(0) == adapter_single.lot_id(0)); + CHECK(same(native_single.lot_price(0), adapter_single.lot_price(0))); + CHECK(native_single.lot_time(0) == adapter_single.lot_time(0)); + CHECK(same(native_single.physical_position().signed_units, + adapter_single.physical_position().signed_units)); + + // (b) The refill cascade of tests/test_native_l4c_coof_literals.cpp. The + // rule reaches the same book on both routes — six lots, the same ids in + // the same order — and the adapter keeps its own literal. + AdapterRefill adapter; + adapter.run(lower.data(), static_cast(lower.size()), "1", "15", true, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(adapter.last_error().empty()); + CHECK(adapter.physical_position().lot_count == 6); // L4c literal + + NativeRefill native; + CHECK(native.configure_native(twin_spec("native-calc-twin-refill")).status + == NativeSetupStatus::Applied); + native.run(lower.data(), static_cast(lower.size()), "1", "15", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(native.last_error().empty()); + CHECK(native.physical_position().lot_count == 6); + for (int i = 0; i < 6; ++i) CHECK(native.lot_id(i) == adapter.lot_id(i)); + CHECK(native.native_recalculation_count() == 6); + CHECK(native.native_recalculations_skipped() == 0); + + // The ONE itemized difference: where each route delivers a request born + // in a fill recalculation. The adapter re-presents it at the chart bar's + // next waypoint (CT11, the TV-only `coof_next_waypoint` refill rule, + // which R5-5 deliberately leaves in the source layer), so its six lots + // fill along script bar 0's own O/L/H/C and then bar 1's open. The kernel + // has no waypoint rule: a newborn market request is eligible at the next + // discrete matching point of the delivered path, which under a retained + // lower feed is the next sub-bar's opening. Nothing else differs: same + // rule, same six ids, same order, same resulting book. + const std::vector adapter_prices = {100.0, 100.0, 99.0, 101.0, 100.25, 100.10}; + const std::vector adapter_times = {900000, 900000, 900000, 900000, + 900000, 960000}; + const std::vector native_prices = {100.0, 100.1, 100.2, 100.3, 100.4, 100.5}; + const std::vector native_times = {900000, 960000, 1020000, 1080000, + 1140000, 1200000}; + for (int i = 0; i < 6; ++i) { + CHECK(same(adapter.lot_price(i), adapter_prices[static_cast(i)])); + CHECK(adapter.lot_time(i) == adapter_times[static_cast(i)]); + CHECK(same(native.lot_price(i), native_prices[static_cast(i)])); + CHECK(native.lot_time(i) == native_times[static_cast(i)]); + } +} + +} // namespace + +int main() { + test_bar_close_default(); + test_every_calculation_is_routed(); + test_fill_cascade(); + test_every_modeled_point_confirmed(); + test_every_modeled_point_intrabar(); + test_sub_bar_hook_needs_a_lower_feed(); + test_every_modeled_point_stream(); + test_open_bar_view(); + test_recalculations_add_no_report_points(); + test_twin_against_the_adapter(); + std::printf("native calculation timing: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_native_htf_subscriptions_stream.cpp b/tests/test_native_htf_subscriptions_stream.cpp new file mode 100644 index 00000000..67dd561d --- /dev/null +++ b/tests/test_native_htf_subscriptions_stream.cpp @@ -0,0 +1,728 @@ +// Declared higher-timeframe series on a STREAM (NativeRunSpec::subscriptions +// through stream_begin / stream_push_bar / stream_end), so a forward-execution +// host reads the same series a backtest of the same bars reads. +// +// Witnesses, all independent of the feature under test: +// 1. the warmup is a batch: a stream_begin over N bars produces exactly the +// callback sequence and the exact buckets a run() over those same N bars +// produces, compared element by element (both publication modes); +// 2. live pushes continue the very bucket the warmup left open and deliver +// it before the calculation of the bar that completed it; +// 3. a bucket still open when the stream ends is never delivered; +// 4. native_series_bar answers with the latest delivered bucket on both +// sides of the warmup -> live boundary; +// 5. a stream whose spec declares no series keeps this tree's event +// sequence and folds nothing new into its run-spec digest (the portable +// half of the continuation identity), with the continuation and stream +// state hashes compared between two streams in this process; +// 6. a session-clipped daily series (a calendar aggregator, whose buckets +// close on the session close and not on a bar count) over a stream whose +// warmup stops mid-session is the batch's series, bucket for bucket; +// 7. `authoritative_bars` are installed once, at begin: they cover the +// buckets the warmup completes, and a live bucket with none of its own +// aggregates the pushed input and is counted as a feed miss; +// 8. a stream that declares a series takes confirmed bars only: tick input +// is refused by name without failing the host, and a stream that +// declares none still takes ticks. + +#include + +#include +#include +#include +#include +#include +#include + +namespace { +using namespace pineforge; + +int checks = 0; +int failures = 0; +const char* scenario = "initialization"; + +#define CHECK(expression) \ + do { \ + ++checks; \ + if (!(expression)) { \ + ++failures; \ + std::printf("FAIL [%s] line %d: %s\n", scenario, __LINE__, \ + #expression); \ + } \ + } while (false) + +bool same(double a, double b) { + return std::isfinite(a) && std::isfinite(b) && std::abs(a - b) <= 1e-9; +} + +// Unix ms of a UTC civil date-time (Howard Hinnant's days_from_civil). +std::int64_t utc_ms(int y, int m, int d, int h = 0, int mi = 0) { + y -= (m <= 2); + long era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = static_cast(y - era * 400); + unsigned doy = (153u * static_cast(m + (m > 2 ? -3 : 9)) + 2) / 5 + + static_cast(d) - 1; + unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + long days = era * 146097L + static_cast(doe) - 719468L; + return (static_cast(days) * 86400 + h * 3600 + mi * 60) * 1000; +} + +constexpr std::int64_t kMinute = 60000; +constexpr std::int64_t kQuarter = 15 * kMinute; +constexpr std::int64_t kHour = 60 * kMinute; + +NativeRunSpec base_spec(const char* input, const char* script, + const char* session_key) { + NativeRunSpec spec; + spec.identity = {session_key, 1}; + spec.input_tf = input; + spec.script_tf = script; + spec.ticker = "SERIES"; + spec.tickerid = "TEST:SERIES"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = "native higher-timeframe subscription fixture"; + spec.volumetype = "base"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.initial_capital = 10000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.fee_kind = NativeFeeKind::CashPerExecution; + spec.fee_value = 0.0; + return spec; +} + +// ---- hosts ---------------------------------------------------------------- + +struct Delivery { + std::size_t subscription = 0; + Bar bar{}; + NativeTimeframeBarContext context{}; + // How many script bars the host had already calculated when the bucket + // arrived: the 0-based index of the bar it is delivered on. + int bars_before = 0; + // What native_series_bar answered inside the callback. + bool accessor_matches = false; +}; + +class SeriesHost final : public NativeStrategyHost { +public: + std::vector log; + std::vector deliveries; + int bars_seen = 0; + + void on_native_input(const Bar& bar, const NativeInputContext& context) override { + log.push_back("input:" + std::to_string(context.input_index) + "@" + + std::to_string(bar.timestamp)); + } + + void on_native_timeframe_bar(const Bar& bar, + const NativeTimeframeBarContext& context) override { + Delivery delivery; + delivery.subscription = context.subscription; + delivery.bar = bar; + delivery.context = context; + delivery.bars_before = bars_seen; + const auto pulled = native_series_bar(context.subscription); + delivery.accessor_matches = pulled.has_value() + && pulled->timestamp == bar.timestamp && same(pulled->open, bar.open) + && same(pulled->high, bar.high) && same(pulled->low, bar.low) + && same(pulled->close, bar.close) && same(pulled->volume, bar.volume); + deliveries.push_back(delivery); + log.push_back("htf:" + std::to_string(context.subscription) + "@" + + std::to_string(bar.timestamp)); + } + + void on_native_bar(const Bar& bar, const NativeDecisionContext&) override { + ++bars_seen; + log.push_back("bar@" + std::to_string(bar.timestamp)); + } +}; + +// ---- fixtures ------------------------------------------------------------- + +std::vector quarter_hour_bars(int n) { + const std::int64_t origin = utc_ms(2024, 1, 1); + std::vector bars; + bars.reserve(static_cast(n)); + for (int i = 0; i < n; ++i) { + const double open = 100.0 + i; + Bar bar{}; + bar.open = open; + bar.high = open + 2.0; + bar.low = open - 1.0; + bar.close = open + 0.5; + bar.volume = static_cast(i + 1); + bar.timestamp = origin + static_cast(i) * kQuarter; + bars.push_back(bar); + } + return bars; +} + +// The oracle: a hand aggregation of one contiguous group of input bars. +Bar hand_aggregate(const std::vector& bars, int from, int count, + std::int64_t label) { + Bar out = bars[static_cast(from)]; + out.timestamp = label; + for (int i = 1; i < count; ++i) { + const Bar& next = bars[static_cast(from + i)]; + out.high = std::max(out.high, next.high); + out.low = std::min(out.low, next.low); + out.close = next.close; + out.volume += next.volume; + } + return out; +} + +bool bars_equal(const Bar& got, const Bar& want) { + return got.timestamp == want.timestamp && same(got.open, want.open) + && same(got.high, want.high) && same(got.low, want.low) + && same(got.close, want.close) && same(got.volume, want.volume); +} + +void check_bucket(const Bar& got, const Bar& want, const char* tag) { + const bool ok = bars_equal(got, want); + if (!ok) { + std::printf(" %s: got t %lld o %.6g h %.6g l %.6g c %.6g v %.6g; " + "want t %lld o %.6g h %.6g l %.6g c %.6g v %.6g\n", + tag, static_cast(got.timestamp), got.open, got.high, + got.low, got.close, got.volume, + static_cast(want.timestamp), want.open, want.high, + want.low, want.close, want.volume); + } + CHECK(ok); +} + +void check_logs_equal(const std::vector& got, + const std::vector& want) { + CHECK(got == want); + if (got == want) return; + for (std::size_t i = 0; i < std::max(got.size(), want.size()); ++i) { + const std::string a = i < got.size() ? got[i] : std::string("-"); + const std::string b = i < want.size() ? want[i] : std::string("-"); + if (a != b) std::printf(" log[%zu]: got %s want %s\n", i, a.c_str(), b.c_str()); + } +} + +// Every delivery field the host can observe, compared one by one. +void check_deliveries_equal(const std::vector& got, + const std::vector& want) { + CHECK(got.size() == want.size()); + if (got.size() != want.size()) { + std::printf(" %zu deliveries, want %zu\n", got.size(), want.size()); + return; + } + for (std::size_t i = 0; i < got.size(); ++i) { + check_bucket(got[i].bar, want[i].bar, "stream vs batch bucket"); + CHECK(got[i].subscription == want[i].subscription); + CHECK(got[i].bars_before == want[i].bars_before); + CHECK(got[i].accessor_matches == want[i].accessor_matches); + CHECK(got[i].context.delivered_at_ms == want[i].context.delivered_at_ms); + CHECK(got[i].context.completion == want[i].context.completion); + CHECK(got[i].context.interval.open_ms == want[i].context.interval.open_ms); + CHECK(got[i].context.interval.next_period_open_ms + == want[i].context.interval.next_period_open_ms); + } +} + +NativeRunSpec hourly_spec(const char* session_key, bool lookahead) { + NativeRunSpec spec = base_spec("15", "15", session_key); + NativeTimeframeSubscription hourly; + hourly.tf = "60"; + hourly.lookahead = lookahead; + spec.subscriptions.push_back(hourly); + return spec; +} + +// A batch of the first `n_warmup` bars: the oracle every stream warmup below +// is compared against. +void run_batch(SeriesHost& host, const std::vector& bars, int n_warmup, + const char* session_key, bool lookahead) { + CHECK(host.configure_native(hourly_spec(session_key, lookahead)).status + == NativeSetupStatus::Applied); + host.run(bars.data(), n_warmup, "15", "15", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(host.last_error().empty()); + if (!host.last_error().empty()) std::printf(" error: %s\n", host.last_error().c_str()); +} + +bool begin_stream(SeriesHost& host, const std::vector& bars, int n_warmup, + const char* session_key, bool lookahead) { + CHECK(host.configure_native(hourly_spec(session_key, lookahead)).status + == NativeSetupStatus::Applied); + const bool began = host.stream_begin(bars.data(), n_warmup, "15", "15"); + CHECK(began); + if (!began) std::printf(" error: %s\n", host.last_error().c_str()); + CHECK(host.last_error().empty()); + CHECK(host.stream_is_realtime()); + return began; +} + +// ---- 1. the warmup is a batch of the same bars ---------------------------- + +void test_warmup_matches_batch(bool lookahead) { + scenario = lookahead ? "warmup vs batch (lookahead_on)" + : "warmup vs batch (lookahead_off)"; + // Ten bars: two whole hourly buckets and a third left half open, so the + // comparison covers a warmup that does not end on a bucket boundary. + const std::vector bars = quarter_hour_bars(16); + const int n_warmup = 10; + + SeriesHost batch; + run_batch(batch, bars, n_warmup, "native-htf-stream-batch", lookahead); + + SeriesHost stream; + if (!begin_stream(stream, bars, n_warmup, "native-htf-stream-warmup", lookahead)) return; + + CHECK(batch.deliveries.size() == 2); + check_logs_equal(stream.log, batch.log); + check_deliveries_equal(stream.deliveries, batch.deliveries); + + // The buckets themselves, against the hand aggregation. + const std::int64_t origin = bars.front().timestamp; + for (std::size_t k = 0; k < stream.deliveries.size(); ++k) { + const Bar want = hand_aggregate(bars, static_cast(k) * 4, 4, + origin + static_cast(k) * kHour); + check_bucket(stream.deliveries[k].bar, want, "warmup bucket"); + CHECK(stream.deliveries[k].accessor_matches); + CHECK(stream.deliveries[k].context.completion == NativeCompletionKind::Confirmed); + // lookahead_off publishes on the group's 4th bar, lookahead_on on its + // first; both are historical resolution over the warmup input. + CHECK(stream.deliveries[k].context.delivered_at_ms + == bars[k * 4 + (lookahead ? 0 : 3)].timestamp); + } +} + +// ---- 2. live pushes continue the bucket the warmup left open -------------- + +void test_live_pushes_extend_buckets(bool lookahead) { + scenario = lookahead ? "live pushes (lookahead_on)" : "live pushes (lookahead_off)"; + const std::vector bars = quarter_hour_bars(16); + const int n_warmup = 10; + + SeriesHost host; + if (!begin_stream(host, bars, n_warmup, "native-htf-stream-live", lookahead)) return; + const std::size_t warmup_deliveries = host.deliveries.size(); + CHECK(warmup_deliveries == 2); + + // Bars 10 and 11 complete the bucket bars 8 and 9 opened during the + // warmup; bars 12..15 are a wholly live bucket. + for (int i = n_warmup; i < 16; ++i) { + const bool pushed = host.stream_push_bar(bars[static_cast(i)]); + CHECK(pushed); + if (!pushed) { + std::printf(" push %d error: %s\n", i, host.last_error().c_str()); + return; + } + } + CHECK(host.deliveries.size() == 4); + if (host.deliveries.size() != 4) return; + + const std::int64_t origin = bars.front().timestamp; + for (std::size_t k = 2; k < 4; ++k) { + const Delivery& delivery = host.deliveries[k]; + const Bar want = hand_aggregate(bars, static_cast(k) * 4, 4, + origin + static_cast(k) * kHour); + check_bucket(delivery.bar, want, "live bucket"); + CHECK(delivery.subscription == 0); + CHECK(delivery.accessor_matches); + CHECK(delivery.context.completion == NativeCompletionKind::Confirmed); + // A live bucket is delivered on the pushed bar that completed it -- + // the group's 4th -- under BOTH publication modes: a realtime bar has + // no future for lookahead_on to resolve over. + CHECK(delivery.context.delivered_at_ms == bars[k * 4 + 3].timestamp); + // Delivered before that bar's calculation: exactly 4k+3 script bars + // had been calculated when it arrived. + CHECK(delivery.bars_before == static_cast(k) * 4 + 3); + // The bucket's own calendar span, from its first contributing bar -- + // bar 8, which the warmup accepted, for the straddling bucket. + CHECK(delivery.context.interval.open_ms + == origin + static_cast(k) * kHour); + CHECK(delivery.context.interval.next_period_open_ms + == origin + static_cast(k + 1) * kHour); + } + + // The live sequence itself: input, then the bucket, then the calculation. + std::vector want_tail; + for (int i = n_warmup; i < 16; ++i) { + want_tail.push_back("input:" + std::to_string(i) + "@" + + std::to_string(bars[static_cast(i)].timestamp)); + if (i % 4 == 3) { + want_tail.push_back("htf:0@" + std::to_string( + origin + static_cast(i / 4) * kHour)); + } + want_tail.push_back("bar@" + + std::to_string(bars[static_cast(i)].timestamp)); + } + CHECK(host.log.size() >= want_tail.size()); + if (host.log.size() < want_tail.size()) return; + const std::vector tail(host.log.end() + - static_cast(want_tail.size()), + host.log.end()); + check_logs_equal(tail, want_tail); +} + +// ---- 3. a partial live bucket is never delivered -------------------------- + +void test_partial_live_bucket_is_not_delivered() { + scenario = "partial live bucket"; + const std::vector bars = quarter_hour_bars(16); + const int n_warmup = 10; + + SeriesHost host; + if (!begin_stream(host, bars, n_warmup, "native-htf-stream-partial", false)) return; + // Through bar 13: the 12..15 bucket is two bars in and still open. + for (int i = n_warmup; i < 14; ++i) { + CHECK(host.stream_push_bar(bars[static_cast(i)])); + } + CHECK(host.deliveries.size() == 3); + const std::size_t before_end = host.deliveries.size(); + CHECK(host.stream_end(false)); + CHECK(host.deliveries.size() == before_end); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + // The last delivered bucket is still the completed one, never the partial. + const std::int64_t origin = bars.front().timestamp; + const auto latest = host.native_series_bar(0); + CHECK(latest.has_value()); + if (latest) CHECK(latest->timestamp == origin + 2 * kHour); +} + +// ---- 4. native_series_bar across the warmup -> live boundary -------------- + +void test_series_bar_across_the_boundary() { + scenario = "series accessor across the boundary"; + const std::vector bars = quarter_hour_bars(16); + const int n_warmup = 10; + const std::int64_t origin = bars.front().timestamp; + + SeriesHost host; + if (!begin_stream(host, bars, n_warmup, "native-htf-stream-accessor", false)) return; + + const Bar hour1 = hand_aggregate(bars, 4, 4, origin + kHour); + const Bar hour2 = hand_aggregate(bars, 8, 4, origin + 2 * kHour); + + // End of the warmup: the last bucket the warmup completed. + auto pulled = host.native_series_bar(0); + CHECK(pulled.has_value()); + if (pulled) check_bucket(*pulled, hour1, "after warmup"); + // An index nobody declared has no series at all. + CHECK(!host.native_series_bar(1).has_value()); + + // The first live bar contributes to the open bucket without completing it. + CHECK(host.stream_push_bar(bars[10])); + pulled = host.native_series_bar(0); + CHECK(pulled.has_value()); + if (pulled) check_bucket(*pulled, hour1, "after the first live bar"); + + // The bar that completes it moves the accessor, and only then. + CHECK(host.stream_push_bar(bars[11])); + pulled = host.native_series_bar(0); + CHECK(pulled.has_value()); + if (pulled) check_bucket(*pulled, hour2, "after the completing live bar"); + + CHECK(host.stream_push_bar(bars[12])); + pulled = host.native_series_bar(0); + CHECK(pulled.has_value()); + if (pulled) check_bucket(*pulled, hour2, "after the next live bar"); + + CHECK(host.stream_end(false)); + pulled = host.native_series_bar(0); + CHECK(pulled.has_value()); + if (pulled) check_bucket(*pulled, hour2, "after stream_end"); +} + +// ---- 5. a stream without subscriptions is unchanged ----------------------- + +// The portable pin of that identity. Neither native_continuation_hash() nor +// stream_state_hash() — which folds broker_state_hash(), and so the +// continuation — can be pinned as a constant: both carry the machine's +// resolved timezone resources (zoneinfo root and zone file paths), so the same +// stream hashes differently here and on each CI runner. +// native_run_spec_digest() is exactly the consumer's run-spec fold and nothing +// else, so it is the same number everywhere. Observed on THIS tree for +// base_spec("15", "15", "native-htf-stream-neutral"); it guards the fold's +// field list and order, while the neutrality itself is the event sequence and +// the in-process equalities below. +constexpr std::uint64_t kNeutralStreamSpecDigest = 10367175860638888234ull; + +void test_stream_without_subscriptions_is_unchanged() { + scenario = "stream neutrality"; + const std::vector bars = quarter_hour_bars(12); + NativeRunSpec spec = base_spec("15", "15", "native-htf-stream-neutral"); + CHECK(spec.subscriptions.empty()); + + SeriesHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(host.stream_begin(bars.data(), 8, "15", "15")); + for (int i = 8; i < 12; ++i) { + CHECK(host.stream_push_bar(bars[static_cast(i)])); + } + CHECK(host.stream_end(false)); + CHECK(host.last_error().empty()); + CHECK(host.bars_seen == 12); + CHECK(host.deliveries.empty()); + + // The event sequence: input then calculation, with nothing between them. + std::vector want_log; + for (int i = 0; i < 12; ++i) { + const std::string stamp = + std::to_string(bars[static_cast(i)].timestamp); + want_log.push_back("input:" + std::to_string(i) + "@" + stamp); + want_log.push_back("bar@" + stamp); + } + check_logs_equal(host.log, want_log); + + const std::uint64_t digest = native_run_spec_digest(spec); + if (digest != kNeutralStreamSpecDigest) { + std::printf(" spec digest %llu, pinned %llu\n", + static_cast(digest), + static_cast(kNeutralStreamSpecDigest)); + } + CHECK(digest == kNeutralStreamSpecDigest); + + // Stating the field at its default — an empty series list — folds nothing. + NativeRunSpec stated = spec; + stated.subscriptions.clear(); + CHECK(native_run_spec_digest(stated) == kNeutralStreamSpecDigest); + + // The run's own identity is compared between two streams in this process, + // never against a constant: the same stream under the spec with its empty + // series list stated outright reaches the same event sequence, the same + // continuation identity and the same stream state. + const std::uint64_t continuation = host.native_continuation_hash(); + const std::uint64_t stream_state = host.stream_state_hash(); + SeriesHost restated; + CHECK(restated.configure_native(stated).status == NativeSetupStatus::Applied); + CHECK(restated.stream_begin(bars.data(), 8, "15", "15")); + for (int i = 8; i < 12; ++i) { + CHECK(restated.stream_push_bar(bars[static_cast(i)])); + } + CHECK(restated.stream_end(false)); + CHECK(restated.last_error().empty()); + CHECK(restated.bars_seen == 12); + CHECK(restated.deliveries.empty()); + check_logs_equal(restated.log, want_log); + CHECK(restated.native_continuation_hash() == continuation); + CHECK(restated.stream_state_hash() == stream_state); + + // Declaring a series cannot land on the same run-spec fold, nor on the + // same continuation. + CHECK(native_run_spec_digest(hourly_spec("native-htf-stream-neutral", false)) + != kNeutralStreamSpecDigest); + SeriesHost declared; + if (begin_stream(declared, bars, 8, "native-htf-stream-neutral", false)) { + for (int i = 8; i < 12; ++i) { + CHECK(declared.stream_push_bar(bars[static_cast(i)])); + } + CHECK(declared.stream_end(false)); + CHECK(declared.native_continuation_hash() != continuation); + } +} + +// ---- 6. a session-clipped calendar series across the boundary ------------- + +// Three 09:30-16:00 New York sessions of 15-minute bars: 26 per session, the +// last one closing on the session close rather than on any nominal period end. +std::vector session_bars(int sessions) { + std::vector bars; + bars.reserve(static_cast(sessions) * 26u); + for (int day = 0; day < sessions; ++day) { + // 09:30 New York is 14:30 UTC on these January dates. + const std::int64_t open = utc_ms(2024, 1, 2 + day, 14, 30); + for (int i = 0; i < 26; ++i) { + const double price = 100.0 + day + i * 0.25; + Bar bar{}; + bar.open = price; + bar.high = price + 2.0; + bar.low = price - 1.0; + bar.close = price + 0.5; + bar.volume = 1.0; + bar.timestamp = open + static_cast(i) * kQuarter; + bars.push_back(bar); + } + } + return bars; +} + +NativeRunSpec daily_spec(const char* session_key) { + NativeRunSpec spec = base_spec("15", "15", session_key); + spec.type = "stock"; + spec.timezone = "America/New_York"; + spec.session = "0930-1600"; + NativeTimeframeSubscription daily; + daily.tf = "D"; + spec.subscriptions.push_back(daily); + return spec; +} + +void test_session_calendar_series_across_the_boundary() { + scenario = "session-clipped daily across the boundary"; + const std::vector bars = session_bars(3); + // Mid second session: the day that straddles the warmup -> live boundary + // is neither the first nor the last. + const int n_warmup = 26 + 13; + + SeriesHost batch; + CHECK(batch.configure_native(daily_spec("native-htf-stream-daily-batch")).status + == NativeSetupStatus::Applied); + batch.run(bars.data(), static_cast(bars.size()), "15", "15", false, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(batch.last_error().empty()); + if (!batch.last_error().empty()) std::printf(" error: %s\n", batch.last_error().c_str()); + CHECK(batch.deliveries.size() == 3); + + SeriesHost stream; + CHECK(stream.configure_native(daily_spec("native-htf-stream-daily-live")).status + == NativeSetupStatus::Applied); + const bool began = stream.stream_begin(bars.data(), n_warmup, "15", "15"); + CHECK(began); + if (!began) { + std::printf(" error: %s\n", stream.last_error().c_str()); + return; + } + CHECK(stream.deliveries.size() == 1); + for (int i = n_warmup; i < static_cast(bars.size()); ++i) { + const bool pushed = stream.stream_push_bar(bars[static_cast(i)]); + CHECK(pushed); + if (!pushed) { + std::printf(" push %d error: %s\n", i, stream.last_error().c_str()); + return; + } + } + // A calendar bucket completes on its own session close, which the stream + // reaches on its own: the whole three-day series is the batch's, bucket + // for bucket and delivery point for delivery point, straddling day and all. + check_deliveries_equal(stream.deliveries, batch.deliveries); + check_logs_equal(stream.log, batch.log); + for (const Delivery& delivery : stream.deliveries) { + CHECK(delivery.context.completion == NativeCompletionKind::Confirmed); + // One unit of volume per contributing bar: a whole 26-bar session. + CHECK(same(delivery.bar.volume, 26.0)); + } + CHECK(stream.stream_end(false)); +} + +// ---- 7. authoritative bars cover the warmup only -------------------------- + +void test_authoritative_bars_cover_the_warmup() { + scenario = "authoritative bars over a stream"; + const std::vector bars = quarter_hour_bars(16); + const std::int64_t origin = bars.front().timestamp; + const int n_warmup = 10; + + // The exchange's own bars for the two hours the warmup completes. They are + // installed once, at begin, so the hours the live phase completes have + // none and aggregate the pushed input instead. + std::vector exchange; + for (int k = 0; k < 2; ++k) { + Bar bar{}; + bar.open = 1000.0 + k; + bar.high = 1010.0 + k; + bar.low = 990.0 + k; + bar.close = 1005.0 + k; + bar.volume = 7777.0 + k; + bar.timestamp = origin + static_cast(k) * kHour; + exchange.push_back(bar); + } + + NativeRunSpec spec = base_spec("15", "15", "native-htf-stream-feed"); + NativeTimeframeSubscription hourly; + hourly.tf = "60"; + hourly.authoritative_bars = exchange; + spec.subscriptions.push_back(hourly); + + SeriesHost host; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + const bool began = host.stream_begin(bars.data(), n_warmup, "15", "15"); + CHECK(began); + if (!began) { + std::printf(" error: %s\n", host.last_error().c_str()); + return; + } + CHECK(host.deliveries.size() == 2); + if (host.deliveries.size() != 2) return; + for (std::size_t k = 0; k < 2; ++k) { + check_bucket(host.deliveries[k].bar, exchange[k], "warmup exchange bucket"); + } + CHECK(host.native_security_substitutions() == 2); + CHECK(host.native_security_misses() == 0); + + for (int i = n_warmup; i < 16; ++i) { + CHECK(host.stream_push_bar(bars[static_cast(i)])); + } + CHECK(host.deliveries.size() == 4); + if (host.deliveries.size() != 4) return; + for (std::size_t k = 2; k < 4; ++k) { + const Bar aggregate = hand_aggregate(bars, static_cast(k) * 4, 4, + origin + static_cast(k) * kHour); + check_bucket(host.deliveries[k].bar, aggregate, "live aggregated bucket"); + } + // Still the warmup's two substitutions; the live buckets are counted as + // the misses they are, against an installed feed that does not reach them. + CHECK(host.native_security_substitutions() == 2); + CHECK(host.native_security_misses() == 2); + CHECK(host.stream_end(false)); +} + +// ---- 8. a subscribed stream takes confirmed bars only --------------------- + +void test_tick_input_is_refused_while_subscribed() { + scenario = "tick input while subscribed"; + const std::vector bars = quarter_hour_bars(16); + const int n_warmup = 10; + + SeriesHost host; + if (!begin_stream(host, bars, n_warmup, "native-htf-stream-ticks", false)) return; + + TradeTick tick{}; + tick.timestamp = bars[10].timestamp + kMinute; + tick.sequence = 1; + tick.price = bars[10].close; + tick.quantity = 1.0; + CHECK(!host.stream_push_tick(tick)); + CHECK(host.last_error() + == "native timeframe subscriptions require confirmed-bar stream input"); + // A refusal, not a failure: the host is still running its stream. + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.stream_is_realtime()); + + CHECK(!host.stream_advance_time(bars[11].timestamp)); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + + // Confirmed bars still work afterwards, and the series still completes. + for (int i = n_warmup; i < 12; ++i) { + CHECK(host.stream_push_bar(bars[static_cast(i)])); + } + CHECK(host.deliveries.size() == 3); + CHECK(host.stream_end(false)); + + // A stream that declares no series is untouched by that rule. + SeriesHost plain; + NativeRunSpec spec = base_spec("15", "15", "native-htf-stream-plain-ticks"); + CHECK(plain.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(plain.stream_begin(bars.data(), n_warmup, "15", "15")); + CHECK(plain.stream_push_tick(tick)); + CHECK(plain.last_error().empty()); + CHECK(plain.stream_end(true)); +} + +} // namespace + +int main() { + test_warmup_matches_batch(false); + test_warmup_matches_batch(true); + test_live_pushes_extend_buckets(false); + test_live_pushes_extend_buckets(true); + test_partial_live_bucket_is_not_delivered(); + test_series_bar_across_the_boundary(); + test_stream_without_subscriptions_is_unchanged(); + test_session_calendar_series_across_the_boundary(); + test_authoritative_bars_cover_the_warmup(); + test_tick_input_is_refused_while_subscribed(); + std::printf("native HTF subscriptions (stream): %d checks, %d failures\n", + checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_native_margin_model.cpp b/tests/test_native_margin_model.cpp new file mode 100644 index 00000000..36d7d613 --- /dev/null +++ b/tests/test_native_margin_model.cpp @@ -0,0 +1,580 @@ +/* + * test_native_margin_model.cpp — R5 lane L4: the generic native margin model. + * + * Everything here is opt-in through NativeRunSpec::margin. The first scenario + * pins that a spec WITHOUT a margin model still books the same fills, the same + * book and the same run-spec fold as the pre-L4 tree, so the adapter — which + * never sets the field — is byte-identical by construction. + * + * Hand arithmetic used throughout (point_value = 1, account fx = 1, no fee): + * equity(P) = capital + realized + dir * (P - entry) * units + * required(P, m) = units * P * m + * liquidation L : equity(L) == required(L, maintenance) + * = (capital + realized - dir * units * entry) + * / (units * (maintenance - dir)) + * restore = (required(mark) - equity(mark)) / (mark * maintenance) + */ +#include "native_current_fixture.hpp" + +#include +#include +#include +#include + +using namespace r4_test; + +namespace { + +constexpr const char* kLiquidationLabel = "__kernel_liquidation__"; + +// ── Portable spec-fold pins ───────────────────────────────────────────── +// A raw native_continuation_hash() constant is NOT portable: the consumer +// folds the run's resolved timezone identity — the zoneinfo root and the zone +// file paths of the machine that ran it — so the same run hashes differently +// here and on each CI runner. native_run_spec_digest() is exactly the +// consumer's run-spec fold and nothing else, so it is the same number +// everywhere. Both constants are observed on THIS tree for the margin-free +// specs of scenarios 1+10 and guard the fold's field list and order; the +// neutrality claim itself is the equalities beside them — `margin` folds +// nothing while it is unset, and declaring a model (even one whose every field +// is its default) is what moves the fold — plus the fills, the book and the +// trade counts, which are unchanged from the pre-L4 tree. +constexpr std::uint64_t kNeutralSpecDigest = 2166775980498865536ULL; +constexpr std::uint64_t kNeutralRichSpecDigest = 17505314342075340318ULL; + +Bar ohlc(int index, double open, double high, double low, double close, + double volume = 1.0) { + return {open, high, low, close, volume, T + static_cast(index) * 60000}; +} + +NativeRunSpec margin_spec(const char* key) { + NativeRunSpec s; + s.identity = {key, 1}; + s.input_tf = "1"; + s.script_tf = "1"; + s.tickerid = "TEST:MARGIN"; + s.timezone = "UTC"; + s.session = "24x7"; + s.initial_capital = 1000.0; + s.point_value = 1.0; + s.account_fx = 1.0; + s.price_tick = 0.01; + s.fee_kind = NativeFeeKind::CashPerExecution; + s.fee_value = 0.0; + s.close_execution = NativeCloseExecution::AfterCalculation; + return s; +} + +NativeMarginModel model(double initial_long, double initial_short, + std::optional maintenance, + NativeLiquidationSizing sizing = NativeLiquidationSizing::RestoreMinimum, + double multiple = 1.0) { + NativeMarginModel m; + m.initial_long = initial_long; + m.initial_short = initial_short; + m.maintenance_long = maintenance; + m.maintenance_short = maintenance; + m.sizing = sizing; + m.shortfall_multiple = multiple; + return m; +} + +// One host that opens a literal position on the first calculation and records +// every applied fill, every margin receipt, and their relative order. +struct MarginHost final : Host { + double open_units = 0.0; + std::function bar_open; + std::function(MarginHost&, const NativeMarginCallView&)> sizer; + std::vector order; + std::vector margin_calls; + std::vector sizer_views; + std::vector> level_at_bar_open; + std::vector> level_at_calculation; + int bars = 0; + + void on_native_bar_open(const Bar&, const NativeDecisionContext&) override { + level_at_bar_open.push_back(native_liquidation_price()); + if (bar_open) bar_open(*this); + } + void on_native_bar(const Bar& bar, const NativeDecisionContext& context) override { + Host::on_native_bar(bar, context); + if (bars++ == 0 && open_units != 0.0) (void)put(*this, tx(open_units, "entry")); + level_at_calculation.push_back(native_liquidation_price()); + } + void on_native_applied(const no::ExecutionAppliedEvent& event, + const NativeDecisionContext& context) override { + Host::on_native_applied(event, context); + order.push_back("applied:" + std::to_string(event.ordinal)); + } + void on_native_margin_call(const no::MarginCallEvent& event) override { + order.push_back("margin:" + std::to_string(event.applied.ordinal)); + margin_calls.push_back(event); + } + std::optional resolve_margin_call_units( + const NativeMarginCallView& view) const override { + auto& self = const_cast(*this); + self.sizer_views.push_back(view); + if (sizer) return sizer(self, view); + return std::nullopt; + } +}; + +void drive(MarginHost& host, const NativeRunSpec& s, const std::vector& bars) { + REQUIRE(host.configure_native(s).status == NativeSetupStatus::Applied); + host.run(bars.data(), static_cast(bars.size())); + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); +} + +std::vector liquidations(const MarginHost& host) { + std::vector out; + for (const auto& row : events(host)) { + if (row.request().label == kLiquidationLabel) out.push_back(row); + } + return out; +} + +// The twin tape: a fully margined 20-unit long opened at 100, then a bar whose +// adverse extreme (95) breaches a 50 % requirement. +std::vector twin_tape() { + return {ohlc(0, 100.0, 100.0, 100.0, 100.0), ohlc(1, 100.0, 101.0, 95.0, 96.0)}; +} + +// ---------------------------------------------------------------- 1 + 10 +// A spec with no margin model books exactly what it booked before L4, and +// hashes to the constant recorded from the pre-L4 tree. Every request in this +// run is RequestOrigin::Host, so authorship folds nothing into the digest. +void neutral_without_margin() { + auto s = margin_spec("l4-neutral"); + s.initial_margin_fraction = 0.5; + MarginHost host; + host.open_units = 20.0; + drive(host, s, twin_tape()); + + const auto fills = events(host); + CHECK(fills.size() == 1); + if (!fills.empty()) { + near(fills[0].resolved_price, 100.0); + near(fills[0].opened_units, 20.0); + CHECK(fills[0].request().label == "entry"); + } + CHECK(liquidations(host).empty()); + CHECK(host.margin_calls.empty()); + near(host.physical_position().signed_units, 20.0); + // The portable half of the pre-L4 neutrality: the run-spec fold this run + // applies is pinned, stating `margin` as absent keeps it exactly there, + // and declaring a model is what moves it. + const auto digest = native_run_spec_digest(s); + if (digest != kNeutralSpecDigest) { + std::printf(" neutral spec digest %llu, pinned %llu\n", + static_cast(digest), + static_cast(kNeutralSpecDigest)); + } + CHECK(digest == kNeutralSpecDigest); + auto stated = s; + stated.margin.reset(); + CHECK(native_run_spec_digest(stated) == kNeutralSpecDigest); + // Presence is the opt-in: a model left at every default still moves the + // fold, against the same spec without one (no initial_margin_fraction + // here, which the model would conflict with). + auto bare = margin_spec("l4-neutral"); + auto declared = bare; + declared.margin = NativeMarginModel{}; + CHECK(native_run_spec_digest(declared) != native_run_spec_digest(bare)); + + // The run-level half, compared between two runs in this process rather + // than against a constant: the same margin-free spec with `margin` spelled + // out as absent books the same fill and reaches the same continuation. + MarginHost restated; + restated.open_units = 20.0; + drive(restated, stated, twin_tape()); + CHECK(events(restated).size() == 1); + CHECK(liquidations(restated).empty()); + near(restated.physical_position().signed_units, 20.0); + CHECK(restated.native_continuation_hash() == host.native_continuation_hash()); + // No maintenance fraction anywhere: the accessor stays silent. + CHECK(!host.native_liquidation_price().has_value()); +} + +// A richer margin-free population — market, resting limit, resting stop, +// cancel, pyramided add, flatten, slippage, percent fee, lot cap — so every +// RequestOrigin::Host definition and every CommandEvent tag takes part in the +// digest, which stays where the pre-L4 tree left it: authorship folds nothing +// for a host request. +struct RichHost final : Host { + int bars = 0; + std::optional resting; + void on_native_bar(const Bar& bar, const NativeDecisionContext& context) override { + Host::on_native_bar(bar, context); + const int index = bars++; + if (index == 0) { + (void)put(*this, tx(10.0, "entry")); + no::Request limit = reduce(2.0, "take"); + limit.trigger = no::Limit{104.0, false}; + const auto out = submit(limit); + if (out.handle) resting = *out.handle; + } else if (index == 1) { + no::Request stop = reduce(3.0, "protect"); + stop.trigger = no::Stop{97.0}; + (void)put(*this, stop); + } else if (index == 2) { + if (resting) (void)cancel(*resting); + (void)put(*this, tx(4.0, "add")); + } else if (index == 3) { + (void)put(*this, flat("exit")); + } + } +}; + +void neutral_rich_population() { + auto s = margin_spec("l4-neutral-rich"); + s.initial_capital = 100000.0; + s.slippage_ticks = 2; + s.fee_kind = NativeFeeKind::Percent; + s.fee_value = 0.001; + s.initial_margin_fraction = 0.25; + s.max_open_lots = 4; + RichHost host; + REQUIRE(host.configure_native(s).status == NativeSetupStatus::Applied); + const std::vector bars = { + ohlc(0, 100.0, 102.0, 99.0, 101.0, 5.0), + ohlc(1, 101.0, 105.0, 96.0, 98.0, 7.0), + ohlc(2, 98.0, 99.5, 93.0, 94.0, 3.0), + ohlc(3, 94.0, 107.0, 94.0, 106.0, 9.0), + ohlc(4, 106.0, 106.5, 103.0, 104.0, 2.0), + }; + host.run(bars.data(), static_cast(bars.size())); + CHECK(host.last_error().empty()); + CHECK(host.trade_count() == 4); + near(host.physical_position().signed_units, 0.0); + + const auto digest = native_run_spec_digest(s); + if (digest != kNeutralRichSpecDigest) { + std::printf(" rich spec digest %llu, pinned %llu\n", + static_cast(digest), + static_cast(kNeutralRichSpecDigest)); + } + CHECK(digest == kNeutralRichSpecDigest); + auto stated = s; + stated.margin.reset(); + CHECK(native_run_spec_digest(stated) == kNeutralRichSpecDigest); + + // In process, not against a constant: the same population under the same + // spec with `margin` stated as absent books the same trades, the same flat + // book and the same continuation identity. + RichHost restated; + REQUIRE(restated.configure_native(stated).status == NativeSetupStatus::Applied); + restated.run(bars.data(), static_cast(bars.size())); + CHECK(restated.last_error().empty()); + CHECK(restated.trade_count() == 4); + near(restated.physical_position().signed_units, 0.0); + CHECK(restated.native_continuation_hash() == host.native_continuation_hash()); +} + +// ------------------------------------------------------------------- 2 +void per_side_initial_margin() { + // initial_long 0.5 admits a 10-unit long (500 <= 1000); initial_short 2.0 + // refuses the same short (2000 > 1000). + for (const double units : {10.0, -10.0}) { + auto s = margin_spec(units > 0 ? "l4-init-long" : "l4-init-short"); + s.margin = model(0.5, 2.0, std::nullopt); + MarginHost host; + host.open_units = units; + drive(host, s, twin_tape()); + const auto fills = events(host); + const auto rejects = events(host); + if (units > 0) { + CHECK(fills.size() == 1); + CHECK(rejects.empty()); + near(host.physical_position().signed_units, 10.0); + } else { + CHECK(fills.empty()); + CHECK(!rejects.empty()); + if (!rejects.empty()) { + CHECK(rejects[0].reason == no::MatchRejectReason::InitialMargin); + } + near(host.physical_position().signed_units, 0.0); + } + } +} + +// ------------------------------------------------------------------- 3 +void maintenance_breach_sizes_by_policy() { + // 20 @ 100, capital 1000, maintenance 0.5. + // L = (1000 - 20*100) / (20 * (0.5 - 1)) = 100 + // mark = adverse extreme of {101, 95, 96} = 95 + // equity = 1000 + 20 * (95 - 100) = 900 + // required = 20 * 95 * 0.5 = 950 + // restore = (950 - 900) / (95 * 0.5) = 1.0526315789473684 + const double restore = 50.0 / 47.5; + struct Case { + const char* key; + NativeLiquidationSizing sizing; + double multiple; + double units; + }; + const Case cases[] = { + {"l4-restore", NativeLiquidationSizing::RestoreMinimum, 1.0, restore}, + {"l4-mult1", NativeLiquidationSizing::ShortfallMultiple, 1.0, restore}, + {"l4-mult4", NativeLiquidationSizing::ShortfallMultiple, 4.0, 4.0 * restore}, + {"l4-flatten", NativeLiquidationSizing::Flatten, 1.0, 20.0}, + }; + for (const auto& row : cases) { + auto s = margin_spec(row.key); + s.margin = model(0.5, 0.5, 0.5, row.sizing, row.multiple); + MarginHost host; + host.open_units = 20.0; + drive(host, s, twin_tape()); + const auto rows = liquidations(host); + CHECK(rows.size() == 1); + if (rows.size() != 1) continue; + // The reduction fills AT the liquidation level, not at the extreme it + // was sized against: the account runs out of margin at 100. + near(rows[0].resolved_price, 100.0); + near(rows[0].closed_units, row.units); + near(host.physical_position().signed_units, 20.0 - row.units); + CHECK(host.margin_calls.size() == 1); + if (host.margin_calls.size() == 1) { + const auto& call = host.margin_calls[0]; + CHECK(call.side == no::Side::Long); + near(call.mark, 100.0); + near(call.units, row.units); + near(call.position_before, 20.0); + near(call.position_after, 20.0 - row.units); + } + } +} + +// ------------------------------------------------------------------- 4 +void liquidation_price_accessor() { + auto s = margin_spec("l4-level"); + s.margin = model(0.5, 0.5, 0.5); + MarginHost host; + host.open_units = 20.0; + drive(host, s, twin_tape()); + // Bar 0 opens flat; bar 1 opens on the untouched 20-unit book. + CHECK(host.level_at_bar_open.size() == 2); + if (host.level_at_bar_open.size() == 2) { + CHECK(!host.level_at_bar_open[0].has_value()); + REQUIRE(host.level_at_bar_open[1].has_value()); + near(*host.level_at_bar_open[1], 100.0); + } + // After the restore-minimum slice the book is 18.947368421052634 @ 100: + // L = (1000 - 18.947368421052634 * 100) + // / (18.947368421052634 * (0.5 - 1)) = 94.44444444444444 + const double left = 20.0 - 50.0 / 47.5; + CHECK(host.level_at_calculation.size() == 2); + if (host.level_at_calculation.size() == 2) { + REQUIRE(host.level_at_calculation[1].has_value()); + near(*host.level_at_calculation[1], (1000.0 - left * 100.0) / (left * -0.5)); + CHECK(*host.level_at_calculation[1] < 100.0); + } +} + +// ------------------------------------------------------------------- 5 +// A host fill between the arming and the trigger moves both the level and the +// units: the first kernel request is withdrawn under Superseded and exactly +// one kernel request is live at any moment. +void repriced_under_superseded() { + auto s = margin_spec("l4-reprice"); + s.margin = model(0.5, 0.5, 0.5); + MarginHost host; + host.open_units = 20.0; + host.bar_open = [](MarginHost& self) { + if (self.bars != 1) return; // only the bar after the entry + const auto handle = put(self, reduce(1.0, "host-trim")); + (void)handle; + }; + drive(host, s, {ohlc(0, 100.0, 100.0, 100.0, 100.0), ohlc(1, 100.0, 101.0, 90.0, 91.0)}); + + std::size_t accepted = 0; + std::size_t terminal = 0; + std::size_t superseded = 0; + std::size_t peak_live = 0; + for (const auto& row : host.native_events(0)) { + if (!row.command) continue; + if (const auto* e = std::get_if(&*row.command)) { + if (e->request().label != kLiquidationLabel) continue; + ++accepted; + peak_live = std::max(peak_live, accepted - terminal); + } else if (const auto* e = std::get_if(&*row.command)) { + if (e->request().label != kLiquidationLabel) continue; + ++terminal; + if (e->reason == no::CancelReason::Superseded) ++superseded; + } else if (const auto* e = std::get_if(&*row.command)) { + if (e->request().label != kLiquidationLabel || !e->terminal) continue; + ++terminal; + } + } + CHECK(accepted == 2); + CHECK(superseded == 1); + CHECK(peak_live == 1); + // The surviving request is the re-priced one: 19 @ 100 breaches at 90 by + // 19*90*0.5 - (1000 - 190) = 855 - 810 = 45, restoring 45/(90*0.5) = 1.0 + // unit at L = (1000 - 1900) / (19 * -0.5) = 94.73684210526316. + const auto rows = liquidations(host); + CHECK(rows.size() == 1); + if (rows.size() == 1) { + near(rows[0].resolved_price, (1000.0 - 1900.0) / (19.0 * -0.5)); + near(rows[0].closed_units, 1.0); + } +} + +// ------------------------------------------------------------------- 6 +void host_override_wins() { + auto s = margin_spec("l4-override"); + s.margin = model(0.5, 0.5, 0.5, NativeLiquidationSizing::ShortfallMultiple, 4.0); + MarginHost host; + host.open_units = 20.0; + host.sizer = [](MarginHost&, const NativeMarginCallView& view) -> std::optional { + near(view.position.signed_units, 20.0); + near(view.mark, 95.0); + near(view.equity, 900.0); + near(view.required, 950.0); + return 7.0; + }; + drive(host, s, twin_tape()); + const auto rows = liquidations(host); + CHECK(rows.size() == 1); + if (rows.size() == 1) near(rows[0].closed_units, 7.0); + near(host.physical_position().signed_units, 13.0); + CHECK(!host.sizer_views.empty()); +} + +// ------------------------------------------------------------------- 7 +void margin_call_follows_applied() { + auto s = margin_spec("l4-order"); + s.margin = model(0.5, 0.5, 0.5, NativeLiquidationSizing::Flatten); + MarginHost host; + host.open_units = 20.0; + drive(host, s, twin_tape()); + const auto rows = liquidations(host); + REQUIRE(rows.size() == 1); + REQUIRE(host.margin_calls.size() == 1); + const auto applied_tag = "applied:" + std::to_string(rows[0].ordinal); + const auto margin_tag = "margin:" + std::to_string(rows[0].ordinal); + std::size_t applied_at = host.order.size(); + std::size_t margin_at = host.order.size(); + for (std::size_t i = 0; i < host.order.size(); ++i) { + if (host.order[i] == applied_tag) applied_at = i; + if (host.order[i] == margin_tag) margin_at = i; + } + CHECK(applied_at < host.order.size()); + CHECK(margin_at == applied_at + 1); + CHECK(host.margin_calls[0].cursor == rows[0].cursor); + CHECK(host.margin_calls[0].applied.ordinal == rows[0].ordinal); +} + +// ------------------------------------------------------------------- 8 +void calculation_only_never_fills_mid_path() { + auto s = margin_spec("l4-calc-only"); + auto m = model(0.5, 0.5, 0.5); + m.check = NativeLiquidationCheck::CalculationOnly; + s.margin = m; + MarginHost host; + host.open_units = 20.0; + drive(host, s, twin_tape()); + const auto rows = liquidations(host); + CHECK(rows.size() == 1); + if (rows.size() == 1) { + // The calculation mark is bar 1's close, not the level and not the + // path's adverse extreme: + // equity(96) = 920, required = 20*96*0.5 = 960, + // restore = 40 / (96 * 0.5) = 0.8333333333333334 + near(rows[0].resolved_price, 96.0); + near(rows[0].closed_units, 40.0 / 48.0); + CHECK(rows[0].cursor.point.provenance == NativePriceProvenance::CurrentExecution); + } + // Nothing rested: no kernel request was ever cancelled or left working. + for (const auto& row : host.native_events(0)) { + if (!row.command) continue; + if (const auto* e = std::get_if(&*row.command)) { + if (e->request().label != kLiquidationLabel) continue; + CHECK(std::holds_alternative(e->request().trigger)); + } + } +} + +// ------------------------------------------------------------------- 9 +void mutually_exclusive_configuration() { + auto s = margin_spec("l4-conflict"); + s.initial_margin_fraction = 0.5; + s.margin = model(0.5, 0.5, 0.5); + MarginHost host; + const auto setup = host.configure_native(s); + CHECK(setup.status == NativeSetupStatus::Failed); + CHECK(setup.validation.error == NativeRunSpecError::MarginModelConflict); + CHECK(setup.validation.field == NativeRunSpecField::MarginModel); + + // Each spelling alone stays valid. + auto only_scalar = margin_spec("l4-scalar"); + only_scalar.initial_margin_fraction = 0.5; + MarginHost a; + CHECK(a.configure_native(only_scalar).status == NativeSetupStatus::Applied); + auto only_model = margin_spec("l4-model"); + only_model.margin = model(0.5, 0.5, 0.5); + MarginHost b; + CHECK(b.configure_native(only_model).status == NativeSetupStatus::Applied); + + // A margin model with a nonpositive per-side fraction is refused. + auto bad = margin_spec("l4-bad"); + auto bad_model = model(0.5, 0.5, std::nullopt); + bad_model.initial_long = 0.0; + bad.margin = bad_model; + MarginHost c; + const auto refused = c.configure_native(bad); + CHECK(refused.status == NativeSetupStatus::Failed); + CHECK(refused.validation.error == NativeRunSpecError::NotFinitePositive); + CHECK(refused.validation.field == NativeRunSpecField::MarginInitial); +} + +// ------------------------------------------------------------------ TWIN +// tests/test_margin_call_l4a.cpp::test_leveraged_long_adverse_low drives the +// SAME tape through the Pine adapter with margin_long = 50 %: 20 units opened +// at 100 on a process-orders-on-close bar, then O100 H101 L95 C96. The adapter +// books 4.2105263157894735 units of "Margin call" at 95. +// +// The native model with initial = maintenance = 0.5 and ShortfallMultiple 4.0 +// liquidates on the SAME bar, on the same side, for the SAME units. The only +// difference is the booked price, and it is a named TV quirk (MG15, fill +// pricing pine_adapter.cpp:11588-11597): the adapter pins its slice to the +// bar's adverse extreme it sized against, while the generic kernel rests the +// reduction at the liquidation level and books it where the account actually +// runs out of margin. +void twin_of_adapter_margin_call() { + auto s = margin_spec("l4-twin"); + s.margin = model(0.5, 0.5, 0.5, NativeLiquidationSizing::ShortfallMultiple, 4.0); + MarginHost host; + host.open_units = 20.0; + drive(host, s, twin_tape()); + const auto rows = liquidations(host); + REQUIRE(rows.size() == 1); + // Same bar as the adapter's row (entry bar + 1). + CHECK(rows[0].cursor.point.interval_index == 1); + // Same units, to the adapter's own 1e-6 tolerance and beyond. + near(rows[0].closed_units, 4.2105263157894735); + near(host.physical_position().signed_units, 15.789473684210526); + // Itemized difference: adapter 95.0 (adverse-extreme fill pricing, MG15), + // kernel 100.0 (the liquidation level). + near(rows[0].resolved_price, 100.0); + CHECK(host.margin_calls.size() == 1); + if (host.margin_calls.size() == 1) near(host.margin_calls[0].mark, 100.0); +} + +} // namespace + +int main() { + test("neutral-without-margin", neutral_without_margin); + test("neutral-rich-population", neutral_rich_population); + test("per-side-initial-margin", per_side_initial_margin); + test("maintenance-breach-sizing", maintenance_breach_sizes_by_policy); + test("liquidation-price", liquidation_price_accessor); + test("repriced-superseded", repriced_under_superseded); + test("host-override", host_override_wins); + test("margin-call-order", margin_call_follows_applied); + test("calculation-only", calculation_only_never_fills_mid_path); + test("configuration-conflict", mutually_exclusive_configuration); + test("twin-adapter-margin-call", twin_of_adapter_margin_call); + std::printf("%d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_native_order_terms_core.cpp b/tests/test_native_order_terms_core.cpp index 8b0e46c9..a68fde2d 100644 --- a/tests/test_native_order_terms_core.cpp +++ b/tests/test_native_order_terms_core.cpp @@ -23,7 +23,7 @@ static_assert(std::variant_size_v == 6); static_assert(std::variant_size_v == 5); static_assert(std::variant_size_v == 5); static_assert(std::variant_size_v == 4); -static_assert(std::variant_size_v == 17); +static_assert(std::variant_size_v == 18); static_assert(std::variant_size_v == 4); static_assert(std::variant_size_v == 3); static_assert(std::variant_size_v == 9);