diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 5a303d6..d6c6abb 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -93,12 +93,14 @@ enum class PositionSide { FLAT, LONG, SHORT }; // the fl(1e-5) product carries). Rules 2 and 5 judge a TRUE-FLAT // placement and a bare strategy.entry REVERSAL, both on the frozen // signal-close equity E_s (4782/4782 taro + every-bar decisions; the -// fill-marked equity fails 813). An entry placed while a position was -// open that fills flat behind a same-bar strategy.close is NOT judged: -// TradingView fills it and margin-calls the deficit (demete1226 -// 2025-04-04 02:30Z, 9.14 USD short after a one-pip gap down: 6008.48 -// 'Margin call'; 2025-04-07 08:00Z, E_s in the tie band, a six-pip gap -// up: 1 unit + 13681.2 'Margin call'). Pinned on 507 famr3 sweep +// fill-marked equity fails 813). An opposite entry placed AFTER a +// same-bar strategy.close and filling from flat takes rule 2 only +// (round 12 AG-C1, six famag-C-cf-d tapes: four rounded-cost drops, +// two admits). Rule 5 is excluded for that ordering; a deficit that +// appears only at the fill is margin-called (demete1226 2025-04-04 +// 02:30Z, 9.14 USD short after a one-pip gap down: 6008.48 'Margin call'; +// 2025-04-07 08:00Z, E_s in rule 5's tie band, a six-pip gap up: +// 1 unit + 13681.2 'Margin call'). Pinned on 507 famr3 sweep // decisions // (famr3-F6: 153 bare-capital longs at C = sig10(cost) + 0.0002, 83 // filled / 70 dropped, a pure function of the close; F7 7-digit ties diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 2d13e81..7e44b5b 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1031,7 +1031,15 @@ void BacktestEngine::process_margin_call(const Bar& bar) { || !std::isfinite(opening_equity)) { return; } - const double required_margin = qty * margin_per_unit; + const double exact_required_margin = qty * margin_per_unit; + // Round 12 AG-C2: required money at the opening checkpoint uses the + // same ten-digit mark as the adverse checkpoint. May28 TV restores + // 62.32 instead of 62.28; June9's +0.000148 rounded deficit takes the + // existing one-contract fallback, then the ordinary adverse retry. + // The helper preserves exact required money outside its established + // same-currency / sub-account-unit lot scope. + const double required_margin = + tv_money_required_margin(exact_required_margin, opening_mark); // TV's converted account-currency broker ledger is cent-rounded, so a // post-fee deficit below half a cent is not a real deficit there: an // exported converted-USD tape does not act on a ~$0.0025 conversion @@ -1056,7 +1064,12 @@ void BacktestEngine::process_margin_call(const Bar& bar) { if (long_full_margin) tv_money_long_margin_call(bar); return; } - q_min = qty - opening_equity / margin_per_unit; + // Use the same required money to decide a trim and compute its + // restore amount. Keep the historical arithmetic byte-for-byte + // when rounding has no effect, including every out-of-scope ledger. + q_min = required_margin == exact_required_margin + ? qty - opening_equity / margin_per_unit + : (required_margin - opening_equity) / margin_per_unit; raw_exit_fill_base = opening_event_raw_fill_base; } else { // finding-308: a chronological pre-exit slice already consumed this @@ -1847,7 +1860,11 @@ bool BacktestEngine::margin_call_1x_long_opening_slice_before_priced_exit( || !std::isfinite(opening_equity)) { return false; } - const double required_margin = qty * margin_per_unit; + const double exact_required_margin = qty * margin_per_unit; + // This is the same opening checkpoint before a priced exit: required + // money and restore arithmetic must match process_margin_call. + const double required_margin = tv_money_required_margin( + exact_required_margin, position_entry_price_); // Cent-rounded converted-ledger affordability tolerance — identical to // the end-of-bar opening branch (identically zero for same-currency // strategies). @@ -1858,7 +1875,9 @@ bool BacktestEngine::margin_call_1x_long_opening_slice_before_priced_exit( if (opening_equity >= required_margin - converted_ledger_guard) { return false; } - double q_min = qty - opening_equity / margin_per_unit; + double q_min = required_margin == exact_required_margin + ? qty - opening_equity / margin_per_unit + : (required_margin - opening_equity) / margin_per_unit; if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return false; // Slice quantity: floor-before-4x plus the opening-event sub-lot @@ -4649,26 +4668,26 @@ void BacktestEngine::apply_filled_order_to_state( const bool reversal_entry = position_side_ != PositionSide::FLAT && position_side_ != requested_side; - // The broker judges a TRUE-FLAT placement (nothing held when the - // order was placed) and a bare REVERSAL (one strategy.entry against - // the held side; the position is still open at this fill). An entry - // placed while a position was open and filling FLAT because a - // separate close order of the same bar took the position first (the - // strategy.close + strategy.entry pair, created_after_position_ - // close_in_bar) is NOT judged: TradingView fills it and lets the - // margin-call machinery absorb the deficit — demete1226 2025-04-04 - // 02:30Z (long 913809.48 closed one pip below the signal close, the - // short 913809.48 costs 9.14 USD more than the cash left: filled, - // 6008.48 'Margin call' at 1.10718) and 2025-04-07 08:00Z (E_s in - // the rule-5 tie band, the open six pips up: filled, 1 unit + 13681.2 - // 'Margin call'). Judging that shape on E_s dropped the 08:00Z short - // (8a4b831), judging it on the post-close cash dropped every gap-down - // pair (ae1d99a, demete 99.2 -> 69.6 %); the bare-entry scripts and - // every from-flat pin are untouched either way. + // Both rules judge a TRUE-FLAT placement and a bare REVERSAL. + // Round 12 AG-C1 (log-20260906t001223z-0fda20b7): an opposite entry + // placed AFTER a same-bar strategy.close, filling from flat, takes + // rule 2 only. Six famag-C-cf-d tapes isolate the rounded SIGNAL + // cost: deltas +0..+0.0003 below its next money unit drop the new + // entry, while -0.0003 (one lot less) and +0.0005 admit. The separate + // close still fills. Rule 5 must remain excluded (B-z-tie-cf). + // Do not judge the post-close cash: demete1226 2025-04-04 02:30Z + // passes at the signal then has a 9.14 USD fill-gap deficit, which + // TV trims (6008.48 'Margin call'); 04-07 08:00Z passes rule 2 but + // sits in rule 5's band and also fills, with 1 + 13681.2 trimmed. const bool true_flat_placement = order.created_position_side == PositionSide::FLAT && !order.created_after_position_close_in_bar; - if (((flat_open && true_flat_placement) || reversal_entry) + const bool close_first_flat_open = + flat_open && order.created_after_position_close_in_bar + && order.created_position_side != PositionSide::FLAT + && order.created_position_side != requested_side; + if (((flat_open && true_flat_placement) || reversal_entry + || close_first_flat_open) && std::isfinite(margin_dir) && std::abs(margin_dir - 100.0) < 1e-12) { const double fx_s = std::isfinite(order.sizing_fx) && order.sizing_fx > 0.0 @@ -4690,7 +4709,7 @@ void BacktestEngine::apply_filled_order_to_state( return; } order.affordability_close_only = true; - } else { + } else if (!close_first_flat_open) { // Rule 5: the price-scale margin check (comment above). const double notional_per_price = order.frozen_default_qty * syminfo_.pointvalue * fx_s; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e12804d..bf87d1a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -140,6 +140,8 @@ set(TEST_SOURCES test_tv_money_band test_famag_close_survives test_close_percent_calltime_basis + test_famag_close_first_admission + test_famag_opening_money test_live_position_market_gross_admission test_lower_tf_parse_extra test_ta_ma_warmup_extra diff --git a/tests/test_famag_close_first_admission.cpp b/tests/test_famag_close_first_admission.cpp new file mode 100644 index 0000000..cd7d509 --- /dev/null +++ b/tests/test_famag_close_first_admission.cpp @@ -0,0 +1,171 @@ +/* + * Close-first all-in admission (round 12 AG-C1): strategy.close(current) + * followed by strategy.entry(opposite) still checks the rounded SIGNAL + * cost (rule 2), but does not take the price-scale whole-drop (rule 5). + * + * Six TradingView sensors, famag-C-cf-d{-3,+0,+1,+2,+3,+5}, pin the capital + * boundary at signal close 1.13384 after short 870000 @ 1.13523. Their exact + * declared capitals are used below in a synthetic five-bar fixture; this + * is not a corpus, feed, or grader replay. Source and TV tape readback: + * campaign log-20260906t001223z-0fda20b7. B-z-tie-cf separately proves that + * passing rule 2 must not activate rule 5 for this ordering. The gap control + * protects the existing demete1226 contract: judge frozen signal equity, + * then let a fill-time deficit be trimmed rather than declining the entry. + */ +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int passed = 0; +static int failed = 0; +#define CHECK(expr) do { \ + if (expr) { ++passed; } else { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); ++failed; \ + } \ +} while (0) + +namespace { +constexpr double kSeedPrice = 1.13523; +constexpr double kSignalPrice = 1.13384; +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +enum class Ordering { CloseFirst, EntryFirst }; + +class CloseFirstProbe : public BacktestEngine { +public: + CloseFirstProbe(double capital, Ordering ordering = Ordering::CloseFirst, + bool seed_long = false) + : ordering_(ordering), seed_long_(seed_long) { + initial_capital_ = capital; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = margin_short_ = 100.0; + pyramiding_ = 1; + slippage_ = 0; + syminfo_.pointvalue = 1.0; + set_syminfo_mintick(0.00001); + qty_step_ = 0.01; + process_orders_on_close_ = false; + set_margin_call_enabled(true); + } + + double fill_position = kNaN; + double settled_position = kNaN; + double frozen_qty = kNaN; + double signal_equity = kNaN; + double signal_price = kNaN; + int margin_rows = 0; + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("Seed", seed_long_, kNaN, kNaN, 870000.0); + } else if (bar_index_ == 1) { + if (ordering_ == Ordering::CloseFirst) strategy_close("Seed"); + strategy_entry("Next", !seed_long_); + for (const PendingOrder& order : pending_orders_) { + if (order.id != "Next") continue; + frozen_qty = order.frozen_default_qty; + signal_equity = order.sizing_equity; + signal_price = order.sizing_price; + } + if (ordering_ == Ordering::EntryFirst) strategy_close("Seed"); + } else if (bar_index_ == 2) { + fill_position = signed_position_size(); + } else if (bar_index_ == 3) { + settled_position = signed_position_size(); + for (const Trade& trade : trades_) { + if (trade.exit_comment == "Margin call") ++margin_rows; + } + strategy_close_all(); + } + } + +private: + Ordering ordering_; + bool seed_long_; +}; + +void execute(CloseFirstProbe& engine, double fill = kSignalPrice) { + const std::vector bars = { + {kSeedPrice, kSeedPrice, kSeedPrice, kSeedPrice, 1, 1000}, + {kSeedPrice, kSeedPrice, kSignalPrice, kSignalPrice, 1, 2000}, + {fill, fill, fill, fill, 1, 3000}, + {fill, fill, fill, fill, 1, 4000}, + {fill, fill, fill, fill, 1, 5000}, + }; + engine.run(bars.data(), static_cast(bars.size())); +} + +void six_pinned_capitals() { + struct Case { double capital; double expected; }; + const Case cases[] = { + {998790.990214, 881958.90}, // d-3: one lot below the rounded-cost tie + {998790.990514, 0.0}, // d+0..3: E_s below sig10(cost) + {998790.990614, 0.0}, + {998790.990714, 0.0}, + {998790.990814, 0.0}, + {998790.991014, 881958.91}, // d+5: E_s covers the rounded cost + }; + for (const Case& c : cases) { + CloseFirstProbe engine(c.capital); + execute(engine); + std::printf("capital %.6f: position %.2f, expected %.2f\n", + c.capital, engine.fill_position, c.expected); + CHECK(std::abs(engine.fill_position - c.expected) < 1e-6); + CHECK(engine.margin_rows == 0); + CHECK((engine.signal_equity + 1e-9 + < tv_money_round(engine.frozen_qty * engine.signal_price)) + == (c.expected == 0.0)); + } +} + +void rule5_is_ordering_specific() { + // B-z-tie-cf: same money passes rule 2 but fails rule 5. Close-first + // admits; entry-first drops the reversal while its separate close fills. + CloseFirstProbe close_first(998790.695916); + execute(close_first); + CHECK(close_first.signal_equity + 1e-9 >= tv_money_round( + close_first.frozen_qty * close_first.signal_price)); + CHECK(tv_money_round(tv_money_round(close_first.signal_equity) + / close_first.frozen_qty) < close_first.signal_price); + CHECK(std::abs(close_first.fill_position - 881958.65) < 1e-6); + + CloseFirstProbe entry_first(998790.695916, Ordering::EntryFirst); + execute(entry_first); + CHECK(entry_first.fill_position == 0.0); +} + +void short_direction_and_fill_gap_controls() { + // Mirrored money boundary: the close survives and the short is declined. + CloseFirstProbe short_drop(1001209.590514, Ordering::CloseFirst, true); + execute(short_drop); + CHECK(short_drop.fill_position == 0.0); + + // Rule 2 passes at the signal, then the gap worsens both the short's + // closing equity and the new long's cost. The entry must fill and trim. + CloseFirstProbe gap(998790.991014); + execute(gap, 1.13394); + CHECK(gap.signal_equity + 1e-9 >= tv_money_round( + gap.frozen_qty * gap.signal_price)); + CHECK(gap.fill_position > 0.0); + CHECK(gap.settled_position > 0.0); + CHECK(gap.settled_position < gap.frozen_qty); + CHECK(gap.margin_rows > 0); +} +} // namespace + +int main() { + six_pinned_capitals(); + rule5_is_ordering_specific(); + short_direction_and_fill_gap_controls(); + std::printf("%d passed, %d failed\n", passed, failed); + return failed == 0 ? 0 : 1; +} diff --git a/tests/test_famag_opening_money.cpp b/tests/test_famag_opening_money.cpp new file mode 100644 index 0000000..33502cd --- /dev/null +++ b/tests/test_famag_opening_money.cpp @@ -0,0 +1,195 @@ +/* + * Round 12 AG-C2: rounded required money at an existing 1x opening check. + * + * TradingView pins: r12-c2-may28, r12-c2-jun09 and the repaired June 9 + * near1/near2/headroom-seedearly sensors. See campaign notes + * log-20260906t030608z-5e7dd07e and log-20260906t030919z-9a77d540. + * Small synthetic fixtures retain the pins' signal/fill prices and + * capital constants, without loading a corpus, feed, or verifier. + * + * May 28: TV trims 62.32 at the long fill, not the exact-cost 62.28. + * June 9: rounded required money exceeds exact equity by 0.000148, so TV + * trims one contract at the short fill, then 1912.92 at the adverse high. + * Moving the old quantity to newQ-0.01/-0.02 keeps both events. Adding + * 0.0006 equity removes only the opening call, giving 1916.92 at the high. + * r12-c2-may28-pricedexit independently pins the same 62.32 opening trim + * before a pending stop at 1.13430, which closes 885063.82 on that bar + * (TV CSV a1a4872e7f5ff6bbebf538f1186bf8d4902ef3566e1f0d6ee195982dc510a496). + */ +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int passed = 0; +static int failed = 0; +#define CHECK(expr) do { \ + if (expr) { ++passed; } else { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); ++failed; \ + } \ +} while (0) + +namespace { +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) < tolerance; +} + +class OpeningMoneyProbe : public BacktestEngine { +public: + OpeningMoneyProbe(double capital, bool seed_long, double seed_qty, + int seed_bar, int reverse_bar, int flatten_bar, + bool same_bar_stop = false) + : seed_long_(seed_long), seed_qty_(seed_qty), seed_bar_(seed_bar), + reverse_bar_(reverse_bar), flatten_bar_(flatten_bar), + same_bar_stop_(same_bar_stop) { + initial_capital_ = capital; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = margin_short_ = 100.0; + pyramiding_ = 0; + slippage_ = 0; + syminfo_.pointvalue = 1.0; + set_syminfo_mintick(0.00001); + qty_step_ = 0.01; + process_orders_on_close_ = false; + set_margin_call_enabled(true); + } + + void on_bar(const Bar&) override { + if (bar_index_ == seed_bar_) + strategy_entry("Seed", seed_long_, kNaN, kNaN, seed_qty_, "SEED"); + if (bar_index_ == reverse_bar_) { + strategy_close("Seed", "CLOSE"); + strategy_entry("Next", !seed_long_, kNaN, kNaN, kNaN, "NEXT"); + if (same_bar_stop_) + strategy_exit("Stop", "Next", kNaN, 1.13430, + kNaN, kNaN, kNaN, 100.0, "STOP"); + } + if (bar_index_ == flatten_bar_) strategy_close("Next", "END"); + } + + std::vector rows() const { return trades_; } + double remaining_position() const { return signed_position_size(); } + +private: + bool seed_long_; + double seed_qty_; + int seed_bar_; + int reverse_bar_; + int flatten_bar_; + bool same_bar_stop_; +}; + +std::vector may_bars() { + return { + {1.13398, 1.13398, 1.13398, 1.13398, 1, 1000}, + {1.13398, 1.13450, 1.13390, 1.13449, 1, 2000}, + {1.13450, 1.13452, 1.13424, 1.13450, 1, 3000}, + {1.13450, 1.13450, 1.13450, 1.13450, 1, 4000}, + {1.13450, 1.13450, 1.13450, 1.13450, 1, 5000}, + }; +} + +std::vector june_bars() { + return { + {1.14106, 1.14106, 1.14106, 1.14106, 1, 1000}, + {1.14108, 1.14186, 1.14100, 1.14176, 1, 2000}, + {1.14175, 1.14182, 1.14070, 1.14085, 1, 3000}, + {1.14086, 1.14117, 1.14054, 1.14090, 1, 4000}, + {1.14088, 1.14116, 1.14056, 1.14065, 1, 5000}, + {1.14064, 1.14064, 1.14064, 1.14064, 1, 6000}, + }; +} + +void run(OpeningMoneyProbe& engine, const std::vector& bars) { + engine.run(bars.data(), static_cast(bars.size())); + CHECK(engine.last_error().empty()); + CHECK(near(engine.remaining_position(), 0.0)); +} + +void may28_restore_amount(bool converted, bool same_bar_stop = false) { + OpeningMoneyProbe engine(1004616.8129284, false, 882466.37, 0, 1, 3, + same_bar_stop); + if (converted) { + // A provider series retains the existing converted-ledger arithmetic, + // even when its numeric rate is 1. No ten-digit rule was pinned there. + const int64_t timestamps[] = {1000}; + const double rates[] = {1.0}; + CHECK(engine.set_account_currency_fx_series(timestamps, rates, 1)); + } + run(engine, may_bars()); + const auto rows = engine.rows(); + CHECK(rows.size() == 3); + if (rows.size() != 3) return; + CHECK(rows[0].exit_comment == "CLOSE"); + CHECK(near(rows[0].qty, 882466.37)); + CHECK(rows[0].exit_time == 3000); + CHECK(rows[1].exit_comment == "Margin call"); + CHECK(near(rows[1].entry_price, 1.13450)); + CHECK(near(rows[1].exit_price, 1.13450)); + CHECK(rows[1].entry_time == 3000 && rows[1].exit_time == 3000); + CHECK(near(rows[1].qty, converted ? 62.28 : 62.32)); + // Composition with finding-325: an armed same-bar stop must consume the + // same rounded remainder. This is the existing entry checkpoint moved + // before a priced exit, not a new cursor or scheduling rule. + CHECK(rows[2].exit_comment == (same_bar_stop ? "STOP" : "END")); + if (same_bar_stop) { + CHECK(rows[2].exit_time == 3000); + CHECK(near(rows[2].exit_price, 1.13430)); + } + CHECK(near(rows[2].qty, converted ? 885063.86 : 885063.82)); + CHECK(near(rows[1].qty + rows[2].qty, 885126.14)); +} + +void june09_opening_and_adverse(double capital, double seed_qty, + int seed_bar, bool headroom) { + OpeningMoneyProbe engine(capital, true, seed_qty, seed_bar, 2, 4); + run(engine, june_bars()); + const auto rows = engine.rows(); + CHECK(rows.size() == (headroom ? 3u : 4u)); + if (rows.size() != (headroom ? 3u : 4u)) return; + CHECK(rows[0].exit_comment == "CLOSE"); + CHECK(near(rows[0].qty, seed_qty)); // proves the seed was actually admitted + CHECK(rows[0].exit_time == 4000); + if (!headroom) { + CHECK(rows[1].exit_comment == "Margin call"); + CHECK(near(rows[1].qty, 1.0)); + CHECK(near(rows[1].entry_price, 1.14086)); + CHECK(near(rows[1].exit_price, 1.14086)); + CHECK(rows[1].entry_time == 4000 && rows[1].exit_time == 4000); + } + const auto& adverse = rows[headroom ? 1 : 2]; + CHECK(adverse.exit_comment == "Margin call"); + CHECK(near(adverse.qty, headroom ? 1916.92 : 1912.92)); + CHECK(near(adverse.entry_price, 1.14086)); + CHECK(near(adverse.exit_price, 1.14117)); + CHECK(adverse.entry_time == 4000 && adverse.exit_time == 4000); + CHECK(rows.back().exit_comment == "END"); + CHECK(near(rows.back().qty, headroom ? 880167.48 : 880170.48)); + double next_total = 0.0; + for (size_t i = 1; i < rows.size(); ++i) next_total += rows[i].qty; + CHECK(near(next_total, 882084.40)); +} +} // namespace + +int main() { + may28_restore_amount(false); + may28_restore_amount(true); + may28_restore_amount(false, true); + may28_restore_amount(true, true); + june09_opening_and_adverse(1007119.8502264001, 882068.96, 1, false); + june09_opening_and_adverse(1006528.8674178, 882084.39, 0, false); + june09_opening_and_adverse(1006528.8674156, 882084.38, 0, false); + june09_opening_and_adverse(1006528.8680178, 882084.39, 0, true); + std::printf("%d passed, %d failed\n", passed, failed); + return failed == 0 ? 0 : 1; +}