Files
eth_hedge_sim/backend/app/api/settings.py
T
dekun 06f612aa52 Allow risk-based 永期 exits via premium multiple as well as base×k.
Settings UI picks 基数 or 权利金×n; sizing preview and control monitor follow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:13:11 +08:00

848 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
"residual_min_premium_pct",
"residual_close_check_sec",
"perp_qty_eth",
"option_qty_eth",
"show_manual_trade_buttons",
"sizing_mode",
"risk_leverage_basis",
"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",
"martingale_enabled",
"martingale_start_after_loss_days",
"martingale_max_doubles",
"hedge_mode",
"oo_amplitude_pct",
"oo_amplitude_hours",
"oo_amplitude_filter_enabled",
"oo_min_option_hours",
"oo_min_leverage",
"oo_reward_ratio",
"oo_budget_cushion",
"oo_strike_max_dev_pct",
)
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)
residual_min_premium_pct: float | None = Field(default=None, ge=1, le=100)
residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400)
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_leverage_basis: str | None = Field(
default=None, pattern="^(actual|selection)$"
)
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)
martingale_enabled: bool | None = None
martingale_start_after_loss_days: int | None = Field(default=None, ge=1, le=30)
martingale_max_doubles: int | None = Field(default=None, ge=1, le=10)
hedge_mode: str | None = Field(
default=None, pattern="^(perp_option|option_option)$"
)
oo_amplitude_pct: float | None = Field(default=None, ge=0.1, le=50)
oo_amplitude_hours: float | None = Field(default=None, ge=1, le=168)
oo_amplitude_filter_enabled: bool | None = None
oo_min_option_hours: float | None = Field(default=None, ge=1, le=720)
oo_min_leverage: float | None = Field(default=None, ge=1, le=10000)
oo_reward_ratio: float | None = Field(default=None, ge=0.5, le=20)
oo_budget_cushion: float | None = Field(default=None, ge=0.5, le=1.0)
oo_strike_max_dev_pct: float | None = Field(default=None, ge=0.1, le=10)
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
),
"residual_min_premium_pct": float(
db.get_setting(
"residual_min_premium_pct", str(s.residual_min_premium_pct)
)
or s.residual_min_premium_pct
),
"residual_close_check_sec": int(
float(
db.get_setting(
"residual_close_check_sec", str(s.residual_close_check_sec)
)
or s.residual_close_check_sec
)
),
"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_leverage_basis": (
rb
if (
rb := str(
db.get_setting(
"risk_leverage_basis",
str(s.risk_leverage_basis),
)
or s.risk_leverage_basis
or "selection"
)
.strip()
.lower()
)
in ("actual", "selection")
else "selection"
),
"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),
"martingale_enabled": _as_bool(
db.get_setting(
"martingale_enabled", str(s.martingale_enabled)
),
s.martingale_enabled,
),
"martingale_start_after_loss_days": int(
float(
db.get_setting(
"martingale_start_after_loss_days",
str(s.martingale_start_after_loss_days),
)
or s.martingale_start_after_loss_days
)
),
"martingale_max_doubles": int(
float(
db.get_setting(
"martingale_max_doubles", str(s.martingale_max_doubles)
)
or s.martingale_max_doubles
)
),
"hedge_mode": (
hm
if (
hm := str(
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode
)
.strip()
.lower()
)
in ("perp_option", "option_option")
else "perp_option"
),
"oo_amplitude_pct": float(
db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct))
or s.oo_amplitude_pct
),
"oo_amplitude_hours": float(
db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours))
or s.oo_amplitude_hours
),
"oo_amplitude_filter_enabled": _as_bool(
db.get_setting(
"oo_amplitude_filter_enabled",
str(s.oo_amplitude_filter_enabled),
),
s.oo_amplitude_filter_enabled,
),
"oo_min_option_hours": float(
db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours))
or s.oo_min_option_hours
),
"oo_min_leverage": float(
db.get_setting("oo_min_leverage", str(s.oo_min_leverage))
or s.oo_min_leverage
),
"oo_reward_ratio": float(
db.get_setting("oo_reward_ratio", str(s.oo_reward_ratio))
or s.oo_reward_ratio
),
"oo_budget_cushion": float(
db.get_setting("oo_budget_cushion", str(s.oo_budget_cushion))
or s.oo_budget_cushion
),
"oo_strike_max_dev_pct": float(
db.get_setting(
"oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct)
)
or s.oo_strike_max_dev_pct
),
"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
old_hedge = str(
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode or "perp_option"
).strip().lower()
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",
"martingale_enabled",
"martingale_start_after_loss_days",
"martingale_max_doubles",
"hedge_mode",
"oo_amplitude_pct",
"oo_amplitude_hours",
"oo_amplitude_filter_enabled",
"oo_min_option_hours",
"oo_min_leverage",
"oo_reward_ratio",
"oo_budget_cushion",
"oo_strike_max_dev_pct",
)
hit = [k for k in locked_keys if k in data]
if hit:
raise HTTPException(
status_code=409,
detail=f"有未平仓,禁止修改本组成交相关参数:{', '.join(hit)};请先平仓",
)
# 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场
hedge_mode = str(
data.get(
"hedge_mode",
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode,
)
).strip().lower()
if hedge_mode not in ("perp_option", "option_option"):
hedge_mode = "perp_option"
data["hedge_mode"] = hedge_mode
if hedge_mode == "option_option":
# 期期:强制以损定仓 + 亏损幅度%
data["sizing_mode"] = "risk_based"
data["risk_loss_mode"] = "percent"
data["exit_mode"] = "fixed_usdt"
data.pop("perp_qty_eth", None)
data["fixed_direction_enabled"] = False
sizing_mode = str(
data.get(
"sizing_mode",
db.get_setting("sizing_mode", "manual") or "manual",
)
).strip().lower()
if sizing_mode == "risk_based":
# 永期以损:允许 exit_mode=fixed_usdt(基数×k)或 premium_multiple(权利金×倍)
# 期期仍强制 fixed(目标由预算×盈亏比写入)
if hedge_mode == "option_option":
data["exit_mode"] = "fixed_usdt"
data.pop("perp_qty_eth", None)
data.pop("option_qty_eth", None)
if str(data.get("exit_mode") or db.get_setting("exit_mode", "") or "").strip().lower() != "premium_multiple":
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",
)
# 倍投:仅以损定仓 + 亏损幅度% + 基础幅度≤3%;条件不满足则强制关闭
from ..strategy.risk_sizing import MARTINGALE_MAX_BASE_PCT
loss_mode_final = str(
data.get(
"risk_loss_mode",
db.get_setting("risk_loss_mode", "percent") or "percent",
)
).strip().lower()
pct_final = data.get("risk_loss_pct")
if pct_final is None:
pct_final = float(db.get_setting("risk_loss_pct", "1") or 1)
else:
pct_final = float(pct_final)
existing_mg = _as_bool(
db.get_setting("martingale_enabled", str(s.martingale_enabled)),
s.martingale_enabled,
)
want_mg = (
bool(data["martingale_enabled"])
if "martingale_enabled" in data
else existing_mg
)
mg_eligible = (
sizing_mode == "risk_based"
and loss_mode_final in ("percent", "pct", "%", "幅度")
and float(pct_final) <= MARTINGALE_MAX_BASE_PCT + 1e-12
)
if want_mg and not mg_eligible:
explicit_on = "martingale_enabled" in data and bool(data["martingale_enabled"])
if explicit_on:
if sizing_mode != "risk_based":
reason = "倍投模式仅可在以损定仓下开启"
elif loss_mode_final not in ("percent", "pct", "%", "幅度"):
reason = "倍投模式仅可在「亏损幅度%」下开启"
else:
reason = (
f"以损定仓亏损幅度超过 {MARTINGALE_MAX_BASE_PCT:g}% 时不可启用倍投"
)
raise HTTPException(status_code=400, detail=reason)
data["martingale_enabled"] = False
elif not mg_eligible:
data["martingale_enabled"] = False
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
new_hedge = str(
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode or "perp_option"
).strip().lower()
if new_hedge != old_hedge and switch_to is None:
# 对冲模式切换:强制按新模式重对齐监控合约(ATM ↔ 虚值)
try:
from ..strategy.session import get_session
await get_session().ensure_atm_async(force=True)
except Exception:
pass
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()}