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: 2 additions & 0 deletions docker/report_annualization.def
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Sub-day boundary for simple proportional CAGR, included by the kernel.
PF_ANNUALIZATION_SPAN_MS(86400000)
137 changes: 120 additions & 17 deletions docker/run_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"longs": { ...pf_trade_stats_t... }, # long trades only
"shorts": { ...pf_trade_stats_t... }, # short trades only
"equity": { ...pf_equity_stats_t... } # sharpe/sortino/cagr/calmar/...
}, # any NaN statistic -> null (see _num)
}, # defined metric absence -> null (see _num)
"equity_curve": [ # ABI v2: one point per script bar
{ "time_ms": int, "equity": float, "open_profit": float },
...
Expand Down Expand Up @@ -129,7 +129,8 @@

NaN convention: any metric with an empty/zero denominator is null (JSON has no
NaN); a real computed 0 stays 0. See the report-schema + metrics reference docs
for the per-field meaning of every metrics.* key.
for the per-field meaning of every metrics.* key. Overflow and other computed
nonfinite report values fail as report_result_invalid, reason nonfinite.

Selected-window mode (selected-window wire v1.3, full/v1 report, no digest). Opt in
with --report-policy selected-window/v1, --window-start-ms T, --window-end-ms E,
Expand Down Expand Up @@ -2210,6 +2211,13 @@ def failure_line(text, code=None, code_args=None) -> str:
(_cut_utf8(_no_surrogates(value), ERROR_ARG_TEXT_MAX)
if isinstance(value, str) else value)
for name, value in (code_args or {}).items()}
if code == "report_result_invalid":
# Translate native metric fields at the public wire boundary.
field = doc["args"].get("field")
if isinstance(field, str):
prefix, separator, name = field.rpartition(".")
if prefix == "metrics.equity":
doc["args"]["field"] = prefix + separator + EQUITY_REPORT_KEYS.get(name, name)
line = _dump_line(doc)
if len(line) > ERROR_LINE_MAX:
if code is not None and len(_dump_line({**doc, "error": ""})) > ERROR_LINE_MAX:
Expand Down Expand Up @@ -3543,12 +3551,13 @@ def fmt_utc(ms: int) -> str:
ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")


def _num(x):
"""JSON-safe float. The engine's metric NaN convention (empty / zero
denominator -> NaN, never 0) cannot survive JSON: json.dump emits a bare
`NaN` token that a strict downstream JSON.parse (the MCP layer) rejects.
Collapse every non-finite double to null so the report stays valid JSON."""
def _num(x, *, report_metric=False):
"""Native metric NaN is the producer's explicit defined-absence sentinel.
Preserve infinity until _validate_report_result can raise the typed R.
Recorded script outputs have their separate nonfinite-to-null convention."""
f = float(x)
if report_metric:
return None if math.isnan(f) else f
return f if math.isfinite(f) else None


Expand All @@ -3559,16 +3568,79 @@ def _num(x):
EQUITY_REPORT_KEYS = {"sharpe_monthly": "sharpe_tv", "sortino_monthly": "sortino_tv"}


# Proportional annualisation (revision 3.1): a span under one day annualises cagr
# by the simple proportional form; one day or more keeps the compounded form. The
# bound is docker/report_annualization.def's; run_json.py ships alone, so
# tests/test_run_json_nonfinite_report.py pins this copy to the native one.
MIN_ANNUALIZATION_SPAN_MS = 86_400_000


