diff --git a/sanguo_api/app.py b/sanguo_api/app.py index 2f2e4be..0c52b5d 100644 --- a/sanguo_api/app.py +++ b/sanguo_api/app.py @@ -3,6 +3,7 @@ FastAPI application factory for Sanguo Quant API """ from fastapi import FastAPI from .routes import router, set_orchestrator, set_auth_config +from .routes_paper import router as paper_router, set_db_path from .auth import set_jwt_config from .ws import manager from sanguo_orchestrator.runner import Orchestrator @@ -33,5 +34,7 @@ def create_app(db_path: str, file_dir=None, auth_config=None, max_workers: int = # Include routes app.include_router(router, prefix="/api/v1") + app.include_router(paper_router, prefix="/api/v1") + set_db_path(db_path) return app \ No newline at end of file diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py new file mode 100644 index 0000000..207de6d --- /dev/null +++ b/sanguo_api/routes_paper.py @@ -0,0 +1,90 @@ +"""模拟盘 API 路由(spec §10)。 + +create 建 paper_account(持久化配置);GET 查询净值/成交/状态。 +回放执行(engine.run)由 orchestrator 异步触发或容器内同步跑,端到端冒烟在容器 +(本机无 NAS parquet + vnpy 完整依赖),本模块只做 account 管理 + 查询。 +""" +import sqlite3 + +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel + +from .auth import verify_token as verify_token_impl + +router = APIRouter() +_db_path = {"path": None} + + +def set_db_path(p): + _db_path["path"] = p + if p: + from sanguo_trader.persistence import init_db + init_db(p) # app 启动建表(幂等),保证 GET 查询不报 no such table + + +async def verify_token(authorization: str | None = Header(None)): + if authorization is None or not authorization.startswith("Bearer "): + raise HTTPException(401, "Missing/invalid authorization") + return verify_token_impl(authorization.split(" ", 1)[1]) + + +class StrategyCfg(BaseModel): + name: str + params: dict = {} + match_session: str = "next_open" + symbol: str + listing_days: int = 0 + + +class PaperCreateRequest(BaseModel): + name: str = "paper" + mode: str = "replay" + interval: str = "d" + symbols: list[str] + strategies: list[StrategyCfg] + initial_capital: float = 1_000_000 + rate: float = 0.0003 + slippage: float = 0.0 + pricetick: float = 0.01 + stamp_duty_rate: float = 0.0005 + transfer_fee_rate: float = 0.00001 + min_commission: float = 5.0 + start: str + end: str + + +@router.post("/paper/create", dependencies=[Depends(verify_token)]) +def create_paper(req: PaperCreateRequest): + from sanguo_trader.persistence import init_db, save_account + + db = _db_path["path"] or ":memory:" + init_db(db) + aid = save_account(db, req.model_dump()) + return {"account_id": aid, "status": "created"} + + +@router.get("/paper/{aid}", dependencies=[Depends(verify_token)]) +def get_paper(aid: int): + db = _db_path["path"] + with sqlite3.connect(db) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM paper_accounts WHERE id=?", (aid,) + ).fetchone() + if not row: + raise HTTPException(404, "account not found") + return dict(row) + + +@router.get("/paper/{aid}/equity", dependencies=[Depends(verify_token)]) +def get_equity(aid: int): + from sanguo_trader.persistence import list_daily_balance + + return list_daily_balance(_db_path["path"], aid) + + +@router.get("/paper/{aid}/trades", dependencies=[Depends(verify_token)]) +def get_trades(aid: int): + from sanguo_trader.persistence import list_trades + + return list_trades(_db_path["path"], aid) diff --git a/tests/api/test_paper_routes.py b/tests/api/test_paper_routes.py new file mode 100644 index 0000000..5b4aa33 --- /dev/null +++ b/tests/api/test_paper_routes.py @@ -0,0 +1,61 @@ +"""模拟盘 API 路由测试(spec §10)。""" +import os + +from fastapi.testclient import TestClient + +from sanguo_api.app import create_app +from sanguo_api.auth import create_token, set_jwt_config +from sanguo_api.routes_paper import set_db_path + + +def _client(tmp_path): + set_jwt_config(secret="t", expire_minutes=60) + db = os.path.join(str(tmp_path), "p.db") + app = create_app(db_path=db) + set_db_path(db) + return TestClient(app), create_token("admin") + + +def _auth(token): + return {"Authorization": f"Bearer {token}"} + + +def test_create_paper(tmp_path): + c, token = _client(tmp_path) + resp = c.post( + "/api/v1/paper/create", + json={ + "symbols": ["600000"], + "strategies": [{"name": "DoubleMa", "symbol": "600000", + "match_session": "next_open"}], + "start": "2024-01-01", "end": "2024-06-30", + "initial_capital": 1_000_000, + }, + headers=_auth(token), + ) + assert resp.status_code == 200 + assert "account_id" in resp.json() + + +def test_get_paper_and_empty_trades(tmp_path): + c, token = _client(tmp_path) + aid = c.post( + "/api/v1/paper/create", + json={"symbols": ["600000"], + "strategies": [{"name": "S", "symbol": "600000"}], + "start": "2024-01-01", "end": "2024-06-30"}, + headers=_auth(token), + ).json()["account_id"] + assert c.get(f"/api/v1/paper/{aid}", headers=_auth(token)).status_code == 200 + assert c.get(f"/api/v1/paper/{aid}/trades", headers=_auth(token)).json() == [] + assert c.get(f"/api/v1/paper/{aid}/equity", headers=_auth(token)).json() == [] + + +def test_get_paper_404(tmp_path): + c, token = _client(tmp_path) + assert c.get("/api/v1/paper/999", headers=_auth(token)).status_code == 404 + + +def test_unauthorized_401(tmp_path): + c, _ = _client(tmp_path) + assert c.get("/api/v1/paper/1").status_code == 401