From f70c27f8bab0788aa0d232231610fa23f45a4d0d Mon Sep 17 00:00:00 2001 From: Ferran Date: Tue, 8 Sep 2026 13:28:19 +0200 Subject: [PATCH] Strategy variables are represented as series --- crates/pine-builtins/src/strategy/mod.rs | 429 ++++++++++-------- crates/pine-interpreter/src/lib.rs | 8 + .../series/history_in_conditional_branch.pine | 30 ++ .../strategy/history_short_circuit.pine | 28 ++ 4 files changed, 307 insertions(+), 188 deletions(-) create mode 100644 tests/testdata/series/history_in_conditional_branch.pine create mode 100644 tests/testdata/strategy/history_short_circuit.pine diff --git a/crates/pine-builtins/src/strategy/mod.rs b/crates/pine-builtins/src/strategy/mod.rs index 2a5090f..638df5c 100644 --- a/crates/pine-builtins/src/strategy/mod.rs +++ b/crates/pine-builtins/src/strategy/mod.rs @@ -4,11 +4,12 @@ //! `strategy` is both callable and a namespace — `strategy("My Strat", ...)` //! declares the script and sets up the simulated [`Broker`], while //! `strategy.entry`/`strategy.close`/… submit orders to it. The read-only -//! values (`strategy.position_size`, `strategy.equity`, …) read straight from -//! the broker on each use; the interpreter itself holds only the broker handle -//! and carries no backtest logic. +//! values (`strategy.position_size`, `strategy.equity`, …) are the [`Strategy`] +//! account: `series float`s taken from the broker once a bar, so `[n]` reaches +//! the bars behind. The interpreter itself holds only the broker handle and +//! carries no backtest logic. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; @@ -17,9 +18,10 @@ use pine_broker::{ RiskRule, RiskType, Sizing, Trade, }; use pine_builtin_macro::BuiltinFunction; -use pine_core::{PineOutput, PineVersion}; +use pine_core::{PineOutput, PineVersion, SeriesBuffer, MAX_LOOKBACK}; use pine_interpreter::{ - Builtin, BuiltinFn, EvaluatedArg, Interpreter, PerBarAdvance, RuntimeError, Value, + Builtin, BuiltinFn, EvaluatedArg, Interpreter, ObjectFields, PerBarAdvance, RuntimeError, + Series, Value, }; /// TradingView's default starting capital. @@ -99,6 +101,18 @@ impl StrategyFn { RuntimeError::TypeError("strategy() has no broker configured".to_string()) })?; ctx.broker = Some(factory.build(&config)); + + // The account rolled before the body ran, when there was no broker + // to read; write this bar's figures now, so the bar that declares + // the strategy does not see a flat account. + if let (Some(fields), Some(close)) = ( + account_fields(ctx), + ctx.current_bar.as_ref().map(|bar| bar.close), + ) { + if let Some(broker) = ctx.broker.as_deref() { + Strategy::write(&mut fields.borrow_mut(), broker, close); + } + } } Ok(Value::Na) @@ -804,10 +818,196 @@ fn open_profit_at(broker: &dyn Broker, close: f64) -> f64 { .fold(0.0, |acc, t| acc + t.profit(close)) } -/// Pine's identity equity = initial + netprofit + openprofit; derive netprofit -/// from it so commission can't make the two drift. -fn net_profit_at(broker: &dyn Broker, close: f64) -> f64 { - broker.equity(close) - broker.initial_capital() - open_profit_at(broker, close) +/// The `strategy` namespace's own fields, so the declaration can write this +/// bar's account figures into them. +fn account_fields(ctx: &Interpreter) -> Option> { + match ctx.get_variable("strategy") { + Some(Value::Object { fields, .. }) => Some(Rc::clone(fields)), + _ => None, + } +} + +/// The `strategy.*` account. The reference types each of these `series float` +/// (or `series int`), so each owns a history buffer and the whole account +/// advances once per BAR from the broker — never per evaluation, which is all a +/// per-site buffer could offer once short-circuiting skips the reference. +struct Strategy { + /// The namespace's fields, shared with the `strategy` object itself. + fields: Rc>>>, + /// A series' value only becomes history from the second bar on. + advanced: Cell, +} + +impl Strategy { + /// The account series, seeded flat and zero-profit — what a script reads + /// before its `strategy` declaration has run. + fn seed(fields: &mut HashMap>) { + let mut series = |name: &str, seed: Value| { + fields.insert( + name.to_string(), + Value::Series(Series { + id: format!("strategy.{name}"), + current: Box::new(seed), + history: Some(Rc::new(RefCell::new(SeriesBuffer::default()))), + }), + ); + }; + for name in [ + "position_size", + "equity", + "initial_capital", + "netprofit", + "openprofit", + "grossprofit", + "grossloss", + "max_drawdown", + "max_runup", + "netprofit_percent", + "openprofit_percent", + "grossprofit_percent", + "grossloss_percent", + "max_drawdown_percent", + "max_runup_percent", + "max_contracts_held_all", + "max_contracts_held_long", + "max_contracts_held_short", + ] { + series(name, Value::Number(0.0)); + } + for name in ["wintrades", "losstrades", "eventrades"] { + series(name, Value::Int(0)); + } + // na, not 0, while flat or with nothing to average — matching Pine. + for name in [ + "position_avg_price", + "avg_trade", + "avg_winning_trade", + "avg_losing_trade", + "avg_trade_percent", + "avg_winning_trade_percent", + "avg_losing_trade_percent", + ] { + series(name, Value::Na); + } + } + + /// Roll the account one bar: every series' value becomes its history, then + /// `broker`'s figures become the new values, computing what they share + /// once. With no broker the account stays flat, but still rolls. + fn advance(&self, broker: Option<&dyn Broker>, close: f64) { + let mut fields = self.fields.borrow_mut(); + if self.advanced.replace(true) { + for value in fields.values_mut() { + if let Value::Series(series) = value { + if let Some(history) = &series.history { + let previous = series + .current + .to_number() + .ok() + .flatten() + .unwrap_or(f64::NAN); + history.borrow_mut().push(previous, MAX_LOOKBACK); + } + } + } + } + + if let Some(broker) = broker { + Self::write(&mut fields, broker, close); + } + } + + /// This bar's figures, computing what they share once. + fn write(fields: &mut HashMap>, broker: &dyn Broker, close: f64) { + let position = broker.position(); + let equity = broker.equity(close); + let initial = broker.initial_capital(); + let open_profit = open_profit_at(broker, close); + // Pine's identity equity = initial + netprofit + openprofit; derive + // netprofit from it so commission can't make the two drift. + let net_profit = equity - initial - open_profit; + let stats = broker.stats(); + let closed = broker.closed_trades().len(); + + let mut set = |name: &str, value: Value| { + if let Some(Value::Series(series)) = fields.get_mut(name) { + *series.current = value; + } + }; + set("position_size", Value::Number(position.size)); + set( + "position_avg_price", + if position.size == 0.0 { + Value::Na + } else { + Value::Number(position.avg_price) + }, + ); + set("equity", Value::Number(equity)); + set("initial_capital", Value::Number(initial)); + set("netprofit", Value::Number(net_profit)); + set("openprofit", Value::Number(open_profit)); + set("grossprofit", Value::Number(stats.gross_profit)); + set("grossloss", Value::Number(stats.gross_loss)); + set("max_drawdown", Value::Number(stats.max_drawdown)); + set("max_runup", Value::Number(stats.max_runup)); + set("wintrades", Value::Int(stats.wins as i64)); + set("losstrades", Value::Int(stats.losses as i64)); + set("eventrades", Value::Int(stats.evens as i64)); + set( + "netprofit_percent", + Value::Number(pct_of(net_profit, initial)), + ); + set( + "openprofit_percent", + Value::Number(pct_of(open_profit, initial)), + ); + set( + "grossprofit_percent", + Value::Number(pct_of(stats.gross_profit, initial)), + ); + set( + "grossloss_percent", + Value::Number(pct_of(stats.gross_loss, initial)), + ); + set( + "max_drawdown_percent", + Value::Number(stats.max_drawdown_percent), + ); + set("max_runup_percent", Value::Number(stats.max_runup_percent)); + set( + "max_contracts_held_all", + Value::Number(stats.max_contracts_all), + ); + set( + "max_contracts_held_long", + Value::Number(stats.max_contracts_long), + ); + set( + "max_contracts_held_short", + Value::Number(stats.max_contracts_short), + ); + set("avg_trade", per_trade(net_profit, closed)); + set( + "avg_winning_trade", + per_trade(stats.gross_profit, stats.wins), + ); + // Losing trades are reported as a negative average, so negate the + // positive gross-loss magnitude. + set( + "avg_losing_trade", + per_trade(-stats.gross_loss, stats.losses), + ); + set("avg_trade_percent", per_trade(stats.pct_sum_all, closed)); + set( + "avg_winning_trade_percent", + per_trade(stats.pct_sum_wins, stats.wins), + ); + set( + "avg_losing_trade_percent", + per_trade(stats.pct_sum_losses, stats.losses), + ); + } } fn pct_of(x: f64, initial: f64) -> f64 { @@ -949,178 +1149,17 @@ pub fn register( }, ); - // Read-only account values, computed from the broker on each use; before a - // `strategy` is declared they read as a flat, zero-profit account. - let zero = Value::Number(0.0); - let account = [ - ( - "position_size", - account_field(zero.clone(), |b, _| Value::Number(b.position().size)), - ), - // na, not 0, when flat — matching Pine. - ( - "position_avg_price", - account_field(Value::Na, |b, _| { - let position = b.position(); - if position.size == 0.0 { - Value::Na - } else { - Value::Number(position.avg_price) - } - }), - ), - ( - "equity", - account_field(zero.clone(), |b, close| Value::Number(b.equity(close))), - ), - ( - "initial_capital", - account_field(zero.clone(), |b, _| Value::Number(b.initial_capital())), - ), - ( - "netprofit", - account_field(zero.clone(), |b, close| { - Value::Number(net_profit_at(b, close)) - }), - ), - ( - "openprofit", - account_field(zero.clone(), |b, close| { - Value::Number(open_profit_at(b, close)) - }), - ), - ( - "grossprofit", - account_field(zero.clone(), |b, _| Value::Number(b.stats().gross_profit)), - ), - ( - "grossloss", - account_field(zero.clone(), |b, _| Value::Number(b.stats().gross_loss)), - ), - ( - "max_drawdown", - account_field(zero.clone(), |b, _| Value::Number(b.stats().max_drawdown)), - ), - ( - "max_runup", - account_field(zero.clone(), |b, _| Value::Number(b.stats().max_runup)), - ), - ( - "wintrades", - account_field(Value::Int(0), |b, _| Value::Int(b.stats().wins as i64)), - ), - ( - "losstrades", - account_field(Value::Int(0), |b, _| Value::Int(b.stats().losses as i64)), - ), - ( - "eventrades", - account_field(Value::Int(0), |b, _| Value::Int(b.stats().evens as i64)), - ), - ( - "netprofit_percent", - account_field(zero.clone(), |b, close| { - Value::Number(pct_of(net_profit_at(b, close), b.initial_capital())) - }), - ), - ( - "openprofit_percent", - account_field(zero.clone(), |b, close| { - Value::Number(pct_of(open_profit_at(b, close), b.initial_capital())) - }), - ), - ( - "grossprofit_percent", - account_field(zero.clone(), |b, _| { - Value::Number(pct_of(b.stats().gross_profit, b.initial_capital())) - }), - ), - ( - "grossloss_percent", - account_field(zero.clone(), |b, _| { - Value::Number(pct_of(b.stats().gross_loss, b.initial_capital())) - }), - ), - ( - "max_drawdown_percent", - account_field(zero.clone(), |b, _| { - Value::Number(b.stats().max_drawdown_percent) - }), - ), - ( - "max_runup_percent", - account_field(zero.clone(), |b, _| { - Value::Number(b.stats().max_runup_percent) - }), - ), - ( - "max_contracts_held_all", - account_field(zero.clone(), |b, _| { - Value::Number(b.stats().max_contracts_all) - }), - ), - ( - "max_contracts_held_long", - account_field(zero.clone(), |b, _| { - Value::Number(b.stats().max_contracts_long) - }), - ), - ( - "max_contracts_held_short", - account_field(zero.clone(), |b, _| { - Value::Number(b.stats().max_contracts_short) - }), - ), - ( - "avg_trade", - account_field(Value::Na, |b, close| { - per_trade(net_profit_at(b, close), b.closed_trades().len()) - }), - ), - ( - "avg_winning_trade", - account_field(Value::Na, |b, _| { - per_trade(b.stats().gross_profit, b.stats().wins) - }), - ), - // Losing trades are reported as a negative average, so negate the - // positive gross-loss magnitude. - ( - "avg_losing_trade", - account_field(Value::Na, |b, _| { - per_trade(-b.stats().gross_loss, b.stats().losses) - }), - ), - ( - "avg_trade_percent", - account_field(Value::Na, |b, _| { - per_trade(b.stats().pct_sum_all, b.closed_trades().len()) - }), - ), - ( - "avg_winning_trade_percent", - account_field(Value::Na, |b, _| { - per_trade(b.stats().pct_sum_wins, b.stats().wins) - }), - ), - ( - "avg_losing_trade_percent", - account_field(Value::Na, |b, _| { - per_trade(b.stats().pct_sum_losses, b.stats().losses) - }), - ), - ( - "position_entry_name", - account_field(Value::Na, |b, _| { - b.open_trades() - .last() - .map_or(Value::Na, |t| Value::String(t.entry_id.clone())) - }), - ), - ]; - for (name, value) in account { - fields.insert(name.to_string(), value); - } + // The account series, plus the one `series string` among them: the history + // buffer holds floats, so that one stays a value read on use. + Strategy::seed(&mut fields); + fields.insert( + "position_entry_name".to_string(), + account_field(Value::Na, |b, _| { + b.open_trades() + .last() + .map_or(Value::Na, |t| Value::String(t.entry_id.clone())) + }), + ); fields.insert("margin_liquidation_price".to_string(), Value::Na); fields.insert( "account_currency".to_string(), @@ -1143,19 +1182,33 @@ pub fn register( fields.insert("closedtrades".to_string(), register_closedtrades()); fields.insert("opentrades".to_string(), register_opentrades()); + let fields = Rc::new(RefCell::new(fields)); let value = Value::Object { type_name: "strategy".to_string(), - fields: Rc::new(RefCell::new(fields)), + fields: Rc::clone(&fields), call: Some(Builtin::untyped( Rc::new(StrategyFn::builtin_fn) as BuiltinFn )), value: None, }; - let pre: PerBarAdvance = Rc::new(|ctx: &mut Interpreter| { - if let (Some(broker), Some(bar)) = (ctx.broker.as_mut(), ctx.current_bar.as_ref()) { - broker.pre_hook(bar); - } - }); + // Before the bar's statements: fill what the previous bar left pending, then + // roll the account, so the body reads this bar's figures and `[n]` reads the + // bars behind it. + let pre: PerBarAdvance = { + let account = Strategy { + fields, + advanced: Cell::new(false), + }; + Rc::new(move |ctx: &mut Interpreter| { + let Some(bar) = ctx.current_bar.clone() else { + return; + }; + if let Some(broker) = ctx.broker.as_mut() { + broker.pre_hook(&bar); + } + account.advance(ctx.broker.as_deref(), bar.close); + }) + }; let post: PerBarAdvance = Rc::new(|ctx: &mut Interpreter| { if let (Some(broker), Some(bar)) = (ctx.broker.as_mut(), ctx.current_bar.as_ref()) { broker.post_hook(bar); diff --git a/crates/pine-interpreter/src/lib.rs b/crates/pine-interpreter/src/lib.rs index 3c86955..91ad8bc 100644 --- a/crates/pine-interpreter/src/lib.rs +++ b/crates/pine-interpreter/src/lib.rs @@ -1956,6 +1956,14 @@ impl Interpreter { } fn values_equal(&self, left: &Value, right: &Value) -> Result { + // A series compares as its current value, exactly as it does in + // arithmetic — otherwise `close == 0` is false however close moves. + if let Value::Series(series) = left { + return self.values_equal(&series.current, right); + } + if let Value::Series(series) = right { + return self.values_equal(left, &series.current); + } match (left, right) { (Value::Int(l), Value::Int(r)) => Ok(l == r), // int and float compare by value, so `1 == 1.0`. diff --git a/tests/testdata/series/history_in_conditional_branch.pine b/tests/testdata/series/history_in_conditional_branch.pine new file mode 100644 index 0000000..7c15ca0 --- /dev/null +++ b/tests/testdata/series/history_in_conditional_branch.pine @@ -0,0 +1,30 @@ +//@version=6 +indicator("series/history_in_conditional_branch") +// KNOWN GAP, pinned here so a fix visibly flips it. +// +// series/index_expr_history.pine guarantees that `(expr)[1]` equals +// `s = expr; s[1]`. That holds only while the reference is reached every bar: +// an expression subscript gets a per-site buffer advanced on EVALUATION, so on +// the bars this branch skips nothing is recorded and `(close * 2)[1]` reports +// the previous evaluation — two bars back — instead of the previous bar. The +// `close[1] * 2` column is the correct value each line. +// +// Closing it means evaluating such sites every bar (they are pure here, but a +// call site would not be), so it is left as-is. Engine-maintained series are +// NOT affected — see strategy/history_short_circuit.pine. +// +// Skip PineTS: PineTS's direct arithmetic subscript is broken for this form — +// it returns na on every evaluation (the same defect noted in +// series/index_expr_history.pine). + +// Bars: 6 + +var int n = 0 +n += 1 +if n % 2 == 0 + log.info(str.tostring(n) + " " + str.tostring((close * 2)[1]) + " " + str.tostring(close[1] * 2)) + +// Expected output: +// 2 NaN 592 +// 4 594 596 +// 6 598 600 diff --git a/tests/testdata/strategy/history_short_circuit.pine b/tests/testdata/strategy/history_short_circuit.pine new file mode 100644 index 0000000..a697685 --- /dev/null +++ b/tests/testdata/strategy/history_short_circuit.pine @@ -0,0 +1,28 @@ +//@version=5 +strategy("strategy/history_short_circuit", initial_capital = 10000) +// `strategy.*` history must advance every bar, not only on the bars where a +// `[n]` reference happens to be evaluated. Under `and` short-circuiting the +// right operand is skipped while the left is false, so both orderings of the +// same condition must agree on the fill bar. + +// Bars: 5 +// Skip PineTS: PineTS gives strategy.position_size a per-evaluation buffer, so +// its `[1]` is na on the fill bar when short-circuiting skips the reference. +// The reference types it `series float`, whose history the runtime advances +// every bar; PineTS also does not model the host broker (see strategy/stats). + +var int n = 0 +n += 1 +if n == 3 + strategy.entry("L", strategy.long) + +lazy = strategy.position_size != 0 and strategy.position_size[1] == 0 +eager = strategy.position_size[1] == 0 and strategy.position_size != 0 +log.info(str.tostring(lazy) + " " + str.tostring(eager)) + +// Expected output: +// false false +// false false +// false false +// true true +// false false