Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion corpus
8 changes: 8 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,14 @@ class BacktestEngine {
// would regress the crypto-on-shifted-chart case).
void set_syminfo_timezone(const std::string& tz) { syminfo_.timezone = tz; }
void set_syminfo_session(const std::string& s) { syminfo_.session = s; }
// Runtime syminfo injection (by design — the engine stores no instrument
// metadata of its own; the harness supplies it per run). mintick drives the
// directional fill snap + slippage*tick economics; pointvalue is the
// futures $-per-point multiplier applied to realized PnL and excursions.
// Both default to crypto/equity values (0.01 / 1.0) and only matter when the
// harness sets a non-default instrument.
void set_syminfo_mintick(double m) { if (m > 0.0) { syminfo_.mintick = m; syminfo_mintick_ = m; } }
void set_syminfo_pointvalue(double pv) { if (pv > 0.0) { syminfo_.pointvalue = pv; } }
void set_syminfo_metadata(const std::string& key, double value) {
syminfo_metadata_[key] = value;
}
Expand Down
12 changes: 12 additions & 0 deletions include/pineforge/pineforge.h
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,18 @@ PF_API void strategy_set_syminfo_timezone(pf_strategy_t s, const char* tz);
* ignored. Call before #run_backtest*. */
PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session);

/** Set the instrument tick size (``syminfo.mintick``, default 0.01). Drives the
* directional stop-entry snap and ``slippage = N*mintick`` economics. Set
* per-instrument (e.g. 0.25 for ES, 0.00001 for FX). Non-positive ignored.
* Call before #run_backtest*. */
PF_API void strategy_set_syminfo_mintick(pf_strategy_t s, double mintick);

/** Set the instrument point value (``syminfo.pointvalue``, default 1.0) — the
* $-per-point-per-contract multiplier applied to realized PnL and MFE/MAE. Set
* per-instrument (e.g. 50 for ES). Non-positive ignored. Call before
* #run_backtest*. */
PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue);

