Files
Auto-Trader/agent/api_server.py
T
Haozhe Wu 2a5557edb6 chore(release): 0.1.10 — version sync + wheel packaging + Docker weasyprint libs (#271)
* chore(release): 0.1.10 — version sync, wheel packaging, Docker weasyprint libs

Release-prep for 0.1.10 (bundles the global data layer + everything since 0.1.9):

- version 0.1.9 -> 0.1.10 synced across all surfaces: pyproject, frontend
  package.json/lock, sidebar footer + i18n, SKILL.md, Dockerfile LABEL. REST
  API + MCP server now derive the version from cli._version (were hardcoded
  "5.0.0" / unset).
- pyproject package-data ships skill example scripts (skills/**/*.py) so the
  wheel + sdist include them; fastmcp floor -> 2.14.0 (first release where the
  MCP version= kwarg applies).
- Dockerfile installs weasyprint native libs (Pango/HarfBuzz/Fontconfig/Cairo/
  gdk-pixbuf + DejaVu) so PDF reports render in the image; .dockerignore *.md
  -> /*.md (root-anchored, skill markdown still ships).
- README news (5 locales): 06-19 reframed as the v0.1.10 release; 06-19/06-18
  trimmed.

Verified: clean-room pip install imports all new loaders/tools; wheel + sdist
carry the eastmoney/sec-edgar skills incl. UTF-8 references + scripts; 4167
backend tests pass (weasyprint PDF path covered on CI).

* docs(release): credit v0.1.10 cycle contributors across READMEs

Refresh the Contributors section in all 5 READMEs from the v0.1.9 cycle to
the v0.1.10 cycle (PRs #162–#260): @Hinotoi-agent, @mvanhorn, @gyx09212214-prog,
@BillDin, @Robin1987China, @LemonCANDY42, @zwrong, @forge-builder, @skloxo,
@LeeCQiang, @KaiLuettmann, @ngoanpv, @ShahNewazKhan, @sambazhu, @bhlt, @octo-patch.
2026-06-19 20:54:20 +08:00

3342 lines
124 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 logging
import os
import re
import signal
import time
import csv
import uuid
import urllib.parse
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, JSONResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from fastapi.middleware.cors import CORSMiddleware
from rich.console import Console
from cli._version import __version__ as APP_VERSION
from src.goal.context import default_goal_criteria
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
console = Console()
logger = logging.getLogger(__name__)
# ============================================================================
# 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")
llm_usage: Optional[Dict[str, Any]] = Field(None, description="Provider-reported AgentLoop usage summary")
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
sse_timeout_seconds: int
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
class CreateGoalRequest(BaseModel):
"""Create or replace a finance research goal."""
objective: str = Field(..., min_length=1, max_length=5000)
criteria: List[str] = Field(default_factory=list)
ui_summary: str = ""
protocol: str = "thesis_review"
risk_tier: str = "research_general"
token_budget: Optional[int] = Field(None, ge=1)
turn_budget: Optional[int] = Field(None, ge=1)
time_budget_seconds: Optional[int] = Field(None, ge=1)
class UpdateGoalRequest(BaseModel):
"""Edit mutable finance research goal fields."""
goal_id: str = Field(..., min_length=1)
expected_goal_id: str = Field(..., min_length=1)
objective: Optional[str] = Field(None, min_length=1, max_length=5000)
ui_summary: Optional[str] = Field(None, max_length=500)
class AddGoalEvidenceRequest(BaseModel):
"""Append evidence to a finance research goal."""
goal_id: str = Field(..., min_length=1)
expected_goal_id: str = Field(..., min_length=1)
text: str = Field(..., min_length=1, max_length=10000)
criterion_id: Optional[str] = None
claim_id: Optional[str] = None
evidence_type: str = "evidence"
tool_call_id: Optional[str] = None
run_id: Optional[str] = None
source_provider: Optional[str] = None
source_type: Optional[str] = None
source_uri: Optional[str] = None
symbol_universe: List[str] = Field(default_factory=list)
benchmark: List[str] = Field(default_factory=list)
timeframe: Optional[str] = None
method: Optional[str] = None
assumptions: Dict[str, Any] = Field(default_factory=dict)
artifact_path: Optional[str] = None
artifact_hash: Optional[str] = None
data_as_of: Optional[str] = None
confidence: Optional[str] = None
caveat: Optional[str] = None
contradicts_claim_ids: List[str] = Field(default_factory=list)
class GoalSnapshotResponse(BaseModel):
"""Finance research goal snapshot."""
goal: Dict[str, Any]
claims: List[Dict[str, Any]]
criteria: List[Dict[str, Any]]
evidence: List[Dict[str, Any]]
evidence_count: int = 0
class AddGoalEvidenceResponse(BaseModel):
"""Response after appending goal evidence."""
evidence: Dict[str, Any]
snapshot: GoalSnapshotResponse
class GoalAuditRowRequest(BaseModel):
"""One criterion row for goal status audits."""
criterion_id: str = Field(..., min_length=1)
result: str = Field(..., min_length=1)
evidence_ids: List[str] = Field(default_factory=list)
notes: str = ""
class UpdateGoalStatusRequest(BaseModel):
"""Update a finance research goal status."""
goal_id: str = Field(..., min_length=1)
expected_goal_id: str = Field(..., min_length=1)
status: str = Field(..., min_length=1)
audit: List[GoalAuditRowRequest] = Field(default_factory=list)
recap: Optional[str] = None
class UpdateGoalStatusResponse(BaseModel):
"""Response after changing a goal status."""
goal: Dict[str, Any]
snapshot: GoalSnapshotResponse
class UpdateGoalResponse(BaseModel):
"""Response after editing a goal."""
goal: Dict[str, Any]
snapshot: GoalSnapshotResponse
# ---- Live trading channel: consent commit + kill switch ----
class CommitMandateRequest(BaseModel):
"""Surface-originated mandate commit (Consent §1 / §3).
This is the ONLY write path that activates a live-trading mandate. It is a
privileged HTTP action the user surface sends on an explicit click/keypress
— NOT a tool the agent model can call. ``consent_ack`` MUST be ``true``.
"""
broker: str = Field(..., min_length=1, max_length=64)
proposal_id: str = Field(..., pattern=r"^mp_[0-9a-f]{32}$")
selected_ordinal: int = Field(..., ge=1, le=10)
adjustments: Optional[Dict[str, Any]] = None
consent_ack: bool = Field(..., description="Explicit affirmative; must be true")
session_id: Optional[str] = None
account_ref: str = Field("", max_length=128)
lifetime_days: int = Field(30, ge=1, le=365)
class LiveHaltRequest(BaseModel):
"""Trip or clear the live kill switch (Consent §4).
Tripping/clearing is a privileged surface action, never an agent tool. When
``broker`` is omitted the GLOBAL switch is used (halts every broker).
"""
broker: Optional[str] = Field(None, max_length=64)
reason: str = Field("user requested halt", max_length=500)
session_id: Optional[str] = None
class LiveAuthorizeRequest(BaseModel):
"""Kick off (or describe) the OAuth bootstrap for a live broker (C2).
Vibe-Trading never holds funds and never operates a venue, so the OAuth
bootstrap runs through the broker's own user-authorized device flow on the
client (CLI / desktop MCP), not a server-side redirect. This endpoint is the
web on-ramp: it tells a Web UI user exactly how to discover/start the flow.
"""
broker: str = Field(..., min_length=1, max_length=64)
class LiveRunnerControlRequest(BaseModel):
"""Start or stop the persistent live runner for one broker (SPEC §7.5).
The runner wakes on schedule/market events and trades autonomously inside a
committed mandate. Starting it is a privileged surface action, never an
agent tool. A committed, unexpired mandate must already exist.
"""
broker: str = Field(..., min_length=1, max_length=64)
session_id: Optional[str] = None
class BrokerAuthState(BaseModel):
"""Per-broker authorization snapshot for ``GET /live/status``."""
broker: str
oauth_token_present: bool = Field(..., description="Whether an OAuth token cache exists")
is_live_broker: bool = Field(..., description="Whether this key is a recognized live broker")
class MandateLimits(BaseModel):
"""Flattened active-mandate limits surfaced to the UI (Mandate layer a/b)."""
max_order_notional_usd: float
max_total_exposure_usd: float
max_leverage: float
max_trades_per_day: int
allowed_instruments: List[str]
account_funding_usd: float
class ActiveMandateState(BaseModel):
"""Active-mandate snapshot with the expiry countdown (SPEC §9 dec. 2)."""
broker: str
account_ref: str
created_at: str
expires_at: str
expires_in_seconds: Optional[int] = Field(
None, description="Seconds until expiry; negative when already expired"
)
expired: bool
limits: MandateLimits
class RunnerLivenessState(BaseModel):
"""Runner liveness snapshot via the §7.5 liveness contract."""
broker: str
alive: bool
last_tick: Optional[float] = Field(None, description="Unix epoch of last heartbeat tick")
last_tick_age_seconds: Optional[float] = None
class LiveBrokerStatus(BaseModel):
"""Combined live-channel status for a single broker."""
auth: BrokerAuthState
mandate: Optional[ActiveMandateState] = None
runner: RunnerLivenessState
halted: bool = Field(..., description="Per-broker OR global kill switch is tripped")
class LiveStatusResponse(BaseModel):
"""Top-level live-channel status (C2)."""
global_halted: bool = Field(..., description="Whether the GLOBAL kill switch is tripped")
brokers: List[LiveBrokerStatus]
# ============================================================================
# FastAPI Application
# ============================================================================
app = FastAPI(
title="Vibe-Trading API",
description="Vibe-Trading API: natural-language finance research, backtesting, and swarm workflows",
version=APP_VERSION,
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",
]
_DEFAULT_LOOPBACK_HOSTS = frozenset({
"localhost",
"127.0.0.1",
"::1",
"[::1]",
# Starlette/FastAPI TestClient default host; included so unit tests exercise
# the API without having to override Host on every request.
"testserver",
})
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
def _parse_extra_loopback_hosts(raw: Optional[str]) -> set[str]:
"""Return additional trusted Host names for loopback API traffic."""
if raw is None or not raw.strip():
return set()
return {host.strip().lower().rstrip(".") for host in raw.split(",") if host.strip()}
_EXTRA_LOOPBACK_HOSTS = _parse_extra_loopback_hosts(os.getenv("API_ALLOWED_HOSTS"))
def _host_without_port(host: str) -> str:
"""Normalize a Host header to a lowercase hostname without a port."""
value = host.strip().lower().rstrip(".")
if not value:
return ""
if value.startswith("["):
end = value.find("]")
if end != -1:
return value[: end + 1]
return value
if value.count(":") == 1:
return value.rsplit(":", 1)[0]
return value
def _is_allowed_loopback_host(host: str) -> bool:
"""Return whether ``host`` is allowed for loopback-trusted API requests."""
normalized = _host_without_port(host)
return normalized in _DEFAULT_LOOPBACK_HOSTS or normalized in _EXTRA_LOOPBACK_HOSTS
# 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.middleware("http")
async def _reject_untrusted_loopback_host(request: Request, call_next):
"""Block DNS-rebinding Host headers before loopback auth bypasses run."""
if _is_local_client(request) and not _is_allowed_loopback_host(request.headers.get("host", "")):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Untrusted local API host"},
)
return await call_next(request)
# ----------------------------------------------------------------------------
# SPA deep-link fallback
# ----------------------------------------------------------------------------
# A handful of API routes share their path with frontend SPA routes (e.g.
# ``/runs/{id}`` and ``/correlation``). Because FastAPI matches registered
# routes before the static SPA mount, a browser that refreshes or bookmarks
# one of these URLs would receive JSON (or 401/422) instead of the SPA shell.
# The middleware below serves ``frontend/dist/index.html`` when the request
# clearly came from a browser (``Accept`` contains ``text/html``); programmatic
# clients are routed to the real API handler as before.
#
# Patterns are written narrowly so the SPA shell only shadows paths that
# actually correspond to frontend pages. In particular ``/runs/{id}`` is
# the RunDetail page, but ``/runs/{id}/code`` and ``/runs/{id}/pine`` are
# API-only endpoints with no SPA route — using a broad ``/runs/`` prefix
# here would incorrectly hijack those when the browser sets ``Accept:
# text/html`` (e.g. a user pasting the URL into the address bar).
_FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
_SPA_HTML_EXACT_PATHS: frozenset[str] = frozenset({"/correlation"})
# Each regex matches a complete request path. Trailing slash optional.
_SPA_HTML_PATH_REGEX: tuple[re.Pattern[str], ...] = (
# ``/runs/{run_id}`` — RunDetail page. Excludes ``/runs/{id}/code``,
# ``/runs/{id}/pine`` (API only) and ``/runs`` (collection endpoint).
re.compile(r"^/runs/[^/]+/?$"),
)
def _is_spa_html_route(path: str) -> bool:
"""Return True when ``path`` corresponds to a frontend SPA page that
shadows an API endpoint and should fall back to ``index.html`` on
browser navigation."""
if path in _SPA_HTML_EXACT_PATHS:
return True
return any(pattern.match(path) for pattern in _SPA_HTML_PATH_REGEX)
@app.middleware("http")
async def _spa_html_deep_link_fallback(request: Request, call_next):
"""Serve ``frontend/dist/index.html`` when a browser navigates directly to
an SPA path that also exists as an API endpoint.
Conflicts: ``/runs/{id}`` (RunDetail page vs API) and ``/correlation``
(Correlation page vs API). Programmatic clients (``Accept: */*`` or
``application/json``) still hit the real API handler.
"""
if request.method == "GET":
accept = request.headers.get("accept", "")
if "text/html" in accept and _is_spa_html_route(request.url.path):
index = _FRONTEND_DIST / "index.html"
if index.exists():
return FileResponse(str(index))
return await call_next(request)
@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 _is_loopback_origin(origin: str) -> bool:
"""Return whether a browser Origin header names a loopback web UI."""
try:
parsed = urllib.parse.urlsplit(origin)
except ValueError:
return False
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
host = parsed.hostname.rstrip(".").lower()
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _reject_cross_site_browser_request(request: Request) -> None:
"""Reject unsafe browser requests from non-loopback origins.
CORS protects response reads, not blind form/fetch side effects. Keep local
CLI/curl clients working while refusing browser-originated cross-site POSTs
to local control-plane actions such as shutdown.
"""
sec_fetch_site = request.headers.get("sec-fetch-site", "").lower()
if sec_fetch_site == "cross-site":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cross-site request denied")
origin = request.headers.get("origin")
if origin and not _is_loopback_origin(origin):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cross-site request denied")
def _require_shutdown_authorization(
*,
request: Request,
cred: Optional[HTTPAuthorizationCredentials],
) -> None:
"""Authorize the local shutdown control-plane action.
Loopback peer IP alone is not enough for this browser-reachable, destructive
action. When API_AUTH_KEY is configured, require the Bearer token even for
loopback requests; otherwise preserve local dev-mode shutdown for direct
loopback clients while rejecting cross-site browser requests.
"""
_reject_cross_site_browser_request(request)
api_key = _configured_api_key()
if api_key:
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
if not token or not hmac.compare_digest(token, api_key):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return
if not _is_local_client(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API_AUTH_KEY is required for non-local API access",
)
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."""
# Loopback clients are always trusted, even when API_AUTH_KEY is set.
# The key only gates non-local (LAN/remote) access.
if _is_local_client(request):
return
api_key = _configured_api_key()
if not api_key:
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."""
# Shell-capable tools execute commands on the host as the API process user.
# Do not infer that privilege from peer IP alone: browser DNS rebinding can
# make attacker-controlled pages appear as loopback clients. Operators who
# intentionally want API-started agents or swarm workers to receive shell
# tools must opt in explicitly.
return _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",
)
async def require_settings_write_auth(
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
) -> None:
"""Require explicit authorization before changing credential-routing settings.
Settings writes can redirect stored provider credentials to a different
endpoint. When an API key is configured, loopback peer IP alone is not a
sufficient user-intent signal because a browser can reach local APIs after
DNS rebinding.
"""
api_key = _configured_api_key()
if api_key:
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
if not token or not hmac.compare_digest(token, api_key):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return
if not _is_local_client(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Settings writes require 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(),
sse_timeout_seconds=_coerce_int(env_values.get("VIBE_TRADING_SSE_TIMEOUT", "90"), 90),
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,
chart_symbol: Optional[str] = None,
chart_payload: str = "full",
chart_symbols_out: Optional[List[str]] = None,
) -> 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
llm_usage_path = run_dir / "llm_usage.json"
if llm_usage_path.exists():
try:
response.llm_usage = json.loads(llm_usage_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,
symbols=[chart_symbol] if chart_symbol else None,
include_payload=chart_payload != "summary" or bool(chart_symbol),
include_symbol_list=chart_symbols_out is not None,
)
if chart_symbols_out is not None:
chart_symbols_out.extend(analysis.get("chart_symbols") or [])
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
def _run_response_payload(response: RunResponse) -> Dict[str, Any]:
"""Return a JSON-ready payload for opt-in run response variants."""
return response.model_dump(mode="json")
# ============================================================================
# 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,
chart_symbol: Optional[str] = Query(None, description="Opt in to chart payloads for a single symbol"),
chart_payload: Optional[str] = Query(
None,
description="Optional chart payload mode. Use 'summary' to omit chart rows and trade markers.",
),
):
"""Fetch details for a historical run by ``run_id``.
The default response stays unchanged for existing consumers. Chart-heavy
optimizations are opt-in via query parameters.
"""
_validate_path_param(run_id, "run_id")
if chart_payload not in (None, "summary"):
raise HTTPException(status_code=400, detail="invalid chart_payload")
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"
)
wants_chart_meta = bool(chart_payload or chart_symbol)
chart_symbols: List[str] = []
response = _build_response_from_run_dir(
run_dir,
elapsed=0.0,
include_analysis=True,
chart_symbol=chart_symbol,
chart_payload=chart_payload or "full",
chart_symbols_out=chart_symbols if wants_chart_meta else None,
)
if wants_chart_meta:
payload = _run_response_payload(response)
payload["chart_symbols"] = chart_symbols
return JSONResponse(payload)
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_settings_write_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_settings_write_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")
async def shutdown_local_api(
background_tasks: BackgroundTasks,
request: Request,
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
):
"""Shut down the local API server after explicit local authorization."""
_require_shutdown_authorization(request=request, cred=cred)
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": APP_VERSION,
"docs": "/docs",
"health": "/health",
}
# ============================================================================
# Session API
# ============================================================================
_session_service = None
_goal_store = 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
def _get_goal_store():
"""Return the shared finance goal store."""
global _goal_store
if _goal_store is None:
from src.goal import GoalStore
_goal_store = GoalStore()
return _goal_store
def _get_existing_session_or_404(session_id: str):
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 svc, session
@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.post(
"/sessions/{session_id}/goal",
response_model=GoalSnapshotResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_auth)],
)
async def create_session_goal(session_id: str, req: CreateGoalRequest):
"""Create or replace the current finance research goal for a session."""
_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from src.goal import RiskTier
criteria = [item.strip() for item in req.criteria if item.strip()]
if not criteria:
criteria = default_goal_criteria()
try:
risk_tier = RiskTier(req.risk_tier)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid risk_tier: {req.risk_tier}") from exc
if risk_tier is RiskTier.LIVE_TRADING_OR_EXECUTION:
raise HTTPException(status_code=400, detail="live trading or execution goals are not supported")
goal_store = _get_goal_store()
try:
goal = goal_store.replace_goal(
session_id=session_id,
objective=req.objective,
criteria=criteria,
ui_summary=req.ui_summary,
source="api",
protocol=req.protocol,
risk_tier=risk_tier,
token_budget=req.token_budget,
turn_budget=req.turn_budget,
time_budget_seconds=req.time_budget_seconds,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal created but could not be reloaded")
svc.event_bus.emit(session_id, "goal.created", {"goal": snapshot["goal"]})
return snapshot
@app.get(
"/sessions/{session_id}/goal",
response_model=GoalSnapshotResponse,
dependencies=[Depends(require_auth)],
)
async def get_session_goal(session_id: str):
"""Return the current finance research goal snapshot for a session."""
_validate_path_param(session_id, "session_id")
_get_existing_session_or_404(session_id)
snapshot = _get_goal_store().get_current_snapshot(session_id)
if snapshot is None:
raise HTTPException(status_code=404, detail="No current goal")
return snapshot
@app.patch(
"/sessions/{session_id}/goal",
response_model=UpdateGoalResponse,
dependencies=[Depends(require_auth)],
)
async def update_session_goal(session_id: str, req: UpdateGoalRequest):
"""Edit the current finance research goal without replacing the session."""
_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from src.goal import StaleGoalError
if req.objective is None and req.ui_summary is None:
raise HTTPException(status_code=400, detail="objective or ui_summary is required")
goal_store = _get_goal_store()
try:
goal = goal_store.update_goal(
session_id=session_id,
goal_id=req.goal_id,
expected_goal_id=req.expected_goal_id,
objective=req.objective,
ui_summary=req.ui_summary,
)
except StaleGoalError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
svc.event_bus.emit(session_id, "goal.updated", {"goal": snapshot["goal"], "snapshot": snapshot})
return {"goal": snapshot["goal"], "snapshot": snapshot}
@app.post(
"/sessions/{session_id}/goal/evidence",
response_model=AddGoalEvidenceResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_auth)],
)
async def add_session_goal_evidence(session_id: str, req: AddGoalEvidenceRequest):
"""Append traceable evidence to the current finance research goal."""
_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from dataclasses import asdict
from src.goal import EvidenceInput, StaleGoalError
goal_store = _get_goal_store()
try:
evidence = goal_store.append_evidence(
session_id=session_id,
goal_id=req.goal_id,
expected_goal_id=req.expected_goal_id,
evidence=EvidenceInput(
criterion_id=req.criterion_id,
claim_id=req.claim_id,
evidence_type=req.evidence_type,
text=req.text,
tool_call_id=req.tool_call_id,
run_id=req.run_id,
source_provider=req.source_provider,
source_type=req.source_type,
source_uri=req.source_uri,
symbol_universe=req.symbol_universe,
benchmark=req.benchmark,
timeframe=req.timeframe,
method=req.method,
assumptions=req.assumptions,
artifact_path=req.artifact_path,
artifact_hash=req.artifact_hash,
data_as_of=req.data_as_of,
confidence=req.confidence,
caveat=req.caveat,
contradicts_claim_ids=req.contradicts_claim_ids,
),
)
except StaleGoalError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(req.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
svc.event_bus.emit(
session_id,
"goal.evidence",
{"evidence": asdict(evidence), "goal_id": req.goal_id},
)
return {"evidence": asdict(evidence), "snapshot": snapshot}
@app.patch(
"/sessions/{session_id}/goal/status",
response_model=UpdateGoalStatusResponse,
dependencies=[Depends(require_auth)],
)
async def update_session_goal_status(session_id: str, req: UpdateGoalStatusRequest):
"""Update the current finance research goal status."""
_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from src.goal import AuditRow, GoalStatus, StaleGoalError
try:
next_status = GoalStatus(req.status)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid goal status: {req.status}") from exc
goal_store = _get_goal_store()
try:
goal = goal_store.update_status(
session_id=session_id,
goal_id=req.goal_id,
expected_goal_id=req.expected_goal_id,
status=next_status,
audit=[
AuditRow(
criterion_id=row.criterion_id,
result=row.result,
evidence_ids=row.evidence_ids,
notes=row.notes,
)
for row in req.audit
],
recap=req.recap,
)
except StaleGoalError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
svc.event_bus.emit(session_id, "goal.updated", {"goal": snapshot["goal"], "snapshot": snapshot})
return {"goal": snapshot["goal"], "snapshot": snapshot}
@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")
_get_goal_store().delete_session_goals(session_id)
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"),
replay: Optional[str] = Query(None),
):
"""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
replay_active = (replay or "").lower() == "active"
replay_all = False
if replay_active and not event_id and session.last_attempt_id:
attempt = svc.store.get_attempt(session_id, session.last_attempt_id)
attempt_status = getattr(attempt.status, "value", attempt.status) if attempt else None
replay_all = attempt_status == "running"
async def event_generator():
async for event in svc.event_bus.subscribe(
session_id,
last_event_id=event_id,
replay_all=replay_all,
):
if await request.is_disconnected():
break
yield event.to_sse()
relayed = _mandate_proposal_frame_from_tool_result(event)
if relayed is not None:
yield relayed
live_action = _live_action_frame_from_tool_result(event)
if live_action is not None:
yield live_action
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(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.",
)
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = UPLOADS_DIR / safe_name
total_size = 0
try:
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
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="Upload failed while storing the file. Please retry or choose a different file.",
) from exc
finally:
await file.close()
return {
"status": "ok",
"file_path": f"uploads/{safe_name}",
"filename": 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.config import load_swarm_agent_config
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)
# Boot-time / operator-trusted: REST API callers cannot influence the
# config path. See docs/2026-05-25_swarm_mcp_tools_roadmap.md.
agent_config = load_swarm_agent_config()
_swarm_runtime = SwarmRuntime(store=store, agent_config=agent_config)
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), reconciled."""
runtime = _get_swarm_runtime()
runs = runtime._store.list_runs(limit=limit)
items = []
for r in runs:
# Reconcile each row: a zombie running run will be auto-finalized so
# the dashboard never shows a permanent "running" stuck row.
reconciled = runtime._store.reconcile_run(r, write=True)
items.append(
{
"id": reconciled.id,
"preset_name": reconciled.preset_name,
"status": reconciled.status.value,
"is_stale": runtime._store.is_run_stale(reconciled),
"created_at": reconciled.created_at,
"completed_at": reconciled.completed_at,
"task_count": len(reconciled.tasks),
"completed_count": sum(1 for t in reconciled.tasks if t.status.value == "completed"),
}
)
return items
@app.get("/swarm/runs/{run_id}", dependencies=[Depends(require_auth)])
async def get_swarm_run(run_id: str):
"""Swarm run detail including task statuses (reconciled)."""
_validate_path_param(run_id, "run_id")
runtime = _get_swarm_runtime()
loaded = runtime._store.load_run(run_id)
if not loaded:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
run = runtime._store.reconcile_run(loaded, write=True)
return {
"id": run.id,
"preset_name": run.preset_name,
"status": run.status.value,
"is_stale": runtime._store.is_run_stale(run),
"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:
# Reconcile so a zombie running run can still close this SSE
# stream cleanly — without it, a dead host would keep the
# stream open forever and block the dashboard's "done" state.
reconciled = runtime._store.reconcile_run(run, write=True)
if reconciled.status.value in ("completed", "failed", "cancelled"):
yield f"event: done\ndata: {{\"status\": \"{reconciled.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"}
@app.post("/swarm/runs/{run_id}/retry", dependencies=[Depends(require_auth)])
async def retry_swarm_run(run_id: str, http_request: Request):
"""Retry a failed, stale, or cancelled swarm run.
Creates a new run with the same preset and user_vars as the original.
"""
_validate_path_param(run_id, "run_id")
runtime = _get_swarm_runtime()
loaded = runtime._store.load_run(run_id)
if not loaded:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
# Reconcile first so a stale "running" run whose host died gets demoted
# before we gate on status; only a genuinely active run blocks retry.
from src.swarm.models import RunStatus
reconciled = runtime._store.reconcile_run(loaded, write=True)
if reconciled.status == RunStatus.running:
raise HTTPException(status_code=409, detail="Cannot retry a running run. Cancel it first.")
try:
new_run = runtime.start_run(
reconciled.preset_name,
reconciled.user_vars or {},
include_shell_tools=_shell_tools_enabled_for_request(http_request),
)
return {"id": new_run.id, "status": new_run.status.value, "preset_name": new_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))
# ============================================================================
# Live trading channel — consent commit + kill switch
# ============================================================================
#
# These are the privileged SURFACE actions of the live-trading channel
# (live-trading SPEC, Consent §1/§3/§4). None is an agent tool:
# - POST /mandate/commit -> the single mandate writer (commit_mandate)
# - POST /live/halt -> trip the kill switch (P5 trip_halt)
# - POST /live/resume -> clear the kill switch (P5 clear_halt)
# Each best-effort relays a mandate.committed / live.halted / live.action event
# through the EXISTING session EventBus, so the frontend's already-wired
# /sessions/{id}/events SSE stream reflects the state change. No new bus.
def _emit_live_event(session_id: Optional[str], event_type: str, data: Dict[str, Any]) -> None:
"""Best-effort relay of a live-channel event through the existing bus.
The event flows out the existing ``/sessions/{session_id}/events`` SSE
stream. Notifications never gate autonomy (SPEC Consent §5): a relay failure
or a missing session is swallowed — the state change already happened on disk.
Args:
session_id: Target session, or ``None`` to skip relay.
event_type: SSE event name (``mandate.committed`` / ``live.halted`` /
``live.resumed`` / ``live.action``).
data: JSON-serializable event payload.
"""
if not session_id:
return
try:
svc = _get_session_service()
if svc and svc.get_session(session_id):
svc.event_bus.emit(session_id, event_type, data)
except Exception: # pragma: no cover - relay is non-blocking by contract
logger.debug("live event relay failed for %s/%s", session_id, event_type, exc_info=True)
# ---- C1: propose_mandate_profiles tool_result -> mandate.proposal SSE frame ----
#
# The agent surfaces a proposal by calling the read-only ``propose_mandate_profiles``
# tool whose tool_result JSON body is ``{"type":"mandate.proposal", ...}`` (SPEC
# Consent §1). The CLI / frontend listen for a TOP-LEVEL ``mandate.proposal`` SSE
# event. ``src/agent/loop.py`` only emits a truncated ``tool_result`` event
# (``preview = result[:200]``) and is PROTECTED — we do NOT edit it. Instead this
# open-file SSE seam (TASKS "Remaining integration items" #1, the recommended
# wiring) detects the propose tool's tool_result on the stream, recovers the
# ``proposal_id`` from the preview, reloads the FULL persisted proposal from the
# proposal store (written by the tool before it returned), and emits the
# ``mandate.proposal`` frame. No protected touch.
_PROPOSAL_TOOL_NAME = "propose_mandate_profiles"
_PROPOSAL_ID_RE = re.compile(r'"proposal_id"\s*:\s*"(mp_[0-9a-f]{32})"')
def _load_full_proposal(proposal_id: str) -> Optional[Dict[str, Any]]:
"""Reload a persisted ``mandate.proposal`` payload by id, broker-agnostic.
The propose tool persists the full proposal under
``<runtime_root>/live/<broker>/proposals/<proposal_id>.json`` before
returning. The SSE ``tool_result`` preview is too short to carry the full
body, so the relay reloads it from disk. The broker segment is unknown from
the preview alone, so every broker's proposals directory is searched.
Args:
proposal_id: The ``mp_...`` id parsed from the tool_result preview.
Returns:
The full proposal dict, or ``None`` when not found / unreadable.
"""
try:
from src.live.paths import live_root
for proposal_path in live_root().glob(f"*/proposals/{proposal_id}.json"):
try:
data = json.loads(proposal_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(data, dict) and data.get("type") == "mandate.proposal":
return data
except Exception: # pragma: no cover - relay must never break the stream
logger.debug("mandate.proposal reload failed for %s", proposal_id, exc_info=True)
return None
def _mandate_proposal_frame_from_tool_result(event: Any) -> Optional[str]:
"""Build a ``mandate.proposal`` SSE frame from a propose-tool tool_result.
Args:
event: An ``SSEEvent`` flowing through the session stream.
Returns:
A ready-to-yield SSE text frame for the ``mandate.proposal`` event, or
``None`` when ``event`` is not a successful propose-tool result or the
proposal cannot be recovered.
"""
data = getattr(event, "data", None)
if getattr(event, "event_type", None) != "tool_result" or not isinstance(data, dict):
return None
if data.get("tool") != _PROPOSAL_TOOL_NAME or data.get("status") != "ok":
return None
match = _PROPOSAL_ID_RE.search(str(data.get("preview") or ""))
if not match:
return None
proposal = _load_full_proposal(match.group(1))
if proposal is None:
return None
from src.session.events import SSEEvent
frame = SSEEvent(
event_type="mandate.proposal",
data=proposal,
session_id=getattr(event, "session_id", "") or "",
)
return frame.to_sse()
_LIVE_ACTION_ID_RE = re.compile(r'"audit_id"\s*:\s*"(la_[0-9a-zA-Z]+)"')
def _load_live_action_record(audit_id: str) -> Optional[Dict[str, Any]]:
"""Reload a redacted live-action record from the ledger by ``audit_id``.
The order guard embeds its (already-redacted) audit record under the
``live_action`` key of its tool_result, but the SSE ``tool_result`` preview
is truncated to ~200 chars, so the full record is reloaded from the
append-only ledger at ``<runtime_root>/live/audit.jsonl``.
Args:
audit_id: The ``la_...`` id parsed from the tool_result preview.
Returns:
The full redacted live-action record, or ``None`` when not found.
"""
try:
from src.live.paths import live_root
ledger = live_root() / "audit.jsonl"
if not ledger.exists():
return None
for line in reversed(ledger.read_text(encoding="utf-8").splitlines()):
if audit_id not in line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(record, dict) and record.get("audit_id") == audit_id:
return record
except Exception: # pragma: no cover - relay must never break the stream
logger.debug("live.action reload failed for %s", audit_id, exc_info=True)
return None
def _live_action_frame_from_tool_result(event: Any) -> Optional[str]:
"""Build a ``live.action`` SSE frame from an order-guard tool_result.
The order guard stamps a ``live_action`` audit record onto its tool_result
(and the ledger) for every live order placed/rejected. The interactive agent
loop only emits a truncated ``tool_result`` event and is PROTECTED, so this
open-file relay surfaces the live action as a top-level ``live.action`` event
for the timeline — without touching ``src/agent/loop.py``. (Autonomous-runner
actions already emit ``live.action`` natively via the runner's event bus.)
Args:
event: An ``SSEEvent`` flowing through the session stream.
Returns:
A ready-to-yield ``live.action`` SSE frame, or ``None`` when the event is
not an order-guard result carrying a recoverable live-action record.
"""
data = getattr(event, "data", None)
if getattr(event, "event_type", None) != "tool_result" or not isinstance(data, dict):
return None
preview = str(data.get("preview") or "")
if '"live_action"' not in preview:
return None
match = _LIVE_ACTION_ID_RE.search(preview)
if not match:
return None
record = _load_live_action_record(match.group(1))
if record is None:
return None
from src.session.events import SSEEvent
frame = SSEEvent(
event_type="live.action",
data=record,
session_id=getattr(event, "session_id", "") or "",
)
return frame.to_sse()
def _fetch_broker_ceilings(broker: str) -> Optional[Dict[str, Any]]:
"""Best-effort fetch of broker-side account ceilings for the commit re-check.
Reads the broker's ``get_account`` tool and derives an authoritative ceiling
snapshot (buying power / funding) so the commit-time fit check binds to the
venue's real limits rather than an agent-proposed number. Returns ``None`` on
any failure (channel not configured, tool error, fields not recognized) so
the caller falls back to the proposal's own snapshot — a commit is never
blocked on a broker read. The exact Robinhood field names are finalized
post-access (L6); we probe the common ones.
Args:
broker: The live-broker key.
Returns:
A ceilings dict (canonical keys) or ``None`` to fall back.
"""
try:
adapter = _live_broker_adapter(broker)
except LiveRunnerUnavailable:
return None
try:
result = adapter.call_tool("get_account", {})
except Exception: # pragma: no cover - status/commit must never raise here
logger.debug("broker ceiling fetch failed for %s", broker, exc_info=True)
return None
if not isinstance(result, dict) or result.get("status") == "error":
return None
payload = result.get("result") if isinstance(result.get("result"), dict) else result
funding: Optional[float] = None
for key in ("account_funding_usd", "buying_power", "cash", "portfolio_value", "equity"):
raw = payload.get(key) if isinstance(payload, dict) else None
try:
if raw is not None:
funding = float(raw)
break
except (TypeError, ValueError):
continue
if funding is None or funding <= 0:
return None
# A single order can never exceed available funding; total exposure is capped
# at funding for a cash account. Leverage stays at 1.0 unless the broker
# reports margin (L6). These canonical keys are normalized by commit_mandate.
return {
"account_funding_usd": funding,
"max_order_notional_usd": funding,
"max_total_exposure_usd": funding,
}
@app.post("/mandate/commit", dependencies=[Depends(require_auth)])
async def commit_mandate_endpoint(payload: CommitMandateRequest):
"""Commit a user-selected mandate profile — the only mandate write path.
Calls :func:`src.live.mandate.commit.commit_mandate`, which re-validates the
proposal is live and the resolved profile still fits the ceilings the user
saw. Requires ``consent_ack=true`` (rejected otherwise). On success emits a
``mandate.committed`` + ``live.action`` event so all surfaces reflect the
newly active mandate.
"""
if payload.consent_ack is not True:
raise HTTPException(status_code=400, detail="consent_ack must be true to commit a mandate")
from src.live.mandate.commit import CommitError, commit_mandate
# Prefer broker-DERIVED ceilings over the agent-supplied proposal snapshot:
# the commit re-check should bind to the venue's real account limits, not a
# number the model proposed. Best-effort — falls back to the proposal's own
# ceilings (commit_mandate handles ceilings_ref=None) when the broker channel
# is unavailable or the read fails (we never block a commit on a broker read).
broker_ceilings = _fetch_broker_ceilings(payload.broker)
try:
result = commit_mandate(
proposal_id=payload.proposal_id,
ordinal=payload.selected_ordinal,
adjustments=payload.adjustments,
consent_ack=payload.consent_ack,
broker=payload.broker,
account_ref=payload.account_ref,
session_id=payload.session_id,
ceilings_ref=broker_ceilings,
lifetime_days=payload.lifetime_days,
)
except CommitError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_emit_live_event(payload.session_id, "mandate.committed", result)
_emit_live_event(
payload.session_id,
"live.action",
{"kind": "mandate_committed", "broker": result["broker"], "mandate_id": result["mandate_id"]},
)
return result
@app.post("/live/halt", dependencies=[Depends(require_auth)])
async def halt_live_endpoint(payload: LiveHaltRequest):
"""Trip the live kill switch (privileged surface action, Consent §4).
Writes the HALT sentinel via :func:`src.live.halt.trip_halt`; the
enforcement gate then rejects every order attempt until resumed. Emits a
``live.halted`` event so all surfaces reflect the halted state.
"""
from src.live.halt import trip_halt
try:
path = trip_halt(by="frontend", reason=payload.reason, broker=payload.broker)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = {"halted": True, "broker": payload.broker, "reason": payload.reason, "sentinel": str(path)}
_emit_live_event(payload.session_id, "live.halted", result)
_emit_live_event(
payload.session_id,
"live.action",
{"kind": "halt_tripped", "broker": payload.broker, "reason": payload.reason},
)
return result
@app.post("/live/resume", dependencies=[Depends(require_auth)])
async def resume_live_endpoint(payload: LiveHaltRequest):
"""Clear the live kill switch (privileged surface action, Consent §4).
Deletes the HALT sentinel via :func:`src.live.halt.clear_halt` (an explicit
re-enable; never an agent tool). Emits a ``live.resumed`` event.
"""
from src.live.halt import clear_halt
try:
cleared = clear_halt(broker=payload.broker)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = {"halted": False, "broker": payload.broker, "cleared": cleared}
_emit_live_event(payload.session_id, "live.resumed", result)
_emit_live_event(
payload.session_id,
"live.action",
{"kind": "halt_cleared", "broker": payload.broker, "cleared": cleared},
)
return result
# ============================================================================
# Live trading channel — status, authorize on-ramp, runner control (C2 + §7.5)
# ============================================================================
#
# C2 surfaces the dormant-by-default channel state so a user can SEE what is and
# is not authorized before trusting it: per-broker OAuth presence, the active
# mandate with its expiry countdown, runner liveness, and the kill-switch state.
# The runner-control endpoints start/stop the persistent §7.5 runner that trades
# autonomously inside a committed mandate. None of these is an agent tool; they
# are privileged surface actions like /mandate/commit and /live/halt.
def _known_live_brokers() -> List[str]:
"""Return the recognized live-broker keys (SPEC §7.2)."""
from src.config.schema import LIVE_BROKER_SERVER_KEYS
return sorted(LIVE_BROKER_SERVER_KEYS)
def _oauth_token_present(broker: str) -> bool:
"""Return whether an OAuth token cache exists for a broker (C2 auth state).
The token cache lives at ``<runtime_root>/live/<broker>/oauth/`` (0700) and
is created only when the user OAuth-authorizes the channel. A missing or
empty directory means the channel is dormant (read-only, no live path).
"""
try:
from src.live.paths import broker_dir
oauth_dir = broker_dir(broker) / "oauth"
return oauth_dir.is_dir() and any(oauth_dir.iterdir())
except Exception: # pragma: no cover - status must never raise
logger.debug("oauth presence check failed for %s", broker, exc_info=True)
return False
def _active_mandate_state(broker: str) -> Optional[ActiveMandateState]:
"""Build the active-mandate snapshot for a broker, or ``None`` when absent.
Reads the committed mandate via the frozen store contract and computes the
``expires_at`` countdown (SPEC §9 dec. 2). A mandate whose ``expires_at`` has
passed is still surfaced, flagged ``expired`` so the UI can prompt re-consent.
"""
from src.live.mandate.store import load_mandate
mandate = load_mandate(broker)
if mandate is None:
return None
consent = mandate.consent
caps = mandate.hard_caps
expires_in: Optional[int] = None
expired = False
try:
expires_dt = datetime.fromisoformat(consent.expires_at.replace("Z", "+00:00"))
from datetime import timezone
now = datetime.now(timezone.utc)
if expires_dt.tzinfo is None:
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
delta = expires_dt - now
expires_in = int(delta.total_seconds())
expired = expires_in <= 0
except (ValueError, AttributeError):
logger.debug("could not parse expires_at for %s mandate", broker, exc_info=True)
return ActiveMandateState(
broker=broker,
account_ref=consent.account_ref,
created_at=consent.created_at,
expires_at=consent.expires_at,
expires_in_seconds=expires_in,
expired=expired,
limits=MandateLimits(
max_order_notional_usd=caps.max_order_notional_usd,
max_total_exposure_usd=caps.max_total_exposure_usd,
max_leverage=caps.max_leverage,
max_trades_per_day=caps.max_trades_per_day,
allowed_instruments=[str(getattr(i, "value", i)) for i in caps.allowed_instruments],
account_funding_usd=caps.account_funding_usd,
),
)
def _runner_liveness_state(broker: str) -> RunnerLivenessState:
"""Build the runner-liveness snapshot for a broker (SPEC §7.5 contract).
Uses the §7.5 ``liveness`` module (``is_runner_alive`` / ``last_tick``),
keyed by broker as the runner id. The module is built concurrently (R1); a
missing module or any error is treated as "not alive" (fail-safe display).
"""
alive = False
tick: Optional[float] = None
age: Optional[float] = None
try:
from src.live.runtime import liveness
alive = bool(liveness.is_runner_alive(broker))
raw_tick = liveness.last_tick(broker)
if raw_tick is not None:
tick = float(raw_tick)
age = max(0.0, time.time() - tick)
except Exception: # pragma: no cover - liveness module is built concurrently
logger.debug("runner liveness lookup failed for %s", broker, exc_info=True)
return RunnerLivenessState(broker=broker, alive=alive, last_tick=tick, last_tick_age_seconds=age)
@app.get("/live/status", response_model=LiveStatusResponse, dependencies=[Depends(require_auth)])
async def live_status_endpoint(broker: Optional[str] = Query(None, max_length=64)):
"""Return live-channel status: auth, active mandate, runner liveness, halt (C2).
Args:
broker: Optional single-broker filter. When omitted, every recognized
live broker is reported.
Returns:
A :class:`LiveStatusResponse` with the global kill-switch state and a
per-broker breakdown so the UI can show exactly what is authorized.
"""
from src.live.halt import halt_flag_set
if broker is not None:
target = broker.strip().lower()
if not target:
raise HTTPException(status_code=400, detail="broker must not be blank")
brokers = [target]
else:
brokers = _known_live_brokers()
known = set(_known_live_brokers())
statuses: List[LiveBrokerStatus] = []
for key in brokers:
statuses.append(
LiveBrokerStatus(
auth=BrokerAuthState(
broker=key,
oauth_token_present=_oauth_token_present(key),
is_live_broker=key in known,
),
mandate=_active_mandate_state(key),
runner=_runner_liveness_state(key),
halted=halt_flag_set(broker=key),
)
)
return LiveStatusResponse(global_halted=halt_flag_set(broker=None), brokers=statuses)
@app.post("/live/authorize", dependencies=[Depends(require_auth)])
async def live_authorize_endpoint(payload: LiveAuthorizeRequest):
"""Describe the OAuth bootstrap on-ramp for a live broker (C2 web on-ramp).
Vibe-Trading holds no funds and runs no venue: the OAuth flow happens on the
broker's own user-authorized device channel (CLI / desktop MCP), never a
server-side redirect. A Web UI user reaches this endpoint to DISCOVER how to
start the flow. It performs no authorization itself and never returns a token.
"""
broker = payload.broker.strip().lower()
if not broker:
raise HTTPException(status_code=400, detail="broker must not be blank")
if broker not in set(_known_live_brokers()):
raise HTTPException(status_code=400, detail=f"unknown live broker: {broker}")
from src.trading.service import connector_profile_id_for_broker
connector_profile = connector_profile_id_for_broker(broker)
return {
"broker": broker,
"connector_profile": connector_profile,
"oauth_token_present": _oauth_token_present(broker),
"instruction": (
f"Run `vibe-trading connector authorize {connector_profile}` "
"from the device that will hold the broker session. This opens the "
"broker's own OAuth consent flow; Vibe-Trading never holds funds and "
"only relays intent once you authorize."
),
"note": (
"The live channel stays read-only until the OAuth token is present AND a "
"mandate is committed AND order tools are explicitly enabled."
),
}
# ---- Runner control (SPEC §7.5): start / stop the persistent live runner ----
#
# A LiveRunner (R2 contract: ``LiveRunner(broker)`` with ``run_loop()`` /
# ``run_once()``) is driven in a background task per broker. The factory is
# injectable (``_runner_factory``) so tests stub it with no real agent/broker.
# ``run_loop`` may be sync (long-blocking) or async; both are supported.
_runner_tasks: Dict[str, "asyncio.Task[Any]"] = {}
_runner_factory: Optional[Any] = None
class LiveRunnerUnavailable(RuntimeError):
"""Raised when a live runner cannot be wired (broker not configured/authorized).
Distinct from a programming error so the start endpoint can map it to a 503
rather than a 500: the runtime is fine, the broker channel just isn't ready.
"""
def _live_broker_adapter(broker: str) -> Any:
"""Build an ``MCPServerAdapter`` for a live broker from the user-side config.
Resolves the broker's MCP server entry by config key OR by a live-broker URL
host (so an aliased key still resolves), mirroring the registry's detection.
Args:
broker: The live-broker key, e.g. ``"robinhood"``.
Returns:
A constructed :class:`MCPServerAdapter` for the broker's read/write tools.
Raises:
LiveRunnerUnavailable: When no MCP server is configured for the broker.
"""
from src.config.loader import load_agent_config
from src.tools.mcp import MCPServerAdapter
try:
from src.config.schema import is_live_broker_entry
except Exception: # pragma: no cover - older schema without URL detection
is_live_broker_entry = None # type: ignore[assignment]
cfg = load_agent_config()
servers = getattr(cfg, "mcp_servers", {}) or {}
for name, server_cfg in servers.items():
is_match = name == broker
if not is_match and is_live_broker_entry is not None and broker == "robinhood":
try:
is_match = is_live_broker_entry(name, server_cfg)
except Exception: # pragma: no cover
is_match = False
if is_match:
return MCPServerAdapter(name, server_cfg)
raise LiveRunnerUnavailable(f"no MCP server configured for live broker {broker!r}")
def _build_live_runner(broker: str) -> Any:
"""Construct a fully-wired ``LiveRunner`` for a broker (SPEC §7.5 R-INT).
Wires the runner to the real surfaces — the public ``SessionService`` agent
caller (never the protected loop internals), the broker's READ/WRITE MCP
tools, the R4 reconciler, the R1 scheduler, and R3 market-hours triggers —
and injects an audit ``event_callback`` so every autonomous live action is
broadcast as a ``live.action`` SSE event on the runner's session bus.
Args:
broker: The live-broker key.
Returns:
A runner object exposing ``run_loop`` / ``run_once`` (R2 contract).
Raises:
LiveRunnerUnavailable: When the broker channel is not configured.
"""
if _runner_factory is not None:
return _runner_factory(broker)
from src.live.audit import write_live_action
from src.live.runtime.reconcile import reconcile
from src.live.runtime.runner import LiveRunner
from src.live.runtime.scheduler import Scheduler
from src.live.runtime.triggers import Trigger
from src.trading.service import runner_tool_name
def _tool(operation: str) -> str:
remote_tool = runner_tool_name(broker, operation)
if remote_tool is None:
raise LiveRunnerUnavailable(
f"live runner for {broker!r} does not define remote tool {operation!r}"
)
return remote_tool
positions_tool = _tool("positions")
balance_tool = _tool("account")
open_orders_tool = _tool("orders")
submit_order_tool = _tool("submit_order")
cancel_order_tool = _tool("cancel_order")
adapter = _live_broker_adapter(broker) # raises LiveRunnerUnavailable if absent
def _read(remote_tool: str):
"""A zero-arg broker READ callable bound to one remote tool."""
return lambda: adapter.call_tool(remote_tool, {})
def _submit(order: Dict[str, Any]) -> Dict[str, Any]:
# Route the flatten sweep's normalized order to the broker's write tools.
# Field mapping against the real Robinhood schema is finalized post-access
# (L6); the action discriminator is broker-agnostic.
if order.get("action") == "cancel":
return adapter.call_tool(cancel_order_tool, order)
return adapter.call_tool(submit_order_tool, order)
svc = _get_session_service()
session = svc.create_session(title=f"live-runner:{broker}")
session_id = session.session_id
async def _agent_caller(sid: str, prompt: str) -> Dict[str, Any]:
# Dispatch one autonomous turn through the PUBLIC SessionService entry.
# The agent then trades within the mandate via the gated order tools.
return await svc.send_message(sid, prompt)
def _audit_with_bus(event: Any) -> Dict[str, Any]:
# Broadcast each live action as a live.action SSE event on the runner's
# session bus (no protected-loop touch — the runner owns its session).
return write_live_action(
event,
event_callback=lambda etype, record: svc.event_bus.emit(session_id, etype, record),
)
# Wire the scheduler's fire callback to the runner's tick. The scheduler is
# constructed before the runner (it needs on_fire), and the runner needs the
# scheduler, so late-bind via a holder to break the cycle.
runner_holder: Dict[str, Any] = {}
async def _on_fire(_job: Any) -> None:
runner = runner_holder.get("runner")
if runner is not None:
await runner.run_once()
scheduler = Scheduler(_on_fire)
runner = LiveRunner(
broker,
agent_caller=_agent_caller,
reconcile_fn=reconcile,
read_positions=_read(positions_tool),
read_balance=_read(balance_tool),
read_open_orders=_read(open_orders_tool),
submit_fn=_submit,
write_audit_fn=_audit_with_bus,
scheduler=scheduler,
triggers=[Trigger.market("us_equity")],
session_id=session_id,
)
runner_holder["runner"] = runner
return runner
async def _drive_runner(runner: Any) -> None:
"""Run a runner's ``run_loop`` to completion, sync or async.
A synchronous ``run_loop`` is offloaded to a worker thread so it does not
block the event loop; an async ``run_loop`` is awaited directly.
"""
result = runner.run_loop()
if asyncio.iscoroutine(result):
await result
else:
await asyncio.get_running_loop().run_in_executor(None, lambda: result)
@app.post("/live/runner/start", dependencies=[Depends(require_auth)])
async def start_runner_endpoint(payload: LiveRunnerControlRequest):
"""Start the persistent live runner for a broker (SPEC §7.5).
Refuses to start unless a committed, unexpired mandate exists and the kill
switch is clear — the runner trades autonomously, so it must not start into a
dead/halted channel. Idempotent: a request for an already-running broker
returns ``already_running`` without spawning a second task.
"""
from src.live.halt import halt_flag_set
broker = payload.broker.strip().lower()
if not broker:
raise HTTPException(status_code=400, detail="broker must not be blank")
from src.trading.service import broker_supports_live_runner
if not broker_supports_live_runner(broker):
raise HTTPException(
status_code=400,
detail=f"live runner is not supported for {broker}",
)
existing = _runner_tasks.get(broker)
if existing is not None and not existing.done():
return {"broker": broker, "started": False, "already_running": True}
mandate = _active_mandate_state(broker)
if mandate is None:
raise HTTPException(status_code=409, detail=f"no committed mandate for {broker}")
if mandate.expired:
raise HTTPException(status_code=409, detail=f"mandate for {broker} has expired; re-authorize first")
if halt_flag_set(broker=broker) or halt_flag_set(broker=None):
raise HTTPException(status_code=409, detail="kill switch is tripped; resume before starting the runner")
try:
runner = _build_live_runner(broker)
except LiveRunnerUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"could not construct runner: {exc}") from exc
task = asyncio.ensure_future(_drive_runner(runner))
_runner_tasks[broker] = task
task.add_done_callback(
lambda t, b=broker: _runner_tasks.pop(b, None) if _runner_tasks.get(b) is t else None
)
_emit_live_event(
payload.session_id,
"live.action",
{"kind": "runner_started", "broker": broker},
)
return {"broker": broker, "started": True, "already_running": False}
@app.post("/live/runner/stop", dependencies=[Depends(require_auth)])
async def stop_runner_endpoint(payload: LiveRunnerControlRequest):
"""Stop the persistent live runner for a broker (SPEC §7.5).
Cancels the background task. This does NOT flatten positions — that is the
preemptive kill switch's job (``/live/halt`` -> flatten); stopping the runner
simply ceases new autonomous turns. Idempotent for an already-stopped broker.
"""
broker = payload.broker.strip().lower()
if not broker:
raise HTTPException(status_code=400, detail="broker must not be blank")
from src.trading.service import broker_supports_live_runner
if not broker_supports_live_runner(broker):
raise HTTPException(
status_code=400,
detail=f"live runner is not supported for {broker}",
)
task = _runner_tasks.pop(broker, None)
if task is None or task.done():
return {"broker": broker, "stopped": False, "was_running": False}
task.cancel()
_emit_live_event(
payload.session_id,
"live.action",
{"kind": "runner_stopped", "broker": broker},
)
return {"broker": broker, "stopped": True, "was_running": True}
# ============================================================================
# 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())