Repository navigation
Expand file tree
/
Copy pathengine_strategy_commands.cpp
More file actions
1090 lines (1025 loc) · 52.3 KB
/
Copy pathengine_strategy_commands.cpp
File metadata and controls
1090 lines (1025 loc) · 52.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* engine_strategy_commands.cpp — the strategy.* command surface.
*
* Carved out of the original monolithic engine source during the v0.1
* file-split (phase 6) — the BacktestEngine implementation now spans the
* src/engine_*.cpp family. Each function is a
* direct translation of one PineScript strategy.* call into a pending
* order placement (and possibly an immediate fill when
* `process_orders_on_close` or `immediately=true` is set).
*
* strategy_entry - place a pending entry order (market/limit/stop)
* strategy_close - close a position (full or partial, by id or all)
* strategy_close_all - thin wrapper over strategy_close("")
* strategy_exit - place a take-profit / stop-loss / trail
* strategy_cancel - drop one pending order by id
* strategy_cancel_all - drop all pending orders
* strategy_order - low-level RAW_ORDER placement
*
* Order matching itself lives in process_pending_orders
* (engine_fills.cpp); the actual fill mechanics are in
* execute_market_entry / execute_market_exit / execute_partial_exit
* (engine_orders.cpp).
*/
#include "engine_internal.hpp"
#include <pineforge/engine.hpp>
#include <pineforge/timeframe.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
namespace pineforge {
using internal::kFullPercentEps;
using internal::kFullQtyEps;
using internal::kQtyEpsilon;
namespace {
// TradingView replays the strategy continuously from the FIRST OHLCV bar.
// The validator's ``trade_start_time`` gate intentionally suppresses
// strategy commands during the warmup span so TA / var accumulators
// converge without polluting comparison output. But the gate is set to
// ``TV first entry - input bar`` (see _trade_start_time_ms_from_tv in
// the validator) which, on a 15m strategy fed 1m OHLCV, lands one
// MINUTE before the first TV trade — far inside the script bar that
// CONTAINS the first TV trade, but BEFORE the script bar that
// PRECEDES it.
//
// A stop/limit placed on the immediately-preceding script bar (where
// TV's first trade originates) is therefore dropped under the strict
// gate, and the engine's first in-window trade fires several bars
// later from a different placement entirely. Validation/62-same-id-
// stop-cross-before-modify is the canonical victim: TV's 03-31 03:30
// long entry was longFirst's 03:15 stop firing — pre-fix the 03:15
// strategy.entry was gated, longModify at 03:30 armed a different
// stop that fired hours later, and the validator's price-fallback
// alignment then misaligned 64 in-window trades.
//
// Subtracting one script TF interval from the gate restores the
// previous script bar's strategy commands (just enough to let
// pre-window placements fire on the first in-window bar) without
// re-introducing the 411 extra pre-window trades that an
// unconditional bypass produces in continuously-firing strategies
// like basic/volty-expan. The buffer matches TV's chart-bar
// resolution rather than the validator-chosen input-bar resolution.
inline bool trading_is_active(int64_t current_ms, int64_t start_ms,
int script_tf_seconds) {
if (start_ms == std::numeric_limits<int64_t>::min()) {
return true;
}
int64_t buffer_ms = (script_tf_seconds > 0)
? static_cast<int64_t>(script_tf_seconds) * 1000
: 0;
return current_ms >= start_ms - buffer_ms;
}
}
void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
double limit_price, double stop_price, double qty,
const std::string& comment,
const std::string& oca_name, int oca_type,
int qty_type) {
if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return;
// TradingView intraday-cap freeze gate (Pine docs:
// ``strategy.risk.max_intraday_filled_orders``):
// "If the limit is reached during the day, the strategy is closed
// at the close of the next bar of the day, and all subsequent
// orders are blocked until the start of the next trading day."
//
// The fill-time gate in apply_filled_order_to_state already drops
// FILLS during the latched window. But TV blocks ORDER PLACEMENT
// too — a strategy.entry call inside a latched bar must not enter
// the pending queue at all, otherwise the order survives until the
// next chart-day rollover and fires a phantom entry on the first
// new-day bar at a price TV never reports. Probe 97 trade #22
// (UTC 04-07 00:00 long @ 1581.99) is the canonical victim — the
// residual exit-price drift after the 97a/97b composition fixes
// was driven by these phantom new-day entries (long-stop placed on
// bar 04-06 23:45 with arm_long=true while the cap had already
// latched on 04-06 07:00, then carrying to fire on the new chart-
// day before the script's else-branch could cancel it).
if (_intraday_cap_currently_latched()) return;
// TradingView broker rule: market-entry orders are admitted only
// when ``qty * <signal-bar close> * margin_pct/100 <= equity``.
// The check happens HERE (at signal time, with current_bar_.close
// — the close of the bar where ``strategy.entry`` was just called),
// NOT later in apply_market_order_fill where fill_price is the
// NEXT bar's open. For ``qty = strategy.equity / close`` sizing
// patterns (parity-anomalies/equity-mirror, community/IES) this
// distinction is load-bearing: the close-vs-open slippage routinely
// inflates overshoot from ~zero (at signal) to ~$20 (at fill), and
// pre-fix engine rejected those at fill time while TV accepted
// them at signal time — accumulating into community/IES's PnL
// drift. Verified empirically by parity-probe-04..06 (all 57/57
// matched) and parity-anomalies/equity-mirror (full-equity sizing
// right at the 1× boundary, where TV's behaviour is itself
// 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)) {
// Position value in account currency includes the futures
// point-value multiplier (1.0 for crypto/equity — unchanged).
// The notional is in the symbol's quote currency; convert it to the
// account currency (FX 1.0 unless the script declared a differing
// currency=) so it is comparable to equity, which is denominated in
// the account currency. Default 1.0 leaves the corpus untouched.
double required_margin = std::abs(qty) * current_bar_.close
* syminfo_.pointvalue
* account_currency_fx_
* (margin_pct / 100.0);
double available_equity = current_equity();
double epsilon = std::max(1e-9, std::abs(available_equity) * 1e-12);
if (required_margin > available_equity + epsilon) {
return;
}
}
}
int64_t preserved_seq = 0;
for (const auto& o : pending_orders_) {
if (o.id == id) {
preserved_seq = o.created_seq;
break;
}
}
// Remove existing pending order with same id
pending_orders_.erase(
std::remove_if(pending_orders_.begin(), pending_orders_.end(),
[&](const PendingOrder& o) { return o.id == id; }),
pending_orders_.end());
PendingOrder order;
order.id = id;
order.from_entry = "";
order.is_long = is_long;
order.trail_points = std::numeric_limits<double>::quiet_NaN();
order.trail_price = std::numeric_limits<double>::quiet_NaN();
order.trail_offset = std::numeric_limits<double>::quiet_NaN();
order.qty = qty;
order.qty_type = qty_type;
order.qty_percent = 100.0;
order.oca_name = oca_name;
order.oca_type = oca_type;
order.created_bar = bar_index_;
order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++;
order.created_position_side = position_side_;
// TradingView empirical rule (probe 52 trade 113): the deferred-flip
// carry is the position size at THIS placement, not the original.
// ``strategy.entry`` with the same id replaces the pending order
// entirely on each call — including a fresh capture of position_qty_.
// If the LE/SE was placed during a non-zero position and that
// position closes before the LE/SE fires, subsequent re-placements
// (now from flat) capture carry=0 and the order fires fresh on
// qty=1 — exactly TV's "chain reset" behaviour at 04-26 16:30 UTC.
//
// Probe 93 cycle B refinement: a ``strategy.close`` call earlier in
// the SAME on_bar must be subtracted off because TV evaluates calls
// in source order. ``pending_close_qty_in_bar_`` accumulates qty of
// strategy.close* calls during the current on_bar and resets at the
// top of each bar. When close is called BEFORE entry, the entry
// captures the post-close position size; when entry is called BEFORE
// close, ``pending_close_qty_in_bar_`` is still 0 and the carry
// equals the open position.
//
// Side-gate: ``position_qty_`` is undefined whenever ``position_side_``
// is FLAT — the engine's default ``position_qty_ = 1.0`` would leak
// into ``tv_carry_qty`` for the FIRST priced ``strategy.entry`` call
// of any session that has never opened a position before, fabricating
// a phantom carry. Probe 62 manifests this: the longModify stop
// placed at 03:30 (after the warmup gate skipped longFirst at 03:15)
// captured carry=1 from the default qty, then fired later with
// qty=2 instead of 1, breaking parity from trade #1 onward. Reading
// the position size through the canonical ``signed_position_size``
// path returns 0 when FLAT regardless of the underlying default.
double live_pos_qty = (position_side_ == PositionSide::FLAT)
? 0.0
: position_qty_;
double effective_pos = std::max(0.0, live_pos_qty - pending_close_qty_in_bar_);
order.tv_carry_qty = effective_pos;
order.comment = comment;
bool has_limit = !std::isnan(limit_price);
bool has_stop = !std::isnan(stop_price);
if (!has_limit && !has_stop) {
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);
// KI-54: persist the sizing basis for the fill-time TV margin
// admission re-check (see PendingOrder::sizing_equity in
// engine.hpp and the gate in apply_filled_order_to_state).
order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long);
order.sizing_equity =
current_equity() + open_profit(current_bar_.close);
order.sizing_mark = current_bar_.close;
}
} else {
order.type = OrderType::ENTRY;
order.limit_price = limit_price;
order.stop_price = stop_price;
}
pending_orders_.push_back(std::move(order));
}
void BacktestEngine::strategy_close(const std::string& id, const std::string& comment,
double qty, double qty_percent, bool immediately) {
if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return;
if (position_side_ == PositionSide::FLAT) {
return;
}
// TradingView one-close-fill-per-bar rule: under process_orders_on_close,
// default-FIFO ``strategy.close(id)`` calls (no explicit qty/qty_percent,
// FIFO close-entries rule) issued on the SAME bar collapse into a single
// surviving market close order that fills at the bar close. Route those
// calls through the same-bar batch; the fill executes at the end-of-bar
// order-processing point (dispatch_bar step 4 / magnifier last tick) via
// flush_same_bar_close(). Everything else (ANY rule, explicit qty,
// close_all, immediately=true, non-POC deferred closes) keeps the
// existing paths.
if (process_orders_on_close_ && !immediately
&& !close_entries_rule_any_ && !id.empty()
&& std::isnan(qty) && std::isnan(qty_percent)) {
enqueue_same_bar_close(id, comment);
return;
}
double matching_qty = 0.0;
double qty_to_close = 0.0;
bool all_entries_match = false;
if (!compute_close_target_qty(id, qty, qty_percent,
matching_qty, qty_to_close, all_entries_match)) {
return;
}
const double eps = kQtyEpsilon;
bool closes_full_position = false;
if (id.empty()) {
closes_full_position = qty_to_close >= position_qty_ - eps;
} else if (close_entries_rule_any_) {
closes_full_position = all_entries_match && qty_to_close >= position_qty_ - eps;
} else {
closes_full_position = qty_to_close >= position_qty_ - eps;
}
// Track this close's qty for the same-bar source-order carry rule.
// A subsequent ``strategy.entry`` on the same on_bar will see the
// post-close position size when capturing its tv_carry_qty.
pending_close_qty_in_bar_ += qty_to_close;
bool closes_fifo_qty = !close_entries_rule_any_ && !closes_full_position;
bool closes_any_qty = close_entries_rule_any_ && !closes_full_position;
if (closes_full_position) {
bool closing_long = (position_side_ == PositionSide::LONG);
cancel_orders_for_full_close(id, closing_long);
}
if (process_orders_on_close_ || immediately) {
execute_immediate_close(id, comment, qty_to_close, matching_qty,
closes_full_position, closes_fifo_qty, closes_any_qty);
return;
}
queue_deferred_close_order(id, comment, qty_to_close, matching_qty,
closes_full_position, closes_any_qty);
}
void BacktestEngine::strategy_close_all() {
strategy_close("");
}
// Qty the pending same-bar close batch will fill at flush time (0 when no
// batch is pending). Lets same-bar order sizing (strategy.exit) see the
// post-close position before the flush actually executes.
double BacktestEngine::pending_same_bar_close_target() const {
if (!sb_close_active_) return 0.0;
auto it = id_unclosed_qty_.find(sb_close_id_);
double unclosed = (it != id_unclosed_qty_.end()) ? it->second : 0.0;
double avail = std::max(0.0, position_qty_ - close_reserved_other_qty(sb_close_id_));
return std::min(unclosed, avail);
}
// Sum of same-bar-close reservations held by OTHER ids. A surviving
// multi-close fill leaves its unconsumed ledger amount reserved against
// the position; other ids' close targets are capped by what remains.
double BacktestEngine::close_reserved_other_qty(const std::string& id) const {
double sum = 0.0;
for (const auto& kv : close_reserved_qty_) {
if (kv.first != id) sum += kv.second;
}
return sum;
}
// Admit a default-FIFO strategy.close(id) call into the same-bar batch
// (TV one-fill-per-bar rule — see the close_reserved_qty_ field block in
// engine.hpp). Order cancels tied to a full-position close keep their
// CALL-time timing (identical to the previous immediate path); only the
// FILL is deferred to flush_same_bar_close().
void BacktestEngine::enqueue_same_bar_close(const std::string& id,
const std::string& comment) {
const double eps = kQtyEpsilon;
auto it = id_unclosed_qty_.find(id);
double unclosed = (it != id_unclosed_qty_.end()) ? it->second : 0.0;
double avail = std::max(0.0, position_qty_ - close_reserved_other_qty(id));
double target = std::min(unclosed, avail);
if (target <= eps) {
return; // zero-target call: no-op, cannot become the survivor
}
// Same-bar source-order carry (see strategy_entry's tv_carry_qty): a
// subsequent strategy.entry on this on_bar sees the post-close size.
pending_close_qty_in_bar_ += target;
bool closes_full_position = target >= position_qty_ - eps;
if (closes_full_position) {
bool closing_long = (position_side_ == PositionSide::LONG);
cancel_orders_for_full_close(id, closing_long);
purge_exit_orders();
}
if (!sb_close_active_ || sb_close_bar_ != bar_index_) {
sb_close_active_ = true;
sb_close_bar_ = bar_index_;
sb_close_calls_ = 1;
sb_close_first_id_ = id;
sb_close_id_ = id;
sb_close_comment_ = comment;
return;
}
if (id == sb_close_id_) {
// Re-issued close for the id already pending: replaces the same
// order in place (comment refresh; not a new call).
sb_close_comment_ = comment;
return;
}
sb_close_calls_ += 1;
if (sb_close_calls_ == 2) {
// The FIRST replaced call's ledger is consumed silently: no fill,
// no trade rows, reservation released. Intermediate replaced
// calls (3rd+) keep their ledgers intact.
id_unclosed_qty_.erase(sb_close_first_id_);
close_reserved_qty_.erase(sb_close_first_id_);
}
sb_close_id_ = id;
sb_close_comment_ = comment;
}
// Execute the surviving same-bar close at the bar's close price. Called
// from the end-of-bar order-processing points (dispatch_bar step 4 and
// the magnifier's last tick), i.e. the same bar and price the immediate
// path used, after the strategy's on_bar has fully run.
void BacktestEngine::flush_same_bar_close() {
if (!sb_close_active_) return;
const std::string id = sb_close_id_;
const std::string comment = sb_close_comment_;
const bool sole_call = (sb_close_calls_ == 1);
sb_close_active_ = false;
sb_close_bar_ = -1;
sb_close_calls_ = 0;
sb_close_first_id_.clear();
sb_close_id_.clear();
sb_close_comment_.clear();
if (position_side_ == PositionSide::FLAT) return;
const double eps = kQtyEpsilon;
auto it = id_unclosed_qty_.find(id);
double unclosed = (it != id_unclosed_qty_.end()) ? it->second : 0.0;
double avail = std::max(0.0, position_qty_ - close_reserved_other_qty(id));
double target = std::min(unclosed, avail);
if (target <= eps) return;
if (sole_call) {
// Single close call this bar: classic semantics — consume the
// ledger and release any prior reservation for this id.
it->second -= target;
if (it->second <= eps) {
id_unclosed_qty_.erase(it);
}
close_reserved_qty_.erase(id);
}
bool closes_full_position = target >= position_qty_ - eps;
size_t trades_before = trades_.size();
if (closes_full_position) {
const bool closed_long = (position_side_ == PositionSide::LONG);
// Exit-order cancel/purge already ran at CALL time in
// enqueue_same_bar_close — orders armed after the close call
// must survive exactly as they did under the immediate path.
execute_market_exit(current_bar_.close);
if (position_side_ == PositionSide::FLAT) {
cancel_same_bar_market_reentries_after_full_close(closed_long);
}
} else {
execute_partial_exit_qty(current_bar_.close, target);
if (position_side_ == PositionSide::FLAT) {
// Retain from_entry brackets whose parent entry is still a
// pending order (it fills right after this flush).
purge_exit_orders(/*retain_for_pending_entries=*/true);
}
}
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = comment;
trades_[ti].exit_id = "__close__" + id;
}
if (!sole_call && position_side_ != PositionSide::FLAT) {
// Surviving multi-call close: ledger NOT consumed (TV leaves the
// id closable again later); the filled amount stays reserved
// against the position for other ids' close targets.
close_reserved_qty_[id] = target;
}
}
void BacktestEngine::strategy_exit(const std::string& id, const std::string& from_entry,
double limit_price, double stop_price,
double trail_points, double trail_offset,
double trail_price, double qty_percent,
const std::string& comment,
double qty, const std::string& oca_name,
double profit_ticks, double loss_ticks) {
if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return;
bool has_explicit_qty = !std::isnan(qty);
double qp = std::isnan(qty_percent) ? 100.0 : std::clamp(qty_percent, 0.0, 100.0);
// A default-FIFO strategy.close batched earlier on this SAME bar has not
// filled yet (it fills at the end-of-bar flush) but its qty is already
// committed. An exit armed after that close call must size against the
// post-close position — exactly what it saw when the immediate path
// executed the close mid-bar. Without this, a close + reversal-entry +
// exit sequence freezes the OLD side's size into the bracket's reserved
// qty (wayward-bison: the Long SL stop filled only the stale short-sized
// 3.9629 of an 8.0672 long, fragmenting one TV exit into two rows).
double sb_pending_close = (sb_close_active_ && sb_close_bar_ == bar_index_)
? pending_same_bar_close_target()
: 0.0;
double live_pos_qty = (position_side_ == PositionSide::FLAT)
? 0.0
: std::max(0.0, position_qty_ - sb_pending_close);
bool effectively_flat = live_pos_qty <= kQtyEpsilon;
// If an explicit qty is given, derive an effective qp from the current
// position size so downstream FIFO accounting (compute_exit_reserved_qty,
// already-reserved tally, etc.) sees a consistent fraction. The order
// itself stores the absolute qty so the per-fill execution path
// honours the literal request.
if (has_explicit_qty && live_pos_qty > kQtyEpsilon) {
double clamped_qty = std::min(qty, live_pos_qty);
qp = (clamped_qty / live_pos_qty) * 100.0;
}
bool is_partial = qp < 100.0 - kFullPercentEps;
bool has_trail_request = !std::isnan(trail_points) || !std::isnan(trail_price);
// Re-issued explicitly partial exits with the same id are one-shot for a live position.
if (is_partial && !effectively_flat
&& consumed_partial_exit_ids_.find(id) != consumed_partial_exit_ids_.end()) {
return;
}
int64_t preserved_seq = 0;
double preserved_reserved_qty = std::numeric_limits<double>::quiet_NaN();
clear_existing_exit_order(id, from_entry, has_trail_request,
preserved_seq, preserved_reserved_qty);
double reserved_qty = std::numeric_limits<double>::quiet_NaN();
if (has_explicit_qty) {
// Honour the explicit qty literally (clamped to the live position
// and subject to the same already-reserved accounting). This is
// the path Pine's ``strategy.exit(... qty=N)`` follows when N is
// strictly smaller than the open position size — required for
// multi-bracket per-position exits (validation_oca/oca-three-way-
// probe-02 has two qty=1 brackets attached to a qty=2 entry).
if (effectively_flat) {
// Defer placement; FIFO accounting will recompute when a
// position eventually exists.
reserved_qty = std::min(qty, std::numeric_limits<double>::infinity());
} else {
double already_reserved = 0.0;
for (const auto& o : pending_orders_) {
if (o.type != OrderType::EXIT || o.from_entry != from_entry) continue;
if (!std::isnan(o.qty)) {
already_reserved += o.qty;
} else {
double oqp = std::isnan(o.qty_percent) ? 100.0
: std::clamp(o.qty_percent, 0.0, 100.0);
already_reserved += live_pos_qty * (oqp / 100.0);
}
}
// Reservation capacity: by default the live position net of any
// same-bar batched close (legacy behaviour, post-close sizing).
// But when a PENDING entry order with
// id == from_entry exists, TV binds the bracket to THAT entry's
// eventual fills, not to the unrelated live position: a reversal
// bar places qty=1 brackets for the about-to-fill opposite qty=2
// entry while the old position (1 lot the other way) is still
// open. Clamping to the live position dropped every bracket
// after the first (thulashimohanr-prev-day-week-levels probe:
// RevShortT2's stop order never existed, so the engine sailed
// through TV's overnight stop-out and desynced for days).
// Capacity then = open fills already tagged from_entry (same-id
// pyramiding remainder) + the pending entry's qty (unbounded
// when the entry's qty only resolves at fill time).
double capacity = live_pos_qty;
bool entry_pending = false;
double pending_entry_qty = 0.0;
for (const auto& o : pending_orders_) {
if (o.id != from_entry) continue;
if (o.type != OrderType::MARKET && o.type != OrderType::ENTRY
&& o.type != OrderType::RAW_ORDER) continue;
entry_pending = true;
if (std::isnan(o.qty)) {
pending_entry_qty = std::numeric_limits<double>::infinity();
} else {
pending_entry_qty += o.qty;
}
}
if (entry_pending) {
double open_from_entry = 0.0;
for (const auto& pe : pyramid_entries_) {
if (pe.entry_id == from_entry) open_from_entry += pe.qty;
}
capacity = open_from_entry + pending_entry_qty;
}
double available = std::max(0.0, capacity - already_reserved);
reserved_qty = std::min(qty, available);
if (reserved_qty <= kQtyEpsilon) return;
}
is_partial = reserved_qty < live_pos_qty - kFullQtyEps;
} else {
// Default-sized (percent) bracket armed while its from_entry ENTRY is
// still a PENDING order in the OPPOSITE direction of the live
// position (the reversal-bar shape: strategy.entry(X) +
// strategy.exit(from_entry=X) issued together while the old opposite
// position is still open). TV binds the bracket to X's eventual
// fill — the bracket closes 100% of the lot the entry actually
// opens. Freezing reserved_qty at the CURRENT position size (the
// old, about-to-be-replaced side) under-sizes the bracket whenever
// the fresh percent-of-equity lot exceeds the old position, leaving
// a dust remainder (q_plain - |old|) open when the bracket fires —
// the seed of jevondijefferson's multi-day tiny-qty desync chains
// (2025-05-23 12:00, 2025-10-04 15:30, 2026-02-13 15:15,
// 2026-02-22 13:45 UTC: e.g. 10-04 bracket froze at 4.3847 against
// the new 4.5089 short, stranding 0.1242). Defer the reservation
// (qty = NaN): the fill-side path then executes a FULL exit against
// the live position, exactly like a bracket placed while flat.
// Mirrors the explicit-qty path's pending-entry capacity rule above
// (thulashimohanr fix); entries with an explicit qty keep the
// legacy reservation math.
bool bind_to_pending_reversal_entry = false;
if (!from_entry.empty() && !effectively_flat
&& qp >= 100.0 - kFullPercentEps) {
for (const auto& o : pending_orders_) {
if (o.id != from_entry) continue;
if (o.type != OrderType::MARKET && o.type != OrderType::ENTRY
&& o.type != OrderType::RAW_ORDER) continue;
PositionSide entry_dir = o.is_long ? PositionSide::LONG
: PositionSide::SHORT;
if (entry_dir != position_side_ && std::isnan(o.qty)) {
bind_to_pending_reversal_entry = true;
}
break; // entry ids are unique in pending_orders_
}
}
if (!bind_to_pending_reversal_entry
&& !compute_exit_reserved_qty(from_entry, preserved_reserved_qty,
live_pos_qty, qp, is_partial, reserved_qty)) {
return;
}
}
PendingOrder order;
order.id = id;
order.from_entry = from_entry;
order.type = OrderType::EXIT;
order.is_long = false;
order.limit_price = limit_price;
order.stop_price = stop_price;
order.trail_points = trail_points;
order.trail_price = trail_price;
order.trail_offset = trail_offset;
order.profit_ticks = profit_ticks;
order.loss_ticks = loss_ticks;
order.qty = reserved_qty;
order.qty_type = -1;
order.qty_percent = qp;
order.requested_partial = is_partial;
// OCA-name plumbing: ``strategy.exit`` supports oca_name (Pine v6) so
// siblings in different OCA groups can fire independently. The cancel
// sweep predicate (engine_fills.cpp::apply_filled_order_to_state →
// cancel_oca_group) already isolates groups by name; without this
// assignment all strategy.exit-issued orders shared an empty name and
// the first bracket's TP would silently leave the other bracket's
// legs intact (probe oca-three-way-02 lost ~42% of its trades).
order.oca_name = oca_name;
order.oca_type = oca_name.empty() ? 0 : 1; // strategy.exit semantics: cancel
order.created_bar = bar_index_;
order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++;
// Position-derived captures use the post-batched-close view (see
// live_pos_qty above) so an exit armed after a same-bar strategy.close
// records the same state it did when the close executed mid-bar.
order.created_position_side = effectively_flat ? PositionSide::FLAT : position_side_;
order.tv_carry_qty = live_pos_qty;
order.comment = comment;
order.created_while_in_position = !effectively_flat;
pending_orders_.push_back(std::move(order));
}
void BacktestEngine::strategy_cancel(const std::string& id) {
pending_orders_.erase(
std::remove_if(pending_orders_.begin(), pending_orders_.end(),
[&](const PendingOrder& o) { return o.id == id; }),
pending_orders_.end());
}
void BacktestEngine::strategy_cancel_all() {
pending_orders_.clear();
}
void BacktestEngine::strategy_order(const std::string& id, bool is_long, double qty,
double limit_price, double stop_price,
const std::string& oca_name, int oca_type) {
if (!trading_is_active(current_bar_.timestamp, trade_start_time_, script_tf_seconds_)) return;
int64_t preserved_seq = 0;
for (const auto& o : pending_orders_) {
if (o.id == id) {
preserved_seq = o.created_seq;
break;
}
}
// Remove existing pending order with same id
pending_orders_.erase(
std::remove_if(pending_orders_.begin(), pending_orders_.end(),
[&](const PendingOrder& o) { return o.id == id; }),
pending_orders_.end());
PendingOrder order;
order.id = id;
order.from_entry = "";
order.is_long = is_long;
order.trail_points = std::numeric_limits<double>::quiet_NaN();
order.trail_price = std::numeric_limits<double>::quiet_NaN();
order.trail_offset = std::numeric_limits<double>::quiet_NaN();
order.qty = qty;
order.qty_type = -1;
order.qty_percent = 100.0;
order.oca_name = oca_name;
order.oca_type = oca_type;
order.created_bar = bar_index_;
order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++;
order.created_position_side = position_side_;
order.tv_carry_qty = position_qty_;
bool has_limit = !std::isnan(limit_price);
bool has_stop = !std::isnan(stop_price);
if (!has_limit && !has_stop) {
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);
// KI-54: same admission snapshot as strategy_entry's MARKET
// branch. The fill-time gate skips opposite-direction RAW fills
// (they only close the position) — see
// apply_filled_order_to_state.
order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long);
order.sizing_equity =
current_equity() + open_profit(current_bar_.close);
order.sizing_mark = current_bar_.close;
}
} else {
order.type = OrderType::RAW_ORDER;
order.limit_price = limit_price;
order.stop_price = stop_price;
}
pending_orders_.push_back(std::move(order));
}
// ────────────────────────────────────────────────────────────────────
// strategy_close / strategy_exit helpers
// ────────────────────────────────────────────────────────────────────
// Validate a strategy.close request against the live position and
// pyramid roster, returning the qty to actually close. Sets
// matching_qty / qty_to_close / all_entries_match. Returns false when
// the id specifies an unknown entry or the resolved qty rounds to
// zero, signalling the caller to early-return.
bool BacktestEngine::compute_close_target_qty(const std::string& id,
double qty,
double qty_percent,
double& matching_qty_out,
double& qty_to_close_out,
bool& all_entries_match_out) {
const double eps0 = kQtyEpsilon;
// Default FIFO close-entries rule: strategy.close(id) with no explicit
// qty/qty_percent closes the UNCLOSED quantity tagged `id`
// (id_unclosed_qty_) and FIFO-attributes the resulting trade records to
// the OLDEST open entries (handled downstream by the plain FIFO drain).
//
// This is what TradingView does: close(id) closes the quantity entered
// under `id` and not yet targeted by a prior close(id) — it does NOT
// re-sum the physical open lots carrying that id. The two agree when each
// id maps to a single open lot. They diverge for grid bots that re-use
// one entry id across sequential buy/sell cycles: the FIFO trade-record
// drain removes the oldest lot (often a DIFFERENT id), so the id-tagged
// lot stays physically open even though a prior close(id) already
// accounted for it. Summing physical lots then double-closes it (engine
// over-closes 2x), while a TP whose id-lot was drained away by an earlier
// close would find no physical match and be skipped (engine under-closes)
// — both fixed here by consulting the logical ledger instead.
//
// The ANY rule keeps the physical id-matched path (closes_any_qty).
if (!close_entries_rule_any_ && !id.empty()
&& std::isnan(qty) && std::isnan(qty_percent)) {
auto it = id_unclosed_qty_.find(id);
double unclosed = (it != id_unclosed_qty_.end()) ? it->second : 0.0;
double target = std::min(unclosed, position_qty_);
all_entries_match_out = false; // FIFO drain may span lots of other ids
if (target <= eps0) {
return false;
}
matching_qty_out = target;
qty_to_close_out = target;
// Consume the id's unclosed ledger now that the close commits.
it->second -= target;
if (it->second <= eps0) {
id_unclosed_qty_.erase(it);
}
return true;
}
bool has_matching_entry = id.empty();
all_entries_match_out = id.empty() ? true : !pyramid_entries_.empty();
matching_qty_out = id.empty() ? position_qty_ : 0.0;
if (!id.empty()) {
has_matching_entry = false;
for (const auto& pe : pyramid_entries_) {
if (pe.entry_id == id) {
has_matching_entry = true;
matching_qty_out += pe.qty;
} else {
all_entries_match_out = false;
}
}
}
if (!id.empty() && !has_matching_entry) {
return false;
}
const double eps = kQtyEpsilon;
qty_to_close_out = matching_qty_out;
if (!std::isnan(qty)) {
qty_to_close_out = std::min(std::max(qty, 0.0), matching_qty_out);
} else if (!std::isnan(qty_percent)) {
double pct = std::clamp(qty_percent, 0.0, 100.0);
qty_to_close_out = matching_qty_out * (pct / 100.0);
}
if (qty_to_close_out <= eps) {
return false;
}
return true;
}
// Wipe pending orders that should not survive a full strategy.close:
//
// Pending strategy.exit orders bound to the same entry id are wiped
// (community/IES regression: a partial TP1 limit and the queued
// market close were both firing on the next bar's open, producing
// two trade rows for the same logical close).
//
// Pending strategy.entry / market orders are LEFT ALONE. Per
// TradingView's documented broker semantics, only cancel()/cancel_all()
// cancel pending orders; close()/close_all() only closes the open
// position — a pending same-direction entry (e.g. a stale add-on stop
// placed while the position was open) survives and can still fire on a
// later bar, "ghost-refilling" into a new position. This mirrors DCA/
// grid-bot bots (3commas-style: N independent price-level orders,
// closing one level must not silently cancel another level's still-
// pending order) — verified against 3commas-3commas-pullback-sniper-
// strategy, where the previous same-direction wipe was itself the bug
// (closed-form count-delta 2.88% -> <0.5%).
void BacktestEngine::cancel_orders_for_full_close(const std::string& id, bool /*closing_long*/) {
pending_orders_.erase(
std::remove_if(
pending_orders_.begin(),
pending_orders_.end(),
[&](const PendingOrder& o) {
if (o.type != OrderType::EXIT) {
return false;
}
if (id.empty()) {
return o.from_entry.empty();
}
return o.from_entry == id;
}),
pending_orders_.end());
}
void BacktestEngine::cancel_same_bar_market_reentries_after_full_close(bool closed_long) {
const PositionSide closed_side = closed_long ? PositionSide::LONG : PositionSide::SHORT;
pending_orders_.erase(
std::remove_if(
pending_orders_.begin(),
pending_orders_.end(),
[&](const PendingOrder& o) {
// Deferred full exits already remove same-direction market
// entries through process_pending_orders' exit_closed_from_bar
// machinery. POOC/immediate closes execute outside that loop,
// so mirror only the market-reentry cleanup here. Priced
// entries intentionally survive (covered by
// test_strategy_close_pooc_keeps_same_bar_pending_entry), and
// opposite-direction market entries remain valid reversals.
return o.type == OrderType::MARKET
&& o.created_bar == bar_index_
&& o.created_position_side == closed_side
&& o.is_long == closed_long;
}),
pending_orders_.end());
}
// Run the close at the current bar's close price (the
// process_orders_on_close / strategy.close(immediately=true) path).
// Dispatches between full, FIFO-partial, and by-entry-percent partial
// exit primitives, then tags the new trade rows with comment + exit_id.
void BacktestEngine::execute_immediate_close(const std::string& id,
const std::string& comment,
double qty_to_close,
double matching_qty,
bool closes_full_position,
bool closes_fifo_qty,
bool closes_any_qty) {
const double eps = kQtyEpsilon;
size_t trades_before = trades_.size();
if (closes_full_position) {
const bool closed_long = (position_side_ == PositionSide::LONG);
execute_market_exit(current_bar_.close);
purge_exit_orders();
if (position_side_ == PositionSide::FLAT) {
cancel_same_bar_market_reentries_after_full_close(closed_long);
}
} else if (closes_fifo_qty) {
execute_partial_exit_qty(current_bar_.close, qty_to_close);
if (position_side_ == PositionSide::FLAT) {
purge_exit_orders();
}
} else if (closes_any_qty) {
double pct = matching_qty > eps ? (qty_to_close / matching_qty) * 100.0 : 100.0;
execute_partial_exit_by_entry_percent(current_bar_.close, id, pct);
if (position_side_ == PositionSide::FLAT) {
purge_exit_orders();
}
}
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = comment;
trades_[ti].exit_id = "__close__" + id;
}
}
// Build the deferred EXIT pending order representing this close, to
// be matched at the next bar's open by process_pending_orders. Mirrors
// the qty / qty_percent shape that the partial-exit dispatch in
// execute_immediate_close would have produced for the same flags.
void BacktestEngine::queue_deferred_close_order(const std::string& id,
const std::string& comment,
double qty_to_close,
double matching_qty,
bool closes_full_position,
bool closes_any_qty) {
const double eps = kQtyEpsilon;
PendingOrder order;
order.id = "__close__" + id;
order.from_entry = close_entries_rule_any_ ? id : "";
order.type = OrderType::EXIT;
order.is_long = false;
order.limit_price = std::numeric_limits<double>::quiet_NaN();
order.stop_price = std::numeric_limits<double>::quiet_NaN();
order.trail_points = std::numeric_limits<double>::quiet_NaN();
order.trail_price = std::numeric_limits<double>::quiet_NaN();
order.trail_offset = std::numeric_limits<double>::quiet_NaN();
if (closes_any_qty) {
order.qty = std::numeric_limits<double>::quiet_NaN();
order.qty_type = -1;
order.qty_percent = matching_qty > eps ? (qty_to_close / matching_qty) * 100.0 : 100.0;
} else {
order.qty = closes_full_position ? std::numeric_limits<double>::quiet_NaN() : qty_to_close;
order.qty_type = -1;
order.qty_percent = closes_full_position ? 100.0
: (position_qty_ > eps ? (qty_to_close / position_qty_) * 100.0 : 100.0);
}
order.oca_name = "";
order.oca_type = 0;
order.created_bar = bar_index_;
order.created_seq = next_order_seq_++;
order.created_position_side = position_side_;
order.tv_carry_qty = position_qty_;
order.comment = comment;
order.created_while_in_position = true;
pending_orders_.push_back(std::move(order));
}
// Capture seq + reserved qty of an existing pending exit with the
// same (id, from_entry), reset the trail high-water mark when starting
// a fresh trail (no prior order, in-position), and erase the matching
// pending EXIT order so the caller can push a freshly built
// replacement.
void BacktestEngine::clear_existing_exit_order(const std::string& id,
const std::string& from_entry,
bool has_trail_request,
int64_t& preserved_seq_out,
double& preserved_reserved_qty_out) {
bool had_existing_order = false;
preserved_seq_out = 0;
preserved_reserved_qty_out = std::numeric_limits<double>::quiet_NaN();
for (const auto& o : pending_orders_) {
if (o.type == OrderType::EXIT && o.id == id && o.from_entry == from_entry) {
had_existing_order = true;
preserved_seq_out = o.created_seq;
if (!std::isnan(o.qty)) {
preserved_reserved_qty_out = o.qty;
}
break;
}
}
if (has_trail_request && !had_existing_order && position_side_ != PositionSide::FLAT) {
trail_best_price_ = current_bar_.close;
}
// Erase only the matching prior EXIT order — mirror the gated lookup
// above. TradingView keeps entry-order ids and exit-order ids in
// independent namespaces: a strategy.exit replacing its prior order
// must never clobber a same-bar pending strategy.entry that happens to
// reuse the id string, nor a sibling exit attached to a different
// from_entry. A bare ``o.id == id`` predicate deleted the still-pending
// entry, so the strategy never opened a position (zero trades).
pending_orders_.erase(
std::remove_if(pending_orders_.begin(), pending_orders_.end(),
[&](const PendingOrder& o) {