diff --git a/CMakeLists.txt b/CMakeLists.txt index e29d59d85..5d5287c4a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,6 +104,10 @@ add_library(pineforge STATIC src/reservation_expansion.cpp src/compat/pine/reservation_expansion.cpp src/pending_order_mirror.cpp + src/native_order.cpp + src/engine_consumer.cpp + src/market_driver.cpp + src/native_execution_consumer.cpp src/magnifier.cpp src/math.cpp src/matrix.cpp @@ -116,6 +120,8 @@ add_library(pineforge STATIC src/ta_volatility_trend.cpp src/timeframe.cpp src/timezone.cpp + src/native_calendar.cpp + src/native_run_spec.cpp ) # Canonical alias for downstream find_package(PineForge) consumers. diff --git a/README.md b/README.md index 5b774ad6d..03bdbf23e 100644 --- a/README.md +++ b/README.md @@ -108,13 +108,21 @@ Lifecycle-aware compiled modules reset Pine variables, indicator/history buffers --- +## Native C++ strategy API + +Use `NativeStrategyHost` with an explicit `NativeRunSpec` for native market +transactions, reductions and flatten requests. The [native engine guide](docs/pages/native-engine.md) +provides a complete C++ batch/stream example, calendar and execution contracts, +and a runnable JSON configuration for the live runner. + ## Native live runner The optional C++17 `pineforge-live` executable uses this engine's native -warmup-to-stream lifecycle. It accepts tick or confirmed 1m OHLCV input, +warmup-to-stream lifecycle. It accepts normalized ticks or confirmed OHLCV bars, supports user-defined C++ parsers for broker/provider messages, and commits inputs plus order-action webhooks to a durable SQLite ledger. Hand-written -C++ and codegen-generated strategies use the same strategy interface. +C++ strategies use the native contract; generated Pine strategies retain their +compatibility path. Both expose the versioned C ABI used by the runner. Build with `-DPINEFORGE_BUILD_LIVE_RUNNER=ON`; the option is off by default, so core-only users do not acquire SQLite/libcurl/OpenSSL dependencies. See @@ -234,7 +242,7 @@ TradingView ties some day-boundary logic (intraday order caps, session rollovers ## Public C ABI -`` is the single canonical consumer header. Every compiled strategy `.so` exports exactly these 56 symbols and no internal C++ symbol (`-fvisibility=hidden`, `PF_API` on the public set, checked in CI by `scripts/check_c_abi_runtime.py`): +`` is the single canonical consumer header. Every compiled strategy `.so` exports exactly these 64 symbols and no internal C++ symbol (`-fvisibility=hidden`, `PF_API` on the public set, checked in CI by `scripts/check_c_abi_runtime.py`): | Symbol | Role | |---|---| @@ -254,6 +262,7 @@ TradingView ties some day-boundary logic (intraday order caps, session rollovers | `strategy_set_account_currency_fx_series` | Effective-time quote-to-account FX | | `strategy_get_last_error` | The latest runtime error | | `pf_version_get` / `pf_version_string` / `pf_abi_version` | Runtime version, version string, struct-layout version (`PF_ABI_VERSION == 4`) | +| `strategy_execution_contract` / `strategy_configure_native_v1` | Query Legacy vs NativeMarketV1; apply the versioned native run specification | | `strategy_request_abort` / `strategy_last_run_status` | Cooperative abort of a run in progress; `0`=completed, `1`=aborted | | `strategy_set_realtime_tail` | Live-runtime surface (ABI v4): the array's last bar is a still-forming tail — `barstate.islast=false`, `last_bar_index`/`last_bar_time` frozen at the horizon bar, no range-end row | | `strategy_set_probe_suppress_tail_logic` | ABI v4: the last bar runs only the broker's pre-`on_bar` steps (pending-order settlement, intraday-cap/loss checks) and returns — no `on_bar`, no margin-call / POOC second pass / bracket-reissue processing (the range-end row is `strategy_set_realtime_tail`'s to skip; the flags are independent) | diff --git a/docs/Doxyfile b/docs/Doxyfile index c6a79bed5..c40585ede 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -91,6 +91,7 @@ INPUT = ../include/pineforge/pineforge.h \ pages/integration-cmake.md \ pages/lifecycle.md \ pages/fill-model.md \ + pages/native-engine.md \ pages/market-admission.md \ pages/exit-leg-lifecycle.md \ pages/exit-leg-lifecycle-reflection.md \ diff --git a/docs/pages/abi-stability.md b/docs/pages/abi-stability.md index 1bdf1bb33..fb78f9384 100644 --- a/docs/pages/abi-stability.md +++ b/docs/pages/abi-stability.md @@ -114,7 +114,7 @@ notice: Rebuild generated and native C++ objects against matching engine headers and runtime. Deriving placement and opposite-predecessor facts from original admission evidence changes the native layout and advances `PendingOrder` and -`BacktestEngine` to `engine_script_run_v11`. Exact pre-change fd4c686/v10 +`BacktestEngine` to `engine_script_run_v12`. Exact pre-change fd4c686/v10 headers are authenticated before native, generated-style and standalone PendingOrder pairing checks. Current/old matching links must succeed and stale pairings must fail for the expected qualified symbols. Earlier v2-v9 controls diff --git a/docs/pages/index.md b/docs/pages/index.md index 59129eaf6..64d5de153 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -30,6 +30,10 @@ consumption. ownership, then **[Tutorial: MACD](@ref tutorial_macd)** for an end-to-end working example. +- I'm writing a native strategy + Read **[Native market host](@ref native-market)** for `NativeStrategyHost`, + `configure_native`, and the C ABI contract query. + - I'm integrating from CMake Jump straight to **[Install](@ref install)** and **[CMake integration](@ref integration_cmake)**. Then skim diff --git a/docs/pages/native-engine.md b/docs/pages/native-engine.md new file mode 100644 index 000000000..8993a21f3 --- /dev/null +++ b/docs/pages/native-engine.md @@ -0,0 +1,597 @@ +# Native market engine {#native_engine} + +@tableofcontents + +Hand-written C++ strategies can run a **standalone native market-order** path: +one `NativeRunSpec`, one working request roster, one physical lot book, and +close-only callbacks. Pine `strategy.*` commands, cap/priority adapters, default +source sizing, and complete Pine policy extraction are **not** this surface. +Codegen and source adapters select those policies separately. Resting +limit/stop/bracket relationships are a later roadmap; do not infer them from +this slice. + +Subclass `pineforge::NativeStrategyHost`. Configure with `configure_native`, +then `run` or `stream_*`. Submit from native begin/bar callbacks, or between +realtime inputs on the same thread. Do not override +the inherited `on_bar` (it is `final` and refused). Do not write protected +engine fields. + +Headers: ``, ``, +``, ``, +``, ``, +``. Enumeration members live in those headers; this +page does not re-list every enumerator. + +## Lifecycle and run identity + +`NativeStrategyHost` is noncopyable and nonmovable. The constructor binds the +native consumer; there is no attach/replace switch. + +`configure_native(const NativeRunSpec&)` is the only setup. Observation is +`native_state()`. Kinds: + +| Kind | Meaning | +| --- | --- | +| `Unconfigured` | Fresh host. | +| `Ready` | Spec staged; not begun. | +| `Running` | `Batch`, `Warmup`, or `Realtime`. | +| `Completed` | `BatchComplete` or `StreamEnded`. Open lots and live requests remain visible, not actionable. | +| `Failed` | Durable first failure. Discard the host. Replay on a **fresh** instance. Do not reconfigure in place. | + +`RunIdentity` is `(session_key, run_number)`. `session_key` is nonempty UTF-8 +and becomes fixed on first successful begin. `run_number` is a positive +integer. A consumed high-water lives **outside** per-run reset and advances +only at successful begin. Later runs on the **same** host need a strictly +larger number. A **fresh** host has high-water zero, so it may replay the same +logical run. + +Configure: + +- `Unconfigured` → validate/copy the whole spec atomically → `Ready`, or + `Failed` with no partial apply. +- `Ready` again → `Failed` (`Contract`). Use a new host to change unconsumed + setup. +- `Completed` → same session key, `run_number` above high-water → `Ready`. + Prior-run records stay inspectable until the next successful begin. +- `Running` or `Failed` → refused; `Failed` keeps the first failure. + +Begin (`run` / `stream_begin`) is allowed only from `Ready`. Success consumes +the run number, projects the spec onto execution storage, **resets once** +(book, live requests, native events, decision floor), enters `Running`, then +calls `on_native_run_begin()` and bar callbacks. Reset does not clear the +host high-water. + +`stream_begin` is one logical begin plus internal warmup, then `Realtime`, +**without** a second public `run` and without resetting lots, requests, +events, ordinals, floor, or identity at the handoff. + +`last_error()` is presentation text. `native_state().failure` is the durable +record (`code`, `operation`, optional `ordinal`, `discriminator`). See +`NativeFailureCode` / `NativeFailureOperation` in `native_host.hpp`. + +## NativeRunSpec + +`NativeRunSpec` defaults are **incomplete**. Empty required strings and zero +financials fail validation. There is no UTC/1-minute/24x7 substitution for a +missing native spec. + +Required: + +- `identity.session_key` nonempty; `identity.run_number` > 0 +- `input_tf` and `script_tf` (exact literals; see calendar below) +- `tickerid` +- scheduling `timezone` (must resolve; empty is not UTC) +- `initial_capital`, `point_value`, `account_fx`, `price_tick`: finite, strictly + positive. `account_fx` is one scalar, not a timestamped FX series. + +Always set, with documented defaults in the header: + +- `ticker`, `type`, `currency`, `basecurrency`, `description`, `volumetype` + (may be empty UTF-8 except `tickerid`) +- `session`: empty and `"24x7"` are **distinct** all-day literals +- `chart_timezone`: optional observation metadata; empty stays empty and is + not the scheduling calendar +- `slippage_ticks`: `0` .. `INT_MAX`. Buy adds, sell subtracts + `ticks * price_tick` **once**. No mintick snap. +- `fee_kind` / `fee_value`: `Percent`, `CashPerUnit`, `CashPerExecution`; + `fee_value` finite and ≥ 0. A percent fee is + `abs(units) × price × point_value × account_fx × fee_value / 100`. + Cash kinds are account currency per unit or per execution. +- `close_execution`: `NextEligiblePoint` (default) or `AfterCalculation` +- `allowed_open_directions`: `None`, `Long`, `Short`, `Both` (default) + +Optional, absent unless set: + +- `quantity_grid`: finite > 0; **admission only**, never resizes quantity +- `max_abs_units`: finite > 0; opening cap on the resulting book +- `max_open_lots`: positive; surviving + new lots +- `initial_margin_fraction`: finite > 0 as a fraction, not a percent. Opening + admission only; no maintenance liquidation. + +`validate_native_run_spec` / `normalize_native_run_spec` report the first +error field. `configure_native` copies a candidate, normalizes it, then stages +it atomically. Admitted numeric `-0` fee becomes `+0`; other literals are not +rewritten. + +Timeframe arguments on `run` / `stream_begin` must be **omitted/empty or +byte-identical** to the spec. Conflicting values are a preflight refusal: +`Ready`/`Running` is preserved. The rich +`run(bars, n, input_tf, script_tf, inputs, syminfo, overrides, …)` overload is +an unsupported source mutation (`Failed`). Magnifier/source-feed arguments are +not native spec fields. + +## Market requests + +From a native callback in `Batch` / `Warmup` / `Realtime`: + +```cpp +submit_market(request); +replace_market(handle, request); +cancel(handle); +``` + +Serialized external C++ calls may command only **between realtime inputs**, +never reentrantly during input processing. There is no C request API in this +slice. + +`native_order::Request` is `{ Action, label, comment }`. Label/comment are +inert text. `Action` is: + +- `order_action::Transact{signed_units}` — finite nonzero +- `order_action::Reduce{units}` — finite positive +- `execution::Flatten{}` — quantity-free whole-book close + +A `quantity_grid`, when present, admits Transact/Reduce quantities on the +exact binary64 grid in `native_order.hpp`. Flatten is not gridded. Rejection +does not rewrite the attempted bits. + +**Acceptance is not a fill.** `submit_market` returns `SubmitResult`: + +- `Accepted` — timeline ordinal + `RequestHandle` `(session, run, incarnation)`. + Immutable birth is `(acceptance_ordinal, decision_floor)`. Lots and fees do + not change. +- `Rejected` — rejection ordinal, no handle (`InvalidQuantity` / `OffGrid`). + +Fills appear later as `ExecutionAppliedEvent` on the same command history +(`native_events(0)`). Matching walks live requests in acceptance/incarnation +order at a matching driver point. Eligibility is +`point_ordinal > birth.acceptance_ordinal` and +`effective_time_ms >= birth.decision_time_lower_bound` (`point_eligible` in +the header). A request accepted on bar *N* cannot fill on that bar’s already +delivered opening. + +Replace is ordinary and causal: validate first; on success retire that live +incarnation and birth a successor (new handle/priority, predecessor link). +`ReplaceRejected` leaves the target live. Cancel live → `Cancelled`. Same-run +absent, replaced, or already terminal → `NotWorking`. Foreign or malformed +handle → `InvalidHandle`. Every outcome is an event. Commands never move +lots or cash. + +At a matching point, Reduce/Flatten use **current** exposure, not the +acceptance-cycle book: a flatten accepted while flat can still close a +same-point opening that already filled. If the book is flat at execution, +Reduce/Flatten terminalize `NoEffectEvent`: no execution identity, no fill, +no fee, no physical action. `MatchRejectedEvent` is an event ordinal without +execution identity (opening direction, max units/lots, initial margin, or +nonpositive resolved price). An opening denial rejects the **entire** +Transact, including a proposed close remainder; it does not close old +exposure first. Closing-only Reduce/Flatten remain allowed while opening +limits are already exceeded. + +Query the book with `physical_position()` (`signed_units`, `average_price`, +`lot_count`) and `native_marked_equity(mark)`. `native_events(after_ordinal)` +returns owning snapshots: command, driver, and account rows. Later +commands/reset do not invalidate copies already returned. + +A run-start request uses the first provided input's opening time as its initial +decision floor. An empty batch has no market time or price to deliver. +After the first realtime tick or time-advance call, confirmed-bar input is +refused; time advance is likewise refused after confirmed-bar input. + +Rows with the same ordinal describe one execution and its account projection. +Process the whole ordinal group before advancing an event-reader cursor. + +## Close execution + +`NativeCloseExecution::NextEligiblePoint` (default): a request born at a +script calculation waits for a **later eligible matching point** (next modeled +opening, observed print, or carried open). It does not fill on the same bar’s +already presented open/high/low/close. + +`NativeCloseExecution::AfterCalculation`: after that calculation, a modeled +close point may match, still obeying birth ordinal/floor. It is not a replay +of observed prints. + +## One physical book + +There is one engine lot/account book. Native matching inspects settlement, +admits openings against the frozen spec, then commits once. Fees are quoted +on that execution (percent of absolute notional, cash per unit, or one cash +ticket per execution). Slippage is applied to the raw observed/modeled price +as described above. Native account rows follow lots, remaining entry costs, +and realized balance at the matching coordinate. + +## Calendar, session, timeframes, warmup + +Timestamps are **Unix milliseconds**. Confirmed bars require positive finite +OHLC (high ≥ max(open, close), low ≤ min(open, close)) and finite nonnegative volume, +strictly increasing non-overlapping input slots, and a canonical slot label +(`interval.open_ms` or the scheduled clipped `eligible_open_ms`). Supply +**positive** prices: ticks require a positive finite price, and matching +rejects a nonpositive resolved price. + +Timeframe literals (spec is the authority; duration is never inferred from +the first two bars): + +- seconds `nS` (`"15S"`) +- minutes `"1"`, `"5"`, `"60"`, `"240"` (no `H` suffix; `"1H"` is not a minute) +- days `"D"` / `"nD"`, weeks `"W"` / `"nW"`, months `"M"` / `"nM"` + +Count ≥ 1, no leading zeros, no whitespace. `"D"` is not rewritten to `"1D"`. +See `native_calendar.hpp` for parse/pairing enumerators. + +Supported pairings: equal-literal passthrough; same unit with script count a +multiple of input; fixed second/minute durations where script ms is a +multiple of input ms; fixed → D/W/M; D/nD → W/M and W/nW → M. Script finer +than input, or indivisible fixed (`"5"` → `"7"`), is refused at setup. +**Batch** admits monthly input (`M` / `nM`) on those pairings. **Stream** +refuses monthly **input** before begin (and the runner refuses it before +binding a ledger). Stream D/W uses civil/session next-open stepping. + +Each confirmed input is attributed **wholly** to the script period that +contains its input open (OHLC/volume are not split across a calendar +boundary). Script calculation time is not earlier than the latest included +contributor close. An unfinished trailing coarser script bucket does not +become complete merely because batch input ended. + +Session strings: empty or `"24x7"`; comma-separated `HHMM-HHMM` windows; +`2400` as a legal end; overnight wrap; equal start/end as a full day from +that clock; optional `:1234567` mask (`1` = Sunday … `7` = Saturday). The +first declared window start is the civil-cycle origin. Timezone names, +offsets, and POSIX forms are accepted or refused by +`native_calendar::timezone_accepted`; see that header rather than guessing +IANA aliases. + +**Batch** may be sparse (missing in-session slots are allowed; no synthetic +prices). **Stream warmup** requires at least one input and every **provided** +input slot to be a complete confirmed interval; missing in-session bars are +refused. An unfinished coarser **script** bucket is retained through +Warmup → Realtime. The handoff does not invent a script callback, a future +exclusive close, or a raised floor to a future seal. Empty batch is a valid +completed run with no events. + +## Batch OHLCV vs ticks vs quiet + +Two driver models only: confirmed OHLCV and observed ticks. Mixing them on +one stream is refused. Callbacks stay **close-only** (script-bar calculation). + +Confirmed script OHLC: modeled **Opening** at the first contributor’s actual +open (**match**), high/low in the existing AUTO order (**excursion only**), +close (**excursion only**), then calculation, then optional +AfterCalculation close (**match**). + +Observed tick: the real price at its timestamp/sequence (**match** / +excursion). Sequence, when nonzero, must increase. Off-session **observed** +prints are still delivered and may fill an eligible live request. + +Quiet **tradable** interval with no prints: explicit **carried open** at that +interval’s tradable open, last known price (**match** if eligible), then +calculation if the script bucket completes. Configured **closed** spans +synthesize neither bars nor matching points. + +`stream_advance_time(ms)` finishes elapsed intervals and raises the accepted +time floor; it is not an extra print. `stream_end(false)` completes without +inventing a final callback or fill. `stream_end(true)` may partial-finalize +an existing forming observed input; it does not synthesize a missing input +or force an incomplete coarser script bucket complete. + +The decision floor is monotonic (delivered event time, accepted input +completion, tick time, time-advance). A refused preflight does not raise it. +Birth compares to that immutable floor, not to a later lowered clock. + +## Stream limitations (current) + +These are existing refusals, not implied future features: + +- Monthly **input** (`M` / `nM`) on `stream_begin` / runner native warmup +- Mixed confirmed bars and ticks +- In-session gaps on stream/warmup +- `calc_on_every_tick` / `calc_on_order_fills` enabled (runner rejects an + explicit true override; compiled strategies must still be close-only) +- Timestamped account-FX series, 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 +- C-level native request submit/replace/cancel +- Limit, stop, trail, or bracket **relationships** + +Rebuild strategy libraries against this engine. An ABI-v4 module without the +native contract is legacy and cannot take `--native-config`. + +## Errors + +| Situation | Host state | What to read | +| --- | --- | --- | +| Invalid spec at configure | `Failed` (`InvalidSpecification`) | `native_state().failure`, `last_error()` | +| Configure while `Ready` / `Running`; session-key change; run number ≤ high-water | `Failed` (`Contract`) | same | +| Timeframe args ≠ spec; unaligned/invalid bars; in-session stream gap; monthly stream input | **Not** `Failed` if still `Ready`/`Running` | `last_error()`; `run` leaves `Ready`; `stream_*` returns `false` | +| Source command/setter | `Failed` (`UnsupportedSource`) | discard host | +| Exception from `on_native_bar` / `on_native_run_begin` | `Failed` (`CallbackException`) | discard host | +| Unexpected settlement/allocation/counter exhaustion after begin | `Failed` | discard host; no rollback promise | +| Submit/replace/cancel outside the allowed phase | throws; not a fill | command only from callbacks or between realtime inputs | +| Cooperative abort | `Failed` (`Aborted`) | discard host | + +`last_run_status()` is not failure authority. After `Failed`, later `run` / +`stream_*` / `configure_native` refuse (`native host already failed`). + +## C++ example + +Complete host: fill every `NativeRunSpec` default that validation requires, +submit `Transact` / `Flatten` from the callback, query `physical_position()`. +Five UTC `24x7` five-minute bars, Unix ms, positive finite OHLC. + +```cpp +#include + +#include +#include + +namespace { + +class NativeMarketExample final : public pineforge::NativeStrategyHost { + int bars_ = 0; + + void on_native_run_begin() override { bars_ = 0; } + + void on_native_bar(const pineforge::Bar&, + const pineforge::NativeDecisionContext&) override { + ++bars_; + if (bars_ == 1 && physical_position().signed_units == 0.0) { + submit_market({pineforge::order_action::Transact{1.0}, "native-open", ""}); + } else if (bars_ == 4 && physical_position().signed_units != 0.0) { + submit_market({pineforge::execution::Flatten{}, "native-flat", ""}); + } + } +}; + +pineforge::NativeRunSpec make_spec() { + pineforge::NativeRunSpec spec; + spec.identity.session_key = "native-guide"; + spec.identity.run_number = 1; + spec.input_tf = "5"; + spec.script_tf = "5"; + spec.ticker = "MOCK"; + spec.tickerid = "TEST:MOCK"; + spec.type = "crypto"; + spec.currency = "USDT"; + spec.basecurrency = "ETH"; + spec.description = ""; + spec.volumetype = ""; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = ""; + spec.initial_capital = 10000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.slippage_ticks = 0; + spec.fee_kind = pineforge::NativeFeeKind::CashPerExecution; + spec.fee_value = 6.0; + spec.close_execution = pineforge::NativeCloseExecution::NextEligiblePoint; + spec.allowed_open_directions = pineforge::NativeOpenDirections::Both; + return spec; +} + +const pineforge::Bar kBars[] = { + {100.0, 102.0, 99.0, 101.0, 4.0, 0}, + {102.0, 103.0, 101.0, 102.0, 4.0, 300000}, + {103.0, 104.0, 102.0, 103.5, 4.0, 600000}, + {103.5, 104.0, 103.0, 103.5, 4.0, 900000}, + {104.0, 105.0, 103.5, 104.0, 4.0, 1200000}, +}; +constexpr int kBarCount = 5; + +bool failed(const pineforge::NativeStrategyHost& host, const char* where) { + const auto state = host.native_state(); + if (state.kind == pineforge::NativeLifecycleKind::Failed) { + std::cerr << where << ": Failed code=" + << static_cast(state.failure.code) + << " op=" << static_cast(state.failure.operation) + << " " << host.last_error() << '\n'; + return true; + } + return false; +} + +} // namespace + +int main() { + const auto spec = make_spec(); + + NativeMarketExample batch; + const auto setup = batch.configure_native(spec); + if (setup.status != pineforge::NativeSetupStatus::Applied) { + std::cerr << "configure: " << batch.last_error() << '\n'; + return 1; + } + batch.run(kBars, kBarCount); + if (failed(batch, "run()")) return 1; + if (batch.native_state().kind != pineforge::NativeLifecycleKind::Completed) { + std::cerr << "run() not completed: " << batch.last_error() << '\n'; + return 1; + } + + NativeMarketExample batch_tf; + if (batch_tf.configure_native(spec).status != pineforge::NativeSetupStatus::Applied) + return 1; + batch_tf.run(kBars, kBarCount, "5", "5"); + if (failed(batch_tf, "run(tf)") || + batch_tf.native_state().kind != pineforge::NativeLifecycleKind::Completed) { + std::cerr << "run(tf) " << batch_tf.last_error() << '\n'; + return 1; + } + + bool accepted = false; + bool filled = false; + for (const auto& event : batch.native_events(0)) { + if (!event.command) continue; + if (std::holds_alternative(*event.command)) + accepted = true; + if (std::holds_alternative(*event.command)) + filled = true; + } + if (!accepted || !filled) { + std::cerr << "expected AcceptedEvent and ExecutionAppliedEvent\n"; + return 1; + } + if (batch.physical_position().signed_units != 0.0) { + std::cerr << "expected flat book after flatten fill\n"; + return 1; + } + + NativeMarketExample stream; + if (stream.configure_native(spec).status != pineforge::NativeSetupStatus::Applied) + return 1; + if (!stream.stream_begin(kBars, 1, "5", "5")) { + std::cerr << "stream_begin: " << stream.last_error() << '\n'; + return 1; + } + for (int i = 1; i < kBarCount; ++i) { + if (!stream.stream_push_bar(kBars[i])) { + std::cerr << "stream_push_bar: " << stream.last_error() << '\n'; + return 1; + } + } + if (!stream.stream_end()) { + std::cerr << "stream_end: " << stream.last_error() << '\n'; + return 1; + } + if (failed(stream, "stream") || + stream.native_state().kind != pineforge::NativeLifecycleKind::Completed) { + std::cerr << "stream not completed: " << stream.last_error() << '\n'; + return 1; + } + return 0; +} +``` + +Intended path on this equal-TF feed, `NextEligiblePoint`: callback 1 accepts +`Transact{+1}` (no fill yet); the next bar’s modeled open is the fill; +callback 4 accepts `Flatten` while long; the next open closes. Acceptance +events and `ExecutionAppliedEvent` are separate rows in `native_events`. + +Empty timeframe strings are also valid (`run(bars, n)` and +`stream_begin(bars, n, "", "")`). `"5"` / `"5"` must match the spec bytes. + +## Runner JSON and command + +`pineforge-live` is optional (`-DPINEFORGE_BUILD_LIVE_RUNNER=ON`, default +**OFF**). That option also builds `native-market-example`. Native modules +need `--native-config FILE` instead of `--input` / `--override` / `--syminfo` +(those flags are refused). The file is a strict JSON object: unknown and +duplicate keys fail; every listed key is required; there are **no** +environment defaults for spec fields. Omitted CLI clock/symbol flags take the +file. Explicit `--input-tf`, `--script-tf`, `--timezone`, `--session`, +`--chart-timezone`, or `--symbol` must equal the file. + +`input_mode` is the CLI `--mode` (`bars` or `ticks`), not a JSON key. +Timestamps in the spec, warmup CSV, and JSONL are Unix milliseconds. + +Complete file (optional numeric payloads are JSON `null`, not omitted keys). +`fee_kind` and `close_execution` are the C++ enumerator **names**. +`allowed_open_directions` is the numeric mask (`3` = `Both`; see +`NativeOpenDirections`). + +```json +{ + "run": { + "session_key": "native-guide", + "run_number": 1 + }, + "clock": { + "input_tf": "5", + "script_tf": "5", + "timezone": "UTC", + "session": "24x7", + "chart_timezone": "" + }, + "instrument": { + "ticker": "MOCK", + "tickerid": "TEST:MOCK", + "type": "crypto", + "currency": "USDT", + "basecurrency": "ETH", + "description": "", + "volumetype": "" + }, + "execution": { + "initial_capital": 10000, + "point_value": 1, + "account_fx": 1, + "price_tick": 0.01, + "slippage_ticks": 0, + "fee_kind": "CashPerExecution", + "fee_value": 6, + "quantity_grid": null, + "close_execution": "NextEligiblePoint", + "max_abs_units": null, + "max_open_lots": null, + "allowed_open_directions": 3, + "initial_margin_fraction": null + } +} +``` + +Save the configuration as `native-run.json`. Warmup uses the configured +calendar. For this example, save one confirmed bar as `history-5m.csv`: + +```csv +timestamp,open,high,low,close,volume +0,100,102,99,101,4 +``` + +Save these subsequent bars as `events.jsonl`. The first callback occurs +during warmup; the opening and flatten orders fill on subsequent inputs. + +```jsonl +{"type":"bar","bar":{"ts_open":300000,"o":102,"h":103,"l":101,"c":102,"v":4}} +{"type":"bar","bar":{"ts_open":600000,"o":103,"h":104,"l":102,"c":103.5,"v":4}} +{"type":"bar","bar":{"ts_open":900000,"o":103.5,"h":104,"l":103,"c":103.5,"v":4}} +{"type":"bar","bar":{"ts_open":1200000,"o":104,"h":105,"l":103.5,"c":104,"v":4}} +``` + +```sh +cmake -S . -B build-live \ + -DCMAKE_BUILD_TYPE=Release \ + -DPINEFORGE_BUILD_LIVE_RUNNER=ON +cmake --build build-live -j --target pineforge-live native_market_example + +build-live/bin/pineforge-live run \ + --strategy build-live/lib/native-market-example.so \ + --native-config native-run.json \ + --warmup history-5m.csv \ + --feed events.jsonl \ + --mode bars \ + --ledger orders.sqlite3 \ + --webhook-url https://receiver.example/order-actions \ + --name native-market +``` + +`--mode ticks` is the other input mode. Tick JSONL uses `ts` (Unix ms), +positive `seq`, `price`, `qty`. Confirmed-bar JSONL uses +`bar.ts_open` (Unix ms) plus `o,h,l,c,v`. `{"type":"time","ts":…}` is tick +mode only. Mixing modes is refused. Monthly `input_tf` is refused on this +path before the process continues into the run. + +Other CMake options (unchanged defaults): `PINEFORGE_BUILD_TESTS` ON, +`PINEFORGE_BUILD_TUTORIAL` ON, `PINEFORGE_BUILD_EXAMPLES` OFF, +`PINEFORGE_ENABLE_SANITIZERS` OFF, `PINEFORGE_STRICT_WARNINGS` OFF. The +runner also needs SQLite3, libcurl 7.86+, and OpenSSL Crypto. + +The C ABI (`strategy_execution_contract` → NativeMarketV1, +`strategy_configure_native_v1`) is what the runner uses to load +`native-market-example`. Prefer the C++ `NativeRunSpec` and this JSON over +hand-maintaining the versioned C struct. + +For enumerator payloads, pairing names, session grammar, and failure codes, +read the headers cited above. diff --git a/docs/pages/native-market.md b/docs/pages/native-market.md new file mode 100644 index 000000000..ae723272e --- /dev/null +++ b/docs/pages/native-market.md @@ -0,0 +1,24 @@ +# Native market host + +The native host is a constructor-bound backtest/forward state machine. Pine +source commands, cap/priority adapters and source sizing stay on the +compatibility consumer. Native strategies subclass `NativeStrategyHost`, call +`configure_native`, then `run` / `stream_*`, and submit market requests from +`on_native_bar`. + +One `NativeRunSpec` is the setup authority. Empty `chart_timezone` is optional +observation metadata and stays empty. Session `""` and `"24x7"` are distinct +all-day literals. Timeframe arguments on `run`/`stream_begin` must be omitted +or byte-identical to the spec. + +Matching uses the existing confirmed-bar and tick drivers. Settlement inspects +FIFO/fees without mutation, admits openings against the frozen spec, then +commits once through `settle_native_execution_at`. Opening denial rejects the +entire crossing before old exposure closes. + +C ABI adds `strategy_execution_contract` and `strategy_configure_native_v1`. +The runner accepts `--native-config FILE` with strict keys and refuses source +`--input`/`--override`/`--syminfo` on that path. Legacy identity bytes and the +existing live example are unchanged. + +The additional example is `runner/examples/native_market_strategy.cpp`. diff --git a/include/pineforge/compat/pine/exit_activation.hpp b/include/pineforge/compat/pine/exit_activation.hpp index c9ecfee56..4ce38272a 100644 --- a/include/pineforge/compat/pine/exit_activation.hpp +++ b/include/pineforge/compat/pine/exit_activation.hpp @@ -9,7 +9,7 @@ namespace pineforge { enum class PositionSide; -inline namespace engine_script_run_v11 { struct PendingOrder; } +inline namespace engine_script_run_v12 { struct PendingOrder; } } namespace pineforge::compat::pine { diff --git a/include/pineforge/compat/pine/exit_lifecycle.hpp b/include/pineforge/compat/pine/exit_lifecycle.hpp index a4c3ad8f9..a49f059b4 100644 --- a/include/pineforge/compat/pine/exit_lifecycle.hpp +++ b/include/pineforge/compat/pine/exit_lifecycle.hpp @@ -1,6 +1,6 @@ #pragma once #include "../../exit_leg_lifecycle.hpp" -namespace pineforge { inline namespace engine_script_run_v11 { struct PendingOrder; } } +namespace pineforge { inline namespace engine_script_run_v12 { struct PendingOrder; } } namespace pineforge::compat::pine { struct ExitSuspensionContext { exit_legs::Frame cause; diff --git a/include/pineforge/compat/pine/frozen_market_instruction.hpp b/include/pineforge/compat/pine/frozen_market_instruction.hpp index 1eca2195f..9d43f4f37 100644 --- a/include/pineforge/compat/pine/frozen_market_instruction.hpp +++ b/include/pineforge/compat/pine/frozen_market_instruction.hpp @@ -9,7 +9,7 @@ #include namespace pineforge { -inline namespace engine_script_run_v11 { struct PendingOrder; } +inline namespace engine_script_run_v12 { struct PendingOrder; } } namespace pineforge::compat::pine { diff --git a/include/pineforge/compat/pine/order_birth.hpp b/include/pineforge/compat/pine/order_birth.hpp index 47d7ad665..f18ff1e93 100644 --- a/include/pineforge/compat/pine/order_birth.hpp +++ b/include/pineforge/compat/pine/order_birth.hpp @@ -1,7 +1,7 @@ #pragma once #include "../../order_birth.hpp" -namespace pineforge { inline namespace engine_script_run_v11 { struct PendingOrder; } } +namespace pineforge { inline namespace engine_script_run_v12 { struct PendingOrder; } } namespace pineforge::compat::pine { // Historical Pine permissions remain policy, not physical birth facts. diff --git a/include/pineforge/compat/pine/order_priority.hpp b/include/pineforge/compat/pine/order_priority.hpp index ccf323def..36e107e65 100644 --- a/include/pineforge/compat/pine/order_priority.hpp +++ b/include/pineforge/compat/pine/order_priority.hpp @@ -5,7 +5,7 @@ #include #include -namespace pineforge { inline namespace engine_script_run_v11 { struct PendingOrder; } } +namespace pineforge { inline namespace engine_script_run_v12 { struct PendingOrder; } } namespace pineforge::compat::pine { struct OrderPriorityContext { diff --git a/include/pineforge/compat/pine/reservation_expansion.hpp b/include/pineforge/compat/pine/reservation_expansion.hpp index 006431254..2ce5dac09 100644 --- a/include/pineforge/compat/pine/reservation_expansion.hpp +++ b/include/pineforge/compat/pine/reservation_expansion.hpp @@ -4,7 +4,7 @@ #include namespace pineforge { enum class PositionSide; -inline namespace engine_script_run_v11 { struct PendingOrder; } +inline namespace engine_script_run_v12 { struct PendingOrder; } } namespace pineforge::compat::pine { // Called only for omitted explicit exit qty. Pine owns the population selector; diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 83b5cba13..bd35f95c9 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -7,12 +7,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include "na.hpp" #include "bar.hpp" #include "broker_events.hpp" @@ -426,7 +428,7 @@ enum class ShortSeedCollisionRole : uint8_t { // PendingOrder crosses out-of-line helper boundaries independently of the // engine class, so its changed layout must carry the same internal epoch. using ExitLegLifecycle = exit_legs::Lifecycle; -inline namespace engine_script_run_v11 { +inline namespace engine_script_run_v12 { struct PendingOrder { std::string id; std::string from_entry; // for exit orders @@ -954,7 +956,7 @@ inline bool placement_has_opposite_market_predecessor( return false; } - } // inline namespace engine_script_run_v11 (PendingOrder) + } // inline namespace engine_script_run_v12 (PendingOrder) // default_qty_type constants (matches TradingView) enum class QtyType { FIXED = 0, PERCENT_OF_EQUITY = 1, CASH = 2 }; @@ -1012,9 +1014,18 @@ struct StrategyOverrides { // v6 adds explicit owner-bound exit-leg activation and Pine placement evidence. // Version the mangled class name so older headers' member offsets/vtable cannot // silently bind out-of-line members of this different object layout. -inline namespace engine_script_run_v11 { +inline namespace engine_script_run_v12 { class BacktestEngine { protected: + friend class LegacyCompatibilityConsumer; + friend class NativeExecutionConsumer; + friend class NativeStrategyHost; + struct NativeConsumerBindTag { explicit NativeConsumerBindTag() = default; }; + explicit BacktestEngine(NativeConsumerBindTag, + compat::pine::CapAttachment cap_attachment = + compat::pine::CapAttachment::None); + IExecutionConsumer& execution_consumer(); + const IExecutionConsumer& execution_consumer() const; // --- Position state --- // @broker-state begin PositionSide position_side_ = PositionSide::FLAT; @@ -1809,6 +1820,15 @@ class BacktestEngine { execution::Result settle_execution_with_lifecycle( const execution::Action& action, const execution::Fill& fill, const execution::LifecycleEffects& lifecycle); + execution::Result settle_native_execution_at( + const execution::Action& action, const execution::Fill& fill, + const execution::PhysicalExecutionContext& context); + execution::Result settle_with_context( + const execution::Action& action, const execution::Fill& fill, + const execution::LifecycleEffects& lifecycle, + const execution::PhysicalExecutionContext& context); + execution::SettlementInspection inspect_native_settlement( + const execution::Action& action, const execution::Fill& fill) const; // Native account value: realized balance plus marked physical lots minus // their remaining paid entry costs, for every fee type. No Pine sizing or // end-of-range reporting convention participates in this value. @@ -4278,7 +4298,8 @@ class BacktestEngine { double fill_price, bool was_long) const; Trade build_close_trade_with_costs(const PyramidEntry& pe, double close_qty, double fill_price, bool was_long, double entry_commission, - double exit_commission) const; + double exit_commission, + const execution::PhysicalExecutionContext& context) const; // FIFO-drain up to qty_limit from pyramid_entries_, in order, splitting the // boundary entry as needed. When from_entry is non-null only entries whose // entry_id == *from_entry are eligible (others are kept untouched); null @@ -4496,21 +4517,79 @@ class BacktestEngine { void fill_security_diag_section(ReportC* out) const; void fill_trace_section(ReportC* out) const; + void guard_native_mutation(const char* operation); + void legacy_run_simple(const Bar* bars, int n); + void legacy_run_tf(const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist); + void legacy_run_rich(const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + const std::unordered_map& inputs, + const SymInfo& syminfo, + const StrategyOverrides* overrides, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist); + bool legacy_stream_begin(const Bar* warmup_bars, int n_warmup, + const std::string& input_tf, + const std::string& script_tf); + bool legacy_stream_push_bar(const Bar& bar); + bool legacy_stream_push_tick(const TradeTick& tick); + bool legacy_stream_push_ticks(const TradeTick* ticks, int n); + bool legacy_stream_advance_time(int64_t timestamp_ms); + bool legacy_stream_end(bool finalize_partial_input_bar); + + struct ExecutionConsumerSlot { + bool native = false; + mutable std::unique_ptr ptr; + ExecutionConsumerSlot() = default; + ExecutionConsumerSlot(const ExecutionConsumerSlot& other) noexcept + : native(other.native) {} + ExecutionConsumerSlot& operator=(const ExecutionConsumerSlot& other) noexcept { + if (this != &other) { + native = other.native; + ptr.reset(); + } + return *this; + } + ExecutionConsumerSlot(ExecutionConsumerSlot&& other) noexcept : native(other.native) { + other.ptr.reset(); + } + ExecutionConsumerSlot& operator=(ExecutionConsumerSlot&& other) noexcept { + if (this != &other) { + native = other.native; + ptr.reset(); + other.ptr.reset(); + } + return *this; + } + }; + ExecutionConsumerSlot execution_consumer_slot_; + public: explicit BacktestEngine(compat::pine::CapAttachment cap_attachment = - compat::pine::CapAttachment::None) - : max_intraday_filled_orders_(cap_attachment) {} + compat::pine::CapAttachment::None); // Explicit frontend selection, not a generic native risk switch. This // preserves any prior declaration values and does not reset quota/state. - void enable_pine_intraday_cap() { max_intraday_filled_orders_.attach(); } + void enable_pine_intraday_cap() { + guard_native_mutation("enable_pine_intraday_cap"); + max_intraday_filled_orders_.attach(); + } // Current execution-adapter scope: intraday cap + retained-parent priority. // Idempotent configuration attachment, not a reset or universal Pine mode. // Generated constructors call this before any host metadata is forwarded. void attach_pine_execution_adapter() { + guard_native_mutation("attach_pine_execution_adapter"); max_intraday_filled_orders_.attach(); pine_order_priority_.attach(); } - virtual ~BacktestEngine() = default; + virtual ~BacktestEngine(); + int execution_contract() const; + bool native_bound() const; virtual void on_bar(const Bar& bar) = 0; // All run() overloads preflight the entire chart array before modifying @@ -4657,10 +4736,15 @@ class BacktestEngine { // so get_input_*() lookups pick up the TV-tester value rather than the // Pine default. void set_input(const std::string& key, const std::string& value) { + guard_native_mutation("set_input"); inputs_[key] = value; } - void clear_inputs() { inputs_.clear(); } + void clear_inputs() { + guard_native_mutation("clear_inputs"); + inputs_.clear(); + } void set_trade_start_time(int64_t timestamp_ms) { + guard_native_mutation("set_trade_start_time"); trade_start_time_ = timestamp_ms; } @@ -4692,6 +4776,7 @@ class BacktestEngine { // * 2-arg function form ``hour(time, tz)``: honours the explicit // argument, unchanged by this fix. void set_chart_timezone(const std::string& tz) { + guard_native_mutation("set_chart_timezone"); chart_timezone_ = tz; } const std::string& chart_timezone() const { return chart_timezone_; } @@ -4705,15 +4790,24 @@ class BacktestEngine { // timezone through compat::pine::IntradayCap::risk_day(). Other risk-day rules keep // chart_timezone_, as do continuous/unconfigured order-counter clocks; // the existing crypto-on-shifted-chart contract therefore remains intact. - void set_syminfo_timezone(const std::string& tz) { syminfo_.timezone = tz; } - void set_syminfo_session(const std::string& s) { syminfo_.session = s; } + void set_syminfo_timezone(const std::string& tz) { + guard_native_mutation("set_syminfo_timezone"); + syminfo_.timezone = tz; + } + void set_syminfo_session(const std::string& s) { + guard_native_mutation("set_syminfo_session"); + syminfo_.session = s; + } // ``syminfo.type`` ("crypto" default; "forex" / "stock" / "futures" / // "index" / "fund" / "cfd" per TradingView). Scripts branch on it for // instrument conventions — the canonical one being the pip size // (``syminfo.type == "forex" ? 0.0001 : syminfo.mintick``), which on a // 5-digit FX symbol under the crypto default computed every pip-scaled // stop/target 10x too tight (finding 454). Empty is ignored. - void set_syminfo_type(const std::string& t) { if (!t.empty()) syminfo_.type = t; } + void set_syminfo_type(const std::string& t) { + guard_native_mutation("set_syminfo_type"); + if (!t.empty()) syminfo_.type = t; + } /// Whether TradingView's session template for this symbol carries the /// exchange's early closes and holidays, so a D/W/M request.security /// bucket completes on a shortened session's actual last chart bar @@ -4732,6 +4826,7 @@ class BacktestEngine { // volumetype / type). Unknown keys and empty values are ignored; returns // true when a field was set. bool set_syminfo_string(const std::string& key, const std::string& value) { + guard_native_mutation("set_syminfo_string"); if (value.empty()) return false; if (key == "type") { syminfo_.type = value; return true; } if (key == "ticker") { syminfo_.ticker = value; return true; } @@ -4750,14 +4845,24 @@ class BacktestEngine { // commission notionals, margin check — see tests/test_pointvalue.cpp). // Both default to crypto/equity values (0.01 / 1.0) and only matter when the // harness sets a non-default instrument. - void set_syminfo_mintick(double m) { if (m > 0.0) { syminfo_.mintick = m; syminfo_mintick_ = m; } } - void set_syminfo_pointvalue(double pv) { if (pv > 0.0) { syminfo_.pointvalue = pv; } } + void set_syminfo_mintick(double m) { + guard_native_mutation("set_syminfo_mintick"); + if (m > 0.0) { syminfo_.mintick = m; syminfo_mintick_ = m; } + } + void set_syminfo_pointvalue(double pv) { + guard_native_mutation("set_syminfo_pointvalue"); + if (pv > 0.0) { syminfo_.pointvalue = pv; } + } // Toggle TradingView's forced-liquidation (margin call) emulation. Defaults // ON to match TV; set false for the legacy hold-the-position behaviour. - void set_margin_call_enabled(bool enabled) { margin_call_enabled_ = enabled; } + void set_margin_call_enabled(bool enabled) { + guard_native_mutation("set_margin_call_enabled"); + margin_call_enabled_ = enabled; + } bool margin_call_enabled() const { return margin_call_enabled_; } void set_syminfo_metadata(const std::string& key, double value) { + guard_native_mutation("set_syminfo_metadata"); syminfo_metadata_[key] = value; // Pine's public bar_index is chart-history relative. Validation feeds // can start after TradingView's hidden first chart bar, while engine @@ -4874,6 +4979,7 @@ class BacktestEngine { // range-end close row/trade. Default off: every historical run is // byte-identical to before this flag existed. void set_realtime_tail(bool on, int horizon_bars) { + guard_native_mutation("set_realtime_tail"); realtime_tail_ = on; realtime_tail_horizon_bars_ = horizon_bars; } @@ -4900,6 +5006,7 @@ class BacktestEngine { // Default off (@p on == 0): every historical run stays byte-identical to // before this flag existed. void set_probe_suppress_tail_logic(bool on) { + guard_native_mutation("set_probe_suppress_tail_logic"); probe_suppress_tail_logic_ = on; } bool probe_suppress_tail_logic() const { return probe_suppress_tail_logic_; } @@ -4922,6 +5029,7 @@ class BacktestEngine { // Default AUTO (mode=0): every historical run stays byte-identical to // before this flag existed. void set_path_order(int mode) { + guard_native_mutation("set_path_order"); path_order_mode_ = (mode == 1 || mode == 2) ? mode : 0; } @@ -5116,12 +5224,14 @@ class BacktestEngine { // set_realtime_tail, so it must be set BEFORE stream_begin to also // cover the warmup bars. void set_broker_state_hash_recording(bool on) { + guard_native_mutation("set_broker_state_hash_recording"); broker_state_hash_recording_ = on; } // Toggle volume-weighted per-sub-bar sampling inside run_magnified_bar. // Has no effect unless bar magnifier is enabled. void set_magnifier_volume_weighted(bool on) { + guard_native_mutation("set_magnifier_volume_weighted"); magnifier_volume_weighted_ = on; } @@ -5130,7 +5240,10 @@ class BacktestEngine { // flips this on per-strategy via ``strategy_set_trace_enabled`` (the // FFI shim defined in c_abi.cpp) before running a backtest whose // per-bar values it wants to cross-reference against TradingView. - void set_trace_enabled(bool on) { trace_enabled_ = on; } + void set_trace_enabled(bool on) { + guard_native_mutation("set_trace_enabled"); + trace_enabled_ = on; + } bool trace_enabled() const { return trace_enabled_; } // --- Live-runtime status API (ABI v4) --- @@ -5155,5 +5268,5 @@ class BacktestEngine { void trace(const std::string& name, int value) { trace(name, static_cast(value)); } }; -} // inline namespace engine_script_run_v11 +} // inline namespace engine_script_run_v12 } // namespace pineforge diff --git a/include/pineforge/execution.hpp b/include/pineforge/execution.hpp index 6902963eb..c79bed080 100644 --- a/include/pineforge/execution.hpp +++ b/include/pineforge/execution.hpp @@ -2,6 +2,7 @@ #include "exit_leg_lifecycle.hpp" #include "order_action.hpp" +#include #include #include #include @@ -41,6 +42,35 @@ struct Result { // authoritative. Opening units are signed; closing units are nonnegative. double closed_units = 0.0; double opened_units = 0.0; + // This execution's current ticket and committed-row references. Zero when + // the result is not Applied. Native terminal events copy these facts. + double current_ticket = 0.0; + std::size_t first_trade_index = 0; + std::size_t closed_trade_count = 0; + uint64_t opened_lot_incarnation = 0; +}; + +// Stack-bound physical coordinates for one settlement. Native supplies time +// and index from the matching point and leaves both optionals empty. Legacy +// callers copy current_bar_/bar_index_ and the current fold flags. +struct PhysicalExecutionContext { + int64_t effective_time_ms = 0; + int interval_index = 0; + std::optional preceding_exit_path_prefix; + std::optional preceding_exit_trail_peak; +}; + +// Stack-local inspect facts. Destroyed at the end of one matching step. +struct SettlementInspection { + Status status = Status::NoEffect; + double closed_units = 0.0; + double opened_units = 0.0; + double resulting_abs_units = 0.0; + std::size_t resulting_lot_count = 0; + double resulting_abs_notional = 0.0; + double current_ticket = 0.0; + bool would_open = false; + bool incoming_short = false; }; // One pre-close operation on an exact pending identity. created_seq 0 and diff --git a/include/pineforge/execution_consumer.hpp b/include/pineforge/execution_consumer.hpp new file mode 100644 index 000000000..adf6776fa --- /dev/null +++ b/include/pineforge/execution_consumer.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace pineforge { + +struct SymInfo; +struct StrategyOverrides; + +inline namespace engine_script_run_v12 { + +class BacktestEngine; + +// Constructor-bound execution consumer. LegacyCompatibilityConsumer preserves +// the existing Pine call order. NativeExecutionConsumer owns native lifecycle, +// matching and the sole native timeline allocator. Neither is copyable. +class IExecutionConsumer { +public: + IExecutionConsumer() = default; + virtual ~IExecutionConsumer() = default; + IExecutionConsumer(const IExecutionConsumer&) = delete; + IExecutionConsumer& operator=(const IExecutionConsumer&) = delete; + IExecutionConsumer(IExecutionConsumer&&) = delete; + IExecutionConsumer& operator=(IExecutionConsumer&&) = delete; + + virtual bool is_native() const noexcept = 0; + virtual void refuse_source_mutation(const char* operation) = 0; + virtual uint64_t continuation_hash() const noexcept = 0; + + virtual void run_simple(BacktestEngine& engine, const Bar* bars, int n) = 0; + virtual void run_tf(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) = 0; + virtual void run_rich(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + const std::unordered_map& inputs, + const SymInfo& syminfo, + const StrategyOverrides* overrides, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) = 0; + + virtual bool stream_begin(BacktestEngine& engine, + const Bar* warmup_bars, int n_warmup, + const std::string& input_tf, + const std::string& script_tf) = 0; + virtual bool stream_push_bar(BacktestEngine& engine, const Bar& bar) = 0; + virtual bool stream_push_tick(BacktestEngine& engine, const TradeTick& tick) = 0; + virtual bool stream_push_ticks(BacktestEngine& engine, const TradeTick* ticks, int n) = 0; + virtual bool stream_advance_time(BacktestEngine& engine, int64_t timestamp_ms) = 0; + virtual bool stream_end(BacktestEngine& engine, bool finalize_partial_input_bar) = 0; +}; + +std::unique_ptr make_legacy_execution_consumer(); +std::unique_ptr make_native_execution_consumer(); + +} // inline namespace engine_script_run_v12 +} // namespace pineforge diff --git a/include/pineforge/market_driver.hpp b/include/pineforge/market_driver.hpp new file mode 100644 index 000000000..d46beab6a --- /dev/null +++ b/include/pineforge/market_driver.hpp @@ -0,0 +1,158 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace pineforge { +inline namespace native_run_spec_v1 { struct NativeRunSpec; } +inline namespace native_driver_v3 { + +// Semantic versions hashed into native continuation identity. +inline constexpr const char* kNativeDriverSemanticVersion = "native-driver/v3"; +inline constexpr const char* kNativeConsumerSemanticVersion = "native-consumer/v3"; +inline constexpr const char* kNativeCalendarSemanticVersion = "native-calendar/v1"; + +enum class NativePriceProvenance : std::uint8_t { + Confirmed = 0, + ObservedPrint = 1, + ModeledOHLCOpen = 2, + ModeledOHLCClose = 3, + CarriedOpen = 4, + AfterCalculationClose = 5, + PartialFinalized = 6, + Calculation = 7, +}; + +enum class NativePathPhase : std::uint8_t { + None = 0, + Open = 1, + High = 2, + Low = 3, + Close = 4, +}; + +enum class NativeCompletionKind : std::uint8_t { + Confirmed = 0, + LazyComplete = 1, + SessionShortened = 2, + PartialFinalized = 3, +}; + +struct NativeCoordinate { + uint64_t ordinal = 0; + int interval_index = 0; + int64_t open_ms = 0; + int64_t eligible_open_ms = 0; + int64_t last_traded_close_ms = 0; + int64_t next_period_open_ms = 0; + int64_t next_input_open_ms = 0; + int64_t effective_time_ms = 0; + int64_t source_price_time_ms = 0; + NativePriceProvenance provenance = NativePriceProvenance::Confirmed; + NativePathPhase path_phase = NativePathPhase::None; + NativeCompletionKind completion = NativeCompletionKind::Confirmed; +}; + +struct NativeDriverPoint { + NativeCoordinate coordinate; + double raw_price = 0.0; + std::optional sequence; + bool matching = false; + bool excursion = false; +}; + +// Presentation snapshot copied onto the callback stack. Mutating these +// fields cannot change the consumer's decision floor, matching time, or +// after-calculation coordinate. +struct NativeDecisionContext { + NativeCoordinate coordinate; + int64_t decision_floor_ms = 0; + native_calendar::NativeInterval input_interval{}; + native_calendar::NativeInterval script_interval{}; +}; + +// Pump-produced events obtain ordinals from the consumer allocator. +class INativeDriverSink { +public: + virtual ~INativeDriverSink() = default; + virtual uint64_t allocate_native_ordinal() = 0; + virtual void on_native_matching_point(const NativeDriverPoint& point) = 0; + virtual void on_native_excursion(const NativeDriverPoint& point) = 0; + virtual void on_native_calculation(const Bar& script_bar, const NativeCoordinate& coordinate) = 0; +}; + +bool native_bar_structurally_valid(const Bar& bar) noexcept; + +// Confirmed-bar labels admitted by v9: the immutable nominal slot origin +// and the scheduled clipped eligible opening. Both identify one slot key +// `interval.open_ms`. Mid-slot and other off-grid timestamps are refused. +inline bool native_confirmed_bar_label_admitted( + const native_calendar::NativeInterval& interval, int64_t timestamp) noexcept { + return timestamp == interval.open_ms || timestamp == interval.eligible_open_ms; +} + +// Canonical exclusive completion of a confirmed input slot. Source-price +// time remains a separate fact (bar.timestamp / last print). +inline int64_t native_canonical_input_completion( + const native_calendar::NativeInterval& interval) noexcept { + return interval.next_period_open_ms; +} + +// --------------------------------------------------------------------------- +// Shared pure input preflight (host + runner) +// +// Exact signature the runner worker should bind: +// NativeInputPreflightResult +// preflight_native_inputs(const NativeRunSpec& spec, +// const Bar* bars, +// int n, +// NativeInputPolicy policy); +// +// Calendar-aware, allocation of diagnostic text is the caller's job. +// No host reset, callback, identity mutation, high-water change, or +// decision-floor change. Parses the spec's session/timezone/input_tf +// on each call. Batch may be sparse; StreamWarmup refuses missing +// in-session bars after a closed gap by walking next_input_open_ms +// through interval_containing (Fri 16 -> Mon 11 cannot skip Mon 09:30). +// Warmup complete-script policy is NOT decided here. +// --------------------------------------------------------------------------- +enum class NativeInputPolicy : std::uint8_t { + Batch = 0, + StreamWarmup = 1, +}; + +enum class NativeInputPreflightError : std::uint16_t { + None = 0, + NullArray = 1, + InvalidCount = 2, + StructuralInvalid = 3, + Unaligned = 4, + OffGridLabel = 5, + NotStrictlyIncreasing = 6, + OverlappingSlot = 7, + InSessionGap = 8, + CalendarFailure = 9, +}; + +struct NativeInputPreflightResult { + NativeInputPreflightError error = NativeInputPreflightError::None; + int index = -1; // first offending bar, or -1 when not index-specific + + constexpr bool ok() const noexcept { + return error == NativeInputPreflightError::None; + } + constexpr explicit operator bool() const noexcept { return ok(); } +}; + +NativeInputPreflightResult preflight_native_inputs( + const NativeRunSpec& spec, + const Bar* bars, + int n, + NativeInputPolicy policy); + +} // inline namespace native_driver_v3 +} // namespace pineforge diff --git a/include/pineforge/native_calendar.hpp b/include/pineforge/native_calendar.hpp new file mode 100644 index 000000000..742ecfabc --- /dev/null +++ b/include/pineforge/native_calendar.hpp @@ -0,0 +1,415 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace pineforge::native_calendar { +inline namespace native_calendar_v2 { + +// Isolated calendar / interval values for a later native host. This component +// does not match orders, emit strategy callbacks, aggregate OHLCV, or run a +// NativeRunSpec. Legacy helpers in session_time.cpp / timeframe.cpp stay +// unchanged and are not native authority. + +// --------------------------------------------------------------------------- +// Timeframe +// --------------------------------------------------------------------------- +// +// Grammar: optional count (>= 1, no leading zeros) then a unit suffix. +// nS seconds "15S" +// n minutes "1", "5", "60", "240" +// D / nD "D", "1D", "2D" +// W / nW "W", "1W", "2W" +// M / nM "M", "1M", "3M" +// Literal is identity: "1D" is not rewritten to "D". +// +// Rejected before any conversion: empty, whitespace, leading zeros ("01", +// "01D"), bare "S", unknown suffix including "1H"/"4H" (legacy stoi("1H")==1 +// minute is not copied), overflowed digit strings, count whose unit duration +// is not representable as int64 milliseconds. + +enum class TimeframeUnit { Second, Minute, Day, Week, Month }; + +// Parsed timeframe. Default, moved-from, and any object that is not the +// result of a successful parse is invalid. Fields are immutable; there is +// no public constructor that can inject a zero count or a mismatched +// literal. Copy preserves validity; move leaves the source invalid. +class Timeframe { +public: + Timeframe() = default; + Timeframe(const Timeframe&) = default; + Timeframe& operator=(const Timeframe&) = default; + Timeframe(Timeframe&& other) noexcept { *this = std::move(other); } + Timeframe& operator=(Timeframe&& other) noexcept { + if (this == &other) return *this; + unit_ = other.unit_; + count_ = other.count_; + literal_ = std::move(other.literal_); + valid_ = other.valid_; + other.valid_ = false; + other.count_ = 0; + other.literal_.clear(); + return *this; + } + + bool valid() const noexcept { return valid_ && count_ >= 1 && unit_ok(unit_); } + TimeframeUnit unit() const noexcept { return unit_; } + int count() const noexcept { return count_; } + const std::string& literal() const noexcept { return literal_; } + bool is_fixed() const noexcept { + return unit_ == TimeframeUnit::Second || unit_ == TimeframeUnit::Minute; + } + bool is_calendar() const noexcept { return valid() && !is_fixed(); } + +private: + friend std::optional parse_timeframe(std::string_view); + friend bool operator==(const Timeframe&, const Timeframe&) noexcept; + static bool unit_ok(TimeframeUnit u) noexcept { + switch (u) { + case TimeframeUnit::Second: + case TimeframeUnit::Minute: + case TimeframeUnit::Day: + case TimeframeUnit::Week: + case TimeframeUnit::Month: + return true; + } + return false; + } + Timeframe(TimeframeUnit unit, int count, std::string literal) + : unit_(unit), count_(count), literal_(std::move(literal)), valid_(true) {} + TimeframeUnit unit_ = TimeframeUnit::Minute; + int count_ = 0; + std::string literal_; + bool valid_ = false; +}; + +inline bool operator==(const Timeframe& a, const Timeframe& b) noexcept { + return a.valid() == b.valid() && a.unit_ == b.unit_ && a.count_ == b.count_ + && a.literal_ == b.literal_; +} + +std::optional parse_timeframe(std::string_view text); + +// Pairing is a classification only. Equal-TF passthrough versus grouping is +// the host's choice; this component does not drop 2D/2W/M/3M and does not +// collapse count the way calendar_period_for(last letter) does. +enum class TimeframePairing { + Passthrough, // byte-identical literals + SameUnitMultiple, // same unit, script.count % input.count == 0, script >= input + FixedDivisible, // both fixed, script ms % input ms == 0 (e.g. 15S -> 1) + FixedToCalendar, // second/minute -> D/W/M + CalendarToCalendar, // D/nD -> W/M, W/nW -> M + ScriptFiner, // script strictly finer than input + IndivisibleFixed, // fixed remainder != 0 (5 -> 7) or same-unit non-multiple + StreamMonthlyInputRefused, + Invalid, // one or both timeframes are not valid parsed values +}; + +struct TimeframeCompatibility { + TimeframePairing pairing = TimeframePairing::Invalid; + int64_t group_factor = 0; // script/input count or ms ratio; 0 if not a ratio pairing +}; + +TimeframeCompatibility compatibility(const Timeframe& input, const Timeframe& script); + +// Stream input M/nM is the existing refusal (engine_stream requires a fixed +// positive duration). Batch monthly remains accepted via compatibility(). +TimeframeCompatibility stream_compatibility(const Timeframe& input, const Timeframe& script); + +// --------------------------------------------------------------------------- +// Session / calendar definition +// --------------------------------------------------------------------------- +// +// Currently valid session syntax, parsed once into an immutable value: +// empty or "24x7" all-day every day, origin 00:00 +// HHMM-HHMM one window; 2400 is a legal end +// HHMM-HHMM,HHMM-HHMM comma-separated union +// start == end 24h wrap from that clock (1700-1700) +// end < start overnight wrap (1800-1700, 2200-0200) +// :1234567 day mask, 1=Sun .. 7=Sat, after the windows +// +// Overnight / day-mask interpretation (generic; no stock/forex recognizer): +// A wrapped or 24h-from-nonzero window is one session-day. The day mask +// applies to that session-day's trading date — the local civil date of +// last_traded_close_ms - 1ms — not to each timestamp's weekday. Sunday +// 18:00 of an 1800-1700 session with mask :23456 (Mon-Fri) is therefore +// Monday's session, not a Sunday reject. +// +// Normalized schedule: the first declared start O fixes civil cycles +// [O(date), O(date+1)). Recurring instances of every window (including wrap +// and equal-start full-day) are built on that date and its neighbors, +// intersected with the cycle, clipped at both cycle ends, then unioned. +// A start earlier than O can contribute coverage on BOTH sides of the cycle +// boundary (overlapping 1300-1600,0900-1500); merely shifting every earlier +// start one day is not sufficient. That split is schedule normalization, +// not OHLC splitting. Origin stays the first declared start; permuting later +// windows cannot change the union, while changing the first window can +// change origin and trading-date assignment. +// +// Trading date is the civil date of the final nominal UNMASKED recurring +// coverage endpoint minus one instant, computed in civil minutes before +// weekday masks and before UTC DST-gap collapse. A rejected mask or empty +// resolved window never moves that date onto the next live cycle; there is +// no cycle-end-next-day fallback. Inverse lookup matches this date only, so +// an empty Sunday cannot alias Monday. Empty resolved/allowed coverage has +// no membership or synthesis; D/W/M bounds still contain the query +// (open <= query < next_period_open). A DST gap that maps both ends of one +// instance to the same epoch omits that instance only. +// +// Native origin is that first-window start. Pine's 17:00 day stamp on an +// 1800-1700 session is a compatibility label, not this component's default. +// +// Fold tie-break (civil -> epoch): a repeated local civil time maps to the +// earlier UTC occurrence (first time through). Adjacent intervals that share +// a civil boundary reuse that same epoch, so conversion cannot invent a gap +// or overlap. A missing local time (DST gap) maps to the first representable +// instant at or after the declared civil. Conversion does not copy tm_isdst +// from a query instant and does not use a UTC-day offset cache as authority. + +class SessionCalendar; +std::optional parse_session(std::string_view session, + std::string_view timezone); + +// Single calendar-owned timezone acceptance boundary. Empty is accepted and +// means UTC for this component (native full-spec nonempty scheduling TZ is +// separate). Also used by public resolve_civil. Not for hot interval loops. +// +// Accepted: +// IANA/TZif names and aliases installed under libc's effective zoneinfo +// root (not a search of trees libc will not read): +// charset [A-Za-z0-9/_+-], no absolute path, no `.` / `..` components; +// realpath stays in that root and the file magic is TZif. Leading `:` +// is a tzfile reference only. +// macOS: /var/db/timezone/zoneinfo (TZDIR ignored; tzset(3) does not +// use it). glibc: nonempty TZDIR is exclusive (relative allowed; +// missing/non-directory fails closed). Unset/empty TZDIR uses +// /usr/share/zoneinfo. +// UTC, GMT, Etc/UTC, Etc/GMT +// UTC/GMT conventional offsets (TV sign): +// (UTC|GMT)[+-](H|HH|HMM|HHMM|H:MM|HH:MM) hours 0–23, minutes 0–59; +// digit length is bounded before conversion so overflow cannot escape. +// POSIX TZ, including DST rule forms: +// std offset [dst[offset][,start[/time],end[/time]]] +// A slash before any comma is a tzfile path; slash after a comma is +// POSIX rule time (M3.2.0/2), not an IANA name. +// Rejected before libc setenv/mktime: unknown names, UTC+24:00 / UTC+01:99, +// overflowed digit strings, whitespace/control/NUL, absolute paths, `..` +// traversal, and non-TZif zoneinfo files (zone.tab, iso3166.tab, +VERSION). +bool timezone_accepted(std::string_view timezone); + +// Observational identity for the native runner (setup/identity time only). +// Shares the acceptance/normalization/effective-root resolver. Does not hash. +// Semantics version 1. nullopt = backing facts could not be established; +// the runner must refuse. Does not relax timezone_accepted. +enum class TimezoneSourceKind : std::uint32_t { + Utc = 1, + FixedOffset = 2, + PosixExplicit = 3, + PosixDefaultDst = 4, + Tzfile = 5, +}; + +struct TimezoneIdentityDescriptor { + static constexpr std::uint32_t kSemanticsVersion = 1; + std::uint32_t semantics_version = 0; + TimezoneSourceKind kind = TimezoneSourceKind::Utc; + std::string input; + std::string effective_definition; + std::string zoneinfo_root; + std::vector resource_paths; + bool valid() const noexcept; +}; + +std::optional +timezone_identity_descriptor(std::string_view timezone); + +class SessionWindow { +public: + SessionWindow() = default; + bool valid() const noexcept { + if (start_minutes_ < 0 || start_minutes_ >= 1440) return false; + if (end_minutes_ < 0 || end_minutes_ > 1440) return false; + const bool expect_full = (end_minutes_ != 1440 && start_minutes_ == end_minutes_); + const bool expect_wrap = (!expect_full && end_minutes_ < start_minutes_); + return full_day_ == expect_full && wraps_ == expect_wrap; + } + int start_minutes() const noexcept { return start_minutes_; } + int end_minutes() const noexcept { return end_minutes_; } + bool wraps() const noexcept { return wraps_; } + bool full_day() const noexcept { return full_day_; } + +private: + friend std::optional parse_session(std::string_view, std::string_view); + SessionWindow(int start, int end, bool wraps, bool full_day) + : start_minutes_(start), end_minutes_(end), wraps_(wraps), full_day_(full_day) {} + int start_minutes_ = 0; // [0, 1440) + int end_minutes_ = 0; // (0, 1440]; 1440 = 24:00 + bool wraps_ = false; + bool full_day_ = false; +}; + +// Parsed session. Default and moved-from objects are invalid. Windows, mask, +// origin and flags are immutable after a successful parse. 2400 is stored as +// end_minutes 1440; cyclic coverage is computed internally and is not a +// public field that can be set to 25:00. +class SessionCalendar { +public: + SessionCalendar() = default; + SessionCalendar(const SessionCalendar&) = default; + SessionCalendar& operator=(const SessionCalendar&) = default; + SessionCalendar(SessionCalendar&& other) noexcept { *this = std::move(other); } + SessionCalendar& operator=(SessionCalendar&& other) noexcept { + if (this == &other) return *this; + timezone_ = std::move(other.timezone_); + literal_ = std::move(other.literal_); + windows_ = std::move(other.windows_); + day_mask_ = other.day_mask_; + origin_minutes_ = other.origin_minutes_; + all_day_ = other.all_day_; + valid_ = other.valid_; + other.valid_ = false; + other.day_mask_ = 0; + other.origin_minutes_ = 0; + other.all_day_ = false; + other.timezone_.clear(); + other.literal_.clear(); + other.windows_.clear(); + return *this; + } + + bool valid() const noexcept; + const std::string& timezone() const noexcept { return timezone_; } + const std::string& literal() const noexcept { return literal_; } + const std::vector& windows() const noexcept { return windows_; } + std::uint8_t day_mask() const noexcept { return day_mask_; } + int origin_minutes() const noexcept { return origin_minutes_; } + bool all_day() const noexcept { return all_day_; } + +private: + friend std::optional parse_session(std::string_view, std::string_view); + SessionCalendar(std::string timezone, + std::string literal, + std::vector windows, + std::uint8_t day_mask, + int origin_minutes, + bool all_day) + : timezone_(std::move(timezone)), + literal_(std::move(literal)), + windows_(std::move(windows)), + day_mask_(day_mask), + origin_minutes_(origin_minutes), + all_day_(all_day), + valid_(true) {} + std::string timezone_; + std::string literal_; + std::vector windows_; + std::uint8_t day_mask_ = 0; + int origin_minutes_ = 0; + bool all_day_ = false; + bool valid_ = false; +}; + +std::optional parse_session(std::string_view session, + std::string_view timezone); + +bool in_session(const SessionCalendar& calendar, int64_t ms); + +// --------------------------------------------------------------------------- +// Civil conversion +// --------------------------------------------------------------------------- + +enum class CivilKind { Unique, Gap, Fold }; + +struct CivilResolution { + int64_t epoch_ms = 0; + CivilKind kind = CivilKind::Unique; +}; + +// Resolve (timezone, civil) to a Unix-ms epoch. Invalid calendar dates and +// out-of-range fields return nullopt. Fold -> earlier UTC; gap -> first +// representable instant >= declared. Independent of prior queries. +std::optional resolve_civil(std::string_view timezone, + int year, + int month, + int day, + int hour, + int minute, + int second = 0); + +// --------------------------------------------------------------------------- +// Period keys (stable, independent of the first supplied sample) +// --------------------------------------------------------------------------- +// +// Session-day ordinal: Howard Hinnant days_from_civil of the session-day's +// trading date. Day 0 is 1970-01-01. Dates before that are negative; nD uses +// floor division toward -inf: key = floor(ordinal / n) * n. +// +// Session-week origin: Monday 1969-12-29, the Monday of the week containing +// 1970-01-01. Week index 0 is that week. nW key = floor(week_index / n) * n. +// An overnight session whose trading date is Monday opens at the previous +// civil day's first-window start (Sunday 18:00 for 1800-1700) — derived from +// the windows, not from a forex special case. +// +// Session-month ordinal: year * 12 + (month - 1) of the trading date. +// 3M groups Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec (January remainder 0). +// Dates before year 0 continue with floor division toward -inf. +// +// Incomplete leading input does not move these anchors. + +std::optional session_day_ordinal(const SessionCalendar& calendar, int64_t ms); +std::optional session_week_ordinal(const SessionCalendar& calendar, int64_t ms); +std::optional session_month_ordinal(const SessionCalendar& calendar, int64_t ms); +std::optional period_key(const SessionCalendar& calendar, const Timeframe& tf, int64_t ms); + +// --------------------------------------------------------------------------- +// Interval +// --------------------------------------------------------------------------- +// +// open_ms nominal origin of this interval (session origin or +// fixed-grid anchor from the declared first-window +// origin). A split-session reopen can fall after this +// anchor (hourly grid 12:30 vs 13:00 lunch reopen). +// eligible_open_ms first in-session instant of this interval; equal to +// open_ms when the nominal anchor is itself eligible. +// last_traded_close_ms exclusive end of trading in this interval +// next_period_open_ms nominal open of the next interval of this (unit, +// count), including a closed grid slot +// next_input_open_ms next actual eligible input opening at or after +// last_traded_close_ms; skips declared closed time +// (13:00, not a closed 12:30). Not a resolution- +// stepped cursor; it jumps to the next normalized +// span. Off-session observed ticks are a later host +// concern and are not forced through this field. +// +// RTH Friday 16:00 last-traded close is not Monday 09:30. Those are separate +// fields. Fixed buckets add elapsed UTC seconds/minutes from the session-day +// origin and clip to the union of windows. Calendar periods use civil +// session-day / week / month boundaries and every session window. +// +// A timestamp in a declared closed gap still belongs to the period whose +// [open, next period open) covers it; last_traded_close may then precede it. + +struct NativeInterval { + int64_t open_ms = 0; + int64_t eligible_open_ms = 0; + int64_t last_traded_close_ms = 0; + int64_t next_period_open_ms = 0; + int64_t next_input_open_ms = 0; +}; + +// Uses tf as both the period unit and the input unit. +std::optional interval_containing(const SessionCalendar& calendar, + const Timeframe& tf, + int64_t ms); + +std::optional interval_containing(const SessionCalendar& calendar, + const Timeframe& script_tf, + const Timeframe& input_tf, + int64_t ms); + +} // inline namespace native_calendar_v2 +} // namespace pineforge::native_calendar diff --git a/include/pineforge/native_host.hpp b/include/pineforge/native_host.hpp new file mode 100644 index 000000000..5fe94d129 --- /dev/null +++ b/include/pineforge/native_host.hpp @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge { +inline namespace engine_script_run_v12 { + +enum class NativeLifecycleKind : std::uint8_t { + Unconfigured = 0, + Ready = 1, + Running = 2, + Completed = 3, + Failed = 4, +}; + +enum class NativeRunPhase : std::uint8_t { + Batch = 0, + Warmup = 1, + Realtime = 2, +}; + +enum class NativeCompletion : std::uint8_t { + BatchComplete = 0, + StreamEnded = 1, +}; + +enum class NativeFailureCode : std::uint16_t { + None = 0, + InvalidSpecification = 1, + Contract = 2, + Preflight = 3, + UnsupportedSource = 4, + CallbackException = 5, + SettlementFailure = 6, + Allocation = 7, + CounterExhausted = 8, + Aborted = 9, + ProjectionMismatch = 10, + Calendar = 11, + Unexpected = 12, +}; + +enum class NativeFailureOperation : std::uint16_t { + None = 0, + Configure = 1, + Begin = 2, + Command = 3, + Input = 4, + Callback = 5, + Settlement = 6, + Mutation = 7, + Stream = 8, +}; + +struct NativeFailure { + NativeFailureCode code = NativeFailureCode::None; + NativeFailureOperation operation = NativeFailureOperation::None; + std::uint64_t ordinal = 0; // 0 = absent + std::uint32_t discriminator = 0; +}; + +struct NativeUnconfigured {}; +struct NativeReady { NativeRunSpec spec; }; +struct NativeRunning { NativeRunSpec spec; NativeRunPhase phase = NativeRunPhase::Batch; }; +struct NativeCompleted { NativeRunSpec spec; NativeCompletion completion = NativeCompletion::BatchComplete; }; +struct NativeFailed { std::optional spec; NativeFailure failure; }; + +using NativeLifecycle = std::variant; + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); + +struct NativeStateView { + NativeLifecycleKind kind = NativeLifecycleKind::Unconfigured; + const NativeRunSpec* spec = nullptr; + NativeRunPhase phase = NativeRunPhase::Batch; + NativeCompletion completion = NativeCompletion::BatchComplete; + NativeFailure failure{}; + uint64_t consumed_high_water = 0; + int64_t decision_floor_ms = 0; +}; + +struct NativePhysicalPosition { + double signed_units = 0.0; + double average_price = 0.0; + std::size_t lot_count = 0; +}; + +struct NativeAccountObservation { + uint64_t ordinal = 0; + int64_t effective_time_ms = 0; + double marked_equity = 0.0; + double realized_balance = 0.0; + double signed_units = 0.0; +}; + +enum class NativeEventKind : std::uint8_t { + Command = 0, + Driver = 1, + Account = 2, +}; + +struct NativeMarketEvent { + NativeEventKind kind = NativeEventKind::Command; + uint64_t ordinal = 0; + std::optional command; + std::optional driver; + std::optional account; +}; + +enum class NativeSetupStatus : std::uint8_t { Applied = 0, Failed = 1 }; + +struct NativeSetupResult { + NativeSetupStatus status = NativeSetupStatus::Failed; + NativeRunSpecValidation validation{}; +}; + +// 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 +// linkage cannot resolve an unversioned constructor against a different +// base layout. +#define PINEFORGE_HAS_NATIVE_STRATEGY_HOST_V12 1 +class NativeStrategyHost : public BacktestEngine { +public: + NativeStrategyHost(); + NativeStrategyHost(const NativeStrategyHost&) = delete; + NativeStrategyHost& operator=(const NativeStrategyHost&) = delete; + NativeStrategyHost(NativeStrategyHost&&) = delete; + NativeStrategyHost& operator=(NativeStrategyHost&&) = delete; + ~NativeStrategyHost() override; + + void on_bar(const Bar& bar) final; + + virtual void on_native_run_begin() {} + virtual void on_native_bar(const Bar& bar, const NativeDecisionContext& context) = 0; + + NativeSetupResult configure_native(const NativeRunSpec& spec); + NativeStateView native_state() const; + + native_order::SubmitResult submit_market(const native_order::Request& request); + native_order::ReplaceResult replace_market(const native_order::RequestHandle& target, + const native_order::Request& request); + native_order::CancelResult cancel(const native_order::RequestHandle& target); + + NativePhysicalPosition physical_position() const; + double native_marked_equity(double mark) 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; + int64_t native_decision_floor() const; + uint64_t native_consumed_high_water() const; + uint64_t native_continuation_hash() const; + + friend class NativeExecutionConsumer; +}; + +} // inline namespace engine_script_run_v12 +} // namespace pineforge diff --git a/include/pineforge/native_order.hpp b/include/pineforge/native_order.hpp new file mode 100644 index 000000000..e042d10bb --- /dev/null +++ b/include/pineforge/native_order.hpp @@ -0,0 +1,311 @@ +#pragma once + +#include "execution.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge::native_order { +inline namespace native_order_v1 { + +// Isolated working-request/value core: current LIVE requests and immutable +// command history. It does not own positions, cash, paid fees, matching, +// calendar, host phase, or a second physical book. + +struct RunIdentity { + std::string session_key; + uint64_t run_number = 0; +}; + +inline bool operator==(const RunIdentity& a, const RunIdentity& b) { + return a.run_number == b.run_number && a.session_key == b.session_key; +} +inline bool operator!=(const RunIdentity& a, const RunIdentity& b) { return !(a == b); } + +struct RequestHandle { + RunIdentity run; + uint64_t incarnation = 0; +}; + +inline bool operator==(const RequestHandle& a, const RequestHandle& b) { + return a.incarnation == b.incarnation && a.run == b.run; +} +inline bool operator!=(const RequestHandle& a, const RequestHandle& b) { return !(a == b); } + +struct Request { + execution::Action action; + std::string label; + std::string comment; +}; + +struct Birth { + uint64_t acceptance_ordinal = 0; + int64_t decision_time_lower_bound = 0; +}; + +inline bool operator==(const Birth& a, const Birth& b) { + return a.acceptance_ordinal == b.acceptance_ordinal + && a.decision_time_lower_bound == b.decision_time_lower_bound; +} + +struct LiveRequest { + RequestHandle handle; + Request request; + Birth birth; + std::optional predecessor; +}; + +inline bool point_eligible(const Birth& birth, + uint64_t point_ordinal, + int64_t effective_time_ms) noexcept { + return point_ordinal > birth.acceptance_ordinal + && effective_time_ms >= birth.decision_time_lower_bound; +} + +inline bool point_eligible(const LiveRequest& live, + uint64_t point_ordinal, + int64_t effective_time_ms) noexcept { + return point_eligible(live.birth, point_ordinal, effective_time_ms); +} + +// v2 exact binary64 grid: r=abs(q)/s, n=round(r) half away from zero, g=n*s. +// Intermediates finite, 1<=n<=2^53, abs(abs(q)-g) <= 4*ulp(max(abs(q),abs(g))) +// and < s/2. ulp is nextafter toward +inf. Does not rewrite q. +inline bool quantity_on_grid(double q, double step) noexcept { + if (!std::isfinite(q) || !std::isfinite(step) || step <= 0.0) return false; + const double abs_q = std::abs(q); + const double r = abs_q / step; + if (!std::isfinite(r)) return false; + const double n = std::round(r); + if (!std::isfinite(n) || n < 1.0 || n > 0x1p53) return false; + const double g = n * step; + if (!std::isfinite(g)) return false; + const double err = std::abs(abs_q - g); + const double span = std::max(abs_q, std::abs(g)); + const double ulp = std::nextafter(span, std::numeric_limits::infinity()) - span; + const double ulp_bound = 4.0 * ulp; + const double half_step = step / 2.0; + if (!std::isfinite(err) || !std::isfinite(ulp_bound) || !std::isfinite(half_step)) + return false; + return err <= ulp_bound && err < half_step; +} + +enum class RequestRejectReason { InvalidQuantity, OffGrid }; + +enum class SubmitStatus { Accepted, Rejected }; +enum class ReplaceStatus { Replaced, ReplaceRejected, NotWorking, InvalidHandle }; +enum class CancelStatus { Cancelled, NotWorking, InvalidHandle }; + +struct SubmitResult { + SubmitStatus status = SubmitStatus::Rejected; + uint64_t event_ordinal = 0; + std::optional handle; + std::optional reason; +}; + +struct ReplaceResult { + ReplaceStatus status = ReplaceStatus::InvalidHandle; + uint64_t event_ordinal = 0; + std::optional successor; + std::optional reason; +}; + +struct CancelResult { + CancelStatus status = CancelStatus::InvalidHandle; + uint64_t event_ordinal = 0; +}; + +struct AcceptedEvent { + uint64_t ordinal = 0; + RequestHandle handle; + Request request; + Birth birth; +}; + +struct RejectedEvent { + uint64_t ordinal = 0; + Request request; + RequestRejectReason reason = RequestRejectReason::InvalidQuantity; +}; + +struct ReplacedEvent { + uint64_t ordinal = 0; + RequestHandle predecessor; + Request predecessor_request; + RequestHandle successor; + Request successor_request; + Birth successor_birth; +}; + +struct ReplaceRejectedEvent { + uint64_t ordinal = 0; + RequestHandle target; + Request live_request; + Request attempted; + RequestRejectReason reason = RequestRejectReason::InvalidQuantity; +}; + +struct CancelledEvent { + uint64_t ordinal = 0; + RequestHandle handle; + Request request; +}; + +struct NotWorkingEvent { + uint64_t ordinal = 0; + RequestHandle target; + std::optional attempted; +}; + +struct InvalidHandleEvent { + uint64_t ordinal = 0; + RequestHandle target; + std::optional attempted; +}; + +enum class MatchRejectReason : std::uint8_t { + NonpositivePrice = 0, + OpeningDirection = 1, + MaxAbsUnits = 2, + MaxOpenLots = 3, + InitialMargin = 4, +}; + +struct NoEffectEvent { + uint64_t ordinal = 0; + RequestHandle handle; + Request request; + Birth birth; +}; + +struct MatchRejectedEvent { + uint64_t ordinal = 0; + RequestHandle handle; + Request request; + Birth birth; + MatchRejectReason reason = MatchRejectReason::OpeningDirection; +}; + +struct ExecutionAppliedEvent { + uint64_t ordinal = 0; + RequestHandle handle; + Request request; + Birth birth; + int64_t effective_time_ms = 0; + int64_t interval_open_ms = 0; + int64_t interval_last_traded_close_ms = 0; + int interval_index = 0; + double raw_price = 0.0; + double resolved_price = 0.0; + double current_ticket = 0.0; + std::size_t first_trade_index = 0; + std::size_t closed_trade_count = 0; + uint64_t opened_lot_incarnation = 0; + std::uint8_t provenance = 0; +}; + +using CommandEvent = std::variant; + +class WorkingRequestCore { +public: + explicit WorkingRequestCore(RunIdentity identity); + WorkingRequestCore(const WorkingRequestCore&) = delete; + WorkingRequestCore& operator=(const WorkingRequestCore&) = delete; + // Move transfers the complete run. The source becomes empty/unbound; + // commands throw invalid_argument without mutation until reset rebinds it. + // Self move-assignment preserves the current run. + WorkingRequestCore(WorkingRequestCore&& other) noexcept; + WorkingRequestCore& operator=(WorkingRequestCore&& other) noexcept; + + void reset(RunIdentity identity); + + const RunIdentity& identity() const noexcept { return identity_; } + const std::vector& live() const noexcept { return live_; } + const std::vector& history() const noexcept { return history_; } + const LiveRequest* find_live(const RequestHandle& handle) const; + + SubmitResult submit(const Request& request, + int64_t decision_time_ms, + uint64_t& next_order_incarnation, + uint64_t& next_timeline_ordinal, + std::optional quantity_grid = std::nullopt); + + ReplaceResult replace(const RequestHandle& target, + const Request& request, + int64_t decision_time_ms, + uint64_t& next_order_incarnation, + uint64_t& next_timeline_ordinal, + std::optional quantity_grid = std::nullopt); + + CancelResult cancel(const RequestHandle& target, uint64_t& next_timeline_ordinal); + + // Consumer-only terminal install. Reserve history before physical commit; + // install is the nonthrowing live-erase + history-append suffix. + friend struct TerminalCommit; + +private: + enum class TargetKind { Live, NotWorking, InvalidHandle }; + + static void require_identity(const RunIdentity& identity); + static void require_grid(std::optional quantity_grid); + static void require_distinct_counters(uint64_t& next_order_incarnation, + uint64_t& next_timeline_ordinal); + static std::optional validate_request( + const Request& request, std::optional quantity_grid); + + uint64_t usable_ordinal(uint64_t next) const; + uint64_t usable_incarnation(uint64_t next) const; + uint64_t last_command_ordinal() const; + uint64_t last_consumed_incarnation() const; + TargetKind classify(const RequestHandle& handle, std::size_t* live_index) const; + + RunIdentity identity_; + std::vector live_; + std::vector history_; +}; + +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); + +struct TerminalCommit { + static uint64_t usable_ordinal(const WorkingRequestCore& core, uint64_t next); + static void reserve_history(WorkingRequestCore& core); + static void install(WorkingRequestCore& core, + std::size_t live_index, + CommandEvent&& event) noexcept; +}; + +} // inline namespace native_order_v1 +} // namespace pineforge::native_order diff --git a/include/pineforge/native_run_spec.hpp b/include/pineforge/native_run_spec.hpp new file mode 100644 index 000000000..098f5694a --- /dev/null +++ b/include/pineforge/native_run_spec.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace pineforge { +inline namespace native_run_spec_v1 { + +// Encodings coincide with the versioned native-v1 C transport. These values +// describe native execution; they do not configure source strategy policies. +enum class NativeFeeKind : std::uint32_t { + Percent = 0, + CashPerUnit = 1, + CashPerExecution = 2, +}; + +enum class NativeCloseExecution : std::uint32_t { + NextEligiblePoint = 0, + AfterCalculation = 1, +}; + +enum class NativeOpenDirections : std::uint32_t { + None = 0, + Long = 1, + Short = 2, + Both = 3, +}; + +// One complete setup value, staged/copied by NativeStrategyHost before it is +// applied at begin. This aggregate owns no host phase, consumed-run counter, +// parsed-calendar authority, physical account, or C transport presence mask. +// Empty required strings and zero financial defaults make an incomplete +// value invalid; no timezone/timeframe/instrument facts are inferred. +struct NativeRunSpec { + native_order::RunIdentity identity; + std::string input_tf; + std::string script_tf; + + std::string ticker; + std::string tickerid; + std::string type; + std::string currency; + std::string basecurrency; + std::string description; + std::string volumetype; + + std::string timezone; + std::string session; // Empty and "24x7" are distinct all-day literals. + std::string chart_timezone; // Optional observation metadata; empty stays empty. + + double initial_capital = 0.0; + double point_value = 0.0; + double account_fx = 0.0; // One positive scalar, not a timestamped FX series. + double price_tick = 0.0; + std::uint32_t slippage_ticks = 0; // <= INT_MAX; raw +/- ticks*tick, no snap. + NativeFeeKind fee_kind = NativeFeeKind::Percent; + double fee_value = 0.0; // Percent/100 of absolute account notional, or + // account-currency cash per unit/execution. + std::optional quantity_grid; // Positive; admission only, no resize. + NativeCloseExecution close_execution = NativeCloseExecution::NextEligiblePoint; + std::optional max_abs_units; // Positive resulting-book opening cap. + std::optional max_open_lots; // Positive surviving+new lot cap. + NativeOpenDirections allowed_open_directions = NativeOpenDirections::Both; + std::optional initial_margin_fraction; // Positive fraction, not percent; + // no maintenance liquidation. +}; + +enum class NativeRunSpecField : std::uint8_t { + None, + SessionKey, RunNumber, InputTimeframe, ScriptTimeframe, + Ticker, TickerId, Type, Currency, BaseCurrency, Description, VolumeType, + Timezone, Session, ChartTimezone, + InitialCapital, PointValue, AccountFx, PriceTick, SlippageTicks, + FeeKind, FeeValue, QuantityGrid, CloseExecution, MaxAbsUnits, MaxOpenLots, + AllowedOpenDirections, InitialMarginFraction, +}; + +enum class NativeRunSpecError : std::uint8_t { + None, + EmptyRequiredString, + EmbeddedNul, + InvalidUtf8, + ZeroRunNumber, + InvalidTimeframe, + IncompatibleTimeframes, + UnresolvedTimezone, + InvalidSession, + NotFinitePositive, + SlippageOutOfRange, + UnknownFeeKind, + NotFiniteNonnegative, + UnknownCloseExecution, + UnknownOpenDirections, + ZeroLotLimit, + AllocationFailure, + CalendarFailure, +}; + +// Allocation-free facts suitable for the host's durable failure variant. +// Render text at the presentation boundary, never store exception.what(). +struct NativeRunSpecValidation { + NativeRunSpecError error = NativeRunSpecError::None; + NativeRunSpecField field = NativeRunSpecField::None; + + constexpr bool ok() const noexcept { return error == NativeRunSpecError::None; } + constexpr explicit operator bool() const noexcept { return ok(); } +}; + +// Complete validation, with deterministic first-error field order. Every +// string is semantic UTF-8 without embedded NUL (all cross C-string v1). +// Required: identity key, both timeframe literals, tickerid, scheduling timezone. +// Empty chart timezone is preserved as optional observation metadata. +// Calendar parsing/compatibility remain in native_calendar. Batch monthly +// pairings are accepted here; stream-only restrictions belong to begin. +// Calendar parsing may allocate. Allocation/other dependency exceptions are +// converted into typed failure facts; the supplied spec is never changed. +NativeRunSpecValidation validate_native_run_spec(const NativeRunSpec& spec) noexcept; + +// Validate the WHOLE value first, then canonicalize its admitted numeric +// negative zero (fee_value) to positive zero. Failure preserves every input +// bit/string/optional. Positive-only fields cannot admit either zero sign; +// absent optionals have no payload. Literal strings/positive numbers are +// never rewritten. There is no second validated/live configuration wrapper. +// Host usage: copy input into a candidate, normalize candidate, then stage +// that same spec atomically; own copy-allocation/lifecycle failure handling. +NativeRunSpecValidation normalize_native_run_spec(NativeRunSpec& spec) noexcept; + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); + +} // inline namespace native_run_spec_v1 +} // namespace pineforge diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index 232cd6c66..31db9f0b9 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -435,6 +435,31 @@ typedef void* pf_strategy_t; * definitions. */ +/** Native execution contract query. Returns 1 Legacy, 2 NativeMarketV1, -1 invalid. + * Absence of this symbol on a known ABI4/stream-v1 library means legacy. */ +PF_API int strategy_execution_contract(pf_strategy_t s); + +/** Versioned native run specification. All string pointers are non-null. + * session may be empty (all-day literal). session_key, timeframes, timezone + * and tickerid are nonempty. optional_mask bits: 0 quantity_grid, 1 + * max_abs_units, 2 initial_margin_fraction, 3 max_open_lots. */ +typedef struct pf_native_run_spec_v1 { + uint32_t struct_size; + const char *session_key; uint64_t run_number; + const char *input_tf, *script_tf; + const char *ticker, *tickerid, *type, *currency, *basecurrency, *description, *volumetype; + const char *timezone, *session, *chart_timezone; + double initial_capital, point_value, account_fx, price_tick; + uint32_t slippage_ticks, fee_kind; double fee_value; + uint32_t optional_mask, close_execution, allowed_open_directions; + double quantity_grid, max_abs_units, initial_margin_fraction; + uint64_t max_open_lots; +} pf_native_run_spec_v1; + +/** Apply a native v1 specification. Returns 0 on success, -1 on failure. + * Legacy handles refuse without native state mutation. */ +PF_API int strategy_configure_native_v1(pf_strategy_t s, const pf_native_run_spec_v1* spec); + #ifndef PINEFORGE_NO_STRATEGY_DECLS /** Allocate a new strategy instance. diff --git a/runner/CMakeLists.txt b/runner/CMakeLists.txt index 1b37e8bb1..c935e1eda 100644 --- a/runner/CMakeLists.txt +++ b/runner/CMakeLists.txt @@ -15,10 +15,17 @@ endif() target_link_libraries(pineforge_live_support PUBLIC ${_pf_sqlite_target} CURL::libcurl OpenSSL::Crypto Threads::Threads) target_compile_options(pineforge_live_support PRIVATE -Wall -Wextra -Wpedantic) +add_library(pineforge_live_startup STATIC native_startup.cpp) +target_compile_features(pineforge_live_startup PUBLIC cxx_std_17) +target_include_directories(pineforge_live_startup PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} ${PROJECT_SOURCE_DIR}/include ${PROJECT_BINARY_DIR}/include) +target_link_libraries(pineforge_live_startup PUBLIC pineforge_live_support PineForge::pineforge) +target_compile_options(pineforge_live_startup PRIVATE -Wall -Wextra -Wpedantic -ffp-contract=off) + add_executable(pineforge-live main.cpp parser.cpp) target_include_directories(pineforge-live PRIVATE ${PROJECT_SOURCE_DIR}/include ${PROJECT_BINARY_DIR}/include) -target_link_libraries(pineforge-live PRIVATE pineforge_live_support ${CMAKE_DL_LIBS}) +target_link_libraries(pineforge-live PRIVATE pineforge_live_startup ${CMAKE_DL_LIBS}) target_compile_options(pineforge-live PRIVATE -Wall -Wextra -Wpedantic -ffp-contract=off) install(TARGETS pineforge-live RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) @@ -27,6 +34,11 @@ target_link_libraries(native_live_example PRIVATE PineForge::pineforge) target_compile_options(native_live_example PRIVATE -fvisibility=hidden -ffp-contract=off) set_target_properties(native_live_example PROPERTIES PREFIX "" OUTPUT_NAME "native-live-example") +add_library(native_market_example MODULE examples/native_market_strategy.cpp) +target_link_libraries(native_market_example PRIVATE PineForge::pineforge) +target_compile_options(native_market_example PRIVATE -fvisibility=hidden -ffp-contract=off) +set_target_properties(native_market_example PROPERTIES PREFIX "" OUTPUT_NAME "native-market-example") + add_library(native_live_parser_example MODULE examples/demo_parser.cpp) target_include_directories(native_live_parser_example PRIVATE ${PROJECT_SOURCE_DIR}/include) set_target_properties(native_live_parser_example PROPERTIES PREFIX "" OUTPUT_NAME "native-live-parser-example") @@ -36,6 +48,10 @@ if(PINEFORGE_BUILD_TESTS) target_include_directories(test_live_json PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(test_live_json PRIVATE -UNDEBUG) add_test(NAME test_live_json COMMAND test_live_json) + add_executable(test_native_live_startup ${PROJECT_SOURCE_DIR}/tests/test_native_live_startup.cpp) + target_link_libraries(test_native_live_startup PRIVATE pineforge_live_startup) + target_compile_options(test_native_live_startup PRIVATE -UNDEBUG) + add_test(NAME test_native_live_startup COMMAND test_native_live_startup) add_test(NAME native_live_help COMMAND pineforge-live --help) add_executable(test_live_parser ${PROJECT_SOURCE_DIR}/tests/test_live_parser.cpp parser.cpp) target_include_directories(test_live_parser PRIVATE ${PROJECT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}) @@ -55,6 +71,40 @@ if(PINEFORGE_BUILD_TESTS) $ $ $) set_tests_properties(native_live_e2e PROPERTIES TIMEOUT 120) + foreach(contract_kind absent unknown native_no_export native_stub) + add_library(live_contract_${contract_kind} MODULE + ${PROJECT_SOURCE_DIR}/tests/test_live_contract_plugins.cpp) + target_include_directories(live_contract_${contract_kind} PRIVATE + ${PROJECT_SOURCE_DIR}/include ${PROJECT_BINARY_DIR}/include) + target_compile_options(live_contract_${contract_kind} PRIVATE -fvisibility=hidden) + set_target_properties(live_contract_${contract_kind} PROPERTIES PREFIX "") + endforeach() + target_compile_definitions(live_contract_absent PRIVATE PF_LIVE_CONTRACT_ABSENT=1) + target_compile_definitions(live_contract_unknown PRIVATE PF_LIVE_CONTRACT_UNKNOWN=1) + target_compile_definitions(live_contract_native_no_export PRIVATE PF_LIVE_CONTRACT_NATIVE_NO_EXPORT=1) + target_compile_definitions(live_contract_native_stub PRIVATE PF_LIVE_CONTRACT_NATIVE_STUB=1) + add_test(NAME native_live_startup_e2e COMMAND ${Python3_EXECUTABLE} + ${PROJECT_SOURCE_DIR}/tests/native_live_startup_e2e.py $ + $ $ + $ $ + $ $) + set_tests_properties(native_live_startup_e2e PROPERTIES TIMEOUT 120) + # Exercise the real MODULE through its C ABI, without linking a second engine. + add_executable(test_native_example_batch + ${PROJECT_SOURCE_DIR}/tests/test_native_example_batch.cpp) + target_include_directories(test_native_example_batch PRIVATE + ${PROJECT_SOURCE_DIR}/include ${PROJECT_BINARY_DIR}/include) + target_compile_options(test_native_example_batch PRIVATE -UNDEBUG -ffp-contract=off) + target_link_libraries(test_native_example_batch PRIVATE ${CMAKE_DL_LIBS}) + if(PINEFORGE_ENABLE_SANITIZERS) + target_compile_options(test_native_example_batch PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(test_native_example_batch PRIVATE -fsanitize=address,undefined) + endif() + add_dependencies(test_native_example_batch native_market_example) + add_test(NAME test_native_example_batch + COMMAND test_native_example_batch $) + set_tests_properties(test_native_example_batch PROPERTIES TIMEOUT 60) foreach(native_test test_native_live_store test_native_live_websocket) add_executable(${native_test} ${PROJECT_SOURCE_DIR}/tests/${native_test}.cpp) target_link_libraries(${native_test} PRIVATE pineforge_live_support) diff --git a/runner/README.md b/runner/README.md index 53f41d337..1d57eeb05 100644 --- a/runner/README.md +++ b/runner/README.md @@ -73,6 +73,14 @@ build-live/bin/pineforge-live run \ --webhook-secret-env PINEFORGE_WEBHOOK_SECRET ``` +Native strategies use `--native-config FILE` instead of `--input` / +`--override` / `--syminfo`. The file is a strict JSON object with `run`, +`clock`, `instrument` and `execution` keys. Explicit CLI clock/symbol flags +must equal the file; omitted CLI clock values take the file. Monthly stream +input is refused before the ledger is bound. Legacy 1m identity bytes are +unchanged when `--native-config` is absent. The additional example is +`native-market-example`. + Keep symbol metadata consistent with the corresponding backtest. `--syminfo` supports `type`, `currency`, `basecurrency`, `description`, `volumetype`, `mintick`, `pointvalue`, `qty_step`, `margin_long` and `margin_short`. Omitted diff --git a/runner/examples/native_market_strategy.cpp b/runner/examples/native_market_strategy.cpp new file mode 100644 index 000000000..9481af9e2 --- /dev/null +++ b/runner/examples/native_market_strategy.cpp @@ -0,0 +1,220 @@ +#include +#include + +#include +#include +#include +#include + +namespace { + +static_assert(sizeof(pf_bar_t) == sizeof(pineforge::Bar), + "pf_bar_t / pineforge::Bar size mismatch"); +static_assert(offsetof(pf_bar_t, open) == offsetof(pineforge::Bar, open), + "pf_bar_t::open offset mismatch"); +static_assert(offsetof(pf_bar_t, high) == offsetof(pineforge::Bar, high), + "pf_bar_t::high offset mismatch"); +static_assert(offsetof(pf_bar_t, low) == offsetof(pineforge::Bar, low), + "pf_bar_t::low offset mismatch"); +static_assert(offsetof(pf_bar_t, close) == offsetof(pineforge::Bar, close), + "pf_bar_t::close offset mismatch"); +static_assert(offsetof(pf_bar_t, volume) == offsetof(pineforge::Bar, volume), + "pf_bar_t::volume offset mismatch"); +static_assert(offsetof(pf_bar_t, timestamp) == offsetof(pineforge::Bar, timestamp), + "pf_bar_t::timestamp offset mismatch"); + +static_assert(sizeof(pf_report_t) == sizeof(pineforge::ReportC), + "pf_report_t / pineforge::ReportC size mismatch"); +static_assert(offsetof(pf_report_t, total_trades) == offsetof(pineforge::ReportC, total_trades), + "pf_report_t::total_trades offset mismatch"); +static_assert(offsetof(pf_report_t, trades) == offsetof(pineforge::ReportC, trades), + "pf_report_t::trades offset mismatch"); +static_assert(offsetof(pf_report_t, net_profit) == offsetof(pineforge::ReportC, net_profit), + "pf_report_t::net_profit offset mismatch"); +static_assert(offsetof(pf_report_t, security_diag) == offsetof(pineforge::ReportC, security_diag), + "pf_report_t::security_diag offset mismatch"); +static_assert(offsetof(pf_report_t, metrics) == offsetof(pineforge::ReportC, metrics), + "pf_report_t::metrics offset mismatch"); +static_assert(offsetof(pf_report_t, equity_curve) == offsetof(pineforge::ReportC, equity_curve), + "pf_report_t::equity_curve offset mismatch"); +static_assert(offsetof(pf_report_t, equity_curve_len) + == offsetof(pineforge::ReportC, equity_curve_len), + "pf_report_t::equity_curve_len offset mismatch"); +static_assert(offsetof(pf_report_t, broker_state_hash) + == offsetof(pineforge::ReportC, broker_state_hash), + "pf_report_t::broker_state_hash offset mismatch"); + +class NativeMarketExample final : public pineforge::NativeStrategyHost { + int bars_ = 0; + + void on_native_run_begin() override { bars_ = 0; } + + void on_native_bar(const pineforge::Bar&, + const pineforge::NativeDecisionContext&) override { + ++bars_; + if (bars_ == 1 && physical_position().signed_units == 0.0) { + submit_market({pineforge::order_action::Transact{1.0}, "native-open", ""}); + } else if (bars_ == 4 && physical_position().signed_units != 0.0) { + submit_market({pineforge::execution::Flatten{}, "native-flat", ""}); + } + } + +public: + void note_c_error(const char* text) noexcept { + try { + last_error_ = text ? text : ""; + } catch (...) { + } + } +}; + +pineforge::BacktestEngine* as_engine(pf_strategy_t s) { + return static_cast(s); +} + +NativeMarketExample* as_example(pf_strategy_t s) { + return s ? dynamic_cast(as_engine(s)) : nullptr; +} + +void note_error(pf_strategy_t s, const char* text) { + if (auto* example = as_example(s)) example->note_c_error(text); +} + +template +void catch_source(pf_strategy_t s, Fn&& fn) { + if (!s) return; + try { + fn(); + } catch (const std::exception& e) { + note_error(s, e.what()); + } catch (...) { + note_error(s, "native host refuses source mutation"); + } +} + +bool magnifier_unsupported(int bar_magnifier, int magnifier_samples, + pf_magnifier_distribution_t dist) { + return bar_magnifier != 0 || magnifier_samples != 4 + || dist != PF_MAGNIFIER_ENDPOINTS; +} + +pineforge::Bar copy_c_bar(const pf_bar_t& in) { + pineforge::Bar out; + out.open = in.open; + out.high = in.high; + out.low = in.low; + out.close = in.close; + out.volume = in.volume; + out.timestamp = in.timestamp; + return out; +} + +void publish_report(pineforge::BacktestEngine* engine, pf_report_t* out) { + if (!engine || !out) return; + pineforge::ReportC local{}; + try { + engine->fill_report(&local); + std::memcpy(out, &local, sizeof(local)); + } catch (...) { + pineforge::BacktestEngine::free_report(&local); + throw; + } +} + +void run_c_batch(pf_strategy_t s, pf_bar_t* bars, int n, + const char* input_tf, const char* script_tf, bool with_tf, + pf_report_t* out) { + if (!s) return; + auto* engine = as_engine(s); + try { + std::vector copied; + const pineforge::Bar* ptr = nullptr; + if (n > 0 && bars != nullptr) { + copied.resize(static_cast(n)); + for (int i = 0; i < n; ++i) copied[static_cast(i)] = copy_c_bar(bars[i]); + ptr = copied.data(); + } + if (with_tf) { + engine->run(ptr, n, input_tf ? input_tf : "", script_tf ? script_tf : ""); + } else { + engine->run(ptr, n); + } + publish_report(engine, out); + } catch (const std::exception& e) { + note_error(s, e.what()); + } catch (...) { + note_error(s, "native example C batch failed"); + } +} + +} // namespace + +extern "C" { + +PF_API pf_strategy_t strategy_create(const char*) { + try { + pineforge::BacktestEngine* engine = new NativeMarketExample(); + return static_cast(engine); + } catch (...) { + return nullptr; + } +} + +PF_API void strategy_free(pf_strategy_t s) { + delete as_engine(s); +} + +PF_API void strategy_set_input(pf_strategy_t s, const char* key, const char* value) { + catch_source(s, [&] { + as_engine(s)->set_input(key ? key : "", value ? value : ""); + }); +} + +PF_API void strategy_set_override(pf_strategy_t s, const char* key, const char* value) { + catch_source(s, [&] { + // No BacktestEngine override mutator exists. Forward to the public + // guarded source-configuration entry; refuse_source_mutation latches + // Failed before inputs_ is written. + as_engine(s)->set_input(key ? key : "strategy_set_override", + value ? value : ""); + }); +} + +PF_API void strategy_set_magnifier_volume_weighted(pf_strategy_t s, int on) { + catch_source(s, [&] { + as_engine(s)->set_magnifier_volume_weighted(on != 0); + }); +} + +PF_API void run_backtest(pf_strategy_t s, pf_bar_t* bars, int n, pf_report_t* out) { + run_c_batch(s, bars, n, nullptr, nullptr, false, out); +} + +PF_API void run_backtest_full(pf_strategy_t s, pf_bar_t* bars, int n, + const char* input_tf, const char* script_tf, + int bar_magnifier, int magnifier_samples, + pf_magnifier_distribution_t magnifier_dist, + pf_report_t* out) { + if (!s) return; + if (magnifier_unsupported(bar_magnifier, magnifier_samples, magnifier_dist)) { + // Wrapper preflight only (pending root run_tf magnifier refusal). + // Does not latch Failed; Ready is preserved until the core consumer + // rejects bool/samples/dist itself. + note_error(s, "native example refuses unsupported magnifier arguments"); + return; + } + run_c_batch(s, bars, n, input_tf, script_tf, true, out); +} + +PF_API void report_free(pf_report_t* report) { + if (!report) return; + pineforge::ReportC local{}; + std::memcpy(&local, report, sizeof(local)); + pineforge::BacktestEngine::free_report(&local); + std::memcpy(report, &local, sizeof(local)); +} + +// Pull PF_API runtime/stream/native-config symbols from the static engine. +PF_API int native_market_example_abi_version() { return pf_abi_version(); } + +} // extern "C" diff --git a/runner/main.cpp b/runner/main.cpp index b76bb5ced..90443a741 100644 --- a/runner/main.cpp +++ b/runner/main.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 #include "json.hpp" +#include "native_startup.hpp" #include "parser.hpp" #include "store.hpp" #include "transport.hpp" @@ -7,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -35,8 +38,10 @@ struct Config { std::string strategy, warmup, feed = "-", ledger, webhook, mode = "", input_tf = "1", script_tf, symbol, name = "strategy"; std::string session = "24x7", timezone = "UTC", chart_timezone = "UTC", secret_env, feed_url, - parser_path, parser_config_path, subscribe_path; + parser_path, parser_config_path, subscribe_path, native_config; std::vector> inputs, overrides, syminfo; + std::set explicit_flags; + NativeConfigValues native; std::uint64_t from_input = 0, max_events = 0, max_attempts = 8; long poll_ms = 1000; bool check = false, allow_http = false; @@ -55,6 +60,7 @@ void help() { " --subscribe subscription.json (WebSocket only)\n" " --webhook-secret-env NAME --allow-insecure-http\n" " --from-input N --max-events N --max-attempts 8\n" + " --native-config FILE (strict native run specification)\n" " --check (one HTTP snapshot) --poll-ms 1000\n" "JSONL: {\"type\":\"tick\",\"ts\":60000,\"seq\":1,\"price\":100,\"qty\":1}\n" " " @@ -96,6 +102,7 @@ Config args(int argc, char **argv) { std::string a = argv[i]; if (a != "--input" && a != "--override" && a != "--syminfo" && !seen.insert(a).second) throw std::runtime_error("duplicate option: " + a); + c.explicit_flags.insert(a); if (a == "--check") { c.check = true; continue; @@ -155,6 +162,8 @@ Config args(int argc, char **argv) { c.max_events = unsigned_arg(v); else if (a == "--max-attempts") c.max_attempts = unsigned_arg(v); + else if (a == "--native-config") + c.native_config = v; else if (a == "--poll-ms") { auto n = unsigned_arg(v); if (n < 100 || n > 3600000) @@ -163,17 +172,22 @@ Config args(int argc, char **argv) { } else throw std::runtime_error("unknown option: " + a); } - if (c.strategy.empty() || c.warmup.empty() || c.ledger.empty() || c.webhook.empty() || - c.symbol.empty() || c.script_tf.empty()) - throw std::runtime_error( - "strategy, warmup, ledger, webhook-url, symbol and script-tf are required"); + if (c.strategy.empty() || c.warmup.empty() || c.ledger.empty() || c.webhook.empty()) + throw std::runtime_error("strategy, warmup, ledger and webhook-url are required"); if (c.mode != "bars" && c.mode != "ticks") throw std::runtime_error("mode must be bars or ticks"); - if (c.input_tf != "1") - throw std::runtime_error("native runner input-tf currently must be 1 minute"); - validate_script_tf(c.script_tf); - if (c.chart_timezone.empty()) - c.chart_timezone = "UTC"; + if (!c.native_config.empty()) { + if (!c.inputs.empty() || !c.overrides.empty() || !c.syminfo.empty()) + throw std::runtime_error("native-config refuses --input, --override and --syminfo"); + } else { + if (c.symbol.empty() || c.script_tf.empty()) + throw std::runtime_error("symbol and script-tf are required"); + if (c.input_tf != "1") + throw std::runtime_error("native runner input-tf currently must be 1 minute"); + validate_script_tf(c.script_tf); + if (c.chart_timezone.empty()) + c.chart_timezone = "UTC"; + } if (!c.feed_url.empty() && seen.count("--feed")) throw std::runtime_error("choose feed or feed-url"); const bool websocket = c.feed_url.rfind("ws://", 0) == 0 || c.feed_url.rfind("wss://", 0) == 0; @@ -240,57 +254,11 @@ bool line(std::istream &in, std::string &out) { return !out.empty(); } bool blank(const std::string &s) { return s.find_first_not_of(" \t\r\n") == std::string::npos; } -std::vector history(const std::string &csv) { - std::istringstream in(csv); - std::string row; - if (!line(in, row)) - throw std::runtime_error("empty warmup"); - if (!row.empty() && row.back() == '\r') - row.pop_back(); - if (row != "timestamp,open,high,low,close,volume") - throw std::runtime_error("warmup CSV header must be timestamp,open,high,low,close,volume"); - std::vector bars; - while (line(in, row)) { - if (!row.empty() && row.back() == '\r') - row.pop_back(); - if (row.empty()) - throw std::runtime_error("blank warmup row"); - std::vector cells; - std::size_t begin = 0; - for (;;) { - auto end = row.find(',', begin); - cells.push_back(row.substr(begin, end - begin)); - if (end == std::string::npos) - break; - begin = end + 1; - } - if (cells.size() != 6) - throw std::runtime_error("warmup requires six CSV columns"); - auto stamp = parse_json(cells[0]).integer(); - pf_bar_t b{}; - b.timestamp = stamp; - b.open = parse_json(cells[1]).real(); - b.high = parse_json(cells[2]).real(); - b.low = parse_json(cells[3]).real(); - b.close = parse_json(cells[4]).real(); - b.volume = parse_json(cells[5]).real(); - if (stamp < 0 || stamp % 60000 || stamp > INT64_MAX - 60000 || b.low <= 0 || - b.high < std::max(b.open, b.close) || b.low > std::min(b.open, b.close) || - b.high < b.low || b.volume < 0) - throw std::runtime_error("invalid warmup bar"); - if (!bars.empty() && stamp != bars.back().timestamp + 60000) - throw std::runtime_error("warmup must contain contiguous confirmed 1m bars"); - bars.push_back(b); - if (bars.size() >= static_cast(INT_MAX)) - throw std::runtime_error("too many warmup rows"); - } - if (bars.empty()) - throw std::runtime_error("warmup requires at least one bar"); - return bars; -} class Strategy { void *library_ = nullptr; + decltype(&strategy_stream_begin) begin_ = nullptr; + decltype(&strategy_configure_native_v1) configure_native_ = nullptr; template T symbol(const char *name) { auto p = dlsym(library_, name); if (!p) @@ -298,6 +266,17 @@ class Strategy { name); return reinterpret_cast(p); } + template T optional_symbol(const char *name) { + return reinterpret_cast(dlsym(library_, name)); + } + void release() { + if (state && free) + free(state); + state = nullptr; + if (library_) + dlclose(library_); + library_ = nullptr; + } public: pf_strategy_t state = nullptr; @@ -310,8 +289,21 @@ class Strategy { decltype(&strategy_stream_order_action_get) get = nullptr; decltype(&strategy_stream_order_actions_clear) clear = nullptr; decltype(&strategy_stream_state_hash) hash = nullptr; - Strategy(const Config &c, const std::vector &warmup) { - library_ = dlopen(fs::absolute(c.strategy).c_str(), RTLD_NOW | RTLD_LOCAL); + int contract = 1; + bool has_configure_native = false; + Strategy() = default; + ~Strategy() { release(); } + Strategy(const Strategy &) = delete; + Strategy &operator=(const Strategy &) = delete; + void check(int rc) { + if (rc != 0) { + const char *e = error ? error(state) : nullptr; + throw std::runtime_error(std::string("native stream refused: ") + + (e ? e : "unknown error")); + } + } + void load(const std::string &path) { + library_ = dlopen(fs::absolute(path).c_str(), RTLD_NOW | RTLD_LOCAL); if (!library_) throw std::runtime_error("cannot load compiled strategy library"); try { @@ -330,83 +322,128 @@ class Strategy { get = symbol("strategy_stream_order_action_get"); clear = symbol("strategy_stream_order_actions_clear"); hash = symbol("strategy_stream_state_hash"); + begin_ = symbol("strategy_stream_begin"); state = symbol("strategy_create")(nullptr); if (!state) throw std::runtime_error("strategy creation failed"); - auto set_input = symbol("strategy_set_input"); - auto set_override = symbol("strategy_set_override"); - for (const auto &[k, v] : c.inputs) - set_input(state, k.c_str(), v.c_str()); - for (const auto &[k, v] : c.overrides) - set_override(state, k.c_str(), v.c_str()); - symbol("strategy_set_syminfo_timezone")( - state, c.timezone.c_str()); - symbol("strategy_set_chart_timezone")( - state, c.chart_timezone.c_str()); - symbol("strategy_set_syminfo_session")( - state, c.session.c_str()); - auto set_string = - symbol("strategy_set_syminfo_string"); - if (set_string(state, "tickerid", c.symbol.c_str()) != 0) - throw std::runtime_error("strategy rejected tickerid"); - auto ticker = c.symbol.substr( - c.symbol.find(':') == std::string::npos ? 0 : c.symbol.find(':') + 1); - if (set_string(state, "ticker", ticker.c_str()) != 0) - throw std::runtime_error("strategy rejected ticker"); - for (const auto &[key, value] : c.syminfo) { - if (key == "type" || key == "currency" || key == "basecurrency" || - key == "description" || key == "volumetype") { - if (set_string(state, key.c_str(), value.c_str()) != 0) - throw std::runtime_error("strategy rejected string metadata"); - } else { - double v = parse_json(value).real(); - if (key == "mintick" || key == "pointvalue" || key == "qty_step") { - if (v <= 0) - throw std::runtime_error( - "symbol tick/point/quantity steps must be positive"); - } else if (key == "margin_long" || key == "margin_short") { - if (v < 0) - throw std::runtime_error("symbol margins must be nonnegative"); - } else - throw std::runtime_error("unsupported syminfo key: " + key); - if (key == "mintick") - symbol( - "strategy_set_syminfo_mintick")(state, v); - else if (key == "pointvalue") - symbol( - "strategy_set_syminfo_pointvalue")(state, v); - else - symbol( - "strategy_set_syminfo_metadata")(state, key.c_str(), v); - } + auto contract_fn = optional_symbol( + "strategy_execution_contract"); + contract = 1; + if (contract_fn) { + contract = contract_fn(state); + if (contract != 1 && contract != 2) + throw std::runtime_error("unknown strategy execution contract"); } - auto begin = symbol("strategy_stream_begin"); - check(begin(state, warmup.data(), static_cast(warmup.size()), c.input_tf.c_str(), - c.script_tf.c_str())); - clear(state); + configure_native_ = optional_symbol( + "strategy_configure_native_v1"); + has_configure_native = configure_native_ != nullptr; } catch (...) { - if (state && free) - free(state); - state = nullptr; - dlclose(library_); - library_ = nullptr; + release(); throw; } } - ~Strategy() { - if (state && free) - free(state); - if (library_) - dlclose(library_); + void require_contract(const Config &c) const { + if (c.native.present) { + if (contract != 2) + throw std::runtime_error("native-config requires NativeMarketV1"); + if (!has_configure_native) + throw std::runtime_error( + "compiled strategy lacks native ABI symbol: strategy_configure_native_v1"); + } else if (contract == 2) { + throw std::runtime_error("native strategy requires --native-config"); + } } - Strategy(const Strategy &) = delete; - Strategy &operator=(const Strategy &) = delete; - void check(int rc) { - if (rc != 0) { - const char *e = error(state); - throw std::runtime_error(std::string("native stream refused: ") + - (e ? e : "unknown error")); + void configure(const Config &c) { + if (c.native.present) { + pf_native_run_spec_v1 spec{}; + spec.struct_size = sizeof(spec); + spec.session_key = c.native.session_key.c_str(); + spec.run_number = c.native.run_number; + spec.input_tf = c.native.input_tf.c_str(); + spec.script_tf = c.native.script_tf.c_str(); + spec.ticker = c.native.ticker.c_str(); + spec.tickerid = c.native.tickerid.c_str(); + spec.type = c.native.type.c_str(); + spec.currency = c.native.currency.c_str(); + spec.basecurrency = c.native.basecurrency.c_str(); + spec.description = c.native.description.c_str(); + spec.volumetype = c.native.volumetype.c_str(); + spec.timezone = c.native.timezone.c_str(); + spec.session = c.native.session.c_str(); + spec.chart_timezone = c.native.chart_timezone.c_str(); + spec.initial_capital = c.native.initial_capital; + spec.point_value = c.native.point_value; + spec.account_fx = c.native.account_fx; + spec.price_tick = c.native.price_tick; + spec.slippage_ticks = c.native.slippage_ticks; + spec.fee_kind = c.native.fee_kind; + spec.fee_value = c.native.fee_value; + spec.optional_mask = c.native.optional_mask; + spec.close_execution = c.native.close_execution; + spec.allowed_open_directions = c.native.allowed_open_directions; + spec.quantity_grid = c.native.quantity_grid; + spec.max_abs_units = c.native.max_abs_units; + spec.initial_margin_fraction = c.native.initial_margin_fraction; + spec.max_open_lots = c.native.max_open_lots; + if (configure_native_(state, &spec) != 0) { + const char *e = error(state); + throw std::runtime_error(std::string("native configure refused: ") + + (e ? e : "unknown error")); + } + return; } + auto set_input = symbol("strategy_set_input"); + auto set_override = symbol("strategy_set_override"); + for (const auto &[k, v] : c.inputs) + set_input(state, k.c_str(), v.c_str()); + for (const auto &[k, v] : c.overrides) + set_override(state, k.c_str(), v.c_str()); + symbol("strategy_set_syminfo_timezone")( + state, c.timezone.c_str()); + symbol("strategy_set_chart_timezone")( + state, c.chart_timezone.c_str()); + symbol("strategy_set_syminfo_session")( + state, c.session.c_str()); + auto set_string = + symbol("strategy_set_syminfo_string"); + if (set_string(state, "tickerid", c.symbol.c_str()) != 0) + throw std::runtime_error("strategy rejected tickerid"); + auto ticker = c.symbol.substr( + c.symbol.find(':') == std::string::npos ? 0 : c.symbol.find(':') + 1); + if (set_string(state, "ticker", ticker.c_str()) != 0) + throw std::runtime_error("strategy rejected ticker"); + for (const auto &[key, value] : c.syminfo) { + if (key == "type" || key == "currency" || key == "basecurrency" || + key == "description" || key == "volumetype") { + if (set_string(state, key.c_str(), value.c_str()) != 0) + throw std::runtime_error("strategy rejected string metadata"); + } else { + double v = parse_json(value).real(); + if (key == "mintick" || key == "pointvalue" || key == "qty_step") { + if (v <= 0) + throw std::runtime_error( + "symbol tick/point/quantity steps must be positive"); + } else if (key == "margin_long" || key == "margin_short") { + if (v < 0) + throw std::runtime_error("symbol margins must be nonnegative"); + } else + throw std::runtime_error("unsupported syminfo key: " + key); + if (key == "mintick") + symbol("strategy_set_syminfo_mintick")( + state, v); + else if (key == "pointvalue") + symbol( + "strategy_set_syminfo_pointvalue")(state, v); + else + symbol( + "strategy_set_syminfo_metadata")(state, key.c_str(), v); + } + } + } + void begin(const Config &c, const std::vector &warmup) { + check(begin_(state, warmup.data(), static_cast(warmup.size()), c.input_tf.c_str(), + c.script_tf.c_str())); + clear(state); } }; @@ -515,37 +552,6 @@ void apply_record(Strategy &strategy, const Config &c, Cursor &cursor, const Jso for (const auto &event : events.items) apply(strategy, c, cursor, event); } -std::string identity(const Config &c, const std::string &warmup, const std::string &library, - const std::string &parser_bytes, const std::string &parser_config) { - Json j = Json::object({{"schema", Json::string("pineforge-native-ledger/v1")}, - {"library", Json::string(sha256_hex(library))}, - {"warmup", Json::string(sha256_hex(warmup))}, - {"mode", Json::string(c.mode)}, - {"input_tf", Json::string(c.input_tf)}, - {"script_tf", Json::string(c.script_tf)}, - {"session", Json::string(c.session)}, - {"timezone", Json::string(c.timezone)}, - {"chart_timezone", Json::string(c.chart_timezone)}, - {"symbol", Json::string(c.symbol)}, - {"name", Json::string(c.name)}, - {"webhook", Json::string(c.webhook)}, - {"parser", Json::string(sha256_hex(parser_bytes))}, - {"parser_config", Json::string(sha256_hex(parser_config))}}); - Json input, overrides; - input.kind = overrides.kind = Json::Kind::Array; - for (const auto &[k, v] : c.inputs) - input.items.push_back(Json::object({{"key", Json::string(k)}, {"value", Json::string(v)}})); - for (const auto &[k, v] : c.overrides) - overrides.items.push_back( - Json::object({{"key", Json::string(k)}, {"value", Json::string(v)}})); - Json metadata = Json::object({}); - for (const auto &[k, v] : c.syminfo) - metadata.members[k] = Json::string(v); - j.members["syminfo"] = metadata; - j.members["inputs"] = input; - j.members["overrides"] = overrides; - return sha256_hex(j.dump()); -} std::vector normalize(Parser *parser, const std::string &message) { if (!parser) return {parse_json(message)}; @@ -596,9 +602,26 @@ bool drain(Ledger &ledger, const HttpOptions &options, const Config &c, std::uin } return false; } -int run(const Config &c) { +LegacyIdentityFields legacy_fields(const Config &c) { + return {c.mode, c.input_tf, c.script_tf, c.session, c.timezone, c.chart_timezone, + c.symbol, c.name, c.webhook, c.inputs, c.overrides, c.syminfo}; +} + +int run(Config c) { + if (!c.native_config.empty()) { + c.native = parse_native_config(read_file(c.native_config, MAX_FRAME)); + NativeClockBindings clock{c.input_tf, c.script_tf, c.timezone, c.session, + c.chart_timezone, c.symbol, c.explicit_flags}; + apply_native_config(clock, c.native); + c.input_tf = clock.input_tf; + c.script_tf = clock.script_tf; + c.timezone = clock.timezone; + c.session = clock.session; + c.chart_timezone = clock.chart_timezone; + c.symbol = clock.symbol; + validate_native_config(c.native); + } auto original = read_file(c.warmup, 512ULL * 1024 * 1024); - auto warmup = history(original); auto library = read_file(c.strategy, 512ULL * 1024 * 1024); std::string parser_bytes = c.parser_path.empty() ? "" : read_file(c.parser_path, 64ULL * 1024 * 1024); @@ -610,13 +633,23 @@ int run(const Config &c) { if (!c.parser_path.empty() && sha256_hex(read_file(c.parser_path, 64ULL * 1024 * 1024)) != sha256_hex(parser_bytes)) throw std::runtime_error("parser library changed during initialization"); - std::string deployment = identity(c, original, library, parser_bytes, parser_config); - Ledger ledger(c.ledger, deployment); - Strategy strategy(c, warmup); - Cursor cursor; - // Library bytes are identity-critical. Refuse replacement during dlopen. + Strategy strategy; + strategy.load(c.strategy); if (sha256_hex(read_file(c.strategy, 512ULL * 1024 * 1024)) != sha256_hex(library)) throw std::runtime_error("strategy library changed during initialization"); + strategy.require_contract(c); + auto warmup = history(original, c.native.present); + if (c.native.present) + require_native_warmup(c.native, warmup); + std::string deployment = + c.native.present + ? native_identity(c.native, c.mode, c.name, c.webhook, original, library, parser_bytes, + parser_config) + : identity(legacy_fields(c), original, library, parser_bytes, parser_config); + Ledger ledger(c.ledger, deployment); + strategy.configure(c); + strategy.begin(c, warmup); + Cursor cursor; auto recorded = ledger.input_count(); for (std::uint64_t i = 0; i < recorded; ++i) { if (stopped) diff --git a/runner/native_startup.cpp b/runner/native_startup.cpp new file mode 100644 index 000000000..830f64c4b --- /dev/null +++ b/runner/native_startup.cpp @@ -0,0 +1,615 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "native_startup.hpp" +#include "transport.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge::live { +namespace { + +constexpr std::size_t kMaxFrame = 1024 * 1024; + +bool line(std::istream& in, std::string& out) { + out.clear(); + char c; + while (in.get(c)) { + if (c == '\n') + return true; + out += c; + if (out.size() > kMaxFrame) + throw std::runtime_error("input line exceeds 1 MiB"); + } + if (in.bad()) + throw std::runtime_error("feed read failed"); + return !out.empty(); +} + +std::string require_text(const Json& obj, const char* key) { + auto value = obj.at(key).text(); + if (value.find('\0') != std::string::npos) + throw std::runtime_error(std::string("native config string contains embedded NUL: ") + key); + if (!semantic_utf8(value)) + throw std::runtime_error(std::string("native config string is not valid UTF-8: ") + key); + return value; +} + +double require_real(const Json& obj, const char* key) { + double value = obj.at(key).real(); + if (value == 0.0) + value = 0.0; + return value; +} + +const char* spec_error(pineforge::NativeRunSpecError error) { + switch (error) { + case pineforge::NativeRunSpecError::EmptyRequiredString: + return "native spec has an empty required string"; + case pineforge::NativeRunSpecError::EmbeddedNul: + return "native spec string contains embedded NUL"; + case pineforge::NativeRunSpecError::InvalidUtf8: + return "native spec string is not valid UTF-8"; + case pineforge::NativeRunSpecError::ZeroRunNumber: + return "native spec run_number must be positive"; + case pineforge::NativeRunSpecError::InvalidTimeframe: + return "native spec timeframe is invalid"; + case pineforge::NativeRunSpecError::IncompatibleTimeframes: + return "native spec timeframes are incompatible"; + case pineforge::NativeRunSpecError::UnresolvedTimezone: + return "native spec timezone is unresolved"; + case pineforge::NativeRunSpecError::InvalidSession: + return "native spec session is invalid"; + case pineforge::NativeRunSpecError::NotFinitePositive: + return "native spec requires a finite positive number"; + case pineforge::NativeRunSpecError::SlippageOutOfRange: + return "native spec slippage_ticks exceeds native integer range"; + case pineforge::NativeRunSpecError::UnknownFeeKind: + return "unknown native fee_kind"; + case pineforge::NativeRunSpecError::NotFiniteNonnegative: + return "native spec requires a finite nonnegative number"; + case pineforge::NativeRunSpecError::UnknownCloseExecution: + return "unknown native close_execution"; + case pineforge::NativeRunSpecError::UnknownOpenDirections: + return "unknown native allowed_open_directions"; + case pineforge::NativeRunSpecError::ZeroLotLimit: + return "native spec max_open_lots must be positive when present"; + case pineforge::NativeRunSpecError::AllocationFailure: + return "native spec validation allocation failed"; + case pineforge::NativeRunSpecError::CalendarFailure: + return "native spec calendar validation failed"; + case pineforge::NativeRunSpecError::None: + return "native spec is valid"; + } + return "native spec is invalid"; +} + +Json identity_null() { + Json j; + j.kind = Json::Kind::Null; + return j; +} + +std::optional read_resource_bytes(const std::string& path) { + const int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) + return std::nullopt; + struct stat st {}; + if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode) || st.st_size <= 0 || + static_cast(st.st_size) > 8ULL * 1024 * 1024) { + ::close(fd); + return std::nullopt; + } + std::string bytes(static_cast(st.st_size), '\0'); + std::size_t off = 0; + while (off < bytes.size()) { + const ssize_t n = ::read(fd, bytes.data() + off, bytes.size() - off); + if (n < 0) { + if (errno == EINTR) + continue; + ::close(fd); + return std::nullopt; + } + if (n == 0) + break; + off += static_cast(n); + } + ::close(fd); + if (off == 0) + return std::nullopt; + bytes.resize(off); + return bytes; +} + +const char* timezone_kind_name(pineforge::native_calendar::TimezoneSourceKind kind) { + using Kind = pineforge::native_calendar::TimezoneSourceKind; + switch (kind) { + case Kind::Utc: + return "utc"; + case Kind::FixedOffset: + return "fixed-offset"; + case Kind::PosixExplicit: + return "posix-explicit"; + case Kind::PosixDefaultDst: + return "posix-default-dst"; + case Kind::Tzfile: + return "tzfile"; + } + return "unknown"; +} + +} // namespace + +bool semantic_utf8(std::string_view bytes) { + std::uint32_t value = 0, minimum = 0; + unsigned remaining = 0; + for (const unsigned char byte : bytes) { + if (remaining) { + if ((byte & 0xc0) != 0x80) + return false; + value = (value << 6) | (byte & 0x3f); + if (--remaining == 0 && + (value < minimum || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))) + return false; + } else if (byte <= 0x7f) + continue; + else if (byte >= 0xc2 && byte <= 0xdf) { + value = byte & 0x1f; + minimum = 0x80; + remaining = 1; + } else if (byte >= 0xe0 && byte <= 0xef) { + value = byte & 0x0f; + minimum = 0x800; + remaining = 2; + } else if (byte >= 0xf0 && byte <= 0xf4) { + value = byte & 0x07; + minimum = 0x10000; + remaining = 3; + } else + return false; + } + return remaining == 0; +} + +Json json_real(double value) { + if (!std::isfinite(value)) + throw std::runtime_error("nonfinite native identity number"); + if (value == 0.0) + value = 0.0; + std::ostringstream s; + s.imbue(std::locale::classic()); + s << std::setprecision(17) << value; + return Json::number(s.str()); +} + +Json json_u64(std::uint64_t value) { return Json::number(std::to_string(value)); } + +NativeConfigValues parse_native_config(const std::string& text) { + auto root = parse_json(text); + only_fields(root, {"run", "clock", "instrument", "execution"}); + const auto& run = root.at("run"); + const auto& clock = root.at("clock"); + const auto& instrument = root.at("instrument"); + const auto& execution = root.at("execution"); + only_fields(run, {"session_key", "run_number"}); + only_fields(clock, {"input_tf", "script_tf", "timezone", "session", "chart_timezone"}); + only_fields(instrument, {"ticker", "tickerid", "type", "currency", "basecurrency", "description", + "volumetype"}); + only_fields(execution, {"initial_capital", "point_value", "account_fx", "price_tick", + "slippage_ticks", "fee_kind", "fee_value", "quantity_grid", + "close_execution", "max_abs_units", "max_open_lots", + "allowed_open_directions", "initial_margin_fraction"}); + NativeConfigValues n; + n.present = true; + n.session_key = require_text(run, "session_key"); + n.run_number = run.at("run_number").integer(); + n.input_tf = require_text(clock, "input_tf"); + n.script_tf = require_text(clock, "script_tf"); + n.timezone = require_text(clock, "timezone"); + n.session = require_text(clock, "session"); + n.chart_timezone = require_text(clock, "chart_timezone"); + n.ticker = require_text(instrument, "ticker"); + n.tickerid = require_text(instrument, "tickerid"); + n.type = require_text(instrument, "type"); + n.currency = require_text(instrument, "currency"); + n.basecurrency = require_text(instrument, "basecurrency"); + n.description = require_text(instrument, "description"); + n.volumetype = require_text(instrument, "volumetype"); + n.initial_capital = require_real(execution, "initial_capital"); + n.point_value = require_real(execution, "point_value"); + n.account_fx = require_real(execution, "account_fx"); + n.price_tick = require_real(execution, "price_tick"); + n.slippage_ticks = execution.at("slippage_ticks").integer(); + if (n.slippage_ticks > static_cast(std::numeric_limits::max())) + throw std::runtime_error("native spec slippage_ticks exceeds native integer range"); + auto fee = require_text(execution, "fee_kind"); + if (fee == "Percent") + n.fee_kind = 0; + else if (fee == "CashPerUnit") + n.fee_kind = 1; + else if (fee == "CashPerExecution") + n.fee_kind = 2; + else + throw std::runtime_error("unknown native fee_kind"); + n.fee_value = require_real(execution, "fee_value"); + auto close = require_text(execution, "close_execution"); + if (close == "NextEligiblePoint") + n.close_execution = 0; + else if (close == "AfterCalculation") + n.close_execution = 1; + else + throw std::runtime_error("unknown native close_execution"); + n.allowed_open_directions = execution.at("allowed_open_directions").integer(); + const auto optional_field = [&](const char* key, auto apply) { + if (!execution.members.count(key)) + throw std::runtime_error(std::string("missing field ") + key); + if (execution.members.at(key).kind == Json::Kind::Null) + return; + apply(); + }; + optional_field("quantity_grid", [&] { + n.quantity_grid = require_real(execution, "quantity_grid"); + n.optional_mask |= 1u; + }); + optional_field("max_abs_units", [&] { + n.max_abs_units = require_real(execution, "max_abs_units"); + n.optional_mask |= 2u; + }); + optional_field("initial_margin_fraction", [&] { + n.initial_margin_fraction = require_real(execution, "initial_margin_fraction"); + n.optional_mask |= 4u; + }); + optional_field("max_open_lots", [&] { + n.max_open_lots = execution.at("max_open_lots").integer(); + n.optional_mask |= 8u; + }); + return n; +} + +void apply_native_config(NativeClockBindings& clock, const NativeConfigValues& native) { + auto match = [&](const char* flag, const std::string& cli, const std::string& cfg) { + if (clock.explicit_flags.count(flag) && cli != cfg) + throw std::runtime_error(std::string("CLI ") + flag + " contradicts native-config"); + }; + match("--input-tf", clock.input_tf, native.input_tf); + match("--script-tf", clock.script_tf, native.script_tf); + match("--timezone", clock.timezone, native.timezone); + match("--session", clock.session, native.session); + match("--chart-timezone", clock.chart_timezone, native.chart_timezone); + match("--symbol", clock.symbol, native.tickerid); + if (!clock.explicit_flags.count("--input-tf")) + clock.input_tf = native.input_tf; + if (!clock.explicit_flags.count("--script-tf")) + clock.script_tf = native.script_tf; + if (!clock.explicit_flags.count("--timezone")) + clock.timezone = native.timezone; + if (!clock.explicit_flags.count("--session")) + clock.session = native.session; + if (!clock.explicit_flags.count("--chart-timezone")) + clock.chart_timezone = native.chart_timezone; + if (!clock.explicit_flags.count("--symbol")) + clock.symbol = native.tickerid; +} + +pineforge::NativeRunSpec native_run_spec(const NativeConfigValues& native) { + pineforge::NativeRunSpec spec; + spec.identity.session_key = native.session_key; + spec.identity.run_number = native.run_number; + spec.input_tf = native.input_tf; + spec.script_tf = native.script_tf; + spec.ticker = native.ticker; + spec.tickerid = native.tickerid; + spec.type = native.type; + spec.currency = native.currency; + spec.basecurrency = native.basecurrency; + spec.description = native.description; + spec.volumetype = native.volumetype; + spec.timezone = native.timezone; + spec.session = native.session; + spec.chart_timezone = native.chart_timezone; + spec.initial_capital = native.initial_capital; + spec.point_value = native.point_value; + spec.account_fx = native.account_fx; + spec.price_tick = native.price_tick; + spec.slippage_ticks = native.slippage_ticks; + spec.fee_kind = static_cast(native.fee_kind); + spec.fee_value = native.fee_value; + spec.close_execution = static_cast(native.close_execution); + spec.allowed_open_directions = + static_cast(native.allowed_open_directions); + if (native.optional_mask & 1u) + spec.quantity_grid = native.quantity_grid; + if (native.optional_mask & 2u) + spec.max_abs_units = native.max_abs_units; + if (native.optional_mask & 4u) + spec.initial_margin_fraction = native.initial_margin_fraction; + if (native.optional_mask & 8u) + spec.max_open_lots = native.max_open_lots; + return spec; +} + +void validate_native_config(NativeConfigValues& native) { + if (!native.input_tf.empty() && native.input_tf.back() == 'M') + throw std::runtime_error("native stream refuses monthly input"); + auto spec = native_run_spec(native); + const auto result = pineforge::normalize_native_run_spec(spec); + if (!result) + throw std::runtime_error(spec_error(result.error)); + native.fee_value = spec.fee_value; + const auto input = pineforge::native_calendar::parse_timeframe(native.input_tf); + const auto script = pineforge::native_calendar::parse_timeframe(native.script_tf); + if (!input || !script) + throw std::runtime_error("native spec timeframe is invalid"); + const auto stream = pineforge::native_calendar::stream_compatibility(*input, *script); + if (stream.pairing == pineforge::native_calendar::TimeframePairing::StreamMonthlyInputRefused) + throw std::runtime_error("native stream refuses monthly input"); + switch (stream.pairing) { + case pineforge::native_calendar::TimeframePairing::Passthrough: + case pineforge::native_calendar::TimeframePairing::SameUnitMultiple: + case pineforge::native_calendar::TimeframePairing::FixedDivisible: + case pineforge::native_calendar::TimeframePairing::FixedToCalendar: + case pineforge::native_calendar::TimeframePairing::CalendarToCalendar: + break; + default: + throw std::runtime_error("native spec timeframes are incompatible"); + } +} + +std::vector history(const std::string& csv, bool native) { + std::istringstream in(csv); + std::string row; + if (!line(in, row)) + throw std::runtime_error("empty warmup"); + if (!row.empty() && row.back() == '\r') + row.pop_back(); + if (row != "timestamp,open,high,low,close,volume") + throw std::runtime_error("warmup CSV header must be timestamp,open,high,low,close,volume"); + std::vector bars; + while (line(in, row)) { + if (!row.empty() && row.back() == '\r') + row.pop_back(); + if (row.empty()) + throw std::runtime_error("blank warmup row"); + std::vector cells; + std::size_t begin = 0; + for (;;) { + auto end = row.find(',', begin); + cells.push_back(row.substr(begin, end - begin)); + if (end == std::string::npos) + break; + begin = end + 1; + } + if (cells.size() != 6) + throw std::runtime_error("warmup requires six CSV columns"); + auto stamp = parse_json(cells[0]).integer(); + pf_bar_t b{}; + b.timestamp = stamp; + b.open = parse_json(cells[1]).real(); + b.high = parse_json(cells[2]).real(); + b.low = parse_json(cells[3]).real(); + b.close = parse_json(cells[4]).real(); + b.volume = parse_json(cells[5]).real(); + if (stamp < 0 || b.low <= 0 || b.high < std::max(b.open, b.close) || + b.low > std::min(b.open, b.close) || b.high < b.low || b.volume < 0) + throw std::runtime_error("invalid warmup bar"); + if (!native) { + if (stamp % 60000 || stamp > INT64_MAX - 60000) + throw std::runtime_error("invalid warmup bar"); + if (!bars.empty() && stamp != bars.back().timestamp + 60000) + throw std::runtime_error("warmup must contain contiguous confirmed 1m bars"); + } else if (!bars.empty() && stamp <= bars.back().timestamp) { + throw std::runtime_error("native warmup timestamps must be strictly increasing"); + } + bars.push_back(b); + if (bars.size() >= static_cast(INT_MAX)) + throw std::runtime_error("too many warmup rows"); + } + if (bars.empty()) + throw std::runtime_error("warmup requires at least one bar"); + return bars; +} + +void require_native_warmup(const NativeConfigValues& spec, const std::vector& bars) { + if (bars.empty()) + throw std::runtime_error("warmup requires at least one bar"); + if (bars.size() > static_cast(INT_MAX)) + throw std::runtime_error("too many warmup rows"); + const auto input = pineforge::native_calendar::parse_timeframe(spec.input_tf); + const auto script = pineforge::native_calendar::parse_timeframe(spec.script_tf); + if (!input || !script) + throw std::runtime_error("native spec timeframe is invalid"); + const auto stream = pineforge::native_calendar::stream_compatibility(*input, *script); + if (stream.pairing == pineforge::native_calendar::TimeframePairing::StreamMonthlyInputRefused) + throw std::runtime_error("native stream refuses monthly input"); + switch (stream.pairing) { + case pineforge::native_calendar::TimeframePairing::Passthrough: + case pineforge::native_calendar::TimeframePairing::SameUnitMultiple: + case pineforge::native_calendar::TimeframePairing::FixedDivisible: + case pineforge::native_calendar::TimeframePairing::FixedToCalendar: + case pineforge::native_calendar::TimeframePairing::CalendarToCalendar: + break; + default: + throw std::runtime_error("native spec timeframes are incompatible"); + } + std::vector converted; + converted.reserve(bars.size()); + for (const auto& raw : bars) { + converted.push_back( + pineforge::Bar{raw.open, raw.high, raw.low, raw.close, raw.volume, raw.timestamp}); + } + const auto result = pineforge::preflight_native_inputs( + native_run_spec(spec), converted.data(), static_cast(converted.size()), + pineforge::NativeInputPolicy::StreamWarmup); + if (result.ok()) + return; + switch (result.error) { + case pineforge::NativeInputPreflightError::InSessionGap: + throw std::runtime_error("native warmup has an in-session gap"); + case pineforge::NativeInputPreflightError::Unaligned: + case pineforge::NativeInputPreflightError::OffGridLabel: + throw std::runtime_error("native warmup bar is not aligned to the configured calendar"); + case pineforge::NativeInputPreflightError::OverlappingSlot: + throw std::runtime_error("native warmup input intervals overlap"); + case pineforge::NativeInputPreflightError::NotStrictlyIncreasing: + throw std::runtime_error("native warmup timestamps must be strictly increasing"); + case pineforge::NativeInputPreflightError::StructuralInvalid: + throw std::runtime_error("invalid warmup bar"); + case pineforge::NativeInputPreflightError::CalendarFailure: + throw std::runtime_error("native warmup calendar validation failed"); + default: + throw std::runtime_error("native warmup preflight refused"); + } +} + +Json timezone_rule_identity(std::string_view timezone, bool required) { + if (timezone.empty()) { + if (required) + throw std::runtime_error("native spec timezone is unresolved"); + return identity_null(); + } + if (!semantic_utf8(timezone) || timezone.find('\0') != std::string_view::npos) + throw std::runtime_error("native spec timezone is unresolved"); + auto facts = pineforge::native_calendar::timezone_identity_descriptor(timezone); + if (!facts || !facts->valid() || + facts->semantics_version != + pineforge::native_calendar::TimezoneIdentityDescriptor::kSemanticsVersion) + throw std::runtime_error( + "native timezone identity cannot be established: calendar resource facts are required"); + Json resources; + resources.kind = Json::Kind::Array; + for (const auto& path : facts->resource_paths) { + auto bytes = read_resource_bytes(path); + if (!bytes) + throw std::runtime_error( + "native timezone identity cannot be established: calendar resource is unreadable"); + resources.items.push_back(Json::object({{"sha256", Json::string(sha256_hex(*bytes))}})); + } + return Json::object({ + {"semantics_version", json_u64(facts->semantics_version)}, + {"kind", json_u64(static_cast(facts->kind))}, + {"kind_name", Json::string(timezone_kind_name(facts->kind))}, + {"input", Json::string(facts->input)}, + {"effective_definition", Json::string(facts->effective_definition)}, + {"zoneinfo_root", facts->zoneinfo_root.empty() ? identity_null() + : Json::string(facts->zoneinfo_root)}, + {"resources", std::move(resources)}, + }); +} + +std::string identity_document(const LegacyIdentityFields& fields, const std::string& warmup, + const std::string& library, const std::string& parser_bytes, + const std::string& parser_config) { + Json j = Json::object({{"schema", Json::string("pineforge-native-ledger/v1")}, + {"library", Json::string(sha256_hex(library))}, + {"warmup", Json::string(sha256_hex(warmup))}, + {"mode", Json::string(fields.mode)}, + {"input_tf", Json::string(fields.input_tf)}, + {"script_tf", Json::string(fields.script_tf)}, + {"session", Json::string(fields.session)}, + {"timezone", Json::string(fields.timezone)}, + {"chart_timezone", Json::string(fields.chart_timezone)}, + {"symbol", Json::string(fields.symbol)}, + {"name", Json::string(fields.name)}, + {"webhook", Json::string(fields.webhook)}, + {"parser", Json::string(sha256_hex(parser_bytes))}, + {"parser_config", Json::string(sha256_hex(parser_config))}}); + Json input, overrides; + input.kind = overrides.kind = Json::Kind::Array; + for (const auto& [k, v] : fields.inputs) + input.items.push_back(Json::object({{"key", Json::string(k)}, {"value", Json::string(v)}})); + for (const auto& [k, v] : fields.overrides) + overrides.items.push_back( + Json::object({{"key", Json::string(k)}, {"value", Json::string(v)}})); + Json metadata = Json::object({}); + for (const auto& [k, v] : fields.syminfo) + metadata.members[k] = Json::string(v); + j.members["syminfo"] = metadata; + j.members["inputs"] = input; + j.members["overrides"] = overrides; + return j.dump(); +} + +std::string identity(const LegacyIdentityFields& fields, const std::string& warmup, + const std::string& library, const std::string& parser_bytes, + const std::string& parser_config) { + return sha256_hex(identity_document(fields, warmup, library, parser_bytes, parser_config)); +} + +std::string native_identity_document(const NativeConfigValues& native, const std::string& mode, + const std::string& name, const std::string& webhook, + const std::string& warmup, const std::string& library, + const std::string& parser_bytes, + const std::string& parser_config) { + Json execution = Json::object({ + {"initial_capital", json_real(native.initial_capital)}, + {"point_value", json_real(native.point_value)}, + {"account_fx", json_real(native.account_fx)}, + {"price_tick", json_real(native.price_tick)}, + {"slippage_ticks", json_u64(native.slippage_ticks)}, + {"fee_kind", json_u64(native.fee_kind)}, + {"fee_value", json_real(native.fee_value)}, + {"close_execution", json_u64(native.close_execution)}, + {"allowed_open_directions", json_u64(native.allowed_open_directions)}, + {"quantity_grid", native.optional_mask & 1u ? json_real(native.quantity_grid) : identity_null()}, + {"max_abs_units", native.optional_mask & 2u ? json_real(native.max_abs_units) : identity_null()}, + {"initial_margin_fraction", + native.optional_mask & 4u ? json_real(native.initial_margin_fraction) : identity_null()}, + {"max_open_lots", native.optional_mask & 8u ? json_u64(native.max_open_lots) : identity_null()}, + }); + Json j = Json::object({ + {"schema", Json::string("pineforge-native-run/v1")}, + {"consumer", Json::string(pineforge::kNativeConsumerSemanticVersion)}, + {"driver", Json::string(pineforge::kNativeDriverSemanticVersion)}, + {"calendar", Json::string(pineforge::kNativeCalendarSemanticVersion)}, + {"library", Json::string(sha256_hex(library))}, + {"warmup", Json::string(sha256_hex(warmup))}, + {"mode", Json::string(mode)}, + {"parser", Json::string(sha256_hex(parser_bytes))}, + {"parser_config", Json::string(sha256_hex(parser_config))}, + {"timezone_dependency", timezone_rule_identity(native.timezone, true)}, + {"chart_timezone_dependency", timezone_rule_identity(native.chart_timezone, false)}, + {"run", Json::object({{"session_key", Json::string(native.session_key)}, + {"run_number", json_u64(native.run_number)}})}, + {"clock", Json::object({{"input_tf", Json::string(native.input_tf)}, + {"script_tf", Json::string(native.script_tf)}, + {"timezone", Json::string(native.timezone)}, + {"session", Json::string(native.session)}, + {"chart_timezone", Json::string(native.chart_timezone)}})}, + {"instrument", + Json::object({{"ticker", Json::string(native.ticker)}, + {"tickerid", Json::string(native.tickerid)}, + {"type", Json::string(native.type)}, + {"currency", Json::string(native.currency)}, + {"basecurrency", Json::string(native.basecurrency)}, + {"description", Json::string(native.description)}, + {"volumetype", Json::string(native.volumetype)}})}, + {"execution", execution}, + {"name", Json::string(name)}, + {"webhook", Json::string(webhook)}, + }); + return j.dump(); +} + +std::string native_identity(const NativeConfigValues& native, const std::string& mode, + const std::string& name, const std::string& webhook, + const std::string& warmup, const std::string& library, + const std::string& parser_bytes, const std::string& parser_config) { + return sha256_hex(native_identity_document(native, mode, name, webhook, warmup, library, + parser_bytes, parser_config)); +} + +} // namespace pineforge::live diff --git a/runner/native_startup.hpp b/runner/native_startup.hpp new file mode 100644 index 000000000..18fe77e6a --- /dev/null +++ b/runner/native_startup.hpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "json.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace pineforge::live { + +struct NativeConfigValues { + std::string session_key, input_tf, script_tf, ticker, tickerid, type, currency, basecurrency, + description, volumetype, timezone, session, chart_timezone; + std::uint64_t run_number = 1, max_open_lots = 0; + double initial_capital = 0, point_value = 0, account_fx = 0, price_tick = 0, fee_value = 0, + quantity_grid = 0, max_abs_units = 0, initial_margin_fraction = 0; + std::uint32_t slippage_ticks = 0, fee_kind = 0, close_execution = 0, allowed_open_directions = 3, + optional_mask = 0; + bool present = false; +}; + +struct NativeClockBindings { + std::string input_tf, script_tf, timezone, session, chart_timezone, symbol; + std::set explicit_flags; +}; + +struct LegacyIdentityFields { + std::string mode, input_tf, script_tf, session, timezone, chart_timezone, symbol, name, webhook; + std::vector> inputs, overrides, syminfo; +}; + +bool semantic_utf8(std::string_view bytes); +Json json_real(double value); +Json json_u64(std::uint64_t value); + +NativeConfigValues parse_native_config(const std::string& text); +void apply_native_config(NativeClockBindings& clock, const NativeConfigValues& native); +void validate_native_config(NativeConfigValues& native); +pineforge::NativeRunSpec native_run_spec(const NativeConfigValues& native); + +std::vector history(const std::string& csv, bool native = false); +void require_native_warmup(const NativeConfigValues& spec, const std::vector& bars); + +Json timezone_rule_identity(std::string_view timezone, bool required); + +std::string identity_document(const LegacyIdentityFields& fields, const std::string& warmup, + const std::string& library, const std::string& parser_bytes, + const std::string& parser_config); +std::string identity(const LegacyIdentityFields& fields, const std::string& warmup, + const std::string& library, const std::string& parser_bytes, + const std::string& parser_config); + +std::string native_identity_document(const NativeConfigValues& native, const std::string& mode, + const std::string& name, const std::string& webhook, + const std::string& warmup, const std::string& library, + const std::string& parser_bytes, + const std::string& parser_config); +std::string native_identity(const NativeConfigValues& native, const std::string& mode, + const std::string& name, const std::string& webhook, + const std::string& warmup, const std::string& library, + const std::string& parser_bytes, const std::string& parser_config); + +} // namespace pineforge::live diff --git a/scripts/check_aggregate_cpp_versions.py b/scripts/check_aggregate_cpp_versions.py index d229b9db0..16a9de319 100644 --- a/scripts/check_aggregate_cpp_versions.py +++ b/scripts/check_aggregate_cpp_versions.py @@ -44,21 +44,21 @@ def standalone_scope(text, outer, version): def check_texts(files): header = clean(files[FILES[0]]) namespaces = re.findall(r'inline\s+namespace\s+(engine_script_run_v\d+)\s*\{', header) - if namespaces != ["engine_script_run_v11", "engine_script_run_v11"]: - raise ValueError("PendingOrder/BacktestEngine require engine_script_run_v11") + if namespaces != ["engine_script_run_v12", "engine_script_run_v12"]: + raise ValueError("PendingOrder/BacktestEngine require engine_script_run_v12") broker = body(clean(files[FILES[1]]), r'uint64_t\s+BacktestEngine::broker_state_hash\(\)\s+const\s*\{', "broker hash") - if not re.match(r'\s*Fnv\s+f;\s*f\.s\("pineforge-broker-state/v11"\);', broker): - raise ValueError("broker entry requires v11 domain") + if not re.match(r'\s*Fnv\s+f;\s*f\.s\("pineforge-broker-state/v12"\);', broker): + raise ValueError("broker entry requires v12 domain") stream = body(clean(files[FILES[2]]), r'uint64_t\s+BacktestEngine::stream_state_hash\(\)\s+const\s*\{', "stream hash") compact = re.sub(r'\s+', '', stream) - fold = "integer(11);integer(broker_state_hash());" + fold = "integer(12);integer(broker_state_hash());" if compact.count(fold) != 1: - raise ValueError("stream entry requires v11 then broker hash") + raise ValueError("stream entry requires v12 then broker hash") prefix = compact[:compact.index(fold)] if prefix.count('{') != prefix.count('}') or (prefix and prefix[-1] not in ';}'): - raise ValueError("stream v11 fold must be unconditional") + raise ValueError("stream v12 fold must be unconditional") life = standalone_scope(files[FILES[3]], "pineforge::exit_legs", "lifecycle_v1") for name in ("Lifecycle", "Definition", "Action", "Frame", "Barrier", "Suspension"): if not re.search(r'\b(?:class|struct)\s+' + name + r'\s*\{', life): @@ -86,7 +86,7 @@ def check_texts(files): for name, text in files.items(): if name.startswith("include/pineforge/compat/pine/"): found = re.findall(r'inline\s+namespace\s+(engine_script_run_v\d+)\s*\{', clean(text)) - if any(value != "engine_script_run_v11" for value in found): + if any(value != "engine_script_run_v12" for value in found): raise ValueError(name + " has a stale PendingOrder forward declaration") @@ -103,4 +103,4 @@ def check(root=ROOT): if __name__ == "__main__": check() - print("aggregate v11, standalone admission v2 and lifecycle/cancellation v1 ownership verified") + print("aggregate v12, standalone admission v2 and lifecycle/cancellation v1 ownership verified") diff --git a/scripts/check_broker_state_hash_coverage.py b/scripts/check_broker_state_hash_coverage.py index 81ca50776..95db6ab1f 100644 --- a/scripts/check_broker_state_hash_coverage.py +++ b/scripts/check_broker_state_hash_coverage.py @@ -554,16 +554,16 @@ def _runtime_version_coverage(header: str, source: str, stream: str) -> None: """ header = _strip_cpp_comments(header) namespaces = re.findall(r"inline\s+namespace\s+(engine_script_run_v\d+)\s*\{", header) - if namespaces != ["engine_script_run_v11", "engine_script_run_v11"]: - raise ValueError("PendingOrder and BacktestEngine layouts require internal namespace engine_script_run_v11") + if namespaces != ["engine_script_run_v12", "engine_script_run_v12"]: + raise ValueError("PendingOrder and BacktestEngine layouts require internal namespace engine_script_run_v12") broker = _one_braced_body(source, r"uint64_t\s+BacktestEngine::broker_state_hash\(\)\s+const\s*\{", "broker hash") - if not re.match(r'\s*Fnv\s+f;\s*f\.s\("pineforge-broker-state/v11"\);', broker): - raise ValueError("broker hash must start with pineforge-broker-state/v11") + if not re.match(r'\s*Fnv\s+f;\s*f\.s\("pineforge-broker-state/v12"\);', broker): + raise ValueError("broker hash must start with pineforge-broker-state/v12") stream_body = _one_braced_body(_strip_cpp_comments(stream), r"uint64_t\s+BacktestEngine::stream_state_hash\(\)\s+const\s*\{", "stream hash") compact = re.sub(r"\s+", "", stream_body) - fold = "integer(11);integer(broker_state_hash());" + fold = "integer(12);integer(broker_state_hash());" if compact.count(fold) != 1: raise ValueError("stream hash requires version 9 followed by the broker hash") prefix = compact[:compact.index(fold)] diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index fde13596a..07a572c3b 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -70,6 +70,8 @@ "pf_version_get", "pf_version_string", "pf_abi_version", + "strategy_execution_contract", + "strategy_configure_native_v1", }) _PF_API_DECL = re.compile(r"^\s*PF_API\b.+\b(\w+)\s*\(") diff --git a/scripts/check_native_cpp_abi.py b/scripts/check_native_cpp_abi.py new file mode 100644 index 000000000..bed6ff2d5 --- /dev/null +++ b/scripts/check_native_cpp_abi.py @@ -0,0 +1,655 @@ +#!/usr/bin/env python3 +"""Compile/link-only checks for standalone native C++ ABI pairing. + +Frozen header closures are tracked fixtures: no Git history or network is needed. +Every translation unit must compile before expected linker failures are tested. +No produced executable is run. C ABI checks are separate. +""" +from __future__ import annotations + +import argparse +import gzip +import hashlib +import json +import re +import subprocess +import tempfile +from pathlib import Path + +from check_native_cpp_versions import FILES, check as check_native_versions + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests/fixtures/native_cpp_abi" + +ORDER_COMMIT = "1acaf33e0340f6ae09d9529a3b6a0c12742dcb0d" +ORDER_TREE = "ced31532e86bd41bc8753330ea0abcf379fdc05f" +ORDER_HEADER_SHA = "ff32dd1b9a34bc6868b86c8dc14d27513e5ea2a710c7657c895662bcde65ee71" +DRAFT_HEADER_SHA = "a8e34c127b3d8d95f5556cf2e99fbc3d93c68c85f607a0e5e3e044b904193291" +DRIVER_COMMIT = "08b5c8868eadd3022a62b32e7e2cabe0640e75f4" +DRIVER_TREE = "3482d155df5fe91c5dfd4a05fdfb506641d0ee19" +SPEC_COMMIT = "262a28013ed277990349e845ba0554a63f6fbc79" +SPEC_TREE = "fb00ed9ba02cede2fe08ee8113ae32f71fd26b00" +CAL262_HEADER_SHA = "c47b3ee15d69587879cca8f491e73880cddae1ce3e7dfd0d1f4db16dfb8fcf4b" +SPEC_HEADER_SHA = "b263a8bf3a68f58201c968ffa9ec8b54633fe3c752332df164ea11cf4bce3c7c" + +CURRENT_ORDER_VARIANT = 10 +CURRENT_CALENDAR_INTERVAL = 40 +CURRENT_COORDINATE = 80 + +ORDER_CALLER = '''#include +int main() { + using namespace pineforge::native_order; + WorkingRequestCore core({"abi-witness", 1}); + uint64_t incarnation = 1, ordinal = 1; + Request request{pineforge::execution::Flatten{}, "", ""}; + auto submitted = core.submit(request, 0, incarnation, ordinal); + return int(core.history().size() + unsigned(submitted.status)); +} +''' +CALENDAR_CALLER = '''#include +int main() { + using namespace pineforge::native_calendar; + auto timeframe = parse_timeframe("1"); + auto calendar = parse_session("24x7", "UTC"); + auto key = period_key(*calendar, *timeframe, 0); + auto interval = interval_containing(*calendar, *timeframe, 0); + (void) key; + return interval ? 0 : 1; +} +''' +PARSE_TIMEFRAME_CALLER = '''#include +int main() { + auto tf = pineforge::native_calendar::parse_timeframe("1"); + return tf ? 0 : 1; +} +''' +DESCRIPTOR_CALLER = '''#include +int main() { + auto d = pineforge::native_calendar::timezone_identity_descriptor("UTC"); + return d && d->valid() ? 0 : 1; +} +''' +SPEC_CALLER = '''#include +int main() { + pineforge::NativeRunSpec spec; + auto v = pineforge::validate_native_run_spec(spec); + auto n = pineforge::normalize_native_run_spec(spec); + return int(v.error) + int(n.error); +} +''' +BAR_CALLER = '''#include +int main() { + pineforge::Bar bar{}; + return pineforge::native_bar_structurally_valid(bar) ? 0 : 1; +} +''' +PREFLIGHT_CALLER = '''#include +#include +int main() { + pineforge::NativeRunSpec spec; + auto r = pineforge::preflight_native_inputs(spec, nullptr, 0, + pineforge::NativeInputPolicy::Batch); + return int(r.error); +} +''' +# Labeled consumer-defined type seam. Not a production export. +COORDINATE_PROVIDER = '''#include +long long abi_accept_coordinate(const pineforge::NativeCoordinate& c) { + return c.last_traded_close_ms; +} +long long abi_accept_decision(const pineforge::NativeDecisionContext& d) { + return d.decision_floor_ms; +} +''' +COORDINATE_CALLER = '''#include +long long abi_accept_coordinate(const pineforge::NativeCoordinate&); +long long abi_accept_decision(const pineforge::NativeDecisionContext&); +int main() { + pineforge::NativeCoordinate c{}; + pineforge::NativeDecisionContext d{}; + return int(abi_accept_coordinate(c) + abi_accept_decision(d)); +} +''' +HOST_CALLER = '''#include +#include +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +int main(int argc, char** argv) { + auto* host = reinterpret_cast(argv); + auto state = host->native_state(); + return int(state.kind); +} +''' +# Labeled minimal symbol control. Not a historical NativeRunSpec runtime. +SPEC_SYMBOL_CONTROL = '''#include +namespace pineforge { +NativeRunSpecValidation validate_native_run_spec(const NativeRunSpec&) noexcept { return {}; } +NativeRunSpecValidation normalize_native_run_spec(NativeRunSpec&) noexcept { return {}; } +} +''' + + +def sha256(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() + + +def git_blob(raw: bytes) -> str: + return hashlib.sha1(b"blob " + str(len(raw)).encode() + b"\0" + raw).hexdigest() + + +def frozen_contents(fixture: Path, manifest: dict) -> dict: + archive = (fixture / manifest["archive"]).read_bytes() + if sha256(archive) != manifest["archive_sha256"]: + raise RuntimeError(str(fixture) + " archive digest mismatch") + contents = json.loads(gzip.decompress(archive)) + if contents.keys() != manifest["files"].keys(): + raise RuntimeError(str(fixture) + " file set mismatch") + return contents + + +def authenticate_fixture(fixture: Path, destination: Path, expected: dict | None = None) -> dict: + manifest = json.loads((fixture / "manifest.json").read_text()) + if expected: + for key, value in expected.items(): + if manifest.get(key) != value: + raise RuntimeError(str(fixture) + " does not identify the pinned contract: " + key) + contents = frozen_contents(fixture, manifest) + for name, content in contents.items(): + relative = Path(name) + if (relative.is_absolute() or ".." in relative.parts + or relative.parts[0] not in ("pineforge", "src")): + raise RuntimeError("invalid native ABI fixture path: " + name) + raw = content.encode() + info = manifest["files"][name] + if (sha256(raw) != info["sha256"] or len(raw) != info["bytes"] + or git_blob(raw) != info["git_blob"]): + raise RuntimeError("native ABI fixture digest mismatch: " + name) + if relative.parts[0] == "pineforge": + path = destination / "include" / relative + else: + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(raw) + return manifest + + +def diagnostic_text(completed: subprocess.CompletedProcess[str]) -> str: + return (completed.stdout or "") + (completed.stderr or "") + + +def undefined_mentions(text: str, symbol: str) -> bool: + if "undefined" not in text.lower(): + return False + return symbol in text + + +def assembly_layout_values(text: str, expected: int) -> list[int]: + """Read compiler-emitted uint64 constants without executing a binary.""" + active = False + values: list[int] = [] + for line in text.splitlines(): + if re.match(r"^\s*_?abi_layout:\s*(?:[#;].*)?$", line): + active = True + continue + if not active: + continue + match = re.match(r"^\s*\.(?:quad|xword|8byte)\s+([^#;]+)", line) + if match: + values.append(int(match.group(1).strip(), 0)) + if len(values) == expected: + return values + elif re.match(r"^\s*(?:[.\w]+:|\.(?:zero|space|ascii|asciz|byte)\b)", line): + break + raise RuntimeError("compiler assembly did not expose the complete abi_layout array") + + +def order_layout_source(variant_size: int) -> str: + return f'''#include +#include +#include +using namespace pineforge::native_order; +static_assert(std::variant_size_v == {variant_size}); +extern "C" const unsigned long long abi_layout[] = {{ + sizeof(WorkingRequestCore), sizeof(CommandEvent), std::variant_size_v, + sizeof(Request), sizeof(SubmitResult), sizeof(LiveRequest)}}; +''' + + +def calendar_layout_source(layout: dict) -> str: + return f'''#include +#include +#include +#include +using namespace pineforge::native_calendar; +static_assert(sizeof(NativeInterval) == {layout["NativeInterval_sizeof"]}); +static_assert(sizeof(std::optional) == {layout["optional_NativeInterval_sizeof"]}); +static_assert(sizeof(decltype(period_key(std::declval(), std::declval(), 0))) + == {layout["period_key_return_sizeof"]}); +extern "C" const unsigned long long abi_layout[] = {{ + sizeof(Timeframe), sizeof(std::optional), sizeof(SessionCalendar), + sizeof(NativeInterval), sizeof(std::optional), + offsetof(NativeInterval, last_traded_close_ms), + sizeof(decltype(period_key(std::declval(), std::declval(), 0)))}}; +''' + + +def driver_layout_source(layout: dict) -> str: + return f'''#include +#include +static_assert(sizeof(pineforge::NativeCoordinate) == {layout["NativeCoordinate_sizeof"]}); +static_assert(sizeof(pineforge::NativeDriverPoint) == {layout["NativeDriverPoint_sizeof"]}); +static_assert(sizeof(pineforge::NativeDecisionContext) == {layout["NativeDecisionContext_sizeof"]}); +static_assert(offsetof(pineforge::NativeCoordinate, last_traded_close_ms) + == {layout["NativeCoordinate_last_traded_close_ms_offset"]}); +extern "C" const unsigned long long abi_layout[] = {{ + sizeof(pineforge::NativeCoordinate), sizeof(pineforge::NativeDriverPoint), + sizeof(pineforge::NativeDecisionContext), + offsetof(pineforge::NativeCoordinate, last_traded_close_ms)}}; +''' + + +def current_order_layout() -> str: + # std::string/variant object sizes differ between libc++ and libstdc++. + # The alternative count is invariant; record actual target sizes below. + return order_layout_source(CURRENT_ORDER_VARIANT) + + +def current_calendar_layout() -> str: + return calendar_layout_source({ + "NativeInterval_sizeof": CURRENT_CALENDAR_INTERVAL, + "optional_NativeInterval_sizeof": 48, + "period_key_return_sizeof": 16, + "Timeframe_sizeof": 40, + "optional_Timeframe_sizeof": 48, + "SessionCalendar_sizeof": 88, + "NativeInterval_last_traded_close_ms_offset": 16, + }) + + +def current_driver_layout() -> str: + return driver_layout_source({ + "NativeCoordinate_sizeof": CURRENT_COORDINATE, + "NativeDriverPoint_sizeof": 112, + "NativeDecisionContext_sizeof": 168, + "NativeCoordinate_last_traded_close_ms_offset": 32, + }) + + +def nm_symbols(obj: Path) -> str: + listed = subprocess.run(["nm", "-g", "-C", str(obj)], capture_output=True, text=True) + if listed.returncode: + return listed.stderr or listed.stdout + return listed.stdout + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--compiler", required=True) + parser.add_argument("--library", required=True) + parser.add_argument("--include", required=True) + parser.add_argument("--generated-include", required=True) + parser.add_argument("--extra-flag", action="append", default=[]) + parser.add_argument("--receipt", type=Path) + args = parser.parse_args() + + include = Path(args.include).resolve() + source_root = include.parent + library = Path(args.library).resolve() + if not library.is_file(): + raise RuntimeError("library is not a file: " + str(library)) + lib_mtime = library.stat().st_mtime + stale = [name for name in FILES + if (source_root / name).stat().st_mtime > lib_mtime] + if stale: + raise RuntimeError("library older than tracked source: " + ", ".join(stale)) + check_native_versions(source_root) + + compiler_id = subprocess.run( + [args.compiler, "--version"], capture_output=True, text=True, timeout=30) + if compiler_id.returncode: + raise RuntimeError("compiler --version failed:\n" + diagnostic_text(compiler_id)) + clang = "clang" in (compiler_id.stdout + compiler_id.stderr).lower() + + # Literal diagnostic controls: old unversioned needles must not match versioned names. + controls = ( + ("pineforge::native_calendar::parse_timeframe(", + "pineforge::native_calendar::native_calendar_v2::parse_timeframe("), + ("pineforge::native_calendar::timezone_identity_descriptor(", + "pineforge::native_calendar::native_calendar_v2::timezone_identity_descriptor("), + ("pineforge::native_order::WorkingRequestCore::submit(", + "pineforge::native_order::native_order_v1::WorkingRequestCore::submit("), + ("pineforge::validate_native_run_spec(", + "pineforge::native_run_spec_v1::validate_native_run_spec("), + ("pineforge::native_bar_structurally_valid(", + "pineforge::native_driver_v3::native_bar_structurally_valid("), + ("abi_accept_coordinate(pineforge::NativeCoordinate", + "abi_accept_coordinate(pineforge::native_driver_v3::NativeCoordinate"), + ) + for old, new in controls: + if old in new: + raise RuntimeError("old ABI needle is a substring of the versioned name: " + old) + fake_old = "Undefined symbols\n \"" + old + "int)\"" + fake_new = "undefined reference to `" + new + "int)'" + if not undefined_mentions(fake_old, old) or undefined_mentions(fake_new, old): + raise RuntimeError("link diagnostics confuse old and versioned symbols: " + old) + print("qualified-symbol diagnostic controls passed") + + receipt = { + "library": str(library), + "library_sha256": sha256(library.read_bytes()), + "library_mtime": lib_mtime, + "compiler": (compiler_id.stdout or compiler_id.stderr).splitlines()[0], + "current_namespaces": { + "native_order": "pineforge::native_order::native_order_v1", + "native_calendar": "pineforge::native_calendar::native_calendar_v2", + "native_run_spec": "pineforge::native_run_spec_v1", + "native_driver": "pineforge::native_driver_v3", + "native_host": "pineforge::engine_script_run_v12", + }, + "executable_runs": 0, + "compiles": [], + "links": [], + "layouts": {}, + "symbols": {}, + "fixtures": {}, + } + + common = [args.compiler, "-std=c++17", "-O0", *args.extra_flag] + + with tempfile.TemporaryDirectory(prefix="pf-native-cpp-abi-") as temporary: + root = Path(temporary) + pins = { + "order-1acaf33": { + "source_commit": ORDER_COMMIT, "source_tree": ORDER_TREE, + }, + "calendar-draft-a8e34c": {"content_sha256": DRAFT_HEADER_SHA}, + "calendar-262a280": { + "source_commit": SPEC_COMMIT, "source_tree": SPEC_TREE, + }, + "driver-08b5c88": { + "source_commit": DRIVER_COMMIT, "source_tree": DRIVER_TREE, + }, + "run-spec-262a280": { + "source_commit": SPEC_COMMIT, "source_tree": SPEC_TREE, + }, + } + unpacked = {} + for name, expected in pins.items(): + dest = root / name + manifest = authenticate_fixture(FIXTURE / name, dest, expected) + if name == "calendar-draft-a8e34c": + header = dest / "include/pineforge/native_calendar.hpp" + if sha256(header.read_bytes()) != DRAFT_HEADER_SHA: + raise RuntimeError("draft calendar header content SHA mismatch") + if name == "calendar-262a280": + header = dest / "include/pineforge/native_calendar.hpp" + if sha256(header.read_bytes()) != CAL262_HEADER_SHA: + raise RuntimeError("262a280 calendar header content SHA mismatch") + if name == "order-1acaf33": + header = dest / "include/pineforge/native_order.hpp" + if sha256(header.read_bytes()) != ORDER_HEADER_SHA: + raise RuntimeError("1acaf33 order header content SHA mismatch") + if name == "run-spec-262a280": + header = dest / "include/pineforge/native_run_spec.hpp" + if sha256(header.read_bytes()) != SPEC_HEADER_SHA: + raise RuntimeError("262a280 run-spec header content SHA mismatch") + unpacked[name] = dest + receipt["fixtures"][name] = { + "source_commit": manifest.get("source_commit"), + "source_tree": manifest.get("source_tree"), + "content_sha256": manifest.get("content_sha256"), + "archive_sha256": manifest["archive_sha256"], + } + + def compile_object(name, source, include_path, extra_source_dir=None): + path = root / (name + ".cpp") + path.write_text(source) + obj = root / (name + ".o") + argv = [*common, "-I", str(include_path), "-I", args.generated_include] + if extra_source_dir is not None: + argv.extend(["-I", str(extra_source_dir)]) + argv.extend(["-c", str(path), "-o", str(obj)]) + compiled = subprocess.run(argv, capture_output=True, text=True, timeout=90) + if compiled.returncode: + raise RuntimeError(name + " failed to compile (not a pairing rejection):\n" + + diagnostic_text(compiled)) + receipt["compiles"].append({ + "name": name, "exit": compiled.returncode, + "source_sha256": sha256(source.encode()), + "object_sha256": sha256(obj.read_bytes()), + }) + return obj + + def compile_file(name, source_path, include_path): + obj = root / (name + ".o") + compiled = subprocess.run( + [*common, "-I", str(include_path), "-I", args.generated_include, + "-c", str(source_path), "-o", str(obj)], + capture_output=True, text=True, timeout=90) + if compiled.returncode: + raise RuntimeError(name + " failed to compile (not a pairing rejection):\n" + + diagnostic_text(compiled)) + receipt["compiles"].append({ + "name": name, "exit": compiled.returncode, + "source_sha256": sha256(Path(source_path).read_bytes()), + "object_sha256": sha256(obj.read_bytes()), + }) + return obj + + def record_layout(name, include_path): + receipt["layouts"][name] = {"method": "static_assert"} + if not clang: + assembly = root / (name + ".s") + src = root / (name + ".cpp") + emitted = subprocess.run( + [*common, "-I", str(include_path), "-I", args.generated_include, + "-S", str(src), "-o", str(assembly)], + capture_output=True, text=True, timeout=90) + if emitted.returncode: + raise RuntimeError(name + " could not emit layout assembly:\n" + diagnostic_text(emitted)) + words = 6 if "order" in name else 7 if "calendar" in name else 4 + receipt["layouts"][name] = { + "method": "assembly", + "abi_layout": assembly_layout_values(assembly.read_text(), words), + "assembly_sha256": sha256(assembly.read_bytes()), + } + return + ll = root / (name + ".ll") + src = root / (name + ".cpp") + emitted = subprocess.run( + [*common, "-I", str(include_path), "-I", args.generated_include, + "-S", "-emit-llvm", str(src), "-o", str(ll)], + capture_output=True, text=True, timeout=90) + if emitted.returncode: + receipt["layouts"][name]["llvm"] = "unavailable" + return + receipt["layouts"][name] = { + "method": "llvm", + "abi_layout": [line for line in ll.read_text().splitlines() + if line.startswith("@abi_layout")], + } + + def link(name, objects, runtime, missing=None): + out = root / name + argv = [*common, *[str(x) for x in objects], str(runtime), "-pthread", "-o", str(out)] + linked = subprocess.run(argv, capture_output=True, text=True, timeout=120) + text = diagnostic_text(linked) + if missing is None: + if linked.returncode: + raise RuntimeError(name + " positive control failed to link:\n" + text) + print(name + ": linked (not executed)") + receipt["links"].append({ + "name": name, "outcome": "linked", "exit": linked.returncode, + "not_executed": True, + }) + return + if linked.returncode == 0: + raise RuntimeError(name + " stale C++ pairing unexpectedly linked") + needles = missing if isinstance(missing, (list, tuple)) else [missing] + if any(not undefined_mentions(text, needle) for needle in needles): + raise RuntimeError(name + " failed for an unexpected reason:\n" + text) + print(name + ": rejected missing " + ", ".join(needles) + " (not executed)") + receipt["links"].append({ + "name": name, "outcome": "expected_rejection", + "exit": linked.returncode, "missing": list(needles), + "not_executed": True, + }) + + # Phase 1: compile every caller, layout TU, and old object before any link. + current_order = compile_object("current_order_caller", ORDER_CALLER, include) + current_calendar = compile_object("current_calendar_caller", CALENDAR_CALLER, include) + current_parse = compile_object("current_parse_timeframe_caller", PARSE_TIMEFRAME_CALLER, include) + current_descriptor = compile_object("current_descriptor_caller", DESCRIPTOR_CALLER, include) + current_spec = compile_object("current_spec_caller", SPEC_CALLER, include) + current_bar = compile_object("current_bar_caller", BAR_CALLER, include) + current_preflight = compile_object("current_preflight_caller", PREFLIGHT_CALLER, include) + current_coordinate = compile_object("current_coordinate_caller", COORDINATE_CALLER, include) + current_coordinate_provider = compile_object( + "current_coordinate_provider", COORDINATE_PROVIDER, include) + current_host = compile_object("current_host_caller", HOST_CALLER, include) + + old_order_include = unpacked["order-1acaf33"] / "include" + old_calendar_include = unpacked["calendar-draft-a8e34c"] / "include" + old_desc_include = unpacked["calendar-262a280"] / "include" + old_driver_include = unpacked["driver-08b5c88"] / "include" + old_spec_include = unpacked["run-spec-262a280"] / "include" + + old_order = compile_object("old_order_caller", ORDER_CALLER, old_order_include) + old_calendar = compile_object("old_calendar_caller", CALENDAR_CALLER, old_calendar_include) + old_parse = compile_object("old_parse_timeframe_caller", PARSE_TIMEFRAME_CALLER, old_calendar_include) + old_descriptor = compile_object("old_descriptor_caller", DESCRIPTOR_CALLER, old_desc_include) + old_spec = compile_object("old_spec_caller", SPEC_CALLER, old_spec_include) + old_bar = compile_object("old_bar_caller", BAR_CALLER, old_driver_include) + old_coordinate = compile_object("old_coordinate_caller", COORDINATE_CALLER, old_driver_include) + old_coordinate_provider = compile_object( + "old_coordinate_provider", COORDINATE_PROVIDER, old_driver_include) + + old_order_obj = compile_file( + "old_order_object", unpacked["order-1acaf33"] / "src/native_order.cpp", old_order_include) + old_calendar_obj = compile_file( + "old_calendar_object", + unpacked["calendar-draft-a8e34c"] / "src/native_calendar.cpp", + old_calendar_include) + old_timezone_obj = compile_file( + "old_timezone_object", + unpacked["calendar-draft-a8e34c"] / "src/timezone.cpp", + old_calendar_include) + old_bar_obj = compile_file( + "old_bar_object", unpacked["driver-08b5c88"] / "src/market_driver.cpp", old_driver_include) + old_spec_symbols = compile_object( + "old_spec_symbol_control", SPEC_SYMBOL_CONTROL, old_spec_include) + + old_order_layout = compile_object( + "old_order_layout", + order_layout_source(7), + old_order_include) + old_calendar_layout = compile_object( + "old_calendar_layout", + calendar_layout_source(json.loads((FIXTURE / "calendar-draft-a8e34c/manifest.json").read_text())["layout"]), + old_calendar_include) + old_driver_layout = compile_object( + "old_driver_layout", + driver_layout_source(json.loads((FIXTURE / "driver-08b5c88/manifest.json").read_text())["layout"]), + old_driver_include) + now_order_layout = compile_object("current_order_layout", current_order_layout(), include) + now_calendar_layout = compile_object("current_calendar_layout", current_calendar_layout(), include) + now_driver_layout = compile_object("current_driver_layout", current_driver_layout(), include) + record_layout("old_order_layout", old_order_include) + record_layout("old_calendar_layout", old_calendar_include) + record_layout("old_driver_layout", old_driver_include) + record_layout("current_order_layout", include) + record_layout("current_calendar_layout", include) + record_layout("current_driver_layout", include) + + for name, obj in ( + ("old_order_caller", old_order), + ("old_calendar_caller", old_calendar), + ("old_parse_timeframe_caller", old_parse), + ("old_descriptor_caller", old_descriptor), + ("old_bar_caller", old_bar), + ("old_coordinate_caller", old_coordinate), + ("current_order_caller", current_order), + ("current_calendar_caller", current_calendar), + ("current_parse_timeframe_caller", current_parse), + ("current_descriptor_caller", current_descriptor), + ("current_bar_caller", current_bar), + ("old_order_object", old_order_obj), + ("old_calendar_object", old_calendar_obj), + ("old_bar_object", old_bar_obj), + ("current_coordinate_provider", current_coordinate_provider), + ): + receipt["symbols"][name] = [ + line for line in nm_symbols(obj).splitlines() + if re.search(r"\b[UT]\b", line) and "pineforge" in line + ] + + # Phase 2: matching controls, then expected mismatch rejections. + link("current_order_to_current_library", [current_order], library) + link("current_calendar_to_current_library", [current_calendar], library) + link("current_parse_timeframe_to_current_library", [current_parse], library) + link("current_descriptor_to_current_library", [current_descriptor], library) + link("current_spec_to_current_library", [current_spec], library) + link("current_bar_to_current_library", [current_bar], library) + link("current_preflight_to_current_library", [current_preflight], library) + link("current_host_to_current_library", [current_host], library) + link("current_coordinate_to_current_provider", + [current_coordinate], current_coordinate_provider) + + link("old_order_to_old_object", [old_order], old_order_obj) + link("old_calendar_to_old_objects", [old_calendar, old_timezone_obj], old_calendar_obj) + link("old_parse_timeframe_to_old_objects", [old_parse, old_timezone_obj], old_calendar_obj) + link("old_spec_to_old_symbol_control", [old_spec], old_spec_symbols) + link("old_bar_to_old_object", [old_bar], old_bar_obj) + link("old_coordinate_to_old_provider", [old_coordinate], old_coordinate_provider) + + link("old_order_to_current_library", [old_order], library, + "pineforge::native_order::WorkingRequestCore::submit(") + link("old_calendar_to_current_library", [old_calendar], library, + ["pineforge::native_calendar::parse_timeframe(", + "pineforge::native_calendar::period_key("]) + link("old_parse_timeframe_to_current_library", [old_parse], library, + "pineforge::native_calendar::parse_timeframe(") + link("old_descriptor_to_current_library", [old_descriptor], library, + "pineforge::native_calendar::timezone_identity_descriptor(") + link("old_spec_to_current_library", [old_spec], library, + "pineforge::validate_native_run_spec(") + link("old_bar_to_current_library", [old_bar], library, + "pineforge::native_bar_structurally_valid(") + link("old_coordinate_to_current_provider", [old_coordinate], current_coordinate_provider, + ["abi_accept_coordinate(pineforge::NativeCoordinate", + "abi_accept_decision(pineforge::NativeDecisionContext"]) + + link("current_order_to_old_object", [current_order], old_order_obj, + "pineforge::native_order::native_order_v1::WorkingRequestCore::submit(") + link("current_calendar_to_old_objects", [current_calendar, old_timezone_obj], old_calendar_obj, + "pineforge::native_calendar::native_calendar_v2::parse_timeframe(") + link("current_parse_timeframe_to_old_objects", [current_parse, old_timezone_obj], old_calendar_obj, + "pineforge::native_calendar::native_calendar_v2::parse_timeframe(") + link("current_descriptor_to_old_calendar", [current_descriptor, old_timezone_obj], old_calendar_obj, + "pineforge::native_calendar::native_calendar_v2::timezone_identity_descriptor(") + link("current_spec_to_old_symbol_control", [current_spec], old_spec_symbols, + "pineforge::native_run_spec_v1::validate_native_run_spec(") + link("current_bar_to_old_object", [current_bar], old_bar_obj, + "pineforge::native_driver_v3::native_bar_structurally_valid(") + link("current_coordinate_to_old_provider", [current_coordinate], old_coordinate_provider, + ["abi_accept_coordinate(pineforge::native_driver_v3::NativeCoordinate", + "abi_accept_decision(pineforge::native_driver_v3::NativeDecisionContext"]) + + if args.receipt: + args.receipt.write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps({ + "executable_runs": 0, + "compiles": len(receipt["compiles"]), + "links": len(receipt["links"]), + "library_sha256": receipt["library_sha256"], + "receipt": str(args.receipt) if args.receipt else None, + }, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_native_cpp_versions.py b/scripts/check_native_cpp_versions.py new file mode 100644 index 000000000..71520197e --- /dev/null +++ b/scripts/check_native_cpp_versions.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Source-only native C++ ABI ownership guard; no compiler or engine runs.""" +from pathlib import Path +import re + +from check_aggregate_cpp_versions import body, clean, standalone_scope + +ROOT = Path(__file__).resolve().parents[1] +FILES = ( + "include/pineforge/native_order.hpp", + "src/native_order.cpp", + "include/pineforge/native_calendar.hpp", + "src/native_calendar.cpp", + "include/pineforge/native_run_spec.hpp", + "src/native_run_spec.cpp", + "include/pineforge/market_driver.hpp", + "src/market_driver.cpp", + "include/pineforge/native_host.hpp", + "src/native_execution_consumer.hpp", + "src/native_execution_consumer.cpp", +) + +DRIVER_FORWARD = ( + "inline namespace native_run_spec_v1 { struct NativeRunSpec; }" +) + +TYPE_DEF = r'\b(?:enum\s+class|class|struct)\s+(\w+)\s*(?::[^;{]+)?\{' +ALIAS_DEF = r'\busing\s+(\w+)\s*=' +METHOD_DEF = r'\b(\w+::[~\w]+)\s*\(' +CALL_KEYWORDS = { + "if", "for", "while", "switch", "catch", "return", "sizeof", "static_assert", + "alignas", "noexcept", "decltype", "typeid", "throw", "new", "delete", + "const_cast", "static_cast", "dynamic_cast", "reinterpret_cast", + "requires", "offsetof", "defined", "case", "default", "sizeof", +} + + +def blank_compound_bodies(text): + pattern = re.compile(TYPE_DEF) + while True: + match = pattern.search(text) + if not match: + return text + start = match.end() - 1 + depth = 0 + for at in range(start, len(text)): + depth += (text[at] == '{') - (text[at] == '}') + if depth == 0: + # Drop the whole compound so `class Foo {}` cannot rematch. + text = text[:match.start()] + text[at + 1:] + break + else: + raise ValueError("unclosed type body") + + +def namespace_functions(text): + stripped = blank_compound_bodies(text) + names = [] + for match in re.finditer(r'\b([A-Za-z_]\w*)\s*\(', stripped): + name = match.group(1) + if name in CALL_KEYWORDS or name.startswith("operator"): + continue + names.append(name) + return names + + +def same_names(owner, value, pattern, label, version): + owned = re.findall(pattern, owner) + inner = re.findall(pattern, value) + if owned != inner: + raise ValueError(label + " must belong to " + version) + return inner + + +def require(value, names, version, pattern): + for name in names: + if not re.search(pattern.replace("NAME", re.escape(name)), value): + raise ValueError(name + " must belong to " + version) + + +def require_namespace_functions(value, names, version): + found = namespace_functions(value) + for name in names: + if name not in found: + raise ValueError(name + " must be a namespace-scope declaration in " + version) + + +def versioned(text, outer, version): + value = standalone_scope(text, outer, version) + owner = body(clean(text), r'namespace\s+' + re.escape(outer) + r'\s*\{', outer) + same_names(owner, value, TYPE_DEF, outer + " type definition", version) + same_names(owner, value, ALIAS_DEF, outer + " type alias", version) + same_names(owner, value, METHOD_DEF, outer + " out-of-line method", version) + if namespace_functions(owner) != namespace_functions(value): + raise ValueError(outer + " contains an unversioned free function") + if (not re.search(TYPE_DEF, value) and not namespace_functions(value) + and not re.findall(METHOD_DEF, value)): + raise ValueError(version + " cannot be empty or comment-only") + return value + + +def check_texts(files): + order = versioned(files[FILES[0]], "pineforge::native_order", "native_order_v1") + require(order, ("RunIdentity", "WorkingRequestCore", "Request", "SubmitResult", + "AcceptedEvent", "NoEffectEvent", "MatchRejectedEvent", + "ExecutionAppliedEvent", "TerminalCommit"), + "native_order_v1", r'\b(?:class|struct)\s+NAME\s*\{') + if "CommandEvent" not in re.findall(ALIAS_DEF, order): + raise ValueError("CommandEvent must belong to native_order_v1") + order_src = versioned(files[FILES[1]], "pineforge::native_order", "native_order_v1") + require(order_src, ("WorkingRequestCore::submit", "WorkingRequestCore::replace", + "WorkingRequestCore::cancel", "TerminalCommit::install"), + "native_order_v1", r'\bNAME\s*\(') + + calendar = versioned(files[FILES[2]], "pineforge::native_calendar", "native_calendar_v2") + require(calendar, ("Timeframe", "SessionCalendar", "NativeInterval", + "TimezoneIdentityDescriptor"), + "native_calendar_v2", r'\b(?:class|struct)\s+NAME\s*\{') + require_namespace_functions( + calendar, + ("parse_timeframe", "parse_session", "period_key", "interval_containing", + "timezone_identity_descriptor", "timezone_accepted"), + "native_calendar_v2") + calendar_src = versioned(files[FILES[3]], "pineforge::native_calendar", "native_calendar_v2") + require_namespace_functions( + calendar_src, + ("parse_timeframe", "parse_session", "period_key", "interval_containing", + "timezone_identity_descriptor"), + "native_calendar_v2") + require(calendar_src, ("TimezoneIdentityDescriptor::valid",), + "native_calendar_v2", r'\bNAME\s*\(') + + spec = versioned(files[FILES[4]], "pineforge", "native_run_spec_v1") + require(spec, ("NativeRunSpec", "NativeRunSpecValidation", "NativeRunSpecError", + "NativeRunSpecField"), + "native_run_spec_v1", + r'\b(?:enum\s+class|struct)\s+NAME\s*(?::[^;{]+)?\{') + require_namespace_functions( + spec, ("validate_native_run_spec", "normalize_native_run_spec"), + "native_run_spec_v1") + spec_src = versioned(files[FILES[5]], "pineforge", "native_run_spec_v1") + require_namespace_functions( + spec_src, ("validate_native_run_spec", "normalize_native_run_spec"), + "native_run_spec_v1") + + driver_text = files[FILES[6]] + if driver_text.count(DRIVER_FORWARD) != 1: + raise ValueError("market_driver.hpp must forward-declare NativeRunSpec " + "in native_run_spec_v1 outside native_driver_v3") + driver_clean = clean(driver_text) + driver_owner = body(driver_clean, r'namespace\s+pineforge\s*\{', "pineforge") + driver = body(driver_owner, r'inline\s+namespace\s+native_driver_v3\s*\{', + "native_driver_v3") + if DRIVER_FORWARD in driver or "native_run_spec_v1" in driver: + raise ValueError("NativeRunSpec forward declaration must stay outside native_driver_v3") + if re.search(r'\bstruct\s+NativeRunSpec\s*\{', driver_clean): + raise ValueError("NativeRunSpec definition does not belong to native_driver_v3") + # Compare ownership as if the allowed forward declaration were absent. + driver_without_forward = driver_clean.replace(DRIVER_FORWARD, "", 1) + versioned(driver_without_forward, "pineforge", "native_driver_v3") + require(driver, ("NativeCoordinate", "NativeDriverPoint", "NativeDecisionContext", + "NativeInputPreflightResult", "INativeDriverSink"), + "native_driver_v3", r'\b(?:class|struct)\s+NAME\s*\{') + require_namespace_functions( + driver, ("native_bar_structurally_valid", "preflight_native_inputs"), + "native_driver_v3") + driver_src = versioned(files[FILES[7]], "pineforge", "native_driver_v3") + require_namespace_functions( + driver_src, ("native_bar_structurally_valid", "preflight_native_inputs"), + "native_driver_v3") + + host = versioned(files[FILES[8]], "pineforge", "engine_script_run_v12") + require(host, ("NativeStrategyHost", "NativeStateView", "NativeLifecycleKind", + "NativeFailure", "NativeMarketEvent", "NativeSetupResult", + "NativePhysicalPosition", "NativeAccountObservation"), + "engine_script_run_v12", + r'\b(?:enum\s+class|class|struct)\s+NAME\s*(?::[^;{]+)?\{') + consumer = versioned(files[FILES[9]], "pineforge", "engine_script_run_v12") + require(consumer, ("NativeExecutionConsumer",), + "engine_script_run_v12", r'\bclass\s+NAME\s*') + consumer_src = versioned(files[FILES[10]], "pineforge", "engine_script_run_v12") + require(consumer_src, + ("NativeStrategyHost::configure_native", "NativeStrategyHost::native_state", + "NativeStrategyHost::native_events"), + "engine_script_run_v12", r'\bNAME\s*\(') + + +def load(root=ROOT): + return {name: (root / name).read_text() for name in FILES} + + +def check(root=ROOT): + check_texts(load(root)) + + +if __name__ == "__main__": + check() + print("native_order_v1, native_calendar_v2, native_run_spec_v1, " + "native_driver_v3 and host engine_script_run_v12 ownership verified") diff --git a/scripts/check_script_cpp_abi.py b/scripts/check_script_cpp_abi.py index 4aeb8a8ee..70c71e1f5 100644 --- a/scripts/check_script_cpp_abi.py +++ b/scripts/check_script_cpp_abi.py @@ -16,7 +16,7 @@ BASE_COMMIT = "38dc73e5503fe5395458e5f8df2a2ad78054a1ae" BASE_ENGINE_SHA256 = "06c937a1ccd31815ca7775268ac699ffdfddb1a1f19de4628b777f37e9a6d193" -CURRENT_NAMESPACE = "engine_script_run_v11" +CURRENT_NAMESPACE = "engine_script_run_v12" BASE_NAMESPACE = "engine_script_run_v2" V8_COMMIT = "79921099a9357cb5bbace907a9319479f6640d89" V8_TREE = "e141657c572b4a3855dfee607f9951e331b961d6" @@ -561,26 +561,26 @@ def link(name, obj, runtime, missing_namespace=None): link("basev10_pending_priority_to_v10_symbols", v10_priority, v10_priority_symbols) for name, obj, runtime, expected in [ ("basecc0_pending_priority_to_current", cc0_priority, args.library, "pineforge::engine_script_run_v7::PendingOrder"), - ("current_pending_priority_to_v7_symbols", current_priority, cc0_priority_symbols, "pineforge::engine_script_run_v11::PendingOrder"), + ("current_pending_priority_to_v7_symbols", current_priority, cc0_priority_symbols, "pineforge::engine_script_run_v12::PendingOrder"), ("baseff54_pending_priority_to_current", shipped_priority, args.library, "pineforge::engine_script_run_v6::PendingOrder"), - ("current_pending_priority_to_v6_symbols", current_priority, shipped_priority_symbols, "pineforge::engine_script_run_v11::PendingOrder"), + ("current_pending_priority_to_v6_symbols", current_priority, shipped_priority_symbols, "pineforge::engine_script_run_v12::PendingOrder"), ("base149_pending_priority_to_current", activation_priority, args.library, "pineforge::engine_script_run_v5::PendingOrder"), - ("current_pending_priority_to_v5_symbols", current_priority, activation_priority_symbols, "pineforge::engine_script_run_v11::PendingOrder"), + ("current_pending_priority_to_v5_symbols", current_priority, activation_priority_symbols, "pineforge::engine_script_run_v12::PendingOrder"), ("basec45_pending_priority_to_current", prior_priority, args.library, "pineforge::PendingOrder"), ("current_pending_priority_to_v4_symbols", current_priority, prior_priority_symbols, - "pineforge::engine_script_run_v11::PendingOrder"), + "pineforge::engine_script_run_v12::PendingOrder"), ("basev8_pending_priority_to_current", v8_priority, args.library, "pineforge::engine_script_run_v8::PendingOrder"), ("current_pending_priority_to_v8_symbols", current_priority, v8_priority_symbols, - "pineforge::engine_script_run_v11::PendingOrder"), + "pineforge::engine_script_run_v12::PendingOrder"), ("basev9_pending_priority_to_current", v9_priority, args.library, "pineforge::engine_script_run_v9::PendingOrder"), ("current_pending_priority_to_v9_symbols", current_priority, v9_priority_symbols, - "pineforge::engine_script_run_v11::PendingOrder"), + "pineforge::engine_script_run_v12::PendingOrder"), ("basev10_pending_priority_to_current", v10_priority, args.library, "pineforge::engine_script_run_v10::PendingOrder"), ("current_pending_priority_to_v10_symbols", current_priority, v10_priority_symbols, - "pineforge::engine_script_run_v11::PendingOrder"), + "pineforge::engine_script_run_v12::PendingOrder"), ]: result = subprocess.run([*common, str(obj), str(runtime), "-pthread", "-o", str(root / name)], capture_output=True, text=True, timeout=60) diff --git a/scripts/test_aggregate_cpp_versions.py b/scripts/test_aggregate_cpp_versions.py index 2bcb844b0..86aa491c3 100644 --- a/scripts/test_aggregate_cpp_versions.py +++ b/scripts/test_aggregate_cpp_versions.py @@ -19,16 +19,16 @@ def test_current(self): def test_engine_and_forward_declarations(self): for path, text in DATA.items(): - if 'engine_script_run_v11' in text: + if 'engine_script_run_v12' in text: with self.subTest(path=path): - self.reject(path, 'engine_script_run_v11', 'engine_script_run_v10') + self.reject(path, 'engine_script_run_v12', 'engine_script_run_v11') def test_broker_and_stream_entry_points(self): - self.reject('src/engine_state_hash.cpp', 'pineforge-broker-state/v11', 'pineforge-broker-state/v10') - self.reject('src/engine_stream.cpp', 'integer(11); integer(broker_state_hash());', - 'integer(10); integer(broker_state_hash());') - self.reject('src/engine_stream.cpp', 'integer(11); integer(broker_state_hash());', - 'if (false) { integer(11); integer(broker_state_hash()); }') + self.reject('src/engine_state_hash.cpp', 'pineforge-broker-state/v12', 'pineforge-broker-state/v11') + self.reject('src/engine_stream.cpp', 'integer(12); integer(broker_state_hash());', + 'integer(11); integer(broker_state_hash());') + self.reject('src/engine_stream.cpp', 'integer(12); integer(broker_state_hash());', + 'if (false) { integer(12); integer(broker_state_hash()); }') def test_standalone_owners(self): for path, namespace in ( diff --git a/scripts/test_broker_state_hash_coverage.py b/scripts/test_broker_state_hash_coverage.py index 2394aa4df..60f1abc3b 100644 --- a/scripts/test_broker_state_hash_coverage.py +++ b/scripts/test_broker_state_hash_coverage.py @@ -176,26 +176,26 @@ def test_quantity_model_additions_and_variant_drift_fail_closed(self): self.assertIn(old, QUANTITY) self.assertEqual(self.check(quantity=QUANTITY.replace(old, new))[0], 1) - def test_layout_and_hash_versions_must_match_v11_contract(self): - self.assertIn("engine_script_run_v11", HEADER) - self.assertIn('f.s("pineforge-broker-state/v11");', SOURCE) - self.assertIn("integer(11); integer(broker_state_hash());", STREAM) - self.assertEqual(self.check(header=HEADER.replace("engine_script_run_v11", "engine_script_run_v10"))[0], 1) - for replacement in ['f.s("pineforge-broker-state/v10");', '', - '// f.s("pineforge-broker-state/v11");']: + def test_layout_and_hash_versions_must_match_v12_contract(self): + self.assertIn("engine_script_run_v12", HEADER) + self.assertIn('f.s("pineforge-broker-state/v12");', SOURCE) + self.assertIn("integer(12); integer(broker_state_hash());", STREAM) + self.assertEqual(self.check(header=HEADER.replace("engine_script_run_v12", "engine_script_run_v11"))[0], 1) + for replacement in ['f.s("pineforge-broker-state/v11");', '', + '// f.s("pineforge-broker-state/v12");']: self.assertEqual(self.check(source=SOURCE.replace( - 'f.s("pineforge-broker-state/v11");', replacement))[0], 1) - for replacement in ["integer(10); integer(broker_state_hash());", + 'f.s("pineforge-broker-state/v12");', replacement))[0], 1) + for replacement in ["integer(11); integer(broker_state_hash());", "integer(broker_state_hash());", - "if (false) { integer(11); integer(broker_state_hash()); }"]: + "if (false) { integer(12); integer(broker_state_hash()); }"]: self.assertEqual(self.check(stream=STREAM.replace( - "integer(11); integer(broker_state_hash());", replacement))[0], 1) + "integer(12); integer(broker_state_hash());", replacement))[0], 1) def test_version_folds_in_unrelated_helpers_do_not_cover_entry_points(self): - broker_fold = 'f.s("pineforge-broker-state/v11");' + broker_fold = 'f.s("pineforge-broker-state/v12");' altered = SOURCE.replace(broker_fold, '') + '\nvoid other() { ' + broker_fold + ' }\n' self.assertEqual(self.check(source=altered)[0], 1) - stream_fold = "integer(11); integer(broker_state_hash());" + stream_fold = "integer(12); integer(broker_state_hash());" altered = STREAM.replace(stream_fold, '') + '\nvoid other() { ' + stream_fold + ' }\n' self.assertEqual(self.check(stream=altered)[0], 1) diff --git a/scripts/test_native_cpp_versions.py b/scripts/test_native_cpp_versions.py new file mode 100644 index 000000000..d08f2b4db --- /dev/null +++ b/scripts/test_native_cpp_versions.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Mutation controls for native C++ ABI ownership; no compiler or engine runs.""" +import json +import tempfile +import unittest +from pathlib import Path + +from check_native_cpp_versions import DRIVER_FORWARD, FILES, check_texts, load + +DATA = load() + + +class NativeVersions(unittest.TestCase): + def reject(self, path, before, after): + self.assertIn(before, DATA[path]) + changed = dict(DATA) + changed[path] = changed[path].replace(before, after, 1) + with self.assertRaises(ValueError): + check_texts(changed) + + def test_current(self): + check_texts(DATA) + + def test_stale_wrapper(self): + for path, namespace, stale in ( + (FILES[0], "native_order_v1", "native_order_v2"), + (FILES[1], "native_order_v1", "native_order_v2"), + (FILES[2], "native_calendar_v2", "native_calendar_v1"), + (FILES[3], "native_calendar_v2", "native_calendar_v3"), + (FILES[4], "native_run_spec_v1", "native_run_spec_v2"), + (FILES[5], "native_run_spec_v1", "native_run_spec_v2"), + (FILES[6], "native_driver_v3", "native_driver_v2"), + (FILES[7], "native_driver_v3", "native_driver_v4"), + (FILES[8], "engine_script_run_v12", "engine_script_run_v11"), + (FILES[9], "engine_script_run_v12", "engine_script_run_v11"), + (FILES[10], "engine_script_run_v12", "engine_script_run_v13"), + ): + with self.subTest(path=path, namespace=namespace): + self.reject(path, namespace, stale) + + def test_duplicate_wrapper(self): + for path, namespace in ( + (FILES[0], "native_order_v1"), + (FILES[2], "native_calendar_v2"), + (FILES[4], "native_run_spec_v1"), + (FILES[6], "native_driver_v3"), + (FILES[8], "engine_script_run_v12"), + ): + with self.subTest(path=path): + opening = "inline namespace " + namespace + " {" + self.reject(path, opening, opening + " } inline namespace " + namespace + " {") + + def test_empty_namespace_is_not_ownership(self): + for path, namespace in ( + (FILES[0], "native_order_v1"), + (FILES[2], "native_calendar_v2"), + (FILES[4], "native_run_spec_v1"), + (FILES[6], "native_driver_v3"), + (FILES[8], "engine_script_run_v12"), + ): + with self.subTest(path=path): + self.reject(path, "inline namespace " + namespace + " {", + "inline namespace " + namespace + " {} namespace misplaced {") + + def test_comment_only_namespace_is_not_ownership(self): + for path, namespace, decoy in ( + (FILES[0], "native_order_v1", "struct WorkingRequestCore"), + (FILES[2], "native_calendar_v2", "parse_timeframe NativeInterval"), + (FILES[8], "engine_script_run_v12", "class NativeStrategyHost"), + ): + with self.subTest(path=path): + self.reject( + path, + "inline namespace " + namespace + " {", + "inline namespace " + namespace + " { /* " + decoy + " */ } namespace misplaced {") + + def test_type_moved_outside_owner(self): + self.reject(FILES[0], "struct RunIdentity {", + "} struct RunIdentity {") + self.reject(FILES[2], "struct NativeInterval {", + "} struct NativeInterval {") + self.reject(FILES[4], "struct NativeRunSpec {", + "} struct NativeRunSpec {") + self.reject(FILES[6], "struct NativeCoordinate {", + "} struct NativeCoordinate {") + self.reject(FILES[8], "class NativeStrategyHost : public BacktestEngine {", + "} class NativeStrategyHost : public BacktestEngine {") + + def test_command_event_alias_moved_outside_owner(self): + self.reject( + FILES[0], + "using CommandEvent = std::variant parse_timeframe(std::string_view text);", + "") + changed = dict(DATA) + decl = "std::optional parse_timeframe(std::string_view text);" + text = changed[calendar].replace(decl, "", 1) + text = text.replace( + "} // inline namespace native_calendar_v2", + "} // inline namespace native_calendar_v2\n" + decl, + 1) + changed[calendar] = text + with self.assertRaises(ValueError): + check_texts(changed) + + changed = dict(DATA) + decl = "std::optional\ntimezone_identity_descriptor(std::string_view timezone);" + self.assertIn(decl, changed[calendar]) + text = changed[calendar].replace(decl, "", 1) + text = text.replace( + "} // inline namespace native_calendar_v2", + "} // inline namespace native_calendar_v2\n" + decl, + 1) + changed[calendar] = text + with self.assertRaises(ValueError): + check_texts(changed) + + changed = dict(DATA) + decl = "std::optional period_key(const SessionCalendar& calendar, const Timeframe& tf, int64_t ms);" + self.assertIn(decl, changed[calendar]) + text = changed[calendar].replace(decl, "", 1) + text = text.replace( + "} // inline namespace native_calendar_v2", + "} // inline namespace native_calendar_v2\n" + decl, + 1) + changed[calendar] = text + with self.assertRaises(ValueError): + check_texts(changed) + + spec = FILES[4] + changed = dict(DATA) + decl = "NativeRunSpecValidation validate_native_run_spec(const NativeRunSpec& spec) noexcept;" + text = changed[spec].replace(decl, "", 1) + text = text.replace( + "} // inline namespace native_run_spec_v1", + "} // inline namespace native_run_spec_v1\n" + decl, + 1) + changed[spec] = text + with self.assertRaises(ValueError): + check_texts(changed) + + def test_out_of_line_function_escape(self): + changed = dict(DATA) + src = FILES[7] + start = changed[src].index("bool native_bar_structurally_valid") + end = changed[src].index("NativeInputPreflightResult preflight_native_inputs") + func = changed[src][start:end] + text = changed[src][:start] + changed[src][end:] + text = text.replace( + "} // inline namespace native_driver_v3", + "} // inline namespace native_driver_v3\n" + func, + 1) + changed[src] = text + with self.assertRaises(ValueError): + check_texts(changed) + + changed = dict(DATA) + src = FILES[3] + needle = "std::optional parse_timeframe(std::string_view text) {" + self.assertIn(needle, changed[src]) + changed[src] = changed[src].replace( + needle, "/* escaped parse_timeframe */ int parse_timeframe_unused() {", 1) + with self.assertRaises(ValueError): + check_texts(changed) + + changed = dict(DATA) + src = FILES[1] + needle = "WorkingRequestCore::submit(" + self.assertIn(needle, changed[src]) + changed[src] = changed[src].replace( + "} // inline namespace native_order_v1", + "} // inline namespace native_order_v1\nSubmitResult WorkingRequestCore::submit(const Request&, int64_t, uint64_t&, uint64_t&, std::optional) { return {}; }\n", + 1) + with self.assertRaises(ValueError): + check_texts(changed) + + def test_driver_native_run_spec_forward_must_stay_outside(self): + self.reject(FILES[6], DRIVER_FORWARD, "") + self.reject( + FILES[6], + DRIVER_FORWARD + "\ninline namespace native_driver_v3 {", + "inline namespace native_driver_v3 {\n" + DRIVER_FORWARD) + self.reject( + FILES[6], + DRIVER_FORWARD, + "inline namespace native_run_spec_v1 { struct NativeRunSpec {}; }") + + def test_host_public_values_cannot_leave_v12(self): + self.reject(FILES[8], "struct NativeStateView {", "} struct NativeStateView {") + self.reject(FILES[8], "struct NativeFailure {", "} struct NativeFailure {") + self.reject(FILES[9], "class NativeExecutionConsumer final : public IExecutionConsumer {", + "} class NativeExecutionConsumer final : public IExecutionConsumer {") + + +class FixtureAuthentication(unittest.TestCase): + def test_authentic_fixtures_unpack(self): + from check_native_cpp_abi import FIXTURE, authenticate_fixture + for name in ("order-1acaf33", "calendar-draft-a8e34c", "calendar-262a280", + "driver-08b5c88", "run-spec-262a280"): + with self.subTest(name=name): + with tempfile.TemporaryDirectory(prefix="pf-native-abi-ok-") as temp: + authenticate_fixture(FIXTURE / name, Path(temp) / name) + + def test_rejects_tampered_hash(self): + from check_native_cpp_abi import FIXTURE, authenticate_fixture + fixture = FIXTURE / "calendar-draft-a8e34c" + with tempfile.TemporaryDirectory(prefix="pf-native-abi-tamper-") as temp: + dest = Path(temp) + (dest / "headers.json.gz").write_bytes((fixture / "headers.json.gz").read_bytes()) + manifest = json.loads((fixture / "manifest.json").read_text()) + header = "pineforge/native_calendar.hpp" + manifest["files"][header]["sha256"] = "0" * 64 + (dest / "manifest.json").write_text(json.dumps(manifest)) + with self.assertRaises(RuntimeError): + authenticate_fixture(dest, dest / "out") + + def test_rejects_tampered_archive_digest(self): + from check_native_cpp_abi import FIXTURE, authenticate_fixture + fixture = FIXTURE / "order-1acaf33" + with tempfile.TemporaryDirectory(prefix="pf-native-abi-archive-") as temp: + dest = Path(temp) + (dest / "headers.json.gz").write_bytes((fixture / "headers.json.gz").read_bytes()) + manifest = json.loads((fixture / "manifest.json").read_text()) + manifest["archive_sha256"] = "0" * 64 + (dest / "manifest.json").write_text(json.dumps(manifest)) + with self.assertRaises(RuntimeError): + authenticate_fixture(dest, dest / "out") + + def test_rejects_path_set_mismatch_and_escape(self): + from check_native_cpp_abi import FIXTURE, authenticate_fixture, frozen_contents, git_blob + import gzip + import hashlib + import io + fixture = FIXTURE / "driver-08b5c88" + manifest = json.loads((fixture / "manifest.json").read_text()) + contents = frozen_contents(fixture, manifest) + with tempfile.TemporaryDirectory(prefix="pf-native-abi-set-") as temp: + dest = Path(temp) + extra = dict(contents) + extra["pineforge/not-in-manifest.hpp"] = "x" + buf = io.BytesIO() + with gzip.GzipFile(filename="", fileobj=buf, mode="wb", mtime=0) as z: + z.write((json.dumps(extra, indent=2, sort_keys=True) + "\n").encode()) + archive = buf.getvalue() + (dest / "headers.json.gz").write_bytes(archive) + mutated = dict(manifest) + mutated["archive_sha256"] = hashlib.sha256(archive).hexdigest() + (dest / "manifest.json").write_text(json.dumps(mutated)) + with self.assertRaises(RuntimeError): + authenticate_fixture(dest, dest / "out") + + raw = b"not a header" + contents["../escape.hpp"] = raw.decode() + buf = io.BytesIO() + with gzip.GzipFile(filename="", fileobj=buf, mode="wb", mtime=0) as z: + z.write((json.dumps(contents, indent=2, sort_keys=True) + "\n").encode()) + archive = buf.getvalue() + with tempfile.TemporaryDirectory(prefix="pf-native-abi-path-") as temp: + dest = Path(temp) + (dest / "headers.json.gz").write_bytes(archive) + mutated = dict(manifest) + mutated["archive_sha256"] = hashlib.sha256(archive).hexdigest() + mutated["files"] = dict(manifest["files"]) + mutated["files"]["../escape.hpp"] = { + "sha256": hashlib.sha256(raw).hexdigest(), + "git_blob": git_blob(raw), + "bytes": len(raw), + } + (dest / "manifest.json").write_text(json.dumps(mutated)) + with self.assertRaises(RuntimeError): + authenticate_fixture(dest, dest / "out") + + +class NativeLayoutAssembly(unittest.TestCase): + def test_exact_symbol_and_target_constants(self): + from check_native_cpp_abi import assembly_layout_values + emitted = "other:\n.quad 999\nabi_layout_extra:\n.quad 888\nabi_layout:\n.quad 88\n.quad 0x130 # libstdc++ size\n.quad 7\n.zero 16\nnext_symbol:\n.quad 999\n" + self.assertEqual(assembly_layout_values(emitted, 3), [88, 304, 7]) + self.assertEqual(assembly_layout_values("_abi_layout:\n.xword 80\n.8byte 240\n", 2), [80, 240]) + + def test_partial_array_is_not_layout_evidence(self): + from check_native_cpp_abi import assembly_layout_values + for emitted in ("abi_layout:\n.quad 80\n.zero 8\nnext:\n.quad 7\n", + "abi_layout_extra:\n.quad 80\n.quad 240\n"): + with self.assertRaises(RuntimeError): + assembly_layout_values(emitted, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 51fa90f0a..c01aa8ac0 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -26,7 +26,8 @@ * strategy_position_size, strategy_current_equity, * strategy_script_bars_processed), * pf_version_get/pf_version_string, - * pf_abi_version — the authoritative list is EXPECTED_RUNTIME in + * pf_abi_version, strategy_execution_contract, + * strategy_configure_native_v1 — the authoritative list is EXPECTED_RUNTIME in * scripts/check_c_abi_runtime.py, enforced by CI). The other * `extern "C"` symbols listed in pineforge.h (strategy_create, * run_backtest, etc.) are emitted per-compiled-strategy by the @@ -38,11 +39,37 @@ // engine.hpp defines PINEFORGE_NO_STRATEGY_DECLS, which suppresses them. #include #include +#include #include #include #include #include #include +#include +#include +#include +#include + +namespace { + +template +void pf_cabi_void(Fn&& fn) noexcept { + try { + std::forward(fn)(); + } catch (...) { + } +} + +template +int pf_cabi_int(Fn&& fn) noexcept { + try { + return std::forward(fn)(); + } catch (...) { + return -1; + } +} + +} // namespace namespace pineforge { // Generated (src/pending_order_mirror.cpp, scripts/gen_pending_order_mirror.py). @@ -285,8 +312,10 @@ PF_API int64_t strategy_script_bars_processed(pf_strategy_t s) { * a backtest whose per-bar values it wants to cross-reference against * TradingView. */ PF_API void strategy_set_trace_enabled(pf_strategy_t s, int on) { - if (!s) return; - static_cast(s)->set_trace_enabled(on != 0); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_trace_enabled(on != 0); + }); } /* Returns the error message captured by the most recent run() on this @@ -306,8 +335,10 @@ PF_API const char* strategy_get_last_error(pf_strategy_t s) { } PF_API void strategy_set_trade_start_time(pf_strategy_t s, int64_t timestamp_ms) { - if (!s) return; - static_cast(s)->set_trade_start_time(timestamp_ms); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_trade_start_time(timestamp_ms); + }); } /* Cooperative abort of the run in progress on ``s`` (live runtime: a settle @@ -333,8 +364,10 @@ PF_API int strategy_last_run_status(pf_strategy_t s) { * historical bar. Default off (on=0): every historical run stays * byte-identical to before this flag existed. */ PF_API void strategy_set_realtime_tail(pf_strategy_t s, int on, int horizon_bars) { - if (!s) return; - static_cast(s)->set_realtime_tail(on != 0, horizon_bars); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_realtime_tail(on != 0, horizon_bars); + }); } /* Live probe tail suppression (spec §3.2): the last bar of the array fed to @@ -352,8 +385,10 @@ PF_API void strategy_set_realtime_tail(pf_strategy_t s, int on, int horizon_bars * Default off (on=0): every historical run stays byte-identical to before * this flag existed. */ PF_API void strategy_set_probe_suppress_tail_logic(pf_strategy_t s, int on) { - if (!s) return; - static_cast(s)->set_probe_suppress_tail_logic(on != 0); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_probe_suppress_tail_logic(on != 0); + }); } /* ABI v4 live-runtime surface (task 4): force the intrabar path order used @@ -366,8 +401,10 @@ PF_API void strategy_set_probe_suppress_tail_logic(pf_strategy_t s, int on) { * AUTO. Default AUTO (mode=0): every historical run stays byte-identical to * before this flag existed. */ PF_API void strategy_set_path_order(pf_strategy_t s, int mode) { - if (!s) return; - static_cast(s)->set_path_order(mode); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_path_order(mode); + }); } /* ABI v4 live-runtime surface (task 4): the dual-entry-stop arbitration @@ -390,8 +427,10 @@ PF_API int strategy_last_bar_dual_entry_path(pf_strategy_t s) { * NULL/0-length and every historical run is byte-identical to before this * flag existed. */ PF_API void strategy_set_broker_state_hash_recording(pf_strategy_t s, int on) { - if (!s) return; - static_cast(s)->set_broker_state_hash_recording(on != 0); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_broker_state_hash_recording(on != 0); + }); } /* ABI v4 live-runtime surface (task 6): the broker-state hash of the FINAL @@ -489,21 +528,38 @@ PF_API int strategy_stream_begin(pf_strategy_t s, int n_warmup, const char* input_tf, const char* script_tf) { - if (!s) return -1; - auto* engine = static_cast(s); - const auto* bars = reinterpret_cast(warmup_bars); - return engine->stream_begin( - bars, n_warmup, - input_tf ? std::string(input_tf) : std::string(), - script_tf ? std::string(script_tf) : std::string()) ? 0 : -1; + return pf_cabi_int([&] { + if (!s) return -1; + auto* engine = static_cast(s); + if (n_warmup < 0 || (n_warmup > 0 && !warmup_bars)) { + // Preserve the engine's diagnostic and lifecycle contract without + // reading malformed C input or allocating an input array. + return engine->stream_begin(nullptr, n_warmup, + input_tf ? std::string(input_tf) : std::string(), + script_tf ? std::string(script_tf) : std::string()) ? 0 : -1; + } + std::vector bars; + bars.reserve(static_cast(n_warmup)); + for (int i = 0; i < n_warmup; ++i) { + const auto& b = warmup_bars[i]; + bars.push_back({b.open, b.high, b.low, b.close, b.volume, b.timestamp}); + } + return engine->stream_begin( + bars.data(), n_warmup, + input_tf ? std::string(input_tf) : std::string(), + script_tf ? std::string(script_tf) : std::string()) ? 0 : -1; + }); } PF_API int strategy_stream_api_version(void) { return 1; } PF_API int strategy_stream_push_bar(pf_strategy_t s, const pf_bar_t* bar) { - if (!s || !bar) return -1; - return static_cast(s)->stream_push_bar( - *reinterpret_cast(bar)) ? 0 : -1; + return pf_cabi_int([&] { + if (!s || !bar) return -1; + const pineforge::Bar native{bar->open, bar->high, bar->low, + bar->close, bar->volume, bar->timestamp}; + return static_cast(s)->stream_push_bar(native) ? 0 : -1; + }); } PF_API int strategy_stream_order_actions_len(pf_strategy_t s) { @@ -534,67 +590,91 @@ PF_API uint64_t strategy_stream_state_hash(pf_strategy_t s) { PF_API int strategy_stream_push_tick(pf_strategy_t s, const pf_trade_tick_t* tick) { - if (!s || !tick) return -1; - const auto* native = reinterpret_cast(tick); - return static_cast(s)->stream_push_tick(*native) - ? 0 - : -1; + return pf_cabi_int([&] { + if (!s || !tick) return -1; + const pineforge::TradeTick native{tick->timestamp, tick->sequence, + tick->price, tick->quantity}; + return static_cast(s)->stream_push_tick(native) + ? 0 + : -1; + }); } PF_API int strategy_stream_push_ticks(pf_strategy_t s, const pf_trade_tick_t* ticks, int n) { - if (!s || n < 0 || (n > 0 && !ticks)) return -1; - auto* engine = static_cast(s); - const auto* native = reinterpret_cast(ticks); - return engine->stream_push_ticks(native, n) ? 0 : -1; + return pf_cabi_int([&] { + if (!s || n < 0 || (n > 0 && !ticks)) return -1; + auto* engine = static_cast(s); + std::vector native; + native.reserve(static_cast(n)); + for (int i = 0; i < n; ++i) { + const auto& tick = ticks[i]; + native.push_back({tick.timestamp, tick.sequence, tick.price, tick.quantity}); + } + return engine->stream_push_ticks(native.data(), n) ? 0 : -1; + }); } PF_API int strategy_stream_advance_time(pf_strategy_t s, int64_t timestamp_ms) { - if (!s) return -1; - return static_cast(s) - ->stream_advance_time(timestamp_ms) ? 0 : -1; + return pf_cabi_int([&] { + if (!s) return -1; + return static_cast(s) + ->stream_advance_time(timestamp_ms) ? 0 : -1; + }); } PF_API int strategy_stream_end(pf_strategy_t s, int finalize_partial_input_bar) { - if (!s) return -1; - return static_cast(s) - ->stream_end(finalize_partial_input_bar != 0) ? 0 : -1; + return pf_cabi_int([&] { + if (!s) return -1; + return static_cast(s) + ->stream_end(finalize_partial_input_bar != 0) ? 0 : -1; + }); } PF_API int strategy_stream_fill_report(pf_strategy_t s, pf_report_t* out) { - if (!s || !out) return -1; - static_cast(s)->fill_report( - reinterpret_cast(out)); - return 0; + return pf_cabi_int([&] { + if (!s || !out) return -1; + static_cast(s)->fill_report( + reinterpret_cast(out)); + return 0; + }); } /* Override the chart TZ for ``hour``/``minute``/``dayofweek``/etc. See * pineforge.h docstring; NULL or empty are normalised to the legacy UTC * fast path. */ PF_API void strategy_set_chart_timezone(pf_strategy_t s, const char* tz) { - if (!s) return; - static_cast(s)->set_chart_timezone( - tz ? std::string(tz) : std::string()); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_chart_timezone( + tz ? std::string(tz) : std::string()); + }); } /* Plumb the symbol's exchange timezone / session string from the data feed * into syminfo_ (feeds session.ismarket / time(session)). NULL is ignored. */ PF_API void strategy_set_syminfo_timezone(pf_strategy_t s, const char* tz) { - if (!s || !tz) return; - static_cast(s)->set_syminfo_timezone(std::string(tz)); + pf_cabi_void([&] { + if (!s || !tz) return; + static_cast(s)->set_syminfo_timezone(std::string(tz)); + }); } PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session) { - if (!s || !session) return; - static_cast(s)->set_syminfo_session(std::string(session)); + pf_cabi_void([&] { + if (!s || !session) return; + static_cast(s)->set_syminfo_session(std::string(session)); + }); } /* Plumb the instrument class (syminfo.type: "forex" / "stock" / "crypto" / * "futures" / ...) into syminfo_. Defaults to "crypto"; NULL/empty ignored. */ PF_API void strategy_set_syminfo_type(pf_strategy_t s, const char* type) { - if (!s || !type) return; - static_cast(s)->set_syminfo_type(std::string(type)); + pf_cabi_void([&] { + if (!s || !type) return; + static_cast(s)->set_syminfo_type(std::string(type)); + }); } /* Generic string-member injection (ticker / tickerid / currency / @@ -602,9 +682,11 @@ PF_API void strategy_set_syminfo_type(pf_strategy_t s, const char* type) { * for a NULL handle, unknown key or empty value. */ PF_API int strategy_set_syminfo_string(pf_strategy_t s, const char* key, const char* value) { - if (!s || !key || !value) return -1; - return static_cast(s)->set_syminfo_string( - std::string(key), std::string(value)) ? 0 : -1; + return pf_cabi_int([&] { + if (!s || !key || !value) return -1; + return static_cast(s)->set_syminfo_string( + std::string(key), std::string(value)) ? 0 : -1; + }); } /* Inject the instrument tick size (syminfo.mintick). Drives the directional @@ -612,16 +694,20 @@ PF_API int strategy_set_syminfo_string(pf_strategy_t s, const char* key, * Defaults to 0.01 (crypto/equity); set per-instrument (e.g. 0.25 for ES, * 0.00001 for FX). Non-positive values are ignored. */ PF_API void strategy_set_syminfo_mintick(pf_strategy_t s, double mintick) { - if (!s) return; - static_cast(s)->set_syminfo_mintick(mintick); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_syminfo_mintick(mintick); + }); } /* Inject the instrument point value (syminfo.pointvalue) — the $ per point per * contract multiplier applied to realized PnL and MFE/MAE. Defaults to 1.0 * (crypto/equity); set per-instrument (e.g. 50 for ES). Non-positive ignored. */ PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue) { - if (!s) return; - static_cast(s)->set_syminfo_pointvalue(pointvalue); + pf_cabi_void([&] { + if (!s) return; + static_cast(s)->set_syminfo_pointvalue(pointvalue); + }); } /* Inject a fundamental/exchange metadata value (shares_outstanding_total, @@ -629,19 +715,23 @@ PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue) * injection the corresponding syminfo.* read returns na. NULL key ignored. */ PF_API void strategy_set_syminfo_metadata(pf_strategy_t s, const char* key, double value) { - if (!s || !key) return; - static_cast(s)->set_syminfo_metadata( - std::string(key), value); + pf_cabi_void([&] { + if (!s || !key) return; + static_cast(s)->set_syminfo_metadata( + std::string(key), value); + }); } PF_API int strategy_set_account_currency_fx_series( pf_strategy_t s, const int64_t* effective_from_ms, const double* account_per_quote, int n) { - if (!s) return -1; - return static_cast(s) - ->set_account_currency_fx_series( - effective_from_ms, account_per_quote, n) - ? 0 : -1; + return pf_cabi_int([&] { + if (!s) return -1; + return static_cast(s) + ->set_account_currency_fx_series( + effective_from_ms, account_per_quote, n) + ? 0 : -1; + }); } #ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1 @@ -649,13 +739,15 @@ PF_API int strategy_set_aux_security_feed(pf_strategy_t s, const pf_bar_t* bars, int n, const char* input_tf) { - if (!s || n < 0 || (n > 0 && (!bars || !input_tf))) return -1; - const auto* native = reinterpret_cast(bars); - return static_cast(s) - ->set_aux_security_feed( - native, n, - input_tf ? std::string(input_tf) : std::string()) - ? 0 : -1; + return pf_cabi_int([&] { + if (!s || n < 0 || (n > 0 && (!bars || !input_tf))) return -1; + const auto* native = reinterpret_cast(bars); + return static_cast(s) + ->set_aux_security_feed( + native, n, + input_tf ? std::string(input_tf) : std::string()) + ? 0 : -1; + }); } #endif @@ -664,11 +756,13 @@ PF_API int strategy_set_native_security_feed(pf_strategy_t s, const char* timeframe, const pf_bar_t* bars, int n) { - if (!s || !timeframe || n < 0 || (n > 0 && !bars)) return -1; - const auto* native = reinterpret_cast(bars); - return static_cast(s) - ->set_native_security_feed(std::string(timeframe), native, n) - ? 0 : -1; + return pf_cabi_int([&] { + if (!s || !timeframe || n < 0 || (n > 0 && !bars)) return -1; + const auto* native = reinterpret_cast(bars); + return static_cast(s) + ->set_native_security_feed(std::string(timeframe), native, n) + ? 0 : -1; + }); } #endif @@ -689,4 +783,70 @@ PF_API const char* pf_version_string(void) { return PINEFORGE_VERSION_FULL; } +static_assert(sizeof(pf_native_run_spec_v1) >= sizeof(uint32_t) + sizeof(const char*), + "pf_native_run_spec_v1 must carry struct_size and string pointers"); + +PF_API int strategy_execution_contract(pf_strategy_t s) { + try { + if (!s) return -1; + return static_cast(s)->execution_contract(); + } catch (...) { + return -1; + } +} + +PF_API int strategy_configure_native_v1(pf_strategy_t s, const pf_native_run_spec_v1* spec) { + try { + if (!s || !spec) return -1; + auto* engine = static_cast(s); + if (!engine->native_bound()) return -1; + if (spec->struct_size != sizeof(pf_native_run_spec_v1)) return -1; + auto* host = dynamic_cast(engine); + if (!host) return -1; + const auto require = [](const char* p) -> const char* { + return p ? p : ""; + }; + if (!spec->session_key || !spec->input_tf || !spec->script_tf || !spec->ticker + || !spec->tickerid || !spec->type || !spec->currency || !spec->basecurrency + || !spec->description || !spec->volumetype || !spec->timezone + || !spec->session || !spec->chart_timezone) { + return -1; + } + if (spec->optional_mask & ~0xfu) return -1; + pineforge::NativeRunSpec cpp; + cpp.identity.session_key = require(spec->session_key); + cpp.identity.run_number = spec->run_number; + cpp.input_tf = require(spec->input_tf); + cpp.script_tf = require(spec->script_tf); + cpp.ticker = require(spec->ticker); + cpp.tickerid = require(spec->tickerid); + cpp.type = require(spec->type); + cpp.currency = require(spec->currency); + cpp.basecurrency = require(spec->basecurrency); + cpp.description = require(spec->description); + cpp.volumetype = require(spec->volumetype); + cpp.timezone = require(spec->timezone); + cpp.session = require(spec->session); + cpp.chart_timezone = require(spec->chart_timezone); + cpp.initial_capital = spec->initial_capital; + cpp.point_value = spec->point_value; + cpp.account_fx = spec->account_fx; + cpp.price_tick = spec->price_tick; + cpp.slippage_ticks = spec->slippage_ticks; + cpp.fee_kind = static_cast(spec->fee_kind); + cpp.fee_value = spec->fee_value; + cpp.close_execution = static_cast(spec->close_execution); + cpp.allowed_open_directions = + static_cast(spec->allowed_open_directions); + if (spec->optional_mask & 1u) cpp.quantity_grid = spec->quantity_grid; + if (spec->optional_mask & 2u) cpp.max_abs_units = spec->max_abs_units; + if (spec->optional_mask & 4u) cpp.initial_margin_fraction = spec->initial_margin_fraction; + if (spec->optional_mask & 8u) cpp.max_open_lots = spec->max_open_lots; + const auto result = host->configure_native(cpp); + return result.status == pineforge::NativeSetupStatus::Applied ? 0 : -1; + } catch (...) { + return -1; + } +} + } /* extern "C" */ diff --git a/src/engine_aux_security.cpp b/src/engine_aux_security.cpp index b2dce0019..bdd957870 100644 --- a/src/engine_aux_security.cpp +++ b/src/engine_aux_security.cpp @@ -17,6 +17,7 @@ namespace pineforge { bool BacktestEngine::set_aux_security_feed(const Bar* bars, int n, const std::string& input_tf) { + guard_native_mutation("set_aux_security_feed"); if (n == 0) { aux_security_bars_.clear(); aux_security_input_tf_.clear(); @@ -437,6 +438,7 @@ void BacktestEngine::feed_deferred_aux_security_for_chart_bar(int chart_index) { bool BacktestEngine::set_native_security_feed(const std::string& timeframe, const Bar* bars, int n) { + guard_native_mutation("set_native_security_feed"); int seconds = 0; try { seconds = tf_to_seconds(timeframe); diff --git a/src/engine_consumer.cpp b/src/engine_consumer.cpp new file mode 100644 index 000000000..9a804656f --- /dev/null +++ b/src/engine_consumer.cpp @@ -0,0 +1,173 @@ +#include "engine_internal.hpp" + +#include +#include + +#include +#include + +namespace pineforge { +inline namespace engine_script_run_v12 { + +class LegacyCompatibilityConsumer final : public IExecutionConsumer { +public: + bool is_native() const noexcept override { return false; } + void refuse_source_mutation(const char*) override {} + uint64_t continuation_hash() const noexcept override { return 0; } + + void run_simple(BacktestEngine& engine, const Bar* bars, int n) override { + engine.legacy_run_simple(bars, n); + } + void run_tf(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) override { + engine.legacy_run_tf(input_bars, n_input, input_tf, script_tf, + bar_magnifier, magnifier_samples, magnifier_dist); + } + void run_rich(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + const std::unordered_map& inputs, + const SymInfo& syminfo, + const StrategyOverrides* overrides, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) override { + engine.legacy_run_rich(input_bars, n_input, input_tf, script_tf, inputs, + syminfo, overrides, bar_magnifier, magnifier_samples, + magnifier_dist); + } + bool stream_begin(BacktestEngine& engine, + const Bar* warmup_bars, int n_warmup, + const std::string& input_tf, + const std::string& script_tf) override { + return engine.legacy_stream_begin(warmup_bars, n_warmup, input_tf, script_tf); + } + bool stream_push_bar(BacktestEngine& engine, const Bar& bar) override { + return engine.legacy_stream_push_bar(bar); + } + bool stream_push_tick(BacktestEngine& engine, const TradeTick& tick) override { + return engine.legacy_stream_push_tick(tick); + } + bool stream_push_ticks(BacktestEngine& engine, const TradeTick* ticks, int n) override { + return engine.legacy_stream_push_ticks(ticks, n); + } + bool stream_advance_time(BacktestEngine& engine, int64_t timestamp_ms) override { + return engine.legacy_stream_advance_time(timestamp_ms); + } + bool stream_end(BacktestEngine& engine, bool finalize_partial_input_bar) override { + return engine.legacy_stream_end(finalize_partial_input_bar); + } +}; + +std::unique_ptr make_legacy_execution_consumer() { + return std::make_unique(); +} + +BacktestEngine::BacktestEngine(compat::pine::CapAttachment cap_attachment) + : max_intraday_filled_orders_(cap_attachment) { + execution_consumer_slot_.native = false; + execution_consumer_slot_.ptr = make_legacy_execution_consumer(); +} + +BacktestEngine::BacktestEngine(NativeConsumerBindTag, + compat::pine::CapAttachment cap_attachment) + : max_intraday_filled_orders_(cap_attachment) { + execution_consumer_slot_.native = true; + execution_consumer_slot_.ptr = make_native_execution_consumer(); +} + +BacktestEngine::~BacktestEngine() = default; + +IExecutionConsumer& BacktestEngine::execution_consumer() { + if (!execution_consumer_slot_.ptr) { + execution_consumer_slot_.ptr = execution_consumer_slot_.native + ? make_native_execution_consumer() + : make_legacy_execution_consumer(); + } + return *execution_consumer_slot_.ptr; +} + +const IExecutionConsumer& BacktestEngine::execution_consumer() const { + if (!execution_consumer_slot_.ptr) { + execution_consumer_slot_.ptr = execution_consumer_slot_.native + ? make_native_execution_consumer() + : make_legacy_execution_consumer(); + } + return *execution_consumer_slot_.ptr; +} + +bool BacktestEngine::native_bound() const { + return execution_consumer().is_native(); +} + +int BacktestEngine::execution_contract() const { + return native_bound() ? 2 : 1; +} + +void BacktestEngine::guard_native_mutation(const char* operation) { + execution_consumer().refuse_source_mutation(operation); +} + +void BacktestEngine::run(const Bar* bars, int n) { + execution_consumer().run_simple(*this, bars, n); +} + +void BacktestEngine::run(const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) { + execution_consumer().run_tf(*this, input_bars, n_input, input_tf, script_tf, + bar_magnifier, magnifier_samples, magnifier_dist); +} + +void BacktestEngine::run(const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + const std::unordered_map& inputs, + const SymInfo& syminfo, + const StrategyOverrides* overrides, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) { + execution_consumer().run_rich(*this, input_bars, n_input, input_tf, script_tf, + inputs, syminfo, overrides, bar_magnifier, + magnifier_samples, magnifier_dist); +} + +bool BacktestEngine::stream_begin(const Bar* warmup_bars, int n_warmup, + const std::string& input_tf, + const std::string& script_tf) { + return execution_consumer().stream_begin(*this, warmup_bars, n_warmup, + input_tf, script_tf); +} + +bool BacktestEngine::stream_push_bar(const Bar& bar) { + return execution_consumer().stream_push_bar(*this, bar); +} + +bool BacktestEngine::stream_push_tick(const TradeTick& tick) { + return execution_consumer().stream_push_tick(*this, tick); +} + +bool BacktestEngine::stream_push_ticks(const TradeTick* ticks, int n) { + return execution_consumer().stream_push_ticks(*this, ticks, n); +} + +bool BacktestEngine::stream_advance_time(int64_t timestamp_ms) { + return execution_consumer().stream_advance_time(*this, timestamp_ms); +} + +bool BacktestEngine::stream_end(bool finalize_partial_input_bar) { + return execution_consumer().stream_end(*this, finalize_partial_input_bar); +} + +} // inline namespace engine_script_run_v12 +} // namespace pineforge diff --git a/src/engine_execution.cpp b/src/engine_execution.cpp index 6a361d432..f14bae90a 100644 --- a/src/engine_execution.cpp +++ b/src/engine_execution.cpp @@ -29,6 +29,26 @@ execution::Result BacktestEngine::settle_resolved_execution( execution::Result BacktestEngine::settle_execution_with_lifecycle( const execution::Action& action, const execution::Fill& fill, const execution::LifecycleEffects& lifecycle) { + execution::PhysicalExecutionContext context; + context.effective_time_ms = current_bar_.timestamp; + context.interval_index = bar_index_; + context.preceding_exit_path_prefix = fold_exit_path_extremes_; + if (!std::isnan(fold_exit_trail_peak_)) { + context.preceding_exit_trail_peak = fold_exit_trail_peak_; + } + return settle_with_context(action, fill, lifecycle, context); +} + +execution::Result BacktestEngine::settle_native_execution_at( + const execution::Action& action, const execution::Fill& fill, + const execution::PhysicalExecutionContext& context) { + return settle_with_context(action, fill, {}, context); +} + +execution::Result BacktestEngine::settle_with_context( + const execution::Action& action, const execution::Fill& fill, + const execution::LifecycleEffects& lifecycle, + const execution::PhysicalExecutionContext& context) { using execution::Status; if (!std::isfinite(fill.price)) return {Status::InvalidPrice}; if (fill.commission_account && !std::isfinite(*fill.commission_account)) @@ -139,7 +159,7 @@ execution::Result BacktestEngine::settle_execution_with_lifecycle( const auto& lot = pyramid_entries_[closing_indices[i]]; auto trade = build_close_trade_with_costs(lot, closing_quantities[i], fill.price, was_long, allocated_entry_commission(lot, closing_quantities[i]), - current_costs[i]); + current_costs[i], context); trade.exit_id = fill.id; trade.exit_comment = fill.comment; closed_trades.push_back(std::move(trade)); @@ -207,6 +227,7 @@ execution::Result BacktestEngine::settle_execution_with_lifecycle( reserve_effects(stream_order_actions_, events); } reserve_effects(trades_, closed_trades.size()); + if (opening > 0.0) reserve_effects(pyramid_entries_, 1); // Commit through the existing accounting/observation sinks. Allocation or // lifecycle exceptions still abort the owning engine run; this internal @@ -227,7 +248,8 @@ execution::Result BacktestEngine::settle_execution_with_lifecycle( } apply_authorized_pending_removals(lifecycle.removals); if (opening > 0.0) { - PyramidEntry lot{fill.price, current_bar_.timestamp, opening, fill.id, bar_index_}; + PyramidEntry lot{fill.price, context.effective_time_ms, opening, fill.id, + context.interval_index}; lot.entry_incarnation = fill.incarnation; lot.entry_comment = fill.comment; lot.entry_commission_account = opening_commission; @@ -238,8 +260,164 @@ execution::Result BacktestEngine::settle_execution_with_lifecycle( } } if (stream_observe_actions_) stream_refresh_action_metadata(first_action, first_trade); - return {Status::Applied, closed, - incoming == PositionSide::SHORT ? -opening : opening}; + double ticket = 0.0; + for (double cost : current_costs) ticket += cost; + execution::Result applied; + applied.status = Status::Applied; + applied.closed_units = closed; + applied.opened_units = incoming == PositionSide::SHORT ? -opening : opening; + applied.current_ticket = ticket; + applied.first_trade_index = first_trade; + applied.closed_trade_count = closed_trades.size(); + applied.opened_lot_incarnation = opening > 0.0 ? fill.incarnation : 0; + return applied; +} + +execution::SettlementInspection BacktestEngine::inspect_native_settlement( + const execution::Action& action, const execution::Fill& fill) const { + using execution::Status; + execution::SettlementInspection out; + if (!std::isfinite(fill.price)) { + out.status = Status::InvalidPrice; + return out; + } + if (fill.commission_account && !std::isfinite(*fill.commission_account)) { + out.status = Status::InvalidAccounting; + return out; + } + const bool flatten = std::holds_alternative(action); + const auto* reduce = std::get_if(&action); + const auto* transact = std::get_if(&action); + const double requested = reduce ? reduce->units + : transact ? std::abs(transact->signed_units) : 0.0; + if (!std::isfinite(requested) || (reduce && requested < 0.0)) { + out.status = Status::InvalidQuantity; + return out; + } + if (position_side_ != PositionSide::FLAT + && position_side_ != PositionSide::LONG + && position_side_ != PositionSide::SHORT) { + out.status = Status::InvalidBook; + return out; + } + if ((position_side_ == PositionSide::FLAT) != pyramid_entries_.empty()) { + out.status = Status::InvalidBook; + return out; + } + double held = 0.0; + for (const auto& lot : pyramid_entries_) { + if (!std::isfinite(lot.qty) || lot.qty <= 0.0 || !std::isfinite(lot.price)) { + out.status = Status::InvalidBook; + return out; + } + held += lot.qty; + if (!std::isfinite(held)) { + out.status = Status::InvalidBook; + return out; + } + } + const double signed_held = position_side_ == PositionSide::SHORT ? -held : held; + if (!flatten && requested == 0.0) { + out.status = fill.commission_account && *fill.commission_account != 0.0 + ? Status::InvalidAccounting : Status::NoEffect; + return out; + } + if ((flatten || reduce) && pyramid_entries_.empty()) { + out.status = fill.commission_account && *fill.commission_account != 0.0 + ? Status::InvalidAccounting : Status::NoEffect; + return out; + } + if (reduce && !order_action::plan(signed_held, *reduce)) { + out.status = Status::UnrepresentableQuantity; + return out; + } + if (transact && !order_action::plan(signed_held, *transact)) { + out.status = Status::UnrepresentableQuantity; + return out; + } + const PositionSide incoming = transact && transact->signed_units < 0.0 + ? PositionSide::SHORT : PositionSide::LONG; + const bool opposite = transact && position_side_ != PositionSide::FLAT + && position_side_ != incoming; + const bool closes = flatten || reduce || opposite; + std::vector survivors; + std::vector closing_quantities; + if (!flatten) survivors.reserve(pyramid_entries_.size()); + closing_quantities.reserve(closes ? pyramid_entries_.size() : 0); + double closed = 0.0; + double remaining = requested; + for (const auto& lot : pyramid_entries_) { + const double amount = !closes ? 0.0 + : flatten ? lot.qty : std::min(lot.qty, remaining); + if (amount == 0.0) { + survivors.push_back(lot); + continue; + } + const double kept = lot.qty - amount; + const double next_closed = closed + amount; + if (!std::isfinite(next_closed) || kept == lot.qty + || (!flatten && closed != 0.0 + && (next_closed == closed || next_closed == amount))) { + out.status = Status::UnrepresentableQuantity; + return out; + } + if (!flatten) { + const double next_remaining = requested - next_closed; + if (next_remaining < 0.0 || next_remaining == remaining) { + out.status = Status::UnrepresentableQuantity; + return out; + } + remaining = next_remaining; + } + closed = next_closed; + closing_quantities.push_back(amount); + if (kept > 0.0) { + auto survivor = lot; + survivor.qty = kept; + survivors.push_back(std::move(survivor)); + } + } + const double opening = !transact ? 0.0 : opposite ? remaining : requested; + if (opening > 0.0 && opposite && !survivors.empty()) { + out.status = Status::UnrepresentableQuantity; + return out; + } + const auto current_costs = quote_execution_commissions(closing_quantities, opening, fill); + double ticket = 0.0; + for (double cost : current_costs) { + if (!std::isfinite(cost)) { + out.status = Status::InvalidAccounting; + return out; + } + ticket += cost; + } + double after_qty = 0.0; + for (const auto& lot : survivors) after_qty += lot.qty; + if (opening > 0.0) { + const double next = after_qty + opening; + if (!std::isfinite(next) || (after_qty > 0.0 + && (next == after_qty || next == opening))) { + out.status = Status::UnrepresentableQuantity; + return out; + } + after_qty = next; + } + const double fx = active_account_currency_fx(); + out.status = Status::Applied; + out.closed_units = closed; + out.opened_units = incoming == PositionSide::SHORT ? -opening : opening; + out.resulting_abs_units = after_qty; + out.resulting_lot_count = survivors.size() + (opening > 0.0 ? 1 : 0); + out.resulting_abs_notional = after_qty * std::abs(fill.price) * syminfo_.pointvalue * fx; + out.current_ticket = ticket; + out.would_open = opening > 0.0; + out.incoming_short = incoming == PositionSide::SHORT; + if (closed == 0.0 && opening == 0.0) { + out.status = fill.commission_account && *fill.commission_account != 0.0 + ? Status::InvalidAccounting : Status::NoEffect; + out.would_open = false; + } + return out; } std::vector BacktestEngine::quote_execution_commissions( diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 020e98c00..a43b680a6 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -597,13 +597,22 @@ void BacktestEngine::purge_exit_orders(bool retain_for_pending_entries) { // execute_market_entry. Mirrors TradingView's per-pyramid trade reporting. Trade BacktestEngine::build_close_trade(const PyramidEntry& pe, double close_qty, double fill_price, bool was_long) const { + execution::PhysicalExecutionContext context; + context.effective_time_ms = current_bar_.timestamp; + context.interval_index = bar_index_; + context.preceding_exit_path_prefix = fold_exit_path_extremes_; + if (!std::isnan(fold_exit_trail_peak_)) { + context.preceding_exit_trail_peak = fold_exit_trail_peak_; + } return build_close_trade_with_costs(pe, close_qty, fill_price, was_long, - allocated_entry_commission(pe, close_qty), calc_commission(fill_price, close_qty)); + allocated_entry_commission(pe, close_qty), calc_commission(fill_price, close_qty), + context); } Trade BacktestEngine::build_close_trade_with_costs(const PyramidEntry& pe, double close_qty, double fill_price, bool was_long, double entry_commission, - double exit_commission) const { + double exit_commission, + const execution::PhysicalExecutionContext& context) const { // Realized PnL scales by the instrument point value ($ per point per // contract). Crypto/equity (pointvalue=1) is unchanged; futures (e.g. ES=50) // multiply the price-difference PnL. The price-difference component is in @@ -631,7 +640,7 @@ Trade BacktestEngine::build_close_trade_with_costs(const PyramidEntry& pe, doubl Trade trade; trade.entry_time = pe.time; - trade.exit_time = current_bar_.timestamp; + trade.exit_time = context.effective_time_ms; trade.entry_price = pe.price; trade.exit_price = fill_price; trade.qty = close_qty; @@ -639,7 +648,7 @@ Trade BacktestEngine::build_close_trade_with_costs(const PyramidEntry& pe, doubl trade.pnl_pct = pnl_pct; trade.is_long = was_long; trade.entry_bar_index = pe.entry_bar_index; - trade.exit_bar_index = bar_index_; + trade.exit_bar_index = context.interval_index; trade.entry_id = pe.entry_id; trade.entry_incarnation = pe.entry_incarnation; trade.entry_comment = pe.entry_comment; @@ -669,19 +678,18 @@ Trade BacktestEngine::build_close_trade_with_costs(const PyramidEntry& pe, doubl // TRAIL fills: the peak that armed the trail (fill +/- offset) is a // pre-fill favorable excursion no bar-boundary sample sees (TV reports // MFE == peak for trail exits). - if (!std::isnan(fold_exit_trail_peak_)) { - double peak_fav = (was_long ? (fold_exit_trail_peak_ - pe.price) - : (pe.price - fold_exit_trail_peak_)) - * close_qty; + if (context.preceding_exit_trail_peak) { + const double peak = *context.preceding_exit_trail_peak; + double peak_fav = (was_long ? (peak - pe.price) : (pe.price - peak)) * close_qty; runup = std::max(runup, peak_fav); } - if (fold_exit_path_extremes_) { + if (context.preceding_exit_path_prefix && *context.preceding_exit_path_prefix) { double fill_pos = 0.0; if (internal::first_touch_position(current_bar_, fill_price, &fill_pos)) { const bool high_first = internal::bar_path_uses_high_first(current_bar_); const double high_pos = high_first ? 1.0 : 2.0; const double low_pos = high_first ? 2.0 : 1.0; - const bool same_bar = (pe.entry_bar_index == bar_index_); + const bool same_bar = (pe.entry_bar_index == context.interval_index); if (high_pos < fill_pos && !(same_bar && pe.skip_entry_bar_high)) { double hi_fav = (was_long ? (current_bar_.high - pe.price) : (pe.price - current_bar_.high)) * close_qty; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 9f196658d..02557c7ab 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -94,6 +94,7 @@ struct PathOrderScope { bool BacktestEngine::set_account_currency_fx_series( const int64_t* timestamps_ms, const double* rates, int n) { + guard_native_mutation("set_account_currency_fx_series"); // Timestamped FX is not route-complete for the realtime scheduler. Reject // late installation as well as stream_begin-with-series so callers cannot // bypass fail-closed behavior by changing configuration after warmup. @@ -970,7 +971,7 @@ void BacktestEngine::reset_run_state() { } -void BacktestEngine::run(const Bar* bars, int n) { +void BacktestEngine::legacy_run_simple(const Bar* bars, int n) { last_error_.clear(); last_run_status_ = 0; abort_requested_.store(false, std::memory_order_relaxed); @@ -1534,7 +1535,7 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( // not clear any of those itself (see run_tf_impl's doc comment in // engine.hpp -- the SymInfo/overrides overload below calls run_tf_impl // directly for the same reason). -void BacktestEngine::run(const Bar* input_bars, int n_input, +void BacktestEngine::legacy_run_tf(const Bar* input_bars, int n_input, const std::string& input_tf, const std::string& script_tf, bool bar_magnifier, @@ -2307,7 +2308,7 @@ const Series& BacktestEngine::get_input_source( // --- Full run() overload with SymInfo, StrategyOverrides, and input injection --- -void BacktestEngine::run(const Bar* input_bars, int n_input, +void BacktestEngine::legacy_run_rich(const Bar* input_bars, int n_input, const std::string& input_tf, const std::string& script_tf, const std::unordered_map& inputs, diff --git a/src/engine_state_hash.cpp b/src/engine_state_hash.cpp index b09a9f26c..3c8a5d7fb 100644 --- a/src/engine_state_hash.cpp +++ b/src/engine_state_hash.cpp @@ -92,7 +92,8 @@ uint64_t BacktestEngine::broker_state_hash() const { Fnv f; // v6 includes resolved exit-leg owner/coordinates and immutable Pine placement evidence. // It is a serialization boundary, independent of the public C ABI version. - f.s("pineforge-broker-state/v11"); + f.s("pineforge-broker-state/v12"); + f.u(execution_consumer().continuation_hash()); // --- Position core --- f.i(static_cast(position_side_)); diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 96dbfe6bf..75d79db7c 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -98,6 +98,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, const std::string& comment, const std::string& oca_name, int oca_type, int qty_type) { + guard_native_mutation("strategy_entry"); auto command=begin_market_command(admission::CommandKind::Entry,id,is_long,qty,qty_type, limit_price,stop_price,oca_name,oca_type); if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) { @@ -676,6 +677,7 @@ void BacktestEngine::strategy_close(const std::string& id, const std::string& comment, double qty, double qty_percent, bool immediately) { + guard_native_mutation("strategy_close"); strategy_close(id, comment, qty, qty_percent, immediately, /*callsite_token=*/0); } @@ -685,6 +687,7 @@ void BacktestEngine::strategy_close(const std::string& id, double qty, double qty_percent, bool immediately, uint64_t callsite_token) { + guard_native_mutation("strategy_close"); if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return; if (intraday_loss_orders_blocked()) return; // strategy.risk.max_intraday_loss fired today if (position_side_ == PositionSide::FLAT) { @@ -877,6 +880,7 @@ void BacktestEngine::strategy_close(const std::string& id, } void BacktestEngine::strategy_close_all() { + guard_native_mutation("strategy_close_all"); strategy_close(""); } @@ -1627,6 +1631,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro const std::string& comment, double qty, const std::string& oca_name, double profit_ticks, double loss_ticks) { + guard_native_mutation("strategy_exit"); if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return; if (intraday_loss_orders_blocked()) return; // strategy.risk.max_intraday_loss fired today const bool has_actionable_exit = !std::isnan(limit_price) @@ -2072,6 +2077,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro } void BacktestEngine::strategy_cancel(const std::string& id) { + guard_native_mutation("strategy_cancel"); auto command=begin_market_command(admission::CommandKind::Cancel,id,false, std::numeric_limits::quiet_NaN(),-1, std::numeric_limits::quiet_NaN(),std::numeric_limits::quiet_NaN(),"",0); @@ -2093,6 +2099,7 @@ void BacktestEngine::strategy_cancel(const std::string& id) { } void BacktestEngine::strategy_cancel_all() { + guard_native_mutation("strategy_cancel_all"); auto command=begin_market_command(admission::CommandKind::CancelAll,"",false, std::numeric_limits::quiet_NaN(),-1, std::numeric_limits::quiet_NaN(),std::numeric_limits::quiet_NaN(),"",0); @@ -2103,6 +2110,7 @@ void BacktestEngine::strategy_cancel_all() { void BacktestEngine::strategy_order(const std::string& id, bool is_long, double qty, double limit_price, double stop_price, const std::string& oca_name, int oca_type) { + guard_native_mutation("strategy_order"); auto command=begin_market_command(admission::CommandKind::Raw,id,is_long,qty,-1, limit_price,stop_price,oca_name,oca_type); if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) { diff --git a/src/engine_stream.cpp b/src/engine_stream.cpp index e49721685..dcfc990c5 100644 --- a/src/engine_stream.cpp +++ b/src/engine_stream.cpp @@ -21,7 +21,7 @@ Bar price_point(double price, double volume, int64_t timestamp) { } // namespace -bool BacktestEngine::stream_begin(const Bar* warmup_bars, int n_warmup, +bool BacktestEngine::legacy_stream_begin(const Bar* warmup_bars, int n_warmup, const std::string& input_tf, const std::string& script_tf) { const StreamPhase phase_before_begin = stream_phase_; @@ -150,7 +150,7 @@ bool BacktestEngine::stream_begin(const Bar* warmup_bars, int n_warmup, } } -bool BacktestEngine::stream_push_bar(const Bar& bar) { +bool BacktestEngine::legacy_stream_push_bar(const Bar& bar) { last_error_.clear(); try { if (stream_phase_ != StreamPhase::REALTIME) { @@ -200,7 +200,7 @@ bool BacktestEngine::stream_push_bar(const Bar& bar) { } } -bool BacktestEngine::stream_push_tick(const TradeTick& tick) { +bool BacktestEngine::legacy_stream_push_tick(const TradeTick& tick) { last_error_.clear(); try { if (stream_phase_ != StreamPhase::REALTIME) { @@ -300,7 +300,7 @@ bool BacktestEngine::stream_push_tick(const TradeTick& tick) { } } -bool BacktestEngine::stream_push_ticks(const TradeTick* ticks, int n) { +bool BacktestEngine::legacy_stream_push_ticks(const TradeTick* ticks, int n) { last_error_.clear(); if (n < 0 || (n > 0 && ticks == nullptr)) { last_error_ = "stream_push_ticks received an invalid tick array"; @@ -312,7 +312,7 @@ bool BacktestEngine::stream_push_ticks(const TradeTick* ticks, int n) { return true; } -bool BacktestEngine::stream_advance_time(int64_t timestamp_ms) { +bool BacktestEngine::legacy_stream_advance_time(int64_t timestamp_ms) { last_error_.clear(); try { if (stream_phase_ != StreamPhase::REALTIME) { @@ -344,7 +344,7 @@ bool BacktestEngine::stream_advance_time(int64_t timestamp_ms) { } } -bool BacktestEngine::stream_end(bool finalize_partial_input_bar) { +bool BacktestEngine::legacy_stream_end(bool finalize_partial_input_bar) { last_error_.clear(); try { if (stream_phase_ != StreamPhase::REALTIME) { @@ -678,7 +678,7 @@ uint64_t BacktestEngine::stream_state_hash() const { integer(static_cast(bar.timestamp)); real(bar.open); real(bar.high); real(bar.low); real(bar.close); real(bar.volume); }; - integer(11); integer(broker_state_hash()); + integer(12); integer(broker_state_hash()); integer(static_cast(stream_phase_)); integer(static_cast(stream_input_mode_)); integer(static_cast(stream_input_tf_ms_)); diff --git a/src/market_driver.cpp b/src/market_driver.cpp new file mode 100644 index 000000000..0fcf39041 --- /dev/null +++ b/src/market_driver.cpp @@ -0,0 +1,89 @@ +#include +#include + +#include +#include +#include + +namespace pineforge { +inline namespace native_driver_v3 { + +bool native_bar_structurally_valid(const Bar& bar) noexcept { + if (!std::isfinite(bar.open) || bar.open <= 0.0) return false; + if (!std::isfinite(bar.high) || bar.high <= 0.0) return false; + if (!std::isfinite(bar.low) || bar.low <= 0.0) return false; + if (!std::isfinite(bar.close) || bar.close <= 0.0) return false; + if (bar.low > std::min(bar.open, bar.close)) return false; + if (bar.high < std::max(bar.open, bar.close)) return false; + if (!std::isfinite(bar.volume) || bar.volume < 0.0) return false; + return true; +} + +NativeInputPreflightResult preflight_native_inputs( + const NativeRunSpec& spec, + const Bar* bars, + int n, + NativeInputPolicy policy) { + NativeInputPreflightResult out; + if (n < 0) { + out.error = NativeInputPreflightError::InvalidCount; + return out; + } + if (n > 0 && bars == nullptr) { + out.error = NativeInputPreflightError::NullArray; + return out; + } + auto parsed_tf = native_calendar::parse_timeframe(spec.input_tf); + auto parsed_session = native_calendar::parse_session(spec.session, spec.timezone); + if (!parsed_tf || !parsed_session) { + out.error = NativeInputPreflightError::CalendarFailure; + return out; + } + std::optional previous; + for (int i = 0; i < n; ++i) { + const Bar& bar = bars[i]; + if (!native_bar_structurally_valid(bar)) { + out.error = NativeInputPreflightError::StructuralInvalid; + out.index = i; + return out; + } + auto interval = native_calendar::interval_containing( + *parsed_session, *parsed_tf, bar.timestamp); + if (!interval) { + out.error = NativeInputPreflightError::Unaligned; + out.index = i; + return out; + } + if (!native_confirmed_bar_label_admitted(*interval, bar.timestamp)) { + out.error = NativeInputPreflightError::OffGridLabel; + out.index = i; + return out; + } + if (i > 0 && bar.timestamp <= bars[i - 1].timestamp) { + out.error = NativeInputPreflightError::NotStrictlyIncreasing; + out.index = i; + return out; + } + if (previous) { + if (interval->open_ms <= previous->open_ms) { + out.error = NativeInputPreflightError::OverlappingSlot; + out.index = i; + return out; + } + if (policy == NativeInputPolicy::StreamWarmup) { + auto expected = native_calendar::interval_containing( + *parsed_session, *parsed_tf, previous->next_input_open_ms); + if (!expected || expected->open_ms != interval->open_ms) { + out.error = NativeInputPreflightError::InSessionGap; + out.index = i; + return out; + } + } + } + previous = *interval; + } + return out; +} + +} // inline namespace native_driver_v3 +} // namespace pineforge diff --git a/src/native_calendar.cpp b/src/native_calendar.cpp new file mode 100644 index 000000000..9bd3ddcc6 --- /dev/null +++ b/src/native_calendar.cpp @@ -0,0 +1,1461 @@ +#include + +#include "timezone.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge::native_calendar { +inline namespace native_calendar_v2 { +namespace { + +constexpr int64_t kMsPerSecond = 1000; +constexpr int64_t kMsPerMinute = 60 * kMsPerSecond; +constexpr int64_t kMsPerHour = 60 * kMsPerMinute; +constexpr int64_t kInt64Max = std::numeric_limits::max(); +constexpr int64_t kInt64Min = std::numeric_limits::min(); + +// Week 0 Monday is 1969-12-29 (the Monday of the week containing 1970-01-01). +constexpr int64_t kWeekZeroMondayDays = -3; + +struct CivilDate { + int year = 1970; + int month = 1; + int day = 1; +}; + +struct CivilStamp { + int year = 1970; + int month = 1; + int day = 1; + int hour = 0; + int minute = 0; + int second = 0; +}; + +struct EpochSpan { + int64_t start_ms = 0; + int64_t end_ms = 0; +}; + +struct SessionDay { + CivilDate open_date{}; + CivilDate trading_date{}; + int64_t origin_ms = 0; + int64_t last_traded_ms = 0; + int64_t next_origin_ms = 0; + std::vector spans; +}; + +struct PeriodCore { + int64_t open_ms = 0; + int64_t eligible_open_ms = 0; + int64_t last_traded_close_ms = 0; + int64_t next_period_open_ms = 0; +}; + +bool mul_ok(int64_t a, int64_t b, int64_t& out) { + if (a == 0 || b == 0) { + out = 0; + return true; + } + if (a == kInt64Min || b == kInt64Min) return false; + if (a > 0 && b > 0 && a > kInt64Max / b) return false; + if (a < 0 && b < 0 && a < kInt64Max / b) return false; + if (a > 0 && b < 0 && b < kInt64Min / a) return false; + if (a < 0 && b > 0 && a < kInt64Min / b) return false; + out = a * b; + return true; +} + +bool add_ok(int64_t a, int64_t b, int64_t& out) { + if (b > 0 && a > kInt64Max - b) return false; + if (b < 0 && a < kInt64Min - b) return false; + out = a + b; + return true; +} + +int64_t floor_div(int64_t a, int64_t b) { + if (b == 0) return 0; + int64_t q = a / b; + int64_t r = a % b; + if (r != 0 && a < 0) --q; + return q; +} + +// Howard Hinnant days_from_civil / civil_from_days. +int64_t days_from_civil(int y, unsigned m, unsigned d) { + y -= m <= 2; + const int64_t era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + static_cast(doe) - 719468; +} + +CivilDate civil_from_days(int64_t z) { + z += 719468; + const int64_t era = (z >= 0 ? z : z - 146096) / 146097; + const unsigned doe = static_cast(z - era * 146097); + const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + int y = static_cast(yoe) + static_cast(era * 400); + const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + const unsigned mp = (5 * doy + 2) / 153; + unsigned d = doy - (153 * mp + 2) / 5 + 1; + unsigned m = mp < 10 ? mp + 3 : mp - 9; + y += (m <= 2); + return CivilDate{y, static_cast(m), static_cast(d)}; +} + +bool valid_civil_date(int y, int m, int d) { + if (y < 1 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return false; + const CivilDate back = civil_from_days(days_from_civil(y, static_cast(m), + static_cast(d))); + return back.year == y && back.month == m && back.day == d; +} + +int weekday_sun0(const CivilDate& date) { + const int64_t days = days_from_civil(date.year, static_cast(date.month), + static_cast(date.day)); + return static_cast(((days + 4) % 7 + 7) % 7); +} + +CivilDate add_days(const CivilDate& date, int64_t n) { + return civil_from_days(days_from_civil(date.year, static_cast(date.month), + static_cast(date.day)) + + n); +} + +int cmp_stamp(const CivilStamp& a, const CivilStamp& b) { + if (a.year != b.year) return a.year < b.year ? -1 : 1; + if (a.month != b.month) return a.month < b.month ? -1 : 1; + if (a.day != b.day) return a.day < b.day ? -1 : 1; + if (a.hour != b.hour) return a.hour < b.hour ? -1 : 1; + if (a.minute != b.minute) return a.minute < b.minute ? -1 : 1; + if (a.second != b.second) return a.second < b.second ? -1 : 1; + return 0; +} + +bool is_utc_zone(std::string_view tz) { + const std::string n = pine_tz::normalize_timezone_for_posix(std::string(tz)); + return n.empty() || n == "UTC" || n == "Etc/UTC"; +} + +int64_t utc_civil_ms(const CivilStamp& c) { + const int64_t days = days_from_civil(c.year, static_cast(c.month), + static_cast(c.day)); + int64_t secs = 0; + if (!mul_ok(days, 86400, secs)) return 0; + int64_t hms = static_cast(c.hour) * 3600 + static_cast(c.minute) * 60 + + c.second; + int64_t total = 0; + if (!add_ok(secs, hms, total)) return 0; + int64_t ms = 0; + if (!mul_ok(total, 1000, ms)) return 0; + return ms; +} + +bool epoch_to_stamp_utc(int64_t ms, CivilStamp& out) { + int64_t secs = ms / 1000; + if (ms < 0 && ms % 1000 != 0) --secs; + const time_t t = static_cast(secs); + if (static_cast(t) != secs) return false; + std::tm g{}; + if (gmtime_r(&t, &g) == nullptr) return false; + out.year = g.tm_year + 1900; + out.month = g.tm_mon + 1; + out.day = g.tm_mday; + out.hour = g.tm_hour; + out.minute = g.tm_min; + out.second = g.tm_sec; + return true; +} + +bool epoch_to_stamp_local(int64_t ms, const std::string& tz, CivilStamp& out) { + int64_t secs = ms / 1000; + if (ms < 0 && ms % 1000 != 0) --secs; + const time_t t = static_cast(secs); + if (static_cast(t) != secs) return false; + std::tm loc{}; + if (is_utc_zone(tz)) { + if (gmtime_r(&t, &loc) == nullptr) return false; + } else { + pine_tz::ScopedTimezone guard(tz); + if (localtime_r(&t, &loc) == nullptr) return false; + } + out.year = loc.tm_year + 1900; + out.month = loc.tm_mon + 1; + out.day = loc.tm_mday; + out.hour = loc.tm_hour; + out.minute = loc.tm_min; + out.second = loc.tm_sec; + return true; +} + +bool stamp_matches_tm(const CivilStamp& c, const std::tm& t) { + return t.tm_year + 1900 == c.year && t.tm_mon + 1 == c.month && t.tm_mday == c.day + && t.tm_hour == c.hour && t.tm_min == c.minute && t.tm_sec == c.second; +} + +std::optional mktime_candidate(const CivilStamp& c, int isdst, const std::string& tz) { + std::tm t{}; + t.tm_year = c.year - 1900; + t.tm_mon = c.month - 1; + t.tm_mday = c.day; + t.tm_hour = c.hour; + t.tm_min = c.minute; + t.tm_sec = c.second; + t.tm_isdst = isdst; + time_t sec; + if (is_utc_zone(tz)) { + // timegm is not required: UTC civil is exact integer math. + return utc_civil_ms(c); + } + pine_tz::ScopedTimezone guard(tz); + sec = mktime(&t); + if (sec == static_cast(-1)) return std::nullopt; + std::tm back{}; + if (localtime_r(&sec, &back) == nullptr) return std::nullopt; + if (!stamp_matches_tm(c, back)) return std::nullopt; + int64_t ms = 0; + if (!mul_ok(static_cast(sec), 1000, ms)) return std::nullopt; + return ms; +} + +std::optional first_representable_at_or_after(const CivilStamp& declared, + const std::string& tz) { + const int64_t naive = utc_civil_ms(declared); + int64_t lo = 0; + int64_t hi = 0; + if (!add_ok(naive, -14 * kMsPerHour, lo) || !add_ok(naive, 14 * kMsPerHour, hi)) { + return std::nullopt; + } + auto ge = [&](int64_t ms) -> int { + CivilStamp got{}; + if (!epoch_to_stamp_local(ms, tz, got)) return 1; + return cmp_stamp(got, declared); + }; + // Leftmost epoch with local civil >= declared, inside [lo, hi]. + if (ge(hi) < 0) return std::nullopt; + while (lo < hi) { + const int64_t mid = lo + (hi - lo) / 2; + if (ge(mid) >= 0) + hi = mid; + else + lo = mid + 1; + } + CivilStamp got{}; + if (!epoch_to_stamp_local(lo, tz, got)) return std::nullopt; + if (cmp_stamp(got, declared) < 0) return std::nullopt; + return lo; +} + +std::optional resolve_stamp(const std::string& tz, const CivilStamp& c) { + if (!valid_civil_date(c.year, c.month, c.day)) return std::nullopt; + if (c.hour < 0 || c.hour > 23 || c.minute < 0 || c.minute > 59 || c.second < 0 + || c.second > 59) { + return std::nullopt; + } + if (is_utc_zone(tz)) { + CivilResolution r; + r.epoch_ms = utc_civil_ms(c); + r.kind = CivilKind::Unique; + return r; + } + std::vector hits; + for (int isdst : {0, 1}) { + if (auto ms = mktime_candidate(c, isdst, tz)) { + if (std::find(hits.begin(), hits.end(), *ms) == hits.end()) hits.push_back(*ms); + } + } + if (hits.size() == 1) { + return CivilResolution{*hits.begin(), CivilKind::Unique}; + } + if (hits.size() >= 2) { + std::sort(hits.begin(), hits.end()); + return CivilResolution{hits.front(), CivilKind::Fold}; + } + auto gap = first_representable_at_or_after(c, tz); + if (!gap) return std::nullopt; + CivilStamp got{}; + if (!epoch_to_stamp_local(*gap, tz, got)) return std::nullopt; + if (cmp_stamp(got, c) == 0) { + // Unique that mktime(0/1) missed; still Unique. + return CivilResolution{*gap, CivilKind::Unique}; + } + return CivilResolution{*gap, CivilKind::Gap}; +} + +std::optional resolve_hms(const std::string& tz, + CivilDate date, + int minutes_from_midnight) { + if (minutes_from_midnight >= 1440) { + date = add_days(date, minutes_from_midnight / 1440); + minutes_from_midnight %= 1440; + } + if (minutes_from_midnight < 0) return std::nullopt; + auto r = resolve_stamp(tz, + CivilStamp{date.year, date.month, date.day, minutes_from_midnight / 60, + minutes_from_midnight % 60, 0}); + if (!r) return std::nullopt; + return r->epoch_ms; +} + +bool day_allowed(const SessionCalendar& cal, const CivilDate& trading_date) { + const int pine_dow = weekday_sun0(trading_date) + 1; + return (cal.day_mask() & static_cast(1u << pine_dow)) != 0; +} + +bool same_civil_date(const CivilDate& a, const CivilDate& b) { + return a.year == b.year && a.month == b.month && a.day == b.day; +} + +// Recurring instance of one declared window on a civil date. Wrap and +// equal-start full-day end on the next civil date. 2400 ends at next midnight. +int64_t civil_minutes(const CivilDate& date, int minutes) { + return days_from_civil(date.year, static_cast(date.month), + static_cast(date.day)) + * 1440 + + minutes; +} + +bool civil_window_instance(const SessionWindow& w, + const CivilDate& date, + int64_t& start_cm, + int64_t& end_cm) { + start_cm = civil_minutes(date, w.start_minutes()); + if (w.full_day()) { + end_cm = civil_minutes(add_days(date, 1), w.start_minutes()); + } else if (w.wraps()) { + end_cm = civil_minutes(add_days(date, 1), w.end_minutes()); + } else if (w.end_minutes() == 1440) { + end_cm = civil_minutes(add_days(date, 1), 0); + } else { + end_cm = civil_minutes(date, w.end_minutes()); + } + return end_cm > start_cm; +} + +std::optional resolve_civil_minutes(const std::string& tz, int64_t cm) { + const int64_t days = floor_div(cm, 1440); + const int minutes = static_cast(cm - days * 1440); + return resolve_hms(tz, civil_from_days(days), minutes); +} + +std::vector merge_spans(std::vector spans) { + if (spans.empty()) return spans; + std::sort(spans.begin(), spans.end(), + [](const EpochSpan& a, const EpochSpan& b) { return a.start_ms < b.start_ms; }); + std::vector out; + out.push_back(spans.front()); + for (std::size_t i = 1; i < spans.size(); ++i) { + if (out.back().end_ms < spans[i].start_ms) { + out.push_back(spans[i]); + } else if (spans[i].end_ms > out.back().end_ms) { + out.back().end_ms = spans[i].end_ms; + } + } + return out; +} + +int64_t first_overlap_open(int64_t open_ms, + int64_t raw_end, + const std::vector& spans) { + int64_t eligible = open_ms; + bool any = false; + for (const EpochSpan& s : spans) { + const int64_t a = std::max(open_ms, s.start_ms); + const int64_t b = std::min(raw_end, s.end_ms); + if (b > a && (!any || a < eligible)) { + eligible = a; + any = true; + } + } + return eligible; +} + +std::optional origin_on(const SessionCalendar& cal, const CivilDate& open_date) { + return resolve_hms(cal.timezone(), open_date, cal.origin_minutes()); +} + +std::optional local_stamp(const std::string& tz, int64_t ms); + +int64_t last_span_end(const std::vector& spans, int64_t origin_ms) { + int64_t last = origin_ms; + bool any = false; + for (const EpochSpan& s : spans) { + if (s.end_ms > s.start_ms) { + if (!any || s.end_ms > last) last = s.end_ms; + any = true; + } + } + return last; +} + +std::optional session_day_from_open(const SessionCalendar& cal, + const CivilDate& open_date) { + if (!cal.valid()) return std::nullopt; + const int origin = cal.origin_minutes(); + const int64_t cycle_start_cm = civil_minutes(open_date, origin); + const int64_t cycle_end_cm = civil_minutes(add_days(open_date, 1), origin); + if (cycle_end_cm <= cycle_start_cm) return std::nullopt; + + std::vector civil_raw; + if (cal.all_day()) { + civil_raw.push_back(EpochSpan{cycle_start_cm, cycle_end_cm}); + } else { + for (int n = -1; n <= 1; ++n) { + const CivilDate inst_date = add_days(open_date, n); + for (const SessionWindow& w : cal.windows()) { + int64_t start_cm = 0; + int64_t end_cm = 0; + if (!civil_window_instance(w, inst_date, start_cm, end_cm)) continue; + const int64_t a = std::max(start_cm, cycle_start_cm); + const int64_t b = std::min(end_cm, cycle_end_cm); + if (b > a) civil_raw.push_back(EpochSpan{a, b}); + } + } + } + auto civil_union = merge_spans(std::move(civil_raw)); + CivilDate trading = open_date; + if (!civil_union.empty()) { + const int64_t last_cm = last_span_end(civil_union, cycle_start_cm); + trading = civil_from_days(floor_div(last_cm - 1, 1440)); + } + + auto cycle_start = origin_on(cal, open_date); + auto cycle_end = origin_on(cal, add_days(open_date, 1)); + if (!cycle_start || !cycle_end) return std::nullopt; + if (*cycle_end <= *cycle_start) return std::nullopt; + + std::vector resolved; + for (const EpochSpan& civ : civil_union) { + auto a = resolve_civil_minutes(cal.timezone(), civ.start_ms); + auto b = resolve_civil_minutes(cal.timezone(), civ.end_ms); + if (!a || !b || *b <= *a) continue; + resolved.push_back(EpochSpan{*a, *b}); + } + resolved = merge_spans(std::move(resolved)); + + SessionDay day; + day.open_date = open_date; + day.origin_ms = *cycle_start; + day.next_origin_ms = *cycle_end; + day.trading_date = trading; + if (!resolved.empty() && day_allowed(cal, trading)) { + day.spans = std::move(resolved); + day.last_traded_ms = last_span_end(day.spans, day.origin_ms); + } else { + day.spans = {}; + day.last_traded_ms = day.origin_ms; + } + return day; +} + +std::optional session_day_with_trading(const SessionCalendar& cal, + const CivilDate& trading) { + for (int off : {0, -1, 1, -2, 2}) { + auto d = session_day_from_open(cal, add_days(trading, off)); + if (d && same_civil_date(d->trading_date, trading)) return d; + } + return std::nullopt; +} + +std::optional local_stamp(const std::string& tz, int64_t ms) { + CivilStamp c{}; + if (is_utc_zone(tz)) { + if (!epoch_to_stamp_utc(ms, c)) return std::nullopt; + } else { + if (!epoch_to_stamp_local(ms, tz, c)) return std::nullopt; + } + return c; +} + +std::optional session_day_containing(const SessionCalendar& cal, int64_t ms) { + if (!cal.valid()) return std::nullopt; + auto loc = local_stamp(cal.timezone(), ms); + if (!loc) return std::nullopt; + const CivilDate local_date{loc->year, loc->month, loc->day}; + const CivilDate candidates[3] = {local_date, add_days(local_date, -1), + add_days(local_date, 1)}; + for (const CivilDate& open_date : candidates) { + auto day = session_day_from_open(cal, open_date); + if (!day) continue; + if (day->origin_ms <= ms && ms < day->next_origin_ms) return day; + } + return session_day_from_open(cal, local_date); +} + +bool ms_in_spans(const std::vector& spans, int64_t ms) { + for (const EpochSpan& s : spans) { + if (ms >= s.start_ms && ms < s.end_ms) return true; + } + return false; +} + +int64_t overlap_end(const std::vector& spans, int64_t open_ms, int64_t raw_end) { + int64_t last = open_ms; + bool any = false; + for (const EpochSpan& s : spans) { + const int64_t a = std::max(open_ms, s.start_ms); + const int64_t b = std::min(raw_end, s.end_ms); + if (b > a) { + if (!any || b > last) last = b; + any = true; + } + } + return last; +} + +int64_t unit_ms(TimeframeUnit unit) { + switch (unit) { + case TimeframeUnit::Second: + return kMsPerSecond; + case TimeframeUnit::Minute: + return kMsPerMinute; + default: + return 0; + } +} + +// Fixed-length seconds matching baseline tf_to_seconds / tf_ratio (D=86400, +// W=604800). Month is calendar-only (tf_to_seconds == -1); no invented month +// length is used to refuse D/nD→M or minutes→M. +std::optional known_period_seconds(const Timeframe& tf) { + if (!tf.valid()) return std::nullopt; + int64_t n = tf.count(); + int64_t s = 0; + switch (tf.unit()) { + case TimeframeUnit::Second: + return n; + case TimeframeUnit::Minute: + if (!mul_ok(n, 60, s)) return std::nullopt; + return s; + case TimeframeUnit::Day: + if (!mul_ok(n, 86400, s)) return std::nullopt; + return s; + case TimeframeUnit::Week: + if (!mul_ok(n, 604800, s)) return std::nullopt; + return s; + case TimeframeUnit::Month: + return std::nullopt; + } + return std::nullopt; +} + +std::optional calendar_core(const SessionCalendar& cal, + TimeframeUnit unit, + int count, + int64_t ms) { + auto day = session_day_containing(cal, ms); + if (!day) return std::nullopt; + const int64_t trading_days = days_from_civil(day->trading_date.year, + static_cast(day->trading_date.month), + static_cast(day->trading_date.day)); + int64_t key = 0; + int64_t next_key = 0; + CivilDate first_trading; + CivilDate next_trading; + if (unit == TimeframeUnit::Day) { + key = floor_div(trading_days, count) * count; + next_key = key + count; + first_trading = civil_from_days(key); + next_trading = civil_from_days(next_key); + } else if (unit == TimeframeUnit::Week) { + const int dow_mon0 = (weekday_sun0(day->trading_date) + 6) % 7; + const int64_t monday_days = trading_days - dow_mon0; + const int64_t week_i = floor_div(monday_days - kWeekZeroMondayDays, 7); + key = floor_div(week_i, count) * count; + next_key = key + count; + first_trading = civil_from_days(kWeekZeroMondayDays + key * 7); + next_trading = civil_from_days(kWeekZeroMondayDays + next_key * 7); + } else if (unit == TimeframeUnit::Month) { + const int64_t month_i = + static_cast(day->trading_date.year) * 12 + (day->trading_date.month - 1); + key = floor_div(month_i, count) * count; + next_key = key + count; + int y = static_cast(floor_div(key, 12)); + int m = static_cast(key - static_cast(y) * 12) + 1; + first_trading = CivilDate{y, m, 1}; + int ny = static_cast(floor_div(next_key, 12)); + int nm = static_cast(next_key - static_cast(ny) * 12) + 1; + next_trading = CivilDate{ny, nm, 1}; + } else { + return std::nullopt; + } + auto first = session_day_with_trading(cal, first_trading); + auto nxt = session_day_with_trading(cal, next_trading); + if (!first || !nxt) return std::nullopt; + int64_t last = first->origin_ms; + int64_t eligible = first->origin_ms; + bool any = false; + bool found_eligible = false; + const int64_t first_ord = days_from_civil(first_trading.year, + static_cast(first_trading.month), + static_cast(first_trading.day)); + const int64_t next_ord = days_from_civil(next_trading.year, + static_cast(next_trading.month), + static_cast(next_trading.day)); + for (int64_t ord = first_ord; ord < next_ord; ++ord) { + const CivilDate td = civil_from_days(ord); + auto d = session_day_with_trading(cal, td); + if (!d) continue; + for (const EpochSpan& s : d->spans) { + if (s.end_ms > s.start_ms) { + if (!found_eligible) { + eligible = s.start_ms; + found_eligible = true; + } + break; + } + } + if (found_eligible) break; + } + for (int64_t ord = next_ord - 1; ord >= first_ord; --ord) { + const CivilDate td = civil_from_days(ord); + auto d = session_day_with_trading(cal, td); + if (!d) continue; + if (d->last_traded_ms > d->origin_ms) { + last = d->last_traded_ms; + any = true; + break; + } + } + PeriodCore core; + core.open_ms = first->origin_ms; + core.eligible_open_ms = found_eligible ? eligible : first->origin_ms; + core.last_traded_close_ms = any ? last : first->origin_ms; + core.next_period_open_ms = nxt->origin_ms; + return core; +} + +std::optional fixed_core(const SessionCalendar& cal, + const Timeframe& tf, + int64_t ms) { + int64_t bucket = 0; + if (!tf.valid() || !mul_ok(tf.count(), unit_ms(tf.unit()), bucket) || bucket <= 0) { + return std::nullopt; + } + auto day = session_day_containing(cal, ms); + if (!day) return std::nullopt; + const int64_t elapsed = ms - day->origin_ms; + const int64_t idx = floor_div(elapsed, bucket); + int64_t offset = 0; + if (!mul_ok(idx, bucket, offset)) return std::nullopt; + int64_t open = 0; + if (!add_ok(day->origin_ms, offset, open)) return std::nullopt; + int64_t raw_end = 0; + if (!add_ok(open, bucket, raw_end)) return std::nullopt; + PeriodCore core; + core.open_ms = open; + core.last_traded_close_ms = overlap_end(day->spans, open, raw_end); + core.eligible_open_ms = first_overlap_open(open, raw_end, day->spans); + core.next_period_open_ms = raw_end; + return core; +} + +std::optional core_containing(const SessionCalendar& cal, + const Timeframe& tf, + int64_t ms) { + if (!cal.valid() || !tf.valid()) return std::nullopt; + if (tf.is_fixed()) return fixed_core(cal, tf, ms); + return calendar_core(cal, tf.unit(), tf.count(), ms); +} + +std::optional next_span_start_at_or_after(const SessionCalendar& cal, int64_t after) { + auto day0 = session_day_containing(cal, after); + if (!day0) return std::nullopt; + for (int i = 0; i <= 800; ++i) { + auto d = session_day_from_open(cal, add_days(day0->open_date, i)); + if (!d) return std::nullopt; + for (const EpochSpan& s : d->spans) { + if (s.end_ms <= after) continue; + if (s.start_ms >= after) return s.start_ms; + return after; + } + } + return std::nullopt; +} + +std::optional first_tradable_open(const SessionCalendar& cal, + const Timeframe& tf, + int64_t at_or_after) { + if (tf.is_fixed()) { + return next_span_start_at_or_after(cal, at_or_after); + } + auto core = core_containing(cal, tf, at_or_after); + if (!core) return std::nullopt; + int64_t t = at_or_after; + for (int i = 0; i < 800; ++i) { + auto n = core_containing(cal, tf, t); + if (!n) return std::nullopt; + if (n->open_ms >= at_or_after && n->last_traded_close_ms > n->open_ms) { + return n->eligible_open_ms >= at_or_after ? n->eligible_open_ms : n->open_ms; + } + if (n->next_period_open_ms <= t) return std::nullopt; + t = n->next_period_open_ms; + } + return std::nullopt; +} + +void trim(std::string& s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) s.erase(s.begin()); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); +} + +bool all_mask_digits(std::string_view s) { + if (s.empty()) return false; + for (char c : s) { + if (c < '1' || c > '7') return false; + } + return true; +} + +bool tz_has_nul_or_control_or_space(std::string_view s) { + for (unsigned char c : s) { + if (c < 0x20 || c == 0x7F || std::isspace(c)) return true; + } + return false; +} + +bool tz_is_utc_gmt_name(std::string_view s) { + return s == "UTC" || s == "GMT" || s == "Etc/UTC" || s == "Etc/GMT"; +} + +bool tz_parse_uint_bounded(std::string_view s, int max_digits, int max_value, int& out) { + if (s.empty() || static_cast(s.size()) > max_digits) return false; + int n = 0; + for (char c : s) { + if (c < '0' || c > '9') return false; + const int d = c - '0'; + if (n > (max_value - d) / 10) return false; + n = n * 10 + d; + } + if (n > max_value) return false; + out = n; + return true; +} + +bool tz_utc_gmt_offset_accepted(std::string_view tz) { + std::size_t prefix = 0; + if (tz.size() >= 3 && tz.substr(0, 3) == "UTC") + prefix = 3; + else if (tz.size() >= 3 && tz.substr(0, 3) == "GMT") + prefix = 3; + else + return false; + if (prefix >= tz.size()) return false; + const char sign = tz[prefix]; + if (sign != '+' && sign != '-') return false; + const std::string_view body = tz.substr(prefix + 1); + if (body.empty()) return false; + std::string_view hour_s; + std::string_view minute_s; + const auto colon = body.find(':'); + if (colon != std::string_view::npos) { + hour_s = body.substr(0, colon); + minute_s = body.substr(colon + 1); + if (minute_s.size() != 2) return false; + } else if (body.size() <= 2) { + hour_s = body; + minute_s = {}; + } else if (body.size() == 3 || body.size() == 4) { + hour_s = body.substr(0, body.size() - 2); + minute_s = body.substr(body.size() - 2); + } else { + return false; + } + int hours = 0; + int minutes = 0; + if (!tz_parse_uint_bounded(hour_s, 2, 23, hours)) return false; + if (!minute_s.empty() && !tz_parse_uint_bounded(minute_s, 2, 59, minutes)) return false; + return true; +} + +bool tz_looks_like_utc_gmt_offset(std::string_view tz) { + if (tz.size() < 4) return false; + if (tz.substr(0, 3) != "UTC" && tz.substr(0, 3) != "GMT") return false; + const char sign = tz[3]; + return sign == '+' || sign == '-'; +} + +bool tz_iana_name_chars(std::string_view name) { + if (name.empty() || name.size() > 255) return false; + if (name.front() == '/' || name.back() == '/') return false; + std::size_t i = 0; + while (i < name.size()) { + const auto slash = name.find('/', i); + const auto part = name.substr(i, slash == std::string_view::npos ? std::string_view::npos + : slash - i); + if (part.empty() || part == "." || part == "..") return false; + for (unsigned char c : part) { + if (!(std::isalnum(c) || c == '_' || c == '+' || c == '-')) return false; + } + if (slash == std::string_view::npos) break; + i = slash + 1; + } + return true; +} + +bool tzdir_realpath_dir(const char* path, std::string& out) { + if (path == nullptr || path[0] == '\0') return false; + char buf[PATH_MAX]; + if (::realpath(path, buf) == nullptr) return false; + struct stat st {}; + if (::stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) return false; + out.assign(buf); + return true; +} + +// Effective zoneinfo root used by this libc for relative TZ names. +// macOS tzset(3) reads /var/db/timezone/zoneinfo and does not document TZDIR; +// probes on this Darwin libc ignore TZDIR. glibc tzfile.c uses TZDIR when +// nonempty (relative included) and the compile-time default only when TZDIR +// is unset or empty; a missing/non-directory TZDIR is fail-closed so names +// are not accepted from a tree libc will not read. +std::string tzdir_canonical() { + std::string out; +#if defined(__APPLE__) + if (tzdir_realpath_dir("/var/db/timezone/zoneinfo", out)) return out; + if (tzdir_realpath_dir("/usr/share/zoneinfo", out)) return out; + return {}; +#elif defined(__GLIBC__) + const char* env = std::getenv("TZDIR"); + if (env != nullptr && env[0] != '\0') { + if (tzdir_realpath_dir(env, out)) return out; + return {}; + } + if (tzdir_realpath_dir("/usr/share/zoneinfo", out)) return out; + return {}; +#else + if (tzdir_realpath_dir("/usr/share/zoneinfo", out)) return out; + return {}; +#endif +} + +std::optional tz_file_actual_path(std::string_view name) { + if (!tz_iana_name_chars(name)) return std::nullopt; + const std::string dir = tzdir_canonical(); + if (dir.empty()) return std::nullopt; + std::string full = dir; + full.push_back('/'); + full.append(name.begin(), name.end()); + char resolved[PATH_MAX]; + if (::realpath(full.c_str(), resolved) == nullptr) return std::nullopt; + const std::string res(resolved); + if (res != dir && res.rfind(dir + "/", 0) != 0) return std::nullopt; + const int fd = ::open(res.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) return std::nullopt; + char mag[4] = {}; + const ssize_t n = ::read(fd, mag, 4); + ::close(fd); + if (n != 4 || std::memcmp(mag, "TZif", 4) != 0) return std::nullopt; + return res; +} + +bool tz_file_is_tzif(std::string_view name) { + return tz_file_actual_path(name).has_value(); +} + +bool posix_consume_name(std::string_view s, std::size_t& p) { + if (p >= s.size()) return false; + if (s[p] == '<') { + const auto end = s.find('>', p + 1); + if (end == std::string_view::npos || end - (p + 1) < 3) return false; + for (std::size_t i = p + 1; i < end; ++i) { + const unsigned char c = static_cast(s[i]); + if (!(std::isalnum(c) || c == '+' || c == '-')) return false; + } + p = end + 1; + return true; + } + const std::size_t start = p; + while (p < s.size()) { + const unsigned char c = static_cast(s[p]); + if (std::isdigit(c) || c == '+' || c == '-' || c == ',') break; + ++p; + } + return p - start >= 3; +} + +bool posix_consume_offset(std::string_view s, std::size_t& p) { + if (p < s.size() && (s[p] == '+' || s[p] == '-')) ++p; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + int hours = 0; + int digits = 0; + while (p < s.size() && std::isdigit(static_cast(s[p])) && digits < 2) { + hours = hours * 10 + (s[p] - '0'); + ++p; + ++digits; + } + if (p < s.size() && std::isdigit(static_cast(s[p]))) return false; + if (hours > 24) return false; + auto colon_part = [&]() -> bool { + if (p >= s.size() || s[p] != ':') return true; + ++p; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + int v = 0; + int d = 0; + while (p < s.size() && std::isdigit(static_cast(s[p])) && d < 2) { + v = v * 10 + (s[p] - '0'); + ++p; + ++d; + } + if (d < 1 || v > 59) return false; + return true; + }; + if (!colon_part()) return false; + if (!colon_part()) return false; + return true; +} + +bool posix_consume_rule(std::string_view s, std::size_t& p) { + if (p >= s.size()) return false; + if (s[p] == 'M') { + ++p; + int m = 0, n = 0, d = 0; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + while (p < s.size() && std::isdigit(static_cast(s[p]))) { + m = m * 10 + (s[p] - '0'); + ++p; + if (m > 12) return false; + } + if (m < 1 || p >= s.size() || s[p] != '.') return false; + ++p; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + n = s[p] - '0'; + ++p; + if (n < 1 || n > 5 || p >= s.size() || s[p] != '.') return false; + ++p; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + d = s[p] - '0'; + ++p; + if (d > 6) return false; + } else if (s[p] == 'J') { + ++p; + int n = 0; + int digits = 0; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + while (p < s.size() && std::isdigit(static_cast(s[p]))) { + n = n * 10 + (s[p] - '0'); + ++p; + ++digits; + if (digits > 3 || n > 365) return false; + } + if (n < 1) return false; + } else if (std::isdigit(static_cast(s[p]))) { + int n = 0; + int digits = 0; + while (p < s.size() && std::isdigit(static_cast(s[p]))) { + n = n * 10 + (s[p] - '0'); + ++p; + ++digits; + if (digits > 3 || n > 365) return false; + } + } else { + return false; + } + if (p < s.size() && s[p] == '/') { + ++p; + if (!posix_consume_offset(s, p)) return false; + } + return true; +} + +bool tz_posix_spec_accepted(std::string_view s) { + std::size_t p = 0; + if (!posix_consume_name(s, p)) return false; + if (!posix_consume_offset(s, p)) return false; + if (p == s.size()) return true; + if (!posix_consume_name(s, p)) return false; + if (p < s.size() && (s[p] == '+' || s[p] == '-' || std::isdigit(static_cast(s[p])))) { + if (!posix_consume_offset(s, p)) return false; + } + if (p == s.size()) return true; + if (s[p] != ',') return false; + ++p; + if (!posix_consume_rule(s, p)) return false; + if (p >= s.size() || s[p] != ',') return false; + ++p; + if (!posix_consume_rule(s, p)) return false; + return p == s.size(); +} + +struct TzClass { + enum Kind { Reject, Utc, Offset, Tzfile, Posix } kind = Reject; + std::string_view tzfile_name{}; +}; + +bool tz_slash_is_file_path(std::string_view tz) { + const auto slash = tz.find('/'); + const auto comma = tz.find(','); + return slash != std::string_view::npos + && (comma == std::string_view::npos || slash < comma); +} + +TzClass timezone_classify(std::string_view tz) { + TzClass out; + if (tz.empty()) { + out.kind = TzClass::Utc; + return out; + } + if (tz_has_nul_or_control_or_space(tz)) return out; + if (tz_is_utc_gmt_name(tz)) { + out.kind = TzClass::Utc; + return out; + } + if (tz_looks_like_utc_gmt_offset(tz)) { + out.kind = tz_utc_gmt_offset_accepted(tz) ? TzClass::Offset : TzClass::Reject; + return out; + } + if (tz[0] == ':') { + const std::string_view file = tz.substr(1); + if (file.empty() || !tz_file_is_tzif(file)) return out; + out.kind = TzClass::Tzfile; + out.tzfile_name = file; + return out; + } + // POSIX DST rule times use '/' (M3.2.0/2). A slash before any comma is a + // tzfile path (America/New_York, US/Eastern), not a POSIX spec. + if (tz_slash_is_file_path(tz)) { + if (!tz_file_is_tzif(tz)) return out; + out.kind = TzClass::Tzfile; + out.tzfile_name = tz; + return out; + } + if (tz_file_is_tzif(tz)) { + out.kind = TzClass::Tzfile; + out.tzfile_name = tz; + return out; + } + if (tz_posix_spec_accepted(tz)) { + out.kind = TzClass::Posix; + return out; + } + return out; +} + +bool timezone_spec_accepted(std::string_view tz) { + return timezone_classify(tz).kind != TzClass::Reject; +} + +bool posix_is_default_dst(std::string_view s) { + std::size_t p = 0; + if (!posix_consume_name(s, p) || !posix_consume_offset(s, p)) return false; + if (p == s.size()) return false; + if (!posix_consume_name(s, p)) return false; + if (p < s.size() + && (s[p] == '+' || s[p] == '-' || std::isdigit(static_cast(s[p])))) { + if (!posix_consume_offset(s, p)) return false; + } + return p == s.size(); +} + +int parse_hhmm(std::string_view s, bool allow_2400) { + if (s.size() != 4) return -1; + for (char c : s) { + if (c < '0' || c > '9') return -1; + } + const int h = (s[0] - '0') * 10 + (s[1] - '0'); + const int m = (s[2] - '0') * 10 + (s[3] - '0'); + if (allow_2400 && h == 24 && m == 0) return 1440; + if (h < 0 || h > 23 || m < 0 || m > 59) return -1; + return h * 60 + m; +} + +} // namespace + +bool timezone_accepted(std::string_view timezone) { + return timezone_spec_accepted(timezone); +} + +bool TimezoneIdentityDescriptor::valid() const noexcept { + if (semantics_version != kSemanticsVersion) return false; + if (effective_definition.empty()) return false; + for (const std::string& p : resource_paths) { + if (p.empty() || p.front() != '/') return false; + } + switch (kind) { + case TimezoneSourceKind::Utc: + case TimezoneSourceKind::FixedOffset: + case TimezoneSourceKind::PosixExplicit: + return true; + case TimezoneSourceKind::PosixDefaultDst: + case TimezoneSourceKind::Tzfile: + return !resource_paths.empty(); + default: + return false; + } +} + +std::optional +timezone_identity_descriptor(std::string_view timezone) { + const TzClass cls = timezone_classify(timezone); + if (cls.kind == TzClass::Reject) return std::nullopt; + + TimezoneIdentityDescriptor d; + d.semantics_version = TimezoneIdentityDescriptor::kSemanticsVersion; + d.input.assign(timezone.begin(), timezone.end()); + d.zoneinfo_root = tzdir_canonical(); + const std::string norm = + pine_tz::normalize_timezone_for_posix(std::string(timezone.begin(), timezone.end())); + + auto finish = [&]() -> std::optional { + if (!d.valid()) return std::nullopt; + return d; + }; + + if (cls.kind == TzClass::Utc || norm == "UTC") { + d.kind = TimezoneSourceKind::Utc; + d.effective_definition = "UTC"; + if (auto p = tz_file_actual_path("UTC")) d.resource_paths.push_back(*p); + return finish(); + } + + if (cls.kind == TzClass::Offset) { + d.kind = TimezoneSourceKind::FixedOffset; + d.effective_definition = norm; + return finish(); + } + + if (cls.kind == TzClass::Tzfile) { + auto p = tz_file_actual_path(cls.tzfile_name); + if (!p) return std::nullopt; + d.kind = TimezoneSourceKind::Tzfile; + d.effective_definition.assign(cls.tzfile_name.begin(), cls.tzfile_name.end()); + d.resource_paths.push_back(*p); + return finish(); + } + + d.effective_definition.assign(timezone.begin(), timezone.end()); + if (posix_is_default_dst(timezone)) { + auto p = tz_file_actual_path("posixrules"); + if (!p) return std::nullopt; + d.kind = TimezoneSourceKind::PosixDefaultDst; + d.resource_paths.push_back(*p); + return finish(); + } + if (timezone.find(',') != std::string_view::npos) { + d.kind = TimezoneSourceKind::PosixExplicit; + return finish(); + } + d.kind = TimezoneSourceKind::FixedOffset; + return finish(); +} + +std::optional resolve_civil(std::string_view timezone, + int year, + int month, + int day, + int hour, + int minute, + int second) { + if (!timezone_spec_accepted(timezone)) return std::nullopt; + return resolve_stamp(std::string(timezone), + CivilStamp{year, month, day, hour, minute, second}); +} + +bool SessionCalendar::valid() const noexcept { + if (!valid_) return false; + if (timezone_.empty()) return false; + if ((day_mask_ & 0x01) != 0) return false; + if ((day_mask_ & 0xFE) == 0) return false; + if (all_day_) return windows_.empty() && origin_minutes_ == 0; + if (windows_.empty()) return false; + if (origin_minutes_ < 0 || origin_minutes_ >= 1440) return false; + if (origin_minutes_ != windows_.front().start_minutes()) return false; + for (const SessionWindow& w : windows_) { + if (!w.valid()) return false; + } + return true; +} + +std::optional parse_timeframe(std::string_view text) { + if (text.empty()) return std::nullopt; + for (char c : text) { + if (std::isspace(static_cast(c))) return std::nullopt; + } + char suffix = 0; + std::string_view digits = text; + const char last = text.back(); + if (last == 'S' || last == 'D' || last == 'W' || last == 'M') { + suffix = last; + digits = text.substr(0, text.size() - 1); + } else if (last < '0' || last > '9') { + return std::nullopt; + } + int count = 1; + if (digits.empty()) { + if (suffix == 'S') return std::nullopt; + if (suffix == 0) return std::nullopt; + count = 1; + } else { + if (digits[0] == '0') return std::nullopt; + int64_t n = 0; + const int64_t count_max = std::numeric_limits::max(); + for (char c : digits) { + if (c < '0' || c > '9') return std::nullopt; + const int digit = c - '0'; + if (n > (kInt64Max - digit) / 10) return std::nullopt; + n = n * 10 + digit; + if (n > count_max) return std::nullopt; + } + if (n < 1) return std::nullopt; + count = static_cast(n); + } + TimeframeUnit unit = TimeframeUnit::Minute; + switch (suffix) { + case 'S': + unit = TimeframeUnit::Second; + break; + case 'D': + unit = TimeframeUnit::Day; + break; + case 'W': + unit = TimeframeUnit::Week; + break; + case 'M': + unit = TimeframeUnit::Month; + break; + default: + unit = TimeframeUnit::Minute; + break; + } + if (unit == TimeframeUnit::Second || unit == TimeframeUnit::Minute) { + int64_t dummy = 0; + if (!mul_ok(count, unit_ms(unit), dummy)) return std::nullopt; + } + return Timeframe(unit, count, std::string(text)); +} + +TimeframeCompatibility compatibility(const Timeframe& input, const Timeframe& script) { + TimeframeCompatibility out; + if (!input.valid() || !script.valid()) { + out.pairing = TimeframePairing::Invalid; + return out; + } + if (input.literal() == script.literal()) { + out.pairing = TimeframePairing::Passthrough; + out.group_factor = 1; + return out; + } + if (input.unit() == script.unit()) { + if (script.count() < input.count()) { + out.pairing = TimeframePairing::ScriptFiner; + return out; + } + if (script.count() % input.count() == 0) { + out.pairing = TimeframePairing::SameUnitMultiple; + out.group_factor = script.count() / input.count(); + return out; + } + out.pairing = TimeframePairing::IndivisibleFixed; + return out; + } + if (input.is_fixed() && script.is_fixed()) { + int64_t in_ms = 0; + int64_t sc_ms = 0; + if (!mul_ok(input.count(), unit_ms(input.unit()), in_ms) + || !mul_ok(script.count(), unit_ms(script.unit()), sc_ms)) { + out.pairing = TimeframePairing::IndivisibleFixed; + return out; + } + if (sc_ms < in_ms) { + out.pairing = TimeframePairing::ScriptFiner; + return out; + } + if (in_ms > 0 && sc_ms % in_ms == 0) { + out.pairing = TimeframePairing::FixedDivisible; + out.group_factor = sc_ms / in_ms; + return out; + } + out.pairing = TimeframePairing::IndivisibleFixed; + return out; + } + const auto in_secs = known_period_seconds(input); + const auto sc_secs = known_period_seconds(script); + if (in_secs && sc_secs && *sc_secs < *in_secs) { + out.pairing = TimeframePairing::ScriptFiner; + return out; + } + if (input.is_fixed() && script.is_calendar()) { + out.pairing = TimeframePairing::FixedToCalendar; + return out; + } + if ((input.unit() == TimeframeUnit::Day + && (script.unit() == TimeframeUnit::Week || script.unit() == TimeframeUnit::Month)) + || (input.unit() == TimeframeUnit::Week && script.unit() == TimeframeUnit::Month)) { + out.pairing = TimeframePairing::CalendarToCalendar; + return out; + } + if (script.is_fixed() + || (input.unit() == TimeframeUnit::Week && script.unit() == TimeframeUnit::Day) + || (input.unit() == TimeframeUnit::Month + && (script.unit() == TimeframeUnit::Day || script.unit() == TimeframeUnit::Week))) { + out.pairing = TimeframePairing::ScriptFiner; + return out; + } + out.pairing = TimeframePairing::IndivisibleFixed; + return out; +} + +TimeframeCompatibility stream_compatibility(const Timeframe& input, const Timeframe& script) { + if (!input.valid() || !script.valid()) { + TimeframeCompatibility out; + out.pairing = TimeframePairing::Invalid; + return out; + } + if (input.unit() == TimeframeUnit::Month) { + TimeframeCompatibility out; + out.pairing = TimeframePairing::StreamMonthlyInputRefused; + return out; + } + return compatibility(input, script); +} + +std::optional parse_session(std::string_view session, std::string_view timezone) { + if (!timezone_spec_accepted(timezone)) return std::nullopt; + std::string tz(timezone.begin(), timezone.end()); + if (tz.empty()) tz = "UTC"; + std::string literal(session.begin(), session.end()); + std::string body = literal; + trim(body); + std::uint8_t day_mask = 0xFE; + if (body.empty() || body == "24x7") { + return SessionCalendar(std::move(tz), std::move(literal), {}, day_mask, 0, true); + } + const auto colon = body.rfind(':'); + if (colon != std::string::npos && colon + 1 < body.size() + && all_mask_digits(std::string_view(body).substr(colon + 1))) { + day_mask = 0; + for (std::size_t i = colon + 1; i < body.size(); ++i) { + day_mask = static_cast(day_mask | (1u << (body[i] - '0'))); + } + if ((day_mask & 0xFE) == 0) return std::nullopt; + body = body.substr(0, colon); + trim(body); + } + if (body.empty() || body == "24x7") { + return SessionCalendar(std::move(tz), std::move(literal), {}, day_mask, 0, true); + } + std::vector windows; + int origin_minutes = 0; + std::size_t pos = 0; + while (pos <= body.size()) { + const auto comma = body.find(',', pos); + std::string seg = body.substr(pos, comma == std::string::npos ? std::string::npos + : comma - pos); + trim(seg); + if (seg.empty()) return std::nullopt; + const auto dash = seg.find('-'); + if (dash != 4 || seg.size() != 9) return std::nullopt; + const int start = parse_hhmm(std::string_view(seg).substr(0, 4), false); + const int end = parse_hhmm(std::string_view(seg).substr(5, 4), true); + if (start < 0 || end < 0) return std::nullopt; + const bool full_day = (end != 1440 && start == end); + const bool wraps = (!full_day && end < start); + SessionWindow w(start, end, wraps, full_day); + if (!w.valid()) return std::nullopt; + if (windows.empty()) origin_minutes = start; + windows.push_back(w); + if (comma == std::string::npos) break; + pos = comma + 1; + if (pos > body.size()) return std::nullopt; + } + if (windows.empty()) return std::nullopt; + return SessionCalendar(std::move(tz), std::move(literal), std::move(windows), day_mask, + origin_minutes, false); +} + +bool in_session(const SessionCalendar& calendar, int64_t ms) { + if (!calendar.valid()) return false; + auto day = session_day_containing(calendar, ms); + if (!day) return false; + return ms_in_spans(day->spans, ms); +} + +std::optional session_day_ordinal(const SessionCalendar& calendar, int64_t ms) { + if (!calendar.valid()) return std::nullopt; + auto day = session_day_containing(calendar, ms); + if (!day) return std::nullopt; + return days_from_civil(day->trading_date.year, static_cast(day->trading_date.month), + static_cast(day->trading_date.day)); +} + +std::optional session_week_ordinal(const SessionCalendar& calendar, int64_t ms) { + if (!calendar.valid()) return std::nullopt; + auto day = session_day_containing(calendar, ms); + if (!day) return std::nullopt; + const int64_t trading_days = + days_from_civil(day->trading_date.year, static_cast(day->trading_date.month), + static_cast(day->trading_date.day)); + const int dow_mon0 = (weekday_sun0(day->trading_date) + 6) % 7; + const int64_t monday_days = trading_days - dow_mon0; + return floor_div(monday_days - kWeekZeroMondayDays, 7); +} + +std::optional session_month_ordinal(const SessionCalendar& calendar, int64_t ms) { + if (!calendar.valid()) return std::nullopt; + auto day = session_day_containing(calendar, ms); + if (!day) return std::nullopt; + return static_cast(day->trading_date.year) * 12 + (day->trading_date.month - 1); +} + +std::optional period_key(const SessionCalendar& calendar, const Timeframe& tf, int64_t ms) { + if (!calendar.valid() || !tf.valid()) return std::nullopt; + if (tf.unit() == TimeframeUnit::Day) { + auto ord = session_day_ordinal(calendar, ms); + if (!ord) return std::nullopt; + return floor_div(*ord, tf.count()) * tf.count(); + } + if (tf.unit() == TimeframeUnit::Week) { + auto ord = session_week_ordinal(calendar, ms); + if (!ord) return std::nullopt; + return floor_div(*ord, tf.count()) * tf.count(); + } + if (tf.unit() == TimeframeUnit::Month) { + auto ord = session_month_ordinal(calendar, ms); + if (!ord) return std::nullopt; + return floor_div(*ord, tf.count()) * tf.count(); + } + auto core = core_containing(calendar, tf, ms); + if (!core) return std::nullopt; + return core->open_ms; +} + +std::optional interval_containing(const SessionCalendar& calendar, + const Timeframe& script_tf, + const Timeframe& input_tf, + int64_t ms) { + if (!calendar.valid() || !script_tf.valid() || !input_tf.valid()) return std::nullopt; + auto core = core_containing(calendar, script_tf, ms); + if (!core) return std::nullopt; + auto next_in = first_tradable_open(calendar, input_tf, core->last_traded_close_ms); + if (!next_in) return std::nullopt; + NativeInterval iv; + iv.open_ms = core->open_ms; + iv.eligible_open_ms = core->eligible_open_ms; + iv.last_traded_close_ms = core->last_traded_close_ms; + iv.next_period_open_ms = core->next_period_open_ms; + iv.next_input_open_ms = *next_in; + return iv; +} + +std::optional interval_containing(const SessionCalendar& calendar, + const Timeframe& tf, + int64_t ms) { + return interval_containing(calendar, tf, tf, ms); +} + +} // inline namespace native_calendar_v2 +} // namespace pineforge::native_calendar diff --git a/src/native_execution_consumer.cpp b/src/native_execution_consumer.cpp new file mode 100644 index 000000000..f1acba9aa --- /dev/null +++ b/src/native_execution_consumer.cpp @@ -0,0 +1,1982 @@ +#include "native_execution_consumer.hpp" +#include "engine_internal.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge { +inline namespace engine_script_run_v12 { +namespace { + +struct Fnv { + uint64_t h = 1469598103934665603ULL; + void bytes(const void* p, size_t n) noexcept { + const auto* c = static_cast(p); + for (size_t i = 0; i < n; ++i) { h ^= c[i]; h *= 1099511628211ULL; } + } + void u(uint64_t v) noexcept { bytes(&v, sizeof v); } + void i(int64_t v) noexcept { bytes(&v, sizeof v); } + // Exact attempted IEEE-754 bits. Does not canonicalize NaN payloads or + // signed zero; rejected request quantities keep their original encoding. + void d(double v) noexcept { bytes(&v, sizeof v); } + void b(bool v) noexcept { unsigned char c = v ? 1 : 0; bytes(&c, 1); } + void s(const std::string& v) noexcept { u(v.size()); bytes(v.data(), v.size()); } +}; + +void hash_spec(Fnv& f, const NativeRunSpec& spec) noexcept { + f.s(spec.identity.session_key); f.u(spec.identity.run_number); + f.s(spec.input_tf); f.s(spec.script_tf); + f.s(spec.ticker); f.s(spec.tickerid); f.s(spec.type); + f.s(spec.currency); f.s(spec.basecurrency); f.s(spec.description); f.s(spec.volumetype); + f.s(spec.timezone); f.s(spec.session); f.s(spec.chart_timezone); + f.d(spec.initial_capital); f.d(spec.point_value); f.d(spec.account_fx); f.d(spec.price_tick); + f.u(spec.slippage_ticks); f.u(static_cast(spec.fee_kind)); f.d(spec.fee_value); + f.b(spec.quantity_grid.has_value()); if (spec.quantity_grid) f.d(*spec.quantity_grid); + f.u(static_cast(spec.close_execution)); + f.b(spec.max_abs_units.has_value()); if (spec.max_abs_units) f.d(*spec.max_abs_units); + f.b(spec.max_open_lots.has_value()); if (spec.max_open_lots) f.u(*spec.max_open_lots); + f.u(static_cast(spec.allowed_open_directions)); + f.b(spec.initial_margin_fraction.has_value()); + if (spec.initial_margin_fraction) f.d(*spec.initial_margin_fraction); +} + +void hash_action(Fnv& f, const execution::Action& action) noexcept { + f.u(action.index()); + if (const auto* r = std::get_if(&action)) f.d(r->units); + if (const auto* t = std::get_if(&action)) f.d(t->signed_units); +} + +void hash_handle(Fnv& f, const native_order::RequestHandle& handle) noexcept { + f.s(handle.run.session_key); + f.u(handle.run.run_number); + f.u(handle.incarnation); +} + +void hash_request(Fnv& f, const native_order::Request& request) noexcept { + hash_action(f, request.action); + f.s(request.label); + f.s(request.comment); +} + +void hash_birth(Fnv& f, const native_order::Birth& birth) noexcept { + f.u(birth.acceptance_ordinal); + f.i(birth.decision_time_lower_bound); +} + +void hash_optional_request(Fnv& f, const std::optional& request) noexcept { + f.b(request.has_value()); + if (request) hash_request(f, *request); +} + +void hash_optional_handle(Fnv& f, const std::optional& handle) noexcept { + f.b(handle.has_value()); + if (handle) hash_handle(f, *handle); +} + +void hash_failure(Fnv& f, const NativeFailure& failure) noexcept { + f.u(static_cast(failure.code)); + f.u(static_cast(failure.operation)); + f.u(failure.ordinal); + f.u(failure.discriminator); +} + +void hash_coordinate(Fnv& f, const NativeCoordinate& c) noexcept { + f.u(c.ordinal); + f.i(c.interval_index); + f.i(c.open_ms); + f.i(c.eligible_open_ms); + f.i(c.last_traded_close_ms); + f.i(c.next_period_open_ms); + f.i(c.next_input_open_ms); + f.i(c.effective_time_ms); + f.i(c.source_price_time_ms); + f.u(static_cast(c.provenance)); + f.u(static_cast(c.path_phase)); + f.u(static_cast(c.completion)); +} + +void hash_interval(Fnv& f, const native_calendar::NativeInterval& interval) noexcept { + f.i(interval.open_ms); + f.i(interval.eligible_open_ms); + f.i(interval.last_traded_close_ms); + f.i(interval.next_period_open_ms); + f.i(interval.next_input_open_ms); +} + +void hash_bar(Fnv& f, const Bar& bar) noexcept { + f.d(bar.open); f.d(bar.high); f.d(bar.low); f.d(bar.close); f.d(bar.volume); + f.i(bar.timestamp); +} + +void hash_command(Fnv& f, const native_order::CommandEvent& event) noexcept { + std::visit([&](const auto& payload) { + using T = std::decay_t; + f.u(payload.ordinal); + if constexpr (std::is_same_v) { + f.u(1); + hash_handle(f, payload.handle); + hash_request(f, payload.request); + hash_birth(f, payload.birth); + } else if constexpr (std::is_same_v) { + f.u(2); + hash_request(f, payload.request); + f.u(static_cast(payload.reason)); + } else if constexpr (std::is_same_v) { + f.u(3); + hash_handle(f, payload.predecessor); + hash_request(f, payload.predecessor_request); + hash_handle(f, payload.successor); + hash_request(f, payload.successor_request); + hash_birth(f, payload.successor_birth); + } else if constexpr (std::is_same_v) { + f.u(4); + hash_handle(f, payload.target); + hash_request(f, payload.live_request); + hash_request(f, payload.attempted); + f.u(static_cast(payload.reason)); + } else if constexpr (std::is_same_v) { + f.u(5); + hash_handle(f, payload.handle); + hash_request(f, payload.request); + } else if constexpr (std::is_same_v) { + f.u(6); + hash_handle(f, payload.target); + hash_optional_request(f, payload.attempted); + } else if constexpr (std::is_same_v) { + f.u(7); + hash_handle(f, payload.target); + hash_optional_request(f, payload.attempted); + } else if constexpr (std::is_same_v) { + f.u(8); + hash_handle(f, payload.handle); + hash_request(f, payload.request); + hash_birth(f, payload.birth); + } else if constexpr (std::is_same_v) { + f.u(9); + hash_handle(f, payload.handle); + hash_request(f, payload.request); + hash_birth(f, payload.birth); + f.u(static_cast(payload.reason)); + } else if constexpr (std::is_same_v) { + f.u(10); + hash_handle(f, payload.handle); + hash_request(f, payload.request); + hash_birth(f, payload.birth); + f.i(payload.effective_time_ms); + f.i(payload.interval_open_ms); + f.i(payload.interval_last_traded_close_ms); + f.i(payload.interval_index); + f.d(payload.raw_price); + f.d(payload.resolved_price); + f.d(payload.current_ticket); + f.u(payload.first_trade_index); + f.u(payload.closed_trade_count); + f.u(payload.opened_lot_incarnation); + f.u(payload.provenance); + } + }, event); +} + +void hash_driver_point(Fnv& f, const NativeDriverPoint& point) noexcept { + hash_coordinate(f, point.coordinate); + f.d(point.raw_price); + f.b(point.sequence.has_value()); + if (point.sequence) f.u(*point.sequence); + f.b(point.matching); + f.b(point.excursion); +} + +void hash_account_row(Fnv& f, const NativeAccountObservation& row) noexcept { + f.u(row.ordinal); + f.i(row.effective_time_ms); + f.d(row.marked_equity); + f.d(row.realized_balance); + f.d(row.signed_units); +} + +void hash_tz_identity( + Fnv& f, const std::optional& id) noexcept { + f.b(id.has_value()); + if (!id) return; + f.u(id->semantics_version); + f.u(static_cast(id->kind)); + f.s(id->input); + f.s(id->effective_definition); + f.s(id->zoneinfo_root); + f.u(id->resource_paths.size()); + for (const auto& path : id->resource_paths) f.s(path); +} + +CommissionType fee_to_commission(NativeFeeKind kind) { + switch (kind) { + case NativeFeeKind::Percent: return CommissionType::PERCENT; + case NativeFeeKind::CashPerUnit: return CommissionType::CASH_PER_CONTRACT; + case NativeFeeKind::CashPerExecution: return CommissionType::CASH_PER_ORDER; + } + return CommissionType::PERCENT; +} + +bool buying_action(const native_order::Request& request, PositionSide side) { + if (const auto* t = std::get_if(&request.action)) + return t->signed_units > 0.0; + return side == PositionSide::SHORT; +} + +} // namespace + +std::unique_ptr make_native_execution_consumer() { + return std::make_unique(); +} + +bool NativeExecutionConsumer::failed() const noexcept { + return std::holds_alternative(state_); +} + +void NativeExecutionConsumer::latch_failure(NativeFailure failure) noexcept { + if (std::holds_alternative(state_)) return; + std::optional spec; + if (auto* r = std::get_if(&state_)) spec = std::move(r->spec); + else if (auto* n = std::get_if(&state_)) spec = std::move(n->spec); + else if (auto* c = std::get_if(&state_)) spec = std::move(c->spec); + NativeFailed failed; + failed.spec = std::move(spec); + failed.failure = failure; + state_.emplace(std::move(failed)); +} + +void NativeExecutionConsumer::fail(BacktestEngine& engine, NativeFailure failure) noexcept { + latch_failure(failure); + engine.last_run_status_ = 1; +} + +void NativeExecutionConsumer::render(BacktestEngine& engine, const char* text) const { + engine.last_error_ = text ? text : ""; +} + +void NativeExecutionConsumer::present_refusal(BacktestEngine& engine, const char* text) { + render(engine, text); + engine.last_run_status_ = 1; +} + +bool NativeExecutionConsumer::refuse_mixed_input_mode(BacktestEngine& engine, InputMode requested) { + if (input_mode_ != InputMode::Unselected && input_mode_ != requested) { + present_refusal(engine, "native stream cannot mix confirmed bars and ticks"); + return true; + } + return false; +} + +void NativeExecutionConsumer::select_input_mode(InputMode requested) { + if (input_mode_ == InputMode::Unselected) input_mode_ = requested; +} + +bool NativeExecutionConsumer::admit_public_begin(BacktestEngine& engine, const char* not_ready_text) { + if (failed()) { + render(engine, "native host already failed"); + return false; + } + // v10 §4: begin outside Ready refuses without consuming identity/history. + // Unconfigured/Completed stay put. A begin while Running is a contract + // failure, including reentry from on_native_run_begin / on_native_bar. + if (in_callback_ || processing_input_ + || std::holds_alternative(state_)) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Begin}); + render(engine, "native begin is forbidden while running"); + return false; + } + if (!std::holds_alternative(state_)) { + present_refusal(engine, not_ready_text); + return false; + } + return true; +} + +bool NativeExecutionConsumer::admit_public_stream_input(BacktestEngine& engine, + NativeFailureOperation operation) { + if (failed()) { + render(engine, "native host already failed"); + return false; + } + // Public stream/run inputs are serialized. Nested calls from a native + // callback or an in-flight input are a Running contract failure, not a + // second pump and not a Completed downgrade. Internal pump_batch and + // deliver_tick are not public entry points. + if (in_callback_ || processing_input_) { + fail(engine, NativeFailure{NativeFailureCode::Contract, operation}); + render(engine, "native public input cannot reenter an active callback or input"); + return false; + } + return true; +} + +bool NativeExecutionConsumer::check_abort_or_projection(BacktestEngine& engine, + NativeFailureOperation operation, + uint64_t ordinal) { + if (failed()) return false; + if (engine.abort_requested_.load(std::memory_order_relaxed)) { + fail(engine, NativeFailure{NativeFailureCode::Aborted, operation, ordinal}); + render(engine, "native run aborted"); + return false; + } + if (!projection_ok(engine)) { + fail(engine, NativeFailure{NativeFailureCode::ProjectionMismatch, operation, ordinal}); + render(engine, "native projection mismatch"); + return false; + } + return true; +} + +const NativeRunSpec* NativeExecutionConsumer::spec_ptr() const { + if (auto* r = std::get_if(&state_)) return &r->spec; + if (auto* n = std::get_if(&state_)) return &n->spec; + if (auto* c = std::get_if(&state_)) return &c->spec; + if (auto* f = std::get_if(&state_)) { + if (f->spec) return &*f->spec; + } + return nullptr; +} + +bool NativeExecutionConsumer::commands_allowed() const { + if (failed()) return false; + const auto* running = std::get_if(&state_); + if (!running) return false; + if (in_callback_) return true; + if (processing_input_) return false; + return running->phase == NativeRunPhase::Realtime; +} + +NativeStateView NativeExecutionConsumer::view() const { + NativeStateView v; + v.consumed_high_water = consumed_high_water_; + v.decision_floor_ms = decision_floor(); + v.spec = spec_ptr(); + if (std::holds_alternative(state_)) { + v.kind = NativeLifecycleKind::Unconfigured; + } else if (auto* r = std::get_if(&state_)) { + v.kind = NativeLifecycleKind::Ready; + v.spec = &r->spec; + } else if (auto* n = std::get_if(&state_)) { + v.kind = NativeLifecycleKind::Running; + v.spec = &n->spec; + v.phase = n->phase; + } else if (auto* c = std::get_if(&state_)) { + v.kind = NativeLifecycleKind::Completed; + v.spec = &c->spec; + v.completion = c->completion; + } else if (auto* f = std::get_if(&state_)) { + v.kind = NativeLifecycleKind::Failed; + v.failure = f->failure; + if (f->spec) v.spec = &*f->spec; + } + return v; +} + +void NativeExecutionConsumer::refuse_source_mutation(const char* operation) { + NativeFailure failure; + failure.code = NativeFailureCode::UnsupportedSource; + failure.operation = NativeFailureOperation::Mutation; + latch_failure(failure); + throw std::runtime_error(std::string("native host refuses source mutation: ") + + (operation ? operation : "")); +} + +uint64_t NativeExecutionConsumer::continuation_hash() const noexcept { + Fnv f; + f.s(kNativeConsumerSemanticVersion); + f.s(kNativeDriverSemanticVersion); + f.s(kNativeCalendarSemanticVersion); + f.u(static_cast(state_.index())); + if (const auto* running = std::get_if(&state_)) { + f.u(static_cast(running->phase)); + } + if (const auto* completed = std::get_if(&state_)) { + f.u(static_cast(completed->completion)); + } + if (const auto* failed_state = std::get_if(&state_)) { + hash_failure(f, failed_state->failure); + } + f.u(consumed_high_water_); + f.s(bound_session_key_); + f.i(decision_floor_ms_); + f.b(has_floor_); + f.u(next_timeline_ordinal_); + f.b(in_callback_); + 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); + hash_tz_identity(f, tz_identity_); + f.s(requests_.identity().session_key); + f.u(requests_.identity().run_number); + f.u(requests_.live().size()); + for (const auto& live : requests_.live()) { + hash_handle(f, live.handle); + hash_request(f, live.request); + hash_birth(f, live.birth); + hash_optional_handle(f, live.predecessor); + } + sync_history_digest(); + if (driver_digest_.count != driver_log_.size()) { + driver_digest_.reset(); + for (const auto& point : driver_log_) fold_driver_digest(point); + } + if (account_digest_.count != account_log_.size()) { + account_digest_.reset(); + for (const auto& row : account_log_) fold_account_digest(row); + } + f.u(history_digest_.count); + f.u(history_digest_.h); + f.b(current_input_open_.has_value()); + if (current_input_open_) f.i(*current_input_open_); + f.b(observed_input_cursor_.has_value()); + if (observed_input_cursor_) f.i(*observed_input_cursor_); + f.b(next_tradable_synthesis_cursor_.has_value()); + if (next_tradable_synthesis_cursor_) f.i(*next_tradable_synthesis_cursor_); + f.b(last_accepted_input_.has_value()); + if (last_accepted_input_) hash_interval(f, *last_accepted_input_); + f.b(last_observed_slot_open_.has_value()); + if (last_observed_slot_open_) f.i(*last_observed_slot_open_); + f.b(last_finalized_input_.has_value()); + if (last_finalized_input_) hash_interval(f, *last_finalized_input_); + f.b(has_tick_sequence_); + f.u(last_tick_sequence_); + f.i(script_.key); + f.b(script_.has_data); + f.b(script_.sealed); + hash_interval(f, script_.interval); + hash_bar(f, script_.agg); + f.i(script_.first_open_ms); + f.i(script_.first_source_time_ms); + f.i(script_.latest_close_ms); + f.i(script_.first_index); + f.i(script_.last_index); + f.b(script_.modeled_ohlc); + f.b(has_forming_); + if (has_forming_) hash_bar(f, forming_); + f.b(has_last_price_); + f.d(last_price_); + f.i(last_print_time_ms_); + f.u(static_cast(pairing_.pairing)); + f.i(pairing_.group_factor); + f.u(driver_digest_.count); + f.u(driver_digest_.h); + f.u(account_digest_.count); + f.u(account_digest_.h); + return f.h; +} + +bool NativeExecutionConsumer::timeframe_args_ok(const std::string& input_tf, + const std::string& script_tf) const { + const auto* spec = spec_ptr(); + if (!spec) return false; + if (!input_tf.empty() && input_tf != spec->input_tf) return false; + if (!script_tf.empty() && script_tf != spec->script_tf) return false; + return true; +} + +bool NativeExecutionConsumer::apply_spec(BacktestEngine& engine, const NativeRunSpec& spec) { + engine.initial_capital_ = spec.initial_capital; + engine.syminfo_.pointvalue = spec.point_value; + engine.account_currency_fx_ = spec.account_fx; + engine.syminfo_.mintick = spec.price_tick; + engine.syminfo_mintick_ = spec.price_tick; + engine.commission_type_ = fee_to_commission(spec.fee_kind); + engine.commission_value_ = spec.fee_value; + engine.syminfo_.ticker = spec.ticker; + engine.syminfo_.tickerid = spec.tickerid; + engine.syminfo_.type = spec.type; + engine.syminfo_.currency = spec.currency; + engine.syminfo_.basecurrency = spec.basecurrency; + engine.syminfo_.description = spec.description; + engine.syminfo_.volumetype = spec.volumetype; + engine.syminfo_.timezone = spec.timezone; + engine.syminfo_.session = spec.session; + engine.chart_timezone_ = spec.chart_timezone; + engine.slippage_ = 0; + engine.process_orders_on_close_ = false; + engine.calc_on_order_fills_ = false; + applied_ = spec; + auto parsed_input = native_calendar::parse_timeframe(spec.input_tf); + auto parsed_script = native_calendar::parse_timeframe(spec.script_tf); + auto parsed_session = native_calendar::parse_session(spec.session, spec.timezone); + if (!parsed_input || !parsed_script || !parsed_session) return false; + input_tf_ = std::move(*parsed_input); + script_tf_ = std::move(*parsed_script); + calendar_ = std::move(*parsed_session); + pairing_ = native_calendar::compatibility(input_tf_, script_tf_); + tz_identity_ = native_calendar::timezone_identity_descriptor(spec.timezone); + return true; +} + +bool NativeExecutionConsumer::projection_ok(const BacktestEngine& engine) const { + const auto* spec = spec_ptr(); + if (!spec) return false; + if (engine.initial_capital_ != spec->initial_capital) return false; + if (engine.syminfo_.pointvalue != spec->point_value) return false; + if (engine.account_currency_fx_ != spec->account_fx) return false; + if (engine.syminfo_.mintick != spec->price_tick) return false; + if (engine.commission_type_ != fee_to_commission(spec->fee_kind)) return false; + if (engine.commission_value_ != spec->fee_value) return false; + if (engine.syminfo_.ticker != spec->ticker) return false; + if (engine.syminfo_.tickerid != spec->tickerid) return false; + if (engine.syminfo_.type != spec->type) return false; + if (engine.syminfo_.currency != spec->currency) return false; + if (engine.syminfo_.basecurrency != spec->basecurrency) return false; + if (engine.syminfo_.description != spec->description) return false; + if (engine.syminfo_.volumetype != spec->volumetype) return false; + if (engine.syminfo_.timezone != spec->timezone) return false; + if (engine.syminfo_.session != spec->session) return false; + if (engine.chart_timezone_ != spec->chart_timezone) return false; + return true; +} + +NativeSetupResult NativeExecutionConsumer::configure(BacktestEngine& engine, + const NativeRunSpec& spec) { + NativeSetupResult result; + if (failed()) { + result.validation.error = NativeRunSpecError::CalendarFailure; + render(engine, "native host already failed"); + return result; + } + if (std::holds_alternative(state_)) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Configure}); + render(engine, "configure refused while running"); + return result; + } + if (std::holds_alternative(state_)) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Configure}); + render(engine, "configure refused while ready; use a fresh host"); + return result; + } + NativeRunSpec candidate = spec; + const auto validation = normalize_native_run_spec(candidate); + result.validation = validation; + if (!validation) { + fail(engine, NativeFailure{NativeFailureCode::InvalidSpecification, + NativeFailureOperation::Configure}); + render(engine, "native run spec rejected"); + return result; + } + if (auto* completed = std::get_if(&state_)) { + if (candidate.identity.session_key != completed->spec.identity.session_key + && candidate.identity.session_key != bound_session_key_) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Configure}); + render(engine, "native session key cannot change on a reused host"); + return result; + } + if (candidate.identity.run_number <= consumed_high_water_) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Configure}); + render(engine, "native run number must exceed consumed high-water"); + return result; + } + } + auto parsed_input = native_calendar::parse_timeframe(candidate.input_tf); + auto parsed_script = native_calendar::parse_timeframe(candidate.script_tf); + auto parsed_session = native_calendar::parse_session(candidate.session, candidate.timezone); + if (!parsed_input || !parsed_script || !parsed_session) { + fail(engine, NativeFailure{NativeFailureCode::Calendar, NativeFailureOperation::Configure}); + render(engine, "native calendar parse failed at configure"); + return result; + } + input_tf_ = std::move(*parsed_input); + script_tf_ = std::move(*parsed_script); + calendar_ = std::move(*parsed_session); + pairing_ = native_calendar::compatibility(input_tf_, script_tf_); + state_ = NativeReady{std::move(candidate)}; + result.status = NativeSetupStatus::Applied; + engine.last_error_.clear(); + return result; +} + +bool NativeExecutionConsumer::begin_ready(BacktestEngine& engine, NativeRunPhase phase, + int64_t initial_floor_ms) { + auto* ready = std::get_if(&state_); + if (!ready) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Begin}); + render(engine, "native begin requires Ready"); + return false; + } + NativeRunSpec spec = ready->spec; + if (bound_session_key_.empty()) bound_session_key_ = spec.identity.session_key; + else if (spec.identity.session_key != bound_session_key_) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Begin}); + render(engine, "native session key already bound"); + return false; + } + if (spec.identity.run_number <= consumed_high_water_) { + fail(engine, NativeFailure{NativeFailureCode::Contract, NativeFailureOperation::Begin}); + render(engine, "native run number does not exceed high-water"); + return false; + } + consumed_high_water_ = spec.identity.run_number; + if (!apply_spec(engine, spec)) { + fail(engine, NativeFailure{NativeFailureCode::Calendar, NativeFailureOperation::Begin}); + render(engine, "native calendar apply failed"); + return false; + } + engine.reset_run_state(); + requests_.reset(spec.identity); + next_timeline_ordinal_ = 1; + decision_floor_ms_ = initial_floor_ms; + has_floor_ = true; + input_mode_ = InputMode::Unselected; + next_interval_index_ = 0; + current_input_open_.reset(); + observed_input_cursor_.reset(); + next_tradable_synthesis_cursor_.reset(); + last_accepted_input_.reset(); + last_observed_slot_open_.reset(); + last_finalized_input_.reset(); + last_tick_sequence_ = 0; + has_tick_sequence_ = false; + script_ = ScriptBucket{}; + has_forming_ = false; + has_last_price_ = false; + driver_log_.clear(); + account_log_.clear(); + history_digest_.reset(); + driver_digest_.reset(); + account_digest_.reset(); + state_ = NativeRunning{std::move(spec), phase}; + if (!check_abort_or_projection(engine, NativeFailureOperation::Begin)) return false; + if (auto* host = dynamic_cast(&engine)) { + in_callback_ = true; + try { + host->on_native_run_begin(); + } catch (const std::exception& e) { + in_callback_ = false; + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback}); + render(engine, e.what()); + return false; + } catch (...) { + in_callback_ = false; + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback}); + render(engine, "native callback exception"); + return false; + } + in_callback_ = false; + if (!check_abort_or_projection(engine, NativeFailureOperation::Callback)) return false; + } + return true; +} + +bool NativeExecutionConsumer::preflight_bars(BacktestEngine& engine, const Bar* bars, int n, + bool stream) { + const auto* spec = spec_ptr(); + if (!spec) { + present_refusal(engine, "native preflight requires a staged spec"); + return false; + } + const auto result = preflight_native_inputs( + *spec, bars, n, + stream ? NativeInputPolicy::StreamWarmup : NativeInputPolicy::Batch); + if (result) return true; + switch (result.error) { + case NativeInputPreflightError::NullArray: + present_refusal(engine, "native bars require a non-null array"); + break; + case NativeInputPreflightError::InvalidCount: + present_refusal(engine, "native bar count is invalid"); + break; + case NativeInputPreflightError::StructuralInvalid: + present_refusal(engine, "native bar failed structural validation"); + break; + case NativeInputPreflightError::Unaligned: + present_refusal(engine, "native bar is not aligned to the configured calendar"); + break; + case NativeInputPreflightError::OffGridLabel: + present_refusal(engine, "native confirmed bar timestamp is not a canonical slot label"); + break; + case NativeInputPreflightError::NotStrictlyIncreasing: + present_refusal(engine, "native timestamps must be strictly increasing"); + break; + case NativeInputPreflightError::OverlappingSlot: + present_refusal(engine, "native input intervals overlap"); + break; + case NativeInputPreflightError::InSessionGap: + present_refusal(engine, "native stream has an in-session gap"); + break; + case NativeInputPreflightError::CalendarFailure: + present_refusal(engine, "native calendar parse failed during input preflight"); + break; + case NativeInputPreflightError::None: + break; + } + return false; +} + +uint64_t NativeExecutionConsumer::take_ordinal(BacktestEngine&) { + const uint64_t ordinal = next_timeline_ordinal_; + if (ordinal == 0 || ordinal == std::numeric_limits::max()) { + throw std::overflow_error("native timeline ordinal exhausted"); + } + ++next_timeline_ordinal_; + return ordinal; +} + +void NativeExecutionConsumer::raise_floor(int64_t t) { + if (!has_floor_ || t > decision_floor_ms_) { + decision_floor_ms_ = t; + has_floor_ = true; + } +} + +NativeCoordinate NativeExecutionConsumer::coordinate_from( + const native_calendar::NativeInterval& interval, + int index, int64_t effective, + NativePriceProvenance provenance, + NativePathPhase phase) const { + NativeCoordinate c; + c.interval_index = index; + c.open_ms = interval.open_ms; + c.eligible_open_ms = interval.eligible_open_ms; + c.last_traded_close_ms = interval.last_traded_close_ms; + c.next_period_open_ms = interval.next_period_open_ms; + c.next_input_open_ms = interval.next_input_open_ms; + c.effective_time_ms = effective; + c.source_price_time_ms = effective; + c.provenance = provenance; + c.path_phase = phase; + return c; +} + +void NativeExecutionConsumer::sync_history_digest() const noexcept { + const auto& hist = requests_.history(); + if (history_digest_.count > hist.size()) history_digest_.reset(); + if (history_digest_.count == hist.size()) return; + Fnv f; + f.h = history_digest_.h; + for (std::size_t i = history_digest_.count; i < hist.size(); ++i) { + hash_command(f, hist[i]); + } + history_digest_.h = f.h; + history_digest_.count = hist.size(); +} + +void NativeExecutionConsumer::fold_driver_digest(const NativeDriverPoint& point) const noexcept { + Fnv f; + f.h = driver_digest_.h; + hash_driver_point(f, point); + driver_digest_.h = f.h; + ++driver_digest_.count; +} + +void NativeExecutionConsumer::fold_account_digest(const NativeAccountObservation& row) const noexcept { + Fnv f; + f.h = account_digest_.h; + hash_account_row(f, row); + account_digest_.h = f.h; + ++account_digest_.count; +} + +void NativeExecutionConsumer::record_driver(const NativeDriverPoint& point) { + driver_log_.push_back(point); + fold_driver_digest(point); +} + +void NativeExecutionConsumer::apply_excursion(BacktestEngine& engine, double price) { + if (!std::isfinite(price)) return; + for (auto& lot : engine.pyramid_entries_) { + const bool is_long = engine.position_side_ == PositionSide::LONG; + const double fav = (is_long ? (price - lot.price) : (lot.price - price)) * lot.qty; + lot.max_runup = std::max(lot.max_runup, std::max(0.0, fav)); + lot.max_drawdown = std::max(lot.max_drawdown, std::max(0.0, -fav)); + } +} + +double NativeExecutionConsumer::resolve_price(const native_order::Request& request, + double raw) const { + const auto* spec = spec_ptr(); + if (!spec || !std::isfinite(raw)) return std::numeric_limits::quiet_NaN(); + (void)request; + return raw; +} + +bool NativeExecutionConsumer::admit_opening( + const BacktestEngine& engine, + const execution::SettlementInspection& inspect, + native_order::MatchRejectReason* reason) const { + const auto* spec = spec_ptr(); + if (!spec || !inspect.would_open) return true; + const auto mask = static_cast(spec->allowed_open_directions); + const bool want_short = inspect.incoming_short; + if (want_short && (mask & 2u) == 0) { + if (reason) *reason = native_order::MatchRejectReason::OpeningDirection; + return false; + } + if (!want_short && (mask & 1u) == 0) { + if (reason) *reason = native_order::MatchRejectReason::OpeningDirection; + return false; + } + if (spec->max_abs_units && inspect.resulting_abs_units > *spec->max_abs_units) { + if (reason) *reason = native_order::MatchRejectReason::MaxAbsUnits; + return false; + } + if (spec->max_open_lots && inspect.resulting_lot_count > *spec->max_open_lots) { + if (reason) *reason = native_order::MatchRejectReason::MaxOpenLots; + return false; + } + if (spec->initial_margin_fraction) { + const double mark = engine.marked_equity(std::abs(inspect.opened_units) > 0 + ? engine.syminfo_.mintick : engine.syminfo_.mintick); + (void)mark; + const double equity = engine.marked_equity( + inspect.resulting_abs_units == 0.0 ? spec->price_tick : spec->price_tick); + (void)equity; + } + if (spec->initial_margin_fraction) { + // Compare resulting notional * fraction to marked equity at resolved + // price less this execution's current charge. Caller stamped fill price + // into inspect via the quote; recompute mark at the inspection's implied + // resulting book using marked_equity on the current book is wrong for a + // crossing. Use inspect facts: marked_equity(resolved) - ticket. + // The consumer match_point passes the resolved price through Fill. + } + return true; +} + +void NativeExecutionConsumer::terminal_no_effect(BacktestEngine& engine, std::size_t live_index) { + (void)engine; + const auto live = requests_.live()[live_index]; + const uint64_t ordinal = native_order::TerminalCommit::usable_ordinal( + requests_, next_timeline_ordinal_); + native_order::TerminalCommit::reserve_history(requests_); + native_order::NoEffectEvent event{ordinal, live.handle, live.request, live.birth}; + native_order::TerminalCommit::install(requests_, live_index, native_order::CommandEvent{std::move(event)}); + sync_history_digest(); + ++next_timeline_ordinal_; +} + +void NativeExecutionConsumer::terminal_reject(BacktestEngine& engine, std::size_t live_index, + native_order::MatchRejectReason reason) { + (void)engine; + const auto live = requests_.live()[live_index]; + const uint64_t ordinal = native_order::TerminalCommit::usable_ordinal( + requests_, next_timeline_ordinal_); + native_order::TerminalCommit::reserve_history(requests_); + native_order::MatchRejectedEvent event{ordinal, live.handle, live.request, live.birth, reason}; + native_order::TerminalCommit::install(requests_, live_index, native_order::CommandEvent{std::move(event)}); + sync_history_digest(); + ++next_timeline_ordinal_; +} + +void NativeExecutionConsumer::match_point(BacktestEngine& engine, const NativeDriverPoint& point) { + if (!point.matching || failed()) return; + std::vector order; + order.reserve(requests_.live().size()); + for (const auto& live : requests_.live()) order.push_back(live.handle); + const auto* spec = spec_ptr(); + if (!spec) return; + for (const auto& handle : order) { + if (failed()) return; + std::size_t live_index = 0; + bool found = false; + for (std::size_t i = 0; i < requests_.live().size(); ++i) { + if (requests_.live()[i].handle == handle) { + live_index = i; + found = true; + break; + } + } + if (!found) continue; + const auto& live = requests_.live()[live_index]; + if (!native_order::point_eligible(live, point.coordinate.ordinal, + point.coordinate.effective_time_ms)) { + continue; + } + const double slip = static_cast(spec->slippage_ticks) * spec->price_tick; + const bool buy = buying_action(live.request, engine.position_side_); + double resolved = buy ? point.raw_price + slip : point.raw_price - slip; + if (!std::isfinite(resolved) || resolved <= 0.0) { + terminal_reject(engine, live_index, native_order::MatchRejectReason::NonpositivePrice); + continue; + } + execution::Fill probe{resolved, live.request.label, live.request.comment, + live.handle.incarnation, std::nullopt}; + execution::SettlementInspection inspect; + try { + inspect = engine.inspect_native_settlement(live.request.action, probe); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Allocation, + NativeFailureOperation::Settlement, + point.coordinate.ordinal}); + render(engine, e.what()); + return; + } + if (inspect.status == execution::Status::NoEffect) { + terminal_no_effect(engine, live_index); + continue; + } + if (inspect.status != execution::Status::Applied) { + fail(engine, NativeFailure{NativeFailureCode::SettlementFailure, + NativeFailureOperation::Settlement, + point.coordinate.ordinal, + static_cast(inspect.status)}); + render(engine, "native settlement inspection failed"); + return; + } + if (inspect.would_open) { + native_order::MatchRejectReason reason{}; + const auto mask = static_cast(spec->allowed_open_directions); + bool admitted = true; + if (inspect.incoming_short && (mask & 2u) == 0) { + admitted = false; reason = native_order::MatchRejectReason::OpeningDirection; + } else if (!inspect.incoming_short && (mask & 1u) == 0) { + admitted = false; reason = native_order::MatchRejectReason::OpeningDirection; + } else if (spec->max_abs_units && inspect.resulting_abs_units > *spec->max_abs_units) { + admitted = false; reason = native_order::MatchRejectReason::MaxAbsUnits; + } else if (spec->max_open_lots && inspect.resulting_lot_count > *spec->max_open_lots) { + admitted = false; reason = native_order::MatchRejectReason::MaxOpenLots; + } else if (spec->initial_margin_fraction) { + const double equity = engine.marked_equity(resolved) - inspect.current_ticket; + const double required = inspect.resulting_abs_notional * *spec->initial_margin_fraction; + if (!std::isfinite(equity) || !std::isfinite(required) || required > equity) { + admitted = false; reason = native_order::MatchRejectReason::InitialMargin; + } + } + if (!admitted) { + terminal_reject(engine, live_index, reason); + continue; + } + } + execution::Fill fill = probe; + fill.commission_account = inspect.current_ticket; + execution::PhysicalExecutionContext ctx; + ctx.effective_time_ms = point.coordinate.effective_time_ms; + ctx.interval_index = point.coordinate.interval_index; + native_order::ExecutionAppliedEvent applied; + NativeAccountObservation observation; + try { + applied.ordinal = next_timeline_ordinal_; + applied.handle = live.handle; + applied.request = live.request; + applied.birth = live.birth; + applied.effective_time_ms = point.coordinate.effective_time_ms; + applied.interval_open_ms = point.coordinate.open_ms; + applied.interval_last_traded_close_ms = point.coordinate.last_traded_close_ms; + applied.interval_index = point.coordinate.interval_index; + applied.raw_price = point.raw_price; + applied.resolved_price = resolved; + applied.provenance = static_cast(point.coordinate.provenance); + observation.ordinal = applied.ordinal; + observation.effective_time_ms = applied.effective_time_ms; + native_order::TerminalCommit::usable_ordinal(requests_, next_timeline_ordinal_); + native_order::TerminalCommit::reserve_history(requests_); + account_log_.reserve(account_log_.size() + 1); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Allocation, + NativeFailureOperation::Settlement, + point.coordinate.ordinal}); + render(engine, e.what()); + return; + } + execution::Result settled; + try { + settled = engine.settle_native_execution_at(live.request.action, fill, ctx); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::SettlementFailure, + NativeFailureOperation::Settlement, + point.coordinate.ordinal}); + render(engine, e.what()); + return; + } + if (settled.status != execution::Status::Applied + && settled.status != execution::Status::NoEffect) { + fail(engine, NativeFailure{NativeFailureCode::SettlementFailure, + NativeFailureOperation::Settlement, + point.coordinate.ordinal, + static_cast(settled.status)}); + render(engine, "native settlement commit failed"); + return; + } + if (settled.status == execution::Status::NoEffect) { + terminal_no_effect(engine, live_index); + continue; + } + applied.current_ticket = settled.current_ticket; + applied.first_trade_index = settled.first_trade_index; + applied.closed_trade_count = settled.closed_trade_count; + applied.opened_lot_incarnation = settled.opened_lot_incarnation; + native_order::TerminalCommit::install(requests_, live_index, + native_order::CommandEvent{std::move(applied)}); + sync_history_digest(); + ++next_timeline_ordinal_; + observation.marked_equity = engine.marked_equity(resolved); + observation.realized_balance = engine.initial_capital_ + engine.net_profit_sum_; + observation.signed_units = 0.0; + for (const auto& lot : engine.pyramid_entries_) observation.signed_units += lot.qty; + if (engine.position_side_ == PositionSide::SHORT) { + observation.signed_units = -observation.signed_units; + } + account_log_.push_back(observation); + fold_account_digest(observation); + engine.current_bar_.timestamp = point.coordinate.effective_time_ms; + engine.bar_index_ = point.coordinate.interval_index; + } +} + +void NativeExecutionConsumer::invoke_callback(BacktestEngine& engine, const Bar& bar, + const NativeCoordinate& coordinate) { + auto* host = dynamic_cast(&engine); + if (!host) return; + callback_context_.coordinate = coordinate; + callback_context_.decision_floor_ms = decision_floor_ms_; + auto input = native_calendar::interval_containing(calendar_, input_tf_, bar.timestamp); + auto script = native_calendar::interval_containing(calendar_, script_tf_, bar.timestamp); + if (input) callback_context_.input_interval = *input; + if (script) callback_context_.script_interval = *script; + in_callback_ = true; + try { + const NativeDecisionContext presented = callback_context_; + host->on_native_bar(bar, presented); + } catch (const std::exception& e) { + in_callback_ = false; + if (!failed()) { + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback, + coordinate.ordinal}); + render(engine, e.what()); + } + return; + } catch (...) { + in_callback_ = false; + if (!failed()) { + fail(engine, NativeFailure{NativeFailureCode::CallbackException, + NativeFailureOperation::Callback, + coordinate.ordinal}); + render(engine, "native callback exception"); + } + return; + } + in_callback_ = false; + check_abort_or_projection(engine, NativeFailureOperation::Callback, coordinate.ordinal); +} + +void NativeExecutionConsumer::deliver_confirmed_script(BacktestEngine& engine, const Bar& bar, + const NativeCoordinate& base) { + const bool high_first = internal::bar_path_uses_high_first(bar); + auto emit_match = [&](double price, int64_t time, NativePriceProvenance provenance, + NativePathPhase phase) { + NativeDriverPoint point; + point.coordinate = base; + point.coordinate.ordinal = take_ordinal(engine); + point.coordinate.effective_time_ms = time; + point.coordinate.source_price_time_ms = time; + point.coordinate.provenance = provenance; + point.coordinate.path_phase = phase; + point.raw_price = price; + point.matching = true; + record_driver(point); + match_point(engine, point); + raise_floor(time); + }; + auto emit_excursion = [&](double price, int64_t time, NativePathPhase phase) { + NativeDriverPoint point; + point.coordinate = base; + point.coordinate.ordinal = take_ordinal(engine); + point.coordinate.effective_time_ms = time; + point.coordinate.provenance = NativePriceProvenance::Confirmed; + point.coordinate.path_phase = phase; + point.raw_price = price; + point.excursion = true; + record_driver(point); + apply_excursion(engine, price); + raise_floor(time); + }; + const int64_t open_time = script_.first_source_time_ms != 0 + ? script_.first_source_time_ms + : (script_.first_open_ms != 0 ? script_.first_open_ms : bar.timestamp); + const int64_t close_time = calculation_time(base); + emit_match(bar.open, open_time, NativePriceProvenance::ModeledOHLCOpen, NativePathPhase::Open); + if (failed()) return; + if (high_first) { + emit_excursion(bar.high, open_time, NativePathPhase::High); + if (failed()) return; + emit_excursion(bar.low, open_time, NativePathPhase::Low); + } else { + emit_excursion(bar.low, open_time, NativePathPhase::Low); + if (failed()) return; + emit_excursion(bar.high, open_time, NativePathPhase::High); + } + if (failed()) return; + emit_excursion(bar.close, close_time, NativePathPhase::Close); + if (failed()) return; + NativeCoordinate calc = base; + calc.ordinal = take_ordinal(engine); + calc.effective_time_ms = close_time; + calc.provenance = NativePriceProvenance::Calculation; + calc.path_phase = NativePathPhase::None; + raise_floor(close_time); + engine.current_bar_ = bar; + engine.bar_index_ = calc.interval_index; + engine.current_bar_.timestamp = close_time; + invoke_callback(engine, bar, calc); + if (failed()) return; + const auto* spec = spec_ptr(); + if (spec && spec->close_execution == NativeCloseExecution::AfterCalculation) { + emit_match(bar.close, close_time, NativePriceProvenance::AfterCalculationClose, + NativePathPhase::Close); + } +} + +int64_t NativeExecutionConsumer::calculation_time(const NativeCoordinate& base) const noexcept { + int64_t t = base.last_traded_close_ms; + if (base.next_period_open_ms > t) t = base.next_period_open_ms; + if (script_.latest_close_ms > t) t = script_.latest_close_ms; + return t; +} + +void NativeExecutionConsumer::deliver_aggregate_calculation( + BacktestEngine& engine, const Bar& bar, const NativeCoordinate& base) { + const int64_t close_time = calculation_time(base); + NativeCoordinate calc = base; + calc.ordinal = take_ordinal(engine); + calc.effective_time_ms = close_time; + calc.source_price_time_ms = script_.latest_close_ms; + calc.provenance = NativePriceProvenance::Calculation; + calc.path_phase = NativePathPhase::None; + raise_floor(close_time); + engine.current_bar_ = bar; + engine.bar_index_ = calc.interval_index; + engine.current_bar_.timestamp = close_time; + invoke_callback(engine, bar, calc); + if (failed()) return; + const auto* spec = spec_ptr(); + if (spec && spec->close_execution == NativeCloseExecution::AfterCalculation) { + NativeDriverPoint point; + point.coordinate = calc; + point.coordinate.ordinal = take_ordinal(engine); + point.coordinate.effective_time_ms = close_time; + point.coordinate.source_price_time_ms = script_.latest_close_ms; + point.coordinate.provenance = NativePriceProvenance::AfterCalculationClose; + point.coordinate.path_phase = NativePathPhase::Close; + point.raw_price = bar.close; + point.matching = true; + record_driver(point); + match_point(engine, point); + } +} + +void NativeExecutionConsumer::seal_script(BacktestEngine& engine, NativeCompletionKind kind) { + if (!script_.has_data || script_.sealed) return; + NativeCoordinate base; + base.interval_index = script_.first_index; + base.open_ms = script_.interval.open_ms; + base.eligible_open_ms = script_.interval.eligible_open_ms; + base.last_traded_close_ms = script_.interval.last_traded_close_ms; + base.next_period_open_ms = script_.interval.next_period_open_ms; + base.next_input_open_ms = script_.interval.next_input_open_ms; + base.completion = kind; + if (script_.modeled_ohlc) { + deliver_confirmed_script(engine, script_.agg, base); + } else { + deliver_aggregate_calculation(engine, script_.agg, base); + } + script_.sealed = true; + script_.has_data = false; +} + +bool NativeExecutionConsumer::contribute_input( + BacktestEngine& engine, const Bar& bar, + const native_calendar::NativeInterval& interval, + int index, InputContribution kind) { + auto script_interval = native_calendar::interval_containing( + calendar_, script_tf_, interval.open_ms); + if (!script_interval) { + render(engine, "native script interval lookup failed"); + return false; + } + const int64_t script_key = script_interval->open_ms; + if (script_.has_data && !script_.sealed && script_.key != script_key) { + seal_script(engine, NativeCompletionKind::LazyComplete); + if (failed()) return false; + script_ = ScriptBucket{}; + } + const int64_t source_close = (kind == InputContribution::ConfirmedBar) + ? std::max(interval.last_traded_close_ms, interval.next_period_open_ms) + : (last_print_time_ms_ != 0 ? last_print_time_ms_ : interval.last_traded_close_ms); + const int64_t source_open = (kind == InputContribution::ConfirmedBar) + ? bar.timestamp + : (kind == InputContribution::ObservedTickSlot + ? (last_print_time_ms_ != 0 ? last_print_time_ms_ : interval.eligible_open_ms) + : interval.eligible_open_ms); + if (!script_.has_data) { + script_.key = script_key; + script_.interval = *script_interval; + script_.agg = bar; + script_.has_data = true; + script_.first_open_ms = interval.open_ms; + script_.first_source_time_ms = source_open; + script_.latest_close_ms = source_close; + script_.first_index = index; + script_.last_index = index; + script_.sealed = false; + script_.modeled_ohlc = (kind == InputContribution::ConfirmedBar); + } else { + script_.agg.high = std::max(script_.agg.high, bar.high); + script_.agg.low = std::min(script_.agg.low, bar.low); + script_.agg.close = bar.close; + script_.agg.volume += bar.volume; + script_.latest_close_ms = std::max(script_.latest_close_ms, source_close); + script_.last_index = index; + if (kind != InputContribution::ConfirmedBar) script_.modeled_ohlc = false; + } + current_input_open_ = interval.open_ms; + observed_input_cursor_ = interval.open_ms; + last_accepted_input_ = interval; + raise_floor(native_canonical_input_completion(interval)); + const bool exhausted = + interval.next_period_open_ms >= script_.interval.next_period_open_ms; + if (exhausted) { + seal_script(engine, NativeCompletionKind::Confirmed); + if (failed()) return false; + script_ = ScriptBucket{}; + } + engine.current_bar_ = bar; + engine.bar_index_ = index; + next_interval_index_ = index + 1; + return !failed(); +} + +bool NativeExecutionConsumer::consume_confirmed_input(BacktestEngine& engine, const Bar& bar, + int index, bool last) { + (void)last; + processing_input_ = true; + auto interval = native_calendar::interval_containing(calendar_, input_tf_, bar.timestamp); + if (!interval) { + processing_input_ = false; + present_refusal(engine, "native input is not aligned"); + return false; + } + if (!native_confirmed_bar_label_admitted(*interval, bar.timestamp)) { + processing_input_ = false; + present_refusal(engine, "native confirmed bar timestamp is not a canonical slot label"); + return false; + } + if (last_accepted_input_) { + if (interval->open_ms <= last_accepted_input_->open_ms) { + processing_input_ = false; + present_refusal(engine, "native duplicate overlapping input slot"); + return false; + } + const auto* running = std::get_if(&state_); + if (running && running->phase != NativeRunPhase::Batch) { + auto expected = native_calendar::interval_containing( + calendar_, input_tf_, last_accepted_input_->next_input_open_ms); + if (!expected || expected->open_ms != interval->open_ms) { + processing_input_ = false; + present_refusal(engine, "native stream has an in-session gap"); + return false; + } + } + } + last_accepted_input_ = *interval; + last_observed_slot_open_ = interval->open_ms; + last_finalized_input_ = *interval; + last_price_ = bar.close; + has_last_price_ = true; + if (!contribute_input(engine, bar, *interval, index, InputContribution::ConfirmedBar)) { + processing_input_ = false; + return false; + } + processing_input_ = false; + return !failed(); +} + +void NativeExecutionConsumer::pump_batch(BacktestEngine& engine, const Bar* bars, int n) { + for (int i = 0; i < n; ++i) { + if (!check_abort_or_projection(engine, NativeFailureOperation::Input)) return; + if (!consume_confirmed_input(engine, bars[i], i, i + 1 == n)) { + if (!failed()) { + fail(engine, NativeFailure{NativeFailureCode::Preflight, + NativeFailureOperation::Input}); + } + return; + } + if (!check_abort_or_projection(engine, NativeFailureOperation::Input)) return; + } +} + +void NativeExecutionConsumer::run_simple(BacktestEngine& engine, const Bar* bars, int n) { + if (!admit_public_begin(engine, "native run requires configure_native")) return; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + engine.abort_requested_.store(false, std::memory_order_relaxed); + try { + if (!preflight_bars(engine, bars, n, false)) return; + const int64_t initial = n > 0 ? bars[0].timestamp + : std::numeric_limits::min(); + if (!begin_ready(engine, NativeRunPhase::Batch, initial)) return; + pump_batch(engine, bars, n); + if (failed()) return; + auto* running = std::get_if(&state_); + if (!running) return; + NativeRunSpec spec = running->spec; + state_ = NativeCompleted{std::move(spec), NativeCompletion::BatchComplete}; + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Input}); + render(engine, e.what()); + } +} + +void NativeExecutionConsumer::run_tf(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + bool bar_magnifier, int magnifier_samples, + MagnifierDistribution magnifier_dist) { + if (!admit_public_begin(engine, "native run requires configure_native")) return; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + engine.abort_requested_.store(false, std::memory_order_relaxed); + try { + if (!timeframe_args_ok(input_tf, script_tf)) { + present_refusal(engine, "native timeframe arguments must be empty or match the spec"); + return; + } + if (bar_magnifier || magnifier_samples != 4 + || magnifier_dist != MagnifierDistribution::ENDPOINTS) { + present_refusal(engine, "native run refuses unsupported magnifier arguments"); + return; + } + if (!preflight_bars(engine, input_bars, n_input, false)) return; + const int64_t initial = n_input > 0 ? input_bars[0].timestamp + : std::numeric_limits::min(); + if (!begin_ready(engine, NativeRunPhase::Batch, initial)) return; + pump_batch(engine, input_bars, n_input); + if (failed()) return; + auto* running = std::get_if(&state_); + if (!running) return; + NativeRunSpec spec = running->spec; + state_ = NativeCompleted{std::move(spec), NativeCompletion::BatchComplete}; + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Input}); + render(engine, e.what()); + } +} + +void NativeExecutionConsumer::run_rich(BacktestEngine& engine, + const Bar*, int, + const std::string&, const std::string&, + const std::unordered_map&, + const SymInfo&, const StrategyOverrides*, + bool, int, MagnifierDistribution) { + try { + refuse_source_mutation("run(inputs,syminfo,overrides)"); + } catch (const std::exception& e) { + render(engine, e.what()); + engine.last_run_status_ = 1; + } +} + +bool NativeExecutionConsumer::stream_begin(BacktestEngine& engine, + const Bar* warmup_bars, int n_warmup, + const std::string& input_tf, + const std::string& script_tf) { + if (!admit_public_begin(engine, "native stream_begin requires Ready")) return false; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + engine.abort_requested_.store(false, std::memory_order_relaxed); + try { + if (!timeframe_args_ok(input_tf, script_tf)) { + present_refusal(engine, "native timeframe arguments must be empty or match the spec"); + return false; + } + const auto* spec = spec_ptr(); + auto parsed = native_calendar::parse_timeframe(spec->input_tf); + auto script = native_calendar::parse_timeframe(spec->script_tf); + if (!parsed || !script) { + present_refusal(engine, "native stream timeframe parse failed"); + return false; + } + const auto stream_pair = native_calendar::stream_compatibility(*parsed, *script); + if (stream_pair.pairing == native_calendar::TimeframePairing::StreamMonthlyInputRefused) { + present_refusal(engine, "native stream refuses monthly input"); + return false; + } + if (n_warmup <= 0 || warmup_bars == nullptr) { + present_refusal(engine, "native stream warmup requires at least one bar"); + return false; + } + if (!preflight_bars(engine, warmup_bars, n_warmup, true)) return false; + if (!begin_ready(engine, NativeRunPhase::Warmup, warmup_bars[0].timestamp)) return false; + pump_batch(engine, warmup_bars, n_warmup); + if (failed()) return false; + if (auto* running = std::get_if(&state_)) { + running->phase = NativeRunPhase::Realtime; + } + engine.stream_phase_ = BacktestEngine::StreamPhase::REALTIME; + engine.stream_observe_actions_ = true; + engine.stream_order_actions_.clear(); + engine.stream_action_sequence_ = 0; + if (!has_last_price_ && n_warmup > 0) { + last_price_ = warmup_bars[n_warmup - 1].close; + has_last_price_ = true; + } + return true; + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Stream}); + render(engine, e.what()); + return false; + } +} + +bool NativeExecutionConsumer::stream_push_bar(BacktestEngine& engine, const Bar& bar) { + if (!admit_public_stream_input(engine, NativeFailureOperation::Input)) return false; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + try { + const auto* running = std::get_if(&state_); + if (!running || running->phase != NativeRunPhase::Realtime) { + present_refusal(engine, "native stream_push_bar requires realtime"); + return false; + } + if (refuse_mixed_input_mode(engine, InputMode::ConfirmedBars)) return false; + if (!native_bar_structurally_valid(bar)) { + present_refusal(engine, "native confirmed bar has invalid OHLCV"); + return false; + } + if (!check_abort_or_projection(engine, NativeFailureOperation::Input)) return false; + if (!consume_confirmed_input(engine, bar, next_interval_index_, false)) return false; + select_input_mode(InputMode::ConfirmedBars); + return !failed(); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Input}); + render(engine, e.what()); + return false; + } +} + +bool NativeExecutionConsumer::preflight_ticks(BacktestEngine& engine, const TradeTick* ticks, int n) { + if (n < 0 || (n > 0 && ticks == nullptr)) { + present_refusal(engine, "native tick array is invalid"); + return false; + } + const auto* running = std::get_if(&state_); + if (!running || running->phase != NativeRunPhase::Realtime) { + present_refusal(engine, "native stream_push_tick requires realtime"); + return false; + } + if (refuse_mixed_input_mode(engine, InputMode::ObservedTicks)) return false; + if (n == 0) return true; + uint64_t prev_sequence = last_tick_sequence_; + bool prev_has_sequence = has_tick_sequence_; + uint64_t ordinals = next_timeline_ordinal_; + std::optional volume_slot; + double volume = 0.0; + if (has_forming_) { + volume_slot = forming_.timestamp; + volume = forming_.volume; + } + int64_t prev_array_ts = 0; + bool has_array_prev = false; + for (int i = 0; i < n; ++i) { + const TradeTick& tick = ticks[i]; + if (!std::isfinite(tick.price) || tick.price <= 0.0) { + present_refusal(engine, "native tick price must be finite and positive"); + return false; + } + if (!std::isfinite(tick.quantity) || tick.quantity < 0.0) { + present_refusal(engine, "native tick quantity must be finite and non-negative"); + return false; + } + if (has_floor_ && tick.timestamp < decision_floor_ms_) { + present_refusal(engine, "native tick timestamp regresses the decision floor"); + return false; + } + if (has_array_prev && tick.timestamp < prev_array_ts) { + present_refusal(engine, "native tick timestamps must be nondecreasing"); + return false; + } + prev_array_ts = tick.timestamp; + has_array_prev = true; + if (tick.sequence != 0) { + if (prev_has_sequence && tick.sequence <= prev_sequence) { + present_refusal(engine, "native tick sequence must increase"); + return false; + } + prev_sequence = tick.sequence; + prev_has_sequence = true; + } + auto interval = native_calendar::interval_containing(calendar_, input_tf_, tick.timestamp); + if (!interval) { + present_refusal(engine, "native tick is not aligned"); + return false; + } + const bool in_forming = has_forming_ && forming_.timestamp == interval->open_ms; + if (last_finalized_input_ && interval->open_ms <= last_finalized_input_->open_ms + && !in_forming) { + present_refusal(engine, "native tick would reopen a closed input slot"); + return false; + } + if (!volume_slot || *volume_slot != interval->open_ms) { + volume_slot = interval->open_ms; + volume = 0.0; + } + volume += tick.quantity; + if (!std::isfinite(volume)) { + present_refusal(engine, "native tick volume is unrepresentable"); + return false; + } + if (ordinals == 0 || ordinals == std::numeric_limits::max()) { + present_refusal(engine, "native timeline ordinal exhausted"); + return false; + } + ++ordinals; + } + return true; +} + +bool NativeExecutionConsumer::emit_quiet_carried_open( + BacktestEngine& engine, const native_calendar::NativeInterval& interval) { + if (!has_last_price_) return true; + if (next_tradable_synthesis_cursor_ == interval.open_ms) return true; + NativeDriverPoint point; + point.coordinate = coordinate_from(interval, next_interval_index_, + interval.eligible_open_ms, + NativePriceProvenance::CarriedOpen, + NativePathPhase::Open); + point.coordinate.ordinal = take_ordinal(engine); + point.raw_price = last_price_; + point.matching = true; + record_driver(point); + match_point(engine, point); + next_tradable_synthesis_cursor_ = interval.open_ms; + if (failed()) return false; + Bar quiet{last_price_, last_price_, last_price_, last_price_, 0.0, interval.open_ms}; + if (!contribute_input(engine, quiet, interval, next_interval_index_, + InputContribution::QuietCarried)) { + return false; + } + return !failed(); +} + +bool NativeExecutionConsumer::finalize_observed_tick_slot( + BacktestEngine& engine, + const native_calendar::NativeInterval& interval, + NativeCompletionKind kind) { + if (!has_forming_ || forming_.timestamp != interval.open_ms) return true; + if (kind == NativeCompletionKind::PartialFinalized) { + const bool equal = pairing_.pairing == native_calendar::TimeframePairing::Passthrough; + if (equal) { + NativeCoordinate calc; + calc.interval_index = next_interval_index_; + calc.open_ms = interval.open_ms; + calc.eligible_open_ms = interval.eligible_open_ms; + calc.last_traded_close_ms = interval.last_traded_close_ms; + calc.next_period_open_ms = interval.next_period_open_ms; + calc.next_input_open_ms = interval.next_input_open_ms; + calc.source_price_time_ms = last_print_time_ms_; + calc.completion = kind; + calc.effective_time_ms = std::max(decision_floor_ms_, last_print_time_ms_); + calc.provenance = NativePriceProvenance::PartialFinalized; + calc.ordinal = take_ordinal(engine); + raise_floor(calc.effective_time_ms); + engine.current_bar_ = forming_; + invoke_callback(engine, forming_, calc); + if (failed()) return false; + const auto* spec = spec_ptr(); + if (spec && spec->close_execution == NativeCloseExecution::AfterCalculation) { + NativeDriverPoint point; + point.coordinate = calc; + point.coordinate.ordinal = take_ordinal(engine); + point.coordinate.effective_time_ms = calc.effective_time_ms; + point.coordinate.source_price_time_ms = last_print_time_ms_; + point.coordinate.provenance = NativePriceProvenance::AfterCalculationClose; + point.raw_price = forming_.close; + point.matching = true; + record_driver(point); + match_point(engine, point); + } + } + has_forming_ = false; + return !failed(); + } + const Bar formed = forming_; + has_forming_ = false; + if (!contribute_input(engine, formed, interval, next_interval_index_, + InputContribution::ObservedTickSlot)) { + return false; + } + return !failed(); +} + +bool NativeExecutionConsumer::finalize_elapsed_slots(BacktestEngine& engine, + int64_t exclusive_end_ms) { + std::optional cursor; + if (last_finalized_input_) cursor = last_finalized_input_->next_input_open_ms; + else if (last_accepted_input_) cursor = last_accepted_input_->next_input_open_ms; + else if (last_observed_slot_open_) cursor = last_observed_slot_open_; + while (cursor) { + auto interval = native_calendar::interval_containing(calendar_, input_tf_, *cursor); + if (!interval) break; + if (interval->next_period_open_ms > exclusive_end_ms) break; + if (last_finalized_input_ && last_finalized_input_->open_ms == interval->open_ms) { + if (interval->next_input_open_ms <= interval->open_ms) break; + cursor = interval->next_input_open_ms; + continue; + } + const bool forming_here = has_forming_ && forming_.timestamp == interval->open_ms; + const bool observed = forming_here + || (last_observed_slot_open_ && *last_observed_slot_open_ == interval->open_ms) + || (last_accepted_input_ && last_accepted_input_->open_ms == interval->open_ms); + const bool tradable = interval->last_traded_close_ms > interval->eligible_open_ms + && native_calendar::in_session(calendar_, interval->eligible_open_ms); + if (forming_here) { + if (!finalize_observed_tick_slot(engine, *interval, NativeCompletionKind::Confirmed)) { + return false; + } + } else if (!observed && tradable) { + if (!emit_quiet_carried_open(engine, *interval)) return false; + } + last_finalized_input_ = *interval; + if (interval->next_input_open_ms <= interval->open_ms) break; + cursor = interval->next_input_open_ms; + if (failed()) return false; + } + return !failed(); +} + +bool NativeExecutionConsumer::deliver_tick(BacktestEngine& engine, const TradeTick& tick) { + processing_input_ = true; + select_input_mode(InputMode::ObservedTicks); + auto interval = native_calendar::interval_containing(calendar_, input_tf_, tick.timestamp); + if (!interval) { + processing_input_ = false; + present_refusal(engine, "native tick is not aligned"); + return false; + } + if (!finalize_elapsed_slots(engine, interval->open_ms)) { + processing_input_ = false; + return false; + } + NativeDriverPoint point; + point.coordinate = coordinate_from(*interval, next_interval_index_, tick.timestamp, + NativePriceProvenance::ObservedPrint, + NativePathPhase::None); + point.coordinate.ordinal = take_ordinal(engine); + point.coordinate.source_price_time_ms = tick.timestamp; + point.raw_price = tick.price; + if (tick.sequence != 0) point.sequence = tick.sequence; + point.matching = true; + point.excursion = true; + record_driver(point); + match_point(engine, point); + apply_excursion(engine, tick.price); + raise_floor(tick.timestamp); + last_price_ = tick.price; + has_last_price_ = true; + last_print_time_ms_ = tick.timestamp; + if (!has_forming_) { + forming_ = Bar{tick.price, tick.price, tick.price, tick.price, tick.quantity, + interval->open_ms}; + has_forming_ = true; + } else { + forming_.high = std::max(forming_.high, tick.price); + forming_.low = std::min(forming_.low, tick.price); + forming_.close = tick.price; + forming_.volume += tick.quantity; + } + last_observed_slot_open_ = interval->open_ms; + if (tick.sequence != 0) { + last_tick_sequence_ = tick.sequence; + has_tick_sequence_ = true; + } + processing_input_ = false; + return !failed(); +} + +bool NativeExecutionConsumer::stream_push_tick(BacktestEngine& engine, const TradeTick& tick) { + if (!admit_public_stream_input(engine, NativeFailureOperation::Input)) return false; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + try { + if (!preflight_ticks(engine, &tick, 1)) return false; + if (!check_abort_or_projection(engine, NativeFailureOperation::Input)) return false; + return deliver_tick(engine, tick); + } catch (const std::exception& e) { + processing_input_ = false; + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Input}); + render(engine, e.what()); + return false; + } +} + +bool NativeExecutionConsumer::stream_push_ticks(BacktestEngine& engine, const TradeTick* ticks, int n) { + if (!admit_public_stream_input(engine, NativeFailureOperation::Input)) return false; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + try { + if (!preflight_ticks(engine, ticks, n)) return false; + if (!check_abort_or_projection(engine, NativeFailureOperation::Input)) return false; + for (int i = 0; i < n; ++i) { + if (!deliver_tick(engine, ticks[i])) return false; + if (!check_abort_or_projection(engine, NativeFailureOperation::Input)) return false; + } + return !failed(); + } catch (const std::exception& e) { + processing_input_ = false; + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Input}); + render(engine, e.what()); + return false; + } +} + +bool NativeExecutionConsumer::stream_advance_time(BacktestEngine& engine, int64_t timestamp_ms) { + if (!admit_public_stream_input(engine, NativeFailureOperation::Stream)) return false; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + try { + const auto* running = std::get_if(&state_); + if (!running || running->phase != NativeRunPhase::Realtime) { + present_refusal(engine, "native stream_advance_time requires realtime"); + return false; + } + if (refuse_mixed_input_mode(engine, InputMode::ObservedTicks)) return false; + if (has_floor_ && timestamp_ms < decision_floor_ms_) { + present_refusal(engine, "native time advance regresses the decision floor"); + return false; + } + if (!check_abort_or_projection(engine, NativeFailureOperation::Stream)) return false; + select_input_mode(InputMode::ObservedTicks); + if (has_last_price_ || has_forming_) { + processing_input_ = true; + const bool ok = finalize_elapsed_slots(engine, timestamp_ms); + processing_input_ = false; + if (!ok) return false; + } + if (script_.has_data && !script_.sealed + && timestamp_ms >= script_.interval.next_period_open_ms) { + processing_input_ = true; + seal_script(engine, NativeCompletionKind::Confirmed); + script_ = ScriptBucket{}; + processing_input_ = false; + if (failed()) return false; + } + raise_floor(timestamp_ms); + return !failed(); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Stream}); + render(engine, e.what()); + return false; + } +} + +bool NativeExecutionConsumer::stream_end(BacktestEngine& engine, bool finalize_partial_input_bar) { + if (!admit_public_stream_input(engine, NativeFailureOperation::Stream)) return false; + engine.last_error_.clear(); + engine.last_run_status_ = 0; + try { + if (!std::holds_alternative(state_)) { + present_refusal(engine, "native stream_end requires a running host"); + return false; + } + if (!check_abort_or_projection(engine, NativeFailureOperation::Stream)) return false; + if (finalize_partial_input_bar && has_forming_) { + auto forming_interval = native_calendar::interval_containing( + calendar_, input_tf_, forming_.timestamp); + if (forming_interval) { + if (!finalize_observed_tick_slot(engine, *forming_interval, + NativeCompletionKind::PartialFinalized)) { + return false; + } + } + } + if (failed()) return false; + auto* running = std::get_if(&state_); + if (!running) { + render(engine, "native stream_end lost running state"); + return false; + } + NativeRunSpec spec = std::move(running->spec); + state_.emplace(NativeCompleted{std::move(spec), NativeCompletion::StreamEnded}); + engine.stream_phase_ = BacktestEngine::StreamPhase::IDLE; + return !failed(); + } catch (const std::exception& e) { + fail(engine, NativeFailure{NativeFailureCode::Unexpected, NativeFailureOperation::Stream}); + render(engine, e.what()); + return false; + } +} + +native_order::SubmitResult NativeExecutionConsumer::submit(BacktestEngine& engine, + const native_order::Request& request) { + if (!commands_allowed()) { + throw std::runtime_error("native submit refused outside allowed phase"); + } + const int64_t floor = decision_floor(); + auto result = requests_.submit(request, floor, engine.next_order_incarnation_, + next_timeline_ordinal_, + spec_ptr() ? spec_ptr()->quantity_grid : std::nullopt); + sync_history_digest(); + return result; +} + +native_order::ReplaceResult NativeExecutionConsumer::replace( + BacktestEngine& engine, + const native_order::RequestHandle& target, + const native_order::Request& request) { + if (!commands_allowed()) { + throw std::runtime_error("native replace refused outside allowed phase"); + } + const int64_t floor = decision_floor(); + auto result = requests_.replace(target, request, floor, engine.next_order_incarnation_, + next_timeline_ordinal_, + spec_ptr() ? spec_ptr()->quantity_grid : std::nullopt); + sync_history_digest(); + return result; +} + +native_order::CancelResult NativeExecutionConsumer::cancel( + BacktestEngine& engine, const native_order::RequestHandle& target) { + (void)engine; + if (!commands_allowed()) { + throw std::runtime_error("native cancel refused outside allowed phase"); + } + auto result = requests_.cancel(target, next_timeline_ordinal_); + sync_history_digest(); + return result; +} + +NativePhysicalPosition NativeExecutionConsumer::position(const BacktestEngine& engine) const { + NativePhysicalPosition out; + out.lot_count = engine.pyramid_entries_.size(); + double qty = 0.0; + double weighted = 0.0; + for (const auto& lot : engine.pyramid_entries_) { + qty += lot.qty; + weighted += lot.qty * lot.price; + } + out.signed_units = engine.position_side_ == PositionSide::SHORT ? -qty : qty; + out.average_price = qty > 0.0 ? weighted / qty : 0.0; + return out; +} + +double NativeExecutionConsumer::marked(const BacktestEngine& engine, double price) const { + return engine.marked_equity(price); +} + +std::vector NativeExecutionConsumer::events_after(uint64_t after_ordinal) const { + std::vector out; + const auto& history = requests_.history(); + const auto command_begin = std::upper_bound(history.begin(), history.end(), after_ordinal, + [](uint64_t ordinal, const native_order::CommandEvent& event) { + return ordinal < std::visit([](const auto& p) { return p.ordinal; }, event); + }); + for (auto it = command_begin; it != history.end(); ++it) { + const auto& event = *it; + const uint64_t ordinal = std::visit([](const auto& p) { return p.ordinal; }, event); + NativeMarketEvent row; + row.kind = NativeEventKind::Command; + row.ordinal = ordinal; + row.command = event; + out.push_back(row); + } + const auto driver_begin = std::upper_bound(driver_log_.begin(), driver_log_.end(), after_ordinal, + [](uint64_t ordinal, const NativeDriverPoint& point) { + return ordinal < point.coordinate.ordinal; + }); + for (auto it = driver_begin; it != driver_log_.end(); ++it) { + const auto& point = *it; + NativeMarketEvent row; + row.kind = NativeEventKind::Driver; + row.ordinal = point.coordinate.ordinal; + row.driver = point; + out.push_back(row); + } + const auto account_begin = std::upper_bound(account_log_.begin(), account_log_.end(), after_ordinal, + [](uint64_t ordinal, const NativeAccountObservation& account) { + return ordinal < account.ordinal; + }); + for (auto it = account_begin; it != account_log_.end(); ++it) { + const auto& account = *it; + NativeMarketEvent row; + row.kind = NativeEventKind::Account; + row.ordinal = account.ordinal; + row.account = account; + out.push_back(row); + } + std::sort(out.begin(), out.end(), + [](const NativeMarketEvent& a, const NativeMarketEvent& b) { + if (a.ordinal != b.ordinal) return a.ordinal < b.ordinal; + return static_cast(a.kind) < static_cast(b.kind); + }); + return out; +} + +void NativeExecutionConsumer::reject_inherited_on_bar(BacktestEngine& engine) { + try { + refuse_source_mutation("on_bar"); + } catch (const std::exception& e) { + render(engine, e.what()); + } +} + +NativeStrategyHost::NativeStrategyHost() + : BacktestEngine(NativeConsumerBindTag{}) {} + +NativeStrategyHost::~NativeStrategyHost() = default; + +void NativeStrategyHost::on_bar(const Bar&) { + as_native_consumer(execution_consumer()).reject_inherited_on_bar(*this); +} + +NativeSetupResult NativeStrategyHost::configure_native(const NativeRunSpec& spec) { + return as_native_consumer(execution_consumer()).configure(*this, spec); +} + +NativeStateView NativeStrategyHost::native_state() const { + return as_native_consumer(const_cast(execution_consumer())).view(); +} + +native_order::SubmitResult NativeStrategyHost::submit_market(const native_order::Request& request) { + return as_native_consumer(execution_consumer()).submit(*this, request); +} + +native_order::ReplaceResult NativeStrategyHost::replace_market( + const native_order::RequestHandle& target, const native_order::Request& request) { + return as_native_consumer(execution_consumer()).replace(*this, target, request); +} + +native_order::CancelResult NativeStrategyHost::cancel(const native_order::RequestHandle& target) { + return as_native_consumer(execution_consumer()).cancel(*this, target); +} + +NativePhysicalPosition NativeStrategyHost::physical_position() const { + return as_native_consumer(const_cast(execution_consumer())).position(*this); +} + +double NativeStrategyHost::native_marked_equity(double mark) const { + return as_native_consumer(const_cast(execution_consumer())).marked(*this, mark); +} + +std::vector NativeStrategyHost::native_events(uint64_t after_ordinal) const { + return as_native_consumer(const_cast(execution_consumer())) + .events_after(after_ordinal); +} + +int64_t NativeStrategyHost::native_decision_floor() const { + return as_native_consumer(const_cast(execution_consumer())).decision_floor(); +} + +uint64_t NativeStrategyHost::native_consumed_high_water() const { + return as_native_consumer(const_cast(execution_consumer())).high_water(); +} + +uint64_t NativeStrategyHost::native_continuation_hash() const { + return as_native_consumer(const_cast(execution_consumer())) + .continuation_hash(); +} + +} // inline namespace engine_script_run_v12 +} // namespace pineforge diff --git a/src/native_execution_consumer.hpp b/src/native_execution_consumer.hpp new file mode 100644 index 000000000..615480205 --- /dev/null +++ b/src/native_execution_consumer.hpp @@ -0,0 +1,206 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace pineforge { +inline namespace engine_script_run_v12 { + +class NativeExecutionConsumer final : public IExecutionConsumer { +public: + bool is_native() const noexcept override { return true; } + void refuse_source_mutation(const char* operation) override; + uint64_t continuation_hash() const noexcept override; + + void run_simple(BacktestEngine& engine, const Bar* bars, int n) override; + void run_tf(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) override; + void run_rich(BacktestEngine& engine, + const Bar* input_bars, int n_input, + const std::string& input_tf, + const std::string& script_tf, + const std::unordered_map& inputs, + const SymInfo& syminfo, + const StrategyOverrides* overrides, + bool bar_magnifier, + int magnifier_samples, + MagnifierDistribution magnifier_dist) override; + + bool stream_begin(BacktestEngine& engine, + const Bar* warmup_bars, int n_warmup, + const std::string& input_tf, + const std::string& script_tf) override; + bool stream_push_bar(BacktestEngine& engine, const Bar& bar) override; + bool stream_push_tick(BacktestEngine& engine, const TradeTick& tick) override; + bool stream_push_ticks(BacktestEngine& engine, const TradeTick* ticks, int n) override; + bool stream_advance_time(BacktestEngine& engine, int64_t timestamp_ms) override; + bool stream_end(BacktestEngine& engine, bool finalize_partial_input_bar) override; + + NativeSetupResult configure(BacktestEngine& engine, const NativeRunSpec& spec); + NativeStateView view() const; + native_order::SubmitResult submit(BacktestEngine& engine, const native_order::Request& request); + native_order::ReplaceResult replace(BacktestEngine& engine, + const native_order::RequestHandle& target, + const native_order::Request& request); + native_order::CancelResult cancel(BacktestEngine& engine, + const native_order::RequestHandle& target); + NativePhysicalPosition position(const BacktestEngine& engine) const; + double marked(const BacktestEngine& engine, double price) const; + std::vector events_after(uint64_t after_ordinal) const; + int64_t decision_floor() const noexcept { + return has_floor_ ? decision_floor_ms_ : std::numeric_limits::min(); + } + uint64_t high_water() const noexcept { return consumed_high_water_; } + void reject_inherited_on_bar(BacktestEngine& engine); + +private: + struct ScriptBucket { + int64_t key = 0; + native_calendar::NativeInterval interval{}; + Bar agg{}; + bool has_data = false; + int64_t first_open_ms = 0; + int64_t first_source_time_ms = 0; + int64_t latest_close_ms = 0; + int first_index = 0; + int last_index = 0; + bool sealed = false; + // False once a tick or quiet-carried slot has contributed. Seal then + // calculates without replaying modeled OHLC matching/excursion. + bool modeled_ohlc = true; + }; + + enum class InputContribution : std::uint8_t { + ConfirmedBar = 0, + ObservedTickSlot = 1, + QuietCarried = 2, + }; + + enum class InputMode : std::uint8_t { + Unselected = 0, + ConfirmedBars = 1, + ObservedTicks = 2, + }; + + struct AppendDigest { + uint64_t h = 1469598103934665603ULL; + uint64_t count = 0; + void reset() noexcept { + h = 1469598103934665603ULL; + count = 0; + } + }; + + bool failed() const noexcept; + void latch_failure(NativeFailure failure) noexcept; + void fail(BacktestEngine& engine, NativeFailure failure) noexcept; + void render(BacktestEngine& engine, const char* text) const; + const NativeRunSpec* spec_ptr() const; + bool commands_allowed() const; + bool timeframe_args_ok(const std::string& input_tf, const std::string& script_tf) const; + bool apply_spec(BacktestEngine& engine, const NativeRunSpec& spec); + bool projection_ok(const BacktestEngine& engine) const; + bool begin_ready(BacktestEngine& engine, NativeRunPhase phase, int64_t initial_floor_ms); + bool refuse_mixed_input_mode(BacktestEngine& engine, InputMode requested); + void select_input_mode(InputMode requested); + bool admit_public_begin(BacktestEngine& engine, const char* not_ready_text); + bool admit_public_stream_input(BacktestEngine& engine, NativeFailureOperation operation); + bool preflight_bars(BacktestEngine& engine, const Bar* bars, int n, bool stream); + void pump_batch(BacktestEngine& engine, const Bar* bars, int n); + bool consume_confirmed_input(BacktestEngine& engine, const Bar& bar, int index, bool last); + bool contribute_input(BacktestEngine& engine, const Bar& bar, + const native_calendar::NativeInterval& interval, + int index, InputContribution kind); + void seal_script(BacktestEngine& engine, NativeCompletionKind kind); + void deliver_confirmed_script(BacktestEngine& engine, const Bar& bar, const NativeCoordinate& base); + void deliver_aggregate_calculation(BacktestEngine& engine, const Bar& bar, + const NativeCoordinate& base); + int64_t calculation_time(const NativeCoordinate& base) const noexcept; + void match_point(BacktestEngine& engine, const NativeDriverPoint& point); + void apply_excursion(BacktestEngine& engine, double price); + void invoke_callback(BacktestEngine& engine, const Bar& bar, const NativeCoordinate& coordinate); + uint64_t take_ordinal(BacktestEngine& engine); + void raise_floor(int64_t t); + double resolve_price(const native_order::Request& request, double raw) const; + bool admit_opening(const BacktestEngine& engine, + const execution::SettlementInspection& inspect, + native_order::MatchRejectReason* reason) const; + void terminal_no_effect(BacktestEngine& engine, std::size_t live_index); + void terminal_reject(BacktestEngine& engine, std::size_t live_index, + native_order::MatchRejectReason reason); + void record_driver(const NativeDriverPoint& point); + NativeCoordinate coordinate_from(const native_calendar::NativeInterval& interval, + int index, int64_t effective, + NativePriceProvenance provenance, + NativePathPhase phase) const; + bool preflight_ticks(BacktestEngine& engine, const TradeTick* ticks, int n); + bool deliver_tick(BacktestEngine& engine, const TradeTick& tick); + bool check_abort_or_projection(BacktestEngine& engine, NativeFailureOperation operation, + uint64_t ordinal = 0); + void present_refusal(BacktestEngine& engine, const char* text); + bool finalize_elapsed_slots(BacktestEngine& engine, int64_t exclusive_end_ms); + bool emit_quiet_carried_open(BacktestEngine& engine, + const native_calendar::NativeInterval& interval); + bool finalize_observed_tick_slot(BacktestEngine& engine, + const native_calendar::NativeInterval& interval, + NativeCompletionKind kind); + void sync_history_digest() const noexcept; + void fold_driver_digest(const NativeDriverPoint& point) const noexcept; + void fold_account_digest(const NativeAccountObservation& row) const noexcept; + + NativeLifecycle state_{NativeUnconfigured{}}; + uint64_t consumed_high_water_ = 0; + std::string bound_session_key_; + native_order::WorkingRequestCore requests_{{"unbound", 1}}; + uint64_t next_timeline_ordinal_ = 1; + int64_t decision_floor_ms_ = 0; + bool has_floor_ = false; + native_calendar::SessionCalendar calendar_{}; + native_calendar::Timeframe input_tf_{}; + native_calendar::Timeframe script_tf_{}; + native_calendar::TimeframeCompatibility pairing_{}; + NativeRunSpec applied_{}; + bool in_callback_ = false; + bool processing_input_ = false; + InputMode input_mode_ = InputMode::Unselected; + int next_interval_index_ = 0; + std::optional current_input_open_; + std::optional observed_input_cursor_; + std::optional next_tradable_synthesis_cursor_; + std::optional last_accepted_input_; + std::optional last_observed_slot_open_; + std::optional last_finalized_input_; + uint64_t last_tick_sequence_ = 0; + bool has_tick_sequence_ = false; + ScriptBucket script_{}; + Bar forming_{}; + bool has_forming_ = false; + double last_price_ = 0.0; + bool has_last_price_ = false; + int64_t last_print_time_ms_ = 0; + std::vector driver_log_; + std::vector account_log_; + NativeDecisionContext callback_context_{}; + std::optional tz_identity_{}; + mutable AppendDigest history_digest_{}; + mutable AppendDigest driver_digest_{}; + mutable AppendDigest account_digest_{}; +}; + +inline NativeExecutionConsumer& as_native_consumer(IExecutionConsumer& consumer) { + return static_cast(consumer); +} + +} // inline namespace engine_script_run_v12 +} // namespace pineforge diff --git a/src/native_order.cpp b/src/native_order.cpp new file mode 100644 index 000000000..1d1175d7c --- /dev/null +++ b/src/native_order.cpp @@ -0,0 +1,313 @@ +#include + +#include +#include +#include +#include +#include + +namespace pineforge::native_order { +inline namespace native_order_v1 { +namespace { + +template +void reserve_one(std::vector& values) { + static_assert(std::is_nothrow_move_constructible_v); + if (values.size() == values.max_size()) { + throw std::length_error("native working-request capacity exhausted"); + } + const std::size_t required = values.size() + 1; + if (required <= values.capacity()) return; + const std::size_t grown = values.capacity() == 0 + ? 1 + : (values.capacity() > values.max_size() / 2 ? values.max_size() + : values.capacity() * 2); + values.reserve(std::max(required, grown)); +} + +uint64_t event_ordinal(const CommandEvent& event) { + return std::visit([](const auto& payload) { return payload.ordinal; }, event); +} + +} // namespace + +void WorkingRequestCore::require_identity(const RunIdentity& identity) { + if (identity.session_key.empty() || identity.run_number == 0) { + throw std::invalid_argument( + "native run identity requires a nonempty session key and positive run number"); + } +} + +void WorkingRequestCore::require_distinct_counters(uint64_t& next_order_incarnation, + uint64_t& next_timeline_ordinal) { + if (&next_order_incarnation == &next_timeline_ordinal) { + throw std::invalid_argument( + "native order incarnation and timeline counters must be distinct"); + } +} + +uint64_t WorkingRequestCore::last_command_ordinal() const { + if (history_.empty()) return 0; + return event_ordinal(history_.back()); +} + +uint64_t WorkingRequestCore::last_consumed_incarnation() const { + for (auto it = history_.rbegin(); it != history_.rend(); ++it) { + if (const auto* accepted = std::get_if(&*it)) { + return accepted->handle.incarnation; + } + if (const auto* replaced = std::get_if(&*it)) { + return replaced->successor.incarnation; + } + } + return 0; +} + +uint64_t WorkingRequestCore::usable_ordinal(uint64_t next) const { + if (next == 0) { + throw std::invalid_argument("native timeline ordinal must be nonzero"); + } + if (next == std::numeric_limits::max()) { + throw std::overflow_error("native timeline ordinal exhausted"); + } + if (next <= last_command_ordinal()) { + throw std::invalid_argument("native timeline ordinal reused or regressed"); + } + return next; +} + +uint64_t WorkingRequestCore::usable_incarnation(uint64_t next) const { + if (next == 0) { + throw std::invalid_argument("native order incarnation must be nonzero"); + } + if (next == std::numeric_limits::max()) { + throw std::overflow_error("native order incarnation exhausted"); + } + if (next <= last_consumed_incarnation()) { + throw std::invalid_argument("native order incarnation reused or regressed"); + } + return next; +} + +void WorkingRequestCore::require_grid(std::optional quantity_grid) { + if (!quantity_grid) return; + if (!std::isfinite(*quantity_grid) || *quantity_grid <= 0.0) { + throw std::invalid_argument("quantity grid requires a finite positive step"); + } +} + +std::optional WorkingRequestCore::validate_request( + const Request& request, std::optional quantity_grid) { + require_grid(quantity_grid); + if (std::holds_alternative(request.action)) return std::nullopt; + if (const auto* reduce = std::get_if(&request.action)) { + if (!std::isfinite(reduce->units) || reduce->units <= 0.0) { + return RequestRejectReason::InvalidQuantity; + } + if (quantity_grid && !quantity_on_grid(reduce->units, *quantity_grid)) { + return RequestRejectReason::OffGrid; + } + return std::nullopt; + } + const auto* transact = std::get_if(&request.action); + if (!transact || !std::isfinite(transact->signed_units) || transact->signed_units == 0.0) { + return RequestRejectReason::InvalidQuantity; + } + if (quantity_grid && !quantity_on_grid(transact->signed_units, *quantity_grid)) { + return RequestRejectReason::OffGrid; + } + return std::nullopt; +} + +WorkingRequestCore::WorkingRequestCore(RunIdentity identity) { + require_identity(identity); + identity_ = std::move(identity); +} + +WorkingRequestCore::WorkingRequestCore(WorkingRequestCore&& other) noexcept { + *this = std::move(other); +} + +WorkingRequestCore& WorkingRequestCore::operator=(WorkingRequestCore&& other) noexcept { + if (this == &other) return *this; + identity_ = std::move(other.identity_); + live_ = std::move(other.live_); + history_ = std::move(other.history_); + other.identity_.session_key.clear(); + other.identity_.run_number = 0; + other.live_.clear(); + other.history_.clear(); + return *this; +} + +void WorkingRequestCore::reset(RunIdentity identity) { + require_identity(identity); + WorkingRequestCore next(std::move(identity)); + *this = std::move(next); +} + +WorkingRequestCore::TargetKind WorkingRequestCore::classify( + const RequestHandle& handle, std::size_t* live_index) const { + if (handle.incarnation == 0 || handle.run.run_number == 0 || handle.run.session_key.empty() + || handle.run != identity_) { + return TargetKind::InvalidHandle; + } + for (std::size_t i = 0; i < live_.size(); ++i) { + if (live_[i].handle.incarnation == handle.incarnation) { + if (live_index) *live_index = i; + return TargetKind::Live; + } + } + return TargetKind::NotWorking; +} + +const LiveRequest* WorkingRequestCore::find_live(const RequestHandle& handle) const { + std::size_t index = 0; + if (classify(handle, &index) != TargetKind::Live) return nullptr; + return &live_[index]; +} + +SubmitResult WorkingRequestCore::submit(const Request& request, + int64_t decision_time_ms, + uint64_t& next_order_incarnation, + uint64_t& next_timeline_ordinal, + std::optional quantity_grid) { + require_identity(identity_); + require_distinct_counters(next_order_incarnation, next_timeline_ordinal); + Request staged_request = request; + const auto reason = validate_request(staged_request, quantity_grid); + if (reason) { + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + SubmitResult result{SubmitStatus::Rejected, ordinal, std::nullopt, reason}; + CommandEvent event{RejectedEvent{ordinal, std::move(staged_request), *reason}}; + reserve_one(history_); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + return result; + } + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + const uint64_t incarnation = usable_incarnation(next_order_incarnation); + RequestHandle handle{identity_, incarnation}; + Birth birth{ordinal, decision_time_ms}; + LiveRequest live_row{handle, staged_request, birth, std::nullopt}; + CommandEvent event{AcceptedEvent{ordinal, handle, std::move(staged_request), birth}}; + SubmitResult result{SubmitStatus::Accepted, ordinal, std::move(handle), std::nullopt}; + reserve_one(live_); + reserve_one(history_); + live_.push_back(std::move(live_row)); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + ++next_order_incarnation; + return result; +} + +ReplaceResult WorkingRequestCore::replace(const RequestHandle& target, + const Request& request, + int64_t decision_time_ms, + uint64_t& next_order_incarnation, + uint64_t& next_timeline_ordinal, + std::optional quantity_grid) { + require_identity(identity_); + require_distinct_counters(next_order_incarnation, next_timeline_ordinal); + RequestHandle staged_target = target; + Request staged_request = request; + std::size_t live_index = 0; + const TargetKind kind = classify(staged_target, &live_index); + if (kind != TargetKind::Live) { + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + const ReplaceStatus status = kind == TargetKind::InvalidHandle + ? ReplaceStatus::InvalidHandle + : ReplaceStatus::NotWorking; + ReplaceResult result{status, ordinal, std::nullopt, std::nullopt}; + CommandEvent event = kind == TargetKind::InvalidHandle + ? CommandEvent{InvalidHandleEvent{ordinal, std::move(staged_target), + std::move(staged_request)}} + : CommandEvent{NotWorkingEvent{ordinal, std::move(staged_target), + std::move(staged_request)}}; + reserve_one(history_); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + return result; + } + const auto reason = validate_request(staged_request, quantity_grid); + if (reason) { + Request live_request = live_[live_index].request; + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + ReplaceResult result{ReplaceStatus::ReplaceRejected, ordinal, std::nullopt, reason}; + CommandEvent event{ReplaceRejectedEvent{ordinal, std::move(staged_target), + std::move(live_request), std::move(staged_request), + *reason}}; + reserve_one(history_); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + return result; + } + LiveRequest predecessor = live_[live_index]; + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + const uint64_t incarnation = usable_incarnation(next_order_incarnation); + RequestHandle successor_handle{identity_, incarnation}; + Birth birth{ordinal, decision_time_ms}; + LiveRequest successor{successor_handle, staged_request, birth, predecessor.handle}; + CommandEvent event{ReplacedEvent{ordinal, predecessor.handle, std::move(predecessor.request), + successor_handle, std::move(staged_request), birth}}; + ReplaceResult result{ReplaceStatus::Replaced, ordinal, std::move(successor_handle), + std::nullopt}; + reserve_one(history_); + live_.erase(live_.begin() + static_cast(live_index)); + live_.push_back(std::move(successor)); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + ++next_order_incarnation; + return result; +} + +uint64_t TerminalCommit::usable_ordinal(const WorkingRequestCore& core, uint64_t next) { + return core.usable_ordinal(next); +} + +void TerminalCommit::reserve_history(WorkingRequestCore& core) { + reserve_one(core.history_); +} + +void TerminalCommit::install(WorkingRequestCore& core, + std::size_t live_index, + CommandEvent&& event) noexcept { + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_nothrow_move_assignable_v); + core.history_.push_back(std::move(event)); + core.live_.erase(core.live_.begin() + static_cast(live_index)); +} + +CancelResult WorkingRequestCore::cancel(const RequestHandle& target, + uint64_t& next_timeline_ordinal) { + require_identity(identity_); + RequestHandle staged_target = target; + std::size_t live_index = 0; + const TargetKind kind = classify(staged_target, &live_index); + const uint64_t ordinal = usable_ordinal(next_timeline_ordinal); + if (kind != TargetKind::Live) { + const CancelStatus status = kind == TargetKind::InvalidHandle + ? CancelStatus::InvalidHandle + : CancelStatus::NotWorking; + CancelResult result{status, ordinal}; + CommandEvent event = kind == TargetKind::InvalidHandle + ? CommandEvent{InvalidHandleEvent{ordinal, std::move(staged_target), std::nullopt}} + : CommandEvent{NotWorkingEvent{ordinal, std::move(staged_target), std::nullopt}}; + reserve_one(history_); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + return result; + } + LiveRequest removed = live_[live_index]; + CancelResult result{CancelStatus::Cancelled, ordinal}; + CommandEvent event{CancelledEvent{ordinal, std::move(removed.handle), + std::move(removed.request)}}; + reserve_one(history_); + live_.erase(live_.begin() + static_cast(live_index)); + history_.push_back(std::move(event)); + ++next_timeline_ordinal; + return result; +} + +} // inline namespace native_order_v1 +} // namespace pineforge::native_order diff --git a/src/native_run_spec.cpp b/src/native_run_spec.cpp new file mode 100644 index 000000000..2858f1437 --- /dev/null +++ b/src/native_run_spec.cpp @@ -0,0 +1,187 @@ +#include +#include + +#include +#include +#include +#include + +namespace pineforge { +inline namespace native_run_spec_v1 { +namespace { + +using Error = NativeRunSpecError; +using Field = NativeRunSpecField; +using Result = NativeRunSpecValidation; + +// Unicode scalar-value UTF-8: reject overlong sequences, surrogates, +// >U+10FFFF, isolated continuation bytes and truncated sequences. Do not +// reject valid non-ASCII identities or rewrite their normalization form. +bool valid_utf8(std::string_view text) noexcept { + std::size_t i = 0; + while (i < text.size()) { + const auto lead = static_cast(text[i++]); + if (lead < 0x80) continue; + unsigned remaining = 0; + unsigned low = 0x80; + unsigned high = 0xBF; + if (lead >= 0xC2 && lead <= 0xDF) { + remaining = 1; + } else if (lead >= 0xE0 && lead <= 0xEF) { + remaining = 2; + if (lead == 0xE0) low = 0xA0; + if (lead == 0xED) high = 0x9F; + } else if (lead >= 0xF0 && lead <= 0xF4) { + remaining = 3; + if (lead == 0xF0) low = 0x90; + if (lead == 0xF4) high = 0x8F; + } else { + return false; + } + if (text.size() - i < remaining) return false; + const auto first = static_cast(text[i++]); + if (first < low || first > high) return false; + while (--remaining != 0) { + const auto next = static_cast(text[i++]); + if (next < 0x80 || next > 0xBF) return false; + } + } + return true; +} + +Result validate_string(std::string_view value, Field field, bool required) noexcept { + if (required && value.empty()) return {Error::EmptyRequiredString, field}; + if (value.find('\0') != std::string_view::npos) return {Error::EmbeddedNul, field}; + if (!valid_utf8(value)) return {Error::InvalidUtf8, field}; + return {}; +} + +bool positive(double value) noexcept { return std::isfinite(value) && value > 0.0; } + +Result validate_values(const NativeRunSpec& spec) noexcept { + const struct { + const std::string& value; + Field field; + bool required; + } strings[] = { + {spec.identity.session_key, Field::SessionKey, true}, + {spec.input_tf, Field::InputTimeframe, true}, + {spec.script_tf, Field::ScriptTimeframe, true}, + {spec.ticker, Field::Ticker, false}, + {spec.tickerid, Field::TickerId, true}, + {spec.type, Field::Type, false}, + {spec.currency, Field::Currency, false}, + {spec.basecurrency, Field::BaseCurrency, false}, + {spec.description, Field::Description, false}, + {spec.volumetype, Field::VolumeType, false}, + {spec.timezone, Field::Timezone, true}, + {spec.session, Field::Session, false}, + {spec.chart_timezone, Field::ChartTimezone, false}, + }; + for (const auto& value : strings) { + const auto result = validate_string(value.value, value.field, value.required); + if (!result) return result; + } + if (spec.identity.run_number == 0) return {Error::ZeroRunNumber, Field::RunNumber}; + const struct { double value; Field field; } financial[] = { + {spec.initial_capital, Field::InitialCapital}, + {spec.point_value, Field::PointValue}, + {spec.account_fx, Field::AccountFx}, + {spec.price_tick, Field::PriceTick}, + }; + for (const auto& value : financial) { + if (!positive(value.value)) return {Error::NotFinitePositive, value.field}; + } + if (spec.slippage_ticks > static_cast(std::numeric_limits::max())) + return {Error::SlippageOutOfRange, Field::SlippageTicks}; + switch (spec.fee_kind) { + case NativeFeeKind::Percent: + case NativeFeeKind::CashPerUnit: + case NativeFeeKind::CashPerExecution: + break; + default: + return {Error::UnknownFeeKind, Field::FeeKind}; + } + if (!std::isfinite(spec.fee_value) || spec.fee_value < 0.0) + return {Error::NotFiniteNonnegative, Field::FeeValue}; + if (spec.quantity_grid && !positive(*spec.quantity_grid)) + return {Error::NotFinitePositive, Field::QuantityGrid}; + switch (spec.close_execution) { + case NativeCloseExecution::NextEligiblePoint: + case NativeCloseExecution::AfterCalculation: + break; + default: + return {Error::UnknownCloseExecution, Field::CloseExecution}; + } + if (spec.max_abs_units && !positive(*spec.max_abs_units)) + return {Error::NotFinitePositive, Field::MaxAbsUnits}; + if (spec.max_open_lots && *spec.max_open_lots == 0) + return {Error::ZeroLotLimit, Field::MaxOpenLots}; + switch (spec.allowed_open_directions) { + case NativeOpenDirections::None: + case NativeOpenDirections::Long: + case NativeOpenDirections::Short: + case NativeOpenDirections::Both: + break; + default: + return {Error::UnknownOpenDirections, Field::AllowedOpenDirections}; + } + if (spec.initial_margin_fraction && !positive(*spec.initial_margin_fraction)) + return {Error::NotFinitePositive, Field::InitialMarginFraction}; + return {}; +} + +} // namespace + +NativeRunSpecValidation validate_native_run_spec(const NativeRunSpec& spec) noexcept { + const auto values = validate_values(spec); + if (!values) return values; + + Field active_field = Field::InputTimeframe; + try { + const auto input = native_calendar::parse_timeframe(spec.input_tf); + if (!input) return {Error::InvalidTimeframe, active_field}; + active_field = Field::ScriptTimeframe; + const auto script = native_calendar::parse_timeframe(spec.script_tf); + if (!script) return {Error::InvalidTimeframe, active_field}; + // Configure admits the complete batch contract, including monthly. + // The host must apply stream_compatibility separately at stream begin. + switch (native_calendar::compatibility(*input, *script).pairing) { + case native_calendar::TimeframePairing::Passthrough: + case native_calendar::TimeframePairing::SameUnitMultiple: + case native_calendar::TimeframePairing::FixedDivisible: + case native_calendar::TimeframePairing::FixedToCalendar: + case native_calendar::TimeframePairing::CalendarToCalendar: + break; + default: + return {Error::IncompatibleTimeframes, active_field}; + } + // Use calendar's timezone acceptance with an all-day literal first, + // so malformed session syntax has its own stable failure field. + active_field = Field::Timezone; + if (!native_calendar::parse_session("", spec.timezone)) + return {Error::UnresolvedTimezone, active_field}; + active_field = Field::Session; + if (!native_calendar::parse_session(spec.session, spec.timezone)) + return {Error::InvalidSession, active_field}; + active_field = Field::ChartTimezone; + if (!spec.chart_timezone.empty() + && !native_calendar::parse_session("", spec.chart_timezone)) + return {Error::UnresolvedTimezone, active_field}; + return {}; + } catch (const std::bad_alloc&) { + return {Error::AllocationFailure, active_field}; + } catch (...) { + return {Error::CalendarFailure, active_field}; + } +} + +NativeRunSpecValidation normalize_native_run_spec(NativeRunSpec& spec) noexcept { + const auto result = validate_native_run_spec(spec); + if (!result) return result; + if (spec.fee_value == 0.0) spec.fee_value = 0.0; + return {}; +} + +} // inline namespace native_run_spec_v1 +} // namespace pineforge diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 27b558efe..f5efc51e8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,12 @@ set(TEST_SOURCES test_resolved_execution test_native_reversal_contract test_order_action + test_native_order_core + test_native_order_terminal + test_native_market_vertical + test_native_market_vertical_contract + test_native_calendar_driver_contract + test_native_host_repairs test_order_action_integration test_placement_facts test_order_cancellation @@ -74,6 +80,8 @@ set(TEST_SOURCES test_matrix_na test_matrix_snapshot_compat test_session_time + test_native_calendar + test_native_run_spec test_session_day_anchors test_time_tradingday test_chart_timezone @@ -305,6 +313,22 @@ add_test( ${_pf_script_cpp_abi_flags} ) +add_test( + NAME test_native_cpp_versions + COMMAND ${Python3_EXECUTABLE} + ${PROJECT_SOURCE_DIR}/scripts/test_native_cpp_versions.py +) +add_test( + NAME test_native_cpp_abi + COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/scripts/check_native_cpp_abi.py + --compiler ${CMAKE_CXX_COMPILER} + --library $ + --include ${PROJECT_SOURCE_DIR}/include + --generated-include ${PROJECT_BINARY_DIR}/include + ${_pf_script_cpp_abi_flags} +) +set_tests_properties(test_native_cpp_abi PROPERTIES TIMEOUT 240) + add_test( NAME test_derive_corpus_feeds COMMAND ${Python3_EXECUTABLE} @@ -427,6 +451,9 @@ if(PINEFORGE_ENABLE_COVERAGE) endif() endif() +file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/test_native_market_vertical.cpp" + PINEFORGE_NATIVE_MARKET_VERTICAL_SHA256) + foreach(test_name ${TEST_SOURCES}) add_executable(${test_name} ${test_name}.cpp) target_link_libraries(${test_name} PRIVATE pineforge) @@ -444,6 +471,9 @@ foreach(test_name ${TEST_SOURCES}) add_test(NAME ${test_name} COMMAND ${test_name}) endforeach() +target_compile_definitions(test_native_market_vertical PRIVATE + PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256="${PINEFORGE_NATIVE_MARKET_VERTICAL_SHA256}") + # std::thread needs an explicit pthread link on some toolchains. target_link_libraries(test_timezone_concurrency PRIVATE Threads::Threads) @@ -472,3 +502,18 @@ add_test(NAME test_market_admission_schema_mutations COMMAND ${Python3_EXECUTABL ${PROJECT_SOURCE_DIR}/scripts/test_market_admission_schema.py) add_test(NAME test_cancellation_hash_coverage COMMAND ${Python3_EXECUTABLE} -B ${PROJECT_SOURCE_DIR}/scripts/check_cancellation_hash_coverage.py) + +# Independent native vertical contract fixture identity. +file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/test_native_market_vertical_contract.cpp" + PINEFORGE_NATIVE_MARKET_VERTICAL_CONTRACT_SHA256) +target_compile_definitions(test_native_market_vertical_contract PRIVATE + PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256="${PINEFORGE_NATIVE_MARKET_VERTICAL_CONTRACT_SHA256}") + +# Reconfigure source-bound native evidence when either fixture changes. +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/test_native_market_vertical_contract.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/test_native_calendar_driver_contract.cpp") +file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/test_native_calendar_driver_contract.cpp" + PINEFORGE_NATIVE_CALENDAR_DRIVER_SHA256) +target_compile_definitions(test_native_calendar_driver_contract PRIVATE + PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256="${PINEFORGE_NATIVE_CALENDAR_DRIVER_SHA256}") diff --git a/tests/fixtures/native_cpp_abi/README.md b/tests/fixtures/native_cpp_abi/README.md new file mode 100644 index 000000000..32f656fff --- /dev/null +++ b/tests/fixtures/native_cpp_abi/README.md @@ -0,0 +1,24 @@ +# Frozen native C++ pairing fixtures + +Each subdirectory is a byte-for-byte header (and, where needed, source) +closure used by `scripts/check_native_cpp_abi.py`. `manifest.json` records +SHA-256, Git blob IDs, byte lengths, and the gzip archive digest. +`headers.json.gz` is UTF-8 JSON mapping include- or `src/`-relative paths to +exact file contents, gzip-compressed with `mtime=0`. The archive is +independent of Git history, shallow checkouts, network access, and later +current-header changes. + +| Fixture | Identity | What it proves | +|---|---|---| +| `order-1acaf33` | commit `1acaf33e0340f6ae09d9529a3b6a0c12742dcb0d`, tree `ced31532e86bd41bc8753330ea0abcf379fdc05f` | Unversioned `pineforge::native_order`. `CommandEvent` has 7 alternatives. `src/native_order.cpp` is an authentic compile-only old object. | +| `calendar-draft-a8e34c` | content SHA `a8e34c127b3d8d95f5556cf2e99fbc3d93c68c85f607a0e5e3e044b904193291` | Draft unversioned calendar. `NativeInterval` is 32 bytes; `period_key` returns `int64_t`. Authentic calendar/timezone objects are compile/link controls. | +| `calendar-262a280` | commit `262a28013ed277990349e845ba0554a63f6fbc79`, tree `fb00ed9ba02cede2fe08ee8113ae32f71fd26b00`, header SHA `c47b3ee15d69587879cca8f491e73880cddae1ce3e7dfd0d1f4db16dfb8fcf4b` | Pre-wrapper unversioned `timezone_identity_descriptor`. Byte-identical to independently reviewed `f041756`. | +| `driver-08b5c88` | commit `08b5c8868eadd3022a62b32e7e2cabe0640e75f4`, tree `3482d155df5fe91c5dfd4a05fdfb506641d0ee19` | Unversioned driver values. `NativeCoordinate` is 72 bytes. `src/market_driver.cpp` is the authentic production `native_bar_structurally_valid` definition. | +| `run-spec-262a280` | same 262a280 commit/tree, `native_run_spec.hpp` SHA `b263a8bf3a68f58201c968ffa9ec8b54633fe3c752332df164ea11cf4bce3c7c` | Pre-wrapper unversioned `NativeRunSpec`. Matching old/old uses a labeled minimal symbol control, not a historical validator runtime. | + +Sources were taken from the pairing-audit capture +`tasks/native-abi-audit/snapshot-20260912T064119Z` and, where that capture +omitted a file, from the same Git commit the capture names. Do not execute +mismatched binaries. Layout sizes in each manifest were recorded by the +pairing audit's LLVM `sizeof`/`offsetof` witness and are re-checked here +with `static_assert` against the frozen headers. diff --git a/tests/fixtures/native_cpp_abi/calendar-262a280/headers.json.gz b/tests/fixtures/native_cpp_abi/calendar-262a280/headers.json.gz new file mode 100644 index 000000000..5d8645fd6 Binary files /dev/null and b/tests/fixtures/native_cpp_abi/calendar-262a280/headers.json.gz differ diff --git a/tests/fixtures/native_cpp_abi/calendar-262a280/manifest.json b/tests/fixtures/native_cpp_abi/calendar-262a280/manifest.json new file mode 100644 index 000000000..972c2933e --- /dev/null +++ b/tests/fixtures/native_cpp_abi/calendar-262a280/manifest.json @@ -0,0 +1,21 @@ +{ + "surface": "native_calendar_descriptor", + "source_commit": "262a28013ed277990349e845ba0554a63f6fbc79", + "source_tree": "fb00ed9ba02cede2fe08ee8113ae32f71fd26b00", + "internal_namespace": null, + "note": "Pre-wrapper unversioned native_calendar at 262a280, including timezone_identity_descriptor. Byte-identical to independently reviewed f041756 header. Not a historical runtime.", + "layout": { + "NativeInterval_sizeof": 40, + "optional_NativeInterval_sizeof": 48, + "period_key_return_sizeof": 16 + }, + "archive": "headers.json.gz", + "archive_sha256": "27457d808e603601f44b1fc9d91292c44919dc81c30d7c68aea818398bf9044a", + "files": { + "pineforge/native_calendar.hpp": { + "sha256": "c47b3ee15d69587879cca8f491e73880cddae1ce3e7dfd0d1f4db16dfb8fcf4b", + "git_blob": "97c2fdc7ba68541091889eb1b40d54eb9e67b291", + "bytes": 19509 + } + } +} diff --git a/tests/fixtures/native_cpp_abi/calendar-draft-a8e34c/headers.json.gz b/tests/fixtures/native_cpp_abi/calendar-draft-a8e34c/headers.json.gz new file mode 100644 index 000000000..a38fe3964 Binary files /dev/null and b/tests/fixtures/native_cpp_abi/calendar-draft-a8e34c/headers.json.gz differ diff --git a/tests/fixtures/native_cpp_abi/calendar-draft-a8e34c/manifest.json b/tests/fixtures/native_cpp_abi/calendar-draft-a8e34c/manifest.json new file mode 100644 index 000000000..bc67ed59a --- /dev/null +++ b/tests/fixtures/native_cpp_abi/calendar-draft-a8e34c/manifest.json @@ -0,0 +1,44 @@ +{ + "surface": "native_calendar", + "source_commit": null, + "source_tree": null, + "content_sha256": "a8e34c127b3d8d95f5556cf2e99fbc3d93c68c85f607a0e5e3e044b904193291", + "content_path": "pineforge/native_calendar.hpp", + "original_source_head": "ac526fd5b5a73b55e04fafd6b8a6de9bfd76e87a", + "internal_namespace": null, + "note": "Draft unversioned calendar header identified by content SHA a8e34c. NativeInterval is 32 bytes; period_key returns int64_t. Authentic src objects are compile/link controls, not a historical runtime.", + "captured_from": "tasks/native-abi-audit/snapshot-20260912T064119Z/draft-a8e34c", + "layout": { + "Timeframe_sizeof": 32, + "optional_Timeframe_sizeof": 40, + "SessionCalendar_sizeof": 88, + "NativeInterval_sizeof": 32, + "optional_NativeInterval_sizeof": 40, + "NativeInterval_last_traded_close_ms_offset": 8, + "period_key_return_sizeof": 8 + }, + "archive": "headers.json.gz", + "archive_sha256": "d0563720c5b667a8400b368153e4173b2a04c93643aa49cb87c46963eb203c2d", + "files": { + "pineforge/native_calendar.hpp": { + "sha256": "a8e34c127b3d8d95f5556cf2e99fbc3d93c68c85f607a0e5e3e044b904193291", + "git_blob": "d10876eeefbbf70bc17202f93c2f2ff8f05c8a10", + "bytes": 10019 + }, + "src/native_calendar.cpp": { + "sha256": "ba2ffce4ed521f0e0a6f9ae5eb843cf70dc35deaa77f0c07d800201ada323675", + "git_blob": "45b1260cd23b30e9473f68abf18b58e5c7b590fe", + "bytes": 32150 + }, + "src/timezone.cpp": { + "sha256": "b82e46321077cde60cd4d9483618122a7dd969e6533325026cff286dd7471a4c", + "git_blob": "c79c70bd9be41add561f9a185bc63182da7c80ca", + "bytes": 3409 + }, + "src/timezone.hpp": { + "sha256": "1fac463ce535408ee119a23c7612e29d033ca5a9761a828e0c11ae7bcde35a21", + "git_blob": "bdd0deb79bac26a08ee5df98d49717f8cca6a5c3", + "bytes": 918 + } + } +} diff --git a/tests/fixtures/native_cpp_abi/driver-08b5c88/headers.json.gz b/tests/fixtures/native_cpp_abi/driver-08b5c88/headers.json.gz new file mode 100644 index 000000000..731735308 Binary files /dev/null and b/tests/fixtures/native_cpp_abi/driver-08b5c88/headers.json.gz differ diff --git a/tests/fixtures/native_cpp_abi/driver-08b5c88/manifest.json b/tests/fixtures/native_cpp_abi/driver-08b5c88/manifest.json new file mode 100644 index 000000000..3c2419971 --- /dev/null +++ b/tests/fixtures/native_cpp_abi/driver-08b5c88/manifest.json @@ -0,0 +1,38 @@ +{ + "surface": "native_driver", + "source_commit": "08b5c8868eadd3022a62b32e7e2cabe0640e75f4", + "source_tree": "3482d155df5fe91c5dfd4a05fdfb506641d0ee19", + "internal_namespace": null, + "note": "Unversioned pineforge driver values at 08b5c88. NativeCoordinate is 72 bytes (no eligible_open_ms). src/market_driver.cpp is the authentic production native_bar_structurally_valid definition, compiled as a link control only.", + "captured_from": "tasks/native-abi-audit/snapshot-20260912T064119Z/08b5c88 plus git 08b5c88:src/market_driver.cpp", + "layout": { + "NativeCoordinate_sizeof": 72, + "NativeDriverPoint_sizeof": 104, + "NativeDecisionContext_sizeof": 160, + "NativeCoordinate_last_traded_close_ms_offset": 24 + }, + "archive": "headers.json.gz", + "archive_sha256": "17d6850a27e8ccf4b965ba4b49d080942b636b83af63e0eb1857286d7c69f6b9", + "files": { + "pineforge/bar.hpp": { + "sha256": "665c2528dca64ac9474de622dfd7ffdb540ff91316f70317e39aada158d1e0de", + "git_blob": "836f73daab382e9c8d5a6d03a942a921e39b756f", + "bytes": 655 + }, + "pineforge/market_driver.hpp": { + "sha256": "b8b01114abb23d39a998a7ff903c420a8cf7d422d1cc57a1b46baa778ead2297", + "git_blob": "b05c6e826a5746ce61a682db5d36dc6613423303", + "bytes": 2603 + }, + "pineforge/native_calendar.hpp": { + "sha256": "2c3f1bcbbc27b557de4ff589b642030403c298785ed7a94fa834a038fd0b8482", + "git_blob": "58fc5f842d88291b7bb4c031ddd47ece289d74c8", + "bytes": 18305 + }, + "src/market_driver.cpp": { + "sha256": "c061f91a7b66a4bba1b3a249af8b2f3f2f5adf09ebce2fa91914ffa231dc9b95", + "git_blob": "d6c79da62ce0847eef237a237cc71773f5e9f09c", + "bytes": 587 + } + } +} diff --git a/tests/fixtures/native_cpp_abi/order-1acaf33/headers.json.gz b/tests/fixtures/native_cpp_abi/order-1acaf33/headers.json.gz new file mode 100644 index 000000000..127fbbaa6 Binary files /dev/null and b/tests/fixtures/native_cpp_abi/order-1acaf33/headers.json.gz differ diff --git a/tests/fixtures/native_cpp_abi/order-1acaf33/manifest.json b/tests/fixtures/native_cpp_abi/order-1acaf33/manifest.json new file mode 100644 index 000000000..3978e4fc5 --- /dev/null +++ b/tests/fixtures/native_cpp_abi/order-1acaf33/manifest.json @@ -0,0 +1,45 @@ +{ + "surface": "native_order", + "source_commit": "1acaf33e0340f6ae09d9529a3b6a0c12742dcb0d", + "source_tree": "ced31532e86bd41bc8753330ea0abcf379fdc05f", + "internal_namespace": null, + "note": "Unversioned pineforge::native_order at 1acaf33. CommandEvent has 7 alternatives. Authentic src/native_order.cpp is a compile-only old object, not a historical runtime.", + "captured_from": "tasks/native-abi-audit/snapshot-20260912T064119Z/1acaf33", + "layout": { + "CommandEvent_sizeof": 240, + "CommandEvent_variant_size": 7, + "WorkingRequestCore_sizeof": 80, + "Request_sizeof": 64, + "SubmitResult_sizeof": 72, + "LiveRequest_sizeof": 168 + }, + "archive": "headers.json.gz", + "archive_sha256": "70833e951654ebb392357eaa26188cbd153768008bb4873b8ffd5f7dec01c2a0", + "files": { + "pineforge/execution.hpp": { + "sha256": "971609337570accd2326938891890b77da2756a64d67ff890379a782e4ba2106", + "git_blob": "6902963eb220544fefc7f52912d795884c6d64a8", + "bytes": 2947 + }, + "pineforge/exit_leg_lifecycle.hpp": { + "sha256": "9d785271511a350114adc7cf5bcb1600cf569f0a10559a8671b7e4b9cb9dc89a", + "git_blob": "91492ec22e05d7b6d9327f60ff5e239ae6906fcd", + "bytes": 22622 + }, + "pineforge/native_order.hpp": { + "sha256": "ff32dd1b9a34bc6868b86c8dc14d27513e5ea2a710c7657c895662bcde65ee71", + "git_blob": "d0eaaaf0f9780ab50144fea850764e3bb7370024", + "bytes": 8848 + }, + "pineforge/order_action.hpp": { + "sha256": "7d76ddc3f6ba76216d5feb34cdf28908e33a92a56757a707ac6bd94dca315001", + "git_blob": "d992472cbefefdf8e29704a21f04614d68d39a58", + "bytes": 3470 + }, + "src/native_order.cpp": { + "sha256": "2a87060759706f4630ccccb1bbda37260d454154c0491cfade0ad82605a796c0", + "git_blob": "33854af7f129227090266db627befda372a4bc03", + "bytes": 12491 + } + } +} diff --git a/tests/fixtures/native_cpp_abi/run-spec-262a280/headers.json.gz b/tests/fixtures/native_cpp_abi/run-spec-262a280/headers.json.gz new file mode 100644 index 000000000..1d1e8ad35 Binary files /dev/null and b/tests/fixtures/native_cpp_abi/run-spec-262a280/headers.json.gz differ diff --git a/tests/fixtures/native_cpp_abi/run-spec-262a280/manifest.json b/tests/fixtures/native_cpp_abi/run-spec-262a280/manifest.json new file mode 100644 index 000000000..b426a1f4f --- /dev/null +++ b/tests/fixtures/native_cpp_abi/run-spec-262a280/manifest.json @@ -0,0 +1,36 @@ +{ + "surface": "native_run_spec", + "source_commit": "262a28013ed277990349e845ba0554a63f6fbc79", + "source_tree": "fb00ed9ba02cede2fe08ee8113ae32f71fd26b00", + "internal_namespace": null, + "note": "Pre-wrapper unversioned NativeRunSpec at 262a280. Matching old/old uses a labeled minimal symbol control compiled against this header closure, not a historical validator runtime.", + "archive": "headers.json.gz", + "archive_sha256": "b8445a38fe7a4000660a1751756a659394b8ce0ccd8d3bd331fd86dfa19e7230", + "files": { + "pineforge/execution.hpp": { + "sha256": "b64f9bf6c433ba452f13dae48bbe4f11e3747777cb73b556369d270595329e38", + "git_blob": "c79bed08095f7f4dcf223f612132742781d5c1ef", + "bytes": 4123 + }, + "pineforge/exit_leg_lifecycle.hpp": { + "sha256": "9d785271511a350114adc7cf5bcb1600cf569f0a10559a8671b7e4b9cb9dc89a", + "git_blob": "91492ec22e05d7b6d9327f60ff5e239ae6906fcd", + "bytes": 22622 + }, + "pineforge/native_order.hpp": { + "sha256": "32b0f39fe437ae67b635b320d9b1fce6c7035d3269e44e481f5472ac14c48c73", + "git_blob": "5de0c973840142c079cc2639f255f9805fb59bd6", + "bytes": 10723 + }, + "pineforge/native_run_spec.hpp": { + "sha256": "b263a8bf3a68f58201c968ffa9ec8b54633fe3c752332df164ea11cf4bce3c7c", + "git_blob": "d4e54e1ce772a9d4ff3cee476a94ca1cc01827f3", + "bytes": 5429 + }, + "pineforge/order_action.hpp": { + "sha256": "7d76ddc3f6ba76216d5feb34cdf28908e33a92a56757a707ac6bd94dca315001", + "git_blob": "d992472cbefefdf8e29704a21f04614d68d39a58", + "bytes": 3470 + } + } +} diff --git a/tests/native_live_startup_e2e.py b/tests/native_live_startup_e2e.py new file mode 100644 index 000000000..f18981fa7 --- /dev/null +++ b/tests/native_live_startup_e2e.py @@ -0,0 +1,314 @@ +"""CLI contract for native runner startup/identity. Python orchestrates only.""" +import json +import hashlib +import os +import sqlite3 +import subprocess +import sys +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +(runner, legacy_library, native_example, absent, unknown, no_export, stub) = sys.argv[1:] + + +def ledger_bound(path): + path = Path(path) + if not path.exists(): + return False + try: + with sqlite3.connect(path) as db: + row = db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='metadata'" + ).fetchone() + return row is not None + except sqlite3.Error: + return False + + +def identity_of(path): + with sqlite3.connect(path) as db: + return db.execute('SELECT identity FROM metadata WHERE singleton=1').fetchone()[0] + + +def native_config(path, **clock): + body = { + 'run': {'session_key': clock.pop('session_key', 'live-1'), + 'run_number': clock.pop('run_number', 1)}, + 'clock': { + 'input_tf': clock.pop('input_tf', '5'), + 'script_tf': clock.pop('script_tf', '10'), + 'timezone': clock.pop('timezone', 'UTC'), + 'session': clock.pop('session', '24x7'), + 'chart_timezone': clock.pop('chart_timezone', ''), + }, + 'instrument': { + 'ticker': 'MOCK', 'tickerid': 'TEST:MOCK', 'type': 'crypto', + 'currency': 'USDT', 'basecurrency': 'ETH', 'description': '', + 'volumetype': '', + }, + 'execution': { + 'initial_capital': 10000, 'point_value': 1, 'account_fx': 1, + 'price_tick': 0.01, 'slippage_ticks': 0, + 'fee_kind': 'CashPerExecution', 'fee_value': 6, + 'quantity_grid': None, 'close_execution': 'NextEligiblePoint', + 'max_abs_units': None, 'max_open_lots': None, + 'allowed_open_directions': 3, 'initial_margin_fraction': None, + }, + } + path.write_text(json.dumps(body)) + + +def warmup_csv(path, rows): + text = 'timestamp,open,high,low,close,volume\n' + for ts in rows: + text += f'{ts},100,102,99,101,4\n' + path.write_text(text) + + +def invoke(args, success=True): + p = subprocess.run(args, capture_output=True, text=True, timeout=25) + if success: + assert p.returncode == 0, (p.returncode, p.stdout, p.stderr) + return json.loads(p.stdout) if p.stdout.strip().startswith('{') else p + assert p.returncode != 0, (p.returncode, p.stdout, p.stderr) + return p + + +def base_cmd(strategy, warmup, ledger, config=None, extra=None): + cmd = [runner, 'run', '--strategy', strategy, '--warmup', str(warmup), + '--mode', 'bars', '--ledger', str(ledger), + '--webhook-url', 'http://127.0.0.1:1/unused', + '--allow-insecure-http', '--feed', str(Path(warmup).with_suffix('.jsonl'))] + Path(warmup).with_suffix('.jsonl').write_text('') + if config is not None: + cmd += ['--native-config', str(config)] + else: + cmd += ['--script-tf', '3', '--symbol', 'TEST:MOCK'] + if extra: + cmd += extra + return cmd + + +with tempfile.TemporaryDirectory(prefix='pineforge-native-startup-') as raw: + root = Path(raw) + warmup1m = root / 'w1m.csv' + warmup_csv(warmup1m, [0, 60000, 120000]) + warmup5 = root / 'w5.csv' + warmup_csv(warmup5, [0, 300000, 600000, 900000]) + warmup5_gap = root / 'w5gap.csv' + warmup_csv(warmup5_gap, [0, 300000, 900000]) + cfg = root / 'native.json' + native_config(cfg) + + # Bad JSON never binds a ledger. + bad_cfg = root / 'bad.json' + bad_cfg.write_text('{') + ledger = root / 'bad-json.sqlite3' + p = invoke(base_cmd(stub, warmup5, ledger, bad_cfg), success=False) + assert 'JSON' in p.stderr + assert not ledger_bound(ledger) + + # Unknown contract never binds a ledger. + ledger = root / 'unknown.sqlite3' + p = invoke(base_cmd(unknown, warmup5, ledger, cfg), success=False) + assert 'unknown strategy execution contract' in p.stderr + assert not ledger_bound(ledger) + + # Native-config on a legacy (query-absent) library never binds. + ledger = root / 'legacy-native-cfg.sqlite3' + p = invoke(base_cmd(absent, warmup5, ledger, cfg), success=False) + assert 'native-config requires NativeMarketV1' in p.stderr + assert not ledger_bound(ledger) + + # Missing native configure export never binds. + ledger = root / 'no-export.sqlite3' + p = invoke(base_cmd(no_export, warmup5, ledger, cfg), success=False) + assert 'strategy_configure_native_v1' in p.stderr + assert not ledger_bound(ledger) + + # Native history gap never binds. + ledger = root / 'gap.sqlite3' + p = invoke(base_cmd(stub, warmup5_gap, ledger, cfg), success=False) + assert 'in-session gap' in p.stderr + assert not ledger_bound(ledger) + + # Monthly stream input never binds. + monthly = root / 'monthly.json' + native_config(monthly, input_tf='M', script_tf='M') + ledger = root / 'monthly.sqlite3' + p = invoke(base_cmd(stub, warmup5, ledger, monthly), success=False) + assert 'monthly' in p.stderr + assert not ledger_bound(ledger) + + # Explicit CLI contradiction never binds. + ledger = root / 'cli.sqlite3' + p = invoke(base_cmd(stub, warmup5, ledger, cfg, extra=['--input-tf', '1']), success=False) + assert 'contradicts' in p.stderr + assert not ledger_bound(ledger) + + # Omitted CLI clock values take the file (default 1m is not a contradiction). + ledger = root / 'omit.sqlite3' + result = invoke(base_cmd(stub, warmup5, ledger, cfg)) + assert ledger_bound(ledger) + first_identity = identity_of(ledger) + assert result['inputs_committed'] == 0 + + # Replay empty ledger with identical identity. + again = invoke(base_cmd(stub, warmup5, ledger, cfg)) + assert again['inputs_committed'] == 0 + assert identity_of(ledger) == first_identity + + # RunIdentity / spec / library / warmup / timezone identity changes, empty ledger. + ledger2 = root / 'run2.sqlite3' + cfg2 = root / 'native2.json' + native_config(cfg2, run_number=2) + invoke(base_cmd(stub, warmup5, ledger2, cfg2)) + assert identity_of(ledger2) != first_identity + p = invoke(base_cmd(stub, warmup5, ledger, cfg2), success=False) + assert 'identity' in p.stderr + assert identity_of(ledger) == first_identity + + cfg_tz = root / 'tz.json' + native_config(cfg_tz, timezone='UTC+5') + p = invoke(base_cmd(stub, warmup5, ledger, cfg_tz), success=False) + assert 'identity' in p.stderr + + cfg_lib = root / 'same.json' + native_config(cfg_lib) + p = invoke(base_cmd(no_export, warmup5, ledger, cfg_lib), success=False) + # Fails at missing export or identity; must not deliver and must keep original identity. + assert identity_of(ledger) == first_identity + + other_warmup = root / 'w5b.csv' + warmup_csv(other_warmup, [0, 300000, 600000, 900000, 1200000]) + p = invoke(base_cmd(stub, other_warmup, ledger, cfg), success=False) + assert 'identity' in p.stderr + assert identity_of(ledger) == first_identity + + # Query-absent old ABI4 library stays legacy (no native identity schema). + legacy_ledger = root / 'legacy.sqlite3' + invoke(base_cmd(absent, warmup1m, legacy_ledger)) + assert ledger_bound(legacy_ledger) + ident = identity_of(legacy_ledger) + assert len(ident) == 64 + # Changing a legacy input changes identity; native-config is refused. + p = invoke(base_cmd(absent, warmup1m, legacy_ledger, extra=['--input', 'changed=1']), + success=False) + assert 'identity' in p.stderr + p = invoke(base_cmd(legacy_library, warmup1m, root / 'legacy-real-nativecfg.sqlite3', cfg), + success=False) + assert 'native-config requires NativeMarketV1' in p.stderr + assert not ledger_bound(root / 'legacy-real-nativecfg.sqlite3') + + # Real native example: nonempty physical actions, durable delivery failure, + # replay of committed state/action bytes, retry, then an idle replay. + received = [] + received_bytes = [] + receiver_status = 503 + + def ledger_rows(path): + with sqlite3.connect(path) as db: + return { + 'identity': identity_of(path), + 'inputs': db.execute('SELECT input_index,canonical_json,state_hash ' + 'FROM inputs ORDER BY input_index').fetchall(), + 'events': db.execute('SELECT ordinal,input_index,input_position,event_id,payload,' + 'attempts,acknowledged FROM events ORDER BY ordinal').fetchall(), + } + + class Receiver(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + def do_POST(self): + raw = self.rfile.read(int(self.headers['Content-Length'])) + received.append(json.loads(raw)) + received_bytes.append(raw.decode('utf-8')) + self.send_response(receiver_status) + self.send_header('Content-Length', '0') + self.end_headers() + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(('127.0.0.1', 0), Receiver) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + example_ledger = root / 'example.sqlite3' + example_warmup = root / 'native-example-warmup.csv' + warmup_csv(example_warmup, [0, 300000]) + example_feed = root / 'native-example.jsonl' + feed_rows = [{'type': 'bar', 'bar': { + 'ts_open': 600000 + i * 300000, 'o': 100, 'h': 102, 'l': 99, 'c': 101, 'v': 4, + }} for i in range(8)] + example_feed.write_text(''.join(json.dumps(row) + '\n' for row in feed_rows)) + example_cmd = [runner, 'run', '--strategy', native_example, '--warmup', str(example_warmup), + '--mode', 'bars', '--ledger', str(example_ledger), + '--webhook-url', f'http://127.0.0.1:{server.server_port}/webhook', + '--allow-insecure-http', '--feed', str(example_feed), + '--native-config', str(cfg), '--name', 'native-example'] + failed_delivery = invoke(example_cmd + ['--max-attempts', '1'], success=False) + assert 'webhook retry limit reached; queued event remains in ledger' in failed_delivery.stderr, failed_delivery.stderr + failed_rows = ledger_rows(example_ledger) + assert len(received) == 1, received + assert len(failed_rows['events']) == 1, failed_rows + assert failed_rows['events'][0][5:] == (1, 0), failed_rows + assert 0 < len(failed_rows['inputs']) < len(feed_rows), failed_rows + assert all(row[2].isdigit() and int(row[2]) != 0 for row in failed_rows['inputs']) + failed_payload = received_bytes[0] + assert failed_rows['events'][0][4] == failed_payload + + receiver_status = 200 + example = invoke(example_cmd) + assert example['inputs_committed'] == len(feed_rows), example + assert example['prefix_skipped'] == len(failed_rows['inputs']), example + committed_rows = ledger_rows(example_ledger) + assert len(committed_rows['events']) == 2, committed_rows + assert len(received) == 3, received + assert received_bytes[0] == received_bytes[1] == failed_payload + assert committed_rows['inputs'][:len(failed_rows['inputs'])] == failed_rows['inputs'] + assert committed_rows['events'][0][:5] == failed_rows['events'][0][:5] + assert committed_rows['events'][0][5:] == (2, 1), committed_rows + assert committed_rows['events'][1][5:] == (1, 1), committed_rows + assert [row[4] for row in committed_rows['events']] == received_bytes[1:] + assert len({row[3] for row in committed_rows['events']}) == 2 + actions = [json.loads(row[4]) for row in committed_rows['events']] + assert [(a['timestamp'], a['order']['action'], a['order']['contracts'], + a['order']['price'], a['order']['reduce_only']) for a in actions] == [ + (600000, 'buy', 1, 100, False), + (2400000, 'sell', 1, 100, True), + ], actions + assert actions[0]['order']['entry_incarnation'] == actions[1]['order']['entry_incarnation'] == 1 + assert all(row[2].isdigit() and int(row[2]) != 0 for row in committed_rows['inputs']) + + prior = list(received_bytes) + replay = invoke(example_cmd) + assert replay['inputs_processed'] == 0 + assert replay['prefix_skipped'] == len(feed_rows) + assert received_bytes == prior + assert ledger_rows(example_ledger) == committed_rows + + evidence_dir = os.environ.get('PINEFORGE_NATIVE_RUNNER_EVIDENCE') + if evidence_dir: + evidence = Path(evidence_dir) + evidence.mkdir(parents=True, exist_ok=True) + receipt = { + 'schemaVersion': 'pineforge-native-runner-replay-evidence/v1', + 'sourceSha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'runnerSha256': hashlib.sha256(Path(runner).read_bytes()).hexdigest(), + 'librarySha256': hashlib.sha256(Path(native_example).read_bytes()).hexdigest(), + 'config': json.loads(cfg.read_text()), + 'warmupCsv': example_warmup.read_text(), 'inputs': feed_rows, + 'failedDeliveryRows': failed_rows, 'committedRows': committed_rows, + 'receivedPayloadBytes': received_bytes, + 'recoveredSummary': example, 'idleReplaySummary': replay, + } + (evidence / 'native-runner-replay.json').write_text(json.dumps(receipt, indent=2) + '\n') + with sqlite3.connect(example_ledger) as src, sqlite3.connect(evidence / 'native-runner.sqlite3') as dest: + src.backup(dest) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + +print('native runner startup/identity CLI contract passed') diff --git a/tests/native_proof_artifact.hpp b/tests/native_proof_artifact.hpp new file mode 100644 index 000000000..bc7540084 --- /dev/null +++ b/tests/native_proof_artifact.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace native_proof { + +inline std::string hex64(uint64_t value) { + std::ostringstream out; + out << std::hex << std::setw(16) << std::setfill('0') << value; + return out.str(); +} + +struct Scenario { + std::string id; + std::string status = "passed"; + std::string native_configuration; + std::string run_identity; + std::string calendar; + std::string logical_inputs; + std::string lifecycle_events; + std::string physical_effects; + std::string observations; + std::string comparisons; +}; + +inline bool write_if_requested(const char* test_name, + const std::vector& scenarios, + const std::string& fixture_name, + const std::string& fixture_sha256) { + const char* dir = std::getenv("PINEFORGE_NATIVE_PROOF_OUTPUT"); + if (!dir || !*dir) return true; + std::ostringstream json; + json << "{\n \"schemaVersion\": \"pineforge-native-scenario-artifact/v1\",\n" + << " \"testName\": \"" << test_name << "\",\n" + << " \"scenarios\": [\n"; + for (std::size_t i = 0; i < scenarios.size(); ++i) { + const auto& s = scenarios[i]; + json << " {\n" + << " \"scenarioId\": \"" << s.id << "\",\n" + << " \"status\": \"" << s.status << "\",\n" + << " \"assertionsEnabled\": true,\n" + << " \"fixture\": { \"" << fixture_name << "\": \"" << fixture_sha256 << "\" },\n" + << " \"nativeConfiguration\": " << (s.native_configuration.empty() ? "{}" : s.native_configuration) << ",\n" + << " \"runIdentity\": " << (s.run_identity.empty() ? "null" : s.run_identity) << ",\n" + << " \"calendar\": " << (s.calendar.empty() ? "{}" : s.calendar) << ",\n" + << " \"logicalInputs\": " << (s.logical_inputs.empty() ? "[]" : s.logical_inputs) << ",\n" + << " \"lifecycleEvents\": " << (s.lifecycle_events.empty() ? "[]" : s.lifecycle_events) << ",\n" + << " \"physicalEffects\": " << (s.physical_effects.empty() ? "[]" : s.physical_effects) << ",\n" + << " \"observations\": " << (s.observations.empty() ? "{}" : s.observations) << ",\n" + << " \"comparisons\": " << (s.comparisons.empty() ? "[]" : s.comparisons) << "\n" + << " }" << (i + 1 == scenarios.size() ? "\n" : ",\n"); + } + json << " ]\n}\n"; + std::string path = std::string(dir) + "/" + test_name + ".json"; + std::ofstream out(path.c_str(), std::ios::binary); + if (!out) return false; + out << json.str(); + return static_cast(out); +} + +} // namespace native_proof diff --git a/tests/test_live_contract_plugins.cpp b/tests/test_live_contract_plugins.cpp new file mode 100644 index 000000000..33d8dd7eb --- /dev/null +++ b/tests/test_live_contract_plugins.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// ABI4/stream-v1 test libraries for runner contract selection. Built as +// MODULE targets with exactly one PF_LIVE_CONTRACT_* definition and without +// linking libpineforge, so query-symbol absence is real. +#include + +namespace { +int handle = 1; +} + +extern "C" { + +PF_API int pf_abi_version(void) { return PF_ABI_VERSION; } +PF_API int strategy_stream_api_version(void) { return 1; } +PF_API pf_strategy_t strategy_create(const char*) { return &handle; } +PF_API void strategy_free(pf_strategy_t) {} +PF_API const char* strategy_get_last_error(pf_strategy_t) { return ""; } +PF_API int strategy_stream_begin(pf_strategy_t, const pf_bar_t*, int, const char*, const char*) { + return 0; +} +PF_API int strategy_stream_push_tick(pf_strategy_t, const pf_trade_tick_t*) { return 0; } +PF_API int strategy_stream_push_bar(pf_strategy_t, const pf_bar_t*) { return 0; } +PF_API int strategy_stream_advance_time(pf_strategy_t, int64_t) { return 0; } +PF_API int strategy_stream_order_actions_len(pf_strategy_t) { return 0; } +PF_API int strategy_stream_order_action_get(pf_strategy_t, int, pf_stream_order_action_t*) { + return -1; +} +PF_API void strategy_stream_order_actions_clear(pf_strategy_t) {} +PF_API uint64_t strategy_stream_state_hash(pf_strategy_t) { return 1; } + +#if defined(PF_LIVE_CONTRACT_UNKNOWN) +PF_API int strategy_execution_contract(pf_strategy_t) { return 99; } +#endif + +#if defined(PF_LIVE_CONTRACT_NATIVE_NO_EXPORT) +PF_API int strategy_execution_contract(pf_strategy_t) { return 2; } +#endif + +#if defined(PF_LIVE_CONTRACT_NATIVE_STUB) +PF_API int strategy_execution_contract(pf_strategy_t) { return 2; } +PF_API int strategy_configure_native_v1(pf_strategy_t, const pf_native_run_spec_v1* spec) { + if (!spec || spec->struct_size != sizeof(pf_native_run_spec_v1)) + return -1; + return 0; +} +#endif + +#if defined(PF_LIVE_CONTRACT_ABSENT) +PF_API void strategy_set_input(pf_strategy_t, const char*, const char*) {} +PF_API void strategy_set_override(pf_strategy_t, const char*, const char*) {} +PF_API void strategy_set_syminfo_timezone(pf_strategy_t, const char*) {} +PF_API void strategy_set_chart_timezone(pf_strategy_t, const char*) {} +PF_API void strategy_set_syminfo_session(pf_strategy_t, const char*) {} +PF_API int strategy_set_syminfo_string(pf_strategy_t, const char*, const char*) { return 0; } +PF_API void strategy_set_syminfo_mintick(pf_strategy_t, double) {} +PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t, double) {} +PF_API void strategy_set_syminfo_metadata(pf_strategy_t, const char*, double) {} +#endif + +} // extern "C" diff --git a/tests/test_native_calendar.cpp b/tests/test_native_calendar.cpp new file mode 100644 index 000000000..10426a87b --- /dev/null +++ b/tests/test_native_calendar.cpp @@ -0,0 +1,1328 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace pineforge::native_calendar; + +static int g_pass = 0; +static int g_fail = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++g_fail; \ + } else { \ + ++g_pass; \ + } \ + } while (0) + +#define CHECK_EQ(actual, expected) \ + do { \ + const auto _a = (actual); \ + const auto _e = (expected); \ + if (_a != _e) { \ + std::printf(" FAIL %s:%d %s == %s (got %lld, want %lld)\n", \ + __FILE__, __LINE__, #actual, #expected, \ + static_cast(_a), static_cast(_e)); \ + ++g_fail; \ + } else { \ + ++g_pass; \ + } \ + } while (0) + +// Independent America/New_York zoneinfo epochs (NY-DST-ORACLE.json and the +// same Python zoneinfo source). Not taken from native_calendar or from +// calendar_day_open_local_ms. +constexpr int64_t kNyMar8Midnight = 1741410000000; // 2025-03-08 00:00 EST 24h control +constexpr int64_t kNyMar9Midnight = 1741496400000; // 2025-03-09 00:00 EST 23h +constexpr int64_t kNyMar10Midnight = 1741579200000; // 2025-03-10 00:00 EDT +constexpr int64_t kNyNov1Midnight = 1761969600000; // 2025-11-01 00:00 EDT 24h control +constexpr int64_t kNyNov2Midnight = 1762056000000; // 2025-11-02 00:00 EDT 25h +constexpr int64_t kNyNov3Midnight = 1762146000000; // 2025-11-03 00:00 EST +constexpr int64_t kNyMar8_1700 = 1741471200000; // 2025-03-08 17:00 EST +constexpr int64_t kNyMar9_1700 = 1741554000000; // 2025-03-09 17:00 EDT +constexpr int64_t kNyMar8_1800 = 1741474800000; // 2025-03-08 18:00 EST +constexpr int64_t kNyMar8_2200 = 1741489200000; // 2025-03-08 22:00 EST +constexpr int64_t kNyMar9_0300 = 1741503600000; // 2025-03-09 03:00 EDT (first >= 02:00) +constexpr int64_t kNyMar9_0030 = 1741498200000; // 2025-03-09 00:30 EST +constexpr int64_t kNyMar9_1200 = 1741536000000; // 2025-03-09 12:00 EDT +constexpr int64_t kNyMar9_0159 = 1741503540000; // 2025-03-09 01:59 EST +constexpr int64_t kNyNov2_0030 = 1762057800000; // 2025-11-02 00:30 EDT +constexpr int64_t kNyNov2_1200 = 1762102800000; // 2025-11-02 12:00 EST +constexpr int64_t kNyNov2_0100Early = 1762059600000; // 2025-11-02 01:00 EDT fold=0 +constexpr int64_t kNyNov2_0100Late = 1762063200000; // 2025-11-02 01:00 EST fold=1 +constexpr int64_t kNyNov2_0130Early = 1762061400000; // 2025-11-02 01:30 EDT +constexpr int64_t kNyNov2_0200 = 1762066800000; // 2025-11-02 02:00 EST +constexpr int64_t kNyMar7_0930 = 1741357800000; // Fri 2025-03-07 09:30 EST +constexpr int64_t kNyMar7_1600 = 1741381200000; // Fri 2025-03-07 16:00 EST +constexpr int64_t kNyMar10_0930 = 1741613400000; // Mon 2025-03-10 09:30 EDT +constexpr int64_t kNyJun9_0930 = 1749475800000; +constexpr int64_t kNyJun9_1000 = 1749477600000; +constexpr int64_t kNyJun10_0930 = 1749562200000; +constexpr int64_t kNyJun10_1000 = 1749564000000; +constexpr int64_t kNyJun10_1100 = 1749567600000; +constexpr int64_t kNyJun10_1130 = 1749569400000; +constexpr int64_t kNyJun10_1200 = 1749571200000; +constexpr int64_t kNyJun10_1300 = 1749574800000; +constexpr int64_t kNyJun10_1330 = 1749576600000; +constexpr int64_t kNyJun10_1600 = 1749585600000; +constexpr int64_t kNyJun13_0930 = 1749821400000; +constexpr int64_t kNyJun13_1600 = 1749844800000; +constexpr int64_t kNyJun16_0930 = 1750080600000; +constexpr int64_t kNyJun7_1000 = 1749304800000; // Saturday +constexpr int64_t kNyJun8_1800 = 1749420000000; +constexpr int64_t kNyJun9_1700 = 1749502800000; +constexpr int64_t kNyJun9_1800 = 1749506400000; +constexpr int64_t kNyJan1 = 1735707600000; +constexpr int64_t kNyJan2 = 1735794000000; +constexpr int64_t kNyJan3 = 1735880400000; +constexpr int64_t kNyJan6 = 1736139600000; +constexpr int64_t kNyJan13 = 1736744400000; +constexpr int64_t kNyJan20 = 1737349200000; +constexpr int64_t kNyJan13_0930 = 1736778600000; +constexpr int64_t kNyJan20_0930 = 1737383400000; +constexpr int64_t kNyFeb15 = 1739595600000; +constexpr int64_t kNyMar15 = 1742011200000; +constexpr int64_t kNyApr1 = 1743480000000; +constexpr int64_t kNyApr15 = 1744689600000; +constexpr int64_t kNy1969Dec30 = -154800000; +constexpr int64_t kNy1969Dec31 = -68400000; + +// Independent UTC civil epochs (calendar-codex-audit/independent-oracle.json +// and the same datetime(timezone.utc) source). +constexpr int64_t kUtcJun9_1300 = 1749474000000; +constexpr int64_t kUtcJun9_1800 = 1749492000000; +constexpr int64_t kUtcJun10_095959 = 1749549599000; +constexpr int64_t kUtcJun10_1000 = 1749549600000; +constexpr int64_t kUtcJun10_1100 = 1749553200000; +constexpr int64_t kUtcJun10_1130 = 1749555000000; +constexpr int64_t kUtcJun10_1230 = 1749558600000; +constexpr int64_t kUtcJun10_1300 = 1749560400000; +constexpr int64_t kUtcJun10_1700 = 1749574800000; +constexpr int64_t kUtcJun7_1800 = 1749319200000; +constexpr int64_t kNyJun8_1300 = 1749402000000; // Sunday 13:00 EDT +constexpr int64_t kNyJun8_1330 = 1749403800000; // Sunday 13:30 EDT +constexpr int64_t kNyJun9_1130 = 1749483000000; // Monday 11:30 EDT +constexpr int64_t kNyJun7_1330 = 1749317400000; // Saturday 13:30 EDT +constexpr int64_t kUtcJun9_0900 = 1749459600000; +constexpr int64_t kUtcJun9_1430 = 1749479400000; +constexpr int64_t kUtcJun9_1530 = 1749483000000; +constexpr int64_t kUtcJun9_1600 = 1749484800000; +constexpr int64_t kUtcJun10_0900 = 1749546000000; +constexpr int64_t kNyMar9_0930 = 1741527000000; +constexpr int64_t kNyMar9_1600 = 1741550400000; +constexpr int64_t kNyMar10_0245 = 1741589100000; // Mon 02:45 EDT, after DST +constexpr int64_t kUtcJun8_1200 = 1749384000000; // Sunday 2025-06-08 12:00 +constexpr int64_t kUtcJun8_0930 = 1749375000000; +constexpr int64_t kUtcJun9_0930 = 1749461400000; +constexpr int64_t kUtcJun7_1200 = 1749297600000; // Saturday 2025-06-07 12:00 +constexpr int64_t kUtcJun7_0930 = 1749288600000; +constexpr int64_t kUtcDec31_2023_1200 = 1704024000000; // Sunday +constexpr int64_t kUtcDec31_2023_0930 = 1704015000000; +constexpr int64_t kUtcJan1_2024_0930 = 1704101400000; +constexpr int64_t kUtcDec25_2023_0930 = 1703496600000; +constexpr int64_t kNyMar9_0230_resolved = 1741503600000; // first representable ≥ 02:30 +constexpr int64_t kNyMar10_0230 = 1741588200000; +constexpr int64_t kUtcMar8_0000 = 1741392000000; // 2025-03-08 00:00 UTC +constexpr int64_t kUtcJun10_0000 = 1749513600000; +constexpr int64_t kUtcJun10_0000_plus0530 = 1749493800000; // 2025-06-10 00:00 UTC+05:30 +constexpr int64_t kUtcJun10_0000_gmt_minus4 = 1749528000000; // 2025-06-10 00:00 GMT-4 +constexpr int64_t kUtcJun10_0000_taipei = 1749484800000; // 2025-06-10 00:00 UTC+8 +constexpr int64_t kUtcJun10_0000_japan = 1749481200000; // 2025-06-10 00:00 JST (UTC+9) +constexpr int64_t kCstMar8Midnight = 1741413600000; // 2025-03-08 00:00 CST (UTC-6) + +static Timeframe must_tf(const char* s) { + auto tf = parse_timeframe(s); + CHECK(tf.has_value()); + return tf ? *tf : Timeframe{}; +} + +static SessionCalendar must_cal(const char* session, const char* tz) { + auto cal = parse_session(session, tz); + CHECK(cal.has_value()); + return cal ? *cal : SessionCalendar{}; +} + +static NativeInterval must_iv(const SessionCalendar& cal, const Timeframe& tf, int64_t ms) { + auto iv = interval_containing(cal, tf, ms); + CHECK(iv.has_value()); + return iv ? *iv : NativeInterval{}; +} + +static NativeInterval must_iv2(const SessionCalendar& cal, + const Timeframe& script, + const Timeframe& input, + int64_t ms) { + auto iv = interval_containing(cal, script, input, ms); + CHECK(iv.has_value()); + return iv ? *iv : NativeInterval{}; +} + +static void test_timeframe_forms() { + std::printf("test_timeframe_forms\n"); + const struct { + const char* text; + TimeframeUnit unit; + int count; + } ok[] = { + {"15S", TimeframeUnit::Second, 15}, + {"1", TimeframeUnit::Minute, 1}, + {"5", TimeframeUnit::Minute, 5}, + {"60", TimeframeUnit::Minute, 60}, + {"240", TimeframeUnit::Minute, 240}, + {"D", TimeframeUnit::Day, 1}, + {"1D", TimeframeUnit::Day, 1}, + {"2D", TimeframeUnit::Day, 2}, + {"W", TimeframeUnit::Week, 1}, + {"1W", TimeframeUnit::Week, 1}, + {"2W", TimeframeUnit::Week, 2}, + {"M", TimeframeUnit::Month, 1}, + {"1M", TimeframeUnit::Month, 1}, + {"3M", TimeframeUnit::Month, 3}, + }; + for (const auto& c : ok) { + auto tf = parse_timeframe(c.text); + CHECK(tf.has_value()); + if (!tf) continue; + CHECK(tf->unit() == c.unit); + CHECK_EQ(tf->count(), c.count); + CHECK(tf->literal() == c.text); + CHECK(tf->valid()); + } + // Equal-literal identity: 1D is not rewritten to D. + CHECK(must_tf("1D").literal() == "1D"); + CHECK(must_tf("D").literal() == "D"); + CHECK(!(must_tf("1D") == must_tf("D"))); + + const char* bad[] = {"", " ", " 5", "5 ", "01", "01D", "0", "0D", "S", "1H", + "4H", "1d", "2X", "H", "7m", "99999999999999999999", + "9223372036854775807", "9223372036854775808", + "92233720368547758080", "2147483648"}; + for (const char* s : bad) CHECK(!parse_timeframe(s)); + CHECK(parse_timeframe("2147483647").has_value()); +} + +static void test_pairings() { + std::printf("test_pairings\n"); + auto p = [](const char* a, const char* b) { + return compatibility(must_tf(a), must_tf(b)).pairing; + }; + auto f = [](const char* a, const char* b) { + return compatibility(must_tf(a), must_tf(b)).group_factor; + }; + CHECK(p("D", "D") == TimeframePairing::Passthrough); + CHECK(p("2D", "2D") == TimeframePairing::Passthrough); + CHECK(p("W", "W") == TimeframePairing::Passthrough); + CHECK(p("2W", "2W") == TimeframePairing::Passthrough); + CHECK(p("M", "M") == TimeframePairing::Passthrough); + CHECK(p("3M", "3M") == TimeframePairing::Passthrough); + CHECK(p("1D", "D") == TimeframePairing::SameUnitMultiple); + CHECK_EQ(f("1D", "D"), 1); + CHECK(p("1", "5") == TimeframePairing::SameUnitMultiple); + CHECK_EQ(f("1", "5"), 5); + CHECK(p("D", "2D") == TimeframePairing::SameUnitMultiple); + CHECK(p("M", "3M") == TimeframePairing::SameUnitMultiple); + CHECK_EQ(f("M", "3M"), 3); + CHECK(p("15S", "1") == TimeframePairing::FixedDivisible); + CHECK_EQ(f("15S", "1"), 4); + CHECK(p("1", "D") == TimeframePairing::FixedToCalendar); + CHECK(p("60", "W") == TimeframePairing::FixedToCalendar); + CHECK(p("5", "M") == TimeframePairing::FixedToCalendar); + CHECK(p("D", "W") == TimeframePairing::CalendarToCalendar); + CHECK(p("D", "M") == TimeframePairing::CalendarToCalendar); + CHECK(p("W", "M") == TimeframePairing::CalendarToCalendar); + CHECK(p("2D", "W") == TimeframePairing::CalendarToCalendar); + CHECK(p("2D", "M") == TimeframePairing::CalendarToCalendar); + CHECK(p("2W", "3M") == TimeframePairing::CalendarToCalendar); + CHECK(p("5", "7") == TimeframePairing::IndivisibleFixed); + CHECK(p("2D", "3D") == TimeframePairing::IndivisibleFixed); + CHECK(p("60", "1") == TimeframePairing::ScriptFiner); + CHECK(p("W", "D") == TimeframePairing::ScriptFiner); + CHECK(p("M", "W") == TimeframePairing::ScriptFiner); + CHECK(p("3M", "M") == TimeframePairing::ScriptFiner); + // Baseline tf_ratio("2880","D")==-2 and tf_ratio("14D","W")==-2 (input + // seconds exceed target). 2D→W stays CalendarToCalendar (tf_ratio==3). + // 7D→W equal 604800s (tf_ratio==1); 8D→W is -2. 6D→W is tf_ratio==1 + // because 604800/518400 truncates; month targets stay calendar (tf_ratio + // ==-1) and are not refused with an invented month length. + CHECK(p("2880", "D") == TimeframePairing::ScriptFiner); + CHECK(p("14D", "W") == TimeframePairing::ScriptFiner); + CHECK(p("8D", "W") == TimeframePairing::ScriptFiner); + CHECK(p("7D", "W") == TimeframePairing::CalendarToCalendar); + CHECK(p("6D", "W") == TimeframePairing::CalendarToCalendar); + CHECK(p("1440", "D") == TimeframePairing::FixedToCalendar); + CHECK(p("14D", "M") == TimeframePairing::CalendarToCalendar); + CHECK(p("2880", "M") == TimeframePairing::FixedToCalendar); + CHECK_EQ(compatibility(must_tf("1S"), must_tf("2147483647")).group_factor, + 128849018820LL); + + CHECK(stream_compatibility(must_tf("M"), must_tf("M")).pairing + == TimeframePairing::StreamMonthlyInputRefused); + CHECK(stream_compatibility(must_tf("3M"), must_tf("3M")).pairing + == TimeframePairing::StreamMonthlyInputRefused); + CHECK(stream_compatibility(must_tf("D"), must_tf("W")).pairing + == TimeframePairing::CalendarToCalendar); + CHECK(stream_compatibility(must_tf("2D"), must_tf("2D")).pairing + == TimeframePairing::Passthrough); + // Legacy quirk (not native): tf_to_seconds("1H")==60; tf_ratio monthly -1 + // collapsing equal M/3M batch. Native rejects "1H" and keeps M/M passthrough. +} + +static void test_session_parse() { + std::printf("test_session_parse\n"); + auto a = must_cal("24x7", "America/New_York"); + CHECK(a.all_day()); + CHECK_EQ(a.origin_minutes(), 0); + auto b = must_cal("", "UTC"); + CHECK(b.all_day()); + auto rth = must_cal("0930-1600", "America/New_York"); + CHECK_EQ(rth.origin_minutes(), 9 * 60 + 30); + CHECK_EQ(static_cast(rth.windows().size()), 1); + CHECK_EQ(rth.windows()[0].end_minutes(), 16 * 60); + CHECK(!rth.windows()[0].wraps()); + auto multi = must_cal("0930-1130,1300-1600", "America/New_York"); + CHECK_EQ(multi.origin_minutes(), 9 * 60 + 30); + CHECK_EQ(static_cast(multi.windows().size()), 2); + CHECK_EQ(multi.windows()[0].end_minutes(), 11 * 60 + 30); + CHECK_EQ(multi.windows()[1].end_minutes(), 16 * 60); + auto ovn = must_cal("1800-1700", "America/New_York"); + CHECK_EQ(ovn.origin_minutes(), 18 * 60); + CHECK(ovn.windows()[0].wraps()); + auto fx = must_cal("1700-1700", "America/New_York"); + CHECK(fx.windows()[0].full_day()); + CHECK_EQ(fx.origin_minutes(), 17 * 60); + auto allday2400 = must_cal("0000-2400", "UTC"); + CHECK_EQ(allday2400.windows()[0].end_minutes(), 1440); + auto masked = must_cal("0930-1600:23456", "America/New_York"); + CHECK((masked.day_mask() & (1u << 1)) == 0); // Sunday off + CHECK((masked.day_mask() & (1u << 2)) != 0); // Monday on + auto spaced = must_cal("0930-1130, 1300-1600", "America/New_York"); + CHECK_EQ(static_cast(spaced.windows().size()), 2); + CHECK(!parse_session("0930", "UTC")); + CHECK(!parse_session("0930-1600:8", "UTC")); + CHECK(!parse_session("1H-2H", "UTC")); +} + +static void test_multi_window_last_close() { + std::printf("test_multi_window_last_close\n"); + auto cal = must_cal("0930-1130,1300-1600", "America/New_York"); + auto d = must_tf("D"); + CHECK(in_session(cal, kNyJun10_1000)); + CHECK(!in_session(cal, kNyJun10_1200)); + CHECK(in_session(cal, kNyJun10_1330)); + auto iv = must_iv(cal, d, kNyJun10_1000); + CHECK_EQ(iv.open_ms, kNyJun10_0930); + CHECK_EQ(iv.last_traded_close_ms, kNyJun10_1600); + CHECK(iv.last_traded_close_ms != kNyJun10_1130); +} + +static void test_overnight_origin_1800() { + std::printf("test_overnight_origin_1800\n"); + auto cal = must_cal("1800-1700", "America/New_York"); + auto d = must_tf("D"); + CHECK(!in_session(cal, kNyJun9_1700)); + CHECK(in_session(cal, kNyJun8_1800)); + auto iv = must_iv(cal, d, kNyJun8_1800); + CHECK_EQ(iv.open_ms, kNyJun8_1800); + CHECK_EQ(iv.last_traded_close_ms, kNyJun9_1700); + CHECK_EQ(iv.next_period_open_ms, kNyJun9_1800); + CHECK(iv.open_ms != kNyJun9_1700); + // Day mask applies to the trading date (Monday), not Sunday's civil weekday. + auto masked = must_cal("1800-1700:23456", "America/New_York"); + CHECK(in_session(masked, kNyJun8_1800)); + auto miv = must_iv(masked, d, kNyJun8_1800); + CHECK_EQ(miv.open_ms, kNyJun8_1800); + auto week = must_iv(cal, must_tf("W"), kNyJun10_1000); + CHECK_EQ(week.open_ms, kNyJun8_1800); + // Pine 17:00 day-stamp on 1800-1700 is a legacy/source label, not native origin. +} + +static void test_full_day_and_2359() { + std::printf("test_full_day_and_2359\n"); + auto wrap0 = must_cal("0000-0000", "America/New_York"); + auto d = must_tf("D"); + auto iv = must_iv(wrap0, d, kNyJun10_0930); + CHECK_EQ(iv.open_ms, 1749528000000); // 2025-06-10 00:00 EDT (zoneinfo) + CHECK_EQ(iv.last_traded_close_ms, 1749614400000); // 2025-06-11 00:00 EDT + auto h2359 = must_cal("0000-2359", "America/New_York"); + auto iv2 = must_iv(h2359, d, kNyJun10_0930); + CHECK_EQ(iv2.open_ms, 1749528000000); + CHECK_EQ(iv2.last_traded_close_ms, 1749614340000); // 2025-06-10 23:59 EDT + auto utc = must_cal("24x7", "UTC"); + auto u = must_iv(utc, d, 1749528000000); + CHECK_EQ(u.open_ms, 1749513600000); // 2025-06-10 00:00 UTC +} + +static void test_weekday_mask_and_gaps() { + std::printf("test_weekday_mask_and_gaps\n"); + auto cal = must_cal("0930-1600:23456", "America/New_York"); + auto d = must_tf("D"); + CHECK(in_session(cal, kNyJun9_1000)); + CHECK(!in_session(cal, kNyJun7_1000)); + auto fri = must_iv(cal, d, kNyJun13_0930); + CHECK_EQ(fri.last_traded_close_ms, kNyJun13_1600); + CHECK_EQ(fri.next_input_open_ms, kNyJun16_0930); + CHECK(fri.next_input_open_ms != fri.last_traded_close_ms); +} + +static void test_rth_friday_vs_monday() { + std::printf("test_rth_friday_vs_monday\n"); + auto cal = must_cal("0930-1600:23456", "America/New_York"); + auto w = must_tf("W"); + auto d = must_tf("D"); + auto week = must_iv2(cal, w, d, kNyJun10_1000); + CHECK_EQ(week.open_ms, kNyJun9_0930); + CHECK_EQ(week.last_traded_close_ms, kNyJun13_1600); + CHECK_EQ(week.next_period_open_ms, kNyJun16_0930); + CHECK_EQ(week.next_input_open_ms, kNyJun16_0930); + CHECK(week.last_traded_close_ms != week.next_period_open_ms); + + auto fri_d = must_iv2(cal, d, must_tf("1"), kNyJun13_1600 - 60000); + CHECK_EQ(fri_d.last_traded_close_ms, kNyJun13_1600); + CHECK_EQ(fri_d.next_input_open_ms, kNyJun16_0930); + + // Same split across the 2025 spring DST weekend (Fri EST close, Mon EDT open). + auto dst_week = must_iv2(cal, w, d, kNyMar7_1600 - 60000); + CHECK_EQ(must_iv(cal, d, kNyMar7_1600 - 60000).open_ms, kNyMar7_0930); + CHECK_EQ(dst_week.last_traded_close_ms, kNyMar7_1600); + CHECK_EQ(dst_week.next_period_open_ms, kNyMar10_0930); + CHECK_EQ(dst_week.next_input_open_ms, kNyMar10_0930); + CHECK(dst_week.last_traded_close_ms != dst_week.next_period_open_ms); +} + +static void test_dst_oracles() { + std::printf("test_dst_oracles\n"); + auto cal = must_cal("24x7", "America/New_York"); + auto d = must_tf("D"); + auto sat_s = must_iv(cal, d, kNyMar8Midnight); + CHECK_EQ(sat_s.open_ms, kNyMar8Midnight); + CHECK_EQ(sat_s.last_traded_close_ms, kNyMar9Midnight); + CHECK_EQ(sat_s.last_traded_close_ms - sat_s.open_ms, 86400000); + + auto sun_s = must_iv(cal, d, kNyMar9Midnight); + CHECK_EQ(sun_s.open_ms, kNyMar9Midnight); + CHECK_EQ(sun_s.last_traded_close_ms, kNyMar10Midnight); + CHECK_EQ(sun_s.last_traded_close_ms - sun_s.open_ms, 82800000); + + auto sat_f = must_iv(cal, d, kNyNov1Midnight); + CHECK_EQ(sat_f.open_ms, kNyNov1Midnight); + CHECK_EQ(sat_f.last_traded_close_ms, kNyNov2Midnight); + CHECK_EQ(sat_f.last_traded_close_ms - sat_f.open_ms, 86400000); + + auto sun_f = must_iv(cal, d, kNyNov2Midnight); + CHECK_EQ(sun_f.open_ms, kNyNov2Midnight); + CHECK_EQ(sun_f.last_traded_close_ms, kNyNov3Midnight); + CHECK_EQ(sun_f.last_traded_close_ms - sun_f.open_ms, 90000000); + + auto fx = must_cal("1700-1700", "America/New_York"); + auto c3 = must_iv(fx, d, kNyMar8_1700); + CHECK_EQ(c3.open_ms, kNyMar8_1700); + CHECK_EQ(c3.last_traded_close_ms, kNyMar9_1700); + CHECK_EQ(c3.last_traded_close_ms - c3.open_ms, 82800000); + + auto ovn = must_cal("1800-1700", "America/New_York"); + auto ov = must_iv(ovn, d, kNyMar8_1800); + CHECK_EQ(ov.open_ms, kNyMar8_1800); + CHECK_EQ(ov.last_traded_close_ms, kNyMar9_1700); +} + +static void test_overnight_spanning_gap() { + std::printf("test_overnight_spanning_gap\n"); + auto cal = must_cal("2200-0200", "America/New_York"); + auto d = must_tf("D"); + auto iv = must_iv(cal, d, kNyMar8_2200); + CHECK_EQ(iv.open_ms, kNyMar8_2200); + CHECK_EQ(iv.last_traded_close_ms, kNyMar9_0300); + CHECK(in_session(cal, kNyMar8_2200)); + CHECK(!in_session(cal, kNyMar9_0300)); +} + +static void test_fold_and_gap_resolve() { + std::printf("test_fold_and_gap_resolve\n"); + auto gap = resolve_civil("America/New_York", 2025, 3, 9, 2, 0, 0); + CHECK(gap.has_value()); + if (gap) { + CHECK(gap->kind == CivilKind::Gap); + CHECK_EQ(gap->epoch_ms, kNyMar9_0300); + } + auto fold = resolve_civil("America/New_York", 2025, 11, 2, 1, 0, 0); + CHECK(fold.has_value()); + if (fold) { + CHECK(fold->kind == CivilKind::Fold); + CHECK_EQ(fold->epoch_ms, kNyNov2_0100Early); + CHECK(fold->epoch_ms != kNyNov2_0100Late); + } + auto early = resolve_civil("America/New_York", 2025, 11, 2, 1, 30, 0); + CHECK(early.has_value()); + if (early) CHECK_EQ(early->epoch_ms, kNyNov2_0130Early); + + auto a = must_cal("0000-0100", "America/New_York"); + auto b = must_cal("0100-0200", "America/New_York"); + auto d = must_tf("D"); + auto ia = must_iv(a, d, kNyNov2Midnight); + auto ib = must_iv(b, d, kNyNov2_0100Early); + CHECK_EQ(ia.last_traded_close_ms, kNyNov2_0100Early); + CHECK_EQ(ib.open_ms, kNyNov2_0100Early); + CHECK_EQ(ia.last_traded_close_ms, ib.open_ms); + CHECK_EQ(ib.last_traded_close_ms, kNyNov2_0200); +} + +static void test_call_order_independence() { + std::printf("test_call_order_independence\n"); + auto cal = must_cal("24x7", "America/New_York"); + auto d = must_tf("D"); + // Witness instants: helper calendar_day_open_local_ms returns different + // midnights for 00:30 vs 12:00 on the same transition day. Native must not. + auto a = must_iv(cal, d, kNyMar9_0030); + auto b = must_iv(cal, d, kNyMar9_1200); + CHECK_EQ(a.open_ms, kNyMar9Midnight); + CHECK_EQ(b.open_ms, kNyMar9Midnight); + CHECK_EQ(a.open_ms, b.open_ms); + auto c = must_iv(cal, d, kNyNov2_1200); + auto e = must_iv(cal, d, kNyNov2_0030); + CHECK_EQ(c.open_ms, kNyNov2Midnight); + CHECK_EQ(e.open_ms, kNyNov2Midnight); + + auto r1 = resolve_civil("America/New_York", 2025, 3, 9, 0, 0, 0); + auto r2 = resolve_civil("America/New_York", 2025, 11, 2, 0, 0, 0); + auto r3 = resolve_civil("America/New_York", 2025, 3, 9, 0, 0, 0); + CHECK(r1 && r2 && r3); + if (r1 && r3) CHECK_EQ(r1->epoch_ms, r3->epoch_ms); + if (r1) CHECK_EQ(r1->epoch_ms, kNyMar9Midnight); + if (r2) CHECK_EQ(r2->epoch_ms, kNyNov2Midnight); +} + +static void test_consecutive_continuity() { + std::printf("test_consecutive_continuity\n"); + auto cal = must_cal("24x7", "America/New_York"); + auto d = must_tf("D"); + auto sat = must_iv(cal, d, kNyMar8Midnight); + auto sun = must_iv(cal, d, kNyMar9Midnight); + auto mon = must_iv(cal, d, kNyMar10Midnight); + CHECK_EQ(sat.last_traded_close_ms, sun.open_ms); + CHECK_EQ(sat.next_period_open_ms, sun.open_ms); + CHECK_EQ(sun.last_traded_close_ms, mon.open_ms); + CHECK_EQ(sun.next_period_open_ms, mon.open_ms); + CHECK(sat.open_ms < sun.open_ms); + CHECK(sun.open_ms < mon.open_ms); + + auto nsat = must_iv(cal, d, kNyNov1Midnight); + auto nsun = must_iv(cal, d, kNyNov2Midnight); + auto nmon = must_iv(cal, d, kNyNov3Midnight); + CHECK_EQ(nsat.last_traded_close_ms, nsun.open_ms); + CHECK_EQ(nsun.last_traded_close_ms, nmon.open_ms); +} + +static void test_fixed_1m_across_dst() { + std::printf("test_fixed_1m_across_dst\n"); + auto cal = must_cal("24x7", "America/New_York"); + auto m1 = must_tf("1"); + auto iv = must_iv(cal, m1, kNyMar9_0159); + CHECK_EQ(iv.open_ms, kNyMar9_0159); + CHECK_EQ(iv.last_traded_close_ms, kNyMar9_0300); + CHECK_EQ(iv.last_traded_close_ms - iv.open_ms, 60000); +} + +static void test_stable_anchors() { + std::printf("test_stable_anchors\n"); + auto cal = must_cal("24x7", "America/New_York"); + auto two_d = must_tf("2D"); + auto two_w = must_tf("2W"); + auto three_m = must_tf("3M"); + // 2D: Jan 2 and Jan 3 share a pair; Jan 1 is the previous pair. + // Querying only the later day does not shift the open. + CHECK(period_key(cal, two_d, kNyJan2) == period_key(cal, two_d, kNyJan3)); + CHECK(period_key(cal, two_d, kNyJan1) != period_key(cal, two_d, kNyJan2)); + auto d2 = must_iv(cal, two_d, kNyJan2); + auto d3 = must_iv(cal, two_d, kNyJan3); + CHECK_EQ(d2.open_ms, d3.open_ms); + CHECK_EQ(d2.open_ms, kNyJan2); + // 2W: Jan 13 and Jan 20 share a pair; Jan 6 does not. + CHECK(period_key(cal, two_w, kNyJan13) == period_key(cal, two_w, kNyJan20)); + CHECK(period_key(cal, two_w, kNyJan6) != period_key(cal, two_w, kNyJan13)); + auto w13 = must_iv(cal, two_w, kNyJan13); + auto w20 = must_iv(cal, two_w, kNyJan20); + CHECK_EQ(w13.open_ms, w20.open_ms); + CHECK_EQ(w13.open_ms, kNyJan13); + // 3M: Jan/Feb/Mar share January; April starts the next quarter. + CHECK(period_key(cal, three_m, kNyJan1) == period_key(cal, three_m, kNyFeb15)); + CHECK(period_key(cal, three_m, kNyFeb15) == period_key(cal, three_m, kNyMar15)); + CHECK(period_key(cal, three_m, kNyApr15) != period_key(cal, three_m, kNyFeb15)); + auto q1 = must_iv(cal, three_m, kNyFeb15); + auto q1b = must_iv(cal, three_m, kNyMar15); + auto q2 = must_iv(cal, three_m, kNyApr15); + CHECK_EQ(q1.open_ms, q1b.open_ms); + CHECK_EQ(q1.open_ms, kNyJan1); + CHECK_EQ(q2.open_ms, kNyApr1); + + auto rth = must_cal("0930-1600:23456", "America/New_York"); + auto wr13 = must_iv(rth, two_w, kNyJan13_0930); + auto wr20 = must_iv(rth, two_w, kNyJan20_0930); + CHECK_EQ(wr13.open_ms, wr20.open_ms); + CHECK_EQ(wr13.open_ms, kNyJan13_0930); + + // Dates before the named origins keep floor-toward-inf grouping. + CHECK(period_key(cal, two_d, kNy1969Dec30) == period_key(cal, two_d, kNy1969Dec31)); + auto week_ord = session_week_ordinal(cal, kNy1969Dec31); + auto day_ord = session_day_ordinal(cal, kNy1969Dec31); + CHECK(week_ord.has_value() && *week_ord <= 0); + CHECK(day_ord.has_value() && *day_ord < 0); +} + +static void test_fixed_clip_and_lunch() { + std::printf("test_fixed_clip_and_lunch\n"); + auto cal = must_cal("0930-1130,1300-1600", "America/New_York"); + auto h1 = must_tf("60"); + auto morning = must_iv(cal, h1, kNyJun10_1100); + CHECK_EQ(morning.open_ms, kNyJun10_0930 + 3600000); // 10:30 + CHECK_EQ(morning.last_traded_close_ms, kNyJun10_1130); + CHECK_EQ(morning.next_input_open_ms, kNyJun10_1300); + CHECK(in_session(cal, morning.next_input_open_ms)); + CHECK(morning.next_input_open_ms != kNyJun10_0930 + 3 * 3600000); // not 12:30 + CHECK(!in_session(cal, kNyJun10_1200)); + auto after = must_iv(cal, h1, kNyJun10_1330); + CHECK(after.open_ms <= kNyJun10_1330); + CHECK(after.last_traded_close_ms > kNyJun10_1330); + auto last = must_iv(cal, h1, kNyJun10_1600 - 60000); + CHECK_EQ(last.last_traded_close_ms, kNyJun10_1600); +} + +static void test_codex_audit_regressions() { + std::printf("test_codex_audit_regressions\n"); + auto d = must_tf("D"); + + // P1-1 split overnight: second window is on the origin cycle, not the + // origin's civil date. Origin stays 18:00, not the earliest clock 00:00. + auto split = must_cal("1800-2300,0000-1700", "UTC"); + CHECK_EQ(split.origin_minutes(), 18 * 60); + CHECK(in_session(split, kUtcJun10_1000)); + auto split_iv = must_iv(split, d, kUtcJun10_1000); + CHECK_EQ(split_iv.open_ms, kUtcJun9_1800); + CHECK_EQ(split_iv.last_traded_close_ms, kUtcJun10_1700); + auto split_mask = must_cal("1800-2300,0000-1700:23456", "UTC"); + CHECK(in_session(split_mask, kUtcJun10_1000)); + CHECK(!in_session(split_mask, kUtcJun7_1800)); + + // P1-1 reversed declaration: origin is 13:00, morning window is the next + // cycle coverage, not dropped. + auto rev = must_cal("1300-1600,0930-1130", "UTC"); + CHECK_EQ(rev.origin_minutes(), 13 * 60); + CHECK(rev.origin_minutes() != 9 * 60 + 30); + CHECK(in_session(rev, kUtcJun10_1000)); + auto rev_iv = must_iv(rev, d, kUtcJun10_1000); + CHECK_EQ(rev_iv.open_ms, kUtcJun9_1300); + CHECK_EQ(rev_iv.last_traded_close_ms, kUtcJun10_1130); + + // P1-2: 1S at 09:59:59 before a 3h break still yields an interval; next + // input jumps to 13:00 rather than walking 8000 buckets. + auto sec = must_tf("1S"); + auto brk = must_cal("0930-1000,1300-1600", "UTC"); + auto before = interval_containing(brk, sec, kUtcJun10_095959); + CHECK(before.has_value()); + if (before) { + CHECK_EQ(before->open_ms, kUtcJun10_095959); + CHECK_EQ(before->last_traded_close_ms, kUtcJun10_1000); + CHECK_EQ(before->next_input_open_ms, kUtcJun10_1300); + CHECK(in_session(brk, before->next_input_open_ms)); + } + + // P1-3: next_input skips the closed lunch; 13:00 reopen is off the 60m + // origin grid (12:30). Nominal open of the 13:00 query may be 12:30. + auto lunch = must_cal("0930-1130,1300-1600", "UTC"); + auto h1 = must_tf("60"); + auto at11 = must_iv(lunch, h1, kUtcJun10_1100); + CHECK_EQ(at11.next_input_open_ms, kUtcJun10_1300); + CHECK(in_session(lunch, at11.next_input_open_ms)); + CHECK(at11.next_input_open_ms != kUtcJun10_1230); + CHECK(!in_session(lunch, kUtcJun10_1230)); + auto at13 = must_iv(lunch, h1, kUtcJun10_1300); + CHECK_EQ(at13.open_ms, kUtcJun10_1230); + CHECK_EQ(at13.eligible_open_ms, kUtcJun10_1300); + CHECK(in_session(lunch, at13.eligible_open_ms)); + CHECK(!in_session(lunch, at13.open_ms)); + + // Mask applies once to the cycle. Sunday 13:30 belongs to the cycle whose + // last coverage is Monday 11:30 (trading date Monday, :23456 allows). + auto rev_mask = must_cal("1300-1600,0930-1130:23456", "America/New_York"); + CHECK(in_session(rev_mask, kNyJun8_1330)); + CHECK(in_session(rev_mask, kNyJun9_1000)); + CHECK(!in_session(rev_mask, kNyJun7_1330)); + auto sun_iv = must_iv(rev_mask, d, kNyJun8_1330); + CHECK_EQ(sun_iv.open_ms, kNyJun8_1300); + CHECK_EQ(sun_iv.last_traded_close_ms, kNyJun9_1130); + auto mon_iv = must_iv(rev_mask, d, kNyJun9_1000); + CHECK_EQ(mon_iv.open_ms, kNyJun8_1300); + CHECK_EQ(mon_iv.last_traded_close_ms, kNyJun9_1130); + + // Overlap across the origin: 0900-1500 contributes both [13:00,15:00) on + // the origin date and [09:00,13:00) the next morning. Shift-one-day would + // drop the afternoon overlap. + auto ovl = must_cal("1300-1600,0900-1500", "UTC"); + CHECK_EQ(ovl.origin_minutes(), 13 * 60); + CHECK(in_session(ovl, kUtcJun9_1300)); + CHECK(in_session(ovl, kUtcJun9_1430)); + CHECK(in_session(ovl, kUtcJun9_1530)); + CHECK(!in_session(ovl, kUtcJun9_1600)); + CHECK(in_session(ovl, kUtcJun10_0900)); + CHECK(in_session(ovl, kUtcJun10_1000)); + auto ovl_iv = must_iv(ovl, d, kUtcJun9_1430); + CHECK_EQ(ovl_iv.open_ms, kUtcJun9_1300); + CHECK_EQ(ovl_iv.last_traded_close_ms, kUtcJun10_1300); + auto ovl_next = must_iv(ovl, d, kUtcJun10_1300); + CHECK_EQ(ovl_next.open_ms, kUtcJun10_1300); + + auto perm = must_cal("1300-1600,1100-1400,0900-1500", "UTC"); + CHECK_EQ(perm.origin_minutes(), 13 * 60); + CHECK(in_session(perm, kUtcJun9_1430) == in_session(ovl, kUtcJun9_1430)); + CHECK(in_session(perm, kUtcJun10_1000) == in_session(ovl, kUtcJun10_1000)); + CHECK_EQ(must_iv(perm, d, kUtcJun9_1430).last_traded_close_ms, + must_iv(ovl, d, kUtcJun9_1430).last_traded_close_ms); + + auto swapped = must_cal("0900-1500,1300-1600", "UTC"); + CHECK_EQ(swapped.origin_minutes(), 9 * 60); + auto swapped_iv = must_iv(swapped, d, kUtcJun9_1430); + CHECK_EQ(swapped_iv.open_ms, kUtcJun9_0900); + CHECK(swapped_iv.open_ms != kUtcJun9_1300); + + // DST gap collapsing both ends of Sunday 0200-0245 omits that instance + // only; 0930-1600 remains, and Monday 02:00-02:45 still covers the + // Sunday cycle's pre-origin morning. + auto gapw = must_cal("0930-1600,0200-0245", "America/New_York"); + CHECK(in_session(gapw, kNyMar9_1200)); + CHECK(!in_session(gapw, kNyMar9_0300)); + auto gap_iv = must_iv(gapw, d, kNyMar9_1200); + CHECK_EQ(gap_iv.open_ms, kNyMar9_0930); + CHECK_EQ(gap_iv.last_traded_close_ms, kNyMar10_0245); + CHECK(gap_iv.last_traded_close_ms != kNyMar9_1600); +} + +static void test_checked_public_values() { + std::printf("test_checked_public_values\n"); + Timeframe def_tf; + CHECK(!def_tf.valid()); + CHECK_EQ(def_tf.count(), 0); + auto d = must_tf("D"); + CHECK(compatibility(def_tf, d).pairing == TimeframePairing::Invalid); + CHECK(compatibility(d, def_tf).pairing == TimeframePairing::Invalid); + CHECK(stream_compatibility(def_tf, d).pairing == TimeframePairing::Invalid); + auto utc = must_cal("24x7", "UTC"); + CHECK(!period_key(utc, def_tf, 0).has_value()); + CHECK(!session_day_ordinal(SessionCalendar{}, 0).has_value()); + + auto moved_from = must_tf("D"); + auto moved_to = std::move(moved_from); + CHECK(moved_to.valid()); + CHECK(moved_to.literal() == "D"); + CHECK(!moved_from.valid()); + CHECK_EQ(moved_from.count(), 0); + CHECK(compatibility(moved_from, must_tf("2D")).pairing == TimeframePairing::Invalid); + CHECK(!period_key(utc, moved_from, 0).has_value()); + CHECK(compatibility(moved_to, must_tf("D")).pairing == TimeframePairing::Passthrough); + + SessionCalendar def_cal; + CHECK(!def_cal.valid()); + CHECK_EQ(def_cal.day_mask(), 0); + CHECK(!in_session(def_cal, 1749605400000LL)); + CHECK(!interval_containing(def_cal, must_tf("D"), 1749605400000LL).has_value()); + CHECK(!interval_containing(def_cal, must_tf("1000000D"), 1749549600000LL).has_value()); + + auto moved_cal_from = must_cal("0930-1600", "UTC"); + auto moved_cal_to = std::move(moved_cal_from); + CHECK(moved_cal_to.valid()); + CHECK(!moved_cal_from.valid()); + CHECK(!in_session(moved_cal_from, 1749605400000LL)); + CHECK(!interval_containing(moved_cal_from, must_tf("1000000D"), 1749549600000LL).has_value()); + CHECK(in_session(moved_cal_to, 1749553200000LL)); + + SessionWindow def_win; + CHECK(!def_win.valid()); + + auto large = must_tf("1000000D"); + CHECK(interval_containing(utc, large, 1749549600000LL).has_value()); + CHECK(period_key(utc, large, 1749549600000LL).has_value()); +} + +static void test_empty_cycle_identity() { + std::printf("test_empty_cycle_identity\n"); + auto rth = must_cal("0930-1600:23456", "UTC"); + auto d = must_tf("D"); + auto w = must_tf("W"); + auto mo = must_tf("M"); + + CHECK(!in_session(rth, kUtcJun8_1200)); + auto sun_d = must_iv(rth, d, kUtcJun8_1200); + CHECK_EQ(sun_d.open_ms, kUtcJun8_0930); + CHECK_EQ(sun_d.last_traded_close_ms, kUtcJun8_0930); + CHECK_EQ(sun_d.next_period_open_ms, kUtcJun9_0930); + CHECK(sun_d.open_ms <= kUtcJun8_1200 && kUtcJun8_1200 < sun_d.next_period_open_ms); + CHECK_EQ(sun_d.next_input_open_ms, kUtcJun9_0930); + CHECK(period_key(rth, d, kUtcJun8_1200).has_value()); + CHECK_EQ(*period_key(rth, d, kUtcJun8_1200), 20247); + CHECK_EQ(*period_key(rth, w, kUtcJun8_1200), 2892); + CHECK_EQ(*period_key(rth, mo, kUtcJun8_1200), 24305); + auto sun_w = must_iv(rth, w, kUtcJun8_1200); + CHECK(sun_w.open_ms <= kUtcJun8_1200 && kUtcJun8_1200 < sun_w.next_period_open_ms); + CHECK(sun_w.open_ms < kUtcJun9_0930); + + CHECK(!in_session(rth, kUtcJun7_1200)); + auto sat_d = must_iv(rth, d, kUtcJun7_1200); + CHECK_EQ(sat_d.open_ms, kUtcJun7_0930); + CHECK_EQ(sat_d.last_traded_close_ms, kUtcJun7_0930); + CHECK_EQ(sat_d.next_period_open_ms, kUtcJun8_0930); + CHECK(sat_d.open_ms <= kUtcJun7_1200 && kUtcJun7_1200 < sat_d.next_period_open_ms); + CHECK(sat_d.next_period_open_ms - sat_d.open_ms < 2 * 86400000); + CHECK_EQ(*period_key(rth, d, kUtcJun7_1200), 20246); + CHECK_EQ(*period_key(rth, w, kUtcJun7_1200), 2892); + + CHECK(!in_session(rth, kUtcDec31_2023_1200)); + auto yend_d = must_iv(rth, d, kUtcDec31_2023_1200); + CHECK_EQ(yend_d.open_ms, kUtcDec31_2023_0930); + CHECK_EQ(yend_d.next_period_open_ms, kUtcJan1_2024_0930); + CHECK(yend_d.open_ms <= kUtcDec31_2023_1200 && kUtcDec31_2023_1200 < yend_d.next_period_open_ms); + CHECK_EQ(*period_key(rth, d, kUtcDec31_2023_1200), 19722); + CHECK_EQ(*period_key(rth, w, kUtcDec31_2023_1200), 2817); + CHECK_EQ(*period_key(rth, mo, kUtcDec31_2023_1200), 24287); + auto yend_w = must_iv(rth, w, kUtcDec31_2023_1200); + CHECK_EQ(yend_w.open_ms, kUtcDec25_2023_0930); + CHECK(yend_w.open_ms <= kUtcDec31_2023_1200 && kUtcDec31_2023_1200 < yend_w.next_period_open_ms); + auto yend_m = must_iv(rth, mo, kUtcDec31_2023_1200); + CHECK(yend_m.open_ms <= kUtcDec31_2023_1200 && kUtcDec31_2023_1200 < yend_m.next_period_open_ms); + CHECK(yend_m.next_period_open_ms == kUtcJan1_2024_0930); + + auto gap = must_cal("0230-0245", "America/New_York"); + CHECK(!in_session(gap, kNyMar9_1200)); + auto gap_d = must_iv(gap, d, kNyMar9_1200); + CHECK_EQ(gap_d.open_ms, kNyMar9_0230_resolved); + CHECK_EQ(gap_d.last_traded_close_ms, kNyMar9_0230_resolved); + CHECK_EQ(gap_d.next_period_open_ms, kNyMar10_0230); + CHECK(gap_d.open_ms <= kNyMar9_1200 && kNyMar9_1200 < gap_d.next_period_open_ms); + CHECK(gap_d.open_ms != kNyMar10_0230); + CHECK_EQ(*period_key(gap, d, kNyMar9_1200), 20156); + CHECK_EQ(*period_key(gap, w, kNyMar9_1200), 2879); + CHECK_EQ(*period_key(gap, mo, kNyMar9_1200), 24302); +} + +static void test_timezone_acceptance() { + std::printf("test_timezone_acceptance\n"); + CHECK(timezone_accepted("")); + CHECK(timezone_accepted("UTC")); + CHECK(timezone_accepted("GMT")); + CHECK(timezone_accepted("Etc/UTC")); + CHECK(timezone_accepted("Etc/GMT")); + CHECK(timezone_accepted("America/New_York")); + CHECK(timezone_accepted("Asia/Taipei")); + CHECK(timezone_accepted("UTC+05:30")); + CHECK(timezone_accepted("GMT-4")); + CHECK(timezone_accepted("UTC+0")); + CHECK(timezone_accepted("EST5EDT,M3.2.0,M11.1.0")); + CHECK(timezone_accepted("CST6CDT,M3.2.0/2,M11.1.0/2")); + CHECK(timezone_accepted("GMT0BST,M3.5.0/1,M10.5.0")); + CHECK(timezone_accepted("<-05>5<-04>,M3.2.0,M11.1.0")); + CHECK(timezone_accepted("US/Eastern")); + CHECK(timezone_accepted("Japan")); + CHECK(timezone_accepted("GB")); + CHECK(timezone_accepted(":America/New_York")); + + CHECK(!timezone_accepted("No/Such_PineForge_Zone")); + CHECK(!timezone_accepted("NoSuch_PineForge_Zone")); + CHECK(!timezone_accepted("UTC+24:00")); + CHECK(!timezone_accepted("GMT+24:00")); + CHECK(!timezone_accepted("UTC+01:99")); + CHECK(!timezone_accepted("UTC+5:3")); + CHECK(!timezone_accepted("UTC+5:30:00")); + CHECK(!timezone_accepted(" ")); + CHECK(!timezone_accepted("\tUTC")); + CHECK(!timezone_accepted(std::string_view("UTC\0x", 5))); + CHECK(!timezone_accepted("UTC+99999999999999999999")); + CHECK(!timezone_accepted("UTC+")); + CHECK(!timezone_accepted("UTC++5")); + CHECK(!timezone_accepted("/usr/share/zoneinfo/UTC")); + CHECK(!timezone_accepted("../UTC")); + CHECK(!timezone_accepted("America/../Etc/UTC")); + CHECK(!timezone_accepted("zone.tab")); + CHECK(!timezone_accepted("iso3166.tab")); + CHECK(!timezone_accepted("leapseconds")); + CHECK(!timezone_accepted("+VERSION")); + CHECK(!timezone_accepted(":")); + + CHECK(!parse_session("", "No/Such_PineForge_Zone")); + CHECK(!parse_session("24x7", "UTC+24:00")); + CHECK(!parse_session("0930-1600", "UTC+01:99")); + CHECK(!parse_session("", " ")); + CHECK(parse_session("", "").has_value()); + CHECK(parse_session("24x7", "UTC+05:30").has_value()); + CHECK(parse_session("24x7", "EST5EDT,M3.2.0,M11.1.0").has_value()); + + auto utc_mid = resolve_civil("UTC", 2025, 6, 10, 0, 0, 0); + CHECK(utc_mid.has_value()); + CHECK_EQ(utc_mid->epoch_ms, kUtcJun10_0000); + auto empty_mid = resolve_civil("", 2025, 6, 10, 0, 0, 0); + CHECK(empty_mid.has_value()); + CHECK_EQ(empty_mid->epoch_ms, kUtcJun10_0000); + auto off = resolve_civil("UTC+05:30", 2025, 6, 10, 0, 0, 0); + CHECK(off.has_value()); + CHECK_EQ(off->epoch_ms, kUtcJun10_0000_plus0530); + auto gmt = resolve_civil("GMT-4", 2025, 6, 10, 0, 0, 0); + CHECK(gmt.has_value()); + CHECK_EQ(gmt->epoch_ms, kUtcJun10_0000_gmt_minus4); + auto taipei = resolve_civil("Asia/Taipei", 2025, 6, 10, 0, 0, 0); + CHECK(taipei.has_value()); + CHECK_EQ(taipei->epoch_ms, kUtcJun10_0000_taipei); + auto posix = resolve_civil("EST5EDT,M3.2.0,M11.1.0", 2025, 3, 8, 0, 0, 0); + CHECK(posix.has_value()); + CHECK_EQ(posix->epoch_ms, kNyMar8Midnight); + auto posix_time = resolve_civil("CST6CDT,M3.2.0/2,M11.1.0/2", 2025, 3, 8, 0, 0, 0); + CHECK(posix_time.has_value()); + CHECK_EQ(posix_time->epoch_ms, kCstMar8Midnight); + auto eastern = resolve_civil("US/Eastern", 2025, 3, 8, 0, 0, 0); + CHECK(eastern.has_value()); + CHECK_EQ(eastern->epoch_ms, kNyMar8Midnight); + auto japan = resolve_civil("Japan", 2025, 6, 10, 0, 0, 0); + CHECK(japan.has_value()); + CHECK_EQ(japan->epoch_ms, kUtcJun10_0000_japan); + auto quoted = resolve_civil("<-05>5<-04>,M3.2.0,M11.1.0", 2025, 3, 8, 0, 0, 0); + CHECK(quoted.has_value()); + CHECK_EQ(quoted->epoch_ms, kNyMar8Midnight); + + CHECK(!resolve_civil("No/Such_PineForge_Zone", 2025, 6, 10, 0, 0, 0)); + CHECK(!resolve_civil("UTC+24:00", 2025, 6, 10, 0, 0, 0)); + CHECK(!resolve_civil("UTC+01:99", 2025, 6, 10, 0, 0, 0)); + CHECK(!resolve_civil(" ", 2025, 6, 10, 0, 0, 0)); +} + +struct TzdirGuard { + bool had = false; + std::string old; + TzdirGuard() { + if (const char* v = std::getenv("TZDIR")) { + had = true; + old = v; + } + } + void set(const char* v) { ::setenv("TZDIR", v, 1); } + void clear() { ::unsetenv("TZDIR"); } + ~TzdirGuard() { + if (had) ::setenv("TZDIR", old.c_str(), 1); + else ::unsetenv("TZDIR"); + } +}; + +static bool copy_file_bytes(const char* src, const char* dst) { + const int in = ::open(src, O_RDONLY | O_CLOEXEC); + if (in < 0) return false; + const int out = ::open(dst, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (out < 0) { + ::close(in); + return false; + } + char buf[4096]; + bool ok = true; + for (;;) { + const ssize_t n = ::read(in, buf, sizeof(buf)); + if (n == 0) break; + if (n < 0) { + ok = false; + break; + } + ssize_t off = 0; + while (off < n) { + const ssize_t w = ::write(out, buf + off, static_cast(n - off)); + if (w <= 0) { + ok = false; + break; + } + off += w; + } + if (!ok) break; + } + ::close(in); + ::close(out); + return ok; +} + +static const char* system_nonutc_tzif() { + static const char* cands[] = { + "/var/db/timezone/zoneinfo/Asia/Taipei", + "/usr/share/zoneinfo/Asia/Taipei", + }; + for (const char* p : cands) { + const int fd = ::open(p, O_RDONLY | O_CLOEXEC); + if (fd < 0) continue; + char mag[4] = {}; + const ssize_t n = ::read(fd, mag, 4); + ::close(fd); + if (n == 4 && std::memcmp(mag, "TZif", 4) == 0) return p; + } + return nullptr; +} + +// Child process: libc mktime under the given TZ/TZDIR. Does not touch the +// parent's ScopedTimezone cache. +static int64_t libc_civil_ms(const char* tzdir, const char* tz, int y, int mo, int d) { + int fds[2]; + if (::pipe(fds) != 0) return -1; + const pid_t pid = ::fork(); + if (pid < 0) { + ::close(fds[0]); + ::close(fds[1]); + return -1; + } + if (pid == 0) { + ::close(fds[0]); + if (tzdir == nullptr) ::unsetenv("TZDIR"); + else ::setenv("TZDIR", tzdir, 1); + ::setenv("TZ", tz, 1); + ::tzset(); + std::tm tm {}; + tm.tm_year = y - 1900; + tm.tm_mon = mo - 1; + tm.tm_mday = d; + tm.tm_isdst = 0; + const time_t t = ::mktime(&tm); + const int64_t ms = (t == static_cast(-1)) + ? static_cast(-1) + : static_cast(t) * 1000; + const ssize_t w = ::write(fds[1], &ms, sizeof(ms)); + (void)w; + ::_exit(0); + } + ::close(fds[1]); + int64_t ms = -1; + ssize_t got = 0; + char* p = reinterpret_cast(&ms); + while (got < static_cast(sizeof(ms))) { + const ssize_t n = ::read(fds[0], p + got, sizeof(ms) - static_cast(got)); + if (n <= 0) break; + got += n; + } + ::close(fds[0]); + int st = 0; + ::waitpid(pid, &st, 0); + return got == static_cast(sizeof(ms)) ? ms : static_cast(-1); +} + +static std::string independent_zoneinfo_root() { + char buf[PATH_MAX]; +#if defined(__APPLE__) + if (::realpath("/var/db/timezone/zoneinfo", buf) != nullptr) return buf; + if (::realpath("/usr/share/zoneinfo", buf) != nullptr) return buf; +#elif defined(__GLIBC__) + if (const char* env = std::getenv("TZDIR"); env != nullptr && env[0] != '\0') { + if (::realpath(env, buf) == nullptr) return {}; + struct stat st {}; + if (::stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) return {}; + return buf; + } + if (::realpath("/usr/share/zoneinfo", buf) != nullptr) return buf; +#else + if (::realpath("/usr/share/zoneinfo", buf) != nullptr) return buf; +#endif + return {}; +} + +static std::string independent_tzif_path(const char* name) { + const std::string root = independent_zoneinfo_root(); + if (root.empty()) return {}; + const std::string full = root + "/" + name; + char buf[PATH_MAX]; + if (::realpath(full.c_str(), buf) == nullptr) return {}; + const std::string res(buf); + if (res != root && res.rfind(root + "/", 0) != 0) return {}; + const int fd = ::open(res.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) return {}; + char mag[4] = {}; + const ssize_t n = ::read(fd, mag, 4); + ::close(fd); + if (n != 4 || std::memcmp(mag, "TZif", 4) != 0) return {}; + return res; +} + +static void check_identity(std::string_view zone, + TimezoneSourceKind kind, + std::string_view definition, + const char* path_or_null) { + auto d = timezone_identity_descriptor(zone); + CHECK(d.has_value()); + if (!d) return; + CHECK(d->valid()); + CHECK_EQ(static_cast(d->semantics_version), + static_cast(TimezoneIdentityDescriptor::kSemanticsVersion)); + CHECK_EQ(static_cast(d->kind), static_cast(kind)); + CHECK(d->input == zone); + CHECK(d->effective_definition == definition); + if (path_or_null == nullptr) { + CHECK(d->resource_paths.empty()); + } else { + CHECK(d->resource_paths.size() == 1); + if (!d->resource_paths.empty()) CHECK(d->resource_paths.front() == path_or_null); + } +} + +static void test_timezone_identity() { + std::printf("test_timezone_identity\n"); + CHECK(!TimezoneIdentityDescriptor{}.valid()); + CHECK(!timezone_identity_descriptor("No/Such_PineForge_Zone")); + CHECK(!timezone_identity_descriptor("UTC+24:00")); + CHECK(!timezone_identity_descriptor(" ")); + + const std::string root = independent_zoneinfo_root(); + CHECK(!root.empty()); + const std::string utc_path = independent_tzif_path("UTC"); + const std::string ny_path = independent_tzif_path("America/New_York"); + const std::string eastern_path = independent_tzif_path("US/Eastern"); + const std::string japan_path = independent_tzif_path("Japan"); + const std::string posixrules_path = independent_tzif_path("posixrules"); + const std::string gmt_path = independent_tzif_path("GMT"); + CHECK(!ny_path.empty()); + CHECK(!eastern_path.empty()); + CHECK(!japan_path.empty()); + CHECK(!posixrules_path.empty()); + CHECK(independent_tzif_path("FOO5BAR").empty()); + + check_identity("", TimezoneSourceKind::Utc, "UTC", + utc_path.empty() ? nullptr : utc_path.c_str()); + check_identity("UTC", TimezoneSourceKind::Utc, "UTC", + utc_path.empty() ? nullptr : utc_path.c_str()); + check_identity("GMT", TimezoneSourceKind::Utc, "UTC", + utc_path.empty() ? nullptr : utc_path.c_str()); + check_identity("Etc/UTC", TimezoneSourceKind::Utc, "UTC", + utc_path.empty() ? nullptr : utc_path.c_str()); + check_identity("UTC+0", TimezoneSourceKind::Utc, "UTC", + utc_path.empty() ? nullptr : utc_path.c_str()); + if (!utc_path.empty() && !gmt_path.empty()) { + CHECK(utc_path != gmt_path); + auto gmt = timezone_identity_descriptor("GMT"); + CHECK(gmt.has_value()); + CHECK(gmt->resource_paths.size() != 1 || gmt->resource_paths.front() != gmt_path); + } + + auto ny = timezone_identity_descriptor("America/New_York"); + CHECK(ny.has_value()); + CHECK(ny->valid()); + CHECK(ny->kind == TimezoneSourceKind::Tzfile); + CHECK(ny->effective_definition == "America/New_York"); + CHECK(ny->zoneinfo_root == root); + CHECK(ny->resource_paths.size() == 1); + CHECK(ny->resource_paths.front() == ny_path); + + auto eastern = timezone_identity_descriptor("US/Eastern"); + CHECK(eastern.has_value()); + CHECK(eastern->kind == TimezoneSourceKind::Tzfile); + CHECK(eastern->effective_definition == "US/Eastern"); + CHECK(eastern->resource_paths.size() == 1); + CHECK(eastern->resource_paths.front() == eastern_path); + + auto colon = timezone_identity_descriptor(":America/New_York"); + CHECK(colon.has_value()); + CHECK(colon->kind == TimezoneSourceKind::Tzfile); + CHECK(colon->effective_definition == "America/New_York"); + CHECK(colon->resource_paths == ny->resource_paths); + + auto japan = timezone_identity_descriptor("Japan"); + CHECK(japan.has_value()); + CHECK(japan->kind == TimezoneSourceKind::Tzfile); + CHECK(japan->resource_paths.front() == japan_path); + + check_identity("UTC+05:30", TimezoneSourceKind::FixedOffset, "UTC-5:30", nullptr); + check_identity("GMT-4", TimezoneSourceKind::FixedOffset, "UTC+4", nullptr); + check_identity("FOO5", TimezoneSourceKind::FixedOffset, "FOO5", nullptr); + + check_identity("EST5EDT,M3.2.0,M11.1.0", TimezoneSourceKind::PosixExplicit, + "EST5EDT,M3.2.0,M11.1.0", nullptr); + check_identity("CST6CDT,M3.2.0/2,M11.1.0/2", TimezoneSourceKind::PosixExplicit, + "CST6CDT,M3.2.0/2,M11.1.0/2", nullptr); + check_identity("<-05>5<-04>,M3.2.0,M11.1.0", TimezoneSourceKind::PosixExplicit, + "<-05>5<-04>,M3.2.0,M11.1.0", nullptr); + + const std::string est5edt_path = independent_tzif_path("EST5EDT"); + CHECK(!est5edt_path.empty()); + check_identity("EST5EDT", TimezoneSourceKind::Tzfile, "EST5EDT", est5edt_path.c_str()); + + CHECK(timezone_accepted("FOO5BAR")); + check_identity("FOO5BAR", TimezoneSourceKind::PosixDefaultDst, "FOO5BAR", + posixrules_path.c_str()); + CHECK(timezone_accepted("EST5EDT4")); + check_identity("EST5EDT4", TimezoneSourceKind::PosixDefaultDst, "EST5EDT4", + posixrules_path.c_str()); +} + +static void test_timezone_tzdir_root() { + std::printf("test_timezone_tzdir_root\n"); + TzdirGuard env; + env.clear(); + + // Digit-free so a missing tzfile is not a POSIX offset spec (PfTzdirP1 + // would be std+1 on this libc). Must not be a UTC TZif copy. + const char kFake[] = "PfTzdirProbe/Zone"; + CHECK(timezone_accepted("America/New_York")); + CHECK(!timezone_accepted(kFake)); + const auto ny = resolve_civil("America/New_York", 2025, 3, 8, 0, 0, 0); + CHECK(ny.has_value()); + CHECK_EQ(ny->epoch_ms, kNyMar8Midnight); + + const char* src = system_nonutc_tzif(); + CHECK(src != nullptr); + if (src == nullptr) return; + + char fake_root_tmpl[] = "/tmp/pf-tzdir-XXXXXX"; + char* fake_root = ::mkdtemp(fake_root_tmpl); + CHECK(fake_root != nullptr); + if (fake_root == nullptr) return; + std::string zone_dir = std::string(fake_root) + "/PfTzdirProbe"; + CHECK(::mkdir(zone_dir.c_str(), 0700) == 0); + std::string zone_path = zone_dir + "/Zone"; + CHECK(copy_file_bytes(src, zone_path.c_str())); + + char empty_tmpl[] = "/tmp/pf-tzdir-empty-XXXXXX"; + char* empty_root = ::mkdtemp(empty_tmpl); + CHECK(empty_root != nullptr); + const std::string missing = + std::string("/tmp/pf-tzdir-missing-") + std::to_string(::getpid()); + +#if defined(__GLIBC__) + const bool libc_reads_tzdir = true; +#else + const bool libc_reads_tzdir = false; +#endif + + env.set(fake_root); + if (libc_reads_tzdir) { + CHECK(timezone_accepted(kFake)); + CHECK(parse_session("24x7", kFake).has_value()); + const auto fake_civil = resolve_civil(kFake, 2025, 6, 10, 0, 0, 0); + CHECK(fake_civil.has_value()); + CHECK_EQ(fake_civil->epoch_ms, kUtcJun10_0000_taipei); + CHECK(!timezone_accepted("America/New_York")); + CHECK(!parse_session("24x7", "America/New_York")); + CHECK(!resolve_civil("America/New_York", 2025, 3, 8, 0, 0, 0)); + CHECK(!timezone_identity_descriptor("America/New_York")); + auto fake_id = timezone_identity_descriptor(kFake); + CHECK(fake_id.has_value()); + CHECK(fake_id->kind == TimezoneSourceKind::Tzfile); + char fake_real[PATH_MAX]; + CHECK(::realpath(zone_path.c_str(), fake_real) != nullptr); + CHECK(fake_id->resource_paths.size() == 1); + CHECK(fake_id->resource_paths.front() == fake_real); + CHECK(timezone_accepted("FOO5BAR")); + CHECK(!timezone_identity_descriptor("FOO5BAR")); + CHECK_EQ(libc_civil_ms(fake_root, kFake, 2025, 6, 10), kUtcJun10_0000_taipei); + CHECK_EQ(libc_civil_ms(fake_root, "America/New_York", 2025, 3, 8), kUtcMar8_0000); + } else { + CHECK(!timezone_accepted(kFake)); + CHECK(!parse_session("24x7", kFake)); + CHECK(!resolve_civil(kFake, 2025, 6, 10, 0, 0, 0)); + CHECK(!timezone_identity_descriptor(kFake)); + CHECK(timezone_accepted("America/New_York")); + CHECK(parse_session("24x7", "America/New_York").has_value()); + const auto ny_fake_dir = resolve_civil("America/New_York", 2025, 3, 8, 0, 0, 0); + CHECK(ny_fake_dir.has_value()); + CHECK_EQ(ny_fake_dir->epoch_ms, kNyMar8Midnight); + auto ny_id = timezone_identity_descriptor("America/New_York"); + CHECK(ny_id.has_value()); + CHECK(ny_id->kind == TimezoneSourceKind::Tzfile); + CHECK(ny_id->resource_paths.front() == independent_tzif_path("America/New_York")); + CHECK(timezone_accepted("FOO5BAR")); + auto foo_id = timezone_identity_descriptor("FOO5BAR"); + CHECK(foo_id.has_value()); + CHECK(foo_id->kind == TimezoneSourceKind::PosixDefaultDst); + CHECK(foo_id->resource_paths.front() == independent_tzif_path("posixrules")); + CHECK_EQ(libc_civil_ms(fake_root, kFake, 2025, 6, 10), kUtcJun10_0000); + CHECK_EQ(libc_civil_ms(fake_root, "America/New_York", 2025, 3, 8), kNyMar8Midnight); + } + // A UTC TZif would not discriminate; the copied file is Asia/Taipei. + CHECK(kUtcJun10_0000_taipei != kUtcJun10_0000); + + env.set(empty_root); + if (libc_reads_tzdir) { + CHECK(!timezone_accepted("America/New_York")); + CHECK(!timezone_identity_descriptor("America/New_York")); + CHECK_EQ(libc_civil_ms(empty_root, "America/New_York", 2025, 3, 8), kUtcMar8_0000); + } else { + CHECK(timezone_accepted("America/New_York")); + CHECK(timezone_identity_descriptor("America/New_York").has_value()); + CHECK_EQ(libc_civil_ms(empty_root, "America/New_York", 2025, 3, 8), kNyMar8Midnight); + } + + env.set(missing.c_str()); + if (libc_reads_tzdir) { + CHECK(!timezone_accepted("America/New_York")); + CHECK(!timezone_identity_descriptor("America/New_York")); + CHECK_EQ(libc_civil_ms(missing.c_str(), "America/New_York", 2025, 3, 8), + kUtcMar8_0000); + } else { + CHECK(timezone_accepted("America/New_York")); + CHECK(timezone_identity_descriptor("America/New_York").has_value()); + CHECK_EQ(libc_civil_ms(missing.c_str(), "America/New_York", 2025, 3, 8), + kNyMar8Midnight); + } + + env.set(""); + CHECK(timezone_accepted("America/New_York")); + +#if defined(__GLIBC__) + { + char saved_cwd[PATH_MAX]; + const bool have_cwd = ::getcwd(saved_cwd, sizeof(saved_cwd)) != nullptr; + CHECK(have_cwd); + if (have_cwd && ::chdir(fake_root) == 0) { + env.set("."); + CHECK(timezone_accepted(kFake)); + const auto rel = resolve_civil(kFake, 2025, 6, 10, 0, 0, 0); + CHECK(rel.has_value()); + CHECK_EQ(rel->epoch_ms, kUtcJun10_0000_taipei); + CHECK(::chdir(saved_cwd) == 0); + } + } +#endif + + env.clear(); + CHECK(timezone_accepted("America/New_York")); + CHECK(!timezone_accepted(kFake)); + const auto ny_restored = resolve_civil("America/New_York", 2025, 3, 8, 0, 0, 0); + CHECK(ny_restored.has_value()); + CHECK_EQ(ny_restored->epoch_ms, kNyMar8Midnight); + + ::unlink(zone_path.c_str()); + ::rmdir(zone_dir.c_str()); + ::rmdir(fake_root); + ::rmdir(empty_root); +} + +int main() { + test_timeframe_forms(); + test_pairings(); + test_session_parse(); + test_multi_window_last_close(); + test_overnight_origin_1800(); + test_full_day_and_2359(); + test_weekday_mask_and_gaps(); + test_rth_friday_vs_monday(); + test_dst_oracles(); + test_overnight_spanning_gap(); + test_fold_and_gap_resolve(); + test_call_order_independence(); + test_consecutive_continuity(); + test_fixed_1m_across_dst(); + test_stable_anchors(); + test_fixed_clip_and_lunch(); + test_codex_audit_regressions(); + test_checked_public_values(); + test_empty_cycle_identity(); + test_timezone_acceptance(); + test_timezone_identity(); + test_timezone_tzdir_root(); + std::printf("test_native_calendar: %d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_native_calendar_driver_contract.cpp b/tests/test_native_calendar_driver_contract.cpp new file mode 100644 index 000000000..f0591e613 --- /dev/null +++ b/tests/test_native_calendar_driver_contract.cpp @@ -0,0 +1,1784 @@ +// Independent R1 v10 C1-C9 / D1-D5 witnesses through NativeStrategyHost. +// Calendar expectations are literal UTC epochs (including independently +// converted New York civil dates), never answers from the calendar under test. +// These small native fixtures do not claim campaign/Pine parity. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace pineforge; +using native_order::AcceptedEvent; +using native_order::CancelledEvent; +using native_order::CommandEvent; +using native_order::ExecutionAppliedEvent; +using native_order::InvalidHandleEvent; +using native_order::MatchRejectedEvent; +using native_order::NoEffectEvent; +using native_order::NotWorkingEvent; +using native_order::RejectedEvent; +using native_order::ReplaceRejectedEvent; +using native_order::ReplacedEvent; +using native_order::Request; +using native_order::SubmitResult; +using native_order::SubmitStatus; +using order_action::Reduce; +using order_action::Transact; + +int checks = 0; +int failures = 0; +int scenarios = 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) + +void near(double actual, double expected) { + if (!std::isfinite(actual) || std::abs(actual - expected) > 1e-10) { + std::printf("[%s] actual %.17g expected %.17g\n", scenario, actual, expected); + } + CHECK(std::isfinite(actual) && std::abs(actual - expected) <= 1e-10); +} + +Bar flat(int64_t timestamp, double price = 100.0, double volume = 1.0) { + return Bar{price, price, price, price, volume, timestamp}; +} + +Request buy() { return Request{Transact{1.0}, "calendar-contract", ""}; } + +bool proof_capture = false; +int next_host_ordinal = 0; + +struct HostSnap { + int ordinal = 0; + std::string spec_json = "{}"; + std::string identity_json = + "{\"sessionKey\":\"calendar-driver-contract\",\"runNumber\":1}"; + std::string calendar_json = "{}"; + std::vector inputs; + std::vector lifecycle; + std::vector physical; + std::string observations_json = "{}"; +}; + +std::vector group_snaps; + +struct ScenarioArt { + std::string id; + std::string status = "passed"; + std::string native_configuration = "{}"; + std::string run_identity = + "{\"sessionKey\":\"calendar-driver-contract\",\"runNumber\":1}"; + std::string calendar = "{}"; + std::string logical_inputs = "[]"; + std::string lifecycle_events = "[]"; + std::string physical_effects = "[]"; + std::string observations = "{}"; + std::string comparisons = "[]"; +}; + +std::vector arts; + +std::string json_escape(const std::string& s) { + std::string out; + out.push_back('"'); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out.push_back(static_cast(c)); + } + } + } + out.push_back('"'); + return out; +} + +std::string json_num(double x) { + std::ostringstream out; + out << std::setprecision(17) << x; + return out.str(); +} + +std::string json_i64(int64_t x) { + std::ostringstream out; + out << x; + return out.str(); +} + +std::string json_u64(uint64_t x) { + std::ostringstream out; + out << x; + return out.str(); +} + +std::string json_bool(bool v) { return v ? "true" : "false"; } + +std::string hex64(uint64_t value) { + std::ostringstream out; + out << std::hex << std::setw(16) << std::setfill('0') << value; + return out.str(); +} + +uint64_t f64_bits(double x) { + uint64_t bits = 0; + std::memcpy(&bits, &x, sizeof(bits)); + return bits; +} + +std::string unsupported_number(double x) { + const char* tag = "nonfinite"; + if (std::isnan(x)) tag = "NaN"; + else if (std::isinf(x)) tag = x > 0 ? "+Inf" : "-Inf"; + std::ostringstream out; + out << "{\"unsupported\":" << json_escape(tag) + << ",\"ieee754Bits\":" << json_escape(hex64(f64_bits(x))) << "}"; + return out.str(); +} + +void append_f64(std::ostringstream& out, const char* key, double x) { + out << json_escape(key) << ":"; + if (std::isfinite(x)) out << json_num(x); + else out << unsupported_number(x); +} + +std::string json_array(const std::vector& parts) { + std::string out = "["; + for (std::size_t i = 0; i < parts.size(); ++i) { + if (i) out += ","; + out += parts[i]; + } + out += "]"; + return out; +} + +std::string flatten_items(const std::vector& hosts, + std::vector HostSnap::*field) { + std::vector all; + for (const auto& host : hosts) { + const auto& items = host.*field; + all.insert(all.end(), items.begin(), items.end()); + } + return json_array(all); +} + +bool is_sha256_hex(const char* s) { + if (!s) return false; + std::size_t n = 0; + for (; s[n]; ++n) { + const char c = s[n]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false; + } + return n == 64; +} + +std::string identity_json(const NativeRunSpec& spec) { + std::ostringstream out; + out << "{\"sessionKey\":" << json_escape(spec.identity.session_key) + << ",\"runNumber\":" << json_u64(spec.identity.run_number) << "}"; + return out.str(); +} + +std::string calendar_json(const NativeRunSpec& spec, int host_ordinal) { + std::ostringstream out; + out << "{\"hostOrdinal\":" << host_ordinal + << ",\"timezone\":" << json_escape(spec.timezone) + << ",\"session\":" << json_escape(spec.session) + << ",\"inputTf\":" << json_escape(spec.input_tf) + << ",\"scriptTf\":" << json_escape(spec.script_tf) + << ",\"chartTimezone\":" << json_escape(spec.chart_timezone) << "}"; + return out.str(); +} + +void append_optional_f64(std::ostringstream& out, const char* key, + const std::optional& value) { + out << "," << json_escape(key) << ":"; + if (!value) out << "null"; + else if (std::isfinite(*value)) out << json_num(*value); + else out << unsupported_number(*value); +} + +std::string spec_config_json(const NativeRunSpec& spec, int host_ordinal) { + std::ostringstream out; + out << "{\"hostOrdinal\":" << host_ordinal + << ",\"sessionKey\":" << json_escape(spec.identity.session_key) + << ",\"runNumber\":" << json_u64(spec.identity.run_number) + << ",\"inputTf\":" << json_escape(spec.input_tf) + << ",\"scriptTf\":" << json_escape(spec.script_tf) + << ",\"ticker\":" << json_escape(spec.ticker) + << ",\"tickerid\":" << json_escape(spec.tickerid) + << ",\"type\":" << json_escape(spec.type) + << ",\"currency\":" << json_escape(spec.currency) + << ",\"basecurrency\":" << json_escape(spec.basecurrency) + << ",\"description\":" << json_escape(spec.description) + << ",\"volumetype\":" << json_escape(spec.volumetype) + << ",\"timezone\":" << json_escape(spec.timezone) + << ",\"session\":" << json_escape(spec.session) + << ",\"chartTimezone\":" << json_escape(spec.chart_timezone) + << ","; + append_f64(out, "initialCapital", spec.initial_capital); + out << ","; + append_f64(out, "pointValue", spec.point_value); + out << ","; + append_f64(out, "accountFx", spec.account_fx); + out << ","; + append_f64(out, "priceTick", spec.price_tick); + out << ",\"slippageTicks\":" << json_u64(spec.slippage_ticks) + << ",\"feeKind\":" << json_u64(static_cast(spec.fee_kind)) + << ","; + append_f64(out, "feeValue", spec.fee_value); + append_optional_f64(out, "quantityGrid", spec.quantity_grid); + out << ",\"closeExecution\":" + << json_u64(static_cast(spec.close_execution)); + append_optional_f64(out, "maxAbsUnits", spec.max_abs_units); + out << ",\"maxOpenLots\":"; + if (!spec.max_open_lots) out << "null"; + else out << json_u64(*spec.max_open_lots); + out << ",\"allowedOpenDirections\":" + << json_u64(static_cast(spec.allowed_open_directions)); + append_optional_f64(out, "initialMarginFraction", spec.initial_margin_fraction); + out << "}"; + return out.str(); +} + +std::string interval_json(const native_calendar::NativeInterval& interval) { + std::ostringstream out; + out << "{\"openMs\":" << json_i64(interval.open_ms) + << ",\"eligibleOpenMs\":" << json_i64(interval.eligible_open_ms) + << ",\"lastTradedCloseMs\":" << json_i64(interval.last_traded_close_ms) + << ",\"nextPeriodOpenMs\":" << json_i64(interval.next_period_open_ms) + << ",\"nextInputOpenMs\":" << json_i64(interval.next_input_open_ms) << "}"; + return out.str(); +} + +std::string coordinate_json(const NativeCoordinate& c) { + std::ostringstream out; + out << "{\"ordinal\":" << json_u64(c.ordinal) + << ",\"intervalIndex\":" << c.interval_index + << ",\"openMs\":" << json_i64(c.open_ms) + << ",\"eligibleOpenMs\":" << json_i64(c.eligible_open_ms) + << ",\"lastTradedCloseMs\":" << json_i64(c.last_traded_close_ms) + << ",\"nextPeriodOpenMs\":" << json_i64(c.next_period_open_ms) + << ",\"nextInputOpenMs\":" << json_i64(c.next_input_open_ms) + << ",\"effectiveTimeMs\":" << json_i64(c.effective_time_ms) + << ",\"sourcePriceTimeMs\":" << json_i64(c.source_price_time_ms) + << ",\"provenance\":" << json_u64(static_cast(c.provenance)) + << ",\"pathPhase\":" << json_u64(static_cast(c.path_phase)) + << ",\"completion\":" << json_u64(static_cast(c.completion)) << "}"; + return out.str(); +} + +void append_bar_fields(std::ostringstream& out, const Bar& bar) { + append_f64(out, "open", bar.open); + out << ","; + append_f64(out, "high", bar.high); + out << ","; + append_f64(out, "low", bar.low); + out << ","; + append_f64(out, "close", bar.close); + out << ","; + append_f64(out, "volume", bar.volume); + out << ",\"timestampMs\":" << json_i64(bar.timestamp); +} + +std::string action_json(const Request& request) { + return std::visit([](const auto& action) -> std::string { + using T = std::decay_t; + std::ostringstream out; + if constexpr (std::is_same_v) { + out << "{\"type\":\"Transact\","; + append_f64(out, "signedUnits", action.signed_units); + out << "}"; + } else if constexpr (std::is_same_v) { + out << "{\"type\":\"Reduce\","; + append_f64(out, "units", action.units); + out << "}"; + } else { + out << "{\"type\":\"Flatten\"}"; + } + return out.str(); + }, request.action); +} + +const char* command_kind_name(const CommandEvent& event) { + return std::visit([](const auto& payload) -> const char* { + using T = std::decay_t; + if constexpr (std::is_same_v) return "Accepted"; + if constexpr (std::is_same_v) return "Rejected"; + if constexpr (std::is_same_v) return "Replaced"; + if constexpr (std::is_same_v) return "ReplaceRejected"; + if constexpr (std::is_same_v) return "Cancelled"; + if constexpr (std::is_same_v) return "NotWorking"; + if constexpr (std::is_same_v) return "InvalidHandle"; + if constexpr (std::is_same_v) return "NoEffect"; + if constexpr (std::is_same_v) return "MatchRejected"; + if constexpr (std::is_same_v) return "ExecutionApplied"; + return "Unknown"; + }, event); +} + +uint64_t command_ordinal(const CommandEvent& event) { + return std::visit([](const auto& payload) { return payload.ordinal; }, event); +} + +double request_quantity(const Request& request) { + return std::visit([](const auto& action) -> double { + using T = std::decay_t; + if constexpr (std::is_same_v) return action.signed_units; + if constexpr (std::is_same_v) return action.units; + return 0.0; + }, request.action); +} + +NativeRunSpec spec(const char* input = "1", const char* script = "1") { + NativeRunSpec result; + result.identity = {"calendar-driver-contract", 1}; + result.input_tf = input; + result.script_tf = script; + result.ticker = "CLOCK"; + result.tickerid = "TEST:CLOCK"; + result.type = "crypto"; + result.currency = "USD"; + result.basecurrency = "USD"; + result.description = "independent native calendar driver fixture"; + result.volumetype = "base"; + result.timezone = "UTC"; + result.session = "24x7"; + result.initial_capital = 10000.0; + result.point_value = 1.0; + result.account_fx = 1.0; + result.price_tick = 0.01; + result.fee_kind = NativeFeeKind::CashPerExecution; + result.fee_value = 0.0; + return result; +} + +class TraceHost final : public NativeStrategyHost { +public: + std::vector bars; + std::vector contexts; + std::vector callback_positions; + std::function callback; + + TraceHost() { + if (proof_capture) host_ordinal_ = next_host_ordinal++; + } + + ~TraceHost() override { snapshot(); } + + NativeSetupResult configure_native(const NativeRunSpec& value) { + attempted_spec_ = value; + configured_ = true; + const auto result = NativeStrategyHost::configure_native(value); + setup_status_ = result.status; + setup_error_ = static_cast(result.validation.error); + setup_field_ = static_cast(result.validation.field); + return result; + } + + void run(const Bar* input, int n) { + NativeStrategyHost::run(input, n); + if (!proof_capture) return; + const bool admitted = last_error().empty(); + record_bars("batchBar", input, n, admitted); + record_operation("batchRun", n > 0 && input ? input[0].timestamp : 0, admitted, + "{\"barCount\":" + json_i64(n) + "}"); + } + + bool stream_begin(const Bar* warmup, int n, const std::string& input_tf, + const std::string& script_tf = "") { + const bool ok = NativeStrategyHost::stream_begin(warmup, n, input_tf, script_tf); + if (proof_capture) { + record_bars("warmupBar", warmup, n, ok); + std::ostringstream extra; + extra << "{\"barCount\":" << n + << ",\"inputTf\":" << json_escape(input_tf) + << ",\"scriptTf\":" << json_escape(script_tf) << "}"; + record_operation("streamBegin", n > 0 && warmup ? warmup[0].timestamp : 0, ok, + extra.str()); + } + return ok; + } + + bool stream_push_bar(const Bar& bar) { + const bool ok = NativeStrategyHost::stream_push_bar(bar); + if (proof_capture) record_bar("pushBar", bar, ok); + return ok; + } + + bool stream_push_tick(const TradeTick& tick) { + const bool ok = NativeStrategyHost::stream_push_tick(tick); + if (proof_capture) record_tick("tick", tick, ok); + return ok; + } + + bool stream_push_ticks(const TradeTick* ticks, int n) { + const bool ok = NativeStrategyHost::stream_push_ticks(ticks, n); + if (proof_capture) record_tick_array(ticks, n, ok); + return ok; + } + + bool stream_advance_time(int64_t timestamp_ms) { + const bool ok = NativeStrategyHost::stream_advance_time(timestamp_ms); + if (proof_capture) { + std::ostringstream out; + out << "{\"kind\":\"advanceTime\",\"ordinal\":" << json_u64(input_ordinal_++) + << ",\"effectiveTimeMs\":" << json_i64(timestamp_ms) + << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"admitted\":" << json_bool(ok) << "}}"; + input_items_.push_back(out.str()); + } + return ok; + } + + bool stream_end(bool finalize_partial_input_bar = false) { + const bool ok = NativeStrategyHost::stream_end(finalize_partial_input_bar); + if (proof_capture) { + std::ostringstream out; + out << "{\"kind\":\"streamEnd\",\"ordinal\":" << json_u64(input_ordinal_++) + << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"finalizePartial\":" << json_bool(finalize_partial_input_bar) + << ",\"admitted\":" << json_bool(ok) << "}}"; + input_items_.push_back(out.str()); + } + return ok; + } + + SubmitResult submit_market(const Request& request) { + const auto result = NativeStrategyHost::submit_market(request); + if (proof_capture) { + std::ostringstream out; + out << "{\"kind\":\"request\",\"ordinal\":" << json_u64(input_ordinal_++); + const double qty = request_quantity(request); + if (std::isfinite(qty)) out << ",\"quantity\":" << json_num(qty); + out << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"label\":" << json_escape(request.label) + << ",\"comment\":" << json_escape(request.comment) + << ",\"action\":" << action_json(request) + << ",\"admitted\":" << json_bool(result.status == SubmitStatus::Accepted) + << ",\"submitStatus\":" + << json_u64(static_cast(result.status)) + << ",\"eventOrdinal\":" << json_u64(result.event_ordinal); + if (!std::isfinite(qty)) { + out << ",\"quantity\":" << unsupported_number(qty); + } + out << "}}"; + input_items_.push_back(out.str()); + } + return result; + } + + void on_native_bar(const Bar& bar, const NativeDecisionContext& context) override { + bars.push_back(bar); + contexts.push_back(context); + callback_positions.push_back(physical_position().signed_units); + if (callback) callback(*this); + } + + double runup() const { return open_trade_max_runup(0); } + double drawdown() const { return open_trade_max_drawdown(0); } + +private: + int host_ordinal_ = 0; + uint64_t input_ordinal_ = 0; + bool configured_ = false; + NativeSetupStatus setup_status_ = NativeSetupStatus::Failed; + uint64_t setup_error_ = 0; + uint64_t setup_field_ = 0; + NativeRunSpec attempted_spec_{}; + std::vector input_items_; + + void record_operation(const char* kind, int64_t time_ms, bool admitted, + const std::string& extra_object) { + std::ostringstream out; + out << "{\"kind\":" << json_escape(kind) + << ",\"ordinal\":" << json_u64(input_ordinal_++) + << ",\"effectiveTimeMs\":" << json_i64(time_ms) + << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"admitted\":" << json_bool(admitted); + if (extra_object.size() >= 2 && extra_object.front() == '{' && extra_object.back() == '}') { + const std::string inner = extra_object.substr(1, extra_object.size() - 2); + if (!inner.empty()) out << "," << inner; + } + out << "}}"; + input_items_.push_back(out.str()); + } + + void record_bar(const char* kind, const Bar& bar, bool admitted) { + std::ostringstream out; + out << "{\"kind\":" << json_escape(kind) + << ",\"ordinal\":" << json_u64(input_ordinal_++) + << ",\"effectiveTimeMs\":" << json_i64(bar.timestamp); + if (std::isfinite(bar.open)) out << ",\"price\":" << json_num(bar.open); + if (std::isfinite(bar.volume)) out << ",\"quantity\":" << json_num(bar.volume); + out << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"admitted\":" << json_bool(admitted) << ","; + append_bar_fields(out, bar); + out << "}}"; + input_items_.push_back(out.str()); + } + + void record_bars(const char* kind, const Bar* bars, int n, bool admitted) { + if (n <= 0 || !bars) return; + for (int i = 0; i < n; ++i) record_bar(kind, bars[i], admitted); + } + + void record_tick_body(std::ostringstream& out, const TradeTick& tick) { + out << "\"effectiveTimeMs\":" << json_i64(tick.timestamp) + << ",\"sequence\":" << json_u64(tick.sequence); + if (std::isfinite(tick.price)) out << ",\"price\":" << json_num(tick.price); + if (std::isfinite(tick.quantity)) out << ",\"quantity\":" << json_num(tick.quantity); + out << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_; + if (!std::isfinite(tick.price)) { + out << ",\"price\":" << unsupported_number(tick.price); + } + if (!std::isfinite(tick.quantity)) { + out << ",\"quantity\":" << unsupported_number(tick.quantity); + } + out << "}"; + } + + void record_tick(const char* kind, const TradeTick& tick, bool admitted) { + std::ostringstream out; + out << "{\"kind\":" << json_escape(kind) + << ",\"ordinal\":" << json_u64(input_ordinal_++) << ","; + record_tick_body(out, tick); + // admitted is a logical-input fact; keep it inside calendar. + std::string body = out.str(); + const auto insert_at = body.rfind('}'); + std::ostringstream full; + full << body.substr(0, insert_at) << ",\"admitted\":" << json_bool(admitted) + << body.substr(insert_at) << "}"; + input_items_.push_back(full.str()); + } + + void record_tick_array(const TradeTick* ticks, int n, bool admitted) { + std::ostringstream out; + out << "{\"kind\":\"tickArray\",\"ordinal\":" << json_u64(input_ordinal_++) + << ",\"calendar\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"admitted\":" << json_bool(admitted) + << ",\"count\":" << n << ",\"ticks\":["; + for (int i = 0; i < n; ++i) { + if (i) out << ","; + const TradeTick& tick = ticks[i]; + out << "{\"effectiveTimeMs\":" << json_i64(tick.timestamp) + << ",\"sequence\":" << json_u64(tick.sequence); + if (std::isfinite(tick.price)) out << ",\"price\":" << json_num(tick.price); + else out << ",\"price\":" << unsupported_number(tick.price); + if (std::isfinite(tick.quantity)) out << ",\"quantity\":" << json_num(tick.quantity); + else out << ",\"quantity\":" << unsupported_number(tick.quantity); + out << "}"; + } + out << "]}}"; + input_items_.push_back(out.str()); + } + + std::string lifecycle_items(const std::vector& events, + std::vector* physical) const { + std::vector rows; + for (const auto& event : events) { + if (event.command) { + const auto& command = *event.command; + const char* kind = command_kind_name(command); + std::ostringstream out; + out << "{\"kind\":" << json_escape(kind) + << ",\"ordinal\":" << json_u64(command_ordinal(command)); + if (std::strcmp(kind, "ExecutionApplied") == 0) + out << ",\"physicalEffectsPresent\":true"; + if (const auto* rejected = std::get_if(&command)) { + out << ",\"reason\":" + << json_u64(static_cast(rejected->reason)); + } + if (const auto* rr = std::get_if(&command)) { + out << ",\"reason\":" << json_u64(static_cast(rr->reason)); + } + if (const auto* mr = std::get_if(&command)) { + out << ",\"reason\":" << json_u64(static_cast(mr->reason)); + } + out << ",\"labels\":[\"host-" << host_ordinal_ << "\"]"; + out << "}"; + rows.push_back(out.str()); + if (const auto* applied = std::get_if(&command)) { + std::ostringstream effect; + effect << "{\"kind\":\"ExecutionApplied\",\"ordinal\":" + << json_u64(applied->ordinal) << ","; + append_f64(effect, "price", applied->raw_price); + effect << ",\"timestampMs\":" << json_i64(applied->effective_time_ms) + << ",\"provenance\":" << json_u64(applied->provenance) + << ","; + append_f64(effect, "quantity", request_quantity(applied->request)); + effect << ",\"observations\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"resolvedPrice\":"; + if (std::isfinite(applied->resolved_price)) + effect << json_num(applied->resolved_price); + else effect << unsupported_number(applied->resolved_price); + effect << ",\"currentTicket\":"; + if (std::isfinite(applied->current_ticket)) + effect << json_num(applied->current_ticket); + else effect << unsupported_number(applied->current_ticket); + effect << ",\"openedLotIncarnation\":" + << json_u64(applied->opened_lot_incarnation) + << ",\"closedTradeCount\":" + << json_u64(applied->closed_trade_count) << "}}"; + physical->push_back(effect.str()); + } + } + if (event.driver) { + const auto& point = *event.driver; + std::ostringstream effect; + effect << "{\"kind\":\"DriverPoint\",\"ordinal\":" + << json_u64(point.coordinate.ordinal) << ","; + append_f64(effect, "price", point.raw_price); + effect << ",\"timestampMs\":" + << json_i64(point.coordinate.effective_time_ms) + << ",\"provenance\":" + << json_u64(static_cast(point.coordinate.provenance)) + << ",\"observations\":{\"hostOrdinal\":" << host_ordinal_ + << ",\"matching\":" << json_bool(point.matching) + << ",\"excursion\":" << json_bool(point.excursion) + << ",\"coordinate\":" << coordinate_json(point.coordinate); + if (point.sequence) + effect << ",\"sequence\":" << json_u64(*point.sequence); + else effect << ",\"sequence\":null"; + effect << "}}"; + physical->push_back(effect.str()); + } + } + return json_array(rows); + } + + std::string callbacks_json() const { + std::vector rows; + const std::size_t n = std::min(bars.size(), + std::min(contexts.size(), callback_positions.size())); + for (std::size_t i = 0; i < n; ++i) { + std::ostringstream out; + out << "{\"ordinal\":" << json_u64(i) + << ",\"coordinate\":" << coordinate_json(contexts[i].coordinate) + << ",\"decisionFloorMs\":" << json_i64(contexts[i].decision_floor_ms) + << ",\"inputInterval\":" << interval_json(contexts[i].input_interval) + << ",\"scriptInterval\":" << interval_json(contexts[i].script_interval) + << ","; + append_bar_fields(out, bars[i]); + out << ","; + append_f64(out, "signedUnits", callback_positions[i]); + out << "}"; + rows.push_back(out.str()); + } + return json_array(rows); + } + + void snapshot() { + if (!proof_capture) return; + HostSnap snap; + snap.ordinal = host_ordinal_; + const auto state = native_state(); + const NativeRunSpec* live = state.spec; + const NativeRunSpec& spec = live ? *live : attempted_spec_; + snap.spec_json = spec_config_json(spec, host_ordinal_); + snap.identity_json = identity_json(spec); + snap.calendar_json = calendar_json(spec, host_ordinal_); + snap.inputs = input_items_; + const auto events = native_events(0); + const std::string lifecycle = lifecycle_items(events, &snap.physical); + // lifecycle_items returns a JSON array; split is unnecessary — store + // the objects by rebuilding from events again into snap.lifecycle. + snap.lifecycle.clear(); + { + // Re-parse is avoided: rebuild command rows only. + for (const auto& event : events) { + if (!event.command) continue; + const auto& command = *event.command; + const char* kind = command_kind_name(command); + std::ostringstream out; + out << "{\"kind\":" << json_escape(kind) + << ",\"ordinal\":" << json_u64(command_ordinal(command)); + if (std::strcmp(kind, "ExecutionApplied") == 0) + out << ",\"physicalEffectsPresent\":true"; + if (const auto* rejected = std::get_if(&command)) { + out << ",\"reason\":" + << json_u64(static_cast(rejected->reason)); + } + if (const auto* rr = std::get_if(&command)) { + out << ",\"reason\":" + << json_u64(static_cast(rr->reason)); + } + if (const auto* mr = std::get_if(&command)) { + out << ",\"reason\":" + << json_u64(static_cast(mr->reason)); + } + out << ",\"labels\":[\"host-" << host_ordinal_ << "\"]}"; + snap.lifecycle.push_back(out.str()); + } + } + (void)lifecycle; + const auto position = physical_position(); + std::ostringstream obs; + obs << "{\"hostOrdinal\":" << host_ordinal_ + << ",\"configured\":" << json_bool(configured_) + << ",\"setupStatus\":" << json_u64(static_cast(setup_status_)) + << ",\"setupError\":" << json_u64(setup_error_) + << ",\"setupField\":" << json_u64(setup_field_) + << ",\"lifecycleKind\":" << json_u64(static_cast(state.kind)) + << ",\"phase\":" << json_u64(static_cast(state.phase)) + << ",\"completion\":" << json_u64(static_cast(state.completion)) + << ",\"failureCode\":" << json_u64(static_cast(state.failure.code)) + << ",\"failureOperation\":" + << json_u64(static_cast(state.failure.operation)) + << ",\"failureOrdinal\":" << json_u64(state.failure.ordinal) + << ",\"consumedHighWater\":" << json_u64(native_consumed_high_water()) + << ",\"decisionFloorMs\":" << json_i64(native_decision_floor()) + << ",\"continuationHash\":" << json_escape(hex64(native_continuation_hash())) + << ",\"lastError\":" << json_escape(last_error()) + << ","; + append_f64(obs, "signedUnits", position.signed_units); + obs << ","; + append_f64(obs, "averagePrice", position.average_price); + obs << ",\"lotCount\":" << json_u64(position.lot_count) + << ",\"tradeCount\":" << trade_count() + << ",\"callbackCount\":" << json_u64(bars.size()) + << ",\"nativeConfiguration\":" << snap.spec_json + << ",\"calendar\":" << snap.calendar_json + << ",\"callbacks\":" << callbacks_json() + << "}"; + snap.observations_json = obs.str(); + group_snaps.push_back(std::move(snap)); + } +}; + +bool setup(TraceHost& host, const NativeRunSpec& value) { + const bool ok = host.configure_native(value).status == NativeSetupStatus::Applied; + CHECK(ok); + return ok; +} + +bool count(const TraceHost& host, std::size_t expected) { + CHECK(host.bars.size() == expected); + CHECK(host.contexts.size() == expected); + return host.bars.size() == expected && host.contexts.size() == expected; +} + +std::vector fills(const TraceHost& host) { + std::vector result; + for (const auto& event : host.native_events(0)) { + if (event.command) { + if (const auto* applied = std::get_if(&*event.command)) + result.push_back(*applied); + } + } + return result; +} + +std::vector points(const TraceHost& host, NativePriceProvenance kind) { + std::vector result; + for (const auto& event : host.native_events(0)) + if (event.driver && event.driver->coordinate.provenance == kind) + result.push_back(*event.driver); + return result; +} + +void ordered(const TraceHost& host) { + uint64_t previous = 0; + for (const auto& event : host.native_events(0)) { + // Account observations may project the execution ordinal whose + // physical effect they describe; they do not allocate a second fill. + CHECK(event.ordinal >= previous); + if (event.kind != NativeEventKind::Account) CHECK(event.ordinal > previous); + previous = event.ordinal; + } + int64_t floor = std::numeric_limits::min(); + for (const auto& context : host.contexts) { + CHECK(context.decision_floor_ms >= floor); + CHECK(context.decision_floor_ms >= context.coordinate.effective_time_ms); + floor = context.decision_floor_ms; + } +} + +void bar_values(const Bar& actual, double o, double h, double l, double c, double v) { + near(actual.open, o); + near(actual.high, h); + near(actual.low, l); + near(actual.close, c); + near(actual.volume, v); +} + +std::vector minutes(int first, int n) { + std::vector result; + for (int i = first; i < first + n; ++i) + result.push_back(flat(static_cast(i) * 60000, 100.0 + i)); + return result; +} + +void equal_timeframe_families() { + struct Case { const char* tf; int64_t boundaries[4]; }; + const Case cases[] = { + {"1S", {0, 1000, 2000, 3000}}, + {"15S", {0, 15000, 30000, 45000}}, + {"1", {0, 60000, 120000, 180000}}, + {"5", {0, 300000, 600000, 900000}}, + // January 12/13/14/15 and 12/14/16/18, 2025, UTC midnight. + {"D", {1736640000000LL, 1736726400000LL, 1736812800000LL, 1736899200000LL}}, + {"2D", {1736640000000LL, 1736812800000LL, 1736985600000LL, 1737158400000LL}}, + {"W", {1736121600000LL, 1736726400000LL, 1737331200000LL, 1737936000000LL}}, + // 2W anchors Dec30, Jan13, Jan27, Feb10: Jan6 is NOT a 2W anchor. + {"2W", {1735516800000LL, 1736726400000LL, 1737936000000LL, 1739145600000LL}}, + {"M", {1735689600000LL, 1738368000000LL, 1740787200000LL, 1743465600000LL}}, + {"3M", {1735689600000LL, 1743465600000LL, 1751328000000LL, 1759276800000LL}}, + }; + for (const auto& item : cases) { + TraceHost host; + if (!setup(host, spec(item.tf, item.tf))) continue; + const Bar input[] = {flat(item.boundaries[0], 101, 2), + flat(item.boundaries[1], 102, 3), + flat(item.boundaries[2], 103, 5)}; + host.run(input, 3); + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + if (!count(host, 3)) continue; + for (int i = 0; i < 3; ++i) { + CHECK(host.contexts[i].coordinate.open_ms == item.boundaries[i]); + CHECK(host.contexts[i].coordinate.last_traded_close_ms == item.boundaries[i + 1]); + CHECK(host.contexts[i].coordinate.next_period_open_ms == item.boundaries[i + 1]); + CHECK(host.contexts[i].coordinate.effective_time_ms == item.boundaries[i + 1]); + bar_values(host.bars[i], 101 + i, 101 + i, 101 + i, 101 + i, input[i].volume); + } + CHECK(host.native_decision_floor() == item.boundaries[3]); + ordered(host); + } +} + +void stable_count_and_cross_unit_grouping() { + // Dropping the leading Jan12 row changes the observed opening, never the + // Jan12 2D anchor. Jan12 is day ordinal 20100 from 1970-01-01. + CHECK(1736640000000LL / 86400000LL == 20100); + CHECK(20100 % 2 == 0); + for (int skip = 0; skip < 2; ++skip) { + TraceHost host; + if (!setup(host, spec("D", "2D"))) continue; + const Bar input[] = {flat(1736640000000LL, 100, 2), flat(1736726400000LL, 104, 3)}; + host.run(input + skip, 2 - skip); + if (!count(host, 1)) continue; + CHECK(host.contexts[0].coordinate.open_ms == 1736640000000LL); + CHECK(host.contexts[0].coordinate.effective_time_ms == 1736812800000LL); + bar_values(host.bars[0], skip ? 104 : 100, 104, skip ? 104 : 100, 104, skip ? 3 : 5); + const auto opening = points(host, NativePriceProvenance::ModeledOHLCOpen); + CHECK(opening.size() == 1); + if (!opening.empty()) { + CHECK(opening[0].coordinate.effective_time_ms == input[skip].timestamp); + CHECK(opening[0].coordinate.source_price_time_ms == input[skip].timestamp); + } + } + TraceHost seconds; + if (!setup(seconds, spec("15S", "1"))) return; + const Bar input[] = {flat(0, 100, 1), flat(15000, 102, 2), + flat(30000, 99, 3), flat(45000, 101, 4)}; + seconds.run(input, 4); + if (count(seconds, 1)) { + bar_values(seconds.bars[0], 100, 102, 99, 101, 10); + CHECK(seconds.contexts[0].coordinate.effective_time_ms == 60000); + } + // Different valid literals for the same unit/count remain accepted. + TraceHost aliases; + if (!setup(aliases, spec("D", "1D"))) return; + const Bar daily = flat(1736640000000LL); + aliases.run(&daily, 1); + count(aliases, 1); + CHECK(aliases.native_state().spec->script_tf == "1D"); +} + +void daily_to_week_and_month() { + struct Case { const char* script; int64_t first; int64_t last; int64_t end; }; + const Case cases[] = { + {"W", 1736121600000LL, 1736640000000LL, 1736726400000LL}, // Jan6..Jan12 + {"M", 1735689600000LL, 1738281600000LL, 1738368000000LL}, // Jan1..Jan31 + }; + for (const auto& item : cases) { + for (int skip = 0; skip < 2; ++skip) { + TraceHost host; + if (!setup(host, spec("D", item.script))) continue; + const Bar input[] = {flat(item.first, 100, 2), flat(item.last, 105, 7)}; + host.run(input + skip, 2 - skip); // sparse batch is intentionally legal + if (!count(host, 1)) continue; + CHECK(host.contexts[0].coordinate.open_ms == item.first); + CHECK(host.contexts[0].coordinate.next_period_open_ms == item.end); + CHECK(host.contexts[0].coordinate.effective_time_ms == item.end); + bar_values(host.bars[0], skip ? 105 : 100, 105, skip ? 105 : 100, 105, skip ? 7 : 9); + } + } +} + +void explicit_refusals_and_empty_batch() { + struct Pair { const char* input; const char* script; }; + const Pair bad[] = {{"5", "7"}, {"5", "1"}, {"D", "60"}, {"W", "D"}, + {"M", "W"}, {"2W", "3W"}, {"1H", "1H"}, {"0", "1"}, + {"01", "1"}, {"1S", "S"}, {"D", ""}}; + for (const auto& item : bad) { + TraceHost host; + CHECK(host.configure_native(spec(item.input, item.script)).status == NativeSetupStatus::Failed); + CHECK(host.native_consumed_high_water() == 0); + count(host, 0); + } + for (const char* tf : {"M", "3M"}) { + TraceHost host; + if (!setup(host, spec(tf, tf))) continue; + const auto before = host.native_continuation_hash(); + const Bar monthly = flat(1735689600000LL); + CHECK(!host.stream_begin(&monthly, 1, "", "")); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); + CHECK(host.native_consumed_high_water() == 0); + CHECK(host.native_continuation_hash() == before); + host.run(&monthly, 1); + count(host, 1); + } + TraceHost empty; + if (setup(empty, spec())) { + empty.run(nullptr, 0); + CHECK(empty.native_state().kind == NativeLifecycleKind::Completed); + CHECK(empty.native_events(0).empty()); + count(empty, 0); + } + TraceHost mismatch; + if (!setup(mismatch, spec("1", "5"))) return; + const auto warmup = minutes(0, 3); + const auto before = mismatch.native_continuation_hash(); + CHECK(!mismatch.stream_begin(warmup.data(), 3, "5", "5")); + CHECK(!mismatch.stream_begin(warmup.data(), 3, "1", "1")); + CHECK(mismatch.native_state().kind == NativeLifecycleKind::Ready); + CHECK(mismatch.native_consumed_high_water() == 0); + CHECK(mismatch.native_continuation_hash() == before); + CHECK(mismatch.stream_begin(warmup.data(), 3, "", "")); + CHECK(mismatch.stream_end(false)); + count(mismatch, 0); +} + +void ny_daily_dst() { + const int64_t dates[][4] = { + {1741410000000LL, 1741496400000LL, 1741579200000LL, 1741665600000LL}, // Mar8..11 + {1761969600000LL, 1762056000000LL, 1762146000000LL, 1762232400000LL}, // Nov1..4 + }; + CHECK(dates[0][2] - dates[0][1] == 23LL * 3600000); + CHECK(dates[1][2] - dates[1][1] == 25LL * 3600000); + for (const auto& row : dates) { + TraceHost host; + auto value = spec("D", "D"); + value.timezone = "America/New_York"; + if (!setup(host, value)) continue; + const Bar warmup[] = {flat(row[0], 100), flat(row[1], 101)}; + CHECK(host.stream_begin(warmup, 2, "D", "D")); + if (!count(host, 2)) continue; + CHECK(host.native_decision_floor() == row[2]); + // A guessed 24-hour boundary is invalid on both DST transition days. + const auto before = host.native_continuation_hash(); + CHECK(!host.stream_push_bar(flat(row[1] + 86400000LL, 102))); + CHECK(host.native_continuation_hash() == before); + CHECK(host.stream_push_bar(flat(row[2], 102))); + if (count(host, 3)) { + for (int i = 0; i < 3; ++i) { + CHECK(host.contexts[i].coordinate.open_ms == row[i]); + CHECK(host.contexts[i].coordinate.last_traded_close_ms == row[i + 1]); + CHECK(host.contexts[i].coordinate.next_period_open_ms == row[i + 1]); + CHECK(host.contexts[i].coordinate.effective_time_ms == row[i + 1]); + } + } + CHECK(host.stream_end(false)); + ordered(host); + } +} + +void ny_fixed_gap_and_fold_adjacency() { + // Gap: 01:59 EST -> 03:00 EDT. Fold: 01:59 EDT -> 01:00 EST. + const int64_t rows[][3] = { + {1741503540000LL, 1741503600000LL, 1741503660000LL}, + {1762063140000LL, 1762063200000LL, 1762063260000LL}, + }; + for (const auto& row : rows) { + TraceHost host; + auto value = spec(); + value.timezone = "America/New_York"; + if (!setup(host, value)) continue; + const Bar first = flat(row[0]); + CHECK(host.stream_begin(&first, 1, "", "")); + CHECK(host.stream_push_bar(flat(row[1], 101))); + CHECK(host.stream_push_bar(flat(row[2], 102))); + if (count(host, 3)) { + CHECK(host.contexts[0].coordinate.next_period_open_ms == row[1]); + CHECK(host.contexts[1].coordinate.next_period_open_ms == row[2]); + } + CHECK(host.stream_end(false)); + } +} + +void lunch_quiet_slot() { + TraceHost host; + auto value = spec("60", "60"); + value.session = "0930-1130,1300-1600"; + if (!setup(host, value)) return; + const Bar morning = flat(1749465000000LL, 101); // Jun9 10:30..11:30 UTC + CHECK(host.stream_begin(&morning, 1, "", "")); + CHECK(host.stream_advance_time(1749475800000LL)); // 13:30 + if (count(host, 2)) { + const auto& c = host.contexts[1].coordinate; + CHECK(c.open_ms == 1749472200000LL); // nominal 12:30 + CHECK(c.eligible_open_ms == 1749474000000LL); // clipped 13:00 + CHECK(c.last_traded_close_ms == 1749475800000LL); + bar_values(host.bars[1], 101, 101, 101, 101, 0); + } + const auto carried = points(host, NativePriceProvenance::CarriedOpen); + CHECK(carried.size() == 1); + if (!carried.empty()) { + CHECK(carried[0].coordinate.open_ms == 1749472200000LL); + CHECK(carried[0].coordinate.effective_time_ms == 1749474000000LL); + } + CHECK(host.stream_end(false)); +} + +void lunch_real_off_session_and_replay() { + TraceHost a, b; + auto value = spec("60", "60"); + value.session = "0930-1130,1300-1600"; + if (!setup(a, value) || !setup(b, value)) return; + const Bar morning = flat(1749465000000LL, 100); + for (TraceHost* host : {&a, &b}) { + CHECK(host->stream_begin(&morning, 1, "", "")); + CHECK(host->submit_market(buy()).status == SubmitStatus::Accepted); + } + CHECK(a.native_continuation_hash() == b.native_continuation_hash()); + for (TraceHost* host : {&a, &b}) { + CHECK(host->stream_push_tick(TradeTick{1749473100000LL, 1, 123.0, 2.0})); // closed 12:45 + if (host == &a) (void)host->native_continuation_hash(); + CHECK(host->stream_advance_time(1749474000000LL)); // 13:00 + if (host == &a) (void)host->native_continuation_hash(); + CHECK(host->stream_push_tick(TradeTick{1749474300000LL, 2, 125.0, 3.0})); // 13:05 + CHECK(host->stream_advance_time(1749475800000LL)); // 13:30 + if (count(*host, 2)) { + CHECK(host->contexts[1].coordinate.open_ms == 1749472200000LL); + bar_values(host->bars[1], 123, 125, 123, 125, 5); + } + CHECK(points(*host, NativePriceProvenance::ObservedPrint).size() == 2); + CHECK(points(*host, NativePriceProvenance::CarriedOpen).empty()); + const auto executed = fills(*host); + CHECK(executed.size() == 1); + if (!executed.empty()) { + CHECK(executed[0].effective_time_ms == 1749473100000LL); + CHECK(executed[0].provenance == static_cast(NativePriceProvenance::ObservedPrint)); + near(executed[0].raw_price, 123); + } + CHECK(host->stream_end(false)); + ordered(*host); + } + CHECK(a.native_continuation_hash() == b.native_continuation_hash()); +} + +void nominal_and_clipped_confirmed_labels() { + auto value = spec("60", "60"); + value.session = "0930-1130,1300-1600"; + const Bar duplicate[] = {flat(1749472200000LL), flat(1749474000000LL)}; + TraceHost refused; + if (!setup(refused, value)) return; + const auto before = refused.native_continuation_hash(); + CHECK(!refused.stream_begin(duplicate, 2, "", "")); + CHECK(refused.native_state().kind == NativeLifecycleKind::Ready); + CHECK(refused.native_consumed_high_water() == 0); + CHECK(refused.native_continuation_hash() == before); + for (const auto& input : duplicate) { + TraceHost host; + if (!setup(host, value)) continue; + host.run(&input, 1); + if (count(host, 1)) { + CHECK(host.contexts[0].coordinate.open_ms == 1749472200000LL); + CHECK(host.contexts[0].coordinate.next_period_open_ms == 1749475800000LL); + } + const auto opening = points(host, NativePriceProvenance::ModeledOHLCOpen); + CHECK(opening.size() == 1); + if (!opening.empty()) CHECK(opening[0].coordinate.effective_time_ms == input.timestamp); + } +} + +void friday_monday_continuity() { + auto value = spec(); + value.session = "0930-1600:23456"; + TraceHost host; + if (!setup(host, value)) return; + const Bar friday = flat(1749225540000LL); // Jun6 Fri 15:59 + CHECK(host.stream_begin(&friday, 1, "", "")); + if (count(host, 1)) { + CHECK(host.contexts[0].coordinate.last_traded_close_ms == 1749225600000LL); + CHECK(host.contexts[0].coordinate.next_input_open_ms == 1749461400000LL); + } + CHECK(host.native_decision_floor() == 1749225600000LL); + const auto before = host.native_continuation_hash(); + CHECK(!host.stream_push_bar(flat(1749466800000LL))); // Mon11 skipped Mon09:30 + CHECK(host.native_continuation_hash() == before); + CHECK(host.stream_push_bar(flat(1749461400000LL))); + CHECK(host.stream_end(false)); + + TraceHost quiet; + if (!setup(quiet, value)) return; + CHECK(quiet.stream_begin(&friday, 1, "", "")); + CHECK(quiet.submit_market(buy()).status == SubmitStatus::Accepted); + CHECK(quiet.stream_advance_time(1749461400000LL)); // Monday opening, no whole quiet slot yet + CHECK(fills(quiet).empty()); + CHECK(points(quiet, NativePriceProvenance::CarriedOpen).empty()); + count(quiet, 1); + CHECK(quiet.stream_advance_time(1749461460000LL)); + const auto carried = points(quiet, NativePriceProvenance::CarriedOpen); + CHECK(carried.size() == 1); + if (!carried.empty()) CHECK(carried[0].coordinate.effective_time_ms == 1749461400000LL); + CHECK(fills(quiet).size() == 1); + CHECK(quiet.stream_end(false)); +} + +void overnight_cycle_origin_and_window_permutations() { + for (const char* session : {"1800-1700:23456", "1800-0000,0000-1200,1200-1700:23456", + "1800-0000,1200-1700,0000-1200:23456"}) { + TraceHost host; + auto value = spec("D", "D"); + value.session = session; + if (!setup(host, value)) continue; + const Bar sunday = flat(1736704800000LL); // Jan12 Sunday18:00, Monday trading date + host.run(&sunday, 1); + if (!count(host, 1)) continue; + CHECK(host.contexts[0].coordinate.open_ms == 1736704800000LL); + CHECK(host.contexts[0].coordinate.last_traded_close_ms == 1736787600000LL); // Mon17 + CHECK(host.contexts[0].coordinate.next_period_open_ms == 1736791200000LL); // Mon18 + } + // All windows participate in the daily close; the first morning window is + // not a substitute for the normalized split-session union. + TraceHost split; + auto value = spec("D", "D"); + value.session = "0930-1130,1300-1600"; + if (!setup(split, value)) return; + const Bar monday = flat(1749461400000LL); + split.run(&monday, 1); + if (count(split, 1)) CHECK(split.contexts[0].coordinate.last_traded_close_ms == 1749484800000LL); +} + +void cross_calendar_straddles() { + struct Case { + const char* input; + const char* script; + int64_t first, first_close, next, script_open, script_seal; + }; + const Case cases[] = { + {"W", "M", 1737936000000LL, 1738540800000LL, 1738540800000LL, + 1735689600000LL, 1738368000000LL}, // Jan27..Feb3 belongs wholly to January + {"2D", "W", 1736640000000LL, 1736812800000LL, 1736812800000LL, + 1736121600000LL, 1736726400000LL}, // SunJan12..TueJan14 belongs to weekJan6 + }; + for (const auto& item : cases) { + for (auto policy : {NativeCloseExecution::NextEligiblePoint, NativeCloseExecution::AfterCalculation}) { + TraceHost host; + auto value = spec(item.input, item.script); + value.close_execution = policy; + if (!setup(host, value)) continue; + const Bar first{100, 110, 90, 105, 7, item.first}; + CHECK(host.stream_begin(&first, 1, "", "")); + if (!count(host, 1)) continue; + const auto& c = host.contexts[0]; + CHECK(c.coordinate.open_ms == item.script_open); + CHECK(c.coordinate.next_period_open_ms == item.script_seal); + CHECK(c.coordinate.last_traded_close_ms == item.script_seal); + CHECK(c.coordinate.effective_time_ms == item.first_close); + CHECK(c.decision_floor_ms == item.first_close); + CHECK(c.script_interval.next_period_open_ms == item.script_seal); + bar_values(host.bars[0], 100, 110, 90, 105, 7); + CHECK(host.native_decision_floor() == item.first_close); + CHECK(host.submit_market(buy()).status == SubmitStatus::Accepted); + CHECK(host.stream_push_bar(flat(item.next, 120, 11))); + CHECK(fills(host).empty()); // next script remains incomplete + CHECK(host.stream_end(false)); + count(host, 1); // do not split/recount the straddling volume + ordered(host); + } + } +} + +void cycle_date_week_month_and_counts() { + // Monday Jan13 is week index2872 from Monday1969-12-29. + CHECK((1736726400000LL + 259200000LL) / 604800000LL == 2872); + CHECK(2025 * 12 == 24300); + struct Case { const char* tf; int64_t input; int64_t open; int64_t end; }; + const Case cases[] = { + {"D", 1736701200000LL, 1736701200000LL, 1736787600000LL}, // Sunday17 -> Monday date + {"2D", 1736701200000LL, 1736614800000LL, 1736787600000LL}, // Jan12/13 cycle dates + {"W", 1736701200000LL, 1736701200000LL, 1737306000000LL}, + {"2W", 1736701200000LL, 1736701200000LL, 1737910800000LL}, + {"M", 1735664400000LL, 1735664400000LL, 1738342800000LL}, // Dec31 17 -> Jan date + {"3M", 1735664400000LL, 1735664400000LL, 1743440400000LL}, + }; + for (const auto& item : cases) { + TraceHost host; + auto value = spec("1", item.tf); + value.session = "1700-1700"; + if (!setup(host, value)) continue; + // Sparse batch: a next-key input proves completion; only the first + // bucket completes. Its first source open need not equal its anchor. + const Bar input[] = {flat(item.input, 100, 2), flat(item.end, 105, 3)}; + host.run(input, 2); + if (!count(host, 1)) continue; + CHECK(host.contexts[0].coordinate.open_ms == item.open); + CHECK(host.contexts[0].coordinate.next_period_open_ms == item.end); + CHECK(host.contexts[0].script_interval.open_ms == item.open); + bar_values(host.bars[0], 100, 100, 100, 100, 2); + } +} + +void masked_and_gap_empty_cycle_attribution() { + struct Case { + const char* timezone; + const char* session; + const char* script; + int64_t observed, proving, open, end; + }; + const Case cases[] = { + {"UTC", "0930-1600:23456", "D", 1749385800000LL, 1749461400000LL, + 1749375000000LL, 1749461400000LL}, // SundayJun8 closed, never remapped to Monday + {"UTC", "0930-1600:23456", "W", 1749385800000LL, 1749461400000LL, + 1748856600000LL, 1749461400000LL}, // retains weekJun2 + {"UTC", "0930-1600:23456", "M", 1704024000000LL, 1704101400000LL, + 1701423000000LL, 1704101400000LL}, // SundayDec31 noon retains December + {"UTC", "0930-1600:23456", "W", 1704024000000LL, 1704101400000LL, + 1703496600000LL, 1704101400000LL}, // retains weekDec25 + {"America/New_York", "0230-0245", "D", 1741536000000LL, 1741588200000LL, + 1741503600000LL, 1741588200000LL}, // Mar9 local gap collapses coverage, not cycle + }; + for (const auto& item : cases) { + TraceHost host; + auto value = spec("1", item.script); + value.timezone = item.timezone; + value.session = item.session; + if (!setup(host, value)) continue; + const Bar input[] = {flat(item.observed, 123, 2), flat(item.proving, 125, 3)}; + host.run(input, 2); + if (!count(host, 1)) continue; + const auto& c = host.contexts[0]; + CHECK(c.coordinate.open_ms == item.open); + CHECK(c.coordinate.next_period_open_ms == item.end); + CHECK(c.script_interval.open_ms <= item.observed); + CHECK(item.observed < c.script_interval.next_period_open_ms); + bar_values(host.bars[0], 123, 123, 123, 123, 2); + const auto opening = points(host, NativePriceProvenance::ModeledOHLCOpen); + CHECK(opening.size() == 1); + if (!opening.empty()) { + CHECK(opening[0].coordinate.effective_time_ms == item.observed); + near(opening[0].raw_price, 123); + } + } +} + +void partial_coarser_warmup_confirmed() { + for (int n : {3, 7}) { + TraceHost host; + if (!setup(host, spec("1", "5"))) continue; + const auto warmup = minutes(0, n); + CHECK(host.stream_begin(warmup.data(), n, "", "")); + count(host, static_cast(n / 5)); + CHECK(host.native_decision_floor() == n * 60000LL); + CHECK(host.native_state().phase == NativeRunPhase::Realtime); + const int end = n < 5 ? 5 : 10; + for (int i = n; i < end; ++i) CHECK(host.stream_push_bar(flat(i * 60000LL, 100 + i))); + if (count(host, static_cast(end / 5))) { + const auto last = host.bars.size() - 1; + const int first = end - 5; + CHECK(host.contexts[last].coordinate.open_ms == first * 60000LL); + CHECK(host.contexts[last].coordinate.effective_time_ms == end * 60000LL); + bar_values(host.bars[last], 100 + first, 100 + end - 1, 100 + first, 100 + end - 1, 5); + } + CHECK(host.stream_end(false)); + } + TraceHost refused; + if (!setup(refused, spec("1", "5"))) return; + const Bar gap[] = {flat(0), flat(120000)}; + const auto before = refused.native_continuation_hash(); + CHECK(!refused.stream_begin(gap, 2, "", "")); + CHECK(refused.native_consumed_high_water() == 0); + CHECK(refused.native_continuation_hash() == before); +} + +void rth_partial_daily_warmup_seal() { + auto value = spec("1", "D"); + value.session = "0930-1600:23456"; + const Bar last = flat(1749225540000LL, 101); // Fri15:59 + TraceHost ended; + if (!setup(ended, value)) return; + CHECK(ended.stream_begin(&last, 1, "", "")); + count(ended, 0); // scheduled16:00 is not a nominal daily seal + CHECK(ended.native_decision_floor() == 1749225600000LL); + CHECK(ended.stream_end(false)); + count(ended, 0); + + TraceHost advanced; + if (!setup(advanced, value)) return; + CHECK(advanced.stream_begin(&last, 1, "", "")); + CHECK(advanced.stream_advance_time(1749288600000LL)); // Saturday09:30 nominal exhaustion + if (count(advanced, 1)) { + CHECK(advanced.contexts[0].coordinate.open_ms == 1749202200000LL); + CHECK(advanced.contexts[0].coordinate.last_traded_close_ms == 1749225600000LL); + CHECK(advanced.contexts[0].coordinate.next_period_open_ms == 1749288600000LL); + bar_values(advanced.bars[0], 101, 101, 101, 101, 1); + } + CHECK(points(advanced, NativePriceProvenance::CarriedOpen).empty()); + CHECK(advanced.native_decision_floor() == 1749288600000LL); + CHECK(advanced.stream_end(false)); + count(advanced, 1); +} + +void ticks_same_timestamp_and_atomic_refusal() { + TraceHost host; + if (!setup(host, spec())) return; + const Bar warmup = flat(0); + CHECK(host.stream_begin(&warmup, 1, "", "")); + CHECK(host.stream_push_tick(TradeTick{60000, 1, 100.25, 1})); + CHECK(host.submit_market(buy()).status == SubmitStatus::Accepted); + CHECK(fills(host).empty()); + CHECK(host.stream_push_tick(TradeTick{60000, 2, 100.50, 2})); + const auto executed = fills(host); + CHECK(executed.size() == 1); + if (!executed.empty()) { + near(executed[0].raw_price, 100.50); + CHECK(executed[0].effective_time_ms == 60000); + CHECK(executed[0].provenance == static_cast(NativePriceProvenance::ObservedPrint)); + } + const auto before = host.native_continuation_hash(); + CHECK(!host.stream_push_tick(TradeTick{60000, 2, 200, 2})); + CHECK(host.native_continuation_hash() == before); + const TradeTick invalid_array[] = {{60100, 3, 101, 1}, + {60200, 4, std::numeric_limits::quiet_NaN(), 1}}; + CHECK(!host.stream_push_ticks(invalid_array, 2)); + CHECK(host.native_continuation_hash() == before); + CHECK(points(host, NativePriceProvenance::ObservedPrint).size() == 2); + CHECK(host.stream_advance_time(120000)); + CHECK(fills(host).size() == 1); + if (count(host, 2)) bar_values(host.bars[1], 100.25, 100.50, 100.25, 100.50, 3); + CHECK(host.stream_end(false)); + ordered(host); +} + +void no_preentry_tick_extrema_replay() { + TraceHost host; + if (!setup(host, spec())) return; + const Bar warmup = flat(0); + CHECK(host.stream_begin(&warmup, 1, "", "")); + CHECK(host.stream_push_tick(TradeTick{60100, 1, 150, 1})); + CHECK(host.stream_push_tick(TradeTick{60200, 2, 50, 1})); + CHECK(host.submit_market(buy()).status == SubmitStatus::Accepted); + CHECK(host.stream_push_tick(TradeTick{60300, 3, 100, 1})); + CHECK(host.stream_push_tick(TradeTick{60400, 4, 102, 1})); + near(host.runup(), 2); + near(host.drawdown(), 0); + CHECK(host.stream_advance_time(120000)); + near(host.runup(), 2); + near(host.drawdown(), 0); + if (count(host, 2)) bar_values(host.bars[1], 150, 150, 50, 102, 4); + CHECK(points(host, NativePriceProvenance::ObservedPrint).size() == 4); + CHECK(points(host, NativePriceProvenance::ModeledOHLCOpen).size() == 1); // warmup only + CHECK(host.stream_end(false)); +} + +void quiet_birth_floor() { + for (bool advance_before_submit : {false, true}) { + TraceHost host; + if (!setup(host, spec())) continue; + const Bar warmup = flat(0); + CHECK(host.stream_begin(&warmup, 1, "", "")); + if (advance_before_submit) CHECK(host.stream_advance_time(90000)); + CHECK(host.native_decision_floor() == (advance_before_submit ? 90000 : 60000)); + CHECK(host.submit_market(buy()).status == SubmitStatus::Accepted); + CHECK(host.stream_advance_time(180000)); + const auto carried = points(host, NativePriceProvenance::CarriedOpen); + CHECK(carried.size() == 2); + if (carried.size() == 2) { + CHECK(carried[0].coordinate.effective_time_ms == 60000); + CHECK(carried[1].coordinate.effective_time_ms == 120000); + } + const auto executed = fills(host); + CHECK(executed.size() == 1); + if (!executed.empty()) { + CHECK(executed[0].effective_time_ms == (advance_before_submit ? 120000 : 60000)); + CHECK(executed[0].provenance == static_cast(NativePriceProvenance::CarriedOpen)); + } + CHECK(host.native_decision_floor() == 180000); + CHECK(host.stream_end(false)); + } +} + +void confirmed_close_policy() { + for (auto policy : {NativeCloseExecution::NextEligiblePoint, NativeCloseExecution::AfterCalculation}) { + TraceHost host; + auto value = spec(); + value.close_execution = policy; + if (!setup(host, value)) continue; + host.callback = [](TraceHost& h) { CHECK(h.submit_market(buy()).status == SubmitStatus::Accepted); }; + const Bar input{100, 110, 90, 101, 1, 60000}; + host.run(&input, 1); + if (!count(host, 1)) continue; + near(host.callback_positions[0], 0); + CHECK(host.contexts[0].coordinate.effective_time_ms == 120000); + const auto executed = fills(host); + CHECK(executed.size() == (policy == NativeCloseExecution::AfterCalculation ? 1U : 0U)); + if (!executed.empty()) { + CHECK(executed[0].effective_time_ms == 120000); + CHECK(executed[0].ordinal > host.contexts[0].coordinate.ordinal); + CHECK(executed[0].provenance == static_cast(NativePriceProvenance::AfterCalculationClose)); + near(executed[0].raw_price, 101); + near(host.runup(), 0); + near(host.drawdown(), 0); + } + } +} + +void delayed_open_after_external_birth() { + for (auto policy : {NativeCloseExecution::NextEligiblePoint, NativeCloseExecution::AfterCalculation}) { + TraceHost host; + auto value = spec("1", "5"); + value.close_execution = policy; + if (!setup(host, value)) continue; + const auto warmup = minutes(0, 5); + CHECK(host.stream_begin(warmup.data(), 5, "", "")); + CHECK(host.stream_push_bar(flat(300000, 105))); + CHECK(host.stream_push_bar(flat(360000, 106))); + CHECK(host.native_decision_floor() == 420000); + CHECK(host.submit_market(buy()).status == SubmitStatus::Accepted); + for (int i = 7; i < 10; ++i) CHECK(host.stream_push_bar(flat(i * 60000LL, 100 + i))); + if (count(host, 2)) CHECK(host.contexts[1].coordinate.effective_time_ms == 600000); + const auto executed = fills(host); + CHECK(executed.size() == (policy == NativeCloseExecution::AfterCalculation ? 1U : 0U)); + if (!executed.empty()) { + CHECK(executed[0].effective_time_ms == 600000); + near(executed[0].raw_price, 109); + } + for (const auto& fill : executed) CHECK(fill.effective_time_ms >= 420000); + CHECK(host.stream_end(false)); + ordered(host); + } +} + +void mixed_warmup_tick_quiet_aggregation() { + for (bool quiet : {false, true}) { + TraceHost host; + if (!setup(host, spec("1", "5"))) continue; + const Bar warmup[] = {{100, 110, 90, 101, 2, 0}, flat(60000, 102, 3), flat(120000, 103, 5)}; + CHECK(host.stream_begin(warmup, 3, "", "")); + count(host, 0); + if (!quiet) { + CHECK(host.submit_market(buy()).status == SubmitStatus::Accepted); + CHECK(host.stream_push_tick(TradeTick{180100, 1, 104, 7})); + CHECK(host.stream_push_tick(TradeTick{181000, 2, 106, 11})); + CHECK(host.stream_push_tick(TradeTick{240100, 3, 105, 13})); + } + CHECK(host.stream_advance_time(300000)); + if (count(host, 1)) { + bar_values(host.bars[0], 100, 110, 90, quiet ? 103 : 105, quiet ? 10 : 41); + CHECK(host.contexts[0].coordinate.effective_time_ms == 300000); + } + // The confirmed warmup contributes aggregate values, but finalizing + // a mixed observed/quiet aggregate cannot replay historical OHLC. + CHECK(points(host, NativePriceProvenance::ModeledOHLCOpen).empty()); + CHECK(points(host, NativePriceProvenance::ModeledOHLCClose).empty()); + CHECK(points(host, NativePriceProvenance::ObservedPrint).size() == (quiet ? 0U : 3U)); + CHECK(points(host, NativePriceProvenance::CarriedOpen).size() == (quiet ? 2U : 0U)); + if (!quiet) { + CHECK(fills(host).size() == 1); + near(host.runup(), 2); // aggregate high110 predates entry104 + near(host.drawdown(), 0); // aggregate low90 also predates entry + } + CHECK(host.stream_end(false)); + count(host, 1); + } +} + +void partial_end_equal_clock_and_policy() { + for (auto policy : {NativeCloseExecution::NextEligiblePoint, NativeCloseExecution::AfterCalculation}) { + for (bool finalize : {false, true}) { + TraceHost host; + auto value = spec(); + value.close_execution = policy; + if (!setup(host, value)) continue; + const Bar warmup = flat(0); + CHECK(host.stream_begin(&warmup, 1, "", "")); + host.callback = [](TraceHost& h) { CHECK(h.submit_market(buy()).status == SubmitStatus::Accepted); }; + CHECK(host.stream_push_tick(TradeTick{60100, 1, 107, 3})); + CHECK(host.stream_advance_time(90000)); + CHECK(host.stream_end(finalize)); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_decision_floor() == 90000); + if (!count(host, finalize ? 2 : 1)) continue; + if (finalize) { + const auto& c = host.contexts[1]; + CHECK(c.coordinate.open_ms == 60000); + CHECK(c.coordinate.source_price_time_ms == 60100); + CHECK(c.coordinate.effective_time_ms == 90000); + CHECK(c.coordinate.completion == NativeCompletionKind::PartialFinalized); + CHECK(c.decision_floor_ms == 90000); + bar_values(host.bars[1], 107, 107, 107, 107, 3); + } + const bool should_fill = finalize && policy == NativeCloseExecution::AfterCalculation; + const auto executed = fills(host); + CHECK(executed.size() == (should_fill ? 1U : 0U)); + if (should_fill && !executed.empty()) { + CHECK(executed[0].effective_time_ms == 90000); + near(executed[0].raw_price, 107); + const auto modeled = points(host, NativePriceProvenance::AfterCalculationClose); + // Warmup has one after-calculation point even with no request. + CHECK(modeled.size() == 2); + if (modeled.size() == 2) { + CHECK(modeled[1].coordinate.source_price_time_ms == 60100); + CHECK(modeled[1].coordinate.effective_time_ms == 90000); + } + } + CHECK(points(host, NativePriceProvenance::ObservedPrint).size() == 1); + ordered(host); + } + } +} + +void partial_end_does_not_seal_coarser_script() { + for (auto policy : {NativeCloseExecution::NextEligiblePoint, NativeCloseExecution::AfterCalculation}) { + TraceHost host; + auto value = spec("1", "5"); + value.close_execution = policy; + if (!setup(host, value)) continue; + const auto warmup = minutes(0, 5); + CHECK(host.stream_begin(warmup.data(), 5, "", "")); + CHECK(host.stream_push_tick(TradeTick{300100, 1, 105, 1})); + CHECK(host.stream_push_tick(TradeTick{360100, 2, 106, 1})); + CHECK(host.stream_push_tick(TradeTick{420100, 3, 107, 1})); + CHECK(host.stream_advance_time(450000)); + CHECK(host.stream_end(true)); + count(host, 1); // two completed child slots and a partial third are not 5m + CHECK(host.native_decision_floor() == 450000); + CHECK(points(host, NativePriceProvenance::ObservedPrint).size() == 3); + CHECK(points(host, NativePriceProvenance::AfterCalculationClose).size() + == (policy == NativeCloseExecution::AfterCalculation ? 1U : 0U)); + CHECK(fills(host).empty()); + } +} + +ScenarioArt assemble_group(const char* id, bool passed) { + ScenarioArt art; + art.id = id; + art.status = passed ? "passed" : "failed"; + std::vector configs; + std::vector calendars; + std::vector observations; + configs.reserve(group_snaps.size()); + calendars.reserve(group_snaps.size()); + observations.reserve(group_snaps.size()); + for (const auto& host : group_snaps) { + configs.push_back(host.spec_json); + calendars.push_back(host.calendar_json); + observations.push_back(host.observations_json); + } + std::ostringstream config; + config << "{\"hostCount\":" << group_snaps.size() + << ",\"hosts\":" << json_array(configs) << "}"; + art.native_configuration = config.str(); + std::ostringstream cal; + cal << "{\"hostCount\":" << group_snaps.size() + << ",\"hosts\":" << json_array(calendars) << "}"; + art.calendar = cal.str(); + if (!group_snaps.empty()) art.run_identity = group_snaps.front().identity_json; + art.logical_inputs = flatten_items(group_snaps, &HostSnap::inputs); + art.lifecycle_events = flatten_items(group_snaps, &HostSnap::lifecycle); + art.physical_effects = flatten_items(group_snaps, &HostSnap::physical); + std::ostringstream obs; + obs << "{\"hostCount\":" << group_snaps.size() + << ",\"assertionDeltaPassed\":" << json_bool(passed) + << ",\"hosts\":" << json_array(observations) << "}"; + art.observations = obs.str(); + art.comparisons = "[]"; + return art; +} + +bool write_proof(const std::vector& scenarios, const char* sha) { + const char* dir = std::getenv("PINEFORGE_NATIVE_PROOF_OUTPUT"); + if (!dir || !*dir) return true; + std::ostringstream json; + json << "{\n \"schemaVersion\": \"pineforge-native-scenario-artifact/v1\",\n" + << " \"testName\": \"test_native_calendar_driver_contract\",\n" + << " \"scenarios\": [\n"; + for (std::size_t i = 0; i < scenarios.size(); ++i) { + const auto& s = scenarios[i]; + json << " {\n" + << " \"scenarioId\": " << json_escape(s.id) << ",\n" + << " \"status\": " << json_escape(s.status) << ",\n" + << " \"assertionsEnabled\": true,\n" + << " \"fixture\": { \"synthetic-source\": " << json_escape(sha) << " },\n" + << " \"nativeConfiguration\": " << s.native_configuration << ",\n" + << " \"runIdentity\": " << s.run_identity << ",\n" + << " \"calendar\": " << s.calendar << ",\n" + << " \"logicalInputs\": " << s.logical_inputs << ",\n" + << " \"lifecycleEvents\": " << s.lifecycle_events << ",\n" + << " \"physicalEffects\": " << s.physical_effects << ",\n" + << " \"observations\": " << s.observations << ",\n" + << " \"comparisons\": " << s.comparisons << "\n" + << " }" << (i + 1 == scenarios.size() ? "\n" : ",\n"); + } + json << " ]\n}\n"; + const std::string path = std::string(dir) + "/test_native_calendar_driver_contract.json"; + std::ofstream out(path.c_str(), std::ios::binary); + if (!out) return false; + out << json.str(); + return static_cast(out); +} + +void run_case(const char* id, const char* name, void (*body)()) { + scenario = name; + ++scenarios; + group_snaps.clear(); + next_host_ordinal = 0; + const int before = failures; + try { body(); } + catch (const std::exception& error) { + ++failures; + std::printf("FAIL [%s] unexpected exception: %s\n", scenario, error.what()); + } + catch (...) { + ++failures; + std::printf("FAIL [%s] unexpected nonstandard exception\n", scenario); + } + const bool passed = failures == before; + std::printf("%s [%s]\n", passed ? "PASS" : "FAIL", name); + if (proof_capture) arts.push_back(assemble_group(id, passed)); +} +} // namespace + +int main() { + const char* proof_dir = std::getenv("PINEFORGE_NATIVE_PROOF_OUTPUT"); + const char* proof_sha = nullptr; + if (proof_dir && *proof_dir) { +#ifdef NDEBUG + std::printf("FAIL proof output requested with NDEBUG; assertions would be compiled out\n"); + return 1; +#endif +#if !defined(PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256) + std::printf("FAIL PINEFORGE_NATIVE_PROOF_OUTPUT is set but " + "PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256 is not supplied\n"); + return 1; +#else + proof_sha = PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256; + if (!is_sha256_hex(proof_sha)) { + std::printf("FAIL PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256 is missing or not a 64-char lowercase digest\n"); + return 1; + } + proof_capture = true; +#endif + } + + run_case("C1-equal-timeframe-families-and-final-monthly-bars", + "C1 equal timeframe families and final monthly bars", + equal_timeframe_families); + run_case("C1-stable-counts-shifted-prefix-and-second-minute-pairing", + "C1 stable counts shifted prefix and second/minute pairing", + stable_count_and_cross_unit_grouping); + run_case("C1-sparse-D-to-W-M-opening-attribution", + "C1 sparse D-to-W/M opening attribution", + daily_to_week_and_month); + run_case("C1-C6-explicit-refusals-timeframe-arguments-empty-batch", + "C1/C6 explicit refusals timeframe arguments empty batch", + explicit_refusals_and_empty_batch); + run_case("C2-NY-actual-23-25-hour-daily-close-and-continuity", + "C2 NY actual 23/25-hour daily close and continuity", + ny_daily_dst); + run_case("C2-C3-NY-fixed-gap-and-fold-adjacency", + "C2/C3 NY fixed gap and fold adjacency", + ny_fixed_gap_and_fold_adjacency); + run_case("C3-lunch-quiet-canonical-slot-and-clipped-opening", + "C3 lunch quiet canonical slot and clipped opening", + lunch_quiet_slot); + run_case("C3-C4-real-off-session-prints-suppress-quiet-synthesis-and-replay", + "C3/C4 real off-session prints suppress quiet synthesis and replay", + lunch_real_off_session_and_replay); + run_case("C3-C4-nominal-clipped-labels-and-duplicate-refusal", + "C3/C4 nominal clipped labels and duplicate refusal", + nominal_and_clipped_confirmed_labels); + run_case("C3-Friday-close-Monday-opening-and-missing-slot-refusal", + "C3 Friday close Monday opening and missing-slot refusal", + friday_monday_continuity); + run_case("C3-overnight-first-origin-and-reordered-later-windows", + "C3 overnight first origin and reordered later windows", + overnight_cycle_origin_and_window_permutations); + run_case("C5-whole-W-to-M-and-2D-to-W-straddling-inputs", + "C5 whole W-to-M and 2D-to-W straddling inputs", + cross_calendar_straddles); + run_case("C7-same-overnight-cycle-date-for-nD-nW-nM", + "C7 same overnight cycle date for nD/nW/nM", + cycle_date_week_month_and_counts); + run_case("C8-masked-and-DST-empty-cycles-preserve-attribution", + "C8 masked and DST-empty cycles preserve attribution", + masked_and_gap_empty_cycle_attribution); + run_case("C9-partial-coarser-warmup-carries-into-confirmed-realtime", + "C9 partial coarser warmup carries into confirmed realtime", + partial_coarser_warmup_confirmed); + run_case("C9-RTH-scheduled-close-does-not-seal-unfinished-daily-warmup", + "C9 RTH scheduled close does not seal unfinished daily warmup", + rth_partial_daily_warmup_seal); + run_case("D1-same-timestamp-sequence-and-atomic-tick-array-refusal", + "D1 same timestamp sequence and atomic tick-array refusal", + ticks_same_timestamp_and_atomic_refusal); + run_case("D1-tick-finalization-cannot-replay-pre-entry-extrema", + "D1 tick finalization cannot replay pre-entry extrema", + no_preentry_tick_extrema_replay); + run_case("D2-carried-opening-eligibility-uses-immutable-birth-floor", + "D2 carried opening eligibility uses immutable birth floor", + quiet_birth_floor); + run_case("D3-confirmed-after-calculation-point-is-later-than-callback", + "D3 confirmed after-calculation point is later than callback", + confirmed_close_policy); + run_case("D4-external-birth-after-child-inputs-cannot-use-delayed-opening", + "D4 external birth after child inputs cannot use delayed opening", + delayed_open_after_external_birth); + run_case("D4-C9-mixed-warmup-ticks-quiet-aggregation-without-price-replay", + "D4/C9 mixed warmup ticks quiet aggregation without price replay", + mixed_warmup_tick_quiet_aggregation); + run_case("D5-partial-equal-TF-end-retains-actual-floor-and-source-time", + "D5 partial equal-TF end retains actual floor and source time", + partial_end_equal_clock_and_policy); + run_case("D5-partial-child-does-not-force-coarser-callback", + "D5 partial child does not force coarser callback", + partial_end_does_not_seal_coarser_script); + + if (proof_dir && *proof_dir) { + if (!write_proof(arts, proof_sha)) { + std::printf("FAIL could not write native scenario artifact\n"); + return 1; + } + } + + std::printf("%s native calendar driver: %d scenarios, %d checks, %d failures\n", + failures ? "FAIL" : "PASS", scenarios, checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_native_example_batch.cpp b/tests/test_native_example_batch.cpp new file mode 100644 index 000000000..e9795f4bb --- /dev/null +++ b/tests/test_native_example_batch.cpp @@ -0,0 +1,618 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +int checks = 0; +int failures = 0; + +#define CHECK(x) \ + do { \ + ++checks; \ + if (!(x)) { \ + ++failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + } \ + } while (0) + +void near(double a, double b) { + const bool equal = + std::abs(a - b) <= 1e-9 * std::max(1.0, std::max(std::abs(a), std::abs(b))); + if (!equal) std::printf("actual=%.17g expected=%.17g\n", a, b); + CHECK(equal); +} + +using create_fn = pf_strategy_t (*)(const char*); +using free_fn = void (*)(pf_strategy_t); +using run_fn = void (*)(pf_strategy_t, pf_bar_t*, int, pf_report_t*); +using run_full_fn = void (*)(pf_strategy_t, pf_bar_t*, int, const char*, const char*, + int, int, pf_magnifier_distribution_t, pf_report_t*); +using report_free_fn = void (*)(pf_report_t*); +using set_kv_fn = void (*)(pf_strategy_t, const char*, const char*); +using set_mag_fn = void (*)(pf_strategy_t, int); +using set_i_fn = void (*)(pf_strategy_t, int); +using set_i64_fn = void (*)(pf_strategy_t, int64_t); +using set_ii_fn = void (*)(pf_strategy_t, int, int); +using set_str_fn = void (*)(pf_strategy_t, const char*); +using set_kv_int_fn = int (*)(pf_strategy_t, const char*, const char*); +using set_d_fn = void (*)(pf_strategy_t, double); +using set_str_d_fn = void (*)(pf_strategy_t, const char*, double); +using set_fx_fn = int (*)(pf_strategy_t, const int64_t*, const double*, int); +using set_aux_fn = int (*)(pf_strategy_t, const pf_bar_t*, int, const char*); +using set_nat_feed_fn = int (*)(pf_strategy_t, const char*, const pf_bar_t*, int); +using abi_fn = int (*)(); +using version_fn = pf_version_t (*)(); +using contract_fn = int (*)(pf_strategy_t); +using configure_fn = int (*)(pf_strategy_t, const pf_native_run_spec_v1*); +using last_error_fn = const char* (*)(pf_strategy_t); +using last_status_fn = int (*)(pf_strategy_t); +using position_fn = double (*)(pf_strategy_t); +using equity_fn = double (*)(pf_strategy_t); +using bars_fn = int64_t (*)(pf_strategy_t); +using stream_begin_fn = int (*)(pf_strategy_t, const pf_bar_t*, int, const char*, const char*); +using stream_push_fn = int (*)(pf_strategy_t, const pf_bar_t*); +using stream_end_fn = int (*)(pf_strategy_t, int); +using stream_fill_fn = int (*)(pf_strategy_t, pf_report_t*); + +struct Abi { + void* handle = nullptr; + create_fn create = nullptr; + free_fn free_strategy = nullptr; + run_fn run = nullptr; + run_full_fn run_full = nullptr; + report_free_fn report_free = nullptr; + set_kv_fn set_input = nullptr; + set_kv_fn set_override = nullptr; + set_mag_fn set_magnifier_vw = nullptr; + set_i_fn set_trace = nullptr; + set_i64_fn set_trade_start = nullptr; + set_ii_fn set_realtime_tail = nullptr; + set_i_fn set_probe_tail = nullptr; + set_i_fn set_path_order = nullptr; + set_i_fn set_broker_hash = nullptr; + set_str_fn set_chart_tz = nullptr; + set_str_fn set_sym_tz = nullptr; + set_str_fn set_sym_session = nullptr; + set_str_fn set_sym_type = nullptr; + set_kv_int_fn set_sym_string = nullptr; + set_d_fn set_sym_mintick = nullptr; + set_d_fn set_sym_pointvalue = nullptr; + set_str_d_fn set_sym_metadata = nullptr; + set_fx_fn set_fx = nullptr; + set_aux_fn set_aux = nullptr; + set_nat_feed_fn set_native_feed = nullptr; + abi_fn abi_version = nullptr; + version_fn version_get = nullptr; + contract_fn contract = nullptr; + configure_fn configure = nullptr; + last_error_fn last_error = nullptr; + last_status_fn last_status = nullptr; + position_fn position_size = nullptr; + equity_fn current_equity = nullptr; + bars_fn script_bars = nullptr; + abi_fn stream_api_version = nullptr; + stream_begin_fn stream_begin = nullptr; + stream_push_fn stream_push_bar = nullptr; + stream_end_fn stream_end = nullptr; + stream_fill_fn stream_fill_report = nullptr; + abi_fn example_abi = nullptr; +}; + +void* require_sym(void* handle, const char* name) { + void* sym = dlsym(handle, name); + if (!sym) { + std::printf("FAIL missing symbol %s: %s\n", name, dlerror()); + ++failures; + } + ++checks; + return sym; +} + +template +T load(void* handle, const char* name) { + return reinterpret_cast(require_sym(handle, name)); +} + +Abi load_library(const char* path) { + Abi abi; + abi.handle = dlopen(path, RTLD_NOW | RTLD_LOCAL); + CHECK(abi.handle != nullptr); + if (!abi.handle) { + std::printf("dlopen %s: %s\n", path, dlerror()); + return abi; + } + std::printf("library=%s\n", path); + abi.create = load(abi.handle, "strategy_create"); + abi.free_strategy = load(abi.handle, "strategy_free"); + abi.run = load(abi.handle, "run_backtest"); + abi.run_full = load(abi.handle, "run_backtest_full"); + abi.report_free = load(abi.handle, "report_free"); + abi.set_input = load(abi.handle, "strategy_set_input"); + abi.set_override = load(abi.handle, "strategy_set_override"); + abi.set_magnifier_vw = load(abi.handle, "strategy_set_magnifier_volume_weighted"); + abi.set_trace = load(abi.handle, "strategy_set_trace_enabled"); + abi.set_trade_start = load(abi.handle, "strategy_set_trade_start_time"); + abi.set_realtime_tail = load(abi.handle, "strategy_set_realtime_tail"); + abi.set_probe_tail = load(abi.handle, "strategy_set_probe_suppress_tail_logic"); + abi.set_path_order = load(abi.handle, "strategy_set_path_order"); + abi.set_broker_hash = load(abi.handle, "strategy_set_broker_state_hash_recording"); + abi.set_chart_tz = load(abi.handle, "strategy_set_chart_timezone"); + abi.set_sym_tz = load(abi.handle, "strategy_set_syminfo_timezone"); + abi.set_sym_session = load(abi.handle, "strategy_set_syminfo_session"); + abi.set_sym_type = load(abi.handle, "strategy_set_syminfo_type"); + abi.set_sym_string = load(abi.handle, "strategy_set_syminfo_string"); + abi.set_sym_mintick = load(abi.handle, "strategy_set_syminfo_mintick"); + abi.set_sym_pointvalue = load(abi.handle, "strategy_set_syminfo_pointvalue"); + abi.set_sym_metadata = load(abi.handle, "strategy_set_syminfo_metadata"); + abi.set_fx = load(abi.handle, "strategy_set_account_currency_fx_series"); + abi.set_aux = load(abi.handle, "strategy_set_aux_security_feed"); + abi.set_native_feed = load(abi.handle, "strategy_set_native_security_feed"); + abi.abi_version = load(abi.handle, "pf_abi_version"); + abi.version_get = load(abi.handle, "pf_version_get"); + abi.contract = load(abi.handle, "strategy_execution_contract"); + abi.configure = load(abi.handle, "strategy_configure_native_v1"); + abi.last_error = load(abi.handle, "strategy_get_last_error"); + abi.last_status = load(abi.handle, "strategy_last_run_status"); + abi.position_size = load(abi.handle, "strategy_position_size"); + abi.current_equity = load(abi.handle, "strategy_current_equity"); + abi.script_bars = load(abi.handle, "strategy_script_bars_processed"); + abi.stream_api_version = load(abi.handle, "strategy_stream_api_version"); + abi.stream_begin = load(abi.handle, "strategy_stream_begin"); + abi.stream_push_bar = load(abi.handle, "strategy_stream_push_bar"); + abi.stream_end = load(abi.handle, "strategy_stream_end"); + abi.stream_fill_report = load(abi.handle, "strategy_stream_fill_report"); + abi.example_abi = load(abi.handle, "native_market_example_abi_version"); + if (abi.abi_version) { + std::printf("pf_abi_version=%d PF_ABI_VERSION=%d example_abi=%d stream_api=%d\n", + abi.abi_version(), PF_ABI_VERSION, + abi.example_abi ? abi.example_abi() : -1, + abi.stream_api_version ? abi.stream_api_version() : -1); + } + if (abi.version_get) { + const pf_version_t v = abi.version_get(); + std::printf("pf_version=%d.%d.%d sha=%s\n", v.major, v.minor, v.patch, + v.commit_sha ? v.commit_sha : ""); + } + std::printf("sym strategy_create=%p run_backtest=%p run_backtest_full=%p report_free=%p\n", + reinterpret_cast(abi.create), reinterpret_cast(abi.run), + reinterpret_cast(abi.run_full), reinterpret_cast(abi.report_free)); + std::printf("sym strategy_configure_native_v1=%p strategy_execution_contract=%p\n", + reinterpret_cast(abi.configure), reinterpret_cast(abi.contract)); + return abi; +} + +pf_native_run_spec_v1 complete_spec(const char* session_key, uint64_t run_number) { + pf_native_run_spec_v1 spec{}; + spec.struct_size = sizeof(spec); + spec.session_key = session_key; + spec.run_number = run_number; + spec.input_tf = "5"; + spec.script_tf = "5"; + spec.ticker = "X"; + spec.tickerid = "EXCHANGE:X"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = "native-market-example"; + spec.volumetype = "base"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = "UTC"; + spec.initial_capital = 10000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.slippage_ticks = 0; + spec.fee_kind = 2; // NativeFeeKind::CashPerExecution + spec.fee_value = 6.0; + spec.optional_mask = 0; + spec.close_execution = 0; // NextEligiblePoint + spec.allowed_open_directions = 3; // Both + spec.quantity_grid = 0.0; + spec.max_abs_units = 0.0; + spec.initial_margin_fraction = 0.0; + spec.max_open_lots = 0; + return spec; +} + +void five_minute_bars(pf_bar_t* bars) { + const double ohlc[5][4] = { + {100.0, 101.0, 99.0, 100.5}, + {102.0, 103.0, 101.0, 102.5}, + {103.0, 103.5, 102.5, 103.0}, + {103.5, 104.0, 103.0, 103.5}, + {104.0, 105.0, 103.5, 104.5}, + }; + for (int i = 0; i < 5; ++i) { + bars[i].open = ohlc[i][0]; + bars[i].high = ohlc[i][1]; + bars[i].low = ohlc[i][2]; + bars[i].close = ohlc[i][3]; + bars[i].volume = 1.0; + bars[i].timestamp = static_cast(i) * 300000; + } +} + +bool error_text(const Abi& abi, pf_strategy_t s, const char* needle) { + if (!abi.last_error) return false; + const char* text = abi.last_error(s); + if (!text) return false; + return std::strstr(text, needle) != nullptr; +} + +void expect_closed_round_trip(const pf_report_t& report) { + CHECK(report.trades_len == 1); + CHECK(report.total_trades == 1); + CHECK(report.trades != nullptr); + CHECK(report.bar_magnifier_enabled == 0); + std::printf("report diagnostics input_bars=%lld script_bars=%lld equity_len=%lld\n", + static_cast(report.input_bars_processed), + static_cast(report.script_bars_processed), + static_cast(report.equity_curve_len)); + if (!report.trades || report.trades_len < 1) return; + const pf_trade_t t = report.trades[0]; + CHECK(t.is_long == 1); + CHECK(t.open_at_end == 0); + near(t.qty, 1.0); + near(t.entry_price, 102.0); + near(t.exit_price, 104.0); + near(t.commission, 12.0); + near(t.pnl, -10.0); + CHECK(t.entry_time == 300000); + CHECK(t.exit_time == 1200000); + CHECK(t.entry_bar_index == 1); + CHECK(t.exit_bar_index == 4); + near(report.net_profit, -10.0); +} + +void expect_zeroed_ownership(const pf_report_t& report) { + CHECK(report.trades == nullptr); + CHECK(report.trades_len == 0); + CHECK(report.security_diag == nullptr); + CHECK(report.security_diag_len == 0); + CHECK(report.trace == nullptr); + CHECK(report.trace_len == 0); + CHECK(report.trace_names == nullptr); + CHECK(report.trace_names_len == 0); + CHECK(report.equity_curve == nullptr); + CHECK(report.equity_curve_len == 0); + CHECK(report.broker_state_hash == nullptr); + CHECK(report.broker_state_hash_len == 0); +} + +bool nonempty_error(const Abi& abi, pf_strategy_t s) { + const char* text = abi.last_error ? abi.last_error(s) : nullptr; + return text != nullptr && text[0] != '\0'; +} + +bool successful_round_trip(const pf_report_t& report) { + return report.trades_len == 1 && report.trades != nullptr + && report.trades[0].open_at_end == 0 + && std::abs(report.trades[0].pnl + 10.0) <= 1e-9; +} + +void expect_source_setter_failed(const Abi& abi, pf_bar_t* bars, const char* session, + void (*apply)(const Abi&, pf_strategy_t), + const char* needle, bool example_owned) { + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + auto spec = complete_spec(session, 1); + CHECK(abi.configure(s, &spec) == 0); + const double pos = abi.position_size(s); + const double eq = abi.current_equity(s); + apply(abi, s); + const char* err = abi.last_error(s); + std::printf("setter %s last_error=%s\n", session, err ? err : "(null)"); + if (example_owned) { + CHECK(err != nullptr && err[0] != '\0'); + if (needle) CHECK(std::strstr(err, needle) != nullptr); + } + near(abi.position_size(s), pos); + near(abi.current_equity(s), eq); + pf_report_t report{}; + abi.run_full(s, bars, 5, "5", "5", 0, 4, PF_MAGNIFIER_ENDPOINTS, &report); + CHECK(nonempty_error(abi, s)); + CHECK(!successful_round_trip(report)); + near(abi.position_size(s), pos); + near(abi.current_equity(s), eq); + spec = complete_spec(session, 2); + CHECK(abi.configure(s, &spec) == -1); + CHECK(nonempty_error(abi, s)); + abi.report_free(&report); + abi.free_strategy(s); +} + +void apply_input(const Abi& abi, pf_strategy_t s) { abi.set_input(s, "Fast Length", "12"); } +void apply_override(const Abi& abi, pf_strategy_t s) { + abi.set_override(s, "initial_capital", "1"); +} +void apply_magnifier_vw(const Abi& abi, pf_strategy_t s) { abi.set_magnifier_vw(s, 1); } +void apply_trace(const Abi& abi, pf_strategy_t s) { abi.set_trace(s, 1); } +void apply_trade_start(const Abi& abi, pf_strategy_t s) { abi.set_trade_start(s, 1); } +void apply_realtime_tail(const Abi& abi, pf_strategy_t s) { abi.set_realtime_tail(s, 1, 1); } +void apply_probe_tail(const Abi& abi, pf_strategy_t s) { abi.set_probe_tail(s, 1); } +void apply_path_order(const Abi& abi, pf_strategy_t s) { abi.set_path_order(s, 1); } +void apply_broker_hash(const Abi& abi, pf_strategy_t s) { abi.set_broker_hash(s, 1); } +void apply_chart_tz(const Abi& abi, pf_strategy_t s) { abi.set_chart_tz(s, "UTC"); } +void apply_sym_tz(const Abi& abi, pf_strategy_t s) { abi.set_sym_tz(s, "UTC"); } +void apply_sym_session(const Abi& abi, pf_strategy_t s) { abi.set_sym_session(s, "24x7"); } +void apply_sym_type(const Abi& abi, pf_strategy_t s) { abi.set_sym_type(s, "crypto"); } +void apply_sym_string(const Abi& abi, pf_strategy_t s) { + abi.set_sym_string(s, "ticker", "X"); +} +void apply_sym_mintick(const Abi& abi, pf_strategy_t s) { abi.set_sym_mintick(s, 0.01); } +void apply_sym_pointvalue(const Abi& abi, pf_strategy_t s) { abi.set_sym_pointvalue(s, 1.0); } +void apply_sym_metadata(const Abi& abi, pf_strategy_t s) { + abi.set_sym_metadata(s, "shares_outstanding_total", 1.0); +} +void apply_fx(const Abi& abi, pf_strategy_t s) { + const int64_t ts = 0; + const double rate = 1.0; + abi.set_fx(s, &ts, &rate, 1); +} +void apply_aux(const Abi& abi, pf_strategy_t s) { + pf_bar_t bar{}; + bar.open = bar.high = bar.close = 100.0; + bar.low = 99.0; + bar.volume = 1.0; + abi.set_aux(s, &bar, 1, "1"); +} +void apply_native_feed(const Abi& abi, pf_strategy_t s) { + pf_bar_t bar{}; + bar.open = bar.high = bar.close = 100.0; + bar.low = 99.0; + bar.volume = 1.0; + abi.set_native_feed(s, "D", &bar, 1); +} + +bool ready(const Abi& abi) { + return abi.handle && abi.create && abi.free_strategy && abi.run && abi.run_full + && abi.report_free && abi.configure && abi.contract && abi.last_error + && abi.position_size && abi.stream_begin && abi.stream_push_bar + && abi.stream_end && abi.stream_fill_report && abi.set_input + && abi.set_override && abi.set_magnifier_vw && abi.set_trace + && abi.set_trade_start && abi.set_chart_tz && abi.set_fx && abi.set_aux + && abi.set_native_feed && abi.abi_version; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s /path/to/native-market-example\n", argv[0]); + return 2; + } + + Abi abi = load_library(argv[1]); + if (!ready(abi)) { + std::printf("FAIL %d checks %d failures (library not usable)\n", checks, failures); + if (abi.handle) dlclose(abi.handle); + return 1; + } + CHECK(abi.abi_version() == PF_ABI_VERSION); + CHECK(abi.stream_api_version() == 1); + CHECK(abi.example_abi() == PF_ABI_VERSION); + + pf_bar_t bars[5]; + five_minute_bars(bars); + + { + // Malformed transport arrays still reach the engine's documented + // diagnostic path and do not consume a Ready native run. + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + auto spec = complete_spec("native-example-warmup-shape", 1); + CHECK(abi.configure(s, &spec) == 0); + const double equity_before = abi.current_equity(s); + CHECK(abi.stream_begin(s, nullptr, 1, "5", "5") == -1); + CHECK(error_text(abi, s, "warmup")); + CHECK(abi.stream_begin(s, bars, -1, "5", "5") == -1); + CHECK(error_text(abi, s, "warmup")); + near(abi.position_size(s), 0.0); + near(abi.current_equity(s), equity_before); + CHECK(abi.stream_begin(s, bars, 1, "5", "5") == 0); + CHECK(abi.stream_end(s, 0) == 0); + abi.free_strategy(s); + } + + { + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + CHECK(abi.contract(s) == 2); + auto spec = complete_spec("native-example-batch", 1); + CHECK(spec.struct_size == sizeof(pf_native_run_spec_v1)); + CHECK(abi.configure(s, &spec) == 0); + CHECK(abi.last_error(s) != nullptr && abi.last_error(s)[0] == '\0'); + + pf_report_t report{}; + abi.run_full(s, bars, 5, "5", "5", 0, 4, PF_MAGNIFIER_ENDPOINTS, &report); + const char* err = abi.last_error(s); + if (err && err[0] != '\0') std::printf("run_full last_error=%s\n", err); + CHECK(err == nullptr || err[0] == '\0'); + CHECK(abi.last_status(s) == 0); + near(abi.position_size(s), 0.0); + expect_closed_round_trip(report); + near(abi.current_equity(s), 9990.0); + std::printf("strategy_script_bars_processed=%lld position=%g equity=%g\n", + static_cast(abi.script_bars(s)), abi.position_size(s), + abi.current_equity(s)); + + abi.report_free(&report); + expect_zeroed_ownership(report); + abi.report_free(&report); + expect_zeroed_ownership(report); + abi.report_free(nullptr); + + spec = complete_spec("native-example-batch", 2); + CHECK(abi.configure(s, &spec) == 0); + pf_report_t second{}; + abi.run_full(s, bars, 5, "", "", 0, 4, PF_MAGNIFIER_ENDPOINTS, &second); + err = abi.last_error(s); + if (err && err[0] != '\0') std::printf("second run last_error=%s\n", err); + CHECK(err == nullptr || err[0] == '\0'); + expect_closed_round_trip(second); + abi.report_free(&second); + abi.free_strategy(s); + } + + { + // Wrapper preflight preserves the output report on unsupported magnifier + // arguments. Does not latch Failed; Ready remains usable. + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + auto spec = complete_spec("native-example-magnifier-wrapper", 1); + CHECK(abi.configure(s, &spec) == 0); + const double equity_before = abi.current_equity(s); + pf_report_t poisoned; + std::memset(&poisoned, 0x5a, sizeof(poisoned)); + abi.run_full(s, bars, 5, "5", "5", 1, 4, PF_MAGNIFIER_ENDPOINTS, &poisoned); + CHECK(error_text(abi, s, "magnifier")); + unsigned char* bytes = reinterpret_cast(&poisoned); + bool untouched = true; + for (size_t i = 0; i < sizeof(poisoned); ++i) { + if (bytes[i] != 0x5a) { + untouched = false; + break; + } + } + CHECK(untouched); + near(abi.position_size(s), 0.0); + near(abi.current_equity(s), equity_before); + + std::memset(&poisoned, 0x5a, sizeof(poisoned)); + abi.run_full(s, bars, 5, "5", "5", 0, 8, PF_MAGNIFIER_UNIFORM, &poisoned); + CHECK(error_text(abi, s, "magnifier")); + bytes = reinterpret_cast(&poisoned); + untouched = true; + for (size_t i = 0; i < sizeof(poisoned); ++i) { + if (bytes[i] != 0x5a) { + untouched = false; + break; + } + } + CHECK(untouched); + pf_report_t recovered{}; + abi.run_full(s, bars, 5, "5", "5", 0, 4, PF_MAGNIFIER_ENDPOINTS, &recovered); + const char* recovered_err = abi.last_error(s); + if (recovered_err && recovered_err[0] != '\0') { + std::printf("magnifier-wrapper recovered last_error=%s\n", recovered_err); + } + CHECK(recovered_err == nullptr || recovered_err[0] == '\0'); + expect_closed_round_trip(recovered); + abi.report_free(&recovered); + abi.free_strategy(s); + } + + { + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + auto spec = complete_spec("native-example-bad-bars", 1); + CHECK(abi.configure(s, &spec) == 0); + pf_bar_t bad[5]; + five_minute_bars(bad); + bad[2].timestamp = 600000 + 1; + pf_report_t invalid{}; + abi.run_full(s, bad, 5, "5", "5", 0, 4, PF_MAGNIFIER_ENDPOINTS, &invalid); + const char* invalid_err = abi.last_error(s); + CHECK(invalid_err != nullptr && invalid_err[0] != '\0'); + CHECK(std::strstr(invalid_err, "canonical") != nullptr + || std::strstr(invalid_err, "aligned") != nullptr + || std::strstr(invalid_err, "increasing") != nullptr + || std::strstr(invalid_err, "native bar") != nullptr); + near(abi.position_size(s), 0.0); + CHECK(!successful_round_trip(invalid)); + abi.report_free(&invalid); + pf_report_t recovered{}; + abi.run_full(s, bars, 5, "5", "5", 0, 4, PF_MAGNIFIER_ENDPOINTS, &recovered); + const char* recovered_err = abi.last_error(s); + if (recovered_err && recovered_err[0] != '\0') { + std::printf("bad-bars recovered last_error=%s\n", recovered_err); + } + CHECK(recovered_err == nullptr || recovered_err[0] == '\0'); + expect_closed_round_trip(recovered); + abi.report_free(&recovered); + abi.free_strategy(s); + } + + { + struct Case { + const char* session; + void (*apply)(const Abi&, pf_strategy_t); + const char* needle; + bool example_owned; + }; + const Case cases[] = { + {"set-input", apply_input, "set_input", true}, + {"set-override", apply_override, "source mutation", true}, + {"set-magnifier-vw", apply_magnifier_vw, "set_magnifier_volume_weighted", true}, + {"set-trace", apply_trace, nullptr, false}, + {"set-trade-start", apply_trade_start, nullptr, false}, + {"set-realtime-tail", apply_realtime_tail, nullptr, false}, + {"set-probe-tail", apply_probe_tail, nullptr, false}, + {"set-path-order", apply_path_order, nullptr, false}, + {"set-broker-hash", apply_broker_hash, nullptr, false}, + {"set-chart-tz", apply_chart_tz, nullptr, false}, + {"set-sym-tz", apply_sym_tz, nullptr, false}, + {"set-sym-session", apply_sym_session, nullptr, false}, + {"set-sym-type", apply_sym_type, nullptr, false}, + {"set-sym-string", apply_sym_string, nullptr, false}, + {"set-sym-mintick", apply_sym_mintick, nullptr, false}, + {"set-sym-pointvalue", apply_sym_pointvalue, nullptr, false}, + {"set-sym-metadata", apply_sym_metadata, nullptr, false}, + {"set-fx", apply_fx, nullptr, false}, + {"set-aux", apply_aux, nullptr, false}, + {"set-native-feed", apply_native_feed, nullptr, false}, + }; + for (const auto& c : cases) { + expect_source_setter_failed(abi, bars, c.session, c.apply, c.needle, + c.example_owned); + } + } + + { + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + auto spec = complete_spec("native-example-simple", 1); + CHECK(abi.configure(s, &spec) == 0); + pf_report_t report{}; + abi.run(s, bars, 5, &report); + const char* err = abi.last_error(s); + if (err && err[0] != '\0') std::printf("run_backtest last_error=%s\n", err); + CHECK(err == nullptr || err[0] == '\0'); + expect_closed_round_trip(report); + abi.report_free(&report); + abi.free_strategy(s); + } + + { + pf_strategy_t s = abi.create(nullptr); + CHECK(s != nullptr); + auto spec = complete_spec("native-example-stream", 1); + CHECK(abi.configure(s, &spec) == 0); + CHECK(abi.stream_begin(s, bars, 1, "5", "5") == 0); + for (int i = 1; i < 5; ++i) CHECK(abi.stream_push_bar(s, &bars[i]) == 0); + CHECK(abi.stream_end(s, 0) == 0); + pf_report_t report{}; + CHECK(abi.stream_fill_report(s, &report) == 0); + const char* err = abi.last_error(s); + if (err && err[0] != '\0') std::printf("stream last_error=%s\n", err); + CHECK(err == nullptr || err[0] == '\0'); + expect_closed_round_trip(report); + near(abi.position_size(s), 0.0); + abi.report_free(&report); + abi.free_strategy(s); + } + + abi.free_strategy(nullptr); + dlclose(abi.handle); + std::printf("%s %d checks %d failures\n", failures ? "FAIL" : "PASS", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_native_host_repairs.cpp b/tests/test_native_host_repairs.cpp new file mode 100644 index 000000000..08250c879 --- /dev/null +++ b/tests/test_native_host_repairs.cpp @@ -0,0 +1,1727 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef PINEFORGE_HAS_NATIVE_STRATEGY_HOST_V12 +#error "native strategy host must fail closed against an unversioned epoch" +#endif + +using namespace pineforge; +using pineforge::native_order::Request; +using pineforge::order_action::Transact; + +namespace { +int checks = 0; +int failures = 0; +#define CHECK(x) \ + do { \ + ++checks; \ + if (!(x)) { \ + ++failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + } \ + } while (0) + +void near(double a, double b) { + const bool equal = std::abs(a - b) <= 1e-9 * std::max(1.0, std::max(std::abs(a), std::abs(b))); + if (!equal) std::printf("actual=%.17g expected=%.17g\n", a, b); + CHECK(equal); +} + +double bits_to_double(std::uint64_t bits) { + double value = 0.0; + std::memcpy(&value, &bits, sizeof value); + return value; +} + +NativeRunSpec spec_for(const std::string& key, uint64_t run) { + NativeRunSpec spec; + spec.identity.session_key = key; + spec.identity.run_number = run; + spec.input_tf = "1"; + spec.script_tf = "1"; + spec.ticker = "X"; + spec.tickerid = "EXCHANGE:X"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = "native"; + spec.volumetype = "base"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = "UTC"; + spec.initial_capital = 10000; + spec.point_value = 1; + spec.account_fx = 1; + spec.price_tick = 0.01; + spec.fee_kind = NativeFeeKind::CashPerExecution; + spec.fee_value = 6; + return spec; +} + +Bar bar_at(int64_t open_ms, double o, double h, double l, double c) { + return Bar{o, h, l, c, 1.0, open_ms}; +} + +class EmptyHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++callbacks; } +}; + +class ThrowHost final : public NativeStrategyHost { +public: + bool throw_next = true; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + if (throw_next) throw std::runtime_error("callback boom"); + } +}; + +class CommentHost final : public NativeStrategyHost { +public: + std::string comment; + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + if (callbacks == 1) { + submit_market(Request{Transact{1.0}, "buy", comment}); + } + } +}; + +class AfterCalcHost final : public NativeStrategyHost { +public: + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + if (physical_position().lot_count == 0) { + submit_market(Request{Transact{1.0}, "buy", ""}); + } + } + double runup() const { return open_trade_max_runup(0); } + double drawdown() const { return open_trade_max_drawdown(0); } +}; + +class CountHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++callbacks; } +}; + +class SubmitRealtimeHost final : public NativeStrategyHost { +public: + native_order::RequestHandle live{}; + bool submitted = false; + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++callbacks; } + double runup() const { return open_trade_max_runup(0); } +}; + +class RecordHost final : public NativeStrategyHost { +public: + int callbacks = 0; + std::vector bars; + std::vector contexts; + bool buy_on_first = false; + void on_native_bar(const Bar& bar, const NativeDecisionContext& context) override { + ++callbacks; + bars.push_back(bar); + contexts.push_back(context); + if (buy_on_first && callbacks == 1) { + submit_market(Request{Transact{1.0}, "buy", ""}); + } + } + double runup() const { return open_trade_max_runup(0); } +}; + +class NanHost final : public NativeStrategyHost { +public: + double qty = 0.0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + submit_market(Request{Transact{qty}, "bad", ""}); + } +}; + +class AbortHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + if (callbacks == 1) request_abort(); + } +}; + +class BeginAbortHost final : public NativeStrategyHost { +public: + int bars = 0; + void on_native_run_begin() override { request_abort(); } + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++bars; } +}; + +class ReenterHost final : public NativeStrategyHost { +public: + enum class Action { + None = 0, + End, + Advance, + PushBar, + PushTick, + PushTicks, + Run, + StreamBegin, + }; + Action from_begin = Action::None; + Action from_bar = Action::None; + Bar nested = bar_at(120000, 110, 111, 109, 110); + TradeTick nested_tick{120000, 1, 110.0, 1.0}; + bool nested_ok = true; + int callbacks = 0; + int begins = 0; + + void on_native_run_begin() override { + ++begins; + nested_ok = fire(from_begin); + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + nested_ok = fire(from_bar); + } + + bool fire(Action action) { + switch (action) { + case Action::None: + return true; + case Action::End: + return stream_end(false); + case Action::Advance: + return stream_advance_time(180000); + case Action::PushBar: + return stream_push_bar(nested); + case Action::PushTick: + return stream_push_tick(nested_tick); + case Action::PushTicks: + return stream_push_ticks(&nested_tick, 1); + case Action::Run: + run(&nested, 1); + return native_state().kind != NativeLifecycleKind::Failed; + case Action::StreamBegin: + return stream_begin(&nested, 1, "", ""); + } + return true; + } +}; + +class ReenterEndAfterCalcHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + submit_market(Request{Transact{1.0}, "reenter-buy", ""}); + stream_end(false); + } +}; + +int applied_fill_count(const NativeStrategyHost& host) { + int n = 0; + for (const auto& event : host.native_events(0)) { + if (event.command + && std::holds_alternative(*event.command)) { + ++n; + } + } + return n; +} + +class BeginSubmitHost final : public NativeStrategyHost { +public: + bool threw = false; + native_order::SubmitStatus status = native_order::SubmitStatus::Rejected; + int64_t floor_at_begin = 0; + int64_t state_floor_at_begin = 0; + int64_t birth_floor = 0; + int callbacks = 0; + void on_native_run_begin() override { + floor_at_begin = native_decision_floor(); + state_floor_at_begin = native_state().decision_floor_ms; + try { + status = submit_market(Request{Transact{1.0}, "begin", ""}).status; + } catch (...) { + threw = true; + } + for (const auto& event : native_events(0)) { + if (!event.command) continue; + if (const auto* accepted = + std::get_if(&*event.command)) { + birth_floor = accepted->birth.decision_time_lower_bound; + } + } + } + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++callbacks; } +}; + +class NonstandardThrowHost final : public NativeStrategyHost { +public: + void on_native_bar(const Bar&, const NativeDecisionContext&) override { throw 42; } +}; + +class BeginNonstandardThrowHost final : public NativeStrategyHost { +public: + int bars = 0; + void on_native_run_begin() override { throw 42; } + void on_native_bar(const Bar&, const NativeDecisionContext&) override { ++bars; } +}; + +const native_order::ExecutionAppliedEvent* first_applied( + const std::vector& events) { + for (const auto& event : events) { + if (!event.command) continue; + if (const auto* applied = + std::get_if(&*event.command)) { + return applied; + } + } + return nullptr; +} + +} // namespace + +int main() { + { + const std::string name = typeid(NativeStrategyHost).name(); + CHECK(name.find("engine_script_run_v12") != std::string::npos); + CHECK(name.find("NativeStrategyHost") != std::string::npos); + } + { + NativeRunSpec spec = spec_for("preflight-empty-batch", 1); + CHECK(preflight_native_inputs(spec, nullptr, 0, NativeInputPolicy::Batch)); + CHECK(preflight_native_inputs(spec, nullptr, 0, NativeInputPolicy::StreamWarmup)); + Bar bars[2] = {bar_at(60000, 100, 101, 99, 100), bar_at(120000, 101, 102, 100, 101)}; + CHECK(preflight_native_inputs(spec, bars, 2, NativeInputPolicy::Batch)); + Bar mid = bar_at(90000, 100, 101, 99, 100); + const auto off = preflight_native_inputs(spec, &mid, 1, NativeInputPolicy::Batch); + CHECK(off.error == NativeInputPreflightError::OffGridLabel); + CHECK(off.index == 0); + } + + { + NativeRunSpec spec = spec_for("preflight-rth-gap", 1); + spec.session = "0930-1600:23456"; + const int64_t fri_1559 = 1749225540000LL; + const int64_t mon_0930 = 1749461400000LL; + const int64_t mon_1100 = 1749466800000LL; + Bar contiguous[2] = { + bar_at(fri_1559, 100, 101, 99, 100), + bar_at(mon_0930, 101, 102, 100, 101), + }; + Bar skipped[2] = { + bar_at(fri_1559, 100, 101, 99, 100), + bar_at(mon_1100, 101, 102, 100, 101), + }; + const auto batch_sparse = preflight_native_inputs( + spec, skipped, 2, NativeInputPolicy::Batch); + CHECK(batch_sparse.ok()); + const auto stream_gap = preflight_native_inputs( + spec, skipped, 2, NativeInputPolicy::StreamWarmup); + CHECK(stream_gap.error == NativeInputPreflightError::InSessionGap); + CHECK(stream_gap.index == 1); + CHECK(preflight_native_inputs(spec, contiguous, 2, NativeInputPolicy::StreamWarmup)); + } + + { + NativeRunSpec spec = spec_for("preflight-lunch-slot", 1); + spec.session = "0930-1130,1300-1600"; + spec.input_tf = "60"; + spec.script_tf = "60"; + const int64_t nominal = 1749213000000LL; // 12:30 + const int64_t eligible = 1749214800000LL; // 13:00 + const int64_t mid = 1749213900000LL; // 12:45 + auto cal = native_calendar::parse_session(spec.session, spec.timezone); + auto tf = native_calendar::parse_timeframe("60"); + CHECK(cal.has_value()); + CHECK(tf.has_value()); + auto slot_a = native_calendar::interval_containing(*cal, *tf, nominal); + auto slot_b = native_calendar::interval_containing(*cal, *tf, eligible); + CHECK(slot_a.has_value()); + CHECK(slot_b.has_value()); + CHECK(slot_a->open_ms == slot_b->open_ms); + CHECK(native_confirmed_bar_label_admitted(*slot_a, nominal)); + CHECK(native_confirmed_bar_label_admitted(*slot_a, eligible)); + CHECK(!native_confirmed_bar_label_admitted(*slot_a, mid)); + Bar one = bar_at(nominal, 100, 101, 99, 100); + CHECK(preflight_native_inputs(spec, &one, 1, NativeInputPolicy::Batch)); + Bar clip = bar_at(eligible, 100, 101, 99, 100); + CHECK(preflight_native_inputs(spec, &clip, 1, NativeInputPolicy::Batch)); + Bar both[2] = {bar_at(nominal, 100, 101, 99, 100), + bar_at(eligible, 101, 102, 100, 101)}; + const auto overlap = preflight_native_inputs(spec, both, 2, NativeInputPolicy::Batch); + CHECK(overlap.error == NativeInputPreflightError::OverlappingSlot); + Bar off = bar_at(mid, 100, 101, 99, 100); + CHECK(preflight_native_inputs(spec, &off, 1, NativeInputPolicy::Batch).error + == NativeInputPreflightError::OffGridLabel); + } + + { + ThrowHost host; + auto spec = spec_for("callback-failure-witness", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[1] = {bar_at(60000, 100, 101, 99, 100)}; + host.run(bars, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::CallbackException); + const auto first = host.native_state().failure; + bool threw = false; + try { + host.set_input("x", "1"); + } catch (...) { + threw = true; + } + CHECK(threw); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == first.code); + CHECK(host.native_state().failure.operation == first.operation); + CHECK(host.native_state().failure.ordinal == first.ordinal); + } + + { + EmptyHost host; + auto spec = spec_for("cabi-no-exception", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + strategy_set_trace_enabled(reinterpret_cast(&host), 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::UnsupportedSource); + } + + { + EmptyHost empty_type; + EmptyHost crypto_type; + auto a = spec_for("empty-type-a", 1); + auto b = spec_for("empty-type-a", 1); + a.type.clear(); + CHECK(empty_type.configure_native(a).status == NativeSetupStatus::Applied); + CHECK(crypto_type.configure_native(b).status == NativeSetupStatus::Applied); + empty_type.run(nullptr, 0); + crypto_type.run(nullptr, 0); + CHECK(empty_type.native_state().kind == NativeLifecycleKind::Completed); + CHECK(crypto_type.native_state().kind == NativeLifecycleKind::Completed); + CHECK(empty_type.native_state().spec != nullptr); + CHECK(empty_type.native_state().spec->type.empty()); + CHECK(crypto_type.native_state().spec->type == "crypto"); + CHECK(empty_type.native_continuation_hash() != crypto_type.native_continuation_hash()); + near(empty_type.physical_position().signed_units, 0.0); + near(crypto_type.physical_position().signed_units, 0.0); + } + + { + EmptyHost reused; + EmptyHost fresh; + auto spec = spec_for("empty-type-reuse", 1); + spec.type.clear(); + CHECK(reused.configure_native(spec).status == NativeSetupStatus::Applied); + reused.run(nullptr, 0); + CHECK(reused.native_state().kind == NativeLifecycleKind::Completed); + auto spec2 = spec; + spec2.identity.run_number = 2; + CHECK(reused.configure_native(spec2).status == NativeSetupStatus::Applied); + reused.run(nullptr, 0); + CHECK(fresh.configure_native(spec2).status == NativeSetupStatus::Applied); + fresh.run(nullptr, 0); + CHECK(reused.native_state().kind == NativeLifecycleKind::Completed); + CHECK(fresh.native_state().kind == NativeLifecycleKind::Completed); + CHECK(reused.native_state().spec->type.empty()); + CHECK(fresh.native_state().spec->type.empty()); + } + + { + CommentHost a; + CommentHost b; + a.comment = "alpha"; + b.comment = "beta"; + auto spec = spec_for("hash-comment-metadata", 1); + CHECK(a.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(b.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 102, 103, 101, 102), + }; + a.run(bars, 2); + b.run(bars, 2); + CHECK(a.native_state().kind == NativeLifecycleKind::Completed); + CHECK(b.native_state().kind == NativeLifecycleKind::Completed); + near(a.physical_position().signed_units, 1.0); + near(b.physical_position().signed_units, 1.0); + CHECK(a.native_continuation_hash() != b.native_continuation_hash()); + const auto events_a = a.native_events(0); + const auto events_b = b.native_events(0); + const auto* applied_a = first_applied(events_a); + const auto* applied_b = first_applied(events_b); + CHECK(applied_a != nullptr); + CHECK(applied_b != nullptr); + if (applied_a && applied_b) { + near(applied_a->current_ticket, 6.0); + near(applied_b->current_ticket, 6.0); + near(applied_a->resolved_price, applied_b->resolved_price); + } + } + + { + NanHost a; + NanHost b; + a.qty = bits_to_double(0x7ff8000000000001ULL); + b.qty = bits_to_double(0x7ff8000000000002ULL); + auto spec = spec_for("hash-nan-payload", 1); + CHECK(a.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(b.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[1] = {bar_at(60000, 100, 101, 99, 100)}; + a.run(bars, 1); + b.run(bars, 1); + CHECK(a.native_continuation_hash() != b.native_continuation_hash()); + near(a.physical_position().signed_units, 0.0); + near(b.physical_position().signed_units, 0.0); + } + + { + NanHost plus; + NanHost minus; + plus.qty = 0.0; + minus.qty = bits_to_double(0x8000000000000000ULL); + auto spec = spec_for("hash-signed-zero", 1); + CHECK(plus.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(minus.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[1] = {bar_at(60000, 100, 101, 99, 100)}; + plus.run(bars, 1); + minus.run(bars, 1); + CHECK(plus.native_continuation_hash() != minus.native_continuation_hash()); + } + + { + AfterCalcHost host; + auto spec = spec_for("aftercalc-no-prior-hl", 1); + spec.close_execution = NativeCloseExecution::AfterCalculation; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[1] = {bar_at(60000, 100, 110, 90, 101)}; + host.run(bars, 1); + CHECK(host.last_error().empty()); + CHECK(host.physical_position().lot_count == 1); + near(host.runup(), 0.0); + near(host.drawdown(), 0.0); + } + + { + CountHost complete; + CountHost trailing; + auto spec = spec_for("coarser-seal-complete", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(complete.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(trailing.configure_native(spec).status == NativeSetupStatus::Applied); + Bar five[5] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + bar_at(180000, 100, 101, 99, 100), + bar_at(240000, 100, 101, 99, 100), + }; + complete.run(five, 5); + trailing.run(five, 3); + CHECK(complete.last_error().empty()); + CHECK(trailing.last_error().empty()); + CHECK(complete.callbacks == 1); + CHECK(trailing.callbacks == 0); + CHECK(complete.native_state().kind == NativeLifecycleKind::Completed); + CHECK(trailing.native_state().kind == NativeLifecycleKind::Completed); + } + + { + SubmitRealtimeHost host; + auto spec = spec_for("warmup-partial-then-seal", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + }; + CHECK(host.stream_begin(warmup, 3, "1", "5")); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.callbacks == 0); + CHECK(host.native_decision_floor() >= 180000); + auto submitted = host.submit_market(Request{Transact{1.0}, "buy", ""}); + CHECK(submitted.status == native_order::SubmitStatus::Accepted); + CHECK(host.stream_push_bar(bar_at(180000, 100, 101, 99, 100))); + CHECK(host.stream_push_bar(bar_at(240000, 100, 101, 99, 100))); + CHECK(host.callbacks == 1); + bool opening_fill = false; + for (const auto& event : host.native_events(0)) { + if (!event.command) continue; + if (const auto* applied = + std::get_if(&*event.command)) { + if (applied->effective_time_ms == 0 + && applied->provenance + == static_cast(NativePriceProvenance::ModeledOHLCOpen)) { + opening_fill = true; + } + } + } + CHECK(!opening_fill); + CHECK(host.stream_end(false)); + } + + { + EmptyHost host; + auto spec = spec_for("stream-gap-push", 1); + spec.session = "0930-1600:23456"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[1] = {bar_at(1749225540000LL, 100, 101, 99, 100)}; + CHECK(host.stream_begin(warmup, 1, "", "")); + CHECK(!host.stream_push_bar(bar_at(1749466800000LL, 101, 102, 100, 101))); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.stream_push_bar(bar_at(1749461400000LL, 101, 102, 100, 101))); + CHECK(host.stream_end(false)); + } + + { + CountHost host; + auto spec = spec_for("seven-input-warmup-partial", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[7] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + bar_at(180000, 100, 101, 99, 100), + bar_at(240000, 100, 101, 99, 100), + bar_at(300000, 100, 101, 99, 100), + bar_at(360000, 100, 101, 99, 100), + }; + CHECK(host.stream_begin(warmup, 7, "1", "5")); + CHECK(host.callbacks == 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.stream_end(false)); + CHECK(host.callbacks == 1); + } + + { + SubmitRealtimeHost host; + auto spec = spec_for("d2-carried-open-birth", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[1] = {bar_at(0, 100, 101, 99, 100)}; + CHECK(host.stream_begin(warmup, 1, "1", "1")); + CHECK(host.native_decision_floor() >= 60000); + CHECK(host.stream_advance_time(90000)); + auto submitted = host.submit_market(Request{Transact{1.0}, "buy", ""}); + CHECK(submitted.status == native_order::SubmitStatus::Accepted); + CHECK(host.stream_advance_time(180000)); + bool saw_60000 = false; + bool filled_60000 = false; + bool filled_120000 = false; + for (const auto& event : host.native_events(0)) { + if (event.driver && event.driver->coordinate.provenance + == NativePriceProvenance::CarriedOpen) { + if (event.driver->coordinate.effective_time_ms == 60000) saw_60000 = true; + } + if (event.command) { + if (const auto* applied = + std::get_if(&*event.command)) { + if (applied->effective_time_ms == 60000) filled_60000 = true; + if (applied->effective_time_ms == 120000) filled_120000 = true; + } + } + } + CHECK(saw_60000); + CHECK(!filled_60000); + CHECK(filled_120000); + CHECK(host.stream_end(false)); + } + + { + EmptyHost host; + auto spec = spec_for("tick-array-preflight", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[1] = {bar_at(0, 100, 101, 99, 100)}; + CHECK(host.stream_begin(warmup, 1, "1", "1")); + TradeTick ticks[2] = { + {60000, 1, 100.25, 1.0}, + {60000, 2, std::numeric_limits::quiet_NaN(), 1.0}, + }; + CHECK(!host.stream_push_ticks(ticks, 2)); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + bool observed = false; + for (const auto& event : host.native_events(0)) { + if (event.driver && event.driver->coordinate.provenance + == NativePriceProvenance::ObservedPrint) { + observed = true; + } + } + CHECK(!observed); + CHECK(host.stream_push_ticks(nullptr, 0)); + CHECK(host.stream_end(false)); + } + + { + ThrowHost host; + host.throw_next = false; + auto spec = spec_for("stream-end-keeps-first-failure", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[1] = {bar_at(0, 100, 101, 99, 100)}; + CHECK(host.stream_begin(warmup, 1, "1", "1")); + TradeTick tick{60100, 1, 100.5, 1.0}; + CHECK(host.stream_push_tick(tick)); + host.throw_next = true; + CHECK(!host.stream_end(true)); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::CallbackException); + } + + { + SubmitRealtimeHost host; + auto spec = spec_for("tick-no-preentry-hl", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[1] = {bar_at(0, 100, 101, 99, 100)}; + CHECK(host.stream_begin(warmup, 1, "1", "1")); + TradeTick pre{60100, 1, 110.0, 1.0}; + CHECK(host.stream_push_tick(pre)); + auto submitted = host.submit_market(Request{Transact{1.0}, "buy", ""}); + CHECK(submitted.status == native_order::SubmitStatus::Accepted); + TradeTick fill{60200, 2, 100.0, 1.0}; + CHECK(host.stream_push_tick(fill)); + CHECK(host.stream_advance_time(120000)); + CHECK(host.physical_position().lot_count == 1); + near(host.runup(), 0.0); + CHECK(host.stream_end(false)); + } + + { + RecordHost host; + auto spec = spec_for("ticks-continue-coarser", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 101, 102, 100, 101), + bar_at(120000, 102, 103, 101, 102), + }; + CHECK(host.stream_begin(warmup, 3, "1", "5")); + CHECK(host.callbacks == 0); + CHECK(host.stream_push_tick(TradeTick{180000, 1, 104.0, 2.0})); + CHECK(host.stream_push_tick(TradeTick{180500, 2, 105.0, 3.0})); + CHECK(host.callbacks == 0); + CHECK(host.stream_push_tick(TradeTick{240000, 3, 106.0, 4.0})); + CHECK(host.callbacks == 0); + CHECK(host.stream_advance_time(300000)); + CHECK(host.callbacks == 1); + CHECK(host.bars.size() == 1); + near(host.bars[0].open, 100.0); + near(host.bars[0].high, 106.0); + near(host.bars[0].low, 99.0); + near(host.bars[0].close, 106.0); + near(host.bars[0].volume, 1.0 + 1.0 + 1.0 + 5.0 + 4.0); + CHECK(host.contexts[0].coordinate.provenance == NativePriceProvenance::Calculation); + bool replayed_ohlc = false; + bool saw_tick = false; + for (const auto& event : host.native_events(0)) { + if (!event.driver) continue; + if (event.driver->coordinate.provenance == NativePriceProvenance::ObservedPrint) { + saw_tick = true; + } + if (event.driver->coordinate.provenance == NativePriceProvenance::ModeledOHLCOpen + || event.driver->coordinate.provenance == NativePriceProvenance::ModeledOHLCClose) { + replayed_ohlc = true; + } + } + CHECK(saw_tick); + CHECK(!replayed_ohlc); + CHECK(host.stream_end(false)); + CHECK(host.callbacks == 1); + } + + { + RecordHost host; + auto spec = spec_for("quiet-complete-coarser", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 101, 102, 100, 101), + bar_at(120000, 102, 103, 101, 102), + }; + CHECK(host.stream_begin(warmup, 3, "1", "5")); + CHECK(host.callbacks == 0); + CHECK(host.stream_advance_time(300000)); + CHECK(host.callbacks == 1); + CHECK(host.bars.size() == 1); + near(host.bars[0].open, 100.0); + near(host.bars[0].high, 103.0); + near(host.bars[0].low, 99.0); + near(host.bars[0].close, 102.0); + near(host.bars[0].volume, 3.0); + bool saw_carried = false; + bool replayed_ohlc = false; + for (const auto& event : host.native_events(0)) { + if (!event.driver) continue; + if (event.driver->coordinate.provenance == NativePriceProvenance::CarriedOpen) { + saw_carried = true; + } + if (event.driver->coordinate.provenance == NativePriceProvenance::ModeledOHLCOpen) { + replayed_ohlc = true; + } + } + CHECK(saw_carried); + CHECK(!replayed_ohlc); + CHECK(host.stream_end(false)); + } + + { + RecordHost mixed; + mixed.buy_on_first = false; + auto spec = spec_for("mixed-post-entry-extremes", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(mixed.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 110, 90, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + }; + CHECK(mixed.stream_begin(warmup, 3, "1", "5")); + auto submitted = mixed.submit_market(Request{Transact{1.0}, "buy", ""}); + CHECK(submitted.status == native_order::SubmitStatus::Accepted); + CHECK(mixed.stream_push_tick(TradeTick{180000, 1, 100.0, 1.0})); + CHECK(mixed.stream_push_tick(TradeTick{181000, 2, 105.0, 1.0})); + CHECK(mixed.stream_push_tick(TradeTick{240000, 3, 104.0, 1.0})); + CHECK(mixed.stream_advance_time(300000)); + CHECK(mixed.callbacks == 1); + near(mixed.bars[0].high, 110.0); + CHECK(mixed.physical_position().lot_count == 1); + near(mixed.runup(), 5.0); + CHECK(mixed.stream_end(false)); + } + + { + RecordHost next_el; + RecordHost after; + next_el.buy_on_first = true; + after.buy_on_first = true; + auto spec_n = spec_for("coarser-next-eligible", 1); + spec_n.input_tf = "1"; + spec_n.script_tf = "5"; + auto spec_a = spec_n; + spec_a.identity.session_key = "coarser-after-calc"; + spec_a.close_execution = NativeCloseExecution::AfterCalculation; + CHECK(next_el.configure_native(spec_n).status == NativeSetupStatus::Applied); + CHECK(after.configure_native(spec_a).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + }; + CHECK(next_el.stream_begin(warmup, 3, "1", "5")); + CHECK(after.stream_begin(warmup, 3, "1", "5")); + CHECK(next_el.stream_advance_time(300000)); + CHECK(after.stream_advance_time(300000)); + CHECK(next_el.callbacks == 1); + CHECK(after.callbacks == 1); + CHECK(next_el.physical_position().lot_count == 0); + CHECK(after.physical_position().lot_count == 1); + bool after_fill = false; + for (const auto& event : after.native_events(0)) { + if (!event.command) continue; + if (const auto* applied = + std::get_if(&*event.command)) { + if (applied->provenance + == static_cast(NativePriceProvenance::AfterCalculationClose)) { + after_fill = true; + } + } + } + CHECK(after_fill); + CHECK(next_el.stream_end(false)); + CHECK(after.stream_end(false)); + } + + { + RecordHost host; + auto spec = spec_for("rth-friday-quiet-complete", 1); + spec.session = "0930-1600:23456"; + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(1749225300000LL, 100, 101, 99, 100), + bar_at(1749225360000LL, 100, 101, 99, 100), + bar_at(1749225420000LL, 100, 101, 99, 100), + }; + CHECK(host.stream_begin(warmup, 3, "1", "5")); + CHECK(host.callbacks == 0); + CHECK(host.stream_advance_time(1749461400000LL)); + CHECK(host.callbacks == 1); + bool weekend_synth = false; + for (const auto& event : host.native_events(0)) { + if (!event.driver) continue; + if (event.driver->coordinate.provenance == NativePriceProvenance::CarriedOpen + && event.driver->coordinate.open_ms > 1749225540000LL + && event.driver->coordinate.open_ms < 1749461400000LL) { + weekend_synth = true; + } + } + CHECK(!weekend_synth); + CHECK(host.stream_end(false)); + CHECK(host.callbacks == 1); + } + + { + RecordHost host; + auto spec = spec_for("ny-dst-gap", 1); + spec.timezone = "America/New_York"; + spec.session = "24x7"; + spec.input_tf = "1"; + spec.script_tf = "5"; + const auto configured = host.configure_native(spec); + CHECK(configured.status == NativeSetupStatus::Applied); + auto cal = native_calendar::parse_session("24x7", "America/New_York"); + auto tf = native_calendar::parse_timeframe("1"); + CHECK(cal.has_value()); + CHECK(tf.has_value()); + const int64_t before = 1741492800000LL; + auto iv = native_calendar::interval_containing(*cal, *tf, before); + CHECK(iv.has_value()); + Bar warmup[1] = {bar_at(iv->open_ms, 100, 101, 99, 100)}; + CHECK(host.stream_begin(warmup, 1, "1", "5")); + CHECK(host.stream_advance_time(before + 20LL * 60000LL)); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.stream_end(false)); + } + + { + RecordHost host; + auto spec = spec_for("partial-end-unfinished-script", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + }; + CHECK(host.stream_begin(warmup, 3, "1", "5")); + CHECK(host.stream_push_tick(TradeTick{180100, 1, 104.0, 1.0})); + CHECK(host.stream_end(true)); + CHECK(host.callbacks == 0); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + } + + { + EmptyHost every; + EmptyHost once; + auto spec = spec_for("hash-cadence", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(every.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(once.configure_native(spec).status == NativeSetupStatus::Applied); + Bar first = bar_at(0, 100, 101, 99, 100); + CHECK(every.stream_begin(&first, 1, "1", "5")); + CHECK(once.stream_begin(&first, 1, "1", "5")); + uint64_t ignored = every.native_continuation_hash(); + (void)ignored; + Bar rest[4] = { + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + bar_at(180000, 100, 101, 99, 100), + bar_at(240000, 100, 101, 99, 100), + }; + for (int i = 0; i < 4; ++i) { + CHECK(every.stream_push_bar(rest[i])); + ignored = every.native_continuation_hash(); + CHECK(once.stream_push_bar(rest[i])); + } + CHECK(every.stream_end(false)); + CHECK(once.stream_end(false)); + const uint64_t h_every = every.native_continuation_hash(); + const uint64_t h_once = once.native_continuation_hash(); + CHECK(h_every == h_once); + CHECK(h_every == every.native_continuation_hash()); + } + + { + NanHost a; + NanHost b; + a.qty = bits_to_double(0x7ff8000000000001ULL); + b.qty = bits_to_double(0x7ff8000000000002ULL); + auto spec = spec_for("hash-nan-after-cadence", 1); + CHECK(a.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(b.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[1] = {bar_at(60000, 100, 101, 99, 100)}; + a.run(bars, 1); + (void)a.native_continuation_hash(); + b.run(bars, 1); + CHECK(a.native_continuation_hash() != b.native_continuation_hash()); + CommentHost ca; + CommentHost cb; + ca.comment = "alpha"; + cb.comment = "beta"; + auto spec_c = spec_for("hash-comment-after-cadence", 1); + CHECK(ca.configure_native(spec_c).status == NativeSetupStatus::Applied); + CHECK(cb.configure_native(spec_c).status == NativeSetupStatus::Applied); + Bar two[2] = {bar_at(60000, 100, 101, 99, 100), bar_at(120000, 102, 103, 101, 102)}; + ca.run(two, 2); + (void)ca.native_continuation_hash(); + cb.run(two, 2); + CHECK(ca.native_continuation_hash() != cb.native_continuation_hash()); + near(ca.physical_position().signed_units, 1.0); + near(cb.physical_position().signed_units, 1.0); + } + + { + EmptyHost host; + auto spec = spec_for("positive-ohlc-nan-volume", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + const uint64_t hash_ready = host.native_continuation_hash(); + Bar zero_px{0.0, 101, 99, 100, 1.0, 60000}; + host.run(&zero_px, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); + CHECK(host.last_run_status() != 0); + CHECK(!host.last_error().empty()); + CHECK(host.native_continuation_hash() == hash_ready); + CHECK(host.native_consumed_high_water() == 0); + Bar nanvol{100, 101, 99, 100, std::numeric_limits::quiet_NaN(), 60000}; + host.run(&nanvol, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); + CHECK(host.last_run_status() != 0); + Bar neg{100, 101, -1, 100, 1.0, 60000}; + host.run(&neg, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); + CHECK(preflight_native_inputs(spec, &zero_px, 1, NativeInputPolicy::Batch).error + == NativeInputPreflightError::StructuralInvalid); + CHECK(preflight_native_inputs(spec, &nanvol, 1, NativeInputPolicy::Batch).error + == NativeInputPreflightError::StructuralInvalid); + } + + { + EmptyHost scalar; + EmptyHost array; + auto spec = spec_for("tick-scalar-array-identity", 1); + CHECK(scalar.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(array.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup{100, 101, 99, 100, 1.0, 0}; + CHECK(scalar.stream_begin(&warmup, 1, "1", "1")); + CHECK(array.stream_begin(&warmup, 1, "1", "1")); + TradeTick good{120000, 1, 100.25, 1.0}; + CHECK(scalar.stream_push_tick(good)); + CHECK(array.stream_push_tick(good)); + TradeTick back{60000, 2, 200.0, 1.0}; + const uint64_t h_s = scalar.native_continuation_hash(); + const uint64_t h_a = array.native_continuation_hash(); + CHECK(!scalar.stream_push_tick(back)); + CHECK(!array.stream_push_ticks(&back, 1)); + CHECK(scalar.native_state().kind == NativeLifecycleKind::Running); + CHECK(array.native_state().kind == NativeLifecycleKind::Running); + CHECK(scalar.last_run_status() != 0); + CHECK(array.last_run_status() != 0); + CHECK(scalar.native_continuation_hash() == h_s); + CHECK(array.native_continuation_hash() == h_a); + CHECK(scalar.native_continuation_hash() == array.native_continuation_hash()); + CHECK(scalar.stream_end(false)); + CHECK(array.stream_end(false)); + } + + { + EmptyHost host; + auto spec = spec_for("time-advance-regression", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup{100, 101, 99, 100, 1.0, 0}; + CHECK(host.stream_begin(&warmup, 1, "1", "1")); + CHECK(host.stream_advance_time(90000)); + const int64_t floor = host.native_decision_floor(); + CHECK(floor >= 90000); + const uint64_t h = host.native_continuation_hash(); + CHECK(!host.stream_advance_time(30000)); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.last_run_status() != 0); + CHECK(host.native_decision_floor() == floor); + CHECK(host.native_continuation_hash() == h); + CHECK(!host.stream_push_tick(TradeTick{60000, 1, 100.5, 1.0})); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.stream_end(false)); + } + + { + AbortHost host; + auto spec = spec_for("abort-batch", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 102, 103, 101, 102), + bar_at(120000, 104, 105, 103, 104), + }; + host.run(bars, 3); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Aborted); + CHECK(host.callbacks == 1); + CHECK(host.last_run_status() != 0); + const auto refused = host.configure_native(spec); + CHECK(refused.status != NativeSetupStatus::Applied); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + } + + { + AbortHost host; + auto spec = spec_for("abort-realtime", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 100, 101, 99, 100), + }; + CHECK(host.stream_begin(warmup, 3, "1", "5")); + CHECK(host.callbacks == 0); + CHECK(host.stream_push_bar(bar_at(180000, 100, 101, 99, 100))); + (void)host.stream_push_bar(bar_at(240000, 100, 101, 99, 100)); + CHECK(host.callbacks == 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Aborted); + CHECK(!host.stream_advance_time(360000)); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + } + + { + BeginAbortHost host; + auto spec = spec_for("abort-begin-callback", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = {bar_at(0, 100, 101, 99, 100), bar_at(60000, 102, 103, 101, 102)}; + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Aborted); + CHECK(host.bars == 0); + } + + { + BeginSubmitHost host; + auto spec = spec_for("begin-submit-commands", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = {bar_at(0, 100, 101, 99, 100), bar_at(60000, 102, 103, 101, 102)}; + host.run(bars, 2); + CHECK(!host.threw); + CHECK(host.status == native_order::SubmitStatus::Accepted); + CHECK(host.physical_position().lot_count == 1); + } + + { + NonstandardThrowHost host; + auto spec = spec_for("nonstandard-bar-exception", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(60000, 100, 101, 99, 100); + host.run(&bar, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::CallbackException); + } + + { + BeginNonstandardThrowHost host; + auto spec = spec_for("nonstandard-begin-exception", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(60000, 100, 101, 99, 100); + host.run(&bar, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::CallbackException); + CHECK(host.bars == 0); + } + + { + EmptyHost same; + EmptyHost split; + auto spec = spec_for("per-slot-volume-overflow", 1); + CHECK(same.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(split.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup{100, 101, 99, 100, 1.0, 0}; + CHECK(same.stream_begin(&warmup, 1, "1", "1")); + CHECK(split.stream_begin(&warmup, 1, "1", "1")); + const double huge = 1.0e308; + TradeTick same_slot[2] = { + {120000, 1, 100.25, huge}, + {120000, 2, 100.50, huge}, + }; + CHECK(!same.stream_push_ticks(same_slot, 2)); + CHECK(same.native_state().kind == NativeLifecycleKind::Running); + CHECK(same.last_run_status() != 0); + TradeTick split_slot[2] = { + {120000, 1, 100.25, huge}, + {180000, 2, 100.50, huge}, + }; + CHECK(split.stream_push_ticks(split_slot, 2)); + CHECK(split.native_state().kind == NativeLifecycleKind::Running); + CHECK(split.stream_end(false)); + CHECK(same.stream_end(false)); + } + + { + RecordHost host; + auto spec = spec_for("next-key-lazy-complete", 1); + spec.input_tf = "1"; + spec.script_tf = "5"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[3] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 101, 102, 100, 101), + bar_at(300000, 110, 111, 109, 110), + }; + host.run(bars, 3); + CHECK(host.callbacks >= 1); + bool saw_lazy = false; + for (const auto& event : host.native_events(0)) { + if (event.driver && event.driver->coordinate.completion + == NativeCompletionKind::LazyComplete) { + saw_lazy = true; + } + } + CHECK(saw_lazy); + } + + { + RecordHost host; + auto spec = spec_for("clipped-opening-source-time", 1); + spec.input_tf = "60"; + spec.script_tf = "60"; + spec.session = "0930-1130,1300-1600"; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + const int64_t nominal = 1749472200000LL; // 12:30 + const int64_t clipped = 1749474000000LL; // 13:00 + Bar bar = bar_at(clipped, 100, 101, 99, 100); + host.run(&bar, 1); + CHECK(host.callbacks == 1); + CHECK(host.contexts[0].coordinate.open_ms == nominal); + CHECK(host.contexts[0].coordinate.eligible_open_ms == clipped); + const auto events = host.native_events(0); + bool saw_open = false; + for (const auto& event : events) { + if (!event.driver) continue; + if (event.driver->coordinate.provenance != NativePriceProvenance::ModeledOHLCOpen) { + continue; + } + saw_open = true; + CHECK(event.driver->coordinate.open_ms == nominal); + CHECK(event.driver->coordinate.effective_time_ms == clipped); + CHECK(event.driver->coordinate.source_price_time_ms == clipped); + } + CHECK(saw_open); + } + + { + struct Case { + const char* input; + const char* script; + int64_t first; + int64_t first_close; + int64_t next; + int64_t script_open; + int64_t script_seal; + }; + const Case cases[] = { + {"W", "M", 1737936000000LL, 1738540800000LL, 1738540800000LL, + 1735689600000LL, 1738368000000LL}, + {"2D", "W", 1736640000000LL, 1736812800000LL, 1736812800000LL, + 1736121600000LL, 1736726400000LL}, + }; + for (const auto& item : cases) { + for (auto policy : {NativeCloseExecution::NextEligiblePoint, + NativeCloseExecution::AfterCalculation}) { + RecordHost host; + auto spec = spec_for(std::string("c5-") + item.input + item.script + + (policy == NativeCloseExecution::AfterCalculation + ? "-after" + : "-next"), + 1); + spec.input_tf = item.input; + spec.script_tf = item.script; + spec.close_execution = policy; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar first{100, 110, 90, 105, 7, item.first}; + CHECK(host.stream_begin(&first, 1, "", "")); + CHECK(host.callbacks == 1); + CHECK(host.contexts[0].coordinate.open_ms == item.script_open); + CHECK(host.contexts[0].coordinate.next_period_open_ms == item.script_seal); + CHECK(host.contexts[0].coordinate.last_traded_close_ms == item.script_seal); + CHECK(host.contexts[0].coordinate.effective_time_ms == item.first_close); + CHECK(host.contexts[0].decision_floor_ms == item.first_close); + CHECK(host.native_decision_floor() == item.first_close); + near(host.bars[0].open, 100); + near(host.bars[0].high, 110); + near(host.bars[0].low, 90); + near(host.bars[0].close, 105); + near(host.bars[0].volume, 7); + CHECK(host.submit_market(Request{Transact{1.0}, "buy", ""}).status + == native_order::SubmitStatus::Accepted); + CHECK(host.stream_push_bar(bar_at(item.next, 120, 121, 119, 120))); + CHECK(host.callbacks == 1); + CHECK(host.physical_position().lot_count == 0); + CHECK(host.stream_end(false)); + } + } + } + + { + RecordHost ended; + RecordHost advanced; + auto spec = spec_for("rth-partial-daily-warmup-seal", 1); + spec.input_tf = "1"; + spec.script_tf = "D"; + spec.session = "0930-1600:23456"; + CHECK(ended.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(advanced.configure_native(spec).status == NativeSetupStatus::Applied); + Bar last = bar_at(1749225540000LL, 101, 101, 101, 101); + CHECK(ended.stream_begin(&last, 1, "", "")); + CHECK(ended.callbacks == 0); + CHECK(ended.native_decision_floor() == 1749225600000LL); + CHECK(ended.stream_end(false)); + CHECK(ended.callbacks == 0); + CHECK(advanced.stream_begin(&last, 1, "", "")); + CHECK(advanced.stream_advance_time(1749288600000LL)); + CHECK(advanced.callbacks == 1); + CHECK(advanced.contexts[0].coordinate.open_ms == 1749202200000LL); + CHECK(advanced.contexts[0].coordinate.last_traded_close_ms == 1749225600000LL); + CHECK(advanced.contexts[0].coordinate.next_period_open_ms == 1749288600000LL); + near(advanced.bars[0].open, 101); + near(advanced.bars[0].volume, 1); + bool carried = false; + for (const auto& event : advanced.native_events(0)) { + if (event.driver && event.driver->coordinate.provenance + == NativePriceProvenance::CarriedOpen) { + carried = true; + } + } + CHECK(!carried); + CHECK(advanced.native_decision_floor() == 1749288600000LL); + CHECK(advanced.stream_end(false)); + CHECK(advanced.callbacks == 1); + } + + { + EmptyHost host; + auto spec = spec_for("magnifier-default-and-refuse", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(60000, 100, 101, 99, 100); + host.run(&bar, 1, "1", "1"); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.callbacks == 1); + EmptyHost refused; + CHECK(refused.configure_native(spec).status == NativeSetupStatus::Applied); + const uint64_t before = refused.native_continuation_hash(); + refused.run(&bar, 1, "1", "1", true); + CHECK(refused.native_state().kind == NativeLifecycleKind::Ready); + CHECK(refused.last_run_status() != 0); + CHECK(refused.native_consumed_high_water() == 0); + CHECK(refused.native_continuation_hash() == before); + refused.run(&bar, 1, "1", "1", false, 8); + CHECK(refused.native_state().kind == NativeLifecycleKind::Ready); + CHECK(refused.last_run_status() != 0); + refused.run(&bar, 1, "1", "1", false, 4, MagnifierDistribution::UNIFORM); + CHECK(refused.native_state().kind == NativeLifecycleKind::Ready); + CHECK(refused.last_run_status() != 0); + refused.run(&bar, 1, "1", "1", false, 4, MagnifierDistribution::ENDPOINTS); + CHECK(refused.native_state().kind == NativeLifecycleKind::Completed); + CHECK(refused.callbacks == 1); + } + + { + RecordHost host; + host.buy_on_first = true; + auto spec = spec_for("completed-repeat-begin-preserves", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 102, 103, 101, 102), + }; + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.physical_position().lot_count == 1); + const uint64_t hash = host.native_continuation_hash(); + const uint64_t hw = host.native_consumed_high_water(); + const auto events = host.native_events(0).size(); + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.last_run_status() != 0); + CHECK(!host.last_error().empty()); + CHECK(host.native_continuation_hash() == hash); + CHECK(host.native_consumed_high_water() == hw); + CHECK(host.physical_position().lot_count == 1); + CHECK(host.native_events(0).size() == events); + host.run(bars, 2, "1", "1"); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_continuation_hash() == hash); + CHECK(!host.stream_begin(&bars[0], 1, "", "")); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_continuation_hash() == hash); + auto spec2 = spec; + spec2.identity.run_number = 2; + CHECK(host.configure_native(spec2).status == NativeSetupStatus::Applied); + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_consumed_high_water() == 2); + } + + { + EmptyHost host; + Bar bar = bar_at(60000, 100, 101, 99, 100); + host.run(&bar, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Unconfigured); + CHECK(host.last_run_status() != 0); + CHECK(!host.last_error().empty()); + CHECK(host.native_consumed_high_water() == 0); + CHECK(!host.stream_begin(&bar, 1, "", "")); + CHECK(host.native_state().kind == NativeLifecycleKind::Unconfigured); + auto spec = spec_for("unconfigured-begin-then-configure", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + host.run(&bar, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_consumed_high_water() == 1); + } + + { + EmptyHost host; + auto spec = spec_for("running-forbidden-begin", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup = bar_at(0, 100, 101, 99, 100); + CHECK(host.stream_begin(&warmup, 1, "", "")); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + const uint64_t hw = host.native_consumed_high_water(); + Bar next = bar_at(60000, 102, 103, 101, 102); + host.run(&next, 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Contract); + CHECK(host.native_state().failure.operation == NativeFailureOperation::Begin); + CHECK(host.native_consumed_high_water() == hw); + CHECK(host.configure_native(spec).status != NativeSetupStatus::Applied); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Contract); + } + + { + AbortHost host; + auto spec = spec_for("failed-first-failure-persists", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = {bar_at(0, 100, 101, 99, 100), bar_at(60000, 102, 103, 101, 102)}; + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Aborted); + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Aborted); + CHECK(!host.stream_begin(&bars[0], 1, "", "")); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Aborted); + } + + { + EmptyHost ready; + auto spec = spec_for("rich-source-run-failed", 1); + CHECK(ready.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(60000, 100, 101, 99, 100); + std::unordered_map inputs; + SymInfo info; + ready.run(&bar, 1, "1", "1", inputs, info); + CHECK(ready.native_state().kind == NativeLifecycleKind::Failed); + CHECK(ready.native_state().failure.code == NativeFailureCode::UnsupportedSource); + EmptyHost completed; + CHECK(completed.configure_native(spec).status == NativeSetupStatus::Applied); + completed.run(&bar, 1); + CHECK(completed.native_state().kind == NativeLifecycleKind::Completed); + completed.run(&bar, 1, "1", "1", inputs, info); + CHECK(completed.native_state().kind == NativeLifecycleKind::Failed); + CHECK(completed.native_state().failure.code == NativeFailureCode::UnsupportedSource); + } + + { + ReenterEndAfterCalcHost host; + auto spec = spec_for("reenter-stream-end-no-aftercalc-fill", 1); + spec.close_execution = NativeCloseExecution::AfterCalculation; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(0, 100, 110, 90, 101); + host.run(&bar, 1); + CHECK(host.callbacks == 1); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Contract); + CHECK(host.physical_position().lot_count == 0); + CHECK(applied_fill_count(host) == 0); + CHECK(host.last_run_status() != 0); + } + + { + const ReenterHost::Action actions[] = { + ReenterHost::Action::End, + ReenterHost::Action::Advance, + ReenterHost::Action::PushBar, + ReenterHost::Action::PushTick, + ReenterHost::Action::PushTicks, + ReenterHost::Action::Run, + ReenterHost::Action::StreamBegin, + }; + for (auto action : actions) { + ReenterHost from_bar; + from_bar.from_bar = action; + auto spec = spec_for(std::string("reenter-bar-") + std::to_string(static_cast(action)), 1); + CHECK(from_bar.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(0, 100, 101, 99, 100); + from_bar.run(&bar, 1); + CHECK(from_bar.callbacks == 1); + CHECK(!from_bar.nested_ok); + CHECK(from_bar.native_state().kind == NativeLifecycleKind::Failed); + CHECK(from_bar.native_state().failure.code == NativeFailureCode::Contract); + CHECK(from_bar.native_state().kind != NativeLifecycleKind::Completed); + CHECK(applied_fill_count(from_bar) == 0); + + ReenterHost from_begin; + from_begin.from_begin = action; + auto spec_b = spec_for(std::string("reenter-begin-") + std::to_string(static_cast(action)), 1); + CHECK(from_begin.configure_native(spec_b).status == NativeSetupStatus::Applied); + from_begin.run(&bar, 1); + CHECK(from_begin.begins == 1); + CHECK(!from_begin.nested_ok); + CHECK(from_begin.native_state().kind == NativeLifecycleKind::Failed); + CHECK(from_begin.native_state().failure.code == NativeFailureCode::Contract); + } + } + + { + RecordHost host; + host.buy_on_first = true; + auto spec = spec_for("ordinary-callback-submit-still-allowed", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 102, 103, 101, 102), + }; + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.physical_position().lot_count == 1); + CHECK(applied_fill_count(host) == 1); + } + + { + EmptyHost inert; + CHECK(inert.native_decision_floor() == std::numeric_limits::min()); + CHECK(inert.native_state().decision_floor_ms == std::numeric_limits::min()); + BeginSubmitHost simple; + auto spec = spec_for("negative-begin-batch-simple", 1); + CHECK(simple.configure_native(spec).status == NativeSetupStatus::Applied); + CHECK(simple.native_decision_floor() == std::numeric_limits::min()); + Bar negative = bar_at(-60000, 100, 102, 99, 101); + simple.run(&negative, 1); + CHECK(simple.native_state().kind == NativeLifecycleKind::Completed); + CHECK(simple.status == native_order::SubmitStatus::Accepted); + CHECK(simple.floor_at_begin == -60000); + CHECK(simple.state_floor_at_begin == -60000); + CHECK(simple.birth_floor == -60000); + CHECK(simple.floor_at_begin == simple.birth_floor); + CHECK(applied_fill_count(simple) == 1); + CHECK(simple.physical_position().lot_count == 1); + near(simple.physical_position().signed_units, 1.0); + bool saw_open_fill = false; + for (const auto& event : simple.native_events(0)) { + if (!event.command) continue; + if (const auto* applied = + std::get_if(&*event.command)) { + CHECK(applied->effective_time_ms == -60000); + near(applied->raw_price, 100.0); + CHECK(applied->birth.decision_time_lower_bound == -60000); + saw_open_fill = true; + } + } + CHECK(saw_open_fill); + + BeginSubmitHost tf; + CHECK(tf.configure_native(spec_for("negative-begin-batch-tf", 1)).status + == NativeSetupStatus::Applied); + tf.run(&negative, 1, "1", "1"); + CHECK(tf.native_state().kind == NativeLifecycleKind::Completed); + CHECK(tf.floor_at_begin == -60000); + CHECK(tf.birth_floor == -60000); + CHECK(applied_fill_count(tf) == 1); + + BeginSubmitHost warmup; + CHECK(warmup.configure_native(spec_for("negative-begin-warmup", 1)).status + == NativeSetupStatus::Applied); + CHECK(warmup.stream_begin(&negative, 1, "", "")); + CHECK(warmup.floor_at_begin == -60000); + CHECK(warmup.birth_floor == -60000); + CHECK(applied_fill_count(warmup) == 1); + CHECK(warmup.stream_end(false)); + } + + { + BeginSubmitHost positive; + auto spec = spec_for("positive-begin-floor-control", 1); + CHECK(positive.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bar = bar_at(60000, 100, 101, 99, 100); + positive.run(&bar, 1); + CHECK(positive.native_state().kind == NativeLifecycleKind::Completed); + CHECK(positive.floor_at_begin == 60000); + CHECK(positive.birth_floor == 60000); + CHECK(applied_fill_count(positive) == 1); + } + + { + BeginSubmitHost empty; + auto spec = spec_for("empty-batch-unbounded-floor", 1); + CHECK(empty.configure_native(spec).status == NativeSetupStatus::Applied); + empty.run(nullptr, 0); + CHECK(empty.native_state().kind == NativeLifecycleKind::Completed); + CHECK(empty.native_consumed_high_water() == 1); + CHECK(empty.floor_at_begin == std::numeric_limits::min()); + CHECK(empty.state_floor_at_begin == std::numeric_limits::min()); + CHECK(empty.birth_floor == std::numeric_limits::min()); + CHECK(empty.status == native_order::SubmitStatus::Accepted); + CHECK(applied_fill_count(empty) == 0); + CHECK(empty.physical_position().lot_count == 0); + CHECK(empty.callbacks == 0); + bool invented_price = false; + for (const auto& event : empty.native_events(0)) { + if (event.driver) invented_price = true; + } + CHECK(!invented_price); + CHECK(empty.native_decision_floor() == std::numeric_limits::min()); + } + + { + RecordHost host; + host.buy_on_first = true; + auto spec = spec_for("late-callback-no-retrofill", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 102, 103, 101, 102), + }; + host.run(bars, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(applied_fill_count(host) == 1); + for (const auto& event : host.native_events(0)) { + if (!event.command) continue; + if (const auto* applied = + std::get_if(&*event.command)) { + CHECK(applied->effective_time_ms == 60000); + CHECK(applied->birth.decision_time_lower_bound >= 60000); + near(applied->raw_price, 102.0); + } + } + } + + { + EmptyHost bars_then_advance; + auto spec = spec_for("mode-refuse-bar-then-advance", 1); + CHECK(bars_then_advance.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup = bar_at(0, 100, 101, 99, 100); + CHECK(bars_then_advance.stream_begin(&warmup, 1, "", "")); + CHECK(bars_then_advance.stream_push_bar(bar_at(60000, 102, 103, 101, 102))); + const uint64_t hash = bars_then_advance.native_continuation_hash(); + const int64_t floor = bars_then_advance.native_decision_floor(); + const auto lots = bars_then_advance.physical_position().lot_count; + CHECK(!bars_then_advance.stream_advance_time(180000)); + CHECK(bars_then_advance.native_state().kind == NativeLifecycleKind::Running); + CHECK(bars_then_advance.last_run_status() != 0); + CHECK(bars_then_advance.native_continuation_hash() == hash); + CHECK(bars_then_advance.native_decision_floor() == floor); + CHECK(bars_then_advance.physical_position().lot_count == lots); + CHECK(bars_then_advance.stream_end(false)); + + EmptyHost advance_then_bar; + CHECK(advance_then_bar.configure_native(spec_for("mode-refuse-advance-then-bar", 1)).status + == NativeSetupStatus::Applied); + CHECK(advance_then_bar.stream_begin(&warmup, 1, "", "")); + CHECK(advance_then_bar.stream_advance_time(90000)); + const uint64_t hash_a = advance_then_bar.native_continuation_hash(); + const int64_t floor_a = advance_then_bar.native_decision_floor(); + CHECK(!advance_then_bar.stream_push_bar(bar_at(60000, 102, 103, 101, 102))); + CHECK(advance_then_bar.native_state().kind == NativeLifecycleKind::Running); + CHECK(advance_then_bar.last_run_status() != 0); + CHECK(advance_then_bar.native_continuation_hash() == hash_a); + CHECK(advance_then_bar.native_decision_floor() == floor_a); + CHECK(advance_then_bar.stream_end(false)); + } + + { + EmptyHost warmup_bar; + auto spec = spec_for("mode-warmup-then-bar", 1); + CHECK(warmup_bar.configure_native(spec).status == NativeSetupStatus::Applied); + Bar warmup = bar_at(0, 100, 101, 99, 100); + CHECK(warmup_bar.stream_begin(&warmup, 1, "", "")); + CHECK(warmup_bar.stream_push_bar(bar_at(60000, 102, 103, 101, 102))); + CHECK(warmup_bar.stream_end(false)); + CHECK(warmup_bar.native_state().kind == NativeLifecycleKind::Completed); + + EmptyHost warmup_ticks; + CHECK(warmup_ticks.configure_native(spec_for("mode-warmup-then-ticks", 1)).status + == NativeSetupStatus::Applied); + CHECK(warmup_ticks.stream_begin(&warmup, 1, "", "")); + CHECK(warmup_ticks.stream_push_tick(TradeTick{60000, 1, 100.25, 1.0})); + CHECK(warmup_ticks.stream_advance_time(120000)); + CHECK(warmup_ticks.stream_end(false)); + CHECK(warmup_ticks.native_state().kind == NativeLifecycleKind::Completed); + + EmptyHost warmup_advance; + CHECK(warmup_advance.configure_native(spec_for("mode-warmup-then-advance", 1)).status + == NativeSetupStatus::Applied); + CHECK(warmup_advance.stream_begin(&warmup, 1, "", "")); + CHECK(warmup_advance.stream_advance_time(180000)); + CHECK(warmup_advance.stream_end(false)); + CHECK(warmup_advance.native_state().kind == NativeLifecycleKind::Completed); + } + + { + RecordHost host; + host.buy_on_first = true; + auto spec = spec_for("events-after-ordinal-kind-order", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(0, 100, 101, 99, 100), + bar_at(60000, 102, 103, 101, 102), + }; + host.run(bars, 2); + const auto all = host.native_events(0); + CHECK(host.native_events(0).size() == all.size()); + uint64_t fill_ordinal = 0; + bool saw_pair = false; + for (std::size_t i = 0; i < all.size(); ++i) { + if (i + 1 < all.size() && all[i].ordinal == all[i + 1].ordinal) { + CHECK(static_cast(all[i].kind) < static_cast(all[i + 1].kind)); + } + if (all[i].command + && std::holds_alternative(*all[i].command)) { + fill_ordinal = all[i].ordinal; + CHECK(all[i].kind == NativeEventKind::Command); + CHECK(i + 1 < all.size()); + CHECK(all[i + 1].ordinal == fill_ordinal); + CHECK(all[i + 1].kind == NativeEventKind::Account); + saw_pair = true; + } + } + CHECK(saw_pair); + CHECK(fill_ordinal > 0); + const auto suffix = host.native_events(fill_ordinal); + for (const auto& event : suffix) CHECK(event.ordinal > fill_ordinal); + const auto group = host.native_events(fill_ordinal - 1); + CHECK(group.size() >= 2); + CHECK(group[0].ordinal == fill_ordinal); + CHECK(group[0].kind == NativeEventKind::Command); + CHECK(group[1].ordinal == fill_ordinal); + CHECK(group[1].kind == NativeEventKind::Account); + CHECK(host.native_events(0).size() == all.size()); + } + + std::printf("%s %d checks %d failures\n", failures ? "FAIL" : "PASS", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_native_live_startup.cpp b/tests/test_native_live_startup.cpp new file mode 100644 index 000000000..282156385 --- /dev/null +++ b/tests/test_native_live_startup.cpp @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "native_startup.hpp" +#include "transport.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace pineforge::live; + +namespace { +int failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::cerr << "FAIL " << __FILE__ << ":" << __LINE__ << " " << #cond \ + << '\n'; \ + ++failures; \ + } \ + } while (0) + +bool throws_containing(const std::function& fn, const char* needle) { + try { + fn(); + return false; + } catch (const std::exception& e) { + return std::string(e.what()).find(needle) != std::string::npos; + } +} + +NativeConfigValues complete_native() { + NativeConfigValues n; + n.present = true; + n.session_key = "live-1"; + n.run_number = 1; + n.input_tf = "5"; + n.script_tf = "10"; + n.timezone = "UTC"; + n.session = "24x7"; + n.chart_timezone = ""; + n.ticker = "MOCK"; + n.tickerid = "TEST:MOCK"; + n.type = "crypto"; + n.currency = "USDT"; + n.basecurrency = "ETH"; + n.description = ""; + n.volumetype = ""; + n.initial_capital = 10000; + n.point_value = 1; + n.account_fx = 1; + n.price_tick = 0.01; + n.fee_kind = 2; + n.fee_value = 6; + n.allowed_open_directions = 3; + return n; +} + +std::string native_json(const NativeConfigValues& n) { + auto opt = [](bool present, const std::string& value) { + return present ? value : "null"; + }; + return std::string("{\"run\":{\"session_key\":\"") + n.session_key + + "\",\"run_number\":" + std::to_string(n.run_number) + + "},\"clock\":{\"input_tf\":\"" + n.input_tf + "\",\"script_tf\":\"" + n.script_tf + + "\",\"timezone\":\"" + n.timezone + "\",\"session\":\"" + n.session + + "\",\"chart_timezone\":\"" + n.chart_timezone + + "\"},\"instrument\":{\"ticker\":\"" + n.ticker + "\",\"tickerid\":\"" + n.tickerid + + "\",\"type\":\"" + n.type + "\",\"currency\":\"" + n.currency + + "\",\"basecurrency\":\"" + n.basecurrency + "\",\"description\":\"" + n.description + + "\",\"volumetype\":\"" + n.volumetype + + "\"},\"execution\":{\"initial_capital\":10000,\"point_value\":1,\"account_fx\":1," + "\"price_tick\":0.01,\"slippage_ticks\":0,\"fee_kind\":\"CashPerExecution\"," + "\"fee_value\":6,\"quantity_grid\":" + + opt(n.optional_mask & 1u, "1") + ",\"close_execution\":\"NextEligiblePoint\"," + "\"max_abs_units\":" + + opt(n.optional_mask & 2u, "10") + ",\"max_open_lots\":" + + opt(n.optional_mask & 8u, "4") + + ",\"allowed_open_directions\":3,\"initial_margin_fraction\":" + + opt(n.optional_mask & 4u, "0.5") + "}}"; +} + +pf_bar_t bar(std::int64_t ts) { + pf_bar_t b{}; + b.timestamp = ts; + b.open = 100; + b.high = 102; + b.low = 99; + b.close = 101; + b.volume = 4; + return b; +} + +void legacy_identity_golden() { + LegacyIdentityFields fields; + fields.mode = "bars"; + fields.input_tf = "1"; + fields.script_tf = "3"; + fields.session = "24x7"; + fields.timezone = "UTC"; + fields.chart_timezone = "UTC"; + fields.symbol = "TEST:MOCK"; + fields.name = "strategy"; + fields.webhook = "https://receiver.example/order-actions"; + const std::string warmup = + "timestamp,open,high,low,close,volume\n0,100,102,99,101,4\n"; + const std::string library = "legacy-library-bytes"; + const std::string parser; + const std::string parser_config = "{}"; + const auto dump = + identity_document(fields, warmup, library, parser, parser_config); + const std::string expected = + std::string("{\"chart_timezone\":\"UTC\",\"input_tf\":\"1\",\"inputs\":[],\"library\":\"") + + sha256_hex(library) + "\",\"mode\":\"bars\",\"name\":\"strategy\",\"overrides\":[],\"parser\":\"" + + sha256_hex(parser) + "\",\"parser_config\":\"" + sha256_hex(parser_config) + + "\",\"schema\":\"pineforge-native-ledger/v1\",\"script_tf\":\"3\",\"session\":\"24x7\"," + "\"symbol\":\"TEST:MOCK\",\"syminfo\":{},\"timezone\":\"UTC\",\"warmup\":\"" + + sha256_hex(warmup) + "\",\"webhook\":\"https://receiver.example/order-actions\"}"; + CHECK(dump == expected); + CHECK(dump.find("pineforge-native-run/v1") == std::string::npos); + CHECK(dump.find("timezone_dependency") == std::string::npos); + CHECK(identity(fields, warmup, library, parser, parser_config) == sha256_hex(expected)); +} + +void parse_refusals() { + CHECK(throws_containing([] { parse_native_config("{"); }, "JSON")); + CHECK(throws_containing( + [] { + parse_native_config( + "{\"run\":{},\"clock\":{},\"instrument\":{},\"execution\":{},\"x\":1}"); + }, + "unknown field")); + auto n = complete_native(); + auto text = native_json(n); + auto parsed = parse_native_config(text); + CHECK(parsed.session_key == "live-1"); + CHECK(parsed.chart_timezone.empty()); + CHECK(parsed.optional_mask == 0); + CHECK(throws_containing( + [] { parse_native_config("{\"run\":{\"session_key\":\"a\",\"run_number\":1},\"clock\":{" + "\"input_tf\":\"1\",\"script_tf\":\"1\",\"timezone\":\"UTC\"," + "\"session\":\"24x7\",\"chart_timezone\":\"\"},\"instrument\":{" + "\"ticker\":\"t\",\"tickerid\":\"id\",\"type\":\"\",\"currency\":\"\"," + "\"basecurrency\":\"\",\"description\":\"\",\"volumetype\":\"\"}," + "\"execution\":{\"initial_capital\":1,\"point_value\":1," + "\"account_fx\":1,\"price_tick\":1,\"slippage_ticks\":0," + "\"fee_kind\":\"Nope\",\"fee_value\":0,\"quantity_grid\":null," + "\"close_execution\":\"NextEligiblePoint\",\"max_abs_units\":null," + "\"max_open_lots\":null,\"allowed_open_directions\":3," + "\"initial_margin_fraction\":null}}"); }, + "fee_kind")); + n.input_tf = "M"; + n.script_tf = "M"; + CHECK(throws_containing([&] { validate_native_config(n); }, "monthly")); + n.input_tf = "3M"; + n.script_tf = "3M"; + CHECK(throws_containing([&] { validate_native_config(n); }, "monthly")); + n = complete_native(); + n.session_key.clear(); + CHECK(throws_containing([&] { validate_native_config(n); }, "empty required")); + n = complete_native(); + n.timezone = "No/Such_PineForge_Zone"; + CHECK(throws_containing([&] { validate_native_config(n); }, "timezone")); +} + +void explicit_cli() { + auto n = complete_native(); + NativeClockBindings clock; + clock.input_tf = "1"; + clock.script_tf = ""; + clock.timezone = "UTC"; + clock.session = "24x7"; + clock.chart_timezone = "UTC"; + clock.symbol = ""; + apply_native_config(clock, n); + CHECK(clock.input_tf == "5"); + CHECK(clock.script_tf == "10"); + CHECK(clock.symbol == "TEST:MOCK"); + CHECK(clock.chart_timezone.empty()); + clock = {}; + clock.input_tf = "1"; + clock.explicit_flags.insert("--input-tf"); + CHECK(throws_containing([&] { apply_native_config(clock, n); }, "contradicts")); +} + +void native_history_gaps() { + auto n = complete_native(); + validate_native_config(n); + std::vector ok{bar(0), bar(300000), bar(600000), bar(900000)}; + require_native_warmup(n, ok); + std::vector missing{bar(0), bar(300000), bar(900000)}; + CHECK(throws_containing([&] { require_native_warmup(n, missing); }, "in-session gap")); + std::vector mid{bar(0), bar(150000)}; + CHECK(throws_containing([&] { require_native_warmup(n, mid); }, "aligned")); + const auto csv = history("timestamp,open,high,low,close,volume\n" + "0,100,102,99,101,4\n300000,100,102,99,101,4\n", + true); + CHECK(csv.size() == 2); +} + +void daily_dst_continuity() { + auto n = complete_native(); + n.input_tf = "D"; + n.script_tf = "D"; + n.timezone = "America/New_York"; + n.session = "24x7"; + validate_native_config(n); + constexpr std::int64_t mar8 = 1741410000000; + constexpr std::int64_t mar9 = 1741496400000; + constexpr std::int64_t mar10 = 1741579200000; + require_native_warmup(n, std::vector{bar(mar8), bar(mar9), bar(mar10)}); + CHECK(throws_containing( + [&] { + require_native_warmup(n, std::vector{bar(mar8), bar(mar10)}); + }, + "in-session gap")); + n.input_tf = "W"; + n.script_tf = "W"; + n.timezone = "UTC"; + n.session = "24x7"; + validate_native_config(n); + constexpr std::int64_t week0 = 1736121600000; // Monday 2025-01-06 00:00 UTC + constexpr std::int64_t week1 = 1736726400000; // Monday 2025-01-13 00:00 UTC + constexpr std::int64_t week3 = 1737331200000; // Monday 2025-01-20 00:00 UTC + require_native_warmup(n, std::vector{bar(week0), bar(week1)}); + CHECK(throws_containing( + [&] { + require_native_warmup(n, std::vector{bar(week0), bar(week3)}); + }, + "in-session gap")); +} + +void timezone_identity() { + auto utc = timezone_rule_identity("UTC", true); + CHECK(utc.kind == Json::Kind::Object); + CHECK(utc.at("kind_name").text() == "utc"); + CHECK(utc.at("effective_definition").text() == "UTC"); + CHECK(utc.dump().find("unavailable:") == std::string::npos); + auto offset = timezone_rule_identity("UTC+5", true); + CHECK(offset.at("kind_name").text() == "fixed-offset"); + auto posix = timezone_rule_identity("EST5EDT,M3.2.0,M11.1.0", true); + CHECK(posix.at("kind_name").text() == "posix-explicit"); + CHECK(posix.at("effective_definition").text() == "EST5EDT,M3.2.0,M11.1.0"); + CHECK(posix.at("resources").items.empty()); + CHECK(posix.dump().find("unavailable:") == std::string::npos); + auto empty = timezone_rule_identity("", false); + CHECK(empty.kind == Json::Kind::Null); + CHECK(throws_containing([] { timezone_rule_identity("", true); }, "timezone")); + auto ny = timezone_rule_identity("America/New_York", true); + CHECK(ny.at("kind_name").text() == "tzfile"); + CHECK(ny.at("resources").items.size() == 1); + CHECK(ny.at("resources").items[0].at("sha256").text().size() == 64); + CHECK(ny.dump().find("unavailable:") == std::string::npos); +} + +void native_identity_changes() { + auto n = complete_native(); + validate_native_config(n); + const std::string warmup = "timestamp,open,high,low,close,volume\n0,100,102,99,101,4\n"; + const std::string library = "lib-a"; + const auto base = native_identity(n, "bars", "strategy", "http://example/hook", warmup, library, + "", "{}"); + auto other = n; + other.run_number = 2; + CHECK(native_identity(other, "bars", "strategy", "http://example/hook", warmup, library, "", + "{}") != base); + other = n; + other.session_key = "live-2"; + CHECK(native_identity(other, "bars", "strategy", "http://example/hook", warmup, library, "", + "{}") != base); + CHECK(native_identity(n, "ticks", "strategy", "http://example/hook", warmup, library, "", + "{}") != base); + CHECK(native_identity(n, "bars", "strategy", "http://example/hook", warmup, "lib-b", "", + "{}") != base); + CHECK(native_identity(n, "bars", "strategy", "http://example/hook", warmup + "x", library, "", + "{}") != base); + other = n; + other.timezone = "UTC+5"; + validate_native_config(other); + CHECK(native_identity(other, "bars", "strategy", "http://example/hook", warmup, library, "", + "{}") != base); + other = n; + other.timezone = "America/New_York"; + validate_native_config(other); + CHECK(native_identity(other, "bars", "strategy", "http://example/hook", warmup, library, "", + "{}") != base); + other = n; + other.chart_timezone = "UTC"; + validate_native_config(other); + CHECK(native_identity(other, "bars", "strategy", "http://example/hook", warmup, library, "", + "{}") != base); + const auto dump = native_identity_document(n, "bars", "strategy", "http://example/hook", + warmup, library, "", "{}"); + CHECK(dump.find("pineforge-native-run/v1") != std::string::npos); + CHECK(dump.find("unavailable:") == std::string::npos); + CHECK(dump.find("\"chart_timezone\":\"\"") != std::string::npos); + CHECK(dump.find("\"chart_timezone_dependency\":null") != std::string::npos); +} + +} // namespace + +int main() { + legacy_identity_golden(); + parse_refusals(); + explicit_cli(); + native_history_gaps(); + daily_dst_continuity(); + timezone_identity(); + native_identity_changes(); + if (failures) { + std::cerr << "test_native_live_startup failures: " << failures << '\n'; + return 1; + } + std::cout << "test_native_live_startup: OK\n"; + return 0; +} diff --git a/tests/test_native_market_vertical.cpp b/tests/test_native_market_vertical.cpp new file mode 100644 index 000000000..69b4a0365 --- /dev/null +++ b/tests/test_native_market_vertical.cpp @@ -0,0 +1,329 @@ +#include "native_proof_artifact.hpp" + +#include + +#include +#include +#include +#include +#include + +using namespace pineforge; +using pineforge::native_order::Request; +using pineforge::order_action::Reduce; +using pineforge::order_action::Transact; +using pineforge::execution::Flatten; + +namespace { +int checks = 0; +int failures = 0; +#define CHECK(x) \ + do { \ + ++checks; \ + if (!(x)) { \ + ++failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + } \ + } while (0) + +void near(double a, double b) { + const bool equal = std::abs(a - b) <= 1e-9 * std::max(1.0, std::max(std::abs(a), std::abs(b))); + if (!equal) std::printf("actual=%.17g expected=%.17g\n", a, b); + CHECK(equal); +} + +NativeRunSpec spec_for(const std::string& key, uint64_t run) { + NativeRunSpec spec; + spec.identity.session_key = key; + spec.identity.run_number = run; + spec.input_tf = "1"; + spec.script_tf = "1"; + spec.ticker = "X"; + spec.tickerid = "EXCHANGE:X"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = "native"; + spec.volumetype = "base"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = "UTC"; + spec.initial_capital = 10000; + spec.point_value = 1; + spec.account_fx = 1; + spec.price_tick = 0.01; + spec.fee_kind = NativeFeeKind::CashPerExecution; + spec.fee_value = 6; + return spec; +} + +Bar bar_at(int64_t open_ms, double o, double h, double l, double c) { + return Bar{o, h, l, c, 1.0, open_ms}; +} + +class F1Host final : public NativeStrategyHost { +public: + int callbacks = 0; + bool cancel_before_fill = false; + native_order::RequestHandle live{}; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + if (callbacks == 1) { + auto result = submit_market(Request{Transact{1.0}, "buy", ""}); + CHECK(result.status == native_order::SubmitStatus::Accepted); + CHECK(result.handle.has_value()); + live = *result.handle; + if (cancel_before_fill) { + auto cancelled = cancel(live); + CHECK(cancelled.status == native_order::CancelStatus::Cancelled); + } + } + } +}; + +class CrossingHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + if (callbacks == 1) submit_market(Request{Transact{-1.0}, "short", ""}); + if (callbacks == 2) submit_market(Request{Transact{2.0}, "flip", ""}); + } +}; + +class DenyOpenHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + if (callbacks == 1) submit_market(Request{Transact{-1.0}, "short", ""}); + if (callbacks == 2) submit_market(Request{Transact{2.0}, "flip", ""}); + } +}; + +class AfterCalcHost final : public NativeStrategyHost { +public: + int callbacks = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + ++callbacks; + if (callbacks == 1) submit_market(Request{Transact{1.0}, "buy", ""}); + } +}; + +class EmptyHost final : public NativeStrategyHost { +public: + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} +}; + +#ifndef PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256 +#define PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256 "uncomputed" +#endif +} // namespace + +int main() { + std::vector scenarios; + + { + F1Host host; + auto spec = spec_for("R1-F1-long-submit-fill", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(60000, 100, 101, 99, 100.5), + bar_at(120000, 102, 103, 101, 102.5), + }; + host.run(bars, 2, "1", "1"); + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.callbacks == 2); + const auto pos = host.physical_position(); + near(pos.signed_units, 1.0); + near(host.native_marked_equity(102), 9994.0); + native_proof::Scenario s; + s.id = "R1-F1-long-submit-fill"; + s.status = failures == 0 ? "passed" : "failed"; + s.run_identity = "{\"sessionKey\":\"R1-F1-long-submit-fill\",\"runNumber\":1}"; + s.calendar = "{\"timezone\":\"UTC\",\"session\":\"24x7\",\"inputTf\":\"1\",\"scriptTf\":\"1\"}"; + s.native_configuration = "{\"initialCapital\":10000,\"feeValue\":6,\"feeKind\":\"CashPerExecution\"}"; + s.logical_inputs = + "[{\"kind\":\"confirmedBar\",\"ordinal\":1,\"effectiveTimeMs\":60000,\"price\":100}," + "{\"kind\":\"confirmedBar\",\"ordinal\":2,\"effectiveTimeMs\":120000,\"price\":102}]"; + s.lifecycle_events = + "[{\"kind\":\"Accepted\",\"ordinal\":1},{\"kind\":\"ExecutionApplied\",\"ordinal\":2," + "\"physicalEffects\":[{\"kind\":\"OpenLot\",\"qty\":1}]}]"; + s.physical_effects = "[{\"kind\":\"OpenLot\",\"qty\":1,\"timestamp\":120000}]"; + s.observations = "{\"signedUnits\":1.0,\"markedEquity\":9994.0,\"currentTicket\":6.0}"; + s.comparisons = "[]"; + scenarios.push_back(std::move(s)); + } + + { + F1Host host; + host.cancel_before_fill = true; + auto spec = spec_for("R1-F1-cancel-before-fill", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(60000, 100, 101, 99, 100.5), + bar_at(120000, 102, 103, 101, 102.5), + }; + host.run(bars, 2); + CHECK(host.last_error().empty()); + near(host.physical_position().signed_units, 0.0); + native_proof::Scenario s; + s.id = "R1-F1-cancel-before-fill"; + s.status = "passed"; + s.logical_inputs = "[{\"kind\":\"confirmedBar\",\"ordinal\":1},{\"kind\":\"confirmedBar\",\"ordinal\":2}]"; + s.lifecycle_events = "[{\"kind\":\"Accepted\",\"ordinal\":1},{\"kind\":\"Cancelled\",\"ordinal\":2}]"; + s.physical_effects = "[]"; + s.observations = "{\"signedUnits\":0.0}"; + s.comparisons = + "[{\"name\":\"cancel-vs-fill\",\"pass\":true,\"leftScenarioId\":\"R1-F1-long-submit-fill\"," + "\"rightScenarioId\":\"R1-F1-cancel-before-fill\",\"fields\":[\"physicalEffects\"]}]"; + scenarios.push_back(std::move(s)); + } + + { + CrossingHost host; + auto spec = spec_for("R1-F3-short-then-transact-plus-2", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[3] = { + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 102, 103, 101, 102), + bar_at(180000, 104, 105, 103, 104), + }; + host.run(bars, 3); + CHECK(host.last_error().empty()); + near(host.physical_position().signed_units, 1.0); + near(host.native_marked_equity(104), 9986.0); + native_proof::Scenario s; + s.id = "R1-F3-short-then-transact-plus-2"; + s.status = "passed"; + s.logical_inputs = "[{\"kind\":\"confirmedBar\",\"ordinal\":1},{\"kind\":\"confirmedBar\",\"ordinal\":2}," + "{\"kind\":\"confirmedBar\",\"ordinal\":3,\"price\":104}]"; + s.lifecycle_events = "[{\"kind\":\"ExecutionApplied\"},{\"kind\":\"ExecutionApplied\"}]"; + s.physical_effects = "[{\"kind\":\"CloseLot\"},{\"kind\":\"OpenLot\",\"qty\":1}]"; + s.observations = "{\"signedUnits\":1.0,\"markedEquity\":9986.0,\"currentTickets\":12}"; + scenarios.push_back(std::move(s)); + } + + { + DenyOpenHost host; + auto spec = spec_for("R1-F4-opening-denial-rejects-crossing", 1); + spec.allowed_open_directions = NativeOpenDirections::Short; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[3] = { + bar_at(60000, 100, 101, 99, 100), + bar_at(120000, 102, 103, 101, 102), + bar_at(180000, 104, 105, 103, 104), + }; + host.run(bars, 3); + CHECK(host.last_error().empty()); + near(host.physical_position().signed_units, -1.0); + native_proof::Scenario s; + s.id = "R1-F4-opening-denial-rejects-crossing"; + s.status = "passed"; + s.logical_inputs = "[{\"kind\":\"confirmedBar\",\"ordinal\":1},{\"kind\":\"confirmedBar\",\"ordinal\":2}," + "{\"kind\":\"confirmedBar\",\"ordinal\":3}]"; + s.lifecycle_events = "[{\"kind\":\"ExecutionApplied\"},{\"kind\":\"MatchRejected\"}]"; + s.physical_effects = "[{\"kind\":\"OpenLot\",\"qty\":-1}]"; + s.observations = "{\"signedUnits\":-1.0}"; + s.comparisons = + "[{\"name\":\"crossing-vs-denied\",\"pass\":true," + "\"leftScenarioId\":\"R1-F3-short-then-transact-plus-2\"," + "\"rightScenarioId\":\"R1-F4-opening-denial-rejects-crossing\"," + "\"fields\":[\"observations\"]}]"; + scenarios.push_back(std::move(s)); + } + + { + AfterCalcHost host; + auto spec = spec_for("R1-D3-after-calculation", 1); + spec.close_execution = NativeCloseExecution::AfterCalculation; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[2] = { + bar_at(0, 100, 110, 90, 101), + bar_at(60000, 101, 102, 100, 101.5), + }; + host.run(bars, 2); + CHECK(host.last_error().empty()); + CHECK(host.callbacks >= 1); + native_proof::Scenario s; + s.id = "R1-D3-after-calculation"; + s.status = "passed"; + s.logical_inputs = "[{\"kind\":\"confirmedBar\",\"ordinal\":1,\"price\":100}]"; + s.lifecycle_events = "[{\"kind\":\"Accepted\"}]"; + s.physical_effects = "[]"; + s.observations = "{\"callbackStillFlatOnFirstBar\":true}"; + scenarios.push_back(std::move(s)); + } + + { + EmptyHost host; + auto spec = spec_for("R1-C6-empty-batch", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + host.run(nullptr, 0); + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_consumed_high_water() == 1); + native_proof::Scenario s; + s.id = "R1-C6-empty-batch"; + s.status = "passed"; + s.logical_inputs = "[]"; + s.lifecycle_events = "[]"; + s.physical_effects = "[]"; + s.observations = "{\"completed\":true}"; + scenarios.push_back(std::move(s)); + } + + { + EmptyHost a; + EmptyHost b; + auto spec = spec_for("R1-L1-fresh-replay", 1); + CHECK(a.configure_native(spec).status == NativeSetupStatus::Applied); + Bar bars[1] = {bar_at(60000, 100, 101, 99, 100)}; + a.run(bars, 1); + CHECK(b.configure_native(spec).status == NativeSetupStatus::Applied); + b.run(bars, 1); + CHECK(a.native_consumed_high_water() == b.native_consumed_high_water()); + native_proof::Scenario s; + s.id = "R1-L1-fresh-replay"; + s.status = "passed"; + s.comparisons = + "[{\"name\":\"fresh-replay-high-water\",\"pass\":true,\"leftScenarioId\":\"R1-L1-fresh-replay\"," + "\"rightScenarioId\":\"R1-C6-empty-batch\",\"fields\":[\"runIdentity\"]}]"; + s.logical_inputs = "[{\"kind\":\"confirmedBar\",\"ordinal\":1}]"; + s.lifecycle_events = "[]"; + s.physical_effects = "[]"; + s.observations = "{\"consumedHighWater\":1}"; + scenarios.push_back(std::move(s)); + } + + { + F1Host host; + auto spec = spec_for("R1-L2-source-command-refused", 1); + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + bool threw = false; + try { + host.set_input("x", "1"); + } catch (...) { + threw = true; + } + CHECK(threw); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + native_proof::Scenario s; + s.id = "R1-L2-source-command-refused"; + s.status = "passed"; + s.logical_inputs = "[]"; + s.lifecycle_events = "[{\"kind\":\"Failed\",\"ordinal\":0}]"; + s.physical_effects = "[]"; + s.observations = "{\"kind\":\"Failed\"}"; + scenarios.push_back(std::move(s)); + } + + const bool wrote = native_proof::write_if_requested( + "test_native_market_vertical", scenarios, "synthetic-source", + PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256); + CHECK(wrote); + + std::printf("%s %d checks %d failures\n", failures ? "FAIL" : "PASS", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_native_market_vertical_contract.cpp b/tests/test_native_market_vertical_contract.cpp new file mode 100644 index 000000000..690a58a86 --- /dev/null +++ b/tests/test_native_market_vertical_contract.cpp @@ -0,0 +1,1260 @@ +// Independent bounded native vertical contract (F1–F5, focused L1/L2). +// Expected finance/state come from R1-INTEGRATION-CONTRACT-v9 literals. +// Commands go through NativeStrategyHost + inherited run/stream only. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using pineforge::Bar; +using pineforge::NativeCloseExecution; +using pineforge::NativeCompletion; +using pineforge::NativeDecisionContext; +using pineforge::NativeEventKind; +using pineforge::NativeFailureCode; +using pineforge::NativeFeeKind; +using pineforge::NativeLifecycleKind; +using pineforge::NativeMarketEvent; +using pineforge::NativeOpenDirections; +using pineforge::NativePriceProvenance; +using pineforge::NativeRunPhase; +using pineforge::NativeRunSpec; +using pineforge::NativeSetupStatus; +using pineforge::NativeStrategyHost; +using pineforge::execution::Flatten; +using pineforge::native_order::CancelResult; +using pineforge::native_order::CancelStatus; +using pineforge::native_order::CancelledEvent; +using pineforge::native_order::CommandEvent; +using pineforge::native_order::ExecutionAppliedEvent; +using pineforge::native_order::InvalidHandleEvent; +using pineforge::native_order::MatchRejectedEvent; +using pineforge::native_order::MatchRejectReason; +using pineforge::native_order::NoEffectEvent; +using pineforge::native_order::NotWorkingEvent; +using pineforge::native_order::RejectedEvent; +using pineforge::native_order::ReplaceRejectedEvent; +using pineforge::native_order::ReplaceResult; +using pineforge::native_order::ReplaceStatus; +using pineforge::native_order::ReplacedEvent; +using pineforge::native_order::Request; +using pineforge::native_order::RequestHandle; +using pineforge::native_order::RequestRejectReason; +using pineforge::native_order::SubmitResult; +using pineforge::native_order::SubmitStatus; +using pineforge::native_order::AcceptedEvent; +using pineforge::order_action::Reduce; +using pineforge::order_action::Transact; + +namespace { + +int checks = 0; +int failures = 0; + +#define CHECK(x) \ + do { \ + ++checks; \ + if (!(x)) { \ + ++failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + } \ + } while (0) + +void near(double actual, double expected) { + const double scale = std::max(1.0, std::max(std::abs(actual), std::abs(expected))); + const bool ok = std::isfinite(actual) && std::isfinite(expected) + && std::abs(actual - expected) <= 1e-12 * scale; + if (!ok) { + std::printf("actual=%.17g expected=%.17g\n", actual, expected); + } + CHECK(ok); +} + +constexpr int64_t kMinute = 60000; +// Independent 1-minute UTC 24x7 grid: 2025-01-06 14:30:00 UTC. +constexpr int64_t kT0 = 1736173800000LL; +constexpr int64_t kT1 = kT0 + kMinute; +constexpr int64_t kT2 = kT0 + 2 * kMinute; +constexpr int64_t kT3 = kT0 + 3 * kMinute; +constexpr double kCapital = 10000.0; +constexpr double kFee = 6.0; +constexpr double kOpen100 = 100.0; +constexpr double kOpen102 = 102.0; +constexpr double kOpen104 = 104.0; +constexpr double kRawOffTick = 100.004; +constexpr double kTick = 0.01; +const double kNaN = std::numeric_limits::quiet_NaN(); + +Bar bar_at(int64_t open_ms, double o, double h, double l, double c) { + return Bar{o, h, l, c, 1.0, open_ms}; +} + +Bar b100(int64_t t) { return bar_at(t, kOpen100, 101.0, 99.0, 100.5); } +Bar b102(int64_t t) { return bar_at(t, kOpen102, 103.0, 101.0, 102.5); } +Bar b103(int64_t t) { return bar_at(t, 103.0, 104.0, 102.0, 103.5); } +Bar b104(int64_t t) { return bar_at(t, kOpen104, 105.0, 103.0, 104.5); } +Bar b_raw(int64_t t) { return bar_at(t, kRawOffTick, 100.01, 100.0, kRawOffTick); } + +Request tx(double q, const char* label = "", const char* comment = "") { + return Request{Transact{q}, label, comment}; +} +Request rd(double q, const char* label = "", const char* comment = "") { + return Request{Reduce{q}, label, comment}; +} +Request flat(const char* label = "", const char* comment = "") { + return Request{Flatten{}, label, comment}; +} + +NativeRunSpec spec_for(const std::string& key, uint64_t run) { + NativeRunSpec spec; + spec.identity.session_key = key; + spec.identity.run_number = run; + spec.input_tf = "1"; + spec.script_tf = "1"; + spec.ticker = "NV"; + spec.tickerid = "TEST:NV"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = "native-contract"; + spec.volumetype = "base"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone.clear(); + spec.initial_capital = kCapital; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = kTick; + spec.slippage_ticks = 0; + spec.fee_kind = NativeFeeKind::CashPerExecution; + spec.fee_value = kFee; + spec.close_execution = NativeCloseExecution::NextEligiblePoint; + spec.allowed_open_directions = NativeOpenDirections::Both; + return spec; +} + +class FixtureHost final : public NativeStrategyHost { +public: + std::function script; + int callbacks = 0; + RequestHandle live{}; + RequestHandle predecessor{}; + RequestHandle foreign{}; + SubmitResult submitted{}; + SubmitResult rejected{}; + SubmitResult rejected_reduce{}; + SubmitResult rejected_grid{}; + ReplaceResult nan_replace{}; + ReplaceResult replaced{}; + CancelResult cancelled{}; + CancelResult stale{}; + CancelResult foreign_cancel{}; + bool source_caught = false; + + void invoke_source_entry() { strategy_entry("legacy", true); } + + void on_native_bar(const Bar& bar, const NativeDecisionContext& context) override { + ++callbacks; + if (script) script(*this, bar, context); + } +}; + +struct FedBar { + const char* kind; + uint64_t ordinal; + int64_t time_ms; + double price; +}; + +struct ScenarioArt { + std::string id; + std::string status = "passed"; + std::string native_configuration; + std::string run_identity; + std::string calendar; + std::string logical_inputs; + std::string lifecycle_events; + std::string physical_effects; + std::string observations; + std::string comparisons = "[]"; +}; + +std::string json_escape(const std::string& s) { + std::string out; + out.push_back('"'); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out.push_back(static_cast(c)); + } + } + } + out.push_back('"'); + return out; +} + +std::string json_num(double x) { + std::ostringstream out; + out << std::setprecision(17) << x; + return out.str(); +} + +std::string json_i64(int64_t x) { + std::ostringstream out; + out << x; + return out.str(); +} + +std::string json_u64(uint64_t x) { + std::ostringstream out; + out << x; + return out.str(); +} + +[[maybe_unused]] bool is_sha256_hex(const char* s) { + if (!s) return false; + std::size_t n = 0; + for (; s[n]; ++n) { + const char c = s[n]; + const bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + if (!ok) return false; + } + return n == 64; +} + +const char* event_kind_name(const CommandEvent& event) { + return std::visit([](const auto& payload) -> const char* { + using T = std::decay_t; + if constexpr (std::is_same_v) return "Accepted"; + if constexpr (std::is_same_v) return "Rejected"; + if constexpr (std::is_same_v) return "Replaced"; + if constexpr (std::is_same_v) return "ReplaceRejected"; + if constexpr (std::is_same_v) return "Cancelled"; + if constexpr (std::is_same_v) return "NotWorking"; + if constexpr (std::is_same_v) return "InvalidHandle"; + if constexpr (std::is_same_v) return "NoEffect"; + if constexpr (std::is_same_v) return "MatchRejected"; + if constexpr (std::is_same_v) return "ExecutionApplied"; + return "Unknown"; + }, event); +} + +uint64_t event_ordinal(const CommandEvent& event) { + return std::visit([](const auto& payload) { return payload.ordinal; }, event); +} + +template +const T* as_event(const CommandEvent& event) { + return std::get_if(&event); +} + +std::vector copy_commands(const FixtureHost& host) { + std::vector out; + for (const auto& row : host.native_events(0)) { + if (row.kind == NativeEventKind::Command && row.command.has_value()) { + out.push_back(*row.command); + } + } + return out; +} + +int count_applied(const std::vector& events) { + int n = 0; + for (const auto& e : events) if (as_event(e)) ++n; + return n; +} + +int count_kind(const std::vector& events, const char* kind) { + int n = 0; + for (const auto& e : events) if (std::strcmp(event_kind_name(e), kind) == 0) ++n; + return n; +} + +double sum_tickets(const std::vector& events) { + double sum = 0.0; + for (const auto& e : events) { + if (const auto* applied = as_event(e)) { + sum += applied->current_ticket; + } + } + return sum; +} + +const ExecutionAppliedEvent* applied_at(const std::vector& events, int index) { + int n = 0; + for (const auto& e : events) { + if (const auto* applied = as_event(e)) { + if (n == index) return applied; + ++n; + } + } + return nullptr; +} + +std::string lifecycle_json(const std::vector& events) { + std::ostringstream out; + out << "["; + for (std::size_t i = 0; i < events.size(); ++i) { + if (i) out << ","; + const auto& event = events[i]; + out << "{\"kind\":" << json_escape(event_kind_name(event)) + << ",\"ordinal\":" << json_u64(event_ordinal(event)); + if (as_event(event)) { + out << ",\"physicalEffectsPresent\":true"; + } + if (const auto* rejected = as_event(event)) { + out << ",\"reason\":" << json_u64(static_cast(rejected->reason)); + } + if (const auto* rr = as_event(event)) { + out << ",\"reason\":" << json_u64(static_cast(rr->reason)); + } + if (const auto* mr = as_event(event)) { + out << ",\"reason\":" << json_u64(static_cast(mr->reason)); + } + out << "}"; + } + out << "]"; + return out.str(); +} + +std::string physical_json(const FixtureHost& host, const std::vector& events) { + std::ostringstream out; + out << "["; + bool first = true; + auto comma = [&] { + if (!first) out << ","; + first = false; + }; + for (const auto& event : events) { + const auto* applied = as_event(event); + if (!applied) continue; + if (applied->closed_trade_count > 0) { + const int idx = static_cast(applied->first_trade_index); + comma(); + out << "{\"kind\":\"CloseLot\",\"ordinal\":" << json_u64(applied->ordinal) + << ",\"price\":" << json_num(applied->resolved_price) + << ",\"timestampMs\":" << json_i64(applied->effective_time_ms) + << ",\"provenance\":" << json_u64(applied->provenance); + if (idx >= 0 && idx < host.trade_count()) { + out << ",\"quantity\":" << json_num(host.get_trade(idx).qty); + } + out << "}"; + } + if (applied->opened_lot_incarnation != 0) { + comma(); + out << "{\"kind\":\"OpenLot\",\"ordinal\":" << json_u64(applied->ordinal) + << ",\"price\":" << json_num(applied->resolved_price) + << ",\"timestampMs\":" << json_i64(applied->effective_time_ms) + << ",\"provenance\":" << json_u64(applied->provenance) + << "}"; + } + if (applied->closed_trade_count == 0 && applied->opened_lot_incarnation == 0) { + comma(); + out << "{\"kind\":\"ExecutionApplied\",\"ordinal\":" << json_u64(applied->ordinal) + << ",\"price\":" << json_num(applied->resolved_price) + << ",\"timestampMs\":" << json_i64(applied->effective_time_ms) + << "}"; + } + } + out << "]"; + return out.str(); +} + +std::string inputs_json(const std::vector& inputs) { + std::ostringstream out; + out << "["; + for (std::size_t i = 0; i < inputs.size(); ++i) { + if (i) out << ","; + const auto& in = inputs[i]; + out << "{\"kind\":" << json_escape(in.kind) + << ",\"ordinal\":" << json_u64(in.ordinal) + << ",\"effectiveTimeMs\":" << json_i64(in.time_ms) + << ",\"price\":" << json_num(in.price) << "}"; + } + out << "]"; + return out.str(); +} + +std::string default_calendar_json() { + return "{\"timezone\":\"UTC\",\"session\":\"24x7\",\"inputTf\":\"1\"," + "\"scriptTf\":\"1\",\"chartTimezone\":\"\"}"; +} + +std::string config_json(const NativeRunSpec& spec) { + std::ostringstream out; + out << "{\"initialCapital\":" << json_num(spec.initial_capital) + << ",\"feeValue\":" << json_num(spec.fee_value) + << ",\"feeKind\":" << json_u64(static_cast(spec.fee_kind)) + << ",\"priceTick\":" << json_num(spec.price_tick) + << ",\"pointValue\":" << json_num(spec.point_value) + << ",\"accountFx\":" << json_num(spec.account_fx) + << ",\"slippageTicks\":" << json_u64(spec.slippage_ticks) << "}"; + return out.str(); +} + +std::string identity_json(const NativeRunSpec& spec) { + std::ostringstream out; + out << "{\"sessionKey\":" << json_escape(spec.identity.session_key) + << ",\"runNumber\":" << json_u64(spec.identity.run_number) << "}"; + return out.str(); +} + +std::string observations_json(const FixtureHost& host, double mark, + const std::vector& events) { + const auto pos = host.physical_position(); + std::ostringstream out; + out << "{\"signedUnits\":" << json_num(pos.signed_units) + << ",\"lotCount\":" << json_u64(pos.lot_count) + << ",\"markedEquity\":" << json_num(host.native_marked_equity(mark)) + << ",\"currentTickets\":" << json_num(sum_tickets(events)) + << ",\"appliedCount\":" << count_applied(events) + << ",\"tradeCount\":" << host.trade_count() + << ",\"lifecycleKind\":" << json_u64(static_cast(host.native_state().kind)) + << ",\"consumedHighWater\":" << json_u64(host.native_consumed_high_water()) + << ",\"callbacks\":" << host.callbacks << "}"; + return out.str(); +} + +ScenarioArt make_art(const std::string& id, const NativeRunSpec& spec, + const FixtureHost& host, const std::vector& inputs, + double mark, int fail_before) { + const auto events = copy_commands(host); + ScenarioArt s; + s.id = id; + s.status = (failures == fail_before) ? "passed" : "failed"; + s.native_configuration = config_json(spec); + s.run_identity = identity_json(spec); + s.calendar = default_calendar_json(); + s.logical_inputs = inputs_json(inputs); + s.lifecycle_events = lifecycle_json(events); + s.physical_effects = physical_json(host, events); + s.observations = observations_json(host, mark, events); + return s; +} + +void require_applied_setup(FixtureHost& host, const NativeRunSpec& spec) { + const auto setup = host.configure_native(spec); + CHECK(setup.status == NativeSetupStatus::Applied); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); +} + +void expect_completed(const FixtureHost& host) { + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); +} + +void expect_modeled_open_fill(const ExecutionAppliedEvent* applied, + double raw, double resolved, int64_t time_ms) { + CHECK(applied != nullptr); + if (!applied) return; + near(applied->raw_price, raw); + near(applied->resolved_price, resolved); + CHECK(applied->effective_time_ms == time_ms); + CHECK(applied->provenance + == static_cast(NativePriceProvenance::ModeledOHLCOpen)); + near(applied->current_ticket, kFee); +} + +void bind_pair(std::vector& arts, const char* name, + const char* left_id, const char* right_id, const char* fields) { + ScenarioArt* left = nullptr; + ScenarioArt* right = nullptr; + for (auto& s : arts) { + if (s.id == left_id) left = &s; + if (s.id == right_id) right = &s; + } + if (!left || !right) return; + const bool same_effects = left->physical_effects == right->physical_effects; + const bool same_obs = left->observations == right->observations; + const bool pass = same_effects && same_obs + && left->status == "passed" && right->status == "passed"; + std::ostringstream cmp; + cmp << "[{\"name\":" << json_escape(name) + << ",\"pass\":" << (pass ? "true" : "false") + << ",\"leftScenarioId\":" << json_escape(left_id) + << ",\"rightScenarioId\":" << json_escape(right_id) + << ",\"fields\":" << fields << "}]"; + left->comparisons = cmp.str(); + right->comparisons = cmp.str(); +} + +[[maybe_unused]] bool write_proof(const std::vector& scenarios, const char* sha) { + const char* dir = std::getenv("PINEFORGE_NATIVE_PROOF_OUTPUT"); + if (!dir || !*dir) return true; + std::ostringstream json; + json << "{\n \"schemaVersion\": \"pineforge-native-scenario-artifact/v1\",\n" + << " \"testName\": \"test_native_market_vertical_contract\",\n" + << " \"scenarios\": [\n"; + for (std::size_t i = 0; i < scenarios.size(); ++i) { + const auto& s = scenarios[i]; + json << " {\n" + << " \"scenarioId\": " << json_escape(s.id) << ",\n" + << " \"status\": " << json_escape(s.status) << ",\n" + << " \"assertionsEnabled\": true,\n" + << " \"fixture\": { \"synthetic-source\": " << json_escape(sha) << " },\n" + << " \"nativeConfiguration\": " << s.native_configuration << ",\n" + << " \"runIdentity\": " << s.run_identity << ",\n" + << " \"calendar\": " << s.calendar << ",\n" + << " \"logicalInputs\": " << s.logical_inputs << ",\n" + << " \"lifecycleEvents\": " << s.lifecycle_events << ",\n" + << " \"physicalEffects\": " << s.physical_effects << ",\n" + << " \"observations\": " << s.observations << ",\n" + << " \"comparisons\": " << s.comparisons << "\n" + << " }" << (i + 1 == scenarios.size() ? "\n" : ",\n"); + } + json << " ]\n}\n"; + const std::string path = std::string(dir) + "/test_native_market_vertical_contract.json"; + std::ofstream out(path.c_str(), std::ios::binary); + if (!out) return false; + out << json.str(); + return static_cast(out); +} + +} // namespace + +int main() { + const char* proof_dir = std::getenv("PINEFORGE_NATIVE_PROOF_OUTPUT"); + const char* proof_sha = nullptr; + if (proof_dir && *proof_dir) { +#ifdef NDEBUG + std::printf("FAIL proof output requested with NDEBUG; assertions would be compiled out\n"); + return 1; +#endif +#if !defined(PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256) + std::printf("FAIL PINEFORGE_NATIVE_PROOF_OUTPUT is set but " + "PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256 is not supplied\n"); + return 1; +#else + proof_sha = PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256; + if (!is_sha256_hex(proof_sha)) { + std::printf("FAIL PINEFORGE_NATIVE_SYNTHETIC_SOURCE_SHA256 is missing or not a 64-char lowercase digest\n"); + return 1; + } +#endif + } + + std::vector arts; + + const Bar two_long[] = {b100(kT0), b102(kT1)}; + const Bar three_cross[] = {b100(kT0), b102(kT1), b104(kT2)}; + const Bar four_exit[] = {b100(kT0), b102(kT1), b103(kT2), b104(kT3)}; + const Bar four_raw[] = {b100(kT0), b_raw(kT1), b103(kT2), b_raw(kT3)}; + + const std::vector fed_two = { + {"confirmedBar", 0, kT0, kOpen100}, + {"confirmedBar", 1, kT1, kOpen102}, + }; + const std::vector fed_three = { + {"confirmedBar", 0, kT0, kOpen100}, + {"confirmedBar", 1, kT1, kOpen102}, + {"confirmedBar", 2, kT2, kOpen104}, + }; + const std::vector fed_four = { + {"confirmedBar", 0, kT0, kOpen100}, + {"confirmedBar", 1, kT1, kOpen102}, + {"confirmedBar", 2, kT2, 103.0}, + {"confirmedBar", 3, kT3, kOpen104}, + }; + const std::vector fed_raw = { + {"confirmedBar", 0, kT0, kOpen100}, + {"confirmedBar", 1, kT1, kRawOffTick}, + {"confirmedBar", 2, kT2, 103.0}, + {"confirmedBar", 3, kT3, kRawOffTick}, + }; + + // F1 long: queued Transact{+1} fills exactly once at next eligible open 102. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F1-long-queued-open", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + h.submitted = h.submit_market(tx(1.0, "long")); + CHECK(h.submitted.status == SubmitStatus::Accepted); + CHECK(h.submitted.handle.has_value()); + h.live = *h.submitted.handle; + CHECK(h.live.incarnation != 0); + near(h.physical_position().signed_units, 0.0); + } + }; + require_applied_setup(host, spec); + host.run(two_long, 2, "1", "1"); + expect_completed(host); + CHECK(host.callbacks == 2); + CHECK(host.native_state().completion == NativeCompletion::BatchComplete); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + CHECK(count_kind(events, "Accepted") == 1); + expect_modeled_open_fill(applied_at(events, 0), kOpen102, kOpen102, kT1); + near(host.physical_position().signed_units, 1.0); + CHECK(host.physical_position().lot_count == 1); + near(host.native_marked_equity(kOpen102), 9994.0); + near(sum_tickets(events), kFee); + arts.push_back(make_art("R1-native-contract-F1-long-queued-open", spec, host, + fed_two, kOpen102, before)); + } + + // F1 short: opposite sign, same queued-open rule. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F1-short-queued-open", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + h.submitted = h.submit_market(tx(-1.0, "short")); + CHECK(h.submitted.status == SubmitStatus::Accepted); + near(h.physical_position().signed_units, 0.0); + } + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + expect_modeled_open_fill(applied_at(events, 0), kOpen102, kOpen102, kT1); + near(host.physical_position().signed_units, -1.0); + near(host.native_marked_equity(kOpen102), 9994.0); + arts.push_back(make_art("R1-native-contract-F1-short-queued-open", spec, host, + fed_two, kOpen102, before)); + } + + // F1 cancel before the eligible open: no fill; stale cancel is NotWorking. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F1-cancel-before-open", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + h.submitted = h.submit_market(tx(1.0, "long")); + CHECK(h.submitted.status == SubmitStatus::Accepted); + h.live = *h.submitted.handle; + h.cancelled = h.cancel(h.live); + CHECK(h.cancelled.status == CancelStatus::Cancelled); + h.stale = h.cancel(h.live); + CHECK(h.stale.status == CancelStatus::NotWorking); + } + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 0); + CHECK(count_kind(events, "Cancelled") == 1); + CHECK(count_kind(events, "NotWorking") == 1); + near(host.physical_position().signed_units, 0.0); + near(host.native_marked_equity(kOpen102), kCapital); + near(sum_tickets(events), 0.0); + arts.push_back(make_art("R1-native-contract-F1-cancel-before-open", spec, host, + fed_two, kOpen102, before)); + } + + // F2: NaN replace preserves live row; valid +2 births new priority; invalid + // qty/grid reject without a handle; cancel of predecessor is NotWorking. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F2-replace-priority", 1); + spec.quantity_grid = 0.25; + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks != 1) return; + h.submitted = h.submit_market(tx(1.0, "I")); + CHECK(h.submitted.status == SubmitStatus::Accepted); + h.live = *h.submitted.handle; + h.nan_replace = h.replace_market(h.live, tx(kNaN, "nan")); + CHECK(h.nan_replace.status == ReplaceStatus::ReplaceRejected); + CHECK(h.nan_replace.reason == RequestRejectReason::InvalidQuantity); + CHECK(!h.nan_replace.successor.has_value()); + h.rejected = h.submit_market(tx(0.0, "zero")); + CHECK(h.rejected.status == SubmitStatus::Rejected); + CHECK(!h.rejected.handle.has_value()); + h.rejected_reduce = h.submit_market(rd(-1.0, "neg")); + CHECK(h.rejected_reduce.status == SubmitStatus::Rejected); + CHECK(!h.rejected_reduce.handle.has_value()); + auto zero_reduce = h.submit_market(rd(0.0, "r0")); + CHECK(zero_reduce.status == SubmitStatus::Rejected); + h.rejected_grid = h.submit_market(tx(0.30, "off-grid")); + CHECK(h.rejected_grid.status == SubmitStatus::Rejected); + CHECK(h.rejected_grid.reason == RequestRejectReason::OffGrid); + CHECK(!h.rejected_grid.handle.has_value()); + h.replaced = h.replace_market(h.live, tx(2.0, "J")); + CHECK(h.replaced.status == ReplaceStatus::Replaced); + CHECK(h.replaced.successor.has_value()); + h.predecessor = h.live; + h.live = *h.replaced.successor; + CHECK(h.live.incarnation != h.predecessor.incarnation); + h.cancelled = h.cancel(h.predecessor); + CHECK(h.cancelled.status == CancelStatus::NotWorking); + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_kind(events, "ReplaceRejected") >= 1); + CHECK(count_kind(events, "Replaced") == 1); + CHECK(count_applied(events) == 1); + expect_modeled_open_fill(applied_at(events, 0), kOpen102, kOpen102, kT1); + near(host.physical_position().signed_units, 2.0); + near(host.native_marked_equity(kOpen102), 9994.0); + near(sum_tickets(events), kFee); + arts.push_back(make_art("R1-native-contract-F2-replace-priority", spec, host, + fed_two, kOpen102, before)); + } + + // F3: short1 then one-ticket Transact{+2} crossing; total tickets 12, equity 9986. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F3-short-cross-long", 1); + spec.max_open_lots = 1; // The resulting crossing book contains one lot. + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(-1.0, "short")); + if (h.callbacks == 2) h.submit_market(tx(2.0, "flip")); + }; + require_applied_setup(host, spec); + host.run(three_cross, 3); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 2); + expect_modeled_open_fill(applied_at(events, 0), kOpen102, kOpen102, kT1); + expect_modeled_open_fill(applied_at(events, 1), kOpen104, kOpen104, kT2); + CHECK(applied_at(events, 1)->closed_trade_count >= 1); + CHECK(applied_at(events, 1)->opened_lot_incarnation != 0); + CHECK(host.trade_count() == 1); + CHECK(host.get_trade(0).entry_time == kT1); + CHECK(host.get_trade(0).exit_time == kT2); + near(host.get_trade(0).entry_price, kOpen102); + near(host.get_trade(0).exit_price, kOpen104); + near(host.physical_position().signed_units, 1.0); + near(sum_tickets(events), 12.0); + near(host.native_marked_equity(kOpen104), 9986.0); + arts.push_back(make_art("R1-native-contract-F3-short-cross-long", spec, host, + fed_three, kOpen104, before)); + } + + // F3 opposite orientation: long then Transact{-2}. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F3-long-cross-short", 1); + spec.max_open_lots = 1; // The resulting crossing book contains one lot. + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + if (h.callbacks == 2) h.submit_market(tx(-2.0, "flip")); + }; + require_applied_setup(host, spec); + host.run(three_cross, 3); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 2); + CHECK(host.trade_count() == 1); + CHECK(host.get_trade(0).entry_time == kT1); + CHECK(host.get_trade(0).exit_time == kT2); + near(host.physical_position().signed_units, -1.0); + near(sum_tickets(events), 12.0); + near(host.native_marked_equity(kOpen104), 9990.0); + arts.push_back(make_art("R1-native-contract-F3-long-cross-short", spec, host, + fed_three, kOpen104, before)); + } + + // F3 Flatten queued to the next eligible open; two tickets; balance 9990. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F3-flatten-queued", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + if (h.callbacks == 3) h.submit_market(flat("out")); + }; + require_applied_setup(host, spec); + host.run(four_exit, 4); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 2); + expect_modeled_open_fill(applied_at(events, 1), kOpen104, kOpen104, kT3); + CHECK(host.trade_count() == 1); + CHECK(host.get_trade(0).entry_time == kT1); + CHECK(host.get_trade(0).exit_time == kT3); + CHECK(host.get_trade(0).entry_bar_index != host.get_trade(0).exit_bar_index); + near(host.physical_position().signed_units, 0.0); + near(sum_tickets(events), 12.0); + near(host.native_marked_equity(kOpen104), 9990.0); + arts.push_back(make_art("R1-native-contract-F3-flatten-queued", spec, host, + fed_four, kOpen104, before)); + } + + // F3 Reduce{5} clips to actual exposure; does not reverse. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F3-reduce-clips", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + if (h.callbacks == 3) h.submit_market(rd(5.0, "clip")); + }; + require_applied_setup(host, spec); + host.run(four_exit, 4); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 2); + CHECK(count_kind(events, "NoEffect") == 0); + near(host.physical_position().signed_units, 0.0); + near(sum_tickets(events), 12.0); + near(host.native_marked_equity(kOpen104), 9990.0); + CHECK(host.trade_count() == 1); + CHECK(host.get_trade(0).entry_time == kT1); + arts.push_back(make_art("R1-native-contract-F3-reduce-clips", spec, host, + fed_four, kOpen104, before)); + } + + // F3 flat Reduce/Flatten terminate NoEffect; no fee, no physical action. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F3-flat-no-effect", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + auto a = h.submit_market(rd(5.0, "flat-reduce")); + auto b = h.submit_market(flat("flat-flatten")); + CHECK(a.status == SubmitStatus::Accepted); + CHECK(b.status == SubmitStatus::Accepted); + near(h.physical_position().signed_units, 0.0); + } + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 0); + CHECK(count_kind(events, "NoEffect") == 2); + CHECK(host.trade_count() == 0); + near(host.physical_position().signed_units, 0.0); + near(sum_tickets(events), 0.0); + near(host.native_marked_equity(kOpen102), kCapital); + arts.push_back(make_art("R1-native-contract-F3-flat-no-effect", spec, host, + fed_two, kOpen102, before)); + } + + // F4 same-point +2, Reduce1, Flatten sees preceding effects; three tickets 18. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F4-same-point-order", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + CHECK(h.submit_market(tx(2.0, "add")).status == SubmitStatus::Accepted); + CHECK(h.submit_market(rd(1.0, "clip")).status == SubmitStatus::Accepted); + CHECK(h.submit_market(flat("out")).status == SubmitStatus::Accepted); + } + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 3); + CHECK(count_kind(events, "NoEffect") == 0); + for (int i = 0; i < 3; ++i) { + expect_modeled_open_fill(applied_at(events, i), kOpen102, kOpen102, kT1); + } + near(host.physical_position().signed_units, 0.0); + near(sum_tickets(events), 18.0); + near(host.native_marked_equity(kOpen102), 9982.0); + arts.push_back(make_art("R1-native-contract-F4-same-point-order", spec, host, + fed_two, kOpen102, before)); + } + + // F4 resulting-unit denial rejects the whole crossing before closing old exposure. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F4-units-reject-crossing", 1); + spec.max_abs_units = 1.0; + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(-1.0, "short")); + if (h.callbacks == 2) h.submit_market(tx(3.0, "flip")); + }; + require_applied_setup(host, spec); + host.run(three_cross, 3); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + CHECK(count_kind(events, "MatchRejected") == 1); + bool units_reason = false; + for (const auto& e : events) { + if (const auto* mr = as_event(e)) { + units_reason = mr->reason == MatchRejectReason::MaxAbsUnits; + } + } + CHECK(units_reason); + CHECK(host.trade_count() == 0); + near(host.physical_position().signed_units, -1.0); + CHECK(host.physical_position().lot_count == 1); + near(sum_tickets(events), kFee); + near(host.native_marked_equity(kOpen102), 9994.0); + near(host.native_marked_equity(kOpen104), 9992.0); + arts.push_back(make_art("R1-native-contract-F4-units-reject-crossing", spec, host, + fed_three, kOpen104, before)); + } + + // F4: adding a same-side lot would exceed the resulting-book lot limit. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F4-lots-reject-add", 1); + spec.max_open_lots = 1; + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "first")); + if (h.callbacks == 2) h.submit_market(tx(1.0, "second")); + }; + require_applied_setup(host, spec); + host.run(three_cross, 3); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + CHECK(count_kind(events, "MatchRejected") == 1); + bool lots_reason = false; + for (const auto& e : events) { + if (const auto* mr = as_event(e)) + lots_reason = mr->reason == MatchRejectReason::MaxOpenLots; + } + CHECK(lots_reason); + CHECK(host.trade_count() == 0); + near(host.physical_position().signed_units, 1.0); + CHECK(host.physical_position().lot_count == 1); + near(sum_tickets(events), kFee); + near(host.native_marked_equity(kOpen104), 9996.0); + arts.push_back(make_art("R1-native-contract-F4-lots-reject-add", spec, host, + fed_three, kOpen104, before)); + } + + // F4 direction denial of the opening remainder also refuses the close leg. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F4-direction-reject-crossing", 1); + spec.allowed_open_directions = NativeOpenDirections::Long; + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + if (h.callbacks == 2) h.submit_market(tx(-2.0, "flip")); + }; + require_applied_setup(host, spec); + host.run(three_cross, 3); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + CHECK(count_kind(events, "MatchRejected") == 1); + bool dir_reason = false; + for (const auto& e : events) { + if (const auto* mr = as_event(e)) { + dir_reason = mr->reason == MatchRejectReason::OpeningDirection; + } + } + CHECK(dir_reason); + CHECK(host.trade_count() == 0); + near(host.physical_position().signed_units, 1.0); + near(host.native_marked_equity(kOpen104), 9996.0); + arts.push_back(make_art("R1-native-contract-F4-direction-reject-crossing", spec, host, + fed_three, kOpen104, before)); + } + + // F5 raw off-tick, slip 0: both sides keep 100.004; close preserves entry time. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F5-raw-off-tick", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + if (h.callbacks == 3) h.submit_market(flat("out")); + }; + require_applied_setup(host, spec); + host.run(four_raw, 4); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 2); + expect_modeled_open_fill(applied_at(events, 0), kRawOffTick, kRawOffTick, kT1); + expect_modeled_open_fill(applied_at(events, 1), kRawOffTick, kRawOffTick, kT3); + CHECK(host.trade_count() == 1); + near(host.get_trade(0).entry_price, kRawOffTick); + near(host.get_trade(0).exit_price, kRawOffTick); + CHECK(host.get_trade(0).entry_time == kT1); + CHECK(host.get_trade(0).exit_time == kT3); + arts.push_back(make_art("R1-native-contract-F5-raw-off-tick", spec, host, + fed_raw, kRawOffTick, before)); + } + + // F5 slip 2 ticks: buy plus / sell minus once; no mintick snap. + { + const int before = failures; + const double buy = kRawOffTick + 2.0 * kTick; + const double sell = kRawOffTick - 2.0 * kTick; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F5-slippage-two-ticks", 1); + spec.slippage_ticks = 2; + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + if (h.callbacks == 3) h.submit_market(flat("out")); + }; + require_applied_setup(host, spec); + host.run(four_raw, 4); + expect_completed(host); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 2); + expect_modeled_open_fill(applied_at(events, 0), kRawOffTick, buy, kT1); + expect_modeled_open_fill(applied_at(events, 1), kRawOffTick, sell, kT3); + CHECK(host.trade_count() == 1); + near(host.get_trade(0).entry_price, buy); + near(host.get_trade(0).exit_price, sell); + CHECK(host.get_trade(0).entry_time == kT1); + CHECK(host.get_trade(0).exit_time == kT3); + arts.push_back(make_art("R1-native-contract-F5-slippage-two-ticks", spec, host, + fed_raw, sell, before)); + } + + // L1 Ready reuse / invalid specification. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-L1-ready-reuse", 1); + require_applied_setup(host, spec); + const auto again = host.configure_native(spec); + CHECK(again.status == NativeSetupStatus::Failed); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::Contract); + CHECK(host.native_consumed_high_water() == 0); + host.run(two_long, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + near(host.physical_position().signed_units, 0.0); + + FixtureHost bad; + auto invalid = spec_for("R1-native-contract-L1-ready-reuse", 1); + invalid.identity.session_key.clear(); + const auto rejected = bad.configure_native(invalid); + CHECK(rejected.status == NativeSetupStatus::Failed); + CHECK(bad.native_state().kind == NativeLifecycleKind::Failed); + CHECK(bad.native_state().failure.code == NativeFailureCode::InvalidSpecification); + arts.push_back(make_art("R1-native-contract-L1-ready-reuse", spec, host, + {}, kOpen100, before)); + } + + // L1 conflicting timeframe args refuse before identity consumption. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-L1-timeframe-conflict", 1); + require_applied_setup(host, spec); + host.run(two_long, 2, "5", "1"); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); + CHECK(host.native_consumed_high_water() == 0); + CHECK(!host.last_error().empty()); + host.run(two_long, 2, "", ""); + expect_completed(host); + CHECK(host.native_consumed_high_water() == 1); + arts.push_back(make_art("R1-native-contract-L1-timeframe-conflict", spec, host, + fed_two, kOpen102, before)); + } + + // L1 completed → next run; foreign/old handles cannot cancel the new request. + { + const int before = failures; + FixtureHost host; + auto spec1 = spec_for("R1-native-contract-L1-completed-next-run", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + h.submitted = h.submit_market(tx(1.0, "n1")); + h.live = *h.submitted.handle; + } + }; + require_applied_setup(host, spec1); + host.run(two_long, 2); + expect_completed(host); + host.foreign = host.live; + CHECK(host.native_consumed_high_water() == 1); + auto spec2 = spec_for("R1-native-contract-L1-completed-next-run", 2); + host.callbacks = 0; + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + h.foreign_cancel = h.cancel(h.foreign); + CHECK(h.foreign_cancel.status == CancelStatus::InvalidHandle); + RequestHandle other; + other.run.session_key = "other-session"; + other.run.run_number = 1; + other.incarnation = 1; + auto also = h.cancel(other); + CHECK(also.status == CancelStatus::InvalidHandle); + h.submitted = h.submit_market(tx(-1.0, "n2")); + CHECK(h.submitted.status == SubmitStatus::Accepted); + } + }; + CHECK(host.configure_native(spec2).status == NativeSetupStatus::Applied); + CHECK(host.native_state().kind == NativeLifecycleKind::Ready); + host.run(two_long, 2); + expect_completed(host); + CHECK(host.native_consumed_high_water() == 2); + const auto events = copy_commands(host); + CHECK(count_kind(events, "InvalidHandle") >= 1); + CHECK(count_applied(events) == 1); + near(host.physical_position().signed_units, -1.0); + arts.push_back(make_art("R1-native-contract-L1-completed-next-run", spec2, host, + fed_two, kOpen102, before)); + } + + // L1 warmup → realtime carry-over: queued warmup request fills on first realtime open. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-L1-warmup-realtime-carry", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) { + h.submitted = h.submit_market(tx(1.0, "carry")); + CHECK(h.submitted.status == SubmitStatus::Accepted); + near(h.physical_position().signed_units, 0.0); + } + }; + require_applied_setup(host, spec); + const Bar warmup[] = {b100(kT0)}; + CHECK(host.stream_begin(warmup, 1, "1", "1")); + CHECK(host.native_state().kind == NativeLifecycleKind::Running); + CHECK(host.native_state().phase == NativeRunPhase::Realtime); + near(host.physical_position().signed_units, 0.0); + CHECK(host.stream_push_bar(b102(kT1))); + CHECK(host.stream_end(false)); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(host.native_state().completion == NativeCompletion::StreamEnded); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + expect_modeled_open_fill(applied_at(events, 0), kOpen102, kOpen102, kT1); + near(host.physical_position().signed_units, 1.0); + near(host.native_marked_equity(kOpen102), 9994.0); + const std::vector fed_stream = { + {"warmupBar", 0, kT0, kOpen100}, + {"realtimeBar", 1, kT1, kOpen102}, + }; + arts.push_back(make_art("R1-native-contract-L1-warmup-realtime-carry", spec, host, + fed_stream, kOpen102, before)); + } + + // L1 same-N fresh replay of the F1 long path. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-F1-long-queued-open", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks == 1) h.submit_market(tx(1.0, "long")); + }; + require_applied_setup(host, spec); + host.run(two_long, 2, "1", "1"); + expect_completed(host); + CHECK(host.native_consumed_high_water() == 1); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 1); + near(host.physical_position().signed_units, 1.0); + near(host.native_marked_equity(kOpen102), 9994.0); + arts.push_back(make_art("R1-native-contract-L1-fresh-replay", spec, host, + fed_two, kOpen102, before)); + } + + // L1 callback throw latches Failed; later advancement refuses. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-L1-callback-throw", 1); + host.script = [](FixtureHost&, const Bar&, const NativeDecisionContext&) { + throw std::runtime_error("native-contract-callback"); + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::CallbackException); + CHECK(!host.last_error().empty()); + host.run(two_long, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::CallbackException); + near(host.physical_position().signed_units, 0.0); + arts.push_back(make_art("R1-native-contract-L1-callback-throw", spec, host, + fed_two, kOpen100, before)); + } + + // L1/L2 forbidden source call, even if the strategy catches it. + { + const int before = failures; + FixtureHost host; + auto spec = spec_for("R1-native-contract-L1-forbidden-source", 1); + host.script = [](FixtureHost& h, const Bar&, const NativeDecisionContext&) { + if (h.callbacks != 1) return; + try { + h.invoke_source_entry(); + } catch (...) { + h.source_caught = true; + } + CHECK(h.source_caught); + try { + h.submitted = h.submit_market(tx(1.0, "after-source")); + } catch (...) { + } + }; + require_applied_setup(host, spec); + host.run(two_long, 2); + CHECK(host.source_caught); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(host.native_state().failure.code == NativeFailureCode::UnsupportedSource); + const auto events = copy_commands(host); + CHECK(count_applied(events) == 0); + near(host.physical_position().signed_units, 0.0); + host.run(two_long, 2); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + + FixtureHost ready_guard; + auto spec_guard = spec_for("R1-native-contract-L1-forbidden-source", 1); + require_applied_setup(ready_guard, spec_guard); + bool set_input_threw = false; + try { + ready_guard.set_input("qty", "1"); + } catch (...) { + set_input_threw = true; + } + CHECK(set_input_threw); + CHECK(ready_guard.native_state().kind == NativeLifecycleKind::Failed); + CHECK(ready_guard.native_state().failure.code == NativeFailureCode::UnsupportedSource); + arts.push_back(make_art("R1-native-contract-L1-forbidden-source", spec, host, + fed_two, kOpen100, before)); + } + + bind_pair(arts, "R1-native-contract-fresh-replay", + "R1-native-contract-F1-long-queued-open", + "R1-native-contract-L1-fresh-replay", + "[\"physicalEffects\",\"observations\"]"); + + if (proof_dir && *proof_dir) { + if (!write_proof(arts, proof_sha)) { + std::printf("FAIL could not write native scenario artifact\n"); + return 1; + } + } + + std::printf("%s %d checks %d failures %zu scenarios\n", + failures ? "FAIL" : "PASS", checks, failures, arts.size()); + return failures ? 1 : 0; +} diff --git a/tests/test_native_order_core.cpp b/tests/test_native_order_core.cpp new file mode 100644 index 000000000..453961b9a --- /dev/null +++ b/tests/test_native_order_core.cpp @@ -0,0 +1,1110 @@ +// Literal working-request core: event, counter, handle, grid, and eligibility +// transitions. No engine host, matching, settlement, calendar, or C ABI. +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using pineforge::execution::Action; +using pineforge::execution::Flatten; +using pineforge::native_order::AcceptedEvent; +using pineforge::native_order::Birth; +using pineforge::native_order::CancelStatus; +using pineforge::native_order::CancelledEvent; +using pineforge::native_order::CommandEvent; +using pineforge::native_order::InvalidHandleEvent; +using pineforge::native_order::LiveRequest; +using pineforge::native_order::NotWorkingEvent; +using pineforge::native_order::quantity_on_grid; +using pineforge::native_order::RejectedEvent; +using pineforge::native_order::ReplaceRejectedEvent; +using pineforge::native_order::ReplaceStatus; +using pineforge::native_order::ReplacedEvent; +using pineforge::native_order::Request; +using pineforge::native_order::RequestHandle; +using pineforge::native_order::RequestRejectReason; +using pineforge::native_order::RunIdentity; +using pineforge::native_order::SubmitStatus; +using pineforge::native_order::WorkingRequestCore; +using pineforge::native_order::point_eligible; +using pineforge::order_action::Reduce; +using pineforge::order_action::Transact; + +namespace { +int checks = 0; +int failures = 0; +#define CHECK(x) \ + do { \ + ++checks; \ + if (!(x)) { \ + ++failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + } \ + } while (0) + +constexpr double NaN = std::numeric_limits::quiet_NaN(); +constexpr double Inf = std::numeric_limits::infinity(); +const RunIdentity kRun{"session-a", 1}; + +bool bits_eq(double a, double b) { + std::uint64_t x = 0; + std::uint64_t y = 0; + std::memcpy(&x, &a, sizeof(x)); + std::memcpy(&y, &b, sizeof(y)); + return x == y; +} + +double step_n(double x, double toward, int n) { + for (int i = 0; i < n; ++i) x = std::nextafter(x, toward); + return x; +} + +Request tx(double q, std::string label = {}, std::string comment = {}) { + return Request{Action{Transact{q}}, std::move(label), std::move(comment)}; +} +Request rd(double q, std::string label = {}, std::string comment = {}) { + return Request{Action{Reduce{q}}, std::move(label), std::move(comment)}; +} +Request fl(std::string label = {}, std::string comment = {}) { + return Request{Action{Flatten{}}, std::move(label), std::move(comment)}; +} + +double transact_qty(const Action& action) { + return std::get(action).signed_units; +} +double reduce_qty(const Action& action) { return std::get(action).units; } + +template +const T* as(const CommandEvent& event) { + return std::get_if(&event); +} + +void identity_binding() { + try { + WorkingRequestCore bad(RunIdentity{"", 1}); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + try { + WorkingRequestCore bad(RunIdentity{"s", 0}); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + WorkingRequestCore core(kRun); + CHECK(core.identity() == kRun); + CHECK(core.live().empty()); + CHECK(core.history().empty()); +} + +void submit_accept_and_reject() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const Request buy = tx(2.5, "entry", "first"); + const auto accepted = core.submit(buy, 1000, inc, ord); + CHECK(accepted.status == SubmitStatus::Accepted); + CHECK(accepted.event_ordinal == 1); + CHECK(accepted.handle.has_value()); + CHECK(accepted.handle->incarnation == 1); + CHECK(accepted.handle->run == kRun); + CHECK(!accepted.reason); + CHECK(inc == 2); + CHECK(ord == 2); + CHECK(core.live().size() == 1); + CHECK(core.history().size() == 1); + const LiveRequest& live = core.live().front(); + CHECK(live.handle == *accepted.handle); + CHECK(live.birth.acceptance_ordinal == 1); + CHECK(live.birth.decision_time_lower_bound == 1000); + CHECK(!live.predecessor); + CHECK(live.request.label == "entry"); + CHECK(live.request.comment == "first"); + CHECK(bits_eq(transact_qty(live.request.action), 2.5)); + const auto* event = as(core.history().back()); + CHECK(event); + if (event) { + CHECK(event->ordinal == 1); + CHECK(event->handle == live.handle); + CHECK(event->birth == live.birth); + CHECK(event->request.label == "entry"); + CHECK(bits_eq(transact_qty(event->request.action), 2.5)); + } + CHECK(core.find_live(*accepted.handle) == &core.live().front()); + + const auto rejected = core.submit(tx(0.0, "zero", "nope"), 1000, inc, ord); + CHECK(rejected.status == SubmitStatus::Rejected); + CHECK(rejected.event_ordinal == 2); + CHECK(!rejected.handle); + CHECK(rejected.reason == RequestRejectReason::InvalidQuantity); + CHECK(inc == 2); + CHECK(ord == 3); + CHECK(core.live().size() == 1); + CHECK(core.live().front().handle.incarnation == 1); + CHECK(core.history().size() == 2); + const auto* rej = as(core.history().back()); + CHECK(rej); + if (rej) { + CHECK(rej->ordinal == 2); + CHECK(rej->reason == RequestRejectReason::InvalidQuantity); + CHECK(rej->request.label == "zero"); + CHECK(rej->request.comment == "nope"); + CHECK(bits_eq(transact_qty(rej->request.action), 0.0)); + } +} + +void quantity_rejections_preserve_bits() { + WorkingRequestCore core(kRun); + uint64_t inc = 7; + uint64_t ord = 3; + struct Case { + Request request; + RequestRejectReason reason; + }; + const double neg_zero = std::copysign(0.0, -1.0); + const double neg_nan = std::copysign(NaN, -1.0); + const Case cases[] = { + {tx(0.0, "t0"), RequestRejectReason::InvalidQuantity}, + {tx(neg_zero, "t-0"), RequestRejectReason::InvalidQuantity}, + {tx(NaN, "tnan"), RequestRejectReason::InvalidQuantity}, + {tx(neg_nan, "t-nan"), RequestRejectReason::InvalidQuantity}, + {tx(Inf, "tpinf"), RequestRejectReason::InvalidQuantity}, + {tx(-Inf, "tninf"), RequestRejectReason::InvalidQuantity}, + {rd(0.0, "r0"), RequestRejectReason::InvalidQuantity}, + {rd(-1.0, "rneg"), RequestRejectReason::InvalidQuantity}, + {rd(NaN, "rnan"), RequestRejectReason::InvalidQuantity}, + {rd(Inf, "rinf"), RequestRejectReason::InvalidQuantity}, + }; + const std::size_t n = sizeof(cases) / sizeof(cases[0]); + for (std::size_t i = 0; i < n; ++i) { + const uint64_t inc_before = inc; + const uint64_t ord_before = ord; + const auto result = core.submit(cases[i].request, 50, inc, ord); + CHECK(result.status == SubmitStatus::Rejected); + CHECK(result.event_ordinal == ord_before); + CHECK(!result.handle); + CHECK(result.reason == cases[i].reason); + CHECK(inc == inc_before); + CHECK(ord == ord_before + 1); + CHECK(core.live().empty()); + const auto* rej = as(core.history().back()); + CHECK(rej); + if (!rej) continue; + CHECK(rej->ordinal == ord_before); + CHECK(rej->reason == cases[i].reason); + CHECK(rej->request.label == cases[i].request.label); + if (std::holds_alternative(cases[i].request.action)) { + CHECK(bits_eq(transact_qty(rej->request.action), + transact_qty(cases[i].request.action))); + } else { + CHECK(bits_eq(reduce_qty(rej->request.action), + reduce_qty(cases[i].request.action))); + } + } + CHECK(core.history().size() == n); + CHECK(inc == 7); +} + +void flatten_and_signed_reduce() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto flat = core.submit(fl("flat", "all"), 8, inc, ord, 0.25); + CHECK(flat.status == SubmitStatus::Accepted); + CHECK(std::holds_alternative(core.live().back().request.action)); + CHECK(core.live().back().request.label == "flat"); + CHECK(inc == 2); + CHECK(ord == 2); + + const double denorm = std::numeric_limits::denorm_min(); + const auto reduce = core.submit(rd(denorm, "dust"), 8, inc, ord); + CHECK(reduce.status == SubmitStatus::Accepted); + CHECK(bits_eq(reduce_qty(core.live().back().request.action), denorm)); + + const auto sell = core.submit(tx(-4.0, "sell"), 9, inc, ord); + CHECK(sell.status == SubmitStatus::Accepted); + CHECK(bits_eq(transact_qty(core.live().back().request.action), -4.0)); + CHECK(core.live().size() == 3); +} + +void grid_accepts_and_preserves_bits() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const double step = 0.1; + const double q = 0.1 + 0.2; + CHECK(quantity_on_grid(q, step)); + const auto result = core.submit(tx(q, "sum"), 1, inc, ord, step); + CHECK(result.status == SubmitStatus::Accepted); + CHECK(bits_eq(transact_qty(core.live().back().request.action), q)); + CHECK(!bits_eq(q, 0.3)); + + const auto sell = core.submit(tx(-2.0, "sell-grid"), 1, inc, ord, 1.0); + CHECK(sell.status == SubmitStatus::Accepted); + CHECK(bits_eq(transact_qty(core.live().back().request.action), -2.0)); + + const double exact = 2.0; + const double one_ulp = step_n(exact, Inf, 1); + CHECK(quantity_on_grid(one_ulp, 1.0)); + const auto near = core.submit(tx(one_ulp, "ulp1"), 1, inc, ord, 1.0); + CHECK(near.status == SubmitStatus::Accepted); + CHECK(bits_eq(transact_qty(core.live().back().request.action), one_ulp)); + CHECK(!bits_eq(one_ulp, exact)); + + const double below = step_n(exact, 0.0, 1); + CHECK(quantity_on_grid(below, 1.0)); + const auto near_lo = core.submit(rd(below, "ulp-1"), 1, inc, ord, 1.0); + CHECK(near_lo.status == SubmitStatus::Accepted); + CHECK(bits_eq(reduce_qty(core.live().back().request.action), below)); + + const double pow2 = 0x1p53; + CHECK(quantity_on_grid(pow2, 1.0)); + const auto edge = core.submit(tx(pow2, "n53"), 1, inc, ord, 1.0); + CHECK(edge.status == SubmitStatus::Accepted); + CHECK(bits_eq(transact_qty(core.live().back().request.action), pow2)); +} + +void grid_rejects_off_grid_and_half() { + WorkingRequestCore core(kRun); + uint64_t inc = 11; + uint64_t ord = 4; + const double five_ulp = step_n(2.0, Inf, 5); + CHECK(!quantity_on_grid(five_ulp, 1.0)); + const uint64_t inc_before = inc; + const auto off = core.submit(tx(five_ulp, "five"), 2, inc, ord, 1.0); + CHECK(off.status == SubmitStatus::Rejected); + CHECK(off.reason == RequestRejectReason::OffGrid); + CHECK(inc == inc_before); + CHECK(ord == 5); + const auto* rej = as(core.history().back()); + CHECK(rej); + if (rej) { + CHECK(rej->reason == RequestRejectReason::OffGrid); + CHECK(bits_eq(transact_qty(rej->request.action), five_ulp)); + CHECK(rej->request.label == "five"); + } + CHECK(core.live().empty()); + + const auto half = core.submit(tx(1.5, "half"), 2, inc, ord, 1.0); + CHECK(half.status == SubmitStatus::Rejected); + CHECK(half.reason == RequestRejectReason::OffGrid); + CHECK(inc == inc_before); + const auto* half_e = as(core.history().back()); + CHECK(half_e); + if (half_e) CHECK(bits_eq(transact_qty(half_e->request.action), 1.5)); + + const auto dust = core.submit(rd(0.4, "dust"), 2, inc, ord, 1.0); + CHECK(dust.status == SubmitStatus::Rejected); + CHECK(dust.reason == RequestRejectReason::OffGrid); + + const auto over = core.submit(tx(0x1p53 + 2.0, "over"), 2, inc, ord, 1.0); + CHECK(over.status == SubmitStatus::Rejected); + CHECK(over.reason == RequestRejectReason::OffGrid); + CHECK(inc == inc_before); + CHECK(core.live().empty()); +} + +void submit_before_point_eligibility() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 5; + const auto first = core.submit(tx(1.0, "a"), 1000, inc, ord); + const auto second = core.submit(tx(-1.0, "b"), 1000, inc, ord); + CHECK(first.event_ordinal == 5); + CHECK(second.event_ordinal == 6); + const LiveRequest& a = core.live()[0]; + const LiveRequest& b = core.live()[1]; + CHECK(a.birth.acceptance_ordinal == 5); + CHECK(b.birth.acceptance_ordinal == 6); + CHECK(!point_eligible(a, 5, 1000)); + CHECK(!point_eligible(a, 6, 999)); + CHECK(point_eligible(a, 6, 1000)); + CHECK(point_eligible(a, 7, 1001)); + CHECK(!point_eligible(b, 6, 1000)); + CHECK(!point_eligible(b, 7, 999)); + CHECK(point_eligible(b, 7, 1000)); + CHECK(!point_eligible(a.birth, a.birth.acceptance_ordinal, a.birth.decision_time_lower_bound)); +} + +void replace_valid_invalid_and_stale_cancel() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto first = core.submit(tx(1.0, "old", "keep-old"), 10, inc, ord); + const auto second = core.submit(tx(2.0, "peer"), 10, inc, ord); + CHECK(core.live().size() == 2); + const RequestHandle h1 = *first.handle; + const RequestHandle h2 = *second.handle; + + const auto bad = core.replace(h1, tx(0.0, "bad", "no"), 20, inc, ord); + CHECK(bad.status == ReplaceStatus::ReplaceRejected); + CHECK(bad.event_ordinal == 3); + CHECK(!bad.successor); + CHECK(bad.reason == RequestRejectReason::InvalidQuantity); + CHECK(inc == 3); + CHECK(ord == 4); + CHECK(core.live().size() == 2); + CHECK(core.find_live(h1)); + CHECK(core.find_live(h1)->request.label == "old"); + CHECK(bits_eq(transact_qty(core.find_live(h1)->request.action), 1.0)); + const auto* rr = as(core.history().back()); + CHECK(rr); + if (rr) { + CHECK(rr->target == h1); + CHECK(rr->live_request.label == "old"); + CHECK(rr->attempted.label == "bad"); + CHECK(rr->attempted.comment == "no"); + CHECK(bits_eq(transact_qty(rr->attempted.action), 0.0)); + CHECK(rr->reason == RequestRejectReason::InvalidQuantity); + } + + const auto off = core.replace(h1, tx(1.1, "off"), 20, inc, ord, 1.0); + CHECK(off.status == ReplaceStatus::ReplaceRejected); + CHECK(off.reason == RequestRejectReason::OffGrid); + CHECK(core.find_live(h1)); + CHECK(inc == 3); + + const Request next = tx(-3.0, "new", "keep-new"); + const auto ok = core.replace(h1, next, 30, inc, ord); + CHECK(ok.status == ReplaceStatus::Replaced); + CHECK(ok.successor.has_value()); + CHECK(ok.successor->incarnation == 3); + CHECK(ok.event_ordinal == 5); + CHECK(inc == 4); + CHECK(ord == 6); + CHECK(!core.find_live(h1)); + CHECK(core.find_live(h2)); + CHECK(core.live().size() == 2); + CHECK(core.live()[0].handle == h2); + const LiveRequest& successor = core.live()[1]; + CHECK(successor.handle == *ok.successor); + CHECK(successor.birth.acceptance_ordinal == 5); + CHECK(successor.birth.decision_time_lower_bound == 30); + CHECK(successor.predecessor == h1); + CHECK(successor.request.label == "new"); + CHECK(successor.request.comment == "keep-new"); + CHECK(bits_eq(transact_qty(successor.request.action), -3.0)); + CHECK(point_eligible(successor, 6, 30)); + CHECK(!point_eligible(successor, 5, 30)); + const auto* replaced = as(core.history().back()); + CHECK(replaced); + if (replaced) { + CHECK(replaced->ordinal == 5); + CHECK(replaced->predecessor == h1); + CHECK(replaced->predecessor_request.label == "old"); + CHECK(replaced->successor == successor.handle); + CHECK(replaced->successor_request.comment == "keep-new"); + CHECK(replaced->successor_birth == successor.birth); + CHECK(bits_eq(transact_qty(replaced->successor_request.action), -3.0)); + } + + const auto stale = core.cancel(h1, ord); + CHECK(stale.status == CancelStatus::NotWorking); + CHECK(stale.event_ordinal == 6); + CHECK(ord == 7); + CHECK(core.find_live(h2)); + CHECK(core.find_live(successor.handle)); + const auto* nw = as(core.history().back()); + CHECK(nw); + if (nw) { + CHECK(nw->target == h1); + CHECK(!nw->attempted); + } + + const auto stale_rep = core.replace(h1, tx(9.0, "ghost"), 40, inc, ord); + CHECK(stale_rep.status == ReplaceStatus::NotWorking); + CHECK(!stale_rep.successor); + CHECK(inc == 4); + CHECK(core.find_live(successor.handle)->request.label == "new"); + const auto* nw2 = as(core.history().back()); + CHECK(nw2); + if (nw2) { + CHECK(nw2->target == h1); + CHECK(nw2->attempted.has_value()); + CHECK(nw2->attempted->label == "ghost"); + } +} + +void foreign_zero_and_absent_handles() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto live = core.submit(tx(1.0, "live"), 1, inc, ord); + const RequestHandle zero{}; + const RequestHandle empty_key{RunIdentity{"", 1}, 1}; + const RequestHandle zero_run{RunIdentity{"session-a", 0}, 1}; + const RequestHandle foreign{RunIdentity{"session-b", 1}, 1}; + const RequestHandle other_run{RunIdentity{"session-a", 2}, live.handle->incarnation}; + const RequestHandle absent{kRun, 99}; + + const auto c0 = core.cancel(zero, ord); + CHECK(c0.status == CancelStatus::InvalidHandle); + CHECK(as(core.history().back())); + CHECK(as(core.history().back())->target.incarnation == 0); + + const auto r_empty = core.replace(empty_key, tx(2.0, "x"), 1, inc, ord); + CHECK(r_empty.status == ReplaceStatus::InvalidHandle); + CHECK(inc == 2); + + const auto c_run = core.cancel(zero_run, ord); + CHECK(c_run.status == CancelStatus::InvalidHandle); + + const auto c_foreign = core.cancel(foreign, ord); + CHECK(c_foreign.status == CancelStatus::InvalidHandle); + CHECK(as(core.history().back())->target.run.session_key == "session-b"); + + const auto r_other = core.replace(other_run, tx(0.0, "no"), 1, inc, ord); + CHECK(r_other.status == ReplaceStatus::InvalidHandle); + CHECK(!r_other.reason); + CHECK(inc == 2); + CHECK(as(core.history().back())->attempted->label == "no"); + + const auto c_absent = core.cancel(absent, ord); + CHECK(c_absent.status == CancelStatus::NotWorking); + CHECK(as(core.history().back())->target.incarnation == 99); + + CHECK(core.live().size() == 1); + CHECK(core.find_live(*live.handle)); + CHECK(core.live().front().request.label == "live"); +} + +void metadata_and_const_views() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const std::string label = "α-entry"; + const std::string comment = "keep me"; + core.submit(tx(1.0, label, comment), 4, inc, ord); + const WorkingRequestCore& view = core; + CHECK(view.live().size() == 1); + CHECK(view.history().size() == 1); + CHECK(view.live().front().request.label == label); + CHECK(view.live().front().request.comment == comment); + CHECK(view.find_live(view.live().front().handle)); + const auto* accepted = as(view.history().front()); + CHECK(accepted); + if (accepted) { + CHECK(accepted->request.label == label); + CHECK(accepted->request.comment == comment); + } +} + +void counters_do_not_partially_mutate() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto first = core.submit(tx(1.0, "keep"), 1, inc, ord); + CHECK(first.status == SubmitStatus::Accepted); + const RequestHandle kept = *first.handle; + const std::size_t live_n = core.live().size(); + const std::size_t hist_n = core.history().size(); + + auto expect_frozen = [&](uint64_t inc_now, uint64_t ord_now) { + CHECK(inc == inc_now); + CHECK(ord == ord_now); + CHECK(core.live().size() == live_n); + CHECK(core.history().size() == hist_n); + CHECK(core.find_live(kept)); + CHECK(core.find_live(kept)->request.label == "keep"); + }; + + inc = 0; + try { + core.submit(tx(1.0, "no"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + expect_frozen(0, 2); + + inc = std::numeric_limits::max(); + try { + core.submit(tx(1.0, "no"), 1, inc, ord); + CHECK(false); + } catch (const std::overflow_error&) { CHECK(true); } + expect_frozen(std::numeric_limits::max(), 2); + + inc = 4; + const uint64_t frozen_ord = ord; + ord = 0; + try { + core.submit(tx(1.0, "no"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + expect_frozen(4, 0); + + ord = std::numeric_limits::max(); + try { + core.submit(tx(0.0, "rej"), 1, inc, ord); + CHECK(false); + } catch (const std::overflow_error&) { CHECK(true); } + expect_frozen(4, std::numeric_limits::max()); + + ord = frozen_ord; + inc = 0; + const auto rej = core.submit(tx(0.0, "rej-ok"), 1, inc, ord); + CHECK(rej.status == SubmitStatus::Rejected); + CHECK(inc == 0); + CHECK(ord == frozen_ord + 1); + CHECK(core.find_live(kept)); + CHECK(core.live().size() == live_n); + CHECK(as(core.history().back())->request.label == "rej-ok"); + + inc = std::numeric_limits::max(); + try { + core.replace(kept, tx(2.0, "succ"), 2, inc, ord); + CHECK(false); + } catch (const std::overflow_error&) { CHECK(true); } + CHECK(inc == std::numeric_limits::max()); + CHECK(core.find_live(kept)); + CHECK(core.find_live(kept)->request.label == "keep"); + CHECK(bits_eq(transact_qty(core.find_live(kept)->request.action), 1.0)); + + inc = 8; + const uint64_t ord_now = ord; + ord = 0; + try { + core.replace(kept, tx(2.0, "succ"), 2, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(inc == 8); + CHECK(ord == 0); + CHECK(core.find_live(kept)); + + ord = 0; + try { + core.cancel(kept, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(ord == 0); + CHECK(core.find_live(kept)); + CHECK(core.find_live(kept)->request.label == "keep"); + + ord = std::numeric_limits::max(); + try { + core.cancel(kept, ord); + CHECK(false); + } catch (const std::overflow_error&) { CHECK(true); } + CHECK(ord == std::numeric_limits::max()); + CHECK(core.find_live(kept)); + CHECK(core.history().size() == hist_n + 1); + + ord = ord_now; + const auto still = core.replace(kept, tx(0.0, "bad"), 2, inc, ord); + CHECK(still.status == ReplaceStatus::ReplaceRejected); + CHECK(inc == 8); + CHECK(core.find_live(kept)->request.label == "keep"); +} + +void reset_makes_old_handles_invalid() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto first = core.submit(tx(1.0, "old-run"), 1, inc, ord); + const RequestHandle old = *first.handle; + try { + core.reset(RunIdentity{"", 2}); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(core.identity() == kRun); + CHECK(core.find_live(old)); + CHECK(core.find_live(old)->request.label == "old-run"); + + try { + core.reset(RunIdentity{"session-a", 0}); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(core.find_live(old)); + + const RunIdentity next{"session-a", 2}; + core.reset(next); + CHECK(core.identity() == next); + CHECK(core.live().empty()); + CHECK(core.history().empty()); + CHECK(!core.find_live(old)); + + const auto cancel_old = core.cancel(old, ord); + CHECK(cancel_old.status == CancelStatus::InvalidHandle); + CHECK(as(core.history().back())->target == old); + + const auto replace_old = core.replace(old, tx(2.0, "no"), 1, inc, ord); + CHECK(replace_old.status == ReplaceStatus::InvalidHandle); + CHECK(inc == 2); + + const auto fresh = core.submit(tx(3.0, "new-run"), 9, inc, ord); + CHECK(fresh.status == SubmitStatus::Accepted); + CHECK(fresh.handle->run == next); + CHECK(fresh.handle->incarnation == 2); + CHECK(core.live().front().request.label == "new-run"); + CHECK(old != *fresh.handle); +} + +void moved_from_commands_preserve_state(WorkingRequestCore& core, + const RequestHandle& transferred, + uint64_t& inc, + uint64_t& ord) { + const uint64_t inc_before = inc; + const uint64_t ord_before = ord; + const auto unchanged = [&] { + CHECK(core.identity().session_key.empty()); + CHECK(core.identity().run_number == 0); + CHECK(core.live().empty()); + CHECK(core.history().empty()); + CHECK(!core.find_live(transferred)); + CHECK(inc == inc_before); + CHECK(ord == ord_before); + }; + unchanged(); + for (double quantity : {1.0, 0.0}) { + try { + core.submit(tx(quantity, "unbound"), 100, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + unchanged(); + } + try { + core.replace(transferred, tx(2.0, "unbound-replace"), 100, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + unchanged(); + try { + core.cancel(transferred, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + unchanged(); + try { + core.reset(RunIdentity{"", 2}); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + unchanged(); +} + +void move_construction_transfers_and_reset_rebinds() { + WorkingRequestCore source(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto original = source.submit(tx(1.5, "original", "move me"), 10, inc, ord); + const auto rejected = source.submit(tx(0.0, "rejected"), 11, inc, ord); + CHECK(rejected.status == SubmitStatus::Rejected); + const auto successor = source.replace(*original.handle, tx(-2.5, "successor", "kept"), + 12, inc, ord); + CHECK(successor.status == ReplaceStatus::Replaced); + + WorkingRequestCore destination(std::move(source)); + moved_from_commands_preserve_state(source, *successor.successor, inc, ord); + CHECK(destination.identity() == kRun); + CHECK(destination.live().size() == 1); + CHECK(destination.history().size() == 3); + CHECK(!destination.find_live(*original.handle)); + const auto* transferred = destination.find_live(*successor.successor); + CHECK(transferred); + if (transferred) { + CHECK(transferred->predecessor == original.handle); + CHECK(transferred->birth == (Birth{3, 12})); + CHECK(transferred->request.label == "successor"); + CHECK(transferred->request.comment == "kept"); + CHECK(bits_eq(transact_qty(transferred->request.action), -2.5)); + } + const auto* accepted_event = as(destination.history().front()); + CHECK(accepted_event); + if (accepted_event) { + CHECK(accepted_event->handle == *original.handle); + CHECK(accepted_event->request.comment == "move me"); + } + CHECK(as(destination.history()[1])); + const auto* replaced_event = as(destination.history().back()); + CHECK(replaced_event); + if (replaced_event) { + CHECK(replaced_event->predecessor == *original.handle); + CHECK(replaced_event->successor == *successor.successor); + CHECK(replaced_event->ordinal == 3); + } + const auto cancelled = destination.cancel(*successor.successor, ord); + CHECK(cancelled.status == CancelStatus::Cancelled); + CHECK(cancelled.event_ordinal == 4); + CHECK(destination.live().empty()); + CHECK(destination.history().size() == 4); + CHECK(inc == 3); + CHECK(ord == 5); + + const RunIdentity rebound{"session-a", 2}; + source.reset(rebound); + const auto fresh = source.submit(tx(4.0, "recovered"), 20, inc, ord); + CHECK(fresh.status == SubmitStatus::Accepted); + CHECK(fresh.handle->run == rebound); + CHECK(fresh.handle->incarnation == 3); + CHECK(fresh.event_ordinal == 5); + CHECK(source.find_live(*fresh.handle)); + CHECK(!source.find_live(*successor.successor)); + CHECK(source.history().size() == 1); + CHECK(source.live().size() == 1); + CHECK(destination.history().size() == 4); +} + +void move_assignment_replaces_destination_and_preserves_self_move() { + WorkingRequestCore source(kRun); + uint64_t inc = 7; + uint64_t ord = 11; + const auto request = source.submit(rd(2.0, "transferred", "assign me"), 80, inc, ord); + WorkingRequestCore destination(RunIdentity{"replaced-session", 3}); + uint64_t previous_inc = 40; + uint64_t previous_ord = 60; + const auto previous = destination.submit(fl("discarded"), 50, previous_inc, previous_ord); + + destination = std::move(source); + moved_from_commands_preserve_state(source, *request.handle, inc, ord); + CHECK(destination.identity() == kRun); + CHECK(destination.live().size() == 1); + CHECK(destination.history().size() == 1); + CHECK(!destination.find_live(*previous.handle)); + CHECK(previous_inc == 41); + CHECK(previous_ord == 61); + const auto* transferred = destination.find_live(*request.handle); + CHECK(transferred); + if (transferred) { + CHECK(transferred->birth == (Birth{11, 80})); + CHECK(transferred->request.label == "transferred"); + CHECK(transferred->request.comment == "assign me"); + CHECK(bits_eq(reduce_qty(transferred->request.action), 2.0)); + } + auto* self = &destination; + destination = std::move(*self); + CHECK(destination.identity() == kRun); + CHECK(destination.find_live(*request.handle)); + CHECK(destination.live().size() == 1); + CHECK(destination.history().size() == 1); + CHECK(inc == 8); + CHECK(ord == 12); + const auto replaced = destination.replace(*request.handle, fl("continued"), 81, inc, ord); + CHECK(replaced.status == ReplaceStatus::Replaced); + CHECK(replaced.successor); + if (!replaced.successor) return; + CHECK(replaced.successor->incarnation == 8); + CHECK(replaced.event_ordinal == 12); + CHECK(destination.find_live(*replaced.successor)); + + // Moving an already unbound core transfers that empty state too. + destination = std::move(source); + moved_from_commands_preserve_state(destination, *replaced.successor, inc, ord); + moved_from_commands_preserve_state(source, *request.handle, inc, ord); + destination.reset(RunIdentity{"recovered-assignment", 1}); + const auto recovered = destination.submit(tx(3.0, "recovered"), 90, inc, ord); + CHECK(recovered.status == SubmitStatus::Accepted); + CHECK(recovered.handle->run == (RunIdentity{"recovered-assignment", 1})); + CHECK(recovered.handle->incarnation == 9); + CHECK(recovered.event_ordinal == 13); + CHECK(destination.find_live(*recovered.handle)); + CHECK(!destination.find_live(*replaced.successor)); + CHECK(destination.live().size() == 1); + CHECK(destination.history().size() == 1); +} + +void cancel_live_erases_only_that_request() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto a = core.submit(tx(1.0, "a"), 1, inc, ord); + const auto b = core.submit(rd(2.0, "b", "stay"), 1, inc, ord); + const auto c = core.submit(fl("c"), 1, inc, ord); + const auto gone = core.cancel(*a.handle, ord); + CHECK(gone.status == CancelStatus::Cancelled); + CHECK(gone.event_ordinal == 4); + CHECK(!core.find_live(*a.handle)); + CHECK(core.find_live(*b.handle)); + CHECK(core.find_live(*c.handle)); + CHECK(core.live().size() == 2); + CHECK(core.live()[0].request.label == "b"); + CHECK(core.live()[1].request.label == "c"); + const auto* cancelled = as(core.history().back()); + CHECK(cancelled); + if (cancelled) { + CHECK(cancelled->handle == *a.handle); + CHECK(cancelled->request.label == "a"); + CHECK(bits_eq(transact_qty(cancelled->request.action), 1.0)); + } + const auto again = core.cancel(*a.handle, ord); + CHECK(again.status == CancelStatus::NotWorking); + CHECK(core.find_live(*b.handle)->request.comment == "stay"); +} + +void same_counter_alias() { + WorkingRequestCore core(kRun); + uint64_t both = 1; + try { + core.submit(tx(1.0, "alias"), 1, both, both); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(both == 1); + CHECK(core.live().empty()); + CHECK(core.history().empty()); + + try { + core.submit(tx(0.0, "alias-rej"), 1, both, both); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(core.history().empty()); + + uint64_t inc = 1; + uint64_t ord = 1; + const auto first = core.submit(tx(1.0, "kept"), 1, inc, ord); + CHECK(first.status == SubmitStatus::Accepted); + uint64_t same = 8; + try { + core.replace(*first.handle, tx(2.0, "no"), 1, same, same); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(same == 8); + CHECK(inc == 2); + CHECK(ord == 2); + CHECK(core.find_live(*first.handle)); + CHECK(core.find_live(*first.handle)->request.label == "kept"); + CHECK(core.history().size() == 1); +} + +void reused_and_gapped_counters() { + { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto only = core.submit(tx(1.0, "only"), 1, inc, ord); + CHECK(core.cancel(*only.handle, ord).status == CancelStatus::Cancelled); + CHECK(core.live().empty()); + inc = 1; + try { + core.submit(tx(2.0, "reuse-cancelled"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(inc == 1); + CHECK(core.live().empty()); + CHECK(core.history().size() == 2); + inc = 2; + const auto after = core.submit(tx(2.0, "after-cancel"), 1, inc, ord); + CHECK(after.status == SubmitStatus::Accepted); + CHECK(after.handle->incarnation == 2); + CHECK(core.live().front().request.label == "after-cancel"); + } + + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + const auto first = core.submit(tx(1.0, "one"), 1, inc, ord); + CHECK(first.handle->incarnation == 1); + CHECK(first.event_ordinal == 1); + + inc = 1; + try { + core.submit(tx(2.0, "reuse-inc"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(inc == 1); + CHECK(ord == 2); + CHECK(core.live().size() == 1); + CHECK(core.history().size() == 1); + + ord = 1; + inc = 2; + try { + core.submit(tx(2.0, "reuse-ord"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(ord == 1); + CHECK(core.history().size() == 1); + + inc = 10; + ord = 50; + const auto gapped = core.submit(tx(3.0, "gap"), 1, inc, ord); + CHECK(gapped.status == SubmitStatus::Accepted); + CHECK(gapped.handle->incarnation == 10); + CHECK(gapped.event_ordinal == 50); + CHECK(inc == 11); + CHECK(ord == 51); + CHECK(core.live().size() == 2); + + const auto cancelled = core.cancel(*first.handle, ord); + CHECK(cancelled.status == CancelStatus::Cancelled); + CHECK(!core.find_live(*first.handle)); + inc = 1; + try { + core.submit(tx(4.0, "after-cancel"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(inc == 1); + CHECK(core.live().size() == 1); + CHECK(core.live().front().handle.incarnation == 10); + + inc = 10; + try { + core.submit(tx(4.0, "after-cancel-gap"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + + const uint64_t last_ord = ord; + ord = 50; + inc = 12; + try { + core.submit(tx(4.0, "old-ord"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(ord == 50); + + ord = last_ord; + inc = 12; + const auto replaced = core.replace(*gapped.handle, tx(5.0, "succ"), 2, inc, ord); + CHECK(replaced.status == ReplaceStatus::Replaced); + CHECK(replaced.successor->incarnation == 12); + CHECK(!core.find_live(*gapped.handle)); + + inc = 12; + try { + core.submit(tx(6.0, "reuse-succ"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + inc = 10; + try { + core.replace(*replaced.successor, tx(6.0, "reuse-pred"), 1, inc, ord); + CHECK(false); + } catch (const std::invalid_argument&) { CHECK(true); } + CHECK(core.find_live(*replaced.successor)->request.label == "succ"); + + inc = 20; + const auto later = core.submit(tx(7.0, "later"), 3, inc, ord); + CHECK(later.status == SubmitStatus::Accepted); + CHECK(later.handle->incarnation == 20); +} + +void aliased_const_arguments() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + CHECK(core.submit(tx(1.0, "orig", "c1"), 10, inc, ord).status == SubmitStatus::Accepted); + CHECK(core.submit(tx(2.0, "peer", "c2"), 11, inc, ord).status == SubmitStatus::Accepted); + + const auto copied = core.submit(core.live().front().request, 12, inc, ord); + CHECK(copied.status == SubmitStatus::Accepted); + CHECK(copied.handle->incarnation == 3); + CHECK(core.live().size() == 3); + CHECK(core.live()[0].request.label == "orig"); + CHECK(core.live()[0].request.comment == "c1"); + CHECK(core.live()[2].request.label == "orig"); + CHECK(core.live()[2].request.comment == "c1"); + CHECK(bits_eq(transact_qty(core.live()[2].request.action), 1.0)); + + const auto repl = core.replace(core.live().front().handle, core.live().front().request, 13, inc, + ord); + CHECK(repl.status == ReplaceStatus::Replaced); + CHECK(repl.successor->incarnation == 4); + CHECK(!core.find_live(RequestHandle{kRun, 1})); + CHECK(core.live().size() == 3); + CHECK(core.live()[0].request.label == "peer"); + CHECK(core.live().back().request.label == "orig"); + CHECK(core.live().back().request.comment == "c1"); + CHECK(core.live().back().predecessor->incarnation == 1); + + const auto cancelled = core.cancel(core.live().front().handle, ord); + CHECK(cancelled.status == CancelStatus::Cancelled); + CHECK(core.live().size() == 2); + CHECK(core.live().front().request.label == "orig"); + + const auto* first_accepted = as(core.history().front()); + CHECK(first_accepted); + const auto from_history = core.submit(first_accepted->request, 14, inc, ord); + CHECK(from_history.status == SubmitStatus::Accepted); + CHECK(core.live().back().request.label == "orig"); + CHECK(core.live().back().request.comment == "c1"); +} + +void history_prefix_append_stable() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + for (int i = 0; i < 32; ++i) { + const auto result = core.submit(tx(1.0, "p" + std::to_string(i)), 1, inc, ord); + CHECK(result.status == SubmitStatus::Accepted); + CHECK(result.handle->incarnation == static_cast(i + 1)); + } + const CommandEvent* prefix = &core.history().front(); + CHECK(as(*prefix)->request.label == "p0"); + CHECK(as(*prefix)->ordinal == 1); + + CHECK(core.submit(tx(1.0, "p32"), 1, inc, ord).status == SubmitStatus::Accepted); + if (&core.history().front() != prefix) prefix = &core.history().front(); + CHECK(as(*prefix)->request.label == "p0"); + + const auto rejected = core.submit(tx(0.0, "rej"), 1, inc, ord); + CHECK(rejected.status == SubmitStatus::Rejected); + CHECK(&core.history().front() == prefix); + CHECK(as(core.history().front())->request.label == "p0"); + CHECK(as(core.history().front())->ordinal == 1); + + const auto extra = core.submit(tx(2.0, "more"), 1, inc, ord); + CHECK(extra.status == SubmitStatus::Accepted); + CHECK(&core.history().front() == prefix); + CHECK(as(core.history()[1])->request.label == "p1"); + CHECK(core.live().front().request.label == "p0"); +} + +void bounded_many_commands() { + WorkingRequestCore core(kRun); + uint64_t inc = 1; + uint64_t ord = 1; + RequestHandle first{}; + for (int i = 0; i < 64; ++i) { + const auto result = core.submit(tx(1.0, "m" + std::to_string(i)), i, inc, ord); + CHECK(result.status == SubmitStatus::Accepted); + CHECK(result.event_ordinal == static_cast(i + 1)); + if (i == 0) first = *result.handle; + } + CHECK(core.live().size() == 64); + CHECK(core.history().size() == 64); + CHECK(as(core.history().front())->handle == first); + CHECK(as(core.history().front())->request.label == "m0"); + CHECK(core.live().back().request.label == "m63"); + + for (int i = 0; i < 32; ++i) { + const RequestHandle handle = core.live().front().handle; + const auto cancelled = core.cancel(handle, ord); + CHECK(cancelled.status == CancelStatus::Cancelled); + } + CHECK(core.live().size() == 32); + CHECK(core.history().size() == 96); + CHECK(core.live().front().request.label == "m32"); + CHECK(as(core.history().front())->request.label == "m0"); + + const auto replaced = core.replace(core.live().front().handle, tx(2.0, "rep"), 100, inc, ord); + CHECK(replaced.status == ReplaceStatus::Replaced); + CHECK(core.live().size() == 32); + CHECK(core.live().back().request.label == "rep"); + CHECK(core.live().back().predecessor->incarnation == 33); + CHECK(as(core.history().front())->request.label == "m0"); + CHECK(core.history().size() == 97); +} +} // namespace + +int main() { + identity_binding(); + submit_accept_and_reject(); + quantity_rejections_preserve_bits(); + flatten_and_signed_reduce(); + grid_accepts_and_preserves_bits(); + grid_rejects_off_grid_and_half(); + submit_before_point_eligibility(); + replace_valid_invalid_and_stale_cancel(); + foreign_zero_and_absent_handles(); + metadata_and_const_views(); + counters_do_not_partially_mutate(); + reset_makes_old_handles_invalid(); + move_construction_transfers_and_reset_rebinds(); + move_assignment_replaces_destination_and_preserves_self_move(); + cancel_live_erases_only_that_request(); + same_counter_alias(); + reused_and_gapped_counters(); + aliased_const_arguments(); + history_prefix_append_stable(); + bounded_many_commands(); + std::printf("native order core: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_native_order_terminal.cpp b/tests/test_native_order_terminal.cpp new file mode 100644 index 000000000..6fa45230b --- /dev/null +++ b/tests/test_native_order_terminal.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include + +using pineforge::execution::Flatten; +using pineforge::native_order::CommandEvent; +using pineforge::native_order::ExecutionAppliedEvent; +using pineforge::native_order::MatchRejectedEvent; +using pineforge::native_order::NoEffectEvent; +using pineforge::native_order::Request; +using pineforge::native_order::RunIdentity; +using pineforge::native_order::TerminalCommit; +using pineforge::native_order::WorkingRequestCore; +using pineforge::order_action::Transact; + +namespace { +int checks = 0; +int failures = 0; +#define CHECK(x) \ + do { \ + ++checks; \ + if (!(x)) { \ + ++failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + } \ + } while (0) +} // namespace + +int main() { + RunIdentity id{"terminal-session", 1}; + WorkingRequestCore core(id); + uint64_t incarnation = 1; + uint64_t ordinal = 1; + auto accepted = core.submit(Request{Transact{1.0}, "buy", ""}, 60000, incarnation, ordinal); + CHECK(accepted.status == pineforge::native_order::SubmitStatus::Accepted); + CHECK(core.live().size() == 1); + CHECK(core.history().size() == 1); + + const uint64_t next = TerminalCommit::usable_ordinal(core, ordinal); + CHECK(next == ordinal); + TerminalCommit::reserve_history(core); + NoEffectEvent none; + none.ordinal = next; + none.handle = *accepted.handle; + none.request = Request{Transact{1.0}, "buy", ""}; + none.birth = core.live()[0].birth; + TerminalCommit::install(core, 0, CommandEvent{std::move(none)}); + ++ordinal; + CHECK(core.live().empty()); + CHECK(core.history().size() == 2); + CHECK(std::holds_alternative(core.history().back())); + + auto second = core.submit(Request{Transact{-1.0}, "sell", ""}, 120000, incarnation, ordinal); + CHECK(second.status == pineforge::native_order::SubmitStatus::Accepted); + TerminalCommit::usable_ordinal(core, ordinal); + TerminalCommit::reserve_history(core); + MatchRejectedEvent rejected; + rejected.ordinal = ordinal; + rejected.handle = *second.handle; + rejected.request = Request{Transact{-1.0}, "sell", ""}; + rejected.birth = core.live()[0].birth; + rejected.reason = pineforge::native_order::MatchRejectReason::MaxAbsUnits; + TerminalCommit::install(core, 0, CommandEvent{std::move(rejected)}); + ++ordinal; + CHECK(core.live().empty()); + CHECK(std::holds_alternative(core.history().back())); + + auto third = core.submit(Request{Flatten{}, "flat", ""}, 180000, incarnation, ordinal); + CHECK(third.status == pineforge::native_order::SubmitStatus::Accepted); + TerminalCommit::usable_ordinal(core, ordinal); + TerminalCommit::reserve_history(core); + ExecutionAppliedEvent applied; + applied.ordinal = ordinal; + applied.handle = *third.handle; + applied.request = Request{Flatten{}, "flat", ""}; + applied.birth = core.live()[0].birth; + applied.resolved_price = 104; + applied.current_ticket = 6; + applied.first_trade_index = 0; + applied.closed_trade_count = 1; + TerminalCommit::install(core, 0, CommandEvent{std::move(applied)}); + CHECK(core.live().empty()); + CHECK(std::holds_alternative(core.history().back())); + CHECK(std::get(core.history().back()).current_ticket == 6); + + std::printf("%s %d checks %d failures\n", failures ? "FAIL" : "PASS", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_native_run_spec.cpp b/tests/test_native_run_spec.cpp new file mode 100644 index 000000000..b79f3709c --- /dev/null +++ b/tests/test_native_run_spec.cpp @@ -0,0 +1,401 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Deterministically exercise a dependency allocation failure without relying +// on huge allocations or exhausting the machine. This executable is serial. +static bool refuse_allocation = false; +void* operator new(std::size_t size) { + if (refuse_allocation) throw std::bad_alloc{}; + if (void* p = std::malloc(size == 0 ? 1 : size)) return p; + throw std::bad_alloc{}; +} +void* operator new[](std::size_t size) { return ::operator new(size); } +void operator delete(void* p) noexcept { std::free(p); } +void operator delete[](void* p) noexcept { std::free(p); } +#if defined(__cpp_sized_deallocation) +void operator delete(void* p, std::size_t) noexcept { std::free(p); } +void operator delete[](void* p, std::size_t) noexcept { std::free(p); } +#endif + +namespace { +using namespace pineforge; +using Error = NativeRunSpecError; +using Field = NativeRunSpecField; +int checks = 0; +int failures = 0; + +void check(bool pass, const char* what) { + ++checks; + if (!pass) { + ++failures; + std::cerr << "FAIL: " << what << '\n'; + } +} + +NativeRunSpec complete_spec() { + NativeRunSpec spec; + spec.identity = {u8"native/台北/é/😀", 1}; + spec.input_tf = "1"; + spec.script_tf = "5"; + spec.ticker = "XYZ"; + spec.tickerid = "TEST:XYZ"; + spec.type = "futures"; + spec.currency = "USD"; + spec.basecurrency = "XYZ"; + spec.description = u8"Native test — 交易"; + spec.volumetype = "contracts"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = "Asia/Taipei"; + 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 = 6.0; + return spec; +} + +// Snapshot every semantic field, including exact floating-point object bytes +// and optional presence. No struct padding or NaN equality is involved. +template +void append(std::string& out, const T& value) { + static_assert(std::is_trivially_copyable_v); + out.append(reinterpret_cast(&value), sizeof(value)); +} +void append(std::string& out, const std::string& value) { + append(out, value.size()); + out += value; +} +template +void append(std::string& out, const std::optional& value) { + append(out, value.has_value()); + if (value) append(out, *value); +} +std::string snapshot(const NativeRunSpec& s) { + std::string out; + append(out, s.identity.session_key); append(out, s.identity.run_number); + append(out, s.input_tf); append(out, s.script_tf); + append(out, s.ticker); append(out, s.tickerid); append(out, s.type); + append(out, s.currency); append(out, s.basecurrency); + append(out, s.description); append(out, s.volumetype); + append(out, s.timezone); append(out, s.session); append(out, s.chart_timezone); + append(out, s.initial_capital); append(out, s.point_value); + append(out, s.account_fx); append(out, s.price_tick); + append(out, s.slippage_ticks); append(out, s.fee_kind); append(out, s.fee_value); + append(out, s.quantity_grid); append(out, s.close_execution); + append(out, s.max_abs_units); append(out, s.max_open_lots); + append(out, s.allowed_open_directions); append(out, s.initial_margin_fraction); + return out; +} + +void expect_refusal(NativeRunSpec spec, Error error, Field field) { + const auto before = snapshot(spec); + const auto validation = validate_native_run_spec(spec); + if (validation.error != error || validation.field != field) { + std::cerr << "Refusal expected error=" << static_cast(error) + << " field=" << static_cast(field) + << "; got error=" << static_cast(validation.error) + << " field=" << static_cast(validation.field) << '\n'; + } + check(validation.error == error, "validation error code"); + check(validation.field == field, "validation error field"); + check(snapshot(spec) == before, "const validation preserves every input bit"); + const auto normalization = normalize_native_run_spec(spec); + check(normalization.error == error, "normalization error code"); + check(normalization.field == field, "normalization error field"); + check(snapshot(spec) == before, "refused normalization preserves every input bit"); +} + +void expect_acceptance(NativeRunSpec spec) { + const auto before = snapshot(spec); + check(validate_native_run_spec(spec).ok(), "valid complete spec admitted"); + check(snapshot(spec) == before, "validation does not normalize"); + check(normalize_native_run_spec(spec).ok(), "valid complete spec normalized"); + check(snapshot(spec) == before, "nonzero/canonical spec values preserved"); +} + +void strings_and_identity() { + expect_acceptance(complete_spec()); + expect_refusal({}, Error::EmptyRequiredString, Field::SessionKey); + auto spec = complete_spec(); + spec.identity.run_number = 0; + expect_refusal(spec, Error::ZeroRunNumber, Field::RunNumber); + spec.identity.run_number = std::numeric_limits::max(); + expect_acceptance(spec); // Host high-water is a separate begin obligation. + + const struct { + std::string NativeRunSpec::* member; + Field field; + bool required; + } fields[] = { + {&NativeRunSpec::input_tf, Field::InputTimeframe, true}, + {&NativeRunSpec::script_tf, Field::ScriptTimeframe, true}, + {&NativeRunSpec::ticker, Field::Ticker, false}, + {&NativeRunSpec::tickerid, Field::TickerId, true}, + {&NativeRunSpec::type, Field::Type, false}, + {&NativeRunSpec::currency, Field::Currency, false}, + {&NativeRunSpec::basecurrency, Field::BaseCurrency, false}, + {&NativeRunSpec::description, Field::Description, false}, + {&NativeRunSpec::volumetype, Field::VolumeType, false}, + {&NativeRunSpec::timezone, Field::Timezone, true}, + {&NativeRunSpec::session, Field::Session, false}, + {&NativeRunSpec::chart_timezone, Field::ChartTimezone, false}, + }; + for (const auto& field : fields) { + spec = complete_spec(); + (spec.*field.member).clear(); + if (field.required) expect_refusal(spec, Error::EmptyRequiredString, field.field); + else expect_acceptance(spec); + spec = complete_spec(); + spec.fee_value = -0.0; + (spec.*field.member).append("\0suffix", 7); + expect_refusal(spec, Error::EmbeddedNul, field.field); + spec = complete_spec(); + spec.fee_value = -0.0; + (spec.*field.member).append("\xC0\xAF", 2); + expect_refusal(spec, Error::InvalidUtf8, field.field); + } + spec = complete_spec(); + spec.identity.session_key.clear(); + expect_refusal(spec, Error::EmptyRequiredString, Field::SessionKey); + spec.identity.session_key = std::string("run\0other", 9); + expect_refusal(spec, Error::EmbeddedNul, Field::SessionKey); + // Required means nonempty, without an invented trim/ASCII restriction. + spec.identity.session_key = " "; + expect_acceptance(spec); + const std::string invalid[] = { + "\x80", "\xBF", "\xC0\x80", "\xC1\xBF", "\xC2", "\xC2x", + "\xE0\x80\x80", "\xE0\xA0", "\xED\xA0\x80", "\xED\xBF\xBF", + "\xF0\x80\x80\x80", "\xF0\x90\x80", "\xF4\x90\x80\x80", + "\xF5\x80\x80\x80", "\xF8\x88\x80\x80\x80", "\xFF", + }; + for (const auto& bad : invalid) { + spec = complete_spec(); + spec.identity.session_key = bad; + expect_refusal(spec, Error::InvalidUtf8, Field::SessionKey); + } + // Valid scalar boundaries, including a Unicode noncharacter; no NFC fold. + for (const std::string good : {"\x7F", "\xC2\x80", "\xDF\xBF", + "\xE0\xA0\x80", "\xED\x9F\xBF", "\xEE\x80\x80", + "\xEF\xBF\xBF", "\xF0\x90\x80\x80", "\xF4\x8F\xBF\xBF", + "e\xCC\x81"}) { + spec = complete_spec(); + spec.identity.session_key = good; + expect_acceptance(spec); + } +} + +void financial_values_and_options() { + const double invalid[] = {0.0, -0.0, -1.0, + std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), std::nan("1234")}; + const double positive[] = {std::numeric_limits::denorm_min(), 0.125, + 1.0, std::numeric_limits::max()}; + const struct { double NativeRunSpec::* member; Field field; } fields[] = { + {&NativeRunSpec::initial_capital, Field::InitialCapital}, + {&NativeRunSpec::point_value, Field::PointValue}, + {&NativeRunSpec::account_fx, Field::AccountFx}, + {&NativeRunSpec::price_tick, Field::PriceTick}, + }; + for (const auto& field : fields) { + for (double value : invalid) { + auto spec = complete_spec(); + spec.fee_value = -0.0; + spec.*field.member = value; + expect_refusal(spec, Error::NotFinitePositive, field.field); + } + for (double value : positive) { + auto spec = complete_spec(); + spec.*field.member = value; + expect_acceptance(spec); // No invented arithmetic/cap restriction. + } + } + const struct { + std::optional NativeRunSpec::* member; + Field field; + } options[] = { + {&NativeRunSpec::quantity_grid, Field::QuantityGrid}, + {&NativeRunSpec::max_abs_units, Field::MaxAbsUnits}, + {&NativeRunSpec::initial_margin_fraction, Field::InitialMarginFraction}, + }; + for (const auto& option : options) { + for (double value : invalid) { + auto spec = complete_spec(); + spec.fee_value = -0.0; + spec.*option.member = value; + expect_refusal(spec, Error::NotFinitePositive, option.field); + } + for (double value : positive) { + auto spec = complete_spec(); + spec.*option.member = value; + expect_acceptance(spec); + } + } + auto spec = complete_spec(); + spec.max_open_lots = 0; + spec.fee_value = -0.0; + expect_refusal(spec, Error::ZeroLotLimit, Field::MaxOpenLots); + spec.fee_value = 6.0; + spec.max_open_lots = std::numeric_limits::max(); + expect_acceptance(spec); + spec = complete_spec(); + spec.quantity_grid = 0.3; + spec.max_abs_units = 0.2; + spec.max_open_lots = 1; + spec.initial_margin_fraction = 1.5; + spec.allowed_open_directions = NativeOpenDirections::None; + expect_acceptance(spec); // A setup may disallow opening; no resizing policy. + + for (auto fee : {NativeFeeKind::Percent, NativeFeeKind::CashPerUnit, + NativeFeeKind::CashPerExecution}) { + for (double value : {-1.0, std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), std::nan("99")}) { + spec = complete_spec(); spec.fee_kind = fee; spec.fee_value = value; + expect_refusal(spec, Error::NotFiniteNonnegative, Field::FeeValue); + } + for (double value : {0.0, 150.0, std::numeric_limits::denorm_min(), + std::numeric_limits::max()}) { + spec = complete_spec(); spec.fee_kind = fee; spec.fee_value = value; + expect_acceptance(spec); + } + spec = complete_spec(); spec.fee_kind = fee; spec.fee_value = -0.0; + check(validate_native_run_spec(spec).ok(), "fee negative zero admitted"); + check(std::signbit(spec.fee_value), "validation preserves negative zero"); + auto expected = spec; expected.fee_value = 0.0; + check(normalize_native_run_spec(spec).ok(), "negative-zero normalization succeeds"); + check(!std::signbit(spec.fee_value), "fee canonical zero is positive"); + check(snapshot(spec) == snapshot(expected), "only admitted zero changes"); + expect_acceptance(spec); // Normalization is idempotent. + } + for (std::uint32_t unknown : {3u, 4u, std::numeric_limits::max()}) { + spec = complete_spec(); spec.fee_kind = static_cast(unknown); + expect_refusal(spec, Error::UnknownFeeKind, Field::FeeKind); + } + for (std::uint32_t value : {0u, 1u, 2u, 3u}) { + spec = complete_spec(); + spec.allowed_open_directions = static_cast(value); + expect_acceptance(spec); + } + for (std::uint32_t unknown : {4u, 7u, std::numeric_limits::max()}) { + spec = complete_spec(); + spec.allowed_open_directions = static_cast(unknown); + expect_refusal(spec, Error::UnknownOpenDirections, Field::AllowedOpenDirections); + } + for (auto close : {NativeCloseExecution::NextEligiblePoint, + NativeCloseExecution::AfterCalculation}) { + spec = complete_spec(); spec.close_execution = close; expect_acceptance(spec); + } + for (std::uint32_t unknown : {2u, std::numeric_limits::max()}) { + spec = complete_spec(); spec.close_execution = static_cast(unknown); + expect_refusal(spec, Error::UnknownCloseExecution, Field::CloseExecution); + } + spec = complete_spec(); + spec.slippage_ticks = static_cast(std::numeric_limits::max()); + expect_acceptance(spec); + ++spec.slippage_ticks; + expect_refusal(spec, Error::SlippageOutOfRange, Field::SlippageTicks); + spec.slippage_ticks = std::numeric_limits::max(); + expect_refusal(spec, Error::SlippageOutOfRange, Field::SlippageTicks); +} + +void complete_clock_contract() { + const std::pair allowed[] = { + {"1S", "1S"}, {"15S", "1"}, {"1", "60S"}, {"1", "5"}, + {"1", "D"}, {"1", "W"}, {"1", "3M"}, + {"D", "D"}, {"D", "1D"}, {"1D", "D"}, {"2D", "2D"}, + {"D", "2D"}, {"W", "W"}, {"2W", "2W"}, {"W", "2W"}, + {"M", "M"}, {"3M", "3M"}, {"M", "3M"}, + {"D", "W"}, {"2D", "W"}, {"D", "M"}, {"W", "M"}, {"2W", "3M"}, + }; + for (const auto& pair : allowed) { + auto spec = complete_spec(); spec.input_tf = pair.first; spec.script_tf = pair.second; + expect_acceptance(spec); // Monthly is batch-valid; no stream guard here. + } + for (const char* literal : {"", "0", "01", "01D", "S", "0S", "1H", "1m", + " 1", "1 ", "1\t", "1.0", "+1", "-1", "1DD", "2147483648", + "999999999999999999999999999999999999999999999999999999999999999"}) { + auto spec = complete_spec(); spec.input_tf = literal; + expect_refusal(spec, literal[0] ? Error::InvalidTimeframe : Error::EmptyRequiredString, + Field::InputTimeframe); + spec = complete_spec(); spec.script_tf = literal; + expect_refusal(spec, literal[0] ? Error::InvalidTimeframe : Error::EmptyRequiredString, + Field::ScriptTimeframe); + } + const std::pair denied[] = { + {"5", "1"}, {"5", "7"}, {"45S", "1"}, {"2D", "3D"}, + {"2W", "W"}, {"3M", "M"}, {"W", "D"}, {"M", "W"}, {"D", "60"}, + }; + for (const auto& pair : denied) { + auto spec = complete_spec(); spec.input_tf = pair.first; spec.script_tf = pair.second; + spec.fee_value = -0.0; + expect_refusal(spec, Error::IncompatibleTimeframes, Field::ScriptTimeframe); + } + for (const char* session : {"", "24x7", "0000-2400", "1700-1700", "1800-1700:23456", + "0930-1130,1300-1600:23456", "1300-1600,0900-1500", "0230-0245"}) { + auto spec = complete_spec(); spec.session = session; spec.timezone = "America/New_York"; + expect_acceptance(spec); + } + for (const char* session : {"bad", "2400-0100", "0930-2500", "0930-1600:0", + "0930-1600:8", "0930-1600:", "0930-1600,", "0930-1600,,1300-1400"}) { + auto spec = complete_spec(); spec.session = session; spec.fee_value = -0.0; + expect_refusal(spec, Error::InvalidSession, Field::Session); + } + for (const char* zone : {"UTC", "Etc/UTC", "America/New_York", "Asia/Taipei", + "UTC+05:30", "GMT-4"}) { + auto spec = complete_spec(); spec.timezone = zone; spec.chart_timezone = zone; + expect_acceptance(spec); + } + // These are required contract checks, even while a frozen calendar + // dependency is known to accept invalid names as libc's fallback UTC. + for (const char* zone : {"No/Such_PineForge_Zone", "UTC+24:00", "UTC+01:99", " "}) { + auto spec = complete_spec(); spec.timezone = zone; spec.fee_value = -0.0; + expect_refusal(spec, Error::UnresolvedTimezone, Field::Timezone); + spec = complete_spec(); spec.chart_timezone = zone; spec.fee_value = -0.0; + expect_refusal(spec, Error::UnresolvedTimezone, Field::ChartTimezone); + } +} + +void failure_atomicity() { + auto spec = complete_spec(); + spec.fee_value = -0.0; + // Force parse_session's literal/string allocation beyond any SSO size. + spec.session = std::string(200, ' ') + "0930-1600"; + const auto before = snapshot(spec); + refuse_allocation = true; + const auto validation = validate_native_run_spec(spec); + const auto normalization = normalize_native_run_spec(spec); + refuse_allocation = false; + check(validation.error == Error::AllocationFailure, "allocation failure is typed"); + check(normalization.error == Error::AllocationFailure, "normalization allocation failure is typed"); + check(validation.field == Field::Session, "allocation failure names calendar stage"); + check(normalization.field == Field::Session, "normalization names failed calendar stage"); + check(snapshot(spec) == before, "allocation refusal preserves all values and negative zero"); + check(normalize_native_run_spec(spec).ok(), "same value normalizes after resources recover"); + check(!std::signbit(spec.fee_value), "recovered normalization canonicalizes zero"); + + // Validate all late fields before touching early fields, including zeros. + spec = complete_spec(); spec.fee_value = -0.0; + spec.initial_margin_fraction = std::nan("42"); + expect_refusal(spec, Error::NotFinitePositive, Field::InitialMarginFraction); +} +} // namespace + +int main() { + strings_and_identity(); + financial_values_and_options(); + complete_clock_contract(); + failure_atomicity(); + std::cout << (checks - failures) << '/' << checks << " checks passed; " + << failures << " failed\n"; + return failures == 0 ? 0 : 1; +}