From 447726a6a4112b67dde3ab5165a70aae9ccf8243 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Fri, 31 Jul 2026 04:17:42 +0800 Subject: [PATCH] campaign/p0-ship-fx-stack-20260731: stack FX series loader + short 1x rollover Ship lead-adjudicated ACCEPT_SHIP stack (update seq 392, sb:59078b18c459): - task-76: engine account-currency FX series JSON loader (run_strategy.py) - task-79 A1: short 1x FX rollover fix (engine fills/run + affordability tests) Gate: pr-gate PASS 14 UP > 6 DOWN (+briancurry missing as DOWN => 14>7) vs pre-FX-wire baseline; test_affordability_fx 128/0. --- include/pineforge/engine.hpp | 8 +- scripts/run_strategy.py | 81 +++++++++++++- src/engine_fills.cpp | 72 +++++++----- src/engine_run.cpp | 2 +- tests/test_affordability_fx.cpp | 192 ++++++++++++++++++++++++++++++-- 5 files changed, 312 insertions(+), 43 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 3a5340c..7782008 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1295,10 +1295,10 @@ class BacktestEngine { // 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); + // adverse-price check. Cell A1 supports carried 1x full-margin long and + // short in ordinary historical dispatch; leveraged shapes stay fail-closed. + // Returns true when it emits a broker liquidation row. + bool process_carried_position_fx_rollover(const Bar& bar); // --- Slippage helper --- double round_to_mintick(double price) const { diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index 3886b91..daebb3a 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -812,6 +812,72 @@ def find_strategy_lib(strategy_dir: Path, so_name: str = "strategy.so") -> Path: return so_path +def _load_account_currency_fx_series_json(path: Path): + """Load a bar-grain effective-time FX series JSON. + + Accepted shapes (effective timestamps already resolved by the producer): + {"timestamps_ms": [int, ...], "rates": [float, ...], ...} + {"points": [{"t"|"timestamp_ms"|"timestamp": int, "rate"|"c2a1": float}, ...]} + + Unlike the daily-close loader, this path does **not** apply a D+1 shift — + the file is the production as-of curve copied straight into the C ABI. + """ + raw_bytes = path.read_bytes() + payload = json.loads(raw_bytes) + points = [] + if isinstance(payload, dict) and isinstance(payload.get("timestamps_ms"), list) and isinstance(payload.get("rates"), list): + timestamps = payload["timestamps_ms"] + rates = payload["rates"] + if len(timestamps) != len(rates) or not timestamps: + raise ValueError( + f"account_currency_fx_series_json timestamps/rates mismatch: {path}") + for ts, rate in zip(timestamps, rates): + try: + t_ms = int(ts) + r = float(rate) + except (TypeError, ValueError) as exc: + raise ValueError( + f"invalid account-currency FX series point {ts!r}: {rate!r}" + ) from exc + if isinstance(rate, bool) or not math.isfinite(r) or r <= 0.0: + raise ValueError( + f"invalid account-currency FX series rate {ts!r}: {rate!r}") + points.append((t_ms, r)) + elif isinstance(payload, dict) and isinstance(payload.get("points"), list): + if not payload["points"]: + raise ValueError( + f"account_currency_fx_series_json points must be non-empty: {path}") + for item in payload["points"]: + if not isinstance(item, dict): + raise ValueError( + f"invalid account-currency FX series point {item!r}: {path}") + raw_t = item.get("t", item.get("timestamp_ms", item.get("timestamp"))) + raw_r = item.get("rate", item.get("c2a1")) + try: + t_ms = int(raw_t) + r = float(raw_r) + except (TypeError, ValueError) as exc: + raise ValueError( + f"invalid account-currency FX series point {item!r}" + ) from exc + if isinstance(raw_r, bool) or not math.isfinite(r) or r <= 0.0: + raise ValueError( + f"invalid account-currency FX series rate {item!r}") + points.append((t_ms, r)) + else: + raise ValueError( + "account_currency_fx_series_json must contain timestamps_ms+rates " + f"or points[]: {path}") + points.sort() + if any(points[i][0] <= points[i - 1][0] for i in range(1, len(points))): + raise ValueError( + f"account_currency_fx_series_json timestamps must be strictly increasing: {path}") + return ( + ([point[0] for point in points], [point[1] for point in points]), + hashlib.sha256(raw_bytes).hexdigest(), + ) + + def _load_account_currency_fx_daily_closes(path: Path): """Load ``{"YYYY-MM-DD": close}`` and shift each close to D+1 00:00Z. @@ -924,9 +990,22 @@ def _num(v): fx_series = None fx_source_sha256 = None + fx_series_json = runtime_overrides.get( + "account_currency_fx_series_json") fx_daily_json = runtime_overrides.get( "account_currency_fx_daily_close_json") - if fx_daily_json: + if fx_series_json and fx_daily_json: + raise ValueError( + "runtime_overrides may set only one of " + "account_currency_fx_series_json or " + "account_currency_fx_daily_close_json") + if fx_series_json: + fx_path = Path(str(fx_series_json)) + if not fx_path.is_absolute(): + fx_path = (strategy_dir / fx_path).resolve() + fx_series, fx_source_sha256 = \ + _load_account_currency_fx_series_json(fx_path) + elif fx_daily_json: fx_path = Path(str(fx_daily_json)) if not fx_path.is_absolute(): fx_path = (strategy_dir / fx_path).resolve() diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 6108a0c..a542657 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -501,11 +501,18 @@ 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) { +// Timestamped quote->account FX rollover for a carried full-margin position. +// 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. Cell A1: 1x long (bit-stable) + 1x short. Leveraged long/short +// stay fail-closed until a TV pin (cells L/R). +bool BacktestEngine::process_carried_position_fx_rollover(const Bar& bar) { + // Capability flags for the broker-open FX rollover matrix. Short 1x is + // the dual of the TV-pinned long path; leveraged cells remain off. + static constexpr bool kEnableShortFxRollover = true; + static constexpr bool kEnableLeveragedLongFxRollover = false; + static constexpr bool kEnableLeveragedShortFxRollover = false; + if (account_currency_fx_timestamps_.empty()) return false; const auto effective_end = std::upper_bound( @@ -536,30 +543,34 @@ bool BacktestEngine::process_carried_long_full_margin_fx_rollover( 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 is_long = position_side_ == PositionSide::LONG; + const double margin_pct = is_long ? margin_long_ : margin_short_; + const bool full_margin = std::isfinite(margin_pct) + && std::abs(margin_pct / 100.0 - 1.0) < 1e-12; + const bool leveraged = std::isfinite(margin_pct) + && margin_pct > 0.0 + && !full_margin; const bool supported_carried_rollover = - position_side_ == PositionSide::LONG - && std::isfinite(margin_long_) - && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + margin_call_enabled_ + && carried_position + && ((is_long + && (full_margin || (leveraged && kEnableLeveragedLongFxRollover))) + || (!is_long + && kEnableShortFxRollover + && (full_margin + || (leveraged && kEnableLeveragedShortFxRollover)))); if (effective_rate != previous_rate && margin_call_enabled_ && carried_position - && std::isfinite(carried_margin) - && carried_margin > 0.0 + && std::isfinite(margin_pct) + && margin_pct > 0.0 && !supported_carried_rollover) { throw std::runtime_error( "timestamped account-currency FX broker-open rollover supports " - "only carried 1x long positions"); + "only carried 1x full-margin 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 + if (!supported_carried_rollover || !std::isfinite(previous_rate) || !(previous_rate > 0.0) || !std::isfinite(effective_rate) || !(effective_rate > 0.0) || effective_rate == previous_rate @@ -569,18 +580,23 @@ bool BacktestEngine::process_carried_long_full_margin_fx_rollover( const double qty = position_qty_; const double pv = syminfo_.pointvalue; + const double side = is_long ? 1.0 : -1.0; + const double m = margin_pct / 100.0; if (!std::isfinite(qty) || !(qty > 0.0) || !std::isfinite(position_entry_price_) || !std::isfinite(pv) + || !std::isfinite(m) || !(m > 0.0) || !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. + // TV revalues a carried full-margin position 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. MTM uses side (+1 long / -1 short); + // margin_unit scales by m (1.0 for full margin). double entry_commission = 0.0; for (const auto& pe : pyramid_entries_) { if (pe.qty <= kQtyEpsilon) continue; @@ -588,12 +604,14 @@ bool BacktestEngine::process_carried_long_full_margin_fx_rollover( if (!std::isfinite(lot_commission)) return false; entry_commission += lot_commission; } - const double margin_per_unit = bar.open * pv * effective_rate; + const double margin_per_unit = bar.open * pv * effective_rate * m; + const double mtm = side * (bar.open - position_entry_price_) + * qty * pv * effective_rate; const double opening_equity = initial_capital_ + net_profit_sum_ - - entry_commission - + (bar.open - position_entry_price_) * qty * pv * effective_rate; + - entry_commission + mtm; if (!std::isfinite(entry_commission) || !std::isfinite(margin_per_unit) || !(margin_per_unit > 0.0) + || !std::isfinite(mtm) || !std::isfinite(opening_equity)) { return false; } diff --git a/src/engine_run.cpp b/src/engine_run.cpp index bef015f..134d089 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -117,7 +117,7 @@ void BacktestEngine::dispatch_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); + process_carried_position_fx_rollover(script_bar); } catch (...) { current_bar_ = script_bar; throw; diff --git a/tests/test_affordability_fx.cpp b/tests/test_affordability_fx.cpp index 1be9e6f..bb6cb34 100644 --- a/tests/test_affordability_fx.cpp +++ b/tests/test_affordability_fx.cpp @@ -145,9 +145,10 @@ class CarriedFxRolloverOrderingProbe : public BacktestEngine { 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. +// Leveraged carried shapes 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. (1x short is +// supported by cell A1; keep this probe for leveraged-only fail-closed.) class UnsupportedCarriedFxRolloverProbe : public BacktestEngine { public: UnsupportedCarriedFxRolloverProbe(bool is_long, double margin_pct) @@ -173,6 +174,48 @@ class UnsupportedCarriedFxRolloverProbe : public BacktestEngine { int on_bar_calls_ = 0; }; +// Cell A1 dual of CarriedFxRolloverOrderingProbe: carried 1x short under a +// timestamped FX epoch change. Mirrors long sizing (qty=100, capital=10000). +class CarriedShortFxRolloverProbe : public BacktestEngine { +public: + CarriedShortFxRolloverProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 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("S", /*is_long=*/false, kNaN, kNaN, 100.0); + } else if (bar_index_ == 2) { + observed_qty_ = position_side_ == PositionSide::SHORT + ? position_qty_ : 0.0; + observed_trades_ = trade_count(); + observed_side_short_ = position_side_ == PositionSide::SHORT; + } + } + int on_bar_calls() const { return on_bar_calls_; } + int observed_trades() const { return observed_trades_; } + double observed_qty() const { return observed_qty_; } + bool observed_side_short() const { return observed_side_short_; } + int trades() const { return trade_count(); } + double open_qty() const { + return position_side_ == PositionSide::SHORT ? position_qty_ : 0.0; + } + const Trade& trade(int i) const { return get_trade(i); } + +private: + int on_bar_calls_ = 0; + int observed_trades_ = -1; + double observed_qty_ = kNaN; + bool observed_side_short_ = false; +}; + // 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 @@ -665,9 +708,10 @@ int main() { 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. + // G6. Leveraged carried-position rate changes still fail before the + // script body (cells L/R off). 1x short is now supported (cell A1) and + // must not throw. 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), @@ -677,21 +721,23 @@ int main() { const double changed_rates[] = {1.0, 1.001}; const double unchanged_rates[] = {1.0, 1.0}; + // G6-short: carried 1x short + rate change — no throw (qty=1 stays + // affordable against 10000 equity). 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); + CHECK(short_position.last_error().empty()); + CHECK(short_position.on_bar_calls() == 3); + // G6-lev-long: leveraged long remains fail-closed. 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") + CHECK(leveraged_long.last_error().find("1x full-margin") != std::string::npos); CHECK(leveraged_long.on_bar_calls() == 2); @@ -704,6 +750,132 @@ int main() { CHECK(same_rate_short.on_bar_calls() == 3); } + // NEW-S1. Carried 1x short under a rate shock that forces a deficit: + // dual of G — required 10010 vs equity 10000 → floor q_min=0.0999 → + // 4x rule closes 0.3996 before on_bar 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, 3000}; + const double rates[] = {1.0, 1.001}; + CarriedShortFxRolloverProbe 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.observed_side_short()); + 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); + } + + // NEW-S2. Carried 1x short under a rate change that stays affordable + // (rate falls → required margin shrinks at flat price): no broker trade. + { + 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, 0.999}; + CarriedShortFxRolloverProbe 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() == 0); + CHECK(std::abs(eng.observed_qty() - 100.0) < 1e-12); + CHECK(eng.observed_side_short()); + CHECK(eng.trades() == 0); + CHECK(std::abs(eng.open_qty() - 100.0) < 1e-12); + } + + // NEW-S3. Flat-epoch consumption still holds for a subsequent short + // open: an FX point crossed while flat must not replay against the new + // short on a later bar. Mirrors G3 (FlatEpochConsumptionProbe) on the + // short side — signal bar0 under rate 1.0, fill next open after the + // broker consumed rate 1.001 while still flat. + { + 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}; + class FlatEpochShortProbe : public BacktestEngine { + public: + FlatEpochShortProbe() { + 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; + margin_short_ = 100.0; + qty_step_ = 0.1; + process_orders_on_close_ = false; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("S", /*is_long=*/false); + } else if (bar_index_ == 1) { + // Observed mid-bar after the open fill; end-of-bar + // post-fill affordability may still nibble once (long + // zero-fee frozen all-in is exempt; short is not). + qty_after_fill_ = position_side_ == PositionSide::SHORT + ? position_qty_ : 0.0; + trades_after_fill_ = trade_count(); + } else if (bar_index_ == 2) { + observed_qty_ = position_side_ == PositionSide::SHORT + ? position_qty_ : 0.0; + observed_trades_ = trade_count(); + still_short_ = position_side_ == PositionSide::SHORT; + } + } + 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_; } + bool still_short() const { return still_short_; } + int trades() const { return trade_count(); } + const Trade& trade(int i) const { return get_trade(i); } + private: + double qty_after_fill_ = kNaN; + int trades_after_fill_ = -1; + double observed_qty_ = kNaN; + int observed_trades_ = -1; + bool still_short_ = false; + }; + FlatEpochShortProbe eng; + CHECK(eng.set_account_currency_fx_series(timestamps, rates, 2)); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.last_error().empty()); + // Flat epoch was consumed while flat: fill lands full size. + CHECK(eng.trades_after_fill() == 0); + CHECK(std::abs(eng.qty_after_fill() - 100.0) < 1e-12); + // No stale FX broker-open liquidation on bar 2 (ts=3000). Any + // post-fill affordability nibble is on the fill bar (ts=2000). + bool stale_epoch_trim = false; + for (int i = 0; i < eng.trades(); ++i) { + if (eng.trade(i).exit_time == 3000 + && eng.trade(i).exit_comment == std::string("Margin call")) { + stale_epoch_trim = true; + } + } + CHECK(!stale_epoch_trim); + CHECK(eng.still_short()); + CHECK(eng.observed_qty() > 0.0); + } + // H. Timestamped FX is currently authoritative only on ordinary // historical dispatch. Unsupported schedulers fail before on_bar runs. {