PineForge v0.13.1-379-g9b50973
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_margin_strategy.cpp
Go to the documentation of this file.
1// Pine-free native example: a generic margin model with a kernel-issued
2// liquidation, and the host hooks around it (R5 lanes L4 and L4b).
3//
4// The run spec declares the broker's margin model: a 20 % initial requirement
5// on a long (5x leverage) and a 15 % maintenance requirement. From there the
6// kernel
7// * gates every opening on the initial requirement: 45 units at 100.00 need
8// 900 of the 1000 account and are admitted; a further 100 units would need
9// 2900 and are refused at every matching candidate
10// (MatchRejectReason::InitialMargin), never reaching the book;
11// * solves the live position's liquidation price on request,
12// native_liquidation_price(): 1000 + 45 (P - 100) = 0.15 * 45 P, so
13// P = 3500 / 38.25 = 91.5033;
14// * tests the maintenance requirement at its own check points and, on the
15// adverse path that breaches it, rests and fills its own liquidation
16// request at that level -- sized here by NativeLiquidationSizing::Flatten
17// -- and reports it as a MarginCallEvent whose closed row carries the
18// model's liquidation_label.
19// The L4b hooks are the policy seams around that mechanism:
20// * margin_check_allowed sees every check point before it runs; a broker
21// that does not check there answers false (this host admits all of them
22// and counts their kinds);
23// * resolve_margin_requirement is offered the two numbers of the breach
24// test and may replace them with its own money rule (this host rounds
25// both to cents, which changes nothing here);
26// * resolve_margin_call_units has the last word on the slice (nullopt keeps
27// the spec's sizing policy).
28//
29// c++ -std=c++17 native_margin_strategy.cpp -lpineforge_kernel -o margin
30//
31// Nothing here is PineScript: no codegen, no `src/source`, no `src/compat`.
32
34
35#include <cmath>
36#include <cstdio>
37#include <iostream>
38#include <optional>
39#include <variant>
40
41namespace {
42
43namespace no = pineforge::native_order;
44
45double cents(double value) { return std::round(value * 100.0) / 100.0; }
46
47class MarginExample : public pineforge::NativeStrategyHost {
48public:
49 std::optional<double> liquidation_price_after_fill;
50 std::optional<no::MarginCallEvent> margin_call;
51 int checks_bar_open = 0;
52 int checks_after_applied = 0;
53 int requirement_views = 0;
54 double last_required = 0.0;
55 double last_equity = 0.0;
56 std::optional<no::RequestHandle> entry;
57 std::optional<no::RequestHandle> over_leveraged;
58
59private:
60 int bars_ = 0;
61
62 void on_native_run_begin() override { bars_ = 0; }
63
64 void on_native_bar(const pineforge::Bar&,
65 const pineforge::NativeDecisionContext&) override {
66 ++bars_;
67 if (bars_ == 1) {
68 // Admitted: 45 * 100.00 * 20 % = 900 <= 1000 of equity.
69 entry = submit({no::Transact{45.0}, "entry", "margin"}).handle;
70 } else if (bars_ == 2) {
71 // Refused by the kernel's opening gate at every candidate: the
72 // resulting book would need 145 * P * 20 % of initial margin.
73 over_leveraged = submit({no::Transact{100.0}, "over-leveraged", "margin"}).handle;
74 }
75 }
76
77 // L4b: the kernel offers each check point before evaluating it.
78 bool margin_check_allowed(const pineforge::NativeMarginCheckPoint& point) const override {
79 auto* self = const_cast<MarginExample*>(this);
80 if (point.kind == pineforge::NativeMarginCheckKind::BarOpen) ++self->checks_bar_open;
81 if (point.kind == pineforge::NativeMarginCheckKind::AfterApplied) ++self->checks_after_applied;
82 return true;
83 }
84
85 // L4b: the two numbers of the breach test, before it runs. A broker's
86 // money rule goes here; rounding both to cents leaves this run unchanged.
87 std::optional<pineforge::NativeMarginDecision> resolve_margin_requirement(
88 const pineforge::NativeMarginRequirementView& view) const override {
89 auto* self = const_cast<MarginExample*>(this);
90 ++self->requirement_views;
91 self->last_required = view.required;
92 self->last_equity = view.equity;
93 pineforge::NativeMarginDecision decision;
94 decision.required = cents(view.required);
95 decision.equity = cents(view.equity);
96 return decision;
97 }
98
99 void on_native_applied(const no::ExecutionAppliedEvent& event,
100 const pineforge::NativeDecisionContext&) override {
101 if (entry && event.handle() == *entry) {
102 // The position is live: where does it run out of margin?
103 liquidation_price_after_fill = native_liquidation_price();
104 }
105 }
106
107 void on_native_margin_call(const no::MarginCallEvent& event) override {
108 margin_call = event;
109 }
110};
111
114 spec.identity.session_key = "native-margin-example";
115 spec.identity.run_number = 1;
116 spec.input_tf = "15";
117 spec.script_tf = "15";
118 spec.ticker = "MOCK";
119 spec.tickerid = "TEST:MOCK";
120 spec.type = "crypto";
121 spec.currency = "USDT";
122 spec.basecurrency = "ETH";
123 spec.timezone = "UTC";
124 spec.session = "24x7";
125 spec.initial_capital = 1000.0;
126 spec.point_value = 1.0;
127 spec.account_fx = 1.0;
128 spec.price_tick = 0.25;
129 spec.fee_kind = pineforge::NativeFeeKind::Percent;
130 spec.fee_value = 0.0;
131
132 // The broker's margin model. initial_* gate openings; maintenance_* is
133 // what the kernel liquidates on. Everything below is generic: which
134 // multiple, which check mode and which ticket are the host's choice.
136 margin.initial_long = 0.20;
137 margin.initial_short = 0.20;
138 margin.maintenance_long = 0.15;
139 margin.maintenance_short = 0.15;
140 margin.sizing = pineforge::NativeLiquidationSizing::Flatten;
141 margin.check = pineforge::NativeLiquidationCheck::PathAdverseExtreme;
142 margin.liquidation_label = "liquidation";
143 margin.liquidation_comment = "maintenance breached";
144 spec.margin = margin;
145 return spec;
146}
147
148constexpr std::int64_t kQuarter = 15LL * 60LL * 1000LL;
149
150// open, high, low, close, volume, timestamp (Unix milliseconds). The entry
151// fills at 100.00 on the second bar's open; the crash on the sixth bar runs
152// through the solved liquidation level.
153const pineforge::Bar kBars[] = {
154 { 99.75, 100.25, 99.50, 100.00, 10.0, 0 * kQuarter},
155 {100.00, 101.00, 99.75, 100.75, 10.0, 1 * kQuarter},
156 {100.75, 102.00, 100.50, 101.50, 10.0, 2 * kQuarter},
157 {101.50, 102.25, 101.00, 101.25, 10.0, 3 * kQuarter},
158 {101.25, 101.50, 96.00, 96.50, 10.0, 4 * kQuarter},
159 { 96.50, 96.75, 88.00, 89.00, 10.0, 5 * kQuarter},
160 { 89.00, 90.00, 88.50, 89.50, 10.0, 6 * kQuarter},
161 { 89.50, 91.00, 89.25, 90.75, 10.0, 7 * kQuarter},
162};
163constexpr int kBarCount = 8;
164
165} // namespace
166
167int main() {
168 MarginExample host;
169 if (host.configure_native(make_spec()).status
170 != pineforge::NativeSetupStatus::Applied) {
171 std::cerr << "configure: " << host.last_error() << '\n';
172 return 1;
173 }
174
175 host.run(kBars, kBarCount);
176 if (host.native_state().kind != pineforge::NativeLifecycleKind::Completed) {
177 std::cerr << "run: " << host.last_error() << '\n';
178 return 1;
179 }
180
181 // --- the opening gate: the second request never reached the book -------
182 int refused_on_margin = 0;
183 for (const auto& event : host.native_events(0)) {
184 if (!event.command) continue;
185 const auto* rejected = std::get_if<no::MatchRejectedEvent>(&*event.command);
186 if (rejected && host.over_leveraged && rejected->handle() == *host.over_leveraged
187 && rejected->reason == no::MatchRejectReason::InitialMargin) {
188 ++refused_on_margin;
189 }
190 }
191 std::printf("over-leveraged opening refused on initial margin at %d candidate(s)\n",
192 refused_on_margin);
193 if (refused_on_margin == 0) {
194 std::cerr << "expected the kernel's opening gate to refuse the 100-unit request\n";
195 return 1;
196 }
197
198 // --- the solved liquidation price -------------------------------------
199 if (!host.liquidation_price_after_fill) {
200 std::cerr << "native_liquidation_price() had no answer after the fill\n";
201 return 1;
202 }
203 std::printf("liquidation price after the fill: %.4f (hand: 3500 / 38.25 = %.4f)\n",
204 *host.liquidation_price_after_fill, 3500.0 / 38.25);
205 if (std::fabs(*host.liquidation_price_after_fill - 3500.0 / 38.25) > 1e-6) {
206 std::cerr << "solved level differs from the hand computation\n";
207 return 1;
208 }
209
210 // --- the hooks were consulted -----------------------------------------
211 std::printf("check points offered: BarOpen=%d AfterApplied=%d; requirement views=%d "
212 "(last: required %.4f vs equity %.4f)\n",
213 host.checks_bar_open, host.checks_after_applied, host.requirement_views,
214 host.last_required, host.last_equity);
215 if (host.checks_bar_open == 0 || host.checks_after_applied == 0 || host.requirement_views == 0) {
216 std::cerr << "expected the margin hooks to be consulted\n";
217 return 1;
218 }
219
220 // --- the liquidation ---------------------------------------------------
221 if (!host.margin_call) {
222 std::cerr << "expected a kernel-issued liquidation on the adverse path\n";
223 return 1;
224 }
225 // The event carries the margin facts of the fill: `mark` is the booked
226 // price; `equity`, `required` and `liquidation_price` describe the book
227 // that SURVIVES the reduction -- flat here, so the requirement is 0 and
228 // no level is left to solve.
229 const auto& call = *host.margin_call;
230 std::printf("margin call: %.4f units booked at %.4f, position %.4f -> %.4f, ticket %s; "
231 "surviving book: equity %.4f, required %.4f, level %.4f\n",
232 call.units, call.mark, call.position_before, call.position_after,
233 call.request().label.c_str(), call.equity, call.required, call.liquidation_price);
234 if (std::fabs(call.mark - 3500.0 / 38.25) > 1e-6) {
235 std::cerr << "expected the liquidation to book at the solved level\n";
236 return 1;
237 }
238 if (call.position_after != 0.0 || call.request().label != "liquidation") {
239 std::cerr << "expected Flatten to close the whole position under the model's ticket\n";
240 return 1;
241 }
242 if (host.trade_count() < 1 || host.get_trade(0).exit_id != "liquidation") {
243 std::cerr << "expected the closed row to carry the liquidation ticket\n";
244 return 1;
245 }
246
247 std::cout << "closed trades: " << host.trade_count() << '\n';
248 for (int i = 0; i < host.trade_count(); ++i) {
249 const auto& trade = host.get_trade(i);
250 std::cout << " " << (trade.is_long ? "long " : "short")
251 << " qty=" << trade.qty
252 << " entry=" << trade.entry_price
253 << " exit=" << trade.exit_price
254 << " pnl=" << trade.pnl
255 << " exit_id=" << trade.exit_id << '\n';
256 }
257 return host.trade_count() > 0 ? 0 : 1;
258}
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)
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.