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
63 changes: 63 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@ struct PendingOrder {
// probe 52 trade 113). Preserving the largest observed carry
// across re-placements would over-extend chains.
double tv_carry_qty = 0.0;
// Quantity frozen at PLACEMENT (signal) time for a DEFAULT-sized (qty=na)
// market order whose default sizing is price-dependent (percent_of_equity
// / cash) — see frozen_default_market_qty. NaN = not frozen.
//
// Deliberately NOT stored in ``qty``: that field doubles as the "an
// explicit qty was provided" flag, and several sites branch on
// ``std::isnan(o.qty)`` to recover "was this order default-sized?" —
// reduce_oca_group's default-sized cancel (engine_orders.cpp), the
// pending-reversal-entry binding (engine_strategy_commands.cpp), the OCA
// fully-filled heuristic and the partial-vs-full exit classification
// (engine_fills.cpp). Writing a frozen quantity into ``qty`` silently
// flips every one of them. Keep ``qty`` NaN; read this field only where a
// quantity is actually computed.
double frozen_default_qty = std::numeric_limits<double>::quiet_NaN();
std::string comment; // order comment for trade reporting
bool requested_partial = false; // true iff caller passed qty_percent < 100
bool created_while_in_position = false; // true if position was open when order was placed
Expand Down Expand Up @@ -861,6 +875,16 @@ class BacktestEngine {
// closed-equity accessor used elsewhere, but size new default
// percent orders from the live equity snapshot so pyramid adds
// and same-bar/re-entry sizing see unrealized PnL.
//
// KNOWN RESIDUAL (unresolved, do not "fix" without a probe):
// for an entry sized while a position is open AND commission_
// type=percent, TV appears to also deduct the entry fee already
// charged on the open lot (it debits at fill; we book both legs
// at close). Three independent reconstructions of TV's equity
// chain disagreed on whether that term belongs here — adding it
// made one strategy's predicted quantities exact and two others
// markedly worse, and it cut end-to-end coverage. Settle it
// with a clean-room TV probe (the KI-52 method), not algebra.
double equity = current_equity() + open_profit(current_bar_.close);
double cash = reserve_percent_commission(equity * (default_qty_value_ / 100.0)) / account_currency_fx_;
// Reject (qty 0) on a non-finite / non-positive fill price — a
Expand All @@ -875,6 +899,45 @@ class BacktestEngine {
return apply_qty_step(default_qty_value_);
}

// TradingView freezes DEFAULT (qty=na) market-order sizing at the SIGNAL
// bar — the bar whose on_bar issued the strategy.entry/strategy.order
// call — not at the fill:
//
// equity_S = initial_capital + realized net profit
// + open_profit(close(S)) // position may still be OPEN
// sizing_price = close(S) + slippage*mintick*(+1 buy / -1 sell)
// qty = floor_step( reserve_percent_commission(budget)
// / fx / (sizing_price * pointvalue) )
//
// The market order then fills at the NEXT bar's open carrying this frozen
// quantity. calc_qty(price) implements exactly that shape when evaluated
// AT SIGNAL TIME (current_bar_ IS the signal bar: open_profit marks at
// close(S) and the divisor is the argument), so the freeze is simply
// calc_qty(slipped signal close) captured at placement. Evaluating the
// same expression at FILL time — the pre-freeze behavior — was wrong in
// three separable ways on a reversal/gap: it double-counted the just-
// closed position's PnL (current_equity() already realized the exit while
// position_* still held the stale lot for open_profit), it marked open
// profit at the FILL bar's close (a look-ahead: that close is unknown
// when the order fills at the open), and it divided by the fill price
// instead of the signal close. Freezing at placement removes all three.
//
// Only PERCENT_OF_EQUITY / CASH default sizing is price/equity-dependent;
// FIXED default sizing stays qty=NaN at placement (identical value at
// fill, and keeping NaN preserves the isnan(order.qty)-keyed semantics
// elsewhere, e.g. the OCA "fully filled" heuristic).
//
// Priced (limit/stop) entries are NOT frozen: TV's sizing basis for an
// order armed one or more bars before its fill is not empirically
// established, so they conservatively keep the legacy fill-time sizing.
double frozen_default_market_qty(bool is_buy) const {
double sizing_price = current_bar_.close;
if (slippage_ != 0 && syminfo_mintick_ > 0.0) {
sizing_price += (is_buy ? 1.0 : -1.0) * slippage_ * syminfo_mintick_;
}
return calc_qty(sizing_price);
}

// --- Strategy variable accessors ---
double signed_position_size() const {
if (position_side_ == PositionSide::LONG) return position_qty_;
Expand Down
20 changes: 17 additions & 3 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,14 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr
const Bar& bar,
double& trail_best_path_state,
bool later_same_tick_entry) {
execute_market_entry(order.id, order.is_long, fill_price, order.qty, order.qty_type,
// A default-sized market order carries a quantity frozen at the signal
// bar's close; hand it through as fixed contracts (qty_type < 0) so the
// fill does not re-derive it from the fill price. Explicit-qty and
// FIXED-default orders keep their own (qty, qty_type) pair unchanged.
const bool frozen = !std::isnan(order.frozen_default_qty);
execute_market_entry(order.id, order.is_long, fill_price,
frozen ? order.frozen_default_qty : order.qty,
frozen ? -1 : order.qty_type,
order.created_position_side, /*close_only_opposite=*/false,
/*is_priced_entry=*/false, /*tv_carry_qty=*/0.0,
order.created_bar, later_same_tick_entry);
Expand Down Expand Up @@ -953,7 +960,9 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price
bool& exit_closed_was_long) {
if (position_side_ == PositionSide::FLAT) {
fill_price = apply_fill_slippage(fill_price, order.is_long);
double qty = std::isnan(order.qty) ? calc_qty(fill_price) : order.qty;
// Prefer the signal-time frozen quantity when the order carries one.
double qty = !std::isnan(order.frozen_default_qty) ? order.frozen_default_qty
: (std::isnan(order.qty) ? calc_qty(fill_price) : order.qty);
position_side_ = order.is_long ? PositionSide::LONG : PositionSide::SHORT;
position_entry_price_ = fill_price;
position_entry_time_ = current_bar_.timestamp;
Expand Down Expand Up @@ -1005,7 +1014,9 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price
return;
}
fill_price = apply_fill_slippage(fill_price, order.is_long);
double new_qty = std::isnan(order.qty) ? calc_qty(fill_price) : order.qty;
// Prefer the signal-time frozen quantity when the order carries one.
double new_qty = !std::isnan(order.frozen_default_qty) ? order.frozen_default_qty
: (std::isnan(order.qty) ? calc_qty(fill_price) : order.qty);
double total_qty = position_qty_ + new_qty;
position_entry_price_ =
(position_entry_price_ * position_qty_ + fill_price * new_qty) / total_qty;
Expand Down Expand Up @@ -1134,6 +1145,9 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility(
// slippage-scale noise.
bool flat_armed_opposite_close = flat_armed_opposite_same_bar;
if (flat_armed_opposite_same_bar) {
// No frozen-qty lookup here: this branch is reached only for
// OrderType::ENTRY (priced entries), and frozen_default_qty is set
// solely on MARKET / RAW_ORDER placements, so it is always NaN.
double approx_price = !std::isnan(order.stop_price) ? order.stop_price
: (!std::isnan(order.limit_price) ? order.limit_price : bar.close);
double approx_tx_qty = calc_qty_for_type(approx_price, order.qty, order.qty_type);
Expand Down
37 changes: 37 additions & 0 deletions src/engine_strategy_commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
// non-deterministic — see corpus/parity-anomalies/README.md).
// Only applied to MARKET entries (limit/stop entries have their
// own price baked into the order itself).
//
// Scope: this signal-time gate covers EXPLICIT-qty market entries only
// (the empirical base above — parity-probe-04..06 — is all explicit
// ``qty = <expr>`` sizing). DEFAULT-sized (qty=na) market entries never
// reach it (qty is NaN here); their quantity is frozen at this bar's
// close further below (see frozen_default_market_qty), AFTER this gate,
// precisely so the freeze cannot accidentally activate a gate whose
// equity basis (realized-only current_equity()) was never validated
// for default sizing.
if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) {
double margin_pct = is_long ? margin_long_ : margin_short_;
if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) {
Expand Down Expand Up @@ -213,6 +222,23 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
order.type = OrderType::MARKET;
order.limit_price = std::numeric_limits<double>::quiet_NaN();
order.stop_price = std::numeric_limits<double>::quiet_NaN();
// TradingView freezes DEFAULT (qty=na) percent_of_equity / cash
// market-order sizing at THIS (signal) bar's close — see
// frozen_default_market_qty (engine.hpp) for the rule and the
// empirical basis. current_bar_.close is close(S) right here, so
// placement is the one point where the frozen computation is
// naturally correct (no double count, no fill-bar look-ahead).
// FIXED default sizing needs no freeze: its fill-time value is
// identical. The frozen quantity goes in frozen_default_qty, NOT in
// order.qty — order.qty must stay NaN so every isnan(order.qty)-keyed
// "was this default-sized?" branch (OCA cancel, reversal binding,
// OCA fully-filled, partial-exit classification) keeps its meaning.
if (std::isnan(qty)
&& (default_qty_type_ == QtyType::PERCENT_OF_EQUITY
|| default_qty_type_ == QtyType::CASH)
&& !std::isnan(current_bar_.close)) {
order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long);
}
} else {
order.type = OrderType::ENTRY;
order.limit_price = limit_price;
Expand Down Expand Up @@ -675,6 +701,17 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double
order.type = OrderType::RAW_ORDER;
order.limit_price = std::numeric_limits<double>::quiet_NaN();
order.stop_price = std::numeric_limits<double>::quiet_NaN();
// Same signal-time freeze as strategy_entry's MARKET branch: a
// default-sized strategy.order market order runs through the same
// TV default-sizing engine, so its quantity is frozen at this
// (signal) bar's close too. Stored off to the side (order.qty stays
// NaN) for the same reason as in strategy_entry.
if (std::isnan(qty)
&& (default_qty_type_ == QtyType::PERCENT_OF_EQUITY
|| default_qty_type_ == QtyType::CASH)
&& !std::isnan(current_bar_.close)) {
order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long);
}
} else {
order.type = OrderType::RAW_ORDER;
order.limit_price = limit_price;
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ set(TEST_SOURCES
test_timeframe_aggregator_extra
test_c_abi_setters
test_fills_edge
test_default_qty_signal_freeze
test_limit_fill_slippage
test_strategy_commands_extra
test_multi_tier_exit_precedence
Expand Down
Loading
Loading