From 2c7b10cf4a55d4e34e7c54004962f8bef4006f5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Feb 2026 20:49:39 +0000 Subject: [PATCH 01/16] fix: harden HTML report and filesystem CLI Co-authored-by: Manuel H. --- src/main.py | 49 ++++++++++++++++++--- src/reporting/html.py | 85 +++++++++++++++++++++++++------------ tests/test_html_reporter.py | 13 ++++++ tests/test_main_cli.py | 53 +++++++++++++++++++++++ 4 files changed, 167 insertions(+), 33 deletions(-) diff --git a/src/main.py b/src/main.py index 3ecc8df..70c2088 100644 --- a/src/main.py +++ b/src/main.py @@ -651,12 +651,30 @@ def package_run( ): import shutil - run_dir = Path(reports_dir) / run_id + def _safe_segment(value: str, label: str) -> str: + """Validate a user-supplied path segment (no traversal).""" + + if not value: + raise typer.BadParameter(f"{label} must not be empty") + if "/" in value or "\\" in value: + raise typer.BadParameter(f"{label} must be a simple name (no path separators)") + if value.startswith(".") or ".." in value: + raise typer.BadParameter(f"Invalid {label}") + if Path(value).is_absolute(): + raise typer.BadParameter(f"Invalid {label}") + return value + + run_id = _safe_segment(run_id, "run_id") + + reports_root = Path(reports_dir).resolve() + run_dir = (reports_root / run_id).resolve() + if not run_dir.is_relative_to(reports_root): + raise typer.BadParameter("Invalid run_id") if not run_dir.exists() or not run_dir.is_dir(): typer.secho(f"Run directory not found: {run_dir}", fg=typer.colors.RED) raise typer.Exit(code=1) - out_path = Path(output) if output else Path(reports_dir) / f"{run_id}.zip" + out_path = Path(output) if output else reports_root / f"{run_id}.zip" out_path.parent.mkdir(parents=True, exist_ok=True) base_name = out_path.with_suffix("") archive = shutil.make_archive(str(base_name), "zip", run_dir) @@ -675,6 +693,8 @@ def clean_cache( ): """Permanently delete stale cache files beyond the retention window.""" + import os + now = datetime.now(UTC) threshold = now - timedelta(days=max_age_days) targets: list[tuple[str, Path]] = [("data", Path(cache_dir))] @@ -689,7 +709,23 @@ def clean_cache( typer.echo(f"cache-clean: {label} cache not found at {root}, skipping") continue - candidate_files = [file for file in root.rglob("*") if file.is_file()] + root = root.resolve() + candidate_files: list[Path] = [] + # Avoid following symlinked directories (which could escape the cache root). + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + dir_path = Path(dirpath) + for name in filenames: + file_path = dir_path / name + # Skip symlinks to avoid acting on files outside cache roots. + if file_path.is_symlink(): + continue + try: + if not file_path.resolve().is_relative_to(root): + continue + except Exception: + continue + if file_path.is_file(): + candidate_files.append(file_path) if not candidate_files: typer.echo(f"cache-clean: {label} cache at {root} has no files") continue @@ -716,8 +752,11 @@ def clean_cache( typer.echo(f"cache-clean: failed to remove {file_path}: {exc}", err=True) if not dry_run: - # Remove emptied directories bottom-up. - for dir_path in sorted({p.parent for p in candidate_files}, reverse=True): + # Remove emptied directories bottom-up (do not follow symlink dirs). + for dirpath, dirnames, filenames in os.walk(root, topdown=False, followlinks=False): + dir_path = Path(dirpath) + if dir_path == root: + continue try: if dir_path.exists() and not any(dir_path.iterdir()): dir_path.rmdir() diff --git a/src/reporting/html.py b/src/reporting/html.py index 852ccc1..5f04b08 100644 --- a/src/reporting/html.py +++ b/src/reporting/html.py @@ -1,5 +1,6 @@ from __future__ import annotations +import html as html_stdlib import json import math from pathlib import Path @@ -27,6 +28,25 @@ def __init__( self.inline_css = inline_css def export(self, best: list[object]): + def _esc(value: Any) -> str: + if value is None: + return "" + return html_stdlib.escape(str(value), quote=True) + + def _json_for_inline_script(value: Any) -> str: + """JSON-safe for embedding directly in a ` breakouts by escaping `<` and also guards common HTML parser + edge cases (`>`, `&`). Values come from configs/strategies and may be user-controlled. + """ + + text = json.dumps(value, ensure_ascii=True) + return ( + text.replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + ) + # Load all rows for top-N sections from results cache all_rows = self.cache.list_by_run(self.run_id) # Fallback: if the current run reused cached results and didn't write @@ -101,12 +121,12 @@ def strategy_section() -> str: row = strategy_best[key] rows_html.append( "" - f"{row.get('symbol')}" - f"{row.get('strategy')}" - f"{row.get('timeframe')}" - f"{row.get('metric')}" + f"{_esc(row.get('symbol'))}" + f"{_esc(row.get('strategy'))}" + f"{_esc(row.get('timeframe'))}" + f"{_esc(row.get('metric'))}" f"{row.get('metric_value', float('nan')):.4f}" - f"{row.get('collection')}" + f"{_esc(row.get('collection'))}" "" ) body = "\n".join(rows_html) @@ -142,12 +162,12 @@ def metric_section() -> str: row = entry["row"] rows_html.append( "" - f"{metric_name.title()}" + f"{_esc(metric_name.title())}" f"{entry['value']:.4f}" - f"{row.get('strategy')}" - f"{row.get('collection')}" - f"{row.get('symbol')}" - f"{row.get('timeframe')}" + f"{_esc(row.get('strategy'))}" + f"{_esc(row.get('collection'))}" + f"{_esc(row.get('symbol'))}" + f"{_esc(row.get('timeframe'))}" "" ) if not rows_html: @@ -182,11 +202,11 @@ def top_section() -> str: rows_html.append( "" f"{idx}" - f"{row.get('collection')}" - f"{row.get('symbol')}" - f"{row.get('strategy')}" - f"{row.get('timeframe')}" - f"{row.get('metric')}" + f"{_esc(row.get('collection'))}" + f"{_esc(row.get('symbol'))}" + f"{_esc(row.get('strategy'))}" + f"{_esc(row.get('timeframe'))}" + f"{_esc(row.get('metric'))}" f"{row.get('metric_value', float('nan')):.4f}" "" ) @@ -245,7 +265,7 @@ def _safe_float(val: Any) -> float | None: ) scatter_section = "" - chart_json = json.dumps(chart_data) + chart_json = _json_for_inline_script(chart_data) if chart_data: scatter_section = """
@@ -273,7 +293,7 @@ def _safe_float(val: Any) -> float | None: } ) - detail_json = json.dumps(detail_records) + detail_json = _json_for_inline_script(detail_records) detail_section = "" if detail_records: @@ -309,13 +329,13 @@ def card_for_row(row: dict[str, Any]) -> str: return f"""
-

{row.get("collection", "")} / {row.get("symbol", "")}

- {row.get("timeframe", "")} +

{_esc(row.get("collection", ""))} / {_esc(row.get("symbol", ""))}

+ {_esc(row.get("timeframe", ""))}
-
Strategy: {row.get("strategy", "")}
-
Metric: {row.get("metric", "")} = {float(row.get("metric_value", float("nan"))):.6f}
-
Params: {row.get("params", {})}
+
Strategy: {_esc(row.get("strategy", ""))}
+
Metric: {_esc(row.get("metric", ""))} = {float(row.get("metric_value", float("nan"))):.6f}
+
Params: {_esc(row.get("params", {}))}
Sharpe: {float(stats.get("sharpe", float("nan"))):.4f}
@@ -333,8 +353,8 @@ def card_for_row(row: dict[str, Any]) -> str: def table_for_topn(rows: list[dict[str, Any]]) -> str: rows = sorted(rows, key=lambda x: x["metric_value"], reverse=True)[: self.top_n] body = "\n".join( - f"{r['timeframe']}{r['strategy']}{r['metric']}{r['metric_value']:.6f}" - f"{r['params']}" + f"{_esc(r['timeframe'])}{_esc(r['strategy'])}{_esc(r['metric'])}{r['metric_value']:.6f}" + f"{_esc(r['params'])}" f"{r['stats'].get('sharpe', float('nan')):.4f}" f"{r['stats'].get('sortino', float('nan')):.4f}" f"{r['stats'].get('omega', float('nan')):.4f}" @@ -431,7 +451,7 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: const trace = {{ x: scatterData.map(r => r.metric_value), y: scatterData.map(r => r.sharpe), - text: scatterData.map(r => `${{r.collection}}/${{r.symbol}} • ${{r.strategy}} (${{r.timeframe}})`), + text: scatterData.map(r => `${{escapeHtml(r.collection)}}/${{escapeHtml(r.symbol)}} • ${{escapeHtml(r.strategy)}} (${{escapeHtml(r.timeframe)}})`), mode: 'markers', hovertemplate: '%{{text}}
Metric: %{{x:.4f}}
Sharpe: %{{y:.4f}}', marker: {{ @@ -458,6 +478,15 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: const detailCharts = document.getElementById('detail-charts'); const tradeTableContainer = document.getElementById('trade-table-container'); + function escapeHtml(value) {{ + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }} + function detailLabel(entry) {{ if (!entry) return '—'; const parts = [ @@ -484,10 +513,10 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: tradeTableContainer.innerHTML = '

No trades captured for this result.

'; return; }} - const headCells = headers.map(h => `${{h}}`).join(''); + const headCells = headers.map(h => `${{escapeHtml(h)}}`).join(''); const limit = Math.min(trades.length, 50); const bodyRows = trades.slice(0, 50).map(row => {{ - const cells = headers.map(h => `${{row[h] ?? ''}}`).join(''); + const cells = headers.map(h => `${{escapeHtml(row[h] ?? '')}}`).join(''); return `${{cells}}`; }}).join(''); tradeTableContainer.innerHTML = ` @@ -564,7 +593,7 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: const options = detailData.map((entry, idx) => {{ const label = detailLabel(entry) || `Result ${idx + 1}`; const selected = idx === 0 ? ' selected' : ''; - return ``; + return ``; }}).join(''); detailSelector.innerHTML = options; detailSelector.disabled = false; diff --git a/tests/test_html_reporter.py b/tests/test_html_reporter.py index 1b82ceb..5c8da85 100644 --- a/tests/test_html_reporter.py +++ b/tests/test_html_reporter.py @@ -77,3 +77,16 @@ def test_html_reporter_fallback_from_best_results(tmp_path: Path): output = (tmp_path / "report.html").read_text() assert "Backtest Report" in output + + +def test_html_reporter_escapes_user_content(tmp_path: Path): + # Symbols/strategy names can come from user configs and external strategy repos. + # The HTML reporter should escape content to avoid XSS when opening report.html. + xss = "" + rows = [_row(xss, 1.2)] + reporter = HTMLReporter(tmp_path, _DummyCache(rows), run_id="run-xss", top_n=1, inline_css=False) + reporter.export([]) + + output = (tmp_path / "report.html").read_text() + assert xss not in output + assert "<img src=x onerror=alert(1)>" in output diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py index 16b6696..23d8b4b 100644 --- a/tests/test_main_cli.py +++ b/tests/test_main_cli.py @@ -443,6 +443,24 @@ def fake_make_archive(base_name: str, fmt: str, root_dir: Path): assert "Packaged run" in result.stdout +def test_package_run_rejects_path_traversal(tmp_path: Path): + reports_dir = tmp_path / "reports" + (reports_dir / "latest").mkdir(parents=True) + + result = runner.invoke( + app, + [ + "package-run", + "../outside", + "--reports-dir", + str(reports_dir), + ], + ) + + assert result.exit_code != 0 + assert "run_id must be a simple name" in result.stdout or "Invalid run_id" in result.stdout + + def test_clean_cache_removes_old_files(tmp_path: Path): cache_dir = tmp_path / "cache" / "data" results_dir = tmp_path / "cache" / "results" @@ -493,3 +511,38 @@ def test_clean_cache_dry_run(tmp_path: Path): assert result.exit_code == 0 assert "DRY-RUN" in result.stdout assert target.exists() + + +def test_clean_cache_does_not_follow_symlink_dirs(tmp_path: Path): + cache_dir = tmp_path / "cache" / "data" + cache_dir.mkdir(parents=True) + + # File that should be deleted (old + inside cache root). + stale = cache_dir / "stale.parquet" + _make_cache_file(stale, age_days=20) + + # File that must NOT be deleted (outside cache root). + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_file = outside_dir / "keep.txt" + _make_cache_file(outside_file, age_days=20) + + # Symlink inside cache root pointing outside. + link_dir = cache_dir / "link-outside" + os.symlink(outside_dir, link_dir) + + result = runner.invoke( + app, + [ + "clean-cache", + "--cache-dir", + str(cache_dir), + "--max-age-days", + "7", + "--no-include-results", + ], + ) + + assert result.exit_code == 0 + assert not stale.exists() + assert outside_file.exists() From 41d168236d5d49c27fd05b2dcbe8d65e6df274ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Feb 2026 20:50:10 +0000 Subject: [PATCH 02/16] fix: satisfy ruff in clean-cache Co-authored-by: Manuel H. --- src/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 70c2088..cf371b4 100644 --- a/src/main.py +++ b/src/main.py @@ -712,7 +712,7 @@ def clean_cache( root = root.resolve() candidate_files: list[Path] = [] # Avoid following symlinked directories (which could escape the cache root). - for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + for dirpath, _dirnames, filenames in os.walk(root, followlinks=False): dir_path = Path(dirpath) for name in filenames: file_path = dir_path / name @@ -753,7 +753,7 @@ def clean_cache( if not dry_run: # Remove emptied directories bottom-up (do not follow symlink dirs). - for dirpath, dirnames, filenames in os.walk(root, topdown=False, followlinks=False): + for dirpath, _dirnames, _filenames in os.walk(root, topdown=False, followlinks=False): dir_path = Path(dirpath) if dir_path == root: continue From a73d481f2589d3a92d6767508ccbb279812717c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Feb 2026 20:51:41 +0000 Subject: [PATCH 03/16] chore: bump fastapi and starlette Co-authored-by: Manuel H. --- poetry.lock | 58 +++++++++++++++++++++++++++++++++++++++----------- pyproject.toml | 3 ++- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4f7ad92..971086c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -273,6 +273,18 @@ requests = ">=2.30.0,<3.0.0" sseclient-py = ">=1.7.2,<2.0.0" websockets = ">=10.4" +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -930,24 +942,27 @@ files = [ [[package]] name = "fastapi" -version = "0.115.14" +version = "0.128.7" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, - {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, + {file = "fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662"}, + {file = "fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24"}, ] [package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.47.0" +annotated-doc = ">=0.0.2" +pydantic = ">=2.7.0" +starlette = ">=0.40.0,<1.0.0" typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.9.3)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=5.8.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -3314,18 +3329,19 @@ files = [ [[package]] name = "starlette" -version = "0.46.2" +version = "0.52.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, - {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, + {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, + {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, ] [package.dependencies] anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] @@ -3413,6 +3429,21 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "tzdata" version = "2025.2" @@ -3494,6 +3525,7 @@ description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.1" groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -3975,4 +4007,4 @@ repair = ["scipy (>=1.6.3)"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.14" -content-hash = "ecc8e790f9139ebf5b788edfe97abaa1ee8d8b52ddbd8b96d8cdf39063558bb4" +content-hash = "5994f3b06e875df8dd4171533dd75c65ea6b4f10f85478e2fe14e82e6f3d6bbc" diff --git a/pyproject.toml b/pyproject.toml index ea8e889..de2cc58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,11 +26,12 @@ python-dotenv = "^1.0.1" numba = "^0.63.1" llvmlite = "^0.46.0" lib-pybroker = "^1.2.10" -fastapi = "^0.115.0" +fastapi = "^0.128.7" jinja2 = "^3.1.4" uvicorn = { version = "^0.30.0", extras = ["standard"] } httpx = "^0.27.2" optuna = "^3.6.1" +starlette = "^0.52.1" [tool.poetry.group.dev.dependencies] ruff = "^0.6.4" From 651a7c6b442b640b1c6f2bd1249c66338e7ffad7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:27:43 +0000 Subject: [PATCH 04/16] chore: vendor offline assets and harden docker Co-authored-by: Manuel H. --- docker-compose.yml | 1 - docker/Dockerfile | 25 +++- src/dashboard/server.py | 261 ++++++++++++++++++++++++------------ src/reporting/html.py | 108 ++++++++++----- tests/test_html_reporter.py | 3 + 5 files changed, 271 insertions(+), 127 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fa619b0..5d51440 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,6 @@ services: # Override STRATEGIES_PATH or DATA_CACHE_DIR via env if needed - STRATEGIES_PATH=/ext/strategies - DATA_CACHE_DIR=/app/.cache/data - - PATH=/root/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin env_file: - .env volumes: diff --git a/docker/Dockerfile b/docker/Dockerfile index ce72589..77814f1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,24 +2,30 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - POETRY_VERSION=1.8.3 \ POETRY_VIRTUALENVS_CREATE=false \ + POETRY_NO_INTERACTION=1 \ + POETRY_CACHE_DIR=/tmp/poetry-cache \ PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_DEFAULT_TIMEOUT=120 \ - PATH="/root/.local/bin:${PATH}" + PATH="/usr/local/bin:${PATH}" RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential curl git \ + build-essential git \ && rm -rf /var/lib/apt/lists/* -RUN curl -sSL https://install.python-poetry.org | python - --version ${POETRY_VERSION} +RUN python -m pip install --no-cache-dir --upgrade "pip==26.0.1" \ + && python -m pip install --no-cache-dir "poetry==2.2.1" -# Ensure poetry is on a standard PATH location -RUN ln -s /root/.local/bin/poetry /usr/local/bin/poetry || true +ARG APP_UID=1000 +ARG APP_GID=1000 +RUN set -eux; \ + groupadd -g "${APP_GID}" app || groupadd app; \ + useradd -m -u "${APP_UID}" -g "${APP_GID}" -s /bin/bash app WORKDIR /app -COPY pyproject.toml ./ +COPY pyproject.toml poetry.lock ./ # Retry install to mitigate transient PyPI timeouts in CI/builders RUN set -euo pipefail; \ poetry --version; \ @@ -37,4 +43,9 @@ RUN set -euo pipefail; \ COPY . /app +RUN mkdir -p /app/.cache /app/reports /tmp/poetry-cache \ + && chown -R app:app /app /tmp/poetry-cache + +USER app + CMD ["bash", "-lc", "poetry run python -m src.main --help"] diff --git a/src/dashboard/server.py b/src/dashboard/server.py index ebc9ce1..9f6aab9 100644 --- a/src/dashboard/server.py +++ b/src/dashboard/server.py @@ -17,6 +17,73 @@ def create_app(reports_dir: Path) -> FastAPI: root = Path(reports_dir) base_root: Path | None = None + base_css = """ + :root { + --bg: #020617; + --panel: #0f172a; + --panel-2: #111c33; + --text: #e2e8f0; + --muted: #94a3b8; + --border: rgba(148, 163, 184, 0.22); + --link: #38bdf8; + } + * { box-sizing: border-box; } + body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, + Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + } + a { color: var(--link); text-decoration: underline; } + a:hover { opacity: 0.9; } + code { font-family: ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } + .container { max-width: 72rem; margin: 0 auto; padding: 2rem 1rem; } + .stack > * + * { margin-top: 1rem; } + .card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 1rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + } + .row { display: flex; gap: 1rem; align-items: baseline; justify-content: space-between; flex-wrap: wrap; } + .muted { color: var(--muted); font-size: 0.9rem; } + .title { font-size: 1.25rem; font-weight: 700; margin: 0; } + .subtitle { margin: 0.25rem 0 0; } + .btn { + display: inline-block; + padding: 0.35rem 0.6rem; + border-radius: 0.5rem; + background: var(--panel-2); + border: 1px solid var(--border); + text-decoration: none; + color: var(--text); + font-size: 0.9rem; + } + table { width: 100%; border-collapse: collapse; } + thead th { + text-align: left; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + background: rgba(148, 163, 184, 0.08); + border-bottom: 1px solid var(--border); + padding: 0.6rem 0.75rem; + white-space: nowrap; + } + tbody td { + border-bottom: 1px solid var(--border); + padding: 0.55rem 0.75rem; + vertical-align: top; + } + tbody tr:hover td { background: rgba(148, 163, 184, 0.05); } + ul { margin: 0.25rem 0 0.25rem 1.2rem; padding: 0; } + li { margin: 0.2rem 0; } + .table-wrap { overflow-x: auto; } + """.strip() + @asynccontextmanager async def lifespan(app: FastAPI): nonlocal base_root @@ -144,15 +211,21 @@ async def index() -> HTMLResponse: "results_count": _escape(summary.get("results_count")), "started_at": _escape(summary.get("started_at")), "finished_at": _escape(summary.get("finished_at")), - "report_url": _escape(_file_url((run_dir / "report.html").resolve())), + "detail_url": _escape(f"/run/{_url_segment(run_dir.name)}"), + "report_url": _escape( + f"/api/runs/{_url_segment(run_dir.name)}/files/report.html" + ), } ) html_rows = "".join( - f"{r['run_id']}" - f"{r.get('metric', '')}" - f"{r.get('results_count', '')}" - f"{r.get('started_at', '')}" - f"Open report" + "" + f"{r['run_id']}" + f"{r.get('metric', '')}" + f"{r.get('results_count', '')}" + f"{r.get('started_at', '')}" + f"View" + f"Open" + "" for r in rows ) html = f""" @@ -162,30 +235,32 @@ async def index() -> HTMLResponse: Quant System Dashboard - + - -
+ +
-

Quant System Runs

-

Browse recent runs, review summaries, and open detailed reports.

+

Quant System Runs

+

Browse recent runs, review summaries, and open detailed reports.

-
- - - - - - - - - - - - - {html_rows if html_rows else ""} - -
Run IDMetricResultsStartedDetailReport
No runs available
+
+
+ + + + + + + + + + + + + {html_rows if html_rows else ""} + +
Run IDMetricResultsStartedDetailReport
No runs available
+
@@ -217,29 +292,33 @@ async def run_page(run_id: str) -> HTMLResponse: notifications = [] summary_rows = "".join( - f"{_escape(k)}{_escape(v)}" + f"{_escape(k)}{_escape(v)}" for k, v in summary.items() if k in {"metric", "results_count", "started_at", "finished_at", "duration_sec"} ) manifest_rows = ( "".join( - f"{_escape(m.get('run_id'))}" - f"{_escape(m.get('status'))}" - f"{_escape(m.get('message', ''))}" + "" + f"{_escape(m.get('run_id'))}" + f"{_escape(m.get('status'))}" + f"{_escape(m.get('message', ''))}" + "" for m in manifest ) - or "No manifest actions" + or "No manifest actions" ) notification_rows = ( "".join( - f"{_escape(n.get('channel'))}" - f"{_escape(n.get('metric'))}" - f"{_escape('sent' if n.get('sent') else n.get('reason', 'skipped'))}" + "" + f"{_escape(n.get('channel'))}" + f"{_escape(n.get('metric'))}" + f"{_escape('sent' if n.get('sent') else n.get('reason', 'skipped'))}" + "" for n in notifications ) - or "No notifications" + or "No notifications" ) downloads = [] @@ -251,7 +330,7 @@ async def run_page(run_id: str) -> HTMLResponse: downloads.append( f"
  • {_escape(name)}
  • " ) - downloads_html = "".join(downloads) or "
  • No files
  • " + downloads_html = "".join(downloads) or "
  • No files
  • " run_id_text = _escape(run_dir.name) html = f""" @@ -261,43 +340,49 @@ async def run_page(run_id: str) -> HTMLResponse: Run {run_id_text} - + - -
    + +
    -

    Run {run_id_text}

    - Back to runs +
    +

    Run {run_id_text}

    + Back to runs +
    -
    -

    Summary

    - - {summary_rows} -
    -
    -

    Downloads

    -
      - {downloads_html} -
    +
    +

    Summary

    +
    + + {summary_rows} +
    +
    +
    +

    Downloads

    +
      {downloads_html}
    -
    -

    Manifest Actions

    - - - - - {manifest_rows} -
    RunStatusMessage
    +
    +

    Manifest Actions

    +
    + + + + + {manifest_rows} +
    RunStatusMessage
    +
    -
    -

    Notifications

    - - - - - {notification_rows} -
    ChannelMetricOutcome
    +
    +

    Notifications

    +
    + + + + + {notification_rows} +
    ChannelMetricOutcome
    +
    @@ -350,27 +435,29 @@ async def compare_page(runs: str) -> HTMLResponse: rows = data.get("top") or [] table_rows = ( "".join( - f"{_escape(row.get('symbol'))}" - f"{_escape(row.get('strategy'))}" - f"{_escape(row.get('metric'))}" - f"{_escape(row.get('metric_value'))}" + "" + f"{_escape(row.get('symbol'))}" + f"{_escape(row.get('strategy'))}" + f"{_escape(row.get('metric'))}" + f"{_escape(row.get('metric_value'))}" + "" for row in rows ) - or "No summary.csv data" + or "No summary.csv data" ) summary_meta = data.get("summary", {}) run_id_text = _escape(run_id) run_cards.append( f""" -
    -
    -

    {run_id_text}

    -

    Metric: {_escape(summary_meta.get("metric", ""))}

    +
    +
    +

    {run_id_text}

    +

    Metric: {_escape(summary_meta.get("metric", ""))}

    -
    - - - +
    +
    SymbolStrategyMetricValue
    + + {table_rows}
    SymbolStrategyMetricValue
    @@ -384,13 +471,15 @@ async def compare_page(runs: str) -> HTMLResponse: Run Comparison - + - -
    + +
    -

    Run Comparison

    - Back to runs +
    +

    Run Comparison

    + Back to runs +
    {cards}
    diff --git a/src/reporting/html.py b/src/reporting/html.py index 5f04b08..fcf8904 100644 --- a/src/reporting/html.py +++ b/src/reporting/html.py @@ -47,6 +47,31 @@ def _json_for_inline_script(value: Any) -> str: .replace("&", "\\u0026") ) + def _write_plotly_asset() -> str: + """Write Plotly JS to disk and return relative script src. + + Plotly is loaded from a CDN by default, but the offline HTML mode uses a local copy + written alongside the report so opening `report.html` works without internet access. + """ + + assets_dir = self.out_dir / "assets" + assets_dir.mkdir(parents=True, exist_ok=True) + dest = assets_dir / "plotly.min.js" + if dest.exists() and dest.stat().st_size > 0: + return "assets/plotly.min.js" + + try: + import importlib.resources as resources + + src = resources.files("plotly").joinpath("package_data/plotly.min.js") + dest.write_bytes(src.read_bytes()) + except Exception: + # Fallback: keep report functional without charts if we cannot materialize the asset. + # (The rest of the HTML is still useful.) + return "https://cdn.plot.ly/plotly-2.32.0.min.js" + + return "assets/plotly.min.js" + # Load all rows for top-N sections from results cache all_rows = self.cache.list_by_run(self.run_id) # Fallback: if the current run reused cached results and didn't write @@ -402,6 +427,54 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: "
    " + card_for_row(top) + table_for_topn(rows) + "
    " ) + plotly_cdn_sri = ( + "sha384-7TVmlZWH60iKX5Uk7lSvQhjtcgw2tkFjuwLcXoRSR4zXTyWFJRm9aPAguMh7CIra" + ) + plotly_src = _write_plotly_asset() if self.inline_css else "https://cdn.plot.ly/plotly-2.32.0.min.js" + if plotly_src.startswith("http"): + plotly_tag = ( + f'' + ) + else: + plotly_tag = f'' + + head_assets = "" + if self.inline_css: + css_inline = ( + ":root{--bg:#020617;--panel:#0f172a;--text:#e2e8f0;--muted:#94a3b8} " + "body{background:var(--bg);color:var(--text);} " + ".container{max-width:72rem;margin:0 auto;padding:1.5rem} " + ".btn{padding:.25rem .75rem;border-radius:.375rem;background:#1e293b;color:#e2e8f0} " + ".grid{display:grid;gap:1rem} " + ".card{padding:1rem;border-radius:.75rem;background:var(--panel);box-shadow:0 1px 2px rgba(0,0,0,.3)} " + ".badge{font-size:.75rem;padding:.1rem .5rem;border-radius:.25rem;background:#1e293b} " + ".space-y-6>*+*{margin-top:1.5rem} " + ".summary-section{margin-bottom:1.5rem} " + ".hidden{display:none} " + ".detail-header{display:flex;justify-content:space-between;align-items:flex-end;gap:1.5rem;flex-wrap:wrap} " + ".detail-control{display:flex;flex-direction:column;gap:.4rem;min-width:220px} " + ".detail-control label{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8} " + ".detail-control select{background:#1e293b;border:1px solid rgba(148,163,184,.35);border-radius:.5rem;padding:.5rem .75rem;color:#e2e8f0} " + ".detail-charts{margin-top:1.5rem} " + ".trade-table{overflow-x:auto} " + ".trade-table table{width:100%;border-collapse:collapse;font-size:.8rem} " + ".trade-table thead{background:rgba(148,163,184,.1);text-transform:uppercase;letter-spacing:.06em;color:#94a3b8} " + ".trade-table th,.trade-table td{padding:.4rem .55rem;text-align:left;border-bottom:1px solid rgba(148,163,184,.2)} " + "table{width:100%;font-size:.875rem} thead{background:#334155;color:#cbd5e1} " + "th,td{padding:.5rem .75rem;text-align:left} code{font-family:ui-monospace,Menlo,Monaco,Consolas,monospace}" + ) + head_assets = f"" + else: + # Tailwind is convenient for rich reports, but requires internet. Users who want a fully + # offline report can pass `--inline-css`. + head_assets = ( + '\n' + " " + ) + html = f""" @@ -409,10 +482,7 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: Backtest Report - - + {head_assets} ", - ) - html = html.replace("tailwind.config = { darkMode: 'class' };", "") html = html.replace("max-w-6xl mx-auto p-6", "container") html = html.replace( "px-3 py-1 rounded bg-slate-800 text-slate-200 hover:bg-slate-700", "btn" diff --git a/tests/test_html_reporter.py b/tests/test_html_reporter.py index 5c8da85..dc34e5f 100644 --- a/tests/test_html_reporter.py +++ b/tests/test_html_reporter.py @@ -57,6 +57,9 @@ def test_html_reporter_exports_inline_css(tmp_path: Path): assert "Metric vs. Sharpe" in output assert "Equity & Drawdown Explorer" in output assert "https://cdn.tailwindcss.com" not in output + assert "https://cdn.plot.ly/plotly-2.32.0.min.js" not in output + assert 'src="assets/plotly.min.js"' in output + assert (tmp_path / "assets" / "plotly.min.js").exists() def test_html_reporter_fallback_from_best_results(tmp_path: Path): From 143f5b99a00ecc25d122afb09395fe7e6d264c95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:28:10 +0000 Subject: [PATCH 05/16] fix: satisfy format placeholders in dashboard compare page Co-authored-by: Manuel H. --- src/dashboard/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dashboard/server.py b/src/dashboard/server.py index 9f6aab9..3c07c11 100644 --- a/src/dashboard/server.py +++ b/src/dashboard/server.py @@ -485,7 +485,7 @@ async def compare_page(runs: str) -> HTMLResponse:
    - """.format(cards="".join(run_cards)) + """.format(cards="".join(run_cards), base_css=base_css) return HTMLResponse(html) return app From 5cfe3038daddb522a32dfce1bf33bd6fcfbad43f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:29:05 +0000 Subject: [PATCH 06/16] chore: create non-root user via adduser in Dockerfile Co-authored-by: Manuel H. --- docker/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 77814f1..a89b3d3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,7 +11,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PATH="/usr/local/bin:${PATH}" RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential git \ + adduser build-essential git \ && rm -rf /var/lib/apt/lists/* RUN python -m pip install --no-cache-dir --upgrade "pip==26.0.1" \ @@ -20,8 +20,8 @@ RUN python -m pip install --no-cache-dir --upgrade "pip==26.0.1" \ ARG APP_UID=1000 ARG APP_GID=1000 RUN set -eux; \ - groupadd -g "${APP_GID}" app || groupadd app; \ - useradd -m -u "${APP_UID}" -g "${APP_GID}" -s /bin/bash app + addgroup --gid "${APP_GID}" app; \ + adduser --disabled-password --gecos "" --uid "${APP_UID}" --ingroup app app WORKDIR /app From ea36243b0b7f2c77b289f974b6758b5c693db725 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:30:16 +0000 Subject: [PATCH 07/16] fix: serve vendored plotly alongside report Co-authored-by: Manuel H. --- src/reporting/dashboard.py | 1 + src/reporting/html.py | 8 +++----- tests/test_html_reporter.py | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/reporting/dashboard.py b/src/reporting/dashboard.py index e21e6c1..6a123d7 100644 --- a/src/reporting/dashboard.py +++ b/src/reporting/dashboard.py @@ -42,6 +42,7 @@ DOWNLOAD_FILE_CANDIDATES: tuple[str, ...] = ( "report.html", + "plotly.min.js", "summary.json", "summary.csv", "all_results.csv", diff --git a/src/reporting/html.py b/src/reporting/html.py index fcf8904..aa5cfa4 100644 --- a/src/reporting/html.py +++ b/src/reporting/html.py @@ -54,11 +54,9 @@ def _write_plotly_asset() -> str: written alongside the report so opening `report.html` works without internet access. """ - assets_dir = self.out_dir / "assets" - assets_dir.mkdir(parents=True, exist_ok=True) - dest = assets_dir / "plotly.min.js" + dest = self.out_dir / "plotly.min.js" if dest.exists() and dest.stat().st_size > 0: - return "assets/plotly.min.js" + return "plotly.min.js" try: import importlib.resources as resources @@ -70,7 +68,7 @@ def _write_plotly_asset() -> str: # (The rest of the HTML is still useful.) return "https://cdn.plot.ly/plotly-2.32.0.min.js" - return "assets/plotly.min.js" + return "plotly.min.js" # Load all rows for top-N sections from results cache all_rows = self.cache.list_by_run(self.run_id) diff --git a/tests/test_html_reporter.py b/tests/test_html_reporter.py index dc34e5f..35e1bde 100644 --- a/tests/test_html_reporter.py +++ b/tests/test_html_reporter.py @@ -58,8 +58,8 @@ def test_html_reporter_exports_inline_css(tmp_path: Path): assert "Equity & Drawdown Explorer" in output assert "https://cdn.tailwindcss.com" not in output assert "https://cdn.plot.ly/plotly-2.32.0.min.js" not in output - assert 'src="assets/plotly.min.js"' in output - assert (tmp_path / "assets" / "plotly.min.js").exists() + assert 'src="plotly.min.js"' in output + assert (tmp_path / "plotly.min.js").exists() def test_html_reporter_fallback_from_best_results(tmp_path: Path): From 7139e6daa73074bd88a54183561a76ac6c8ce9d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:31:04 +0000 Subject: [PATCH 08/16] chore: avoid bash and pipefail in Dockerfile Co-authored-by: Manuel H. --- docker/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a89b3d3..76b0b95 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,7 +27,7 @@ WORKDIR /app COPY pyproject.toml poetry.lock ./ # Retry install to mitigate transient PyPI timeouts in CI/builders -RUN set -euo pipefail; \ +RUN set -eu; \ poetry --version; \ for i in 1 2 3; do \ if poetry install --no-root --no-interaction --no-ansi; then \ @@ -48,4 +48,4 @@ RUN mkdir -p /app/.cache /app/reports /tmp/poetry-cache \ USER app -CMD ["bash", "-lc", "poetry run python -m src.main --help"] +CMD ["python", "-m", "src.main", "--help"] From 9ce370f9b0ea37ca08c854bb238d8a5170c44a6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:32:29 +0000 Subject: [PATCH 09/16] chore: add SRI pin for tailwind CDN script Co-authored-by: Manuel H. --- src/reporting/html.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/reporting/html.py b/src/reporting/html.py index aa5cfa4..27384b7 100644 --- a/src/reporting/html.py +++ b/src/reporting/html.py @@ -467,7 +467,9 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: # Tailwind is convenient for rich reports, but requires internet. Users who want a fully # offline report can pass `--inline-css`. head_assets = ( - '\n' + '\n' " " From b5f78b9835bd2abef3492e377400badfe39ccd75 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Feb 2026 03:35:05 +0000 Subject: [PATCH 10/16] fix: avoid CORS breakage for tailwind SRI Co-authored-by: Manuel H. --- src/reporting/html.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reporting/html.py b/src/reporting/html.py index 27384b7..4a547f4 100644 --- a/src/reporting/html.py +++ b/src/reporting/html.py @@ -467,9 +467,9 @@ def table_for_topn(rows: list[dict[str, Any]]) -> str: # Tailwind is convenient for rich reports, but requires internet. Users who want a fully # offline report can pass `--inline-css`. head_assets = ( - '\n' + 'referrerpolicy="no-referrer">\n' " " From 7e1f184061387959dd3dea50cf4b9f46e08b8a78 Mon Sep 17 00:00:00 2001 From: "Manuel H." <36189959+LouisLetcher@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:25:26 +0100 Subject: [PATCH 11/16] Update src/main.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Manuel H. <36189959+LouisLetcher@users.noreply.github.com> --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index cf371b4..a604628 100644 --- a/src/main.py +++ b/src/main.py @@ -722,7 +722,7 @@ def clean_cache( try: if not file_path.resolve().is_relative_to(root): continue - except Exception: + except OSError: continue if file_path.is_file(): candidate_files.append(file_path) From 45db462a55fb16c9fa318f2d0d6b73f6fa51f559 Mon Sep 17 00:00:00 2001 From: "Manuel H." <36189959+LouisLetcher@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:25:34 +0100 Subject: [PATCH 12/16] Update src/reporting/html.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Manuel H. <36189959+LouisLetcher@users.noreply.github.com> --- src/reporting/html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reporting/html.py b/src/reporting/html.py index 4a547f4..554acd6 100644 --- a/src/reporting/html.py +++ b/src/reporting/html.py @@ -31,7 +31,7 @@ def export(self, best: list[object]): def _esc(value: Any) -> str: if value is None: return "" - return html_stdlib.escape(str(value), quote=True) + return html_stdlib.escape(json.dumps(value) if isinstance(value, dict) else str(value), quote=True) def _json_for_inline_script(value: Any) -> str: """JSON-safe for embedding directly in a \n' + 'crossorigin="anonymous" referrerpolicy="no-referrer">\n' " " diff --git a/tests/test_html_reporter.py b/tests/test_html_reporter.py index 35e1bde..d7dd303 100644 --- a/tests/test_html_reporter.py +++ b/tests/test_html_reporter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path from src.backtest.runner import BestResult @@ -80,6 +81,12 @@ def test_html_reporter_fallback_from_best_results(tmp_path: Path): output = (tmp_path / "report.html").read_text() assert "Backtest Report" in output + # SRI on a cross-origin