Repository navigation
Expand file tree
/
Copy pathengine_fills.cpp
More file actions
1634 lines (1552 loc) · 85.2 KB
/
Copy pathengine_fills.cpp
File metadata and controls
1634 lines (1552 loc) · 85.2 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_fills.cpp — process_pending_orders — the bar-pump fill loop
*/
#include "engine_internal.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <stdexcept>
#include <unordered_set>
namespace pineforge {
using namespace internal;
// strategy_entry / strategy_close / strategy_close_all / strategy_exit
// moved to engine_strategy_commands.cpp.
void BacktestEngine::process_pending_orders(const Bar& bar) {
// Update risk state
update_risk_state();
double trail_best_path_state = trail_best_price_;
update_trail_best_for_bar_open(bar);
materialize_relative_exit_prices_for_live_position();
sort_exit_siblings_by_path_fill(bar);
sort_orders_by_fill_phase(bar);
if (priced_entry_activity_bar_ != bar_index_) {
priced_entry_activity_bar_ = bar_index_;
priced_entry_filled_this_bar_ = false;
}
int exit_closed_from_bar = -1; // created_bar of the last full-close exit
bool exit_closed_was_long = false; // direction of the closed position
// Reusable member scratchpad (capacity persists across calls; avoids a
// heap allocation per process_pending_orders call). Must start empty.
std::unordered_set<std::string>& pass0_opposing_skip_ids = scratch_skip_ids_;
pass0_opposing_skip_ids.clear();
DualEntryStopPathWinner dual_entry_path_ = DualEntryStopPathWinner::None;
if (position_side_ == PositionSide::FLAT) {
dual_entry_path_ = dual_entry_stop_path_winner(bar, pending_orders_);
}
for (int opposing_pass = 0; opposing_pass < 2; ++opposing_pass) {
// Pass 1 only re-evaluates orders pass 0 deferred into the skip set;
// with an empty set every order classifies Skip and the pass is a
// structural no-op. Bail before paying the scan.
if (opposing_pass == 1 && pass0_opposing_skip_ids.empty()) break;
std::vector<size_t>& filled_indices = scratch_filled_indices_;
filled_indices.clear();
// TV cancels pending SAME-DIRECTION entries placed on a prior on_bar when
// a full strategy.exit closes the position on this bar. Opposite-direction
// (reversal) entries still fire. Entries placed on the SAME on_bar as the
// fired exit also still fire (close + entry reversal placed together).
for (size_t i = 0; i < pending_orders_.size(); i++) {
PendingOrder& order = pending_orders_[i];
auto eligibility = classify_order_eligibility(
order, opposing_pass, dual_entry_path_, pass0_opposing_skip_ids,
exit_closed_from_bar, exit_closed_was_long, bar);
if (eligibility == OrderEligibility::Remove) {
filled_indices.push_back(i);
continue;
}
if (eligibility == OrderEligibility::Skip) {
continue;
}
auto fill = evaluate_fill_price(
order, i, bar, opposing_pass, trail_best_path_state,
pass0_opposing_skip_ids);
if (fill.kind != FillEvaluation::Kind::Fill) {
continue;
}
apply_filled_order_to_state(
order, i, fill.fill_price, fill.is_limit_fill, bar,
trail_best_path_state,
exit_closed_from_bar, exit_closed_was_long,
filled_indices);
materialize_relative_exit_prices_for_live_position();
}
compact_filled_pending_orders(filled_indices, exit_closed_from_bar, exit_closed_was_long);
} // opposing_pass
// If position is flat after processing, purge remaining exit orders — but
// RETAIN from_entry brackets whose parent entry is still pending (a limit
// entry that has not yet filled), so they fire once the entry fills.
if (position_side_ == PositionSide::FLAT) {
purge_exit_orders(/*retain_for_pending_entries=*/true);
}
}
// TradingView force-liquidation (margin call).
//
// Run once per script bar (end of dispatch_bar / magnifier bar) after all
// order processing. While a position is open, TV's broker emulator checks
// whether strategy equity still covers the position's required margin using
// the bar's ADVERSE extreme (bar HIGH for shorts, bar LOW for longs). When the
// adverse extreme reaches/passes the liquidation price the emulator force-
// closes part of the position:
//
// - fill price = the bar's ADVERSE extreme (high for shorts, low for longs),
// which is what TV's exported margin-call rows report — not the bare
// liquidation level (the liq price only gates whether a call happens).
// - quantity = 4x the minimum amount needed to restore margin at the adverse
// extreme, capped at the full position. The documented 4x over-liquidation
// prevents a margin call recurring on every subsequent bar and produces
// TV's iterative "nibble" pattern (a deep-underwater position closes in
// several 4x chunks across bars).
// - the resulting trade rows are tagged with the "Margin call" exit comment.
//
// Validated against the p2 margin-call short probe (TV: 68 margin calls, first
// at ~1798.26) and the leverage-margin-call-perp-5x long probe.
void BacktestEngine::process_margin_call(const Bar& bar) {
if (!margin_call_enabled_) return;
if (position_side_ == PositionSide::FLAT) return;
// No post-entry adverse path exists on a bar where the position opened at
// the bar CLOSE: with process_orders_on_close, market entries fill at the
// close, so there is no remaining intrabar movement for price to breach
// margin on the entry bar itself (TV emits the first margin call on the
// NEXT bar — p2 probe: entry 15:30, first call 15:45). When entries fill at
// the bar OPEN (process_orders_on_close=false) the full bar path IS
// post-entry, so a same-bar liquidation is allowed (leverage probe: entry
// and first margin call land on the same bar).
if (process_orders_on_close_ && position_open_bar_ == bar_index_) return;
const double liq = compute_liquidation_price();
// A LONG at exactly 100% margin yields denom == (m/100 - dir) == 0, so
// compute_liquidation_price() returns na (there is no leverage-derived
// liquidation price — a 1x long can only be wiped at price 0). But such a
// position CAN still be force-liquidated when its NOTIONAL exceeds equity:
// TradingView over-allocates percent_of_equity entries whose fill price
// exceeds the sizing price (stop/gap fills, slippage, reversals, or the
// entry commission), leaving the fresh position momentarily under-margined
// and triggering a same-bar "Margin call" trim on longs. The equity /
// required-margin gate below is the only check that can decide this case
// (it reduces to notional > equity for a 1x long), so do NOT hard-bail on
// na here for that one situation. Every OTHER na cause (flat, qty<=0,
// pv<=0, non-finite) is still rejected by the explicit guards that follow,
// and shorts never hit denom==0 (denom = m/100 + 1 > 0), so their behavior
// is byte-identical. margin_liquidation_price() itself keeps returning na
// for a 1x long, matching TradingView's strategy.margin_liquidation_price.
const bool long_full_margin =
(position_side_ == PositionSide::LONG)
&& std::isfinite(margin_long_)
&& std::abs(margin_long_ / 100.0 - 1.0) < 1e-12;
if (std::isnan(liq) && !long_full_margin) return; // flat / denom==0 / invalid size already filtered
const double pv = syminfo_.pointvalue;
const double qty = position_qty_;
const double direction = (position_side_ == PositionSide::LONG) ? 1.0 : -1.0;
const double margin_pct = (position_side_ == PositionSide::LONG)
? margin_long_ : margin_short_;
const double m = margin_pct / 100.0;
if (!(m > 0.0)) return;
// Adversarial / degenerate feeds (NaN/Inf prices, non-finite state) must
// never let a non-finite value escape into a trade record.
if (!std::isfinite(qty) || !(qty > 0.0) || !std::isfinite(position_entry_price_)
|| !std::isfinite(pv) || !std::isfinite(initial_capital_)
|| !std::isfinite(net_profit_sum_)) {
return;
}
// Adverse extreme: shorts lose as price rises (bar high); longs lose as
// price falls (bar low).
const double adverse = (position_side_ == PositionSide::LONG) ? bar.low : bar.high;
if (!std::isfinite(adverse) || !(adverse > 0.0)) return;
// Equity at the adverse extreme = capital + realized PnL + open P/L on the
// FULL position. Required margin = position value at the adverse price.
const double equity_adv = initial_capital_ + net_profit_sum_
+ direction * (adverse - position_entry_price_) * qty * pv;
const double req_margin_adv = qty * adverse * pv * m;
if (equity_adv >= req_margin_adv) return; // margin still covered — no call
// Minimum qty whose removal restores margin at the adverse extreme. Closing
// q units leaves the bar-equity unchanged (realized + open P/L just
// reclassify) while the required margin shrinks: need
// (qty - q) * adverse * pv * m <= equity_adv => q >= qty - equity_adv/(adverse*pv*m).
double q_min = qty - equity_adv / (adverse * pv * m);
if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return;
// Per-instrument lot quantization. TradingView floors the minimum-restore
// qty to the instrument's quantity step BEFORE applying the 4x over-
// liquidation — not after. Flooring the 4x PRODUCT instead injects a
// ~qty_step/4 error into the first nibble that compounds ~3x per step
// through the margin-call cascade (row-diff vs the ETHUSDT.P export,
// alpha-wizard-channel percent_of_equity=100: floor-BEFORE reproduces the
// first 14 cascade nibbles bit-exact — 7.7232 / 30.3796 / 35.716 / 19.1516
// / 53.0532 / 59.69 / … ; floor-AFTER matched 0/19 and desynced by step 7).
// qty_step_ == 0 (corpus default; the explicit-leverage p2/5x margin probes
// never set it) leaves both q_min and qty_liq untouched -> byte-identical.
if (qty_step_ > 0.0) {
q_min = std::floor(q_min / qty_step_) * qty_step_;
}
double qty_liq = 4.0 * q_min;
if (qty_step_ > 0.0) {
// q_min is already a multiple of qty_step_, so 4*q_min is mathematically
// a multiple too — but binary float makes e.g. 4*5.7089 = 22.83559999…,
// which a bare std::floor drops a whole lot (→ 22.8355 vs TV's 22.8356).
// The +1e-6 epsilon (same guard as quantize_qty in engine.hpp) pins it to
// the intended lot. Without it the tail nibbles desync from ~step 14 on;
// with it alpha-wizard-channel cascade-1 matches TV 19/19 bit-exact.
double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_;
if (floored <= kQtyEpsilon) {
// A liquidation IS required (we passed the margin-shortfall gate)
// but the floored lot rounds to zero (sub-lot shortfall). 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;
// Fill at the bar's adverse extreme. TradingView's exported margin-call
// rows fill at the bar HIGH (shorts) / LOW (longs) — the worst price the
// bar reached after the liquidation level was crossed — NOT at the bare
// liquidation price. Verified row-for-row against the p2 probe TV export
// (first short call fills at 1798.26 = that bar's high, with
// liq=1793.12). ``liq`` above is used only to gate na/denominator cases.
// ``adverse`` already lies within [low, high]. current_fill_is_limit_
// stays false so execute_market_exit / execute_partial_exit_qty apply
// market (slipped) economics.
const double fill = adverse;
const size_t trades_before = trades_.size();
if (qty_liq >= qty - kQtyEpsilon) {
execute_market_exit(fill);
} else {
execute_partial_exit_qty(fill, qty_liq);
}
// Tag every trade row this liquidation produced with TV's "Margin call".
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = "Margin call";
trades_[ti].exit_id = "__margin_call__";
}
}
// ────────────────────────────────────────────────────────────────────
// process_pending_orders helpers
// ────────────────────────────────────────────────────────────────────
// Update trailing stop best price for the current bar's open / high / low.
// Called once per bar before any intra-bar fill evaluation.
void BacktestEngine::update_trail_best_for_bar_open(const Bar& bar) {
if (position_side_ == PositionSide::LONG) {
if (std::isnan(trail_best_price_) || bar.high > trail_best_price_)
trail_best_price_ = bar.high;
} else if (position_side_ == PositionSide::SHORT) {
if (std::isnan(trail_best_price_) || bar.low < trail_best_price_)
trail_best_price_ = bar.low;
}
}
// Order sibling EXIT orders (sharing the same from_entry id): by earliest
// intra-bar OHLC path trigger when neither uses trail; otherwise full
// (100%) before partial. Stable so PineScript source order is preserved
// for ties.
//
// PERF NOTE (P3): this stable_sort and the following sort_orders_by_fill_phase
// stable_sort are intentionally kept as two passes. They CANNOT be merged into
// one combined comparator without risking a change in fill order:
// - This pass orders exit siblings by a path-fill metric (or full-before-
// partial) that the fill-phase comparator has no knowledge of.
// - The fill-phase pass breaks final ties by created_seq, NOT by current
// array position, so it does not preserve this pass's path-fill ordering
// for orders that tie on fill phase. Folding the path-fill metric into the
// fill-phase comparator would re-rank those ties and alter which sibling
// fills first.
// Correctness over perf: leave as two sequential stable_sorts.
void BacktestEngine::sort_exit_siblings_by_path_fill(const Bar& bar) {
if (pending_orders_.size() < 2) return; // nothing to order; skips stable_sort's temp-buffer alloc
std::stable_sort(pending_orders_.begin(), pending_orders_.end(),
[&](const PendingOrder& a, const PendingOrder& b) {
if (a.type != OrderType::EXIT || b.type != OrderType::EXIT
|| a.from_entry != b.from_entry || a.from_entry.empty()) {
return false;
}
auto qp = [](const PendingOrder& o) {
double q = std::isnan(o.qty_percent) ? 100.0 : std::clamp(o.qty_percent, 0.0, 100.0);
return q;
};
bool a_full = qp(a) >= 100.0 - kFullPercentEps;
bool b_full = qp(b) >= 100.0 - kFullPercentEps;
const bool a_trail = !std::isnan(a.trail_points) || !std::isnan(a.trail_price);
const bool b_trail = !std::isnan(b.trail_points) || !std::isnan(b.trail_price);
if (a_trail || b_trail) {
if (a_full != b_full) {
return a_full;
}
return false;
}
bool is_ent_bar = (position_open_bar_ == bar_index_);
double ma = exit_order_earliest_path_metric_no_trail(
bar, a, position_side_, is_ent_bar, position_entry_price_);
double mb = exit_order_earliest_path_metric_no_trail(
bar, b, position_side_, is_ent_bar, position_entry_price_);
const double inf = std::numeric_limits<double>::infinity();
const double eps = kPathPosEps;
if (ma < inf && mb < inf) {
if (ma < mb - eps) {
return true;
}
if (mb < ma - eps) {
return false;
}
}
if (ma < inf && mb >= inf) {
return true;
}
if (mb < inf && ma >= inf) {
return false;
}
if (a_full != b_full) {
return a_full;
}
return false;
});
}
// Sort by the first possible fill point, then by PineScript source order.
// Market orders fill at bar open. Priced orders that gap through at open
// share that same fill point; other priced orders evaluate later on the
// synthetic OHLC path. This avoids broad type-based reordering.
void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) {
if (pending_orders_.size() < 2) return; // nothing to order; skips stable_sort's temp-buffer alloc
std::stable_sort(pending_orders_.begin(), pending_orders_.end(),
[&](const PendingOrder& a, const PendingOrder& b) {
auto fill_phase = [&](const PendingOrder& o) {
bool has_stop = !std::isnan(o.stop_price);
bool has_limit = !std::isnan(o.limit_price);
bool has_trail = !std::isnan(o.trail_points) || !std::isnan(o.trail_price);
bool exit_style = order_is_exit_style(o, position_side_);
if (o.type == OrderType::MARKET
|| (!has_stop && !has_limit && !has_trail)) {
return 0;
}
if (exit_style) {
if (position_side_ == PositionSide::LONG) {
if (has_stop && bar.open <= o.stop_price) return 0;
if (has_limit && bar.open >= o.limit_price) return 0;
} else if (position_side_ == PositionSide::SHORT) {
if (has_stop && bar.open >= o.stop_price) return 0;
if (has_limit && bar.open <= o.limit_price) return 0;
}
return 1;
}
if (o.is_long) {
if (has_stop && bar.open >= o.stop_price) return 0;
if (has_limit && bar.open <= o.limit_price) return 0;
} else {
if (has_stop && bar.open <= o.stop_price) return 0;
if (has_limit && bar.open >= o.limit_price) return 0;
}
return 1;
};
int pa = fill_phase(a);
int pb = fill_phase(b);
if (pa != pb) return pa < pb;
auto is_entry_same_as_current_position = [&](const PendingOrder& o) {
return (o.type == OrderType::MARKET || o.type == OrderType::ENTRY)
&& ((position_side_ == PositionSide::LONG && o.is_long)
|| (position_side_ == PositionSide::SHORT && !o.is_long));
};
bool a_exit_style = order_is_exit_style(a, position_side_);
bool b_exit_style = order_is_exit_style(b, position_side_);
bool a_entry_same = is_entry_same_as_current_position(a);
bool b_entry_same = is_entry_same_as_current_position(b);
if (a_exit_style && b_entry_same) return true;
if (b_exit_style && a_entry_same) return false;
// TradingView empirically processes a same-bar full market
// exit BEFORE an opposite-direction priced (stop/limit) entry,
// even when the priced entry gaps through the open and would
// otherwise share the entry's same fill phase. Verified by
// ``test_market_close_fills_before_same_bar_opposite_stop_entry``
// (close-then-fresh-stop) and probes 52, 63, 72, 92 (close-
// then-deferred-flip-stop). Without this rule the priced entry
// would flip the still-open position at the open, eating the
// close-driven exit's deferred-flip carry.
auto is_full_market_exit = [&](const PendingOrder& o) {
if (o.type != OrderType::EXIT) return false;
bool has_stop = !std::isnan(o.stop_price);
bool has_limit = !std::isnan(o.limit_price);
bool has_trail = !std::isnan(o.trail_points) || !std::isnan(o.trail_price);
if (has_stop || has_limit || has_trail) return false;
double qp = std::isnan(o.qty_percent) ? 100.0 : o.qty_percent;
return qp >= 100.0 - kFullPercentEps;
};
auto is_opposite_priced_entry = [&](const PendingOrder& o) {
if (o.type != OrderType::ENTRY) return false;
if (position_side_ == PositionSide::FLAT) return false;
bool entry_long = o.is_long;
bool pos_long = (position_side_ == PositionSide::LONG);
return entry_long != pos_long;
};
bool a_full_close = is_full_market_exit(a);
bool b_full_close = is_full_market_exit(b);
bool a_opp_priced = is_opposite_priced_entry(a);
bool b_opp_priced = is_opposite_priced_entry(b);
if (a_full_close && b_opp_priced) return true;
if (b_full_close && a_opp_priced) return false;
return a.created_seq < b.created_seq;
});
}
// Remove filled orders in O(n) single pass and mirror the in-loop wipe
// predicate: only stale entries that were ADDED to the just-closed
// position (created_position_side matches the closed direction) get
// cleaned out. Opposite-direction-prep stops armed during a previous
// position cycle survive (probe 93).
void BacktestEngine::compact_filled_pending_orders(
const std::vector<size_t>& filled_indices,
int exit_closed_from_bar,
bool exit_closed_was_long) {
if (filled_indices.empty()) return;
// filled_indices is built by push_back(i) with i strictly increasing over
// the inner fill loop (at most one push per iteration), so it is already
// sorted ascending with no duplicates. A binary search over the vector
// replaces a per-call hash-table build for the membership test below.
auto is_filled = [&](size_t idx) {
return std::binary_search(filled_indices.begin(), filled_indices.end(), idx);
};
PositionSide closed_side =
exit_closed_was_long ? PositionSide::LONG : PositionSide::SHORT;
size_t write = 0;
for (size_t read = 0; read < pending_orders_.size(); ++read) {
// Mirror classify_order_eligibility's carve-out: a resting pure-limit
// entry (a GTC limit order from a prior bar, no stop/trail) survives a
// full close — see the rationale there (3commas DCA safety orders).
bool resting_limit_entry_carry =
pending_orders_[read].type == OrderType::ENTRY
&& pending_orders_[read].created_bar < bar_index_
&& !std::isnan(pending_orders_[read].limit_price)
&& std::isnan(pending_orders_[read].stop_price);
bool stale_same_direction_entry_after_exit =
exit_closed_from_bar >= 0
&& (pending_orders_[read].type == OrderType::ENTRY
|| pending_orders_[read].type == OrderType::MARKET)
&& pending_orders_[read].is_long == exit_closed_was_long
&& pending_orders_[read].created_position_side == closed_side
&& !resting_limit_entry_carry;
if (!is_filled(read)
&& !stale_same_direction_entry_after_exit) {
if (write != read) pending_orders_[write] = std::move(pending_orders_[read]);
++write;
}
}
pending_orders_.resize(write);
}
// Apply a successfully matched fill to engine state. Dispatches by
// order.type to the appropriate execute_* method, updates trailing-stop
// best price, handles risk gating + intraday-fill caps + OCA group
// cancellation, and tracks the same-direction-after-exit cleanup that
// the post-loop compaction needs to mirror.
void BacktestEngine::apply_filled_order_to_state(
PendingOrder& order,
size_t order_index,
double fill_price,
bool fill_is_limit,
const Bar& bar,
double& trail_best_path_state,
int& exit_closed_from_bar,
bool& exit_closed_was_long,
std::vector<size_t>& filled_indices) {
if (order.type == OrderType::MARKET || order.type == OrderType::ENTRY) {
PositionSide requested = order.is_long ? PositionSide::LONG : PositionSide::SHORT;
bool is_opposite_entry =
position_side_ != PositionSide::FLAT && position_side_ != requested;
if (!is_opposite_entry && !check_risk_allow_entry(order.is_long)) {
filled_indices.push_back(order_index);
return;
}
}
// KI-54: TradingView fill-time margin admission for FROZEN default-sized
// market orders (the snapshot fields are captured at placement — see
// PendingOrder::sizing_equity/sizing_price, engine.hpp):
//
// same_dir = position open AND order direction matches it
// reversal = position open AND order direction opposes it
// free_funds = same_dir ? sizing_equity - held_margin : sizing_equity
// admit_price = reversal ? slipped(fill_price) : sizing_price
// required = |qty| * admit_price * pointvalue * fx * margin_pct/100
// drop iff required > free_funds + eps (silently: no trade row)
//
// eps absorbs double rounding AND one whole lot of notional: the quantity
// was floored to the lot step, so a decline whose margin is under one
// lot's worth of budget is decided by where the floor landed, not by
// affordability.
//
// Admission price, by position state at the fill:
// - FLAT open (incl. close-then-reenter, where the strategy.close leg
// filled earlier this tick): the SIZING notional. For percent-of-
// equity with pct <= 100, margin <= 100 and sizing_equity > 0 the
// floor in apply_qty_step guarantees
// qty*sizing_price*pv*fx <= sizing_equity, so a flat open is
// undeclinable no matter how the bar gaps. Outside that regime the
// invariant fails and the gate does not run at all. Pricing flat opens at
// the fill was refuted against TV exports: it drops razor-thin
// gap-up entries that exact-count close-then-reenter strategies
// demonstrably take.
// - TRUE REVERSAL (opposite position still open when the order
// processes): the FILL price, slipped the way the fill kernel
// will book it. Established independently by two from-the-feed
// replicas of all-in flip strategies: an all-in flip's sizing
// notional sits within lot-floor slack of equity, so once the
// fill gap pushes the requirement past equity TV silently drops
// the flip. Exports of such strategies contain no gap-up flip
// fill at all, on a feed where roughly half the bars gap; the
// ungated engine took every one.
// - SAME-direction add: the sizing notional, against free funds —
// the held position keeps its capital committed, so an all-in add
// sees free_funds ~= 0 and declines (TV performs no such adds even
// where pyramiding permits them), while a fractional add
// (pct=10, held ~= 0.1*equity) still fills.
//
// Scope: the re-check runs ONLY for percent_of_equity default sizing
// with pct <= 100 — the one regime where the floor invariant above
// exists AND TV ground truth pins the behavior. CASH default sizing
// has NO equity term (cash/(price*pv)), so required is unbounded by
// sizing_equity and the gate would decline perfectly ordinary flat
// opens whenever cash_value > equity (a real transpiled cash-20k-on-
// 10k-capital probe lost all 73 of its trades to it). The same
// applies to pct > 100 (leveraged sizing). Neither regime has TV
// ground truth; frozen CASH / pct>100 orders keep their freeze but
// are admitted unconditionally here.
//
// Frozen MARKET entries and frozen RAW market orders are checked; an
// opposite-direction RAW fill only CLOSES the position
// (apply_raw_order_fill's exit branch) and is never dropped.
// Explicit-qty entries keep the signal-time gate in strategy_entry;
// priced (limit/stop) entries carry no snapshot. Runs BEFORE the
// intraday-cap accounting below: a dropped order was never filled, so
// it must not consume a max_intraday_filled_orders slot.
// sizing_equity > 0 and frozen_default_qty > 0 are part of the invariant,
// not paranoia: apply_qty_step returns qty UNFLOORED for qty <= 0
// (engine.hpp), so on a bankrupt account the frozen quantity is negative,
// |qty|*sizing_price == |sizing_equity|, and free_funds < 0 — every order,
// including a flat open, would be declined forever. There is no TV ground
// truth for what a negative-equity account may open; leave it to the
// legacy path.
if (!std::isnan(order.sizing_equity) && !std::isnan(order.sizing_price)
&& !std::isnan(order.frozen_default_qty)
&& order.sizing_equity > 0.0 && order.frozen_default_qty > 0.0
&& default_qty_type_ == QtyType::PERCENT_OF_EQUITY
&& default_qty_value_ <= 100.0
&& (order.type == OrderType::MARKET
|| order.type == OrderType::RAW_ORDER)) {
bool same_dir = position_side_ != PositionSide::FLAT
&& ((position_side_ == PositionSide::LONG) == order.is_long);
bool reversal = position_side_ != PositionSide::FLAT && !same_dir;
bool raw_opposite_close = order.type == OrderType::RAW_ORDER && reversal;
double margin_pct = order.is_long ? margin_long_ : margin_short_;
// A same-direction add (fractional OR all-in) IS gated, against
// MARK-TO-MARKET free margin. This is pinned by a clean-room TV probe
// (data/probes/margin-basis-frac: pct=50, pyramiding=2). At pct=50 the
// two candidate rules give OPPOSITE verdicts on the add — mark-to-
// market admits it only when the open lot is UNDERWATER, cost basis
// only when it is IN PROFIT — and TV admitted 1535/1538 adds while
// underwater (2 in profit, float-noise), i.e. mark-to-market. The
// held side below uses that basis. (An earlier revision exempted the
// fractional add for lack of ground truth; the probe removes the
// ambiguity and TV declines the in-profit adds the exemption let
// through.)
//
// margin_pct > 100 breaks the flat-open invariant outright
// (required = equity * pct/100 * margin/100 > equity), which would
// silently drop every flat open. Leverage below 1x has no TV pin.
bool leverage_below_1x = margin_pct > 100.0;
if (!raw_opposite_close && !leverage_below_1x && margin_pct > 0.0) {
// The margin the OPEN position ties up, marked at the SAME price
// sizing_equity was marked at (the signal bar's close). Only the
// all-in add reaches this (see unpinned_fractional_add), where
// every convention agrees; marking it at cost basis instead —
// |qty * entry_price| — would leave
// free_funds = cash + open_profit rather than free margin, so the
// admission threshold would drift with unrealized PnL in the wrong
// direction: an underwater add gets declined while a profitable one
// gets admitted and then immediately margin-called. This also keeps
// the gate consistent with process_margin_call, which marks the
// required margin to the current price. Scaled by the same
// margin_pct/100 the required side carries; at margin 100 (every
// specimen we have) the scaling is a no-op.
double held = same_dir
? std::abs(position_qty_) * order.sizing_mark
* syminfo_.pointvalue * account_currency_fx_
* (margin_pct / 100.0)
: 0.0;
double free_funds = order.sizing_equity - held;
// Price the reversal at the price the fill kernel will actually
// book. ``fill_price`` here is still unslipped, while
// ``sizing_price`` already carries the slippage adjustment (see
// frozen_default_market_qty), so comparing the raw fill price
// against a slipped budget mixes two conventions and declines
// even a zero-gap reversal whenever slippage_ != 0.
double admit_price = reversal
? apply_fill_slippage(fill_price, order.is_long)
: order.sizing_price;
double required_margin = std::abs(order.frozen_default_qty)
* admit_price
* syminfo_.pointvalue
* account_currency_fx_
* (margin_pct / 100.0);
// The epsilon absorbs double rounding AND one whole lot of
// notional.
//
// The quantity was floored to the lot step, so the budget it left
// unspent is an unobservable remainder anywhere in
// [0, qty_step * price). A decline whose margin is smaller than
// that remainder is not a decision about affordability at all —
// it is a coin flip on where the floor happened to land. On a
// continuous feed nearly half of all bars gap by exactly one
// mintick, so without this term the reversal branch resolves such
// bars by lot-floor luck: deterministic at large equity, arbitrary
// at small. Widening by one lot keeps every decline that TV's
// exports actually confirm (their margins exceed a lot of
// notional) and drops the ones no ground truth supports.
double epsilon =
std::max(1e-9, std::abs(free_funds) * 1e-12);
epsilon = std::max(epsilon, qty_step_ * admit_price
* syminfo_.pointvalue
* account_currency_fx_
* (margin_pct / 100.0));
if (required_margin > free_funds + epsilon) {
filled_indices.push_back(order_index);
return;
}
}
}
// Check max_intraday_filled_orders limit.
//
// TV's broker emulator (LATCH-TILL-DAY-ROLLOVER semantics):
// 1. Track fills on the current chart-day. When the Nth fill
// (== max_intraday_filled_orders) lands and the resulting
// position is non-flat, TV synthesises a full close at the
// SAME BAR / SAME FILL PRICE tagged
// "Close Position (Max number of filled orders in one day)".
// 2. After the synthetic close fires, a LATCH (intraday_cap_hit_)
// is set. ALL subsequent fills on that chart-day are silently
// rejected — TV emits at most one cap-close per chart-day.
// 3. The latch (and the counter) reset only at chart-day rollover.
//
// Verified empirically against validation probe 97b's tv_trades.csv:
// - 382 cap-close exits across 13 months of data (~one per
// chart-day where the cap fires). NOT multiple per day.
// - cap-trigger entry + synthetic close share the same timestamp
// and price (close trade carries pnl == 0)
//
// Two prior bugs:
// - First impl just early-returned when the cap was hit, leaving
// the position carried open across day boundaries (382 cap-
// close exits in TV, 0 in engine).
// - Second impl recharged the counter after each cap-cycle so
// multiple cap-closes fired per chart-day (3459 engine vs
// 1957 TV trades on 97b — 43% over-count).
bool will_trigger_cap = false;
if (max_intraday_filled_orders_ > 0) {
BarTime bt = _decompose_bar_time_chart_tz();
int cur_day = bt.dayofmonth * 100 + bt.month;
if (cur_day != intraday_day_) {
intraday_day_ = cur_day;
intraday_fill_count_ = 0;
intraday_cap_hit_ = false; // RESET LATCH on chart-day rollover
}
if (intraday_cap_hit_) {
// Latched: drop this pending order and skip dispatch.
// Removing from pending_orders_ matches TV's behaviour of
// silently consuming/rejecting fills past the daily cap.
filled_indices.push_back(order_index);
return;
}
intraday_fill_count_++;
will_trigger_cap =
(intraday_fill_count_ >= max_intraday_filled_orders_);
}
filled_indices.push_back(order_index);
// Track trades before fill to set exit_comment/exit_id on new trades
size_t trades_before = trades_.size();
// Snapshot signed position before the fill so we can compute the
// filled qty for OCA-reduce semantics. Long = +qty, Short = -qty.
auto signed_pos = [&]() {
if (position_side_ == PositionSide::LONG) return position_qty_;
if (position_side_ == PositionSide::SHORT) return -position_qty_;
return 0.0;
};
double signed_pos_before = signed_pos();
// Priced (stop/limit) fills happen mid-bar: any trade they close must
// fold the pre-fill portion of the bar's path into its excursion
// (emit_close_trade reads this flag). Market fills land at the bar
// boundary (open / close) where the boundary sampling already covers
// the trade's bars, so the flag stays false for them.
fold_exit_path_extremes_ =
!std::isnan(order.stop_price) || !std::isnan(order.limit_price)
|| !std::isnan(order.trail_points) || !std::isnan(order.trail_price)
|| !std::isnan(order.trail_offset);
// Route LIMIT-triggered fills onto the unslipped limit-or-better
// price path (apply_fill_slippage). RAII guard scoped strictly to the
// dispatch block below: the intraday-cap synthetic close further down
// must stay on the market (slipped) path even when the cap-triggering
// fill was a limit fill, and any future early return inside the
// dispatch cannot leak a stale true into the next fill.
struct FillKindGuard {
bool& flag_;
FillKindGuard(bool& flag, bool value) : flag_(flag) { flag_ = value; }
~FillKindGuard() { flag_ = false; }
FillKindGuard(const FillKindGuard&) = delete;
FillKindGuard& operator=(const FillKindGuard&) = delete;
};
{
FillKindGuard fill_kind_guard(current_fill_is_limit_, fill_is_limit);
if (last_exit_fill_was_trail_) {
// TRAIL fills retrace exactly trail_offset from the armed peak, so
// peak = fill +/- offset — a pre-fill favorable excursion of the
// closing trade that no bar-boundary sample ever sees.
double off = std::isnan(order.trail_offset)
? 0.0
: std::ceil(order.trail_offset) * syminfo_mintick_;
fold_exit_trail_peak_ = (position_side_ == PositionSide::LONG)
? fill_price + off
: fill_price - off;
}
if (order.type == OrderType::MARKET) {
// TV same-tick multi-entry rule R* (see
// sequential_same_tick_reversal_fill): detect whether ANOTHER
// same-direction market entry with a DIFFERENT id, placed on the
// same on_bar, fills later at this same processing point. Orders
// after order_index in the sorted array are exactly the ones this
// pass has not yet evaluated (market orders always fill at the
// first processing point after placement, so a pending sibling
// here IS a same-tick fill).
bool later_same_tick_entry = false;
for (size_t j = order_index + 1; j < pending_orders_.size(); ++j) {
const PendingOrder& sib = pending_orders_[j];
if (sib.type == OrderType::MARKET
&& sib.is_long == order.is_long
&& sib.id != order.id
&& sib.created_bar == order.created_bar) {
later_same_tick_entry = true;
break;
}
}
apply_market_order_fill(order, fill_price, bar, trail_best_path_state,
later_same_tick_entry);
} else if (order.type == OrderType::ENTRY) {
apply_entry_order_fill(order, fill_price, bar, trail_best_path_state);
} else if (order.type == OrderType::EXIT) {
apply_exit_order_fill(order, fill_price, exit_closed_from_bar, exit_closed_was_long);
} else if (order.type == OrderType::RAW_ORDER) {
apply_raw_order_fill(order, fill_price, trail_best_path_state,
exit_closed_from_bar, exit_closed_was_long);
}
fold_exit_path_extremes_ = false;
fold_exit_trail_peak_ = std::numeric_limits<double>::quiet_NaN();
} // fill_kind_guard dtor clears current_fill_is_limit_
double signed_pos_after = signed_pos();
double filled_qty = std::abs(signed_pos_after - signed_pos_before);
// This fill just opened a position from FLAT via an entry order. Freeze
// any LAYERED strategy.exit legs bound to that entry that were armed while
// flat (qty=NaN, reservation deferred): bind each to a fixed slice of the
// opened lot so a percent partial + its sibling 100% leg no longer
// over-close the whole position depending on which leg fills first.
if (std::abs(signed_pos_before) < kQtyEpsilon
&& position_side_ != PositionSide::FLAT
&& (order.type == OrderType::MARKET
|| order.type == OrderType::ENTRY
|| order.type == OrderType::RAW_ORDER)) {
reconcile_deferred_layered_exits(order.id);
}
if (position_side_ == PositionSide::FLAT) {
trail_best_path_state = trail_best_price_;
}
// Set exit_comment and exit_id on any trades created by this fill
for (size_t ti = trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment = order.comment;
trades_[ti].exit_id = order.id;
}
// Handle OCA groups: cancel (type 1) cancels all siblings; reduce
// (type 2, Pine v6 strategy.oca.reduce) reduces siblings' remaining
// qty by the qty just filled — only siblings whose qty drops to 0
// are removed. See TradingView Pine v6 docs strategy.oca.reduce.
//
// OCA-cancel full-fill gate (validation_oca/oca-three-way-probe-02):
// TV cancels CANCEL-group siblings only after the originating order
// is FULLY filled, not after the first contract fills. With qty=4
// long + qty=2 sibling A_TP: A_TP fills qty=2, position=2 remaining,
// A_SL stays alive until the second sibling fires. We compare the
// qty actually transacted (``filled_qty``) against the order's
// explicit qty. If the request was default-sized (qty == NaN), we
// can't compute a residual so we conservatively cancel siblings on
// any fill (matches the prior, blanket-cancel behaviour for that
// subset). The OCA group name scoping inside cancel_oca_group /
// reduce_oca_group already isolates groups from each other.
if (!order.oca_name.empty()) {
bool fully_filled = std::isnan(order.qty)
|| filled_qty + kOcaQtyEpsilon >= order.qty;
if (order.oca_type == 1 && fully_filled) {
cancel_oca_group(order.oca_name, order.id);
} else if (order.oca_type == 2) {
reduce_oca_group(order.oca_name, order.id, filled_qty);
}
}
// When an exit fill causes position to go flat, subsequent EXIT
// orders in this iteration are naturally skipped by the flat guard
// earlier in the inner loop body.
// max_intraday_filled_orders auto-close: if this fill was the
// cap-triggering one and the position is still non-flat after
// dispatch (entries leave a position open; exits that flatten
// already need no synthetic close), emit TV's synthetic
// "Close Position (Max number of filled orders in one day)" exit at
// the same fill price, then LATCH so all subsequent fills on this
// chart-day are silently rejected. TV emits at most one cap-close
// per chart-day (probe 97b: 382 cap-closes across 13 months,
// ~one per chart-day where the cap fires). The latch is reset
// only on chart-day rollover (see top of this function).
if (will_trigger_cap) {
if (position_side_ != PositionSide::FLAT) {
// TV cap-close exit price empirics (probe 97 stop-entry +
// cap composition):
//
// When the cap-triggering fill is a STOP entry that fired
// INTRA-bar (stop > bar.open for long, stop < bar.open
// for short), TV's synthetic "Close Position (Max number
// of filled orders in one day)" exit emits at the bar's
// FAVORABLE extreme — bar.high for a long, bar.low for a
// short — not at the entry's stop trigger price. The
// model: TV's broker traces the bar path past the stop
// trigger to the next extreme (continuation through the
// stop direction is the "worst case" assumption Pine uses
// for path resolution), and the cap-close fires at that
// reached extreme. Verified against 152 cap-close trades
// in probe 97: long stop-entry fills with stop > open
// close at bar.high; short stop-entry fills with
// stop < open close at bar.low.
//
// When the entry filled AT bar.open (gap-fill: long stop
// <= open, short stop >= open, or a market entry — no
// intra-bar travel was needed to reach the trigger), TV's
// cap-close emits at fill_price = bar.open. Probe 97b
// (market entries only, no stops) confirms 382/382 cap-
// closes at fill_price = entry_price = bar.open.
//
// This ONLY applies to ENTRY/MARKET fills that opened the
// position. Other fill types (RAW_ORDER bracket exits,
// EXIT close-deferred orders) reach this path only when
// they themselves flatten — but a flatten leaves
// position_side_ FLAT, so the outer guard already skips
// the synthetic close emit. So we only need the bar-
// extreme adjustment for the entry-fill cases.
double cap_close_price = fill_price;
const bool entry_kind = (order.type == OrderType::ENTRY ||
order.type == OrderType::MARKET);
if (entry_kind) {
if (position_side_ == PositionSide::LONG && fill_price > bar.open) {
cap_close_price = bar.high;
} else if (position_side_ == PositionSide::SHORT && fill_price < bar.open) {
cap_close_price = bar.low;
}
}
size_t close_trades_before = trades_.size();
execute_market_exit(cap_close_price);
for (size_t ti = close_trades_before; ti < trades_.size(); ++ti) {
trades_[ti].exit_comment =
"Close Position (Max number of filled orders in one day)";
trades_[ti].exit_id = "";
}
}
intraday_cap_hit_ = true; // latch — block further fills until day rollover
}
}
// ── Per-OrderType fill kernels (called from apply_filled_order_to_state) ──
// Compute the pre-fill excursion masks for a priced (stop/limit) entry that
// filled intrabar: on the assumed OHLC path, an extreme that occurs BEFORE
// the fill position is not part of the new trade's excursion, so
// update_per_trade_extremes must skip it on the fill bar (TV convention —
// TV starts excursion tracking at the fill, the engine otherwise samples
// the full bar range including the pre-fill leg). Open/gap fills resolve to
// path position 0 and leave both masks false.
static void set_entry_fill_excursion_masks(PyramidEntry& pe, const Bar& bar,
double fill_price) {
double fill_pos = 0.0;
if (!internal::first_touch_position(bar, fill_price, &fill_pos)) return;
const bool high_first = internal::bar_path_uses_high_first(bar);
const double high_pos = high_first ? 1.0 : 2.0;
const double low_pos = high_first ? 2.0 : 1.0;
pe.skip_entry_bar_high = (high_pos < fill_pos);
pe.skip_entry_bar_low = (low_pos < fill_pos);
}
void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_price,
const Bar& bar,
double& trail_best_path_state,
bool later_same_tick_entry) {
// 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);
double trail_best_after_fill = trail_best_price_;
// Set entry comment on the just-created pyramid entry
if (!pyramid_entries_.empty()) pyramid_entries_.back().entry_comment = order.comment;
// Update trail_best_price_ with intra-bar extremes for same-bar exit eval
// -- EXCEPT when this fill happened AT the bar's close (a POOC market
// order created and filled on this same bar): the whole bar's high/low
// precedes that fill point, so folding them in pre-arms the trail
// above/below a level the position never actually saw, which then
// gap-fills the next bar's exit at its open instead of TV's real
// intrabar retrace price. See apply_entry_order_fill's matching guard.
bool same_bar_close_fill = process_orders_on_close_ && order.created_bar == bar_index_;
if (!same_bar_close_fill) {
if (position_side_ == PositionSide::LONG)
trail_best_price_ = std::max(trail_best_price_, bar.high);
else if (position_side_ == PositionSide::SHORT)
trail_best_price_ = std::min(trail_best_price_, bar.low);
}
trail_best_path_state = trail_best_after_fill;
}
void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_price,
const Bar& bar,
double& trail_best_path_state) {
PositionSide side_before = position_side_;
double qty_before = position_qty_;
int count_before = position_entry_count_;
size_t trades_before_entry = trades_.size();
// Flat-issued pending ENTRY stops/limits act as a bracket: when one
// side opens the position, a touch of the opposite pending ENTRY
// (still flat-issued) closes at the touch price (TradingView's
// List of trades shows an exit tied to the opposite bracket),
// not a full reversal-and-new-position. The bracket persists
// across bars: probes 80-87 confirm TV closes the position on a
// later bar when only one leg fired earlier and the opposite
// leg's stop is touched subsequently.
PositionSide entry_req = order.is_long ? PositionSide::LONG : PositionSide::SHORT;
bool close_only_opposite =
order.created_position_side == PositionSide::FLAT
&& position_side_ != PositionSide::FLAT
&& entry_req != position_side_;
execute_market_entry(order.id, order.is_long, fill_price, order.qty, order.qty_type,
order.created_position_side, close_only_opposite,
/*is_priced_entry=*/true,
order.tv_carry_qty,
order.created_bar);
bool did_execute =
(position_side_ != side_before)
|| (std::abs(position_qty_ - qty_before) > 1e-12)
|| (position_entry_count_ != count_before)
|| (trades_.size() != trades_before_entry);
bool was_priced_entry = !std::isnan(order.stop_price) || !std::isnan(order.limit_price);
if (did_execute) {
double trail_best_after_fill = trail_best_price_;
if (!pyramid_entries_.empty()) pyramid_entries_.back().entry_comment = order.comment;
// See apply_market_order_fill's matching guard: skip folding this
// bar's pre-fill high/low into the trail when the fill happened AT
// the bar's close (a POOC entry created and filled this same bar).
bool same_bar_close_fill = process_orders_on_close_ && order.created_bar == bar_index_;
if (!same_bar_close_fill) {
if (position_side_ == PositionSide::LONG)
trail_best_price_ = std::max(trail_best_price_, bar.high);
else if (position_side_ == PositionSide::SHORT)
trail_best_price_ = std::min(trail_best_price_, bar.low);
}
if (was_priced_entry) {
priced_entry_filled_this_bar_ = true;
// Mask pre-fill bar extremes for the entry this fill created