Files
eth_hedge_sim/backend/app/api/settings.py
T
dekun ec87cf2104 Fix LIVE open/close double-book and security audit findings.
Prevent expiry dual-close from re-booking option cash, abandon ledger rejection, margin-mode mismatch, and manual close races; harden fill wait and refuse default AUTH_SECRET on LIVE.

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

433 lines
15 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",
"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",
)
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)
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)$")
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),
"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
),
"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="有未平仓,无法切换永续保证金模式;请先平仓后再改",
)
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()}