Skip to content

fix(engine): freeze default percent_of_equity/cash market-order sizing at the signal bar#79

Merged
luisleo526 merged 1 commit into
mainfrom
fix/default-qty-signal-freeze
Jul 8, 2026
Merged

fix(engine): freeze default percent_of_equity/cash market-order sizing at the signal bar#79
luisleo526 merged 1 commit into
mainfrom
fix/default-qty-signal-freeze

Conversation

@luisleo526

Copy link
Copy Markdown
Collaborator

Summary

TradingView sizes a DEFAULT-qty (qty=na) market order at the signal bar, not at the fill:

equity_S     = initial_capital + realized net profit + open_profit(close(S))
sizing_price = close(S) + slippage*mintick*(+1 buy / -1 sell)
qty          = floor_step(reserve_percent_commission(equity_S * pct/100) / fx / (sizing_price * pointvalue))

The order then fills at the next bar's open carrying that frozen quantity. Evaluating the same expression at fill time — the previous behaviour — was wrong three separable ways on a reversal or a gapped bar:

  1. Double countflip_market_position_to() books emit_close_trade() (adding the closing lot's PnL to net_profit_sum_) and only then calls calc_qty_for_type(), while position_* still holds the lot just closed, so open_profit() re-adds that PnL as a phantom mark.
  2. Look-aheadopen_profit() marked at current_bar_.close, i.e. the fill bar's close, a price unknown when the order fills at that bar's open.
  3. Wrong divisor — divided by fill_price = open(S+1) rather than close(S). 49.9% of 15m bars in the reference window have open != prev close.

Freezing at placement removes all three at once: current_bar_ is the signal bar there.

Evidence

A flat entry isolates the divisor (no position, so the double count cannot apply). Corpus golden analyzer-parity-percent-of-equity-sizing-01 entry #1: close(S)=1807.83, open(S+1)=1807.82, capital 1e6, pct 99 → TV Size(qty) = 547.6178 = 990000/1807.83. Base engine emitted 547.6209 = 990000/1807.82. Patched engine emits 547.6180.

The buggy fill-time formula predicts 1739/1739 reversal quantities in one exercised strategy; close(S) predicts 370/370 gapped reversals and 507/507 gapped flat entries where open(S+1) predicts 0.

Why a new field

The frozen quantity is stored in a new PendingOrder::frozen_default_qty, not in qty. qty doubles as the "an explicit qty was provided" flag, and several sites recover "was this default-sized?" from std::isnan(o.qty): reduce_oca_group's default-sized cancel, the pending-reversal-entry binding, the OCA fully-filled heuristic, and the partial-vs-full exit classification. An earlier revision wrote into qty and silently flipped all of them — it disabled reversal binding for exactly the orders this change targets, and cost 5 downward tier moves. qty now stays NaN; the frozen value is read only where a quantity is actually computed.

Scope

MARKET (strategy.entry) and RAW market (strategy.order) placements with default PERCENT_OF_EQUITY / CASH sizing. FIXED default sizing needs no freeze (identical value at fill). Priced (limit/stop) entries keep the legacy fill-time sizing — TV's basis for an order armed bars before its fill is not established.

Known residual (deliberately not addressed)

For an entry sized while a position is open with commission_type=percent, TV may additionally deduct the entry fee already charged on the open lot. Independent reconstructions of TV's equity chain disagree, and the end-to-end evidence is confounded by an unrelated order-admission gap. An in-code comment warns against re-adding it by algebra; it needs a clean-room TV probe.

Test plan

  • ctest80/80 (adds tests/test_default_qty_signal_freeze.cpp, pinning the reversal, gap-down and gap-up flat entries, process_orders_on_close, and CASH; a gap must neither re-size nor decline a flat, fully-affordable entry)
  • scripts/run_corpus.sh — 252/252 ran, excellent=239 strong=12 anomaly=1, the unchanged baseline
  • Downstream validation sweep of 409 community strategies — 5 tier upgrades, 0 downgrades

🤖 Generated with Claude Code

…g at the signal bar

TradingView sizes a DEFAULT-qty (qty=na) market order at the SIGNAL bar, not at
the fill:

  equity_S     = initial_capital + realized net profit + open_profit(close(S))
  sizing_price = close(S) + slippage*mintick*(+1 buy / -1 sell)
  qty          = floor_step(reserve_percent_commission(equity_S * pct/100)
                            / fx / (sizing_price * pointvalue))

The order then fills at the next bar's open carrying that frozen quantity.

Evaluating the same expression at FILL time — the previous behaviour — was wrong
three separable ways on a reversal or a gapped bar:

  1. double count: flip_market_position_to() books emit_close_trade() (which
     adds the closing lot's PnL to net_profit_sum_) and only then calls
     calc_qty_for_type(), while position_* still holds the lot just closed, so
     open_profit() re-adds that PnL as a phantom mark;
  2. look-ahead: open_profit() marked at current_bar_.close = the FILL bar's
     close, a price unknown when the order fills at that bar's open;
  3. wrong divisor: it divided by fill_price = open(S+1) rather than close(S).
     49.9% of 15m bars in the reference window have open != prev close.

Freezing at placement removes all three at once: current_bar_ IS the signal bar
there, so open_profit() marks at close(S) and the divisor is the argument.

The frozen quantity is stored in a NEW PendingOrder::frozen_default_qty field
rather than in ``qty``. ``qty`` doubles as the "an explicit qty was provided"
flag, and several sites recover "was this default-sized?" from
std::isnan(o.qty) — reduce_oca_group's default-sized cancel, the
pending-reversal-entry binding, the OCA fully-filled heuristic, and the
partial-vs-full exit classification. Writing into ``qty`` silently flips all of
them; ``qty`` stays NaN and the frozen value is read only where a quantity is
actually computed.

Scope: MARKET (strategy.entry) and RAW market (strategy.order) placements with
default PERCENT_OF_EQUITY / CASH sizing. FIXED default sizing needs no freeze
(same value at fill). Priced (limit/stop) entries keep the legacy fill-time
sizing: TV's basis for an order armed bars before its fill is not established.

Known residual, deliberately NOT addressed here: for an entry sized while a
position is open with commission_type=percent, TV may additionally deduct the
entry fee already charged on the open lot. Reconstructions of TV's equity chain
disagree and the end-to-end evidence is confounded; an in-code comment warns
against re-adding it by algebra. It needs a clean-room TV probe.

Gates: ctest 80/80 (adds tests/test_default_qty_signal_freeze.cpp, which pins
the reversal, gap-down and gap-up flat entries, process_orders_on_close, and
CASH); corpus run_corpus.sh 252/252 -> excellent=239 strong=12 anomaly=1
(unchanged baseline); scraper data sweep of 409 strategies -> 5 tier upgrades,
0 downgrades.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@luisleo526 luisleo526 merged commit 778ab27 into main Jul 8, 2026
5 checks passed
@luisleo526 luisleo526 deleted the fix/default-qty-signal-freeze branch July 8, 2026 17:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant