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)
|
||||||
- [实盘策略说明](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]:
|
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
|
||||||
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
|
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"))
|
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||||
sig = hmac.new(
|
sig = hmac.new(
|
||||||
settings.auth_secret.encode("utf-8"),
|
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
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
|
||||||
if int(payload.get("exp") or 0) < int(time.time()):
|
if int(payload.get("exp") or 0) < int(time.time()):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
|
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 "")
|
username = str(payload.get("u") or "")
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
|
||||||
|
|||||||
@@ -1,28 +1,71 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Annotated
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..config import Settings, get_settings
|
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
|
from .auth import LoginRequest, LoginResponse, issue_token, require_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
_login_hits: dict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
class ChangeCredentialsRequest(BaseModel):
|
class ChangeCredentialsRequest(BaseModel):
|
||||||
current_password: str = Field(min_length=1)
|
current_password: str = Field(min_length=1)
|
||||||
new_username: str = Field(min_length=1, max_length=64)
|
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)
|
@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()
|
user, pwd = get_credentials()
|
||||||
if body.username != user or body.password != pwd:
|
user_ok = hmac.compare_digest(
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
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)
|
token, ttl = issue_token(body.username, settings)
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -34,7 +77,10 @@ async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_se
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@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 {
|
return {
|
||||||
"username": username,
|
"username": username,
|
||||||
"env_name": settings.env_name,
|
"env_name": settings.env_name,
|
||||||
@@ -50,17 +96,30 @@ async def change_credentials(
|
|||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
) -> LoginResponse:
|
) -> LoginResponse:
|
||||||
_cur_user, cur_pwd = get_credentials()
|
_cur_user, cur_pwd = get_credentials()
|
||||||
if body.current_password != cur_pwd:
|
if not hmac.compare_digest(
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
|
body.current_password.encode("utf-8"), cur_pwd.encode("utf-8")
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确"
|
||||||
|
)
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
raise HTTPException(
|
||||||
token, ttl = issue_token(body.new_username.strip(), settings)
|
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(
|
return LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
username=body.new_username.strip(),
|
username=body.new_username.strip(),
|
||||||
expires_in=ttl,
|
expires_in=ttl,
|
||||||
env_name=settings.env_name,
|
env_name=settings2.env_name,
|
||||||
mode=settings.mode,
|
mode=settings2.mode,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ async def put_strategy_settings(
|
|||||||
class RuntimeSettingsBody(BaseModel):
|
class RuntimeSettingsBody(BaseModel):
|
||||||
mode: Literal["SIM", "LIVE"] | None = None
|
mode: Literal["SIM", "LIVE"] | None = None
|
||||||
confirm_live: bool | None = False
|
confirm_live: bool | None = False
|
||||||
|
confirm_live_phrase: str | None = None
|
||||||
okx_api_key: str | None = None
|
okx_api_key: str | None = None
|
||||||
okx_api_secret: str | None = None
|
okx_api_secret: str | None = None
|
||||||
okx_api_passphrase: str | None = None
|
okx_api_passphrase: str | None = None
|
||||||
@@ -272,6 +273,12 @@ async def put_runtime_settings(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="切换到 LIVE 须二次确认(confirm_live=true)",
|
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] = {}
|
updates: dict[str, str] = {}
|
||||||
if body.okx_api_key is not None and body.okx_api_key.strip():
|
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_password: str = "admin123"
|
||||||
auth_secret: str = "change-me-eth-hedge-sim-secret"
|
auth_secret: str = "change-me-eth-hedge-sim-secret"
|
||||||
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
|
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_key: str = ""
|
||||||
okx_api_secret: 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:
|
def upsert_env_file(key: str, value: str) -> Path | None:
|
||||||
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
|
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
|
||||||
|
if "\n" in value or "\r" in value:
|
||||||
|
raise ValueError(f"{key} 值不能包含换行")
|
||||||
paths = _env_paths()
|
paths = _env_paths()
|
||||||
target = next((p for p in paths if p.is_file()), paths[0])
|
target = next((p for p in paths if p.is_file()), paths[0])
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
text = target.read_text(encoding="utf-8") if target.is_file() else ""
|
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)}=.*$")
|
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
|
||||||
if pattern.search(text):
|
if pattern.search(text):
|
||||||
text = pattern.sub(line, text)
|
text = pattern.sub(line, text)
|
||||||
@@ -51,6 +55,10 @@ def upsert_env_file(key: str, value: str) -> Path | None:
|
|||||||
text += "\n"
|
text += "\n"
|
||||||
text += line + "\n"
|
text += line + "\n"
|
||||||
target.write_text(text, encoding="utf-8")
|
target.write_text(text, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
target.chmod(0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,12 @@ def upsert_env_keys(updates: dict[str, str]) -> Path | None:
|
|||||||
return target
|
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()
|
s = (raw or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
return None
|
||||||
if len(s) <= keep:
|
return "********"
|
||||||
return "*" * len(s)
|
|
||||||
return "*" * max(4, len(s) - keep) + s[-keep:]
|
|
||||||
|
|
||||||
|
|
||||||
def okx_keys_configured(s=None) -> bool:
|
def okx_keys_configured(s=None) -> bool:
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import time
|
|||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..env_store import live_ready
|
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.matcher import CloseResult, Matcher, OpenResult
|
||||||
from ..sim.pricing import option_expiry_settle, option_intrinsic
|
from ..sim.pricing import option_expiry_settle, option_intrinsic
|
||||||
from ..strategy.session import get_session
|
from ..strategy.session import get_session
|
||||||
from .binance_trade import BinanceTradeClient
|
from .binance_trade import BinanceTradeClient
|
||||||
|
from .symbols import live_settings, resolve_perp_inst_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,9 +39,11 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
return base
|
return base
|
||||||
from .live_pnl import enrich_live_unrealized
|
from .live_pnl import enrich_live_unrealized
|
||||||
|
|
||||||
s = get_settings()
|
|
||||||
gid = base.get("group_id")
|
gid = base.get("group_id")
|
||||||
open_at = None
|
open_at = None
|
||||||
|
perp_inst = resolve_perp_inst_id(
|
||||||
|
self.db, group_id=str(gid) if gid else None
|
||||||
|
)
|
||||||
if gid:
|
if gid:
|
||||||
g = self.db.fetchone(
|
g = self.db.fetchone(
|
||||||
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
|
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
|
||||||
@@ -48,11 +51,8 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
if g:
|
if g:
|
||||||
open_at = int(g["open_at_ms"] or 0) or None
|
open_at = int(g["open_at_ms"] or 0) or None
|
||||||
perp_inst = str(g["perp_inst_id"] or s.perp_inst_id)
|
if g["perp_inst_id"]:
|
||||||
else:
|
perp_inst = str(g["perp_inst_id"])
|
||||||
perp_inst = s.perp_inst_id
|
|
||||||
else:
|
|
||||||
perp_inst = s.perp_inst_id
|
|
||||||
try:
|
try:
|
||||||
client = self._client()
|
client = self._client()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -83,7 +83,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
if err:
|
if err:
|
||||||
return OpenResult(ok=False, detail=err)
|
return OpenResult(ok=False, detail=err)
|
||||||
|
|
||||||
s = get_settings()
|
s = live_settings()
|
||||||
if self.has_open_position():
|
if self.has_open_position():
|
||||||
st = self.position_status()
|
st = self.position_status()
|
||||||
return OpenResult(
|
return OpenResult(
|
||||||
@@ -92,6 +92,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client = self._client()
|
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)
|
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)
|
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
|
||||||
ct_mult = self._ct_mult(option_inst_id)
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
@@ -107,6 +108,12 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
logger.exception("binance live open option failed")
|
logger.exception("binance live open option failed")
|
||||||
return OpenResult(ok=False, detail=f"币安开期权失败: {e}")
|
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:
|
try:
|
||||||
if perp_side == "long":
|
if perp_side == "long":
|
||||||
@@ -114,7 +121,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
else:
|
else:
|
||||||
side, pos_side = "SELL", "SHORT"
|
side, pos_side = "SELL", "SHORT"
|
||||||
perp_fill_live = client.place_perp_market(
|
perp_fill_live = client.place_perp_market(
|
||||||
symbol=s.perp_inst_id,
|
symbol=perp_inst,
|
||||||
side=side,
|
side=side,
|
||||||
qty_eth=perp_qty,
|
qty_eth=perp_qty,
|
||||||
position_side=pos_side,
|
position_side=pos_side,
|
||||||
@@ -159,6 +166,12 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
pf_px = float(perp_fill_live.avg_px)
|
pf_px = float(perp_fill_live.avg_px)
|
||||||
of_fee = float(opt_fill.fee)
|
of_fee = float(opt_fill.fee)
|
||||||
pf_fee = float(perp_fill_live.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
|
initial_premium = of_px * opt_qty
|
||||||
of_notional = of_px * opt_qty
|
of_notional = of_px * opt_qty
|
||||||
pf_notional = pf_px * perp_qty
|
pf_notional = pf_px * perp_qty
|
||||||
@@ -194,7 +207,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
option_side,
|
option_side,
|
||||||
perp_side,
|
perp_side,
|
||||||
option_inst_id,
|
option_inst_id,
|
||||||
s.perp_inst_id,
|
perp_inst,
|
||||||
strike,
|
strike,
|
||||||
expiry_ymd,
|
expiry_ymd,
|
||||||
entry_index_px,
|
entry_index_px,
|
||||||
@@ -235,7 +248,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
"perp",
|
"perp",
|
||||||
"open",
|
"open",
|
||||||
perp_side,
|
perp_side,
|
||||||
s.perp_inst_id,
|
perp_inst,
|
||||||
perp_qty,
|
perp_qty,
|
||||||
None,
|
None,
|
||||||
pf_px,
|
pf_px,
|
||||||
@@ -303,7 +316,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
detail: str,
|
detail: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。"""
|
"""期权已成交、永续未开且回滚失败 → 落 half_open,禁止新开,待 repair。"""
|
||||||
s = get_settings()
|
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||||
initial_premium = of_px * opt_qty
|
initial_premium = of_px * opt_qty
|
||||||
self.ledger.apply_cash(
|
self.ledger.apply_cash(
|
||||||
-(of_px * opt_qty + of_fee),
|
-(of_px * opt_qty + of_fee),
|
||||||
@@ -331,7 +344,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
option_side,
|
option_side,
|
||||||
perp_side,
|
perp_side,
|
||||||
option_inst_id,
|
option_inst_id,
|
||||||
s.perp_inst_id,
|
perp_inst,
|
||||||
strike,
|
strike,
|
||||||
expiry_ymd,
|
expiry_ymd,
|
||||||
entry_index_px,
|
entry_index_px,
|
||||||
@@ -475,7 +488,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
if err:
|
if err:
|
||||||
return CloseResult(ok=False, detail=err)
|
return CloseResult(ok=False, detail=err)
|
||||||
|
|
||||||
s = get_settings()
|
s = live_settings()
|
||||||
pos = self.current_position()
|
pos = self.current_position()
|
||||||
st = str(pos.get("status") or "")
|
st = str(pos.get("status") or "")
|
||||||
if st == "half_open":
|
if st == "half_open":
|
||||||
@@ -490,6 +503,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
opt_qty = float(pos["option_qty_eth"])
|
opt_qty = float(pos["option_qty_eth"])
|
||||||
perp_qty = float(pos["perp_qty_eth"])
|
perp_qty = float(pos["perp_qty_eth"])
|
||||||
opt_contracts = float(pos["option_qty_contracts"] or 0)
|
opt_contracts = float(pos["option_qty_contracts"] or 0)
|
||||||
|
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||||
client = self._client()
|
client = self._client()
|
||||||
is_expiry = reason == "expiry"
|
is_expiry = reason == "expiry"
|
||||||
fee_rate = self._fee_rate()
|
fee_rate = self._fee_rate()
|
||||||
@@ -527,34 +541,45 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
of_fee = float(prev["fee"] or 0)
|
of_fee = float(prev["fee"] or 0)
|
||||||
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
of_notional = float(prev["notional"] or (of_px * opt_qty))
|
||||||
of_slip = float(prev["slip"] or 0)
|
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:
|
else:
|
||||||
|
# 含到期:优先交易所真实平期权;失败且无内在价值时可本地结算
|
||||||
try:
|
try:
|
||||||
opt_live = client.place_option_market(
|
opt_live = client.place_option_market(
|
||||||
symbol=option_inst_id,
|
symbol=option_inst_id,
|
||||||
side="SELL",
|
side="SELL",
|
||||||
quantity=opt_contracts,
|
quantity=max(1.0, opt_contracts),
|
||||||
reduce_only=True,
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
of_px = float(opt_live.avg_px)
|
of_px = float(opt_live.avg_px)
|
||||||
of_fee = float(opt_live.fee)
|
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
|
of_notional = of_px * opt_qty
|
||||||
except Exception as e:
|
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(
|
return CloseResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
detail=f"币安平期权失败: {e}",
|
detail=f"币安平期权失败: {e}",
|
||||||
liquidity_wait=True,
|
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(
|
self._mark_option_closed_perp_pending(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
option_inst_id=option_inst_id,
|
option_inst_id=option_inst_id,
|
||||||
@@ -574,7 +599,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
else:
|
else:
|
||||||
side, pos_side = "BUY", "SHORT"
|
side, pos_side = "BUY", "SHORT"
|
||||||
perp_live = client.place_perp_market(
|
perp_live = client.place_perp_market(
|
||||||
symbol=s.perp_inst_id,
|
symbol=perp_inst,
|
||||||
side=side,
|
side=side,
|
||||||
qty_eth=perp_qty,
|
qty_eth=perp_qty,
|
||||||
position_side=pos_side,
|
position_side=pos_side,
|
||||||
@@ -681,7 +706,8 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
option_fill_already_written: bool,
|
option_fill_already_written: bool,
|
||||||
skip_option_cash: bool,
|
skip_option_cash: bool,
|
||||||
) -> CloseResult:
|
) -> 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_side = str(pos["perp_side"])
|
||||||
perp_qty = float(pos["perp_qty_eth"])
|
perp_qty = float(pos["perp_qty_eth"])
|
||||||
opt_entry = float(pos["option_entry_px"])
|
opt_entry = float(pos["option_entry_px"])
|
||||||
@@ -749,7 +775,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
"perp",
|
"perp",
|
||||||
"close",
|
"close",
|
||||||
"flat",
|
"flat",
|
||||||
s.perp_inst_id,
|
perp_inst,
|
||||||
perp_qty,
|
perp_qty,
|
||||||
None,
|
None,
|
||||||
pf_px,
|
pf_px,
|
||||||
@@ -793,7 +819,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
client=self._client(),
|
client=self._client(),
|
||||||
exchange="binance",
|
exchange="binance",
|
||||||
group_id=group_id,
|
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,
|
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,
|
local_net=float(net) if net is not None else None,
|
||||||
)
|
)
|
||||||
@@ -816,10 +842,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
err = self._guard_live()
|
err = self._guard_live()
|
||||||
if err:
|
if err:
|
||||||
return CloseResult(ok=False, detail=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()
|
pos = self.current_position()
|
||||||
st = str(pos.get("status") or "")
|
st = str(pos.get("status") or "")
|
||||||
if st not in ("open", "option_closed_perp_pending") or not pos.get("group_id"):
|
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)
|
return self.close_group(reason=reason, bypass_liquidity=True)
|
||||||
|
|
||||||
group_id = str(pos["group_id"])
|
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_side = str(pos["perp_side"])
|
||||||
perp_qty = float(pos["perp_qty_eth"])
|
perp_qty = float(pos["perp_qty_eth"])
|
||||||
perp_entry = float(pos["perp_entry_px"])
|
perp_entry = float(pos["perp_entry_px"])
|
||||||
|
perp_inst = resolve_perp_inst_id(self.db, group_id=group_id)
|
||||||
client = self._client()
|
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:
|
try:
|
||||||
if perp_side == "long":
|
if perp_side == "long":
|
||||||
side, pos_side = "SELL", "LONG"
|
side, pos_side = "SELL", "LONG"
|
||||||
else:
|
else:
|
||||||
side, pos_side = "BUY", "SHORT"
|
side, pos_side = "BUY", "SHORT"
|
||||||
perp_live = client.place_perp_market(
|
perp_live = client.place_perp_market(
|
||||||
symbol=s.perp_inst_id,
|
symbol=perp_inst,
|
||||||
side=side,
|
side=side,
|
||||||
qty_eth=perp_qty,
|
qty_eth=perp_qty,
|
||||||
position_side=pos_side,
|
position_side=pos_side,
|
||||||
@@ -862,8 +925,6 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
note=f"LIVE-BN close perp abandon option {reason}",
|
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)
|
strike = self._group_strike(group_id, option_inst_id)
|
||||||
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_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
|
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
|
||||||
@@ -893,7 +954,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
"perp",
|
"perp",
|
||||||
"close",
|
"close",
|
||||||
"flat",
|
"flat",
|
||||||
s.perp_inst_id,
|
perp_inst,
|
||||||
perp_qty,
|
perp_qty,
|
||||||
None,
|
None,
|
||||||
pf_px,
|
pf_px,
|
||||||
|
|||||||
@@ -219,12 +219,17 @@ class BinanceTradeClient:
|
|||||||
sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity"))
|
sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity"))
|
||||||
st = str(q.get("status") or "").upper()
|
st = str(q.get("status") or "").upper()
|
||||||
data = q
|
data = q
|
||||||
if avg and avg > 0 and st in ("FILLED", "PARTIALLY_FILLED"):
|
if avg and avg > 0 and st == "FILLED":
|
||||||
break
|
break
|
||||||
if st in ("CANCELED", "REJECTED", "EXPIRED"):
|
if st in ("CANCELED", "REJECTED", "EXPIRED"):
|
||||||
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
|
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
|
||||||
|
if st == "PARTIALLY_FILLED":
|
||||||
|
continue
|
||||||
if not avg or avg <= 0:
|
if not avg or avg <= 0:
|
||||||
raise RuntimeError(f"币安期权无成交均价: {data}")
|
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
|
from .money import abs_fee_usdt
|
||||||
|
|
||||||
fee = abs(safe_float(data.get("fee")) or 0.0)
|
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)
|
logger.warning("live unrealized exchange overlay failed: %s", e)
|
||||||
|
|
||||||
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
|
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:付出为负,直接加总
|
# 资金费 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 = dict(base)
|
||||||
out["perp_upl"] = perp_upl
|
out["perp_upl"] = perp_upl
|
||||||
out["option_upl"] = option_upl
|
out["option_upl"] = option_upl
|
||||||
out["fees_paid"] = fees_paid
|
out["fees_paid"] = fees_paid
|
||||||
out["funding_usdt"] = funding
|
out["funding_usdt"] = funding
|
||||||
out["est_close_fees"] = 0.0 # LIVE 不估平仓费
|
out["est_close_fees"] = est_close
|
||||||
out["net_pnl"] = net_pnl
|
out["net_pnl"] = net_pnl
|
||||||
out["pnl_source"] = "live_exchange"
|
out["pnl_source"] = "live_exchange"
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -126,9 +126,7 @@ class OkxTradeClient:
|
|||||||
if v and v > 0:
|
if v and v > 0:
|
||||||
self._ct_val_cache[inst_id] = float(v)
|
self._ct_val_cache[inst_id] = float(v)
|
||||||
return float(v)
|
return float(v)
|
||||||
default = 0.01
|
raise RuntimeError(f"OKX 无法取得合约面值 ctVal: {inst_id} instType={inst_type}")
|
||||||
self._ct_val_cache[inst_id] = default
|
|
||||||
return default
|
|
||||||
|
|
||||||
def place_market(
|
def place_market(
|
||||||
self,
|
self,
|
||||||
@@ -159,7 +157,7 @@ class OkxTradeClient:
|
|||||||
fill = self._wait_fill(inst_id, ord_id)
|
fill = self._wait_fill(inst_id, ord_id)
|
||||||
return fill
|
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}"
|
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
|
||||||
last: dict[str, Any] = {}
|
last: dict[str, Any] = {}
|
||||||
for _ in range(tries):
|
for _ in range(tries):
|
||||||
@@ -168,11 +166,11 @@ class OkxTradeClient:
|
|||||||
last = rows[0]
|
last = rows[0]
|
||||||
state = str(last.get("state") or "")
|
state = str(last.get("state") or "")
|
||||||
avg = safe_float(last.get("avgPx"))
|
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
|
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 = abs(safe_float(last.get("fee")) or 0.0)
|
||||||
fee_ccy = str(last.get("feeCcy") or "USDT")
|
fee_ccy = str(last.get("feeCcy") or "USDT")
|
||||||
# 订单上 fee 常为空,再查成交明细
|
|
||||||
if fee <= 0 and ord_id:
|
if fee <= 0 and ord_id:
|
||||||
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
|
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
|
||||||
from .money import abs_fee_usdt
|
from .money import abs_fee_usdt
|
||||||
@@ -188,8 +186,8 @@ class OkxTradeClient:
|
|||||||
)
|
)
|
||||||
if state in ("canceled", "failed"):
|
if state in ("canceled", "failed"):
|
||||||
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
||||||
time.sleep(0.25)
|
time.sleep(0.3)
|
||||||
raise RuntimeError(f"OKX 订单未成交 ordId={ord_id} last={last}")
|
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
|
||||||
|
|
||||||
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
|
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()
|
settings = load_runtime_settings()
|
||||||
engine = StrategyEngine()
|
engine = StrategyEngine()
|
||||||
set_engine(engine)
|
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()
|
engine.ensure_loop()
|
||||||
|
|
||||||
session = bootstrap_session(settings)
|
session = bootstrap_session(settings)
|
||||||
@@ -74,14 +85,17 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="比特骆驼自动化对冲系统",
|
title="比特骆驼自动化对冲系统",
|
||||||
version="0.3.0",
|
version="0.3.1",
|
||||||
description="比特骆驼自动化对冲系统(eth_hedge_sim)",
|
description="比特骆驼自动化对冲系统(eth_hedge_sim)",
|
||||||
lifespan=lifespan,
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=[],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ from app.env_store import live_ready, mask_secret, okx_keys_configured
|
|||||||
def test_mask_secret() -> None:
|
def test_mask_secret() -> None:
|
||||||
assert mask_secret(None) is None
|
assert mask_secret(None) is None
|
||||||
assert mask_secret("") is None
|
assert mask_secret("") is None
|
||||||
assert mask_secret("abcd") == "****"
|
assert mask_secret("abcd") == "********"
|
||||||
m = mask_secret("abcdefghij")
|
assert mask_secret("abcdefghij") == "********"
|
||||||
assert m is not None
|
|
||||||
assert m.endswith("ghij")
|
|
||||||
assert m.startswith("*")
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_ready_sim(monkeypatch) -> None:
|
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> = {
|
const body: Record<string, unknown> = {
|
||||||
mode,
|
mode,
|
||||||
confirm_live: goingLive,
|
confirm_live: goingLive,
|
||||||
|
confirm_live_phrase: goingLive ? "LIVE" : undefined,
|
||||||
};
|
};
|
||||||
if (okxKey.trim()) body.okx_api_key = okxKey.trim();
|
if (okxKey.trim()) body.okx_api_key = okxKey.trim();
|
||||||
if (okxSecret.trim()) body.okx_api_secret = okxSecret.trim();
|
if (okxSecret.trim()) body.okx_api_secret = okxSecret.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user