Files
Auto-Trader/agent/api_server.py
T
Haozhe Wu d77c64e2ce feat(alpha-zoo): bench runner + CLI + Web UI + 4 REST routes
End-to-end product surfaces over the factor framework: a CLI for
power users, an agent-tool layer for natural-language workflows,
a REST + SSE API for browser clients, and the Web UI itself.

CLI (vibe-trading alpha <subcommand>)
- list / show / bench / compare / export-manifest with security
  guards (path-traversal, alpha_id allowlist before any FS read)
- agent/cli.py wired (7-line glue), all logic in cli_handlers.py
- alpha bench: requires --zoo (or --yes); drives bench_runner with
  an on_progress callback feeding a Rich Progress bar; prints ETA
  banner; emits stderr summary "✓ Bench complete. N tested, M
  skipped. Report: <path>"; returns exit 1 on status != ok
- alpha list: --limit N / --json flags; TTY next-step hints
- alpha show: --brief; Syntax(word_wrap=True) so source survives
  narrow terminals
- TUSHARE_TOKEN errors append a 3-step fix banner
- ConstantInputWarning silenced at module level

Agent tools (auto-discovered via BaseTool.__subclasses__)
- AlphaZooTool: list_alphas / get_alpha / health
- AlphaBenchTool: orchestrator with Jinja2 autoescape HTML report +
  strict CSP meta. Module-level run_alpha_bench() reused by CLI

Universe loader (agent/src/tools/alpha_bench_tool.py)
- csi300 → Tushare index_weight + pro.daily, in-house panel build
  because the bundled tushare loader drops `amount`. Parallel fetch
  via ThreadPoolExecutor(max_workers=4); cache at ~/.vibe-trading/
  cache/<universe>_<start>_<end>.pkl with sha256 sidecar integrity
  guard
- sp500 → Wikipedia constituent list (UA-spoofed; bare pd.read_html
  returns 403) → yfinance loader; surfaces survivorship_bias=True
  in panel._meta because the constituent set is point-in-NOW
- btc-usdt → bundled okx loader; fails fast (ValueError) on single-
  asset cross-sectional IC degeneracy

REST API (agent/src/api/alpha_routes.py + agent/api_server.py +6 lines)
- GET /alpha/list?zoo=&theme=&universe=&limit= — paginated catalogue
- GET /alpha/{alpha_id} — meta + source code via Registry.get_source
- POST /alpha/bench — kicks off asyncio.create_task running the
  bench in asyncio.to_thread; in-memory job state with threading.Lock
  + 1-hour TTL + asyncio.Semaphore(2) concurrency cap (429 on
  saturation)
- GET /alpha/bench/{job_id}/stream — Server-Sent Events emitting
  progress / result / done / error events. Heartbeat ": ping\n\n"
  every 15s so reverse proxies don't close idle streams. Unexpected
  exceptions are sanitised to "internal error; see server logs"

Web UI (frontend/src/pages/AlphaZoo.tsx ~950 LOC, 3 sub-views)
- Browse: 4 gradient zoo cards (qlib158 / Kakushadze 101 / gtja191 /
  academic) + filter bar (zoo, theme, universe, id substring) +
  paginated table (50 rows + Load more)
- Detail: formula_latex in <code> block (no MathJax under strict
  CSP); metadata table; collapsible <details> source code; "Run
  bench" CTA prefilled
- Bench: form → SSE-streamed progress bar + currently-computing
  alpha id → Alive/Reversed/Dead/Skipped stat cards + Top-5-by-IR +
  Most-Reversed tables + by-theme echarts bar chart
- New /alpha-zoo route triplet wired in router.tsx; Layout.tsx
  nav entry with the Layers icon
- Raw EventSource pattern (useSSE hard-codes agent-stream event
  types and would drop our progress/result/done/error)
- a11y: htmlFor/id pairs on every label, aria-hidden on decorative
  lucide icons, sr-only table caption, CategoryBadge component
- BTC universe fail-fast surfaces as inline form error with hint
  to try sp500/csi300

Registry singleton via get_default_registry() so API requests don't
each pay the 452-module AST scan. compute_ic_series vectorised via
pandas corrwith(method="spearman"); ~5-10x speedup, numerically
identical to the prior scipy loop within 1.1e-16.

VWAP 千元 fix: empirical Tushare probe against 000001.SZ confirmed
daily.amount is in 千元 (not raw CNY). base.vwap(EQUITY_CN) now does
safe_div(amount * 1000.0, volume * 100.0 + 1.0) — previously
~1/1000th of true vwap. Affects factors mixing vwap with price
additively (alpha101_093 etc.).

969 tests pass; registry health 452 loaded / 0 failed; CI grep
gates green; TypeScript clean.
2026-05-17 20:04:02 +08:00

1799 lines
64 KiB
Python

