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.
-
-
-
-
- | Run ID |
- Metric |
- Results |
- Started |
- Detail |
- Report |
-
-
-
- {html_rows if html_rows else "| No runs available |
"}
-
-
+
+
+
+
+
+ | Run ID |
+ Metric |
+ Results |
+ Started |
+ Detail |
+ Report |
+
+
+
+ {html_rows if html_rows else "| 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}
-
+
-
-
+
+
-
- Summary
-
-
-
Downloads
-
+
-
- Manifest Actions
-
-
- | Run | Status | Message |
-
- {manifest_rows}
-
+
+ Manifest Actions
+
+
+
+ | Run | Status | Message |
+
+ {manifest_rows}
+
+
-
- Notifications
-
-
- | Channel | Metric | Outcome |
-
- {notification_rows}
-
+
+ Notifications
+
+
+
+ | Channel | Metric | Outcome |
+
+ {notification_rows}
+
+
@@ -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"""
-