PineForge v0.13.1-379-g9b50973
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_open_lots_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: the open book lot by lot, and the account
2// statistics a host reads beside it (R5 gap lane N18).
3//
4// strategy.opentrades.* is Pine's view of the still-open book. Its native
5// counterpart is one call: native_open_lots(mark) copies out one NativeOpenLot
6// per open physical lot, oldest first, with the lot's identity (ordinal, entry
7// incarnation, position cycle), its booking facts (label, comment, time, bar,
8// price, signed units), the entry fee still on it, and three values computed
9// at the price you pass — the live P&L and the two excursions. Pine marks at
10// the current close; a native host passes whichever price it means.
11//
12// The scenario pyramids a long three times and then reduces it partially, on
13// flat bars (open = high = low = close) with
14// NativeCloseExecution::AfterCalculation, so a request submitted in bar k's
15// calculation fills at bar k's own close and every number below is exact:
16//
17// idx: 0 1 2 3 4 5 6 7
18// price: 100 100 102 101 104 106 103 101
19// 1: Transact +1 "L1" -> lot @100 5: read the three lots
20// 2: Transact +2 "L2" -> lot @102 6: Reduce 1.5 (FIFO)
21// 4: Transact +1 "L3" -> lot @104 7: read what is left
22//
23// What the example proves:
24// * the rows are the book: one per lot, oldest first, ordinals 0..n-1, and
25// as many rows as physical_position().lot_count;
26// * they are marked, not stored: unrealized_pnl is the move from entry_price
27// to `mark` less the lot's own entry fee, and the marked equity is exactly
28// the account balance plus the sum of those rows —
29// native_marked_equity(mark) == current_equity() + sum(unrealized_pnl);
30// * a partial close is FIFO and takes its SHARE of the entry fee with it:
31// the 1.5-unit reduce closes all of L1 and a quarter of L2, leaving L2
32// with 1.5 units and 1.5 of its original 2.0 fee;
33// * a NaN mark keeps every booking fact, leaves unrealized_pnl NaN and folds
34// nothing into the excursions — reading the book is an observation and
35// never a decision the run depends on.
36//
37// The last block is the other half of Pine's report namespace. strategy.equity,
38// strategy.netprofit, strategy.grossprofit, strategy.wintrades and the rest are
39// PROTECTED accessors on BacktestEngine, which NativeStrategyHost derives from:
40// they are reachable from inside your own host, exactly as they are reachable
41// from inside a generated Pine strategy, and they are what fill_report() writes
42// into pf_report_t for a C caller.
43//
44// c++ -std=c++17 native_open_lots_strategy.cpp -lpineforge_kernel -o open_lots
45//
46// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
47
49
50#include <cmath>
51#include <cstdio>
52#include <iostream>
53#include <limits>
54#include <string>
55#include <vector>
56
57namespace {
58
59namespace no = pineforge::native_order;
60
61constexpr std::int64_t kQuarter = 15LL * 60LL * 1000LL;
62constexpr double kFee = 2.0; // CashPerExecution: one ticket per execution
63constexpr double kNaN = std::numeric_limits<double>::quiet_NaN();
64
65const double kPrices[] = {100.0, 100.0, 102.0, 101.0, 104.0, 106.0, 103.0, 101.0};
66constexpr int kBarCount = 8;
67
68bool near(double a, double b) {
69 return std::isfinite(a) && std::isfinite(b) && std::abs(a - b) <= 1e-9;
70}
71
72int failures = 0;
73
74void check(bool ok, const char* what) {
75 if (ok) return;
76 ++failures;
77 std::printf("FAIL: %s\n", what);
78}
79
80class OpenLotsExample : public pineforge::NativeStrategyHost {
81public:
82 // One snapshot per calculation: the rows, the identity they must satisfy,
83 // and the account statistics read at the same point.
84 struct Snapshot {
85 std::vector<pineforge::NativeOpenLot> rows;
86 std::vector<pineforge::NativeOpenLot> unmarked; // the same rows at a NaN mark
87 std::size_t lot_count = 0;
88 double mark = 0.0;
89 double marked_equity = 0.0;
90 double balance = 0.0;
91 };
92 std::vector<Snapshot> snaps;
93
94 // Pine's report namespace, read through the protected accessors this host
95 // inherits, at the end of the run.
96 struct Statistics {
97 double equity = 0.0; // strategy.equity (at the final close)
98 double netprofit = 0.0; // strategy.netprofit
99 double grossprofit = 0.0; // strategy.grossprofit
100 double grossloss = 0.0; // strategy.grossloss
101 double openprofit = 0.0; // strategy.openprofit
102 double avg_trade = 0.0; // strategy.avg_trade
103 double capital_held = 0.0; // strategy.opentrades.capital_held
104 int wintrades = 0; // strategy.wintrades
105 int losstrades = 0; // strategy.losstrades
106 double position_size = 0.0; // strategy.position_size
107 double max_drawdown = 0.0; // strategy.max_drawdown
108 double max_runup = 0.0; // strategy.max_runup
109 double contracts_held = 0.0; // strategy.max_contracts_held_all
110 };
111 Statistics statistics;
112
113private:
114 int bar_ = -1;
115
116 void on_native_run_begin() override {
117 bar_ = -1;
118 snaps.clear();
119 }
120
121 void on_native_bar(const pineforge::Bar& bar,
122 const pineforge::NativeDecisionContext&) override {
123 ++bar_;
124
125 Snapshot snap;
126 snap.mark = bar.close;
127 snap.rows = native_open_lots(bar.close);
128 snap.unmarked = native_open_lots(kNaN);
129 snap.lot_count = physical_position().lot_count;
130 snap.marked_equity = native_marked_equity(bar.close);
131 snap.balance = current_equity(); // protected: initial capital + realized
132 snaps.push_back(std::move(snap));
133
134 switch (bar_) {
135 case 1: submit({no::Transact{1.0}, "L1", "first"}); break;
136 case 2: submit({no::Transact{2.0}, "L2", "second"}); break;
137 case 4: submit({no::Transact{1.0}, "L3", "third"}); break;
138 case 6: submit({no::Reduce{no::ExplicitUnits{1.5}}, "partial", ""}); break;
139 default: break;
140 }
141
142 // The protected report accessors, refreshed at every calculation so the
143 // last calculation's values are the run's.
144 statistics.equity = current_equity() + open_profit(bar.close);
145 statistics.netprofit = net_profit();
146 statistics.grossprofit = gross_profit();
147 statistics.grossloss = gross_loss();
148 statistics.openprofit = open_profit(bar.close);
149 statistics.avg_trade = avg_trade();
150 statistics.capital_held = open_trades_capital_held();
151 statistics.wintrades = count_wintrades();
152 statistics.losstrades = count_losstrades();
153 statistics.position_size = physical_position().signed_units;
154 // The equity extremes and the position-size peaks are folded by the
155 // kernel's own report point, so they stand at zero unless the run
156 // asked for one (NativeReportPolicy::KernelRecorded). Both are
157 // protected members of BacktestEngine, like the accessors above.
158 statistics.max_drawdown = max_drawdown_;
159 statistics.max_runup = max_runup_;
160 statistics.contracts_held = max_contracts_held_all();
161 }
162};
163
166 spec.identity.session_key = "native-open-lots-example";
167 spec.identity.run_number = 1;
168 spec.input_tf = "15";
169 spec.script_tf = "15";
170 spec.ticker = "MOCK";
171 spec.tickerid = "TEST:MOCK";
172 spec.type = "crypto";
173 spec.currency = "USDT";
174 spec.basecurrency = "ETH";
175 spec.timezone = "UTC";
176 spec.session = "24x7";
177 spec.initial_capital = 10000.0;
178 spec.point_value = 1.0;
179 spec.account_fx = 1.0;
180 spec.price_tick = 0.01;
181 spec.fee_kind = pineforge::NativeFeeKind::CashPerExecution;
182 spec.fee_value = kFee;
183 // A market request submitted in bar k's calculation fills at bar k's close.
184 spec.close_execution = pineforge::NativeCloseExecution::AfterCalculation;
185 spec.report_policy = policy;
186 return spec;
187}
188
189// Flat bars: every matching point of bar k is that bar's own price.
190std::vector<pineforge::Bar> flat_bars() {
191 std::vector<pineforge::Bar> bars;
192 bars.reserve(kBarCount);
193 for (int i = 0; i < kBarCount; ++i) {
194 pineforge::Bar bar{};
195 bar.open = bar.high = bar.low = bar.close = kPrices[i];
196 bar.volume = 4.0;
197 bar.timestamp = static_cast<std::int64_t>(i) * kQuarter;
198 bars.push_back(bar);
199 }
200 return bars;
201}
202
203void print_rows(const std::vector<pineforge::NativeOpenLot>& rows, double mark) {
204 for (const auto& row : rows) {
205 std::printf(" lot %zu %-3s %-2s units=%+.2f entry=%.2f fee=%.2f "
206 "pnl@%.2f=%+.2f runup=%.2f drawdown=%.2f bar=%d cycle=%lld\n",
207 row.ordinal, row.entry_label.c_str(),
208 row.side == no::Side::Long ? "L" : "S",
209 row.signed_units, row.entry_price, row.entry_commission,
210 mark, row.unrealized_pnl, row.favorable_excursion,
211 row.adverse_excursion, row.entry_bar_index,
212 static_cast<long long>(row.cycle));
213 }
214}
215
216bool run_once(OpenLotsExample& host, pineforge::NativeReportPolicy policy) {
217 if (host.configure_native(make_spec(policy)).status
218 != pineforge::NativeSetupStatus::Applied) {
219 std::cerr << "configure: " << host.last_error() << '\n';
220 return false;
221 }
222 const std::vector<pineforge::Bar> bars = flat_bars();
223 host.run(bars.data(), static_cast<int>(bars.size()));
224 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
225 std::cerr << "run: " << host.last_error() << '\n';
226 return false;
227 }
228 return host.snaps.size() == static_cast<std::size_t>(kBarCount);
229}
230
231} // namespace
232
233int main() {
234 // The run this example reads: the kernel records one report point per
235 // script calculation, so the equity curve, the equity extremes and the
236 // position-size peaks are the kernel's. Recording books no cash and
237 // places no order — the run below proves the trades are the same.
238 OpenLotsExample host;
239 if (!run_once(host, pineforge::NativeReportPolicy::KernelRecorded)) {
240 std::cerr << "the KernelRecorded run did not complete with one snapshot per bar\n";
241 return 1;
242 }
243
244 // --- the invariants that hold at every calculation ----------------------
245 for (std::size_t k = 0; k < host.snaps.size(); ++k) {
246 const auto& snap = host.snaps[k];
247 check(snap.rows.size() == snap.lot_count,
248 "one row per lot of physical_position().lot_count");
249 double sum = 0.0;
250 for (std::size_t i = 0; i < snap.rows.size(); ++i) {
251 check(snap.rows[i].ordinal == i, "rows are ordinals 0..n-1, oldest first");
252 check(snap.rows[i].favorable_excursion >= 0.0
253 && snap.rows[i].adverse_excursion >= 0.0,
254 "both excursions are magnitudes");
255 sum += snap.rows[i].unrealized_pnl;
256 }
257 check(near(snap.marked_equity, snap.balance + sum),
258 "native_marked_equity(mark) == balance + sum(unrealized_pnl)");
259
260 // A NaN mark keeps the booking facts and computes nothing.
261 check(snap.unmarked.size() == snap.rows.size(), "a NaN mark still lists the book");
262 for (std::size_t i = 0; i < snap.unmarked.size(); ++i) {
263 check(snap.unmarked[i].entry_label == snap.rows[i].entry_label
264 && near(snap.unmarked[i].entry_price, snap.rows[i].entry_price)
265 && near(snap.unmarked[i].signed_units, snap.rows[i].signed_units)
266 && near(snap.unmarked[i].entry_commission, snap.rows[i].entry_commission),
267 "a NaN mark keeps every booking fact");
268 check(std::isnan(snap.unmarked[i].unrealized_pnl),
269 "a NaN mark leaves unrealized_pnl NaN");
270 }
271 (void)k;
272 }
273
274 // --- bar 5: the pyramided book, three lots, marked at 106 ---------------
275 const auto& full = host.snaps[5];
276 std::printf("open lots at bar 5 (mark %.2f), marked equity %.2f = balance %.2f + rows\n",
277 full.mark, full.marked_equity, full.balance);
278 print_rows(full.rows, full.mark);
279 check(full.rows.size() == 3, "three lots after three openings");
280 if (full.rows.size() == 3) {
281 const auto& a = full.rows[0];
282 const auto& b = full.rows[1];
283 const auto& c = full.rows[2];
284 check(a.entry_label == "L1" && a.entry_comment == "first"
285 && near(a.entry_price, 100.0) && near(a.signed_units, 1.0)
286 && near(a.entry_commission, kFee) && a.entry_bar_index == 1,
287 "lot 0 is L1: one unit at 100, its own 2.00 ticket, opened on bar 1");
288 check(b.entry_label == "L2" && near(b.entry_price, 102.0)
289 && near(b.signed_units, 2.0) && near(b.entry_commission, kFee),
290 "lot 1 is L2: two units at 102 on one ticket");
291 check(c.entry_label == "L3" && near(c.entry_price, 104.0)
292 && near(c.signed_units, 1.0) && near(c.entry_commission, kFee),
293 "lot 2 is L3: one unit at 104");
294 // (106 - entry) * units - the fee still on the lot.
295 check(near(a.unrealized_pnl, 4.0), "L1 marks +4.00 at 106 (6.00 gross less its 2.00 fee)");
296 check(near(b.unrealized_pnl, 6.0), "L2 marks +6.00 at 106");
297 check(near(c.unrealized_pnl, 0.0), "L3 marks 0.00 at 106");
298 check(a.side == no::Side::Long && a.entry_incarnation != 0
299 && a.cycle == b.cycle && b.cycle == c.cycle,
300 "one long position cycle, and every lot names the request that opened it");
301 }
302
303 // --- bar 7: after the FIFO partial, the fee share went with the slice ----
304 const auto& rest = host.snaps[7];
305 std::printf("open lots at bar 7 (mark %.2f), after Reduce 1.5 filled at 103.00\n", rest.mark);
306 print_rows(rest.rows, rest.mark);
307 check(rest.rows.size() == 2, "FIFO closed L1 whole and split L2");
308 if (rest.rows.size() == 2) {
309 check(rest.rows[0].entry_label == "L2" && near(rest.rows[0].signed_units, 1.5)
310 && near(rest.rows[0].entry_price, 102.0),
311 "the remainder of L2 is 1.5 units, still at its own entry price");
312 check(near(rest.rows[0].entry_commission, kFee * 0.75),
313 "the closed quarter took a quarter of L2's entry fee with it");
314 check(rest.rows[1].entry_label == "L3" && near(rest.rows[1].signed_units, 1.0)
315 && near(rest.rows[1].entry_commission, kFee),
316 "L3 is untouched");
317 }
318
319 // --- the report namespace, read through the protected accessors ---------
320 const auto& stats = host.statistics;
321 std::printf("report at the last calculation: equity=%.4f netprofit=%.4f "
322 "grossprofit=%.4f grossloss=%.4f openprofit=%.4f avg_trade=%.4f\n",
323 stats.equity, stats.netprofit, stats.grossprofit, stats.grossloss,
324 stats.openprofit, stats.avg_trade);
325 std::printf(" wintrades=%d losstrades=%d eventrades=%d "
326 "max_contracts_held=%.2f capital_held=%.2f position_size=%+.2f\n",
327 stats.wintrades, stats.losstrades, host.eventrades(),
328 host.max_contracts_held_all(), stats.capital_held, stats.position_size);
329 check(near(stats.netprofit, stats.grossprofit + stats.grossloss),
330 "netprofit is grossprofit plus grossloss (the loss term is signed)");
331 check(stats.wintrades + stats.losstrades + host.eventrades() == host.trade_count(),
332 "every closed row is a win, a loss or an even trade");
333 check(near(stats.contracts_held, 4.0),
334 "the book peaked at four units (1 + 2 + 1)");
335 check(near(stats.position_size, 2.5), "2.5 units are still open at the end");
336
337 // --- who folds the extremes: the report policy, and nothing else --------
338 //
339 // max_drawdown / max_runup / max_contracts_held_* are folded at the
340 // kernel's own report point, which exists only under
341 // NativeReportPolicy::KernelRecorded (or KernelRecordedAtHostMarks). The
342 // default HostRecorded leaves the whole report series to the host, so a
343 // bare host that never asks sees them at zero — and sees exactly the same
344 // trades, because recording is reporting.
345 OpenLotsExample bare;
346 if (!run_once(bare, pineforge::NativeReportPolicy::HostRecorded)) {
347 std::cerr << "the HostRecorded run did not complete with one snapshot per bar\n";
348 return 1;
349 }
350 const auto& bare_stats = bare.statistics;
351 std::printf("report policy: KernelRecorded folds max_drawdown=%.4f max_runup=%.4f "
352 "max_contracts_held=%.2f; HostRecorded folds %.4f / %.4f / %.2f\n",
353 stats.max_drawdown, stats.max_runup, stats.contracts_held,
354 bare_stats.max_drawdown, bare_stats.max_runup, bare_stats.contracts_held);
355 // The marked equity peaks at bar 5 and only falls after it, so this tape's
356 // run-up is legitimately zero and the drawdown is the whole fall.
357 check(near(stats.max_drawdown, 16.5) && near(stats.max_runup, 0.0)
358 && near(stats.contracts_held, 4.0),
359 "KernelRecorded folds the equity extremes and the position-size peak");
360 check(bare_stats.max_drawdown == 0.0 && bare_stats.max_runup == 0.0
361 && bare_stats.contracts_held == 0.0,
362 "HostRecorded folds none of them: that series is the host's");
363 check(bare.trade_count() == host.trade_count()
364 && near(bare_stats.netprofit, stats.netprofit),
365 "recording moves no fill: the two runs close the same trades for the same money");
366
367 if (failures != 0) {
368 std::cerr << failures << " check(s) failed\n";
369 return 1;
370 }
371 std::cout << "closed trades: " << host.trade_count() << '\n';
372 for (int i = 0; i < host.trade_count(); ++i) {
373 const auto& trade = host.get_trade(i);
374 std::cout << " " << (trade.is_long ? "long " : "short")
375 << " qty=" << trade.qty
376 << " entry=" << trade.entry_price
377 << " exit=" << trade.exit_price
378 << " pnl=" << trade.pnl
379 << " commission=" << trade.commission << '\n';
380 }
381 return host.trade_count() > 0 ? 0 : 1;
382}
The public native host: an abstract subclass of BacktestEngine with no PineScript on it.
static pf_native_run_spec_v1 make_spec(void)
@ kBarCount
NativeReportPolicy
Who records the per-script-bar report series.
static void submit(struct host_state *state, uint32_t intent, double value, uint32_t trigger, double price, const char *label)
double open
Definition bar.hpp:7
double close
Definition bar.hpp:7
double low
Definition bar.hpp:7
double volume
Definition bar.hpp:7
int64_t timestamp
Definition bar.hpp:8
double high
Definition bar.hpp:7
One complete setup value, staged/copied by NativeStrategyHost before it is applied at begin.