PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
trail_ticks.hpp
Go to the documentation of this file.
1#pragma once
2// TradingView's trailing-exit tick arithmetic: how strategy.exit's
3// trail_points / trail_offset are read as tick counts and how a trailing
4// level is materialized on the tick grid. Every constant here is a
5// TradingView calibration (the pins are quoted below), so it belongs to the
6// source layer; the kernel's own trail is TrailTicks (native_order.hpp),
7// which needs none of it (the source-runtime extraction: moved verbatim from
8// src/engine_internal.hpp, where no kernel translation unit used it).
9#include <algorithm>
10#include <cmath>
11#include <limits>
12
14
15// ── Trailing-exit tick arithmetic ─────────────────────────────────────
16//
17// TradingView reads strategy.exit's trail_points / trail_offset as TICK
18// COUNTS: trail_points is ceiled WITH a tolerance that swallows floating-
19// point residue — a value computed as close * 0.02 / syminfo.mintick that
20// lands a few ulps past a whole number is that whole number, not the next
21// one — while trail_offset is floored EXACTLY. Pinned with `lab tv` on
22// NYSE:F 15m; a synthetic short entered at 20.06 with mintick 0.01
23// illustrates the same rule, with each zero-offset
24// exit filling at its activation level:
25// trail_points 14.00001 -> 14 ticks (19.92)
26// trail_points 14.0001 / 14.001 -> 15 ticks (19.91)
27// trail_points 0.14 / syminfo.mintick (= 14.000000000000002) -> 14 (19.92)
28// trail_points 14.0000001 -> 14 (19.92)
29// trail_points 18.2 -> 19 (19.87)
30// trail_offset 0.3 / (syminfo.mintick * 10) (= 2.9999999999999996)
31// -> 2 ticks (synthetic trough 19.80: fill 19.82
32// = trough + 2t; a tolerant floor would trail 3t and print 19.83)
33// A synthetic BINANCE:BTCUSDT 15m short at 12807.99 with
34// trail_points = 12808 * 0.02 / 0.01 = 25616.000000000004 exits
35// at 12551.83 (25616 ticks); std::ceil gives 25617 -> 12551.82.
36// The ceil tolerance is bounded in [1e-5, 1e-4) by the 14.00001 / 14.0001
37// pair; 5e-5 sits in the middle. Round 5's sub-tick pins hold: 0.0006 ->
38// 1 tick, offsets 0 / 0.5 / 0.9 -> 0, 1.4 -> 1. This constant is
39// only the fallback for a run without a usable mintick: TradingView's
40// tolerance is c * mintick ticks (the two-argument overload below), which
41// at mintick 0.01 also satisfies every pin above.
42constexpr double kTrailPointsCeilEps = 5e-5;
43
44inline double trail_points_to_ticks(double trail_points) {
45 return std::ceil(trail_points - kTrailPointsCeilEps); // NaN stays NaN
46}
47
48// The tolerance is not a constant number of ticks: it is c * mintick ticks.
49// Pinned with `lab tv` synthetics (4 identical exports each; the
50// callback-lifecycle test tapes, *-trailpts-*), each long's trail
51// filling at its activation:
52// BINANCE:ETHUSDT.P (0.01): 24.000057417376482 -> 24, 24.00001 -> 24,
53// 24.0001 / 24.001 / 24.4 / 24.6 -> 25
54// OANDA:XAUUSD (0.001): 1000.0 / 1000.000003 -> 1000, 1000.00003 -> 1001
55// OANDA:EURUSD (1e-5): 20.0 -> 20, 20.000001 / 20.00003 / 20.02 / 20.2 -> 21
56// The ETH pair bounds c to (5.7417e-3, 1e-2); the XAU and EUR rows refute
57// any constant tick or price tolerance, and 8e-3 sits inside the interval.
58// At mintick 0.01 the tolerance is 8e-5 ticks, so every F and BTC pin above
59// still holds. Without a usable mintick the constant rule applies.
60constexpr double kTrailPointsCeilMintickScale = 8e-3;
61
62inline double trail_points_to_ticks(double trail_points, double mintick) {
63 if (!(mintick > 0.0) || !std::isfinite(mintick))
64 return trail_points_to_ticks(trail_points);
65 return std::ceil(trail_points - kTrailPointsCeilMintickScale * mintick);
66}
67
68inline double trail_offset_to_ticks(double trail_offset) {
69 return std::floor(trail_offset); // exact; NaN stays NaN
70}
71
72// The broker keeps trailing levels ON the tick grid: an activation level
73// entry -/+ N ticks and a trailing level best -/+ K ticks are tick counts,
74// and a bar extreme landing exactly on the level TOUCHES it. Built in
75// doubles, 20.06 - 21 * 0.01 is 19.849999999999998 — one ulp under the 19.85
76// low — and the inclusive segment test reads it as "not reached" (NYSE:F
77// synthetic 15m bar O 20.10 H 20.12 L 19.85 C 19.85: the trail fills
78// there at 19.85; a raw comparison would miss the touch and gap-fill
79// the next open at 19.84). Materialize a level within a millionth of a tick
80// of the grid as the grid point, spelled the way tick_grid_price
81// (engine.hpp) spells it so it compares EQUAL to a feed print bit for bit;
82// a genuinely sub-tick level (best 35.035 - 1 tick = 35.025) stays raw
83// and takes the directional fill snap downstream.
84inline double snap_trail_level_to_tick_grid(double price, double mintick) {
85 if (std::isnan(price) || !(mintick > 0.0)) return price;
86 const double r = price / mintick;
87 const double k = std::floor(r + 0.5);
88 if (std::abs(r - k) > 1e-6) return price;
89 const double inv = 1.0 / mintick;
90 const double inv_int = std::floor(inv + 0.5);
91 if (inv_int > 0.0 && std::abs(inv - inv_int) <= 1e-6 * inv_int) {
92 return k / inv_int;
93 }
94 return k * mintick;
95}
96
97// The level the kernel's generic native_order::Trail rests at for a running
98// best and a trailing distance, as the source layer's carried-trail tick
99// reach reads it (PineExecutionAdapter::retune_carried_trails_for_tick_reach,
100// which walks a bar through bests the kernel has not reported yet): best +/-
101// distance, except that a distance of whole ticks from a best on the run's
102// price ladder IS that ladder point, spelled on the reachable side
103// (native_matching::checked_trail_stop and ladder_trail_stop, with the run's
104// price tick, which the adapter declares as the symbol's mintick).
105// tests/test_kernel_trail_stop_replica.cpp holds it to the kernel's
106// arithmetic bit for bit.
107inline double kernel_trail_stop_level(double best, double distance, bool is_buy,
108 double tick) noexcept {
109 const double raw = is_buy ? best + distance : best - distance;
110 if (distance == 0.0 || !(tick > 0.0) || !std::isfinite(tick)) return raw;
111 const auto ladder_index = [&](double price) {
112 const double k = std::round(price / tick);
113 if (k * tick == price) return k;
114 const double inverse = 1.0 / tick;
115 const double n = std::round(inverse);
116 if (n > 0.0 && std::abs(inverse - n) <= 1e-6 * n && k / n == price) return k;
117 return std::numeric_limits<double>::quiet_NaN();
118 };
119 const double best_index = ladder_index(best);
120 const double distance_index = ladder_index(distance);
121 if (std::isnan(best_index) || std::isnan(distance_index) || !(distance_index >= 1.0))
122 return raw;
123 const double index = is_buy ? best_index + distance_index : best_index - distance_index;
124 const double product = index * tick;
125 const double inverse = 1.0 / tick;
126 const double n = std::round(inverse);
127 if (!std::isfinite(product) || !(n > 0.0) || std::abs(inverse - n) > 1e-6 * n)
128 return product;
129 const double quotient = index / n;
130 if (!std::isfinite(quotient)) return product;
131 // A buy trail's region is price >= level: the lower spelling; a sell
132 // trail's is price <= level: the higher.
133 return is_buy ? std::min(product, quotient) : std::max(product, quotient);
134}
135
136} // namespace pineforge::compat::pine
double trail_offset_to_ticks(double trail_offset)
double kernel_trail_stop_level(double best, double distance, bool is_buy, double tick) noexcept
double trail_points_to_ticks(double trail_points)
double snap_trail_level_to_tick_grid(double price, double mintick)
constexpr double kTrailPointsCeilMintickScale
constexpr double kTrailPointsCeilEps