release: v0.12.3

This commit is contained in:
github-actions[bot]
2026-07-15 11:32:05 +00:00
parent 18dc662d1a
commit e73d224cda
7 changed files with 54 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "manifoldbt" name = "manifoldbt"
version = "0.12.2" version = "0.12.3"
description = "Rust-powered backtesting engine for quantitative research" description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9" requires-python = ">=3.9"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+1 -1
View File
@@ -1,4 +1,4 @@
"""Min/max time-series decimation for plotting pure numpy. """Min/max time-series decimation for plotting - pure numpy.
A chart is ~1000-2500 px wide, so plotting 10^5-10^6 samples draws hundreds of A chart is ~1000-2500 px wide, so plotting 10^5-10^6 samples draws hundreds of
sub-pixel points per column and bloats saved HTML. Per pixel column we keep the sub-pixel points per column and bloats saved HTML. Per pixel column we keep the
+5 -5
View File
@@ -1,16 +1,16 @@
"""Clean dark theme modern, readable, quant-oriented (plotly template).""" """Clean dark theme - modern, readable, quant-oriented (plotly template)."""
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Color palette neutral dark, no decorative colors # Color palette - neutral dark, no decorative colors
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
WHITE = "#e8e6e3" WHITE = "#e8e6e3"
GRAY = "#8a8a8a" GRAY = "#8a8a8a"
DARK_GRAY = "#555555" DARK_GRAY = "#555555"
ACCENT = "#60a5fa" # Neutral blue primary data line ACCENT = "#60a5fa" # Neutral blue - primary data line
ACCENT_ALT = "#a78bfa" # Subtle purple secondary series ACCENT_ALT = "#a78bfa" # Subtle purple - secondary series
GREEN = "#22c55e" # Positive only GREEN = "#22c55e" # Positive only
RED = "#ef4444" # Negative only RED = "#ef4444" # Negative only
ORANGE = "#f59e0b" # OOS / warning ORANGE = "#f59e0b" # OOS / warning
@@ -27,7 +27,7 @@ FONT_FAMILY = "Inter, system-ui, Segoe UI, Arial, sans-serif"
MONO_FAMILY = "SF Mono, Fira Code, Cascadia Code, Consolas, monospace" MONO_FAMILY = "SF Mono, Fira Code, Cascadia Code, Consolas, monospace"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Colorscales (plotly format) same stops as the old matplotlib colormaps # Colorscales (plotly format) - same stops as the old matplotlib colormaps
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CS_DIVERGING = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#15803d"]] CS_DIVERGING = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#15803d"]]
CS_SEQUENTIAL = [[0.0, "#b91c1c"], [0.5, "#d97706"], [1.0, "#15803d"]] CS_SEQUENTIAL = [[0.0, "#b91c1c"], [0.5, "#d97706"], [1.0, "#15803d"]]
+1 -1
View File
@@ -67,7 +67,7 @@ def finalize(
raise RuntimeError( raise RuntimeError(
f"Static image export to {ext} is optional and needs kaleido. " f"Static image export to {ext} is optional and needs kaleido. "
"Install it with: pip install manifoldbt[png] " "Install it with: pip install manifoldbt[png] "
"(the default is the interactive chart save to .html)" "(the default is the interactive chart - save to .html)"
) from exc ) from exc
else: else:
write_responsive_html(fig, path) write_responsive_html(fig, path)
+1 -1
View File
@@ -124,7 +124,7 @@ def show() -> None:
_pending.clear() _pending.clear()
try: try:
import webview # noqa: F401 only to detect the backend import webview # noqa: F401 - only to detect the backend
except ImportError: except ImportError:
for div, title, _ in pending: for div, title, _ in pending:
_open_browser(div, title) _open_browser(div, title)
+40 -11
View File
@@ -1,4 +1,4 @@
"""Charts for research analysis results (sweep, walk-forward, stability) plotly.""" """Charts for research analysis results (sweep, walk-forward, stability) - plotly."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@@ -47,17 +47,46 @@ def _grid_window_size(nx: int, ny: int, plot: int = 720, cbar: int = 160,
return (int(pw + cbar), int(ph + top)) return (int(pw + cbar), int(ph + top))
def _plateau_best(grid: np.ndarray): def _moving_average_1d(a: np.ndarray, radius: int, axis: int) -> np.ndarray:
"""Plateau-optimal cell: Gaussian blur finds the center of the best stable """Edge-replicated moving average of window 2*radius+1 along axis (numpy)."""
region, not a lucky spike (overfit-resistant). sigma = ~5% of each axis.""" if radius < 1:
from scipy.ndimage import gaussian_filter return a
pad = [(radius, radius) if ax == axis else (0, 0) for ax in range(a.ndim)]
padded = np.pad(a, pad, mode="edge")
cumsum = np.cumsum(padded, axis=axis)
zero = np.zeros_like(np.take(cumsum, [0], axis=axis))
cumsum = np.concatenate([zero, cumsum], axis=axis)
n = a.shape[axis]
width = 2 * radius + 1
upper = np.take(cumsum, np.arange(width, width + n), axis=axis)
lower = np.take(cumsum, np.arange(0, n), axis=axis)
return (upper - lower) / width
def _box_blur_2d(a: np.ndarray, sigma_y: float, sigma_x: float, passes: int = 3) -> np.ndarray:
"""Separable box blur that approximates a Gaussian (central-limit theorem),
pure numpy. A scipy-free fallback for _plateau_best."""
out = a.astype(float)
ry, rx = max(1, int(round(sigma_y))), max(1, int(round(sigma_x)))
for _ in range(passes):
out = _moving_average_1d(out, ry, axis=0)
out = _moving_average_1d(out, rx, axis=1)
return out
def _plateau_best(grid: np.ndarray):
"""Plateau-optimal cell: a blur finds the center of the best stable region,
not a lucky spike (overfit-resistant). sigma = ~5% of each axis. Uses
scipy's Gaussian filter when installed, else a pure-numpy box blur so the
plotting extra needs no scipy."""
filled = np.nan_to_num(grid, nan=np.nanmin(grid))
sigma_y = max(1.0, grid.shape[0] * 0.05) sigma_y = max(1.0, grid.shape[0] * 0.05)
sigma_x = max(1.0, grid.shape[1] * 0.05) sigma_x = max(1.0, grid.shape[1] * 0.05)
smoothed = gaussian_filter( try:
np.nan_to_num(grid, nan=np.nanmin(grid)), from scipy.ndimage import gaussian_filter
sigma=(sigma_y, sigma_x), smoothed = gaussian_filter(filled, sigma=(sigma_y, sigma_x))
) except ImportError:
smoothed = _box_blur_2d(filled, sigma_y, sigma_x)
return np.unravel_index(np.argmax(smoothed), smoothed.shape) return np.unravel_index(np.argmax(smoothed), smoothed.shape)
@@ -137,7 +166,7 @@ def heatmap_2d(
best_label = f"best: {best_val:{fmt}} ({x_param}={best_x:.0f}, {y_param}={best_y:.0f})" best_label = f"best: {best_val:{fmt}} ({x_param}={best_x:.0f}, {y_param}={best_y:.0f})"
combos = nx * ny combos = nx * ny
main_title = title or f"{metric} Parameter Sweep ({combos:,} combos)" main_title = title or f"{metric} · Parameter Sweep ({combos:,} combos)"
if best_label: if best_label:
main_title = f"{main_title}<br><span style='font-size:11px;color:{GRAY}'>{best_label}</span>" main_title = f"{main_title}<br><span style='font-size:11px;color:{GRAY}'>{best_label}</span>"
fig.update_layout(title_text=main_title, hovermode="closest") fig.update_layout(title_text=main_title, hovermode="closest")
@@ -224,7 +253,7 @@ def surface_3d(
) )
combos = len(x_vals) * len(y_vals) combos = len(x_vals) * len(y_vals)
main_title = title or f"{metric} Surface ({combos:,} combos)" main_title = title or f"{metric} · Surface ({combos:,} combos)"
if best_label: if best_label:
main_title = f"{main_title}<br><span style='font-size:11px;color:{GRAY}'>{best_label}</span>" main_title = f"{main_title}<br><span style='font-size:11px;color:{GRAY}'>{best_label}</span>"
+5 -5
View File
@@ -1,4 +1,4 @@
"""Composite tearsheet HTML strategy report with interactive plotly charts.""" """Composite tearsheet - HTML strategy report with interactive plotly charts."""
from __future__ import annotations from __future__ import annotations
import tempfile import tempfile
@@ -144,7 +144,7 @@ def tearsheet(
dpi: int = 150, dpi: int = 150,
plotlyjs: str = "cdn", plotlyjs: str = "cdn",
) -> str: ) -> str:
"""Strategy report self-contained HTML page with interactive charts. """Strategy report - self-contained HTML page with interactive charts.
Returns the HTML string. Opens in browser when ``show=True``, Returns the HTML string. Opens in browser when ``show=True``,
writes to disk when ``save`` is given. writes to disk when ``save`` is given.
@@ -225,7 +225,7 @@ def tearsheet(
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(strategy_name)} Tearsheet</title> <title>{escape(strategy_name)} · Tearsheet</title>
<style>{_CSS}</style> <style>{_CSS}</style>
{plotly_js_tag} {plotly_js_tag}
</head> </head>
@@ -296,7 +296,7 @@ def research_report(
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
dpi: int = 150, dpi: int = 150,
) -> List[Any]: ) -> List[Any]:
"""Research report one figure per analysis (plotly Figures).""" """Research report - one figure per analysis (plotly Figures)."""
from manifoldbt.plot.research import ( from manifoldbt.plot.research import (
heatmap_2d, heatmap_2d,
stability, stability,
@@ -340,7 +340,7 @@ def research_report(
def _fmt_hold_time(seconds): def _fmt_hold_time(seconds):
"""Format holding time in human-readable units.""" """Format holding time in human-readable units."""
if seconds <= 0: if seconds <= 0:
return "" return "-"
days = seconds / 86400 days = seconds / 86400
if days >= 365: if days >= 365:
return f"{days / 365:.1f}y" return f"{days / 365:.1f}y"