diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index a730913..1863856 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -322,6 +322,17 @@ class BacktestEngine { double margin_long_ = 100.0; double margin_short_ = 100.0; + // Account-currency FX multiplier for the broker affordability gate. 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 + // the quote currency, converting the latter via the account-currency FX + // 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. + double account_currency_fx_ = 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 // a no-op for the validation corpus (long-only positions at the default @@ -1570,6 +1581,14 @@ class BacktestEngine { qty_step_ = (std::isfinite(value) && value > 0.0) ? value : 0.0; syminfo_.qty_step = qty_step_; } + // Account-currency FX rate (account-currency units per quote-currency + // unit). Scales the broker affordability gate's required_margin when + // the script's currency differs from the symbol quote currency. A + // non-positive / non-finite value resets to the 1.0 (no-op) default. + if (key == "account_currency_fx") { + account_currency_fx_ = + (std::isfinite(value) && value > 0.0) ? value : 1.0; + } } // Returns the script's active timeframe string (e.g. "15" for 15-minute, diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 5cebcff..10f65b7 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -127,8 +127,13 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) { // Position value in account currency includes the futures // point-value multiplier (1.0 for crypto/equity — unchanged). + // The notional is in the symbol's quote currency; convert it to the + // account currency (FX 1.0 unless the script declared a differing + // currency=) so it is comparable to equity, which is denominated in + // the account currency. Default 1.0 leaves the corpus untouched. double required_margin = std::abs(qty) * current_bar_.close * syminfo_.pointvalue + * account_currency_fx_ * (margin_pct / 100.0); double available_equity = current_equity(); double epsilon = std::max(1e-9, std::abs(available_equity) * 1e-12); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 216b85c..1eb514e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -43,6 +43,7 @@ set(TEST_SOURCES test_get_input_int64 test_get_input_source test_syminfo_metadata + test_affordability_fx test_determinism_reproducibility test_exit_path_segment_tiebreak test_accounting_reconciliation diff --git a/tests/test_affordability_fx.cpp b/tests/test_affordability_fx.cpp new file mode 100644 index 0000000..9d89501 --- /dev/null +++ b/tests/test_affordability_fx.cpp @@ -0,0 +1,114 @@ +/* + * test_affordability_fx.cpp — account-currency FX on the broker affordability + * gate. + * + * The market-entry affordability gate admits an order only when + * required_margin = qty * close * pointvalue * fx * (margin_pct/100) <= equity. + * When a script declares currency=currency.XXX differing from the symbol's + * quote currency (e.g. currency.INR on a USDT-quoted perp), TradingView keeps + * equity in the account currency but converts the quote-currency notional via + * the account-currency FX rate before this comparison. The engine exposes that + * rate through the syminfo-metadata channel ("account_currency_fx"); it + * defaults to 1.0 (no-op) so the validation corpus is byte-identical. + * + * This pins: + * A. FX 1.0 (default): a qty-1 long whose notional (600) fits inside equity + * (1000) is ACCEPTED -> 1 closed trade. + * B. FX 2.0: the same notional scales to 1200 > 1000 and the entry is + * REJECTED -> 0 trades. Proves the FX factor reaches required_margin. + * C. A non-positive / non-finite FX resets to the 1.0 default (accepted). + */ + +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double c) { + Bar b; + b.open = c; b.high = c; b.low = c; b.close = c; b.volume = 1.0; b.timestamp = ts; + return b; +} + +namespace { + +// Enters one fixed-size long market order on bar 0 (fills at the bar close +// because process_orders_on_close is on), then closes it on bar 1. Whether the +// entry survives the affordability gate is observable as trade_count() == 1 (or +// 0 if rejected). +class FxProbe : public BacktestEngine { +public: + explicit FxProbe(double fx_or_nan) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + 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)) + set_syminfo_metadata("account_currency_fx", fx_or_nan); + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 1.0); // qty=1 market long + else if (bar_index_ == 1) + strategy_close("L"); + } + int trades() const { return trade_count(); } +}; + +void run_case(double fx, int expected_trades, const char* label) { + std::vector bars = { + mk_bar(1000, 600.0), // 0: long fills @600, notional = 1*600 = 600 + mk_bar(2000, 600.0), // 1: close + }; + FxProbe eng(fx); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == expected_trades); + std::printf(" %s: fx=%.2f trades=%d (expected %d)\n", label, fx, + eng.trades(), expected_trades); +} + +} // namespace + +int main() { + std::printf("--- affordability_fx ---\n"); + // A. Default FX (1.0): notional 600 <= equity 1000 -> accepted. + run_case(1.0, 1, "fx=1 accepts"); + // Same as default when no metadata is injected at all. + { + std::vector bars = {mk_bar(1000, 600.0), mk_bar(2000, 600.0)}; + FxProbe eng(kNaN); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trades() == 1); + std::printf(" no-fx (default 1.0): trades=%d (expected 1)\n", eng.trades()); + } + // B. FX 2.0: required_margin = 600*2 = 1200 > 1000 -> rejected. + run_case(2.0, 0, "fx=2 rejects"); + // C. Non-positive FX resets to 1.0 default -> accepted. + run_case(-5.0, 1, "fx<=0 resets to 1.0"); + + std::printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}