PineForge v0.13.1-379-g9b50973
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 spec.input_tf = "15";
111 spec.script_tf = "15";
112 spec.ticker = "MOCK";
113 spec.tickerid = "TEST:MOCK";
114 spec.type = "crypto";
115 spec.currency = "USDT";
116 spec.basecurrency = "ETH";
117 spec.timezone = "UTC";
118 spec.session = "24x7";
119 spec.initial_capital = 10000.0;
120 spec.point_value = 1.0;
121 spec.account_fx = 1.0;
122 spec.price_tick = 0.25;
123 spec.fee_kind = pineforge::NativeFeeKind::Percent;
124 spec.fee_value = 0.0;
125 // The account's risk rule: two fills a day, then no more openings.
127 risk.max_fills_per_day = 2u;
128 risk.action = pineforge::NativeRiskAction::BlockOpenings;
129 spec.risk = risk;
130 return spec;
131}
132
133constexpr std::int64_t kQuarter = 15LL * 60LL * 1000LL;
134
135// open, high, low, close, volume, timestamp (Unix milliseconds). The entry
136// fills at 100.00; the trail arms at 102.00 (best 103.00, level 101.00),
137// follows the best to 106.00 (level 104.00) and exits when the fifth bar
138// trades down through 104.00.
139const pineforge::Bar kBars[] = {
140 { 99.75, 100.25, 99.50, 100.00, 10.0, 0 * kQuarter},
141 {100.00, 101.00, 99.75, 100.75, 10.0, 1 * kQuarter},
142 {100.75, 103.00, 100.50, 102.75, 10.0, 2 * kQuarter},
143 {102.75, 106.00, 102.50, 105.75, 10.0, 3 * kQuarter},
144 {105.75, 105.75, 101.00, 101.50, 10.0, 4 * kQuarter},
145 {101.50, 102.00, 101.00, 101.75, 10.0, 5 * kQuarter},
146};
147constexpr int kBarCount = 6;
148
149const char* limit_name(no::RiskLimitKind kind) {
150 switch (kind) {
151 case no::RiskLimitKind::MaxDrawdown: return "MaxDrawdown";
152 case no::RiskLimitKind::MaxIntradayLoss: return "MaxIntradayLoss";
153 case no::RiskLimitKind::MaxConsecutiveLossDays: return "MaxConsecutiveLossDays";
154 case no::RiskLimitKind::MaxFillsPerDay: return "MaxFillsPerDay";
155 }
156 return "?";
157}
158
159} // namespace
160
161int main() {
162 TrailRiskExample host;
163 if (host.configure_native(make_spec()).status
164 != pineforge::NativeSetupStatus::Applied) {
165 std::cerr << "configure: " << host.last_error() << '\n';
166 return 1;
167 }
168
169 host.run(kBars, kBarCount);
170 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
171 std::cerr << "run: " << host.last_error() << '\n';
172 return 1;
173 }
174
175 // --- L7: the trail --------------------------------------------------------
176 std::printf("working requests after the trail was placed: %zu; stored trail offset %.2f "
177 "(8 ticks x 0.25, resolved at acceptance)\n",
178 host.working_after_trail,
179 host.stored_trail_offset ? *host.stored_trail_offset : -1.0);
180 if (host.working_after_trail != 2 || !host.stored_trail_offset || *host.stored_trail_offset != 2.0) {
181 std::cerr << "expected the far limit and the trail working, with the ticks resolved to 2.00\n";
182 return 1;
183 }
184 if (!host.armed_state || !host.last_state) {
185 std::cerr << "expected the trail to arm and track\n";
186 return 1;
187 }
188 std::printf("trail armed: best=%.2f level=%.2f; last tracked: best=%.2f level=%.2f\n",
189 host.armed_state->best_price, host.armed_state->current_level,
190 host.last_state->best_price, host.last_state->current_level);
191 if (host.armed_state->best_price != 103.0 || host.armed_state->current_level != 101.0
192 || host.last_state->best_price != 106.0 || host.last_state->current_level != 104.0) {
193 std::cerr << "expected the level to ride 2.00 behind the running best\n";
194 return 1;
195 }
196 std::printf("cancel_where(\"parked\") cancelled %zu request(s)\n", host.cancelled_by_comment);
197 if (host.cancelled_by_comment != 1) {
198 std::cerr << "expected the far limit to be cancelled by its comment\n";
199 return 1;
200 }
201
202 // --- L9: the risk limit -----------------------------------------------------
203 int refused_on_risk = 0;
204 std::optional<no::NativeRiskEvent> tripped;
205 for (const auto& event : host.native_events(0)) {
206 if (!event.command) continue;
207 if (const auto* rejected = std::get_if<no::MatchRejectedEvent>(&*event.command)) {
208 if (host.re_entry && rejected->handle() == *host.re_entry
209 && rejected->reason == no::MatchRejectReason::RiskLimit) {
210 ++refused_on_risk;
211 }
212 }
213 if (const auto* risk = std::get_if<no::NativeRiskEvent>(&*event.command)) {
214 if (!tripped) tripped = *risk;
215 }
216 }
217 const auto ledger = host.native_risk_state();
218 std::printf("risk ledger: blocked=%d fills_today=%llu reason=%s\n",
219 ledger.blocked ? 1 : 0, static_cast<unsigned long long>(ledger.fills_today),
220 ledger.reason ? limit_name(*ledger.reason) : "-");
221 if (tripped) {
222 std::printf("risk event: %s limit=%.0f observed=%.0f\n",
223 limit_name(tripped->kind), tripped->limit, tripped->observed);
224 }
225 std::printf("re-entry: %s at submit, refused on the risk block at %d candidate(s), final position %.2f\n",
226 host.re_entry_accepted_at_submit ? "accepted" : "rejected", refused_on_risk,
227 host.physical_position().signed_units);
228 if (!ledger.blocked || ledger.fills_today != 2 || !ledger.reason
229 || *ledger.reason != no::RiskLimitKind::MaxFillsPerDay || !tripped
230 || !host.re_entry_accepted_at_submit || refused_on_risk == 0
231 || host.physical_position().signed_units != 0.0) {
232 std::cerr << "expected max_fills_per_day to block the re-entry after two fills\n";
233 return 1;
234 }
235
236 std::cout << "closed trades: " << host.trade_count() << '\n';
237 for (int i = 0; i < host.trade_count(); ++i) {
238 const auto& trade = host.get_trade(i);
239 std::cout << " " << (trade.is_long ? "long " : "short")
240 << " qty=" << trade.qty
241 << " entry=" << trade.entry_price
242 << " exit=" << trade.exit_price
243 << " pnl=" << trade.pnl
244 << " exit_id=" << trade.exit_id << '\n';
245 }
246 return host.trade_count() > 0 ? 0 : 1;
247}
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.