diff --git a/README.md b/README.md index e155b8a..bc0c24a 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ for the full tool catalog, request schemas, and env vars (`PINEFORGE_ALLOW_ANYWH - ๐ŸŽฏ **TradingView-exact.** 251 of 252 reference strategies match TV trade-for-trade. The lone outlier is a stress probe at the 1ร— margin boundary where TV's broker emulator is non-deterministic โ€” engine is correct. **100 of 100** PineForge excellent vs PyneCore + PineTS on the public three-way benchmark (~167,000 TV trades; PyneCore: 85 of 100; PineTS indicator-only). - โšก **Microsecond-class.** Median **162ร— faster than PyneCore** across 99 commonly-timed strategies (full 41,307-bar OHLCV, magnifier-on hot loop; see [benchmarks/results/speed.md](benchmarks/results/speed.md)). Parameter sweeps load one `.so` and re-run with new inputs โ€” no recompile, no fork, no IPC. -- ๐Ÿ”’ **Stable C ABI.** 27 functions, one header (``). Append-only across minor versions, `static_assert`-pinned struct layouts, hidden-visibility hygiene. Drop a strategy `.so` in any harness; it just runs. +- ๐Ÿ”’ **Stable C ABI.** 28 functions, one header (``). Append-only across minor versions, `static_assert`-pinned struct layouts, hidden-visibility hygiene. Drop a strategy `.so` in any harness; it just runs. - ๐Ÿงช **Reproducible to the bit.** Deterministic float ordering, deterministic bar magnifier, no internal RNG seeded from time. Two runs with the same inputs produce bit-identical trade lists. - ๐Ÿงฐ **FFI-friendly.** Call from Python (`ctypes`), Rust (`libloading`), Go (`cgo`), Node, Julia. Worked examples for [pure C](https://cdocs.pineforge.dev/examples_c.html), [Python sweep](https://cdocs.pineforge.dev/examples_python_sweep.html), [Rust](https://cdocs.pineforge.dev/examples_rust.html), [multi-strategy harness](https://cdocs.pineforge.dev/examples_multi.html), and [magnifier A/B](https://cdocs.pineforge.dev/examples_magnifier.html) ship in the docs. - ๐ŸŒ **Cross-platform CI.** Linux + macOS ร— Release + Debug. Universal mac binary. Static library, no runtime DSO surprises at deploy time. @@ -129,7 +129,7 @@ for the full tool catalog, request schemas, and env vars (`PINEFORGE_ALLOW_ANYWH ## For developers: embed the runtime directly -PineForge ships as a static C library (`libpineforge.a`) with a stable 27-symbol C ABI. Call from C, Python, Rust, Go, Node, Julia โ€” one harness, swap strategies forever. +PineForge ships as a static C library (`libpineforge.a`) with a stable 28-symbol C ABI. Call from C, Python, Rust, Go, Node, Julia โ€” one harness, swap strategies forever. ### See it in 30 seconds @@ -320,6 +320,7 @@ int main(void) { | `run_backtest` | Run with auto-detected timeframe | | `run_backtest_full` | Run with timeframe + magnifier configuration | | `report_free` | Free arrays inside a filled `pf_report_t` | +| `strategy_closed_trade_entry_incarnation`| Read per-run physical entry provenance | | `strategy_set_input` | Override a Pine `input.*()` value | | `strategy_set_override` | Override a `strategy(...)` declaration param | | `strategy_set_magnifier_volume_weighted` | Toggle volume-weighted magnifier | @@ -398,7 +399,7 @@ cmake/smoke_consumer/ - Minimal find_package(PineForge) CI smoke project ## Visibility hygiene -Every compiled strategy `.so` that statically links `libpineforge.a` exports **exactly the 27 documented C ABI symbols** and zero internal C++ symbols. This is enforced at the library level: +Every compiled strategy `.so` that statically links `libpineforge.a` exports **exactly the 28 documented C ABI symbols** and zero internal C++ symbols. This is enforced at the library level: - `libpineforge.a` is built with `-fvisibility=hidden -fvisibility-inlines-hidden` - Public symbols are tagged `PF_API` (visibility=default) diff --git a/docker/run_json.py b/docker/run_json.py index 9569c6c..6aba802 100755 --- a/docker/run_json.py +++ b/docker/run_json.py @@ -41,7 +41,9 @@ "max_drawdown": float, "commission": float, # ABI v2 "entry_bar_index": int, # ABI v2: script-bar index of entry fill - "exit_bar_index": int # ABI v2: script-bar index of exit fill + "exit_bar_index": int, # ABI v2: script-bar index of exit fill + "entry_incarnation": int # run-scoped physical-entry provenance; + # 0 when the strategy lacks the accessor }, ... ], @@ -552,6 +554,10 @@ def load_strategy(so_path: Path) -> ctypes.CDLL: if hasattr(lib, "strategy_get_last_error"): lib.strategy_get_last_error.argtypes = [ctypes.c_void_p] lib.strategy_get_last_error.restype = ctypes.c_char_p + if hasattr(lib, "strategy_closed_trade_entry_incarnation"): + lib.strategy_closed_trade_entry_incarnation.argtypes = [ + ctypes.c_void_p, ctypes.c_int] + lib.strategy_closed_trade_entry_incarnation.restype = ctypes.c_uint64 # syminfo setters โ€” declare argtypes so ctypes does not default the float # args to c_int (which would truncate mintick=0.5 to 0). Guarded with @@ -624,7 +630,8 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, elapsed: float, applied_inputs: dict[str, str], applied_overrides: dict[str, str], - applied_runtime: dict[str, object] | None = None) -> dict: + applied_runtime: dict[str, object] | None = None, + trade_entry_incarnations: list[int] | None = None) -> dict: trades = [] pnls: list[float] = [] for i in range(report.trades_len): @@ -645,6 +652,11 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, "commission": float(t.commission), "entry_bar_index": int(t.entry_bar_index), "exit_bar_index": int(t.exit_bar_index), + "entry_incarnation": ( + int(trade_entry_incarnations[i]) + if trade_entry_incarnations is not None + and i < len(trade_entry_incarnations) else 0 + ), }) n = len(pnls) @@ -946,8 +958,17 @@ def _run(st, rep): "trade_start_ms": args.trade_start_ms, "chart_tz": args.chart_tz or "", } - out = build_report_dict(report, args.ohlcv, n, first_ts, last_ts, - elapsed, inputs, overrides, applied_runtime) + incarnation_accessor = getattr( + lib, "strategy_closed_trade_entry_incarnation", None) + trade_entry_incarnations = ( + [int(incarnation_accessor(state, i)) + for i in range(report.trades_len)] + if incarnation_accessor is not None else None + ) + out = build_report_dict( + report, args.ohlcv, n, first_ts, last_ts, + elapsed, inputs, overrides, applied_runtime, + trade_entry_incarnations) if timing is not None: out["diagnostics"]["timing"] = timing out["diagnostics"]["throughput"] = _throughput_block( diff --git a/docker/run_json_diagnostics_test.py b/docker/run_json_diagnostics_test.py index 7e1b1ac..c05040b 100644 --- a/docker/run_json_diagnostics_test.py +++ b/docker/run_json_diagnostics_test.py @@ -69,6 +69,39 @@ def test_summary_bars_processed_unchanged(): assert rep["summary"]["bars_processed"] == 525_600 +def test_trade_entry_incarnation_is_optional_and_positionally_aligned(): + report = _FakeReport() + report.trades_len = 1 + report.trades = (run_json.TradeC * 1)() + + with_identity = run_json.build_report_dict( + report, + ohlcv_path="x.csv", + n_bars=0, + first_ts=0, + last_ts=0, + elapsed=0.0, + applied_inputs={}, + applied_overrides={}, + applied_runtime={}, + trade_entry_incarnations=[41], + ) + legacy = run_json.build_report_dict( + report, + ohlcv_path="x.csv", + n_bars=0, + first_ts=0, + last_ts=0, + elapsed=0.0, + applied_inputs={}, + applied_overrides={}, + applied_runtime={}, + ) + + assert with_identity["trades"][0]["entry_incarnation"] == 41 + assert legacy["trades"][0]["entry_incarnation"] == 0 + + # --- --bench diagnostics contract (timing + throughput) --------------------- # These pure blocks back the raw-data benchmark drivers # (benchmarks/speed/time_pineforge_docker.py, diff --git a/docs/coverage.md b/docs/coverage.md index 061dc9a..752e13d 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -60,7 +60,7 @@ ## Public C ABI `` is the **single canonical consumer header**. -Every compiled PineForge strategy `.so` exports exactly the 27 symbols +Every compiled PineForge strategy `.so` exports exactly the 28 symbols declared there: @@ -71,6 +71,7 @@ declared there: | `run_backtest` | Run with auto-detected timeframe | | `run_backtest_full` | Run with timeframe + magnifier configuration | | `report_free` | Free arrays inside a filled `pf_report_t` | +| `strategy_closed_trade_entry_incarnation`| Read per-run physical entry provenance | | `strategy_set_input` | Override a Pine `input.*()` value | | `strategy_set_override` | Override a `strategy(...)` declaration param | | `strategy_set_magnifier_volume_weighted` | Toggle volume-weighted magnifier | @@ -120,7 +121,7 @@ single `.hpp`): | Module | Header | Source | Pine-facing role | | ------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | The 27 documented C symbols every compiled strategy `.so` exports. | +| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | The 28 documented C symbols every compiled strategy `.so` exports. | | Engine | `engine.hpp` | `engine_run.cpp`, `engine_stream.cpp`, `engine_orders.cpp`, `engine_fills.cpp`, `engine_path_resolve.cpp`, `engine_strategy_commands.cpp`, `engine_trade_accessors.cpp`, `engine_security.cpp`, `engine_lower_tf.cpp`, `engine_risk.cpp`, `engine_report.cpp` | One-shot and continuous lifecycle, orders, raw-trade/bar fills, risk, reports, inputs / syminfo, magnifier, TF aggregation, and `request.security` plumbing. | | Engine internals | `engine_internal.hpp` | (private cross-TU header) | `pineforge::internal::`* types and helpers shared between engine `.cpp` partitions; not part of the public ABI. | | Technical analysis | `ta.hpp` | `ta_moving_averages.cpp`, `ta_oscillators.cpp`, `ta_volatility_trend.cpp`, `ta_extremes_volume.cpp`, `ta_misc.cpp` | Official `ta.`* functions and series variables backed by stateful runtime classes with `compute` / `recompute`, plus `pivot_point_levels(...)` free function. | diff --git a/docs/pages/abi-stability.md b/docs/pages/abi-stability.md index 863cfc7..a68bb52 100644 --- a/docs/pages/abi-stability.md +++ b/docs/pages/abi-stability.md @@ -54,7 +54,7 @@ Three layers: ## Symbol inventory -A compiled strategy `.so` exports **exactly these 27 C symbols** and +A compiled strategy `.so` exports **exactly these 28 C symbols** and zero internal C++ symbols: | Symbol | Group | @@ -64,6 +64,7 @@ zero internal C++ symbols: | `run_backtest` | @ref pf_lifecycle | | `run_backtest_full` | @ref pf_lifecycle | | `report_free` | @ref pf_lifecycle | +| `strategy_closed_trade_entry_incarnation` | @ref pf_lifecycle | | `strategy_set_input` | @ref pf_config | | `strategy_set_override` | @ref pf_config | | `strategy_set_magnifier_volume_weighted` | @ref pf_config | @@ -87,10 +88,11 @@ zero internal C++ symbols: | `pf_abi_version` | @ref pf_version | | `pf_version_string` | @ref pf_version | -The five strategy-lifecycle functions are emitted by codegen. Runtime exports -are force-linked into each strategy library, so consumers resolve the same -complete ABI from the strategy `.so`. All additions remain covered by the -minor-version append-only guarantee. +The five create/run/free lifecycle functions are emitted by codegen. The +closed-trade incarnation accessor and other runtime exports are force-linked +into each strategy library, so consumers resolve the same complete ABI from the +strategy `.so`. All additions remain covered by the minor-version append-only +guarantee. You can verify this against any strategy `.so`: diff --git a/docs/pages/index.md b/docs/pages/index.md index dd496ae..4137b24 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -80,18 +80,18 @@ End-to-end, runnable examples that go beyond the MACD tutorial: ## API at a glance -The entire public surface fits in **one header** and **27 functions**: +The entire public surface fits in **one header** and **28 functions**: | Group | Symbols | Reference | | --- | --- | --- | -| Lifecycle | `strategy_create`, `strategy_free`, `run_backtest`, `run_backtest_full`, `report_free` | @ref pf_lifecycle | +| Lifecycle | `strategy_create`, `strategy_free`, `run_backtest`, `run_backtest_full`, `report_free`, `strategy_closed_trade_entry_incarnation` | @ref pf_lifecycle | | Streaming | `strategy_stream_begin`, `strategy_stream_push_tick`, `strategy_stream_push_ticks`, `strategy_stream_advance_time`, `strategy_stream_end`, `strategy_stream_fill_report` | @ref pf_streaming | | Configuration | Inputs, strategy overrides, tracing, trade start, chart / symbol timezone, session, tick size, point value, numeric metadata, and timestamped account-currency FX | @ref pf_config | | Diagnostics | `strategy_get_last_error` | #strategy_get_last_error | | Version | `pf_version_get`, `pf_abi_version`, `pf_version_string` | @ref pf_version | | Types | `pf_bar_t`, `pf_trade_tick_t`, `pf_trade_t`, `pf_report_t`, metrics, diagnostics, trace, equity, version, and `pf_magnifier_distribution_t` | @ref pf_types | -Every PineForge-generated strategy `.so` exports exactly these 27 symbols +Every PineForge-generated strategy `.so` exports exactly these 28 symbols and zero internal C++ symbols โ€” see **[ABI stability](@ref abi_stability)** for the full guarantee. diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 21e6d82..723cfc1 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -74,6 +74,13 @@ struct PyramidEntry { // every production entry path initializes the snapshot at fill time. double entry_commission_account = std::numeric_limits::quiet_NaN(); + // Monotonic per-run identity of the PendingOrder object whose broker fill + // created this physical lot. Unlike Pine's user-visible entry_id, an + // incarnation is never reused by same-id replacements or later calls. + // Every partial-close fragment copied from this lot therefore retains the + // same physical-entry provenance. Zero is reserved for legacy/test-only + // synthetic lots that were not created by a PendingOrder. + uint64_t entry_incarnation = 0; }; struct Trade { @@ -94,6 +101,9 @@ struct Trade { double max_runup = 0.0; double max_drawdown = 0.0; double commission = 0.0; + // Physical-entry provenance copied from PyramidEntry. This is deliberately + // separate from entry_id: Pine permits user-visible IDs to be reused. + uint64_t entry_incarnation = 0; }; struct TradeC { @@ -174,6 +184,17 @@ struct ReportC { enum class OrderType { MARKET, ENTRY, EXIT, RAW_ORDER }; +// Order-local provenance for the one empirically pinned default-FIFO +// SHORT-seed collision. The broker book remains in its ordinary fill order; +// these roles only change the two transaction kernels after the complete +// prior-bar three-object shape has been proven. +enum class ShortSeedCollisionRole : uint8_t { + NONE = 0, + LONG_ENTRY, + MATERIALIZE_LONG, + FINAL_SHORT, +}; + struct PendingOrder { std::string id; std::string from_entry; // for exit orders @@ -539,6 +560,8 @@ struct PendingOrder { // debit the id ledger). double suppressed_close_consumed_ledger_qty = std::numeric_limits::quiet_NaN(); + ShortSeedCollisionRole short_seed_collision_role = + ShortSeedCollisionRole::NONE; }; // default_qty_type constants (matches TradingView) @@ -1031,6 +1054,10 @@ class BacktestEngine { std::vector trades_; std::vector pending_orders_; + // A rejected strategy.entry call leaves no PendingOrder behind. The exact + // collision gate can consume only the immediately preceding source bar, so + // one scalar tombstone is sufficient and cannot grow with feed length. + int last_rejected_strategy_entry_call_bar_ = -1; // Source bars whose otherwise eligible flat MARKET candidate set was // mutated (same-id replacement/cancel) or contained an extra rejected // call. Even if two orders survive, the original bar contained more or @@ -2156,6 +2183,10 @@ class BacktestEngine { if (idx < 0 || idx >= (int)trades_.size()) return std::string(); return trades_[idx].exit_id; } + uint64_t closed_trade_entry_incarnation(int idx) const { + if (idx < 0 || idx >= (int)trades_.size()) return 0; + return trades_[idx].entry_incarnation; + } double closed_trade_entry_price(int idx) const { if (idx < 0 || idx >= (int)trades_.size()) return std::numeric_limits::quiet_NaN(); return trades_[idx].entry_price; @@ -2246,16 +2277,17 @@ class BacktestEngine { private: void execute_market_entry(const std::string& id, bool is_long, double fill_price, - double explicit_qty = std::numeric_limits::quiet_NaN(), - int explicit_qty_type = -1, - PositionSide created_position_side = PositionSide::FLAT, - bool close_only_opposite = false, - bool is_priced_entry = false, - double tv_carry_qty = 0.0, - int created_bar = -1, - bool later_same_tick_entry = false, - bool paired_flat_market_transaction = false, - bool explicit_qty_prequantized = false); + double explicit_qty, + int explicit_qty_type, + PositionSide created_position_side, + bool close_only_opposite, + bool is_priced_entry, + double tv_carry_qty, + int created_bar, + bool later_same_tick_entry, + bool paired_flat_market_transaction, + bool explicit_qty_prequantized, + uint64_t entry_incarnation); void execute_market_exit(double fill_price); void execute_partial_exit_qty(double fill_price, double qty_to_close); void execute_partial_exit(double fill_price, double qty_percent); @@ -2288,6 +2320,10 @@ class BacktestEngine { void apply_pooc_coof_explicit_flat_market_gross_admission(); void finalize_pending_flat_market_pairs(const Bar& bar); void sort_orders_by_fill_phase(const Bar& bar); + bool short_seed_collision_materialization_is_live( + const PendingOrder& order) const; + bool short_seed_collision_final_short_is_live( + const PendingOrder& order) const; // TradingView binds a valid, single/full, non-trailing strategy.exit to a // co-queued high-level MARKET parent. If that parent fills at the next open // and its stop is already breached, the newborn lot scratches at that open. @@ -2475,26 +2511,32 @@ class BacktestEngine { PositionSide created_position_side, bool is_priced_entry, double tv_carry_qty, int created_bar, - bool explicit_qty_prequantized = false); + bool explicit_qty_prequantized, + uint64_t entry_incarnation); void add_to_pyramid_market(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, PositionSide created_position_side, - bool is_priced_entry); + bool is_priced_entry, + uint64_t entry_incarnation); void close_opposite_then_enter(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, - bool purge_pending_exits = true, - bool explicit_qty_prequantized = false); + bool purge_pending_exits, + bool explicit_qty_prequantized, + uint64_t entry_incarnation); void flip_market_position_to(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, - bool close_only = false); + bool close_only, + uint64_t entry_incarnation); void sequential_same_tick_reversal_fill(const std::string& id, bool is_long, double fill_price, double explicit_qty, - int explicit_qty_type); + int explicit_qty_type, + uint64_t entry_incarnation); void open_fresh_position(PositionSide requested, double fill_price, - double qty, const std::string& id); + double qty, const std::string& id, + uint64_t entry_incarnation); void consume_tv_carry_from_siblings(const std::string& id, PositionSide created_position_side, int created_bar); diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index 263463a..e8f0e64 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -488,7 +488,7 @@ PF_API void strategy_set_magnifier_volume_weighted(pf_strategy_t s, #endif /* PINEFORGE_NO_STRATEGY_DECLS */ /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - * RUNTIME LIBRARY EXPORTS โ€” implemented in libruntime + * RUNTIME LIBRARY EXPORTS โ€” implemented in libpineforge * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ /** Toggle per-bar trace recording. Default off (zero-cost when off). @@ -506,6 +506,27 @@ PF_API void strategy_set_trade_start_time(pf_strategy_t s, int64_t timestamp_ms) /** @} */ /* end of pf_config */ +/** @addtogroup pf_lifecycle + * @{ + */ + +/** Return the physical entry incarnation for one closed-trade row. + * + * Partial-close/FIFO fragments emitted from the same physical entry share + * this value. Distinct broker entry objects receive distinct monotonically + * increasing values even when Pine reuses the same user-visible entry ID. + * The value is scoped to one strategy run and is intended as report + * provenance, not as a stable cross-run identifier. + * + * @param s Strategy handle whose most recent run filled a report. + * @param trade_index Zero-based closed-trade index in that report. + * @return Non-zero physical-entry identity, or 0 for an invalid index or a + * legacy/synthetic trade without PendingOrder provenance. */ +PF_API uint64_t strategy_closed_trade_entry_incarnation( + pf_strategy_t s, int trade_index); + +/** @} */ /* end of pf_lifecycle */ + /** @defgroup pf_streaming Historical to realtime streaming * @brief Warm on confirmed OHLCV and continue the same strategy instance on * normalized ordered trades from any data source. diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index 8c900b1..aac3d68 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -16,6 +16,7 @@ ROOT = Path(__file__).resolve().parents[1] EXPECTED_RUNTIME = frozenset({ + "strategy_closed_trade_entry_incarnation", "strategy_set_trace_enabled", "strategy_set_trade_start_time", "strategy_set_chart_timezone", diff --git a/scripts/pf_release_run.py b/scripts/pf_release_run.py index f980ab3..229bc0f 100644 --- a/scripts/pf_release_run.py +++ b/scripts/pf_release_run.py @@ -155,5 +155,6 @@ def report_trades_to_runstrategy_shape(report: dict) -> list[dict]: "commission": float(t.get("commission", 0.0)), "entry_bar_index": int(t.get("entry_bar_index", -1)), "exit_bar_index": int(t.get("exit_bar_index", -1)), + "entry_incarnation": int(t.get("entry_incarnation", 0) or 0), }) return out diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index 55acdd5..0542ec6 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -768,6 +768,10 @@ def _setup_signatures(self) -> None: L.strategy_free.argtypes = [ctypes.c_void_p] L.report_free.argtypes = [ctypes.POINTER(ReportC)] + if hasattr(L, "strategy_closed_trade_entry_incarnation"): + L.strategy_closed_trade_entry_incarnation.argtypes = [ + ctypes.c_void_p, ctypes.c_int] + L.strategy_closed_trade_entry_incarnation.restype = ctypes.c_uint64 if hasattr(L, "strategy_get_last_error"): L.strategy_get_last_error.argtypes = [ctypes.c_void_p] L.strategy_get_last_error.restype = ctypes.c_char_p @@ -1027,6 +1031,13 @@ def run(self, bars_csv: Path, params: dict | None = None, if on_report is not None: on_report(report) result = _report_to_dict(report) + incarnation_accessor = getattr( + self.lib, "strategy_closed_trade_entry_incarnation", None) + for i, trade in enumerate(result["trades"]): + trade["entry_incarnation"] = ( + int(incarnation_accessor(state, i)) + if incarnation_accessor is not None else 0 + ) if source_feed_sha256 is not None: result["source_feed_sha256"] = source_feed_sha256 return result @@ -1230,7 +1241,7 @@ def write_engine_trades_csv(trades: list[dict], path: Path) -> None: "Trade #", "Type", "Date and time", "Price", "Qty", "Net PnL", "Net PnL %", "Favorable excursion USD", "Adverse excursion USD", - "Cumulative PnL", + "Cumulative PnL", "Engine entry incarnation", ]) for n, t in reversed(list(enumerate(trades, 1))): direction = "long" if t["is_long"] else "short" @@ -1251,6 +1262,9 @@ def write_engine_trades_csv(trades: list[dict], path: Path) -> None: f"{t['max_runup']:.6f}", f"{adverse:.6f}", f"{cum:.6f}", + str(int(t.get("entry_incarnation", 0))) + if (side.startswith("Entry") + and int(t.get("entry_incarnation", 0)) > 0) else "", ]) diff --git a/scripts/test_run_strategy_identity.py b/scripts/test_run_strategy_identity.py new file mode 100644 index 0000000..8a2f953 --- /dev/null +++ b/scripts/test_run_strategy_identity.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Focused CSV tests for physical closed-trade entry provenance.""" + +from __future__ import annotations + +import csv +import tempfile +import unittest +from pathlib import Path + +from pf_release_run import report_trades_to_runstrategy_shape +from run_strategy import write_engine_trades_csv + + +class EngineEntryIdentityCsvTests(unittest.TestCase): + def test_writer_emits_incarnation_only_on_entry_row(self) -> None: + trade = { + "entry_time": 1_735_689_600_000, + "exit_time": 1_735_689_660_000, + "entry_price": 100.0, + "exit_price": 101.0, + "pnl": 1.0, + "pnl_pct": 1.0, + "is_long": True, + "max_runup": 1.0, + "max_drawdown": 0.0, + "qty": 1.0, + "entry_incarnation": 41, + } + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "engine_trades.csv" + write_engine_trades_csv([trade], path) + with path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["Type"], "Exit long") + self.assertEqual(rows[0]["Engine entry incarnation"], "") + self.assertEqual(rows[1]["Type"], "Entry long") + self.assertEqual(rows[1]["Engine entry incarnation"], "41") + + def test_legacy_trade_without_incarnation_stays_blank(self) -> None: + trade = { + "entry_time": 1_735_689_600_000, + "exit_time": 1_735_689_660_000, + "entry_price": 100.0, + "exit_price": 100.0, + "pnl": 0.0, + "pnl_pct": 0.0, + "is_long": False, + "max_runup": 0.0, + "max_drawdown": 0.0, + "qty": 1.0, + } + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "engine_trades.csv" + write_engine_trades_csv([trade], path) + with path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + self.assertTrue(all( + row["Engine entry incarnation"] == "" for row in rows)) + + def test_release_report_mapping_preserves_incarnation(self) -> None: + mapped = report_trades_to_runstrategy_shape({"trades": [{ + "entry_time": 1, + "exit_time": 2, + "entry_price": 100.0, + "exit_price": 101.0, + "pnl": 1.0, + "pnl_pct": 1.0, + "side": "long", + "max_runup": 1.0, + "max_drawdown": 0.0, + "qty": 1.0, + "entry_incarnation": 73, + }]}) + + self.assertEqual(mapped[0]["entry_incarnation"], 73) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_verify_corpus_metrics.py b/scripts/test_verify_corpus_metrics.py index 1df0c75..3024b7c 100644 --- a/scripts/test_verify_corpus_metrics.py +++ b/scripts/test_verify_corpus_metrics.py @@ -4,21 +4,29 @@ from __future__ import annotations import json +import math import sys import tempfile import unittest from contextlib import redirect_stdout +from datetime import timezone from io import StringIO from pathlib import Path from unittest.mock import patch from verify_corpus import ( + STRICT_ENTRY_DELTA, TradePair, _apply_declared_tier_override, _print_verification, analyze_strategy, + consolidate_fragments, + distinct_entry_fill_keys, + distinct_entry_fill_mismatches, has_cross_entry_fifo_allocation, main, + parse_trades, + relative_max, schedule_exit_metrics, schedule_rows_for_gate, ) @@ -34,6 +42,8 @@ def trade( direction: str = "long", qty: float = 1.0, pnl: float = 0.0, + entry_signal: str = "", + entry_identity: str = "", ) -> TradePair: return TradePair( direction=direction, @@ -44,9 +54,494 @@ def trade( qty=qty, pnl=pnl, trade_num=number, + entry_signal=entry_signal, + entry_identity=entry_identity, ) +class FragmentConsolidationIdentityTests(unittest.TestCase): + def test_distinct_tv_entry_signals_preserve_same_tick_trades(self) -> None: + rows = [ + trade(221, 100, 4627.73, 100, 4627.73, + entry_signal="Long"), + trade(222, 100, 4627.73, 100, 4627.73, + entry_signal="Close entry(s) order Short"), + ] + + self.assertEqual(len(consolidate_fragments(rows)), 2) + self.assertEqual( + distinct_entry_fill_keys(rows), {(100, 4627.73, "long")}) + + def test_engine_fragments_share_one_physical_identity(self) -> None: + tv_rows = [ + trade(221, 100, 4627.73, 100, 4627.73, + entry_signal="Long"), + trade(222, 100, 4627.73, 100, 4627.73, + entry_signal="Close entry(s) order Short"), + ] + engine_rows = [ + trade(221, 100, 4627.73, 100, 4627.73, + entry_identity="41"), + trade(222, 100, 4627.73, 100, 4627.73, + entry_identity="41"), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + self.assertEqual( + len(consolidate_fragments( + engine_rows, preserve_entry_keys=preserve, + identity_field="entry_identity")), 1) + self.assertEqual( + distinct_entry_fill_mismatches(tv_rows, engine_rows, preserve), 1) + + def test_direction_substitution_fails_distinct_entry_identity(self) -> None: + tv_rows = [ + trade(221, 100, 4627.73, 100, 4627.73, + entry_signal="Long"), + trade(222, 100, 4627.73, 100, 4627.73, + entry_signal="Close entry(s) order Short"), + ] + engine_rows = [ + trade(221, 100, 4627.73, 100, 4627.73), + trade(222, 100, 4627.73, 100, 4627.73, direction="short"), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + self.assertEqual( + distinct_entry_fill_mismatches(tv_rows, engine_rows, preserve), 1) + + def test_distinct_engine_incarnations_pass_identity_gate(self) -> None: + tv_rows = [ + trade(221, 100, 4627.73, 100, 4627.73, + entry_signal="Long"), + trade(222, 100, 4627.73, 100, 4627.73, + entry_signal="Close entry(s) order Short"), + ] + engine_rows = [ + trade(221, 100, 4627.73, 100, 4627.73, + entry_identity="41"), + trade(222, 100, 4627.73, 100, 4627.73, + entry_identity="42"), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + self.assertEqual( + distinct_entry_fill_mismatches(tv_rows, engine_rows, preserve), 0) + + def test_tolerant_projection_preserves_fragmented_multiplicity(self) -> None: + tv_rows = [ + trade(1, 100, 100.0, 200, 102.0, qty=0.25, + entry_signal="A"), + trade(2, 100, 100.0, 250, 103.0, qty=0.75, + entry_signal="A"), + trade(3, 100, 100.0, 300, 104.0, qty=1.0, + entry_signal="B"), + ] + engine_rows = [ + trade(1, 100, 100.000001, 200, 102.0, qty=0.25, + entry_identity="41"), + trade(2, 100, 100.000001, 250, 103.0, qty=0.75, + entry_identity="41"), + trade(3, 100, 100.000001, 300, 104.0, qty=1.0, + entry_identity="42"), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + consolidated = consolidate_fragments( + engine_rows, preserve_entry_keys=preserve, + identity_field="entry_identity") + + self.assertEqual(len(consolidated), 2) + self.assertEqual( + [row.entry_identity for row in consolidated], ["41", "42"]) + self.assertEqual( + distinct_entry_fill_mismatches( + tv_rows, consolidated, preserve), 0) + + def test_tolerant_projection_rejects_just_outside_price(self) -> None: + tv_rows = [ + trade(1, 100, 100.0, 200, 102.0, entry_signal="A"), + trade(2, 100, 100.0, 200, 102.0, entry_signal="B"), + ] + engine_rows = [ + trade(1, 100, 100.0101, 200, 102.0, entry_identity="41"), + trade(2, 100, 100.0101, 200, 102.0, entry_identity="42"), + ] + + self.assertGreater( + relative_max(100.0, 100.0101), STRICT_ENTRY_DELTA) + preserve = distinct_entry_fill_keys(tv_rows) + self.assertEqual( + distinct_entry_fill_mismatches( + tv_rows, engine_rows, preserve), 1) + + def test_tolerant_projection_uses_strict_boundary(self) -> None: + tv_rows = [ + trade(1, 100, 100.0, 200, 102.0, entry_signal="A"), + trade(2, 100, 100.0, 200, 102.0, entry_signal="B"), + ] + last_inside = 100.0 / (1.0 - STRICT_ENTRY_DELTA) + first_outside = math.nextafter(last_inside, math.inf) + self.assertLess( + relative_max(100.0, last_inside), STRICT_ENTRY_DELTA) + self.assertGreaterEqual( + relative_max(100.0, first_outside), STRICT_ENTRY_DELTA) + preserve = distinct_entry_fill_keys(tv_rows) + + def mismatches_at(price: float) -> int: + engine_rows = [ + trade(1, 100, price, 200, 102.0, entry_identity="41"), + trade(2, 100, price, 200, 102.0, entry_identity="42"), + ] + return distinct_entry_fill_mismatches( + tv_rows, engine_rows, preserve) + + self.assertEqual(mismatches_at(last_inside), 0) + self.assertEqual(mismatches_at(first_outside), 1) + + def test_tolerant_projection_fails_closed_on_non_transitive_overlap( + self, + ) -> None: + low, middle, high = 100.0, 100.009, 100.018 + self.assertLess(relative_max(low, middle), STRICT_ENTRY_DELTA) + self.assertLess(relative_max(middle, high), STRICT_ENTRY_DELTA) + self.assertGreater(relative_max(low, high), STRICT_ENTRY_DELTA) + tv_rows = [ + trade(1, 100, low, 200, 102.0, entry_signal="A1"), + trade(2, 100, low, 200, 102.0, entry_signal="A2"), + trade(3, 100, high, 200, 102.0, entry_signal="B1"), + trade(4, 100, high, 200, 102.0, entry_signal="B2"), + ] + engine_rows = [ + trade(1, 100, middle, 200, 102.0, entry_identity="41"), + trade(2, 100, middle, 200, 102.0, entry_identity="42"), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + consolidated = consolidate_fragments( + engine_rows, preserve_entry_keys=preserve, + identity_field="entry_identity") + + self.assertEqual(len(consolidated), 2) + self.assertEqual( + distinct_entry_fill_mismatches( + tv_rows, consolidated, preserve), 2) + + def test_exact_distinct_prices_win_inside_overlapping_tolerance( + self, + ) -> None: + low, high = 100.0, 100.005 + self.assertLess(relative_max(low, high), STRICT_ENTRY_DELTA) + tv_rows = [ + trade(1, 100, low, 200, 102.0, entry_signal="A1"), + trade(2, 100, low, 200, 102.0, entry_signal="A2"), + trade(3, 100, high, 200, 102.0, entry_signal="B1"), + trade(4, 100, high, 200, 102.0, entry_signal="B2"), + ] + engine_rows = [ + trade(1, 100, low, 200, 102.0, entry_identity="41"), + trade(2, 100, low, 200, 102.0, entry_identity="42"), + trade(3, 100, high, 200, 102.0, entry_identity="43"), + trade(4, 100, high, 200, 102.0, entry_identity="44"), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + consolidated = consolidate_fragments( + engine_rows, preserve_entry_keys=preserve, + identity_field="entry_identity") + + self.assertEqual(len(preserve), 2) + self.assertEqual(len(consolidated), 4) + self.assertEqual( + distinct_entry_fill_mismatches( + tv_rows, consolidated, preserve), 0) + + def test_tolerant_projection_keeps_time_and_direction_exact(self) -> None: + tv_rows = [ + trade(1, 100, 100.0, 200, 102.0, entry_signal="A"), + trade(2, 100, 100.0, 200, 102.0, entry_signal="B"), + ] + preserve = distinct_entry_fill_keys(tv_rows) + + for entry_time, direction in [(101, "long"), (100, "short")]: + with self.subTest(entry_time=entry_time, direction=direction): + engine_rows = [ + trade(1, entry_time, 100.000001, 200, 102.0, + direction=direction, entry_identity="41"), + trade(2, entry_time, 100.000001, 200, 102.0, + direction=direction, entry_identity="42"), + ] + self.assertEqual( + distinct_entry_fill_mismatches( + tv_rows, engine_rows, preserve), 1) + + def test_missing_engine_identity_fails_closed_despite_matching_rows(self) -> None: + tv_rows = [ + trade(1, 100, 10.0, 200, 12.0, qty=0.25, + entry_signal="A"), + trade(2, 100, 10.0, 200, 12.0, qty=0.75, + entry_signal="A"), + trade(3, 100, 10.0, 200, 12.0, qty=1.0, + entry_signal="B"), + ] + engine_rows = [ + trade(1, 100, 10.0, 200, 12.0, qty=1.0), + trade(2, 100, 10.0, 200, 12.0, qty=1.0), + ] + + preserve = distinct_entry_fill_keys(tv_rows) + self.assertEqual( + distinct_entry_fill_mismatches(tv_rows, engine_rows, preserve), 1) + + def test_same_signal_fragments_still_merge(self) -> None: + rows = [ + trade(1, 100, 10.0, 200, 12.0, qty=0.25, + entry_signal="Long"), + trade(2, 100, 10.0, 200, 12.0, qty=0.75, + entry_signal="Long"), + ] + + merged = consolidate_fragments(rows) + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0].qty, 1.0) + self.assertEqual(merged[0].entry_signal, "Long") + + def test_mixed_unique_and_repeated_signals_preserve_physical_buckets(self) -> None: + rows = [ + trade(1, 100, 10.0, 200, 12.0, qty=0.25, + entry_signal="Grid_L35"), + trade(2, 100, 10.0, 200, 12.0, qty=0.75, + entry_signal="Grid_L35"), + trade(3, 100, 10.0, 200, 12.0, qty=1.0, + entry_signal="Grid_L36"), + ] + + self.assertEqual( + distinct_entry_fill_keys(rows), {(100, 10.0, "long")}) + merged = consolidate_fragments(rows) + self.assertEqual(len(merged), 2) + self.assertEqual([trade.entry_signal for trade in merged], + ["Grid_L35", "Grid_L36"]) + self.assertEqual([trade.qty for trade in merged], [1.0, 1.0]) + + def test_mixed_signal_identity_cannot_false_certify_one_engine_row(self) -> None: + tv_rows = [ + trade(1, 100, 10.0, 200, 12.0, qty=0.25, + entry_signal="Grid_L35"), + trade(2, 100, 10.0, 200, 12.0, qty=0.75, + entry_signal="Grid_L35"), + trade(3, 100, 10.0, 200, 12.0, qty=1.0, + entry_signal="Grid_L36"), + ] + engine_rows = [trade(1, 100, 10.0, 200, 12.0, qty=2.0)] + + preserve = distinct_entry_fill_keys(tv_rows) + tv_consolidated = consolidate_fragments( + tv_rows, preserve_entry_keys=preserve) + engine_consolidated = consolidate_fragments( + engine_rows, preserve_entry_keys=preserve) + + self.assertEqual(len(tv_consolidated), 2) + self.assertEqual(len(engine_consolidated), 1) + self.assertEqual( + distinct_entry_fill_mismatches( + tv_consolidated, engine_consolidated, preserve), + 1, + ) + + def test_parser_retains_entry_side_signal(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "trades.csv" + path.write_text( + "Trade #,Type,Date and time,Signal,Price,Qty,Net PnL\n" + "1,Exit long,2025-01-01 00:00,Short,100,1,0\n" + "1,Entry long,2025-01-01 00:00," + "Close entry(s) order Short,100,1,0\n", + encoding="utf-8", + ) + + parsed = parse_trades(path, tz=timezone.utc) + + self.assertEqual(len(parsed), 1) + self.assertEqual( + parsed[0].entry_signal, "Close entry(s) order Short") + + def test_parser_retains_engine_entry_incarnation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "engine_trades.csv" + path.write_text( + "Trade #,Type,Date and time,Price,Qty,Net PnL," + "Engine entry incarnation\n" + "1,Exit long,2025-01-01 00:00,100,1,0,41\n" + "1,Entry long,2025-01-01 00:00,100,1,0,41\n", + encoding="utf-8", + ) + + parsed = parse_trades(path, tz=timezone.utc) + + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0].entry_identity, "41") + + def test_end_to_end_engine_identity_matrix(self) -> None: + tv_csv = ( + "Trade #,Type,Date and time,Signal,Price,Qty,Net PnL\n" + "1,Exit long,2025-08-15 12:45,X,100,0.5,0\n" + "1,Entry long,2025-08-15 12:45,A,100,0.5,0\n" + "2,Exit long,2025-08-15 12:45,X,100,0.5,0\n" + "2,Entry long,2025-08-15 12:45,A,100,0.5,0\n" + "3,Exit long,2025-08-15 12:45,X,100,1,0\n" + "3,Entry long,2025-08-15 12:45,B,100,1,0\n" + ) + scenarios = { + "same-incarnation": ( + "Trade #,Type,Date and time,Price,Qty,Net PnL," + "Engine entry incarnation\n" + "1,Exit long,2025-08-15 12:45,100,0.5,0,41\n" + "1,Entry long,2025-08-15 12:45,100,0.5,0,41\n" + "2,Exit long,2025-08-15 12:45,100,0.5,0,41\n" + "2,Entry long,2025-08-15 12:45,100,0.5,0,41\n", + "weak", 1, False, 1, + ), + "distinct-incarnations": ( + "Trade #,Type,Date and time,Price,Qty,Net PnL," + "Engine entry incarnation\n" + "1,Exit long,2025-08-15 12:45,100,1,0,41\n" + "1,Entry long,2025-08-15 12:45,100,1,0,41\n" + "2,Exit long,2025-08-15 12:45,100,1,0,42\n" + "2,Entry long,2025-08-15 12:45,100,1,0,42\n", + "excellent", 0, True, 0, + ), + "tolerant-distinct-incarnations": ( + "Trade #,Type,Date and time,Price,Qty,Net PnL," + "Engine entry incarnation\n" + "1,Exit long,2025-08-15 12:45,100,1,0,41\n" + "1,Entry long,2025-08-15 12:45,100.000001,1,0,41\n" + "2,Exit long,2025-08-15 12:45,100,1,0,42\n" + "2,Entry long,2025-08-15 12:45,100.000001,1,0,42\n", + "excellent", 0, True, 0, + ), + "missing-identity": ( + "Trade #,Type,Date and time,Price,Qty,Net PnL\n" + "1,Exit long,2025-08-15 12:45,100,1,0\n" + "1,Entry long,2025-08-15 12:45,100,1,0\n" + "2,Exit long,2025-08-15 12:45,100,1,0\n" + "2,Entry long,2025-08-15 12:45,100,1,0\n", + "strong", 0, False, 1, + ), + } + + for name, ( + engine_csv, expected_label, expected_count_delta, + expected_identity_ok, expected_identity_delta, + ) in scenarios.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + strategy = Path(tmp) / name + strategy.mkdir() + (strategy / "inputs.json").write_text( + json.dumps({"tv_trades_csv_tz": "utc"}), + encoding="utf-8", + ) + (strategy / "tv_trades.csv").write_text( + tv_csv, encoding="utf-8") + (strategy / "engine_trades.csv").write_text( + engine_csv, encoding="utf-8") + + result = analyze_strategy(strategy) + + self.assertEqual(result.label, expected_label) + self.assertEqual(result.count_abs_delta, expected_count_delta) + self.assertEqual( + result.distinct_entry_identity_ok, expected_identity_ok) + self.assertEqual( + result.distinct_entry_mismatches, expected_identity_delta) + + def test_identity_collision_outside_declared_interior_is_excluded( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + strategy = Path(tmp) / "outside-interior" + strategy.mkdir() + (strategy / "inputs.json").write_text( + json.dumps({"tv_trades_csv_tz": "utc", "trim_bars": 1}), + encoding="utf-8", + ) + (strategy / "ohlcv.csv").write_text( + "timestamp,open\n" + "1735689600,1\n" + "1735693200,1\n" + "1735696800,1\n" + "1735700400,1\n" + "1735704000,1\n", + encoding="utf-8", + ) + (strategy / "tv_trades.csv").write_text( + "Trade #,Type,Date and time,Signal,Price,Qty,Net PnL\n" + "1,Exit long,2025-01-01 00:00,X,100,0.5,0\n" + "1,Entry long,2025-01-01 00:00,A,100,0.5,0\n" + "2,Exit long,2025-01-01 00:00,X,100,0.5,0\n" + "2,Entry long,2025-01-01 00:00,A,100,0.5,0\n" + "3,Exit long,2025-01-01 00:00,X,100,1,0\n" + "3,Entry long,2025-01-01 00:00,B,100,1,0\n", + encoding="utf-8", + ) + (strategy / "engine_trades.csv").write_text( + "Trade #,Type,Date and time,Price,Qty,Net PnL\n" + "1,Exit long,2025-01-01 00:00,100,1,0\n" + "1,Entry long,2025-01-01 00:00,100,1,0\n" + "2,Exit long,2025-01-01 00:00,100,1,0\n" + "2,Entry long,2025-01-01 00:00,100,1,0\n", + encoding="utf-8", + ) + + result = analyze_strategy(strategy) + + self.assertIsNotNone(result.bounds) + self.assertEqual(result.tv_gate_count, 0) + self.assertEqual(result.eng_gate_count, 0) + self.assertTrue(result.distinct_entry_identity_ok) + self.assertEqual(result.distinct_entry_mismatches, 0) + self.assertEqual(result.label, "excellent") + + def test_identity_gate_blocks_false_excellent_direction_substitution( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + strategy = Path(tmp) / "identity-substitution" + strategy.mkdir() + (strategy / "inputs.json").write_text( + json.dumps({"tv_trades_csv_tz": "utc"}), + encoding="utf-8", + ) + (strategy / "tv_trades.csv").write_text( + "Trade #,Type,Date and time,Signal,Price,Qty,Net PnL\n" + "1,Exit long,2025-08-15 12:45,Short,100,1,0\n" + "1,Entry long,2025-08-15 12:45,Long,100,1,0\n" + "2,Exit long,2025-08-15 12:45,Short,100,1,0\n" + "2,Entry long,2025-08-15 12:45," + "Close entry(s) order Short,100,1,0\n", + encoding="utf-8", + ) + (strategy / "engine_trades.csv").write_text( + "Trade #,Type,Date and time,Price,Qty,Net PnL\n" + "1,Exit long,2025-08-15 12:45,100,1,0\n" + "1,Entry long,2025-08-15 12:45,100,1,0\n" + "2,Exit short,2025-08-15 12:45,100,1,0\n" + "2,Entry short,2025-08-15 12:45,100,1,0\n", + encoding="utf-8", + ) + + result = analyze_strategy(strategy) + + self.assertEqual(result.tv_count, 2) + self.assertEqual(result.eng_count, 2) + self.assertEqual(result.count_abs_delta, 0) + self.assertEqual(result.unmatched_in_window, 1) + self.assertTrue(result.coverage_ok) + self.assertFalse(result.distinct_entry_identity_ok) + self.assertEqual(result.distinct_entry_mismatches, 1) + self.assertEqual(result.label, "strong") + + class FragmentedFifoEligibilityTests(unittest.TestCase): def test_true_fifo_requires_split_entry_and_cross_entry_exit(self) -> None: rows = [ diff --git a/scripts/verify_corpus.py b/scripts/verify_corpus.py index db2e1ce..aaa3ad2 100755 --- a/scripts/verify_corpus.py +++ b/scripts/verify_corpus.py @@ -32,6 +32,8 @@ import csv import re import sys +from collections import Counter +from collections.abc import Iterable from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta from pathlib import Path @@ -261,6 +263,19 @@ class TradePair: qty: float pnl: float trade_num: int = 0 + # TradingView's entry-side ``Signal`` identifies the broker instruction + # that opened this physical trade. It is intentionally report/oracle + # provenance rather than an alignment key: engine CSVs historically omit + # it, but distinct non-empty TV signals prove that equal-time/equal-price + # rows are independent entries and must not be fragment-consolidated. + entry_signal: str = "" + # Engine-only physical-entry provenance. New PineForge runners export the + # unique PendingOrder incarnation that created the lot; every partial-close + # fragment of that lot retains the same value. This must not reuse Signal: + # TradingView's Signal column can contain a user-visible comment rather + # than the Pine entry ID. Empty means the engine artifact cannot prove + # physical multiplicity at a TV-proven collision key. + entry_identity: str = "" # Report-only fields (compared but NOT gated โ€” see the field-coverage # disclosure). pnl_pct is in percent. mfe/mae are TOTAL USD excursions # ((price diff) * qty, summed over pyramid entries) in TV's export @@ -314,6 +329,8 @@ class VerificationResult: exit_ok: bool = False pnl_ok: bool = False coverage_ok: bool = False + distinct_entry_identity_ok: bool = True + distinct_entry_mismatches: int = 0 unmatched_in_window: int = 0 entry_p100: float = 0.0 exit_p100: float = 0.0 @@ -347,6 +364,8 @@ def report_row(self) -> dict: "entry_p90": self.entry_p90, "exit_p90": self.exit_p90, "pnl_p90": self.pnl_p90, + "distinct_entry_identity_ok": self.distinct_entry_identity_ok, + "distinct_entry_mismatches": self.distinct_entry_mismatches, "profile": self.profile, "notes": self.notes, } @@ -468,6 +487,14 @@ def _ccy_col(row: dict, *prefixes: str): if kind.startswith("Entry"): r["entry_time"] = parse_dt(time_field, tz) r["entry_price"] = price + r["entry_signal"] = str(row.get("Signal") or "").strip() + raw_identity = str( + row.get("Engine entry incarnation") + or row.get("Engine entry ID") + or "" + ).strip() + r["entry_identity"] = ( + raw_identity if raw_identity not in {"", "0"} else "") else: r["exit_time"] = parse_dt(time_field, tz) r["exit_price"] = price @@ -486,6 +513,8 @@ def _ccy_col(row: dict, *prefixes: str): qty=r["qty"], pnl=r["pnl"], trade_num=n, + entry_signal=r.get("entry_signal", ""), + entry_identity=r.get("entry_identity", ""), pnl_pct=r.get("pnl_pct", 0.0), mfe=r.get("mfe", 0.0), mae=r.get("mae", 0.0), @@ -494,7 +523,144 @@ def _ccy_col(row: dict, *prefixes: str): return pairs -def consolidate_fragments(pairs: list[TradePair]) -> list[TradePair]: +EntryFillKey = tuple[int, float, str] + + +def distinct_entry_fill_keys(pairs: list[TradePair]) -> set[EntryFillKey]: + """Return entry keys proven to contain multiple physical broker entries. + + TradingView can report independent trades at the same timestamp, price, + and direction. The entry-side ``Signal`` is the only exported provenance + that separates those instructions. Two or more distinct non-empty + signals therefore prove at least two physical entries even when one signal + also owns quantity/FIFO fragments. Callers project the TV-derived keys onto + the engine side, which must supply independent physical-incarnation + provenance rather than relying on raw row count. + """ + signals_by_key: dict[EntryFillKey, list[str]] = {} + for trade in pairs: + key = (trade.entry_time, trade.entry_price, trade.direction) + signals_by_key.setdefault(key, []).append(trade.entry_signal) + return { + key + for key, signals in signals_by_key.items() + if len({signal for signal in signals if signal}) > 1 + } + + +def _project_entry_fill_keys( + observed_keys: Iterable[EntryFillKey], + proven_keys: set[EntryFillKey] | frozenset[EntryFillKey], +) -> tuple[ + dict[EntryFillKey, EntryFillKey], + dict[EntryFillKey, set[EntryFillKey]], +]: + """Project observed entry-price keys onto unambiguous TV oracle keys. + + Time and direction remain exact. Price alone may drift by less than the + strict entry-parity tolerance, using the same :func:`relative_max` metric as + the headline gate. Exact price equality wins. A non-exact observed key is + projected only when exactly one TV key in its time/direction scope is in + tolerance; overlapping tolerance neighborhoods are returned as ambiguous + so callers can fail closed instead of choosing a nearest key or allowing + one engine row to satisfy multiple distinct TV prices. + """ + proven_by_scope: dict[tuple[int, str], list[EntryFillKey]] = {} + for key in proven_keys: + proven_by_scope.setdefault((key[0], key[2]), []).append(key) + + projected: dict[EntryFillKey, EntryFillKey] = {} + ambiguous: dict[EntryFillKey, set[EntryFillKey]] = {} + for observed in set(observed_keys): + candidates = proven_by_scope.get((observed[0], observed[2]), []) + exact = next( + (candidate for candidate in candidates + if candidate[1] == observed[1]), + None, + ) + if exact is not None: + projected[observed] = exact + continue + tolerant = { + candidate for candidate in candidates + if relative_max(candidate[1], observed[1]) < STRICT_ENTRY_DELTA + } + if len(tolerant) == 1: + projected[observed] = next(iter(tolerant)) + elif len(tolerant) > 1: + ambiguous[observed] = tolerant + return projected, ambiguous + + +def distinct_entry_fill_mismatches( + tv: list[TradePair], + eng: list[TradePair], + proven_keys: set[EntryFillKey] | frozenset[EntryFillKey], +) -> int: + """Count physical-entry identity failures at TV-proven distinct keys. + + TV's distinct non-empty Signals prove separate report buckets. Engine row + count is not evidence because one entry may fragment into several + closed-trade rows. Excellent certification therefore requires every engine + row projected to the key to carry a non-empty physical incarnation and + requires its distinct incarnation count to equal the oracle's provable + Signal buckets. Projection keeps time/direction exact and permits only an + unambiguous entry-price drift inside the strict parity tolerance. + This is intentionally conservative when repeated TV Signals are ambiguous: + it may refuse Excellent, but it cannot certify multiplicity from row count. + Missing identity or an engine price inside multiple TV-key tolerance + neighborhoods fails the affected keys closed even when raw row counts + happen to match. + """ + tv_signals: dict[EntryFillKey, set[str]] = {} + engine_identities: dict[EntryFillKey, set[str]] = {} + engine_rows: dict[EntryFillKey, list[TradePair]] = {} + for trade in tv: + key = (trade.entry_time, trade.entry_price, trade.direction) + if trade.entry_signal: + tv_signals.setdefault(key, set()).add(trade.entry_signal) + observed_engine_rows: dict[EntryFillKey, list[TradePair]] = {} + for trade in eng: + key = (trade.entry_time, trade.entry_price, trade.direction) + observed_engine_rows.setdefault(key, []).append(trade) + + projected, ambiguous = _project_entry_fill_keys( + observed_engine_rows, proven_keys) + ambiguous_proven_keys = { + candidate + for candidates in ambiguous.values() + for candidate in candidates + } + for observed, rows in observed_engine_rows.items(): + key = projected.get(observed) + if key is None: + continue + engine_rows.setdefault(key, []).extend(rows) + engine_identities.setdefault(key, set()).update( + trade.entry_identity for trade in rows if trade.entry_identity) + + mismatches = 0 + for key in proven_keys: + expected = len(tv_signals.get(key, set())) + rows = engine_rows.get(key, []) + if expected < 2: + continue + if key in ambiguous_proven_keys: + mismatches += 1 + continue + if not rows or any(not trade.entry_identity for trade in rows): + mismatches += 1 + continue + mismatches += abs(expected - len(engine_identities.get(key, set()))) + return mismatches + + +def consolidate_fragments( + pairs: list[TradePair], + *, + preserve_entry_keys: set[EntryFillKey] | frozenset[EntryFillKey] = frozenset(), + identity_field: str = "entry_signal", +) -> list[TradePair]: """Reunite the fragment rows that split a single logical fill into one trade. TradingView's "List of Trades" (and the engine, mirroring it) splits one @@ -513,14 +679,20 @@ def consolidate_fragments(pairs: list[TradePair]) -> list[TradePair]: SYMMETRICALLY to the TV and engine lists, so a genuinely fragmented strategy still pairs 1:1. - Merge key = ``(entry_time, entry_price, direction)`` compared EXACTLY: two - rows merge iff they share the same bar, the same fill price (read from the - identical CSV cell, hence bit-identical within one file) and the same side - โ€” i.e. they are the same fill event. Two *distinct* trades can never - collide, because a second independent entry must occur on a different bar - or at a different fill price (a different grid level), either of which - changes the key. For an un-fragmented strategy every group has size 1, so - this is a strict no-op and the reference corpus is left byte-identical. + Merge key = ``(entry_time, entry_price, direction)`` compared EXACTLY. + That key is sufficient only when every member has the same entry + provenance. TradingView can execute independent instructions at one + broker tick and price; two or more distinct non-empty entry Signals prove + that the key contains multiple physical entries. For TV rows, repeated + members of the same signal are consolidated within that signal only. + Engine rows use the separate ``entry_identity`` incarnation field for the + same operation. A row missing the appropriate identity cannot be assigned + safely to a bucket, so the TV-proven key stays raw and the identity gate + fails closed. Engine keys may project onto one TV key when time/direction + match exactly and price drift is unambiguously inside the strict entry + tolerance. Overlapping price neighborhoods remain identity-sensitive but + fail the gate as ambiguous. This can retain extra fragment detail, but it + cannot merge independent entries into a false Excellent result. The merged trade keeps the shared entry (time + price) and direction, sums the per-lot qty / pnl / excursions, and represents the exit by the lots' @@ -546,8 +718,8 @@ def consolidate_fragments(pairs: list[TradePair]) -> list[TradePair]: >>> len(g), g[0].qty, g[0].exit_price, g[0].exit_time (1, 1.0, 13.0, 250) """ - groups: dict[tuple[int, float, str], list[TradePair]] = {} - order: list[tuple[int, float, str]] = [] + groups: dict[EntryFillKey, list[TradePair]] = {} + order: list[EntryFillKey] = [] for t in pairs: key = (t.entry_time, t.entry_price, t.direction) if key not in groups: @@ -555,9 +727,34 @@ def consolidate_fragments(pairs: list[TradePair]) -> list[TradePair]: order.append(key) groups[key].append(t) + preserved = set(preserve_entry_keys) + preserved.update(distinct_entry_fill_keys(pairs)) + projected, ambiguous = _project_entry_fill_keys(groups, preserved) + identity_sensitive_keys = set(projected) | set(ambiguous) out: list[TradePair] = [] for key in order: members = groups[key] + if key in identity_sensitive_keys: + identities = [ + getattr(member, identity_field) for member in members] + if all(identities): + signal_groups: dict[str, list[TradePair]] = {} + signal_order: list[str] = [] + for member, identity in zip(members, identities): + if identity not in signal_groups: + signal_groups[identity] = [] + signal_order.append(identity) + signal_groups[identity].append(member) + for signal in signal_order: + bucket = signal_groups[signal] + if len(bucket) == 1: + out.append(bucket[0]) + else: + out.extend(consolidate_fragments( + bucket, identity_field=identity_field)) + else: + out.extend(members) + continue if len(members) == 1: out.append(members[0]) continue @@ -583,6 +780,8 @@ def consolidate_fragments(pairs: list[TradePair]) -> list[TradePair]: qty=qty, pnl=sum(m.pnl for m in members), trade_num=min(m.trade_num for m in members), + entry_signal=rep.entry_signal, + entry_identity=rep.entry_identity, pnl_pct=sum(m.pnl_pct * m.qty for m in members) / denom, mfe=sum(m.mfe for m in members), mae=sum(m.mae for m in members), @@ -836,8 +1035,12 @@ def analyze_strategy(strategy_dir: Path) -> VerificationResult: # FIFO partial-close lots of one fill) into a single logical trade BEFORE # pairing, symmetrically on both sides, so the entry-time matcher does not # cross-pair same-entry lots. No-op for un-fragmented strategies. - tv = consolidate_fragments(tv) - eng = consolidate_fragments(eng) + distinct_tv_entry_keys = distinct_entry_fill_keys(tv_raw_all) + tv = consolidate_fragments( + tv, preserve_entry_keys=distinct_tv_entry_keys) + eng = consolidate_fragments( + eng, preserve_entry_keys=distinct_tv_entry_keys, + identity_field="entry_identity") matched = align_by_time(tv, eng) tv_cmp, eng_cmp = trim_to_common_match_window(tv, eng, matched) matched = align_by_time(tv_cmp, eng_cmp) @@ -894,6 +1097,9 @@ def _split_interior(pairs: list[tuple[TradePair, TradePair]]) -> list[tuple[Trad count_abs_delta = abs(len(tv_gate) - len(eng_gate)) count_delta = relative_max(len(tv_gate), len(eng_gate)) + distinct_entry_mismatches = distinct_entry_fill_mismatches( + tv_gate, eng_gate, distinct_tv_entry_keys) + distinct_entry_identity_ok = distinct_entry_mismatches == 0 entry_deltas = [relative_max(t.entry_price, e.entry_price) for t, e in gating_matched] exit_deltas = [relative_max(t.exit_price, e.exit_price) for t, e in gating_matched] # PnL p90 uses *relative-to-tv_pnl*, with near-zero trades excluded so @@ -1028,7 +1234,14 @@ def _is_dropped_open(t): cov_strong = coverage >= COVERAGE_STRONG or unmatched_total <= 1 cov_moderate = coverage >= COVERAGE_MODERATE - all_ok = count_ok and entry_ok and exit_ok and pnl_ok and cov_excellent + all_ok = ( + count_ok + and entry_ok + and exit_ok + and pnl_ok + and cov_excellent + and distinct_entry_identity_ok + ) if all_ok: label = "excellent" elif ( @@ -1066,6 +1279,10 @@ def _is_dropped_open(t): failures.append(f"pnl p90 {pnl_p90*100:.4f}%") if not cov_excellent: failures.append(f"coverage {coverage*100:.1f}%") + if not distinct_entry_identity_ok: + failures.append( + "distinct-entry multiplicity ฮ” " + f"{distinct_entry_mismatches}") notes = "; ".join(failures) if failures else "non-excellent" return VerificationResult( @@ -1100,6 +1317,8 @@ def _is_dropped_open(t): exit_ok=exit_ok, pnl_ok=pnl_ok, coverage_ok=cov_excellent, + distinct_entry_identity_ok=distinct_entry_identity_ok, + distinct_entry_mismatches=distinct_entry_mismatches, unmatched_in_window=unmatched_in_window, entry_p100=entry_p100, exit_p100=exit_p100, diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 5fd1312..35e6139 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -7,8 +7,9 @@ * and the internal C++ types they mirror. If any of these trip, * the C ABI has drifted from the internal representation โ€” fix * BEFORE shipping a .so that consumers depend on. - * - The runtime-library-side `extern "C"` symbols (the setters, - * strategy_get_last_error, the strategy_stream_* lifecycle, + * - The runtime-library-side `extern "C"` symbols (the closed-trade + * incarnation accessor, setters, strategy_get_last_error, + * the strategy_stream_* lifecycle, * pf_version_get/pf_version_string, pf_abi_version โ€” 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, @@ -138,7 +139,7 @@ static_assert(static_cast(pineforge::MagnifierDistribution::BACK_LOADED) = /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * Runtime-library-side extern "C" implementations. * - * These live in libruntime.a (statically linked into every compiled + * These live in libpineforge.a (statically linked into every compiled * strategy .so), so consumers find them via dlsym on any .so. The * other extern "C" symbols listed in pineforge.h are emitted PER * STRATEGY by the codegen (in emit_top.py::_emit_extern_c). @@ -146,6 +147,14 @@ static_assert(static_cast(pineforge::MagnifierDistribution::BACK_LOADED) = extern "C" { +PF_API uint64_t strategy_closed_trade_entry_incarnation( + pf_strategy_t s, int trade_index) { + if (!s) return 0; + const auto* engine = static_cast(s); + if (trade_index < 0 || trade_index >= engine->trade_count()) return 0; + return engine->get_trade(trade_index).entry_incarnation; +} + /* Toggle per-bar trace recording on a live strategy. Default off; the * harness flips it on per-strategy via this entry point before running * a backtest whose per-bar values it wants to cross-reference against diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 3d666f1..f17a7d9 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -11,6 +11,14 @@ #include #include +#ifndef PINEFORGE_SHORT_SEED_COLLISION_MATERIALIZE_LONG +#define PINEFORGE_SHORT_SEED_COLLISION_MATERIALIZE_LONG 1 +#endif + +#ifndef PINEFORGE_SHORT_SEED_COLLISION_FINAL_SHORT_CLOSE_ONLY +#define PINEFORGE_SHORT_SEED_COLLISION_FINAL_SHORT_CLOSE_ONLY 1 +#endif + namespace pineforge { using namespace internal; @@ -1488,6 +1496,11 @@ void BacktestEngine::finalize_pending_flat_market_pairs(const Bar& bar) { // share that same fill point; other priced orders evaluate later on the // synthetic OHLC path. This avoids broad type-based reordering. void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { + // Roles are derived from the complete live book at each broker boundary; + // never let a partially surviving or carried object retain the transaction. + for (PendingOrder& order : pending_orders_) { + order.short_seed_collision_role = ShortSeedCollisionRole::NONE; + } if (pending_orders_.size() < 2) return; // nothing to order; skips stable_sort's temp-buffer alloc // Validate pair links before stable_sort starts moving elements. Scanning @@ -1623,6 +1636,195 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { } } } + + // Raw-TV-faithful SHORT-seed transaction. The ordinary comparator already + // produces the observed broker order: + // + // Long -> __close__Short -> Short + // + // Do not reorder it. Tag only the exact authoritative three-object book so + // the close kernel can materialize TV's second LONG lot and the final Short + // kernel can close both LONG lots to flat. The long-seed mirror is + // deliberately unproven and remains ordinary. + if (!close_entries_rule_any_ + && !process_orders_on_close_ + && !calc_on_order_fills_ + && !coof_scheduler_active_ + && !bar_magnifier_enabled_ + && !stream_warmup_mode_ + && stream_phase_ == StreamPhase::IDLE + && risk_direction_ == RiskDirection::BOTH + && risk_max_cons_loss_days_ == 0 + && risk_max_drawdown_ <= 0.0 + && risk_max_intraday_loss_ <= 0.0 + && risk_max_position_size_ <= 0.0 + && max_intraday_filled_orders_ <= 0 + && !risk_halted_ + && default_qty_type_ == QtyType::FIXED + && pyramiding_ == 1 + && pending_orders_.size() == 3 + && position_side_ == PositionSide::SHORT + && position_entry_count_ == 1 + && position_cycle_seq_ > 0 + && pyramid_entries_.size() == 1) { + PendingOrder* source[3] = { + &pending_orders_[0], &pending_orders_[1], &pending_orders_[2]}; + std::sort( + source, source + 3, + [](const PendingOrder* lhs, const PendingOrder* rhs) { + return lhs->created_seq < rhs->created_seq; + }); + + const int source_bar = source[0]->created_bar; + const auto fresh_plain_object = [&](const PendingOrder& order) { + return order.created_bar == source_bar + && source_bar + 1 == bar_index_ + && order.created_position_side == PositionSide::SHORT + && !order.created_by_same_id_replacement + && order.replaced_exit_order_incarnation == 0 + && order.recreated_after_named_cancelled_entry_incarnation == 0 + && order.named_cancel_surviving_exit_incarnation == 0 + && !order.created_during_coof_recalc + && !order.coof_born_at_close_recalc + && !order.coof_born_mid_bar + && order.oca_name.empty() + && order.oca_type == 0; + }; + const auto pure_default_market_entry = [&](const PendingOrder& order) { + return order.type == OrderType::MARKET + && std::isnan(order.qty) + && order.qty_type == -1 + && std::isnan(order.frozen_default_qty) + && std::isnan(order.limit_price) + && std::isnan(order.stop_price) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && std::isnan(order.profit_ticks) + && std::isnan(order.loss_ticks) + && !order.created_after_position_close_in_bar + && !order.created_while_in_position; + }; + const auto exact_full_fifo_close_short = + [&](const PendingOrder& order, const std::string& held_id) { + return order.type == OrderType::EXIT + && order.id == "__close__" + held_id + && order.from_entry.empty() + && !order.is_long + && order.created_while_in_position + && !order.requested_partial + && std::isnan(order.qty) + && std::abs(order.qty_percent - 100.0) <= kFullPercentEps + && std::isnan(order.limit_price) + && std::isnan(order.stop_price) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && std::isnan(order.profit_ticks) + && std::isnan(order.loss_ticks) + && !order.pooc_global_full_exit_dynamic_qty + && !order.pooc_global_full_exit_tracks_bound_adds + && !order.suppress_as_declined_reversal_close + && std::isfinite(order.suppressed_close_consumed_ledger_qty) + && order.suppressed_close_consumed_ledger_qty > kQtyEpsilon; + }; + + const PyramidEntry& seed = pyramid_entries_.front(); + const double default_qty = apply_qty_step(default_qty_value_); + // The middle EXIT materializes a second LONG before the final Short is + // processed. That final order still traverses the ordinary fixed- + // default fill-time admission gate before its close-only kernel. Do + // not tag the transaction unless the projected two-lot state makes + // that admission provably non-rejecting; otherwise the middle leg could + // create synthetic exposure and the final leg could be declined with + // no rollback. At zero cost and 100% margins, marked equity is + // invariant across the two same-open predecessors, so this mirrors the + // later gate using the exact projected held and transaction quantities. + const auto projected_final_short_admission_is_safe = [&]() { + if (!std::isfinite(bar.open) || bar.open <= 0.0 + || std::abs(margin_long_ - 100.0) >= 1e-12 + || std::abs(margin_short_ - 100.0) >= 1e-12) { + return false; + } + const double admit_price = + apply_fill_slippage(bar.open, /*is_buy=*/false); + const double final_short_qty = std::abs(calc_qty_for_type( + admit_price, source[1]->qty, source[1]->qty_type)); + const double marked_equity = current_equity() + open_profit(bar.open); + const double fx = active_account_currency_fx(); + const double notional_k = syminfo_.pointvalue * fx; + const double projected_long_qty = 2.0 * seed.qty; + const double projected_held_margin = + projected_long_qty * bar.open * notional_k; + const double projected_free_funds = + marked_equity - projected_held_margin; + const double projected_transaction_qty = + projected_long_qty + final_short_qty; + const double projected_required_margin = + projected_transaction_qty * admit_price * notional_k; + const double epsilon = + std::max(1e-9, std::abs(marked_equity) * 1e-12); + return std::isfinite(admit_price) && admit_price > 0.0 + && std::isfinite(final_short_qty) + && final_short_qty > kQtyEpsilon + && std::isfinite(marked_equity) + && std::isfinite(notional_k) && notional_k > 0.0 + && std::isfinite(projected_free_funds) + && std::isfinite(projected_required_margin) + && projected_required_margin + <= projected_free_funds + epsilon; + }; + const bool exact_book = + source_bar >= 0 + // The authoritative six-strategy cohort uses TradingView's + // zero-cost broker model. The physical second-LONG transaction + // has not been established for slipped or commissioned fills, so + // keep those configurations on the ordinary broker path. + && slippage_ == 0 + && commission_value_ == 0.0 + && last_rejected_strategy_entry_call_bar_ != source_bar + && source[0]->created_seq + 1 == source[1]->created_seq + && source[1]->created_seq + 1 == source[2]->created_seq + && source[0]->incarnation + 1 == source[1]->incarnation + && source[1]->incarnation + 1 == source[2]->incarnation + && fresh_plain_object(*source[0]) + && fresh_plain_object(*source[1]) + && fresh_plain_object(*source[2]) + && pure_default_market_entry(*source[0]) + && pure_default_market_entry(*source[1]) + && !source[0]->id.empty() + && source[0]->is_long + && !source[0]->over_pyramiding_cap_at_placement + && !source[1]->id.empty() + && source[0]->id != source[1]->id + && source[0]->id != source[2]->id + && !source[1]->is_long + && source[1]->over_pyramiding_cap_at_placement + && source[0]->created_position_cycle_seq == position_cycle_seq_ + && source[1]->created_position_cycle_seq == position_cycle_seq_ + && std::abs(source[0]->tv_carry_qty - seed.qty) <= kQtyEpsilon + && std::abs(source[1]->tv_carry_qty - seed.qty) <= kQtyEpsilon + && exact_full_fifo_close_short(*source[2], source[1]->id) + && seed.entry_id == source[1]->id + && seed.entry_bar_index < bar_index_ + && seed.qty > kQtyEpsilon + && std::isfinite(default_qty) + && std::abs(default_qty - seed.qty) <= kQtyEpsilon + && std::abs(position_qty_ - seed.qty) <= kQtyEpsilon + && std::abs(source[2]->tv_carry_qty - seed.qty) <= kQtyEpsilon + && std::abs( + source[2]->suppressed_close_consumed_ledger_qty - seed.qty) + <= kQtyEpsilon + && projected_final_short_admission_is_safe(); + if (exact_book) { + source[0]->short_seed_collision_role = + ShortSeedCollisionRole::LONG_ENTRY; + source[1]->short_seed_collision_role = + ShortSeedCollisionRole::FINAL_SHORT; + source[2]->short_seed_collision_role = + ShortSeedCollisionRole::MATERIALIZE_LONG; + } + } std::stable_sort(pending_orders_.begin(), pending_orders_.end(), [&](const PendingOrder& a, const PendingOrder& b) { auto fill_phase = [&](const PendingOrder& o) { @@ -1797,6 +1999,117 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { }); } +bool BacktestEngine::short_seed_collision_materialization_is_live( + const PendingOrder& order) const { + if (!PINEFORGE_SHORT_SEED_COLLISION_MATERIALIZE_LONG + || order.short_seed_collision_role + != ShortSeedCollisionRole::MATERIALIZE_LONG + || order.type != OrderType::EXIT + || order.created_bar + 1 != bar_index_ + || position_side_ != PositionSide::LONG + || position_open_bar_ != bar_index_ + || position_entry_count_ != 1 + || pyramid_entries_.size() != 1 + || !std::isfinite(order.suppressed_close_consumed_ledger_qty)) { + return false; + } + + const PendingOrder* long_entry = nullptr; + const PendingOrder* final_short = nullptr; + int long_roles = 0; + int materialize_roles = 0; + int final_short_roles = 0; + for (const PendingOrder& pending : pending_orders_) { + switch (pending.short_seed_collision_role) { + case ShortSeedCollisionRole::LONG_ENTRY: + ++long_roles; + long_entry = &pending; + break; + case ShortSeedCollisionRole::MATERIALIZE_LONG: + ++materialize_roles; + break; + case ShortSeedCollisionRole::FINAL_SHORT: + ++final_short_roles; + final_short = &pending; + break; + case ShortSeedCollisionRole::NONE: + break; + } + } + if (long_roles != 1 || materialize_roles != 1 || final_short_roles != 1 + || long_entry == nullptr || final_short == nullptr + || long_entry->id.empty() || final_short->id.empty() + || order.id != "__close__" + final_short->id) { + return false; + } + + const PyramidEntry& long_lot = pyramid_entries_.front(); + const double qty = order.suppressed_close_consumed_ledger_qty; + return long_lot.entry_id == long_entry->id + && long_lot.entry_bar_index == bar_index_ + && long_lot.qty > kQtyEpsilon + && std::abs(long_lot.qty - qty) <= kQtyEpsilon + && std::abs(position_qty_ - qty) <= kQtyEpsilon; +} + +bool BacktestEngine::short_seed_collision_final_short_is_live( + const PendingOrder& order) const { + if (!PINEFORGE_SHORT_SEED_COLLISION_FINAL_SHORT_CLOSE_ONLY + || order.short_seed_collision_role != ShortSeedCollisionRole::FINAL_SHORT + || order.type != OrderType::MARKET + || order.is_long + || order.created_bar + 1 != bar_index_ + || position_side_ != PositionSide::LONG + || position_open_bar_ != bar_index_ + || position_entry_count_ != 2 + || pyramid_entries_.size() != 2 + || order.tv_carry_qty <= kQtyEpsilon) { + return false; + } + + const PendingOrder* long_entry = nullptr; + const PendingOrder* materialize_long = nullptr; + int long_roles = 0; + int materialize_roles = 0; + int final_short_roles = 0; + for (const PendingOrder& pending : pending_orders_) { + switch (pending.short_seed_collision_role) { + case ShortSeedCollisionRole::LONG_ENTRY: + ++long_roles; + long_entry = &pending; + break; + case ShortSeedCollisionRole::MATERIALIZE_LONG: + ++materialize_roles; + materialize_long = &pending; + break; + case ShortSeedCollisionRole::FINAL_SHORT: + ++final_short_roles; + break; + case ShortSeedCollisionRole::NONE: + break; + } + } + if (long_roles != 1 || materialize_roles != 1 || final_short_roles != 1 + || long_entry == nullptr || materialize_long == nullptr + || order.id.empty() || long_entry->id.empty() + || materialize_long->id != "__close__" + order.id) { + return false; + } + + const PyramidEntry& source_long = pyramid_entries_[0]; + const PyramidEntry& close_short_long = pyramid_entries_[1]; + const double qty = order.tv_carry_qty; + return source_long.entry_id == long_entry->id + && close_short_long.entry_id == materialize_long->id + && source_long.entry_bar_index == bar_index_ + && close_short_long.entry_bar_index == bar_index_ + && std::abs(source_long.qty - qty) <= kQtyEpsilon + && std::abs(close_short_long.qty - qty) <= kQtyEpsilon + && std::abs(position_qty_ - 2.0 * qty) <= kQtyEpsilon + && std::abs(source_long.price - close_short_long.price) + <= std::max(1e-12, std::abs(source_long.price) * 1e-12); +} + // A strategy.exit can be armed on the signal bar together with the MARKET // strategy.entry named by from_entry. The child is valid before the parent // fills: TradingView binds it to the eventual lot, and if the next open has @@ -3151,6 +3464,17 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr const Bar& bar, double& trail_best_path_state, bool later_same_tick_entry) { + // The final Short in the exact SHORT-seed collision is the broker + // transaction that closes both physical LONG lots. It does not open a + // residual short. Re-prove the complete two-lot state here so any rejected, + // partial, or otherwise interrupted predecessor falls back to the ordinary + // strategy.entry kernel. + if (short_seed_collision_final_short_is_live(order)) { + execute_market_exit(fill_price); + trail_best_path_state = trail_best_price_; + return; + } + // A default-sized market order carries a quantity frozen at the signal // bar's close; hand it through as fixed contracts (qty_type < 0) so the // fill does not re-derive it from the fill price. Explicit-qty and @@ -3172,7 +3496,8 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr order.created_bar, later_same_tick_entry, /*paired_flat_market_transaction=*/paired_flat_market, /*explicit_qty_prequantized=*/ - (frozen || paired_flat_market)); + (frozen || paired_flat_market), + order.incarnation); double trail_best_after_fill = trail_best_price_; // Set entry comment on the just-created pyramid entry if (!pyramid_entries_.empty() @@ -3296,7 +3621,8 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri /*later_same_tick_entry=*/false, /*paired_flat_market_transaction=*/false, /*explicit_qty_prequantized=*/ - use_placement_open_qty); + use_placement_open_qty, + order.incarnation); bool did_execute = (position_side_ != side_before) @@ -3366,6 +3692,36 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric int& exit_closed_from_bar, uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long) { + // In the raw TV tape, the exact default-FIFO close-Short object between the + // Long and final Short transactions materializes a second physical LONG + // lot. It is not an exit from the freshly opened Long. This bypasses the + // pyramiding cap only for the pre-tagged, re-proven transaction. + if (short_seed_collision_materialization_is_live(order)) { + const double qty = order.suppressed_close_consumed_ledger_qty; + const double entry_fill = apply_fill_slippage(fill_price, /*is_buy=*/true); + if (!std::isfinite(entry_fill) || qty <= kQtyEpsilon) return; + + const double total_qty = position_qty_ + qty; + position_entry_price_ = + (position_entry_price_ * position_qty_ + entry_fill * qty) + / total_qty; + position_qty_ = total_qty; + ++position_entry_count_; + trail_best_price_ = entry_fill; + PyramidEntry materialized{}; + materialized.price = entry_fill; + materialized.time = current_bar_.timestamp; + materialized.qty = qty; + materialized.entry_id = order.id; + materialized.entry_bar_index = bar_index_; + materialized.entry_comment = order.comment; + materialized.entry_incarnation = order.incarnation; + snapshot_entry_commission(materialized); + pyramid_entries_.push_back(std::move(materialized)); + id_unclosed_qty_[order.id] += qty; + return; + } + double qp = std::isnan(order.qty_percent) ? 100.0 : std::clamp(order.qty_percent, 0.0, 100.0); const bool dynamic_full_live_qty = order.pooc_global_full_exit_dynamic_qty; @@ -3526,6 +3882,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price pyramid_entries_.clear(); id_unclosed_qty_.clear(); pyramid_entries_.push_back({fill_price, current_bar_.timestamp, qty, order.id, bar_index_}); + pyramid_entries_.back().entry_incarnation = order.incarnation; snapshot_entry_commission(pyramid_entries_.back()); id_unclosed_qty_[order.id] += qty; if (!std::isnan(order.stop_price) || !std::isnan(order.limit_price)) { @@ -3578,6 +3935,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price position_entry_count_++; trail_best_price_ = fill_price; pyramid_entries_.push_back({fill_price, current_bar_.timestamp, new_qty, order.id, bar_index_}); + pyramid_entries_.back().entry_incarnation = order.incarnation; snapshot_entry_commission(pyramid_entries_.back()); // KI-62: flag same-direction MARKET adds (strategy.order path) so a // same-bar from_entry bracket exit can scratch them dur-0. @@ -3714,6 +4072,8 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( } bool exit_style = order_is_exit_style(order, position_side_); + const bool short_seed_materializes_long = + short_seed_collision_materialization_is_live(order); // The close cursor is a single broker point. A fill-triggered script // execution at C may create orders, but those orders cannot consume C a @@ -3740,7 +4100,8 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( && order.created_while_in_position && order.id.rfind("__close__", 0) == 0 && position_side_ != PositionSide::FLAT - && position_open_bar_ > order.created_bar; + && position_open_bar_ > order.created_bar + && !short_seed_materializes_long; if (stale_close_order_for_new_position) { return OrderEligibility::Remove; } @@ -3975,7 +4336,8 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( // close. Under calc_on_order_fills, a post-fill execution can // legitimately create this close and the monotonic scheduler owns // its same-bar eligibility. - if (!(calc_on_order_fills_ && coof_scheduler_active_)) { + if (!(calc_on_order_fills_ && coof_scheduler_active_) + && !short_seed_materializes_long) { return OrderEligibility::Skip; } } diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 2c9aa16..1c21050 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -72,7 +72,8 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d int created_bar, bool later_same_tick_entry, bool paired_flat_market_transaction, - bool explicit_qty_prequantized) { + bool explicit_qty_prequantized, + uint64_t entry_incarnation) { // Degenerate-bar guard: never open a position at a non-finite fill price // (e.g. a NaN/Inf print). Dropping the fill keeps trade output finite and // a single bad tick from poisoning the backtest. Clean feeds never hit this. @@ -104,13 +105,15 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d created_bar, /*explicit_qty_prequantized=*/ (explicit_qty_prequantized - || paired_flat_market_transaction)); + || paired_flat_market_transaction), + entry_incarnation); return; } if (position_side_ == requested) { add_to_pyramid_market(id, is_long, fill_price, explicit_qty, explicit_qty_type, - created_position_side, is_priced_entry); + created_position_side, is_priced_entry, + entry_incarnation); return; } @@ -120,13 +123,15 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d /*purge_pending_exits=*/!paired_flat_market_transaction, /*explicit_qty_prequantized=*/ (explicit_qty_prequantized - || paired_flat_market_transaction)); + || paired_flat_market_transaction), + entry_incarnation); return; } if (later_same_tick_entry) { sequential_same_tick_reversal_fill(id, is_long, fill_price, explicit_qty, - explicit_qty_type); + explicit_qty_type, + entry_incarnation); return; } @@ -137,7 +142,8 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d // transaction whose whole broker movement is consumed by the close. Both // close the live opposite position without opening their own leg. flip_market_position_to(id, is_long, fill_price, explicit_qty, explicit_qty_type, - /*close_only=*/close_only_opposite); + /*close_only=*/close_only_opposite, + entry_incarnation); } @@ -470,6 +476,7 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, trade.entry_bar_index = pe.entry_bar_index; trade.exit_bar_index = bar_index_; trade.entry_id = pe.entry_id; + trade.entry_incarnation = pe.entry_incarnation; trade.entry_comment = pe.entry_comment; trade.commission = entry_commission + exit_commission; // Excursions: TV's per-trade excursion includes the exit fill itself โ€” @@ -615,7 +622,8 @@ void BacktestEngine::settle_position_after_partial_exit() { // pyramid entry. Used by every entry path that opens a brand-new position // (FLAT entry, close-only-opposite remainder, opposite flip). void BacktestEngine::open_fresh_position(PositionSide requested, double fill_price, - double qty, const std::string& id) { + double qty, const std::string& id, + uint64_t entry_incarnation) { position_side_ = requested; position_cycle_seq_ = next_position_cycle_seq_++; position_entry_price_ = fill_price; @@ -640,6 +648,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri callsite_close_two_call_first_qty_.clear(); consumed_partial_exit_ids_.clear(); pyramid_entries_.push_back({fill_price, current_bar_.timestamp, qty, id, bar_index_}); + pyramid_entries_.back().entry_incarnation = entry_incarnation; snapshot_entry_commission(pyramid_entries_.back()); id_unclosed_qty_[id] += qty; } @@ -735,7 +744,8 @@ void BacktestEngine::enter_market_from_flat(const std::string& id, bool is_long, PositionSide created_position_side, bool is_priced_entry, double tv_carry_qty, int created_bar, - bool explicit_qty_prequantized) { + bool explicit_qty_prequantized, + uint64_t entry_incarnation) { bool carry_was_long = (created_position_side == PositionSide::LONG); bool tv_deferred_flip = script_has_strategy_close_ @@ -763,7 +773,7 @@ void BacktestEngine::enter_market_from_flat(const std::string& id, bool is_long, // the gap-reject gate in apply_filled_order_to_state, upstream of this // helper โ€” a dropped order never reaches enter_market_from_flat. PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; - open_fresh_position(requested, fill_price, qty, id); + open_fresh_position(requested, fill_price, qty, id, entry_incarnation); } @@ -784,7 +794,8 @@ void BacktestEngine::add_to_pyramid_market(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, PositionSide created_position_side, - bool is_priced_entry) { + bool is_priced_entry, + uint64_t entry_incarnation) { PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; bool flat_armed_priced = is_priced_entry && created_position_side == PositionSide::FLAT; @@ -803,6 +814,7 @@ void BacktestEngine::add_to_pyramid_market(const std::string& id, bool is_long, position_entry_count_++; trail_best_price_ = fill_price; pyramid_entries_.push_back({fill_price, current_bar_.timestamp, new_qty, id, bar_index_}); + pyramid_entries_.back().entry_incarnation = entry_incarnation; snapshot_entry_commission(pyramid_entries_.back()); // KI-62: only a same-direction MARKET add is scratched by a same-bar // from_entry bracket exit; a priced pyramid add is not this collision. @@ -819,7 +831,8 @@ void BacktestEngine::close_opposite_then_enter(const std::string& id, bool is_lo double fill_price, double explicit_qty, int explicit_qty_type, bool purge_pending_exits, - bool explicit_qty_prequantized) { + bool explicit_qty_prequantized, + uint64_t entry_incarnation) { double tx_qty = explicit_qty_prequantized ? explicit_qty : calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type); @@ -840,7 +853,8 @@ void BacktestEngine::close_opposite_then_enter(const std::string& id, bool is_lo } fill_price = apply_fill_slippage(fill_price, is_long); PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; - open_fresh_position(requested, fill_price, remainder, id); + open_fresh_position( + requested, fill_price, remainder, id, entry_incarnation); } @@ -867,7 +881,8 @@ void BacktestEngine::close_opposite_then_enter(const std::string& id, bool is_lo void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, - bool close_only) { + bool close_only, + uint64_t entry_incarnation) { // For the close we need exit slippage based on closing direction. // Closing a long = sell (price - slip); closing a short = buy (price + slip). // fill_price already has entry slippage applied; un-slip it before @@ -905,7 +920,7 @@ void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long double new_qty = calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type); PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; - open_fresh_position(requested, fill_price, new_qty, id); + open_fresh_position(requested, fill_price, new_qty, id, entry_incarnation); } @@ -955,7 +970,8 @@ void BacktestEngine::sequential_same_tick_reversal_fill(const std::string& id, bool is_long, double fill_price, double explicit_qty, - int explicit_qty_type) { + int explicit_qty_type, + uint64_t entry_incarnation) { // fill_price arrives entry-slipped (execute_market_entry applied entry // slippage). The close leg needs EXIT slippage for the closing // direction โ€” un-slip, then re-apply, mirroring flip_market_position_to. @@ -986,7 +1002,8 @@ void BacktestEngine::sequential_same_tick_reversal_fill(const std::string& id, double remainder = tx_qty - old_qty; if (remainder > kQtyEpsilon) { PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; - open_fresh_position(requested, fill_price, remainder, id); + open_fresh_position( + requested, fill_price, remainder, id, entry_incarnation); } } diff --git a/src/engine_run.cpp b/src/engine_run.cpp index dad3445..bef015f 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -579,6 +579,11 @@ void BacktestEngine::reset_run_state() { reset_position_state_to_flat(); // position_side_/qty/price/time/count, // pyramid_entries_, trail, partial ids pending_orders_.clear(); + // PendingOrder incarnations are report provenance scoped to one run. + // Resetting keeps a reused handle byte/identity-equivalent to a fresh + // handle while preserving the invariant that zero means unavailable. + next_order_incarnation_ = 1; + last_rejected_strategy_entry_call_bar_ = -1; pending_flat_market_pair_disqualified_bars_.clear(); default_flat_market_gross_disqualified_bars_.clear(); named_entry_cancelled_incarnation_in_current_eval_.clear(); diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 3e9203a..cc33144 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -114,7 +114,10 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // bar 04-06 23:45 with arm_long=true while the cap had already // latched on 04-06 07:00, then carrying to fire on the new chart- // day before the script's else-branch could cancel it). - if (_intraday_cap_currently_latched()) return; + if (_intraday_cap_currently_latched()) { + last_rejected_strategy_entry_call_bar_ = bar_index_; + return; + } // KI-65 MARKET/MARKET follow-up. Every explicit MARKET call first receives // the established OWN-qty admission below. Eligible own-admitted calls are @@ -227,6 +230,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, double available_equity = current_equity(); double epsilon = std::max(1e-9, std::abs(available_equity) * 1e-12); if (required_margin > available_equity + epsilon) { + last_rejected_strategy_entry_call_bar_ = bar_index_; // A rejected third call leaves no PendingOrder or incarnation, // but it still means the source body was not the clean exact // two-call terminal-C shape. Preserve that invisible history. @@ -303,7 +307,10 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, && position_side_ == (is_long ? PositionSide::LONG : PositionSide::SHORT) && position_entry_count_ >= pyramiding_; bool is_priced_entry = !std::isnan(limit_price) || !std::isnan(stop_price); - if (is_priced_entry && !process_orders_on_close_ && over_pyramiding_cap) return; + if (is_priced_entry && !process_orders_on_close_ && over_pyramiding_cap) { + last_rejected_strategy_entry_call_bar_ = bar_index_; + return; + } PendingOrder order; order.id = id; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6c151c6..8986c0d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -110,6 +110,7 @@ set(TEST_SOURCES test_prearmed_exit_path_cursor test_prearmed_market_parent_gap_exit test_relative_exit_after_limit_parent + test_short_seed_close_collision ) find_package(Threads REQUIRED) @@ -139,6 +140,12 @@ add_test( ${PROJECT_SOURCE_DIR}/scripts/test_verify_corpus_metrics.py ) +add_test( + NAME test_run_strategy_identity + COMMAND ${Python3_EXECUTABLE} + ${PROJECT_SOURCE_DIR}/scripts/test_run_strategy_identity.py +) + # When PINEFORGE_ENABLE_COVERAGE is ON we also instrument the test # binaries โ€” header-only code (Series, na, color, log, math::pine_random) # is otherwise reported as 0% because it's only inlined into test TUs. diff --git a/tests/test_c_abi_setters.cpp b/tests/test_c_abi_setters.cpp index 34e0e6b..a567f67 100644 --- a/tests/test_c_abi_setters.cpp +++ b/tests/test_c_abi_setters.cpp @@ -3,7 +3,8 @@ * entry points in src/c_abi.cpp (lines ~119-205) that the existing * tests/test_c_abi.c does NOT touch: * - * strategy_set_trace_enabled, strategy_get_last_error, + * strategy_closed_trade_entry_incarnation, strategy_set_trace_enabled, + * strategy_get_last_error, * strategy_set_trade_start_time, strategy_set_chart_timezone, * strategy_set_syminfo_timezone / _session / _mintick / _pointvalue / * _metadata / _account_currency_fx_series, and pf_version_string. @@ -85,6 +86,11 @@ class ProbeEngine : public pineforge::BacktestEngine { // get_syminfo_metadata() is protected on BacktestEngine; expose it via // the subclass. It returns na() (NaN) for keys never injected. double meta(const std::string& key) const { return get_syminfo_metadata(key); } + void append_trade(uint64_t entry_incarnation) { + pineforge::Trade trade{}; + trade.entry_incarnation = entry_incarnation; + trades_.push_back(trade); + } }; } @@ -115,6 +121,7 @@ int main() { CHECK(strategy_stream_end(nullptr, 0) == -1); CHECK(strategy_stream_fill_report(nullptr, nullptr) == -1); CHECK(strategy_get_last_error(nullptr) == nullptr); + CHECK(strategy_closed_trade_entry_incarnation(nullptr, 0) == 0); // Secondary `|| !arg` guard arms (valid handle, NULL arg) โ€” these // must early-out too, leaving engine state untouched. @@ -138,6 +145,17 @@ int main() { CHECK(err != nullptr); CHECK(err[0] == '\0'); + // Closed-trade physical identity is runtime-owned, bounds-checked, and + // preserves zero for legacy/synthetic rows without order provenance. + CHECK(strategy_closed_trade_entry_incarnation(h, -1) == 0); + CHECK(strategy_closed_trade_entry_incarnation(h, 0) == 0); + eng.append_trade(0); + eng.append_trade(UINT64_C(0x123456789abcdef0)); + CHECK(strategy_closed_trade_entry_incarnation(h, 0) == 0); + CHECK(strategy_closed_trade_entry_incarnation(h, 1) + == UINT64_C(0x123456789abcdef0)); + CHECK(strategy_closed_trade_entry_incarnation(h, 2) == 0); + // trace_enabled: default false โ†’ 1/non-zero maps to true, 0 to false. CHECK(eng.trace_on() == false); strategy_set_trace_enabled(h, 1); diff --git a/tests/test_handle_reuse_reset.cpp b/tests/test_handle_reuse_reset.cpp index e4d8bd1..16e222a 100644 --- a/tests/test_handle_reuse_reset.cpp +++ b/tests/test_handle_reuse_reset.cpp @@ -94,6 +94,7 @@ bool trades_equal(const BacktestEngine& a, const BacktestEngine& b) { if (x.qty != y.qty || x.pnl != y.pnl) return false; if (x.is_long != y.is_long) return false; if (x.entry_id != y.entry_id) return false; + if (x.entry_incarnation != y.entry_incarnation) return false; } return true; } diff --git a/tests/test_short_seed_close_collision.cpp b/tests/test_short_seed_close_collision.cpp new file mode 100644 index 0000000..1d797e9 --- /dev/null +++ b/tests/test_short_seed_close_collision.cpp @@ -0,0 +1,752 @@ +/* + * Regression coverage for the raw-TV SHORT-seed default-FIFO close collision. + */ + +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int g_pass = 0; +static int g_fail = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + ++g_fail; \ + } else { \ + ++g_pass; \ + } \ + } while (0) + +namespace { + +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +Bar flat_bar(int64_t timestamp) { + return {100.0, 101.0, 99.0, 100.0, 1'000.0, timestamp}; +} + +class SourceOrderChain final : public BacktestEngine { +public: + explicit SourceOrderChain(bool source_long) : source_long_(source_long) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + slippage_ = 0; + commission_value_ = 0.0; + } + + void on_bar(const Bar&) override { + const std::string held = source_long_ ? "Long" : "Short"; + const std::string opposite = source_long_ ? "Short" : "Long"; + if (bar_index_ == 0) { + strategy_entry(held, source_long_); + } else if (bar_index_ == 1) { + CHECK(position_side_ == + (source_long_ ? PositionSide::LONG : PositionSide::SHORT)); + CHECK(pyramid_entries_.size() == 1); + CHECK(pyramid_entries_[0].entry_id == held); + + // The first close has no live default-FIFO id ledger and therefore + // queues no broker object. The surviving book is exactly: + // opposite entry -> held-side entry -> close(held). + strategy_entry(opposite, !source_long_); + strategy_entry(held, source_long_); + strategy_close(opposite); + strategy_close(held); + + queued_ids_.clear(); + queued_types_.clear(); + for (const PendingOrder& order : pending_orders_) { + queued_ids_.push_back(order.id); + queued_types_.push_back(order.type); + } + } + } + + PositionSide final_side() const { return position_side_; } + double final_qty() const { return signed_position_size(); } + const std::vector& queued_ids() const { return queued_ids_; } + const std::vector& queued_types() const { return queued_types_; } + uint64_t reported_entry_incarnation(int index) const { + return closed_trade_entry_incarnation(index); + } + +private: + bool source_long_; + std::vector queued_ids_; + std::vector queued_types_; +}; + +class SameDirectionCloseControl final : public BacktestEngine { +public: + explicit SameDirectionCloseControl(bool source_long) + : source_long_(source_long) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + // One live lot leaves admission headroom for the co-queued add. + pyramiding_ = 2; + slippage_ = 0; + commission_value_ = 0.0; + } + + void on_bar(const Bar&) override { + const std::string held = source_long_ ? "Long" : "Short"; + if (bar_index_ == 0) { + strategy_entry(held, source_long_); + } else if (bar_index_ == 1) { + strategy_entry(held, source_long_); + strategy_close(held); + queued_count_ = pending_orders_.size(); + } + } + + PositionSide final_side() const { return position_side_; } + double final_qty() const { return signed_position_size(); } + std::size_t queued_count() const { return queued_count_; } + +private: + bool source_long_; + std::size_t queued_count_ = 0; +}; + +enum class RejectedLeg { FirstOpposite, SecondHeld }; + +class RejectionControl final : public BacktestEngine { +public: + explicit RejectionControl(RejectedLeg rejected_leg) + : rejected_leg_(rejected_leg) { + initial_capital_ = 10'000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + pyramiding_ = 1; + margin_call_enabled_ = false; + syminfo_mintick_ = 0.01; + commission_value_ = 0.0; + slippage_ = 0; + // At the +1 fill gap, 100% margin declines the all-in reversal. + // Giving the first SHORT leg 50% margin admits only that leg, so the + // second LONG leg faces the intended decline independently. + if (rejected_leg_ == RejectedLeg::SecondHeld) { + margin_short_ = 50.0; + } + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Long", true); + } else if (bar_index_ == 1) { + strategy_entry("Short", false); + strategy_entry("Long", true); + strategy_close("Short"); // no live default-FIFO ledger + strategy_close("Long"); + queued_count_ = pending_orders_.size(); + } + } + + PositionSide final_side() const { return position_side_; } + double final_qty() const { return signed_position_size(); } + std::size_t queued_count() const { return queued_count_; } + +private: + RejectedLeg rejected_leg_; + std::size_t queued_count_ = 0; +}; + +void run_source_order_chain(bool source_long) { + SourceOrderChain probe(source_long); + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + const std::string held = source_long ? "Long" : "Short"; + const std::string opposite = source_long ? "Short" : "Long"; + const std::vector expected_ids = { + opposite, held, "__close__" + held}; + const std::vector expected_types = { + OrderType::MARKET, OrderType::MARKET, OrderType::EXIT}; + CHECK(probe.queued_ids() == expected_ids); + CHECK(probe.queued_types() == expected_types); + if (!source_long) { + // Authoritative SHORT-seed tape: the ordinary broker order is + // Long -> __close__Short -> Short. The middle object materializes a + // second LONG lot; final Short closes both LONG lots and leaves flat. + CHECK(probe.final_side() == PositionSide::FLAT); + CHECK(std::fabs(probe.final_qty()) < 1e-9); + CHECK(probe.trade_count() == 3); + if (probe.trade_count() == 3) { + const Trade& seed = probe.get_trade(0); + const Trade& first_long = probe.get_trade(1); + const Trade& close_short_long = probe.get_trade(2); + CHECK(!seed.is_long); + CHECK(seed.entry_id == "Short"); + CHECK(seed.exit_id == "Long"); + CHECK(seed.entry_time == 1'200'000); + CHECK(seed.exit_time == 1'800'000); + CHECK(std::fabs(seed.entry_price - 100.0) < 1e-9); + CHECK(std::fabs(seed.exit_price - 100.0) < 1e-9); + CHECK(std::fabs(seed.pnl) < 1e-9); + CHECK(std::fabs(seed.commission) < 1e-9); + CHECK(seed.entry_incarnation != 0); + CHECK(first_long.is_long); + CHECK(first_long.entry_id == "Long"); + CHECK(first_long.exit_id == "Short"); + CHECK(first_long.entry_time == 1'800'000); + CHECK(first_long.exit_time == 1'800'000); + CHECK(std::fabs(first_long.entry_price - 100.0) < 1e-9); + CHECK(std::fabs(first_long.exit_price - 100.0) < 1e-9); + CHECK(std::fabs(first_long.pnl) < 1e-9); + CHECK(std::fabs(first_long.commission) < 1e-9); + CHECK(first_long.entry_incarnation != 0); + CHECK(close_short_long.is_long); + CHECK(close_short_long.entry_id == "__close__Short"); + CHECK(close_short_long.exit_id == "Short"); + CHECK(close_short_long.entry_time == 1'800'000); + CHECK(close_short_long.exit_time == 1'800'000); + CHECK(std::fabs(close_short_long.entry_price - 100.0) < 1e-9); + CHECK(std::fabs(close_short_long.exit_price - 100.0) < 1e-9); + CHECK(std::fabs(close_short_long.pnl) < 1e-9); + CHECK(std::fabs(close_short_long.commission) < 1e-9); + CHECK(close_short_long.entry_incarnation != 0); + CHECK(first_long.entry_incarnation + != close_short_long.entry_incarnation); + CHECK(probe.reported_entry_incarnation(1) + == first_long.entry_incarnation); + CHECK(probe.reported_entry_incarnation(2) + == close_short_long.entry_incarnation); + CHECK(first_long.entry_bar_index == first_long.exit_bar_index); + CHECK(close_short_long.entry_bar_index + == close_short_long.exit_bar_index); + CHECK(std::fabs(first_long.qty - 1.0) < 1e-9); + CHECK(std::fabs(close_short_long.qty - 1.0) < 1e-9); + } + } else { + // No long-seed mirror is established. Keep the baseline source-order + // lifecycle: Short reversal, stale close removal, Long reversal. + CHECK(probe.final_side() == PositionSide::LONG); + CHECK(std::fabs(probe.final_qty() - 1.0) < 1e-9); + CHECK(probe.trade_count() == 2); + if (probe.trade_count() == 2) { + const Trade& seed = probe.get_trade(0); + const Trade& short_lot = probe.get_trade(1); + CHECK(seed.is_long); + CHECK(seed.entry_id == "Long"); + CHECK(seed.exit_id == "Short"); + CHECK(!short_lot.is_long); + CHECK(short_lot.entry_id == "Short"); + CHECK(short_lot.exit_id == "Long"); + } + } +} + +void run_same_direction_close_control(bool source_long) { + SameDirectionCloseControl probe(source_long); + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + CHECK(probe.queued_count() == 2); + CHECK(probe.final_side() == + (source_long ? PositionSide::LONG : PositionSide::SHORT)); + CHECK(std::fabs(std::fabs(probe.final_qty()) - 1.0) < 1e-9); + CHECK(probe.trade_count() == 1); +} + +void run_rejection_control(RejectedLeg rejected_leg) { + RejectionControl probe(rejected_leg); + Bar bars[] = { + {100.0, 100.0, 100.0, 100.0, 1'000.0, 600'000}, + {100.0, 112.0, 99.0, 110.0, 1'000.0, 1'200'000}, + {111.0, 112.0, 110.0, 111.0, 1'000.0, 1'800'000}, + {111.0, 111.0, 111.0, 111.0, 1'000.0, 2'400'000}, + }; + probe.run(bars, 4); + + CHECK(probe.queued_count() == 3); + if (rejected_leg == RejectedLeg::FirstOpposite) { + // The first reversal decline leaves the seed LONG in place. The + // second same-side attempt cannot add an all-in lot, and the paired + // close is atomically suppressed by the existing decline rule. + CHECK(probe.final_side() == PositionSide::LONG); + CHECK(std::fabs(probe.final_qty() - 100.0) < 1e-9); + CHECK(probe.trade_count() == 0); + } else { + // The 50%-margin SHORT reversal fills, but the second 100%-margin LONG + // reversal declines at the same +1 gap. Since the side never returns + // to the close's creation side, the exact-close bypass must stay off. + CHECK(probe.final_side() == PositionSide::SHORT); + CHECK(std::fabs(probe.final_qty() + 100.0) < 1e-9); + CHECK(probe.trade_count() == 1); + } +} + +void run_empty_held_id_fail_closed(bool source_long) { + class Probe final : public BacktestEngine { + public: + explicit Probe(bool source_long) : source_long_(source_long) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + commission_value_ = 0.0; + slippage_ = 0; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("", source_long_); + } else if (bar_index_ == 1) { + strategy_entry("Opposite", !source_long_); + strategy_entry("", source_long_); + strategy_close("Opposite"); + strategy_close(""); // close_all, never close(held-id) + queued_count_ = pending_orders_.size(); + } + } + PositionSide final_side() const { return position_side_; } + std::size_t queued_count() const { return queued_count_; } + private: + bool source_long_; + std::size_t queued_count_ = 0; + }; + + Probe probe(source_long); + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + CHECK(probe.queued_count() == 3); + CHECK(probe.final_side() == + (source_long ? PositionSide::LONG : PositionSide::SHORT)); + CHECK(probe.trade_count() == 2); +} + +void run_mismatched_reentry_qty_fail_closed(bool source_long) { + class Probe final : public BacktestEngine { + public: + explicit Probe(bool source_long) : source_long_(source_long) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + commission_value_ = 0.0; + slippage_ = 0; + } + void on_bar(const Bar&) override { + const std::string held = source_long_ ? "Long" : "Short"; + const std::string opposite = source_long_ ? "Short" : "Long"; + if (bar_index_ == 0) { + strategy_entry(held, source_long_, kNaN, kNaN, 1.0); + } else if (bar_index_ == 1) { + strategy_entry(opposite, !source_long_, kNaN, kNaN, 1.0); + strategy_entry(held, source_long_, kNaN, kNaN, 2.0); + strategy_close(opposite); + strategy_close(held); + queued_count_ = pending_orders_.size(); + } + } + PositionSide final_side() const { return position_side_; } + double final_qty() const { return signed_position_size(); } + std::size_t queued_count() const { return queued_count_; } + private: + bool source_long_; + std::size_t queued_count_ = 0; + }; + + Probe probe(source_long); + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + CHECK(probe.queued_count() == 3); + CHECK(probe.final_side() == + (source_long ? PositionSide::LONG : PositionSide::SHORT)); + CHECK(std::fabs(std::fabs(probe.final_qty()) - 2.0) < 1e-9); + CHECK(probe.trade_count() == 2); +} + +class StructuralIdProbe final : public BacktestEngine { +public: + StructuralIdProbe() { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + commission_value_ = 0.0; + slippage_ = 0; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("S", false); + } else if (bar_index_ == 1) { + strategy_entry("L", true); + strategy_entry("S", false); + strategy_close("L"); + strategy_close("S"); + } + } + + PositionSide final_side() const { return position_side_; } +}; + +void run_structural_id_control() { + StructuralIdProbe probe; + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + CHECK(probe.final_side() == PositionSide::FLAT); + CHECK(probe.trade_count() == 3); + if (probe.trade_count() == 3) { + CHECK(!probe.get_trade(0).is_long); + CHECK(probe.get_trade(0).entry_id == "S"); + CHECK(probe.get_trade(0).exit_id == "L"); + CHECK(probe.get_trade(1).is_long); + CHECK(probe.get_trade(1).entry_id == "L"); + CHECK(probe.get_trade(1).exit_id == "S"); + CHECK(probe.get_trade(2).is_long); + CHECK(probe.get_trade(2).entry_id == "__close__S"); + CHECK(probe.get_trade(2).exit_id == "S"); + } +} + +void run_projected_final_admission_fail_closed() { + class Probe final : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 3.0; + pyramiding_ = 1; + margin_long_ = 100.0; + margin_short_ = 100.0; + commission_value_ = 0.0; + slippage_ = 0; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Short", false); + } else if (bar_index_ == 1) { + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_close("Long"); // no live default-FIFO ledger + strategy_close("Short"); + } + } + + PositionSide final_side() const { return position_side_; } + double final_qty() const { return signed_position_size(); } + bool has_open_materialized_lot() const { + for (const PyramidEntry& entry : pyramid_entries_) { + if (entry.entry_id == "__close__Short") return true; + } + return false; + } + }; + + Probe probe; + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + // Materializing Long3 would leave Long6 before the final Short. Its normal + // admission requires $900 against only $400 of projected free funds, so + // the special transaction must fail closed to the ordinary Short3 result. + CHECK(probe.final_side() == PositionSide::SHORT); + CHECK(std::fabs(probe.final_qty() + 3.0) < 1e-9); + CHECK(probe.trade_count() == 2); + CHECK(!probe.has_open_materialized_lot()); + for (int i = 0; i < probe.trade_count(); ++i) { + CHECK(probe.get_trade(i).entry_id != "__close__Short"); + } +} + +void run_partial_close_fragments_share_entry_incarnation() { + class Probe final : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + pyramiding_ = 1; + commission_value_ = 0.0; + slippage_ = 0; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true); + } else if (bar_index_ == 1) { + strategy_close("L", "half", 1.0); + } else if (bar_index_ == 2) { + strategy_close("L"); + } + } + }; + + Probe probe; + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + CHECK(probe.trade_count() == 2); + if (probe.trade_count() == 2) { + const Trade& first = probe.get_trade(0); + const Trade& second = probe.get_trade(1); + CHECK(first.entry_incarnation != 0); + CHECK(second.entry_incarnation == first.entry_incarnation); + CHECK(std::fabs(first.qty - 1.0) < 1e-9); + CHECK(std::fabs(second.qty - 1.0) < 1e-9); + } +} + +void run_internal_close_id_collision_fail_closed() { + class Probe final : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + commission_value_ = 0.0; + slippage_ = 0; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Short", false); + } else if (bar_index_ == 1) { + // A user entry may legally occupy the engine's synthesized + // close-id namespace. It must not become indistinguishable + // from the physical close transaction. + strategy_entry("__close__Short", true); + strategy_entry("Short", false); + strategy_close("__close__Short"); + strategy_close("Short"); + } + } + + PositionSide final_side() const { return position_side_; } + double final_qty() const { return signed_position_size(); } + }; + + Probe probe; + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + probe.run(bars, 4); + + CHECK(probe.final_side() == PositionSide::SHORT); + CHECK(std::fabs(probe.final_qty() + 1.0) < 1e-9); + CHECK(probe.trade_count() == 2); +} + +enum class GateControl { + AnyCloseRule, + ProcessOnClose, + CalcOnFills, + Magnifier, + ExtraObject, + RejectedExtraCall, + PartialClose, + PricedEntry, + SameIdReplacement, + NonconsecutiveSequence, + NonzeroSlippage, + NonzeroCommission, +}; + +class GateControlProbe final : public BacktestEngine { +public: + explicit GateControlProbe(GateControl control) : control_(control) { + initial_capital_ = 1'000'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + commission_value_ = 0.0; + slippage_ = 0; + switch (control_) { + case GateControl::AnyCloseRule: + close_entries_rule_any_ = true; + break; + case GateControl::ProcessOnClose: + process_orders_on_close_ = true; + break; + case GateControl::CalcOnFills: + calc_on_order_fills_ = true; + break; + case GateControl::Magnifier: + bar_magnifier_enabled_ = true; + break; + case GateControl::NonzeroSlippage: + slippage_ = 1; + syminfo_mintick_ = 0.01; + break; + case GateControl::NonzeroCommission: + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.1; + break; + default: + break; + } + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0 && !seed_issued_) { + seed_issued_ = true; + strategy_entry("Short", false); + return; + } + if (bar_index_ != 1 || signal_issued_) return; + signal_issued_ = true; + + if (control_ == GateControl::RejectedExtraCall) { + // Signal-time margin rejection: no PendingOrder/incarnation remains, + // so the source-bar rejection tombstone is the only proof this was + // not the exact three-call book. + strategy_entry("Rejected", true, kNaN, kNaN, 1'000'000.0); + } + + strategy_entry("Long", true, + kNaN, + control_ == GateControl::PricedEntry ? 100.0 : kNaN); + + if (control_ == GateControl::SameIdReplacement) { + strategy_entry("Long", true); + } else if (control_ == GateControl::NonconsecutiveSequence) { + strategy_entry("Gap", true); + strategy_cancel("Gap"); + } + + strategy_entry("Short", false); + if (control_ == GateControl::ExtraObject) { + strategy_entry("ExtraLong", true); + } + strategy_close("Long"); + if (control_ == GateControl::PartialClose) { + strategy_close("Short", "", kNaN, 50.0); + } else { + strategy_close("Short"); + } + queued_count_ = pending_orders_.size(); + } + + std::size_t queued_count() const { return queued_count_; } + bool has_materialized_close_lot() const { + for (int i = 0; i < trade_count(); ++i) { + if (get_trade(i).entry_id == "__close__Short") return true; + } + return false; + } + +private: + GateControl control_; + bool seed_issued_ = false; + bool signal_issued_ = false; + std::size_t queued_count_ = 0; +}; + +void run_gate_control(GateControl control) { + GateControlProbe probe(control); + Bar bars[] = { + flat_bar(600'000), + flat_bar(1'200'000), + flat_bar(1'800'000), + flat_bar(2'400'000), + }; + if (control == GateControl::Magnifier) { + probe.run(bars, 4, "1", "1", /*bar_magnifier=*/true, 4, + MagnifierDistribution::ENDPOINTS); + } else { + probe.run(bars, 4); + } + + const std::size_t expected_queued = control == GateControl::ExtraObject + ? 4U + : (control == GateControl::ProcessOnClose ? 2U : 3U); + if (probe.queued_count() != expected_queued + || probe.has_materialized_close_lot()) { + std::fprintf(stderr, + "gate control %d: queued=%zu expected=%zu materialized=%d\n", + static_cast(control), probe.queued_count(), + expected_queued, + probe.has_materialized_close_lot() ? 1 : 0); + } + CHECK(probe.queued_count() == expected_queued); + CHECK(!probe.has_materialized_close_lot()); +} + +} // namespace + +int main() { + run_source_order_chain(false); + run_source_order_chain(true); + run_same_direction_close_control(false); + run_same_direction_close_control(true); + run_rejection_control(RejectedLeg::FirstOpposite); + run_rejection_control(RejectedLeg::SecondHeld); + run_empty_held_id_fail_closed(false); + run_empty_held_id_fail_closed(true); + run_mismatched_reentry_qty_fail_closed(false); + run_mismatched_reentry_qty_fail_closed(true); + run_structural_id_control(); + run_projected_final_admission_fail_closed(); + run_partial_close_fragments_share_entry_incarnation(); + run_internal_close_id_collision_fail_closed(); + run_gate_control(GateControl::AnyCloseRule); + run_gate_control(GateControl::ProcessOnClose); + run_gate_control(GateControl::CalcOnFills); + run_gate_control(GateControl::Magnifier); + run_gate_control(GateControl::ExtraObject); + run_gate_control(GateControl::RejectedExtraCall); + run_gate_control(GateControl::PartialClose); + run_gate_control(GateControl::PricedEntry); + run_gate_control(GateControl::SameIdReplacement); + run_gate_control(GateControl::NonconsecutiveSequence); + run_gate_control(GateControl::NonzeroSlippage); + run_gate_control(GateControl::NonzeroCommission); + std::printf("%d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +}