PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_trail_risk_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: a trail spelled in ticks, the working book, and a
2// generic account risk limit (R5 lanes L7 and L9).
3//
4// L7, order ergonomics:
5// * Trail{offset, arm_price, ticks = TrailTicks{8}}: the stop rides eight
6// ticks (8 x 0.25 = 2.00) behind the running best once the arm price is
7// reached; acceptance resolves the tick spelling into the price offset
8// once, so the stored definition carries a plain distance;
9// * trail_state(handle): the live trail's best price and current level;
10// * native_working_requests(): the working book, copied out;
11// * cancel_where(comment): bulk cancellation by comment.
12// L9, risk limits: NativeRunSpec::risk with max_fills_per_day = 2 and
13// action BlockOpenings. The entry and the trail exit are the day's two fills;
14// the re-entry submitted right after the exit is ACCEPTED at submit and then
15// refused at its matching candidate (MatchRejectReason::RiskLimit) while the
16// block lasts, so it never reaches the book. native_risk_state() reads the
17// ledger back and the NativeRiskEvent names the limit that tripped.
18//
19// c++ -std=c++17 native_trail_risk_strategy.cpp -lpineforge_kernel -o trail_risk
20//
21// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
22
24
25#include <cstdio>
26#include <iostream>
27#include <optional>
28#include <variant>
29#include <vector>
30
31namespace {
32
33namespace no = pineforge::native_order;
34
35class TrailRiskExample : public pineforge::NativeStrategyHost {
36public:
37 std::optional<pineforge::NativeTrailState> armed_state;
38 std::optional<pineforge::NativeTrailState> last_state;
39 std::optional<double> stored_trail_offset; // after acceptance resolved the ticks
40 std::size_t working_after_trail = 0;
41 std::size_t cancelled_by_comment = 0;
42 std::optional<no::RequestHandle> trail;
43 std::optional<no::RequestHandle> re_entry;
44 bool re_entry_accepted_at_submit = false;
45
46private:
47 int bars_ = 0;
48 std::optional<no::RequestHandle> entry_;
49
50 void on_native_run_begin() override { bars_ = 0; }
51
52 void on_native_bar(const pineforge::Bar&,
53 const pineforge::NativeDecisionContext&) override {
54 ++bars_;
55 if (bars_ == 1) {
56 entry_ = submit({no::Transact{2.0}, "entry", "trail-risk"}).handle;
57 // A resting buy far below the market, cancelled by comment later.
58 no::Request far{no::Transact{1.0}, "far-limit", "parked"};
59 far.trigger = no::Limit{90.0};
60 submit(far);
61 return;
62 }
63 if (bars_ == 2) {
64 // The trail: arm at 102.00, then ride 8 ticks behind the best.
65 no::Request request{no::Reduce{no::ExplicitUnits{2.0}}, "trail", "trail-risk"};
66 no::Trail spelled;
67 spelled.offset = 0.0;
68 spelled.arm_price = 102.0;
69 spelled.ticks = no::TrailTicks{8.0};
70 request.trigger = spelled;
71 trail = submit(request).handle;
72 // The working book, copied out: the stored trail carries the
73 // resolved price offset, and the ticks spelling is gone.
74 const auto working = native_working_requests();
75 working_after_trail = working.size();
76 for (const auto& row : working) {
77 if (trail && row.definition->handle == *trail) {
78 if (const auto* live = std::get_if<no::Trail>(&row.definition->request.trigger)) {
79 stored_trail_offset = live->offset;
80 }
81 }
82 }
83 return;
84 }
85 if (trail) {
86 if (const auto state = trail_state(*trail); state && state->activated) {
87 if (!armed_state) armed_state = state;
88 last_state = state;
89 }
90 }
91 if (bars_ == 6) cancelled_by_comment = cancel_where("parked");
92 }
93
94 void on_native_applied(const no::ExecutionAppliedEvent& event,
95 const pineforge::NativeDecisionContext&) override {
96 if (trail && event.handle() == *trail) {
97 // The day's fill budget is spent: this opening is accepted at
98 // submit and refused at every candidate while the block lasts.
99 const auto placed = submit({no::Transact{1.0}, "re-entry", "trail-risk"});
100 re_entry_accepted_at_submit = placed.status == no::SubmitStatus::Accepted;
101 re_entry = placed.handle;
102 }
103 }
104};
105
108 spec.identity.session_key = "native-trail-risk-example";
109 spec.identity.run_number = 1;
110 // This example reads its whole event record once the run has ended, so
111 // it keeps it all: the default, Window, keeps only what a host has not
112 // yet acknowledged (native_acknowledge_events).
113 spec.event_retention = pineforge::NativeEventRetention::Full;
114 spec.input_tf = "15";
115 spec.script_tf = "15";
116 spec.ticker = "MOCK";
117 spec.tickerid = "TEST:MOCK";
118 spec.type = "crypto";
119 spec.currency = "USDT";
120 spec.basecurrency = "ETH";
121 spec.timezone = "UTC";
122 spec.session = "24x7";
123 spec.initial_capital = 10000.0;
124 spec.point_value = 1.0;
125 spec.account_fx = 1.0;
126 spec.price_tick = 0.25;
127 spec.fee_kind = pineforge::NativeFeeKind::Percent;
128 spec.fee_value = 0.0;
129 // The account's risk rule: two fills a day, then no more openings.
131 risk.max_fills_per_day = 2u;
132 risk.action = pineforge::NativeRiskAction::BlockOpenings;
133 spec.risk = risk;
134 return spec;
135}
136
137constexpr std::int64_t kQuarter = 15LL * 60LL * 1000LL;
138
139// open, high, low, close, volume, timestamp (Unix milliseconds). The entry
140// fills at 100.00; the trail arms at 102.00 (best 103.00, level 101.00),
141// follows the best to 106.00 (level 104.00) and exits when the fifth bar
142// trades down through 104.00.
143const pineforge::Bar kBars[] = {
144 { 99.75, 100.25, 99.50, 100.00, 10.0, 0 * kQuarter},
145 {100.00, 101.00, 99.75, 100.75, 10.0, 1 * kQuarter},
146 {100.75, 103.00, 100.50, 102.75, 10.0, 2 * kQuarter},
147 {102.75, 106.00, 102.50, 105.75, 10.0, 3 * kQuarter},
148 {105.75, 105.75, 101.00, 101.50, 10.0, 4 * kQuarter},
149 {101.50, 102.00, 101.00, 101.75, 10.0, 5 * kQuarter},
150};
151constexpr int kBarCount = 6;
152
153const char* limit_name(no::RiskLimitKind kind) {
154 switch (kind) {
155 case no::RiskLimitKind::MaxDrawdown: return "MaxDrawdown";
156 case no::RiskLimitKind::MaxIntradayLoss: return "MaxIntradayLoss";
157 case no::RiskLimitKind::MaxConsecutiveLossDays: return "MaxConsecutiveLossDays";
158 case no::RiskLimitKind::MaxFillsPerDay: return "MaxFillsPerDay";
159 }
160 return "?";
161}
162
163} // namespace
164
165int main() {
166 TrailRiskExample host;
167 if (host.configure_native(make_spec()).status
168 != pineforge::NativeSetupStatus::Applied) {
169 std::cerr << "configure: " << host.last_error() << '\n';
170 return 1;
171 }
172
173 host.run(kBars, kBarCount);
174 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
175 std::cerr << "run: " << host.last_error() << '\n';
176 return 1;
177 }
178
179 // --- L7: the trail --------------------------------------------------------
180 std::printf("working requests after the trail was placed: %zu; stored trail offset %.2f "
181 "(8 ticks x 0.25, resolved at acceptance)\n",
182 host.working_after_trail,
183 host.stored_trail_offset ? *host.stored_trail_offset : -1.0);
184 if (host.working_after_trail != 2 || !host.stored_trail_offset || *host.stored_trail_offset != 2.0) {
185 std::cerr << "expected the far limit and the trail working, with the ticks resolved to 2.00\n";
186 return 1;
187 }
188 if (!host.armed_state || !host.last_state) {
189 std::cerr << "expected the trail to arm and track\n";
190 return 1;
191 }
192 std::printf("trail armed: best=%.2f level=%.2f; last tracked: best=%.2f level=%.2f\n",
193 host.armed_state->best_price, host.armed_state->current_level,
194 host.last_state->best_price, host.last_state->current_level);
195 if (host.armed_state->best_price != 103.0 || host.armed_state->current_level != 101.0
196 || host.last_state->best_price != 106.0 || host.last_state->current_level != 104.0) {
197 std::cerr << "expected the level to ride 2.00 behind the running best\n";
198 return 1;
199 }
200 std::printf("cancel_where(\"parked\") cancelled %zu request(s)\n", host.cancelled_by_comment);
201 if (host.cancelled_by_comment != 1) {
202 std::cerr << "expected the far limit to be cancelled by its comment\n";
203 return 1;
204 }
205
206 // --- L9: the risk limit -----------------------------------------------------
207 int refused_on_risk = 0;
208 std::optional<no::NativeRiskEvent> tripped;
209 for (const auto& event : host.native_events(0)) {
210 if (!event.command) continue;
211 if (const auto* rejected = std::get_if<no::MatchRejectedEvent>(&*event.command)) {
212 if (host.re_entry && rejected->handle() == *host.re_entry
213 && rejected->reason == no::MatchRejectReason::RiskLimit) {
214 ++refused_on_risk;
215 }
216 }
217 if (const auto* risk = std::get_if<no::NativeRiskEvent>(&*event.command)) {
218 if (!tripped) tripped = *risk;
219 }
220 }
221 const auto ledger = host.native_risk_state();
222 std::printf("risk ledger: blocked=%d fills_today=%llu reason=%s\n",
223 ledger.blocked ? 1 : 0, static_cast<unsigned long long>(ledger.fills_today),
224 ledger.reason ? limit_name(*ledger.reason) : "-");
225 if (tripped) {
226 std::printf("risk event: %s limit=%.0f observed=%.0f\n",
227 limit_name(tripped->kind), tripped->limit, tripped->observed);
228 }
229 std::printf("re-entry: %s at submit, refused on the risk block at %d candidate(s), final position %.2f\n",
230 host.re_entry_accepted_at_submit ? "accepted" : "rejected", refused_on_risk,
231 host.physical_position().signed_units);
232 if (!ledger.blocked || ledger.fills_today != 2 || !ledger.reason
233 || *ledger.reason != no::RiskLimitKind::MaxFillsPerDay || !tripped
234 || !host.re_entry_accepted_at_submit || refused_on_risk == 0
235 || host.physical_position().signed_units != 0.0) {
236 std::cerr << "expected max_fills_per_day to block the re-entry after two fills\n";
237 return 1;
238 }
239
240 std::cout << "closed trades: " << host.trade_count() << '\n';
241 for (int i = 0; i < host.trade_count(); ++i) {
242 const auto& trade = host.get_trade(i);
243 std::cout << " " << (trade.is_long ? "long " : "short")
244 << " qty=" << trade.qty
245 << " entry=" << trade.entry_price
246 << " exit=" << trade.exit_price
247 << " pnl=" << trade.pnl
248 << " exit_id=" << trade.exit_id << '\n';
249 }
250 return host.trade_count() > 0 ? 0 : 1;
251}
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)
Generic account risk limits (L9), entirely opt-in: a spec that leaves NativeRunSpec::risk unset evalu...
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.