diff --git a/dashboard/backend/api/auth.py b/dashboard/backend/api/auth.py index c6ed9c65..eb6d4c6a 100644 --- a/dashboard/backend/api/auth.py +++ b/dashboard/backend/api/auth.py @@ -436,17 +436,60 @@ def _store_unavailable(exc: BaseException, *, route: str) -> HTTPException: return HTTPException(status_code=503, detail=_STORE_UNAVAILABLE_DETAIL) +def _local_autologin_user(request: Request) -> Optional[dict]: + """Local-dev convenience (never active in CI or production). + + When ``ATL_LOCAL_AUTOLOGIN_EMAIL`` is set AND the request originates from + loopback, resolve that seeded admin account without a session cookie — + so a locally-run console opens pre-authenticated for verification. The + env var exists only in the local launchd plist; the loopback check keeps + a leaked flag from ever authenticating a network peer, and the role check + means a misconfigured email silently disables the feature instead of + elevating a stranger. + """ + email = (os.getenv("ATL_LOCAL_AUTOLOGIN_EMAIL") or "").strip() + if not email: + return None + client = request.client.host if request.client else "" + if client not in {"127.0.0.1", "::1"}: + return None + try: + user = users_module.user_store.get_user_by_email(email) + except _USER_STORE_OUTAGE: + return None + if not user or user.get("role") != "admin": + return None + enriched = dict(user) + if user.get("id") is not None: + try: + enriched["entitlements"] = users_module.user_store.get_entitlements(user["id"]) + except Exception: + pass + return enriched + + def get_current_user( request: Request, authorization: Optional[str] = Header(default=None), ) -> dict: token = _session_token(request, authorization) + user = None + if token: + try: + user = users_module.user_store.get_user_for_token(token) + except _USER_STORE_OUTAGE as exc: + raise _store_unavailable(exc, route="get_current_user") from None + if not user: + # The local-console fallback covers BOTH shapes of "no live session": + # no cookie at all, and a cookie whose session died (wiped dev DB, + # expired row) — the second shape is what a stale browser cookie + # produces after the database is swapped, and it must land on the + # seeded admin instead of a 401 nothing on the page explains. + autologin = _local_autologin_user(request) + if autologin is not None: + return autologin if not token: raise HTTPException(status_code=401, detail="Not authenticated") - try: - user = users_module.user_store.get_user_for_token(token) - except _USER_STORE_OUTAGE as exc: - raise _store_unavailable(exc, route="get_current_user") from None if not user: raise HTTPException(status_code=401, detail="Invalid or expired session") return user diff --git a/dashboard/backend/api/router.py b/dashboard/backend/api/router.py index 92134cf8..f9b45d03 100644 --- a/dashboard/backend/api/router.py +++ b/dashboard/backend/api/router.py @@ -20,6 +20,7 @@ from dashboard.backend.api.routers.leaderboard import router as leaderboard_router from dashboard.backend.api.routers.news import router as news_router from dashboard.backend.api.routers.portfolio import router as portfolio_router +from dashboard.backend.api.routers.research import router as research_router from dashboard.backend.api.routers.runs import router as runs_router from dashboard.backend.api.routers.strategies import router as strategies_router from dashboard.backend.api.routers.robinhood_live import router as robinhood_router @@ -31,6 +32,7 @@ api_router.include_router(admin_users_router) api_router.include_router(algo_router) api_router.include_router(agents_router) +api_router.include_router(research_router) api_router.include_router(analytics_router) api_router.include_router(admin_analytics_router) api_router.include_router(discord_router) diff --git a/dashboard/backend/api/routers/research.py b/dashboard/backend/api/routers/research.py new file mode 100644 index 00000000..96fb43df --- /dev/null +++ b/dashboard/backend/api/routers/research.py @@ -0,0 +1,355 @@ +"""Research-agent routes (design N2/PR2). + +The /admin-style analogue of the marketplace for report-output agents: the +catalog lists them (shelf ``research``), "add" is the research clone, and the +workbench's runs proxy to the agent's external service via the N2/PR2 contract +(manifest / runs / status / result — see research-agent-integration/contract.md). + +Auth model: everything requires a signed-in user (runs and reports are +per-user; a due-diligence report is not platform-public content). Service-side +calls carry ``X-Service-Token`` from RESEARCH_SERVICE_TOKEN. + +Completion side effects happen on the poll that *discovers* completion (the +frontend polls every ~15s while a run is open; there is no background sweeper +in v1). If the user closes the browser, the email fires the next time anyone +fetches that run's status — acceptable for v1, noted in the route docstring. +""" + +from __future__ import annotations + +import base64 +import os +from typing import Any, Dict, Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response + +from dashboard.backend.api.auth import get_current_user +from dashboard.backend.domain.agents import marketplace as marketplace_mod +from dashboard.backend.domain.agents import research_store + +router = APIRouter(prefix="/v1/research", tags=["research"]) + +POLL_TIMEOUT_SECONDS = 10.0 +MANIFEST_CACHE_SECONDS = 300.0 +_manifest_cache: Dict[str, Any] = {} + +ARTIFACT_CONTENT_TYPES = { + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "pdf": "application/pdf", + "markdown": "text/markdown; charset=utf-8", + "markdown_report": "text/markdown; charset=utf-8", + "evidence_json": "application/json", + "evidence": "application/json", +} + + +def _service_token() -> str: + return os.getenv("RESEARCH_SERVICE_TOKEN", "dev-token") + + +def _template_or_404(template_id: str) -> Dict[str, Any]: + template = marketplace_mod.get_marketplace_template(template_id) + if not template or not marketplace_mod.shelf_is_research(template): + raise HTTPException(status_code=404, detail="Research template not found") + return template + + +def _service_base(template: Dict[str, Any]) -> str: + return marketplace_mod.research_service_config(template)["base_url"] + + +def _agent_id(template: Dict[str, Any]) -> str: + return (template.get("research") or {}).get("agent_id", "") + + +def _service_headers() -> Dict[str, str]: + return {"X-Service-Token": _service_token()} + + +def _service_error(exc: httpx.HTTPError, action: str) -> HTTPException: + status = getattr(getattr(exc, "response", None), "status_code", None) + if status == 401: + return HTTPException(status_code=502, detail="Research service rejected its token") + if status == 404: + return HTTPException(status_code=404, detail="Research service does not know this run") + if status == 422: + try: + detail = exc.response.json() + except Exception: + detail = {"detail": "validation failed"} + return HTTPException(status_code=422, detail=detail) + return HTTPException(status_code=503, detail=f"Research service unavailable while {action}") + + +def _cached_manifest(template: Dict[str, Any]) -> Dict[str, Any]: + import time + + cache_key = _agent_id(template) + cached = _manifest_cache.get(cache_key) + now = time.time() + if cached and now - cached["at"] < MANIFEST_CACHE_SECONDS: + return cached["data"] + response = httpx.get( + f"{_service_base(template)}/manifest", + params={"agent_id": _agent_id(template)}, + headers=_service_headers(), + timeout=POLL_TIMEOUT_SECONDS, + ) + response.raise_for_status() + data = response.json() + _manifest_cache[cache_key] = {"at": now, "data": data} + return data + + +def _public_agent_card(template: Dict[str, Any]) -> Dict[str, Any]: + """Catalog card + added-state for the Community / My Agents shelves.""" + public = marketplace_mod._public_template(template) + return public + + +@router.get("/agents") +def list_research_agents(current_user: dict = Depends(get_current_user)): + added = set(research_store.list_added_template_ids(current_user["id"])) + items = [ + _public_agent_card(raw) + for raw in marketplace_mod._load_catalog().values() + if marketplace_mod.shelf_is_research(raw) + ] + for item in items: + item["added"] = item["template_id"] in added + return {"agents": items} + + +@router.post("/agents/{template_id}/add") +def add_research_agent(template_id: str, current_user: dict = Depends(get_current_user)): + _template_or_404(template_id) + created = research_store.add_research_agent(current_user["id"], template_id) + return {"added": True, "created": created} + + +@router.delete("/agents/{template_id}/add") +def remove_research_agent(template_id: str, current_user: dict = Depends(get_current_user)): + removed = research_store.remove_research_agent(current_user["id"], template_id) + return {"removed": removed} + + +@router.get("/agents/{template_id}/manifest") +def get_manifest(template_id: str, current_user: dict = Depends(get_current_user)): + template = _template_or_404(template_id) + try: + return _cached_manifest(template) + except httpx.HTTPError as exc: + raise _service_error(exc, "loading the agent manifest") from None + + +@router.post("/agents/{template_id}/runs") +def create_research_run( + template_id: str, + body: dict, + current_user: dict = Depends(get_current_user), +): + template = _template_or_404(template_id) + settings = body.get("settings") + if not isinstance(settings, dict): + raise HTTPException(status_code=422, detail={"detail": "settings object required"}) + try: + manifest = _cached_manifest(template) + except httpx.HTTPError as exc: + raise _service_error(exc, "loading the agent manifest") from None + + # Server-side required-field check so a stale frontend cannot silently + # submit an incomplete mandate (the service would 422 anyway; this gives + # the same field_errors shape without depending on its copy). + field_errors = { + field["id"]: "required" + for field in manifest.get("settings_schema", {}).get("fields", []) + if field.get("required") and not str(settings.get(field["id"]) or "").strip() + } + if field_errors: + raise HTTPException( + status_code=422, + detail={"detail": "validation failed", "field_errors": field_errors}, + ) + + email_me = bool(body.get("email_me")) + payload = {"agent_id": _agent_id(template), "settings": settings} + try: + response = httpx.post( + f"{_service_base(template)}/runs", + json=payload, + headers=_service_headers(), + timeout=POLL_TIMEOUT_SECONDS, + ) + response.raise_for_status() + service_run = response.json() + except httpx.HTTPError as exc: + raise _service_error(exc, "submitting the research run") from None + + import uuid + + service_run_id = str(service_run.get("run_id") or "").strip() + run_id = f"rr_{service_run_id}" if service_run_id else f"rr_{uuid.uuid4().hex[:12]}" + research_store.create_run( + run_id=run_id, + user_id=current_user["id"], + template_id=template_id, + service_run_id=service_run_id, + status=str(service_run.get("status") or "running"), + settings=settings, + email_me=email_me, + ) + return {"run_id": run_id, "status": service_run.get("status") or "running"} + + +def _service_run_id(run: Dict[str, Any]) -> str: + return str(run.get("service_run_id") or "") + + +def _maybe_complete_run(run: Dict[str, Any], template: Dict[str, Any], + current_user: dict) -> Dict[str, Any]: + """Poll the service once for a non-terminal run; on first discovery of + completion, store artifacts and send the notification email.""" + if run["status"] in ("completed", "failed"): + return run + try: + response = httpx.get( + f"{_service_base(template)}/runs/{_service_run_id(run)}", + headers=_service_headers(), + timeout=POLL_TIMEOUT_SECONDS, + ) + response.raise_for_status() + service_status = response.json() + except httpx.HTTPError: + # A single poll failure is tolerated — the next frontend poll retries. + return run + + status = str(service_status.get("status") or "running") + if status == "failed": + research_store.update_run_status(run["run_id"], "failed", + error=service_status.get("error") or "Research failed", + completed=True) + run["status"] = "failed" + return run + if status != "completed": + return run + + try: + result = httpx.get( + f"{_service_base(template)}/runs/{_service_run_id(run)}/result", + headers=_service_headers(), + timeout=POLL_TIMEOUT_SECONDS, + ) + result.raise_for_status() + payload = result.json() + except httpx.HTTPError: + research_store.update_run_status(run["run_id"], "failed", + error="Result could not be retrieved", + completed=True) + run["status"] = "failed" + return run + + research_store.store_artifacts( + run["run_id"], + payload.get("artifacts") or {}, + payload.get("evidence"), + payload.get("report_markdown") or "", + ) + research_store.update_run_status(run["run_id"], "completed", completed=True) + run["status"] = "completed" + + # Notification email (link, not attachment — Brevo sender is plain-text v1). + if run.get("email_me") and not run.get("emailed"): + from dashboard.backend.infrastructure.email.sender import email_configured, send_email + + if email_configured(): + base = os.getenv("PUBLIC_BASE_URL", "https://agentic-trading-lab.vercel.app") + link = f"{base}/app?view=research" + sent = send_email( + current_user["email"], + f"[ATL] Your research report is ready — {template['name']}", + "Your research report has completed.\n\n" + f"Open it here: {link}\n" + "(The report page offers Markdown / DOCX / PDF downloads.)\n", + ) + if sent: + research_store.mark_emailed(run["run_id"]) + return run + + +@router.get("/runs") +def list_my_runs(current_user: dict = Depends(get_current_user)): + runs = research_store.list_runs_for_user(current_user["id"]) + for run in runs: + try: + run["settings"] = __import__("json").loads(run.pop("settings_json") or "{}") + except Exception: + run["settings"] = {} + return {"runs": runs} + + +@router.get("/runs/{run_id}") +def get_run_status(run_id: str, current_user: dict = Depends(get_current_user)): + run = research_store.get_run(run_id, current_user["id"]) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + template = _template_or_404(run["template_id"]) + run = _maybe_complete_run(run, template, current_user) + return { + "run_id": run["run_id"], + "template_id": run["template_id"], + "status": run["status"], + "error": run.get("error"), + "created_at": run["created_at"], + "completed_at": run["completed_at"], + } + + +def _run_and_template_or_404(run_id: str, user: dict): + run = research_store.get_run(run_id, user["id"]) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + if run["status"] != "completed": + raise HTTPException(status_code=409, detail="Run not completed") + template = _template_or_404(run["template_id"]) + return run, template + + +@router.get("/runs/{run_id}/report") +def get_run_report(run_id: str, current_user: dict = Depends(get_current_user)): + run, _template = _run_and_template_or_404(run_id, current_user) + artifact = research_store.get_artifact(run_id, "markdown") + if not artifact: + raise HTTPException(status_code=404, detail="Report not found") + return { + "run_id": run_id, + "template_id": run["template_id"], + "report_markdown": artifact["content_base64"], + "filename": artifact["filename"], + } + + +@router.get("/runs/{run_id}/artifacts/{kind}") +def download_artifact(run_id: str, kind: str, current_user: dict = Depends(get_current_user)): + run, _template = _run_and_template_or_404(run_id, current_user) + artifact = research_store.get_artifact(run_id, kind) + if not artifact: + raise HTTPException(status_code=404, detail="Artifact not found") + content = artifact["content_base64"] or "" + if artifact["kind"] in ("markdown_report", "evidence_json"): + # These two are stored as plain text, not base64. + return Response( + content=content, + media_type=ARTIFACT_CONTENT_TYPES[artifact["kind"]], + headers={"Content-Disposition": f'attachment; filename="{artifact["filename"]}"'}, + ) + try: + raw = base64.b64decode(content) + except Exception: + raise HTTPException(status_code=500, detail="Artifact payload is corrupt") + return Response( + content=raw, + media_type=ARTIFACT_CONTENT_TYPES.get(artifact["kind"], "application/octet-stream"), + headers={"Content-Disposition": f'attachment; filename="{artifact["filename"]}"'}, + ) diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 3b2501b0..fd631ad7 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -374,6 +374,11 @@ def bar_cache_background(): frontend_path = FRONTEND_DIR +# HTML/JS/CSS always revalidate: the local console bumps ?v= per change, and +# browsers heuristic-cache FileResponses that carry no Cache-Control header, +# which is how Chrome kept serving a pre-autologin app.js (design N2/PR2). +NO_CACHE = {"Cache-Control": "no-cache"} + @app.get("/", include_in_schema=False) async def serve_root(): """Serve marketing landing page.""" @@ -398,7 +403,7 @@ async def serve_app(request: Request): if user_query: target += f"?user={quote(str(user_query), safe='')}" return RedirectResponse(url=target, status_code=307) - return FileResponse(frontend_path / "app.html") + return FileResponse(frontend_path / "app.html", headers=NO_CACHE) @app.get("/app/", include_in_schema=False) @@ -512,12 +517,12 @@ async def redirect_admin_analytics(request: Request): @app.get("/styles.css", include_in_schema=False) async def serve_styles(): """Serve styles.css.""" - return FileResponse(frontend_path / "styles.css", media_type="text/css") + return FileResponse(frontend_path / "styles.css", media_type="text/css", headers=NO_CACHE) @app.get("/app.js", include_in_schema=False) async def serve_app_js(): """Serve app.js.""" - return FileResponse(frontend_path / "app.js", media_type="text/javascript") + return FileResponse(frontend_path / "app.js", media_type="text/javascript", headers=NO_CACHE) @app.get("/home-page.js", include_in_schema=False) async def serve_home_page_js(): diff --git a/dashboard/backend/domain/agents/marketplace.py b/dashboard/backend/domain/agents/marketplace.py index f4097d30..5fd00bb7 100644 --- a/dashboard/backend/domain/agents/marketplace.py +++ b/dashboard/backend/domain/agents/marketplace.py @@ -19,15 +19,17 @@ # Community supermarket rows. Declared order is display order: LLMs first, # then Agents. Unknown / omitted values fall through ``_normalize_shelf``. -MARKETPLACE_SHELVES = ("llms", "open") +MARKETPLACE_SHELVES = ("llms", "open", "research") def _normalize_shelf(raw: Dict[str, Any]) -> str: - """Return ``llms`` or ``open``. + """Return ``llms``, ``open`` or ``research``. Explicit ``shelf`` on the catalog row wins. Otherwise a non-pipeline runtime (today: AI Hedge Fund) is an open agent, so a future hosted project does not have to remember the field to land on the right row. + Research agents (design N2/PR2) are always explicit — they are external + services, not runtimes this process hosts. """ explicit = str(raw.get("shelf") or "").strip().lower() if explicit in MARKETPLACE_SHELVES: @@ -36,6 +38,29 @@ def _normalize_shelf(raw: Dict[str, Any]) -> str: return "open" if runtime_type != "pipeline" else "llms" +def shelf_is_research(raw: Dict[str, Any]) -> bool: + """True when a catalog row is a research-agent service template.""" + return _normalize_shelf(raw) == "research" + + +def research_service_config(raw: Dict[str, Any]) -> Dict[str, str]: + """Resolve the service base URL for a research template row. + + The URL comes from the environment named by ``service_base_url_env`` + (deployment-owned) with the catalog's ``service_base_url_default`` as the + local-dev fallback — same pattern as every other credential in the app. + """ + import os + + research = raw.get("research") or {} + env_name = str(research.get("service_base_url_env") or "").strip() + base = os.getenv(env_name, "") if env_name else "" + return { + "base_url": (base or str(research.get("service_base_url_default") or "")).rstrip("/"), + "agent_id": str(research.get("agent_id") or ""), + } + + def _public_template(raw: Dict[str, Any]) -> Dict[str, Any]: # This "category" and an agent's "category" used to be two different # vocabularies under one key name: templates carried display strings @@ -73,6 +98,19 @@ def _public_template(raw: Dict[str, Any]) -> Dict[str, Any]: } if repo_url.startswith(("https://github.com/", "http://github.com/")): public["repo_url"] = repo_url + # Research rows (N2/PR2) project their service pointer and delivery facts + # instead of runtime/step fields, which mean nothing for an external + # Deep Research service. + research = raw.get("research") + if shelf_is_research(raw): + public["mode"] = "research" + public["model_name"] = raw.get("model_name") or "deep-research" + public["research"] = { + "agent_id": research.get("agent_id"), + "estimated_runtime_seconds": int(research.get("estimated_runtime_seconds") or 300), + "max_runtime_seconds": int(research.get("max_runtime_seconds") or 1800), + "output_formats": list(research.get("output_formats") or []), + } return public diff --git a/dashboard/backend/domain/agents/research_store.py b/dashboard/backend/domain/agents/research_store.py new file mode 100644 index 00000000..f221ca80 --- /dev/null +++ b/dashboard/backend/domain/agents/research_store.py @@ -0,0 +1,210 @@ +"""Storage for the research-agent module (design N2/PR2). + +Three concerns, one small sqlite module (same DATABASE_PATH as the rest of the +dashboard — the deploy runs sqlite stores, consistent with the agent store): + +- ``research_agent_adds`` : which user cloned which research template + (the research analogue of the marketplace clone) +- ``research_runs`` : one row per submitted research run +- ``research_artifacts`` : the completed run's deliverables, stored as + base64 text (Render's filesystem is ephemeral; + the database is the only durable home) + +Write-shape notes: +- Every helper opens its own connection (short-lived, like users_store). +- ``_init_schema`` runs on module import — cheap CREATE IF NOT EXISTS, and the + research module is the only caller. +""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import Any, Dict, List, Optional + +from dashboard.backend.database import DB_PATH + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def _init_schema() -> None: + with _connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS research_agent_adds ( + user_id INTEGER NOT NULL, + template_id TEXT NOT NULL, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, template_id) + ); + + CREATE TABLE IF NOT EXISTS research_runs ( + run_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + template_id TEXT NOT NULL, + service_run_id TEXT, + status TEXT NOT NULL DEFAULT 'queued', + settings_json TEXT NOT NULL, + email_me INTEGER NOT NULL DEFAULT 0, + emailed INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS research_artifacts ( + run_id TEXT NOT NULL, + kind TEXT NOT NULL, + filename TEXT, + content_base64 TEXT, + PRIMARY KEY (run_id, kind) + ); + + CREATE INDEX IF NOT EXISTS idx_research_runs_user + ON research_runs(user_id, created_at DESC); + """ + ) + + +_init_schema() + + +# --- adds (the research "clone") ------------------------------------------- + +def add_research_agent(user_id: int, template_id: str) -> bool: + """Idempotent add. Returns True when a new row was created.""" + with _connect() as conn: + cursor = conn.execute( + "INSERT OR IGNORE INTO research_agent_adds (user_id, template_id) VALUES (?, ?)", + (user_id, template_id), + ) + return cursor.rowcount > 0 + + +def remove_research_agent(user_id: int, template_id: str) -> bool: + with _connect() as conn: + cursor = conn.execute( + "DELETE FROM research_agent_adds WHERE user_id = ? AND template_id = ?", + (user_id, template_id), + ) + return cursor.rowcount > 0 + + +def list_added_template_ids(user_id: int) -> List[str]: + with _connect() as conn: + rows = conn.execute( + "SELECT template_id FROM research_agent_adds WHERE user_id = ? ORDER BY added_at DESC", + (user_id,), + ).fetchall() + return [row["template_id"] for row in rows] + + +# --- runs ------------------------------------------------------------------- + +def create_run( + *, + run_id: str, + user_id: int, + template_id: str, + service_run_id: str, + status: str, + settings: Dict[str, Any], + email_me: bool, +) -> None: + with _connect() as conn: + conn.execute( + "INSERT INTO research_runs (run_id, user_id, template_id, service_run_id," + " status, settings_json, email_me) VALUES (?, ?, ?, ?, ?, ?, ?)", + (run_id, user_id, template_id, service_run_id, status, + json.dumps(settings, ensure_ascii=False), int(email_me)), + ) + + +def get_run(run_id: str, user_id: int) -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + "SELECT * FROM research_runs WHERE run_id = ? AND user_id = ?", + (run_id, user_id), + ).fetchone() + return dict(row) if row else None + + +def list_runs_for_user(user_id: int, limit: int = 50) -> List[Dict[str, Any]]: + with _connect() as conn: + rows = conn.execute( + "SELECT run_id, template_id, status, settings_json, error," + " created_at, completed_at FROM research_runs" + " WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", + (user_id, limit), + ).fetchall() + return [dict(row) for row in rows] + + +def update_run_status(run_id: str, status: str, error: Optional[str] = None, + completed: bool = False) -> None: + with _connect() as conn: + if completed: + conn.execute( + "UPDATE research_runs SET status = ?, error = ?," + " completed_at = CURRENT_TIMESTAMP WHERE run_id = ?", + (status, error, run_id), + ) + else: + conn.execute( + "UPDATE research_runs SET status = ?, error = ? WHERE run_id = ?", + (status, error, run_id), + ) + + +def mark_emailed(run_id: str) -> None: + with _connect() as conn: + conn.execute("UPDATE research_runs SET emailed = 1 WHERE run_id = ?", (run_id,)) + + +# --- artifacts -------------------------------------------------------------- + +def store_artifacts(run_id: str, artifacts: Dict[str, Dict[str, str]], + evidence: Dict[str, Any], report_markdown: str) -> None: + """Persist everything a completed run delivered (replace-on-complete).""" + rows = [ + (run_id, "markdown", f"{run_id}.md", None), + ] + with _connect() as conn: + conn.execute("DELETE FROM research_artifacts WHERE run_id = ?", (run_id,)) + conn.execute( + "INSERT INTO research_artifacts (run_id, kind, filename, content_base64)" + " VALUES (?, 'markdown_report', ?, ?)", + (run_id, f"{run_id}.md", report_markdown), + ) + for kind, item in (artifacts or {}).items(): + safe_kind = str(kind).replace("/", "_") + conn.execute( + "INSERT INTO research_artifacts (run_id, kind, filename, content_base64)" + " VALUES (?, ?, ?, ?)", + (run_id, safe_kind, + item.get("filename") or f"{run_id}.{safe_kind}", + item.get("content_base64")), + ) + if evidence is not None: + conn.execute( + "INSERT INTO research_artifacts (run_id, kind, filename, content_base64)" + " VALUES (?, 'evidence_json', ?, ?)", + (run_id, f"{run_id}_evidence.json", + json.dumps(evidence, ensure_ascii=False)), + ) + + +def get_artifact(run_id: str, kind: str) -> Optional[Dict[str, Any]]: + kind_map = {"markdown": "markdown_report", "evidence_json": "evidence_json"} + lookup = kind_map.get(kind, kind) + with _connect() as conn: + row = conn.execute( + "SELECT kind, filename, content_base64 FROM research_artifacts" + " WHERE run_id = ? AND kind = ?", + (run_id, lookup), + ).fetchone() + return dict(row) if row else None diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py index 991107fe..3054ac0b 100644 --- a/dashboard/backend/tests/test_admin_analytics_frontend.py +++ b/dashboard/backend/tests/test_admin_analytics_frontend.py @@ -161,7 +161,7 @@ def test_profile_menu_admin_entry_opens_the_admin_page(): def test_app_lifecycle_and_cache_versions_are_wired(): # Lockstep owner for the console's bumped tags and the /admin page's pins: # every bump edits this test in the same change (Global Constraints). - assert 'styles.css?v=144' in APP_HTML + assert 'styles.css?v=145' in APP_HTML assert 'app.js?v=138' in APP_HTML assert 'js/admin-tabs.js?v=12' in APP_HTML for tag in ( diff --git a/dashboard/backend/tests/test_agent_taxonomy.py b/dashboard/backend/tests/test_agent_taxonomy.py index 21f7d2c7..4dd63624 100644 --- a/dashboard/backend/tests/test_agent_taxonomy.py +++ b/dashboard/backend/tests/test_agent_taxonomy.py @@ -124,6 +124,12 @@ def test_no_template_is_left_on_the_retired_prompting_llms_slug(): import dashboard.backend.domain.agents.marketplace as marketplace_mod marketplace_mod.reload_marketplace_catalog() - slugs = {t.get("category") for t in marketplace_mod.list_marketplace_templates()} + # Research rows (N2/PR2) carry no category: they are not market-trading + # templates, so the taxonomy guard scopes to the trading shelves only. + slugs = { + t.get("category") + for t in marketplace_mod.list_marketplace_templates() + if t.get("shelf") != "research" + } assert "prompting_llms" not in slugs assert slugs <= AGENT_CATEGORIES diff --git a/dashboard/backend/tests/test_agents_api.py b/dashboard/backend/tests/test_agents_api.py index 75157822..ab61987c 100644 --- a/dashboard/backend/tests/test_agents_api.py +++ b/dashboard/backend/tests/test_agents_api.py @@ -850,7 +850,8 @@ def test_marketplace_listing_is_ordered_by_shelf_not_by_slug(): assert llms, "the LLM shelf is empty" assert opens, "the Agents shelf is empty" assert templates[0]["shelf"] == "llms" - assert templates[-1]["shelf"] == "open" + # The research shelf (N2/PR2) is declared after the two trading shelves. + assert templates[-1]["shelf"] == "research" assert [t["name"] for t in opens][0] == "AI Hedge Fund" assert {t["template_id"] for t in opens} >= { "ai-hedge-fund", diff --git a/dashboard/backend/tests/test_app_composition.py b/dashboard/backend/tests/test_app_composition.py index 3b979caf..cb10de44 100644 --- a/dashboard/backend/tests/test_app_composition.py +++ b/dashboard/backend/tests/test_app_composition.py @@ -282,6 +282,15 @@ ("GET", "/admin"), ("GET", "/admin.css"), ("GET", "/admin-console.css"), + ("GET", "/api/v1/research/agents"), + ("GET", "/api/v1/research/agents/{template_id}/manifest"), + ("POST", "/api/v1/research/agents/{template_id}/add"), + ("DELETE", "/api/v1/research/agents/{template_id}/add"), + ("POST", "/api/v1/research/agents/{template_id}/runs"), + ("GET", "/api/v1/research/runs"), + ("GET", "/api/v1/research/runs/{run_id}"), + ("GET", "/api/v1/research/runs/{run_id}/report"), + ("GET", "/api/v1/research/runs/{run_id}/artifacts/{kind}"), ("GET", "/admin-analytics"), ("GET", "/styles.css"), ("GET", "/ticker"), diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py index 61200f41..fe0e9925 100644 --- a/dashboard/backend/tests/test_backtest_comparison_frontend.py +++ b/dashboard/backend/tests/test_backtest_comparison_frontend.py @@ -192,7 +192,7 @@ def test_exact_raw_ties_mark_every_tied_series_best(): def test_comparison_script_and_semantic_table_ship_before_app(): helper = '' app = '' - assert 'href="styles.css?v=144"' in APP_HTML + assert 'href="styles.css?v=145"' in APP_HTML assert APP_HTML.index(helper) < APP_HTML.index(app) for element_id in ( "performanceLegend", diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py index 6d358848..80bc3f1b 100644 --- a/dashboard/backend/tests/test_frontend_fast_boot.py +++ b/dashboard/backend/tests/test_frontend_fast_boot.py @@ -193,7 +193,7 @@ def test_cache_busters_bumped(): # round of follow-ups (#347/#348). assert "app.js?v=138" in APP_HTML assert "js/agent-editor.js?v=31" in APP_HTML - assert "styles.css?v=144" in APP_HTML + assert "styles.css?v=145" in APP_HTML assert "js/leaderboard.js?v=33" in APP_HTML assert "home-page.js?v=50" in APP_HTML assert "js/credit-format.js?v=1" in APP_HTML diff --git a/dashboard/backend/tests/test_frontend_model_facets.py b/dashboard/backend/tests/test_frontend_model_facets.py index cd035f5b..d84da950 100644 --- a/dashboard/backend/tests/test_frontend_model_facets.py +++ b/dashboard/backend/tests/test_frontend_model_facets.py @@ -54,6 +54,8 @@ def test_every_catalog_model_matches_a_vendor_prefix(): "AI-powered" with no chip and no badge, which is otherwise invisible.""" prefixes = [row[1] for row in _vendor_rows()] for template in _CATALOG: + if template.get("shelf") == "research": + continue # external Deep Research service (N2/PR2): no ATL-owned model model = template["model_name"].lower() assert any(model.startswith(p) for p in prefixes), ( f"{template['template_id']} runs {model!r}, which matches no MODEL_VENDORS prefix" diff --git a/dashboard/backend/tests/test_marketplace_catalog_models.py b/dashboard/backend/tests/test_marketplace_catalog_models.py index 9baec802..47861f70 100644 --- a/dashboard/backend/tests/test_marketplace_catalog_models.py +++ b/dashboard/backend/tests/test_marketplace_catalog_models.py @@ -47,6 +47,8 @@ def test_every_template_runs_a_supported_or_hosted_model(template): if template.get("runtime_type"): return # hosted runtime: its model is not user-selectable + if template.get("shelf") == "research": + return # external Deep Research service (N2/PR2): no ATL-owned model assert template["model_name"] in (_SUPPORTED_SLUGS | _LEADERBOARD_ONLY_SLUGS), ( f"{template['template_id']} runs {template['model_name']!r}, " "which is not in SUPPORTED_MODELS or the leaderboard" @@ -151,7 +153,11 @@ def test_catalog_rows_declare_a_supermarket_shelf(): def test_catalog_covers_every_pickable_vendor(): """The facet is decorative if most of its chips are empty.""" - vendors = {t["model_name"].split("/", 1)[0] for t in _CATALOG} + vendors = { + t["model_name"].split("/", 1)[0] + for t in _CATALOG + if t.get("shelf") != "research" + } assert {"anthropic", "openai", "google", "deepseek", "qwen"} <= vendors @@ -160,4 +166,6 @@ def test_catalog_includes_both_markets(): Agents shelf, so the China A-Share chip ships again without a hardcoded chip list. """ - assert {t.get("category") for t in _CATALOG} == {"us_stocks", "cn_ashares"} + assert { + t.get("category") for t in _CATALOG if t.get("shelf") != "research" + } == {"us_stocks", "cn_ashares"} diff --git a/dashboard/config/marketplace.json b/dashboard/config/marketplace.json index be95d9cb..b53e8c58 100644 --- a/dashboard/config/marketplace.json +++ b/dashboard/config/marketplace.json @@ -1,417 +1,468 @@ { - "templates": [ + "templates": [ + { + "template_id": "claude-haiku-4-5", + "shelf": "llms", + "name": "Claude Haiku 4.5", + "model_name": "anthropic/claude-haiku-4-5", + "description": "The Competition Leaderboard's Claude Haiku 4.5 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "claude-haiku-4-5", - "shelf": "llms", - "name": "Claude Haiku 4.5", - "model_name": "anthropic/claude-haiku-4-5", - "description": "The Competition Leaderboard's Claude Haiku 4.5 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_claude_haiku", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_claude_haiku", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "claude-sonnet-4-6", + "shelf": "llms", + "name": "Claude Sonnet 4.6", + "model_name": "anthropic/claude-sonnet-4-6", + "description": "The Competition Leaderboard's Claude Sonnet 4.6 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "claude-sonnet-4-6", - "shelf": "llms", - "name": "Claude Sonnet 4.6", - "model_name": "anthropic/claude-sonnet-4-6", - "description": "The Competition Leaderboard's Claude Sonnet 4.6 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_claude_sonnet", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_claude_sonnet", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "deepseek-v4-pro", + "shelf": "llms", + "name": "DeepSeek V4 Pro", + "model_name": "deepseek/deepseek-v4-pro", + "description": "The Competition Leaderboard's DeepSeek V4 Pro agent — the only model that beat the passive baselines in the April 2026 contest window. Same long-only DJIA hourly backtest; add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "deepseek-v4-pro", - "shelf": "llms", - "name": "DeepSeek V4 Pro", - "model_name": "deepseek/deepseek-v4-pro", - "description": "The Competition Leaderboard's DeepSeek V4 Pro agent — the only model that beat the passive baselines in the April 2026 contest window. Same long-only DJIA hourly backtest; add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_deepseek_v4", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_deepseek_v4", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "gpt-5-5", + "shelf": "llms", + "name": "GPT-5.5", + "model_name": "openai/gpt-5.5", + "description": "The Competition Leaderboard's GPT-5.5 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "gpt-5-5", - "shelf": "llms", - "name": "GPT-5.5", - "model_name": "openai/gpt-5.5", - "description": "The Competition Leaderboard's GPT-5.5 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_gpt_5_5", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_gpt_5_5", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "gemini-3-1-pro-preview", + "shelf": "llms", + "name": "Gemini 3.1 Pro Preview", + "model_name": "google/gemini-3.1-pro-preview", + "description": "The Competition Leaderboard's Gemini 3.1 Pro Preview agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "gemini-3-1-pro-preview", - "shelf": "llms", - "name": "Gemini 3.1 Pro Preview", - "model_name": "google/gemini-3.1-pro-preview", - "description": "The Competition Leaderboard's Gemini 3.1 Pro Preview agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_gemini_pro", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_gemini_pro", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "nemotron-3-nano-30b", + "shelf": "llms", + "name": "Nemotron 3 Nano 30B", + "model_name": "nvidia/nemotron-3-nano-30b-a3b", + "description": "The Competition Leaderboard's NVIDIA Nemotron 3 Nano 30B agent. An open-weight model on the same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "nemotron-3-nano-30b", - "shelf": "llms", - "name": "Nemotron 3 Nano 30B", - "model_name": "nvidia/nemotron-3-nano-30b-a3b", - "description": "The Competition Leaderboard's NVIDIA Nemotron 3 Nano 30B agent. An open-weight model on the same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_nemotron_nano", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_nemotron_nano", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "qwen3-7-plus", + "shelf": "llms", + "name": "Qwen3.7 Plus", + "model_name": "qwen/qwen3.7-plus", + "description": "The Competition Leaderboard's Qwen3.7 Plus agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "qwen3-7-plus", - "shelf": "llms", - "name": "Qwen3.7 Plus", - "model_name": "qwen/qwen3.7-plus", - "description": "The Competition Leaderboard's Qwen3.7 Plus agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", - "category": "us_stocks", - "tags": [ - "competition model", - "DJIA" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_qwen3_7", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_qwen3_7", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "ai-hedge-fund", + "shelf": "open", + "card_subtitle": "Open-source multi-agent system", + "name": "AI Hedge Fund", + "model_name": "nvidia/nemotron-3-nano-30b-a3b", + "description": "A team of AI investors that analyzes the market, develops trading ideas, and tests them through backtesting.", + "category": "us_stocks", + "tags": [ + "analyst team", + "fundamentals", + "official template" + ], + "author": "virattt / Agentic Trading Lab", + "repo_url": "https://github.com/virattt/ai-hedge-fund", + "runtime_type": "ai_hedge_fund", + "runtime_config": { + "analysts": [ + "fundamentals_analyst", + "technical_analyst", + "sentiment_analyst", + "valuation_analyst" + ] + } + }, + { + "template_id": "balanced-starter", + "shelf": "open", + "name": "Balanced Starter", + "model_name": "anthropic/claude-haiku-4-5", + "description": "A simple starter agent that diversifies across strong stocks, buys dips, and takes profits after run-ups.", + "category": "us_stocks", + "tags": [ + "starter", + "diversified" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "ai-hedge-fund", - "shelf": "open", - "card_subtitle": "Open-source multi-agent system", - "name": "AI Hedge Fund", - "model_name": "nvidia/nemotron-3-nano-30b-a3b", - "description": "A team of AI investors that analyzes the market, develops trading ideas, and tests them through backtesting.", - "category": "us_stocks", - "tags": [ - "analyst team", - "fundamentals", - "official template" - ], - "author": "virattt / Agentic Trading Lab", - "repo_url": "https://github.com/virattt/ai-hedge-fund", - "runtime_type": "ai_hedge_fund", - "runtime_config": { - "analysts": [ - "fundamentals_analyst", - "technical_analyst", - "sentiment_analyst", - "valuation_analyst" - ] - } - }, + "id": "sub_balanced_starter", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "momentum-scout", + "shelf": "open", + "name": "Momentum Scout", + "model_name": "anthropic/claude-haiku-4-5", + "description": "Focus on recent price strength and volume. Favor leaders with positive momentum and trim laggards quickly.", + "category": "us_stocks", + "tags": [ + "momentum", + "trend" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "balanced-starter", - "shelf": "open", - "name": "Balanced Starter", - "model_name": "anthropic/claude-haiku-4-5", - "description": "A simple starter agent that diversifies across strong stocks, buys dips, and takes profits after run-ups.", - "category": "us_stocks", - "tags": [ - "starter", - "diversified" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_balanced_starter", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_momentum_instruction", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Prioritize stocks showing the strongest recent momentum and healthy volume. Add to winners on pullbacks, cut positions that lose momentum, and keep cash when the tape is unclear.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "pipeline-analyst", + "shelf": "open", + "name": "Three-Step Analyst", + "model_name": "anthropic/claude-sonnet-4-6", + "description": "A three-step strategy: gather market facts, convert them into signals, then produce executable orders.", + "category": "us_stocks", + "tags": [ + "multi-step strategy", + "official template" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "momentum-scout", - "shelf": "open", - "name": "Momentum Scout", - "model_name": "anthropic/claude-haiku-4-5", - "description": "Focus on recent price strength and volume. Favor leaders with positive momentum and trim laggards quickly.", - "category": "us_stocks", - "tags": [ - "momentum", - "trend" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_momentum_instruction", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Prioritize stocks showing the strongest recent momentum and healthy volume. Add to winners on pullbacks, cut positions that lose momentum, and keep cash when the tape is unclear.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] + "id": "sub_gather", + "presetKey": "info_gather", + "label": "Information Gathering", + "prompt": "You are the information-gathering sub-agent. Collect key facts relevant to trading decisions from market data, news, and macro events. Filter noise and keep high-confidence facts and indicator changes.", + "outputFormat": "JSON: { \"timestamp\": \"ISO8601\", \"symbols\": [\"...\"], \"facts\": [{ \"source\": \"...\", \"summary\": \"...\", \"impact\": \"bullish|bearish|neutral\" }], \"confidence\": 0.0-1.0 }" }, { - "template_id": "pipeline-analyst", - "shelf": "open", - "name": "Three-Step Analyst", - "model_name": "anthropic/claude-sonnet-4-6", - "description": "A three-step strategy: gather market facts, convert them into signals, then produce executable orders.", - "category": "us_stocks", - "tags": [ - "multi-step strategy", - "official template" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_gather", - "presetKey": "info_gather", - "label": "Information Gathering", - "prompt": "You are the information-gathering sub-agent. Collect key facts relevant to trading decisions from market data, news, and macro events. Filter noise and keep high-confidence facts and indicator changes.", - "outputFormat": "JSON: { \"timestamp\": \"ISO8601\", \"symbols\": [\"...\"], \"facts\": [{ \"source\": \"...\", \"summary\": \"...\", \"impact\": \"bullish|bearish|neutral\" }], \"confidence\": 0.0-1.0 }" - }, - { - "id": "sub_signal", - "presetKey": "info_to_signal", - "label": "Information to Signal", - "prompt": "You are the signal-generation sub-agent. Based on upstream information-gathering output, convert facts and indicators into executable trading signals (direction, strength, time horizon).", - "outputFormat": "JSON: { \"signals\": [{ \"symbol\": \"...\", \"direction\": \"long|short|flat\", \"strength\": 0.0-1.0, \"horizon\": \"1h|4h|1d\", \"rationale\": \"...\" }] }" - }, - { - "id": "sub_exec", - "presetKey": "signal_to_execution", - "label": "Signal to Execution", - "prompt": "You are the trade-execution sub-agent. Turn signals into concrete order instructions, respecting position limits, liquidity, and slippage. Output a submit-ready buy/sell plan.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] + "id": "sub_signal", + "presetKey": "info_to_signal", + "label": "Information to Signal", + "prompt": "You are the signal-generation sub-agent. Based on upstream information-gathering output, convert facts and indicators into executable trading signals (direction, strength, time horizon).", + "outputFormat": "JSON: { \"signals\": [{ \"symbol\": \"...\", \"direction\": \"long|short|flat\", \"strength\": 0.0-1.0, \"horizon\": \"1h|4h|1d\", \"rationale\": \"...\" }] }" }, { - "template_id": "blue-chip-steady", - "shelf": "open", - "name": "Blue-Chip Steady", - "model_name": "anthropic/claude-haiku-4-5", - "description": "Buy and hold a handful of the strongest Dow companies, selling only when a position deteriorates badly. Mirrors the buy-and-hold benchmark on our leaderboard.", - "category": "us_stocks", - "tags": [ - "buy and hold", - "blue chips" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_blue_chip_steady", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Buy and hold a handful of the strongest Dow companies. Sell only if a company's position deteriorates badly. Do not chase short-term moves.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_exec", + "presetKey": "signal_to_execution", + "label": "Signal to Execution", + "prompt": "You are the trade-execution sub-agent. Turn signals into concrete order instructions, respecting position limits, liquidity, and slippage. Output a submit-ready buy/sell plan.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "blue-chip-steady", + "shelf": "open", + "name": "Blue-Chip Steady", + "model_name": "anthropic/claude-haiku-4-5", + "description": "Buy and hold a handful of the strongest Dow companies, selling only when a position deteriorates badly. Mirrors the buy-and-hold benchmark on our leaderboard.", + "category": "us_stocks", + "tags": [ + "buy and hold", + "blue chips" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "even-split-dow", - "shelf": "open", - "name": "Even-Split Dow", - "model_name": "anthropic/claude-haiku-4-5", - "description": "Spread the money evenly across all available Dow stocks and keep the split even. Mirrors the equal-weight benchmark on our leaderboard.", - "category": "us_stocks", - "tags": [ - "equal weight", - "diversified" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_even_split_dow", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money evenly across all available Dow stocks and keep the split even, rebalancing when any position drifts far from its equal share.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_blue_chip_steady", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Buy and hold a handful of the strongest Dow companies. Sell only if a company's position deteriorates badly. Do not chase short-term moves.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "even-split-dow", + "shelf": "open", + "name": "Even-Split Dow", + "model_name": "anthropic/claude-haiku-4-5", + "description": "Spread the money evenly across all available Dow stocks and keep the split even. Mirrors the equal-weight benchmark on our leaderboard.", + "category": "us_stocks", + "tags": [ + "equal weight", + "diversified" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "ashare-steady-t1", - "shelf": "open", - "name": "A-Share Steady (T+1)", - "model_name": "anthropic/claude-haiku-4-5", - "description": "A patient strategy for Chinese A-shares, built for that market's rule that shares bought today cannot be sold until the next trading day.", - "category": "cn_ashares", - "tags": [ - "a-shares", - "patient" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_ashare_steady", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Trade the available Chinese A-share stocks patiently. Because shares bought today cannot be sold until the next trading day, avoid quick in-and-out trades; build positions you are willing to hold overnight.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_even_split_dow", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money evenly across all available Dow stocks and keep the split even, rebalancing when any position drifts far from its equal share.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "ashare-steady-t1", + "shelf": "open", + "name": "A-Share Steady (T+1)", + "model_name": "anthropic/claude-haiku-4-5", + "description": "A patient strategy for Chinese A-shares, built for that market's rule that shares bought today cannot be sold until the next trading day.", + "category": "cn_ashares", + "tags": [ + "a-shares", + "patient" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "contrarian-dip-buyer", - "shelf": "open", - "name": "Contrarian Dip Buyer", - "model_name": "openai/gpt-5.5", - "description": "Buys stocks that have sold off hard and trims them back once they have recovered. The opposite instinct to a momentum strategy.", - "category": "us_stocks", - "tags": [ - "contrarian", - "mean reversion" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_contrarian_dip", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Look for stocks that have fallen well below where they were trading recently and buy those, in small pieces rather than all at once. Sell back into strength once a position has recovered. Do not chase stocks that are already running.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_ashare_steady", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Trade the available Chinese A-share stocks patiently. Because shares bought today cannot be sold until the next trading day, avoid quick in-and-out trades; build positions you are willing to hold overnight.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "contrarian-dip-buyer", + "shelf": "open", + "name": "Contrarian Dip Buyer", + "model_name": "openai/gpt-5.5", + "description": "Buys stocks that have sold off hard and trims them back once they have recovered. The opposite instinct to a momentum strategy.", + "category": "us_stocks", + "tags": [ + "contrarian", + "mean reversion" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "sector-rotator", - "shelf": "open", - "name": "Sector Rotator", - "model_name": "google/gemini-3.1-pro-preview", - "description": "Concentrates into whichever part of the market is leading, and moves on when leadership changes.", - "category": "us_stocks", - "tags": [ - "rotation", - "trend" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_sector_rotator", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Group the available stocks by the kind of business they are in. Put most of the money into the group that has been performing best, and hold two or three names from it rather than one. When a different group takes the lead, sell out of the old one before building the new position.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_contrarian_dip", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Look for stocks that have fallen well below where they were trading recently and buy those, in small pieces rather than all at once. Sell back into strength once a position has recovered. Do not chase stocks that are already running.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "sector-rotator", + "shelf": "open", + "name": "Sector Rotator", + "model_name": "google/gemini-3.1-pro-preview", + "description": "Concentrates into whichever part of the market is leading, and moves on when leadership changes.", + "category": "us_stocks", + "tags": [ + "rotation", + "trend" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "volatility-guard", - "shelf": "open", - "name": "Volatility Guard", - "model_name": "deepseek/deepseek-v4-pro", - "description": "Holds a steady portfolio in calm markets and cuts exposure when prices start swinging. Runs on the only model that has beaten the passive baselines on our leaderboard.", - "category": "us_stocks", - "tags": [ - "risk management", - "defensive" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_volatility_guard", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Judge how violently prices have been moving lately compared with earlier in the period. While things are calm, stay invested across several stocks. When the swings get noticeably larger, sell part of every position and hold the cash rather than switching stocks. Rebuild the positions gradually once the market settles down. Protecting the money matters more here than catching every rally.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, + "id": "sub_sector_rotator", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Group the available stocks by the kind of business they are in. Put most of the money into the group that has been performing best, and hold two or three names from it rather than one. When a different group takes the lead, sell out of the old one before building the new position.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "volatility-guard", + "shelf": "open", + "name": "Volatility Guard", + "model_name": "deepseek/deepseek-v4-pro", + "description": "Holds a steady portfolio in calm markets and cuts exposure when prices start swinging. Runs on the only model that has beaten the passive baselines on our leaderboard.", + "category": "us_stocks", + "tags": [ + "risk management", + "defensive" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_volatility_guard", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Judge how violently prices have been moving lately compared with earlier in the period. While things are calm, stay invested across several stocks. When the swings get noticeably larger, sell part of every position and hold the cash rather than switching stocks. Rebuild the positions gradually once the market settles down. Protecting the money matters more here than catching every rally.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "ashare-momentum-t1", + "shelf": "open", + "name": "A-Share Momentum (T+1)", + "model_name": "qwen/qwen3.7-plus", + "description": "Rides the strongest Chinese A-shares while respecting that market's rule that shares bought today cannot be sold until the next trading day.", + "category": "cn_ashares", + "tags": [ + "a-shares", + "momentum" + ], + "author": "Agentic Trading Lab", + "pipeline": [ { - "template_id": "ashare-momentum-t1", - "shelf": "open", - "name": "A-Share Momentum (T+1)", - "model_name": "qwen/qwen3.7-plus", - "description": "Rides the strongest Chinese A-shares while respecting that market's rule that shares bought today cannot be sold until the next trading day.", - "category": "cn_ashares", - "tags": [ - "a-shares", - "momentum" - ], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_ashare_momentum", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Buy the Chinese A-shares that have been climbing most steadily and hold them while they keep leading. Shares bought today cannot be sold until the next trading day, so only buy what you are happy to still own tomorrow, and plan any exit at least a day ahead. Sell a position once it stops leading.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] + "id": "sub_ashare_momentum", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Buy the Chinese A-shares that have been climbing most steadily and hold them while they keep leading. Shares bought today cannot be sold until the next trading day, so only buy what you are happy to still own tomorrow, and plan any exit at least a day ahead. Sell a position once it stops leading.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" } - ] + ] + }, + { + "template_id": "shell-company-screening", + "shelf": "research", + "name": "Shell Company Screening Agent", + "description": "Deep Research agent that identifies and evaluates listed shell-company candidates for reverse mergers and other M&A transactions. Fill in a client mandate, get a structured, evidence-backed screening report.", + "tags": [ + "M&A", + "deep research", + "screening" + ], + "author": "Lin-Feihan", + "research": { + "agent_id": "shell-company-screening", + "service_base_url_env": "RESEARCH_SHELL_SERVICE_URL", + "service_base_url_default": "http://127.0.0.1:9100", + "estimated_runtime_seconds": 300, + "max_runtime_seconds": 1800, + "output_formats": [ + "markdown", + "docx", + "pdf", + "evidence_json" + ] + }, + "repo_url": "https://github.com/Lin-Feihan/shell-company-screening-agent" + }, + { + "template_id": "due-diligence-agent", + "shelf": "research", + "name": "Due Diligence Agent", + "description": "Deep Research agent for public-source corporate due diligence: planning, multi-dimensional investigation, risk assessment, valuation and deal-impact analysis, delivered as a standalone DD report.", + "tags": [ + "M&A", + "deep research", + "due diligence" + ], + "author": "Lin-Feihan", + "research": { + "agent_id": "due-diligence-agent", + "service_base_url_env": "RESEARCH_DD_SERVICE_URL", + "service_base_url_default": "http://127.0.0.1:9100", + "estimated_runtime_seconds": 300, + "max_runtime_seconds": 1800, + "output_formats": [ + "markdown", + "pdf", + "evidence_json" + ] + }, + "repo_url": "https://github.com/Lin-Feihan/due-diligence-agent" + } + ] } diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html index 2d17b152..245a4b67 100644 --- a/dashboard/frontend/app.html +++ b/dashboard/frontend/app.html @@ -13,7 +13,7 @@ because every API call is a CORS request. --> - + @@ -189,6 +189,7 @@ + @@ -1031,6 +1032,22 @@

Open Agents

+ +
+
+
+ +

Research Agents

+ +
+

Deep Research agents that produce analyst reports. Add them from Community, fill in a mandate, and get a downloadable report.

+
+
+ +
+ + + + +