diff --git a/README.md b/README.md index 6df8ef6..e155b8a 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.** 26 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.** 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. - ๐Ÿงช **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 26-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 27-symbol C ABI. Call from C, Python, Rust, Go, Node, Julia โ€” one harness, swap strategies forever. ### See it in 30 seconds @@ -337,6 +337,7 @@ int main(void) { | `strategy_set_syminfo_mintick` | Set symbol tick size | | `strategy_set_syminfo_pointvalue` | Set symbol point value | | `strategy_set_syminfo_metadata` | Inject numeric symbol metadata | +| `strategy_set_account_currency_fx_series`| Set effective-time quote-to-account FX | | `strategy_get_last_error` | Read the latest runtime error | | `pf_version_get` | Runtime version | | `pf_abi_version` | Struct-layout version (`PF_ABI_VERSION`) | @@ -397,7 +398,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 26 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 27 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/docs/cheatsheet-runtime-and-execution.md b/docs/cheatsheet-runtime-and-execution.md index 38f1acb..7a0892c 100644 --- a/docs/cheatsheet-runtime-and-execution.md +++ b/docs/cheatsheet-runtime-and-execution.md @@ -33,6 +33,7 @@ recompiling. "Compile-time" = baked into `generated.cpp` from the Pine | `strategy_set_syminfo_timezone(s, tz)` | `UTC` | Exchange TZ โ†’ feeds `session.ismarket` / `time(session)`. **Separate slot** from chart tz. | | `strategy_set_syminfo_session(s, str)` | `24x7` | Session string e.g. `0930-1600:23456` (days 1=Sun..7=Sat). | | `strategy_set_syminfo_metadata(s, key, double)` | reads `na` | Inject a fundamental field (`shares_outstanding_total`, `target_price_*`, โ€ฆ); engine only computes โ€” data fed externally. | +| `strategy_set_account_currency_fx_series(s, timestamps, rates, n)` | scalar metadata fallback | Copy a strictly increasing effective-time quote-to-account FX curve. Selection is inclusive as-of, carries forward, and uses the scalar fallback before the first point. Curve presence selects the converted-currency broker ledger even when the effective rate equals 1. Passing `n=0` clears it. Ordinary historical, non-COOF, non-magnifier runs only; a broker-open rate change on a margin-call-enabled carried position is supported for TV-pinned 1x longs, while carried shorts and leveraged positions fail closed. | Run entry points: `run_backtest(s,bars,n,out)` (auto-detect TF, no magnifier) / `run_backtest_full(s,bars,n,input_tf,script_tf,bar_magnifier,magnifier_samples,magnifier_dist,out)`. @@ -94,7 +95,12 @@ Any non-meta key โ†’ `strategy_set_input`. Recognized: `ohlcv_csv`, `ohlcv_start_ms`, `script_tf`, `input_tf`, `chart_timezone`, and a `runtime_overrides` block: `bar_magnifier`, `magnifier_distribution`, `magnifier_samples`, `magnifier_volume_weighted`, `timezone`, `session`, -`syminfo_metadata`. `tv_trades_csv` / `tv_trades_csv_tz` are verify-only. +`syminfo_metadata`, `account_currency_fx_daily_close_json`. +The FX JSON is a non-empty `{\"YYYY-MM-DD\": close}` object. The adapter sorts +dates and makes day D's UTC close effective at D+1 00:00 UTC; the latest point +whose effective timestamp is at or before the broker event carries forward. +Duplicate dates and non-positive/non-finite rates fail closed. The Docker runner +does not support this input. `tv_trades_csv` / `tv_trades_csv_tz` are verify-only. > `engine_chart_timezone` is **NOT** a `run_strategy.py` key โ€” it's consumed > only by the private canonical validator. In this harness use `chart_timezone`. diff --git a/docs/coverage.md b/docs/coverage.md index e04eaf6..061dc9a 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 26 symbols +Every compiled PineForge strategy `.so` exports exactly the 27 symbols declared there: @@ -88,6 +88,7 @@ declared there: | `strategy_set_syminfo_mintick` | Instrument tick size (`syminfo.mintick`) | | `strategy_set_syminfo_pointvalue` | Futures $-per-point multiplier (`syminfo.pointvalue`) | | `strategy_set_syminfo_metadata` | Inject fundamental / exchange metadata by Pine member name | +| `strategy_set_account_currency_fx_series`| Effective-time quote-to-account conversion curve | | `strategy_get_last_error` | Error message from the most recent failed run | | `pf_version_get` | Runtime version (struct) | | `pf_abi_version` | Caller-allocated POD layout version | @@ -119,7 +120,7 @@ single `.hpp`): | Module | Header | Source | Pine-facing role | | ------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | The 26 documented C symbols every compiled strategy `.so` exports. | +| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | The 27 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 278e3c6..863cfc7 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 26 C symbols** and +A compiled strategy `.so` exports **exactly these 27 C symbols** and zero internal C++ symbols: | Symbol | Group | @@ -81,6 +81,7 @@ zero internal C++ symbols: | `strategy_set_syminfo_mintick` | @ref pf_config | | `strategy_set_syminfo_pointvalue` | @ref pf_config | | `strategy_set_syminfo_metadata` | @ref pf_config | +| `strategy_set_account_currency_fx_series` | @ref pf_config | | `strategy_get_last_error` | Diagnostics | | `pf_version_get` | @ref pf_version | | `pf_abi_version` | @ref pf_version | diff --git a/docs/pages/index.md b/docs/pages/index.md index f205504..dd496ae 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 **26 functions**: +The entire public surface fits in **one header** and **27 functions**: | Group | Symbols | Reference | | --- | --- | --- | | Lifecycle | `strategy_create`, `strategy_free`, `run_backtest`, `run_backtest_full`, `report_free` | @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, and numeric metadata | @ref pf_config | +| 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 26 symbols +Every PineForge-generated strategy `.so` exports exactly these 27 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 e168a21..4fb5415 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -65,6 +65,15 @@ struct PyramidEntry { // path suffix at/after this cursor on the entry bar. NaN marks every // unrouted parent class (market, stop-limit, raw order). double entry_path_position = std::numeric_limits::quiet_NaN(); + // Entry-leg commission in account currency at this slice's actual fill + // boundary. Percentage commission depends on quote->account FX, so an + // effective-time provider must not retroactively reprice this already-paid + // fee when a later rate becomes active. Closed-trade reporting deliberately + // ignores this snapshot: TV converts the complete realized trade at the + // exit-time FX rate. NaN is reserved for legacy/synthetic test injection; + // every production entry path initializes the snapshot at fill time. + double entry_commission_account = + std::numeric_limits::quiet_NaN(); }; struct Trade { @@ -430,6 +439,12 @@ struct PendingOrder { // NaN = no snapshot, no re-check. double sizing_equity = std::numeric_limits::quiet_NaN(); double sizing_price = std::numeric_limits::quiet_NaN(); + // Quote->account FX observed at the same placement boundary as the + // frozen quantity/equity/price tuple. A daily provider can roll between + // the signal bar and next-bar fill; fill admission must adjudicate the + // frozen signal snapshot, then the post-fill affordability pass applies + // the fill-time rate and emits any required broker margin trim. + double sizing_fx = std::numeric_limits::quiet_NaN(); // The bar close sizing_equity was marked at. free_funds subtracts the // margin the OPEN position ties up, and that must be marked at the same // price the equity was, or the two sides of the comparison mix a @@ -769,7 +784,7 @@ class BacktestEngine { double margin_long_ = 100.0; double margin_short_ = 100.0; - // Account-currency FX multiplier for the broker affordability gate. When a + // Account-currency FX multiplier for every quote->account money path. When a // strategy declares ``currency=currency.XXX`` differing from the symbol's // quote currency (e.g. currency.INR on a USDT-quoted perp), TradingView // denominates equity in the account currency but the position notional in @@ -777,8 +792,20 @@ class BacktestEngine { // rate before the ``required_margin <= equity`` check. The engine otherwise // assumes account == quote (FX 1.0). Injected via the syminfo metadata // channel (key "account_currency_fx"); defaults to 1.0 so every corpus - // strategy (which never sets it) is byte-identical. + // strategy (which never sets it) is byte-identical. A timestamped provider + // may override it as bars advance; the configured scalar remains the + // fallback before the provider's first effective point and across reruns. double account_currency_fx_ = 1.0; + std::vector account_currency_fx_timestamps_; + std::vector account_currency_fx_rates_; + // Per-run broker clock for timestamped FX. The epoch is the number of + // provider points effective at the current script-bar open (0 means the + // scalar fallback). Consuming an epoch even while flat prevents a later + // entry from being mistaken for a carried position when the rate has not + // changed again. + bool account_currency_fx_broker_epoch_initialized_ = false; + std::size_t account_currency_fx_broker_epoch_ = 0; + double account_currency_fx_broker_rate_ = 1.0; // TradingView force-liquidation (margin call) toggle. TV runs the broker // margin-call emulator by default, so this defaults ON to match TV. It is @@ -1169,6 +1196,11 @@ class BacktestEngine { // adverse-price liquidation; only an eligible one-shot post-fill // affordability event can trim it. void process_margin_call(const Bar& bar); + // A timestamped FX rollover is a broker-open event, not an end-of-bar + // adverse-price check. This narrow path applies only to a carried 1x long + // in ordinary historical dispatch and returns true when it emits a broker + // liquidation row. + bool process_carried_long_full_margin_fx_rollover(const Bar& bar); // --- Slippage helper --- double round_to_mintick(double price) const { @@ -1262,7 +1294,8 @@ class BacktestEngine { double calc_commission(double fill_price, double qty) const { switch (commission_type_) { case CommissionType::PERCENT: - return fill_price * qty * syminfo_.pointvalue * account_currency_fx_ + return fill_price * qty * syminfo_.pointvalue + * active_account_currency_fx() * (commission_value_ / 100.0); case CommissionType::CASH_PER_ORDER: return commission_value_; @@ -1272,6 +1305,20 @@ class BacktestEngine { return 0.0; } + // Read the paid entry fee for a still-open pyramid slice. Production entry + // paths always initialize the snapshot. The fallback keeps hand-constructed + // PyramidEntry fixtures source-compatible without weakening real lifecycle + // behavior. + double open_entry_commission(const PyramidEntry& pe) const { + return std::isfinite(pe.entry_commission_account) + ? pe.entry_commission_account + : calc_commission(pe.price, pe.qty); + } + + void snapshot_entry_commission(PyramidEntry& pe) const { + pe.entry_commission_account = calc_commission(pe.price, pe.qty); + } + // --- Position sizing helper --- // PERCENT_OF_EQUITY / CASH size a budget that is denominated in ACCOUNT // currency (equity, and a strategy.cash default_qty_value are both @@ -1356,7 +1403,7 @@ class BacktestEngine { // markedly worse, and it cut end-to-end coverage. Settle it // with a clean-room TV probe (the KI-52 method), not algebra. double equity = current_equity() + open_profit(current_bar_.close); - double cash = reserve_percent_commission(equity * (default_qty_value_ / 100.0)) / account_currency_fx_; + double cash = reserve_percent_commission(equity * (default_qty_value_ / 100.0)) / active_account_currency_fx(); // Reject (qty 0) on a non-finite / non-positive fill price โ€” a // degenerate $0/NaN print must NOT size as the raw % number. return (std::isfinite(fill_price) && fill_price > 0) @@ -1364,7 +1411,7 @@ class BacktestEngine { } case QtyType::CASH: return (std::isfinite(fill_price) && fill_price > 0) - ? apply_qty_step((default_qty_value_ / account_currency_fx_) / (fill_price * syminfo_.pointvalue)) : 0.0; + ? apply_qty_step((default_qty_value_ / active_account_currency_fx()) / (fill_price * syminfo_.pointvalue)) : 0.0; } return apply_qty_step(default_qty_value_); } @@ -1441,6 +1488,7 @@ class BacktestEngine { o.sizing_equity = current_equity() + open_profit(current_bar_.close); } + o.sizing_fx = active_account_currency_fx(); } } @@ -1564,7 +1612,7 @@ class BacktestEngine { // compared against bar.high/low), so convert back via the same // account_currency_fx_ inverse used in calc_qty; default 1.0 is a // no-op for the corpus. - const double equity_basis = (initial_capital_ + net_profit_sum_) / account_currency_fx_; + const double equity_basis = (initial_capital_ + net_profit_sum_) / active_account_currency_fx(); double liq = (equity_basis / (qty * pv) - direction * position_entry_price_) / denom; if (syminfo_mintick_ > 0.0) { @@ -1590,7 +1638,7 @@ class BacktestEngine { // Account-currency, matching emit_close_trade / open_trade_profit โ€” // callers combine this with initial_capital_ + net_profit_sum_ (both // account-currency) to get total equity. fx=1.0 is a no-op. - return diff * position_qty_ * syminfo_.pointvalue * account_currency_fx_; + return diff * position_qty_ * syminfo_.pointvalue * active_account_currency_fx(); } int count_wintrades() const { return win_trades_count_; } @@ -2365,6 +2413,8 @@ class BacktestEngine { // are set before run() and must survive it. Called at the top of every // run() loop entrypoint. See tests/test_handle_reuse_reset.cpp. void reset_run_state(); + double account_currency_fx_at(int64_t timestamp_ms) const; + double active_account_currency_fx() const; void settle_position_after_partial_exit(); void enter_market_from_flat(const std::string& id, bool is_long, double fill_price, double explicit_qty, @@ -2471,6 +2521,14 @@ class BacktestEngine { bool stream_end(bool finalize_partial_input_bar = false); bool stream_is_realtime() const { return stream_phase_ == StreamPhase::REALTIME; } + // Install an effective-time FX curve (account-currency units per one unit + // of symbol quote currency). Points are copied and must have strictly + // increasing epoch-ms timestamps plus positive finite rates. The latest + // point whose timestamp is <= the current broker event is active. Passing + // n=0 clears the curve and restores the scalar metadata fallback. + bool set_account_currency_fx_series(const int64_t* timestamps_ms, + const double* rates, int n); + void run(const Bar* input_bars, int n_input, const std::string& input_tf, const std::string& script_tf, diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index a7c4ed8..263463a 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -611,6 +611,28 @@ PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue); PF_API void strategy_set_syminfo_metadata(pf_strategy_t s, const char* key, double value); +/** Install a timestamped quote-to-account currency conversion curve. + * + * Each value is account-currency units per one unit of the symbol's quote + * currency and becomes active, inclusively, at the corresponding Unix-ms + * timestamp. The latest active value carries forward; broker events before + * the first point use the scalar `account_currency_fx` metadata fallback. + * Installing a curve also selects the converted account-currency broker + * ledger, including during that pre-first fallback interval; it is not + * equivalent to a same-currency run merely because a rate happens to be 1. + * Arrays are copied. Timestamps must be strictly increasing and rates + * positive and finite. Pass `n == 0` to clear the curve and restore scalar + * behavior. Timestamped curves currently support ordinary historical runs. + * Broker-open rate changes on margin-call-enabled carried positions are + * TV-pinned for 1x longs; carried shorts and leveraged positions fail closed + * at the crossing. Streaming, calc-on-order-fills, and bar-magnifier runs + * also fail closed. + * + * @return 0 on success, -1 for a null strategy or invalid arrays. */ +PF_API int strategy_set_account_currency_fx_series( + pf_strategy_t s, const int64_t* effective_from_ms, + const double* account_per_quote, int n); + /** Returns the error message captured by the most recent #run_backtest / * #run_backtest_full call on this strategy. * diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index 6631588..8c900b1 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -24,6 +24,7 @@ "strategy_set_syminfo_mintick", "strategy_set_syminfo_pointvalue", "strategy_set_syminfo_metadata", + "strategy_set_account_currency_fx_series", "strategy_get_last_error", "strategy_stream_begin", "strategy_stream_push_tick", diff --git a/scripts/fingerprint_self_test.py b/scripts/fingerprint_self_test.py index b0b7944..850dd6e 100644 --- a/scripts/fingerprint_self_test.py +++ b/scripts/fingerprint_self_test.py @@ -180,6 +180,87 @@ def check(name: str, cond: bool) -> None: check("run_strategy: same effective gate has deterministic digest", gated_fp["digest"] == same_gate_fp["digest"]) + # Timestamped FX provenance is pinned to the exact effective C-ABI values, + # not only to a provider-file hash, point count, or endpoints. Changing an + # interior rate must therefore change both the value digest and the final + # run fingerprint even when every summary field is otherwise identical. + fx_timestamps = [1_000, 2_000, 3_000] + fx_kwargs_a = dict(runtime_kwargs, + account_currency_fx_series=(fx_timestamps, + [1.0, 1.001, 1.002]), + account_currency_fx_source_sha256="a" * 64) + fx_kwargs_b = dict(runtime_kwargs, + account_currency_fx_series=(fx_timestamps, + [1.0, 1.0015, 1.002]), + account_currency_fx_source_sha256="a" * 64) + fx_runtime_a = rs.build_runtime_provenance(fx_kwargs_a, gate_ms) + fx_runtime_b = rs.build_runtime_provenance(fx_kwargs_b, gate_ms) + fx_meta_a = fx_runtime_a["account_currency_fx_series"] + expected_fx_hasher = hashlib.sha256() + expected_fx_hasher.update( + b"pineforge:account-currency-fx:i64-f64-le:v1\0") + fx_record = struct.Struct("/dev/null 2>&1; then + run_output="" + if run_output="$("${run_cmd[@]}" 2>&1)"; then n_ok=$((n_ok + 1)) else n_fail=$((n_fail + 1)) failed+=("$strat_dir") + warn "$strat_dir failed:" + while IFS= read -r line; do + warn " $line" + done <<< "$run_output" fi done + if (( n_selected == 0 )); then + fail "no corpus strategies matched${ONLY:+ ONLY=$ONLY}; refusing to verify stale engine_trades.csv" + fi elapsed=$(( $(date +%s) - started )) log "ran $((n_ok + n_fail)) strategies in ${elapsed}s -- ok=$n_ok fail=$n_fail" if (( n_fail > 0 )); then @@ -130,6 +159,7 @@ if [[ "${SKIP_RUN:-0}" != "1" ]]; then for f in "${failed[@]}"; do warn " $f" done + fail "$n_fail strategy run(s) failed; refusing to verify stale engine_trades.csv" fi fi diff --git a/scripts/run_corpus_self_test.py b/scripts/run_corpus_self_test.py new file mode 100644 index 0000000..1d56c58 --- /dev/null +++ b/scripts/run_corpus_self_test.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Fail-closed contract tests for scripts/run_corpus.sh.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + + +RUN_CORPUS = Path(__file__).with_name("run_corpus.sh") + + +def fixture(*, generated: bool, library: bool) -> tuple[Path, Path]: + root = Path(tempfile.mkdtemp(prefix="pf-run-corpus-self-test-")) + scripts = root / "scripts" + strategy = root / "corpus" / "validation" / "probe" + scripts.mkdir(parents=True) + strategy.mkdir(parents=True) + shutil.copy2(RUN_CORPUS, scripts / "run_corpus.sh") + (root / "corpus" / "CMakeLists.txt").write_text("# fixture\n") + (strategy / "strategy.pine").write_text("//@version=6\nstrategy('x')\n") + if generated: + (strategy / "generated.cpp").write_text("// fixture\n") + if library: + (strategy / "strategy.so").write_bytes(b"fixture") + + fake_python = scripts / "fake_python.py" + fake_python.write_text( + "#!/usr/bin/env python3\n" + "import os, sys\n" + "if len(sys.argv) > 1 and sys.argv[1].endswith('run_strategy.py') " + "and os.environ.get('PF_FAKE_RUN_FAIL') == '1':\n" + " print('synthetic runner failure', file=sys.stderr)\n" + " raise SystemExit(42)\n" + ) + fake_python.chmod(0o755) + return root, fake_python + + +def run_case(root: Path, fake_python: Path, **extra_env: str) -> subprocess.CompletedProcess: + env = os.environ.copy() + env.update({ + "PYTHON": str(fake_python), + "SKIP_BUILD": "1", + "SKIP_VERIFY": "1", + **extra_env, + }) + return subprocess.run( + [str(root / "scripts" / "run_corpus.sh")], + cwd=root, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + +def check_failure(result: subprocess.CompletedProcess, expected: str) -> None: + assert result.returncode != 0, result.stdout + assert expected in result.stdout, result.stdout + assert "regenerating canonical validation report" not in result.stdout, result.stdout + + +def main() -> int: + roots: list[Path] = [] + try: + root, py = fixture(generated=False, library=False) + roots.append(root) + check_failure(run_case(root, py), "missing generated.cpp") + + root, py = fixture(generated=True, library=False) + roots.append(root) + check_failure(run_case(root, py), "missing compiled strategy library") + + root, py = fixture(generated=True, library=True) + roots.append(root) + check_failure( + run_case(root, py, ONLY="does-not-match"), + "no corpus strategies matched ONLY=does-not-match", + ) + + root, py = fixture(generated=True, library=True) + roots.append(root) + result = run_case(root, py, PF_FAKE_RUN_FAIL="1") + check_failure(result, "synthetic runner failure") + assert "refusing to verify stale engine_trades.csv" in result.stdout + + root, py = fixture(generated=True, library=True) + roots.append(root) + result = run_case(root, py) + assert result.returncode == 0, result.stdout + assert "ran 1 strategies" in result.stdout, result.stdout + assert "done." in result.stdout, result.stdout + finally: + for root in roots: + shutil.rmtree(root, ignore_errors=True) + + print("run_corpus_self_test: 5 passed, 0 failed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index af6f3b1..55acdd5 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -44,12 +44,13 @@ import ctypes import hashlib import json +import math import os import re import struct import sys import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -317,8 +318,66 @@ def build_fingerprint(provenance: dict) -> dict: # <<< fingerprint helpers +# Timestamped quote->account FX is fingerprinted from the exact values passed +# through the C ABI, not merely from provider-file metadata. The raw file hash +# remains useful provenance, while this canonical digest proves the effective +# broker input independently of JSON key order or whitespace. +ACCOUNT_CURRENCY_FX_CANONICALIZATION = "pf-account-currency-fx-i64-f64-le-v1" +_ACCOUNT_CURRENCY_FX_HASH_PREFIX = \ + b"pineforge:account-currency-fx:i64-f64-le:v1\0" +_ACCOUNT_CURRENCY_FX_RECORD = struct.Struct(" str: + if len(timestamps) != len(rates) or not timestamps: + raise ValueError( + "account_currency_fx_series requires equal non-empty timestamp/rate arrays") + h = hashlib.sha256() + h.update(_ACCOUNT_CURRENCY_FX_HASH_PREFIX) + previous_timestamp = None + for raw_timestamp, raw_rate in zip(timestamps, rates): + timestamp = int(raw_timestamp) + rate = float(raw_rate) + if previous_timestamp is not None and timestamp <= previous_timestamp: + raise ValueError( + "account_currency_fx_series timestamps must be strictly increasing") + if not math.isfinite(rate) or rate <= 0.0: + raise ValueError( + "account_currency_fx_series rates must be positive and finite") + h.update(_ACCOUNT_CURRENCY_FX_RECORD.pack(timestamp, rate)) + previous_timestamp = timestamp + return h.hexdigest() + + +def _effective_account_currency_fx_scalar(run_kwargs: dict) -> float: + metadata = run_kwargs.get("syminfo_metadata") + raw_value = metadata.get("account_currency_fx", 1.0) \ + if isinstance(metadata, dict) else 1.0 + try: + value = float(raw_value) + except (TypeError, ValueError): + return 1.0 + return value if math.isfinite(value) and value > 0.0 else 1.0 + + def build_runtime_provenance(run_kwargs: dict, trade_start_ms: int | None) -> dict: """Return the effective runtime settings that participate in a fingerprint.""" + fx_scalar = _effective_account_currency_fx_scalar(run_kwargs) + fx_series = run_kwargs.get("account_currency_fx_series") + fx_summary = None + if fx_series: + timestamps, rates = fx_series + fx_summary = { + "canonicalization": ACCOUNT_CURRENCY_FX_CANONICALIZATION, + "effective_values_sha256": + _account_currency_fx_values_sha256(timestamps, rates), + "source_file_sha256": run_kwargs.get( + "account_currency_fx_source_sha256") or "", + "fallback_account_per_quote": fx_scalar, + "points": len(timestamps), + "first_effective_ms": int(timestamps[0]), + "last_effective_ms": int(timestamps[-1]), + } return { "input_tf": run_kwargs.get("input_tf") or "", "script_tf": run_kwargs.get("script_tf") or "", @@ -327,6 +386,8 @@ def build_runtime_provenance(run_kwargs: dict, trade_start_ms: int | None) -> di "magnifier_distribution": run_kwargs.get("magnifier_distribution") or "ENDPOINTS", "chart_timezone": run_kwargs.get("chart_timezone") or "", "trade_start_ms": None if trade_start_ms is None else int(trade_start_ms), + "account_currency_fx_scalar": fx_scalar, + "account_currency_fx_series": fx_summary, } @@ -534,6 +595,49 @@ def find_strategy_lib(strategy_dir: Path, so_name: str = "strategy.so") -> Path: return so_path +def _load_account_currency_fx_daily_closes(path: Path): + """Load ``{"YYYY-MM-DD": close}`` and shift each close to D+1 00:00Z. + + TradingView applies the previous UTC day's quote/account close. The C ABI + deliberately accepts effective timestamps instead of provider-specific + daily semantics; this harness adapter owns the one-day shift. + """ + raw_bytes = path.read_bytes() + + def reject_duplicate_keys(pairs): + obj = {} + for key, value in pairs: + if key in obj: + raise ValueError( + f"duplicate account-currency FX date {key!r}: {path}") + obj[key] = value + return obj + + payload = json.loads(raw_bytes, object_pairs_hook=reject_duplicate_keys) + if not isinstance(payload, dict) or not payload: + raise ValueError( + f"account_currency_fx_daily_close_json must contain a non-empty object: {path}") + points = [] + for day, raw_rate in payload.items(): + try: + date = datetime.strptime(str(day), "%Y-%m-%d").replace( + tzinfo=timezone.utc) + rate = float(raw_rate) + except (TypeError, ValueError) as exc: + raise ValueError(f"invalid account-currency FX point {day!r}: {raw_rate!r}") from exc + if isinstance(raw_rate, bool) or not math.isfinite(rate) or rate <= 0.0: + raise ValueError(f"invalid account-currency FX rate {day!r}: {raw_rate!r}") + effective_ms = int((date + timedelta(days=1)).timestamp() * 1000) + points.append((effective_ms, rate)) + points.sort() + if any(points[i][0] <= points[i - 1][0] for i in range(1, len(points))): + raise ValueError(f"duplicate account-currency FX dates: {path}") + return ( + ([point[0] for point in points], [point[1] for point in points]), + hashlib.sha256(raw_bytes).hexdigest(), + ) + + def inputs_run_kwargs(params, strategy_dir: Path, default_ohlcv: Path, default_chart_tz: str = "") -> tuple[Path, dict]: """Resolve per-probe ``inputs.json`` metadata into the OHLCV path and the @@ -601,6 +705,17 @@ def _num(v): syminfo_metadata = dict(syminfo_metadata) syminfo_metadata["qty_step"] = qty_step + fx_series = None + fx_source_sha256 = None + fx_daily_json = runtime_overrides.get( + "account_currency_fx_daily_close_json") + if fx_daily_json: + fx_path = Path(str(fx_daily_json)) + if not fx_path.is_absolute(): + fx_path = (strategy_dir / fx_path).resolve() + fx_series, fx_source_sha256 = \ + _load_account_currency_fx_daily_closes(fx_path) + kwargs = dict( strategy_overrides=strategy_overrides or None, chart_timezone=chart_tz, @@ -609,6 +724,8 @@ def _num(v): syminfo_metadata=syminfo_metadata, syminfo_mintick=_num(runtime_overrides.get("mintick")), syminfo_pointvalue=_num(runtime_overrides.get("pointvalue")), + account_currency_fx_series=fx_series, + account_currency_fx_source_sha256=fx_source_sha256, input_tf=str(params.get("input_tf") or "") or None, script_tf=str(params.get("script_tf") or "") or None, ohlcv_start_ms=ohlcv_start_ms, @@ -712,6 +829,11 @@ def _setup_signatures(self) -> None: L.strategy_set_syminfo_metadata.argtypes = [ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double] L.strategy_set_syminfo_metadata.restype = None + if hasattr(L, "strategy_set_account_currency_fx_series"): + L.strategy_set_account_currency_fx_series.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(ctypes.c_int64), + ctypes.POINTER(ctypes.c_double), ctypes.c_int] + L.strategy_set_account_currency_fx_series.restype = ctypes.c_int if hasattr(L, "strategy_set_syminfo_mintick"): L.strategy_set_syminfo_mintick.argtypes = [ctypes.c_void_p, ctypes.c_double] L.strategy_set_syminfo_mintick.restype = None @@ -732,6 +854,8 @@ def run(self, bars_csv: Path, params: dict | None = None, syminfo_metadata: dict | None = None, syminfo_mintick: float | None = None, syminfo_pointvalue: float | None = None, + account_currency_fx_series: "tuple | None" = None, + account_currency_fx_source_sha256: str | None = None, input_tf: str | None = None, script_tf: str | None = None, ohlcv_start_ms: int | None = None, ohlcv_end_ms: int | None = None, @@ -826,6 +950,23 @@ def run(self, bars_csv: Path, params: dict | None = None, state, str(mkey).encode(), float(mval)) except (TypeError, ValueError): continue + if account_currency_fx_series is not None: + if not hasattr(self.lib, "strategy_set_account_currency_fx_series"): + raise RuntimeError( + "strategy library lacks timestamped account-currency FX support; rebuild it") + fx_timestamps, fx_rates = account_currency_fx_series + if len(fx_timestamps) != len(fx_rates) or not fx_timestamps: + raise ValueError( + "account_currency_fx_series requires equal non-empty timestamp/rate arrays") + fx_timestamps_c = (ctypes.c_int64 * len(fx_timestamps))( + *[int(value) for value in fx_timestamps]) + fx_rates_c = (ctypes.c_double * len(fx_rates))( + *[float(value) for value in fx_rates]) + rc = self.lib.strategy_set_account_currency_fx_series( + state, fx_timestamps_c, fx_rates_c, len(fx_timestamps)) + if rc != 0: + raise ValueError( + "engine rejected account_currency_fx_series") if syminfo_mintick is not None and hasattr(self.lib, "strategy_set_syminfo_mintick"): self.lib.strategy_set_syminfo_mintick(state, float(syminfo_mintick)) if syminfo_pointvalue is not None and hasattr(self.lib, "strategy_set_syminfo_pointvalue"): @@ -1179,6 +1320,10 @@ def _run_via_docker(strategy_dir: Path, ohlcv_path: Path, params: dict, pre-trimming the CSV fed to the container (the ctypes path trims in _load_bars). NEVER re-transpiles .pine (uses the committed cpp, no codegen variance).""" import pf_release_run as _rel + if run_kwargs.get("account_currency_fx_series") is not None: + raise RuntimeError( + "--runner docker does not support timestamped account-currency FX; " + "use the ctypes runner with a freshly built strategy library") generated_cpp = strategy_dir / "generated.cpp" if not generated_cpp.exists(): raise FileNotFoundError(f"generated.cpp not found for --runner docker: {generated_cpp}") @@ -1357,6 +1502,11 @@ def main() -> int: sys.exit("error: --trace-json needs --emit-plots (deferred); not supported with --runner docker.") if args.fingerprint_json is not None: sys.exit("error: --fingerprint-json is not supported with --runner docker.") + if run_kwargs.get("account_currency_fx_series") is not None: + sys.exit( + "error: --runner docker does not support timestamped " + "account-currency FX; use --runner ctypes with a freshly " + "built strategy library.") strat = None report = _run_via_docker(strategy_dir, ohlcv_path, params, run_kwargs, trade_start_ms, args.image) diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 317f156..5fd1312 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -275,6 +275,16 @@ PF_API void strategy_set_syminfo_metadata(pf_strategy_t s, const char* key, std::string(key), value); } +PF_API int strategy_set_account_currency_fx_series( + pf_strategy_t s, const int64_t* effective_from_ms, + const double* account_per_quote, int n) { + if (!s) return -1; + return static_cast(s) + ->set_account_currency_fx_series( + effective_from_ms, account_per_quote, n) + ? 0 : -1; +} + /* See PF_ABI_VERSION doc in pineforge.h. */ PF_API int pf_abi_version(void) { return PF_ABI_VERSION; } diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 202fbc2..3d666f1 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -463,6 +463,174 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( return result; } +// Timestamped quote->account FX rollover for a carried 1x long. Unlike the +// ordinary adverse-price pass below, this is consumed at the first broker open +// under the newly effective provider epoch, before pending orders and on_bar. +bool BacktestEngine::process_carried_long_full_margin_fx_rollover( + const Bar& bar) { + if (account_currency_fx_timestamps_.empty()) return false; + + const auto effective_end = std::upper_bound( + account_currency_fx_timestamps_.begin(), + account_currency_fx_timestamps_.end(), bar.timestamp); + const std::size_t effective_epoch = static_cast( + std::distance(account_currency_fx_timestamps_.begin(), effective_end)); + const double effective_rate = effective_epoch == 0 + ? account_currency_fx_ + : account_currency_fx_rates_[effective_epoch - 1]; + + // The first script bar establishes the broker's starting epoch. Later + // epoch changes are consumed exactly once, including while flat or on an + // ineligible position, so a subsequently opened position cannot inherit a + // stale rollover event. + if (!account_currency_fx_broker_epoch_initialized_) { + account_currency_fx_broker_epoch_initialized_ = true; + account_currency_fx_broker_epoch_ = effective_epoch; + account_currency_fx_broker_rate_ = effective_rate; + return false; + } + if (effective_epoch == account_currency_fx_broker_epoch_) return false; + + const double previous_rate = account_currency_fx_broker_rate_; + account_currency_fx_broker_epoch_ = effective_epoch; + account_currency_fx_broker_rate_ = effective_rate; + + const bool carried_position = + position_side_ != PositionSide::FLAT + && position_open_bar_ < bar_index_; + const double carried_margin = position_side_ == PositionSide::LONG + ? margin_long_ : margin_short_; + const bool supported_carried_rollover = + position_side_ == PositionSide::LONG + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + if (effective_rate != previous_rate + && margin_call_enabled_ + && carried_position + && std::isfinite(carried_margin) + && carried_margin > 0.0 + && !supported_carried_rollover) { + throw std::runtime_error( + "timestamped account-currency FX broker-open rollover supports " + "only carried 1x long positions"); + } + + const bool carried_long_full_margin = + margin_call_enabled_ + && position_side_ == PositionSide::LONG + && position_open_bar_ < bar_index_ + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + if (!carried_long_full_margin + || !std::isfinite(previous_rate) || !(previous_rate > 0.0) + || !std::isfinite(effective_rate) || !(effective_rate > 0.0) + || effective_rate == previous_rate + || !std::isfinite(bar.open) || !(bar.open > 0.0)) { + return false; + } + + const double qty = position_qty_; + const double pv = syminfo_.pointvalue; + if (!std::isfinite(qty) || !(qty > 0.0) + || !std::isfinite(position_entry_price_) + || !std::isfinite(pv) + || !std::isfinite(initial_capital_) + || !std::isfinite(net_profit_sum_)) { + return false; + } + + // TV revalues a carried 1x long at the first broker open under the newly + // confirmed rate. Entry fees are immediate account-currency costs; this + // engine otherwise realizes both fee legs when a trade closes, so include + // the still-open entry fees explicitly in the affordability ledger. + double entry_commission = 0.0; + for (const auto& pe : pyramid_entries_) { + if (pe.qty <= kQtyEpsilon) continue; + const double lot_commission = open_entry_commission(pe); + if (!std::isfinite(lot_commission)) return false; + entry_commission += lot_commission; + } + const double margin_per_unit = bar.open * pv * effective_rate; + const double opening_equity = initial_capital_ + net_profit_sum_ + - entry_commission + + (bar.open - position_entry_price_) * qty * pv * effective_rate; + if (!std::isfinite(entry_commission) + || !std::isfinite(margin_per_unit) || !(margin_per_unit > 0.0) + || !std::isfinite(opening_equity)) { + return false; + } + const double required_margin = qty * margin_per_unit; + if (!std::isfinite(required_margin) || opening_equity >= required_margin) { + return false; + } + + double q_min = qty - opening_equity / margin_per_unit; + if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return false; + const double raw_q_min = q_min; + if (qty_step_ > 0.0) { + double step_count = q_min / qty_step_; + if (margin_zero_cover_full_liquidation_) { + const double nearest_step = std::round(step_count); + if (std::abs(step_count - nearest_step) < 1e-6) { + step_count = nearest_step; + } + } + q_min = std::floor(step_count) * qty_step_; + } + + // TV's converted-currency carried-rollover edge is discontinuous: when a + // real positive restore quantity floors below the instrument lot step, it + // closes one whole contract (not one tiny qty_step and not a dust no-op). + // Keep the candidate capped to a sub-one position and require it to lie on + // the configured grid; otherwise fail closed rather than invent a fill. + double floor_zero_fallback = std::numeric_limits::quiet_NaN(); + if (q_min <= kQtyEpsilon) { + if (qty_step_ > 0.0 + && qty_step_ <= 1.0 + && raw_q_min > kQtyEpsilon + && raw_q_min < 1.0) { + const double candidate = std::min(1.0, qty); + const bool full_position_cap = candidate >= qty - kQtyEpsilon; + const double gridded = apply_exit_qty_step(candidate); + const double grid_guard = std::max( + 1e-12, std::abs(candidate) * 1e-12); + if (full_position_cap + || std::abs(gridded - candidate) <= grid_guard) { + floor_zero_fallback = candidate; + } + } + if (!std::isfinite(floor_zero_fallback)) return false; + } + + double qty_liq = std::isfinite(floor_zero_fallback) + ? floor_zero_fallback + : 4.0 * q_min; + if (qty_step_ > 0.0) { + double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; + if (floored <= kQtyEpsilon) { + return false; + } + qty_liq = floored; + } + if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; + if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return false; + + const std::size_t trades_before = trades_.size(); + if (qty_liq >= qty - kQtyEpsilon) { + execute_market_exit(bar.open); + } else { + execute_partial_exit_qty(bar.open, qty_liq); + } + if (trades_.size() == trades_before) return false; + + ++broker_fill_event_seq_; + for (std::size_t ti = trades_before; ti < trades_.size(); ++ti) { + trades_[ti].exit_comment = "Margin call"; + trades_[ti].exit_id = "__margin_call__"; + } + return true; +} + // TradingView force-liquidation (margin call). // // Run once per script bar (end of dispatch_bar / magnifier bar) after all @@ -581,7 +749,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // q_min then removes only enough required margin to restore that // opening budget. The raw matched base is retained separately for the // broker-generated closing fill below. - const double fx = account_currency_fx_; + const double fx = active_account_currency_fx(); if (!std::isfinite(fx) || !(fx > 0.0)) return; // Preserve the established long calculation byte-for-byte. A scoped // short add instead marks the WHOLE position at the latest raw fill: @@ -598,7 +766,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // It was not an accepted fill and must not incur CASH_PER_ORDER's // fixed fee in this post-fill affordability sum. if (pe.qty <= kQtyEpsilon) continue; - const double lot_commission = calc_commission(pe.price, pe.qty); + const double lot_commission = open_entry_commission(pe); if (!std::isfinite(lot_commission)) return; entry_commission += lot_commission; } @@ -623,8 +791,18 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // Keep both comparisons directional and tolerance-aware. An absolute // difference alone would turn affordable headroom into a false call. const double pre_fee_equity = opening_equity + entry_commission; + // TV's converted account-currency broker ledger is cent-rounded at + // this discontinuity. A sub-half-cent post-fee deficit is therefore + // still affordable only when a quote->account conversion provider is + // active. Same-currency strategies retain the raw directional test: + // z8830's tape proves that a ~$0.0026 deficit still receives the + // one-contract fallback, while crypt0graf's converted USD ledger does + // not act on the corresponding ~$0.0025 conversion remainder. + // Keep the comparison directional so positive headroom cannot be + // mistaken for a deficit merely because its absolute magnitude matches. const double comparison_guard = std::max( - 1e-9, std::abs(pre_fee_equity) * 1e-12); + account_currency_fx_timestamps_.empty() ? 1e-9 : 0.005, + std::abs(pre_fee_equity) * 1e-12); const double pre_fee_headroom = pre_fee_equity - required_margin; const double post_fee_deficit = required_margin - opening_equity; fee_created_floor_zero_candidate = @@ -644,7 +822,7 @@ void BacktestEngine::process_margin_call(const Bar& bar) { const double adverse = (position_side_ == PositionSide::LONG) ? bar.low : bar.high; if (!std::isfinite(adverse) || !(adverse > 0.0)) return; - const double fx = account_currency_fx_; + const double fx = active_account_currency_fx(); if (!std::isfinite(fx) || !(fx > 0.0)) return; const double equity_adv = initial_capital_ + net_profit_sum_ + direction * (adverse - position_entry_price_) * qty * pv * fx; @@ -996,7 +1174,8 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { || calc_commission(signal_close, second_qty) != 0.0) { return; } - const double notional_k = syminfo_.pointvalue * account_currency_fx_; + const double notional_k = syminfo_.pointvalue + * active_account_currency_fx(); if (!std::isfinite(notional_k) || !(notional_k > 0.0)) return; const double gross_required = @@ -1131,7 +1310,8 @@ void BacktestEngine::apply_pooc_coof_explicit_flat_market_gross_admission() { return; } - const double notional_k = syminfo_.pointvalue * account_currency_fx_; + const double notional_k = syminfo_.pointvalue + * active_account_currency_fx(); if (!std::isfinite(notional_k) || !(notional_k > 0.0)) return; const double required_margin = (first_qty + second_qty) * signal_close * notional_k; @@ -1877,7 +2057,8 @@ bool BacktestEngine::stop_entry_margin_admission_declines( ? std::abs(order.stop_placement_open_qty) : std::abs(calc_qty_for_type(fill_price, order.qty, order.qty_type)); const double required = fill_qty * bar.open * syminfo_.pointvalue - * account_currency_fx_ * (margin_pct / 100.0); + * active_account_currency_fx() + * (margin_pct / 100.0); const double available = use_placement_open_qty ? order.stop_placement_open_equity : current_equity(); @@ -2016,13 +2197,13 @@ void BacktestEngine::apply_filled_order_to_state( current_equity() + open_profit(fill_price); const double held_margin = std::abs(position_qty_) * fill_price - * syminfo_.pointvalue * account_currency_fx_; + * syminfo_.pointvalue * active_account_currency_fx(); const double free_funds = equity_at_fill - held_margin; const double transaction_qty = std::abs(position_qty_) + std::abs(new_qty); const double required_margin = transaction_qty * admit_price - * syminfo_.pointvalue * account_currency_fx_; + * syminfo_.pointvalue * active_account_currency_fx(); const double epsilon = std::max(1e-9, std::abs(equity_at_fill) * 1e-12); if (required_margin > free_funds + epsilon) { @@ -2150,6 +2331,14 @@ void BacktestEngine::apply_filled_order_to_state( bool reversal = position_side_ != PositionSide::FLAT && !same_dir; bool raw_opposite_close = order.type == OrderType::RAW_ORDER && reversal; double margin_pct = order.is_long ? margin_long_ : margin_short_; + // The qty/equity/price admission tuple is a signal-time snapshot. + // Keep FX on that same lifecycle boundary: when a daily rate becomes + // effective on the next-bar fill, TV admits the frozen order first and + // lets the post-fill affordability pass trim it at the new rate. + const double sizing_fx = + std::isfinite(order.sizing_fx) && order.sizing_fx > 0.0 + ? order.sizing_fx + : active_account_currency_fx(); // Gap-reject (design-cntvxiao-gap-reject, PANEL-CLEARED): a high-level // strategy.entry with omitted qty, sized percent_of_equity at EXACTLY // 100%, direction-appropriate margin == 100, placed TRUE-FLAT and still @@ -2206,11 +2395,11 @@ void BacktestEngine::apply_filled_order_to_state( if (calc_commission(slipped_fill, order.frozen_default_qty) == 0.0) { const double gap_notional = std::abs(order.frozen_default_qty) * slipped_fill * syminfo_.pointvalue - * account_currency_fx_ + * sizing_fx * (margin_pct / 100.0); const double one_lot_slack = qty_step_ * slipped_fill * syminfo_.pointvalue - * account_currency_fx_ + * sizing_fx * (margin_pct / 100.0); const double float_guard = std::max(1e-9, std::abs(order.sizing_equity) * 1e-12); @@ -2253,7 +2442,7 @@ void BacktestEngine::apply_filled_order_to_state( // specimen we have) the scaling is a no-op. double held = same_dir ? std::abs(position_qty_) * order.sizing_mark - * syminfo_.pointvalue * account_currency_fx_ + * syminfo_.pointvalue * sizing_fx * (margin_pct / 100.0) : 0.0; double free_funds = order.sizing_equity - held; @@ -2269,7 +2458,7 @@ void BacktestEngine::apply_filled_order_to_state( double required_margin = std::abs(order.frozen_default_qty) * admit_price * syminfo_.pointvalue - * account_currency_fx_ + * sizing_fx * (margin_pct / 100.0); // The epsilon absorbs double rounding AND one whole lot of // notional. @@ -2289,7 +2478,7 @@ void BacktestEngine::apply_filled_order_to_state( std::max(1e-9, std::abs(free_funds) * 1e-12); epsilon = std::max(epsilon, qty_step_ * admit_price * syminfo_.pointvalue - * account_currency_fx_ + * sizing_fx * (margin_pct / 100.0)); if (required_margin > free_funds + epsilon) { // design-declined-reversal-close-leg: ONLY the reversal decline @@ -2350,7 +2539,8 @@ void BacktestEngine::apply_filled_order_to_state( if (margin_pct > 0.0) { const double slipped_fill = apply_fill_slippage(fill_price, order.is_long); - const double notional_k = syminfo_.pointvalue * account_currency_fx_ + const double notional_k = syminfo_.pointvalue + * active_account_currency_fx() * (margin_pct / 100.0); // A finalized pair's first broker fill may be the later source // call and moves the frozen GROSS transaction. Cost that exact @@ -2726,8 +2916,7 @@ void BacktestEngine::apply_filled_order_to_state( double new_opening_commission = pyramid_entries_.empty() ? std::numeric_limits::quiet_NaN() - : calc_commission(pyramid_entries_.back().price, - pyramid_entries_.back().qty); + : open_entry_commission(pyramid_entries_.back()); const bool frozen_all_in_true_flat_exemption = successful_fresh_open && order.opening_affordability_exemption_candidate @@ -3337,6 +3526,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_}); + snapshot_entry_commission(pyramid_entries_.back()); id_unclosed_qty_[order.id] += qty; if (!std::isnan(order.stop_price) || !std::isnan(order.limit_price)) { set_entry_fill_excursion_masks(pyramid_entries_.back(), current_bar_, fill_price); @@ -3388,6 +3578,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_}); + 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. pyramid_entries_.back().market_pyramid_add = !is_priced_entry; diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 7367b20..8a6fa16 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -41,11 +41,13 @@ double BacktestEngine::calc_qty_for_type(double fill_price, double qty_value, in // the quote-currency fill_price โ€” same convention as calc_qty() in // engine.hpp. fx=1.0 is a no-op. return (std::isfinite(fill_price) && fill_price > 0.0) - ? apply_qty_step((cash / account_currency_fx_) / (fill_price * syminfo_.pointvalue)) : 0.0; + ? apply_qty_step((cash / active_account_currency_fx()) + / (fill_price * syminfo_.pointvalue)) : 0.0; } if (qty_type == static_cast(QtyType::CASH)) { return (std::isfinite(fill_price) && fill_price > 0.0) - ? apply_qty_step((qty_value / account_currency_fx_) / (fill_price * syminfo_.pointvalue)) : 0.0; + ? apply_qty_step((qty_value / active_account_currency_fx()) + / (fill_price * syminfo_.pointvalue)) : 0.0; } return apply_qty_step(qty_value); } @@ -186,14 +188,18 @@ double BacktestEngine::fifo_drain(const std::string* from_entry, double qty_limi // remaining entry's extremes stay consistent with its reduced // qty (update_per_trade_extremes accumulates (diff) * pe.qty). double keep_scale = keep_qty / pe.qty; - remaining.push_back({pe.price, pe.time, keep_qty, pe.entry_id, - pe.entry_bar_index, pe.entry_comment, - pe.max_runup * keep_scale, - pe.max_drawdown * keep_scale, - pe.skip_entry_bar_high, - pe.skip_entry_bar_low, - pe.market_pyramid_add, - pe.entry_path_position}); + PyramidEntry kept = pe; + kept.qty = keep_qty; + kept.max_runup *= keep_scale; + kept.max_drawdown *= keep_scale; + // Percent and per-contract fees follow the surviving quantity. + // CASH_PER_ORDER remains one fee for the accepted entry order, + // matching calc_commission's established qty-independent shape. + if (std::isfinite(kept.entry_commission_account) + && commission_type_ != CommissionType::CASH_PER_ORDER) { + kept.entry_commission_account *= keep_scale; + } + remaining.push_back(std::move(kept)); } } @@ -436,7 +442,7 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, // is account-currency by construction), so it is NOT scaled again here. const double pv = syminfo_.pointvalue; double pnl = (was_long ? (fill_price - pe.price) : (pe.price - fill_price)) - * close_qty * pv * account_currency_fx_; + * close_qty * pv * active_account_currency_fx(); const double entry_commission = calc_commission(pe.price, close_qty); const double exit_commission = calc_commission(fill_price, close_qty); pnl -= entry_commission + exit_commission; @@ -448,7 +454,8 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, // (exit/entry-1) form; shorts diverge on large moves ((entry/exit-1) // was wrong). Computed AFTER the commission subtraction above โ€” order // matters. - const double entry_cost = pe.price * close_qty * pv * account_currency_fx_; + const double entry_cost = pe.price * close_qty * pv + * active_account_currency_fx(); double pnl_pct = (entry_cost > 0.0) ? (pnl / entry_cost) * 100.0 : 0.0; Trade trade; @@ -530,8 +537,10 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, // account currency via account_currency_fx_ (default 1.0, no-op) before // combining with entry_commission, which is already account-currency // (see calc_commission) โ€” same convention as pnl above. - trade.max_runup = std::max(0.0, runup * pv * account_currency_fx_ - entry_commission); - trade.max_drawdown = drawdown * pv * account_currency_fx_ + entry_commission; + trade.max_runup = std::max( + 0.0, runup * pv * active_account_currency_fx() - entry_commission); + trade.max_drawdown = drawdown * pv * active_account_currency_fx() + + entry_commission; const double trade_pnl = trade.pnl; trades_.push_back(std::move(trade)); net_profit_sum_ += trade_pnl; @@ -627,6 +636,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri close_two_call_first_qty_.clear(); consumed_partial_exit_ids_.clear(); pyramid_entries_.push_back({fill_price, current_bar_.timestamp, qty, id, bar_index_}); + snapshot_entry_commission(pyramid_entries_.back()); id_unclosed_qty_[id] += qty; } @@ -789,6 +799,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_}); + 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. pyramid_entries_.back().market_pyramid_add = !is_priced_entry; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 29d6e0d..8174646 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -15,6 +15,57 @@ namespace pineforge { using namespace internal; +bool BacktestEngine::set_account_currency_fx_series( + const int64_t* timestamps_ms, const double* rates, int n) { + // Timestamped FX is not route-complete for the realtime scheduler. Reject + // late installation as well as stream_begin-with-series so callers cannot + // bypass fail-closed behavior by changing configuration after warmup. + if (stream_phase_ == StreamPhase::REALTIME || stream_warmup_mode_) { + return false; + } + if (n < 0 || (n > 0 && (!timestamps_ms || !rates))) return false; + if (n == 0) { + account_currency_fx_timestamps_.clear(); + account_currency_fx_rates_.clear(); + return true; + } + + std::vector next_timestamps; + std::vector next_rates; + next_timestamps.reserve(static_cast(n)); + next_rates.reserve(static_cast(n)); + for (int i = 0; i < n; ++i) { + if ((i > 0 && timestamps_ms[i] <= timestamps_ms[i - 1]) + || !std::isfinite(rates[i]) || rates[i] <= 0.0) { + return false; + } + next_timestamps.push_back(timestamps_ms[i]); + next_rates.push_back(rates[i]); + } + account_currency_fx_timestamps_ = std::move(next_timestamps); + account_currency_fx_rates_ = std::move(next_rates); + return true; +} + +double BacktestEngine::account_currency_fx_at(int64_t timestamp_ms) const { + if (account_currency_fx_timestamps_.empty()) { + return account_currency_fx_; + } + const auto it = std::upper_bound( + account_currency_fx_timestamps_.begin(), + account_currency_fx_timestamps_.end(), timestamp_ms); + if (it == account_currency_fx_timestamps_.begin()) { + return account_currency_fx_; + } + const std::size_t index = static_cast( + std::distance(account_currency_fx_timestamps_.begin(), it) - 1); + return account_currency_fx_rates_[index]; +} + +double BacktestEngine::active_account_currency_fx() const { + return account_currency_fx_at(current_bar_.timestamp); +} + // open_trade_* accessors moved to engine_trade_accessors.cpp. @@ -56,6 +107,24 @@ void BacktestEngine::dispatch_bar() { return; } + // A confirmed timestamped FX point is consumed at the broker boundary, + // before any resting order or the close-time script body can observe the + // position. Restrict current_bar_ to the opening point while the broker + // emits the forced exit so the trade cannot inherit future high/low state + // from the script bar. + { + const Bar script_bar = current_bar_; + current_bar_ = Bar{script_bar.open, script_bar.open, script_bar.open, + script_bar.open, 0.0, script_bar.timestamp}; + try { + process_carried_long_full_margin_fx_rollover(script_bar); + } catch (...) { + current_bar_ = script_bar; + throw; + } + current_bar_ = script_bar; + } + // A C-factor inheritance is same-ordinary-bar state. A candidate erased // by replacement/OCA/cancel never reaches the fill kernel, so discard any // stale identity before starting the next broker batch. @@ -573,6 +642,9 @@ void BacktestEngine::reset_run_state() { // Per-bar cursor + session-predicate state. bar_index_ = 0; prev_bar_timestamp_ = 0; + account_currency_fx_broker_epoch_initialized_ = false; + account_currency_fx_broker_epoch_ = 0; + account_currency_fx_broker_rate_ = account_currency_fx_; prev_in_session_ = false; session_ismarket_ = false; session_isfirstbar_ = false; @@ -630,6 +702,10 @@ void BacktestEngine::run(const Bar* bars, int n) { last_bar_index_ = 0; } try { + if (!account_currency_fx_timestamps_.empty() && calc_on_order_fills_) { + throw std::runtime_error( + "timestamped account-currency FX does not support calc_on_order_fills"); + } reset_run_state(); equity_curve_.reserve((size_t)std::max(n, 0)); @@ -1059,6 +1135,12 @@ void BacktestEngine::run(const Bar* input_bars, int n_input, last_bar_time_ = 0; } try { + if (!account_currency_fx_timestamps_.empty() + && (calc_on_order_fills_ || bar_magnifier)) { + throw std::runtime_error( + "timestamped account-currency FX supports ordinary historical dispatch only; " + "calc_on_order_fills and bar magnifier are unsupported"); + } // Auto-detect input_tf from bar timestamps if not provided std::string effective_input_tf = input_tf; if (effective_input_tf.empty() && n_input >= 2) { diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 5671fcf..bd85eae 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -221,7 +221,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // the account currency. Default 1.0 leaves the corpus untouched. double required_margin = std::abs(qty) * current_bar_.close * syminfo_.pointvalue - * account_currency_fx_ + * active_account_currency_fx() * (margin_pct / 100.0); double available_equity = current_equity(); double epsilon = std::max(1e-9, std::abs(available_equity) * 1e-12); @@ -392,7 +392,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.paired_flat_market_signal_margin_pct = is_long ? margin_long_ : margin_short_; order.paired_flat_market_signal_pointvalue = syminfo_.pointvalue; - order.paired_flat_market_signal_fx = account_currency_fx_; + order.paired_flat_market_signal_fx = active_account_currency_fx(); } // TradingView freezes DEFAULT (qty=na) percent_of_equity / cash // market-order sizing at THIS (signal) bar's close โ€” see @@ -417,6 +417,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.sizing_equity = current_equity() + open_profit(current_bar_.close); order.sizing_mark = current_bar_.close; + order.sizing_fx = active_account_currency_fx(); // Direction-neutral: two fill-time consumers read this flag. // 1. KI-61 long entry-bar affordability trim // (engine_fills.cpp): re-checks order.is_long and margin_long @@ -440,7 +441,9 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, && std::isfinite(order.frozen_default_qty) && std::isfinite(order.sizing_equity) && std::isfinite(order.sizing_price) - && std::isfinite(order.sizing_mark); + && std::isfinite(order.sizing_mark) + && std::isfinite(order.sizing_fx) + && order.sizing_fx > 0.0; order.default_flat_market_gross_candidate = default_flat_market_gross_call && order.opening_affordability_exemption_candidate @@ -1579,6 +1582,7 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double order.sizing_equity = current_equity() + open_profit(current_bar_.close); order.sizing_mark = current_bar_.close; + order.sizing_fx = active_account_currency_fx(); } } else { order.type = OrderType::RAW_ORDER; diff --git a/src/engine_stream.cpp b/src/engine_stream.cpp index bac820b..cbb6542 100644 --- a/src/engine_stream.cpp +++ b/src/engine_stream.cpp @@ -25,6 +25,10 @@ bool BacktestEngine::stream_begin(const Bar* warmup_bars, int n_warmup, const std::string& script_tf) { last_error_.clear(); try { + if (!account_currency_fx_timestamps_.empty()) { + throw std::runtime_error( + "timestamped account-currency FX is not supported by streaming"); + } if (stream_phase_ == StreamPhase::REALTIME) { throw std::runtime_error("stream is already realtime"); } diff --git a/src/engine_trade_accessors.cpp b/src/engine_trade_accessors.cpp index 75e5806..ccecbd0 100644 --- a/src/engine_trade_accessors.cpp +++ b/src/engine_trade_accessors.cpp @@ -27,8 +27,8 @@ double BacktestEngine::open_trade_profit(int idx) const { // consistent with the realized-trade path in emit_close_trade // (pointvalue=1, fx=1.0 leaves the corpus unchanged). double pnl = (is_long ? (px - pe.price) * pe.qty : (pe.price - px) * pe.qty) - * syminfo_.pointvalue * account_currency_fx_; - pnl -= calc_commission(pe.price, pe.qty); + * syminfo_.pointvalue * active_account_currency_fx(); + pnl -= open_entry_commission(pe); return pnl; } @@ -44,7 +44,8 @@ double BacktestEngine::open_trade_profit_percent(int idx) const { // * pointvalue * account_currency_fx_, matching open_trade_profit's // fx-scaled pnl so the ratio is currency-invariant). No new commission // handling is invented here; the percent just mirrors open_trade_profit. - const double entry_cost = pe.price * pe.qty * syminfo_.pointvalue * account_currency_fx_; + const double entry_cost = pe.price * pe.qty * syminfo_.pointvalue + * active_account_currency_fx(); if (!(entry_cost > 0.0)) return na(); return open_trade_profit(idx) / entry_cost * 100.0; } @@ -53,7 +54,7 @@ double BacktestEngine::open_trade_commission(int idx) const { if (position_side_ == PositionSide::FLAT || idx < 0 || idx >= (int)pyramid_entries_.size()) return na(); const PyramidEntry& pe = pyramid_entries_[(size_t)idx]; - return calc_commission(pe.price, pe.qty); + return open_entry_commission(pe); } int BacktestEngine::open_trade_entry_bar_index(int idx) const { @@ -101,7 +102,8 @@ double BacktestEngine::open_trade_size(int idx) const { double BacktestEngine::open_trade_max_drawdown(int idx) const { if (position_side_ == PositionSide::FLAT || idx < 0 || idx >= (int)pyramid_entries_.size()) return 0.0; - return pyramid_entries_[(size_t)idx].max_drawdown * syminfo_.pointvalue * account_currency_fx_; + return pyramid_entries_[(size_t)idx].max_drawdown * syminfo_.pointvalue + * active_account_currency_fx(); } double BacktestEngine::open_trade_max_drawdown_percent(int idx) const { @@ -115,7 +117,8 @@ double BacktestEngine::open_trade_max_drawdown_percent(int idx) const { double BacktestEngine::open_trade_max_runup(int idx) const { if (position_side_ == PositionSide::FLAT || idx < 0 || idx >= (int)pyramid_entries_.size()) return 0.0; - return pyramid_entries_[(size_t)idx].max_runup * syminfo_.pointvalue * account_currency_fx_; + return pyramid_entries_[(size_t)idx].max_runup * syminfo_.pointvalue + * active_account_currency_fx(); } double BacktestEngine::open_trade_max_runup_percent(int idx) const { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index eec8eaf..ac7dbeb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -124,6 +124,12 @@ add_test( ${PROJECT_SOURCE_DIR}/scripts/fingerprint_self_test.py ) +add_test( + NAME test_run_corpus + COMMAND ${Python3_EXECUTABLE} + ${PROJECT_SOURCE_DIR}/scripts/run_corpus_self_test.py +) + add_test( NAME test_verify_corpus_metrics COMMAND ${Python3_EXECUTABLE} diff --git a/tests/test_affordability_fx.cpp b/tests/test_affordability_fx.cpp index 9d89501..1be9e6f 100644 --- a/tests/test_affordability_fx.cpp +++ b/tests/test_affordability_fx.cpp @@ -59,11 +59,12 @@ namespace { // 0 if rejected). class FxProbe : public BacktestEngine { public: - explicit FxProbe(double fx_or_nan) { + explicit FxProbe(double fx_or_nan, double commission_percent = 0.0) { initial_capital_ = 1000.0; default_qty_type_ = QtyType::FIXED; default_qty_value_ = 1.0; - commission_value_ = 0.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = commission_percent; margin_long_ = 100.0; // 1x -> required_margin == notional process_orders_on_close_ = true; // market entry fills at bar close if (!std::isnan(fx_or_nan)) @@ -76,6 +77,298 @@ class FxProbe : public BacktestEngine { strategy_close("L"); } int trades() const { return trade_count(); } + double first_pnl() const { return trades() ? get_trade(0).pnl : kNaN; } +}; + +// A default 100%-of-equity order is placed under FX=1.0 and fills on the +// next bar after FX rolls to 1.001. TV admits the frozen signal snapshot, then +// revalues the live fill and emits a broker margin trim at the new rate. +class FrozenFxRolloverProbe : public BacktestEngine { +public: + FrozenFxRolloverProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.04; + margin_long_ = 100.0; + qty_step_ = 0.0001; + process_orders_on_close_ = false; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("L", true); + } + int trades() const { return trade_count(); } + double open_qty() const { + return position_side_ == PositionSide::LONG ? position_qty_ : 0.0; + } + const Trade& trade(int i) const { return get_trade(i); } +}; + +// A live 1x long crosses a timestamped FX epoch on bar 2. The broker must +// consume that epoch and emit any required trim at bar OPEN, before on_bar can +// observe the position or place another order. +class CarriedFxRolloverOrderingProbe : public BacktestEngine { +public: + CarriedFxRolloverOrderingProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_long_ = 100.0; + qty_step_ = 0.0001; + process_orders_on_close_ = true; + } + void on_bar(const Bar& /*bar*/) override { + ++on_bar_calls_; + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 100.0); + } else if (bar_index_ == 2) { + observed_qty_ = position_side_ == PositionSide::LONG + ? position_qty_ : 0.0; + observed_trades_ = trade_count(); + } + } + void enable_coof() { calc_on_order_fills_ = true; } + int on_bar_calls() const { return on_bar_calls_; } + int observed_trades() const { return observed_trades_; } + double observed_qty() const { return observed_qty_; } + double open_qty() const { + return position_side_ == PositionSide::LONG ? position_qty_ : 0.0; + } + int trades() const { return trade_count(); } + const Trade& trade(int i) const { return get_trade(i); } + +private: + int on_bar_calls_ = 0; + int observed_trades_ = -1; + double observed_qty_ = kNaN; +}; + +// Rate changes on carried shorts and leveraged longs do not yet have a +// TV-pinned broker-open liquidation rule. They must reject before on_bar +// instead of silently falling through to the end-of-bar adverse-price pass. +class UnsupportedCarriedFxRolloverProbe : public BacktestEngine { +public: + UnsupportedCarriedFxRolloverProbe(bool is_long, double margin_pct) + : is_long_(is_long) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + margin_long_ = is_long ? margin_pct : 100.0; + margin_short_ = is_long ? 100.0 : margin_pct; + process_orders_on_close_ = true; + } + void on_bar(const Bar& /*bar*/) override { + ++on_bar_calls_; + if (bar_index_ == 0) { + strategy_entry(is_long_ ? "L" : "S", is_long_, + kNaN, kNaN, 1.0); + } + } + int on_bar_calls() const { return on_bar_calls_; } + +private: + bool is_long_; + int on_bar_calls_ = 0; +}; + +// A pending entry is born before an FX epoch, then fills after the broker has +// crossed that epoch while still flat. The flat crossing must consume the +// rollover permanently: once margin calls are enabled after the fill, the next +// bar must not replay the old epoch against the newly opened position. +class FlatEpochConsumptionProbe : public BacktestEngine { +public: + FlatEpochConsumptionProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + qty_step_ = 0.1; + process_orders_on_close_ = false; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + // Signal under FX=1.0; the frozen admission tuple lets this fill at + // the next open after FX=1.001 becomes effective. + strategy_entry("L", true); + } else if (bar_index_ == 1) { + qty_after_fill_ = position_side_ == PositionSide::LONG + ? position_qty_ : 0.0; + trades_after_fill_ = trade_count(); + } else if (bar_index_ == 2) { + observed_qty_ = position_side_ == PositionSide::LONG + ? position_qty_ : 0.0; + observed_trades_ = trade_count(); + } + } + double qty_after_fill() const { return qty_after_fill_; } + int trades_after_fill() const { return trades_after_fill_; } + double observed_qty() const { return observed_qty_; } + int observed_trades() const { return observed_trades_; } + +private: + double qty_after_fill_ = kNaN; + int trades_after_fill_ = -1; + double observed_qty_ = kNaN; + int observed_trades_ = -1; +}; + +// Entry fees are paid in account currency at the entry fill. A later FX epoch +// changes open gross PnL and exit-time trade reporting, but must not reprice the +// already-paid fee exposed by strategy.opentrades.* while the slice is live. +class EntryFeeAccessorLifecycleProbe : public BacktestEngine { +public: + EntryFeeAccessorLifecycleProbe(CommissionType type, double value, + double qty) + : qty_(qty) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = type; + commission_value_ = value; + margin_long_ = 0.0; // isolate accounting from broker liquidation + process_orders_on_close_ = true; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, qty_); + } else if (bar_index_ == 1) { + observed_open_commission_ = open_trade_commission(0); + observed_open_profit_ = open_trade_profit(0); + strategy_close("L"); + } + } + double observed_open_commission() const { + return observed_open_commission_; + } + double observed_open_profit() const { return observed_open_profit_; } + int trades() const { return trade_count(); } + const Trade& trade(int i) const { return get_trade(i); } + +private: + double qty_; + double observed_open_commission_ = kNaN; + double observed_open_profit_ = kNaN; +}; + +// Exercises lifecycle transitions that retain or replace PyramidEntry slices: +// a rate-1 entry, a rate-2 pyramid add, a FIFO partial exit, then a reversal. +class PyramidEntryFeeLifecycleProbe : public BacktestEngine { +public: + PyramidEntryFeeLifecycleProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 10.0; + margin_long_ = 0.0; + margin_short_ = 0.0; + process_orders_on_close_ = true; + pyramiding_ = 3; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L1", true, kNaN, kNaN, 2.0); + } else if (bar_index_ == 1) { + strategy_entry("L2", true, kNaN, kNaN, 1.0); + } else if (bar_index_ == 2) { + before_partial_first_ = open_trade_commission(0); + before_partial_second_ = open_trade_commission(1); + strategy_close("L1", "partial", /*qty=*/1.0, + /*qty_percent=*/kNaN, /*immediately=*/true); + after_partial_first_ = open_trade_commission(0); + after_partial_second_ = open_trade_commission(1); + partial_trade_commission_ = get_trade(0).commission; + } else if (bar_index_ == 3) { + strategy_entry("S", false, kNaN, kNaN, 1.0); + } else if (bar_index_ == 4) { + reversal_commission_ = open_trade_commission(0); + reversal_is_short_ = position_side_ == PositionSide::SHORT; + } + } + + double before_partial_first() const { return before_partial_first_; } + double before_partial_second() const { return before_partial_second_; } + double after_partial_first() const { return after_partial_first_; } + double after_partial_second() const { return after_partial_second_; } + double partial_trade_commission() const { + return partial_trade_commission_; + } + double reversal_commission() const { return reversal_commission_; } + bool reversal_is_short() const { return reversal_is_short_; } + +private: + double before_partial_first_ = kNaN; + double before_partial_second_ = kNaN; + double after_partial_first_ = kNaN; + double after_partial_second_ = kNaN; + double partial_trade_commission_ = kNaN; + double reversal_commission_ = kNaN; + bool reversal_is_short_ = false; +}; + +// With a 2x FX rollover, repricing the old 10% entry fee would manufacture a +// broker-open deficit and an 0.08-contract margin row. The paid rate-1 fee +// leaves the carried 2.75-contract position affordable. +class CarriedEntryFeeSnapshotProbe : public BacktestEngine { +public: + CarriedEntryFeeSnapshotProbe() { + initial_capital_ = 600.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 10.0; + margin_long_ = 100.0; + qty_step_ = 0.01; + process_orders_on_close_ = true; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 2.75); + } else if (bar_index_ == 1) { + observed_trades_ = trade_count(); + observed_qty_ = position_qty_; + } + } + int observed_trades() const { return observed_trades_; } + double observed_qty() const { return observed_qty_; } + +private: + int observed_trades_ = -1; + double observed_qty_ = kNaN; +}; + +// Post-fill affordability must sum each live slice's paid fee: rate-1 L1 costs +// 20 and rate-2 L2 costs 10. Repricing both at rate 2 would use 50 instead of +// 30 and manufacture a margin trim from an otherwise affordable position. +class PostFillEntryFeeSnapshotProbe : public BacktestEngine { +public: + PostFillEntryFeeSnapshotProbe() { + initial_capital_ = 540.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 10.0; + margin_long_ = 100.0; + qty_step_ = 0.01; + process_orders_on_close_ = true; + pyramiding_ = 2; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L1", true, kNaN, kNaN, 2.0); + } else if (bar_index_ == 1) { + strategy_entry("L2", true, kNaN, kNaN, 0.5); + } else if (bar_index_ == 2) { + observed_trades_ = trade_count(); + observed_qty_ = position_qty_; + } + } + int observed_trades() const { return observed_trades_; } + double observed_qty() const { return observed_qty_; } + +private: + int observed_trades_ = -1; + double observed_qty_ = kNaN; }; void run_case(double fx, int expected_trades, const char* label) { @@ -109,6 +402,336 @@ int main() { // C. Non-positive FX resets to 1.0 default -> accepted. run_case(-5.0, 1, "fx<=0 resets to 1.0"); + // D. A timestamped rate is selected as-of each broker event. The entry at + // t=1000 uses the scalar fallback 1.0, while the close at t=2000 uses the + // rate 2.0 that became effective at t=1500, doubling quote-currency PnL. + { + // Keep the carried position affordable after the rollover so this + // case isolates PnL conversion rather than the broker-open margin path + // pinned separately below. + std::vector bars = {mk_bar(1000, 400.0), mk_bar(2000, 450.0)}; + const int64_t timestamps[] = {1500}; + const double rates[] = {2.0}; + FxProbe eng(1.0); + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 1)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == 1); + CHECK(std::abs(eng.first_pnl() - 100.0) < 1e-12); + + // Configuration survives a reused handle and the as-of lookup does not + // leak an end-of-run cursor into the next run. + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == 1); + CHECK(std::abs(eng.first_pnl() - 100.0) < 1e-12); + + // Invalid replacement is atomic: the installed valid curve remains. + const int64_t unsorted[] = {1500, 1400}; + const double valid_rates[] = {2.0, 3.0}; + CHECK(!eng.set_account_currency_fx_series(unsorted, valid_rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(std::abs(eng.first_pnl() - 100.0) < 1e-12); + + // n=0 clears the provider and restores scalar fallback behavior. + CHECK(eng.set_account_currency_fx_series(nullptr, nullptr, 0)); + eng.run(bars.data(), (int)bars.size()); + CHECK(std::abs(eng.first_pnl() - 50.0) < 1e-12); + } + + // E. A series point effective on the entry bar participates in the same + // affordability gate as a scalar FX value. + { + std::vector bars = {mk_bar(1000, 600.0), mk_bar(2000, 600.0)}; + const int64_t timestamps[] = {1000}; + const double rates[] = {2.0}; + FxProbe eng(1.0); + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 1)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == 0); + } + + // E2. TradingView converts a realized trade's complete net symbol-currency + // PnL at the EXIT bar's daily rate, including both percent-commission legs + // (validation-adhoc/4emarsi.../tv_commission_crack.py: 335/336 exact). + // gross 50*2 - entry fee 400*10%*2 - exit fee 450*10%*2 = -70. + { + std::vector bars = {mk_bar(1000, 400.0), mk_bar(2000, 450.0)}; + const int64_t timestamps[] = {1500}; + const double rates[] = {2.0}; + FxProbe eng(/*scalar_fx=*/1.0, /*commission_percent=*/10.0); + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 1)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == 1); + CHECK(std::abs(eng.first_pnl() - (-70.0)) < 1e-12); + } + + // E3. The live entry fee stays at its entry-time account value, while a + // closed trade intentionally converts both percent-fee legs at exit-time + // FX. Cash fee modes are already account-currency-native and remain + // unchanged by the provider. + { + std::vector bars = {mk_bar(1000, 100.0), mk_bar(2000, 100.0)}; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 2.0}; + + EntryFeeAccessorLifecycleProbe percent( + CommissionType::PERCENT, /*value=*/10.0, /*qty=*/1.0); + CHECK(percent.set_account_currency_fx_series(timestamps, rates, 2)); + percent.run(bars.data(), (int)bars.size()); + CHECK(std::abs(percent.observed_open_commission() - 10.0) < 1e-12); + CHECK(std::abs(percent.observed_open_profit() - (-10.0)) < 1e-12); + CHECK(percent.trades() == 1); + CHECK(std::abs(percent.trade(0).commission - 40.0) < 1e-12); + CHECK(std::abs(percent.trade(0).pnl - (-40.0)) < 1e-12); + + EntryFeeAccessorLifecycleProbe cash_order( + CommissionType::CASH_PER_ORDER, /*value=*/7.0, /*qty=*/1.0); + CHECK(cash_order.set_account_currency_fx_series(timestamps, rates, 2)); + cash_order.run(bars.data(), (int)bars.size()); + CHECK(std::abs(cash_order.observed_open_commission() - 7.0) < 1e-12); + CHECK(std::abs(cash_order.observed_open_profit() - (-7.0)) < 1e-12); + CHECK(std::abs(cash_order.trade(0).commission - 14.0) < 1e-12); + + EntryFeeAccessorLifecycleProbe cash_contract( + CommissionType::CASH_PER_CONTRACT, /*value=*/3.0, /*qty=*/2.0); + CHECK(cash_contract.set_account_currency_fx_series( + timestamps, rates, 2)); + cash_contract.run(bars.data(), (int)bars.size()); + CHECK(std::abs(cash_contract.observed_open_commission() - 6.0) + < 1e-12); + CHECK(std::abs(cash_contract.observed_open_profit() - (-6.0)) + < 1e-12); + CHECK(std::abs(cash_contract.trade(0).commission - 12.0) < 1e-12); + } + + // E4. Snapshots follow physical pyramid slices across partial exits and + // are replaced on reversal. The partial trade still uses rate-2 realized + // reporting (40), while the surviving half of the rate-1 L1 fee is 10. + { + std::vector bars = { + mk_bar(1000, 100.0), mk_bar(2000, 100.0), + mk_bar(3000, 100.0), mk_bar(4000, 100.0), + mk_bar(5000, 100.0), + }; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 2.0}; + PyramidEntryFeeLifecycleProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(std::abs(eng.before_partial_first() - 20.0) < 1e-12); + CHECK(std::abs(eng.before_partial_second() - 20.0) < 1e-12); + CHECK(std::abs(eng.after_partial_first() - 10.0) < 1e-12); + CHECK(std::abs(eng.after_partial_second() - 20.0) < 1e-12); + CHECK(std::abs(eng.partial_trade_commission() - 40.0) < 1e-12); + CHECK(eng.reversal_is_short()); + CHECK(std::abs(eng.reversal_commission() - 20.0) < 1e-12); + + // A reused engine clears all position slices, then recreates the same + // entry-time snapshots from the still-configured provider. + eng.run(bars.data(), (int)bars.size()); + CHECK(std::abs(eng.before_partial_first() - 20.0) < 1e-12); + CHECK(std::abs(eng.after_partial_first() - 10.0) < 1e-12); + CHECK(std::abs(eng.reversal_commission() - 20.0) < 1e-12); + } + + // F. A rate rollover between placement and fill does not retroactively + // reject a frozen all-in order. Admission uses the complete signal-time + // tuple (qty/equity/price/FX); post-fill affordability uses the new FX and + // trims 4x the minimum restore quantity. + { + std::vector bars = {mk_bar(1000, 100.0), mk_bar(2000, 100.0)}; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 1.001}; + FrozenFxRolloverProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == 1); + CHECK(eng.trade(0).exit_comment == std::string("Margin call")); + CHECK(std::abs(eng.trade(0).qty - 0.3992) < 1e-12); + CHECK(std::abs(eng.open_qty() - 99.5608) < 1e-12); + } + + // G. A carried-position rollover is a broker-open event. The new rate + // makes 100 units require 10010 of margin against 10000 equity; flooring + // the minimum restore quantity to 0.0999 lots and applying TV's 4x rule + // closes 0.3996 before the bar-2 script body runs. + { + std::vector bars = { + mk_bar(1000, 100.0), + mk_bar(2000, 100.0), + mk_bar(3000, 100.0), + }; + const int64_t timestamps[] = {1000, 3000}; + const double rates[] = {1.0, 1.001}; + CarriedFxRolloverOrderingProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.last_error().empty()); + CHECK(eng.observed_trades() == 1); + CHECK(std::abs(eng.observed_qty() - 99.6004) < 1e-12); + CHECK(eng.trades() == 1); + CHECK(eng.trade(0).exit_comment == std::string("Margin call")); + CHECK(eng.trade(0).exit_id == std::string("__margin_call__")); + CHECK(std::abs(eng.trade(0).qty - 0.3996) < 1e-12); + CHECK(std::abs(eng.trade(0).exit_price - 100.0) < 1e-12); + CHECK(eng.trade(0).exit_time == 3000); + CHECK(std::abs(eng.open_qty() - 99.6004) < 1e-12); + + // The consumed-epoch cursor is per-run state: a reused engine must + // reproduce the same broker-open row instead of retaining epoch 2. + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.last_error().empty()); + CHECK(eng.observed_trades() == 1); + CHECK(std::abs(eng.observed_qty() - 99.6004) < 1e-12); + CHECK(eng.trades() == 1); + } + + // G2. A real carried-rollover deficit can be smaller than one 0.0001 lot. + // TV's source-faithful crypt tape still emits a one-CONTRACT margin row at + // this discontinuity (2025-07-06 08:00), rather than one qty_step or no + // trade. The broker action must again be visible before on_bar. + { + std::vector bars = { + mk_bar(1000, 100.0), + mk_bar(2000, 100.0), + mk_bar(3000, 100.0), + }; + const int64_t timestamps[] = {1000, 3000}; + const double rates[] = {1.0, 1.0000005}; + CarriedFxRolloverOrderingProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.last_error().empty()); + CHECK(eng.observed_trades() == 1); + CHECK(std::abs(eng.observed_qty() - 99.0) < 1e-12); + CHECK(eng.trades() == 1); + CHECK(std::abs(eng.trade(0).qty - 1.0) < 1e-12); + CHECK(eng.trade(0).exit_comment == std::string("Margin call")); + CHECK(eng.trade(0).exit_time == 3000); + } + + // G3. An FX point crossed while flat is still a consumed broker event. A + // position filled later must not inherit and replay that historical event. + // The zero-fee frozen-all-in opening is exempt from a fill-time trim. A + // stale epoch would therefore be the only event capable of wrongly closing + // one contract from the new 100-contract position on bar 2. + { + std::vector bars = { + mk_bar(1000, 100.0), + mk_bar(2000, 100.0), + mk_bar(3000, 100.0), + }; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 1.001}; + FlatEpochConsumptionProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.last_error().empty()); + CHECK(eng.trades_after_fill() == 0); + CHECK(std::abs(eng.qty_after_fill() - 100.0) < 1e-12); + CHECK(eng.observed_trades() == 0); + CHECK(std::abs(eng.observed_qty() - 100.0) < 1e-12); + } + + // G4. Broker-open affordability uses the rate-1 paid entry fee after the + // provider doubles. Repricing that fee at rate 2 would emit a false 0.08 + // margin row from this deliberately chosen boundary. + { + std::vector bars = { + mk_bar(1000, 100.0), mk_bar(2000, 100.0), + }; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 2.0}; + CarriedEntryFeeSnapshotProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.observed_trades() == 0); + CHECK(std::abs(eng.observed_qty() - 2.75) < 1e-12); + } + + // G5. Post-fill affordability sums each pyramid lot's own entry-time fee. + // L1 paid 20 at rate 1 and L2 pays 10 at rate 2, keeping the 2.5-contract + // position affordable. Repricing L1 would manufacture a margin row. + { + std::vector bars = { + mk_bar(1000, 100.0), mk_bar(2000, 100.0), + mk_bar(3000, 100.0), + }; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 2.0}; + PostFillEntryFeeSnapshotProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.observed_trades() == 0); + CHECK(std::abs(eng.observed_qty() - 2.5) < 1e-12); + } + + // G6. Unsupported carried-position rate changes fail before the script + // body. A duplicate provider epoch with the same numeric rate is harmless + // and is consumed without inventing a broker event. + { + std::vector bars = { + mk_bar(1000, 100.0), mk_bar(2000, 100.0), + mk_bar(3000, 100.0), + }; + const int64_t timestamps[] = {1000, 3000}; + const double changed_rates[] = {1.0, 1.001}; + const double unchanged_rates[] = {1.0, 1.0}; + + UnsupportedCarriedFxRolloverProbe short_position( + /*is_long=*/false, /*margin_pct=*/100.0); + CHECK(short_position.set_account_currency_fx_series( + timestamps, changed_rates, 2)); + short_position.run(bars.data(), (int)bars.size()); + CHECK(short_position.last_error().find("carried 1x long") + != std::string::npos); + CHECK(short_position.on_bar_calls() == 2); + + UnsupportedCarriedFxRolloverProbe leveraged_long( + /*is_long=*/true, /*margin_pct=*/50.0); + CHECK(leveraged_long.set_account_currency_fx_series( + timestamps, changed_rates, 2)); + leveraged_long.run(bars.data(), (int)bars.size()); + CHECK(leveraged_long.last_error().find("carried 1x long") + != std::string::npos); + CHECK(leveraged_long.on_bar_calls() == 2); + + UnsupportedCarriedFxRolloverProbe same_rate_short( + /*is_long=*/false, /*margin_pct=*/100.0); + CHECK(same_rate_short.set_account_currency_fx_series( + timestamps, unchanged_rates, 2)); + same_rate_short.run(bars.data(), (int)bars.size()); + CHECK(same_rate_short.last_error().empty()); + CHECK(same_rate_short.on_bar_calls() == 3); + } + + // H. Timestamped FX is currently authoritative only on ordinary + // historical dispatch. Unsupported schedulers fail before on_bar runs. + { + std::vector bars = {mk_bar(1000, 100.0), mk_bar(2000, 100.0)}; + const int64_t timestamps[] = {1000, 2000}; + const double rates[] = {1.0, 1.001}; + + CarriedFxRolloverOrderingProbe coof; + CHECK(coof.set_account_currency_fx_series(timestamps, rates, 2)); + coof.enable_coof(); + coof.run(bars.data(), (int)bars.size()); + CHECK(coof.last_error().find("calc_on_order_fills") != std::string::npos); + CHECK(coof.on_bar_calls() == 0); + + CarriedFxRolloverOrderingProbe magnifier; + CHECK(magnifier.set_account_currency_fx_series(timestamps, rates, 2)); + magnifier.run(bars.data(), (int)bars.size(), "1", "1", true, 4, + MagnifierDistribution::ENDPOINTS); + CHECK(magnifier.last_error().find("bar magnifier") != std::string::npos); + CHECK(magnifier.on_bar_calls() == 0); + + CarriedFxRolloverOrderingProbe stream; + CHECK(stream.set_account_currency_fx_series(timestamps, rates, 2)); + CHECK(!stream.stream_begin(bars.data(), (int)bars.size(), "1", "1")); + CHECK(stream.last_error().find("streaming") != std::string::npos); + CHECK(stream.on_bar_calls() == 0); + } + std::printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; } diff --git a/tests/test_c_abi_setters.cpp b/tests/test_c_abi_setters.cpp index 1f45964..34e0e6b 100644 --- a/tests/test_c_abi_setters.cpp +++ b/tests/test_c_abi_setters.cpp @@ -6,7 +6,7 @@ * 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, and pf_version_string. + * _metadata / _account_currency_fx_series, and pf_version_string. * * Each entry point begins with a null-guard (`if (!s) return;` โ€” or * `if (!s || !arg) return;`) and then forwards to a BacktestEngine @@ -73,6 +73,15 @@ class ProbeEngine : public pineforge::BacktestEngine { const std::string& sym_session() const { return syminfo_.session; } double sym_mintick() const { return syminfo_.mintick; } double sym_pv() const { return syminfo_.pointvalue; } + std::size_t fx_points() const { + return account_currency_fx_timestamps_.size(); + } + int64_t fx_timestamp(std::size_t i) const { + return account_currency_fx_timestamps_.at(i); + } + double fx_rate(std::size_t i) const { + return account_currency_fx_rates_.at(i); + } // 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); } @@ -97,6 +106,8 @@ int main() { strategy_set_syminfo_mintick(nullptr, 0.25); strategy_set_syminfo_pointvalue(nullptr, 50.0); strategy_set_syminfo_metadata(nullptr, "shares_outstanding_total", 1.0); + CHECK(strategy_set_account_currency_fx_series( + nullptr, nullptr, nullptr, 0) == -1); CHECK(strategy_stream_begin(nullptr, nullptr, 0, "1", "1") == -1); CHECK(strategy_stream_push_tick(nullptr, nullptr) == -1); CHECK(strategy_stream_push_ticks(nullptr, nullptr, 0) == -1); @@ -181,6 +192,69 @@ int main() { // A key that was never injected still reports na. CHECK(pineforge::is_na(eng.meta("never_injected"))); + // Timestamped account-currency FX: the C ABI validates the complete input + // before replacing engine configuration, copies caller-owned arrays, and + // treats n=0 as an explicit clear. Every malformed shape fails atomically. + int64_t fx_ts[] = {1000, 2000}; + double fx_rates[] = {1.001, 1.002}; + CHECK(strategy_set_account_currency_fx_series( + h, fx_ts, fx_rates, 2) == 0); + CHECK(eng.fx_points() == 2); + CHECK(eng.fx_timestamp(0) == 1000); + CHECK(eng.fx_timestamp(1) == 2000); + CHECK(eng.fx_rate(0) == 1.001); + CHECK(eng.fx_rate(1) == 1.002); + + // Caller mutation after the setter returns cannot alter the installed + // provider; the ABI owns a deep copy rather than retaining raw pointers. + fx_ts[0] = -1; + fx_rates[0] = 99.0; + CHECK(eng.fx_timestamp(0) == 1000); + CHECK(eng.fx_rate(0) == 1.001); + + const int64_t one_ts[] = {3000}; + const double one_rate[] = {1.003}; + CHECK(strategy_set_account_currency_fx_series( + h, one_ts, one_rate, -1) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, nullptr, one_rate, 1) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, one_ts, nullptr, 1) == -1); + + const int64_t duplicate_ts[] = {3000, 3000}; + const int64_t unsorted_ts[] = {4000, 3000}; + const int64_t sorted_ts[] = {3000, 4000}; + const double two_rates[] = {1.003, 1.004}; + const double nan_rates[] = { + 1.003, std::numeric_limits::quiet_NaN()}; + const double inf_rates[] = { + 1.003, std::numeric_limits::infinity()}; + const double zero_rates[] = {1.003, 0.0}; + const double negative_rates[] = {1.003, -1.0}; + CHECK(strategy_set_account_currency_fx_series( + h, duplicate_ts, two_rates, 2) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, unsorted_ts, two_rates, 2) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, sorted_ts, nan_rates, 2) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, sorted_ts, inf_rates, 2) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, sorted_ts, zero_rates, 2) == -1); + CHECK(strategy_set_account_currency_fx_series( + h, sorted_ts, negative_rates, 2) == -1); + + // Every rejected replacement above leaves the original copied provider + // intact. Clearing is the sole successful empty-provider transition. + CHECK(eng.fx_points() == 2); + CHECK(eng.fx_timestamp(0) == 1000); + CHECK(eng.fx_timestamp(1) == 2000); + CHECK(eng.fx_rate(0) == 1.001); + CHECK(eng.fx_rate(1) == 1.002); + CHECK(strategy_set_account_currency_fx_series( + h, nullptr, nullptr, 0) == 0); + CHECK(eng.fx_points() == 0); + // โ”€โ”€ Historical -> realtime lifecycle wrappers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ProbeEngine stream_eng; pf_strategy_t sh = static_cast(&stream_eng); @@ -190,6 +264,14 @@ int main() { warmup.timestamp = 0; CHECK(strategy_stream_begin(sh, &warmup, 1, "1", "1") == 0); + // Timestamped FX is not supported by the realtime scheduler. Installing a + // curve after stream_begin must fail atomically; otherwise callers could + // bypass the begin-time fail-closed check. + const int64_t stream_fx_ts[] = {0}; + const double stream_fx_rates[] = {1.001}; + CHECK(strategy_set_account_currency_fx_series( + sh, stream_fx_ts, stream_fx_rates, 1) == -1); + pf_trade_tick_t tick{}; tick.timestamp = 60010; tick.sequence = 7; diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index f6c8097..cf9247b 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -734,6 +734,37 @@ static void test_fee_created_floor_zero_caps_sub_one_position() { CHECK(near(eng.position_size(), 0.0)); } +static void test_fee_created_sub_half_cent_deficit_respects_fx_ledger() { + std::printf("test_fee_created_sub_half_cent_deficit_respects_fx_ledger\n"); + std::vector bars = { + mk_bar(1000, 1500.0, 1500.0, 1500.0, 1500.0, 1.0), + mk_bar(2000, 1500.005, 1500.005, 1500.005, 1500.005, 1.0), + }; + // q=0.666 leaves enough lot-floor headroom that the adverse fill creates + // only a $0.00233 post-fee deficit. A same-currency broker compares the + // raw amounts and applies the one-contract fallback (capped to the full + // sub-one position). + CommissionedDefaultPoeDustProbe same_currency( + /*initial_capital=*/1000.0, /*qty_step=*/0.0001); + same_currency.run(bars.data(), (int)bars.size()); + CHECK(same_currency.trade_count() == 1); + CHECK(near(same_currency.trade_size(0), 0.666)); + CHECK(near(same_currency.position_size(), 0.0)); + + // A configured quote->account provider selects TV's converted cent ledger. + // Both bars use rate 1 so conversion lifecycle, not rate magnitude, is the + // sole factor. The sub-half-cent remainder stays affordable. + CommissionedDefaultPoeDustProbe converted_currency( + /*initial_capital=*/1000.0, /*qty_step=*/0.0001); + const int64_t timestamps[] = {0}; + const double rates[] = {1.0}; + CHECK(converted_currency.set_account_currency_fx_series( + timestamps, rates, 1)); + converted_currency.run(bars.data(), (int)bars.size()); + CHECK(converted_currency.trade_count() == 0); + CHECK(near(converted_currency.position_size(), 0.666)); +} + static void test_fee_created_nonzero_floor_keeps_four_x_quantity() { std::printf("test_fee_created_nonzero_floor_keeps_four_x_quantity\n"); std::vector bars = { @@ -1882,6 +1913,7 @@ int main() { test_paired_short_close_default_long_gap_remains_eligible(); test_fee_created_floor_zero_closes_one_contract(); test_fee_created_floor_zero_caps_sub_one_position(); + test_fee_created_sub_half_cent_deficit_respects_fx_ledger(); test_fee_created_nonzero_floor_keeps_four_x_quantity(); test_fee_created_floor_zero_rejects_off_grid_one_contract(); test_cash_per_order_floor_zero_stays_outside_percent_fee_rule();