PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_bracket_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: a market entry with three anchored bracket legs
2// -- take-profit, stop-loss and a trail -- on an instrument price grid (R5
3// lane L7b).
4//
5// The legs are placed BEFORE the entry has a price. Each carries a
6// FromOwnerFill anchor spelled in ticks; when the entry's fill arms them the
7// kernel materializes the level as fill + offset, snapped onto the run's tick
8// ladder with NativeAnchorRounding::Directional (a sell limit up, a sell stop
9// down, and a trail's arm threshold up, like the limit it is reached as),
10// offers it once to resolve_anchored_level, and writes the installed level
11// into the ArmedEvent. PendingUntilArmed keeps the legs out of the working
12// enumeration until that arm, exactly like a broker that shows no working
13// child before the parent fills.
14//
15// The entry fills at 100.10, off the 0.25 ladder, so every level is snapped:
16//
17// take-profit 100.10 + 12 ticks (3.00) = 103.10 -> up -> 103.25
18// stop-loss 100.10 - 8 ticks (2.00) = 98.10 -> down -> 98.00
19// trail arm 100.10 + 6 ticks (1.50) = 101.60 -> up -> 101.75
20//
21// The working enumeration goes from 1 row (the entry) to 3 (the three armed
22// legs) at the fill. The legs share one OCA group, so when the take-profit
23// fills at 103.25 the kernel cancels the stop-loss and the trail with
24// CancelReason::Group. The example asserts each of those facts.
25//
26// c++ -std=c++17 native_bracket_strategy.cpp -lpineforge -o native_bracket
27//
28// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
29
31
32#include <cassert>
33#include <cmath>
34#include <cstdio>
35#include <iostream>
36#include <optional>
37#include <string>
38#include <variant>
39
40namespace {
41
42namespace no = pineforge::native_order;
43namespace tk = pineforge::native_toolkit;
44
45const char* leg_name(tk::BracketLeg which) {
46 switch (which) {
47 case tk::BracketLeg::TakeProfit: return "take-profit";
48 case tk::BracketLeg::StopLoss: return "stop-loss";
49 case tk::BracketLeg::Trail: break;
50 }
51 return "trail";
52}
53
54const char* reject_name(no::RequestRejectReason reason) {
55 switch (reason) {
56 case no::RequestRejectReason::InvalidQuantity: return "InvalidQuantity";
57 case no::RequestRejectReason::OffGrid: return "OffGrid";
58 case no::RequestRejectReason::InvalidTrigger: return "InvalidTrigger";
59 case no::RequestRejectReason::InvalidCapacity: return "InvalidCapacity";
60 case no::RequestRejectReason::InvalidOwner: return "InvalidOwner";
61 case no::RequestRejectReason::InvalidQuantityBasis: return "InvalidQuantityBasis";
62 case no::RequestRejectReason::InvalidGroup: return "InvalidGroup";
63 case no::RequestRejectReason::PlacementAdmission: break;
64 }
65 return "PlacementAdmission";
66}
67
68const char* action_name(tk::OrderBookAction action) {
69 switch (action) {
70 case tk::OrderBookAction::Replaced: return "replaced in place";
71 case tk::OrderBookAction::ReplaceRejected: return "replacement REFUSED";
72 case tk::OrderBookAction::SubmitAccepted: return "submitted fresh";
73 case tk::OrderBookAction::SubmitRejected: break;
74 }
75 return "submit REFUSED";
76}
77
78// The same reading a receipt asks for, one key at a time. A returned handle
79// alone cannot say whether the book re-priced the order the host named or
80// replaced it with a brand new request born behind everything already
81// resting, and an empty one cannot say whether the replacement was refused
82// (the previous order still working) or the submit was (the key now unbound).
83void report_key(const char* key, const tk::OrderBookOutcome& outcome) {
84 std::printf("key %s: %s", key, action_name(outcome.action));
85 if (const auto handle = outcome.handle()) {
86 std::printf(", request %llu", static_cast<unsigned long long>(handle->incarnation));
87 }
88 if (const auto reason = outcome.reason()) {
89 std::printf(" (%s)", reject_name(*reason));
90 }
91 std::printf(", ordinal %llu\n", static_cast<unsigned long long>(outcome.event_ordinal()));
92}
93
94// What a host must do with a receipt: read every leg, not just the handles.
95// An empty handle alone cannot say whether the leg was never asked for or
96// whether the kernel refused it -- and a refused exit leg that goes unread is
97// a position left running without the protection its strategy believes it
98// placed.
99void report_legs(const tk::BracketReceipt& legs) {
100 for (const tk::BracketLeg which : {tk::BracketLeg::TakeProfit, tk::BracketLeg::StopLoss,
101 tk::BracketLeg::Trail}) {
102 const tk::BracketLegOutcome& leg = legs.outcome(which);
103 switch (leg.state) {
104 case tk::BracketLegState::NotRequested:
105 std::printf("leg %s: not requested\n", leg_name(which));
106 break;
107 case tk::BracketLegState::NotSubmitted:
108 std::printf("leg %s: not submitted (the parent was never allocated)\n",
109 leg_name(which));
110 break;
111 case tk::BracketLegState::Accepted:
112 std::printf("leg %s: accepted as request %llu\n", leg_name(which),
113 static_cast<unsigned long long>(leg.handle()->incarnation));
114 break;
115 case tk::BracketLegState::Rejected:
116 std::printf("leg %s: REFUSED (%s)\n", leg_name(which),
117 leg.reason() ? reject_name(*leg.reason()) : "no reason given");
118 break;
119 }
120 }
121}
122
123class BracketExample : public pineforge::NativeStrategyHost {
124public:
125 // Read back by main(). resolve_anchored_level is const, so the levels it
126 // is offered are recorded in mutable members.
127 std::size_t working_before_fill = 0;
128 std::size_t working_after_fill = 0;
129 mutable std::optional<double> offered_take_profit;
130 mutable std::optional<double> offered_stop_loss;
131 mutable std::optional<double> offered_trail_arm;
132
133private:
134 int bars_ = 0;
135 no::RequestHandle entry_;
136 tk::BracketReceipt legs_;
137 std::optional<tk::OrderBook<std::string>> book_;
138
139 void on_native_run_begin() override {
140 bars_ = 0;
141 book_.emplace(*this);
142 }
143
144 // A resting bid far under the market: it never fills, so it is only ever
145 // the book's own order to re-price, refuse and withdraw.
146 static no::Request ladder(double price, const char* label) {
147 no::Request request{no::Transact{1.0}, label, "ladder"};
148 request.trigger = no::Limit{price};
149 return request;
150 }
151
152 // The key the host addresses that order by. Everything below reads the
153 // outcome, never the bare handle.
154 void work_the_book() {
155 switch (bars_) {
156 case 2: {
157 const auto opened = book_->submit_or_replace_outcome("ladder", ladder(90.0, "bid-1"));
158 report_key("ladder", opened);
159 assert(opened.action == tk::OrderBookAction::SubmitAccepted);
160 assert(opened.bound() && !opened.replace.has_value());
161 break;
162 }
163 case 3: {
164 // Re-pricing: the replace decided it and no submit happened, so
165 // the host knows it kept its order rather than queueing a new one.
166 const auto repriced =
167 book_->submit_or_replace_outcome("ladder", ladder(91.0, "bid-2"));
168 report_key("ladder", repriced);
169 assert(repriced.replaced());
170 assert(repriced.replace && !repriced.submit.has_value());
171
172 // A replacement the kernel must refuse: a reduce of zero units.
173 // The empty handle alone would be indistinguishable from a
174 // refused submit, and would carry no reason; the outcome says
175 // both, and the order the host named is still working.
176 no::Request zero{no::Reduce{no::ExplicitUnits{0.0}}, "bid-bad", "ladder"};
177 zero.trigger = no::Limit{91.0};
178 const auto refused = book_->submit_or_replace_outcome("ladder", zero);
179 report_key("ladder", refused);
180 assert(refused.action == tk::OrderBookAction::ReplaceRejected);
181 assert(refused.reason() == no::RequestRejectReason::InvalidQuantity);
182 assert(!refused.handle().has_value() && !refused.submit.has_value());
183 assert(book_->handle("ladder") == repriced.handle());
184 break;
185 }
186 case 4: {
187 const auto withdrawn = book_->cancel_outcome("ladder");
188 std::printf("key ladder: cancel commanded=%d status=%s\n",
189 withdrawn.commanded() ? 1 : 0,
190 withdrawn.status() == no::CancelStatus::Cancelled ? "Cancelled"
191 : "NotWorking");
192 assert(withdrawn.known && withdrawn.commanded());
193 assert(withdrawn.status() == no::CancelStatus::Cancelled);
194 // An unknown key is the book's own silence, not the kernel's.
195 const auto never = book_->cancel_outcome("no-such-key");
196 assert(!never.known && !never.commanded());
197 assert(never.status() == no::CancelStatus::NotWorking);
198 break;
199 }
200 default:
201 break;
202 }
203 }
204
205 void on_native_bar(const pineforge::Bar&,
206 const pineforge::NativeDecisionContext&) override {
207 ++bars_;
208 if (bars_ != 1) {
209 work_the_book();
210 return;
211 }
212 // The entry: two units at the next open.
213 const auto placed = submit({no::Transact{2.0}, "entry", "bracket"});
214 if (placed.status != no::SubmitStatus::Accepted || !placed.handle) return;
215 entry_ = *placed.handle;
216
217 // The legs: relative to the fill, in ticks, level unknown until then.
218 no::Request take_profit{no::Reduce{no::OwnerOpenedUnits{}}, "take-profit", "bracket"};
219 take_profit.trigger = no::Limit{0.0};
220 take_profit.anchor = no::FromOwnerFill{+12.0, /*ticks=*/true};
221 no::Request stop_loss{no::Reduce{no::OwnerOpenedUnits{}}, "stop-loss", "bracket"};
222 stop_loss.trigger = no::Stop{0.0};
223 stop_loss.anchor = no::FromOwnerFill{-8.0, /*ticks=*/true};
224 // The trail rides 4 ticks behind its best once the price has reached
225 // its arm threshold. The threshold is the anchored level, so arm_price
226 // stays absent: the fill supplies it.
227 no::Trail ride;
228 ride.ticks = no::TrailTicks{4.0};
229 no::Request trail{no::Reduce{no::OwnerOpenedUnits{}}, "trail", "bracket"};
230 trail.trigger = ride;
231 trail.anchor = no::FromOwnerFill{+6.0, /*ticks=*/true};
232
233 tk::BracketSpec bracket;
234 bracket.parent = entry_;
235 bracket.take_profit = take_profit;
236 bracket.stop_loss = stop_loss;
237 bracket.trail = trail;
238 bracket.anchor_rounding = no::NativeAnchorRounding::Directional;
239 bracket.visibility = no::NativeArmVisibility::PendingUntilArmed;
240 legs_ = tk::submit_bracket(*this, bracket);
241
242 // The receipt, leg by leg. A host that reads only the handles cannot
243 // tell a refused leg from one it never asked for, which is how an
244 // anchored leg can vanish while the strategy keeps running: assert
245 // instead that every leg this bracket asked for is resting.
246 report_legs(legs_);
247 assert(legs_.every_requested_leg_accepted());
248 assert(legs_.outcome(tk::BracketLeg::TakeProfit).state
249 == tk::BracketLegState::Accepted);
250 assert(legs_.outcome(tk::BracketLeg::StopLoss).state == tk::BracketLegState::Accepted);
251 assert(legs_.outcome(tk::BracketLeg::TakeProfit).handle() == legs_.take_profit);
252 assert(!legs_.outcome(tk::BracketLeg::TakeProfit).reason().has_value());
253 // expectation corrected: trail NotRequested -> Accepted, because the
254 // bracket now asks for its trailing leg (R5 lane F12: the example
255 // arms a take-profit, a stop-loss AND a trail). A leg the spec leaves
256 // unset still reads NotRequested, with no result, never a refusal.
257 assert(legs_.outcome(tk::BracketLeg::Trail).state == tk::BracketLegState::Accepted);
258 assert(legs_.outcome(tk::BracketLeg::Trail).result.has_value());
259 assert(legs_.trail.has_value() && legs_.outcome(tk::BracketLeg::Trail).handle() == legs_.trail);
260
261 // Before the fill the legs are live but not working orders: only the
262 // entry is enumerated.
263 working_before_fill = native_working_requests().size();
264 std::cout << "working before the fill: " << working_before_fill << '\n';
265 assert(working_before_fill == 1);
266 }
267
268 // The one policy point: the kernel level is offered once per leg. This
269 // host keeps it (nullopt); a host with its own trigger projection would
270 // return the level it wants installed instead.
271 std::optional<double> resolve_anchored_level(
272 const pineforge::NativeAnchoredLevelView& view) const override {
273 const char* trigger = "stop";
274 if (view.trigger == pineforge::NativeAnchoredTrigger::Limit) {
275 trigger = "limit";
276 offered_take_profit = view.kernel_level;
277 } else if (view.trigger == pineforge::NativeAnchoredTrigger::TrailArm) {
278 trigger = "trail arm";
279 offered_trail_arm = view.kernel_level;
280 } else {
281 offered_stop_loss = view.kernel_level;
282 }
283 std::printf("arm: %s leg of owner %llu, fill %.4f, offset %+.4f, kernel level %.4f\n",
284 trigger, static_cast<unsigned long long>(view.owner.incarnation),
285 view.owner_fill_price, view.offset, view.kernel_level);
286 return std::nullopt;
287 }
288
289 void on_native_applied(const no::ExecutionAppliedEvent& event,
290 const pineforge::NativeDecisionContext&) override {
291 if (event.definition && event.definition->handle == entry_) {
292 // The fill armed all three legs: from here on they are working
293 // orders, and the entry itself is done.
294 working_after_fill = native_working_requests().size();
295 std::cout << "working after the fill: " << working_after_fill << '\n';
296 assert(working_after_fill == 3);
297 }
298 }
299};
300
303 spec.identity.session_key = "native-bracket-example";
304 spec.identity.run_number = 1;
305 // This example reads its whole event record once the run has ended, so
306 // it keeps it all: the default, Window, keeps only what a host has not
307 // yet acknowledged (native_acknowledge_events).
308 spec.event_retention = pineforge::NativeEventRetention::Full;
309 spec.input_tf = "5";
310 spec.script_tf = "5";
311 spec.ticker = "MOCK";
312 spec.tickerid = "TEST:MOCK";
313 spec.type = "crypto";
314 spec.currency = "USDT";
315 spec.basecurrency = "ETH";
316 spec.timezone = "UTC";
317 spec.session = "24x7";
318 spec.initial_capital = 10000.0;
319 spec.point_value = 1.0;
320 spec.account_fx = 1.0;
321 // A quarter-point ladder: the legs snap onto it at the arm.
322 spec.price_tick = 0.25;
323 spec.fee_kind = pineforge::NativeFeeKind::Percent;
324 spec.fee_value = 0.0;
325 return spec;
326}
327
328// open, high, low, close, volume, timestamp (Unix milliseconds). The entry
329// fills at 100.10 (off the ladder); the legs arm at 103.25 (100.10 + 3.00
330// rounded up onto the ladder), 98.00 (100.10 - 2.00 rounded down) and, for
331// the trail, 101.75 (100.10 + 1.50 rounded up), and the rise in the third bar
332// takes the profit: its path dips to 100.80 first, then climbs through the
333// trail's arm threshold to the take-profit's 103.25.
334const pineforge::Bar kBars[] = {
335 {100.00, 100.50, 99.75, 100.10, 4.0, 0},
336 {100.10, 101.00, 100.00, 100.90, 4.0, 300000},
337 {100.90, 103.60, 100.80, 103.20, 4.0, 600000},
338 {103.20, 103.40, 102.90, 103.00, 4.0, 900000},
339};
340constexpr int kBarCount = 4;
341
342} // namespace
343
344int main() {
345 BracketExample host;
346 if (host.configure_native(make_spec()).status
347 != pineforge::NativeSetupStatus::Applied) {
348 std::cerr << "configure: " << host.last_error() << '\n';
349 return 1;
350 }
351
352 host.run(kBars, kBarCount);
353 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
354 std::cerr << "run: " << host.last_error() << '\n';
355 return 1;
356 }
357
358 // The ArmedEvent carries the materialized level: read it back, and the
359 // cancellations the take-profit's fill caused.
360 std::optional<double> take_profit_level;
361 std::optional<double> stop_loss_level;
362 std::optional<double> trail_arm_level;
363 int group_cancels = 0;
364 for (const auto& event : host.native_events(0)) {
365 if (!event.command) continue;
366 if (const auto* cancelled = std::get_if<no::CancelledEvent>(&*event.command)) {
367 // The one-cancels-all group: whichever leg fills first ends its
368 // siblings, and the event names the group as the reason.
369 const std::string& label = cancelled->request().label;
370 if (label == "stop-loss" || label == "trail") {
371 std::cout << "cancelled " << label << " (reason "
372 << (cancelled->reason == no::CancelReason::Group ? "Group" : "other")
373 << ")\n";
374 assert(cancelled->reason == no::CancelReason::Group);
375 ++group_cancels;
376 }
377 continue;
378 }
379 const auto* armed = std::get_if<no::ArmedEvent>(&*event.command);
380 if (!armed || !armed->definition) continue;
381 const auto& request = armed->definition->request;
382 if (const auto* limit = std::get_if<no::Limit>(&request.trigger)) {
383 std::cout << "armed " << request.label << " at " << limit->price << '\n';
384 take_profit_level = limit->price;
385 } else if (const auto* stop = std::get_if<no::Stop>(&request.trigger)) {
386 std::cout << "armed " << request.label << " at " << stop->price << '\n';
387 stop_loss_level = stop->price;
388 } else if (const auto* trail = std::get_if<no::Trail>(&request.trigger)) {
389 // The anchored arm threshold is written into the armed definition;
390 // the tick-spelled ride is resolved into a price distance.
391 std::cout << "armed " << request.label << " at "
392 << trail->arm_price.value_or(-1.0) << ", riding " << trail->offset
393 << " behind its best\n";
394 trail_arm_level = trail->arm_price;
395 assert(trail->offset == 1.0);
396 }
397 }
398
399 // Every level is the one the hook was offered, and every one sits on the
400 // 0.25 ladder, snapped toward the region its leg needs.
401 const auto on_ladder = [](double level) {
402 const double ticks = level / 0.25;
403 return ticks == std::floor(ticks);
404 };
405 assert(take_profit_level == 103.25 && host.offered_take_profit == take_profit_level);
406 assert(stop_loss_level == 98.00 && host.offered_stop_loss == stop_loss_level);
407 assert(trail_arm_level == 101.75 && host.offered_trail_arm == trail_arm_level);
408 assert(on_ladder(*take_profit_level) && on_ladder(*stop_loss_level)
409 && on_ladder(*trail_arm_level));
410 // Working rows went 1 -> 3 at the fill, and the take-profit's fill
411 // cancelled both of its siblings.
412 assert(host.working_before_fill == 1 && host.working_after_fill == 3);
413 assert(group_cancels == 2);
414 assert(host.trade_count() == 1 && host.get_trade(0).exit_id == "take-profit"
415 && host.get_trade(0).exit_price == 103.25);
416
417 // The summary line, printed only once every check above has passed.
418 std::cout << "armed tp=" << *take_profit_level << " sl=" << *stop_loss_level
419 << " trail=" << *trail_arm_level << " working " << host.working_before_fill
420 << " -> " << host.working_after_fill << " oca cancels: " << group_cancels
421 << " closed trades: " << host.trade_count() << '\n';
422 for (int i = 0; i < host.trade_count(); ++i) {
423 const auto& trade = host.get_trade(i);
424 std::cout << " " << (trade.is_long ? "long " : "short")
425 << " qty=" << trade.qty
426 << " entry=" << trade.entry_price
427 << " exit=" << trade.exit_price
428 << " pnl=" << trade.pnl << '\n';
429 }
430 return host.trade_count() > 0 ? 0 : 1;
431}
The public native host: an abstract subclass of BacktestEngine with no PineScript on it.
static const pf_bar_t kBars[]
static pf_native_run_spec_v1 make_spec(void)
@ kBarCount
static void submit(struct host_state *state, uint32_t intent, double value, uint32_t trigger, double price, const char *label)
One complete setup value, staged/copied by NativeStrategyHost before it is applied at begin.
NativeEventRetention event_retention
What native_events() can still return; see NativeEventRetention.