from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from ..config import get_settings from ..exchange.runtime import ( load_runtime_settings, normalize_exchange_name, persist_exchange_choice, reload_market_session, ) from ..models.db import get_db from ..sim.ledger import Ledger from ..sim.matcher import Matcher from .auth import require_user router = APIRouter(prefix="/api/settings", tags=["settings"]) KEYS = ( "fee_rate", "exit_move_pct", "exit_mode", "net_profit_target", "premium_exit_multiple", "rest_seconds", "skip_weekends", "initial_equity", "leverage", "min_option_hours", "min_option_leverage", "close_bid_mark_max_pct", "perp_qty_eth", "option_qty_eth", ) class StrategySettingsBody(BaseModel): fee_rate: float | None = Field(default=None, ge=0, le=0.05) exit_move_pct: float | None = Field(default=None, ge=0.1, le=50) exit_mode: str | None = Field(default=None, pattern="^(fixed_usdt|premium_multiple)$") net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000) premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100) rest_seconds: int | None = Field(default=None, ge=0, le=3600) skip_weekends: bool | None = None initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000) leverage: float | None = Field(default=None, ge=1, le=125) min_option_hours: float | None = Field(default=None, ge=1, le=720) min_option_leverage: float | None = Field(default=None, ge=1, le=10000) close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100) perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100) option_qty_eth: float | None = Field(default=None, ge=0.01, le=100) exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$") def _as_bool(raw: str | None, default: bool) -> bool: if raw is None or raw == "": return default return str(raw).strip().lower() in ("1", "true", "yes", "on") def _read_settings() -> dict: db = get_db() s = get_settings() rt = load_runtime_settings() mode = str(db.get_setting("exit_mode", s.exit_mode) or s.exit_mode) if mode not in ("fixed_usdt", "premium_multiple"): mode = "fixed_usdt" return { "fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate), "exit_move_pct": float( db.get_setting("exit_move_pct", str(s.exit_move_pct)) or s.exit_move_pct ), "exit_mode": mode, "net_profit_target": float( db.get_setting("net_profit_target", str(s.net_profit_target)) or s.net_profit_target ), "premium_exit_multiple": float( db.get_setting("premium_exit_multiple", str(s.premium_exit_multiple)) or s.premium_exit_multiple ), "rest_seconds": int( float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds) ), "skip_weekends": _as_bool( db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends ), "initial_equity": float( db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity ), "leverage": float(db.get_setting("leverage", str(s.leverage)) or s.leverage), "min_option_hours": float( db.get_setting("min_option_hours", str(s.min_option_hours)) or s.min_option_hours ), "min_option_leverage": float( db.get_setting("min_option_leverage", str(s.min_option_leverage)) or s.min_option_leverage ), "close_bid_mark_max_pct": float( db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct)) or s.close_bid_mark_max_pct ), "perp_qty_eth": float( db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth ), "option_qty_eth": float( db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth ), "exchange": rt.exchange, "perp_inst_id": rt.perp_inst_id, "option_inst_family": rt.option_inst_family, "index_inst_id": rt.index_inst_id, "ledger": Ledger(db).snapshot(), } @router.get("/strategy") async def get_strategy_settings(_user: Annotated[str, Depends(require_user)]) -> dict: return _read_settings() @router.put("/strategy") async def put_strategy_settings( body: StrategySettingsBody, _user: Annotated[str, Depends(require_user)], ) -> dict: db = get_db() s = get_settings() data = body.model_dump(exclude_none=True) equity_to_apply: float | None = None switch_to: str | None = None if "exchange" in data: new_ex = normalize_exchange_name(str(data.pop("exchange"))) old_ex = normalize_exchange_name( db.get_setting("exchange", s.exchange) or s.exchange ) if new_ex != old_ex: if Matcher(db).has_open_position(): raise HTTPException( status_code=409, detail="有未平仓,无法切换交易所;请先平仓后再改", ) switch_to = new_ex if "initial_equity" in data: new_eq = float(data["initial_equity"]) old_eq = float( db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity ) if abs(new_eq - old_eq) > 1e-9: if Matcher(db).has_open_position(): raise HTTPException( status_code=409, detail="有未平仓,无法重置模拟资金;请先平仓后再改", ) equity_to_apply = new_eq for k, v in data.items(): if k in KEYS: db.set_setting(k, str(v)) if equity_to_apply is not None: Ledger(db).reset_equity( equity_to_apply, note=f"设置模拟资金={equity_to_apply:.2f}", ) if switch_to is not None: rt = persist_exchange_choice(switch_to) try: await reload_market_session(rt) except Exception as e: raise HTTPException( status_code=502, detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}", ) from e return _read_settings()