PineForge v1.4.0-4-g873b25da
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_fx_roll_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: an account-currency FX curve, and the margin
2// check point a step of it produces (NativeFxCurve, NativeMarginCheckKind::
3// FxRoll).
4//
5// A JPY account (3,000,000 JPY) holds 750 shares of a USD-quoted stock on a
6// maintenance-only margin model: no opening requirement on the long side,
7// a 10 % maintenance requirement, liquidation by Flatten at the solved level.
8// Every money figure converts quote -> account at the rate in force, and the
9// run declares that rate as a curve rather than one scalar:
10//
11// before the first point 150 JPY/USD (NativeRunSpec::account_fx)
12// at bar 2's open 150 restates the rate: NOT a step
13// at bar 4's open 160 a step: the yen weakens
14//
15// The requirement carries the rate -- required(P) = Q * P * fx * 10 % -- so the
16// step moves the account toward its maintenance line while no price moves.
17// The kernel therefore offers the step as a check point of its own, FxRoll,
18// immediately before the first driver point that converts at the new rate. A
19// curve point stamped at a bar's OPEN activates at the PREVIOUS bar's Close
20// coordinate, whose effective time is that same instant: bar 3's Close
21// segment, standing at its Low 212.90 with only the Close 213.00 left to walk,
22// so the mark (the most adverse price a long still reaches) is 212.90.
23//
24// The liquidation level is the price where the marked equity meets the
25// requirement. With K = 3,000,000 - 23,968.125 (the entry fee, paid at 150):
26//
27// L(fx) = (213.05 * 750 * fx - K) / (750 * fx * 0.9)
28// L(150) = 207.3293... L(160) = 209.1663...
29//
30// Bar 5 crashes to 208.40: through L(160), not through L(150). With the step
31// the kernel liquidates the whole position at L(160); the same run with a
32// curve that never steps (only the restating point) offers no FxRoll point at
33// all and liquidates nothing. The rate moved the line, not the price.
34//
35// c++ -std=c++17 native_fx_roll_strategy.cpp -lpineforge_kernel -o fx_roll
36//
37// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
38
40
41#include <cassert>
42#include <cmath>
43#include <cstdio>
44#include <iostream>
45#include <optional>
46#include <vector>
47
48namespace {
49
50namespace no = pineforge::native_order;
51
52constexpr std::int64_t kQuarter = 15LL * 60LL * 1000LL;
53constexpr double kUnits = 750.0;
54constexpr double kEntryPrice = 213.05;
55constexpr double kFeePercent = 0.1; // 0.1 % of the account notional
56constexpr double kCapital = 3000000.0; // JPY
57constexpr double kMaintenance = 0.10;
58
59// The account after the entry: the capital less the entry fee, paid at 150.
60constexpr double kBase = kCapital - kUnits * kEntryPrice * 150.0 * kFeePercent / 100.0;
61
62// The hand-solved liquidation level at an account rate.
63double level(double fx) {
64 return (kEntryPrice * kUnits * fx - kBase) / (kUnits * fx * (1.0 - kMaintenance));
65}
66
67bool near(double value, double expected, double tolerance) {
68 return std::isfinite(value) && std::fabs(value - expected) <= tolerance;
69}
70
71class FxRollExample : public pineforge::NativeStrategyHost {
72public:
73 // What every check point offered looked like: margin_check_allowed and
74 // resolve_margin_requirement are const, so the record is mutable.
75 struct Offered {
76 pineforge::NativeMarginCheckKind kind = pineforge::NativeMarginCheckKind::BarOpen;
77 double units = 0.0;
78 std::size_t lots = 0;
79 double mark = 0.0;
80 std::int64_t time_ms = 0;
81 pineforge::NativePathPhase phase = pineforge::NativePathPhase::None;
82 int interval_index = -1;
83 };
84 mutable std::vector<Offered> offered;
85 mutable std::optional<pineforge::NativeMarginRequirementView> roll_view;
86 std::optional<double> level_bar2; // native_liquidation_price() at bar 2
87 std::optional<double> level_bar4; // ... and at bar 4, after the step
88 std::optional<no::MarginCallEvent> margin_call;
89
90private:
91 int bar_ = -1;
92
93 void on_native_run_begin() override { bar_ = -1; }
94
95 void on_native_bar(const pineforge::Bar&,
96 const pineforge::NativeDecisionContext&) override {
97 ++bar_;
98 if (bar_ == 0) submit({no::Transact{kUnits}, "entry", "fx-roll"});
99 if (bar_ == 2) level_bar2 = native_liquidation_price();
100 if (bar_ == 4) level_bar4 = native_liquidation_price();
101 }
102
103 bool margin_check_allowed(const pineforge::NativeMarginCheckPoint& point) const override {
104 Offered row;
105 row.kind = point.kind;
106 row.units = point.position.signed_units;
107 row.lots = point.position.lot_count;
108 row.mark = point.mark;
109 row.time_ms = point.cursor.point.effective_time_ms;
110 row.phase = point.cursor.point.path_phase;
111 row.interval_index = point.cursor.point.interval_index;
112 offered.push_back(row);
113 return true;
114 }
115
116 // The numbers the kernel is about to compare at the roll: both at the NEW
117 // rate. nullopt keeps the kernel's own.
118 std::optional<pineforge::NativeMarginDecision> resolve_margin_requirement(
119 const pineforge::NativeMarginRequirementView& view) const override {
120 if (view.kind == pineforge::NativeMarginCheckKind::FxRoll && !roll_view) roll_view = view;
121 return std::nullopt;
122 }
123
124 void on_native_margin_call(const no::MarginCallEvent& event) override {
125 margin_call = event;
126 }
127};
128
131 spec.identity.session_key = "native-fx-roll-example";
132 spec.identity.run_number = 1;
133 spec.input_tf = "15";
134 spec.script_tf = "15";
135 spec.ticker = "ACME";
136 spec.tickerid = "TEST:ACME";
137 spec.type = "stock";
138 spec.currency = "USD"; // the instrument's quote currency
139 spec.basecurrency = "";
140 spec.timezone = "UTC";
141 spec.session = "24x7";
142 spec.initial_capital = kCapital; // the account, in JPY
143 spec.point_value = 1.0;
144 spec.account_fx = 150.0; // JPY per USD before the curve's first point
145 spec.price_tick = 0.05;
146 spec.fee_kind = pineforge::NativeFeeKind::Percent;
147 spec.fee_value = kFeePercent; // a percent: 0.1 %
148 spec.quantity_grid = 1.0;
149
150 // Maintenance-only on the long side: initial_long 0.0 leaves opening
151 // admission to the host. Each side must declare a requirement, so the
152 // short side keeps an ordinary one.
154 margin.initial_long = 0.0;
155 margin.initial_short = 0.5;
156 margin.maintenance_long = kMaintenance;
157 margin.sizing = pineforge::NativeLiquidationSizing::Flatten;
158 margin.check = pineforge::NativeLiquidationCheck::PathAdverseExtreme;
159 margin.liquidation_label = "margin-call";
160 margin.liquidation_comment = "maintenance 10%";
161 spec.margin = margin;
162 return spec;
163}
164
165// open, high, low, close, volume, timestamp (Unix milliseconds).
166const pineforge::Bar kBars[] = {
167 {212.95, 213.10, 212.85, 213.02, 900.0, 0 * kQuarter}, // submit 750 shares
168 {213.05, 213.20, 212.90, 213.10, 1200.0, 1 * kQuarter}, // filled at 213.05
169 {213.10, 213.25, 212.95, 213.15, 900.0, 2 * kQuarter}, // the restating point
170 {213.15, 213.30, 212.90, 213.00, 900.0, 3 * kQuarter}, // its Close: the roll
171 {213.00, 213.10, 212.75, 212.80, 1200.0, 4 * kQuarter}, // 160 from this open
172 {212.60, 212.70, 208.40, 208.90, 3000.0, 5 * kQuarter}, // the crash
173 {208.90, 209.20, 208.60, 209.00, 900.0, 6 * kQuarter},
174};
175constexpr int kBarCount = 7;
176
177pineforge::NativeFxCurve curve(bool step) {
179 fx.effective_from_ms.push_back(2 * kQuarter);
180 fx.account_per_quote.push_back(150.0);
181 if (step) {
182 fx.effective_from_ms.push_back(4 * kQuarter);
183 fx.account_per_quote.push_back(160.0);
184 }
185 return fx;
186}
187
188std::vector<FxRollExample::Offered> rolls_of(const FxRollExample& host) {
189 std::vector<FxRollExample::Offered> rolls;
190 for (const auto& row : host.offered) {
191 if (row.kind == pineforge::NativeMarginCheckKind::FxRoll) rolls.push_back(row);
192 }
193 return rolls;
194}
195
196// Configure, stage the curve while the host is Ready, run.
197void run(FxRollExample& host, bool step) {
198 const auto setup = host.configure_native(make_spec());
199 assert(setup.status == pineforge::NativeSetupStatus::Applied);
200 const auto staged = host.configure_native_fx_curve(curve(step));
201 assert(staged.status == pineforge::NativeSetupStatus::Applied);
202 host.run(kBars, kBarCount);
203 assert(host.native_state().kind == pineforge::NativeLifecycleKind::Completed);
204}
205
206} // namespace
207
208int main() {
209 // --- the curve steps 150 -> 160 ------------------------------------------
210 FxRollExample stepped;
211 run(stepped, true);
212
213 // One FxRoll point: the restating point at bar 2 is not a step.
214 const auto rolls = rolls_of(stepped);
215 std::printf("FxRoll points: %zu\n", rolls.size());
216 assert(rolls.size() == 1);
217 const auto& roll = rolls[0];
218 std::printf(" at t=%lld (bar 4's open), bar %d's %s: %g units in %zu lot, mark %.2f\n",
219 static_cast<long long>(roll.time_ms), roll.interval_index,
220 roll.phase == pineforge::NativePathPhase::Close ? "Close" : "?",
221 roll.units, roll.lots, roll.mark);
222 assert(roll.time_ms == 4 * kQuarter && roll.interval_index == 3
223 && roll.phase == pineforge::NativePathPhase::Close);
224 assert(roll.units == kUnits && roll.lots == 1 && roll.mark == 212.90);
225
226 // The requirement view at the roll converts at the NEW rate:
227 // equity = K + (212.90 - 213.05) * 750 * 160 = K - 18,000
228 // required = 750 * 212.90 * 160 * 10 % = 2,554,800
229 // No breach yet: the step moved the line, it did not cross it.
230 assert(stepped.roll_view.has_value());
231 std::printf(" requirement at the roll: equity %.4f, required %.4f\n",
232 stepped.roll_view->equity, stepped.roll_view->required);
233 assert(near(stepped.roll_view->equity, kBase - 18000.0, 1e-6));
234 assert(near(stepped.roll_view->required, kUnits * 212.90 * 160.0 * kMaintenance, 1e-6));
235 assert(stepped.roll_view->equity > stepped.roll_view->required);
236
237 // The level moved with the rate.
238 std::printf("liquidation level: bar 2 %.6f (hand %.6f at 150), bar 4 %.6f (hand %.6f at 160)\n",
239 stepped.level_bar2.value_or(-1.0), level(150.0),
240 stepped.level_bar4.value_or(-1.0), level(160.0));
241 assert(stepped.level_bar2 && near(*stepped.level_bar2, level(150.0), 1e-9));
242 assert(stepped.level_bar4 && near(*stepped.level_bar4, level(160.0), 1e-9));
243
244 // Bar 5 crosses L(160): the kernel flattens the book at the level, under
245 // the model's ticket, and books the loss and the exit fee at 160.
246 assert(stepped.margin_call.has_value());
247 const auto& call = *stepped.margin_call;
248 std::printf("margin call: %g units at %.6f, position %g -> %g, ticket %s\n", call.units,
249 call.mark, call.position_before, call.position_after,
250 call.request().label.c_str());
251 assert(call.units == kUnits && call.position_before == kUnits && call.position_after == 0.0);
252 assert(near(call.mark, level(160.0), 1e-9) && call.request().label == "margin-call");
253 assert(stepped.trade_count() == 1);
254 const auto& trade = stepped.get_trade(0);
255 const double entry_fee = kUnits * kEntryPrice * 150.0 * kFeePercent / 100.0;
256 const double exit_fee = kUnits * trade.exit_price * 160.0 * kFeePercent / 100.0;
257 std::printf(" row: %s -> %s, commission %.4f, pnl %.4f\n", trade.entry_id.c_str(),
258 trade.exit_id.c_str(), trade.commission, trade.pnl);
259 assert(trade.exit_id == "margin-call" && trade.exit_price == call.mark);
260 assert(near(trade.commission, entry_fee + exit_fee, 1e-6));
261 assert(near(trade.pnl, (trade.exit_price - kEntryPrice) * kUnits * 160.0 - entry_fee - exit_fee,
262 1e-6));
263
264 // --- the same run, the curve never steps ---------------------------------
265 FxRollExample flat;
266 run(flat, false);
267 std::printf("no step: FxRoll points %zu, level at bar 4 %.6f, margin call %s, closed trades %d\n",
268 rolls_of(flat).size(), flat.level_bar4.value_or(-1.0),
269 flat.margin_call ? "yes" : "none", flat.trade_count());
270 assert(rolls_of(flat).empty());
271 assert(flat.level_bar4 && near(*flat.level_bar4, level(150.0), 1e-9));
272 assert(!flat.margin_call.has_value() && flat.trade_count() == 0);
273 assert(flat.physical_position().signed_units == kUnits);
274
275 // The summary line, printed only once every check above has passed.
276 std::printf("FxRoll units=%g mark=%.2f no-step FxRoll points: %zu, margin calls: %d "
277 "closed trades: %d\n",
278 roll.units, roll.mark, rolls_of(flat).size(), flat.margin_call ? 1 : 0,
279 stepped.trade_count());
280 return 0;
281}
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
NativeMarginCheckKind
Which kernel check point is about to test the maintenance requirement.
NativePathPhase
Which leg of a modeled OHLC walk a point sits on.
static void submit(struct host_state *state, uint32_t intent, double value, uint32_t trigger, double price, const char *label)
An immutable account-currency FX curve: parallel arrays of effective-from timestamps in epoch millise...
A generic per-side broker margin model (L4).
One complete setup value, staged/copied by NativeStrategyHost before it is applied at begin.
std::optional< NativeMarginModel > margin
Opt-in generic margin model.