PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
Multi-Timeframe (MTF)

PineForge exposes two distinct multi-timeframe surfaces. They look similar from Pine but resolve through completely different runtime paths, and they have different rules:

Surface Pine call Direction Bars source
Upward (HTF) request.security(sym, "60", expr) target TF coarser than input aggregated from input bars
Downward (LTF) request.security_lower_tf(sym, "1", expr) target TF finer than the chart the input's (or an auxiliary feed's) real bars when the target is not finer than them, else synthesized from each bar's OHLC path
Another symbol request.security("TVC:DXY", "15", expr) any TF the other symbol's own bars, installed before the run

The downward path is where PineForge can diverge from TradingView. TV reads real bars of the requested lower timeframe; PF reads real ones only when the host supplies them — an input finer than the chart (input_tf < script_tf) or an auxiliary feed (strategy_set_aux_security_feed). A target finer than every bar the run was given is synthesized on demand by walking the input bar's OHLC path. See Bar magnifier for the same idea applied to fill resolution.

For the upward path and the chart-aggregation rules, see also Timeframes.

Another symbol's bars

A request.security of another symbol reads that symbol's own bars; no aggregation of the chart can produce them. The host installs them before the run, keyed by the exact symbol string the script passes and by the timeframe, each bar with its own close:

strategy_set_symbol_feed(s, "TVC:DXY", "15", bars, close_ms, n); /* PINEFORGE_HAS_SYMBOL_FEED_V1 */
strategy_set_symbol_facts(s, "TVC:DXY", "type", "index"); /* PINEFORGE_HAS_SYMBOL_FACTS_V1 */
int strategy_set_symbol_facts(pf_strategy_t s, const char *key, const char *field, const char *value)
Set one fact of the symbol a request site names by key: field is one of "canonical",...
int strategy_set_symbol_feed(pf_strategy_t s, const char *key, const char *timeframe, const pf_bar_t *bars, const int64_t *close_ms, int n)
Copy another symbol's own bars of one timeframe, keyed by the exact symbol string a request site pass...

On each chart bar the site sees the last bar of the other symbol that has closed by the chart bar's close (lookahead_off) or that opened by the chart bar's open (lookahead_on); no calendar is guessed for the other symbol, so a daily bar that closes after the chart's session close is seen one chart day late, as TradingView sees it. The site's expression runs on every bar of the other symbol, in order, so its history offsets and indicators run over that symbol's own bars; inside it bar_index, time_close and syminfo.* are the other symbol's. gaps_on reads na on a chart bar that received no new bar; the generated clear_security() that blanks the site there also clears the expression's own history series, as it does for a same-symbol site, so under gaps_on an offset or an indicator inside the expression starts over after each such bar (a codegen limit, not the merge's). A symbol with no installed feed fails the run closed, naming the symbol and the timeframe; a feed installed with no bars reads na on every chart bar, and an invalid symbol reads na under ignore_invalid_symbol. Historical runs only: a stream refuses an installed symbol feed, and every setter answers -1 while a run is in progress. The merge itself is the kernel's (NativeRunSpec::instrument_feeds, Native engine) and holds for a native host too.

scripts/run_strategy.py installs a probe's pinned feeds, facts and recorded request values (strategy_set_recorded_series, TradingView's per-chart-bar fundamentals; a tape with a header only is a request na on every chart bar) from PINEFORGE_REQUESTS_ROOT/<probe>/requests.json when that variable is set. pineforge-codegen 1.0.0 lowers a foreign request.security onto this surface; its 0.10.4 release refuses a symbol that is not the chart's.

The script_tf / input_tf model

Every backtest run takes two timeframe strings on run_backtest_full(...):

run_backtest_full(s, bars, n,
/* input_tf */ "", // empty → auto-detect from bar timestamps
/* script_tf */ "", // empty → defaults to input_tf
/* magnifier */ 0, 4, PF_MAGNIFIER_ENDPOINTS,
&report);
void run_backtest_full(pf_strategy_t s, pf_bar_t *bars, int n, const char *input_tf, const char *script_tf, int bar_magnifier, int magnifier_samples, pf_magnifier_distribution_t magnifier_dist, pf_report_t *out)
Run a backtest with explicit timeframe and magnifier configuration.
@ PF_MAGNIFIER_ENDPOINTS
Default — exact O,H,L,C points plus uniform fill between.
Definition pineforge.h:132

The runtime resolves them in order:

  1. input_tf — what the bar feed actually is. If empty, the runtime computes the median delta between consecutive pf_bar_t::timestamp values and snaps to a canonical Pine TF.
  2. script_tf — what the strategy script believes it's running on. If empty, defaults to the resolved input_tf.
  3. If script_tf > input_tf, the runtime aggregates input bars up to script-TF parents before each on_bar dispatch. The two timeframes are concatenated by an aggregator (ratio or calendar — TimeframeAggregator, src/timeframe.cpp).

The report exposes the resolved values and the ratio:

Report field Meaning
input_tf_seconds Resolved input TF (after auto-detect).
script_tf_seconds Resolved script TF (after defaulting).
script_tf_ratio script_tf_seconds / input_tf_seconds.
needs_aggregation 1 when ratio > 1, 0 otherwise.

Switching timeframes — C