/** Inject a fundamental/exchange metadata value by Pine member name
* (e.g. "shares_outstanding_total", "target_price_average"). These have
* no OHLCV source; reads of un-injected members return na. Call before
Expand Down
2 changes: 2 additions & 0 deletions scripts/check_c_abi_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"strategy_set_chart_timezone",
"strategy_set_syminfo_timezone",
"strategy_set_syminfo_session",
"strategy_set_syminfo_mintick",
"strategy_set_syminfo_pointvalue",
"strategy_set_syminfo_metadata",
"strategy_get_last_error",
"pf_version_get",
Expand Down
45 changes: 38 additions & 7 deletions scripts/run_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,21 @@ def _setup_signatures(self) -> None:
L.strategy_set_syminfo_metadata.argtypes = [
ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double]
L.strategy_set_syminfo_metadata.restype = None
if hasattr(L, "strategy_set_syminfo_mintick"):
L.strategy_set_syminfo_mintick.argtypes = [ctypes.c_void_p, ctypes.c_double]
L.strategy_set_syminfo_mintick.restype = None
if hasattr(L, "strategy_set_syminfo_pointvalue"):
L.strategy_set_syminfo_pointvalue.argtypes = [ctypes.c_void_p, ctypes.c_double]
L.strategy_set_syminfo_pointvalue.restype = None

def run(self, bars_csv: Path, params: dict | None = None,
*, trace_enabled: bool = False, trade_start_time_ms: int | None = None,
chart_timezone: str | None = None,
syminfo_timezone: str | None = None,
syminfo_session: str | None = None,
syminfo_metadata: dict | None = None,
syminfo_mintick: float | None = None,
syminfo_pointvalue: float | None = None,
input_tf: str | None = None, script_tf: str | None = None,
ohlcv_start_ms: int | None = None,
bar_magnifier: bool = False,
Expand Down Expand Up @@ -274,6 +282,10 @@ def run(self, bars_csv: Path, params: dict | None = None,
state, str(mkey).encode(), float(mval))
except (TypeError, ValueError):
continue
if syminfo_mintick is not None and hasattr(self.lib, "strategy_set_syminfo_mintick"):
self.lib.strategy_set_syminfo_mintick(state, float(syminfo_mintick))
if syminfo_pointvalue is not None and hasattr(self.lib, "strategy_set_syminfo_pointvalue"):
self.lib.strategy_set_syminfo_pointvalue(state, float(syminfo_pointvalue))
if hasattr(self.lib, "strategy_set_input"):
for key, value in params.items():
if key.startswith("tv_"):
Expand Down Expand Up @@ -490,14 +502,26 @@ def write_engine_trades_csv(trades: list[dict], path: Path) -> None:



def _tv_timezone_offset(meta: dict) -> int:
tz_name = str(meta.get("tv_trades_csv_tz", "")).lower()
return {"utc_plus_8": 8, "asia_taipei": 8, "utc": 0}.get(tz_name, 8)
def _tv_tzinfo(meta: dict):
"""Resolve the TV export tz to a tzinfo. Fixed aliases (utc/utc_plus_8/
asia_taipei) OR any IANA name (DST-aware, e.g. America/New_York) so the
emit-window for a DST-bearing exchange is computed correctly."""
from datetime import timedelta
name = str(meta.get("tv_trades_csv_tz", "")).strip()
low = name.lower()
fixed = {"utc_plus_8": 8, "asia_taipei": 8, "utc": 0}
if low in fixed:
return timezone(timedelta(hours=fixed[low]))
if "/" in name:
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except Exception:
pass
return timezone(timedelta(hours=8))


def _parse_trade_dt(s: str, tz_offset_hours: int) -> int:
from datetime import timedelta
tz = timezone(timedelta(hours=tz_offset_hours))
def _parse_trade_dt(s: str, tz) -> int:
return int(datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=tz).timestamp() * 1000)


Expand All @@ -506,7 +530,7 @@ def _load_tv_entry_window(strategy_dir: Path, meta: dict, bar_interval_ms: int)
tv_path = strategy_dir / tv_name
if not tv_path.exists():
return None
tz_offset = _tv_timezone_offset(meta)
tz_offset = _tv_tzinfo(meta)
entries: list[int] = []
with tv_path.open(encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
Expand Down Expand Up @@ -663,13 +687,20 @@ def main() -> int:
syminfo_metadata = runtime_overrides.get("syminfo_metadata")
if not isinstance(syminfo_metadata, dict):
syminfo_metadata = None
def _num(v):
try: return float(v)
except (TypeError, ValueError): return None
syminfo_mintick = _num(runtime_overrides.get("mintick"))
syminfo_pointvalue = _num(runtime_overrides.get("pointvalue"))
report = strat.run(ohlcv_path, params=params,
trace_enabled=args.trace_json is not None,
trade_start_time_ms=trade_start_ms,
chart_timezone=chart_tz,
syminfo_timezone=syminfo_tz,
syminfo_session=syminfo_session,
syminfo_metadata=syminfo_metadata,
syminfo_mintick=syminfo_mintick,
syminfo_pointvalue=syminfo_pointvalue,
input_tf=input_tf_override or None,
script_tf=script_tf_override or None,
ohlcv_start_ms=ohlcv_start_ms_val,
Expand Down
41 changes: 33 additions & 8 deletions scripts/verify_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,38 @@ class TradePair:
mae: float = 0.0


def parse_dt(s: str, tz_offset_hours: int) -> int:
"""Parse 'YYYY-MM-DD HH:MM' (wall time in tz_offset_hours) as unix seconds (UTC)."""
tz = timezone(timedelta(hours=tz_offset_hours))
def tv_tzinfo(meta: dict):
"""Resolve the TV export timezone to a tzinfo.

Accepts the legacy fixed-offset aliases (utc / utc_plus_8 / asia_taipei) AND
any IANA zone name (e.g. 'America/New_York', 'Europe/London') so DST-bearing
exchanges parse correctly — a fixed integer offset silently mis-aligns trades
across a DST transition (e.g. US equities flip UTC-4 -> UTC-5 in November).
"""
name = str(meta.get("tv_trades_csv_tz", "")).strip()
low = name.lower()
if low in TV_TZ_BY_NAME:
return timezone(timedelta(hours=TV_TZ_BY_NAME[low]))
if "/" in name: # looks like an IANA zone -> DST-aware
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except Exception:
pass
return timezone(timedelta(hours=TV_CSV_TZ_OFFSET_HOURS)) # default Asia/Taipei


def parse_dt(s: str, tz) -> int:
"""Parse 'YYYY-MM-DD HH:MM' (wall time in tz) as unix seconds (UTC).

``tz`` is any datetime.tzinfo — a fixed timezone(...) or a DST-aware
zoneinfo.ZoneInfo. localizing the naive wall clock then taking .timestamp()
yields the correct POSIX instant including DST.
"""
return int(datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=tz).timestamp())


def parse_trades(csv_path: Path, *, tz_offset_hours: int) -> list[TradePair]:
def parse_trades(csv_path: Path, *, tz) -> list[TradePair]:
by_num: dict[int, dict] = {}
# TradingView exports include a UTF-8 BOM; utf-8-sig strips it.
with csv_path.open(encoding="utf-8-sig") as f:
Expand Down Expand Up @@ -291,10 +316,10 @@ def parse_trades(csv_path: Path, *, tz_offset_hours: int) -> list[TradePair]:
r["mfe"] = mfe
r["mae"] = mae
if kind.startswith("Entry"):
r["entry_time"] = parse_dt(time_field, tz_offset_hours)
r["entry_time"] = parse_dt(time_field, tz)
r["entry_price"] = price
else:
r["exit_time"] = parse_dt(time_field, tz_offset_hours)
r["exit_time"] = parse_dt(time_field, tz)
r["exit_price"] = price

pairs: list[TradePair] = []
Expand Down Expand Up @@ -412,8 +437,8 @@ def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0)
print(f"{rel}\n MISSING (tv: {tv_path.exists()}, engine: {eng_path.exists()})")
return "missing"

tv = parse_trades(tv_path, tz_offset_hours=tv_timezone_offset(meta))
eng = parse_trades(eng_path, tz_offset_hours=ENGINE_CSV_TZ_OFFSET_HOURS)
tv = parse_trades(tv_path, tz=tv_tzinfo(meta))
eng = parse_trades(eng_path, tz=timezone.utc)
matched = align_by_time(tv, eng)
tv_cmp, eng_cmp = trim_to_common_match_window(tv, eng, matched)
matched = align_by_time(tv_cmp, eng_cmp)
Expand Down
17 changes: 17 additions & 0 deletions src/c_abi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session) {
static_cast<pineforge::BacktestEngine*>(s)->set_syminfo_session(std::string(session));
}

/* Inject the instrument tick size (syminfo.mintick). Drives the directional
* stop-entry snap (long ceil / short floor) and slippage = N*mintick economics.
* Defaults to 0.01 (crypto/equity); set per-instrument (e.g. 0.25 for ES,
* 0.00001 for FX). Non-positive values are ignored. */
PF_API void strategy_set_syminfo_mintick(pf_strategy_t s, double mintick) {
if (!s) return;
static_cast<pineforge::BacktestEngine*>(s)->set_syminfo_mintick(mintick);
}

