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 (R5 lane N14: 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 2025-04-01..05, short from the 04-02 19:00Z signal (entry
23// 10.11, mintick 0.01; scratchpad/r7/pins/trail-eq-*), each zero-offset
24// exit filling at its activation level:
25// trail_points 14.00001 -> 14 ticks (9.97)
26// trail_points 14.0001 / 14.001 -> 15 ticks (9.96)
27// trail_points 0.14 / syminfo.mintick (= 14.000000000000002) -> 14 (9.97)
28// trail_points 14.0000001 -> 14 (9.97)
29// trail_points 18.2 -> 19 (9.92)
30// trail_offset 0.3 / (syminfo.mintick * 10) (= 2.9999999999999996)
31// -> 2 ticks (trail_points 18: fills 04-03 14:15Z @9.85 = trough 9.83
32// + 2t; a tolerant floor would trail 3t and print 9.86)
33// and BINANCE:BTCUSDT 15m short 2025-08-17 23:30Z @117559.99 with
34// trail_points = 117560 * 0.02 / 0.01 = 235120.00000000003: TV exits
35// 08-18 03:30Z @115208.79 (235120 ticks); std::ceil gave 235121 -> .78.
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, 10.11 - 21 * 0.01 is 9.899999999999999 — one ulp under the 9.9
76// low — and the inclusive segment test reads it as "not reached" (NYSE:F
77// 15m 2025-04-03 13:45Z, bar O 10.165 H 10.18 L 9.9 C 9.9: TV fills the
78// trail there @9.90, `lab tv` trail-eq-S-off0-tp21; the engine gap-filled
79// the next open @9.89). 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 196.135 - 1 tick = 196.125) 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