PineForge v1.4.0-4-g873b25da
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_fee_reserve_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: a cash-sized opening that reserves its own
2// commission (Sized::reserve_percent_fee), from a JPY account trading a
3// USD-quoted stock.
4//
5// The account holds 3,000,000 JPY. The stock is quoted in USD, converted at a
6// scalar account_fx of 150 JPY per USD, on a 0.05 tick ladder with whole-share
7// lots (quantity_grid 1). The broker charges a percent of the account notional
8// on every execution: fee_kind Percent with fee_value 0.1, which is a PERCENT
9// -- 0.1 %, not 10 %.
10//
11// The host names what the opening is worth, 1,497,700 JPY, and the kernel
12// resolves the shares once, at acceptance (SizeTime::AtAcceptance), against
13// the decision close on the instrument's own ladder (SizePrice::SignalOnTick:
14// 212.33 -> 212.35), so one share costs 212.35 * 150 = 31,852.50 JPY:
15//
16// without the reserve 1,497,700 / 31,852.50 = 47.02 -> 47 shares
17// with the reserve 1,497,700 / 1.001 / 31,852.50 = 46.97 -> 46 shares
18//
19// 47 shares are 1,497,067.50 JPY of notional and pay a 1,497.07 JPY entry fee
20// on top: 864.57 JPY more than the cash the host named. The reserve divides
21// the cash by (1 + fee) first, so the shares AND the commission they pay fit
22// inside it -- here that costs one share. native_sized_units() answers the
23// same resolution as a pure query, before anything is submitted.
24//
25// Both runs then trade the same tape -- in at 212.45, out at 213.05 -- and
26// every leg pays 0.1 % of its own account notional:
27//
28// 46 shares entry 1,465.905 exit 1,470.045 commission 2,935.950
29// gross (213.05 - 212.45) * 46 * 150 = 4,140 -> net 1,204.050
30// 47 shares entry 1,497.7725 exit 1,502.0025 commission 2,999.775
31// gross (213.05 - 212.45) * 47 * 150 = 4,230 -> net 1,230.225
32//
33// Each fill reports the commission it booked (ExecutionAppliedEvent::
34// current_ticket, account currency); the open lot carries the entry leg's
35// (NativeOpenLot::entry_commission); the closed row carries both.
36//
37// Under any other fee kind the reserve is an exact no-op: a CashPerExecution
38// broker's ticket is not a fraction of the notional, so there is nothing to
39// divide by.
40//
41// c++ -std=c++17 native_fee_reserve_strategy.cpp -lpineforge_kernel -o fee_reserve
42//
43// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
44
46
47#include <cassert>
48#include <cmath>
49#include <cstdio>
50#include <iostream>
51#include <optional>
52
53namespace {
54
55namespace no = pineforge::native_order;
56
57constexpr double kCash = 1497700.0; // JPY the host spends on the opening
58constexpr double kFx = 150.0; // JPY per USD
59constexpr double kFeePercent = 0.1; // 0.1 % of the account notional
60constexpr double kSizingPrice = 212.35; // the 212.33 close on the 0.05 ladder
61constexpr double kEntryPrice = 212.45; // the next bar's open
62constexpr double kExitPrice = 213.05; // the open after the flatten
63
64// Money is compared to a relative 1e-9: binary64 carries the JPY amounts
65// above to about 1e-10 of their size, and nothing here rounds to a currency.
66bool money(double value, double expected) {
67 return std::isfinite(value) && std::fabs(value - expected) <= 1e-9 * std::fabs(expected);
68}
69
70// The commission one leg of `units` shares pays at `price`, in JPY.
71double leg_fee(double units, double price) {
72 return units * price * kFx * kFeePercent / 100.0;
73}
74
75no::Sized cash_opening(bool reserve) {
76 no::Sized sized;
77 sized.side = no::Side::Long;
78 sized.basis = no::CashValue{kCash};
79 sized.time = no::SizeTime::AtAcceptance;
80 sized.price = no::SizePrice::SignalOnTick;
81 sized.reserve_percent_fee = reserve;
82 return sized;
83}
84
85class FeeReserveExample : public pineforge::NativeStrategyHost {
86public:
87 explicit FeeReserveExample(bool reserve) : reserve_(reserve) {}
88
89 // Read back by main().
90 std::optional<double> preview_reserved; // native_sized_units, reserve on
91 std::optional<double> preview_plain; // native_sized_units, reserve off
92 double entry_units = 0.0;
93 double entry_price = 0.0;
94 double entry_ticket = 0.0; // the entry leg's commission
95 double exit_ticket = 0.0; // the exit leg's commission
96 double lot_entry_commission = 0.0; // what the open lot still carries
97
98private:
99 bool reserve_ = false;
100 int bar_ = -1;
101 std::optional<no::RequestHandle> entry_;
102 std::optional<no::RequestHandle> exit_;
103
104 void on_native_run_begin() override { bar_ = -1; }
105
106 void on_native_bar(const pineforge::Bar& bar,
107 const pineforge::NativeDecisionContext&) override {
108 ++bar_;
109 if (bar_ == 0) {
110 // The kernel's own resolution as a query, both ways, at the
111 // sizing price the submit below freezes. The equity argument is
112 // only read by an EquityFraction basis.
113 const double equity = native_marked_equity(bar.close);
114 preview_reserved = native_sized_units(cash_opening(true), kSizingPrice, equity, kFx);
115 preview_plain = native_sized_units(cash_opening(false), kSizingPrice, equity, kFx);
116 entry_ = submit({cash_opening(reserve_), "entry", "fee-reserve"}).handle;
117 } else if (bar_ == 2) {
118 // The lot the entry opened still carries the entry leg's fee.
119 const auto lots = native_open_lots(bar.close);
120 assert(lots.size() == 1);
121 lot_entry_commission = lots[0].entry_commission;
122 exit_ = submit({no::Flatten{}, "exit", "fee-reserve"}).handle;
123 }
124 }
125
126 void on_native_applied(const no::ExecutionAppliedEvent& event,
127 const pineforge::NativeDecisionContext&) override {
128 if (entry_ && event.handle() == *entry_) {
129 entry_units = event.opened_units;
130 entry_price = event.resolved_price;
131 entry_ticket = event.current_ticket;
132 } else if (exit_ && event.handle() == *exit_) {
133 exit_ticket = event.current_ticket;
134 }
135 }
136};
137
140 spec.identity.session_key = "native-fee-reserve-example";
141 spec.identity.run_number = 1;
142 spec.input_tf = "15";
143 spec.script_tf = "15";
144 spec.ticker = "ACME";
145 spec.tickerid = "TEST:ACME";
146 spec.type = "stock";
147 spec.currency = "USD"; // the instrument's quote currency
148 spec.basecurrency = "";
149 spec.timezone = "UTC";
150 spec.session = "24x7";
151 spec.initial_capital = 3000000.0; // the account, in JPY
152 spec.point_value = 1.0;
153 spec.account_fx = kFx; // JPY per USD: quote -> account
154 spec.price_tick = 0.05;
155 spec.fee_kind = fee_kind;
156 spec.fee_value = fee_value;
157 spec.quantity_grid = 1.0; // whole shares: the sized units floor onto it
158 return spec;
159}
160
161constexpr std::int64_t kQuarter = 15LL * 60LL * 1000LL;
162
163// open, high, low, close, volume, timestamp (Unix milliseconds).
164const pineforge::Bar kBars[] = {
165 {212.12, 212.40, 211.95, 212.33, 900.0, 0 * kQuarter}, // decide: size and submit
166 {212.45, 212.70, 212.30, 212.62, 1200.0, 1 * kQuarter}, // entry fills at the open
167 {212.62, 212.95, 212.55, 212.90, 1500.0, 2 * kQuarter}, // read the lot, flatten
168 {213.05, 213.35, 212.80, 213.20, 1800.0, 3 * kQuarter}, // exit fills at the open
169};
170constexpr int kBarCount = 4;
171
172// What one run leaves behind: its closed row and how many it closed.
173struct Outcome {
174 pineforge::Trade trade;
175 int closed_trades = 0;
176};
177
178// One run over the tape; everything it asserts is printed first.
179Outcome run_once(bool reserve) {
180 FeeReserveExample host(reserve);
181 const auto setup = host.configure_native(make_spec(pineforge::NativeFeeKind::Percent,
182 kFeePercent));
183 assert(setup.status == pineforge::NativeSetupStatus::Applied);
184 host.run(kBars, kBarCount);
185 assert(host.native_state().kind == pineforge::NativeLifecycleKind::Completed);
186
187 const double units = reserve ? 46.0 : 47.0;
188 std::printf("%s: preview %g / %g shares (reserve / none); filled %g @ %.2f\n",
189 reserve ? "reserve" : "no reserve",
190 host.preview_reserved.value_or(-1.0), host.preview_plain.value_or(-1.0),
191 host.entry_units, host.entry_price);
192 assert(host.preview_reserved == 46.0 && host.preview_plain == 47.0);
193 assert(host.entry_units == units && host.entry_price == kEntryPrice);
194
195 // The commission, leg by leg: what each fill booked, what the open lot
196 // kept of the entry's, and what the closed row sums.
197 assert(host.trade_count() == 1);
198 const pineforge::Trade trade = host.get_trade(0);
199 std::printf(" entry fee %.4f (lot keeps %.4f) exit fee %.4f row commission %.4f "
200 "net %.4f\n",
201 host.entry_ticket, host.lot_entry_commission, host.exit_ticket,
202 trade.commission, trade.pnl);
203 assert(money(host.entry_ticket, leg_fee(units, kEntryPrice)));
204 assert(money(host.lot_entry_commission, host.entry_ticket));
205 assert(money(host.exit_ticket, leg_fee(units, kExitPrice)));
206 assert(money(trade.commission, leg_fee(units, kEntryPrice) + leg_fee(units, kExitPrice)));
207 assert(money(trade.commission, reserve ? 2935.95 : 2999.775));
208 assert(trade.qty == units && trade.entry_price == kEntryPrice
209 && trade.exit_price == kExitPrice);
210 assert(money(trade.pnl, (kExitPrice - kEntryPrice) * units * kFx - trade.commission));
211 assert(money(trade.pnl, reserve ? 1204.05 : 1230.225));
212 return {trade, host.trade_count()};
213}
214
215} // namespace
216
217int main() {
218 // The arithmetic the reserve exists for: 47 shares at the sizing price
219 // overspend the cash once their own fee is paid; 46 do not.
220 const double share = kSizingPrice * kFx;
221 const double with_fee = 1.0 + kFeePercent / 100.0;
222 std::printf("one share %.2f JPY; 47 shares + fee %.4f, 46 shares + fee %.4f, cash %.2f\n",
223 share, 47.0 * share * with_fee, 46.0 * share * with_fee, kCash);
224 assert(47.0 * share * with_fee > kCash && 46.0 * share * with_fee <= kCash);
225
226 const Outcome reserved = run_once(true);
227 const Outcome plain = run_once(false);
228 // Same tape, same prices: only the share count -- and with it every
229 // money figure -- differs.
230 assert(reserved.closed_trades == plain.closed_trades);
231 assert(reserved.trade.entry_time == plain.trade.entry_time
232 && reserved.trade.exit_time == plain.trade.exit_time);
233
234 // Any other fee kind: the reserve changes nothing. A 250 JPY ticket per
235 // execution is not a fraction of the notional, so the query resolves the
236 // same 47 shares with the flag on as off.
237 FeeReserveExample cash_ticket(true);
238 const auto setup = cash_ticket.configure_native(
239 make_spec(pineforge::NativeFeeKind::CashPerExecution, 250.0));
240 assert(setup.status == pineforge::NativeSetupStatus::Applied);
241 const auto flat_fee_units =
242 cash_ticket.native_sized_units(cash_opening(true), kSizingPrice, kCash, kFx);
243 std::printf("CashPerExecution: reserve on -> %g shares\n", flat_fee_units.value_or(-1.0));
244 assert(flat_fee_units == 47.0);
245
246 // The summary line, printed only once every check above has passed.
247 std::printf("reserve %g shares, none %g commission %.4f / %.4f closed trades: %d per run\n",
248 reserved.trade.qty, plain.trade.qty, reserved.trade.commission,
249 plain.trade.commission, reserved.closed_trades);
250 return 0;
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
NativeFeeKind
Encodings coincide with the versioned native-v1 C transport.
static void submit(struct host_state *state, uint32_t intent, double value, uint32_t trigger, double price, const char *label)
double close
Definition bar.hpp:7
double commission
Definition engine.hpp:201
int64_t exit_time
Definition engine.hpp:174
double exit_price
Definition engine.hpp:176
int64_t entry_time
Definition engine.hpp:173
double entry_price
Definition engine.hpp:175
One complete setup value, staged/copied by NativeStrategyHost before it is applied at begin.