PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_risk_limits_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: account risk limits as the desk's stop-out rule
2// (R5 lane L9). native_trail_risk_strategy.cpp shows the fill-count cap with
3// BlockOpenings; this one shows the money limits, the kernel's own flatten and
4// the risk day.
5//
6// The strategy has no stop of its own: once a day, at the day's first
7// calculation, it buys 100 units at the market and holds. The ACCOUNT carries
8// the rule, declared once in NativeRunSpec::risk:
9// * max_intraday_loss = 1 % of the day's opening equity;
10// * max_consecutive_loss_days = 2;
11// * max_drawdown = 5 % of the running peak (spelled, never reached here);
12// * action = FlattenAndBlock: at a breach the kernel closes the book with
13// one Flatten of its own (RequestOrigin::KernelRisk, ticket
14// "__kernel_risk__") at the point the breach was measured at, then refuses
15// every opening while the block lasts;
16// * day_basis = CalendarDayInTimezone: the civil date of the spec timezone.
17//
18// Three days of 6-hour bars:
19// day 1 long 100 @ 100.00; the third bar opens at 88.00: the marked equity
20// is 1200 under the day's opening 100000 (limit 1 % = 1000), so the
21// kernel flattens there (-1200) and blocks the day. The strategy's
22// same-day re-entry is accepted at submit and refused at its match.
23// day 2 a new day re-arms the intraday limit: long 100 @ 90.00; the third
24// bar opens at 80.00: 1000 under the day's opening 98800 (limit 988),
25// flattened (-1000), blocked.
26// day 3 its open settles day 2 as the second losing day in a row:
27// MaxConsecutiveLossDays blocks openings to the end of the run, and
28// the day-3 entry never fills.
29//
30// c++ -std=c++17 native_risk_limits_strategy.cpp -lpineforge_kernel -o risk_limits
31//
32// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
33// TradingView's strategy.risk.* rules are a different model and stay in the
34// Pine adapter, which never declares this block; the generic limits are a
35// native-host feature by ruling — docs/design/native-feature-parity.md §3.6.
36
38
39#include <cmath>
40#include <cstdio>
41#include <iostream>
42#include <optional>
43#include <string>
44#include <variant>
45#include <vector>
46
47namespace {
48
49namespace no = pineforge::native_order;
50
51constexpr std::int64_t kSixHours = 6LL * 60LL * 60LL * 1000LL;
52constexpr int kBarsPerDay = 4;
53// 2025-01-06 00:00 UTC, a Monday: bar 0 opens a civil day.
54constexpr std::int64_t kFirstOpen = 1736121600000LL;
55
56class RiskLimitsExample : public pineforge::NativeStrategyHost {
57public:
58 std::optional<no::RequestHandle> same_day_re_entry;
59 std::optional<no::RequestHandle> day_three_entry;
60 // The ledger as the strategy saw it at each day's last calculation.
61 std::vector<pineforge::NativeRiskState> ledger_at_day_end;
62
63private:
64 int bars_ = 0;
65
66 void on_native_run_begin() override { bars_ = 0; }
67
68 void on_native_bar(const pineforge::Bar&,
69 const pineforge::NativeDecisionContext&) override {
70 const int index = bars_++;
71 const int day = index / kBarsPerDay;
72 const int bar_of_day = index % kBarsPerDay;
73 if (bar_of_day == 0) {
74 // The day's one entry. It fills at the next bar's open.
75 const auto placed = submit({no::Transact{100.0}, "daily-long", "risk-limits"});
76 if (day == 2) day_three_entry = placed.handle;
77 }
78 if (day == 0 && bar_of_day == 2) {
79 // The kernel has just flattened the book under the intraday limit.
80 // Trying again the same day is accepted here and refused at its
81 // match: the block refuses openings, it does not reject commands.
82 same_day_re_entry = submit({no::Transact{100.0}, "same-day-re-entry", "risk-limits"}).handle;
83 }
84 if (bar_of_day == kBarsPerDay - 1) ledger_at_day_end.push_back(native_risk_state());
85 }
86};
87
90 spec.identity.session_key = "native-risk-limits-example";
91 spec.identity.run_number = 1;
92 // This example reads its whole event record once the run has ended, so
93 // it keeps it all: the default, Window, keeps only what a host has not
94 // yet acknowledged (native_acknowledge_events).
95 spec.event_retention = pineforge::NativeEventRetention::Full;
96 spec.input_tf = "360";
97 spec.script_tf = "360";
98 spec.ticker = "MOCK";
99 spec.tickerid = "TEST:MOCK";
100 spec.type = "crypto";
101 spec.currency = "USDT";
102 spec.basecurrency = "ETH";
103 spec.timezone = "UTC";
104 spec.session = "24x7";
105 spec.initial_capital = 100000.0;
106 spec.point_value = 1.0;
107 spec.account_fx = 1.0;
108 spec.price_tick = 0.01;
109 spec.fee_kind = pineforge::NativeFeeKind::Percent;
110 spec.fee_value = 0.0;
111
113 risk.max_intraday_loss = pineforge::NativeLossLimit{1.0, true}; // 1 % of the day's open
115 risk.max_drawdown = pineforge::NativeLossLimit{5.0, true}; // 5 % of the peak
116 risk.day_basis = pineforge::NativeRiskDay::CalendarDayInTimezone;
117 risk.action = pineforge::NativeRiskAction::FlattenAndBlock;
118 spec.risk = risk;
119 return spec;
120}
121
122pineforge::Bar bar(int index, double open, double high, double low, double close) {
123 return {open, high, low, close, 10.0, kFirstOpen + index * kSixHours};
124}
125
126// Four 6-hour bars a day. The entry fills at the second bar's open, the third
127// bar gaps down through the account's intraday limit, the fourth drifts.
128std::vector<pineforge::Bar> make_tape() {
129 return {
130 bar(0, 100.00, 100.50, 99.50, 100.00),
131 bar(1, 100.00, 100.25, 94.00, 95.00), // long 100 @ 100.00; -500 at the close
132 bar(2, 88.00, 89.00, 87.50, 88.50), // opens 1200 under the day's open: flatten @ 88.00
133 bar(3, 88.50, 90.50, 88.00, 90.00),
134
135 bar(4, 90.00, 90.50, 89.50, 90.00), // day 2: the intraday limit is re-armed
136 bar(5, 90.00, 90.25, 86.00, 87.00), // long 100 @ 90.00
137 bar(6, 80.00, 81.00, 79.50, 80.50), // opens 1000 under 98800 (limit 988): flatten @ 80.00
138 bar(7, 80.50, 82.00, 80.00, 81.50),
139
140 bar(8, 81.50, 82.00, 81.00, 81.75), // day 3 opens: two losing days in a row
141 bar(9, 81.75, 83.00, 81.50, 82.50), // the day-3 entry is refused here
142 bar(10, 82.50, 83.50, 82.00, 83.00),
143 bar(11, 83.00, 84.00, 82.50, 83.50),
144 };
145}
146
147const char* limit_name(no::RiskLimitKind kind) {
148 switch (kind) {
149 case no::RiskLimitKind::MaxDrawdown: return "MaxDrawdown";
150 case no::RiskLimitKind::MaxIntradayLoss: return "MaxIntradayLoss";
151 case no::RiskLimitKind::MaxConsecutiveLossDays: return "MaxConsecutiveLossDays";
152 case no::RiskLimitKind::MaxFillsPerDay: return "MaxFillsPerDay";
153 }
154 return "?";
155}
156
157bool near(double actual, double expected) { return std::fabs(actual - expected) <= 1e-9; }
158
159} // namespace
160
161int main() {
162 RiskLimitsExample host;
163 if (host.configure_native(make_spec()).status != pineforge::NativeSetupStatus::Applied) {
164 std::cerr << "configure: " << host.last_error() << '\n';
165 return 1;
166 }
167 const auto tape = make_tape();
168 host.run(tape.data(), static_cast<int>(tape.size()));
169 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
170 std::cerr << "run: " << host.last_error() << '\n';
171 return 1;
172 }
173
174 // --- the breaches, in the order the kernel reported them ---------------------
175 std::vector<no::NativeRiskEvent> breaches;
176 int kernel_flattens = 0;
177 int re_entry_refusals = 0;
178 int day_three_refusals = 0;
179 for (const auto& event : host.native_events(0)) {
180 if (!event.command) continue;
181 if (const auto* risk = std::get_if<no::NativeRiskEvent>(&*event.command)) {
182 breaches.push_back(*risk);
183 }
184 if (const auto* accepted = std::get_if<no::AcceptedEvent>(&*event.command)) {
185 if (accepted->definition
186 && accepted->definition->origin == no::RequestOrigin::KernelRisk) {
187 ++kernel_flattens;
188 }
189 }
190 if (const auto* rejected = std::get_if<no::MatchRejectedEvent>(&*event.command)) {
191 if (rejected->reason != no::MatchRejectReason::RiskLimit) continue;
192 if (host.same_day_re_entry && rejected->handle() == *host.same_day_re_entry) {
193 ++re_entry_refusals;
194 }
195 if (host.day_three_entry && rejected->handle() == *host.day_three_entry) {
196 ++day_three_refusals;
197 }
198 }
199 }
200 for (const auto& breach : breaches) {
201 std::printf("risk event: %-22s limit=%8.2f observed=%8.2f at script bar %lld\n",
202 limit_name(breach.kind), breach.limit, breach.observed,
203 static_cast<long long>(breach.cursor.point.interval_index));
204 }
205 // Hand-computed: 1 % of 100000 against 100 x (100 - 88); 1 % of 98800
206 // against 100 x (90 - 80); then the streak of two against its limit of two.
207 if (breaches.size() != 3
208 || breaches[0].kind != no::RiskLimitKind::MaxIntradayLoss
209 || !near(breaches[0].limit, 1000.0) || !near(breaches[0].observed, 1200.0)
210 || breaches[0].cursor.point.interval_index != 2
211 || breaches[1].kind != no::RiskLimitKind::MaxIntradayLoss
212 || !near(breaches[1].limit, 988.0) || !near(breaches[1].observed, 1000.0)
213 || breaches[1].cursor.point.interval_index != 6
214 || breaches[2].kind != no::RiskLimitKind::MaxConsecutiveLossDays
215 || !near(breaches[2].limit, 2.0) || !near(breaches[2].observed, 2.0)
216 || breaches[2].cursor.point.interval_index != 8) {
217 std::cerr << "expected two intraday-loss breaches and the loss-days breach\n";
218 return 1;
219 }
220 if (breaches[1].day_ordinal != breaches[0].day_ordinal + 1
221 || breaches[2].day_ordinal != breaches[1].day_ordinal + 1) {
222 std::cerr << "expected the three breaches on three consecutive risk days\n";
223 return 1;
224 }
225
226 std::printf("kernel-originated flattens: %d; same-day re-entry refused %d time(s); "
227 "day-3 entry refused %d time(s)\n",
228 kernel_flattens, re_entry_refusals, day_three_refusals);
229 // Day 3's block finds a flat book: FlattenAndBlock issues nothing there.
230 if (kernel_flattens != 2 || re_entry_refusals == 0 || day_three_refusals == 0) {
231 std::cerr << "expected one kernel flatten per intraday breach and both openings refused\n";
232 return 1;
233 }
234
235 // --- the ledger -------------------------------------------------------------
236 for (std::size_t day = 0; day < host.ledger_at_day_end.size(); ++day) {
237 const auto& row = host.ledger_at_day_end[day];
238 std::printf("day %zu close: blocked=%d reason=%-22s fills_today=%llu loss_days=%u "
239 "day_open_equity=%.2f peak=%.2f\n",
240 day + 1, row.blocked ? 1 : 0, row.reason ? limit_name(*row.reason) : "-",
241 static_cast<unsigned long long>(row.fills_today), row.consecutive_loss_days,
242 row.day_open_equity, row.peak_equity);
243 }
244 if (host.ledger_at_day_end.size() != 3) return 1;
245 const auto& day1 = host.ledger_at_day_end[0];
246 const auto& day2 = host.ledger_at_day_end[1];
247 const auto& day3 = host.ledger_at_day_end[2];
248 if (!day1.blocked || !day1.reason || *day1.reason != no::RiskLimitKind::MaxIntradayLoss
249 || day1.fills_today != 2 || day1.consecutive_loss_days != 0
250 || !near(day1.day_open_equity, 100000.0)
251 || !day2.blocked || !day2.reason || *day2.reason != no::RiskLimitKind::MaxIntradayLoss
252 || day2.fills_today != 2 || day2.consecutive_loss_days != 1
253 || !near(day2.day_open_equity, 98800.0)
254 || !day3.blocked || !day3.reason
255 || *day3.reason != no::RiskLimitKind::MaxConsecutiveLossDays
256 || day3.fills_today != 0 || day3.consecutive_loss_days != 2
257 || !near(day3.day_open_equity, 97800.0) || !near(day3.peak_equity, 100000.0)) {
258 std::cerr << "expected the ledger to carry each day's opening equity, fills and streak\n";
259 return 1;
260 }
261 if (host.physical_position().signed_units != 0.0) {
262 std::cerr << "expected a flat book at the end\n";
263 return 1;
264 }
265
266 // Both rows were closed by the account rule, not by the strategy.
267 double net = 0.0;
268 for (int i = 0; i < host.trade_count(); ++i) net += host.get_trade(i).pnl;
269 if (host.trade_count() != 2 || !near(net, -2200.0)
270 || std::string(host.get_trade(0).exit_id) != "__kernel_risk__"
271 || std::string(host.get_trade(1).exit_id) != "__kernel_risk__") {
272 std::cerr << "expected two kernel-closed rows netting -2200\n";
273 return 1;
274 }
275
276 std::cout << "closed trades: " << host.trade_count() << '\n';
277 for (int i = 0; i < host.trade_count(); ++i) {
278 const auto& trade = host.get_trade(i);
279 std::cout << " " << (trade.is_long ? "long " : "short")
280 << " qty=" << trade.qty
281 << " entry=" << trade.entry_price
282 << " exit=" << trade.exit_price
283 << " pnl=" << trade.pnl
284 << " exit_id=" << trade.exit_id << '\n';
285 }
286 return 0;
287}
The public native host: an abstract subclass of BacktestEngine with no PineScript on it.
static pf_native_run_spec_v1 make_spec(void)
static void submit(struct host_state *state, uint32_t intent, double value, uint32_t trigger, double price, const char *label)
One risk threshold (L9).
Generic account risk limits (L9), entirely opt-in: a spec that leaves NativeRunSpec::risk unset evalu...
std::optional< NativeLossLimit > max_intraday_loss
std::optional< std::uint32_t > max_consecutive_loss_days
std::optional< NativeLossLimit > max_drawdown
One complete setup value, staged/copied by NativeStrategyHost before it is applied at begin.
std::optional< NativeRiskLimits > risk
Opt-in generic account risk limits.
NativeEventRetention event_retention
What native_events() can still return; see NativeEventRetention.