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
21 changes: 21 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ struct SymInfo {
std::string description = "";
double mintick = 0.01;
double pointvalue = 1.0;
// Per-instrument quantity step (syminfo.* "qty_step" — the smallest
// tradable lot increment, e.g. 0.0004 for BINANCE:ETHUSDT.P). 0 = disabled
// (the engine default), so no quantity quantization is applied — corpus
// instruments leave this 0 and are byte-identical. Only the forced-
// liquidation (margin call) path floors its computed lot to this step to
// mirror TradingView, which nibbles the position in exact lot multiples.
double qty_step = 0.0;
};

struct StrategyOverrides {
Expand Down Expand Up @@ -271,6 +278,12 @@ class BacktestEngine {
double commission_value_ = 0.0;
int slippage_ = 0; // slippage in ticks
double syminfo_mintick_ = 0.01; // tick size for slippage calculation
// Per-instrument lot-size step for forced-liquidation quantization.
// 0 = disabled (default; corpus no-op). Fed via the syminfo_metadata
// channel ("qty_step") or the SymInfo struct on the explicit run() path.
// process_margin_call floors each liquidation lot DOWN to a multiple of
// this, matching TradingView's per-instrument margin-call lot sizing.
double qty_step_ = 0.0;
int max_intraday_filled_orders_ = 0; // 0 = unlimited
bool close_entries_rule_any_ = false; // true = "ANY", false = "FIFO" (default)
// Percentage of margin required to open a long/short position. Default
Expand Down Expand Up @@ -1527,6 +1540,14 @@ class BacktestEngine {
bool margin_call_enabled() const { return margin_call_enabled_; }
void set_syminfo_metadata(const std::string& key, double value) {
syminfo_metadata_[key] = value;
// "qty_step" is the per-instrument lot increment used by the forced-
// liquidation quantizer. Route it onto the dedicated member so the
// codegen run(const Bar*, int) path (which never overwrites it) keeps
// the value the data feed injected. A non-positive value disables it.
if (key == "qty_step") {
qty_step_ = (std::isfinite(value) && value > 0.0) ? value : 0.0;
syminfo_.qty_step = qty_step_;
}
}

// Returns the script's active timeframe string (e.g. "15" for 15-minute,
Expand Down
13 changes: 13 additions & 0 deletions scripts/run_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,19 @@ def _num(v):
except (TypeError, ValueError):
return None

# Per-instrument forced-liquidation lot step. Accept it either as a
# top-level runtime_overrides.qty_step or inside syminfo_metadata, and
# route it through the existing syminfo_metadata channel (key "qty_step")
# so the engine quantizes margin-call lots without a new C-ABI export.
qty_step = _num(runtime_overrides.get("qty_step"))
if qty_step is None and isinstance(syminfo_metadata, dict):
qty_step = _num(syminfo_metadata.get("qty_step"))
if qty_step is not None and qty_step > 0.0:
if not isinstance(syminfo_metadata, dict):
syminfo_metadata = {}
syminfo_metadata = dict(syminfo_metadata)
syminfo_metadata["qty_step"] = qty_step

kwargs = dict(
strategy_overrides=strategy_overrides or None,
chart_timezone=chart_tz,
Expand Down
17 changes: 17 additions & 0 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,23 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
const double q_min = qty - equity_adv / (adverse * pv * m);
if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return;
double qty_liq = 4.0 * q_min;
// Per-instrument lot quantization. TradingView floors each forced-
// liquidation lot to the instrument's quantity step (verified row-for-row
// against the p2 ETHUSDT.P export: every margin-call qty is an exact
// multiple of 0.0004). Without this the engine's nibbles are slightly
// larger and drain the position in fewer calls, drifting the per-call exit
// prices. qty_step_ == 0 (corpus default) leaves qty_liq untouched.
if (qty_step_ > 0.0) {
double floored = std::floor(qty_liq / qty_step_) * qty_step_;
if (floored <= kQtyEpsilon) {
// A liquidation IS required (we passed the margin-shortfall gate)
// but the floored lot rounds to zero. Take the smallest step that
// still makes progress — one qty_step_, or the full residual if it
// is smaller — so the per-bar call loop cannot stall forever.
floored = std::min(qty_step_, qty);
}
qty_liq = floored;
}
if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; // cap at the whole position
if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return;

Expand Down
5 changes: 5 additions & 0 deletions src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,11 @@ void BacktestEngine::run(const Bar* input_bars, int n_input,
// Store syminfo and inputs
syminfo_ = syminfo;
syminfo_mintick_ = syminfo.mintick;
// Forced-liquidation lot step (0 = disabled). On the codegen run(Bar*,n)
// path this member is fed via set_syminfo_metadata("qty_step", …) and is
// never reset; on this explicit-SymInfo path the struct is authoritative.
if (std::isfinite(syminfo.qty_step) && syminfo.qty_step > 0.0)
qty_step_ = syminfo.qty_step;
inputs_ = inputs;

// Apply overrides
Expand Down
58 changes: 57 additions & 1 deletion tests/test_margin_call.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class MCEngine : public BacktestEngine {
class ShortLiqProbe : public MCEngine {
public:
bool disable_mc_ = false;
explicit ShortLiqProbe(bool disable_mc = false) {
explicit ShortLiqProbe(bool disable_mc = false, double qty_step = 0.0) {
initial_capital_ = 1000.0;
default_qty_type_ = QtyType::PERCENT_OF_EQUITY;
default_qty_value_ = 100.0; // size the short at 100% of equity
Expand All @@ -89,6 +89,7 @@ class ShortLiqProbe : public MCEngine {
margin_short_ = 100.0; // 1x, default TV margin
process_orders_on_close_ = true; // market entry fills at bar close
disable_mc_ = disable_mc;
qty_step_ = qty_step; // 0 = no lot quantization
if (disable_mc_) set_margin_call_enabled(false);
}
void on_bar(const Bar& /*bar*/) override {
Expand Down Expand Up @@ -165,6 +166,60 @@ static void test_short_margin_call_disabled() {
CHECK(eng.trade_count() == 0);
}

// ---- A': lot quantization floors each forced-liquidation lot to qty_step ----

// Returns true when |x| is an integer multiple of step (within tol).
static bool is_multiple_of(double x, double step, double tol = 1e-9) {
if (step <= 0.0) return false;
double n = std::round(x / step);
return std::fabs(x - n * step) <= tol;
}

static void test_short_margin_call_qty_step() {
std::printf("test_short_margin_call_qty_step\n");

// Same scenario as test_short_margin_call. Unquantized, the first forced
// lot is 4x the shortfall = 3.80952381 contracts. With a 0.5 lot step it
// must floor DOWN to floor(3.80952381 / 0.5) * 0.5 = 3.5 (an exact step
// multiple), and the exit price is unchanged (bar1 high = 105).
std::vector<Bar> bars = {
mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: short fills @100
mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), // 1: high 105 -> margin call
mk_bar(3000, 104.0, 130.0, 103.0, 128.0, 1.0), // 2: high 130 -> further call
mk_bar(4000, 128.0, 140.0, 127.0, 139.0, 1.0), // 3: keep rising
};

const double step = 0.5;
ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/step);
eng.run(bars.data(), (int)bars.size());

CHECK(eng.trade_count() >= 1);
// First quantized lot: floored to 3.5, an exact multiple of the 0.5 step.
CHECK(near(eng.trade_size(0), 3.5));
CHECK(is_multiple_of(eng.trade_size(0), step));
// Quantization never enlarges the lot: floored <= unquantized 3.80952381.
CHECK(eng.trade_size(0) <= 3.80952381 + 1e-9);
CHECK(near(eng.exit_price(0), 105.0));
CHECK(eng.exit_comment(0) == std::string("Margin call"));

// Every partial (non-final) forced lot must be a step multiple. The final
// exit closes whatever residual remains (the position size itself is not a
// step multiple, so only the intermediate nibbles are checked).
int partial_checked = 0;
for (int i = 0; i + 1 < eng.trade_count(); ++i) {
CHECK(is_multiple_of(eng.trade_size(i), step));
++partial_checked;
}
CHECK(partial_checked >= 1);

// Teeth: with qty_step = 0 the same first lot is the UNQUANTIZED 3.80952381,
// which is NOT a multiple of 0.5 — proving the assertion above can fail.
ShortLiqProbe raw(/*disable_mc=*/false, /*qty_step=*/0.0);
raw.run(bars.data(), (int)bars.size());
CHECK(near(raw.trade_size(0), 3.80952381, 1e-4));
CHECK(!is_multiple_of(raw.trade_size(0), step));
}

// ---- B: long at 100% margin is never liquidated ----------------------------

class LongNoLiqProbe : public MCEngine {
Expand Down Expand Up @@ -237,6 +292,7 @@ static void test_long_leveraged_margin_call() {

int main() {
test_short_margin_call();
test_short_margin_call_qty_step();
test_margin_liquidation_price_formula();
test_short_margin_call_disabled();
test_long_100pct_margin_no_call();
Expand Down
Loading