from __future__ import annotations import asyncio from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ..models.db import get_db from ..strategy import get_engine from .auth import require_user router = APIRouter(prefix="/api/plan", tags=["plan"]) @router.get("/state") async def plan_state(_user: Annotated[str, Depends(require_user)]) -> dict: return get_engine().state() @router.post("/start") async def plan_start(_user: Annotated[str, Depends(require_user)]) -> dict: return await get_engine().start() @router.post("/pause") async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict: return await get_engine().pause() @router.post("/emergency-close") async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict: return await get_engine().emergency_close() class SemiArmBody(BaseModel): armed: bool = True class SemiParamsBody(BaseModel): """首页半自动本单参数(不进系统设置表单主路径,但落同一 settings 表)。""" semi_view_side: Literal["long", "short"] | None = None semi_option_move_points: float | None = Field(default=None, ge=1, le=5000) semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000) semi_min_option_hours: float | None = Field(default=None, ge=1, le=720) semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000) semi_moneyness: Literal["itm", "atm", "otm"] | None = None semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000) semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100) semi_option_unit: float | None = Field(default=None, ge=0.01, le=100) @router.post("/semi/arm") async def plan_semi_arm( body: SemiArmBody, _user: Annotated[str, Depends(require_user)], ) -> dict: return get_engine().arm_semi(armed=bool(body.armed)) @router.put("/semi/params") async def plan_semi_params( body: SemiParamsBody, _user: Annotated[str, Depends(require_user)], ) -> dict: eng = get_engine() if eng.matcher.has_open_position(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="有未平仓,禁止修改半自动本单参数", ) from ..strategy.semi_auto import is_armed if is_armed(eng.ledger): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="已授权盯开,禁止改本单参数;请先取消授权", ) st = eng.db.fetchone("SELECT phase FROM strategy_state WHERE id=1") phase = str(st["phase"] or "") if st else "" if phase in ("wait_signal", "opening", "open", "closing", "liquidity_wait"): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"当前阶段 {phase} 禁止修改半自动本单参数", ) db = get_db() data = body.model_dump(exclude_none=True) for k, v in data.items(): db.set_setting(k, str(v)) return eng.state() class ResidualCloseBody(BaseModel): group_id: str = Field(min_length=1, max_length=128) @router.post("/residual/close") async def plan_residual_close( body: ResidualCloseBody, _user: Annotated[str, Depends(require_user)], ) -> dict: """手动平单条残留:只验流动性,不验权利金回收比例。""" matcher = get_engine().matcher result = await asyncio.to_thread(matcher.close_residual_manual, body.group_id) if not result.ok: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=result.detail or "平残留失败", ) return { "ok": True, "detail": result.detail, "data": result.data, "liquidity_wait": result.liquidity_wait, }