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.
1799 lines
64 KiB
Python
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())
|