Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 41 additions & 22 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions tests/test_famag_close_first_admission.cpp
Original file line number Diff line number Diff line change
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <vector>

#include <pineforge/bar.hpp>
#include <pineforge/engine.hpp>

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<double>::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<Bar> 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<int>(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;
}
Loading
Loading