#!/usr/bin/env python3
"""Vibe-Trading API Server - RESTful API for finance research and backtesting.
V5: ReAct Agent + async /run + CORS env + SSE tool events.
"""
from __future__ import annotations
import asyncio
import hmac
import ipaddress
import json
import os
import signal
import time
import csv
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request, Security, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
from rich.console import Console
from src.ui_services import build_run_analysis, load_run_context
# UTF-8 on Windows
import sys as _sys
for _s in ("stdout", "stderr"):
_r = getattr(getattr(_sys, _s, None), "reconfigure", None)
if callable(_r):
_r(encoding="utf-8", errors="replace")
RUNS_DIR = Path(__file__).resolve().parent / "runs"
SESSIONS_DIR = Path(__file__).resolve().parent / "sessions"
UPLOADS_DIR = Path(__file__).resolve().parent / "uploads"
AGENT_DIR = Path(__file__).resolve().parent
ENV_PATH = AGENT_DIR / ".env"
ENV_EXAMPLE_PATH = AGENT_DIR / ".env.example"
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
# Rich console for colored logs
console = Console()
# ============================================================================
# Pydantic Models
# ============================================================================
class Artifact(BaseModel):
"""Artifact file metadata."""
name: str = Field(..., description="File name")
path: str = Field(..., description="File path")
type: str = Field(..., description="File type: csv, json, txt, etc.")
size: int = Field(..., description="Size in bytes")
exists: bool = Field(..., description="Whether the file exists")
class BacktestMetrics(BaseModel):
"""Backtest summary metrics."""
model_config = {"extra": "allow"}
final_value: float = Field(..., description="Ending portfolio value")
total_return: float = Field(..., description="Total return")
annual_return: float = Field(..., description="Annualized return")
max_drawdown: float = Field(..., description="Max drawdown")
sharpe: float = Field(..., description="Sharpe ratio")
win_rate: float = Field(..., description="Win rate")
trade_count: int = Field(..., description="Number of trades")
class RAGSelection(BaseModel):
"""RAG routing result."""
selected_api: str = Field(..., description="Selected API code")
selected_name: str = Field(..., description="Selected API name")
selected_score: float = Field(..., description="Match score")
class RunInfo(BaseModel):
"""Compact run row for list views."""
run_id: str
status: str
created_at: str
prompt: Optional[str] = None
total_return: Optional[float] = None
sharpe: Optional[float] = None
codes: List[str] = Field(default_factory=list)
start_date: Optional[str] = None
end_date: Optional[str] = None
class RunResponse(BaseModel):
"""API response payload for a single run."""
status: str = Field(..., description="Run status: success, failed, aborted")
run_id: str = Field(..., description="Run identifier")
elapsed_seconds: float = Field(..., description="Execution time in seconds")
reason: Optional[str] = Field(None, description="Failure reason when available")
planner_output: Optional[Dict[str, Any]] = Field(None, description="Planner output")
strategy_spec: Optional[Dict[str, Any]] = Field(None, description="Strategy specification")
rag_selection: Optional[RAGSelection] = Field(None, description="Selected RAG metadata")
metrics: Optional[BacktestMetrics] = Field(None, description="Backtest metrics")
artifacts: List[Artifact] = Field(default_factory=list, description="Run artifacts")
run_card: Optional[Dict[str, Any]] = Field(None, description="Trust Layer run card payload")
equity_curve: Optional[List[Dict[str, Any]]] = Field(None, description="Equity preview")
trade_log: Optional[List[Dict[str, Any]]] = Field(None, description="Trade preview")
artifacts_equity_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full equity rows")
artifacts_metrics_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full metrics rows")
artifacts_trades_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full trade rows")
validation: Optional[Dict[str, Any]] = Field(None, description="Statistical validation results")
run_directory: str = Field(..., description="Run directory path")
run_stage: Optional[str] = Field(None, description="UI-facing run stage")
run_context: Optional[Dict[str, Any]] = Field(None, description="Normalized request context")
price_series: Optional[Dict[str, List[Dict[str, Any]]]] = Field(None, description="Grouped OHLC series")
indicator_series: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = Field(
None,
description="Grouped indicator overlays",
)
trade_markers: Optional[List[Dict[str, Any]]] = Field(None, description="Trade markers for charts")
run_logs: Optional[List[Dict[str, Any]]] = Field(None, description="Structured stdout/stderr lines")
class HealthResponse(BaseModel):
"""Health check payload."""
status: str = Field(..., description="Service status")
service: str = Field(..., description="Service name")
timestamp: str = Field(..., description="Server timestamp")
class LLMProviderOption(BaseModel):
"""Supported LLM provider metadata for the settings UI."""
name: str
label: str
api_key_env: Optional[str] = None
base_url_env: str
default_model: str
default_base_url: str
api_key_required: bool = True
auth_type: str = "api_key"
login_command: Optional[str] = None
class LLMSettingsResponse(BaseModel):
"""Current LLM runtime settings."""
provider: str
model_name: str
base_url: str
api_key_env: Optional[str] = None
api_key_configured: bool
api_key_hint: Optional[str] = None
api_key_required: bool
temperature: float
timeout_seconds: int
max_retries: int
reasoning_effort: str
env_path: str
providers: List[LLMProviderOption]
class UpdateLLMSettingsRequest(BaseModel):
"""Update LLM settings persisted to agent/.env."""
provider: str = Field(..., min_length=1)
model_name: str = Field(..., min_length=1)
base_url: Optional[str] = None
api_key: Optional[str] = None
clear_api_key: bool = False
temperature: float = 0.0
timeout_seconds: int = Field(120, ge=1, le=3600)
max_retries: int = Field(2, ge=0, le=20)
reasoning_effort: Optional[str] = None
class DataSourceSettingsResponse(BaseModel):
"""Current data source credential settings."""
tushare_token_configured: bool
tushare_token_hint: Optional[str] = None
baostock_supported: bool
baostock_installed: bool
baostock_message: str
env_path: str
class UpdateDataSourceSettingsRequest(BaseModel):
"""Update project-local data source credentials."""
tushare_token: Optional[str] = None
clear_tushare_token: bool = False
# ---- V4 Session Models ----
class CreateSessionRequest(BaseModel):
"""Create session request body."""
title: str = Field("", description="Session title")
config: Optional[Dict[str, Any]] = Field(None, description="Session config")
class SessionResponse(BaseModel):
"""Session record."""
session_id: str
title: str
status: str
created_at: str
updated_at: str
last_attempt_id: Optional[str] = None
class SendMessageRequest(BaseModel):
"""Send chat message: natural-language strategy description."""
content: str = Field(..., description="Natural language strategy description", min_length=1, max_length=5000)
class MessageResponse(BaseModel):
"""Stored chat message."""
message_id: str
session_id: str
role: str
content: str
created_at: str
linked_attempt_id: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
# ============================================================================
# FastAPI Application
# ============================================================================
app = FastAPI(
title="Vibe-Trading API",
description="Vibe-Trading API: natural-language finance research, backtesting, and swarm workflows",
version="5.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
_DEFAULT_CORS_ORIGINS = [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:8000",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:8000",
]
def _parse_cors_origins(raw: Optional[str]) -> List[str]:
"""Parse CORS origins and reject credentialed wildcard configuration.
Args:
raw: Comma-separated CORS origins from ``CORS_ORIGINS``. ``None`` or a
blank value uses the loopback development defaults.
Returns:
Explicit CORS origins accepted by the API server.
Raises:
RuntimeError: If a wildcard origin is configured while credentials are
enabled.
"""
if raw is None or not raw.strip():
return list(_DEFAULT_CORS_ORIGINS)
origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
if "*" in origins:
raise RuntimeError(
"CORS_ORIGINS='*' is not allowed while credentials are enabled; "
"configure explicit Web UI origins instead."
)
return origins
# CORS: override with CORS_ORIGINS (comma-separated explicit origins)
_CORS_ORIGINS = _parse_cors_origins(os.getenv("CORS_ORIGINS"))
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def _run_startup_preflight() -> None:
"""Run preflight checks on server startup."""
from src.preflight import run_preflight
run_preflight(console)
# ============================================================================
# API Key Authentication
# ============================================================================
_security = HTTPBearer(auto_error=False)
_API_KEY = os.getenv("API_AUTH_KEY")
_SHELL_TOOLS_ENV = "VIBE_TRADING_ENABLE_SHELL_TOOLS"
_DOCKER_LOOPBACK_ENV = "VIBE_TRADING_TRUST_DOCKER_LOOPBACK"
def _configured_api_key() -> str:
"""Return the current API auth key, if configured."""
return os.getenv("API_AUTH_KEY") or _API_KEY or ""
async def require_auth(
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Validate Bearer token for sensitive API endpoints.
Args:
request: Incoming HTTP request.
cred: HTTP Bearer credentials extracted from the Authorization header.
Raises:
HTTPException: 403 when dev-mode auth is reached from a non-local client.
HTTPException: 401 when API_AUTH_KEY is set but the token is missing or wrong.
"""
_validate_api_auth(request=request, cred=cred)
async def require_event_stream_auth(
request: Request,
api_key: Optional[str] = Query(None),
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Validate auth for browser EventSource streams.
Native EventSource cannot send custom Authorization headers, so event
stream endpoints may accept the API key from the query string. Normal JSON
endpoints must continue to use Bearer auth only.
Args:
request: Incoming HTTP request.
api_key: Optional query-string API key for EventSource clients.
cred: HTTP Bearer credentials extracted from the Authorization header.
"""
_validate_api_auth(request=request, cred=cred, query_api_key=api_key, allow_query=True)
def _auth_credential_from_header_or_query(
cred: Optional[HTTPAuthorizationCredentials],
query_api_key: Optional[str],
*,
allow_query: bool,
) -> str:
"""Return the supplied API credential from the permitted source."""
if cred and cred.credentials:
return cred.credentials
if allow_query and query_api_key:
return query_api_key
return ""
def _validate_api_auth(
*,
request: Request,
cred: Optional[HTTPAuthorizationCredentials],
query_api_key: Optional[str] = None,
allow_query: bool = False,
) -> None:
"""Validate configured auth, preserving loopback-only dev mode."""
api_key = _configured_api_key()
if not api_key:
if _is_local_client(request):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API_AUTH_KEY is required for non-local API access",
)
token = _auth_credential_from_header_or_query(cred, query_api_key, allow_query=allow_query)
if not token or not hmac.compare_digest(token, api_key):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
def _is_local_client(request: Request) -> bool:
"""Return whether the request originates from a loopback client."""
host = request.client.host if request.client else ""
if host in {"localhost", "testclient"}:
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
if ip.is_loopback:
return True
return _trusted_docker_loopback_ip(ip)
def _env_flag_enabled(name: str) -> bool:
"""Return whether a boolean environment flag is enabled."""
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _default_gateway_ips() -> set[ipaddress.IPv4Address]:
"""Return IPv4 default gateway addresses from Linux procfs."""
gateways: set[ipaddress.IPv4Address] = set()
try:
lines = Path("/proc/net/route").read_text(encoding="utf-8").splitlines()
except OSError:
return gateways
for line in lines[1:]:
fields = line.split()
if len(fields) < 3 or fields[1] != "00000000":
continue
try:
raw = int(fields[2], 16).to_bytes(4, byteorder="little")
gateways.add(ipaddress.IPv4Address(raw))
except ValueError:
continue
return gateways
def _trusted_docker_loopback_ip(ip: ipaddress._BaseAddress) -> bool:
"""Return whether an IP is the trusted Docker host gateway.
Docker Desktop presents host requests to a container as the bridge gateway
instead of 127.0.0.1. This escape hatch is safe only when the published
port is bound to host loopback, so the official compose file enables it
together with a 127.0.0.1 port binding.
"""
if not isinstance(ip, ipaddress.IPv4Address):
return False
if not _env_flag_enabled(_DOCKER_LOOPBACK_ENV):
return False
return ip in _default_gateway_ips()
def _env_shell_tools_enabled() -> bool:
"""Return whether server-side shell tools are explicitly enabled."""
return _env_flag_enabled(_SHELL_TOOLS_ENV)
def _shell_tools_enabled_for_request(request: Request) -> bool:
"""Return whether this API request may expose shell tools to the agent."""
return _is_local_client(request) or _env_shell_tools_enabled()
async def require_local_or_auth(
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Protect settings access when dev-mode auth is disabled.
If API_AUTH_KEY is configured, require the bearer token. If not, allow only
loopback clients so an API server bound to 0.0.0.0 cannot accept remote
credential reads or writes in dev mode.
"""
if _configured_api_key():
await require_auth(request, cred)
return
if not _is_local_client(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Settings access requires API_AUTH_KEY or a local loopback client",
)
# ============================================================================
# Workflow Factory
# ============================================================================
# ============================================================================
# Helper Functions
# ============================================================================
LLM_PROVIDER_CONFIG_PATH = AGENT_DIR / "src" / "providers" / "llm_providers.json"
def _load_llm_providers() -> List[LLMProviderOption]:
"""Load provider metadata from JSON so additions stay data-driven."""
try:
raw = json.loads(LLM_PROVIDER_CONFIG_PATH.read_text(encoding="utf-8"))
providers = [LLMProviderOption(**item) for item in raw]
except Exception as exc:
raise RuntimeError(f"Failed to load LLM provider config: {LLM_PROVIDER_CONFIG_PATH}") from exc
seen: set[str] = set()
for provider in providers:
if provider.name in seen:
raise RuntimeError(f"Duplicate LLM provider name: {provider.name}")
seen.add(provider.name)
if not providers:
raise RuntimeError("LLM provider config must not be empty")
return providers
LLM_PROVIDERS = _load_llm_providers()
LLM_PROVIDER_BY_NAME = {provider.name: provider for provider in LLM_PROVIDERS}
LLM_REASONING_EFFORTS = {"", "low", "medium", "high", "max"}
LLM_API_KEY_PLACEHOLDERS = {"", "sk-or-v1-your-key-here", "sk-xxx", "xxx", "gsk_xxx"}
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
def _ensure_agent_env_file() -> Path:
"""Ensure the project-local agent/.env exists."""
if not ENV_PATH.exists():
ENV_PATH.write_text("# Created by Vibe-Trading Web UI settings.\n", encoding="utf-8")
return ENV_PATH
def _strip_env_value(value: str) -> str:
"""Remove basic dotenv quotes and inline comments."""
value = value.strip()
if " #" in value:
value = value.split(" #", 1)[0].rstrip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
return value.strip()
def _read_env_values(path: Path) -> Dict[str, str]:
"""Read active KEY=value entries from a dotenv file."""
values: Dict[str, str] = {}
if not path.exists():
return values
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key:
values[key] = _strip_env_value(value)
return values
def _read_settings_env_values() -> Dict[str, str]:
"""Read settings without creating agent/.env.
Prefer the user's active agent/.env. If it does not exist yet, fall back to
agent/.env.example for display defaults only.
"""
if ENV_PATH.exists():
return _read_env_values(ENV_PATH)
if ENV_EXAMPLE_PATH.exists():
return _read_env_values(ENV_EXAMPLE_PATH)
return {}
def _project_relative_path(path: Path) -> str:
"""Return a project-relative display path without leaking an absolute path."""
try:
return path.resolve().relative_to(AGENT_DIR.parent.resolve()).as_posix()
except ValueError:
return path.name
def _format_env_value(value: str) -> str:
"""Format a dotenv value without allowing multiline injection."""
if "\n" in value or "\r" in value:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Environment values cannot contain newlines")
value = value.strip()
if not value:
return ""
if any(ch.isspace() for ch in value) or "#" in value:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
return value
def _write_env_values(path: Path, updates: Dict[str, str]) -> None:
"""Upsert active dotenv values while preserving comments and ordering."""
_ensure_agent_env_file()
lines = path.read_text(encoding="utf-8").splitlines()
seen: set[str] = set()
for index, raw in enumerate(lines):
stripped = raw.lstrip()
is_comment = stripped.startswith("#")
candidate = stripped[1:].lstrip() if is_comment else stripped
if "=" not in candidate:
continue
key = candidate.split("=", 1)[0].strip()
if key in updates and key not in seen:
lines[index] = f"{key}={_format_env_value(updates[key])}"
seen.add(key)
missing = [key for key in updates if key not in seen]
if missing:
if lines and lines[-1].strip():
lines.append("")
lines.append("# Updated from Web UI")
for key in missing:
lines.append(f"{key}={_format_env_value(updates[key])}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _is_configured_secret(value: str, placeholders: set[str]) -> bool:
"""Return True when a secret is set and not a documented placeholder."""
normalized = value.strip().strip('"').strip("'")
if not normalized:
return False
return normalized.lower() not in {placeholder.lower() for placeholder in placeholders}
def _coerce_float(value: str, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _coerce_int(value: str, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _build_llm_settings_response(values: Optional[Dict[str, str]] = None) -> LLMSettingsResponse:
"""Build the public settings payload from dotenv values."""
env_values = values if values is not None else _read_settings_env_values()
provider_name = env_values.get("LANGCHAIN_PROVIDER", "openai").strip().lower()
provider = LLM_PROVIDER_BY_NAME.get(provider_name, LLM_PROVIDER_BY_NAME["openai"])
api_key = env_values.get(provider.api_key_env or "", "") if provider.api_key_env else ""
api_key_configured = _is_configured_secret(api_key, LLM_API_KEY_PLACEHOLDERS)
api_key_hint = None
if provider.auth_type == "oauth":
try:
from src.providers.openai_codex import get_openai_codex_login_status
token = get_openai_codex_login_status()
except Exception:
token = None
api_key_configured = bool(token)
api_key_hint = None
return LLMSettingsResponse(
provider=provider.name,
model_name=env_values.get("LANGCHAIN_MODEL_NAME", provider.default_model),
base_url=env_values.get(provider.base_url_env, provider.default_base_url),
api_key_env=provider.api_key_env,
api_key_configured=api_key_configured,
api_key_hint=api_key_hint,
api_key_required=provider.api_key_required,
temperature=_coerce_float(env_values.get("LANGCHAIN_TEMPERATURE", "0.0"), 0.0),
timeout_seconds=_coerce_int(env_values.get("TIMEOUT_SECONDS", "120"), 120),
max_retries=_coerce_int(env_values.get("MAX_RETRIES", "2"), 2),
reasoning_effort=env_values.get("LANGCHAIN_REASONING_EFFORT", "").strip().lower(),
env_path=_project_relative_path(ENV_PATH),
providers=LLM_PROVIDERS,
)
def _baostock_supported() -> bool:
"""Check whether the project has a BaoStock loader implementation."""
loader_dir = AGENT_DIR / "backtest" / "loaders"
return any((loader_dir / name).exists() for name in ("baostock.py", "baostock_loader.py"))
def _baostock_installed() -> bool:
"""Check whether the optional BaoStock package is importable."""
import importlib.util
return importlib.util.find_spec("baostock") is not None
def _build_data_source_settings_response(values: Optional[Dict[str, str]] = None) -> DataSourceSettingsResponse:
"""Build the public data source settings payload."""
env_values = values if values is not None else _read_settings_env_values()
token = env_values.get("TUSHARE_TOKEN", "")
token_configured = _is_configured_secret(token, TUSHARE_TOKEN_PLACEHOLDERS)
supported = _baostock_supported()
installed = _baostock_installed()
if supported:
baostock_message = "BaoStock loader is available."
elif installed:
baostock_message = "BaoStock package is installed, but this project has no BaoStock loader."
else:
baostock_message = "No BaoStock loader is registered in this project."
return DataSourceSettingsResponse(
tushare_token_configured=token_configured,
tushare_token_hint=None,
baostock_supported=supported,
baostock_installed=installed,
baostock_message=baostock_message,
env_path=_project_relative_path(ENV_PATH),
)
def _sync_runtime_env(provider: LLMProviderOption, updates: Dict[str, str]) -> None:
"""Apply saved LLM settings to the running API process."""
for key, value in updates.items():
if value:
os.environ[key] = value
else:
os.environ.pop(key, None)
if provider.api_key_env:
key_value = os.environ.get(provider.api_key_env, "")
if _is_configured_secret(key_value, LLM_API_KEY_PLACEHOLDERS):
os.environ["OPENAI_API_KEY"] = key_value
else:
os.environ.pop("OPENAI_API_KEY", None)
elif provider.auth_type == "oauth":
os.environ.pop("OPENAI_API_KEY", None)
else:
os.environ["OPENAI_API_KEY"] = "ollama"
base_url = os.environ.get(provider.base_url_env, "")
if base_url:
os.environ["OPENAI_API_BASE"] = base_url
os.environ["OPENAI_BASE_URL"] = base_url
else:
os.environ.pop("OPENAI_API_BASE", None)
os.environ.pop("OPENAI_BASE_URL", None)
def _load_json_file(path: Path) -> Optional[Dict[str, Any]]:
"""Load JSON from disk if present."""
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
pass
return None
def _load_csv_to_dict(path: Path, limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""Load CSV rows into a list of dictionaries."""
try:
if not path.exists():
return []
with path.open("r", encoding="utf-8", newline="") as handle:
rows = [dict(row) for row in csv.DictReader(handle)]
if limit is not None:
rows = rows[:limit]
return rows
except Exception:
return []
def _build_response_from_run_dir(run_dir: Path, elapsed: float, *, include_analysis: bool = False) -> RunResponse:
"""Build a run response from a persisted run directory."""
run_id = run_dir.name
response = RunResponse(
status="unknown",
run_id=run_id,
elapsed_seconds=elapsed,
run_directory=str(run_dir),
)
state_data = _load_json_file(run_dir / "state.json")
if state_data:
state_status = str(state_data.get("status") or "").lower()
if state_status == "success":
response.status = "success"
elif state_status == "failed":
response.status = "failed"
response.reason = state_data.get("reason", "")
else:
response.status = state_status or "unknown"
else:
response.status = "unknown"
planner_path = run_dir / "planner_output.json"
response.planner_output = _load_json_file(planner_path)
design_path = run_dir / "design_spec.json"
response.strategy_spec = _load_json_file(design_path)
rag_path = run_dir / "rag_metadata.json"
rag_data = _load_json_file(rag_path)
if rag_data:
response.rag_selection = RAGSelection(
selected_api=rag_data.get("selected_api") or rag_data.get("api_code", ""),
selected_name=rag_data.get("selected_name") or rag_data.get("api_name", ""),
selected_score=float(rag_data.get("selected_score") or rag_data.get("score", 0.0)),
)
metrics_path = run_dir / "artifacts" / "metrics.csv"
if metrics_path.exists():
metrics_dict_list = _load_csv_to_dict(metrics_path, limit=1)
if metrics_dict_list:
row = metrics_dict_list[0]
try:
# Pass ALL CSV columns to BacktestMetrics (extra="allow")
parsed: dict = {}
for k, v in row.items():
if not k or not v:
continue
try:
parsed[k] = int(float(v)) if k == "trade_count" or k == "max_consecutive_loss" else float(v)
except (ValueError, TypeError):
continue
if "final_value" in parsed:
response.metrics = BacktestMetrics(**parsed)
except (ValueError, TypeError):
pass
artifacts_dir = run_dir / "artifacts"
if artifacts_dir.exists():
for file_path in artifacts_dir.iterdir():
if file_path.is_file():
file_type = file_path.suffix.lstrip(".")
response.artifacts.append(
Artifact(
name=file_path.name,
path=str(file_path),
type=file_type if file_type else "unknown",
size=file_path.stat().st_size,
exists=True,
)
)
equity_path = run_dir / "artifacts" / "equity.csv"
if equity_path.exists():
response.artifacts_equity_csv = _load_csv_to_dict(equity_path)
metrics_csv_path = run_dir / "artifacts" / "metrics.csv"
if metrics_csv_path.exists():
response.artifacts_metrics_csv = _load_csv_to_dict(metrics_csv_path)
run_card_path = run_dir / "run_card.json"
if run_card_path.exists():
try:
response.run_card = json.loads(run_card_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
trades_path = run_dir / "artifacts" / "trades.csv"
if trades_path.exists():
response.artifacts_trades_csv = _load_csv_to_dict(trades_path)
validation_path = run_dir / "artifacts" / "validation.json"
if validation_path.exists():
try:
response.validation = json.loads(validation_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
if response.artifacts_equity_csv:
filtered_equity = []
for row in response.artifacts_equity_csv[:1000]:
filtered_row: Dict[str, Any] = {}
if "timestamp" in row:
filtered_row["time"] = row["timestamp"]
if "equity" in row:
filtered_row["equity"] = row["equity"]
if "drawdown" in row:
filtered_row["drawdown"] = row["drawdown"]
filtered_equity.append(filtered_row)
response.equity_curve = filtered_equity
if response.artifacts_trades_csv:
response.trade_log = response.artifacts_trades_csv[:500]
if include_analysis:
analysis = build_run_analysis(run_dir)
response.run_stage = analysis.get("run_stage")
response.run_context = analysis.get("run_context")
response.price_series = analysis.get("price_series")
response.indicator_series = analysis.get("indicator_series")
response.trade_markers = analysis.get("trade_markers")
response.run_logs = analysis.get("run_logs")
return response
# ============================================================================
# Path-parameter validation
# ============================================================================
# ``run_id`` and ``session_id`` flow directly into filesystem paths
# (``RUNS_DIR / run_id`` etc.). Restrict to a safe character class so that
# values like ``..`` or ``foo/../bar`` cannot escape the parent directory.
_SAFE_PATH_PARAM_RE = __import__("re").compile(r"^[A-Za-z0-9_-]{1,128}$")
def _validate_path_param(value: str, kind: str) -> None:
"""Reject path parameters that could escape the parent directory.
Args:
value: User-supplied path-parameter value.
kind: Parameter name, used in the error detail.
Raises:
HTTPException: 400 when ``value`` does not match the safe character
class, mirroring the existing ``_SHADOW_ID_RE`` check.
"""
if not _SAFE_PATH_PARAM_RE.fullmatch(value or ""):
raise HTTPException(status_code=400, detail=f"invalid {kind}")
# ============================================================================
# API Endpoints
# ============================================================================
@app.get("/runs/{run_id}/code", dependencies=[Depends(require_auth)])
async def get_run_code(run_id: str):
"""Return strategy source files for a run.
Args:
run_id: Run identifier.
Returns:
Map filename -> source text.
"""
_validate_path_param(run_id, "run_id")
run_dir = RUNS_DIR / run_id / "code"
if not run_dir.exists():
raise HTTPException(status_code=404, detail=f"Code directory for run {run_id} not found")
result = {}
for f in ["signal_engine.py"]:
p = run_dir / f
if p.exists():
result[f] = p.read_text(encoding="utf-8")
return result
@app.get("/runs/{run_id}/pine", dependencies=[Depends(require_auth)])
async def get_run_pine(run_id: str):
"""Return Pine Script file for a run.
Args:
run_id: Run identifier.
Returns:
Object with pine script content and exists flag.
"""
_validate_path_param(run_id, "run_id")
pine_path = RUNS_DIR / run_id / "artifacts" / "strategy.pine"
if not pine_path.exists():
return {"exists": False, "content": None}
return {
"exists": True,
"content": pine_path.read_text(encoding="utf-8"),
}
@app.get("/runs/{run_id}", response_model=RunResponse, dependencies=[Depends(require_auth)])
async def get_run_result(run_id: str):
"""Fetch full details for a historical run by ``run_id``."""
_validate_path_param(run_id, "run_id")
run_dir = RUNS_DIR / run_id
if not run_dir.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Run {run_id} not found"
)
response = _build_response_from_run_dir(run_dir, elapsed=0.0, include_analysis=True)
return response
@app.get("/runs", response_model=List[RunInfo], dependencies=[Depends(require_auth)])
async def list_runs(limit: int = 20):
"""List recent runs with summary fields."""
limit = min(max(1, limit), 100)
runs_dir = RUNS_DIR
if not runs_dir.exists():
return []
run_dirs = sorted(
[d for d in runs_dir.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True
)
results = []
for d in run_dirs[:limit]:
run_id = d.name
# Status from state.json or artifacts
status_val = "unknown"
state_file = _load_json_file(d / "state.json")
if state_file:
status_val = str(state_file.get("status") or "unknown").lower()
elif (d / "artifacts" / "equity.csv").exists():
status_val = "success"
elif (d / "review_report.json").exists():
status_val = "success"
# Parse created_at from run_id (YYYYMMDD_HHMMSS or run_YYYYMMDD_HHMMSS)
created_at = "Unknown"
if run_id.startswith("run_"):
parts = run_id.split('_')
if len(parts) >= 3:
d_str, t_str = parts[1], parts[2]
if len(d_str) == 8 and len(t_str) == 6:
created_at = f"{d_str[:4]}-{d_str[4:6]}-{d_str[6:8]} {t_str[:2]}:{t_str[2:4]}:{t_str[4:6]}"
elif "_" in run_id:
parts = run_id.split('_')
if len(parts) >= 2:
d_str, t_str = parts[0], parts[1]
if len(d_str) == 8 and len(t_str) == 6:
created_at = f"{d_str[:4]}-{d_str[4:6]}-{d_str[6:8]} {t_str[:2]}:{t_str[2:4]}:{t_str[4:6]}"
if created_at == "Unknown":
mtime = datetime.fromtimestamp(d.stat().st_mtime)
created_at = mtime.strftime("%Y-%m-%d %H:%M:%S")
prompt = None
req_file = d / "req.json"
planner_file = d / "planner_output.json"
if req_file.exists():
try:
req_data = json.loads(req_file.read_text(encoding="utf-8"))
prompt = req_data.get("prompt")
except (json.JSONDecodeError, OSError):
pass
if not prompt and planner_file.exists():
try:
planner_data = json.loads(planner_file.read_text(encoding="utf-8"))
prompt = planner_data.get("user_goal") or planner_data.get("goal")
except (json.JSONDecodeError, OSError):
pass
if not prompt:
prompt_file = d / "user_prompt.txt"
if prompt_file.exists():
prompt = prompt_file.read_text(encoding="utf-8").strip()
total_return = None
sharpe = None
metrics_file = d / "artifacts" / "metrics.csv"
if metrics_file.exists():
try:
import csv
with open(metrics_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
total_return = float(row.get('total_return', 0) or 0)
sharpe = float(row.get('sharpe', 0) or 0)
break
except (OSError, ValueError):
pass
run_context = load_run_context(d)
results.append(RunInfo(
run_id=run_id,
status=status_val,
created_at=created_at,
prompt=prompt or "Manual Analysis",
total_return=total_return,
sharpe=sharpe,
codes=run_context.get("codes") or [],
start_date=run_context.get("start_date"),
end_date=run_context.get("end_date"),
))
return results
@app.get(
"/settings/llm",
response_model=LLMSettingsResponse,
dependencies=[Depends(require_local_or_auth)],
)
async def get_llm_settings():
"""Return project-local LLM settings for the Web UI."""
return _build_llm_settings_response()
@app.put("/settings/llm", response_model=LLMSettingsResponse, dependencies=[Depends(require_local_or_auth)])
async def update_llm_settings(payload: UpdateLLMSettingsRequest):
"""Persist project-local LLM settings and update the running process."""
provider_name = payload.provider.strip().lower()
provider = LLM_PROVIDER_BY_NAME.get(provider_name)
if provider is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported LLM provider")
model_name = payload.model_name.strip()
if not model_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Model name is required")
if payload.temperature < 0 or payload.temperature > 2:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Temperature must be between 0 and 2")
reasoning_effort = (payload.reasoning_effort or "").strip().lower()
if reasoning_effort not in LLM_REASONING_EFFORTS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reasoning effort must be low, medium, high, or max")
current_values = _read_settings_env_values()
base_url = (payload.base_url if payload.base_url is not None else provider.default_base_url).strip()
if provider.auth_type == "oauth":
try:
from src.providers.openai_codex import validate_codex_base_url
base_url = validate_codex_base_url(base_url)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
updates: Dict[str, str] = {
"LANGCHAIN_PROVIDER": provider.name,
"LANGCHAIN_MODEL_NAME": model_name,
provider.base_url_env: base_url,
"LANGCHAIN_TEMPERATURE": str(payload.temperature),
"TIMEOUT_SECONDS": str(payload.timeout_seconds),
"MAX_RETRIES": str(payload.max_retries),
}
if reasoning_effort or "LANGCHAIN_REASONING_EFFORT" in current_values:
updates["LANGCHAIN_REASONING_EFFORT"] = reasoning_effort
if provider.api_key_env:
if payload.clear_api_key:
updates[provider.api_key_env] = ""
elif payload.api_key is not None and payload.api_key.strip():
api_key = payload.api_key.strip()
updates[provider.api_key_env] = api_key if _is_configured_secret(api_key, LLM_API_KEY_PLACEHOLDERS) else ""
elif provider.api_key_env in current_values and _is_configured_secret(
current_values[provider.api_key_env],
LLM_API_KEY_PLACEHOLDERS,
):
updates[provider.api_key_env] = current_values[provider.api_key_env]
elif payload.clear_api_key:
os.environ.pop("OPENAI_API_KEY", None)
_write_env_values(ENV_PATH, updates)
_sync_runtime_env(provider, updates)
return _build_llm_settings_response(_read_env_values(ENV_PATH))
@app.get(
"/settings/data-sources",
response_model=DataSourceSettingsResponse,
dependencies=[Depends(require_local_or_auth)],
)
async def get_data_source_settings():
"""Return project-local data source credentials for the Web UI."""
return _build_data_source_settings_response()
@app.put(
"/settings/data-sources",
response_model=DataSourceSettingsResponse,
dependencies=[Depends(require_local_or_auth)],
)
async def update_data_source_settings(payload: UpdateDataSourceSettingsRequest):
"""Persist project-local data source credentials and update the running process."""
current_values = _read_settings_env_values()
updates: Dict[str, str] = {}
if payload.clear_tushare_token:
updates["TUSHARE_TOKEN"] = ""
elif payload.tushare_token is not None and payload.tushare_token.strip():
updates["TUSHARE_TOKEN"] = payload.tushare_token.strip()
elif "TUSHARE_TOKEN" in current_values:
updates["TUSHARE_TOKEN"] = current_values["TUSHARE_TOKEN"]
if updates:
_write_env_values(ENV_PATH, updates)
token = updates.get("TUSHARE_TOKEN", "").strip()
if _is_configured_secret(token, TUSHARE_TOKEN_PLACEHOLDERS):
os.environ["TUSHARE_TOKEN"] = token
else:
os.environ.pop("TUSHARE_TOKEN", None)
return _build_data_source_settings_response(_read_env_values(ENV_PATH))
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Liveness probe."""
return HealthResponse(
status="healthy",
service="Vibe-Trading API",
timestamp=datetime.now().isoformat()
)
@app.get("/correlation")
async def get_correlation_matrix(
codes: str = Query(..., description="Comma-separated asset codes, e.g. BTC-USDT,ETH-USDT,SPY"),
days: int = Query(90, description="Lookback window in days", ge=7, le=365),
method: str = Query("pearson", description="Correlation method: pearson or spearman"),
):
"""Compute cross-asset correlation matrix from daily returns.
Fetches price data for each code via available data loaders,
computes pairwise correlation of daily returns over the lookback window.
"""
from backtest.correlation import compute_correlation_matrix
code_list = [c.strip() for c in codes.split(",") if c.strip()]
if len(code_list) < 2:
raise HTTPException(status_code=400, detail="At least 2 asset codes required")
if len(code_list) > 20:
raise HTTPException(status_code=400, detail="Maximum 20 assets per request")
if method not in ("pearson", "spearman"):
raise HTTPException(status_code=400, detail="method must be 'pearson' or 'spearman'")
try:
result = compute_correlation_matrix(codes=code_list, days=days, method=method)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Correlation computation failed: {exc}")
def _terminate_current_process() -> None:
"""Stop the current API process after the response has been sent."""
time.sleep(0.25)
os.kill(os.getpid(), signal.SIGTERM)
@app.post("/system/shutdown", dependencies=[Depends(require_auth)])
async def shutdown_local_api(background_tasks: BackgroundTasks, request: Request):
"""Shut down the local API server when requested from loopback clients."""
client_host = request.client.host if request.client else ""
if client_host not in {"127.0.0.1", "::1", "localhost"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Local access only")
background_tasks.add_task(_terminate_current_process)
return {
"status": "shutting-down",
"service": "Vibe-Trading API",
"timestamp": datetime.now().isoformat(),
}
@app.get("/skills")
async def list_skills():
"""List registered skills (name and description)."""
from src.agent.skills import SkillsLoader
loader = SkillsLoader()
return [
{
"name": s.name,
"description": s.description,
}
for s in loader.skills
]
@app.get("/api")
async def api_info():
"""Service metadata."""
return {
"service": "Vibe-Trading API",
"version": "5.0.0",
"docs": "/docs",
"health": "/health",
}
# ============================================================================
# Session API
# ============================================================================
_session_service = None
def _get_session_service():
"""Lazy-init session service when ENABLE_SESSION_RUNTIME=true."""
global _session_service
if _session_service is not None:
return _session_service
if os.getenv("ENABLE_SESSION_RUNTIME", "true").lower() != "true":
return None
import asyncio
from src.session.store import SessionStore
from src.session.events import EventBus
from src.session.service import SessionService
store = SessionStore(base_dir=SESSIONS_DIR)
event_bus = EventBus()
try:
loop = asyncio.get_event_loop()
event_bus.set_loop(loop)
except RuntimeError:
pass
_session_service = SessionService(
store=store,
event_bus=event_bus,
runs_dir=RUNS_DIR,
)
return _session_service
@app.post("/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_auth)])
async def create_session(request: CreateSessionRequest):
"""Create a chat session."""
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.create_session(title=request.title, config=request.config)
return SessionResponse(
session_id=session.session_id,
title=session.title,
status=session.status.value,
created_at=session.created_at,
updated_at=session.updated_at,
last_attempt_id=session.last_attempt_id,
)
@app.get("/sessions", response_model=List[SessionResponse], dependencies=[Depends(require_auth)])
async def list_sessions(limit: int = Query(50, ge=1, le=200)):
"""List sessions."""
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
sessions = svc.list_sessions(limit=limit)
return [
SessionResponse(
session_id=s.session_id,
title=s.title,
status=s.status.value,
created_at=s.created_at,
updated_at=s.updated_at,
last_attempt_id=s.last_attempt_id,
)
for s in sessions
]
@app.get("/sessions/{session_id}", response_model=SessionResponse, dependencies=[Depends(require_auth)])
async def get_session(session_id: str):
"""Get one session by id."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return SessionResponse(
session_id=session.session_id,
title=session.title,
status=session.status.value,
created_at=session.created_at,
updated_at=session.updated_at,
last_attempt_id=session.last_attempt_id,
)
@app.delete("/sessions/{session_id}", dependencies=[Depends(require_auth)])
async def delete_session(session_id: str):
"""Delete a session."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
deleted = svc.delete_session(session_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return {"status": "deleted", "session_id": session_id}
class UpdateSessionRequest(BaseModel):
"""Session update fields."""
title: Optional[str] = None
@app.patch("/sessions/{session_id}", dependencies=[Depends(require_auth)])
async def update_session(session_id: str, req: UpdateSessionRequest):
"""Update session fields (e.g. title)."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.store.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
if req.title is not None:
session.title = req.title
from datetime import datetime
session.updated_at = datetime.now().isoformat()
svc.store.update_session(session)
return {"status": "updated", "session_id": session_id}
@app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)])
async def send_message(session_id: str, payload: SendMessageRequest, http_request: Request):
"""Send a user message and start the agent loop (natural language strategy)."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
try:
result = await svc.send_message(
session_id=session_id,
content=payload.content,
include_shell_tools=_shell_tools_enabled_for_request(http_request),
)
return result
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@app.post("/sessions/{session_id}/cancel", dependencies=[Depends(require_auth)])
async def cancel_session(session_id: str):
"""Cancel the in-flight agent loop for this session."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
cancelled = svc.cancel_current(session_id)
if not cancelled:
return {"status": "no_active_loop"}
return {"status": "cancelled"}
@app.get("/sessions/{session_id}/messages", response_model=List[MessageResponse], dependencies=[Depends(require_auth)])
async def get_messages(session_id: str, limit: int = Query(100, ge=1, le=1000)):
"""List messages for a session."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
messages = svc.get_messages(session_id, limit=limit)
return [
MessageResponse(
message_id=m.message_id,
session_id=m.session_id,
role=m.role,
content=m.content,
created_at=m.created_at,
linked_attempt_id=m.linked_attempt_id,
metadata=m.metadata if m.metadata else None,
)
for m in messages
]
@app.get("/sessions/{session_id}/events", dependencies=[Depends(require_event_stream_auth)])
async def session_events(
session_id: str,
request: Request,
last_event_id: Optional[str] = Query(None, alias="Last-Event-ID"),
):
"""SSE stream for agent events."""
_validate_path_param(session_id, "session_id")
svc = _get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
header_id = request.headers.get("Last-Event-ID")
event_id = header_id or last_event_id
async def event_generator():
async for event in svc.event_bus.subscribe(session_id, last_event_id=event_id):
if await request.is_disconnected():
break
yield event.to_sse()
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ============================================================================
# File Upload
# ============================================================================
_BLOCKED_UPLOAD_EXT = {
# binaries / executables we should never accept
".exe", ".msi", ".bat", ".cmd", ".com", ".scr", ".app", ".dmg",
".so", ".dll", ".dylib",
# executable-adjacent source, shell, config, and template files
".py", ".pyw", ".sh", ".bash", ".zsh", ".fish", ".ps1",
".yaml", ".yml", ".j2", ".jinja", ".jinja2", ".template",
# archives — don't auto-extract; user can unpack locally
".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".bz2", ".xz",
}
_BLOCKED_UPLOAD_NAMES = {
"dockerfile",
"containerfile",
}
_SHADOW_ID_RE = __import__("re").compile(r"^shadow_[0-9a-f]{8}$")
@app.get("/shadow-reports/{shadow_id}", dependencies=[Depends(require_auth)])
async def get_shadow_report(shadow_id: str, format: str = "html"):
"""Serve a rendered Shadow Account report (HTML by default, PDF if available).
Reports live under ``~/.vibe-trading/shadow_reports/<shadow_id>.{html,pdf}``.
"""
if not _SHADOW_ID_RE.match(shadow_id):
raise HTTPException(status_code=400, detail="invalid shadow_id")
if format not in ("html", "pdf"):
raise HTTPException(status_code=400, detail="format must be html or pdf")
reports_dir = Path.home() / ".vibe-trading" / "shadow_reports"
path = reports_dir / f"{shadow_id}.{format}"
if not path.exists():
raise HTTPException(status_code=404, detail=f"Shadow report not found: {shadow_id}.{format}")
media_type = "text/html; charset=utf-8" if format == "html" else "application/pdf"
# Inline so browsers render HTML/PDF directly instead of forcing download.
return FileResponse(
path,
media_type=media_type,
headers={"Content-Disposition": f'inline; filename="{shadow_id}.{format}"'},
)
@app.post("/upload", dependencies=[Depends(require_auth)])
async def upload_file(file: UploadFile):
"""Upload any document or data file (max 50MB).
Accepts most common formats: PDF, Word, Excel, PowerPoint, images,
CSV/TSV, plain text, JSON, and TOML. Executables, executable-adjacent
source/config/template files, and archives are rejected.
"""
if not file.filename:
raise HTTPException(status_code=400, detail="Missing filename")
filename = Path(file.filename).name
ext = Path(file.filename).suffix.lower()
if ext in _BLOCKED_UPLOAD_EXT or filename.lower() in _BLOCKED_UPLOAD_NAMES:
raise HTTPException(
status_code=400,
detail="This file type is not allowed for upload.",
)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = UPLOADS_DIR / safe_name
total_size = 0
try:
with dest.open("wb") as handle:
while True:
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_UPLOAD_SIZE:
handle.close()
if dest.exists():
dest.unlink()
raise HTTPException(
status_code=413,
detail=f"File too large (limit {MAX_UPLOAD_SIZE // (1024 * 1024)} MB)",
)
handle.write(chunk)
except HTTPException:
raise
except OSError as exc:
if dest.exists():
dest.unlink()
raise HTTPException(status_code=500, detail=f"Failed to store upload: {exc}") from exc
finally:
await file.close()
return {
"status": "ok",
"file_path": str(dest.resolve()),
"filename": file.filename,
}
# ============================================================================
# Swarm API
# ============================================================================
_swarm_runtime = None
def _get_swarm_runtime():
"""Lazy-init SwarmRuntime singleton."""
global _swarm_runtime
if _swarm_runtime is not None:
return _swarm_runtime
from src.swarm.store import SwarmStore
from src.swarm.runtime import SwarmRuntime
swarm_dir = Path(__file__).resolve().parent / ".swarm" / "runs"
store = SwarmStore(base_dir=swarm_dir)
_swarm_runtime = SwarmRuntime(store=store)
return _swarm_runtime
@app.get("/swarm/presets")
async def list_swarm_presets():
"""List Swarm YAML presets."""
from src.swarm.presets import list_presets
return list_presets()
@app.post("/swarm/runs", dependencies=[Depends(require_auth)])
async def create_swarm_run(payload: dict, http_request: Request):
"""Start a swarm run: body must include preset_name and user_vars."""
runtime = _get_swarm_runtime()
preset_name = payload.get("preset_name", "")
user_vars = payload.get("user_vars", {})
try:
run = runtime.start_run(
preset_name,
user_vars,
include_shell_tools=_shell_tools_enabled_for_request(http_request),
)
return {"id": run.id, "status": run.status.value, "preset_name": run.preset_name}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/swarm/runs", dependencies=[Depends(require_auth)])
async def list_swarm_runs(limit: int = Query(20, ge=1, le=100)):
"""List swarm runs (newest first)."""
runtime = _get_swarm_runtime()
runs = runtime._store.list_runs(limit=limit)
return [
{
"id": r.id,
"preset_name": r.preset_name,
"status": r.status.value,
"created_at": r.created_at,
"task_count": len(r.tasks),
"completed_count": sum(1 for t in r.tasks if t.status.value == "completed"),
}
for r in runs
]
@app.get("/swarm/runs/{run_id}", dependencies=[Depends(require_auth)])
async def get_swarm_run(run_id: str):
"""Swarm run detail including task statuses."""
from src.swarm.task_store import TaskStore
_validate_path_param(run_id, "run_id")
runtime = _get_swarm_runtime()
run = runtime._store.load_run(run_id)
if not run:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
# Merge real-time task statuses from task_store (updated during execution)
run_dir = runtime._store.run_dir(run_id)
tasks_dir = run_dir / "tasks"
if tasks_dir.exists():
task_store = TaskStore(run_dir)
live_tasks = task_store.load_all()
if live_tasks:
run.tasks = live_tasks
return {
"id": run.id,
"preset_name": run.preset_name,
"status": run.status.value,
"user_vars": run.user_vars,
"agents": [a.model_dump() for a in run.agents],
"tasks": [t.model_dump() for t in run.tasks],
"created_at": run.created_at,
"completed_at": run.completed_at,
"final_report": run.final_report,
}
@app.get("/swarm/runs/{run_id}/events", dependencies=[Depends(require_event_stream_auth)])
async def swarm_run_events(run_id: str, request: Request, last_index: int = Query(0, ge=0)):
"""SSE stream for a swarm run."""
import asyncio
_validate_path_param(run_id, "run_id")
runtime = _get_swarm_runtime()
async def event_stream():
idx = last_index
while True:
if await request.is_disconnected():
break
events = runtime._store.read_events(run_id, after_index=idx)
for evt in events:
idx += 1
yield f"id: {idx}\nevent: {evt.type}\ndata: {json.dumps(evt.model_dump(), ensure_ascii=False)}\n\n"
run = runtime._store.load_run(run_id)
if run and run.status.value in ("completed", "failed", "cancelled"):
yield f"event: done\ndata: {{\"status\": \"{run.status.value}\"}}\n\n"
break
await asyncio.sleep(2)
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/swarm/runs/{run_id}/cancel", dependencies=[Depends(require_auth)])
async def cancel_swarm_run(run_id: str):
"""Cancel an active swarm run."""
_validate_path_param(run_id, "run_id")
runtime = _get_swarm_runtime()
ok = runtime.cancel_run(run_id)
if not ok:
raise HTTPException(status_code=404, detail=f"No active run {run_id}")
return {"status": "cancelled"}
# ============================================================================
# Alpha Zoo routes (Web UI) — defined in src/api/alpha_routes.py
# ============================================================================
from src.api.alpha_routes import register_alpha_routes # noqa: E402
register_alpha_routes(app)
# ============================================================================
# Main Entry Point
# ============================================================================
def serve_main(argv: list[str] | None = None) -> int:
"""Start the API server from CLI-style arguments."""
import argparse
import subprocess
import uvicorn
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
class SPAStaticFiles(StaticFiles):
"""Serve index.html for browser refreshes on client-side routes."""
async def get_response(self, path: str, scope: Dict[str, Any]):
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code != status.HTTP_404_NOT_FOUND:
raise
return await super().get_response("index.html", scope)
parser = argparse.ArgumentParser(description="Vibe-Trading Server")
parser.add_argument("--port", type=int, default=8000, help="Listen port (default 8000)")
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
parser.add_argument("--dev", action="store_true", help="Dev mode: spawn Vite on :5173")
try:
args = parser.parse_args(argv)
except SystemExit as exc:
return int(exc.code) if isinstance(exc.code, int) else 2
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
frontend_root = Path(__file__).resolve().parent.parent / "frontend"
vite_proc = None
if args.dev and frontend_root.exists():
print("[dev] Starting Vite dev server on :5173 ...")
vite_proc = subprocess.Popen(
["npx", "vite", "--host", "0.0.0.0"],
cwd=str(frontend_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
print(f"[dev] Vite PID={vite_proc.pid}")
print("[dev] Frontend: http://localhost:5173")
print(f"[dev] API: http://localhost:{args.port}")
elif frontend_dist.exists():
if not any(route.path == "/" for route in app.routes):
app.mount("/", SPAStaticFiles(directory=str(frontend_dist), html=True), name="frontend")
print(f"[prod] Frontend served from {frontend_dist}")
else:
print(f"[warn] No frontend build found at {frontend_dist}")
print("[warn] Run: cd frontend && npm run build")
print("=" * 50)
print(" Vibe-Trading Server")
print(f" http://127.0.0.1:{args.port}")
print("=" * 50)
try:
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
finally:
if vite_proc:
vite_proc.terminate()
print("[dev] Vite stopped")
return 0
if __name__ == "__main__":
raise SystemExit(serve_main())