def _annualization_diagnostics(curve, metrics, trades=(), window_anchor=False):
"""Name the simple proportional form of cagr, and of the calmar made from it.

No span makes cagr, calmar, sharpe_bar or sortino_bar null, so the writer
names the form from what it holds. Under one day the kernel always takes
the simple form: a finite cagr on a positive sub-day span is flagged. From
one day on it takes the compounded form unless that is not finite, so the
writer repeats the kernel's arithmetic on the curve's own marks: the ratio
of the last mark to the initial capital. The initial capital is the first
mark only when the rows prove it untouched: the selected-window anchor
(window_anchor), or no open profit and no trade entered at it. With no such
proof the writer makes no claim and names nothing. calmar is cagr over the
drawdown percent, so it carries the same flag as the cagr it was computed
from: its key is present exactly when the cagr key is and calmar is not
null (zero drawdown leaves it null: then no calmar key). The bar ratios use
one formula on every span and have no reason.
Python's integer subtraction is exact across the full int64 timestamp
domain; convert the difference to double just as the kernel does.
"""
cagr = metrics["equity"]["cagr"]
if not curve or cagr is None or not math.isfinite(cagr):
return {}
first, last = curve[0]["time_ms"], curve[-1]["time_ms"]
span_ms = float(last - first) if last >= first else -float(first - last)
if not span_ms > 0:
return {}
if span_ms < MIN_ANNUALIZATION_SPAN_MS:
form = "simple_proportional_subday"
else:
if not window_anchor and (curve[0]["open_profit"] != 0
or any(row["entry_bar_index"] <= 0 for row in trades)):
return {}
capital, final = curve[0]["equity"], curve[-1]["equity"]
if not (math.isfinite(capital) and capital > 0 and math.isfinite(final) and final > 0):
return {}
span_years = span_ms / (365.25 * 86400.0 * 1000.0)
try:
compounded = (math.pow(final / capital, 1.0 / span_years) - 1.0) * 100.0
except OverflowError:
compounded = math.inf
if math.isfinite(compounded):
return {}
form = "simple_proportional_overflow"
if metrics["equity"]["calmar"] is None:
return {"cagr": form}
return {"cagr": form, "calmar": form}


def _annualization_block(curve, metrics, trades=(), window_anchor=False) -> dict:
"""The diagnostics entry for revision 3.1: present only when cagr used a simple form."""
reasons = _annualization_diagnostics(curve, metrics, trades, window_anchor)
return {"annualization": reasons} if reasons else {}


def _stats_dict(s) -> dict:
"""Serialize a pf_trade_stats_t / pf_equity_stats_t ctypes struct to a dict,
keying off each field's ctype: integer counters stay ints, every double is
sanitized through _num. Driven by _fields_ so it tracks the struct verbatim;
normalized through _num's metric mode. Computed nonfinite values must
already have failed native M checks; infinities also fail the C backstop.
Driven by _fields_ so it tracks the struct verbatim;
an equity struct's two monthly ratios take their EQUITY_REPORT_KEYS."""
keys = EQUITY_REPORT_KEYS if isinstance(s, EquityStatsC) else {}
out = {}
for name, ctype in s._fields_:
v = getattr(s, name)
out[keys.get(name, name)] = _num(v) if ctype is ctypes.c_double else int(v)
out[keys.get(name, name)] = _num(v, report_metric=True) if ctype is ctypes.c_double else int(v)
return out