/* Inject the instrument point value (syminfo.pointvalue) — the $ per point per
* contract multiplier applied to realized PnL and MFE/MAE. Defaults to 1.0
* (crypto/equity); set per-instrument (e.g. 50 for ES). Non-positive ignored. */
PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue) {
if (!s) return;
static_cast<pineforge::BacktestEngine*>(s)->set_syminfo_pointvalue(pointvalue);
}

/* Inject a fundamental/exchange metadata value (shares_outstanding_total,
* recommendations_*, target_price_*, …) by Pine member name. Without an
* injection the corresponding syminfo.* read returns na. NULL key ignored. */
Expand Down
16 changes: 11 additions & 5 deletions src/engine_orders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,15 @@ void BacktestEngine::purge_exit_orders() {
// execute_market_entry. Mirrors TradingView's per-pyramid trade reporting.
void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty,
double fill_price, bool was_long) {
double pnl = was_long
? (fill_price - pe.price) * close_qty
: (pe.price - fill_price) * close_qty;
// Realized PnL scales by the instrument point value ($ per point per
// contract). Crypto/equity (pointvalue=1) is unchanged; futures (e.g. ES=50)
// multiply the price-difference PnL. pnl_pct is a price-return % and is
// point-value-invariant. Commission is in account currency (cash-per-* is
// already absolute; percent-commission notional×pointvalue is a follow-up
// and does not affect cash-per-contract instruments like ES).
const double pv = syminfo_.pointvalue;
double pnl = (was_long ? (fill_price - pe.price) : (pe.price - fill_price))
* close_qty * pv;
double pnl_pct = was_long
? (fill_price / pe.price - 1.0) * 100.0
: (pe.price / fill_price - 1.0) * 100.0;
Expand All @@ -301,8 +307,8 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty,
trade.exit_bar_index = bar_index_;
trade.entry_id = pe.entry_id;
trade.entry_comment = pe.entry_comment;
trade.max_runup = pe.max_runup;
trade.max_drawdown = pe.max_drawdown;
trade.max_runup = pe.max_runup * pv; // $ excursion per unit scales with point value
trade.max_drawdown = pe.max_drawdown * pv;
trades_.push_back(trade);
net_profit_sum_ += trade.pnl;
if (trade.pnl > 0) { gross_profit_sum_ += trade.pnl; win_trades_count_++; }
Expand Down
11 changes: 9 additions & 2 deletions src/engine_security.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,22 @@ void BacktestEngine::validate_security_timeframes(const std::string& input_tf) {
// "abc" produces a precise diagnostic instead of an opaque
// std::invalid_argument from stoi.
int requested_seconds = safe_tf_to_seconds(state.tf);
if (requested_seconds <= 0) {
// Calendar month ("M" / "NM") has no fixed second count; tf_to_seconds
// returns -1 as a calendar marker (a genuine parse failure returns 0).
// Month is always a COARSER HTF, so admit it for request.security and
// let the CALENDAR TimeframeAggregator (tf_ratio == -1) aggregate it.
// request.security_lower_tf("M") stays invalid — month is never an
// intrabar TF. (Weekly/daily already pass: they return positive seconds.)
bool is_calendar_month = (requested_seconds == -1 && !state.lower_tf_array_requested);
if (requested_seconds <= 0 && !is_calendar_month) {
const char* api = state.lower_tf_array_requested
? "request.security_lower_tf" : "request.security";
throw std::runtime_error(
std::string(api) + ": invalid timeframe literal '" + state.tf + "'"
);
}

if (requested_seconds < input_seconds) {
if (!is_calendar_month && requested_seconds < input_seconds) {
// Finer than input — only valid for security_lower_tf with
// an integer divisor ratio.
if (!state.lower_tf_array_requested) {
Expand Down
27 changes: 19 additions & 8 deletions src/timeframe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,26 @@ bool crosses_boundary(int64_t prev_ms, int64_t curr_ms, CalendarPeriod period) {
return prev_tm.tm_yday != curr_tm.tm_yday ||
prev_tm.tm_year != curr_tm.tm_year;
case CalendarPeriod::WEEK: {
// ISO week boundary: Monday is start of week.
// Compare (year, week-of-year) using Monday-based week number.
auto monday_week = [](const struct tm& t) -> int {
// tm_wday: 0=Sunday..6=Saturday. Convert to Mon=0..Sun=6.
int dow = (t.tm_wday + 6) % 7;
return (t.tm_yday - dow + 7) / 7;
// Monday-start weeks, CONTINUOUS across the year boundary: the ISO
// week that straddles Dec/Jan (e.g. Mon 2025-12-29 .. Sun 2026-01-04)
// is ONE week, not two. The previous (year || week-of-year) test
// forced a spurious boundary at every Jan 1, splitting that week and
// updating weekly request.security a bar early — visible as flipped
// crosses in a chop-at-level month (proposed-probes weekly probe).
// Fix: compare the absolute calendar date of each timestamp's Monday.
auto monday_epoch_day = [](const struct tm& t) -> long {
// days since 1970-01-01 (proleptic Gregorian), then back up to Monday.
int y = t.tm_year + 1900, m = t.tm_mon + 1, d = t.tm_mday;
y -= (m <= 2);
long era = (y >= 0 ? y : y - 399) / 400;
unsigned yoe = (unsigned)(y - era * 400);
unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
long day = era * 146097L + (long)doe - 719468L;
int dow = (t.tm_wday + 6) % 7; // Mon=0..Sun=6
return day - dow; // the Monday that starts this week
};
return prev_tm.tm_year != curr_tm.tm_year ||
monday_week(prev_tm) != monday_week(curr_tm);
return monday_epoch_day(prev_tm) != monday_epoch_day(curr_tm);
}
case CalendarPeriod::MONTH:
return prev_tm.tm_mon != curr_tm.tm_mon ||
Expand Down
13 changes: 13 additions & 0 deletions tests/test_calendar_aggregation_wm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,22 @@ static void test_weekly_full_weeks_are_seven() {
CHECK(sevens >= 6); // most interior weeks are full
}

// The ISO week straddling the year boundary (Mon 2025-12-29 .. Sun 2026-01-04)
// must aggregate as ONE 7-day week, not split at Jan 1. Regression for the
// weekly-HTF year-rollover bug found via the proposed-probes weekly probe.
static void test_weekly_year_boundary_not_split() {
std::printf("test_weekly_year_boundary_not_split\n");
TimeframeAggregator agg("W", "D");
// 2025-12-22 (Mon) .. 2026-01-19 (Mon): interior completed weeks must all be 7.
std::vector<int> got = completed_subcounts(agg, 2025, 12, 22, 2026, 1, 19);
CHECK(got.size() >= 3);
for (int c : got) CHECK(c == 7); // pre-fix: the Dec29-Jan4 week split into 3+4
}

int main() {
test_monthly_subcounts_match_day_count();
test_weekly_full_weeks_are_seven();
test_weekly_year_boundary_not_split();
std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed);
return tests_failed == 0 ? 0 : 1;
}
Loading
Loading