pf_report_t r = {0};
// 1. Both empty → auto-detect input, default script to input.
run_backtest_full(s, bars, n, "", "", 0, 4, PF_MAGNIFIER_ENDPOINTS, &r);
// r.input_tf_seconds == 900 (15m, auto-detected)
// r.script_tf_seconds == 900 (defaulted)
// r.script_tf_ratio == 1
// r.needs_aggregation == 0
// 2. Explicit input + explicit higher script → 4:1 aggregation.
run_backtest_full(s, bars, n, "15", "60", 0, 4, PF_MAGNIFIER_ENDPOINTS, &r);
// r.input_tf_seconds == 900
// r.script_tf_seconds == 3600
// r.script_tf_ratio == 4
// r.needs_aggregation == 1
// 3. Explicit input, script defaults to input.
run_backtest_full(s, bars, n, "15", "", 0, 4, PF_MAGNIFIER_ENDPOINTS, &r);
// r.input_tf_seconds == 900
// r.script_tf_seconds == 900 (defaulted, NOT inferred separately)
void report_free(pf_report_t *report)
Free heap arrays attached to a filled report.
Backtest report filled by run_backtest / run_backtest_full.
Definition pineforge.h:366

Switching timeframes — Python (ctypes)

# Same three calls, byte-string TFs; free each report before the next run.
for in_tf, script_tf in ((b"", b""), (b"15", b"60"), (b"15", b"")):
lib.run_backtest_full(s, bars, n, in_tf, script_tf, 0, 4, 3, byref(r))
lib.report_free(byref(r))

The full runnable harness — three tables walking the script_tf sweep, the input_tf/script_tf pair matrix, and the lower-TF synthesis ratio — is in tutorial/run_mtf.py. See the Worked example section below.

request.security_lower_tf — the codegen contract

The lower-TF builtin returns an array of one value per synthesized sub-bar. The codegen pattern in generated.cpp is:

class GeneratedStrategy : public pineforge::source::PineStrategyHost {
public:
// Accumulator vector for the sub-bar values.
std::vector<double> _req_sec_lower_tf_0{};
void configure_security_evaluators() override {
security_eval_states_.clear();
// sec_id 0, target "1" minute, input_tf passed through from the
// run-time resolved value.
register_security_lower_tf_eval(0, std::string("1"), input_tf_);
}
void evaluate_security(int sec_id, const Bar& bar, bool is_complete) override {
if (sec_id != 0 || !is_complete) return;
// Sub-bar 0 marks the start of a new chart bar — clear before pushing.
_req_sec_lower_tf_0.clear();
}
_req_sec_lower_tf_0.push_back(bar.close);
}
void on_source_bar(const Bar& bar) override {
// _req_sec_lower_tf_0 is the Pine array returned by
// request.security_lower_tf(sym, "1", close).
// ... compute on it ...
}
};
int security_lower_tf_sub_bar_index(int sec_id) const
void register_security_lower_tf_eval(int sec_id, const std::string &requested_tf, const std::string &input_tf)
virtual void on_source_bar(const Bar &)=0

register_security_eval, register_security_lower_tf_eval, security_lower_tf_sub_bar_index and security_series_slot_is_new are the Pine source host's (source::PineStrategyHost, src/source/pine_security_eval.cpp). The kernel underneath registers a plain aggregating evaluator and keeps the feed store; every request.security rule on this page — lookahead and gaps publication, Heikin-Ashi, the lower-timeframe emulation, the diagnostics below — is source-host state kept per sec_id beside it.

Validation rules

When the run begins, the source host validates each registered lower-TF site against the run's evaluator input timeframe (validate_security_timeframes):

  • The target TF must be strictly finer than the script TF.
  • Finer than the input TF, it must divide the input TF evenly (sub-bars are synthesized); otherwise it must be an integer multiple of the input TF and divide the script TF (input bars are gathered) — non-clean ratios are rejected.
  • lookahead and gaps must be off (TV does not expose them on this builtin and PF refuses to fake them).

Violations raise at run-time with a precise diagnostic, e.g.:

request.security_lower_tf: requested timeframe '30' must be finer than script
timeframe '15'. Lower-TF API requires a strictly finer timeframe.

Sub-bar synthesis

For each input bar, the engine generates input_tf / target_tf synthetic sub-bars by walking the input bar's OHLC endpoints (the same path-sampling primitive the bar magnifier uses, see src/engine_lower_tf.cpp). Each synthetic sub-bar gets 1/ratio of the input bar's volume.

Per-run sub-bar counts surface in the report's security_feeds_total field. With a 15m chart and a "1" lower-TF target, expect:

security_feeds_total == 15 * input_bars_processed

Worked example

tutorial/mtf/ ships two strategies side-by-side:

File Demonstrates
tutorial/mtf/strategy_htf.pine + generated_htf.cpp request.security (HTF SMA filter on a 15m chart). HTF is an input — sweepable without rebuild.
tutorial/mtf/strategy_ltf.pine + generated_ltf.cpp request.security_lower_tf("1", close) — intra-bar 1m range as an entry signal.
tutorial/run_mtf.py Three tables: script_tf sweep, input_tf/script_tf pair matrix, lower-TF synthesis ratio.

Build:

cmake --build build --target strategy_tutorial_mtf_htf strategy_tutorial_mtf_ltf -j
python3 tutorial/run_mtf.py

Expected (excerpt):

Table A — script_tf sweep, fixed input (HTF .so)
script_tf in_s sc_s ratio agg? in_bars sc_bars trades net_pnl
"" 900 900 1 0 672 672 2 +4381.53
"15" 900 900 1 0 672 672 2 +4381.53
"60" 900 3600 4 1 672 168 7 -888.46
"240" 900 14400 16 1 672 42 1 +1387.95

Numbers depend on the OHLCV snapshot.

See also