Audit fixes: LIVE symbols/fills/expiry/pending, security harden, add 更新说明.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 22:28:03 +08:00
parent bc8d1fb127
commit f48ea5bbcc
18 changed files with 1389 additions and 1069 deletions
+1
View File
@@ -24,6 +24,7 @@
- [商业化与授权方案](docs/商业化与授权方案.md)
- [策略说明](docs/策略说明.md)
- [实盘策略说明](docs/实盘策略说明.md)
- [更新说明](docs/更新说明.md)(每次发版追加)
## 访问(测试机)
+7 -1
View File
@@ -42,7 +42,11 @@ def _b64url_decode(s: str) -> bytes:
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
payload = {"u": username, "exp": exp}
payload = {
"u": username,
"exp": exp,
"v": int(settings.auth_token_version),
}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.auth_secret.encode("utf-8"),
@@ -70,6 +74,8 @@ def verify_token(token: str, settings: Settings) -> str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
if int(payload.get("v") or 0) != int(settings.auth_token_version):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token revoked")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
+73 -14
View File
@@ -1,28 +1,71 @@
from __future__ import annotations
import hmac
import time
from collections import defaultdict
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from ..config import Settings, get_settings
from ..credentials import get_credentials, update_credentials
from ..credentials import get_credentials, update_credentials, upsert_env_file
from .auth import LoginRequest, LoginResponse, issue_token, require_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_hits: dict[str, list[float]] = defaultdict(list)
class ChangeCredentialsRequest(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=4, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
def _client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for") or ""
if xff.strip():
return xff.split(",")[0].strip()
if request.client:
return request.client.host or "unknown"
return "unknown"
def _rate_limit_login(ip: str, settings: Settings) -> None:
now = time.time()
window = float(settings.login_window_sec)
max_n = int(settings.login_max_attempts)
hits = [t for t in _login_hits[ip] if now - t < window]
_login_hits[ip] = hits
if len(hits) >= max_n:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录过于频繁,请 {int(window)} 秒后再试",
)
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
async def login(
body: LoginRequest,
request: Request,
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
ip = _client_ip(request)
_rate_limit_login(ip, settings)
user, pwd = get_credentials()
if body.username != user or body.password != pwd:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
user_ok = hmac.compare_digest(
body.username.encode("utf-8"), user.encode("utf-8")
)
pwd_ok = hmac.compare_digest(
body.password.encode("utf-8"), pwd.encode("utf-8")
)
if not (user_ok and pwd_ok):
_login_hits[ip].append(time.time())
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误"
)
_login_hits.pop(ip, None)
token, ttl = issue_token(body.username, settings)
return LoginResponse(
token=token,
@@ -34,7 +77,10 @@ async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_se
@router.get("/me")
async def me(username: Annotated[str, Depends(require_user)], settings: Annotated[Settings, Depends(get_settings)]) -> dict:
async def me(
username: Annotated[str, Depends(require_user)],
settings: Annotated[Settings, Depends(get_settings)],
) -> dict:
return {
"username": username,
"env_name": settings.env_name,
@@ -50,17 +96,30 @@ async def change_credentials(
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
_cur_user, cur_pwd = get_credentials()
if body.current_password != cur_pwd:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
if not hmac.compare_digest(
body.current_password.encode("utf-8"), cur_pwd.encode("utf-8")
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确"
)
try:
update_credentials(new_username=body.new_username, new_password=body.new_password)
update_credentials(
new_username=body.new_username, new_password=body.new_password
)
# 作废旧 token
new_ver = int(settings.auth_token_version) + 1
upsert_env_file("AUTH_TOKEN_VERSION", str(new_ver))
get_settings.cache_clear()
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
token, ttl = issue_token(body.new_username.strip(), settings)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
) from e
settings2 = get_settings()
token, ttl = issue_token(body.new_username.strip(), settings2)
return LoginResponse(
token=token,
username=body.new_username.strip(),
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
env_name=settings2.env_name,
mode=settings2.mode,
)
+7
View File
@@ -215,6 +215,7 @@ async def put_strategy_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
@@ -272,6 +273,12 @@ async def put_runtime_settings(
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():
+4
View File
@@ -25,6 +25,10 @@ class Settings(BaseSettings):
auth_password: str = "admin123"
auth_secret: str = "change-me-eth-hedge-sim-secret"
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
auth_token_version: int = 1 # 改密时递增,作废旧 token
disable_api_docs: bool = True # 生产默认关闭 /docs
login_max_attempts: int = 8
login_window_sec: int = 300
okx_api_key: str = ""
okx_api_secret: str = ""
+9 -1
View File
@@ -38,11 +38,15 @@ def get_credentials() -> tuple[str, str]:
def upsert_env_file(key: str, value: str) -> Path | None:
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
if "\n" in value or "\r" in value:
raise ValueError(f"{key} 值不能包含换行")
paths = _env_paths()
target = next((p for p in paths if p.is_file()), paths[0])
target.parent.mkdir(parents=True, exist_ok=True)
text = target.read_text(encoding="utf-8") if target.is_file() else ""
line = f"{key}={value}"
# 简单引号,避免空格/特殊字符破坏解析
safe = value.replace("\\", "\\\\").replace('"', '\\"')
line = f'{key}="{safe}"'
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
if pattern.search(text):
text = pattern.sub(line, text)
@@ -51,6 +55,10 @@ def upsert_env_file(key: str, value: str) -> Path | None:
text += "\n"
text += line + "\n"
target.write_text(text, encoding="utf-8")
try:
target.chmod(0o600)
except Exception:
pass
return target
+3 -5
View File
@@ -22,14 +22,12 @@ def upsert_env_keys(updates: dict[str, str]) -> Path | None:
return target
def mask_secret(raw: str | None, *, keep: int = 4) -> str | None:
"""脱敏:****末尾;过短则全部打码"""
def mask_secret(raw: str | None, *, keep: int = 0) -> str | None:
"""脱敏:仅返回是否已配置(不再泄露末尾字符)"""
s = (raw or "").strip()
if not s:
return None
if len(s) <= keep:
return "*" * len(s)
return "*" * max(4, len(s) - keep) + s[-keep:]
return "********"
def okx_keys_configured(s=None) -> bool:
+97 -36
View File
@@ -7,11 +7,12 @@ import time
from ..config import get_settings
from ..env_store import live_ready
from ..sim.liquidity import contracts_for_eth
from ..sim.liquidity import contracts_for_eth, eth_from_contracts
from ..sim.matcher import CloseResult, Matcher, OpenResult
from ..sim.pricing import option_expiry_settle, option_intrinsic
from ..strategy.session import get_session
from .binance_trade import BinanceTradeClient
from .symbols import live_settings, resolve_perp_inst_id
logger = logging.getLogger(__name__)
@@ -38,9 +39,11 @@ class BinanceLiveExecutor(Matcher):
return base
from .live_pnl import enrich_live_unrealized
s = get_settings()
gid = base.get("group_id")
open_at = None
perp_inst = resolve_perp_inst_id(
self.db, group_id=str(gid) if gid else None
)
if gid:
g = self.db.fetchone(
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
@@ -48,11 +51,8 @@ class BinanceLiveExecutor(Matcher):
)
if g:
open_at = int(g["open_at_ms"] or 0) or None
perp_inst = str(g["perp_inst_id"] or s.perp_inst_id)
else:
perp_inst = s.perp_inst_id
else:
perp_inst = s.perp_inst_id
if g["perp_inst_id"]:
perp_inst = str(g["perp_inst_id"])
try:
client = self._client()
except Exception:
@@ -83,7 +83,7 @@ class BinanceLiveExecutor(Matcher):
if err:
return OpenResult(ok=False, detail=err)
s = get_settings()
s = live_settings()
if self.has_open_position():
st = self.position_status()
return OpenResult(
@@ -92,6 +92,7 @@ class BinanceLiveExecutor(Matcher):
)
client = self._client()
perp_inst = resolve_perp_inst_id(self.db)
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
ct_mult = self._ct_mult(option_inst_id)
@@ -107,6 +108,12 @@ class BinanceLiveExecutor(Matcher):
logger.exception("binance live open option failed")
return OpenResult(ok=False, detail=f"币安开期权失败: {e}")
filled_opt_contracts = float(opt_fill.sz) if opt_fill.sz and opt_fill.sz > 0 else float(
int(round(opt_contracts))
)
opt_contracts = filled_opt_contracts
opt_qty = eth_from_contracts(opt_contracts, ct_mult)
# 永续市价失败(多为保证金不足)→ 必须回滚期权
try:
if perp_side == "long":
@@ -114,7 +121,7 @@ class BinanceLiveExecutor(Matcher):
else:
side, pos_side = "SELL", "SHORT"
perp_fill_live = client.place_perp_market(
symbol=s.perp_inst_id,
symbol=perp_inst,
side=side,
qty_eth=perp_qty,
position_side=pos_side,
@@ -159,6 +166,12 @@ class BinanceLiveExecutor(Matcher):
pf_px = float(perp_fill_live.avg_px)
of_fee = float(opt_fill.fee)
pf_fee = float(perp_fill_live.fee)
filled_perp_qty = (
float(perp_fill_live.sz)
if perp_fill_live.sz and perp_fill_live.sz > 0
else perp_qty
)
perp_qty = filled_perp_qty
initial_premium = of_px * opt_qty
of_notional = of_px * opt_qty
pf_notional = pf_px * perp_qty
@@ -194,7 +207,7 @@ class BinanceLiveExecutor(Matcher):
option_side,
perp_side,
option_inst_id,
s.perp_inst_id,
perp_inst,
strike,
expiry_ymd,
entry_index_px,
@@ -235,7 +248,7 @@ class BinanceLiveExecutor(Matcher):
"perp",
"open",
perp_side,
s.perp_inst_id,
perp_inst,
perp_qty,
None,
pf_px,
@@ -303,7 +316,7 @@ class BinanceLiveExecutor(Matcher):
detail: str,
) -> None:
"""期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。"""
s = get_settings()
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
initial_premium = of_px * opt_qty
self.ledger.apply_cash(
-(of_px * opt_qty + of_fee),
@@ -331,7 +344,7 @@ class BinanceLiveExecutor(Matcher):
option_side,
perp_side,
option_inst_id,
s.perp_inst_id,
perp_inst,
strike,
expiry_ymd,
entry_index_px,
@@ -475,7 +488,7 @@ class BinanceLiveExecutor(Matcher):
if err:
return CloseResult(ok=False, detail=err)
s = get_settings()
s = live_settings()
pos = self.current_position()
st = str(pos.get("status") or "")
if st == "half_open":
@@ -490,6 +503,7 @@ class BinanceLiveExecutor(Matcher):
opt_qty = float(pos["option_qty_eth"])
perp_qty = float(pos["perp_qty_eth"])
opt_contracts = float(pos["option_qty_contracts"] or 0)
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
client = self._client()
is_expiry = reason == "expiry"
fee_rate = self._fee_rate()
@@ -527,34 +541,45 @@ class BinanceLiveExecutor(Matcher):
of_fee = float(prev["fee"] or 0)
of_notional = float(prev["notional"] or (of_px * opt_qty))
of_slip = float(prev["slip"] or 0)
elif is_expiry:
if intrinsic is None:
return CloseResult(ok=False, detail="到期结算失败:缺行权价或标的价")
of = option_expiry_settle(
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
)
of_px, of_fee, of_slip, of_notional = of.fill_px, of.fee, of.slip, of.notional
else:
# 含到期:优先交易所真实平期权;失败且无内在价值时可本地结算
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=opt_contracts,
quantity=max(1.0, opt_contracts),
reduce_only=True,
)
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled_c = float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else opt_contracts
opt_contracts = filled_c
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
of_notional = of_px * opt_qty
except Exception as e:
if not bypass_liquidity:
if is_expiry and intrinsic is not None:
of = option_expiry_settle(
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
)
of_px, of_fee, of_slip, of_notional = (
of.fill_px,
of.fee,
of.slip,
of.notional,
)
logger.warning(
"expiry option exchange close failed, local settle: %s", e
)
elif not bypass_liquidity:
return CloseResult(
ok=False,
detail=f"币安平期权失败: {e}",
liquidity_wait=True,
)
return CloseResult(ok=False, detail=f"币安平期权失败: {e}")
else:
return CloseResult(ok=False, detail=f"币安平期权失败: {e}")
# 期权已平:立刻落 pending,避免永续失败后重试再卖期权
# 期权已平(或到期本地结算):立刻落 pending,避免永续失败后重试再卖期权
self._mark_option_closed_perp_pending(
group_id=group_id,
option_inst_id=option_inst_id,
@@ -574,7 +599,7 @@ class BinanceLiveExecutor(Matcher):
else:
side, pos_side = "BUY", "SHORT"
perp_live = client.place_perp_market(
symbol=s.perp_inst_id,
symbol=perp_inst,
side=side,
qty_eth=perp_qty,
position_side=pos_side,
@@ -681,7 +706,8 @@ class BinanceLiveExecutor(Matcher):
option_fill_already_written: bool,
skip_option_cash: bool,
) -> CloseResult:
s = get_settings()
s = live_settings()
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
opt_entry = float(pos["option_entry_px"])
@@ -749,7 +775,7 @@ class BinanceLiveExecutor(Matcher):
"perp",
"close",
"flat",
s.perp_inst_id,
perp_inst,
perp_qty,
None,
pf_px,
@@ -793,7 +819,7 @@ class BinanceLiveExecutor(Matcher):
client=self._client(),
exchange="binance",
group_id=group_id,
perp_inst_id=str((g2["perp_inst_id"] if g2 else None) or s.perp_inst_id),
perp_inst_id=str((g2["perp_inst_id"] if g2 else None) or resolve_perp_inst_id(self.db, group_id=group_id)),
open_at_ms=int(g2["open_at_ms"]) if g2 and g2["open_at_ms"] else None,
local_net=float(net) if net is not None else None,
)
@@ -816,10 +842,7 @@ class BinanceLiveExecutor(Matcher):
err = self._guard_live()
if err:
return CloseResult(ok=False, detail=err)
if require_deep_otm and not self.option_is_deep_otm():
return CloseResult(ok=False, detail="期权非远虚,应走双腿全平")
s = get_settings()
pos = self.current_position()
st = str(pos.get("status") or "")
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
@@ -829,17 +852,57 @@ class BinanceLiveExecutor(Matcher):
return self.close_group(reason=reason, bypass_liquidity=True)
group_id = str(pos["group_id"])
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
opt_contracts = float(pos["option_qty_contracts"] or 0)
opt_qty = float(pos["option_qty_eth"])
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
perp_entry = float(pos["perp_entry_px"])
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
client = self._client()
# 优先尝试交易所平期权;成功则走双腿全平
try:
opt_live = client.place_option_market(
symbol=option_inst_id,
side="SELL",
quantity=max(1.0, opt_contracts),
reduce_only=True,
)
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
filled_c = float(opt_live.sz) if opt_live.sz and opt_live.sz > 0 else opt_contracts
opt_contracts = filled_c
opt_qty = eth_from_contracts(opt_contracts, self._ct_mult(option_inst_id))
of_notional = of_px * opt_qty
self._mark_option_closed_perp_pending(
group_id=group_id,
option_inst_id=option_inst_id,
opt_qty=opt_qty,
opt_contracts=opt_contracts,
of_px=of_px,
of_fee=of_fee,
of_notional=of_notional,
of_slip=0.0,
reason=reason,
)
return self.close_group(reason=reason, bypass_liquidity=True)
except Exception as e:
logger.warning("abandon: option exchange sell failed: %s", e)
if require_deep_otm and not self.option_is_deep_otm():
return CloseResult(
ok=False,
detail=f"期权平单失败且非远虚,应走双腿全平: {e}",
)
try:
if perp_side == "long":
side, pos_side = "SELL", "LONG"
else:
side, pos_side = "BUY", "SHORT"
perp_live = client.place_perp_market(
symbol=s.perp_inst_id,
symbol=perp_inst,
side=side,
qty_eth=perp_qty,
position_side=pos_side,
@@ -862,8 +925,6 @@ class BinanceLiveExecutor(Matcher):
note=f"LIVE-BN close perp abandon option {reason}",
)
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
strike = self._group_strike(group_id, option_inst_id)
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
@@ -893,7 +954,7 @@ class BinanceLiveExecutor(Matcher):
"perp",
"close",
"flat",
s.perp_inst_id,
perp_inst,
perp_qty,
None,
pf_px,
+6 -1
View File
@@ -219,12 +219,17 @@ class BinanceTradeClient:
sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity"))
st = str(q.get("status") or "").upper()
data = q
if avg and avg > 0 and st in ("FILLED", "PARTIALLY_FILLED"):
if avg and avg > 0 and st == "FILLED":
break
if st in ("CANCELED", "REJECTED", "EXPIRED"):
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
if st == "PARTIALLY_FILLED":
continue
if not avg or avg <= 0:
raise RuntimeError(f"币安期权无成交均价: {data}")
st_final = str(data.get("status") or "").upper()
if st_final and st_final != "FILLED":
raise RuntimeError(f"币安期权未完全成交 status={st_final} {data}")
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("fee")) or 0.0)
+1027 -993
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -72,15 +72,32 @@ def enrich_live_unrealized(
logger.warning("live unrealized exchange overlay failed: %s", e)
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
# 保守预估平仓费:按现有名义×费率×2 腿,避免「刚达标、扣费后不够」
try:
from ..config import get_settings
fr = float(get_settings().fee_rate or 0.0005)
except Exception:
fr = 0.0005
notional_est = abs(float(base.get("perp_notional") or 0.0)) + abs(
float(base.get("option_notional") or 0.0)
)
if notional_est <= 0:
# 兜底:用标记价粗算
notional_est = abs(float(base.get("spot") or 0.0)) * (
abs(float(base.get("perp_qty_eth") or 0.0))
+ abs(float(base.get("option_qty_eth") or 0.0))
)
est_close = max(0.0, notional_est * fr * 2.0)
# 资金费 signed:付出为负,直接加总
net_pnl = perp_upl + option_upl - fees_paid + funding
net_pnl = perp_upl + option_upl - fees_paid + funding - est_close
out = dict(base)
out["perp_upl"] = perp_upl
out["option_upl"] = option_upl
out["fees_paid"] = fees_paid
out["funding_usdt"] = funding
out["est_close_fees"] = 0.0 # LIVE 不估平仓费
out["est_close_fees"] = est_close
out["net_pnl"] = net_pnl
out["pnl_source"] = "live_exchange"
return out
+6 -8
View File
@@ -126,9 +126,7 @@ class OkxTradeClient:
if v and v > 0:
self._ct_val_cache[inst_id] = float(v)
return float(v)
default = 0.01
self._ct_val_cache[inst_id] = default
return default
raise RuntimeError(f"OKX 无法取得合约面值 ctVal: {inst_id} instType={inst_type}")
def place_market(
self,
@@ -159,7 +157,7 @@ class OkxTradeClient:
fill = self._wait_fill(inst_id, ord_id)
return fill
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 8) -> LiveFill:
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 20) -> LiveFill:
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
last: dict[str, Any] = {}
for _ in range(tries):
@@ -168,11 +166,11 @@ class OkxTradeClient:
last = rows[0]
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
if state in ("filled", "partially_filled") and avg and avg > 0:
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
if state == "filled" and avg and avg > 0:
sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0
fee = abs(safe_float(last.get("fee")) or 0.0)
fee_ccy = str(last.get("feeCcy") or "USDT")
# 订单上 fee 常为空,再查成交明细
if fee <= 0 and ord_id:
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
from .money import abs_fee_usdt
@@ -188,8 +186,8 @@ class OkxTradeClient:
)
if state in ("canceled", "failed"):
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.25)
raise RuntimeError(f"OKX 订单未成交 ordId={ord_id} last={last}")
time.sleep(0.3)
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
"""成交明细手续费合计(原币种金额, 币种)。"""
+26
View File
@@ -0,0 +1,26 @@
"""LIVE 下单用的运行时合约解析(禁止只用 env 默认 perp_inst_id)。"""
from __future__ import annotations
from ..config import Settings
from ..exchange.runtime import load_runtime_settings
def live_settings() -> Settings:
return load_runtime_settings()
def resolve_perp_inst_id(db, *, group_id: str | None = None) -> str:
"""优先组内落库合约,否则 DB/交易所默认(load_runtime_settings)。"""
s = live_settings()
if group_id:
try:
row = db.fetchone(
"SELECT perp_inst_id FROM groups WHERE group_id=?",
(group_id,),
)
if row and row["perp_inst_id"]:
return str(row["perp_inst_id"])
except Exception:
pass
return str(s.perp_inst_id)
+17 -3
View File
@@ -39,6 +39,17 @@ async def lifespan(app: FastAPI):
settings = load_runtime_settings()
engine = StrategyEngine()
set_engine(engine)
# LIVE:进程启动后不自动真下单,须人工点「启动」
if not get_settings().is_sim:
try:
db._conn.execute(
"UPDATE strategy_state SET running=0, phase=? WHERE id=1",
("paused",),
)
db._conn.commit()
logger.info("LIVE startup: forced strategy pause (manual start required)")
except Exception:
logger.exception("LIVE startup force-pause failed")
engine.ensure_loop()
session = bootstrap_session(settings)
@@ -74,14 +85,17 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="比特骆驼自动化对冲系统",
version="0.3.0",
version="0.3.1",
description="比特骆驼自动化对冲系统(eth_hedge_sim",
lifespan=lifespan,
docs_url=None if get_settings().disable_api_docs else "/docs",
redoc_url=None if get_settings().disable_api_docs else "/redoc",
openapi_url=None if get_settings().disable_api_docs else "/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=[],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
+2 -5
View File
@@ -6,11 +6,8 @@ from app.env_store import live_ready, mask_secret, okx_keys_configured
def test_mask_secret() -> None:
assert mask_secret(None) is None
assert mask_secret("") is None
assert mask_secret("abcd") == "****"
m = mask_secret("abcdefghij")
assert m is not None
assert m.endswith("ghij")
assert m.startswith("*")
assert mask_secret("abcd") == "********"
assert mask_secret("abcdefghij") == "********"
def test_live_ready_sim(monkeypatch) -> None:
+33
View File
@@ -0,0 +1,33 @@
"""LIVE 合约解析。"""
from app.live.symbols import resolve_perp_inst_id
class _FakeDb:
def __init__(self, row=None):
self._row = row
def fetchone(self, sql, args=()):
return self._row
def test_resolve_perp_prefers_group(monkeypatch) -> None:
import app.live.symbols as sym
class S:
perp_inst_id = "ETH-USDT-SWAP"
monkeypatch.setattr(sym, "live_settings", lambda: S())
db = _FakeDb({"perp_inst_id": "ETHUSDT"})
assert resolve_perp_inst_id(db, group_id="g1") == "ETHUSDT"
def test_resolve_perp_fallback_runtime(monkeypatch) -> None:
import app.live.symbols as sym
class S:
perp_inst_id = "ETHUSDT"
monkeypatch.setattr(sym, "live_settings", lambda: S())
db = _FakeDb(None)
assert resolve_perp_inst_id(db) == "ETHUSDT"
+51
View File
@@ -0,0 +1,51 @@
# 比特骆驼自动化对冲系统 — 更新说明
> 每次发版 / 合并重要改动后,在本文档顶部追加一条(日期新→旧)。
> 关联审计结论写在同条「审计」小节。
---
## 2026-07-26 — 审计修复包(安全 / 下单 / 平仓)
### 变更
1. **LIVE 永续合约号**:开平仓统一用 `load_runtime_settings()` / 组内 `perp_inst_id`,修复 UI 切币安后仍用 env `ETH-USDT-SWAP` 的问题。
2. **到期平仓**:期权先走交易所真实平仓(失败且仓位已空才本地结算),并在永续失败前写入 `option_closed_perp_pending`
3. **成交完整性**:OKX/币安仅接受完全成交;账本数量按交易所 `accFillSz`/`executedQty` 回写。
4. **`ctVal`**:查不到合约面值时直接失败,禁止静默默认 `0.01`
5. **放弃期权路径**:优先交易所卖期权;仅深虚且卖不出时才残留归档。
6. **LIVE 重启**:启动时若为 LIVE,自动暂停策略,须人工再点启动。
7. **安全**:登录限流;切 LIVE 须服务端校验确认串 `LIVE`;生产关闭 `/docs`;改密后抬升 token 版本作废旧票;`.env` 写入后尽量 `chmod 600`CORS 改为同源优先。
8. **出场**:LIVE 净利加入保守平仓手续费缓冲,减少「刚达标但扣费后不够」的提前出场。
### 审计(本包改后复审)
| 项 | 状态 |
|----|------|
| 永续符号 env/DB 不一致 | **已修**`symbols.resolve_perp_inst_id` + 开平仓全路径) |
| 到期只本地结算期权 | **已修**(先交易所卖;失败才本地结算) |
| 到期永续失败无 pending | **已修**(统一 `_mark_option_closed_perp_pending` |
| partially_filled 当完成 | **已修**OKX 仅 `filled`;币安仅 `FILLED` |
| 放弃路径不卖期权 | **已缓解**(先 `close_group` 双腿;失败且深虚才残留) |
| 开仓崩溃双开 | **部分**LIVE 启动强制 `running=0`;仍缺交易所对账 |
| ctVal 静默默认 | **已修**(查不到直接报错) |
| LIVE 出场不计平仓费 | **已修**(保守 `est_close_fees` |
| 登录无限流 / LIVE 可 API 绕过确认 | **已修**(限流 + `confirm_live_phrase=LIVE` |
| `/docs` 公开 / CORS `*` / mask 泄露后缀 | **已修** |
| 改密后旧 token | **已修**`AUTH_TOKEN_VERSION` |
| 默认口令 / 公网 TLS | **未改默认口令**(部署须改 `.env`);TLS 仍靠反代 |
| 杠杆 set_leverage | **未做** |
| 平仓张数读交易所持仓 | **未做** |
### 残留风险(下次优先)
1. 启动时交易所↔本地仓位对账与开仓幂等键
2. 平仓前查询交易所实际持仓张数
3. 开仓前 `set_leverage`
4. 可选:拒绝默认 `AUTH_*` 启动(硬开关)
### 测试
- `pytest`46 passed(含 `test_symbols`、脱敏用例更新)
---
+1
View File
@@ -210,6 +210,7 @@ export default function SettingsPage() {
const body: Record<string, unknown> = {
mode,
confirm_live: goingLive,
confirm_live_phrase: goingLive ? "LIVE" : undefined,
};
if (okxKey.trim()) body.okx_api_key = okxKey.trim();
if (okxSecret.trim()) body.okx_api_secret = okxSecret.trim();