PineForge v0.13.1-379-g9b50973
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 spec.input_tf = "360";
93 spec.script_tf = "360";
94 spec.ticker = "MOCK";
95 spec.tickerid = "TEST:MOCK";
96 spec.type = "crypto";
97 spec.currency = "USDT";
98 spec.basecurrency = "ETH";
99 spec.timezone = "UTC";
100 spec.session = "24x7";
101 spec.initial_capital = 100000.0;
102 spec.point_value = 1.0;
103 spec.account_fx = 1.0;
104 spec.price_tick = 0.01;
105 spec.fee_kind = pineforge::NativeFeeKind::Percent;
106 spec.fee_value = 0.0;
107
109 risk.max_intraday_loss = pineforge::NativeLossLimit{1.0, true}; // 1 % of the day's open
111 risk.max_drawdown = pineforge::NativeLossLimit{5.0, true}; // 5 % of the peak
112 risk.day_basis = pineforge::NativeRiskDay::CalendarDayInTimezone;
113 risk.action = pineforge::NativeRiskAction::FlattenAndBlock;
114 spec.risk = risk;
115 return spec;
116}
117
118pineforge::Bar bar(int index, double open, double high, double low, double close) {
119 return {open, high, low, close, 10.0, kFirstOpen + index * kSixHours};
120}
121
122// Four 6-hour bars a day. The entry fills at the second bar's open, the third
123// bar gaps down through the account's intraday limit, the fourth drifts.
124std::vector<pineforge::Bar> make_tape() {
125 return {
126 bar(0, 100.00, 100.50, 99.50, 100.00),
127 bar(1, 100.00, 100.25, 94.00, 95.00), // long 100 @ 100.00; -500 at the close
128 bar(2, 88.00, 89.00, 87.50, 88.50), // opens 1200 under the day's open: flatten @ 88.00
129 bar(3, 88.50, 90.50, 88.00, 90.00),
130
131 bar(4, 90.00, 90.50, 89.50, 90.00), // day 2: the intraday limit is re-armed
132 bar(5, 90.00, 90.25, 86.00, 87.00), // long 100 @ 90.00
133 bar(6, 80.00, 81.00, 79.50, 80.50), // opens 1000 under 98800 (limit 988): flatten @ 80.00
134 bar(7, 80.50, 82.00, 80.00, 81.50),
135
136 bar(8, 81.50, 82.00, 81.00, 81.75), // day 3 opens: two losing days in a row
137 bar(9, 81.75, 83.00, 81.50, 82.50), // the day-3 entry is refused here
138 bar(10, 82.50, 83.50, 82.00, 83.00),
139 bar(11, 83.00, 84.00, 82.50, 83.50),
140 };
141}
142
143const char* limit_name(no::RiskLimitKind kind) {
144 switch (kind) {
145 case no::RiskLimitKind::MaxDrawdown: return "MaxDrawdown";
146 case no::RiskLimitKind::MaxIntradayLoss: return "MaxIntradayLoss";
147 case no::RiskLimitKind::MaxConsecutiveLossDays: return "MaxConsecutiveLossDays";
148 case no::RiskLimitKind::MaxFillsPerDay: return "MaxFillsPerDay";
149 }
150 return "?";
151}
152
153bool near(double actual, double expected) { return std::fabs(actual - expected) <= 1e-9; }
154
155} // namespace
156
157int main() {
158 RiskLimitsExample host;
159 if (host.configure_native(make_spec()).status != pineforge::NativeSetupStatus::Applied) {
160 std::cerr << "configure: " << host.last_error() << '\n';
161 return 1;
162 }
163 const auto tape = make_tape();
164 host.run(tape.data(), static_cast<int>(tape.size()));
165 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
166 std::cerr << "run: " << host.last_error() << '\n';
167 return 1;
168 }
169
170 // --- the breaches, in the order the kernel reported them ---------------------
171 std::vector<no::NativeRiskEvent> breaches;
172 int kernel_flattens = 0;
173 int re_entry_refusals = 0;
174 int day_three_refusals = 0;
175 for (const auto& event : host.native_events(0)) {
176 if (!event.command) continue;
177 if (const auto* risk = std::get_if<no::NativeRiskEvent>(&*event.command)) {
178 breaches.push_back(*risk);
179 }
180 if (const auto* accepted = std::get_if<no::AcceptedEvent>(&*event.command)) {
181 if (accepted->definition
182 && accepted->definition->origin == no::RequestOrigin::KernelRisk) {
183 ++kernel_flattens;
184 }
185 }
186 if (const auto* rejected = std::get_if<no::MatchRejectedEvent>(&*event.command)) {
187 if (rejected->reason != no::MatchRejectReason::RiskLimit) continue;
188 if (host.same_day_re_entry && rejected->handle() == *host.same_day_re_entry) {
189 ++re_entry_refusals;
190 }
191 if (host.day_three_entry && rejected->handle() == *host.day_three_entry) {
192 ++day_three_refusals;
193 }
194 }
195 }
196 for (const auto& breach : breaches) {
197 std::printf("risk event: %-22s limit=%8.2f observed=%8.2f at script bar %lld\n",
198 limit_name(breach.kind), breach.limit, breach.observed,
199 static_cast<long long>(breach.cursor.point.interval_index));
200 }
201 // Hand-computed: 1 % of 100000 against 100 x (100 - 88); 1 % of 98800
202 // against 100 x (90 - 80); then the streak of two against its limit of two.
203 if (breaches.size() != 3
204 || breaches[0].kind != no::RiskLimitKind::MaxIntradayLoss
205 || !near(breaches[0].limit, 1000.0) || !near(breaches[0].observed, 1200.0)
206 || breaches[0].cursor.point.interval_index != 2
207 || breaches[1].kind != no::RiskLimitKind::MaxIntradayLoss
208 || !near(breaches[1].limit, 988.0) || !near(breaches[1].observed, 1000.0)
209 || breaches[1].cursor.point.interval_index != 6
210 || breaches[2].kind != no::RiskLimitKind::MaxConsecutiveLossDays
211 || !near(breaches[2].limit, 2.0) || !near(breaches[2].observed, 2.0)
212 || breaches[2].cursor.point.interval_index != 8) {
213 std::cerr << "expected two intraday-loss breaches and the loss-days breach\n";
214 return 1;
215 }
216 if (breaches[1].day_ordinal != breaches[0].day_ordinal + 1
217 || breaches[2].day_ordinal != breaches[1].day_ordinal + 1) {
218 std::cerr << "expected the three breaches on three consecutive risk days\n";
219 return 1;
220 }
221
222 std::printf("kernel-originated flattens: %d; same-day re-entry refused %d time(s); "
223 "day-3 entry refused %d time(s)\n",
224 kernel_flattens, re_entry_refusals, day_three_refusals);
225 // Day 3's block finds a flat book: FlattenAndBlock issues nothing there.
226 if (kernel_flattens != 2 || re_entry_refusals == 0 || day_three_refusals == 0) {
227 std::cerr << "expected one kernel flatten per intraday breach and both openings refused\n";
228 return 1;
229 }
230
231 // --- the ledger -------------------------------------------------------------
232 for (std::size_t day = 0; day < host.ledger_at_day_end.size(); ++day) {
233 const auto& row = host.ledger_at_day_end[day];
234 std::printf("day %zu close: blocked=%d reason=%-22s fills_today=%llu loss_days=%u "
235 "day_open_equity=%.2f peak=%.2f\n",
236 day + 1, row.blocked ? 1 : 0, row.reason ? limit_name(*row.reason) : "-",
237 static_cast<unsigned long long>(row.fills_today), row.consecutive_loss_days,
238 row.day_open_equity, row.peak_equity);
239 }
240 if (host.ledger_at_day_end.size() != 3) return 1;
241 const auto& day1 = host.ledger_at_day_end[0];
242 const auto& day2 = host.ledger_at_day_end[1];
243 const auto& day3 = host.ledger_at_day_end[2];
244 if (!day1.blocked || !day1.reason || *day1.reason != no::RiskLimitKind::MaxIntradayLoss
245 || day1.fills_today != 2 || day1.consecutive_loss_days != 0
246 || !near(day1.day_open_equity, 100000.0)
247 || !day2.blocked || !day2.reason || *day2.reason != no::RiskLimitKind::MaxIntradayLoss
248 || day2.fills_today != 2 || day2.consecutive_loss_days != 1
249 || !near(day2.day_open_equity, 98800.0)
250 || !day3.blocked || !day3.reason
251 || *day3.reason != no::RiskLimitKind::MaxConsecutiveLossDays
252 || day3.fills_today != 0 || day3.consecutive_loss_days != 2
253 || !near(day3.day_open_equity, 97800.0) || !near(day3.peak_equity, 100000.0)) {
254 std::cerr << "expected the ledger to carry each day's opening equity, fills and streak\n";
255 return 1;
256 }
257 if (host.physical_position().signed_units != 0.0) {
258 std::cerr << "expected a flat book at the end\n";
259 return 1;
260 }
261
262 // Both rows were closed by the account rule, not by the strategy.
263 double net = 0.0;
264 for (int i = 0; i < host.trade_count(); ++i) net += host.get_trade(i).pnl;
265 if (host.trade_count() != 2 || !near(net, -2200.0)
266 || std::string(host.get_trade(0).exit_id) != "__kernel_risk__"
267 || std::string(host.get_trade(1).exit_id) != "__kernel_risk__") {
268 std::cerr << "expected two kernel-closed rows netting -2200\n";
269 return 1;
270 }
271
272 std::cout << "closed trades: " << host.trade_count() << '\n';
273 for (int i = 0; i < host.trade_count(); ++i) {
274 const auto& trade = host.get_trade(i);
275 std::cout << " " << (trade.is_long ? "long " : "short")
276 << " qty=" << trade.qty
277 << " entry=" << trade.entry_price
278 << " exit=" << trade.exit_price
279 << " pnl=" << trade.pnl
280 << " exit_id=" << trade.exit_id << '\n';
281 }
282 return 0;
283}
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.