Audit fixes: LIVE symbols/fills/expiry/pending, security harden, add 更新说明.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
- [商业化与授权方案](docs/商业化与授权方案.md)
|
||||
- [策略说明](docs/策略说明.md)
|
||||
- [实盘策略说明](docs/实盘策略说明.md)
|
||||
- [更新说明](docs/更新说明.md)(每次发版追加)
|
||||
|
||||
## 访问(测试机)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
"""成交明细手续费合计(原币种金额, 币种)。"""
|
||||
|
||||
@@ -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
@@ -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=["*"],
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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`、脱敏用例更新)
|
||||
|
||||
---
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user