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", "one_expiry_per_day", "initial_equity", "leverage", "perp_margin_mode", "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", "sizing_mode", "risk_loss_mode", "risk_loss_pct", "risk_loss_usdt", "risk_capital_source", "risk_manual_capital_usdt", "risk_perp_unit", "risk_option_unit", "risk_exit_unit", ) 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 one_expiry_per_day: 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) perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$") 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)$") sizing_mode: str | None = Field(default=None, pattern="^(manual|risk_based)$") risk_loss_mode: str | None = Field(default=None, pattern="^(percent|absolute)$") risk_loss_pct: float | None = Field(default=None, ge=0.01, le=100) risk_loss_usdt: float | None = Field(default=None, ge=0.1, le=1_000_000) risk_capital_source: str | None = Field( default=None, pattern="^(trading_account|manual)$" ) risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000) risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100) risk_option_unit: float | None = Field(default=None, ge=0.01, le=100) risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000) 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 _risk_preview_safe() -> dict: try: from ..strategy.risk_sizing import preview_risk_sizing return preview_risk_sizing() except Exception as e: return {"ok": False, "detail": f"预览失败: {e}", "risk_based": False} 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 ), "one_expiry_per_day": _as_bool( db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)), s.one_expiry_per_day, ), "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), "perp_margin_mode": ( mm if ( mm := str( db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode or "cross" ) .strip() .lower() ) in ("cross", "isolated") else "cross" ), "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 ), "sizing_mode": ( sm if ( sm := str(db.get_setting("sizing_mode", "manual") or "manual") .strip() .lower() ) in ("manual", "risk_based") else "manual" ), "risk_loss_mode": ( lm if ( lm := str(db.get_setting("risk_loss_mode", "percent") or "percent") .strip() .lower() ) in ("percent", "absolute") else "percent" ), "risk_loss_pct": float(db.get_setting("risk_loss_pct", "1") or 1), "risk_loss_usdt": float(db.get_setting("risk_loss_usdt", "15") or 15), "risk_capital_source": ( cs if ( cs := str( db.get_setting("risk_capital_source", "trading_account") or "trading_account" ) .strip() .lower() ) in ("trading_account", "manual") else "trading_account" ), "risk_manual_capital_usdt": float( db.get_setting("risk_manual_capital_usdt", "10000") or 10000 ), "risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1), "risk_option_unit": float(db.get_setting("risk_option_unit", "2") or 2), "risk_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15), "risk_sizing_preview": _risk_preview_safe(), "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 if "perp_margin_mode" in data: new_mm = str(data["perp_margin_mode"]).strip().lower() old_mm = str( db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode ).strip().lower() if new_mm != old_mm and Matcher(db).has_open_position(): raise HTTPException( status_code=409, detail="有未平仓,无法切换永续保证金模式;请先平仓后再改", ) # 持仓中禁止改动会影响本组成交/出场的参数(基线:运行中无人工开平仓,策略锁定本组成交) if Matcher(db).has_open_position(): locked_keys = ( "net_profit_target", "exit_mode", "premium_exit_multiple", "perp_qty_eth", "option_qty_eth", "leverage", "sizing_mode", "risk_perp_unit", "risk_option_unit", "risk_exit_unit", "risk_loss_mode", "risk_loss_pct", "risk_loss_usdt", "risk_capital_source", "risk_manual_capital_usdt", ) hit = [k for k in locked_keys if k in data] if hit: raise HTTPException( status_code=409, detail=f"有未平仓,禁止修改本组成交相关参数:{', '.join(hit)};请先平仓", ) # 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场 sizing_mode = str( data.get( "sizing_mode", db.get_setting("sizing_mode", "manual") or "manual", ) ).strip().lower() if sizing_mode == "risk_based": data["exit_mode"] = "fixed_usdt" data.pop("perp_qty_eth", None) data.pop("option_qty_eth", None) data.pop("net_profit_target", None) loss_mode = str( data.get( "risk_loss_mode", db.get_setting("risk_loss_mode", "percent") or "percent", ) ).strip().lower() if loss_mode == "absolute": loss_u = data.get("risk_loss_usdt") if loss_u is None: loss_u = float(db.get_setting("risk_loss_usdt", "0") or 0) if float(loss_u) <= 0: raise HTTPException( status_code=400, detail="以损定仓选用亏损值时,须填写 risk_loss_usdt > 0", ) else: src = str( data.get( "risk_capital_source", db.get_setting("risk_capital_source", "trading_account") or "trading_account", ) ).strip().lower() if src == "manual": cap = data.get("risk_manual_capital_usdt") if cap is None: cap = float(db.get_setting("risk_manual_capital_usdt", "0") or 0) if float(cap) <= 0: raise HTTPException( status_code=400, detail="以损定仓选用单独本金时,须填写 risk_manual_capital_usdt > 0", ) pct = data.get("risk_loss_pct") if pct is None: pct = float(db.get_setting("risk_loss_pct", "0") or 0) if float(pct) <= 0: raise HTTPException( status_code=400, detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0", ) 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", ) secret = (s.auth_secret or "").strip() if not secret or secret == "change-me-eth-hedge-sim-secret": raise HTTPException( status_code=400, detail="切到 LIVE 前请在 .env 设置非默认 AUTH_SECRET", ) 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", ) # 热切 LIVE:强制暂停策略(对齐冷启动护栏;运行中禁止人工开平仓基线) if new_mode == "LIVE" and cur_mode != "LIVE": db.execute( "UPDATE strategy_state SET running=0, phase=?, last_error=? WHERE id=1", ("paused", "已切换 LIVE,策略已强制暂停;确认就绪后再启动"), ) try: from ..strategy import get_engine # 同步停循环标志(pause 为 async,此处只写状态) get_engine()._set_state( running=0, phase="paused", last_error="已切换 LIVE,策略已强制暂停;确认就绪后再启动", ) except Exception: pass 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 machine_name: str | None = Field(default=None, max_length=64) def _notify_payload() -> dict: from ..notify import wecom from ..env_store import mask_secret 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(), "machine_name": wecom.wecom_machine_name() or "", } @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 body.machine_name is not None: name = body.machine_name.strip()[:64] updates["WECOM_MACHINE_NAME"] = name get_db().set_setting("wecom_machine_name", name) if updates: upsert_env_keys(updates) get_settings.cache_clear() 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()}