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
8 changes: 4 additions & 4 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1295,10 +1295,10 @@ class BacktestEngine {
// affordability event can trim it.
void process_margin_call(const Bar& bar);
// A timestamped FX rollover is a broker-open event, not an end-of-bar
// adverse-price check. This narrow path applies only to a carried 1x long
// in ordinary historical dispatch and returns true when it emits a broker
// liquidation row.
bool process_carried_long_full_margin_fx_rollover(const Bar& bar);
// adverse-price check. Cell A1 supports carried 1x full-margin long and
// short in ordinary historical dispatch; leveraged shapes stay fail-closed.
// Returns true when it emits a broker liquidation row.
bool process_carried_position_fx_rollover(const Bar& bar);

// --- Slippage helper ---
double round_to_mintick(double price) const {
Expand Down
81 changes: 80 additions & 1 deletion scripts/run_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,72 @@ def find_strategy_lib(strategy_dir: Path, so_name: str = "strategy.so") -> Path:
return so_path


def _load_account_currency_fx_series_json(path: Path):
"""Load a bar-grain effective-time FX series JSON.

Accepted shapes (effective timestamps already resolved by the producer):
{"timestamps_ms": [int, ...], "rates": [float, ...], ...}
{"points": [{"t"|"timestamp_ms"|"timestamp": int, "rate"|"c2a1": float}, ...]}

Unlike the daily-close loader, this path does **not** apply a D+1 shift —
the file is the production as-of curve copied straight into the C ABI.
"""
raw_bytes = path.read_bytes()
payload = json.loads(raw_bytes)
points = []
if isinstance(payload, dict) and isinstance(payload.get("timestamps_ms"), list) and isinstance(payload.get("rates"), list):
timestamps = payload["timestamps_ms"]
rates = payload["rates"]
if len(timestamps) != len(rates) or not timestamps:
raise ValueError(
f"account_currency_fx_series_json timestamps/rates mismatch: {path}")
for ts, rate in zip(timestamps, rates):
try:
t_ms = int(ts)
r = float(rate)
except (TypeError, ValueError) as exc:
raise ValueError(
f"invalid account-currency FX series point {ts!r}: {rate!r}"
) from exc
Comment on lines +835 to +841
if isinstance(rate, bool) or not math.isfinite(r) or r <= 0.0:
raise ValueError(
f"invalid account-currency FX series rate {ts!r}: {rate!r}")
points.append((t_ms, r))
elif isinstance(payload, dict) and isinstance(payload.get("points"), list):
if not payload["points"]:
raise ValueError(
f"account_currency_fx_series_json points must be non-empty: {path}")
for item in payload["points"]:
if not isinstance(item, dict):
raise ValueError(
f"invalid account-currency FX series point {item!r}: {path}")
raw_t = item.get("t", item.get("timestamp_ms", item.get("timestamp")))
raw_r = item.get("rate", item.get("c2a1"))
try:
t_ms = int(raw_t)
r = float(raw_r)
except (TypeError, ValueError) as exc:
raise ValueError(
f"invalid account-currency FX series point {item!r}"
) from exc
if isinstance(raw_r, bool) or not math.isfinite(r) or r <= 0.0:
raise ValueError(
f"invalid account-currency FX series rate {item!r}")
points.append((t_ms, r))
else:
raise ValueError(
"account_currency_fx_series_json must contain timestamps_ms+rates "
f"or points[]: {path}")
points.sort()
if any(points[i][0] <= points[i - 1][0] for i in range(1, len(points))):
raise ValueError(
f"account_currency_fx_series_json timestamps must be strictly increasing: {path}")
return (
([point[0] for point in points], [point[1] for point in points]),
hashlib.sha256(raw_bytes).hexdigest(),
)


def _load_account_currency_fx_daily_closes(path: Path):
"""Load ``{"YYYY-MM-DD": close}`` and shift each close to D+1 00:00Z.

Expand Down Expand Up @@ -924,9 +990,22 @@ def _num(v):

fx_series = None
fx_source_sha256 = None
fx_series_json = runtime_overrides.get(
"account_currency_fx_series_json")
fx_daily_json = runtime_overrides.get(
"account_currency_fx_daily_close_json")
if fx_daily_json:
if fx_series_json and fx_daily_json:
raise ValueError(
"runtime_overrides may set only one of "
"account_currency_fx_series_json or "
"account_currency_fx_daily_close_json")
if fx_series_json:
fx_path = Path(str(fx_series_json))
if not fx_path.is_absolute():
fx_path = (strategy_dir / fx_path).resolve()
fx_series, fx_source_sha256 = \
_load_account_currency_fx_series_json(fx_path)
elif fx_daily_json:
fx_path = Path(str(fx_daily_json))
if not fx_path.is_absolute():
fx_path = (strategy_dir / fx_path).resolve()
Expand Down
72 changes: 45 additions & 27 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -501,11 +501,18 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order(
return result;
}

// Timestamped quote->account FX rollover for a carried 1x long. Unlike the
// ordinary adverse-price pass below, this is consumed at the first broker open
// under the newly effective provider epoch, before pending orders and on_bar.
bool BacktestEngine::process_carried_long_full_margin_fx_rollover(
const Bar& bar) {
// Timestamped quote->account FX rollover for a carried full-margin position.
// Unlike the ordinary adverse-price pass below, this is consumed at the first
// broker open under the newly effective provider epoch, before pending orders
// and on_bar. Cell A1: 1x long (bit-stable) + 1x short. Leveraged long/short
// stay fail-closed until a TV pin (cells L/R).
bool BacktestEngine::process_carried_position_fx_rollover(const Bar& bar) {
// Capability flags for the broker-open FX rollover matrix. Short 1x is
// the dual of the TV-pinned long path; leveraged cells remain off.
static constexpr bool kEnableShortFxRollover = true;
static constexpr bool kEnableLeveragedLongFxRollover = false;
static constexpr bool kEnableLeveragedShortFxRollover = false;

if (account_currency_fx_timestamps_.empty()) return false;

const auto effective_end = std::upper_bound(
Expand Down Expand Up @@ -536,30 +543,34 @@ bool BacktestEngine::process_carried_long_full_margin_fx_rollover(
const bool carried_position =
position_side_ != PositionSide::FLAT
&& position_open_bar_ < bar_index_;
const double carried_margin = position_side_ == PositionSide::LONG
? margin_long_ : margin_short_;
const bool is_long = position_side_ == PositionSide::LONG;
const double margin_pct = is_long ? margin_long_ : margin_short_;
const bool full_margin = std::isfinite(margin_pct)
&& std::abs(margin_pct / 100.0 - 1.0) < 1e-12;
const bool leveraged = std::isfinite(margin_pct)
&& margin_pct > 0.0
&& !full_margin;
const bool supported_carried_rollover =
position_side_ == PositionSide::LONG
&& std::isfinite(margin_long_)
&& std::abs(margin_long_ / 100.0 - 1.0) < 1e-12;
margin_call_enabled_
&& carried_position
&& ((is_long
&& (full_margin || (leveraged && kEnableLeveragedLongFxRollover)))
|| (!is_long
&& kEnableShortFxRollover
&& (full_margin
|| (leveraged && kEnableLeveragedShortFxRollover))));
if (effective_rate != previous_rate
&& margin_call_enabled_
&& carried_position
&& std::isfinite(carried_margin)
&& carried_margin > 0.0
&& std::isfinite(margin_pct)
&& margin_pct > 0.0
&& !supported_carried_rollover) {
throw std::runtime_error(
"timestamped account-currency FX broker-open rollover supports "
"only carried 1x long positions");
"only carried 1x full-margin positions");
}

const bool carried_long_full_margin =
margin_call_enabled_
&& position_side_ == PositionSide::LONG
&& position_open_bar_ < bar_index_
&& std::isfinite(margin_long_)
&& std::abs(margin_long_ / 100.0 - 1.0) < 1e-12;
if (!carried_long_full_margin
if (!supported_carried_rollover
|| !std::isfinite(previous_rate) || !(previous_rate > 0.0)
|| !std::isfinite(effective_rate) || !(effective_rate > 0.0)
|| effective_rate == previous_rate
Expand All @@ -569,31 +580,38 @@ bool BacktestEngine::process_carried_long_full_margin_fx_rollover(

const double qty = position_qty_;
const double pv = syminfo_.pointvalue;
const double side = is_long ? 1.0 : -1.0;
const double m = margin_pct / 100.0;
if (!std::isfinite(qty) || !(qty > 0.0)
|| !std::isfinite(position_entry_price_)
|| !std::isfinite(pv)
|| !std::isfinite(m) || !(m > 0.0)
|| !std::isfinite(initial_capital_)
|| !std::isfinite(net_profit_sum_)) {
return false;
}

// TV revalues a carried 1x long at the first broker open under the newly
// confirmed rate. Entry fees are immediate account-currency costs; this
// engine otherwise realizes both fee legs when a trade closes, so include
// the still-open entry fees explicitly in the affordability ledger.
// TV revalues a carried full-margin position at the first broker open
// under the newly confirmed rate. Entry fees are immediate
// account-currency costs; this engine otherwise realizes both fee legs
// when a trade closes, so include the still-open entry fees explicitly
// in the affordability ledger. MTM uses side (+1 long / -1 short);
// margin_unit scales by m (1.0 for full margin).
double entry_commission = 0.0;
for (const auto& pe : pyramid_entries_) {
if (pe.qty <= kQtyEpsilon) continue;
const double lot_commission = open_entry_commission(pe);
if (!std::isfinite(lot_commission)) return false;
entry_commission += lot_commission;
}
const double margin_per_unit = bar.open * pv * effective_rate;
const double margin_per_unit = bar.open * pv * effective_rate * m;
const double mtm = side * (bar.open - position_entry_price_)
* qty * pv * effective_rate;
const double opening_equity = initial_capital_ + net_profit_sum_
- entry_commission
+ (bar.open - position_entry_price_) * qty * pv * effective_rate;
- entry_commission + mtm;
if (!std::isfinite(entry_commission)
|| !std::isfinite(margin_per_unit) || !(margin_per_unit > 0.0)
|| !std::isfinite(mtm)
|| !std::isfinite(opening_equity)) {
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ void BacktestEngine::dispatch_bar() {
current_bar_ = Bar{script_bar.open, script_bar.open, script_bar.open,
script_bar.open, 0.0, script_bar.timestamp};
try {
process_carried_long_full_margin_fx_rollover(script_bar);
process_carried_position_fx_rollover(script_bar);
} catch (...) {
current_bar_ = script_bar;
throw;
Expand Down
Loading
Loading