You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""TGTC Paper Trading API endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import os
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/tgtc", tags=["tgtc-trading"])
|
|
|
|
|
|
def _db_path() -> str:
|
|
from pathlib import Path
|
|
project_root = Path(__file__).parent.parent.parent.parent
|
|
env_path = os.environ.get("TGTC_TRADER_DB", "data/paper/tgtc.db")
|
|
path = Path(env_path) if Path(env_path).is_absolute() else project_root / env_path
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
return str(path)
|
|
|
|
|
|
def _state() -> Any:
|
|
from apps.tgtc_trader.state import TGTCStateManager
|
|
return TGTCStateManager(_db_path())
|
|
|
|
|
|
# ── Request models ────────────────────────────────────────────────────────────
|
|
|
|
class CreateSessionRequest(BaseModel):
|
|
name: str
|
|
config: str
|
|
capital: float = 10000.0
|
|
|
|
|
|
class AutoStartRequest(BaseModel):
|
|
sessions: list[str] = []
|
|
dry_run: bool = True
|
|
|
|
|
|
class StartNowRequest(BaseModel):
|
|
sessions: list[str] = []
|
|
dry_run: bool = True
|
|
collect_duration_secs: int = 300
|
|
quick_end_after_mins: int = 60
|
|
|
|
|
|
class BacktestRequest(BaseModel):
|
|
date: str # YYYY-MM-DD start date
|
|
config: str # config path
|
|
universe: str | None = None
|
|
end_date: str | None = None # YYYY-MM-DD end date (multi-day if set)
|
|
|
|
|
|
# ── Sessions ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/sessions")
|
|
def list_sessions() -> dict[str, Any]:
|
|
state = _state()
|
|
sessions = state.list_sessions()
|
|
today = dt.date.today().isoformat()
|
|
results = []
|
|
for s in sessions:
|
|
daily = state.get_daily_state(s.session_id, today)
|
|
ran_today = daily is not None and daily.phase not in ("idle", "", None)
|
|
equity = state.get_equity(s.session_id) or s.initial_equity
|
|
total_pnl = equity - s.initial_equity
|
|
results.append({
|
|
"session_id": s.session_id,
|
|
"session_name": s.session_name,
|
|
"config_path": s.config_path,
|
|
"initial_equity": s.initial_equity,
|
|
"current_equity": round(equity, 2),
|
|
"total_return_pct": round((total_pnl / s.initial_equity) * 100, 2) if s.initial_equity > 0 else 0.0,
|
|
"created_at": s.created_at,
|
|
"status": s.status,
|
|
"ran_today": ran_today,
|
|
"phase": daily.phase if daily else "idle",
|
|
})
|
|
return {"sessions": results}
|
|
|
|
|
|
@router.post("/sessions")
|
|
def create_session(req: CreateSessionRequest) -> dict[str, Any]:
|
|
from pathlib import Path
|
|
import yaml
|
|
|
|
project_root = Path(__file__).parent.parent.parent.parent
|
|
config_path = req.config if Path(req.config).is_absolute() else str(project_root / req.config)
|
|
if not Path(config_path).exists():
|
|
raise HTTPException(status_code=404, detail=f"Config not found: {config_path}")
|
|
|
|
try:
|
|
raw = yaml.safe_load(Path(config_path).read_text()) or {}
|
|
if raw.get("strategy_mode") != "tgtc":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Config strategy_mode must be 'tgtc', got '{raw.get('strategy_mode')}'"
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid config: {e}")
|
|
|
|
state = _state()
|
|
if state.get_session(req.name) is not None:
|
|
raise HTTPException(status_code=409, detail=f"Session '{req.name}' already exists")
|
|
|
|
session_id = state.create_session(req.name, config_path, req.capital)
|
|
return {"session_id": session_id, "session_name": req.name,
|
|
"config_path": config_path, "initial_equity": req.capital}
|
|
|
|
|
|
@router.get("/sessions/{session_id}")
|
|
def get_session(session_id: str) -> dict[str, Any]:
|
|
state = _state()
|
|
session = state.get_session(session_id)
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
equity = state.get_equity(session.session_id) or session.initial_equity
|
|
return {
|
|
"session_id": session.session_id,
|
|
"session_name": session.session_name,
|
|
"config_path": session.config_path,
|
|
"initial_equity": session.initial_equity,
|
|
"current_equity": round(equity, 2),
|
|
"created_at": session.created_at,
|
|
"status": session.status,
|
|
}
|
|
|
|
|
|
@router.delete("/sessions/{session_id}")
|
|
def delete_session(session_id: str) -> dict[str, Any]:
|
|
state = _state()
|
|
session = state.get_session(session_id)
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
state.delete_session(session_id)
|
|
return {"deleted": session_id}
|
|
|
|
|
|
# ── Strategies list ───────────────────────────────────────────────────────────
|
|
|
|
@router.get("/strategies")
|
|
def list_strategies() -> dict[str, Any]:
|
|
"""List available TGTC strategy configs."""
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
project_root = Path(__file__).parent.parent.parent.parent
|
|
strat_dir = project_root / "configs" / "intraday" / "strategies"
|
|
strategies = []
|
|
for p in sorted(strat_dir.glob("tgtc_*.yaml")):
|
|
try:
|
|
raw = yaml.safe_load(p.read_text()) or {}
|
|
if raw.get("strategy_mode") != "tgtc":
|
|
continue
|
|
meta = raw.get("_meta", {})
|
|
strategies.append({
|
|
"config_path": str(p.relative_to(project_root)),
|
|
"name": meta.get("name", p.stem),
|
|
"id": meta.get("id", p.stem),
|
|
"status": meta.get("status", ""),
|
|
"live_readiness": meta.get("live_readiness", ""),
|
|
"description": meta.get("description", ""),
|
|
})
|
|
except Exception:
|
|
pass
|
|
return {"strategies": strategies}
|
|
|
|
|
|
# ── Live data ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/sessions/{session_id}/snapshots")
|
|
def get_snapshots(session_id: str, date: str | None = None) -> dict[str, Any]:
|
|
state = _state()
|
|
if state.get_session(session_id) is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
d = date or dt.date.today().isoformat()
|
|
rows = state.get_latest_snapshots(session_id, d)
|
|
return {"date": d, "snapshots": rows, "count": len(rows)}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/candidates")
|
|
def get_candidates(session_id: str, date: str | None = None) -> dict[str, Any]:
|
|
state = _state()
|
|
if state.get_session(session_id) is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
d = date or dt.date.today().isoformat()
|
|
return {"date": d, "candidates": state.get_candidates(session_id, d)}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/positions")
|
|
def get_positions(session_id: str) -> dict[str, Any]:
|
|
state = _state()
|
|
if state.get_session(session_id) is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
positions = state.get_all_positions(session_id)
|
|
return {"positions": positions}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/trades")
|
|
def get_trades(session_id: str) -> dict[str, Any]:
|
|
state = _state()
|
|
if state.get_session(session_id) is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return {"trades": state.get_trades(session_id)}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/equity")
|
|
def get_equity(session_id: str) -> dict[str, Any]:
|
|
state = _state()
|
|
session = state.get_session(session_id)
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
snapshots = state.get_daily_snapshots(session_id)
|
|
return {
|
|
"initial_equity": session.initial_equity,
|
|
"current_equity": state.get_equity(session_id) or session.initial_equity,
|
|
"daily_snapshots": snapshots,
|
|
}
|
|
|
|
|
|
# ── Auto scheduler ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/auto")
|
|
def get_auto_status() -> dict[str, Any]:
|
|
from apps.web.tgtc_service import tgtc_auto_scheduler
|
|
status = tgtc_auto_scheduler.get_status()
|
|
log_text = tgtc_auto_scheduler.get_log(lines=200)
|
|
return {**status, "log": log_text, "log_lines": log_text.splitlines()[-50:]}
|
|
|
|
|
|
@router.post("/auto/start")
|
|
async def start_auto(req: AutoStartRequest) -> dict[str, Any]:
|
|
from apps.web.tgtc_service import tgtc_auto_scheduler
|
|
if tgtc_auto_scheduler.running:
|
|
raise HTTPException(status_code=409, detail="TGTC scheduler already running")
|
|
try:
|
|
tgtc_auto_scheduler.start(
|
|
sessions=req.sessions,
|
|
db_path=_db_path(),
|
|
dry_run=req.dry_run,
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
return {"started": True, "dry_run": req.dry_run}
|
|
|
|
|
|
@router.post("/auto/start-now")
|
|
async def start_auto_now(req: StartNowRequest) -> dict[str, Any]:
|
|
from apps.web.tgtc_service import tgtc_auto_scheduler
|
|
if tgtc_auto_scheduler.running:
|
|
raise HTTPException(status_code=409, detail="TGTC scheduler already running")
|
|
try:
|
|
tgtc_auto_scheduler.start(
|
|
sessions=req.sessions,
|
|
db_path=_db_path(),
|
|
dry_run=req.dry_run,
|
|
start_now=True,
|
|
collect_duration_secs=req.collect_duration_secs,
|
|
quick_end_after_mins=req.quick_end_after_mins,
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
return {
|
|
"started_now": True,
|
|
"dry_run": req.dry_run,
|
|
"collect_duration_secs": req.collect_duration_secs,
|
|
"quick_end_after_mins": req.quick_end_after_mins,
|
|
}
|
|
|
|
|
|
@router.post("/auto/stop")
|
|
def stop_auto() -> dict[str, Any]:
|
|
from apps.web.tgtc_service import tgtc_auto_scheduler
|
|
tgtc_auto_scheduler.stop()
|
|
return {"stopped": True}
|
|
|
|
|
|
@router.post("/auto/clear-log")
|
|
def clear_auto_log() -> dict[str, Any]:
|
|
from apps.web.tgtc_service import tgtc_auto_scheduler
|
|
tgtc_auto_scheduler.clear_log()
|
|
return {"cleared": True}
|
|
|
|
|
|
# ── Backtest ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/backtest/submit")
|
|
async def submit_backtest(req: BacktestRequest) -> dict[str, Any]:
|
|
from pathlib import Path
|
|
|
|
project_root = Path(__file__).parent.parent.parent.parent
|
|
config_path = req.config if Path(req.config).is_absolute() else str(project_root / req.config)
|
|
if not Path(config_path).exists():
|
|
raise HTTPException(status_code=404, detail=f"Config not found: {config_path}")
|
|
|
|
try:
|
|
dt.date.fromisoformat(req.date)
|
|
if req.end_date:
|
|
end = dt.date.fromisoformat(req.end_date)
|
|
if end < dt.date.fromisoformat(req.date):
|
|
raise HTTPException(status_code=400, detail="end_date must be >= date")
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
|
|
|
|
from apps.web.tgtc_service import submit_backtest as _submit
|
|
task_id = await _submit(req.date, config_path, req.universe, req.end_date)
|
|
return {"task_id": task_id, "status": "pending"}
|
|
|
|
|
|
@router.get("/backtest/tasks")
|
|
def list_backtest_tasks() -> dict[str, Any]:
|
|
from apps.web.tgtc_service import get_bt_tasks
|
|
return {"tasks": get_bt_tasks()}
|
|
|
|
|
|
@router.get("/backtest/tasks/{task_id}")
|
|
def get_backtest_task(task_id: str) -> dict[str, Any]:
|
|
from apps.web.tgtc_service import get_bt_task
|
|
task = get_bt_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
return task
|
|
|
|
|
|
@router.get("/backtest/tasks/{task_id}/result")
|
|
def get_backtest_result(task_id: str) -> dict[str, Any]:
|
|
from apps.web.tgtc_service import get_bt_task
|
|
task = get_bt_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
if task["status"] != "completed":
|
|
raise HTTPException(status_code=409, detail=f"Task status: {task['status']}")
|
|
return task.get("result") or {}
|