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..76b0b95 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,26 +2,32 @@ 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 \ + adduser 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; \ + addgroup --gid "${APP_GID}" app; \ + adduser --disabled-password --gecos "" --uid "${APP_UID}" --ingroup app 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; \ +RUN set -eu; \ poetry --version; \ for i in 1 2 3; do \ if poetry install --no-root --no-interaction --no-ansi; then \ @@ -37,4 +43,9 @@ RUN set -euo pipefail; \ COPY . /app -CMD ["bash", "-lc", "poetry run python -m src.main --help"] +RUN mkdir -p /app/.cache /app/reports /tmp/poetry-cache \ + && chown -R app:app /app /tmp/poetry-cache + +USER app + +CMD ["python", "-m", "src.main", "--help"] diff --git a/poetry.lock b/poetry.lock index 9a5d0ab..5d72ede 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" @@ -925,24 +937,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" @@ -3343,18 +3358,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"] @@ -3426,6 +3442,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" diff --git a/pyproject.toml b/pyproject.toml index 228288f..6ba06d5 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" diff --git a/src/dashboard/server.py b/src/dashboard/server.py index ebc9ce1..11941b6 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 @@ -35,9 +102,6 @@ def _escape(value: Any) -> str: def _url_segment(value: Any) -> str: return quote(str(value), safe="") - def _file_url(path: Path) -> str: - return f"file://{quote(str(path), safe='/:')}" - def _base_root() -> Path: return base_root if base_root is not None else root.resolve() @@ -144,15 +208,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 +232,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 +289,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 +327,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 +337,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 +432,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,19 +468,21 @@ async def compare_page(runs: str) -> HTMLResponse: Run Comparison - + - -
    + +
    -

    Run Comparison

    - Back to runs +
    +

    Run Comparison

    + Back to runs +
    {cards}
    - """.format(cards="".join(run_cards)) + """.format(cards="".join(run_cards), base_css=base_css) return HTMLResponse(html) return app diff --git a/src/main.py b/src/main.py index 3ecc8df..a604628 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 OSError: + 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/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 852ccc1..0fdde67 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 @@ -10,6 +11,8 @@ if TYPE_CHECKING: pass +PLOTLY_MIN_JS_FILENAME: str = "plotly.min.js" + class HTMLReporter: def __init__( @@ -27,6 +30,57 @@ def __init__( self.inline_css = inline_css def export(self, best: list[object]): + def _esc(value: Any) -> str: + if value is None: + return "" + text: str + # `params` can be a dict; JSON is more readable than Python's repr. + if isinstance(value, dict): + try: + text = json.dumps(value, sort_keys=True, ensure_ascii=True, default=str) + except Exception: + text = str(value) + else: + text = str(value) + return html_stdlib.escape(text, 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") + ) + + 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. + """ + + dest = self.out_dir / PLOTLY_MIN_JS_FILENAME + if dest.exists() and dest.stat().st_size > 0: + return PLOTLY_MIN_JS_FILENAME + + 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 PLOTLY_MIN_JS_FILENAME + # 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 +155,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 +196,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 +236,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 +299,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 +327,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 +363,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 +387,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}" @@ -382,6 +436,56 @@ 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""" @@ -389,10 +493,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 1b82ceb..ffa6a0e 100644 --- a/tests/test_html_reporter.py +++ b/tests/test_html_reporter.py @@ -1,11 +1,42 @@ from __future__ import annotations +from html.parser import HTMLParser from pathlib import Path from src.backtest.runner import BestResult from src.reporting.html import HTMLReporter +class _ScriptTagParser(HTMLParser): + """Collect