Expand All @@ -3578,7 +3650,10 @@ def build_report_dict(report: ReportC, ohlcv_path: Path,
applied_inputs: dict[str, str],
applied_overrides: dict[str, str],
applied_runtime: dict[str, object] | None = None,
trade_entry_incarnations: list[int] | None = None) -> dict:
trade_entry_incarnations: list[int] | None = None,
window_anchor: bool = False) -> dict:
# window_anchor: the curve's first point is the selected-window anchor, the
# engine's own initial capital by construction (_annualization_diagnostics).
trades = []
pnls: list[float] = []
for i in range(report.trades_len):
Expand Down Expand Up @@ -3613,9 +3688,18 @@ def build_report_dict(report: ReportC, ohlcv_path: Path,

cum, peak, max_dd = 0.0, 0.0, 0.0
for p in pnls:
if not math.isfinite(p):
raise RunFailure("the report result trades.pnl is not finite",
"report_result_invalid",
{"field": "trades.pnl", "reason": "nonfinite"})
cum += p
peak = max(peak, cum)
max_dd = min(max_dd, cum - peak)
drawdown = cum - peak
if not math.isfinite(cum) or not math.isfinite(drawdown):
raise RunFailure("the report result summary.max_drawdown is not finite",
"report_result_invalid",
{"field": "summary.max_drawdown", "reason": "nonfinite"})
max_dd = min(max_dd, drawdown)

# Computed trading metrics (ABI v2): all/longs/shorts trade stats + the
# equity-curve-derived block (sharpe/sortino/cagr/calmar/...). See the
Expand All @@ -3636,8 +3720,8 @@ def build_report_dict(report: ReportC, ohlcv_path: Path,
p = report.equity_curve[i]
equity_curve.append({
"time_ms": int(p.time_ms),
"equity": _num(p.equity),
"open_profit": _num(p.open_profit),
"equity": float(p.equity),
"open_profit": float(p.open_profit),
})

return {
Expand Down Expand Up @@ -3674,6 +3758,7 @@ def build_report_dict(report: ReportC, ohlcv_path: Path,
"magnifier_sub_bars_total": int(report.magnifier_sub_bars_total),
"magnifier_sample_ticks_total": int(report.magnifier_sample_ticks_total),
"bar_magnifier_enabled": bool(report.bar_magnifier_enabled),
**_annualization_block(equity_curve, metrics, trades, window_anchor),
},
"trades": trades,
"metrics": metrics,
Expand All @@ -3682,9 +3767,9 @@ def build_report_dict(report: ReportC, ohlcv_path: Path,


def _validate_report_result(out: dict) -> None:
"""Canonical trade and summary doubles have no defined non-finite absence.
Keep the failure fields as engine-owned literals; metrics, equity and
recorded outputs retain their existing _num-to-null convention."""
"""Required results are finite; only native metric absence is nullable.
Metric keys come from the producer's ctypes descriptors and alias map,
after native computation has distinguished absence from computed NaN."""
for trade in out["trades"]:
for key, field in (
("entry_price", "trades.entry_price"),
Expand All @@ -3701,6 +3786,7 @@ def _validate_report_result(out: dict) -> None:
"report_result_invalid",
{"field": field, "reason": "nonfinite"})
for key, field in (
("win_rate_pct", "summary.win_rate_pct"),
("net_pnl", "summary.net_pnl"),
("avg_trade", "summary.avg_trade"),
("best_trade", "summary.best_trade"),
Expand All @@ -3711,6 +3797,22 @@ def _validate_report_result(out: dict) -> None:
raise RunFailure(f"the report result {field} is not finite",
"report_result_invalid",
{"field": field, "reason": "nonfinite"})
for block in ("all", "longs", "shorts", "equity"):
for name, value in out["metrics"][block].items():
if value is not None and not math.isfinite(value):
metric_field = f"metrics.{block}.{name}"
raise RunFailure(f"the report result {metric_field} is not finite",
"report_result_invalid",
{"field": metric_field, "reason": "nonfinite"})
for point in out["equity_curve"]:
for key, field in (
("equity", "equity_curve.equity"),
("open_profit", "equity_curve.open_profit"),
):
if not math.isfinite(point[key]):
raise RunFailure(f"the report result {field} is not finite",
"report_result_invalid",
{"field": field, "reason": "nonfinite"})


def _request_invalid(text: str, option: str, exit_status: int = 1) -> RunFailure:
Expand Down Expand Up @@ -4387,7 +4489,8 @@ def _make_state():
if incarnation_accessor is not None else None)
out = build_report_dict(
report, args.ohlcv, fed_count, plan["fed_first_data_ms"], plan["fed_last_data_ms"],
elapsed, inputs, overrides, applied_runtime, trade_entry_incarnations)
elapsed, inputs, overrides, applied_runtime, trade_entry_incarnations,
window_anchor=True)
_validate_report_result(out)
out["report_window"] = window
out["report_shape"] = swr.report_shape_full(len(out["equity_curve"]),
Expand Down
6 changes: 4 additions & 2 deletions docker/selected_window_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ def _established_number(value: Any) -> Optional[float]:

def _finite_actual(value: Any) -> bool:
"""Whether value, as the engine produced it, is a finite number: an int or a float that is
not a bool, not None (the report writes a non-finite double as null), not text, and not NaN
not a bool, not None (executed curve marks must be finite), not text, and not NaN
or an infinity. Nothing is converted or read from text."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False
Expand All @@ -619,7 +619,9 @@ def check_selected_curve(curve: Sequence[Mapping[str, Any]], start_ms: int,
with the capital is skipped. No capital is guessed or written back, and every other check
above, the finiteness of the actual anchor equity included, is made in both cases. When the
provenance does establish a finite capital (a number or text spelling one), the anchor
equity must equal it, or the run is an engine invariant failure."""
equity must equal it, or the run is an engine invariant failure. The anchor is also the
initial capital the report writer recomputes the compounded cagr from, with or without an
established capital (run_json._annualization_diagnostics, window_anchor)."""
if len(curve) != window_script_bars + 1:
raise _invariant(f"the selected curve has {len(curve)} points, the plan says "
f"{window_script_bars + 1}")
Expand Down
Loading
Loading