from __future__ import annotations from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from ..config import get_settings from ..env_store import ( binance_keys_configured, live_ready, mask_secret, okx_keys_configured, upsert_env_keys, ) 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", "live_order_interval_sec", "skip_weekends", "initial_equity", "leverage", "min_option_hours", "min_option_leverage", "atm_open_offset_enabled", "max_atm_open_offset", "fixed_direction_enabled", "fixed_perp_side", "close_bid_mark_max_pct", "perp_qty_eth", "option_qty_eth", "show_manual_trade_buttons", ) 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) live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30) 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) atm_open_offset_enabled: bool | None = None max_atm_open_offset: float | None = Field(default=None, ge=0, le=100) fixed_direction_enabled: bool | None = None fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$") 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) show_manual_trade_buttons: bool | None = None 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) ), "live_order_interval_sec": float( db.get_setting( "live_order_interval_sec", str(s.live_order_interval_sec) ) or s.live_order_interval_sec ), "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 ), "atm_open_offset_enabled": _as_bool( db.get_setting( "atm_open_offset_enabled", str(s.atm_open_offset_enabled) ), s.atm_open_offset_enabled, ), "max_atm_open_offset": float( db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset)) or s.max_atm_open_offset ), "fixed_direction_enabled": _as_bool( db.get_setting( "fixed_direction_enabled", str(s.fixed_direction_enabled) ), s.fixed_direction_enabled, ), "fixed_perp_side": ( side if ( side := str( db.get_setting("fixed_perp_side", s.fixed_perp_side) or s.fixed_perp_side ) .strip() .lower() ) in ("long", "short") else "long" ), "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 ), "show_manual_trade_buttons": _as_bool( db.get_setting("show_manual_trade_buttons", "0"), False ), "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() class RuntimeSettingsBody(BaseModel): mode: Literal["SIM", "LIVE"] | None = None confirm_live: bool | None = False confirm_live_phrase: str | None = None okx_api_key: str | None = None okx_api_secret: str | None = None okx_api_passphrase: str | None = None binance_api_key: str | None = None binance_api_secret: str | None = None def _runtime_payload() -> dict: s = get_settings() rt = load_runtime_settings() mode = "SIM" if s.is_sim else "LIVE" ready, reason = live_ready(exchange=rt.exchange) return { "mode": mode, "exchange": rt.exchange, "okx_configured": okx_keys_configured(s), "binance_configured": binance_keys_configured(s), "okx_api_key_masked": mask_secret(s.okx_api_key), "okx_api_secret_masked": mask_secret(s.okx_api_secret), "okx_api_passphrase_masked": mask_secret(s.okx_api_passphrase), "binance_api_key_masked": mask_secret(s.binance_api_key), "binance_api_secret_masked": mask_secret(s.binance_api_secret), "live_ready": bool(ready) if mode == "LIVE" else True, "live_ready_reason": reason if mode == "LIVE" else "sim", "sim": s.is_sim, } @router.get("/runtime") async def get_runtime_settings(_user: Annotated[str, Depends(require_user)]) -> dict: return _runtime_payload() @router.put("/runtime") async def put_runtime_settings( body: RuntimeSettingsBody, _user: Annotated[str, Depends(require_user)], ) -> dict: db = get_db() s = get_settings() cur_mode = "SIM" if s.is_sim else "LIVE" new_mode = (body.mode or cur_mode).strip().upper() if new_mode not in ("SIM", "LIVE"): raise HTTPException(status_code=400, detail="mode 须为 SIM 或 LIVE") if new_mode != cur_mode and Matcher(db).has_open_position(): raise HTTPException( status_code=409, detail="有未平仓,无法切换 SIM/LIVE;请先平仓后再改", ) if new_mode == "LIVE" and cur_mode != "LIVE": if not body.confirm_live: raise HTTPException( status_code=400, detail="切换到 LIVE 须二次确认(confirm_live=true)", ) phrase = (body.confirm_live_phrase or "").strip() if phrase != "LIVE": raise HTTPException( status_code=400, detail="切换到 LIVE 须在 confirm_live_phrase 传入 LIVE", ) updates: dict[str, str] = {} if body.okx_api_key is not None and body.okx_api_key.strip(): updates["OKX_API_KEY"] = body.okx_api_key.strip() if body.okx_api_secret is not None and body.okx_api_secret.strip(): updates["OKX_API_SECRET"] = body.okx_api_secret.strip() if body.okx_api_passphrase is not None and body.okx_api_passphrase.strip(): updates["OKX_API_PASSPHRASE"] = body.okx_api_passphrase.strip() if body.binance_api_key is not None and body.binance_api_key.strip(): updates["BINANCE_API_KEY"] = body.binance_api_key.strip() if body.binance_api_secret is not None and body.binance_api_secret.strip(): updates["BINANCE_API_SECRET"] = body.binance_api_secret.strip() if new_mode != cur_mode: updates["MODE"] = new_mode if updates: upsert_env_keys(updates) s2 = get_settings() if new_mode == "LIVE": rt = load_runtime_settings() if rt.exchange == "okx" and not okx_keys_configured(s2): if cur_mode == "SIM": upsert_env_keys({"MODE": "SIM"}) raise HTTPException( status_code=400, detail="切到 LIVE 前请先配置完整 OKX API Key/Secret/Passphrase", ) if rt.exchange == "binance" and not binance_keys_configured(s2): if cur_mode == "SIM": upsert_env_keys({"MODE": "SIM"}) raise HTTPException( status_code=400, detail="切到 LIVE 前请先配置完整币安 API Key/Secret", ) try: from ..strategy import get_engine get_engine().refresh_executor() except Exception: pass return _runtime_payload() class NotifySettingsBody(BaseModel): enabled: bool | None = None webhook_url: str | None = None def _notify_payload() -> dict: from ..notify import wecom from ..env_store import mask_secret s = get_settings() url = wecom.wecom_webhook_url() return { "enabled": wecom.wecom_enabled(), "webhook_configured": bool(url), "webhook_url_masked": mask_secret(url) if url else None, "venue_label": wecom.venue_label(), } @router.get("/notify") async def get_notify_settings(_user: Annotated[str, Depends(require_user)]) -> dict: return _notify_payload() @router.put("/notify") async def put_notify_settings( body: NotifySettingsBody, _user: Annotated[str, Depends(require_user)], ) -> dict: updates: dict[str, str] = {} if body.enabled is not None: updates["WECOM_ENABLED"] = "1" if body.enabled else "0" get_db().set_setting("wecom_enabled", "1" if body.enabled else "0") if body.webhook_url is not None and body.webhook_url.strip(): updates["WECOM_WEBHOOK_URL"] = body.webhook_url.strip() get_db().set_setting("wecom_webhook_url", body.webhook_url.strip()) if updates: upsert_env_keys(updates) return _notify_payload() @router.post("/notify/test") async def test_notify(_user: Annotated[str, Depends(require_user)]) -> dict: from ..notify import wecom ok, msg = wecom.notify_test() if not ok: raise HTTPException(status_code=400, detail=f"推送失败: {msg}") return {"ok": True, "detail": "测试消息已发送", **_notify_payload()}