Compare commits
200 Commits
b0dca59681
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc74b8e913 | |||
| 3520fc0214 | |||
| 77d2effb6d | |||
| 3264dd4381 | |||
| 4a5d19e30f | |||
| 26e6d98338 | |||
| 3491681c28 | |||
| 8d67f3fc6c | |||
| 99e58910d3 | |||
| 4fe15c41fa | |||
| 50783e18d0 | |||
| 723c5fc1fc | |||
| 43e2df2b62 | |||
| 626c9c1323 | |||
| 4f56eff40c | |||
| 6f983ed2ab | |||
| 5ea54db43b | |||
| f1e3d5527a | |||
| 4db1e0c470 | |||
| b09d1b0886 | |||
| 7db8b724ac | |||
| ebd88718a7 | |||
| 58c12aa160 | |||
| d98bf46549 | |||
| 457838ef64 | |||
| 4585dba3c3 | |||
| bf3441537e | |||
| a88d708ea3 | |||
| b377956367 | |||
| 06f612aa52 | |||
| ab612df818 | |||
| 0daac05ef5 | |||
| 6d63c52ae0 | |||
| bd7640e36a | |||
| 3b5a8c1ff9 | |||
| 9f7b1b24e6 | |||
| 43a2a9634d | |||
| 0ac03cc726 | |||
| e7115e396a | |||
| 24a860ad3f | |||
| 24943adf13 | |||
| eaf94448a2 | |||
| 7d21bc26c7 | |||
| 0659611d82 | |||
| cd88fa5d83 | |||
| 8d3c181246 | |||
| 42e56d940c | |||
| ec244c63c6 | |||
| 15fe2f72dc | |||
| 04e5c11027 | |||
| 930e6c26c5 | |||
| 0f552eb50e | |||
| 3d1f9f3d50 | |||
| 0370e91d2f | |||
| 82f0c1c27c | |||
| 3312a8fb50 | |||
| 24f4dea798 | |||
| 68d02b9c17 | |||
| 52948c0a54 | |||
| b09bcd8414 | |||
| 44a545e6e4 | |||
| 1252f45aac | |||
| 09abb8d35b | |||
| a10ecf7409 | |||
| 5c334a4d89 | |||
| d97f7d20e5 | |||
| b983b63c46 | |||
| 3e71621702 | |||
| 5f1dce825b | |||
| 86955c3cf5 | |||
| c70a1fafd5 | |||
| 553435eb20 | |||
| a7aee8f425 | |||
| 92c2e89b0e | |||
| 4059d826e1 | |||
| 469e7a258a | |||
| 640ecc9530 | |||
| 62c91d4bd0 | |||
| 60b3743e8d | |||
| 51aef1c2fb | |||
| cd46d7748c | |||
| 921e96321f | |||
| 0c12d76212 | |||
| 8745d8e6d1 | |||
| 5982cb7414 | |||
| 62c9d2ce01 | |||
| 77e68dd0e9 | |||
| d210db19cf | |||
| ec3156021a | |||
| 46a861d311 | |||
| bc3041dcf2 | |||
| ead3545df8 | |||
| 9427d9c429 | |||
| 05520f82bd | |||
| cad86813dc | |||
| 987467a808 | |||
| 60a1e7ad48 | |||
| b76ef0d248 | |||
| 18fdfc6d0a | |||
| 63225f64de | |||
| 079dc83132 | |||
| 0e3b9e53ac | |||
| aab0b9bdf6 | |||
| 9c515644a3 | |||
| f1da2778ef | |||
| 22c42a19a0 | |||
| dffcc4eeb9 | |||
| dffcd77837 | |||
| 8ceb521049 | |||
| 021b0d0a22 | |||
| 2b7c504134 | |||
| c05478015d | |||
| 67f22626e9 | |||
| c5f477b8bc | |||
| be5803332f | |||
| 3c2589f653 | |||
| b3ba0fd431 | |||
| 7136adcaa9 | |||
| 3556450ff0 | |||
| e7529e9dc4 | |||
| d46eaf43ab | |||
| 8034464c2f | |||
| 7c87c42061 | |||
| 75e89ca442 | |||
| a317822f7a | |||
| 08fe06d074 | |||
| da5eb4c18c | |||
| 200702d066 | |||
| 4c537e7520 | |||
| cc0f0ffd0d | |||
| a9939bbbdd | |||
| 0837982714 | |||
| f0b0b91f1b | |||
| 5eb169a636 | |||
| a47a9b6377 | |||
| 3a4c8d639c | |||
| 0b51aa15ff | |||
| c425a08a8f | |||
| 467203b61d | |||
| 9ec0cacf45 | |||
| 17ba41923a | |||
| c41122ae71 | |||
| 101cb3c045 | |||
| e2a19a1614 | |||
| 44fd0371b9 | |||
| ec87cf2104 | |||
| c6e8f6fe1e | |||
| 50a4540aaf | |||
| 9b3693e542 | |||
| a668214e5d | |||
| b2e23e973f | |||
| e1da830eac | |||
| 0ee1d8be5d | |||
| 55109e82d3 | |||
| 71b5fcae79 | |||
| a5945f0837 | |||
| 6be46a1126 | |||
| 81e51236c0 | |||
| 80c561bdce | |||
| 6081fb7e48 | |||
| f49365b4d4 | |||
| 5c3bd4b654 | |||
| 1c75db4a19 | |||
| 8518a207a7 | |||
| 3df64aeb2d | |||
| 195e63fb2e | |||
| 1f6de95711 | |||
| 70a294c948 | |||
| 3d7c453bd1 | |||
| 14c6ba51a8 | |||
| 0e3a8b08ec | |||
| ed96b3ffb0 | |||
| bc0be9ce1a | |||
| b4f709024f | |||
| 60112bf8eb | |||
| 56a4007130 | |||
| 4d331da962 | |||
| 2b348854fe | |||
| 9d3b36826c | |||
| 16efa44ffb | |||
| 0cf3756b09 | |||
| f48ea5bbcc | |||
| bc8d1fb127 | |||
| bffbdafbfc | |||
| 8813fd3e37 | |||
| 86d3c94f32 | |||
| cb97a45051 | |||
| aca9d80e64 | |||
| ca66c494e7 | |||
| b7e055e37b | |||
| dbc86a1ce6 | |||
| e666230d0b | |||
| c2113b1a57 | |||
| 5369b4b04f | |||
| 38ffbf728b | |||
| 4994aaab16 | |||
| 96e8e5cb70 | |||
| 21b8f587a2 | |||
| e3671d9798 | |||
| a58d97938c |
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: 改完必推送部署;用户说「先聊」则只讨论不改代码
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 交付与对话模式(硬指令)
|
||||
|
||||
## 先聊模式(最高优先)
|
||||
|
||||
用户消息含 **「先聊」**(或明确说只讨论、不要改代码、先别动手)时:
|
||||
|
||||
- **禁止**改代码、写文件、提交、推送、部署
|
||||
- 只做讨论、方案对比、审计结论、问答
|
||||
- 用户之后明确说「改吧 / 实现 / 动手」再进入改代码
|
||||
|
||||
## 改完必推送 + 部署
|
||||
|
||||
在**本仓库**完成一轮实际代码修改后(用户未说「先聊」、且不是纯问答),必须收尾:
|
||||
|
||||
1. **提交**:有改动则 `git add` 相关文件并 `git commit`(勿提交 `.env`、密钥、本地临时脚本如 `scripts/_ssh_*`、`_t.py`)
|
||||
2. **推送**:`git push origin HEAD`(或当前跟踪分支)
|
||||
3. **部署**:用本机脚本更新服务器(git pull + 构建 + pm2),例如:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING='utf-8'
|
||||
# DEPLOY_PASS 须已在环境中配置;勿把密码写进规则或提交到仓库
|
||||
& .\.venv\Scripts\python.exe .\scripts\deploy_remote.py
|
||||
```
|
||||
|
||||
- 服务器:`47.236.184.99`,目录 `/opt/eth_hedge_sim`,进程 `eth-hedge-api`
|
||||
- 部署成功后简要回报:commit、push、health 是否 OK
|
||||
- 若用户明确说「先别部署 / 只提交不推 / 只推不部署」,则按用户当次要求覆盖本条
|
||||
|
||||
## 例外(可不推送部署)
|
||||
|
||||
- 「先聊」或只读审计、解释、方案讨论
|
||||
- 仅改本地未跟踪的临时探测脚本且用户未要求交付
|
||||
- 推送/部署失败时说明原因与已完成步骤,不要假装成功
|
||||
@@ -0,0 +1,15 @@
|
||||
# 比特骆驼中控(本地服务器)
|
||||
# 一键部署会自动补全缺失项;已有非空值不会覆盖
|
||||
CONTROL_AUTH_USERNAME=admin
|
||||
CONTROL_AUTH_PASSWORD=admin123
|
||||
CONTROL_AUTH_SECRET=change-me-control-secret-please
|
||||
CONTROL_AUTH_TOKEN_VERSION=1
|
||||
CONTROL_TOKEN_TTL_SEC=604800
|
||||
CONTROL_POLL_INTERVAL_SEC=8
|
||||
# 监控区 SSE 推送间隔(秒)与单机探测超时
|
||||
CONTROL_SSE_INTERVAL_SEC=1
|
||||
CONTROL_SSE_PROBE_TIMEOUT_SEC=2.5
|
||||
CONTROL_HTTP_TIMEOUT_SEC=12
|
||||
# 局域网免登录:1=开启(仅私网 IP),0=关闭
|
||||
CONTROL_LAN_AUTH_BYPASS=0
|
||||
# CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db
|
||||
+14
-3
@@ -1,6 +1,7 @@
|
||||
# eth_hedge_sim — 独立自动对冲模拟盘
|
||||
# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。
|
||||
# eth_hedge_sim — 独立自动对冲(SIM 本地撮合 / LIVE 实盘)
|
||||
# 复制为 .env 后按需填写。真密钥不上库。
|
||||
|
||||
# SIM | LIVE(设置页可改;切 LIVE 须二次确认)
|
||||
MODE=SIM
|
||||
ENV_NAME=test
|
||||
TZ=Asia/Shanghai
|
||||
@@ -17,7 +18,7 @@ AUTH_PASSWORD=admin123
|
||||
AUTH_SECRET=change-me-eth-hedge-sim-secret
|
||||
AUTH_TOKEN_TTL_SEC=604800
|
||||
|
||||
# OKX(SIM 阶段公共盘口可不填 Key)
|
||||
# OKX(SIM 公共盘口可不填;LIVE 下单必填)
|
||||
OKX_API_KEY=
|
||||
OKX_API_SECRET=
|
||||
OKX_API_PASSPHRASE=
|
||||
@@ -26,6 +27,10 @@ OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public
|
||||
# 云上一般直连留空;本机受限时再填代理
|
||||
OKX_HTTP_PROXY=
|
||||
|
||||
# 币安私有交易密钥(可落盘;实盘下单后续接入)
|
||||
BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
|
||||
# 币安公共行情(SIM)
|
||||
BINANCE_FAPI_BASE=https://fapi.binance.com
|
||||
BINANCE_EAPI_BASE=https://eapi.binance.com
|
||||
@@ -50,3 +55,9 @@ CLOSE_BID_MARK_MAX_PCT=30
|
||||
REST_SECONDS=300
|
||||
PERP_QTY_ETH=1
|
||||
OPTION_QTY_ETH=2
|
||||
|
||||
# 企业微信群机器人(系统设置页可改)
|
||||
WECOM_ENABLED=0
|
||||
WECOM_WEBHOOK_URL=
|
||||
# 推送标题前缀,多机时区分,如 云A / 云B
|
||||
WECOM_MACHINE_NAME=
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Shell scripts must use LF on Ubuntu deploy hosts
|
||||
*.sh text eol=lf
|
||||
deploy/**/*.sh text eol=lf
|
||||
@@ -1,6 +1,7 @@
|
||||
# env / secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.control
|
||||
*.pem
|
||||
|
||||
# python
|
||||
@@ -18,6 +19,10 @@ backend/data/*.db
|
||||
backend/data/*.sqlite
|
||||
backend/data/*.sqlite3
|
||||
!backend/data/.gitkeep
|
||||
control/data/*
|
||||
!control/data/.gitkeep
|
||||
control/frontend/node_modules/
|
||||
control/frontend/dist/
|
||||
|
||||
# frontend
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
# eth_hedge_sim
|
||||
# 比特骆驼自动化对冲系统(eth_hedge_sim)
|
||||
|
||||
独立自动对冲**模拟盘**:OKX 实盘只读行情 + 本地虚拟资金撮合。
|
||||
**产品名:比特骆驼自动化对冲系统**
|
||||
工程仓库 / 目录名:`eth_hedge_sim`(与产品名分离,部署路径不变)。
|
||||
|
||||
独立自动对冲系统:OKX / 币安行情 + 本地 SIM 撮合,可选 LIVE 实盘。
|
||||
与现网 `crypto_monitor` **无代码、进程、密钥共用**。
|
||||
|
||||
仓库:<https://git.bz121.com/dekun/eth_hedge_sim.git>
|
||||
|
||||
## 运行环境
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 操作系统 | **Ubuntu 22.04** LTS |
|
||||
| 进程管理 | PM2(`eth-hedge-api`) |
|
||||
| 安装目录 | `/opt/eth_hedge_sim` |
|
||||
| 端口 | **5155** |
|
||||
| 云服务器(推荐) | **2 vCPU / 2 GB / 40 GB SSD**;地域优先香港、新加坡等低延迟区 |
|
||||
|
||||
云服务器最低/推荐/余量规格、地域与带宽选择见 **[开发方案 §6.0 云服务器配置选择说明](docs/开发方案.md#60-云服务器配置选择说明)**。
|
||||
|
||||
## 文档
|
||||
|
||||
- [开发方案](docs/开发方案.md)
|
||||
- [代码结构](docs/代码结构.md)
|
||||
- [商业化与授权方案](docs/商业化与授权方案.md)
|
||||
- [策略说明](docs/策略说明.md)
|
||||
- [实盘策略说明](docs/实盘策略说明.md)
|
||||
- [中控 Fleet 说明](docs/中控Fleet说明.md)(本地中控:多机监控 / 启停 / 更新 / 免密登录)
|
||||
- [更新说明](docs/更新说明.md)(每次发版追加)
|
||||
|
||||
## 访问(测试机)
|
||||
|
||||
- 反代:[`https://dc.hyf2.cc`](https://dc.hyf2.cc) → 本机 `5155`(PM2: `eth-hedge-api`)
|
||||
- 默认登录:见服务器 `/opt/eth_hedge_sim/.env` 的 `AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123`)
|
||||
- 登录账号:见服务器 `/opt/eth_hedge_sim/.env` 的 `AUTH_USERNAME` / `AUTH_PASSWORD`
|
||||
- 可在「系统设置」修改用户名/密码;前端固定同源 API,无 API 地址配置项
|
||||
- **本地中控**:`http://<局域网IP>:5160`(PM2: `eth-hedge-control`);说明见 [中控 Fleet 说明](docs/中控Fleet说明.md)
|
||||
|
||||
## 一键部署 / 更新(禁止 scp 传代码)
|
||||
|
||||
服务器目录:`/opt/eth_hedge_sim`
|
||||
目标机:**Ubuntu 22.04**,目录 `/opt/eth_hedge_sim`。
|
||||
更新方式:**只允许 `git pull`**,然后构建并 `pm2 startOrReload` 本项目进程。
|
||||
|
||||
### 推荐:curl 一键管理器(菜单)
|
||||
@@ -31,9 +50,11 @@ curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/mana
|
||||
|
||||
菜单:
|
||||
|
||||
1. 一键部署
|
||||
2. 一键卸载(仅停删 `eth-hedge-api`,移走目录并备份 `.env`)
|
||||
3. 更新(`git pull` + 构建 + reload)
|
||||
1. 一键部署策略机
|
||||
2. 一键部署中控机
|
||||
3. 一键卸载
|
||||
4. 更新策略机
|
||||
5. 更新中控机
|
||||
0. 退出
|
||||
|
||||
已安装后也可:
|
||||
@@ -42,30 +63,34 @@ curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/mana
|
||||
bash /opt/eth_hedge_sim/deploy/manage.sh
|
||||
```
|
||||
|
||||
### 本机触发远程更新
|
||||
### 从开发机触发远程更新(可选)
|
||||
|
||||
在能 SSH 到 Ubuntu 服务器的机器上:
|
||||
|
||||
```bash
|
||||
pip install paramiko
|
||||
set DEPLOY_PASS=*** # Windows PowerShell: $env:DEPLOY_PASS='***'
|
||||
python scripts/deploy_remote.py
|
||||
export DEPLOY_PASS='***'
|
||||
python scripts/deploy_remote.py # 策略机
|
||||
# python scripts/deploy_control.py # 中控(需 CONTROL_HOST / CONTROL_PASS)
|
||||
```
|
||||
|
||||
PM2 进程名:`eth-hedge-api`(端口 **5155**)。禁止 `pm2 restart all`。
|
||||
PM2 进程名:`eth-hedge-api`(端口 **5155**)、`eth-hedge-control`(端口 **5160**)。禁止 `pm2 restart all`。
|
||||
|
||||
## 本地开发
|
||||
## 服务器上本地开发 / 调试(Ubuntu 22.04)
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
cd /opt/eth_hedge_sim
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
copy .env.example .env
|
||||
cp .env.example .env
|
||||
|
||||
python scripts/smoke_market.py
|
||||
cd backend
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 5155 --reload
|
||||
|
||||
# 另开终端
|
||||
cd frontend
|
||||
cd /opt/eth_hedge_sim/frontend
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
@@ -76,3 +101,4 @@ npm run dev
|
||||
- 永续市价;期权只吃买卖一;滑点 = 1×手续费
|
||||
- 仓位:永续 1 ETH,期权 2 ETH 名义
|
||||
- 部署更新只用 git pull,不用 scp
|
||||
- 部署环境固定为 Ubuntu 22.04
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth_routes import router as auth_router
|
||||
from .backup_routes import router as backup_router
|
||||
from .fleet import router as fleet_router
|
||||
from .funds import router as funds_router
|
||||
from .market import router as market_router
|
||||
from .plan import router as plan_router
|
||||
from .settings import router as settings_router
|
||||
@@ -15,4 +18,7 @@ router.include_router(sim_router)
|
||||
router.include_router(plan_router)
|
||||
router.include_router(trades_router)
|
||||
router.include_router(stats_router)
|
||||
router.include_router(funds_router)
|
||||
router.include_router(settings_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(fleet_router)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+122
-14
@@ -1,28 +1,93 @@
|
||||
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
|
||||
from .fleet import consume_login_ticket
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class FleetExchangeRequest(BaseModel):
|
||||
ticket: str = Field(min_length=8, max_length=256)
|
||||
|
||||
_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("/fleet-exchange", response_model=LoginResponse)
|
||||
async def fleet_exchange(
|
||||
body: FleetExchangeRequest,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> LoginResponse:
|
||||
"""中控签发的一次性 ticket 兑换为普通登录会话(免密)。"""
|
||||
username = consume_login_ticket(body.ticket)
|
||||
token, ttl = issue_token(username, settings)
|
||||
return LoginResponse(
|
||||
token=token,
|
||||
username=username,
|
||||
expires_in=ttl,
|
||||
env_name=settings.env_name,
|
||||
mode=settings.mode,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
@@ -33,16 +98,46 @@ async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_se
|
||||
)
|
||||
|
||||
|
||||
@router.get("/branding")
|
||||
async def branding() -> dict:
|
||||
"""登录页/顶栏展示用(无敏感信息):与企微通知同一机器名。"""
|
||||
from ..notify import wecom
|
||||
|
||||
return {"machine_name": wecom.wecom_machine_name() or ""}
|
||||
|
||||
|
||||
@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:
|
||||
from ..notify import wecom
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"env_name": settings.env_name,
|
||||
"mode": settings.mode,
|
||||
"sim": settings.is_sim,
|
||||
"machine_name": wecom.wecom_machine_name() or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh_token(
|
||||
username: Annotated[str, Depends(require_user)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> LoginResponse:
|
||||
"""用仍有效的 Bearer 换发新 HMAC token(自动轮换,无需重登)。"""
|
||||
token, ttl = issue_token(username, settings)
|
||||
return LoginResponse(
|
||||
token=token,
|
||||
username=username,
|
||||
expires_in=ttl,
|
||||
env_name=settings.env_name,
|
||||
mode=settings.mode,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-credentials", response_model=LoginResponse)
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsRequest,
|
||||
@@ -50,17 +145,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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""备份下载 / 上传恢复 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..backup import (
|
||||
backup_settings,
|
||||
create_backup,
|
||||
list_backups,
|
||||
prune_backups,
|
||||
read_backup_file,
|
||||
restore_from_zip,
|
||||
validate_backup_zip,
|
||||
)
|
||||
from ..models.db import get_db
|
||||
from ..strategy import get_engine
|
||||
from .auth import require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/backup", tags=["backup"])
|
||||
|
||||
|
||||
class BackupSettingsBody(BaseModel):
|
||||
auto_enabled: bool | None = None
|
||||
interval_hours: int | None = Field(default=None, ge=1, le=168)
|
||||
keep_count: int | None = Field(default=None, ge=1, le=90)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def backup_status(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
cfg = backup_settings()
|
||||
return {
|
||||
**cfg,
|
||||
"items": list_backups(),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def put_backup_settings(
|
||||
body: BackupSettingsBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
db = get_db()
|
||||
data = body.model_dump(exclude_none=True)
|
||||
if "auto_enabled" in data:
|
||||
db.set_setting("backup_auto_enabled", "1" if data["auto_enabled"] else "0")
|
||||
if "interval_hours" in data:
|
||||
db.set_setting("backup_interval_hours", str(int(data["interval_hours"])))
|
||||
if "keep_count" in data:
|
||||
db.set_setting("backup_keep_count", str(int(data["keep_count"])))
|
||||
prune_backups(int(data["keep_count"]))
|
||||
return await backup_status(_user)
|
||||
|
||||
|
||||
@router.post("/now")
|
||||
async def backup_now(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
try:
|
||||
return await asyncio.to_thread(create_backup, reason="manual")
|
||||
except Exception as e:
|
||||
logger.exception("backup now failed")
|
||||
raise HTTPException(status_code=500, detail=f"备份失败: {e}") from e
|
||||
|
||||
|
||||
@router.get("/download/{name}")
|
||||
async def download_backup(
|
||||
name: str,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> FileResponse:
|
||||
try:
|
||||
path = read_backup_file(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from ..backup import materialize_download_zip
|
||||
|
||||
# 下载包剥离 .env,机内完整备份仍保留供恢复
|
||||
safe = materialize_download_zip(path)
|
||||
return FileResponse(
|
||||
safe,
|
||||
media_type="application/zip",
|
||||
filename=path.name.replace(".zip", "_noenv.zip"),
|
||||
background=BackgroundTask(lambda: safe.unlink(missing_ok=True)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_backup(
|
||||
request: Request,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
x_confirm_phrase: Annotated[str, Header(alias="X-Confirm-Phrase")] = "",
|
||||
) -> dict:
|
||||
"""上传 zip 原始字节恢复数据库与 .env(新服务器迁移)。成功后进程退出由 PM2 拉起。"""
|
||||
if (x_confirm_phrase or "").strip() != "RESTORE":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请输入确认串 RESTORE(大写)后再恢复(请求头 X-Confirm-Phrase)",
|
||||
)
|
||||
|
||||
try:
|
||||
eng = get_engine()
|
||||
if eng.matcher.has_open_position():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="有未平仓,请先平仓或紧急全平后再恢复备份",
|
||||
)
|
||||
await eng.pause()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("pause before restore: %s", e)
|
||||
|
||||
raw = await request.body()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="请上传 .zip 备份包")
|
||||
if len(raw) > 80 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="备份包过大(上限 80MB)")
|
||||
if raw[:2] != b"PK":
|
||||
raise HTTPException(status_code=400, detail="文件不是有效的 zip 备份包")
|
||||
|
||||
tmp = Path(tempfile.mkstemp(prefix="restore_", suffix=".zip")[1])
|
||||
try:
|
||||
tmp.write_bytes(raw)
|
||||
try:
|
||||
validate_backup_zip(tmp)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
result = await asyncio.to_thread(restore_from_zip, tmp, make_safety_backup=True)
|
||||
|
||||
async def _exit_soon() -> None:
|
||||
await asyncio.sleep(1.2)
|
||||
logger.warning("exiting after backup restore for process restart")
|
||||
os._exit(0)
|
||||
|
||||
asyncio.create_task(_exit_soon())
|
||||
return {
|
||||
**result,
|
||||
"detail": "恢复成功,服务即将自动重启以加载 .env 与数据库;请数秒后刷新页面",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("restore failed")
|
||||
raise HTTPException(status_code=500, detail=f"恢复失败: {e}") from e
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,520 @@
|
||||
"""中控(Fleet)专用 API:X-Fleet-Token 鉴权,不开放资金/下单。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..credentials import get_credentials
|
||||
from ..models.db import get_db
|
||||
from ..strategy import get_engine
|
||||
from .auth import require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/fleet", tags=["fleet"])
|
||||
|
||||
_SETTING_HASH = "fleet_api_token_hash"
|
||||
_TICKET_TTL_SEC = 60
|
||||
_tickets: dict[str, dict] = {}
|
||||
_tickets_lock = threading.Lock()
|
||||
_update_lock = threading.Lock()
|
||||
_update_state: dict = {"running": False, "started_at_ms": 0, "last_error": ""}
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fleet_token_configured(db=None) -> bool:
|
||||
db = db or get_db()
|
||||
h = (db.get_setting(_SETTING_HASH, "") or "").strip()
|
||||
return bool(h)
|
||||
|
||||
|
||||
def set_fleet_token(plain: str, db=None) -> None:
|
||||
db = db or get_db()
|
||||
plain = (plain or "").strip()
|
||||
if not plain:
|
||||
db.set_setting(_SETTING_HASH, "")
|
||||
return
|
||||
if len(plain) < 16:
|
||||
raise ValueError("中控 API Token 至少 16 位")
|
||||
db.set_setting(_SETTING_HASH, _hash_token(plain))
|
||||
|
||||
|
||||
def clear_fleet_token(db=None) -> None:
|
||||
set_fleet_token("", db)
|
||||
|
||||
|
||||
def require_fleet_token(
|
||||
x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> str:
|
||||
db = get_db()
|
||||
stored = (db.get_setting(_SETTING_HASH, "") or "").strip()
|
||||
if not stored:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="策略机未配置中控 API Token",
|
||||
)
|
||||
provided = (x_fleet_token or "").strip()
|
||||
if not provided and authorization:
|
||||
auth = authorization.strip()
|
||||
if auth.lower().startswith("fleet "):
|
||||
provided = auth[6:].strip()
|
||||
if not provided or not hmac.compare_digest(stored, _hash_token(provided)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid fleet token",
|
||||
)
|
||||
return provided
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _purge_tickets() -> None:
|
||||
now = time.time()
|
||||
dead = [k for k, v in _tickets.items() if float(v.get("exp", 0)) < now]
|
||||
for k in dead:
|
||||
_tickets.pop(k, None)
|
||||
|
||||
|
||||
def create_login_ticket(username: str) -> tuple[str, int]:
|
||||
with _tickets_lock:
|
||||
_purge_tickets()
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
_tickets[ticket] = {"exp": time.time() + _TICKET_TTL_SEC, "u": username}
|
||||
return ticket, _TICKET_TTL_SEC
|
||||
|
||||
|
||||
def consume_login_ticket(ticket: str) -> str:
|
||||
ticket = (ticket or "").strip()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=401, detail="invalid ticket")
|
||||
with _tickets_lock:
|
||||
_purge_tickets()
|
||||
meta = _tickets.pop(ticket, None)
|
||||
if not meta:
|
||||
raise HTTPException(status_code=401, detail="ticket invalid or used")
|
||||
if float(meta.get("exp", 0)) < time.time():
|
||||
raise HTTPException(status_code=401, detail="ticket expired")
|
||||
username = str(meta.get("u") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="invalid ticket")
|
||||
return username
|
||||
|
||||
|
||||
class FleetTokenBody(BaseModel):
|
||||
token: str = Field(default="", max_length=256)
|
||||
|
||||
|
||||
@router.get("/meta")
|
||||
async def fleet_meta(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return {
|
||||
"configured": fleet_token_configured(),
|
||||
"hint": "在中控生成 Token 后粘贴到此保存;用于远程启停、更新与免密登录。",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/token")
|
||||
async def put_fleet_token(
|
||||
body: FleetTokenBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
try:
|
||||
set_fleet_token(body.token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"ok": True, "configured": fleet_token_configured()}
|
||||
|
||||
|
||||
@router.delete("/token")
|
||||
async def delete_fleet_token(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
clear_fleet_token()
|
||||
return {"ok": True, "configured": False}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
settings = get_settings()
|
||||
try:
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
from ..strategy.session import get_session
|
||||
|
||||
rt = load_runtime_settings()
|
||||
exchange_name = rt.exchange
|
||||
sess = get_session()
|
||||
snap = sess.snapshot() if sess else None
|
||||
except Exception:
|
||||
exchange_name = settings.exchange
|
||||
snap = None
|
||||
try:
|
||||
st = get_engine().state()
|
||||
except Exception:
|
||||
st = {}
|
||||
pos = st.get("position") if isinstance(st.get("position"), dict) else {}
|
||||
hedge_mode = str(
|
||||
pos.get("hedge_mode")
|
||||
or st.get("hedge_mode")
|
||||
or ""
|
||||
).strip().lower()
|
||||
is_oo = hedge_mode == "option_option" or bool(pos.get("option2_inst_id"))
|
||||
legs: list[dict] = []
|
||||
if pos.get("has_position") or str(pos.get("status") or "") in (
|
||||
"open",
|
||||
"half_open",
|
||||
"option_closed_perp_pending",
|
||||
"opening",
|
||||
):
|
||||
# 期期无永续腿;有 perp 才展示
|
||||
if not is_oo and (pos.get("perp_side") or pos.get("perp_inst_id")):
|
||||
legs.append(
|
||||
{
|
||||
"kind": "perp",
|
||||
"side": pos.get("perp_side"),
|
||||
"inst_id": pos.get("perp_inst_id"),
|
||||
"qty": pos.get("perp_qty_eth"),
|
||||
"avg_px": pos.get("perp_entry_px"),
|
||||
"mark_px": pos.get("perp_mark_px"),
|
||||
"upl": pos.get("perp_upl"),
|
||||
"margin": pos.get("perp_margin"),
|
||||
"premium": None,
|
||||
}
|
||||
)
|
||||
if pos.get("option_side") or pos.get("option_inst_id"):
|
||||
legs.append(
|
||||
{
|
||||
"kind": "option",
|
||||
"side": pos.get("option_side") or ("call" if is_oo else None),
|
||||
"inst_id": pos.get("option_inst_id"),
|
||||
"qty": pos.get("option_qty_eth"),
|
||||
"avg_px": pos.get("option_entry_px"),
|
||||
"mark_px": pos.get("option_mark_px"),
|
||||
"upl": pos.get("option_upl"),
|
||||
"margin": None,
|
||||
"premium": pos.get("initial_premium"),
|
||||
"strike": pos.get("strike"),
|
||||
}
|
||||
)
|
||||
if pos.get("option2_inst_id") or pos.get("option2_side"):
|
||||
legs.append(
|
||||
{
|
||||
"kind": "option",
|
||||
"side": pos.get("option2_side") or "put",
|
||||
"inst_id": pos.get("option2_inst_id"),
|
||||
"qty": pos.get("option2_qty_eth"),
|
||||
"avg_px": pos.get("option2_entry_px"),
|
||||
"mark_px": pos.get("option2_mark_px"),
|
||||
"upl": pos.get("option2_upl"),
|
||||
"margin": None,
|
||||
"premium": pos.get("initial_premium2"),
|
||||
"strike": pos.get("strike2"),
|
||||
}
|
||||
)
|
||||
|
||||
# 风控展示字段以引擎 state 为准;缺省时回落 settings 表(避免旧进程漏字段)
|
||||
db = get_db()
|
||||
|
||||
def _sf(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(db.get_setting(key, str(default)) or default)
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
def _pick(key: str, default: float | None = None):
|
||||
if key in st and st.get(key) is not None:
|
||||
return st.get(key)
|
||||
if default is None:
|
||||
return None
|
||||
return _sf(key, default)
|
||||
|
||||
latest_funds = 0.0
|
||||
try:
|
||||
from ..sim.funds_wallets import FundsWallets
|
||||
|
||||
latest_funds = float(FundsWallets(db).total_usdt_equiv())
|
||||
except Exception:
|
||||
try:
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
latest_funds = float(Ledger(db).snapshot().get("equity") or 0)
|
||||
except Exception:
|
||||
latest_funds = 0.0
|
||||
|
||||
residuals: list[dict] = []
|
||||
try:
|
||||
residuals = get_engine().matcher.list_residual_options_enriched()
|
||||
except Exception:
|
||||
residuals = []
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": settings.mode,
|
||||
"env_name": settings.env_name,
|
||||
"exchange": exchange_name,
|
||||
"sim": settings.is_sim,
|
||||
"latest_funds": latest_funds,
|
||||
"residuals": residuals,
|
||||
"market_connected": bool(snap.connected) if snap else False,
|
||||
"pair": snap.pair.to_dict() if snap and snap.pair else None,
|
||||
"index_px": (
|
||||
pos.get("index_px")
|
||||
if pos.get("index_px") is not None
|
||||
else (getattr(snap, "index_px", None) if snap else None)
|
||||
),
|
||||
"updated_at_ms": snap.updated_at_ms if snap else None,
|
||||
"strategy": {
|
||||
"running": st.get("running"),
|
||||
"phase": st.get("phase"),
|
||||
"rounds_done": st.get("rounds_done"),
|
||||
"last_error": st.get("last_error"),
|
||||
"group_id": pos.get("group_id"),
|
||||
"rest_left_sec": st.get("rest_left_sec"),
|
||||
"exit_mode": st.get("exit_mode"),
|
||||
"exit_target_usdt": st.get("exit_target_usdt"),
|
||||
"net_profit_target": st.get("net_profit_target"),
|
||||
"premium_exit_multiple": _pick("premium_exit_multiple"),
|
||||
"semi_auto_enabled": st.get("semi_auto_enabled"),
|
||||
"semi_armed": st.get("semi_armed"),
|
||||
"semi_view_side": st.get("semi_view_side"),
|
||||
"semi_option_move_points": st.get("semi_option_move_points"),
|
||||
"semi_perp_exit_unit": st.get("semi_perp_exit_unit"),
|
||||
"semi_net_exit_target": st.get("semi_net_exit_target"),
|
||||
"semi_min_option_hours": st.get("semi_min_option_hours"),
|
||||
"semi_min_option_leverage": st.get("semi_min_option_leverage"),
|
||||
"semi_moneyness": st.get("semi_moneyness"),
|
||||
"semi_otm_max_offset": st.get("semi_otm_max_offset"),
|
||||
"semi_perp_unit": st.get("semi_perp_unit"),
|
||||
"semi_option_unit": st.get("semi_option_unit"),
|
||||
"leverage": _pick("leverage", float(settings.leverage)),
|
||||
"min_option_leverage": _pick(
|
||||
"min_option_leverage", float(settings.min_option_leverage)
|
||||
),
|
||||
"min_option_hours": _pick(
|
||||
"min_option_hours", float(settings.min_option_hours)
|
||||
),
|
||||
"perp_margin_mode": st.get("perp_margin_mode"),
|
||||
"perp_qty_eth": st.get("perp_qty_eth"),
|
||||
"option_qty_eth": st.get("option_qty_eth"),
|
||||
"oo_put_qty_eth": (
|
||||
st.get("oo_put_qty_eth")
|
||||
if st.get("oo_put_qty_eth") is not None
|
||||
else _sf("oo_put_qty_eth", 0.0) or None
|
||||
),
|
||||
"sizing_mode": st.get("sizing_mode"),
|
||||
"risk_last_k": st.get("risk_last_k"),
|
||||
"risk_sizing_locked": st.get("risk_sizing_locked"),
|
||||
"risk_sizing_preview": st.get("risk_sizing_preview"),
|
||||
"risk_loss_pct": _pick("risk_loss_pct", 1.0),
|
||||
"risk_perp_unit": _pick("risk_perp_unit", 1.0),
|
||||
"risk_option_unit": _pick("risk_option_unit", 2.0),
|
||||
"risk_exit_unit": _pick("risk_exit_unit", 15.0),
|
||||
"martingale_enabled": st.get("martingale_enabled"),
|
||||
"martingale_doubles": st.get("martingale_doubles"),
|
||||
"risk_effective_loss_pct": st.get("risk_effective_loss_pct"),
|
||||
"oo_amplitude_pct": _pick(
|
||||
"oo_amplitude_pct", float(settings.oo_amplitude_pct)
|
||||
),
|
||||
"oo_amplitude_hours": _pick(
|
||||
"oo_amplitude_hours", float(settings.oo_amplitude_hours)
|
||||
),
|
||||
"oo_amplitude_filter_enabled": (
|
||||
str(
|
||||
st.get("oo_amplitude_filter_enabled")
|
||||
if "oo_amplitude_filter_enabled" in st
|
||||
else db.get_setting(
|
||||
"oo_amplitude_filter_enabled",
|
||||
str(settings.oo_amplitude_filter_enabled),
|
||||
)
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
in ("1", "true", "yes", "on")
|
||||
),
|
||||
"oo_min_option_hours": _pick(
|
||||
"oo_min_option_hours", float(settings.oo_min_option_hours)
|
||||
),
|
||||
"oo_min_leverage": _pick(
|
||||
"oo_min_leverage", float(settings.oo_min_leverage)
|
||||
),
|
||||
"oo_reward_ratio": _pick(
|
||||
"oo_reward_ratio", float(settings.oo_reward_ratio)
|
||||
),
|
||||
"hedge_mode": (
|
||||
hm
|
||||
if (
|
||||
hm := str(
|
||||
st.get("hedge_mode")
|
||||
or ("option_option" if is_oo else None)
|
||||
or db.get_setting("hedge_mode", settings.hedge_mode)
|
||||
or settings.hedge_mode
|
||||
or "perp_option"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("perp_option", "option_option")
|
||||
else "perp_option"
|
||||
),
|
||||
},
|
||||
"position": {
|
||||
"status": pos.get("status") or ("open" if pos.get("has_position") else "flat"),
|
||||
"has_position": bool(pos.get("has_position")),
|
||||
"hedge_mode": "option_option" if is_oo else "perp_option",
|
||||
"group_id": pos.get("group_id"),
|
||||
"open_at_ms": pos.get("open_at_ms"),
|
||||
"initial_premium": pos.get("initial_premium"),
|
||||
"initial_premium2": pos.get("initial_premium2"),
|
||||
"perp_margin": pos.get("perp_margin"),
|
||||
"exit_target_usdt": pos.get("exit_target_usdt"),
|
||||
"net_pnl": pos.get("net_pnl"),
|
||||
"perp_upl": pos.get("perp_upl"),
|
||||
"option_upl": pos.get("option_upl"),
|
||||
"option2_upl": pos.get("option2_upl"),
|
||||
"strike": pos.get("strike"),
|
||||
"strike2": pos.get("strike2"),
|
||||
"expiry_ymd": pos.get("expiry_ymd"),
|
||||
"legs": legs,
|
||||
},
|
||||
"update": {
|
||||
"running": bool(_update_state.get("running")),
|
||||
"started_at_ms": _update_state.get("started_at_ms") or 0,
|
||||
"last_error": _update_state.get("last_error") or "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def fleet_stats(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
"""中控拉取策略机整体统计(同 /api/stats/summary,Fleet Token 鉴权)。"""
|
||||
from .stats import build_stats_summary
|
||||
|
||||
return build_stats_summary()
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def fleet_start(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
return await get_engine().start()
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
async def fleet_pause(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
return await get_engine().pause()
|
||||
|
||||
|
||||
class ResidualCloseBody(BaseModel):
|
||||
group_id: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
@router.post("/residual/close")
|
||||
async def fleet_residual_close(
|
||||
body: ResidualCloseBody,
|
||||
_tok: Annotated[str, Depends(require_fleet_token)],
|
||||
) -> dict:
|
||||
"""中控手动平单条残留:只验流动性,不验权利金回收比例。"""
|
||||
matcher = get_engine().matcher
|
||||
result = await asyncio.to_thread(matcher.close_residual_manual, body.group_id)
|
||||
if not result.ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.detail or "平残留失败",
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": result.detail,
|
||||
"data": result.data,
|
||||
"liquidity_wait": result.liquidity_wait,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/issue-login")
|
||||
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
username, _ = get_credentials()
|
||||
ticket, ttl = create_login_ticket(username)
|
||||
return {
|
||||
"ok": True,
|
||||
"ticket": ticket,
|
||||
"expires_in": ttl,
|
||||
"login_path": f"/fleet-login?ticket={ticket}",
|
||||
}
|
||||
|
||||
|
||||
def _run_update_job() -> None:
|
||||
root = _repo_root()
|
||||
script = root / "deploy" / "lib" / "update.sh"
|
||||
if not script.is_file():
|
||||
script = root / "deploy" / "pull_and_restart.sh"
|
||||
try:
|
||||
if os.name == "nt":
|
||||
_update_state["last_error"] = "update script requires bash (Linux deploy host)"
|
||||
logger.error("fleet update skipped: not a Linux deploy host")
|
||||
return
|
||||
if not script.is_file():
|
||||
_update_state["last_error"] = f"update script missing: {script}"
|
||||
logger.error("fleet update: %s", _update_state["last_error"])
|
||||
return
|
||||
logger.info("fleet update starting: %s", script)
|
||||
proc = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "")[-2000:]
|
||||
_update_state["last_error"] = f"exit={proc.returncode} {err}"
|
||||
logger.error("fleet update failed: %s", _update_state["last_error"])
|
||||
else:
|
||||
_update_state["last_error"] = ""
|
||||
logger.info("fleet update finished ok")
|
||||
except Exception as e:
|
||||
_update_state["last_error"] = str(e)
|
||||
logger.exception("fleet update exception")
|
||||
finally:
|
||||
_update_state["running"] = False
|
||||
|
||||
|
||||
@router.post("/update")
|
||||
async def fleet_update(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
"""接受更新请求:后台跑 deploy update(会 reload 本进程)。"""
|
||||
with _update_lock:
|
||||
if _update_state.get("running"):
|
||||
return {
|
||||
"ok": True,
|
||||
"accepted": False,
|
||||
"running": True,
|
||||
"msg": "更新已在进行中",
|
||||
}
|
||||
_update_state["running"] = True
|
||||
_update_state["started_at_ms"] = int(time.time() * 1000)
|
||||
_update_state["last_error"] = ""
|
||||
|
||||
def _deferred() -> None:
|
||||
time.sleep(0.8)
|
||||
_run_update_job()
|
||||
|
||||
threading.Thread(target=_deferred, name="fleet-update", daemon=True).start()
|
||||
return {
|
||||
"ok": True,
|
||||
"accepted": True,
|
||||
"running": True,
|
||||
"msg": "已接受更新,进程即将 reload,请稍后探活",
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"""资金摘要 / 兑换 / 划转(SIM 本地钱包 + LIVE OKX)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..live.okx_funds import OkxFundsClient, usdc_usdt_mid_rate
|
||||
from ..models.db import get_db
|
||||
from ..sim.funds_wallets import SimFundsWallets
|
||||
from ..sim.ledger import Ledger
|
||||
from ..strategy.engine import get_engine
|
||||
from .auth import require_user
|
||||
|
||||
router = APIRouter(prefix="/api/funds", tags=["funds"])
|
||||
|
||||
SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _pl_ratio(pnls: list[float]) -> float | None:
|
||||
wins = [x for x in pnls if x > 0]
|
||||
losses = [abs(x) for x in pnls if x < 0]
|
||||
if not wins or not losses:
|
||||
return None
|
||||
avg_w = sum(wins) / len(wins)
|
||||
avg_l = sum(losses) / len(losses)
|
||||
if avg_l <= 1e-12:
|
||||
return None
|
||||
return round(avg_w / avg_l, 2)
|
||||
|
||||
|
||||
def _fmt_acct(usdt: float | None, usdc: float | None) -> str:
|
||||
parts: list[str] = []
|
||||
if usdt is not None:
|
||||
parts.append(f"{usdt:.2f}U")
|
||||
if usdc is not None and abs(usdc) > 1e-8:
|
||||
parts.append(f"{usdc:.2f} USDC")
|
||||
return " + ".join(parts) if parts else "—"
|
||||
|
||||
|
||||
class ConvertBody(BaseModel):
|
||||
direction: Literal["usdt_to_usdc", "usdc_to_usdt"]
|
||||
amount: float = Field(gt=0)
|
||||
|
||||
|
||||
class TransferBody(BaseModel):
|
||||
ccy: Literal["USDT", "USDC", "usdt", "usdc"] = "USDC"
|
||||
amount: float = Field(gt=0)
|
||||
from_account: Literal["funding", "trading"] = "funding"
|
||||
to_account: Literal["funding", "trading"] = "trading"
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[str, Any]:
|
||||
s = get_settings()
|
||||
db = get_db()
|
||||
eng = get_engine()
|
||||
st = eng.state()
|
||||
# 不在摘要里强调 SIM/LIVE 文案;仅给前端内部用
|
||||
mode = "LIVE" if not s.is_sim else "SIM"
|
||||
exchange = str(st.get("exchange") or s.exchange or "okx").upper()
|
||||
trading_day = datetime.now(SH).strftime("%Y-%m-%d")
|
||||
|
||||
# 顶栏「总交易 / 胜率 / 盈亏比」:用展示口径净盈亏(含到期内在价值修复)
|
||||
closed = db.fetchall("SELECT * FROM groups WHERE status='closed'")
|
||||
pnls: list[float] = []
|
||||
try:
|
||||
from .trades import _enrich_group, persist_expiry_overlay_if_needed
|
||||
|
||||
for r in closed:
|
||||
g = dict(r)
|
||||
fills = db.fetchall(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
|
||||
(g["group_id"],),
|
||||
)
|
||||
gr = _enrich_group(g, fills)
|
||||
try:
|
||||
persist_expiry_overlay_if_needed(db, gr, list(fills))
|
||||
except Exception:
|
||||
pass
|
||||
pnls.append(float(gr.get("net_pnl") or gr.get("realized_pnl") or 0))
|
||||
except Exception:
|
||||
pnls = [float(r["realized_pnl"] or 0) for r in closed]
|
||||
n = len(pnls)
|
||||
wins = sum(1 for x in pnls if x > 0)
|
||||
win_rate = (wins / n) if n else 0.0
|
||||
|
||||
pos = st.get("position") or {}
|
||||
realtime = None
|
||||
pos_st = str(pos.get("status") or "")
|
||||
if pos_st in ("open", "half_open", "option_closed_perp_pending"):
|
||||
realtime = float(pos.get("net_pnl") or 0)
|
||||
|
||||
if s.is_sim:
|
||||
wallets = SimFundsWallets(db)
|
||||
w = wallets.snapshot()
|
||||
# 若钱包全 0 但账本有权益,补种一次
|
||||
if wallets.total_usdt_equiv(w) < 1e-9:
|
||||
eq = float(Ledger(db).snapshot().get("equity") or 0)
|
||||
if eq > 0:
|
||||
w = wallets.reset_from_equity(eq)
|
||||
v = wallets.view()
|
||||
funding_usdt = float(v["funding_usdt"])
|
||||
trading_usdt = float(v["trading_usdt"])
|
||||
funding_usdc = float(v["funding_usdc"])
|
||||
trading_usdc = float(v["trading_usdc"])
|
||||
total = wallets.total_usdt_equiv()
|
||||
rate = usdc_usdt_mid_rate()
|
||||
else:
|
||||
rate = usdc_usdt_mid_rate()
|
||||
funding_usdt = trading_usdt = funding_usdc = trading_usdc = None
|
||||
total = None
|
||||
if exchange == "OKX":
|
||||
client = OkxFundsClient()
|
||||
try:
|
||||
bal = client.fetch_balances()
|
||||
funding_usdt = bal.get("funding_usdt")
|
||||
trading_usdt = bal.get("trading_usdt")
|
||||
funding_usdc = bal.get("funding_usdc")
|
||||
trading_usdc = bal.get("trading_usdc")
|
||||
r = float(rate) if rate and rate > 0 else 1.0
|
||||
total = round(
|
||||
(funding_usdt or 0.0)
|
||||
+ (trading_usdt or 0.0)
|
||||
+ ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r,
|
||||
2,
|
||||
)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"mode": mode,
|
||||
"exchange": exchange,
|
||||
"detail": str(e),
|
||||
}
|
||||
finally:
|
||||
client.close()
|
||||
elif exchange in ("BINANCE", "BN"):
|
||||
from ..live.binance_trade import BinanceTradeClient
|
||||
|
||||
client = BinanceTradeClient()
|
||||
try:
|
||||
bal = client.fetch_balances()
|
||||
funding_usdt = bal.get("funding_usdt")
|
||||
trading_usdt = bal.get("trading_usdt")
|
||||
funding_usdc = bal.get("funding_usdc")
|
||||
trading_usdc = bal.get("trading_usdc")
|
||||
r = float(rate) if rate and rate > 0 else 1.0
|
||||
total = round(
|
||||
(funding_usdt or 0.0)
|
||||
+ (trading_usdt or 0.0)
|
||||
+ ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r,
|
||||
2,
|
||||
)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"mode": mode,
|
||||
"exchange": exchange,
|
||||
"detail": str(e),
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 其它交易所:不回退模拟账本(避免实盘显示假资金)
|
||||
return {
|
||||
"ok": False,
|
||||
"mode": mode,
|
||||
"exchange": exchange,
|
||||
"detail": f"{exchange} 实盘资金摘要暂未接入,请用交易所 App 查看",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"exchange": exchange,
|
||||
"trading_day": trading_day,
|
||||
"total_trades": n,
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": _pl_ratio(pnls),
|
||||
"total_funds": total,
|
||||
"funding_usdt": funding_usdt,
|
||||
"trading_usdt": trading_usdt,
|
||||
"funding_usdc": funding_usdc,
|
||||
"trading_usdc": trading_usdc,
|
||||
"funding_label": _fmt_acct(funding_usdt, funding_usdc),
|
||||
"trading_label": _fmt_acct(trading_usdt, trading_usdc),
|
||||
"realtime_pnl": realtime,
|
||||
"usdc_usdt_rate": rate,
|
||||
"perp_inst_id": str(
|
||||
db.get_setting("perp_inst_id") or s.perp_inst_id or "ETH-USDT-SWAP"
|
||||
),
|
||||
"updated_at_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def funds_convert(
|
||||
body: ConvertBody, _user: Annotated[str, Depends(require_user)]
|
||||
) -> dict[str, Any]:
|
||||
"""USDT↔USDC 市价兑换:一律在交易账户(对齐 OKX 现货 cash;SIM 同口径)。"""
|
||||
s = get_settings()
|
||||
rate = usdc_usdt_mid_rate()
|
||||
if s.is_sim:
|
||||
r = SimFundsWallets(get_db()).convert(
|
||||
direction=body.direction,
|
||||
amount=float(body.amount),
|
||||
rate=rate,
|
||||
account="trading",
|
||||
)
|
||||
if not r.get("ok"):
|
||||
raise HTTPException(status_code=400, detail=r.get("detail") or "兑换失败")
|
||||
return r
|
||||
|
||||
if str(s.exchange).lower() != "okx":
|
||||
raise HTTPException(status_code=400, detail="当前仅 OKX 支持 USDC/USDT 兑换")
|
||||
client = OkxFundsClient()
|
||||
try:
|
||||
r = client.spot_swap_usdt_usdc(
|
||||
direction=body.direction, amount=float(body.amount)
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
try:
|
||||
from ..strategy.open_capacity import invalidate_live_balance_cache
|
||||
|
||||
invalidate_live_balance_cache()
|
||||
except Exception:
|
||||
pass
|
||||
if not r.get("ok"):
|
||||
raise HTTPException(status_code=400, detail=r.get("detail") or "兑换失败")
|
||||
return r
|
||||
|
||||
|
||||
@router.post("/transfer")
|
||||
async def funds_transfer(
|
||||
body: TransferBody, _user: Annotated[str, Depends(require_user)]
|
||||
) -> dict[str, Any]:
|
||||
s = get_settings()
|
||||
ccy = str(body.ccy).upper()
|
||||
if s.is_sim:
|
||||
r = SimFundsWallets(get_db()).transfer(
|
||||
ccy=ccy,
|
||||
amount=float(body.amount),
|
||||
from_account=body.from_account,
|
||||
to_account=body.to_account,
|
||||
)
|
||||
if not r.get("ok"):
|
||||
raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败")
|
||||
return r
|
||||
|
||||
if str(s.exchange).lower() != "okx":
|
||||
raise HTTPException(status_code=400, detail="当前仅 OKX 支持账户划转")
|
||||
client = OkxFundsClient()
|
||||
try:
|
||||
r = client.transfer(
|
||||
ccy=ccy,
|
||||
amount=float(body.amount),
|
||||
from_account=body.from_account,
|
||||
to_account=body.to_account,
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
try:
|
||||
from ..strategy.open_capacity import invalidate_live_balance_cache
|
||||
|
||||
invalidate_live_balance_cache()
|
||||
except Exception:
|
||||
pass
|
||||
if not r.get("ok"):
|
||||
raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败")
|
||||
return r
|
||||
@@ -0,0 +1,87 @@
|
||||
"""交易组持仓周期:目标出场以策略平仓时刻为准;永续先平则以永续平仓为准。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
# 目标平仓(含 15U / 权利金倍数 / 只平永续)
|
||||
_TARGET_REASONS = frozenset({"fixed_usdt", "premium_multiple", "target_perp_only"})
|
||||
|
||||
|
||||
def _ts(v: Any) -> int | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return n if n > 0 else None
|
||||
|
||||
|
||||
def _as_map(row: Mapping[str, Any] | Any) -> Mapping[str, Any]:
|
||||
if isinstance(row, Mapping):
|
||||
return row
|
||||
try:
|
||||
return dict(row)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def first_perp_close_ts_ms(fills: Sequence[Mapping[str, Any] | Any]) -> int | None:
|
||||
"""永续平仓成交时间(目标只平永续时作为持仓结束时刻)。"""
|
||||
best: int | None = None
|
||||
for raw in fills:
|
||||
f = _as_map(raw)
|
||||
if str(f.get("leg") or "") != "perp":
|
||||
continue
|
||||
if str(f.get("action") or "") != "close":
|
||||
continue
|
||||
ts = _ts(f.get("ts_ms"))
|
||||
if ts is None:
|
||||
continue
|
||||
if best is None or ts < best:
|
||||
best = ts
|
||||
return best
|
||||
|
||||
|
||||
def hold_timing(
|
||||
group: Mapping[str, Any] | Any, fills: Sequence[Mapping[str, Any] | Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回展示用开仓/平仓/持仓时长。
|
||||
|
||||
- 开仓:groups.open_at_ms
|
||||
- 平仓(策略持仓周期):
|
||||
- `target_perp_only` / `option_residual`:永续平仓 fill 时间
|
||||
- 其它已平:groups.close_at_ms(缺则回退成交)
|
||||
"""
|
||||
g = _as_map(group)
|
||||
open_ms = _ts(g.get("open_at_ms"))
|
||||
status = str(g.get("status") or "")
|
||||
reason = str(g.get("close_reason") or "")
|
||||
group_close = _ts(g.get("close_at_ms"))
|
||||
perp_close = first_perp_close_ts_ms(fills)
|
||||
|
||||
use_perp = reason == "target_perp_only" or status == "option_residual"
|
||||
if use_perp:
|
||||
close_ms = perp_close or group_close
|
||||
basis = "perp"
|
||||
elif status == "open":
|
||||
close_ms = None
|
||||
basis = "open"
|
||||
else:
|
||||
close_ms = group_close
|
||||
if close_ms is None and reason in _TARGET_REASONS:
|
||||
close_ms = perp_close
|
||||
basis = "group"
|
||||
|
||||
hold_ms: int | None = None
|
||||
if open_ms is not None and close_ms is not None and close_ms >= open_ms:
|
||||
hold_ms = close_ms - open_ms
|
||||
|
||||
return {
|
||||
"hold_open_at_ms": open_ms,
|
||||
"hold_close_at_ms": close_ms,
|
||||
"hold_ms": hold_ms,
|
||||
"hold_basis": basis,
|
||||
}
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..market import get_gateway
|
||||
from .auth import require_user
|
||||
@@ -18,6 +18,21 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return snap
|
||||
|
||||
|
||||
@router.get("/option-ladder")
|
||||
async def market_option_ladder(
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
wings: int = Query(default=5, ge=1, le=12),
|
||||
side: str = Query(default="call", pattern="^(call|put)$"),
|
||||
min_hours: float = Query(default=30, ge=1, le=720),
|
||||
) -> dict:
|
||||
"""半自动页单边报价:选剩余时长≥min_hours 的最近到期。"""
|
||||
gw = get_gateway()
|
||||
ladder = getattr(gw, "option_ladder", None)
|
||||
if not callable(ladder):
|
||||
raise HTTPException(status_code=501, detail="当前会话不支持 option-ladder")
|
||||
return ladder(wings=wings, side=side, min_hours=min_hours)
|
||||
|
||||
|
||||
@router.post("/realign")
|
||||
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
|
||||
|
||||
+88
-2
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
import asyncio
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..models.db import get_db
|
||||
from ..strategy import get_engine
|
||||
from .auth import require_user
|
||||
|
||||
@@ -28,3 +31,86 @@ async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
@router.post("/emergency-close")
|
||||
async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return await get_engine().emergency_close()
|
||||
|
||||
|
||||
class SemiArmBody(BaseModel):
|
||||
armed: bool = True
|
||||
|
||||
|
||||
class SemiParamsBody(BaseModel):
|
||||
"""首页半自动本单参数(不进系统设置表单主路径,但落同一 settings 表)。"""
|
||||
|
||||
semi_view_side: Literal["long", "short"] | None = None
|
||||
semi_option_move_points: float | None = Field(default=None, ge=1, le=5000)
|
||||
semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||
semi_min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||||
semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
||||
semi_moneyness: Literal["itm", "atm", "otm"] | None = None
|
||||
semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000)
|
||||
semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||
semi_option_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||
|
||||
|
||||
@router.post("/semi/arm")
|
||||
async def plan_semi_arm(
|
||||
body: SemiArmBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
return get_engine().arm_semi(armed=bool(body.armed))
|
||||
|
||||
|
||||
@router.put("/semi/params")
|
||||
async def plan_semi_params(
|
||||
body: SemiParamsBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
eng = get_engine()
|
||||
if eng.matcher.has_open_position():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="有未平仓,禁止修改半自动本单参数",
|
||||
)
|
||||
from ..strategy.semi_auto import is_armed
|
||||
|
||||
if is_armed(eng.ledger):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已授权盯开,禁止改本单参数;请先取消授权",
|
||||
)
|
||||
st = eng.db.fetchone("SELECT phase FROM strategy_state WHERE id=1")
|
||||
phase = str(st["phase"] or "") if st else ""
|
||||
if phase in ("wait_signal", "opening", "open", "closing", "liquidity_wait"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"当前阶段 {phase} 禁止修改半自动本单参数",
|
||||
)
|
||||
db = get_db()
|
||||
data = body.model_dump(exclude_none=True)
|
||||
for k, v in data.items():
|
||||
db.set_setting(k, str(v))
|
||||
return eng.state()
|
||||
|
||||
|
||||
class ResidualCloseBody(BaseModel):
|
||||
group_id: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
@router.post("/residual/close")
|
||||
async def plan_residual_close(
|
||||
body: ResidualCloseBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
"""手动平单条残留:只验流动性,不验权利金回收比例。"""
|
||||
matcher = get_engine().matcher
|
||||
result = await asyncio.to_thread(matcher.close_residual_manual, body.group_id)
|
||||
if not result.ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.detail or "平残留失败",
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": result.detail,
|
||||
"data": result.data,
|
||||
"liquidity_wait": result.liquidity_wait,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""公开行情辅助:补历史到期结算指数展示(不发明成交现金)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE: dict[int, float] = {}
|
||||
_CACHE_MAX = 256
|
||||
|
||||
|
||||
def looks_binance_option(inst_id: str | None) -> bool:
|
||||
return "USD_UM" in str(inst_id or "")
|
||||
|
||||
|
||||
def eth_usdt_close_at_ms(ts_ms: int | None) -> float | None:
|
||||
"""币安 ETHUSDT 1m K 线收盘价(近似期权结算指数)。失败返回 None。"""
|
||||
if ts_ms is None:
|
||||
return None
|
||||
try:
|
||||
ms = int(ts_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if ms <= 0:
|
||||
return None
|
||||
minute = (ms // 60_000) * 60_000
|
||||
cached = _CACHE.get(minute)
|
||||
if cached is not None:
|
||||
return cached
|
||||
url = (
|
||||
"https://api.binance.com/api/v3/klines"
|
||||
f"?symbol=ETHUSDT&interval=1m&startTime={minute}&limit=1"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=4) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
rows = json.loads(raw)
|
||||
if not rows:
|
||||
return None
|
||||
close_px = float(rows[0][4])
|
||||
if close_px <= 0:
|
||||
return None
|
||||
if len(_CACHE) >= _CACHE_MAX:
|
||||
_CACHE.clear()
|
||||
_CACHE[minute] = close_px
|
||||
return close_px
|
||||
except (urllib.error.URLError, TimeoutError, ValueError, TypeError, IndexError) as e:
|
||||
logger.debug("eth_usdt_close_at_ms failed ms=%s: %s", minute, e)
|
||||
return None
|
||||
|
||||
|
||||
def maybe_public_settle_index(g: dict[str, Any]) -> float | None:
|
||||
"""库内无结算价时,币安期权到期组用公开 ETHUSDT 收盘近似。"""
|
||||
if str(g.get("close_reason") or "") != "expiry":
|
||||
return None
|
||||
inst = g.get("option_inst_id") or g.get("option2_inst_id")
|
||||
if not looks_binance_option(str(inst) if inst else None):
|
||||
return None
|
||||
ts = g.get("close_at_ms") or g.get("hold_close_at_ms")
|
||||
return eth_usdt_close_at_ms(ts if ts is not None else None)
|
||||
+756
-1
@@ -1,11 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..env_store import (
|
||||
binance_keys_configured,
|
||||
live_ready,
|
||||
mask_secret,
|
||||
okx_keys_configured,
|
||||
upsert_env_keys,
|
||||
)
|
||||
from ..exchange.runtime import (
|
||||
load_runtime_settings,
|
||||
normalize_exchange_name,
|
||||
@@ -26,16 +33,57 @@ KEYS = (
|
||||
"net_profit_target",
|
||||
"premium_exit_multiple",
|
||||
"rest_seconds",
|
||||
"live_order_interval_sec",
|
||||
"skip_weekends",
|
||||
"one_expiry_per_day",
|
||||
"initial_equity",
|
||||
"leverage",
|
||||
"perp_margin_mode",
|
||||
"min_option_hours",
|
||||
"min_option_leverage",
|
||||
"atm_open_offset_enabled",
|
||||
"max_atm_open_offset",
|
||||
"fixed_direction_enabled",
|
||||
"fixed_perp_side",
|
||||
"semi_auto_enabled",
|
||||
"semi_armed",
|
||||
"semi_view_side",
|
||||
"semi_option_move_points",
|
||||
"semi_perp_exit_unit",
|
||||
"semi_min_option_hours",
|
||||
"semi_min_option_leverage",
|
||||
"semi_moneyness",
|
||||
"semi_otm_max_offset",
|
||||
"semi_perp_unit",
|
||||
"semi_option_unit",
|
||||
"close_bid_mark_max_pct",
|
||||
"residual_min_premium_pct",
|
||||
"residual_close_check_sec",
|
||||
"perp_qty_eth",
|
||||
"option_qty_eth",
|
||||
"show_manual_trade_buttons",
|
||||
"sizing_mode",
|
||||
"risk_leverage_basis",
|
||||
"risk_loss_mode",
|
||||
"risk_loss_pct",
|
||||
"risk_loss_usdt",
|
||||
"risk_capital_source",
|
||||
"risk_manual_capital_usdt",
|
||||
"risk_perp_unit",
|
||||
"risk_option_unit",
|
||||
"risk_exit_unit",
|
||||
"martingale_enabled",
|
||||
"martingale_start_after_loss_days",
|
||||
"martingale_max_doubles",
|
||||
"hedge_mode",
|
||||
"oo_amplitude_pct",
|
||||
"oo_amplitude_hours",
|
||||
"oo_amplitude_filter_enabled",
|
||||
"oo_min_option_hours",
|
||||
"oo_min_leverage",
|
||||
"oo_reward_ratio",
|
||||
"oo_budget_cushion",
|
||||
"oo_strike_max_dev_pct",
|
||||
)
|
||||
|
||||
|
||||
@@ -46,17 +94,64 @@ class StrategySettingsBody(BaseModel):
|
||||
net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
|
||||
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
||||
live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30)
|
||||
skip_weekends: bool | None = None
|
||||
one_expiry_per_day: bool | None = None
|
||||
initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000)
|
||||
leverage: float | None = Field(default=None, ge=1, le=125)
|
||||
perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$")
|
||||
min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||||
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
||||
atm_open_offset_enabled: bool | None = None
|
||||
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
|
||||
fixed_direction_enabled: bool | None = None
|
||||
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$")
|
||||
semi_auto_enabled: bool | None = None
|
||||
# semi_armed 仅允许经 /api/plan/semi/arm,不接受设置接口写入
|
||||
semi_view_side: str | None = Field(default=None, pattern="^(long|short)$")
|
||||
semi_option_move_points: float | None = Field(default=None, ge=1, le=5000)
|
||||
semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||
semi_min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||||
semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
||||
semi_moneyness: str | None = Field(default=None, pattern="^(itm|atm|otm)$")
|
||||
semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000)
|
||||
semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||
semi_option_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
|
||||
residual_min_premium_pct: float | None = Field(default=None, ge=1, le=100)
|
||||
residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400)
|
||||
perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||
show_manual_trade_buttons: bool | None = None
|
||||
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
|
||||
sizing_mode: str | None = Field(default=None, pattern="^(manual|risk_based)$")
|
||||
risk_leverage_basis: str | None = Field(
|
||||
default=None, pattern="^(actual|selection)$"
|
||||
)
|
||||
risk_loss_mode: str | None = Field(default=None, pattern="^(percent|absolute)$")
|
||||
risk_loss_pct: float | None = Field(default=None, ge=0.01, le=100)
|
||||
risk_loss_usdt: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||
risk_capital_source: str | None = Field(
|
||||
default=None, pattern="^(trading_account|manual)$"
|
||||
)
|
||||
risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000)
|
||||
risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||
risk_option_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||
risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||
martingale_enabled: bool | None = None
|
||||
martingale_start_after_loss_days: int | None = Field(default=None, ge=1, le=30)
|
||||
martingale_max_doubles: int | None = Field(default=None, ge=1, le=10)
|
||||
hedge_mode: str | None = Field(
|
||||
default=None, pattern="^(perp_option|option_option)$"
|
||||
)
|
||||
oo_amplitude_pct: float | None = Field(default=None, ge=0.1, le=50)
|
||||
oo_amplitude_hours: float | None = Field(default=None, ge=1, le=168)
|
||||
oo_amplitude_filter_enabled: bool | None = None
|
||||
oo_min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||||
oo_min_leverage: float | None = Field(default=None, ge=1, le=10000)
|
||||
oo_reward_ratio: float | None = Field(default=None, ge=0.5, le=20)
|
||||
oo_budget_cushion: float | None = Field(default=None, ge=0.5, le=1.0)
|
||||
oo_strike_max_dev_pct: float | None = Field(default=None, ge=0.1, le=10)
|
||||
|
||||
|
||||
def _as_bool(raw: str | None, default: bool) -> bool:
|
||||
@@ -65,6 +160,15 @@ def _as_bool(raw: str | None, default: bool) -> bool:
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _risk_preview_safe() -> dict:
|
||||
try:
|
||||
from ..strategy.risk_sizing import preview_risk_sizing
|
||||
|
||||
return preview_risk_sizing()
|
||||
except Exception as e:
|
||||
return {"ok": False, "detail": f"预览失败: {e}", "risk_based": False}
|
||||
|
||||
|
||||
def _read_settings() -> dict:
|
||||
db = get_db()
|
||||
s = get_settings()
|
||||
@@ -89,13 +193,37 @@ def _read_settings() -> dict:
|
||||
"rest_seconds": int(
|
||||
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
||||
),
|
||||
"live_order_interval_sec": float(
|
||||
db.get_setting(
|
||||
"live_order_interval_sec", str(s.live_order_interval_sec)
|
||||
)
|
||||
or s.live_order_interval_sec
|
||||
),
|
||||
"skip_weekends": _as_bool(
|
||||
db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends
|
||||
),
|
||||
"one_expiry_per_day": _as_bool(
|
||||
db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)),
|
||||
s.one_expiry_per_day,
|
||||
),
|
||||
"initial_equity": float(
|
||||
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||||
),
|
||||
"leverage": float(db.get_setting("leverage", str(s.leverage)) or s.leverage),
|
||||
"perp_margin_mode": (
|
||||
mm
|
||||
if (
|
||||
mm := str(
|
||||
db.get_setting("perp_margin_mode", s.perp_margin_mode)
|
||||
or s.perp_margin_mode
|
||||
or "cross"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("cross", "isolated")
|
||||
else "cross"
|
||||
),
|
||||
"min_option_hours": float(
|
||||
db.get_setting("min_option_hours", str(s.min_option_hours))
|
||||
or s.min_option_hours
|
||||
@@ -114,16 +242,248 @@ def _read_settings() -> dict:
|
||||
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
|
||||
or s.max_atm_open_offset
|
||||
),
|
||||
"fixed_direction_enabled": _as_bool(
|
||||
db.get_setting(
|
||||
"fixed_direction_enabled", str(s.fixed_direction_enabled)
|
||||
),
|
||||
s.fixed_direction_enabled,
|
||||
),
|
||||
"fixed_perp_side": (
|
||||
side
|
||||
if (
|
||||
side := str(
|
||||
db.get_setting("fixed_perp_side", s.fixed_perp_side)
|
||||
or s.fixed_perp_side
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("long", "short")
|
||||
else "long"
|
||||
),
|
||||
"semi_auto_enabled": _as_bool(
|
||||
db.get_setting("semi_auto_enabled", str(s.semi_auto_enabled)),
|
||||
s.semi_auto_enabled,
|
||||
),
|
||||
"semi_armed": _as_bool(
|
||||
db.get_setting("semi_armed", str(s.semi_armed)),
|
||||
s.semi_armed,
|
||||
),
|
||||
"semi_view_side": (
|
||||
sv
|
||||
if (
|
||||
sv := str(
|
||||
db.get_setting("semi_view_side", s.semi_view_side)
|
||||
or s.semi_view_side
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("long", "short")
|
||||
else "long"
|
||||
),
|
||||
"semi_option_move_points": float(
|
||||
db.get_setting(
|
||||
"semi_option_move_points", str(s.semi_option_move_points)
|
||||
)
|
||||
or s.semi_option_move_points
|
||||
),
|
||||
"semi_perp_exit_unit": float(
|
||||
db.get_setting("semi_perp_exit_unit", str(s.semi_perp_exit_unit))
|
||||
or s.semi_perp_exit_unit
|
||||
),
|
||||
"semi_min_option_hours": float(
|
||||
db.get_setting(
|
||||
"semi_min_option_hours", str(s.semi_min_option_hours)
|
||||
)
|
||||
or s.semi_min_option_hours
|
||||
),
|
||||
"semi_min_option_leverage": float(
|
||||
db.get_setting(
|
||||
"semi_min_option_leverage", str(s.semi_min_option_leverage)
|
||||
)
|
||||
or s.semi_min_option_leverage
|
||||
),
|
||||
"semi_moneyness": (
|
||||
lambda m: m
|
||||
if m in ("itm", "atm", "otm")
|
||||
else "otm"
|
||||
)(
|
||||
str(
|
||||
db.get_setting("semi_moneyness", s.semi_moneyness) or s.semi_moneyness
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
),
|
||||
"semi_otm_max_offset": float(
|
||||
db.get_setting("semi_otm_max_offset", str(s.semi_otm_max_offset))
|
||||
or s.semi_otm_max_offset
|
||||
),
|
||||
"semi_perp_unit": float(
|
||||
db.get_setting("semi_perp_unit", str(s.semi_perp_unit)) or s.semi_perp_unit
|
||||
),
|
||||
"semi_option_unit": float(
|
||||
db.get_setting("semi_option_unit", str(s.semi_option_unit))
|
||||
or s.semi_option_unit
|
||||
),
|
||||
"close_bid_mark_max_pct": float(
|
||||
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
|
||||
or s.close_bid_mark_max_pct
|
||||
),
|
||||
"residual_min_premium_pct": float(
|
||||
db.get_setting(
|
||||
"residual_min_premium_pct", str(s.residual_min_premium_pct)
|
||||
)
|
||||
or s.residual_min_premium_pct
|
||||
),
|
||||
"residual_close_check_sec": int(
|
||||
float(
|
||||
db.get_setting(
|
||||
"residual_close_check_sec", str(s.residual_close_check_sec)
|
||||
)
|
||||
or s.residual_close_check_sec
|
||||
)
|
||||
),
|
||||
"perp_qty_eth": float(
|
||||
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
||||
),
|
||||
"option_qty_eth": float(
|
||||
db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth
|
||||
),
|
||||
"show_manual_trade_buttons": _as_bool(
|
||||
db.get_setting("show_manual_trade_buttons", "0"), False
|
||||
),
|
||||
"sizing_mode": (
|
||||
sm
|
||||
if (
|
||||
sm := str(db.get_setting("sizing_mode", "manual") or "manual")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("manual", "risk_based")
|
||||
else "manual"
|
||||
),
|
||||
"risk_leverage_basis": (
|
||||
rb
|
||||
if (
|
||||
rb := str(
|
||||
db.get_setting(
|
||||
"risk_leverage_basis",
|
||||
str(s.risk_leverage_basis),
|
||||
)
|
||||
or s.risk_leverage_basis
|
||||
or "selection"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("actual", "selection")
|
||||
else "selection"
|
||||
),
|
||||
"risk_loss_mode": (
|
||||
lm
|
||||
if (
|
||||
lm := str(db.get_setting("risk_loss_mode", "percent") or "percent")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("percent", "absolute")
|
||||
else "percent"
|
||||
),
|
||||
"risk_loss_pct": float(db.get_setting("risk_loss_pct", "1") or 1),
|
||||
"risk_loss_usdt": float(db.get_setting("risk_loss_usdt", "15") or 15),
|
||||
"risk_capital_source": (
|
||||
cs
|
||||
if (
|
||||
cs := str(
|
||||
db.get_setting("risk_capital_source", "trading_account")
|
||||
or "trading_account"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("trading_account", "manual")
|
||||
else "trading_account"
|
||||
),
|
||||
"risk_manual_capital_usdt": float(
|
||||
db.get_setting("risk_manual_capital_usdt", "10000") or 10000
|
||||
),
|
||||
"risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1),
|
||||
"risk_option_unit": float(db.get_setting("risk_option_unit", "2") or 2),
|
||||
"risk_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15),
|
||||
"martingale_enabled": _as_bool(
|
||||
db.get_setting(
|
||||
"martingale_enabled", str(s.martingale_enabled)
|
||||
),
|
||||
s.martingale_enabled,
|
||||
),
|
||||
"martingale_start_after_loss_days": int(
|
||||
float(
|
||||
db.get_setting(
|
||||
"martingale_start_after_loss_days",
|
||||
str(s.martingale_start_after_loss_days),
|
||||
)
|
||||
or s.martingale_start_after_loss_days
|
||||
)
|
||||
),
|
||||
"martingale_max_doubles": int(
|
||||
float(
|
||||
db.get_setting(
|
||||
"martingale_max_doubles", str(s.martingale_max_doubles)
|
||||
)
|
||||
or s.martingale_max_doubles
|
||||
)
|
||||
),
|
||||
"hedge_mode": (
|
||||
hm
|
||||
if (
|
||||
hm := str(
|
||||
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("perp_option", "option_option")
|
||||
else "perp_option"
|
||||
),
|
||||
"oo_amplitude_pct": float(
|
||||
db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct))
|
||||
or s.oo_amplitude_pct
|
||||
),
|
||||
"oo_amplitude_hours": float(
|
||||
db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours))
|
||||
or s.oo_amplitude_hours
|
||||
),
|
||||
"oo_amplitude_filter_enabled": _as_bool(
|
||||
db.get_setting(
|
||||
"oo_amplitude_filter_enabled",
|
||||
str(s.oo_amplitude_filter_enabled),
|
||||
),
|
||||
s.oo_amplitude_filter_enabled,
|
||||
),
|
||||
"oo_min_option_hours": float(
|
||||
db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours))
|
||||
or s.oo_min_option_hours
|
||||
),
|
||||
"oo_min_leverage": float(
|
||||
db.get_setting("oo_min_leverage", str(s.oo_min_leverage))
|
||||
or s.oo_min_leverage
|
||||
),
|
||||
"oo_reward_ratio": float(
|
||||
db.get_setting("oo_reward_ratio", str(s.oo_reward_ratio))
|
||||
or s.oo_reward_ratio
|
||||
),
|
||||
"oo_budget_cushion": float(
|
||||
db.get_setting("oo_budget_cushion", str(s.oo_budget_cushion))
|
||||
or s.oo_budget_cushion
|
||||
),
|
||||
"oo_strike_max_dev_pct": float(
|
||||
db.get_setting(
|
||||
"oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct)
|
||||
)
|
||||
or s.oo_strike_max_dev_pct
|
||||
),
|
||||
"risk_sizing_preview": _risk_preview_safe(),
|
||||
"exchange": rt.exchange,
|
||||
"perp_inst_id": rt.perp_inst_id,
|
||||
"option_inst_family": rt.option_inst_family,
|
||||
@@ -145,8 +505,13 @@ async def put_strategy_settings(
|
||||
db = get_db()
|
||||
s = get_settings()
|
||||
data = body.model_dump(exclude_none=True)
|
||||
# 授权态只经 /api/plan/semi/arm;设置接口不可伪造 armed=true
|
||||
data.pop("semi_armed", None)
|
||||
equity_to_apply: float | None = None
|
||||
switch_to: str | None = None
|
||||
old_hedge = str(
|
||||
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode or "perp_option"
|
||||
).strip().lower()
|
||||
|
||||
if "exchange" in data:
|
||||
new_ex = normalize_exchange_name(str(data.pop("exchange")))
|
||||
@@ -174,6 +539,189 @@ async def put_strategy_settings(
|
||||
)
|
||||
equity_to_apply = new_eq
|
||||
|
||||
if "perp_margin_mode" in data:
|
||||
new_mm = str(data["perp_margin_mode"]).strip().lower()
|
||||
old_mm = str(
|
||||
db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode
|
||||
).strip().lower()
|
||||
if new_mm != old_mm and Matcher(db).has_open_position():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="有未平仓,无法切换永续保证金模式;请先平仓后再改",
|
||||
)
|
||||
|
||||
# 持仓中禁止改动会影响本组成交/出场的参数(基线:运行中无人工开平仓,策略锁定本组成交)
|
||||
if Matcher(db).has_open_position():
|
||||
locked_keys = (
|
||||
"net_profit_target",
|
||||
"exit_mode",
|
||||
"premium_exit_multiple",
|
||||
"perp_qty_eth",
|
||||
"option_qty_eth",
|
||||
"leverage",
|
||||
"sizing_mode",
|
||||
"risk_perp_unit",
|
||||
"risk_option_unit",
|
||||
"risk_exit_unit",
|
||||
"risk_loss_mode",
|
||||
"risk_loss_pct",
|
||||
"risk_loss_usdt",
|
||||
"risk_capital_source",
|
||||
"risk_manual_capital_usdt",
|
||||
"martingale_enabled",
|
||||
"martingale_start_after_loss_days",
|
||||
"martingale_max_doubles",
|
||||
"hedge_mode",
|
||||
"oo_amplitude_pct",
|
||||
"oo_amplitude_hours",
|
||||
"oo_amplitude_filter_enabled",
|
||||
"oo_min_option_hours",
|
||||
"oo_min_leverage",
|
||||
"oo_reward_ratio",
|
||||
"oo_budget_cushion",
|
||||
"oo_strike_max_dev_pct",
|
||||
"semi_view_side",
|
||||
"semi_option_move_points",
|
||||
"semi_perp_exit_unit",
|
||||
"semi_min_option_hours",
|
||||
"semi_min_option_leverage",
|
||||
"semi_moneyness",
|
||||
"semi_otm_max_offset",
|
||||
"semi_perp_unit",
|
||||
"semi_option_unit",
|
||||
"semi_auto_enabled",
|
||||
)
|
||||
hit = [k for k in locked_keys if k in data]
|
||||
if hit:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"有未平仓,禁止修改本组成交相关参数:{', '.join(hit)};请先平仓",
|
||||
)
|
||||
|
||||
# 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场
|
||||
hedge_mode = str(
|
||||
data.get(
|
||||
"hedge_mode",
|
||||
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode,
|
||||
)
|
||||
).strip().lower()
|
||||
if hedge_mode not in ("perp_option", "option_option"):
|
||||
hedge_mode = "perp_option"
|
||||
data["hedge_mode"] = hedge_mode
|
||||
if hedge_mode == "option_option":
|
||||
# 期期:强制以损定仓 + 亏损幅度%
|
||||
data["sizing_mode"] = "risk_based"
|
||||
data["risk_loss_mode"] = "percent"
|
||||
data["exit_mode"] = "fixed_usdt"
|
||||
data.pop("perp_qty_eth", None)
|
||||
data["fixed_direction_enabled"] = False
|
||||
data["semi_auto_enabled"] = False
|
||||
data["semi_armed"] = False
|
||||
|
||||
# 开关半自动时强制清授权,避免陈旧 armed 或关半自动后落入全自动误开
|
||||
if "semi_auto_enabled" in data:
|
||||
data["semi_armed"] = False
|
||||
|
||||
sizing_mode = str(
|
||||
data.get(
|
||||
"sizing_mode",
|
||||
db.get_setting("sizing_mode", "manual") or "manual",
|
||||
)
|
||||
).strip().lower()
|
||||
if sizing_mode == "risk_based":
|
||||
# 永期以损:允许 exit_mode=fixed_usdt(基数×k)或 premium_multiple(权利金×倍)
|
||||
# 期期仍强制 fixed(目标由预算×盈亏比写入)
|
||||
if hedge_mode == "option_option":
|
||||
data["exit_mode"] = "fixed_usdt"
|
||||
data.pop("perp_qty_eth", None)
|
||||
data.pop("option_qty_eth", None)
|
||||
if str(data.get("exit_mode") or db.get_setting("exit_mode", "") or "").strip().lower() != "premium_multiple":
|
||||
data.pop("net_profit_target", None)
|
||||
loss_mode = str(
|
||||
data.get(
|
||||
"risk_loss_mode",
|
||||
db.get_setting("risk_loss_mode", "percent") or "percent",
|
||||
)
|
||||
).strip().lower()
|
||||
if loss_mode == "absolute":
|
||||
loss_u = data.get("risk_loss_usdt")
|
||||
if loss_u is None:
|
||||
loss_u = float(db.get_setting("risk_loss_usdt", "0") or 0)
|
||||
if float(loss_u) <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="以损定仓选用亏损值时,须填写 risk_loss_usdt > 0",
|
||||
)
|
||||
else:
|
||||
src = str(
|
||||
data.get(
|
||||
"risk_capital_source",
|
||||
db.get_setting("risk_capital_source", "trading_account")
|
||||
or "trading_account",
|
||||
)
|
||||
).strip().lower()
|
||||
if src == "manual":
|
||||
cap = data.get("risk_manual_capital_usdt")
|
||||
if cap is None:
|
||||
cap = float(db.get_setting("risk_manual_capital_usdt", "0") or 0)
|
||||
if float(cap) <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="以损定仓选用单独本金时,须填写 risk_manual_capital_usdt > 0",
|
||||
)
|
||||
pct = data.get("risk_loss_pct")
|
||||
if pct is None:
|
||||
pct = float(db.get_setting("risk_loss_pct", "0") or 0)
|
||||
if float(pct) <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0",
|
||||
)
|
||||
|
||||
# 倍投:仅以损定仓 + 亏损幅度% + 基础幅度≤3%;条件不满足则强制关闭
|
||||
from ..strategy.risk_sizing import MARTINGALE_MAX_BASE_PCT
|
||||
|
||||
loss_mode_final = str(
|
||||
data.get(
|
||||
"risk_loss_mode",
|
||||
db.get_setting("risk_loss_mode", "percent") or "percent",
|
||||
)
|
||||
).strip().lower()
|
||||
pct_final = data.get("risk_loss_pct")
|
||||
if pct_final is None:
|
||||
pct_final = float(db.get_setting("risk_loss_pct", "1") or 1)
|
||||
else:
|
||||
pct_final = float(pct_final)
|
||||
existing_mg = _as_bool(
|
||||
db.get_setting("martingale_enabled", str(s.martingale_enabled)),
|
||||
s.martingale_enabled,
|
||||
)
|
||||
want_mg = (
|
||||
bool(data["martingale_enabled"])
|
||||
if "martingale_enabled" in data
|
||||
else existing_mg
|
||||
)
|
||||
mg_eligible = (
|
||||
sizing_mode == "risk_based"
|
||||
and loss_mode_final in ("percent", "pct", "%", "幅度")
|
||||
and float(pct_final) <= MARTINGALE_MAX_BASE_PCT + 1e-12
|
||||
)
|
||||
if want_mg and not mg_eligible:
|
||||
explicit_on = "martingale_enabled" in data and bool(data["martingale_enabled"])
|
||||
if explicit_on:
|
||||
if sizing_mode != "risk_based":
|
||||
reason = "倍投模式仅可在以损定仓下开启"
|
||||
elif loss_mode_final not in ("percent", "pct", "%", "幅度"):
|
||||
reason = "倍投模式仅可在「亏损幅度%」下开启"
|
||||
else:
|
||||
reason = (
|
||||
f"以损定仓亏损幅度超过 {MARTINGALE_MAX_BASE_PCT:g}% 时不可启用倍投"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=reason)
|
||||
data["martingale_enabled"] = False
|
||||
elif not mg_eligible:
|
||||
data["martingale_enabled"] = False
|
||||
|
||||
for k, v in data.items():
|
||||
if k in KEYS:
|
||||
db.set_setting(k, str(v))
|
||||
@@ -194,4 +742,211 @@ async def put_strategy_settings(
|
||||
detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}",
|
||||
) from e
|
||||
|
||||
new_hedge = str(
|
||||
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode or "perp_option"
|
||||
).strip().lower()
|
||||
if new_hedge != old_hedge and switch_to is None:
|
||||
# 对冲模式切换:强制按新模式重对齐监控合约(ATM ↔ 虚值)
|
||||
try:
|
||||
from ..strategy.session import get_session
|
||||
|
||||
await get_session().ensure_atm_async(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _read_settings()
|
||||
|
||||
|
||||
class RuntimeSettingsBody(BaseModel):
|
||||
mode: Literal["SIM", "LIVE"] | None = None
|
||||
confirm_live: bool | None = False
|
||||
confirm_live_phrase: str | None = None
|
||||
okx_api_key: str | None = None
|
||||
okx_api_secret: str | None = None
|
||||
okx_api_passphrase: str | None = None
|
||||
binance_api_key: str | None = None
|
||||
binance_api_secret: str | None = None
|
||||
|
||||
|
||||
def _runtime_payload() -> dict:
|
||||
s = get_settings()
|
||||
rt = load_runtime_settings()
|
||||
mode = "SIM" if s.is_sim else "LIVE"
|
||||
ready, reason = live_ready(exchange=rt.exchange)
|
||||
return {
|
||||
"mode": mode,
|
||||
"exchange": rt.exchange,
|
||||
"okx_configured": okx_keys_configured(s),
|
||||
"binance_configured": binance_keys_configured(s),
|
||||
"okx_api_key_masked": mask_secret(s.okx_api_key),
|
||||
"okx_api_secret_masked": mask_secret(s.okx_api_secret),
|
||||
"okx_api_passphrase_masked": mask_secret(s.okx_api_passphrase),
|
||||
"binance_api_key_masked": mask_secret(s.binance_api_key),
|
||||
"binance_api_secret_masked": mask_secret(s.binance_api_secret),
|
||||
"live_ready": bool(ready) if mode == "LIVE" else True,
|
||||
"live_ready_reason": reason if mode == "LIVE" else "sim",
|
||||
"sim": s.is_sim,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runtime")
|
||||
async def get_runtime_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return _runtime_payload()
|
||||
|
||||
|
||||
@router.put("/runtime")
|
||||
async def put_runtime_settings(
|
||||
body: RuntimeSettingsBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
db = get_db()
|
||||
s = get_settings()
|
||||
cur_mode = "SIM" if s.is_sim else "LIVE"
|
||||
new_mode = (body.mode or cur_mode).strip().upper()
|
||||
if new_mode not in ("SIM", "LIVE"):
|
||||
raise HTTPException(status_code=400, detail="mode 须为 SIM 或 LIVE")
|
||||
|
||||
if new_mode != cur_mode and Matcher(db).has_open_position():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="有未平仓,无法切换 SIM/LIVE;请先平仓后再改",
|
||||
)
|
||||
|
||||
if new_mode == "LIVE" and cur_mode != "LIVE":
|
||||
if not body.confirm_live:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="切换到 LIVE 须二次确认(confirm_live=true)",
|
||||
)
|
||||
phrase = (body.confirm_live_phrase or "").strip()
|
||||
if phrase != "LIVE":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="切换到 LIVE 须在 confirm_live_phrase 传入 LIVE",
|
||||
)
|
||||
secret = (s.auth_secret or "").strip()
|
||||
if not secret or secret == "change-me-eth-hedge-sim-secret":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="切到 LIVE 前请在 .env 设置非默认 AUTH_SECRET",
|
||||
)
|
||||
|
||||
updates: dict[str, str] = {}
|
||||
if body.okx_api_key is not None and body.okx_api_key.strip():
|
||||
updates["OKX_API_KEY"] = body.okx_api_key.strip()
|
||||
if body.okx_api_secret is not None and body.okx_api_secret.strip():
|
||||
updates["OKX_API_SECRET"] = body.okx_api_secret.strip()
|
||||
if body.okx_api_passphrase is not None and body.okx_api_passphrase.strip():
|
||||
updates["OKX_API_PASSPHRASE"] = body.okx_api_passphrase.strip()
|
||||
if body.binance_api_key is not None and body.binance_api_key.strip():
|
||||
updates["BINANCE_API_KEY"] = body.binance_api_key.strip()
|
||||
if body.binance_api_secret is not None and body.binance_api_secret.strip():
|
||||
updates["BINANCE_API_SECRET"] = body.binance_api_secret.strip()
|
||||
|
||||
if new_mode != cur_mode:
|
||||
updates["MODE"] = new_mode
|
||||
|
||||
if updates:
|
||||
upsert_env_keys(updates)
|
||||
|
||||
s2 = get_settings()
|
||||
if new_mode == "LIVE":
|
||||
rt = load_runtime_settings()
|
||||
if rt.exchange == "okx" and not okx_keys_configured(s2):
|
||||
if cur_mode == "SIM":
|
||||
upsert_env_keys({"MODE": "SIM"})
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="切到 LIVE 前请先配置完整 OKX API Key/Secret/Passphrase",
|
||||
)
|
||||
if rt.exchange == "binance" and not binance_keys_configured(s2):
|
||||
if cur_mode == "SIM":
|
||||
upsert_env_keys({"MODE": "SIM"})
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="切到 LIVE 前请先配置完整币安 API Key/Secret",
|
||||
)
|
||||
|
||||
# 热切 LIVE:强制暂停策略(对齐冷启动护栏;运行中禁止人工开平仓基线)
|
||||
if new_mode == "LIVE" and cur_mode != "LIVE":
|
||||
db.execute(
|
||||
"UPDATE strategy_state SET running=0, phase=?, last_error=? WHERE id=1",
|
||||
("paused", "已切换 LIVE,策略已强制暂停;确认就绪后再启动"),
|
||||
)
|
||||
try:
|
||||
from ..strategy import get_engine
|
||||
|
||||
# 同步停循环标志(pause 为 async,此处只写状态)
|
||||
get_engine()._set_state(
|
||||
running=0,
|
||||
phase="paused",
|
||||
last_error="已切换 LIVE,策略已强制暂停;确认就绪后再启动",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from ..strategy import get_engine
|
||||
|
||||
get_engine().refresh_executor()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _runtime_payload()
|
||||
|
||||
|
||||
class NotifySettingsBody(BaseModel):
|
||||
enabled: bool | None = None
|
||||
webhook_url: str | None = None
|
||||
machine_name: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
def _notify_payload() -> dict:
|
||||
from ..notify import wecom
|
||||
from ..env_store import mask_secret
|
||||
|
||||
url = wecom.wecom_webhook_url()
|
||||
return {
|
||||
"enabled": wecom.wecom_enabled(),
|
||||
"webhook_configured": bool(url),
|
||||
"webhook_url_masked": mask_secret(url) if url else None,
|
||||
"venue_label": wecom.venue_label(),
|
||||
"machine_name": wecom.wecom_machine_name() or "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/notify")
|
||||
async def get_notify_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return _notify_payload()
|
||||
|
||||
|
||||
@router.put("/notify")
|
||||
async def put_notify_settings(
|
||||
body: NotifySettingsBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
updates: dict[str, str] = {}
|
||||
if body.enabled is not None:
|
||||
updates["WECOM_ENABLED"] = "1" if body.enabled else "0"
|
||||
get_db().set_setting("wecom_enabled", "1" if body.enabled else "0")
|
||||
if body.webhook_url is not None and body.webhook_url.strip():
|
||||
updates["WECOM_WEBHOOK_URL"] = body.webhook_url.strip()
|
||||
get_db().set_setting("wecom_webhook_url", body.webhook_url.strip())
|
||||
if body.machine_name is not None:
|
||||
name = body.machine_name.strip()[:64]
|
||||
updates["WECOM_MACHINE_NAME"] = name
|
||||
get_db().set_setting("wecom_machine_name", name)
|
||||
if updates:
|
||||
upsert_env_keys(updates)
|
||||
get_settings.cache_clear()
|
||||
return _notify_payload()
|
||||
|
||||
|
||||
@router.post("/notify/test")
|
||||
async def test_notify(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
from ..notify import wecom
|
||||
|
||||
ok, msg = wecom.notify_test()
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"推送失败: {msg}")
|
||||
return {"ok": True, "detail": "测试消息已发送", **_notify_payload()}
|
||||
|
||||
+102
-6
@@ -6,10 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..env_store import live_ready
|
||||
from ..live import get_executor
|
||||
from ..market import get_gateway
|
||||
from ..models.db import get_db
|
||||
from ..sim.ledger import Ledger
|
||||
from ..sim.matcher import Matcher
|
||||
from ..strategy.clock import can_open_new, window_key
|
||||
from ..strategy.group import next_group_id
|
||||
from .auth import require_user
|
||||
@@ -29,7 +30,7 @@ async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
|
||||
@router.get("/position")
|
||||
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
m = Matcher()
|
||||
m = get_executor()
|
||||
return {"position": m.current_position(), "unrealized": m.unrealized()}
|
||||
|
||||
|
||||
@@ -38,10 +39,28 @@ async def sim_open_group(
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
body: ManualOpenBody | None = None,
|
||||
) -> dict:
|
||||
if Matcher().has_open_position():
|
||||
ok, reason = live_ready()
|
||||
if not get_settings().is_sim and not ok:
|
||||
raise HTTPException(status_code=400, detail=reason)
|
||||
from ..strategy import get_engine
|
||||
|
||||
st = get_engine().state()
|
||||
if st.get("running"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="策略自动运行中,禁止手动开仓;请先暂停",
|
||||
)
|
||||
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
|
||||
)
|
||||
ex = get_executor()
|
||||
if ex.has_open_position():
|
||||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||
s = get_settings()
|
||||
skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends)
|
||||
ledger = Ledger()
|
||||
skip_weekends = ledger.get_setting_bool("skip_weekends", s.skip_weekends)
|
||||
if not can_open_new(skip_weekends=skip_weekends):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -54,6 +73,22 @@ async def sim_open_group(
|
||||
status_code=409,
|
||||
detail="无合格期权:请检查剩余时长、ATM开仓偏差(若已开启)与杠杆(现价/卖一)",
|
||||
)
|
||||
one_expiry = ledger.get_setting_bool("one_expiry_per_day", s.one_expiry_per_day)
|
||||
if one_expiry:
|
||||
from ..strategy.clock import (
|
||||
expiry_blocked_by_one_per_day,
|
||||
used_expiry_ymds,
|
||||
)
|
||||
|
||||
used = used_expiry_ymds(get_db())
|
||||
if expiry_blocked_by_one_per_day(pick.pair.expiry_ymd, used, enabled=True):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"每个到期只开一次:已用过 {pick.pair.expiry_ymd},"
|
||||
"请等下一到期日"
|
||||
),
|
||||
)
|
||||
|
||||
force = (body.force_option_side if body else None) or None
|
||||
if force in ("call", "put"):
|
||||
@@ -78,14 +113,34 @@ async def sim_open_group(
|
||||
option_inst = (
|
||||
pick.pair.call_inst_id if option_side == "call" else pick.pair.put_inst_id
|
||||
)
|
||||
# 强制方向时用该腿卖一估权利金;否则用选向结果
|
||||
sizing_ask = float(
|
||||
option_ask
|
||||
if force in ("call", "put")
|
||||
else pick.option_ask
|
||||
)
|
||||
|
||||
wkey = window_key()
|
||||
db = get_db()
|
||||
from ..strategy.open_pipeline import size_and_gate
|
||||
|
||||
prep = size_and_gate(
|
||||
index_px=float(pick.underlying_px),
|
||||
option_ask=sizing_ask,
|
||||
db=db,
|
||||
)
|
||||
if not prep.ok:
|
||||
raise HTTPException(status_code=409, detail=prep.detail)
|
||||
|
||||
count = len(
|
||||
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
||||
)
|
||||
gid = next_group_id(count)
|
||||
r = Matcher().open_group(
|
||||
engine = get_engine()
|
||||
async with engine._lock:
|
||||
if ex.has_open_position():
|
||||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||
r = ex.open_group(
|
||||
group_id=gid,
|
||||
bias=bias,
|
||||
option_side=option_side,
|
||||
@@ -97,6 +152,24 @@ async def sim_open_group(
|
||||
)
|
||||
if not r.ok:
|
||||
raise HTTPException(status_code=400, detail=r.detail)
|
||||
try:
|
||||
from ..notify import wecom
|
||||
|
||||
wecom.notify_open(
|
||||
group_id=gid,
|
||||
detail=r.detail,
|
||||
extra={
|
||||
"bias": bias,
|
||||
"option_side": option_side,
|
||||
"perp_side": perp_side,
|
||||
"option_inst_id": option_inst,
|
||||
"strike": pick.pair.strike,
|
||||
"expiry_ymd": pick.pair.expiry_ymd,
|
||||
**(r.data or {}),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": True,
|
||||
**(r.data or {}),
|
||||
@@ -110,9 +183,32 @@ async def sim_open_group(
|
||||
|
||||
@router.post("/close-group")
|
||||
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
r = Matcher().close_group(reason="manual")
|
||||
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
|
||||
)
|
||||
from ..strategy import get_engine
|
||||
|
||||
engine = get_engine()
|
||||
# 与策略引擎共用锁,避免与自动平仓/开仓竞态
|
||||
async with engine._lock:
|
||||
r = get_executor().close_group(reason="manual")
|
||||
if not r.ok and not r.liquidity_wait:
|
||||
raise HTTPException(status_code=400, detail=r.detail)
|
||||
if r.ok:
|
||||
# 与自动/紧急全平一致:成功全平后进入组间休息
|
||||
engine.enter_rest_after_close()
|
||||
try:
|
||||
from ..notify import wecom
|
||||
|
||||
wecom.notify_close(
|
||||
reason="manual",
|
||||
detail=r.detail,
|
||||
data=r.data or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": r.ok,
|
||||
"liquidity_wait": r.liquidity_wait,
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.db import get_db
|
||||
from ..sim.pnl import summarize_fills_pnl
|
||||
from .auth import require_user
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
db = get_db()
|
||||
rows = db.fetchall("SELECT * FROM groups WHERE status='closed'")
|
||||
def _current_loss_streak(curve: list[dict[str, Any]]) -> int:
|
||||
"""从最近已平仓组往前数连续亏损次数。"""
|
||||
streak = 0
|
||||
for item in reversed(curve):
|
||||
if float(item.get("realized_pnl") or 0) < 0:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
return streak
|
||||
|
||||
|
||||
def build_stats_summary(db: Any | None = None) -> dict[str, Any]:
|
||||
"""已平仓组汇总(策略页统计 / 中控 Fleet 共用)。"""
|
||||
database = db or get_db()
|
||||
s = get_settings()
|
||||
mode = "LIVE" if not s.is_sim else "SIM"
|
||||
rows = database.fetchall("SELECT * FROM groups WHERE status='closed'")
|
||||
n = len(rows)
|
||||
wins = sum(1 for r in rows if float(r["realized_pnl"] or 0) > 0)
|
||||
total_pnl = sum(float(r["realized_pnl"] or 0) for r in rows)
|
||||
total_fees = sum(float(r["fees"] or 0) for r in rows)
|
||||
total_slip = sum(float(r["slip_cost"] or 0) for r in rows)
|
||||
pnls = [float(r["realized_pnl"] or 0) for r in rows]
|
||||
max_single_loss = min(pnls) if pnls else 0.0
|
||||
if max_single_loss > 0:
|
||||
max_single_loss = 0.0
|
||||
|
||||
fees_perp = 0.0
|
||||
fees_option = 0.0
|
||||
total_slip = 0.0
|
||||
for r in rows:
|
||||
fills = database.fetchall(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
|
||||
(r["group_id"],),
|
||||
)
|
||||
summary = summarize_fills_pnl(list(fills))
|
||||
fees_perp += float(summary.get("fees_perp") or 0)
|
||||
fees_option += float(summary.get("fees_option") or 0)
|
||||
# LIVE 不展示、不计入滑点;按组成交模式判断(可混有历史 SIM 组)
|
||||
exec_mode = str(r["exec_mode"] or mode).upper()
|
||||
if exec_mode != "LIVE":
|
||||
total_slip += float(summary.get("slip_total") or 0)
|
||||
|
||||
total_fees = fees_perp + fees_option
|
||||
reasons: dict[str, int] = {}
|
||||
for r in rows:
|
||||
k = str(r["close_reason"] or "unknown")
|
||||
@@ -31,13 +66,47 @@ async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
}
|
||||
for r in sorted(rows, key=lambda x: int(x["close_at_ms"] or 0))
|
||||
]
|
||||
latest_funds = 0.0
|
||||
try:
|
||||
from ..sim.funds_wallets import FundsWallets
|
||||
|
||||
latest_funds = float(FundsWallets(database).total_usdt_equiv())
|
||||
except Exception:
|
||||
try:
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
latest_funds = float(Ledger(database).snapshot().get("equity") or 0)
|
||||
except Exception:
|
||||
latest_funds = 0.0
|
||||
|
||||
try:
|
||||
initial_funds = float(
|
||||
database.get_setting("initial_equity", str(s.initial_equity))
|
||||
or s.initial_equity
|
||||
)
|
||||
except Exception:
|
||||
initial_funds = float(s.initial_equity)
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"show_slip": mode == "SIM",
|
||||
"groups": n,
|
||||
"wins": wins,
|
||||
"win_rate": (wins / n) if n else 0.0,
|
||||
"total_pnl": total_pnl,
|
||||
"fees_perp": fees_perp,
|
||||
"fees_option": fees_option,
|
||||
"total_fees": total_fees,
|
||||
"total_slip": total_slip,
|
||||
"total_slip": total_slip if mode == "SIM" else 0.0,
|
||||
"close_reasons": reasons,
|
||||
"equity_curve": curve,
|
||||
"initial_funds": initial_funds,
|
||||
"latest_funds": latest_funds,
|
||||
"max_single_loss": max_single_loss,
|
||||
"loss_streak": _current_loss_streak(curve),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return build_stats_summary()
|
||||
|
||||
+516
-5
@@ -5,7 +5,9 @@ from typing import Annotated, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..models.db import get_db
|
||||
from ..sim.pnl import summarize_fills_pnl
|
||||
from .auth import require_user
|
||||
from .hold_timing import hold_timing
|
||||
|
||||
router = APIRouter(prefix="/api/trades", tags=["trades"])
|
||||
|
||||
@@ -14,16 +16,463 @@ def _row(r: Any) -> dict:
|
||||
return dict(r)
|
||||
|
||||
|
||||
def _is_oo_group(g: dict) -> bool:
|
||||
return (
|
||||
str(g.get("hedge_mode") or "") == "option_option"
|
||||
or bool(g.get("option2_inst_id"))
|
||||
or str(g.get("bias") or "") == "option_option"
|
||||
)
|
||||
|
||||
|
||||
def _infer_settle_index(g: dict, fills: list) -> float | None:
|
||||
"""优先库内 settle_index_px;否则用「实值腿」成交反推;再否则公开指数近似。"""
|
||||
settle_index = g.get("settle_index_px")
|
||||
if settle_index is not None:
|
||||
try:
|
||||
v = float(settle_index)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
candidates: list[float] = []
|
||||
for raw in fills:
|
||||
f = dict(raw) if not isinstance(raw, dict) else raw
|
||||
if str(f.get("action") or "") != "close":
|
||||
continue
|
||||
leg = str(f.get("leg") or "")
|
||||
if leg not in ("option", "option2"):
|
||||
continue
|
||||
if abs(float(f.get("slip") or 0)) > 1e-12:
|
||||
continue
|
||||
try:
|
||||
px = float(f.get("fill_px") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# 虚值到期 fill=0:k+0 / k-0 会得到行权价,不是真实结算指数
|
||||
if px <= 1e-9:
|
||||
continue
|
||||
if leg == "option":
|
||||
strike = g.get("strike")
|
||||
side = str(g.get("option_side") or "").lower()
|
||||
else:
|
||||
strike = g.get("strike2")
|
||||
side = str(g.get("option2_side") or "put").lower()
|
||||
if strike is None:
|
||||
continue
|
||||
try:
|
||||
k = float(strike)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if side in ("call", "c"):
|
||||
candidates.append(k + px)
|
||||
elif side in ("put", "p"):
|
||||
candidates.append(k - px)
|
||||
if candidates:
|
||||
return round(sum(candidates) / len(candidates), 4)
|
||||
|
||||
try:
|
||||
from .public_index import maybe_public_settle_index
|
||||
|
||||
pub = maybe_public_settle_index(g)
|
||||
if pub is not None and pub > 0:
|
||||
return round(float(pub), 4)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _overlay_expiry_intrinsic_fills(
|
||||
g: dict, fills: list, settle_index: float | None
|
||||
) -> list:
|
||||
"""到期且已有结算指数:close 成交按内在价值覆盖展示/盈亏(响应层)。
|
||||
|
||||
交易所账单偶发落成近 0 价(如 0.2),与内在价值(如 42.2)严重不符时
|
||||
若只覆盖 fill≈0,Put 仍会按错误价算成巨亏。
|
||||
"""
|
||||
if settle_index is None or settle_index <= 0:
|
||||
return fills
|
||||
if str(g.get("close_reason") or "") != "expiry":
|
||||
return fills
|
||||
out: list = []
|
||||
changed = False
|
||||
for raw in fills:
|
||||
f = dict(raw) if not isinstance(raw, dict) else dict(raw)
|
||||
if str(f.get("action") or "") == "close" and str(f.get("leg") or "") in (
|
||||
"option",
|
||||
"option2",
|
||||
):
|
||||
try:
|
||||
px = float(f.get("fill_px") or 0)
|
||||
except (TypeError, ValueError):
|
||||
px = 0.0
|
||||
leg = str(f.get("leg") or "")
|
||||
if leg == "option":
|
||||
strike = g.get("strike")
|
||||
side = str(g.get("option_side") or "").lower()
|
||||
else:
|
||||
strike = g.get("strike2")
|
||||
side = str(g.get("option2_side") or "put").lower()
|
||||
if strike is not None:
|
||||
try:
|
||||
intrinsic = float(
|
||||
_intrinsic(side, float(settle_index), float(strike))
|
||||
)
|
||||
# 与内在价值偏差超过 0.5 USDT(或相对 5%)则覆盖
|
||||
tol = max(0.5, abs(intrinsic) * 0.05)
|
||||
if abs(px - intrinsic) > tol:
|
||||
qty = float(f.get("qty_eth") or 0)
|
||||
f["fill_px"] = intrinsic
|
||||
f["base_px"] = intrinsic
|
||||
f["notional"] = intrinsic * qty
|
||||
f["slip"] = 0.0
|
||||
f["_overlay_intrinsic"] = True
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out.append(f)
|
||||
return out if changed else fills
|
||||
|
||||
|
||||
# 兼容旧测试名
|
||||
_overlay_expiry_zero_fills = _overlay_expiry_intrinsic_fills
|
||||
|
||||
|
||||
def _intrinsic(side: str, settle_index: float, strike: float) -> float:
|
||||
s = str(side or "").lower()
|
||||
if s in ("call", "c"):
|
||||
return max(settle_index - strike, 0.0)
|
||||
if s in ("put", "p"):
|
||||
return max(strike - settle_index, 0.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _expiry_settle_info(g: dict, fills: list) -> dict | None:
|
||||
"""到期结算口径:期权价 = 内在价值(指数 vs 行权价),非盘口。"""
|
||||
if str(g.get("close_reason") or "") != "expiry":
|
||||
return None
|
||||
settle_index = _infer_settle_index(g, fills)
|
||||
strike = g.get("strike")
|
||||
side = str(g.get("option_side") or "").lower()
|
||||
intrinsic = None
|
||||
if settle_index is not None and strike is not None:
|
||||
intrinsic = _intrinsic(side, float(settle_index), float(strike))
|
||||
formula = (
|
||||
"Call: max(指数−行权价, 0)"
|
||||
if side in ("call", "c")
|
||||
else "Put: max(行权价−指数, 0)"
|
||||
if side in ("put", "p")
|
||||
else ""
|
||||
)
|
||||
is_oo = _is_oo_group(g)
|
||||
out: dict[str, Any] = {
|
||||
"settle_index_px": float(settle_index) if settle_index is not None else None,
|
||||
"strike": float(strike) if strike is not None else None,
|
||||
"intrinsic": intrinsic,
|
||||
"formula": formula,
|
||||
"perp_note": (
|
||||
"期期无永续腿;两腿均按内在价值结算"
|
||||
if is_oo
|
||||
else "永续仍按市价平仓(非指数交割)"
|
||||
),
|
||||
"is_oo": is_oo,
|
||||
}
|
||||
if is_oo:
|
||||
strike2 = g.get("strike2")
|
||||
side2 = str(g.get("option2_side") or "put").lower()
|
||||
intrinsic2 = None
|
||||
if settle_index is not None and strike2 is not None:
|
||||
intrinsic2 = _intrinsic(side2, float(settle_index), float(strike2))
|
||||
out["strike2"] = float(strike2) if strike2 is not None else None
|
||||
out["intrinsic2"] = intrinsic2
|
||||
out["formula2"] = (
|
||||
"Put: max(行权价−指数, 0)"
|
||||
if side2 in ("put", "p")
|
||||
else "Call: max(指数−行权价, 0)"
|
||||
if side2 in ("call", "c")
|
||||
else ""
|
||||
)
|
||||
out["option2_side"] = side2
|
||||
return out
|
||||
|
||||
|
||||
def _close_index_px(g: dict, fills: list) -> float | None:
|
||||
"""平仓时标的指数:优先库内 settle;到期才用实值腿反推;否则永续平仓价。"""
|
||||
raw = g.get("settle_index_px")
|
||||
if raw is not None:
|
||||
try:
|
||||
v = float(raw)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# 仅到期:期权平仓价=内在价值,可反推指数;中途卖出的权利金不能当指数
|
||||
if str(g.get("close_reason") or "") == "expiry":
|
||||
inferred = _infer_settle_index(g, fills)
|
||||
if inferred is not None and inferred > 0:
|
||||
return inferred
|
||||
for row in fills:
|
||||
f = dict(row) if not isinstance(row, dict) else row
|
||||
if str(f.get("leg") or "") == "perp" and str(f.get("action") or "") == "close":
|
||||
try:
|
||||
v = float(f.get("fill_px") or 0)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _move_points(g: dict, fills: list) -> float | None:
|
||||
"""开仓指数 → 平仓指数的点数(带符号:上涨为正)。持仓中无平仓价则空。"""
|
||||
entry = g.get("entry_index_px")
|
||||
if entry is None:
|
||||
return None
|
||||
try:
|
||||
e = float(entry)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if e <= 0:
|
||||
return None
|
||||
close_px = _close_index_px(g, fills)
|
||||
if close_px is None:
|
||||
return None
|
||||
return round(float(close_px) - e, 2)
|
||||
|
||||
|
||||
def _option_entry_px(fills: list, *, leg: str = "option") -> float | None:
|
||||
for row in fills:
|
||||
f = dict(row) if not isinstance(row, dict) else row
|
||||
if str(f.get("leg") or "") != leg or str(f.get("action") or "") != "open":
|
||||
continue
|
||||
try:
|
||||
v = float(f.get("fill_px") or 0)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _option_leverage_for_leg(
|
||||
g: dict, fills: list, *, leg: str = "option"
|
||||
) -> float | None:
|
||||
"""开仓期权杠杆 = 开仓指数 ÷ 期权开仓均价(与选约门限口径一致)。"""
|
||||
from ..strategy.selection import option_leverage
|
||||
|
||||
try:
|
||||
entry = float(g.get("entry_index_px") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
opt_px = _option_entry_px(fills, leg=leg)
|
||||
if entry <= 0 or opt_px is None:
|
||||
return None
|
||||
lev = option_leverage(entry, opt_px)
|
||||
return round(float(lev), 1) if lev is not None else None
|
||||
|
||||
|
||||
def _enrich_group(g: dict, fills: list) -> dict:
|
||||
is_oo = _is_oo_group(g)
|
||||
g["is_oo"] = is_oo
|
||||
settle = _infer_settle_index(g, fills)
|
||||
view_fills = _overlay_expiry_intrinsic_fills(g, fills, settle)
|
||||
overlaid = any(
|
||||
isinstance(f, dict) and f.get("_overlay_intrinsic") for f in view_fills
|
||||
)
|
||||
summary = summarize_fills_pnl(view_fills)
|
||||
# LIVE 且未做内在价值覆盖:优先 groups.realized_pnl(含资金费)
|
||||
if (
|
||||
not overlaid
|
||||
and str(g.get("exec_mode") or "").upper() == "LIVE"
|
||||
and g.get("realized_pnl") is not None
|
||||
):
|
||||
summary = dict(summary)
|
||||
summary["net_pnl"] = float(g["realized_pnl"])
|
||||
if g.get("funding_usdt") is not None:
|
||||
summary["funding_usdt"] = float(g["funding_usdt"])
|
||||
summary["pnl_source"] = "live_exchange"
|
||||
elif overlaid:
|
||||
summary = dict(summary)
|
||||
summary["pnl_source"] = "expiry_intrinsic_overlay"
|
||||
elif (
|
||||
is_oo
|
||||
and g.get("realized_pnl") is not None
|
||||
and (
|
||||
summary.get("option_pnl") is None
|
||||
or summary.get("option2_pnl") is None
|
||||
)
|
||||
):
|
||||
summary = dict(summary)
|
||||
summary["net_pnl"] = float(g["realized_pnl"])
|
||||
summary["pnl_source"] = "group_realized"
|
||||
g["pnl_summary"] = summary
|
||||
if summary.get("net_pnl") is not None:
|
||||
g["net_pnl"] = summary["net_pnl"]
|
||||
elif g.get("realized_pnl") is not None:
|
||||
g["net_pnl"] = float(g["realized_pnl"])
|
||||
prem1 = float(g.get("initial_premium") or 0)
|
||||
prem2 = float(g.get("initial_premium2") or 0) if is_oo else 0.0
|
||||
g["total_initial_premium"] = prem1 + prem2 if is_oo else prem1
|
||||
g.update(hold_timing(g, fills))
|
||||
if settle is not None and g.get("settle_index_px") is None:
|
||||
g["settle_index_px"] = float(settle)
|
||||
info = _expiry_settle_info(g, view_fills)
|
||||
if info:
|
||||
g["expiry_settle"] = info
|
||||
if g.get("settle_index_px") is None and info.get("settle_index_px") is not None:
|
||||
g["settle_index_px"] = info["settle_index_px"]
|
||||
mp = _move_points(g, view_fills)
|
||||
g["move_points"] = mp
|
||||
g["close_index_px"] = _close_index_px(g, view_fills)
|
||||
g["option_leverage"] = _option_leverage_for_leg(g, fills, leg="option")
|
||||
if is_oo:
|
||||
g["option2_leverage"] = _option_leverage_for_leg(g, fills, leg="option2")
|
||||
g["_view_fills"] = view_fills
|
||||
g["_overlay_settle"] = float(settle) if settle is not None else None
|
||||
g["_overlaid"] = overlaid
|
||||
return g
|
||||
|
||||
|
||||
def persist_expiry_overlay_if_needed(db: Any, g: dict, raw_fills: list) -> None:
|
||||
"""把内在价值覆盖写回库:fills / realized_pnl / settle_index,并补本地账本差额。"""
|
||||
if not g.get("_overlaid"):
|
||||
return
|
||||
view_fills = g.get("_view_fills") or []
|
||||
net = g.get("net_pnl")
|
||||
if net is None:
|
||||
return
|
||||
settle = g.get("_overlay_settle")
|
||||
group_id = str(g.get("group_id") or "")
|
||||
if not group_id:
|
||||
return
|
||||
old_net = float(g.get("realized_pnl") or 0)
|
||||
# 已对齐则跳过(避免每次列表刷库)
|
||||
if abs(old_net - float(net)) < 0.02:
|
||||
stored = g.get("settle_index_px")
|
||||
try:
|
||||
if settle is None or (
|
||||
stored is not None and abs(float(stored) - float(settle)) < 0.05
|
||||
):
|
||||
# 仍可能 fills 未写回;检查是否还有 overlay 标记需要落库
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
raw_close = {
|
||||
str(dict(f).get("leg")): dict(f)
|
||||
for f in raw_fills
|
||||
if str(dict(f).get("action") or "") == "close"
|
||||
and str(dict(f).get("leg") or "") in ("option", "option2")
|
||||
}
|
||||
cash_delta = 0.0
|
||||
updates: list[tuple] = []
|
||||
for vf in view_fills:
|
||||
if not isinstance(vf, dict) or not vf.get("_overlay_intrinsic"):
|
||||
continue
|
||||
leg = str(vf.get("leg") or "")
|
||||
old = raw_close.get(leg)
|
||||
if not old or old.get("id") is None:
|
||||
continue
|
||||
try:
|
||||
old_px = float(old.get("fill_px") or 0)
|
||||
new_px = float(vf.get("fill_px") or 0)
|
||||
qty = float(vf.get("qty_eth") or old.get("qty_eth") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(old_px - new_px) <= 1e-9:
|
||||
continue
|
||||
cash_delta += (new_px - old_px) * qty
|
||||
updates.append(
|
||||
(
|
||||
new_px,
|
||||
new_px,
|
||||
new_px * qty,
|
||||
int(old["id"]),
|
||||
)
|
||||
)
|
||||
|
||||
if not updates and abs(old_net - float(net)) < 0.02:
|
||||
# 只缺 settle
|
||||
if settle is None:
|
||||
return
|
||||
try:
|
||||
if g.get("settle_index_px") is not None and abs(
|
||||
float(g["settle_index_px"]) - float(settle)
|
||||
) < 0.05:
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
with db._lock:
|
||||
for base, fill, notional, fid in updates:
|
||||
db._conn.execute(
|
||||
"UPDATE fills SET base_px=?, fill_px=?, notional=?, slip=0 WHERE id=?",
|
||||
(base, fill, notional, fid),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""UPDATE groups SET realized_pnl=?,
|
||||
settle_index_px=COALESCE(?, settle_index_px),
|
||||
note=CASE
|
||||
WHEN instr(COALESCE(note,''), 'expiry_intrinsic_repair')>0 THEN note
|
||||
ELSE trim(COALESCE(note,'') || ' | expiry_intrinsic_repair')
|
||||
END
|
||||
WHERE group_id=? AND status='closed'""",
|
||||
(
|
||||
float(net),
|
||||
float(settle) if settle is not None else None,
|
||||
group_id,
|
||||
),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
if abs(cash_delta) > 1e-9:
|
||||
try:
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
Ledger(db).apply_cash(
|
||||
cash_delta,
|
||||
kind="repair_option_intrinsic",
|
||||
group_id=group_id,
|
||||
note=f"expiry intrinsic overlay cash_delta={cash_delta:.4f}",
|
||||
allow_negative=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 刷新内存中的 realized,供同请求后续使用
|
||||
g["realized_pnl"] = float(net)
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
rows = get_db().fetchall(
|
||||
"SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200"
|
||||
db = get_db()
|
||||
rows = db.fetchall("SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200")
|
||||
groups = []
|
||||
for r in rows:
|
||||
g = _row(r)
|
||||
fills = db.fetchall(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
|
||||
(g["group_id"],),
|
||||
)
|
||||
return {"groups": [_row(x) for x in rows]}
|
||||
gr = _enrich_group(g, fills)
|
||||
try:
|
||||
persist_expiry_overlay_if_needed(db, gr, list(fills))
|
||||
except Exception:
|
||||
pass
|
||||
gr.pop("_view_fills", None)
|
||||
gr.pop("_overlay_settle", None)
|
||||
gr.pop("_overlaid", None)
|
||||
groups.append(gr)
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}")
|
||||
async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
async def group_detail(
|
||||
group_id: str, _user: Annotated[str, Depends(require_user)]
|
||||
) -> dict:
|
||||
db = get_db()
|
||||
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
||||
if g is None:
|
||||
@@ -31,4 +480,66 @@ async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user
|
||||
fills = db.fetchall(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||
)
|
||||
return {"group": _row(g), "fills": [_row(x) for x in fills]}
|
||||
gr = _enrich_group(_row(g), fills)
|
||||
try:
|
||||
persist_expiry_overlay_if_needed(db, gr, list(fills))
|
||||
except Exception:
|
||||
pass
|
||||
view_fills = gr.pop("_view_fills", None) or fills
|
||||
gr.pop("_overlay_settle", None)
|
||||
gr.pop("_overlaid", None)
|
||||
return {
|
||||
"group": gr,
|
||||
"fills": [
|
||||
{
|
||||
k: v
|
||||
for k, v in (dict(x) if not isinstance(x, dict) else x).items()
|
||||
if k != "_overlay_intrinsic"
|
||||
}
|
||||
for x in view_fills
|
||||
],
|
||||
"pnl_summary": gr.get("pnl_summary"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(
|
||||
group_id: str, _user: Annotated[str, Depends(require_user)]
|
||||
) -> dict:
|
||||
"""删除一条已平仓交易记录(组/成交/残留/相关账本流水)。不回滚权益。"""
|
||||
db = get_db()
|
||||
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
||||
if g is None:
|
||||
raise HTTPException(status_code=404, detail="group not found")
|
||||
status = str(g["status"] or "").lower()
|
||||
if status != "closed":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="只能删除已平仓记录;持仓中或开仓中的组不可删",
|
||||
)
|
||||
pos = db.fetchone("SELECT group_id FROM positions WHERE id=1")
|
||||
if pos and str(pos["group_id"] or "") == group_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="当前持仓仍引用该组,不可删除",
|
||||
)
|
||||
with db._lock:
|
||||
db._conn.execute("DELETE FROM fills WHERE group_id=?", (group_id,))
|
||||
db._conn.execute(
|
||||
"DELETE FROM residual_options WHERE group_id=?", (group_id,)
|
||||
)
|
||||
db._conn.execute(
|
||||
"DELETE FROM ledger_entries WHERE group_id=?", (group_id,)
|
||||
)
|
||||
cur = db._conn.execute(
|
||||
"DELETE FROM groups WHERE group_id=? AND status='closed'",
|
||||
(group_id,),
|
||||
)
|
||||
if cur.rowcount <= 0:
|
||||
db._conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="删除失败:组状态已变更",
|
||||
)
|
||||
db._conn.commit()
|
||||
return {"ok": True, "group_id": group_id}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""数据库 + .env 备份 / 恢复(自动落盘到 /root/eth_hedge_backups)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import get_settings
|
||||
from .credentials import resolve_env_file_path
|
||||
from .models.db import Database, get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
DB_ARCNAME = "hedge.db"
|
||||
ENV_ARCNAME = ".env"
|
||||
BACKUP_PREFIX = "eth_hedge_backup_"
|
||||
DEFAULT_KEEP = 14
|
||||
DEFAULT_INTERVAL_HOURS = 24
|
||||
|
||||
|
||||
def resolve_backup_dir() -> Path:
|
||||
"""优先 BACKUP_DIR;Linux 默认 /root/eth_hedge_backups;否则用户目录。"""
|
||||
override = (os.environ.get("BACKUP_DIR") or "").strip()
|
||||
if override:
|
||||
p = Path(override)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
if os.name != "nt":
|
||||
root = Path("/root/eth_hedge_backups")
|
||||
try:
|
||||
if Path("/root").is_dir():
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
except OSError as e:
|
||||
logger.warning("cannot use /root/eth_hedge_backups: %s", e)
|
||||
p = Path.home() / "eth_hedge_backups"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def resolve_env_path() -> Path | None:
|
||||
p = resolve_env_file_path()
|
||||
return p
|
||||
|
||||
|
||||
def _settings_int(db: Database, key: str, default: int) -> int:
|
||||
raw = db.get_setting(key, str(default))
|
||||
try:
|
||||
return int(float(raw or default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _settings_bool(db: Database, key: str, default: bool) -> bool:
|
||||
raw = db.get_setting(key, "1" if default else "0")
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def backup_settings(db: Database | None = None) -> dict[str, Any]:
|
||||
db = db or get_db()
|
||||
return {
|
||||
"auto_enabled": _settings_bool(db, "backup_auto_enabled", True),
|
||||
"interval_hours": max(1, _settings_int(db, "backup_interval_hours", DEFAULT_INTERVAL_HOURS)),
|
||||
"keep_count": max(1, min(90, _settings_int(db, "backup_keep_count", DEFAULT_KEEP))),
|
||||
"last_at_ms": _settings_int(db, "backup_last_at_ms", 0) or None,
|
||||
"backup_dir": str(resolve_backup_dir()),
|
||||
}
|
||||
|
||||
|
||||
def list_backups() -> list[dict[str, Any]]:
|
||||
d = resolve_backup_dir()
|
||||
items: list[dict[str, Any]] = []
|
||||
if not d.is_dir():
|
||||
return items
|
||||
for p in sorted(d.glob(f"{BACKUP_PREFIX}*.zip"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
st = p.stat()
|
||||
items.append(
|
||||
{
|
||||
"name": p.name,
|
||||
"path": str(p),
|
||||
"size_bytes": st.st_size,
|
||||
"mtime_ms": int(st.st_mtime * 1000),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def prune_backups(keep: int | None = None) -> int:
|
||||
keep_n = keep if keep is not None else backup_settings()["keep_count"]
|
||||
items = list_backups()
|
||||
removed = 0
|
||||
for old in items[int(keep_n) :]:
|
||||
try:
|
||||
Path(old["path"]).unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
logger.warning("prune backup failed %s: %s", old.get("name"), e)
|
||||
return removed
|
||||
|
||||
|
||||
def _sqlite_snapshot(db: Database, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
with db._lock:
|
||||
dst = sqlite3.connect(str(dest))
|
||||
try:
|
||||
db._conn.backup(dst)
|
||||
dst.commit()
|
||||
finally:
|
||||
dst.close()
|
||||
|
||||
|
||||
def create_backup(
|
||||
*,
|
||||
db: Database | None = None,
|
||||
reason: str = "manual",
|
||||
include_env: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
db = db or get_db()
|
||||
backup_dir = resolve_backup_dir()
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
out = backup_dir / f"{BACKUP_PREFIX}{ts}.zip"
|
||||
env_path = resolve_env_path()
|
||||
db_path = Path(db.path)
|
||||
# 机内备份默认可含 .env;BACKUP_INCLUDE_ENV=0 可关。HTTP 下载另做剥离。
|
||||
if include_env is None:
|
||||
include_env = (os.environ.get("BACKUP_INCLUDE_ENV") or "1").strip() != "0"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="eth_hedge_bak_") as td:
|
||||
tmp_db = Path(td) / "hedge.db"
|
||||
_sqlite_snapshot(db, tmp_db)
|
||||
has_env = bool(include_env and env_path and env_path.is_file())
|
||||
manifest = {
|
||||
"product": "比特骆驼自动化对冲系统",
|
||||
"version": 1,
|
||||
"created_at_ms": int(time.time() * 1000),
|
||||
"reason": reason,
|
||||
"db_source": str(db_path),
|
||||
"env_source": str(env_path) if has_env else None,
|
||||
"has_env": has_env,
|
||||
"mode": get_settings().mode,
|
||||
"env_name": get_settings().env_name,
|
||||
}
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(tmp_db, DB_ARCNAME)
|
||||
if has_env and env_path is not None:
|
||||
zf.write(env_path, ENV_ARCNAME)
|
||||
zf.writestr(
|
||||
MANIFEST_NAME,
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
db.set_setting("backup_last_at_ms", str(int(time.time() * 1000)))
|
||||
prune_backups()
|
||||
st = out.stat()
|
||||
logger.info("backup created path=%s reason=%s size=%s has_env=%s", out, reason, st.st_size, has_env)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": out.name,
|
||||
"path": str(out),
|
||||
"size_bytes": st.st_size,
|
||||
"mtime_ms": int(st.st_mtime * 1000),
|
||||
"backup_dir": str(backup_dir),
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
|
||||
def materialize_download_zip(src: Path) -> Path:
|
||||
"""HTTP 下载用:去掉包内 .env,避免令牌失窃带走交易所密钥。"""
|
||||
fd, name = tempfile.mkstemp(prefix="eth_hedge_dl_", suffix=".zip")
|
||||
os.close(fd)
|
||||
dest = Path(name)
|
||||
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
|
||||
dest, "w", compression=zipfile.ZIP_DEFLATED
|
||||
) as zout:
|
||||
for info in zin.infolist():
|
||||
base = Path(info.filename).name
|
||||
if base == ENV_ARCNAME or base.endswith(".env"):
|
||||
continue
|
||||
zout.writestr(info, zin.read(info.filename))
|
||||
return dest
|
||||
|
||||
|
||||
def read_backup_file(name: str) -> Path:
|
||||
safe = Path(name).name
|
||||
if not safe.startswith(BACKUP_PREFIX) or not safe.endswith(".zip"):
|
||||
raise ValueError("非法备份文件名")
|
||||
path = resolve_backup_dir() / safe
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"备份不存在: {safe}")
|
||||
return path
|
||||
|
||||
|
||||
def _extract_backup_zip(zip_path: Path, dest_dir: Path) -> dict[str, Path]:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
if DB_ARCNAME not in names:
|
||||
# 兼容偶发相对路径
|
||||
db_candidates = [n for n in names if n.endswith("hedge.db") or n.endswith(".db")]
|
||||
if not db_candidates:
|
||||
raise ValueError("备份包缺少 hedge.db")
|
||||
db_name = db_candidates[0]
|
||||
else:
|
||||
db_name = DB_ARCNAME
|
||||
env_name = ENV_ARCNAME if ENV_ARCNAME in names else (
|
||||
next((n for n in names if n.endswith(".env") or n == "env"), None)
|
||||
)
|
||||
zf.extract(db_name, dest_dir)
|
||||
db_out = dest_dir / db_name
|
||||
if not db_out.is_file():
|
||||
# zip 内可能带目录
|
||||
found = list(dest_dir.rglob("*.db"))
|
||||
if not found:
|
||||
raise ValueError("解压后未找到数据库文件")
|
||||
db_out = found[0]
|
||||
env_out: Path | None = None
|
||||
if env_name:
|
||||
zf.extract(env_name, dest_dir)
|
||||
env_out = dest_dir / env_name
|
||||
if not env_out.is_file():
|
||||
found_env = list(dest_dir.rglob(".env")) + list(dest_dir.rglob("*.env"))
|
||||
env_out = found_env[0] if found_env else None
|
||||
return {"db": db_out, "env": env_out} # type: ignore[dict-item]
|
||||
|
||||
|
||||
def validate_backup_zip(zip_path: Path) -> dict[str, Any]:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
has_db = any(n.endswith("hedge.db") or n == DB_ARCNAME or n.endswith(".db") for n in names)
|
||||
has_env = any(n.endswith(".env") or n == ENV_ARCNAME for n in names)
|
||||
manifest = None
|
||||
if MANIFEST_NAME in names:
|
||||
try:
|
||||
manifest = json.loads(zf.read(MANIFEST_NAME).decode("utf-8"))
|
||||
except Exception:
|
||||
manifest = None
|
||||
if not has_db:
|
||||
raise ValueError("备份包无效:缺少数据库")
|
||||
return {"has_db": has_db, "has_env": has_env, "manifest": manifest, "files": names}
|
||||
|
||||
|
||||
def restore_from_zip(
|
||||
zip_path: Path,
|
||||
*,
|
||||
db: Database | None = None,
|
||||
make_safety_backup: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""用备份包覆盖当前 hedge.db 与 .env。调用方应在此前后暂停策略并准备重启进程。"""
|
||||
db = db or get_db()
|
||||
info = validate_backup_zip(zip_path)
|
||||
safety = None
|
||||
if make_safety_backup:
|
||||
try:
|
||||
safety = create_backup(db=db, reason="pre_restore")
|
||||
except Exception as e:
|
||||
logger.warning("pre_restore backup failed: %s", e)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="eth_hedge_restore_") as td:
|
||||
extracted = _extract_backup_zip(zip_path, Path(td))
|
||||
src_db = extracted["db"]
|
||||
src_env = extracted.get("env")
|
||||
|
||||
db_path = Path(db.path)
|
||||
bak_live: Path | None = None
|
||||
db.close()
|
||||
|
||||
if db_path.is_file():
|
||||
bak_live = db_path.with_suffix(db_path.suffix + f".pre_restore_{int(time.time())}")
|
||||
shutil.copy2(db_path, bak_live)
|
||||
shutil.copy2(src_db, db_path)
|
||||
|
||||
env_written = None
|
||||
if src_env and Path(src_env).is_file():
|
||||
env_target = resolve_env_file_path()
|
||||
env_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if env_target.is_file():
|
||||
shutil.copy2(env_target, env_target.with_suffix(".env.pre_restore"))
|
||||
shutil.copy2(src_env, env_target)
|
||||
try:
|
||||
env_target.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
env_written = str(env_target)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"db_path": str(db_path),
|
||||
"env_path": env_written,
|
||||
"had_env_in_backup": bool(info.get("has_env")),
|
||||
"safety_backup": safety.get("name") if safety else None,
|
||||
"local_db_copy": str(bak_live) if bak_live and Path(bak_live).exists() else None,
|
||||
"restart_required": True,
|
||||
"manifest": info.get("manifest"),
|
||||
}
|
||||
|
||||
|
||||
_auto_task: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
async def auto_backup_loop() -> None:
|
||||
"""后台定时备份;默认开启。"""
|
||||
logger.info("auto backup loop started dir=%s", resolve_backup_dir())
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
db = get_db()
|
||||
cfg = backup_settings(db)
|
||||
if not cfg["auto_enabled"]:
|
||||
continue
|
||||
interval_ms = int(cfg["interval_hours"]) * 3600 * 1000
|
||||
last = int(cfg["last_at_ms"] or 0)
|
||||
now = int(time.time() * 1000)
|
||||
if last and now - last < interval_ms:
|
||||
continue
|
||||
await asyncio.to_thread(create_backup, db=db, reason="auto")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("auto backup tick failed")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def start_auto_backup_task() -> asyncio.Task[None]:
|
||||
global _auto_task
|
||||
if _auto_task is None or _auto_task.done():
|
||||
_auto_task = asyncio.create_task(auto_backup_loop(), name="auto-backup")
|
||||
return _auto_task
|
||||
|
||||
|
||||
async def stop_auto_backup_task() -> None:
|
||||
global _auto_task
|
||||
if _auto_task and not _auto_task.done():
|
||||
_auto_task.cancel()
|
||||
try:
|
||||
await _auto_task
|
||||
except Exception:
|
||||
pass
|
||||
_auto_task = None
|
||||
@@ -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 = ""
|
||||
@@ -33,6 +37,10 @@ class Settings(BaseSettings):
|
||||
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
|
||||
okx_http_proxy: str = ""
|
||||
|
||||
# 币安私有交易密钥(LIVE 真下单:fapi 永续 + eapi 期权)
|
||||
binance_api_key: str = ""
|
||||
binance_api_secret: str = ""
|
||||
|
||||
# 币安公共行情(SIM 只读)
|
||||
binance_fapi_base: str = "https://fapi.binance.com"
|
||||
binance_eapi_base: str = "https://eapi.binance.com"
|
||||
@@ -57,17 +65,61 @@ class Settings(BaseSettings):
|
||||
net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT)
|
||||
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
|
||||
rest_seconds: int = 300
|
||||
live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒)
|
||||
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
|
||||
# 每个到期只开一次(跨日):历史上已开过某 expiry_ymd 后不可再开同到期,只能盯下一档
|
||||
one_expiry_per_day: bool = True
|
||||
leverage: float = 3.0 # 永续杠杆
|
||||
# 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cash(OKX 逐仓/现金)
|
||||
perp_margin_mode: str = "cross"
|
||||
min_option_hours: float = 12.0 # 期权最小剩余小时
|
||||
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
|
||||
# 以损定仓权利金口径:actual=盘口卖一;selection=指数/选约杠杆(控节奏,默认)
|
||||
risk_leverage_basis: str = "selection"
|
||||
# 倍投:默认关;仅以损定仓+亏损幅度%且基础幅度≤3% 可开
|
||||
martingale_enabled: bool = False
|
||||
martingale_start_after_loss_days: int = 2 # 连续亏损 N 天后开始翻倍
|
||||
martingale_max_doubles: int = 3 # 最多翻倍次数(如 2→4→8→16 为 3 次)
|
||||
# 对冲模式:perp_option=永期(默认)| option_option=期期
|
||||
hedge_mode: str = "perp_option"
|
||||
oo_amplitude_pct: float = 1.5 # 振幅最大 %(过滤开启时:超过则不开)
|
||||
oo_amplitude_hours: float = 12.0 # 振幅回看小时(选约高低点;过滤关也用)
|
||||
oo_amplitude_filter_enabled: bool = False # 振幅过滤开关(默认关)
|
||||
oo_min_option_hours: float = 24.0 # 期期:最短剩余到期小时
|
||||
oo_min_leverage: float = 200.0 # 期期:单腿最低杠杆
|
||||
oo_reward_ratio: float = 2.0 # 盈亏比:出场目标 = 预算 × 比
|
||||
oo_budget_cushion: float = 0.92 # 定仓预留余地(用于权利金的预算比例)
|
||||
oo_strike_max_dev_pct: float = 1.0 # 虚值行权价相对振幅高低点最大偏离 %
|
||||
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
|
||||
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
|
||||
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
|
||||
fixed_direction_enabled: bool = False
|
||||
fixed_perp_side: str = "long" # long|short;long→买Put,short→买Call
|
||||
# 半自动:系统设置开关;首页授权后盯开盯平,平完停
|
||||
semi_auto_enabled: bool = False
|
||||
semi_armed: bool = False # 是否已授权本单(平完清零)
|
||||
semi_view_side: str = "long" # long=买Call+永续空;short=买Put+永续多
|
||||
semi_option_move_points: float = 50.0 # 顺方向:标的波动点数
|
||||
semi_perp_exit_unit: float = 5.0 # 净利出场基数(×k)
|
||||
semi_min_option_hours: float = 30.0
|
||||
semi_min_option_leverage: float = 200.0 # 虚值默认 200(虚值门仍强制≥180)
|
||||
semi_moneyness: str = "otm" # itm|atm|otm
|
||||
semi_otm_max_offset: float = 25.0 # 虚值 |K−S| 上限(可配)
|
||||
semi_perp_unit: float = 1.0 # 半自动永续:期权 配比分子
|
||||
semi_option_unit: float = 4.0 # 配比分母,默认 1:4
|
||||
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
|
||||
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
|
||||
residual_min_premium_pct: float = 20.0
|
||||
residual_close_check_sec: int = 300 # 残留巡检间隔(秒)
|
||||
perp_qty_eth: float = 1.0
|
||||
option_qty_eth: float = 2.0
|
||||
db_path: str = "" # empty -> backend/data/hedge.db
|
||||
|
||||
# 企业微信群机器人
|
||||
wecom_enabled: bool = False
|
||||
wecom_webhook_url: str = ""
|
||||
wecom_machine_name: str = ""
|
||||
|
||||
@property
|
||||
def is_sim(self) -> bool:
|
||||
return self.mode.strip().upper() != "LIVE"
|
||||
|
||||
@@ -20,6 +20,12 @@ def _env_paths() -> list[Path]:
|
||||
return [root / ".env", Path.cwd() / ".env", Path.cwd().parent / ".env"]
|
||||
|
||||
|
||||
def resolve_env_file_path() -> Path:
|
||||
"""第一个已存在的 .env;都不存在则返回仓库根 .env 路径(可写入)。"""
|
||||
paths = _env_paths()
|
||||
return next((p for p in paths if p.is_file()), paths[0])
|
||||
|
||||
|
||||
def _ensure_loaded() -> None:
|
||||
global _username, _password
|
||||
if _username is not None and _password is not None:
|
||||
@@ -38,11 +44,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 +61,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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""批量写入 .env 并刷新 Settings 缓存。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_settings
|
||||
from .credentials import upsert_env_file
|
||||
|
||||
|
||||
def upsert_env_keys(updates: dict[str, str]) -> Path | None:
|
||||
"""写入多项;空 value 跳过。返回最后写入的 .env 路径。"""
|
||||
target: Path | None = None
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
continue
|
||||
# 允许显式清空密钥(传空串以外的 sentinel 由调用方决定);空串表示跳过
|
||||
if value == "":
|
||||
continue
|
||||
target = upsert_env_file(key, value)
|
||||
get_settings.cache_clear()
|
||||
return target
|
||||
|
||||
|
||||
def mask_secret(raw: str | None, *, keep: int = 0) -> str | None:
|
||||
"""脱敏:仅返回是否已配置(不再泄露末尾字符)。"""
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
return "********"
|
||||
|
||||
|
||||
def okx_keys_configured(s=None) -> bool:
|
||||
st = s or get_settings()
|
||||
return bool(
|
||||
(st.okx_api_key or "").strip()
|
||||
and (st.okx_api_secret or "").strip()
|
||||
and (st.okx_api_passphrase or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def binance_keys_configured(s=None) -> bool:
|
||||
st = s or get_settings()
|
||||
return bool(
|
||||
(st.binance_api_key or "").strip() and (st.binance_api_secret or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def live_ready(*, exchange: str | None = None) -> tuple[bool, str]:
|
||||
"""LIVE 是否可下单。返回 (ok, reason)。"""
|
||||
from .exchange.runtime import load_runtime_settings, normalize_exchange_name
|
||||
|
||||
st = get_settings()
|
||||
if st.is_sim:
|
||||
return True, "sim"
|
||||
ex = normalize_exchange_name(exchange or load_runtime_settings().exchange)
|
||||
if ex == "binance":
|
||||
if not binance_keys_configured(st):
|
||||
return False, "币安 API Key/Secret 未配置"
|
||||
return True, "ok"
|
||||
if ex == "okx":
|
||||
if not okx_keys_configured(st):
|
||||
return False, "OKX API Key/Secret/Passphrase 未配置"
|
||||
return True, "ok"
|
||||
return False, f"未知交易所: {ex}"
|
||||
@@ -0,0 +1,185 @@
|
||||
"""指数/永续 K 线高低点:期期对冲振幅回看。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .okx.parse import safe_float as okx_safe_float
|
||||
from .binance.parse import safe_float as bn_safe_float
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AmplitudeHL:
|
||||
high: float
|
||||
low: float
|
||||
mid: float
|
||||
hours: float
|
||||
bar_count: int
|
||||
|
||||
@property
|
||||
def range_pct(self) -> float:
|
||||
if self.mid <= 0:
|
||||
return 0.0
|
||||
return (self.high - self.low) / self.mid * 100.0
|
||||
|
||||
|
||||
def _hl_from_okx_candles(rows: list[Any]) -> tuple[float, float] | None:
|
||||
"""OKX candle row: [ts, o, h, l, c, ...] newest first."""
|
||||
highs: list[float] = []
|
||||
lows: list[float] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, (list, tuple)) or len(row) < 5:
|
||||
continue
|
||||
h = okx_safe_float(row[2])
|
||||
lo = okx_safe_float(row[3])
|
||||
if h is None or lo is None or h <= 0 or lo <= 0:
|
||||
continue
|
||||
highs.append(float(h))
|
||||
lows.append(float(lo))
|
||||
if not highs or not lows:
|
||||
return None
|
||||
return max(highs), min(lows)
|
||||
|
||||
|
||||
def _hl_from_binance_klines(rows: list[Any]) -> tuple[float, float] | None:
|
||||
"""Binance kline: [openTime, o, h, l, c, ...] oldest first."""
|
||||
highs: list[float] = []
|
||||
lows: list[float] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, (list, tuple)) or len(row) < 5:
|
||||
continue
|
||||
h = bn_safe_float(row[2])
|
||||
lo = bn_safe_float(row[3])
|
||||
if h is None or lo is None or h <= 0 or lo <= 0:
|
||||
continue
|
||||
highs.append(float(h))
|
||||
lows.append(float(lo))
|
||||
if not highs or not lows:
|
||||
return None
|
||||
return max(highs), min(lows)
|
||||
|
||||
|
||||
def fetch_okx_amplitude_hl(
|
||||
*,
|
||||
inst_id: str,
|
||||
hours: float,
|
||||
base_url: str = "https://www.okx.com",
|
||||
proxy: str | None = None,
|
||||
) -> AmplitudeHL | None:
|
||||
"""用 1H K 线回看 hours;inst 可用指数 ETH-USD 或永续 ETH-USDT-SWAP。"""
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
hrs = max(1.0, float(hours))
|
||||
limit = int(min(300, max(2, math.ceil(hrs) + 1)))
|
||||
try:
|
||||
with httpx.Client(
|
||||
base_url=base_url.rstrip("/"),
|
||||
timeout=15.0,
|
||||
proxy=(proxy or "").strip() or None,
|
||||
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
|
||||
) as client:
|
||||
r = client.get(
|
||||
"/api/v5/market/candles",
|
||||
params={"instId": inst_id, "bar": "1H", "limit": str(limit)},
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if str(body.get("code")) != "0":
|
||||
logger.warning("OKX candles error: %s", body.get("msg"))
|
||||
return None
|
||||
data = body.get("data") or []
|
||||
except Exception as e:
|
||||
logger.warning("OKX candles fetch failed: %s", e)
|
||||
return None
|
||||
hl = _hl_from_okx_candles(data)
|
||||
if hl is None:
|
||||
return None
|
||||
high, low = hl
|
||||
mid = (high + low) / 2.0
|
||||
return AmplitudeHL(
|
||||
high=high, low=low, mid=mid, hours=hrs, bar_count=len(data)
|
||||
)
|
||||
|
||||
|
||||
def fetch_binance_amplitude_hl(
|
||||
*,
|
||||
symbol: str,
|
||||
hours: float,
|
||||
fapi_base: str = "https://fapi.binance.com",
|
||||
proxy: str | None = None,
|
||||
) -> AmplitudeHL | None:
|
||||
"""USDT 永续 1h klines。"""
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
hrs = max(1.0, float(hours))
|
||||
limit = int(min(500, max(2, math.ceil(hrs) + 1)))
|
||||
sym = str(symbol or "ETHUSDT").upper().replace("-", "")
|
||||
try:
|
||||
with httpx.Client(
|
||||
base_url=fapi_base.rstrip("/"),
|
||||
timeout=15.0,
|
||||
proxy=(proxy or "").strip() or None,
|
||||
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
|
||||
trust_env=False,
|
||||
) as client:
|
||||
r = client.get(
|
||||
"/fapi/v1/klines",
|
||||
params={"symbol": sym, "interval": "1h", "limit": limit},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Binance klines fetch failed: %s", e)
|
||||
return None
|
||||
hl = _hl_from_binance_klines(data)
|
||||
if hl is None:
|
||||
return None
|
||||
high, low = hl
|
||||
mid = (high + low) / 2.0
|
||||
return AmplitudeHL(
|
||||
high=high, low=low, mid=mid, hours=hrs, bar_count=len(data)
|
||||
)
|
||||
|
||||
|
||||
def fetch_amplitude_hl_for_runtime(hours: float) -> AmplitudeHL | None:
|
||||
"""按当前交易所 runtime 拉振幅高低点。"""
|
||||
from ..config import get_settings
|
||||
from .runtime import load_runtime_settings
|
||||
|
||||
s = get_settings()
|
||||
rt = load_runtime_settings()
|
||||
ex = str(rt.exchange or "okx").strip().lower()
|
||||
hrs = float(hours)
|
||||
if ex in ("binance", "bn"):
|
||||
return fetch_binance_amplitude_hl(
|
||||
symbol=str(rt.perp_inst_id or "ETHUSDT"),
|
||||
hours=hrs,
|
||||
fapi_base=s.binance_fapi_base,
|
||||
proxy=s.binance_http_proxy or None,
|
||||
)
|
||||
# OKX:优先指数,失败再试永续
|
||||
idx = str(rt.index_inst_id or "ETH-USD")
|
||||
amp = fetch_okx_amplitude_hl(
|
||||
inst_id=idx,
|
||||
hours=hrs,
|
||||
base_url=s.okx_rest_base,
|
||||
proxy=s.okx_http_proxy or None,
|
||||
)
|
||||
if amp is not None:
|
||||
return amp
|
||||
return fetch_okx_amplitude_hl(
|
||||
inst_id=str(rt.perp_inst_id or "ETH-USDT-SWAP"),
|
||||
hours=hrs,
|
||||
base_url=s.okx_rest_base,
|
||||
proxy=s.okx_http_proxy or None,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""期权合约 ID 工具:从持仓合约还原同到期同行权价的 Call/Put 对。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .expiry import expiry_ms_from_ymd
|
||||
from .types import OptionPair
|
||||
|
||||
|
||||
def flip_option_side(inst_id: str) -> str | None:
|
||||
"""ETH-...-1880-P ↔ ...-C;币安 ETH-YYMMDD-STRIKE-P ↔ -C。"""
|
||||
s = (inst_id or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.endswith("-C"):
|
||||
return s[:-1] + "P"
|
||||
if s.endswith("-P"):
|
||||
return s[:-1] + "C"
|
||||
if s.endswith("-c"):
|
||||
return s[:-1] + "p"
|
||||
if s.endswith("-p"):
|
||||
return s[:-1] + "c"
|
||||
return None
|
||||
|
||||
|
||||
def pair_from_option_inst(inst_id: str) -> OptionPair | None:
|
||||
"""由任一腿合约 ID 还原同 strike/expiry 的 OptionPair。"""
|
||||
from .okx.parse import parse_option_inst_id
|
||||
from .binance.parse import parse_option_symbol
|
||||
|
||||
s = (inst_id or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
ymd, strike, side = parse_option_inst_id(s)
|
||||
if ymd is None:
|
||||
ymd, strike, side = parse_option_symbol(s)
|
||||
if ymd is None or strike is None or side not in ("C", "P"):
|
||||
return None
|
||||
other = flip_option_side(s)
|
||||
if not other:
|
||||
return None
|
||||
call_id = s if side == "C" else other
|
||||
put_id = s if side == "P" else other
|
||||
try:
|
||||
ems = expiry_ms_from_ymd(ymd)
|
||||
except ValueError:
|
||||
return None
|
||||
return OptionPair(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=int(ems),
|
||||
strike=float(strike),
|
||||
call_inst_id=call_id,
|
||||
put_inst_id=put_id,
|
||||
)
|
||||
@@ -43,12 +43,20 @@ class OptionPair:
|
||||
strike: float
|
||||
call_inst_id: str
|
||||
put_inst_id: str
|
||||
put_strike: float | None = None # 期期:Put 行权价;None=与 strike 同(ATM)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
put_k = (
|
||||
float(self.put_strike)
|
||||
if self.put_strike is not None
|
||||
else float(self.strike)
|
||||
)
|
||||
return {
|
||||
"expiry_ymd": self.expiry_ymd,
|
||||
"expiry_ms": self.expiry_ms,
|
||||
"strike": self.strike,
|
||||
"call_strike": float(self.strike),
|
||||
"put_strike": put_k,
|
||||
"call_inst_id": self.call_inst_id,
|
||||
"put_inst_id": self.put_inst_id,
|
||||
}
|
||||
@@ -88,7 +96,26 @@ def _open_bias(
|
||||
call: Quote | None,
|
||||
put: Quote | None,
|
||||
) -> str:
|
||||
"""与开仓 decide 一致:先按 ATM 相对现价,贴平时再卖一比价。"""
|
||||
"""与开仓 decide 一致;固定方向开启时显示 fixed_*。"""
|
||||
try:
|
||||
from ..config import get_settings
|
||||
from ..models.db import get_db
|
||||
|
||||
s = get_settings()
|
||||
db = get_db()
|
||||
raw = db.get_setting(
|
||||
"fixed_direction_enabled", str(s.fixed_direction_enabled)
|
||||
)
|
||||
on = str(raw or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
if on:
|
||||
side = str(
|
||||
db.get_setting("fixed_perp_side", s.fixed_perp_side)
|
||||
or s.fixed_perp_side
|
||||
or "long"
|
||||
).strip().lower()
|
||||
return "fixed_long_put" if side == "long" else "fixed_short_call"
|
||||
except Exception:
|
||||
pass
|
||||
mark = None
|
||||
if index_px is not None and index_px > 0:
|
||||
mark = float(index_px)
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
# Placeholder: live OKX trade adapter (P5). Default off.
|
||||
"""实盘执行适配层。"""
|
||||
|
||||
from .binance_executor import BinanceLiveExecutor
|
||||
from .executor import OkxLiveExecutor, get_executor
|
||||
|
||||
__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveExecutor"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
"""币安私有交易:USDT-M 永续 (fapi) + 欧洲期权 (eapi)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..exchange.okx.parse import safe_float
|
||||
from .okx_trade import LiveFill
|
||||
from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BinanceTradeClient:
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
proxy = (self.settings.binance_http_proxy or "").strip() or None
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "eth-hedge-live/0.1",
|
||||
"X-MBX-APIKEY": self.settings.binance_api_key or "",
|
||||
}
|
||||
self._fapi = httpx.Client(
|
||||
base_url=self.settings.binance_fapi_base.rstrip("/"),
|
||||
timeout=20.0,
|
||||
proxy=proxy,
|
||||
headers=headers,
|
||||
trust_env=False,
|
||||
)
|
||||
self._eapi = httpx.Client(
|
||||
base_url=self.settings.binance_eapi_base.rstrip("/"),
|
||||
timeout=20.0,
|
||||
proxy=proxy,
|
||||
headers=headers,
|
||||
trust_env=False,
|
||||
)
|
||||
self._hedge: bool | None = None
|
||||
self._fapi_throttle = get_throttle("binance_fapi_trade", min_interval_sec=1.0)
|
||||
self._eapi_throttle = get_throttle(
|
||||
"binance_eapi_trade",
|
||||
min_interval_sec=1.0,
|
||||
cooldown_429_sec=20.0,
|
||||
cooldown_418_sec=120.0,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._fapi.close()
|
||||
self._eapi.close()
|
||||
|
||||
def _sign(self, params: dict[str, Any]) -> str:
|
||||
qs = urlencode(params, doseq=True)
|
||||
secret = (self.settings.binance_api_secret or "").encode("utf-8")
|
||||
return hmac.new(secret, qs.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _throttle_for(self, client: httpx.Client):
|
||||
if client is self._eapi:
|
||||
return self._eapi_throttle
|
||||
return self._fapi_throttle
|
||||
|
||||
def _signed(
|
||||
self,
|
||||
client: httpx.Client,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
throttle = self._throttle_for(client)
|
||||
throttle.before_request()
|
||||
p = dict(params or {})
|
||||
p["timestamp"] = int(time.time() * 1000)
|
||||
p["signature"] = self._sign(p)
|
||||
r = client.request(method.upper(), path, params=p)
|
||||
if r.status_code in (418, 429):
|
||||
ra = parse_retry_after_header(r.headers)
|
||||
throttle.mark_http(r.status_code, ra)
|
||||
raise RateLimitError(
|
||||
f"Binance {path} HTTP {r.status_code}: {r.text[:200]}",
|
||||
retry_after=throttle.remaining_cooldown(),
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
raise RuntimeError(f"Binance {path} HTTP {r.status_code}: {r.text[:400]}")
|
||||
data = r.json()
|
||||
if isinstance(data, dict) and "code" in data and "orderId" not in data:
|
||||
code = data.get("code")
|
||||
try:
|
||||
code_i = int(code)
|
||||
except (TypeError, ValueError):
|
||||
code_i = None
|
||||
msg = str(data.get("msg") or "")
|
||||
# -1003 too many requests; -1015 too many orders
|
||||
if code_i in (-1003, -1015) or "too many" in msg.lower():
|
||||
throttle.mark_seconds(20.0)
|
||||
raise RateLimitError(
|
||||
f"Binance rate-limited code={code} msg={msg}",
|
||||
retry_after=throttle.remaining_cooldown(),
|
||||
)
|
||||
if code_i is not None and code_i != 0:
|
||||
raise RuntimeError(f"Binance error code={code} msg={msg}")
|
||||
if code_i is None:
|
||||
raise RuntimeError(f"Binance error code={code} msg={msg}")
|
||||
return data
|
||||
|
||||
def is_hedge_mode(self) -> bool:
|
||||
if self._hedge is not None:
|
||||
return self._hedge
|
||||
try:
|
||||
data = self._signed(self._fapi, "GET", "/fapi/v1/positionSide/dual")
|
||||
self._hedge = bool(data.get("dualSidePosition") in (True, "true", "True"))
|
||||
except Exception as e:
|
||||
logger.warning("binance hedge mode probe failed: %s; assume one-way", e)
|
||||
self._hedge = False
|
||||
return self._hedge
|
||||
|
||||
def place_perp_market(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str, # BUY|SELL
|
||||
qty_eth: float,
|
||||
position_side: str | None = None, # LONG|SHORT|None
|
||||
reduce_only: bool = False,
|
||||
) -> LiveFill:
|
||||
# ETHUSDT 数量单位为 ETH
|
||||
qty = f"{float(qty_eth):.3f}".rstrip("0").rstrip(".")
|
||||
if not qty or qty == "0":
|
||||
qty = "0.001"
|
||||
params: dict[str, Any] = {
|
||||
"symbol": symbol,
|
||||
"side": side.upper(),
|
||||
"type": "MARKET",
|
||||
"quantity": qty,
|
||||
}
|
||||
hedge = self.is_hedge_mode()
|
||||
if hedge:
|
||||
ps = (position_side or ("LONG" if side.upper() == "BUY" else "SHORT")).upper()
|
||||
params["positionSide"] = ps
|
||||
elif reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
data = self._signed(self._fapi, "POST", "/fapi/v1/order", params)
|
||||
return self._fill_from_fapi(symbol, data)
|
||||
|
||||
def _fill_from_fapi(self, symbol: str, data: dict[str, Any]) -> LiveFill:
|
||||
ord_id = str(data.get("orderId") or "")
|
||||
avg = safe_float(data.get("avgPrice"))
|
||||
sz = safe_float(data.get("executedQty"))
|
||||
if (not avg or avg <= 0) and ord_id:
|
||||
q = self._signed(
|
||||
self._fapi,
|
||||
"GET",
|
||||
"/fapi/v1/order",
|
||||
{"symbol": symbol, "orderId": ord_id},
|
||||
)
|
||||
avg = safe_float(q.get("avgPrice")) or avg
|
||||
sz = safe_float(q.get("executedQty")) or sz
|
||||
data = q
|
||||
if not avg or avg <= 0:
|
||||
raise RuntimeError(f"币安永续无成交均价 orderId={ord_id} last={data}")
|
||||
from .money import abs_fee_usdt
|
||||
|
||||
fee = abs(safe_float(data.get("cumCommission")) or 0.0)
|
||||
fee_asset = str(data.get("commissionAsset") or "USDT")
|
||||
if fee <= 0 and ord_id:
|
||||
fee, fee_asset = self.sum_perp_trade_fees(symbol, ord_id)
|
||||
return LiveFill(
|
||||
inst_id=symbol,
|
||||
side=str(data.get("side") or "").lower(),
|
||||
avg_px=float(avg),
|
||||
sz=float(sz or 0),
|
||||
fee=abs_fee_usdt(fee, fee_asset),
|
||||
ord_id=ord_id,
|
||||
raw=data if isinstance(data, dict) else {},
|
||||
)
|
||||
|
||||
def place_option_market(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str, # BUY|SELL
|
||||
quantity: float,
|
||||
reduce_only: bool = False,
|
||||
) -> LiveFill:
|
||||
qty = str(int(round(quantity)))
|
||||
if qty == "0":
|
||||
qty = "1"
|
||||
params: dict[str, Any] = {
|
||||
"symbol": symbol,
|
||||
"side": side.upper(),
|
||||
"type": "MARKET",
|
||||
"quantity": qty,
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
|
||||
return self._fill_from_eapi(symbol, data)
|
||||
|
||||
def place_option_ioc(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str, # BUY|SELL
|
||||
quantity: float,
|
||||
price: float,
|
||||
reduce_only: bool = False,
|
||||
) -> LiveFill:
|
||||
"""期权限价 IOC:按买一/卖一价吃单,未成交部分取消。"""
|
||||
qty = str(int(round(quantity)))
|
||||
if qty == "0":
|
||||
qty = "1"
|
||||
px = f"{float(price):.8f}".rstrip("0").rstrip(".")
|
||||
if not px or px == "0":
|
||||
raise RuntimeError("币安期权 IOC 价格无效")
|
||||
params: dict[str, Any] = {
|
||||
"symbol": symbol,
|
||||
"side": side.upper(),
|
||||
"type": "LIMIT",
|
||||
"timeInForce": "IOC",
|
||||
"quantity": qty,
|
||||
"price": px,
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
|
||||
return self._fill_from_eapi(symbol, data, allow_partial=True)
|
||||
|
||||
def _fill_from_eapi(
|
||||
self, symbol: str, data: dict[str, Any], *, allow_partial: bool = False
|
||||
) -> LiveFill:
|
||||
ord_id = str(data.get("orderId") or data.get("id") or "")
|
||||
avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price"))
|
||||
sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity"))
|
||||
if (not avg or avg <= 0) and ord_id:
|
||||
# 轮询几轮
|
||||
for _ in range(8):
|
||||
time.sleep(0.2)
|
||||
q = self._signed(
|
||||
self._eapi,
|
||||
"GET",
|
||||
"/eapi/v1/order",
|
||||
{"symbol": symbol, "orderId": ord_id},
|
||||
)
|
||||
avg = safe_float(q.get("avgPrice")) or safe_float(q.get("price"))
|
||||
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 == "FILLED":
|
||||
break
|
||||
if st in ("CANCELED", "REJECTED", "EXPIRED"):
|
||||
if allow_partial and sz and sz > 1e-12 and avg and avg > 0:
|
||||
break
|
||||
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
|
||||
if st == "PARTIALLY_FILLED":
|
||||
continue
|
||||
if not avg or avg <= 0:
|
||||
raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}")
|
||||
st_final = str(data.get("status") or "").upper()
|
||||
executed = safe_float(data.get("executedQty")) or float(sz or 0)
|
||||
if st_final and st_final != "FILLED":
|
||||
if not (
|
||||
allow_partial
|
||||
and executed > 1e-12
|
||||
and st_final in ("CANCELED", "EXPIRED", "PARTIALLY_FILLED")
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
|
||||
)
|
||||
sz = executed
|
||||
from .money import abs_fee_usdt
|
||||
|
||||
fee = abs(safe_float(data.get("fee")) or 0.0)
|
||||
fee_asset = "USDT"
|
||||
if fee <= 0 and ord_id:
|
||||
fee, fee_asset = self.sum_option_trade_fees(symbol, ord_id)
|
||||
return LiveFill(
|
||||
inst_id=symbol,
|
||||
side=str(data.get("side") or "").lower(),
|
||||
avg_px=float(avg),
|
||||
sz=float(sz or 0),
|
||||
fee=abs_fee_usdt(fee, fee_asset),
|
||||
ord_id=ord_id,
|
||||
raw=data if isinstance(data, dict) else {},
|
||||
)
|
||||
|
||||
def sum_perp_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._fapi,
|
||||
"GET",
|
||||
"/fapi/v1/userTrades",
|
||||
{"symbol": symbol, "orderId": order_id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance perp userTrades fee failed: %s", e)
|
||||
return 0.0, "USDT"
|
||||
if not isinstance(rows, list):
|
||||
rows = [rows] if isinstance(rows, dict) else []
|
||||
total = 0.0
|
||||
asset = "USDT"
|
||||
for row in rows:
|
||||
total += abs(safe_float(row.get("commission")) or 0.0)
|
||||
if row.get("commissionAsset"):
|
||||
asset = str(row.get("commissionAsset"))
|
||||
return total, asset
|
||||
|
||||
def sum_option_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._eapi,
|
||||
"GET",
|
||||
"/eapi/v1/userTrades",
|
||||
{"symbol": symbol, "orderId": order_id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance option userTrades fee failed: %s", e)
|
||||
return 0.0, "USDT"
|
||||
if not isinstance(rows, list):
|
||||
rows = [rows] if isinstance(rows, dict) else []
|
||||
total = 0.0
|
||||
asset = "USDT"
|
||||
for row in rows:
|
||||
total += abs(safe_float(row.get("commission")) or safe_float(row.get("fee")) or 0.0)
|
||||
if row.get("commissionAsset") or row.get("feeAsset"):
|
||||
asset = str(row.get("commissionAsset") or row.get("feeAsset"))
|
||||
return total, asset
|
||||
|
||||
def get_perp_upl_usdt(self, symbol: str, *, position_side: str | None = None) -> float | None:
|
||||
from .money import to_usdt
|
||||
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance positionRisk failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
want = (position_side or "").strip().upper()
|
||||
for row in rows:
|
||||
amt = safe_float(row.get("positionAmt")) or 0.0
|
||||
if abs(amt) < 1e-12:
|
||||
continue
|
||||
ps = str(row.get("positionSide") or "").upper()
|
||||
if want and ps and ps not in ("BOTH",) and ps != want:
|
||||
continue
|
||||
upl = safe_float(row.get("unRealizedProfit"))
|
||||
if upl is None:
|
||||
continue
|
||||
return to_usdt(float(upl), "USDT")
|
||||
return 0.0
|
||||
|
||||
def fetch_balances(self) -> dict[str, float | None]:
|
||||
"""交易侧可用:USDT-M 钱包 USDT + 期权账户 USDT/USDC(尽力而为)。"""
|
||||
out: dict[str, float | None] = {
|
||||
"trading_usdt": None,
|
||||
"trading_usdc": None,
|
||||
}
|
||||
try:
|
||||
rows = self._signed(self._fapi, "GET", "/fapi/v2/balance")
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
asset = str(row.get("asset") or "").upper()
|
||||
avail = safe_float(row.get("availableBalance"))
|
||||
if avail is None:
|
||||
avail = safe_float(row.get("balance"))
|
||||
if asset == "USDT" and avail is not None:
|
||||
out["trading_usdt"] = float(avail)
|
||||
elif asset == "USDC" and avail is not None:
|
||||
# 永续侧 USDC 少见;若有则记
|
||||
if out["trading_usdc"] is None:
|
||||
out["trading_usdc"] = float(avail)
|
||||
except Exception as e:
|
||||
logger.warning("binance fapi balance failed: %s", e)
|
||||
try:
|
||||
data = self._signed(self._eapi, "GET", "/eapi/v1/marginAccount")
|
||||
asset_list = []
|
||||
if isinstance(data, dict):
|
||||
asset_list = data.get("asset") or data.get("assets") or []
|
||||
if isinstance(asset_list, list):
|
||||
for row in asset_list:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
asset = str(
|
||||
row.get("asset") or row.get("currency") or ""
|
||||
).upper()
|
||||
avail = (
|
||||
safe_float(row.get("available"))
|
||||
or safe_float(row.get("marginBalance"))
|
||||
or safe_float(row.get("equity"))
|
||||
)
|
||||
if asset == "USDT" and avail is not None:
|
||||
# 期权保证金常用 USDT;与 fapi 取较大可用
|
||||
cur = out.get("trading_usdt")
|
||||
out["trading_usdt"] = (
|
||||
float(avail)
|
||||
if cur is None
|
||||
else max(float(cur), float(avail))
|
||||
)
|
||||
elif asset == "USDC" and avail is not None:
|
||||
out["trading_usdc"] = float(avail)
|
||||
except Exception as e:
|
||||
logger.warning("binance eapi marginAccount failed: %s", e)
|
||||
# 币安期权常用 USDT 保证金:eapi 无独立 USDC 时,用 USDT 作为期权侧可用
|
||||
if out.get("trading_usdt") is not None and out.get("trading_usdc") is None:
|
||||
out["trading_usdc"] = float(out["trading_usdt"])
|
||||
return out
|
||||
|
||||
def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None:
|
||||
"""当前永续绝对持仓(ETH)。"""
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance get_perp_pos_sz failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
want = (position_side or "").strip().upper()
|
||||
for row in rows:
|
||||
amt = safe_float(row.get("positionAmt")) or 0.0
|
||||
if abs(amt) < 1e-12:
|
||||
continue
|
||||
ps = str(row.get("positionSide") or "").upper()
|
||||
if want and ps and ps not in ("BOTH",) and ps != want:
|
||||
continue
|
||||
return abs(float(amt))
|
||||
return 0.0
|
||||
|
||||
def get_option_pos_sz(self, symbol: str) -> float | None:
|
||||
"""期权持仓绝对张数;查不到接口时返回 None。"""
|
||||
try:
|
||||
rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {"symbol": symbol})
|
||||
except Exception as e:
|
||||
logger.warning("binance get_option_pos_sz failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
total = 0.0
|
||||
hit = False
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol:
|
||||
continue
|
||||
qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0
|
||||
hit = True
|
||||
total += abs(float(qty))
|
||||
return total if hit else 0.0
|
||||
|
||||
def any_option_pos_abs(self) -> float | None:
|
||||
"""账户任意期权绝对持仓合计(ETH 期权)。"""
|
||||
try:
|
||||
rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {})
|
||||
except Exception as e:
|
||||
logger.warning("binance any_option_pos_abs failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
total = 0.0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
sym = str(row.get("symbol") or "")
|
||||
if sym and not sym.upper().startswith("ETH"):
|
||||
continue
|
||||
qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0
|
||||
total += abs(float(qty))
|
||||
return total
|
||||
|
||||
def set_margin_type(self, symbol: str, margin_type: str) -> None:
|
||||
"""ISOLATED | CROSSED。"""
|
||||
mt = "ISOLATED" if str(margin_type).lower() == "isolated" else "CROSSED"
|
||||
try:
|
||||
self._signed(
|
||||
self._fapi,
|
||||
"POST",
|
||||
"/fapi/v1/marginType",
|
||||
{"symbol": symbol, "marginType": mt},
|
||||
)
|
||||
except Exception as e:
|
||||
# 已是目标模式时币安常报错,忽略
|
||||
msg = str(e).lower()
|
||||
if "no need to change" in msg or "-4046" in msg:
|
||||
return
|
||||
raise
|
||||
|
||||
def set_leverage(self, symbol: str, leverage: int | float) -> None:
|
||||
lev = int(round(float(leverage)))
|
||||
if lev < 1:
|
||||
lev = 1
|
||||
self._signed(
|
||||
self._fapi,
|
||||
"POST",
|
||||
"/fapi/v1/leverage",
|
||||
{"symbol": symbol, "leverage": lev},
|
||||
)
|
||||
|
||||
def get_option_exercise_records(
|
||||
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> list[dict] | None:
|
||||
"""用户期权行权/到期结算记录 GET /eapi/v1/exerciseRecord。"""
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
begin = int(begin_ms)
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._eapi,
|
||||
"GET",
|
||||
"/eapi/v1/exerciseRecord",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"startTime": begin,
|
||||
"endTime": end,
|
||||
"limit": 100,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance exerciseRecord failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
def get_funding_usdt(
|
||||
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> float:
|
||||
from .money import to_usdt
|
||||
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._fapi,
|
||||
"GET",
|
||||
"/fapi/v1/income",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"incomeType": "FUNDING_FEE",
|
||||
"startTime": int(begin_ms),
|
||||
"endTime": end,
|
||||
"limit": 1000,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance funding income failed: %s", e)
|
||||
return 0.0
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
total = 0.0
|
||||
for row in rows:
|
||||
raw = safe_float(row.get("income"))
|
||||
if raw is None:
|
||||
continue
|
||||
asset = str(row.get("asset") or "USDT")
|
||||
total += to_usdt(float(raw), asset)
|
||||
return total
|
||||
|
||||
def get_closed_perp_pnl_usdt(
|
||||
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> float | None:
|
||||
"""用 REALIZED_PNL income 近似已实现(含部分平仓);资金费另计。"""
|
||||
from .money import to_usdt
|
||||
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._fapi,
|
||||
"GET",
|
||||
"/fapi/v1/income",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"incomeType": "REALIZED_PNL",
|
||||
"startTime": int(begin_ms),
|
||||
"endTime": end,
|
||||
"limit": 1000,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance realized income failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
if not rows:
|
||||
return None
|
||||
total = 0.0
|
||||
for row in rows:
|
||||
raw = safe_float(row.get("income"))
|
||||
if raw is None:
|
||||
continue
|
||||
asset = str(row.get("asset") or "USDT")
|
||||
total += to_usdt(float(raw), asset)
|
||||
return total
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
"""LIVE 组净盈亏:交易所永续 UPL/资金费 + 本地期权净盈亏 − 真实手续费。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _FeeFundingClient(Protocol):
|
||||
def get_perp_upl_usdt(self, *args: Any, **kwargs: Any) -> float | None: ...
|
||||
|
||||
def get_funding_usdt(self, *args: Any, **kwargs: Any) -> float: ...
|
||||
|
||||
def get_closed_perp_pnl_usdt(self, *args: Any, **kwargs: Any) -> float | None: ...
|
||||
|
||||
|
||||
def group_paid_fees_usdt(db: Any, group_id: str) -> float:
|
||||
rows = db.fetchall(
|
||||
"SELECT fee FROM fills WHERE group_id=?",
|
||||
(group_id,),
|
||||
)
|
||||
return sum(abs(float(r["fee"] or 0)) for r in rows)
|
||||
|
||||
|
||||
def enrich_live_unrealized(
|
||||
*,
|
||||
base: dict[str, Any],
|
||||
db: Any,
|
||||
client: Any,
|
||||
exchange: str,
|
||||
perp_inst_id: str,
|
||||
perp_side: str,
|
||||
open_at_ms: int | None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
在 Matcher.unrealized 结果上覆盖 LIVE 口径:
|
||||
net = 永续交易所UPL + 期权净盈亏(本地) − 入场手续费×2 + 资金费(signed)
|
||||
离场手续费按入场手续费估算(开+平 ≈ 已付×2)。
|
||||
"""
|
||||
if not base.get("has_position"):
|
||||
return base
|
||||
group_id = str(base.get("group_id") or "")
|
||||
fees_paid = group_paid_fees_usdt(db, group_id) if group_id else 0.0
|
||||
|
||||
begin = int(open_at_ms or 0)
|
||||
funding = 0.0
|
||||
perp_upl = float(base.get("perp_upl") or 0.0)
|
||||
ex = (exchange or "").lower()
|
||||
|
||||
try:
|
||||
if ex == "binance":
|
||||
side = "LONG" if perp_side == "long" else "SHORT"
|
||||
upl = client.get_perp_upl_usdt(perp_inst_id, position_side=side)
|
||||
if upl is not None:
|
||||
perp_upl = float(upl)
|
||||
if begin > 0:
|
||||
funding = float(
|
||||
client.get_funding_usdt(perp_inst_id, begin_ms=begin) or 0.0
|
||||
)
|
||||
else:
|
||||
upl = client.get_perp_upl_usdt(perp_inst_id, pos_side=perp_side)
|
||||
if upl is not None:
|
||||
perp_upl = float(upl)
|
||||
if begin > 0:
|
||||
funding = float(
|
||||
client.get_funding_usdt(perp_inst_id, begin_ms=begin) or 0.0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("live unrealized exchange overlay failed: %s", e)
|
||||
|
||||
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
|
||||
option2_upl = float(base.get("option2_upl") or 0.0) # 期期 Put 腿
|
||||
# 盯盘/达标:离场费已在 base.net_pnl(盘口可成交价)计入;这里只叠加资金费。
|
||||
# 勿用交易所永续标记 UPL 覆盖净利,否则会虚高触发达标、成交后变亏。
|
||||
book_net = base.get("net_pnl")
|
||||
if book_net is not None:
|
||||
net_pnl = float(book_net) + funding
|
||||
else:
|
||||
fees_est = float(fees_paid) * 2.0
|
||||
net_pnl = perp_upl + option_upl + option2_upl - fees_est + funding
|
||||
|
||||
out = dict(base)
|
||||
out["perp_upl"] = float(base.get("perp_upl") or perp_upl)
|
||||
out["perp_upl_exchange"] = perp_upl
|
||||
out["option_upl"] = option_upl
|
||||
out["option2_upl"] = option2_upl
|
||||
out["fees_paid"] = fees_paid
|
||||
out["funding_usdt"] = funding
|
||||
out["est_close_fees"] = float(base.get("est_close_fees") or fees_paid)
|
||||
out["net_pnl"] = net_pnl
|
||||
out["net_pnl_exchange"] = (
|
||||
perp_upl + option_upl + option2_upl - float(fees_paid) * 2.0 + funding
|
||||
)
|
||||
out["pnl_source"] = "live_book_plus_funding"
|
||||
return out
|
||||
|
||||
|
||||
def reconcile_closed_group_pnl(
|
||||
*,
|
||||
db: Any,
|
||||
client: Any,
|
||||
exchange: str,
|
||||
group_id: str,
|
||||
perp_inst_id: str,
|
||||
open_at_ms: int | None,
|
||||
local_net: float | None,
|
||||
) -> float:
|
||||
"""
|
||||
平仓后回写:净盈亏优先用 交易所永续已实现 + 本地期权腿盈亏 − 手续费 + 资金费。
|
||||
失败则退回 local_net。
|
||||
"""
|
||||
from ..sim.pnl import summarize_fills_pnl
|
||||
|
||||
fills = db.fetchall(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||
)
|
||||
summary = summarize_fills_pnl(list(fills))
|
||||
fees = float(summary.get("fees_total") or 0.0)
|
||||
option_pnl = summary.get("option_pnl")
|
||||
local_perp = summary.get("perp_pnl")
|
||||
|
||||
begin = int(open_at_ms or 0)
|
||||
end = int(time.time() * 1000)
|
||||
funding = 0.0
|
||||
exch_perp: float | None = None
|
||||
ex = (exchange or "").lower()
|
||||
try:
|
||||
if begin > 0:
|
||||
funding = float(client.get_funding_usdt(perp_inst_id, begin_ms=begin, end_ms=end) or 0.0)
|
||||
exch_perp = client.get_closed_perp_pnl_usdt(
|
||||
perp_inst_id, begin_ms=begin, end_ms=end
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("reconcile exchange pnl failed: %s", e)
|
||||
|
||||
perp_pnl = float(exch_perp) if exch_perp is not None else (
|
||||
float(local_perp) if local_perp is not None else 0.0
|
||||
)
|
||||
opt = float(option_pnl) if option_pnl is not None else 0.0
|
||||
|
||||
# 期权平仓 fill 为 0 价镜像时:尝试用交易所交割账单补期权腿
|
||||
try:
|
||||
zero_opt_close = False
|
||||
for fr in fills:
|
||||
if str(fr["leg"] or "") in ("option", "option2") and str(
|
||||
fr["action"] or ""
|
||||
) == "close":
|
||||
if abs(float(fr["notional"] or 0)) < 1e-12 and abs(
|
||||
float(fr["fill_px"] or 0)
|
||||
) < 1e-12:
|
||||
zero_opt_close = True
|
||||
break
|
||||
if zero_opt_close:
|
||||
from .option_settle import fetch_option_settlement
|
||||
|
||||
g = db.fetchone(
|
||||
"SELECT option_inst_id, option2_inst_id FROM groups WHERE group_id=?",
|
||||
(group_id,),
|
||||
)
|
||||
settle_cash = 0.0
|
||||
for inst_key in ("option_inst_id", "option2_inst_id"):
|
||||
inst = str((g[inst_key] if g else None) or "")
|
||||
if not inst:
|
||||
continue
|
||||
st = fetch_option_settlement(
|
||||
client,
|
||||
exchange=ex,
|
||||
option_inst_id=inst,
|
||||
qty_eth=1.0,
|
||||
begin_ms=begin,
|
||||
end_ms=end,
|
||||
)
|
||||
if st.found:
|
||||
settle_cash += float(st.cash)
|
||||
if abs(settle_cash) > 1e-12:
|
||||
# 用交割净现金替换本地 0 价期权盈亏近似:仍减 fees(交割费若已在 cash 内则可能双计,保守保留)
|
||||
opt = float(settle_cash)
|
||||
except Exception as e:
|
||||
logger.warning("reconcile option settlement overlay failed: %s", e)
|
||||
|
||||
net = perp_pnl + opt - fees + funding
|
||||
|
||||
if local_net is not None and exch_perp is None and abs(funding) < 1e-12:
|
||||
# 交易所永续已实现拉不到且无资金费 → 保持本地
|
||||
net = float(local_net)
|
||||
|
||||
with db._lock:
|
||||
try:
|
||||
db._conn.execute(
|
||||
"UPDATE groups SET realized_pnl=?, funding_usdt=?, fees=? WHERE group_id=?",
|
||||
(float(net), float(funding), float(fees), group_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
except Exception:
|
||||
# funding_usdt 列未迁移时降级
|
||||
db._conn.execute(
|
||||
"UPDATE groups SET realized_pnl=?, fees=? WHERE group_id=?",
|
||||
(float(net), float(fees), group_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
return float(net)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""实盘金额口径:统一折 USDT(USDC 等 1:1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def to_usdt(amount: float, ccy: str | None = None) -> float:
|
||||
"""按约定将币种金额折成 USDT;USDC/USD/USDT 一律 1:1。"""
|
||||
a = float(amount or 0.0)
|
||||
if a == 0.0:
|
||||
return 0.0
|
||||
c = (ccy or "USDT").strip().upper()
|
||||
if c in ("USDT", "USDC", "USD", ""):
|
||||
return a
|
||||
# 其他币种暂按面值记(极少见);后续可扩汇率
|
||||
return a
|
||||
|
||||
|
||||
def abs_fee_usdt(fee: float, ccy: str | None = None) -> float:
|
||||
"""手续费记为正成本(USDT)。"""
|
||||
return abs(to_usdt(fee, ccy))
|
||||
@@ -0,0 +1,181 @@
|
||||
"""OKX 资金:余额 / USDT↔USDC 现货兑换 / 账户划转(对齐 crypto_monitor)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .okx_trade import OkxTradeClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OKX acct: 6=资金, 18=交易
|
||||
_ACCT_CODE = {
|
||||
"funding": "6",
|
||||
"trading": "18",
|
||||
"spot": "18",
|
||||
}
|
||||
|
||||
|
||||
def _f(v: Any) -> float | None:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class OkxFundsClient:
|
||||
def __init__(self, trade: OkxTradeClient | None = None) -> None:
|
||||
self.trade = trade or OkxTradeClient()
|
||||
|
||||
def close(self) -> None:
|
||||
self.trade.close()
|
||||
|
||||
def fetch_balances(self) -> dict[str, float | None]:
|
||||
"""
|
||||
拉取资金账户 + 交易账户 USDT/USDC。
|
||||
资金:GET /api/v5/asset/balances
|
||||
交易:GET /api/v5/account/balance
|
||||
"""
|
||||
out: dict[str, float | None] = {
|
||||
"funding_usdt": None,
|
||||
"funding_usdc": None,
|
||||
"trading_usdt": None,
|
||||
"trading_usdc": None,
|
||||
}
|
||||
try:
|
||||
rows = self.trade._request("GET", "/api/v5/asset/balances")
|
||||
for row in rows:
|
||||
ccy = str(row.get("ccy") or "").upper()
|
||||
bal = _f(row.get("bal")) or _f(row.get("availBal"))
|
||||
if ccy == "USDT":
|
||||
out["funding_usdt"] = bal
|
||||
elif ccy == "USDC":
|
||||
out["funding_usdc"] = bal
|
||||
except Exception as e:
|
||||
logger.warning("OKX asset balances failed: %s", e)
|
||||
|
||||
try:
|
||||
rows = self.trade._request("GET", "/api/v5/account/balance")
|
||||
for block in rows:
|
||||
details = block.get("details") or []
|
||||
if not isinstance(details, list):
|
||||
continue
|
||||
for row in details:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
ccy = str(row.get("ccy") or "").upper()
|
||||
eq = _f(row.get("eq")) or _f(row.get("cashBal")) or _f(row.get("availBal"))
|
||||
if ccy == "USDT":
|
||||
out["trading_usdt"] = eq
|
||||
elif ccy == "USDC":
|
||||
out["trading_usdc"] = eq
|
||||
except Exception as e:
|
||||
logger.warning("OKX account balance failed: %s", e)
|
||||
|
||||
return out
|
||||
|
||||
def spot_swap_usdt_usdc(self, *, direction: str, amount: float) -> dict[str, Any]:
|
||||
"""现货市价兑换 USDC-USDT(与 crypto_monitor spot_market_swap_usdt_usdc 同口径)。"""
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "数量须大于 0"}
|
||||
d = (direction or "").strip().lower()
|
||||
inst_id = "USDC-USDT"
|
||||
if d == "usdt_to_usdc":
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": "buy",
|
||||
"ordType": "market",
|
||||
"sz": str(amt),
|
||||
"tgtCcy": "quote_ccy",
|
||||
}
|
||||
elif d == "usdc_to_usdt":
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": "sell",
|
||||
"ordType": "market",
|
||||
"sz": str(amt),
|
||||
"tgtCcy": "base_ccy",
|
||||
}
|
||||
else:
|
||||
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
|
||||
try:
|
||||
rows = self.trade._request("POST", "/api/v5/trade/order", body)
|
||||
if not rows:
|
||||
return {"ok": False, "detail": "兑换下单无返回"}
|
||||
row = rows[0]
|
||||
if str(row.get("sCode") or "0") not in ("0", ""):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(row.get("sMsg") or row.get("sCode") or "兑换失败"),
|
||||
"raw": row,
|
||||
}
|
||||
return {"ok": True, "detail": "converted", "data": row}
|
||||
except Exception as e:
|
||||
return {"ok": False, "detail": str(e)}
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
*,
|
||||
ccy: str,
|
||||
amount: float,
|
||||
from_account: str,
|
||||
to_account: str,
|
||||
) -> dict[str, Any]:
|
||||
"""同一 API Key 下资金↔交易划转。"""
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "划转金额须大于 0"}
|
||||
fa = (from_account or "").strip().lower()
|
||||
ta = (to_account or "").strip().lower()
|
||||
if fa == ta:
|
||||
return {"ok": False, "detail": "来源与目标账户不能相同"}
|
||||
from_code = _ACCT_CODE.get(fa)
|
||||
to_code = _ACCT_CODE.get(ta)
|
||||
if not from_code or not to_code:
|
||||
return {"ok": False, "detail": "账户须为 funding / trading"}
|
||||
body = {
|
||||
"ccy": str(ccy).upper(),
|
||||
"amt": str(amt),
|
||||
"from": from_code,
|
||||
"to": to_code,
|
||||
"type": "0",
|
||||
}
|
||||
try:
|
||||
rows = self.trade._request("POST", "/api/v5/asset/transfer", body)
|
||||
if not rows:
|
||||
return {"ok": False, "detail": "划转无返回"}
|
||||
return {"ok": True, "detail": "transferred", "data": rows[0]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "detail": str(e)}
|
||||
|
||||
|
||||
def usdc_usdt_mid_rate() -> float:
|
||||
"""公共盘口中间价:1 USDC ≈ ? USDT;失败则 1.0。"""
|
||||
try:
|
||||
import httpx
|
||||
from ..config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
proxy = (s.okx_http_proxy or "").strip() or None
|
||||
with httpx.Client(base_url=s.okx_rest_base.rstrip("/"), timeout=8.0, proxy=proxy) as c:
|
||||
r = c.get("/api/v5/market/ticker", params={"instId": "USDC-USDT"})
|
||||
r.raise_for_status()
|
||||
rows = (r.json() or {}).get("data") or []
|
||||
if not rows:
|
||||
return 1.0
|
||||
bid = _f(rows[0].get("bidPx"))
|
||||
ask = _f(rows[0].get("askPx"))
|
||||
last = _f(rows[0].get("last"))
|
||||
if bid and ask and bid > 0 and ask > 0:
|
||||
return (bid + ask) / 2.0
|
||||
if last and last > 0:
|
||||
return last
|
||||
except Exception as e:
|
||||
logger.warning("USDC-USDT mid failed: %s", e)
|
||||
return 1.0
|
||||
@@ -0,0 +1,473 @@
|
||||
"""OKX V5 私有交易 REST(下单)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..exchange.okx.parse import safe_float
|
||||
from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LiveFill:
|
||||
inst_id: str
|
||||
side: str
|
||||
avg_px: float
|
||||
sz: float # 张或币,取决于合约
|
||||
fee: float
|
||||
ord_id: str
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
class OkxTradeClient:
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
proxy = (self.settings.okx_http_proxy or "").strip() or None
|
||||
self._client = httpx.Client(
|
||||
base_url=self.settings.okx_rest_base.rstrip("/"),
|
||||
timeout=20.0,
|
||||
proxy=proxy,
|
||||
headers={"Accept": "application/json", "User-Agent": "eth-hedge-live/0.1"},
|
||||
)
|
||||
self._ct_val_cache: dict[str, float] = {}
|
||||
self._throttle = get_throttle("okx_trade", min_interval_sec=1.0)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _ts(self) -> str:
|
||||
# OKX: ISO8601 with milliseconds
|
||||
return (
|
||||
time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
|
||||
+ f".{int(time.time() * 1000) % 1000:03d}Z"
|
||||
)
|
||||
|
||||
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
|
||||
secret = (self.settings.okx_api_secret or "").encode("utf-8")
|
||||
msg = f"{ts}{method.upper()}{path}{body}".encode("utf-8")
|
||||
dig = hmac.new(secret, msg, hashlib.sha256).digest()
|
||||
return base64.b64encode(dig).decode("utf-8")
|
||||
|
||||
def _headers(self, ts: str, sign: str) -> dict[str, str]:
|
||||
return {
|
||||
"OK-ACCESS-KEY": self.settings.okx_api_key or "",
|
||||
"OK-ACCESS-SIGN": sign,
|
||||
"OK-ACCESS-TIMESTAMP": ts,
|
||||
"OK-ACCESS-PASSPHRASE": self.settings.okx_api_passphrase or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, body: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
self._throttle.before_request()
|
||||
payload = "" if body is None else json.dumps(body, separators=(",", ":"))
|
||||
ts = self._ts()
|
||||
sign = self._sign(ts, method, path, payload)
|
||||
headers = self._headers(ts, sign)
|
||||
if method.upper() == "GET":
|
||||
r = self._client.get(path, headers=headers)
|
||||
else:
|
||||
r = self._client.request(method.upper(), path, content=payload, headers=headers)
|
||||
if r.status_code in (418, 429):
|
||||
ra = parse_retry_after_header(r.headers)
|
||||
self._throttle.mark_http(r.status_code, ra)
|
||||
raise RateLimitError(
|
||||
f"OKX HTTP {r.status_code}: {r.text[:200]}",
|
||||
retry_after=self._throttle.remaining_cooldown(),
|
||||
)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise RuntimeError(f"OKX HTTP {r.status_code}: {r.text[:300]}") from e
|
||||
data = r.json()
|
||||
code = str(data.get("code") or "")
|
||||
msg = str(data.get("msg") or "")
|
||||
# OKX 业务层频率类错误
|
||||
if code != "0":
|
||||
low = f"{code} {msg}".lower()
|
||||
if code in ("50011", "50061") or "too many" in low or "频率" in msg:
|
||||
self._throttle.mark_seconds(20.0)
|
||||
raise RateLimitError(
|
||||
f"OKX trade rate-limited code={code} msg={msg}",
|
||||
retry_after=self._throttle.remaining_cooldown(),
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"OKX trade error code={code} msg={msg} data={data.get('data')}"
|
||||
)
|
||||
rows = data.get("data") or []
|
||||
return [x for x in rows if isinstance(x, dict)]
|
||||
|
||||
def get_ct_val(self, inst_id: str, *, inst_type: str) -> float:
|
||||
if inst_id in self._ct_val_cache:
|
||||
return self._ct_val_cache[inst_id]
|
||||
r = self._client.get(
|
||||
"/api/v5/public/instruments",
|
||||
params={"instType": inst_type, "instId": inst_id},
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
rows = body.get("data") or []
|
||||
for row in rows:
|
||||
if str(row.get("instId")) == inst_id:
|
||||
v = safe_float(row.get("ctVal")) or safe_float(row.get("ctMult"))
|
||||
if v and v > 0:
|
||||
self._ct_val_cache[inst_id] = float(v)
|
||||
return float(v)
|
||||
raise RuntimeError(f"OKX 无法取得合约面值 ctVal: {inst_id} instType={inst_type}")
|
||||
|
||||
def place_market(
|
||||
self,
|
||||
*,
|
||||
inst_id: str,
|
||||
side: str, # buy|sell
|
||||
sz: str,
|
||||
td_mode: str,
|
||||
pos_side: str | None = None,
|
||||
reduce_only: bool = False,
|
||||
) -> LiveFill:
|
||||
body: dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"tdMode": td_mode,
|
||||
"side": side,
|
||||
"ordType": "market",
|
||||
"sz": str(sz),
|
||||
}
|
||||
if pos_side:
|
||||
body["posSide"] = pos_side
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = True
|
||||
rows = self._request("POST", "/api/v5/trade/order", body)
|
||||
if not rows:
|
||||
raise RuntimeError("OKX 下单无返回")
|
||||
ord_id = str(rows[0].get("ordId") or "")
|
||||
# 查单取均价
|
||||
fill = self._wait_fill(inst_id, ord_id)
|
||||
return fill
|
||||
|
||||
def place_ioc(
|
||||
self,
|
||||
*,
|
||||
inst_id: str,
|
||||
side: str, # buy|sell
|
||||
sz: str,
|
||||
px: float | str,
|
||||
td_mode: str,
|
||||
pos_side: str | None = None,
|
||||
reduce_only: bool = False,
|
||||
) -> LiveFill:
|
||||
"""限价 IOC:残留回收等场景按指定买一/卖一吃单,不成交部分立即取消。"""
|
||||
body: dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"tdMode": td_mode,
|
||||
"side": side,
|
||||
"ordType": "ioc",
|
||||
"sz": str(sz),
|
||||
"px": str(px),
|
||||
}
|
||||
if pos_side:
|
||||
body["posSide"] = pos_side
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = True
|
||||
rows = self._request("POST", "/api/v5/trade/order", body)
|
||||
if not rows:
|
||||
raise RuntimeError("OKX IOC 下单无返回")
|
||||
ord_id = str(rows[0].get("ordId") or "")
|
||||
return self._wait_fill(inst_id, ord_id, allow_partial=True)
|
||||
|
||||
def _fill_from_order_row(self, inst_id: str, ord_id: str, row: dict[str, Any]) -> LiveFill:
|
||||
avg = safe_float(row.get("avgPx")) or 0.0
|
||||
sz = safe_float(row.get("accFillSz")) or safe_float(row.get("sz")) or 0.0
|
||||
fee = abs(safe_float(row.get("fee")) or 0.0)
|
||||
fee_ccy = str(row.get("feeCcy") or "USDT")
|
||||
if fee <= 0 and ord_id:
|
||||
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
|
||||
from .money import abs_fee_usdt
|
||||
|
||||
return LiveFill(
|
||||
inst_id=inst_id,
|
||||
side=str(row.get("side") or ""),
|
||||
avg_px=float(avg),
|
||||
sz=float(sz),
|
||||
fee=abs_fee_usdt(fee, fee_ccy),
|
||||
ord_id=ord_id,
|
||||
raw=row,
|
||||
)
|
||||
|
||||
def _wait_fill(
|
||||
self,
|
||||
inst_id: str,
|
||||
ord_id: str,
|
||||
*,
|
||||
tries: int = 40,
|
||||
allow_partial: bool = False,
|
||||
) -> LiveFill:
|
||||
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
|
||||
last: dict[str, Any] = {}
|
||||
for _ in range(tries):
|
||||
rows = self._request("GET", path)
|
||||
if rows:
|
||||
last = rows[0]
|
||||
state = str(last.get("state") or "")
|
||||
avg = safe_float(last.get("avgPx"))
|
||||
acc = safe_float(last.get("accFillSz")) or 0.0
|
||||
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
|
||||
if state == "filled" and avg and avg > 0:
|
||||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||||
if state in ("canceled", "failed"):
|
||||
# IOC:未成交部分取消;若已有成交量则按部分成交入账
|
||||
if (
|
||||
allow_partial
|
||||
and acc > 1e-12
|
||||
and avg
|
||||
and avg > 0
|
||||
):
|
||||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||||
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
|
||||
time.sleep(0.3)
|
||||
# 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲)
|
||||
state = str(last.get("state") or "")
|
||||
avg = safe_float(last.get("avgPx"))
|
||||
acc = safe_float(last.get("accFillSz")) or 0.0
|
||||
if state == "filled" and avg and avg > 0:
|
||||
logger.warning(
|
||||
"OKX fill wait timeout but order filled ordId=%s",
|
||||
ord_id,
|
||||
)
|
||||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||||
if allow_partial and acc > 1e-12 and avg and avg > 0:
|
||||
logger.warning(
|
||||
"OKX IOC partial fill on timeout ordId=%s acc=%s",
|
||||
ord_id,
|
||||
acc,
|
||||
)
|
||||
return self._fill_from_order_row(inst_id, ord_id, last)
|
||||
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
|
||||
|
||||
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
|
||||
"""成交明细手续费合计(原币种金额, 币种)。"""
|
||||
path = f"/api/v5/trade/fills?instId={inst_id}&ordId={ord_id}"
|
||||
try:
|
||||
rows = self._request("GET", path)
|
||||
except Exception as e:
|
||||
logger.warning("okx fills fee query failed: %s", e)
|
||||
return 0.0, "USDT"
|
||||
total = 0.0
|
||||
ccy = "USDT"
|
||||
for row in rows:
|
||||
f = abs(safe_float(row.get("fee")) or 0.0)
|
||||
total += f
|
||||
if row.get("feeCcy"):
|
||||
ccy = str(row.get("feeCcy"))
|
||||
return total, ccy
|
||||
|
||||
def get_perp_upl_usdt(self, inst_id: str, *, pos_side: str | None = None) -> float | None:
|
||||
"""当前永续未实现盈亏(USDT,1:1)。"""
|
||||
from .money import to_usdt
|
||||
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET", f"/api/v5/account/positions?instId={inst_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("okx positions failed: %s", e)
|
||||
return None
|
||||
want = (pos_side or "").strip().lower()
|
||||
for row in rows:
|
||||
ps = str(row.get("posSide") or "").lower()
|
||||
pos = safe_float(row.get("pos")) or 0.0
|
||||
if abs(pos) < 1e-12:
|
||||
continue
|
||||
if want and want not in ("net", "") and ps and ps != want and ps != "net":
|
||||
continue
|
||||
upl = safe_float(row.get("upl"))
|
||||
if upl is None:
|
||||
continue
|
||||
ccy = str(row.get("ccy") or row.get("settleCcy") or "USDT")
|
||||
return to_usdt(float(upl), ccy)
|
||||
return 0.0
|
||||
|
||||
def get_perp_pos_sz(self, inst_id: str, *, pos_side: str | None = None) -> float | None:
|
||||
"""当前永续绝对持仓张数。"""
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET", f"/api/v5/account/positions?instId={inst_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("okx get_perp_pos_sz failed: %s", e)
|
||||
return None
|
||||
want = (pos_side or "").strip().lower()
|
||||
for row in rows:
|
||||
ps = str(row.get("posSide") or "").lower()
|
||||
pos = safe_float(row.get("pos")) or 0.0
|
||||
if abs(pos) < 1e-12:
|
||||
continue
|
||||
if want and want not in ("net", "") and ps and ps != want and ps != "net":
|
||||
continue
|
||||
return abs(float(pos))
|
||||
return 0.0
|
||||
|
||||
def get_option_pos_sz(self, inst_id: str) -> float | None:
|
||||
"""期权绝对持仓张数。"""
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET",
|
||||
f"/api/v5/account/positions?instType=OPTION&instId={inst_id}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("okx get_option_pos_sz failed: %s", e)
|
||||
return None
|
||||
total = 0.0
|
||||
for row in rows:
|
||||
pos = safe_float(row.get("pos")) or 0.0
|
||||
total += abs(float(pos))
|
||||
return total
|
||||
|
||||
def any_option_pos_abs(self) -> float | None:
|
||||
"""账户任意期权绝对持仓张数合计。"""
|
||||
try:
|
||||
rows = self._request("GET", "/api/v5/account/positions?instType=OPTION")
|
||||
except Exception as e:
|
||||
logger.warning("okx any_option_pos_abs failed: %s", e)
|
||||
return None
|
||||
total = 0.0
|
||||
for row in rows:
|
||||
pos = safe_float(row.get("pos")) or 0.0
|
||||
total += abs(float(pos))
|
||||
return total
|
||||
|
||||
def set_leverage(
|
||||
self,
|
||||
inst_id: str,
|
||||
leverage: float,
|
||||
*,
|
||||
mgn_mode: str = "cross",
|
||||
pos_side: str | None = None,
|
||||
) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"lever": str(leverage),
|
||||
"mgnMode": mgn_mode,
|
||||
}
|
||||
if pos_side:
|
||||
body["posSide"] = pos_side
|
||||
self._request("POST", "/api/v5/account/set-leverage", body)
|
||||
|
||||
def get_funding_usdt(
|
||||
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> float:
|
||||
"""资金费合计(已计入账户的 signed 金额,USDT 1:1)。付费为负。"""
|
||||
from .money import to_usdt
|
||||
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
# type=8 funding fee
|
||||
path = (
|
||||
f"/api/v5/account/bills?instType=SWAP&instId={inst_id}"
|
||||
f"&type=8&begin={int(begin_ms)}&end={end}"
|
||||
)
|
||||
total = 0.0
|
||||
try:
|
||||
rows = self._request("GET", path)
|
||||
except Exception as e:
|
||||
logger.warning("okx funding bills failed: %s", e)
|
||||
return 0.0
|
||||
for row in rows:
|
||||
# balChg / pnl 视接口;资金费常用 pnl 或 balChg
|
||||
raw = safe_float(row.get("pnl"))
|
||||
if raw is None:
|
||||
raw = safe_float(row.get("balChg"))
|
||||
if raw is None:
|
||||
continue
|
||||
ccy = str(row.get("ccy") or "USDT")
|
||||
total += to_usdt(float(raw), ccy)
|
||||
return total
|
||||
|
||||
def get_option_settlement_bills(
|
||||
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> list[dict] | None:
|
||||
"""期权交割/行权相关账单(近 7 日 bills;失败再试 archive)。"""
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
begin = int(begin_ms)
|
||||
# type=3 Delivery;再本地按 subType 170/171/172 过滤
|
||||
path = (
|
||||
f"/api/v5/account/bills?instType=OPTION&instId={inst_id}"
|
||||
f"&type=3&begin={begin}&end={end}"
|
||||
)
|
||||
try:
|
||||
rows = self._request("GET", path)
|
||||
except Exception as e:
|
||||
logger.warning("okx option bills type=3 failed: %s; try all types", e)
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET",
|
||||
f"/api/v5/account/bills?instType=OPTION&instId={inst_id}"
|
||||
f"&begin={begin}&end={end}",
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.warning("okx option bills failed: %s; try archive", e2)
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET",
|
||||
f"/api/v5/account/bills-archive?instType=OPTION&instId={inst_id}"
|
||||
f"&begin={begin}&end={end}",
|
||||
)
|
||||
except Exception as e3:
|
||||
logger.warning("okx option bills-archive failed: %s", e3)
|
||||
return None
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
def get_closed_perp_pnl_usdt(
|
||||
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> float | None:
|
||||
"""平仓后从历史仓位取已实现盈亏(不含手续费;含部分仓位盈亏)。"""
|
||||
from .money import to_usdt
|
||||
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
begin = int(begin_ms)
|
||||
# OKX:after=更早时间戳边界,before=更晚;再本地按 uTime 过滤兜底
|
||||
path = (
|
||||
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
|
||||
f"&after={begin}&before={end}"
|
||||
)
|
||||
try:
|
||||
rows = self._request("GET", path)
|
||||
except Exception as e:
|
||||
logger.warning("okx positions-history failed: %s", e)
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET",
|
||||
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.warning("okx positions-history fallback failed: %s", e2)
|
||||
return None
|
||||
total = 0.0
|
||||
hit = False
|
||||
for row in rows:
|
||||
u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0)
|
||||
if u_time and (u_time < begin - 60_000 or u_time > end + 60_000):
|
||||
continue
|
||||
rpnl = safe_float(row.get("realizedPnl"))
|
||||
if rpnl is None:
|
||||
rpnl = safe_float(row.get("pnl"))
|
||||
if rpnl is None:
|
||||
continue
|
||||
hit = True
|
||||
ccy = str(row.get("ccy") or "USDT")
|
||||
total += to_usdt(float(rpnl), ccy)
|
||||
return total if hit else None
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""LIVE 期权到期交割:从交易所账单/行权记录取结算现金,禁止本地 intrinsic 发明。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OKX bills subType:行权 / 对手行权 / 虚值到期
|
||||
_OKX_EXERCISE_SUBTYPES = frozenset({"170", "171", "172"})
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OptionSettlement:
|
||||
"""qty_eth>0 时 fill_px = notional/qty_eth;cash 为入账净额(已扣费)。"""
|
||||
|
||||
found: bool
|
||||
notional: float = 0.0 # 结算毛额(入账前)
|
||||
fee: float = 0.0
|
||||
cash: float = 0.0 # notional - fee
|
||||
fill_px: float = 0.0
|
||||
source: str = ""
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def settlement_to_fill(
|
||||
st: OptionSettlement, *, qty_eth: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""返回 (fill_px, fee, notional)。未找到则全 0。"""
|
||||
if not st.found:
|
||||
return 0.0, 0.0, 0.0
|
||||
q = float(qty_eth)
|
||||
if q > 1e-12 and st.fill_px <= 0 and st.notional > 0:
|
||||
return float(st.notional) / q, float(st.fee), float(st.notional)
|
||||
return float(st.fill_px), float(st.fee), float(st.notional)
|
||||
|
||||
|
||||
def fetch_option_settlement(
|
||||
client: Any,
|
||||
*,
|
||||
exchange: str,
|
||||
option_inst_id: str,
|
||||
qty_eth: float,
|
||||
begin_ms: int | None,
|
||||
end_ms: int | None = None,
|
||||
) -> OptionSettlement:
|
||||
"""查交易所期权交割/行权入账。查不到 → found=False(调用方零价镜像、不发明)。"""
|
||||
inst = str(option_inst_id or "").strip()
|
||||
if not inst or client is None:
|
||||
return OptionSettlement(found=False, detail="no_inst_or_client")
|
||||
begin = int(begin_ms or 0)
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
if begin <= 0:
|
||||
# 无开仓时间:收窄到近 48h,避免扫全量
|
||||
begin = end - 48 * 3600 * 1000
|
||||
ex = (exchange or "").strip().lower()
|
||||
try:
|
||||
if ex in ("binance", "bn"):
|
||||
return _bn_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end)
|
||||
return _okx_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end)
|
||||
except Exception as e:
|
||||
logger.warning("fetch_option_settlement failed %s %s: %s", ex, inst, e)
|
||||
return OptionSettlement(found=False, detail=str(e)[:160])
|
||||
|
||||
|
||||
def _okx_settlement(
|
||||
client: Any, inst_id: str, *, qty_eth: float, begin: int, end: int
|
||||
) -> OptionSettlement:
|
||||
from .money import to_usdt
|
||||
from ..exchange.okx.parse import safe_float
|
||||
|
||||
getter = getattr(client, "get_option_settlement_bills", None)
|
||||
if callable(getter):
|
||||
rows = getter(inst_id, begin_ms=begin, end_ms=end)
|
||||
else:
|
||||
rows = None
|
||||
if rows is None:
|
||||
return OptionSettlement(found=False, detail="okx_bills_unavailable")
|
||||
|
||||
cash = 0.0
|
||||
fee = 0.0
|
||||
hit = False
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
sub = str(row.get("subType") or "")
|
||||
typ = str(row.get("type") or "")
|
||||
if sub not in _OKX_EXERCISE_SUBTYPES and typ not in ("3",):
|
||||
continue
|
||||
ts = int(safe_float(row.get("ts")) or 0)
|
||||
if ts and (ts < begin - 120_000 or ts > end + 120_000):
|
||||
continue
|
||||
hit = True
|
||||
raw = safe_float(row.get("balChg"))
|
||||
if raw is None:
|
||||
raw = safe_float(row.get("pnl"))
|
||||
ccy = str(row.get("ccy") or "USDT")
|
||||
if raw is not None:
|
||||
cash += to_usdt(float(raw), ccy)
|
||||
fraw = safe_float(row.get("fee"))
|
||||
if fraw is not None:
|
||||
# OKX fee 常为负
|
||||
fee += abs(to_usdt(float(fraw), ccy))
|
||||
# 有些账单 pnl 已含费,fee 字段为 0
|
||||
|
||||
if not hit:
|
||||
return OptionSettlement(found=False, detail="okx_no_exercise_bill")
|
||||
|
||||
# balChg/pnl 视为账户净变动;fee 另计时用净额+费还原毛额作 fill notional
|
||||
net_cash = float(cash)
|
||||
fee = float(fee)
|
||||
if net_cash >= 0:
|
||||
notional = float(net_cash) + fee
|
||||
else:
|
||||
notional = 0.0
|
||||
|
||||
q = float(qty_eth)
|
||||
fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0
|
||||
return OptionSettlement(
|
||||
found=True,
|
||||
notional=float(notional),
|
||||
fee=float(fee),
|
||||
cash=float(net_cash),
|
||||
fill_px=float(fill_px),
|
||||
source="okx_bills",
|
||||
detail=f"subTypes exercise bills cash={net_cash:.6f}",
|
||||
)
|
||||
|
||||
|
||||
def _bn_settlement(
|
||||
client: Any, symbol: str, *, qty_eth: float, begin: int, end: int
|
||||
) -> OptionSettlement:
|
||||
from .money import to_usdt
|
||||
from ..exchange.okx.parse import safe_float
|
||||
|
||||
getter = getattr(client, "get_option_exercise_records", None)
|
||||
if not callable(getter):
|
||||
return OptionSettlement(found=False, detail="bn_exercise_api_missing")
|
||||
rows = getter(symbol, begin_ms=begin, end_ms=end)
|
||||
if rows is None:
|
||||
return OptionSettlement(found=False, detail="bn_exercise_unavailable")
|
||||
if not rows:
|
||||
return OptionSettlement(found=False, detail="bn_no_exercise_record")
|
||||
|
||||
amount = 0.0
|
||||
fee = 0.0
|
||||
hit = False
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol:
|
||||
continue
|
||||
hit = True
|
||||
ccy = str(row.get("currency") or row.get("quoteAsset") or "USDT")
|
||||
am = safe_float(row.get("amount"))
|
||||
if am is not None:
|
||||
amount += to_usdt(float(am), ccy)
|
||||
fr = safe_float(row.get("fee"))
|
||||
if fr is not None:
|
||||
fee += abs(to_usdt(float(fr), ccy))
|
||||
|
||||
if not hit:
|
||||
return OptionSettlement(found=False, detail="bn_no_matching_record")
|
||||
|
||||
notional = max(0.0, float(amount))
|
||||
net = float(amount) - float(fee)
|
||||
q = float(qty_eth)
|
||||
fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0
|
||||
return OptionSettlement(
|
||||
found=True,
|
||||
notional=notional,
|
||||
fee=float(fee),
|
||||
cash=float(net),
|
||||
fill_px=float(fill_px),
|
||||
source="binance_exerciseRecord",
|
||||
detail=f"amount={amount:.6f} fee={fee:.6f}",
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""实盘交易限流:私有 REST 冷却 + 失败退避。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_429_SEC = 20.0
|
||||
_DEFAULT_418_SEC = 120.0
|
||||
_INTERVAL_MIN = 0.2
|
||||
_INTERVAL_MAX = 30.0
|
||||
|
||||
|
||||
def resolve_live_order_interval_sec() -> float:
|
||||
"""读取前端可配的 LIVE 下单最小间隔(秒),默认 1。"""
|
||||
try:
|
||||
from ..config import get_settings
|
||||
from ..models.db import get_db
|
||||
|
||||
s = get_settings()
|
||||
default = float(s.live_order_interval_sec)
|
||||
raw = get_db().get_setting("live_order_interval_sec", str(default))
|
||||
v = float(raw if raw not in (None, "") else default)
|
||||
if v != v: # NaN
|
||||
return 1.0
|
||||
return max(_INTERVAL_MIN, min(_INTERVAL_MAX, v))
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
|
||||
class RateLimitError(RuntimeError):
|
||||
"""处于限流/冷却中,调用方应退避,勿立即重试下单。"""
|
||||
|
||||
def __init__(self, message: str, *, retry_after: float = 0.0) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = float(retry_after)
|
||||
|
||||
|
||||
class TradeThrottle:
|
||||
"""按通道节流:最小间隔 + 418/429 冷却。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
min_interval_sec: float = 1.0,
|
||||
cooldown_429_sec: float = _DEFAULT_429_SEC,
|
||||
cooldown_418_sec: float = _DEFAULT_418_SEC,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.min_interval_sec = float(min_interval_sec)
|
||||
self.cooldown_429_sec = float(cooldown_429_sec)
|
||||
self.cooldown_418_sec = float(cooldown_418_sec)
|
||||
self._lock = threading.Lock()
|
||||
self._last_at = 0.0
|
||||
self._cool_until = 0.0
|
||||
|
||||
def remaining_cooldown(self) -> float:
|
||||
with self._lock:
|
||||
return max(0.0, self._cool_until - time.monotonic())
|
||||
|
||||
def before_request(self) -> None:
|
||||
"""请求前调用:冷却中抛 RateLimitError;否则等待最小间隔(可读设置)。"""
|
||||
interval = resolve_live_order_interval_sec()
|
||||
with self._lock:
|
||||
self.min_interval_sec = interval
|
||||
now = time.monotonic()
|
||||
if now < self._cool_until:
|
||||
left = self._cool_until - now
|
||||
raise RateLimitError(
|
||||
f"{self.name} rate-limit cooldown {left:.1f}s",
|
||||
retry_after=left,
|
||||
)
|
||||
gap = now - self._last_at
|
||||
wait = interval - gap
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
with self._lock:
|
||||
self._last_at = time.monotonic()
|
||||
|
||||
def mark_http(self, status_code: int, retry_after: float | None = None) -> None:
|
||||
if status_code not in (418, 429):
|
||||
return
|
||||
if status_code == 418:
|
||||
wait = self.cooldown_418_sec
|
||||
else:
|
||||
wait = float(retry_after) if retry_after and retry_after > 0 else self.cooldown_429_sec
|
||||
wait = max(wait, self.cooldown_429_sec)
|
||||
with self._lock:
|
||||
self._cool_until = time.monotonic() + wait
|
||||
logger.warning("%s HTTP %s → cooldown %.0fs", self.name, status_code, wait)
|
||||
|
||||
def mark_seconds(self, seconds: float) -> None:
|
||||
wait = max(1.0, float(seconds))
|
||||
with self._lock:
|
||||
self._cool_until = max(self._cool_until, time.monotonic() + wait)
|
||||
logger.warning("%s cooldown %.0fs (manual)", self.name, wait)
|
||||
|
||||
|
||||
_THROTTLES: dict[str, TradeThrottle] = {}
|
||||
_THROTTLES_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_throttle(name: str, **kwargs: Any) -> TradeThrottle:
|
||||
with _THROTTLES_LOCK:
|
||||
t = _THROTTLES.get(name)
|
||||
if t is None:
|
||||
t = TradeThrottle(name, **kwargs)
|
||||
_THROTTLES[name] = t
|
||||
return t
|
||||
|
||||
|
||||
def is_rate_limit_error(exc: BaseException | str) -> bool:
|
||||
if isinstance(exc, RateLimitError):
|
||||
return True
|
||||
text = str(exc).lower()
|
||||
needles = (
|
||||
"429",
|
||||
"418",
|
||||
"rate limit",
|
||||
"rate-limit",
|
||||
"ratelimit",
|
||||
"too many request",
|
||||
"cooldown",
|
||||
"banned",
|
||||
"frequency",
|
||||
"请求过于频繁",
|
||||
"超出频率",
|
||||
)
|
||||
return any(n in text for n in needles)
|
||||
|
||||
|
||||
def parse_retry_after_header(headers: Any) -> float | None:
|
||||
try:
|
||||
raw = headers.get("Retry-After") if headers is not None else None
|
||||
if raw is None:
|
||||
return None
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class LiveRetryGate:
|
||||
"""引擎侧失败退避:避免 half_open / pending / liquidity 每秒砸单。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_sec: float = 2.0,
|
||||
max_sec: float = 60.0,
|
||||
rate_limit_min_sec: float = 20.0,
|
||||
trip_after: int = 12,
|
||||
trip_cooldown_sec: float = 180.0,
|
||||
) -> None:
|
||||
self.base_sec = float(base_sec)
|
||||
self.max_sec = float(max_sec)
|
||||
self.rate_limit_min_sec = float(rate_limit_min_sec)
|
||||
self.trip_after = int(trip_after)
|
||||
self.trip_cooldown_sec = float(trip_cooldown_sec)
|
||||
self._fails: dict[str, int] = {}
|
||||
self._next_at: dict[str, float] = {}
|
||||
|
||||
def allow(self, key: str) -> tuple[bool, float]:
|
||||
"""返回 (可否执行, 剩余等待秒)。"""
|
||||
left = max(0.0, self._next_at.get(key, 0.0) - time.monotonic())
|
||||
return left <= 0.0, left
|
||||
|
||||
def success(self, key: str) -> None:
|
||||
self._fails.pop(key, None)
|
||||
self._next_at.pop(key, None)
|
||||
|
||||
def fail(self, key: str, *, rate_limited: bool = False) -> float:
|
||||
n = int(self._fails.get(key, 0)) + 1
|
||||
self._fails[key] = n
|
||||
if rate_limited:
|
||||
delay = max(self.rate_limit_min_sec, self.rate_limit_min_sec * (1.5 ** min(n - 1, 4)))
|
||||
delay = min(delay, 120.0)
|
||||
elif n >= self.trip_after:
|
||||
delay = self.trip_cooldown_sec
|
||||
logger.error(
|
||||
"live retry gate tripped key=%s fails=%s cooldown=%.0fs",
|
||||
key,
|
||||
n,
|
||||
delay,
|
||||
)
|
||||
else:
|
||||
delay = min(self.max_sec, self.base_sec * (2 ** min(n - 1, 5)))
|
||||
self._next_at[key] = time.monotonic() + delay
|
||||
return delay
|
||||
|
||||
def fails(self, key: str) -> int:
|
||||
return int(self._fails.get(key, 0))
|
||||
@@ -0,0 +1,408 @@
|
||||
"""LIVE 开仓对账:占槽、交易所持仓核对、平永续数量解析、stuck opening 恢复。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
from ..sim.matcher import BLOCKING_STATUSES, CloseResult
|
||||
from .symbols import resolve_perp_inst_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PERP_EPS = 1e-8
|
||||
_OPT_EPS = 1e-8
|
||||
|
||||
|
||||
def claim_open_slot(db) -> tuple[bool, str]:
|
||||
"""原子占用开仓槽:flat/空 → opening;已有 blocking 状态则拒绝。"""
|
||||
with db._lock:
|
||||
row = db._conn.execute("SELECT status FROM positions WHERE id=1").fetchone()
|
||||
st = str(row["status"] or "") if row else ""
|
||||
if st in BLOCKING_STATUSES:
|
||||
return False, f"已有持仓/半仓状态({st}),请先修复或平仓"
|
||||
cur = db._conn.execute(
|
||||
"""UPDATE positions SET status='opening'
|
||||
WHERE id=1 AND (status IS NULL OR status='' OR status='flat')"""
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
st2 = st or "unknown"
|
||||
return False, f"无法占用开仓槽(当前 status={st2})"
|
||||
db._conn.commit()
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def stamp_opening_intent(
|
||||
db,
|
||||
*,
|
||||
group_id: str,
|
||||
option_inst_id: str,
|
||||
option_side: str,
|
||||
perp_side: str,
|
||||
option_qty_eth: float,
|
||||
option_qty_contracts: float,
|
||||
entry_index_px: float | None = None,
|
||||
option_entry_px: float | None = None,
|
||||
) -> None:
|
||||
"""开仓意图落库:崩溃后仍可按 option_inst_id 恢复,禁止「opening 无元数据」。"""
|
||||
with db._lock:
|
||||
self_row = db._conn.execute(
|
||||
"SELECT status FROM positions WHERE id=1"
|
||||
).fetchone()
|
||||
st = str(self_row["status"] or "") if self_row else ""
|
||||
if st != "opening":
|
||||
return
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=?, option_inst_id=?, option_side=?, perp_side=?,
|
||||
option_qty_eth=?, option_qty_contracts=?,
|
||||
entry_index_px=COALESCE(?, entry_index_px),
|
||||
option_entry_px=COALESCE(?, option_entry_px),
|
||||
status='opening'
|
||||
WHERE id=1 AND status='opening'""",
|
||||
(
|
||||
group_id,
|
||||
option_inst_id,
|
||||
option_side,
|
||||
perp_side,
|
||||
float(option_qty_eth),
|
||||
float(option_qty_contracts),
|
||||
entry_index_px,
|
||||
option_entry_px,
|
||||
),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def release_open_slot_if_opening(db) -> None:
|
||||
"""开仓失败且未落 half_open/open 时,释放 opening 占槽。"""
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
status='flat', group_id=NULL, option_inst_id=NULL,
|
||||
option_side=NULL, perp_side=NULL,
|
||||
option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
entry_index_px=NULL, initial_premium=0
|
||||
WHERE id=1 AND status='opening'"""
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def exchange_perp_abs_size(
|
||||
client: Any,
|
||||
exchange: str,
|
||||
perp_inst_id: str,
|
||||
perp_side: str,
|
||||
) -> float | None:
|
||||
"""查询交易所永续绝对持仓:OKX 张数,Binance ETH。"""
|
||||
ex = (exchange or "").strip().lower()
|
||||
try:
|
||||
if ex in ("binance", "bn"):
|
||||
ps = "LONG" if perp_side == "long" else "SHORT"
|
||||
return client.get_perp_pos_sz(perp_inst_id, position_side=ps)
|
||||
ps = "long" if perp_side == "long" else "short"
|
||||
return client.get_perp_pos_sz(perp_inst_id, pos_side=ps)
|
||||
except Exception as e:
|
||||
logger.warning("exchange_perp_abs_size failed exchange=%s: %s", ex, e)
|
||||
return None
|
||||
|
||||
|
||||
def exchange_option_abs_size(
|
||||
client: Any, option_inst_id: str
|
||||
) -> float | None:
|
||||
try:
|
||||
return client.get_option_pos_sz(option_inst_id)
|
||||
except Exception as e:
|
||||
logger.warning("exchange_option_abs_size failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def exchange_any_option_abs(client: Any) -> float | None:
|
||||
try:
|
||||
return client.any_option_pos_abs()
|
||||
except Exception as e:
|
||||
logger.warning("exchange_any_option_abs failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def assert_safe_to_open_live(executor) -> tuple[bool, str]:
|
||||
"""LIVE 开仓前:本地无 blocking 仓,且交易所无残留永续/期权(flat/opening 时)。"""
|
||||
if get_settings().is_sim:
|
||||
return True, "ok"
|
||||
|
||||
pos = executor.current_position()
|
||||
st = str(pos.get("status") or "")
|
||||
if st in BLOCKING_STATUSES and st != "opening":
|
||||
return False, f"已有持仓/半仓状态({st}),请先修复或平仓"
|
||||
|
||||
client, ex_name = _executor_client_and_exchange(executor)
|
||||
if client is None:
|
||||
return False, "无法核对交易所持仓"
|
||||
|
||||
perp_inst = resolve_perp_inst_id(executor.db)
|
||||
if st in ("flat", "", "opening"):
|
||||
sides = ("long", "short")
|
||||
else:
|
||||
sides = (str(pos.get("perp_side") or "long"),)
|
||||
total = 0.0
|
||||
for side in sides:
|
||||
ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
|
||||
if ex_sz is None:
|
||||
return False, "无法核对交易所持仓"
|
||||
total += float(ex_sz)
|
||||
|
||||
if total > _PERP_EPS and st in ("flat", "", "opening"):
|
||||
return (
|
||||
False,
|
||||
"交易所有永续仓但本地无持仓,禁止新开,请人工核对",
|
||||
)
|
||||
|
||||
# 期权:有具体合约则查该合约;flat 时查账户任意期权残留
|
||||
opt_inst = str(pos.get("option_inst_id") or "")
|
||||
if opt_inst:
|
||||
opt_sz = exchange_option_abs_size(client, opt_inst)
|
||||
if opt_sz is None:
|
||||
return False, "无法核对交易所期权持仓"
|
||||
if opt_sz > _OPT_EPS and st in ("flat", "", "opening"):
|
||||
return (
|
||||
False,
|
||||
f"交易所有期权仓({opt_inst})但本地未确认持仓,禁止新开,请人工核对",
|
||||
)
|
||||
elif st in ("flat", "", "opening"):
|
||||
# opening 且尚未 stamp option_inst_id 时仍须扫任意期权残留
|
||||
any_opt = exchange_any_option_abs(client)
|
||||
if any_opt is None:
|
||||
return False, "无法核对交易所期权持仓"
|
||||
if any_opt > _OPT_EPS:
|
||||
return (
|
||||
False,
|
||||
"交易所有期权残留仓但本地无持仓,禁止新开,请人工核对",
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def log_exchange_db_mismatch(executor) -> None:
|
||||
"""LIVE 启动时记录交易所 vs 本地持仓不一致(仅日志,不阻断)。"""
|
||||
if get_settings().is_sim:
|
||||
return
|
||||
ok, msg = assert_safe_to_open_live(executor)
|
||||
if ok:
|
||||
logger.info("LIVE startup reconcile: exchange/DB OK")
|
||||
else:
|
||||
logger.warning("LIVE startup reconcile mismatch: %s", msg)
|
||||
# 启动时尝试恢复 stuck opening
|
||||
try:
|
||||
r = recover_stuck_opening(executor)
|
||||
if r is not None:
|
||||
logger.info("LIVE startup recover_opening: ok=%s detail=%s", r.ok, r.detail)
|
||||
except Exception:
|
||||
logger.exception("LIVE startup recover_opening failed")
|
||||
|
||||
|
||||
def perp_close_contracts_okx(
|
||||
client: Any,
|
||||
*,
|
||||
perp_inst: str,
|
||||
perp_side: str,
|
||||
perp_qty_eth: float,
|
||||
ct_val: float,
|
||||
allow_db_fallback: bool = False,
|
||||
) -> int | None:
|
||||
"""平永续张数:以交易所持仓为准。
|
||||
|
||||
返回 >0 应下单;0=已确认空仓;None=查仓失败(调用方不得当空仓 finalize)。
|
||||
交易所已确认空仓时绝不回退 DB。allow_db_fallback 仅在查仓失败时可用。
|
||||
"""
|
||||
ps = "long" if perp_side == "long" else "short"
|
||||
ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps)
|
||||
if ex_sz is None:
|
||||
if not allow_db_fallback:
|
||||
return None
|
||||
return max(1, int(round(perp_qty_eth / ct_val)))
|
||||
if ex_sz > _PERP_EPS:
|
||||
return max(1, int(round(ex_sz)))
|
||||
return 0
|
||||
|
||||
|
||||
def perp_close_qty_eth_binance(
|
||||
client: Any,
|
||||
*,
|
||||
perp_inst: str,
|
||||
perp_side: str,
|
||||
perp_qty_eth: float,
|
||||
allow_db_fallback: bool = False,
|
||||
) -> float | None:
|
||||
"""平永续 ETH:>0 下单;0=已确认空;None=查仓失败。已空绝不回退 DB。"""
|
||||
ps = "LONG" if perp_side == "long" else "SHORT"
|
||||
ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps)
|
||||
if ex_sz is None:
|
||||
if not allow_db_fallback:
|
||||
return None
|
||||
return float(perp_qty_eth)
|
||||
if ex_sz > _PERP_EPS:
|
||||
return float(ex_sz)
|
||||
return 0.0
|
||||
|
||||
|
||||
def perp_open_contracts_okx(*, perp_qty_eth: float, ct_val: float) -> int:
|
||||
"""开仓张数:仅按设置名义/面值,不跟交易所残留。"""
|
||||
if ct_val <= 0:
|
||||
raise RuntimeError("ct_val invalid")
|
||||
return max(1, int(round(float(perp_qty_eth) / float(ct_val))))
|
||||
|
||||
|
||||
def recover_stuck_opening(executor) -> CloseResult | None:
|
||||
"""恢复本地 status=opening:
|
||||
|
||||
- 交易所期权+永续皆空 → 清槽
|
||||
- 仅期权 → half_open 并尝试 repair
|
||||
- 期权+永续 → 提升为 open(用本地已 stamp 的数量/均价)
|
||||
- 无元数据且交易所仍有仓 → 保持 opening,返回失败详情
|
||||
"""
|
||||
if get_settings().is_sim:
|
||||
return None
|
||||
pos = executor.current_position()
|
||||
st = str(pos.get("status") or "")
|
||||
if st != "opening":
|
||||
return None
|
||||
|
||||
client, ex_name = _executor_client_and_exchange(executor)
|
||||
if client is None:
|
||||
return CloseResult(ok=False, detail="recover_opening: 无交易客户端")
|
||||
|
||||
option_inst = str(pos.get("option_inst_id") or "")
|
||||
perp_side = str(pos.get("perp_side") or "long")
|
||||
group_id = str(pos.get("group_id") or "")
|
||||
perp_inst = resolve_perp_inst_id(executor.db, group_id=group_id or None)
|
||||
|
||||
# 期期 opening:perp_side 形如 oo_put:{put_inst}
|
||||
oo_put_inst = ""
|
||||
if perp_side.startswith("oo_put:"):
|
||||
oo_put_inst = perp_side.split(":", 1)[1].strip()
|
||||
|
||||
perp_total = 0.0
|
||||
if not oo_put_inst:
|
||||
for side in ("long", "short"):
|
||||
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
|
||||
if sz is None:
|
||||
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
|
||||
perp_total += float(sz)
|
||||
|
||||
opt_sz = 0.0
|
||||
put_sz = 0.0
|
||||
if option_inst:
|
||||
raw = exchange_option_abs_size(client, option_inst)
|
||||
if raw is None:
|
||||
return CloseResult(ok=False, detail="recover_opening: 无法查期权")
|
||||
opt_sz = float(raw)
|
||||
else:
|
||||
any_opt = exchange_any_option_abs(client)
|
||||
if any_opt is None:
|
||||
return CloseResult(ok=False, detail="recover_opening: 无法查期权")
|
||||
if any_opt > _OPT_EPS:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
"recover_opening: opening 无 option_inst_id 但交易所有期权仓,"
|
||||
"禁止自动清槽,请人工核对"
|
||||
),
|
||||
)
|
||||
opt_sz = 0.0
|
||||
if oo_put_inst:
|
||||
raw_p = exchange_option_abs_size(client, oo_put_inst)
|
||||
if raw_p is None:
|
||||
return CloseResult(ok=False, detail="recover_opening: 无法查期期 Put")
|
||||
put_sz = float(raw_p)
|
||||
|
||||
# 两边皆空 → 清槽(期期须 Call+Put 皆空)
|
||||
if opt_sz <= _OPT_EPS and put_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
|
||||
release_open_slot_if_opening(executor.db)
|
||||
return CloseResult(ok=True, detail="recover_opening: 交易所空仓,已释放 opening")
|
||||
|
||||
# 期期:任一腿残留且无完整双边 → 禁止清槽;仅 Call 则 half_open repair
|
||||
if oo_put_inst:
|
||||
if put_sz > _OPT_EPS and opt_sz <= _OPT_EPS:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
f"recover_opening: 期期 Put 残留({oo_put_inst}) Call 已空,"
|
||||
"禁止清槽,请人工核对"
|
||||
),
|
||||
)
|
||||
if opt_sz > _OPT_EPS and put_sz > _OPT_EPS:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="recover_opening: 期期两腿均在交易所,请人工核对后 promote",
|
||||
)
|
||||
# 仅 Call → 落入下方 half_open
|
||||
|
||||
# 无元数据但有仓 → 不自动处理
|
||||
if not option_inst:
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="recover_opening: 交易所有仓但本地 opening 缺 option_inst_id",
|
||||
)
|
||||
|
||||
# 仅期权 → half_open + repair
|
||||
if opt_sz > _OPT_EPS and perp_total <= _PERP_EPS:
|
||||
persist = getattr(executor, "_persist_half_open", None)
|
||||
if not callable(persist):
|
||||
return CloseResult(ok=False, detail="recover_opening: 无 half_open 落库")
|
||||
of_px = float(pos.get("option_entry_px") or 0) or 0.0
|
||||
# 数量以交易所为准
|
||||
opt_contracts = float(opt_sz)
|
||||
opt_qty = float(pos.get("option_qty_eth") or 0)
|
||||
if hasattr(executor, "_ct_mult"):
|
||||
from ..sim.liquidity import eth_from_contracts
|
||||
|
||||
opt_qty = eth_from_contracts(opt_contracts, executor._ct_mult(option_inst))
|
||||
elif opt_qty <= 0:
|
||||
opt_qty = float(opt_sz)
|
||||
persist(
|
||||
group_id=group_id or f"RCV-{option_inst[-12:]}",
|
||||
bias="recover",
|
||||
option_side=str(pos.get("option_side") or "call"),
|
||||
perp_side=perp_side,
|
||||
option_inst_id=option_inst,
|
||||
entry_index_px=float(pos.get("entry_index_px") or 0) or 0.0,
|
||||
strike=None,
|
||||
expiry_ymd=None,
|
||||
opt_qty=opt_qty,
|
||||
opt_contracts=opt_contracts or opt_sz,
|
||||
of_px=of_px,
|
||||
of_fee=0.0,
|
||||
detail="recover_opening option-only → half_open",
|
||||
)
|
||||
repair = getattr(executor, "repair_half_open", None)
|
||||
if callable(repair):
|
||||
return repair()
|
||||
return CloseResult(ok=True, detail="recover_opening: 已落 half_open")
|
||||
|
||||
# 期权+永续 → 提升为 open
|
||||
promote = getattr(executor, "_promote_opening_to_open", None)
|
||||
if callable(promote):
|
||||
return promote(pos=pos, perp_inst=perp_inst, opt_sz=opt_sz, perp_total=perp_total)
|
||||
return CloseResult(
|
||||
ok=False,
|
||||
detail="recover_opening: 双边有仓但执行器无 promote,请人工核对",
|
||||
)
|
||||
|
||||
|
||||
def _executor_client_and_exchange(executor) -> tuple[Any | None, str | None]:
|
||||
if get_settings().is_sim:
|
||||
return None, None
|
||||
try:
|
||||
ex_name = load_runtime_settings().exchange
|
||||
except Exception:
|
||||
ex_name = get_settings().exchange
|
||||
client = None
|
||||
if hasattr(executor, "_client"):
|
||||
try:
|
||||
client = executor._client()
|
||||
except Exception as e:
|
||||
logger.warning("live executor client unavailable: %s", e)
|
||||
return None, ex_name
|
||||
return client, ex_name
|
||||
@@ -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)
|
||||
+88
-6
@@ -36,17 +36,57 @@ async def lifespan(app: FastAPI):
|
||||
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
|
||||
from .exchange.runtime import load_runtime_settings
|
||||
|
||||
app_settings = get_settings()
|
||||
if not app_settings.is_sim:
|
||||
secret = (app_settings.auth_secret or "").strip()
|
||||
if not secret or secret == "change-me-eth-hedge-sim-secret":
|
||||
raise RuntimeError(
|
||||
"LIVE 拒绝启动:请在 .env 设置非默认 AUTH_SECRET(勿用 change-me-eth-hedge-sim-secret)"
|
||||
)
|
||||
|
||||
settings = load_runtime_settings()
|
||||
engine = StrategyEngine()
|
||||
set_engine(engine)
|
||||
# 半自动:无仓时清陈旧授权,须首页重新「授权开下一单」
|
||||
try:
|
||||
from .strategy.semi_auto import clear_armed_if_flat
|
||||
|
||||
clear_armed_if_flat(
|
||||
db, has_open_position=engine.matcher.has_open_position()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("clear stale semi_armed on startup failed")
|
||||
# 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")
|
||||
try:
|
||||
from .live import get_executor
|
||||
from .live.reconcile import log_exchange_db_mismatch
|
||||
|
||||
log_exchange_db_mismatch(get_executor(db))
|
||||
except Exception:
|
||||
logger.exception("LIVE startup reconcile log failed")
|
||||
engine.ensure_loop()
|
||||
|
||||
from .backup import start_auto_backup_task
|
||||
|
||||
start_auto_backup_task()
|
||||
|
||||
session = bootstrap_session(settings)
|
||||
try:
|
||||
await session.start()
|
||||
logger.info(
|
||||
"exchange=%s strategy session started (SIM)",
|
||||
"exchange=%s strategy session started mode=%s",
|
||||
settings.exchange,
|
||||
"SIM" if get_settings().is_sim else "LIVE",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("strategy session failed to start")
|
||||
@@ -60,6 +100,9 @@ async def lifespan(app: FastAPI):
|
||||
await engine._task
|
||||
except Exception:
|
||||
pass
|
||||
from .backup import stop_auto_backup_task
|
||||
|
||||
await stop_auto_backup_task()
|
||||
await session.stop()
|
||||
from .exchange import set_exchange
|
||||
from .strategy.session import set_session
|
||||
@@ -72,15 +115,18 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="eth_hedge_sim",
|
||||
version="0.3.0",
|
||||
description="ETH 自动对冲模拟盘 P1/P2",
|
||||
title="比特骆驼自动化对冲系统",
|
||||
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=["*"],
|
||||
)
|
||||
@@ -127,6 +173,41 @@ _DIST = resolve_frontend_dist()
|
||||
if (_DIST / "assets").is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
|
||||
|
||||
_ICONS = _DIST / "icons"
|
||||
if _ICONS.is_dir():
|
||||
app.mount("/icons", StaticFiles(directory=str(_ICONS)), name="icons")
|
||||
|
||||
|
||||
def _dist_file(name: str) -> Path:
|
||||
return _DIST / name
|
||||
|
||||
|
||||
@app.get("/manifest.webmanifest")
|
||||
async def web_manifest():
|
||||
path = _dist_file("manifest.webmanifest")
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="manifest missing")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
path = _dist_file("sw.js")
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="service worker missing")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Service-Worker-Allowed": "/",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index_page():
|
||||
@@ -146,6 +227,7 @@ async def index_page():
|
||||
@app.get("/stats")
|
||||
@app.get("/settings")
|
||||
@app.get("/login")
|
||||
@app.get("/fleet-login")
|
||||
async def spa_pages(full_path: str = ""):
|
||||
index = _DIST / "index.html"
|
||||
if not index.exists():
|
||||
|
||||
@@ -40,6 +40,7 @@ def select_option_pair(
|
||||
expiry_ymd: str | None = None,
|
||||
min_hours: float | None = None,
|
||||
now=None,
|
||||
option_side: str | None = None,
|
||||
) -> OptionPair | None:
|
||||
contracts = normalize_contracts(instruments)
|
||||
return _select_pair(
|
||||
@@ -48,6 +49,7 @@ def select_option_pair(
|
||||
expiry_ymd=expiry_ymd,
|
||||
min_hours=min_hours,
|
||||
now=now,
|
||||
option_side=option_side,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,13 +34,18 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
expiry_ymd TEXT,
|
||||
entry_index_px REAL,
|
||||
initial_premium REAL DEFAULT 0,
|
||||
exit_target_usdt REAL,
|
||||
open_at_ms INTEGER,
|
||||
close_at_ms INTEGER,
|
||||
close_reason TEXT,
|
||||
realized_pnl REAL DEFAULT 0,
|
||||
fees REAL DEFAULT 0,
|
||||
slip_cost REAL DEFAULT 0,
|
||||
note TEXT
|
||||
note TEXT,
|
||||
exec_mode TEXT,
|
||||
funding_usdt REAL,
|
||||
settle_index_px REAL,
|
||||
perp_margin_mode TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fills (
|
||||
@@ -58,6 +63,7 @@ CREATE TABLE IF NOT EXISTS fills (
|
||||
slip REAL NOT NULL,
|
||||
notional REAL NOT NULL,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
exec_mode TEXT,
|
||||
FOREIGN KEY(group_id) REFERENCES groups(group_id)
|
||||
);
|
||||
|
||||
@@ -74,6 +80,7 @@ CREATE TABLE IF NOT EXISTS positions (
|
||||
option_entry_px REAL,
|
||||
entry_index_px REAL,
|
||||
initial_premium REAL DEFAULT 0,
|
||||
exit_target_usdt REAL,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
@@ -97,6 +104,38 @@ CREATE TABLE IF NOT EXISTS strategy_state (
|
||||
last_error TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS residual_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id TEXT NOT NULL UNIQUE,
|
||||
option_inst_id TEXT NOT NULL,
|
||||
option_side TEXT NOT NULL,
|
||||
option_qty_eth REAL NOT NULL,
|
||||
option_qty_contracts REAL,
|
||||
option_entry_px REAL NOT NULL,
|
||||
strike REAL,
|
||||
expiry_ymd TEXT,
|
||||
expiry_ms INTEGER,
|
||||
entry_index_px REAL,
|
||||
initial_premium REAL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
settled_at_ms INTEGER,
|
||||
settle_px REAL,
|
||||
settle_pnl REAL,
|
||||
note TEXT,
|
||||
FOREIGN KEY(group_id) REFERENCES groups(group_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS funds_wallets (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
funding_usdt REAL NOT NULL DEFAULT 0,
|
||||
trading_usdt REAL NOT NULL DEFAULT 0,
|
||||
options_funding_usdc REAL NOT NULL DEFAULT 0,
|
||||
options_trading_usdc REAL NOT NULL DEFAULT 0,
|
||||
options_funding_usdt REAL NOT NULL DEFAULT 0,
|
||||
options_trading_usdt REAL NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -118,8 +157,45 @@ class Database:
|
||||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
self._migrate_columns()
|
||||
self._ensure_seed()
|
||||
|
||||
def _migrate_columns(self) -> None:
|
||||
"""幂等补列:exec_mode。"""
|
||||
with self._lock:
|
||||
for table, col, decl in (
|
||||
("groups", "exec_mode", "TEXT"),
|
||||
("groups", "funding_usdt", "REAL"),
|
||||
("groups", "settle_index_px", "REAL"),
|
||||
("groups", "perp_margin_mode", "TEXT"),
|
||||
("groups", "exit_target_usdt", "REAL"),
|
||||
("groups", "hedge_mode", "TEXT"),
|
||||
("groups", "option2_inst_id", "TEXT"),
|
||||
("groups", "option2_side", "TEXT"),
|
||||
("groups", "strike2", "REAL"),
|
||||
("groups", "initial_premium2", "REAL"),
|
||||
("positions", "exit_target_usdt", "REAL"),
|
||||
("positions", "hedge_mode", "TEXT"),
|
||||
("positions", "option2_inst_id", "TEXT"),
|
||||
("positions", "option2_side", "TEXT"),
|
||||
("positions", "option2_qty_eth", "REAL"),
|
||||
("positions", "option2_qty_contracts", "REAL"),
|
||||
("positions", "option2_entry_px", "REAL"),
|
||||
("positions", "strike2", "REAL"),
|
||||
("positions", "initial_premium2", "REAL"),
|
||||
("fills", "exec_mode", "TEXT"),
|
||||
("fills", "fee_ccy", "TEXT"),
|
||||
):
|
||||
cols = {
|
||||
str(r[1])
|
||||
for r in self._conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
}
|
||||
if col not in cols:
|
||||
self._conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN {col} {decl}"
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
@@ -145,6 +221,19 @@ class Database:
|
||||
"INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)",
|
||||
(now,),
|
||||
)
|
||||
fw = self._conn.execute("SELECT id FROM funds_wallets WHERE id=1").fetchone()
|
||||
if fw is None:
|
||||
led = self._conn.execute(
|
||||
"SELECT equity FROM ledger_meta WHERE id=1"
|
||||
).fetchone()
|
||||
eq = float(led["equity"]) if led else float(s.initial_equity)
|
||||
self._conn.execute(
|
||||
"""INSERT INTO funds_wallets(
|
||||
id, funding_usdt, trading_usdt, options_funding_usdc, options_trading_usdc,
|
||||
options_funding_usdt, options_trading_usdt, updated_at_ms
|
||||
) VALUES (1,?,0,0,0,0,0,?)""",
|
||||
(eq, now),
|
||||
)
|
||||
defaults = {
|
||||
"fee_rate": str(s.fee_rate),
|
||||
"initial_equity": str(s.initial_equity),
|
||||
@@ -154,13 +243,19 @@ class Database:
|
||||
"net_profit_target": str(s.net_profit_target),
|
||||
"premium_exit_multiple": str(s.premium_exit_multiple),
|
||||
"rest_seconds": str(s.rest_seconds),
|
||||
"live_order_interval_sec": str(s.live_order_interval_sec),
|
||||
"skip_weekends": str(s.skip_weekends),
|
||||
"one_expiry_per_day": str(s.one_expiry_per_day),
|
||||
"max_rounds": str(s.max_rounds),
|
||||
"leverage": str(s.leverage),
|
||||
"perp_margin_mode": str(s.perp_margin_mode),
|
||||
"min_option_hours": str(s.min_option_hours),
|
||||
"min_option_leverage": str(s.min_option_leverage),
|
||||
"risk_leverage_basis": str(s.risk_leverage_basis),
|
||||
"atm_open_offset_enabled": str(s.atm_open_offset_enabled),
|
||||
"max_atm_open_offset": str(s.max_atm_open_offset),
|
||||
"fixed_direction_enabled": str(s.fixed_direction_enabled),
|
||||
"fixed_perp_side": str(s.fixed_perp_side),
|
||||
"close_bid_mark_max_pct": str(s.close_bid_mark_max_pct),
|
||||
"perp_qty_eth": str(s.perp_qty_eth),
|
||||
"option_qty_eth": str(s.option_qty_eth),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""通知子系统。"""
|
||||
|
||||
from . import wecom
|
||||
|
||||
__all__ = ["wecom"]
|
||||
@@ -0,0 +1,386 @@
|
||||
"""企业微信群机器人通知(独立模块)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import get_settings
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
from ..models.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 消息标识(Markdown 内展示,便于检索)
|
||||
TAG_OPEN = "OPEN"
|
||||
TAG_CLOSE = "CLOSE"
|
||||
TAG_START = "START"
|
||||
TAG_PAUSE = "PAUSE"
|
||||
TAG_FAULT = "FAULT"
|
||||
TAG_TEST = "TEST"
|
||||
|
||||
_last_fault_key: str | None = None
|
||||
_last_fault_ms: float = 0.0
|
||||
_FAULT_DEDUP_SEC = 300.0
|
||||
|
||||
CLOSE_REASON_ZH: dict[str, str] = {
|
||||
"expiry": "到期结算全平",
|
||||
"target_perp_only": "净盈利达标·只平永续(期权归档到期)",
|
||||
"semi_target_points": "半自动·标的到点且组合净利>0",
|
||||
"semi_perp_exit": "半自动·净利基数达标",
|
||||
"fixed_usdt": "固定净盈利达标·双腿全平",
|
||||
"premium_multiple": "权利金倍数达标·双腿全平",
|
||||
"emergency": "紧急全平",
|
||||
"emergency_perp": "紧急·只平永续",
|
||||
"manual": "手动全平",
|
||||
"perp_pending_retry": "续平永续",
|
||||
"liquidity_retry": "等待流动性后全平",
|
||||
"residual_premium_close": "残留期权·权利金回收中途平",
|
||||
"unknown": "未知原因",
|
||||
}
|
||||
|
||||
BIAS_ZH: dict[str, str] = {
|
||||
"call_ask_gt_put": "买Call + 永续空",
|
||||
"put_ask_gt_call": "买Put + 永续多",
|
||||
"strike_below_spot": "买Call + 永续空",
|
||||
"strike_above_spot": "买Put + 永续多",
|
||||
"fixed_long_put": "固定方向·买Put + 永续多",
|
||||
"fixed_short_call": "固定方向·买Call + 永续空",
|
||||
"manual_call": "手动·买Call + 永续空",
|
||||
"manual_put": "手动·买Put + 永续多",
|
||||
}
|
||||
|
||||
|
||||
def _as_bool(raw: str | None, default: bool = False) -> bool:
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def wecom_enabled() -> bool:
|
||||
s = get_settings()
|
||||
if getattr(s, "wecom_enabled", False):
|
||||
return True
|
||||
try:
|
||||
return _as_bool(get_db().get_setting("wecom_enabled", "0"), False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def wecom_webhook_url() -> str:
|
||||
s = get_settings()
|
||||
url = (getattr(s, "wecom_webhook_url", None) or "").strip()
|
||||
if url:
|
||||
return url
|
||||
try:
|
||||
return (get_db().get_setting("wecom_webhook_url", "") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def wecom_machine_name() -> str:
|
||||
"""多机推送区分用的机器名(设置页 / WECOM_MACHINE_NAME)。"""
|
||||
s = get_settings()
|
||||
name = (getattr(s, "wecom_machine_name", None) or "").strip()
|
||||
if name:
|
||||
return name[:64]
|
||||
try:
|
||||
return (get_db().get_setting("wecom_machine_name", "") or "").strip()[:64]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def venue_label() -> str | None:
|
||||
"""实盘时返回「实盘·交易所」;SIM 不展示盘口标签。"""
|
||||
s = get_settings()
|
||||
if s.is_sim:
|
||||
return None
|
||||
try:
|
||||
ex = load_runtime_settings().exchange
|
||||
except Exception:
|
||||
ex = s.exchange or "okx"
|
||||
ex_u = str(ex).strip().lower()
|
||||
if ex_u in ("binance", "bn"):
|
||||
return "实盘·币安"
|
||||
if ex_u in ("gate", "gateio"):
|
||||
return "实盘·Gate"
|
||||
return "实盘·OKX"
|
||||
|
||||
|
||||
def close_reason_zh(reason: str | None) -> str:
|
||||
r = str(reason or "").strip()
|
||||
if not r:
|
||||
return "未知原因"
|
||||
return CLOSE_REASON_ZH.get(r, r)
|
||||
|
||||
|
||||
def direction_zh(extra: dict[str, Any]) -> str:
|
||||
bias = str(extra.get("bias") or "").strip()
|
||||
if bias in BIAS_ZH:
|
||||
return BIAS_ZH[bias]
|
||||
opt = str(extra.get("option_side") or "").strip().lower()
|
||||
perp = str(extra.get("perp_side") or "").strip().lower()
|
||||
if opt == "put" and perp == "long":
|
||||
return "买Put + 永续多"
|
||||
if opt == "call" and perp == "short":
|
||||
return "买Call + 永续空"
|
||||
if opt == "put":
|
||||
return "买Put"
|
||||
if opt == "call":
|
||||
return "买Call"
|
||||
if bias:
|
||||
return bias
|
||||
return "—"
|
||||
|
||||
|
||||
def _fmt_num(x: Any, digits: int = 2) -> str:
|
||||
try:
|
||||
if x is None or x == "":
|
||||
return "—"
|
||||
return f"{float(x):.{digits}f}"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _fmt_money(x: Any, *, signed: bool = False) -> str:
|
||||
try:
|
||||
if x is None or x == "":
|
||||
return "—"
|
||||
v = float(x)
|
||||
if signed:
|
||||
return f"{v:+.2f}U"
|
||||
return f"{v:.2f}U"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _pick_float(data: dict[str, Any], *keys: str) -> float | None:
|
||||
for k in keys:
|
||||
if k not in data or data[k] is None or data[k] == "":
|
||||
continue
|
||||
try:
|
||||
return float(data[k])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
|
||||
body = "\n".join(f"> {ln}" if not ln.startswith(">") else ln for ln in (lines or []))
|
||||
machine = wecom_machine_name()
|
||||
venue = venue_label()
|
||||
prefix_parts: list[str] = []
|
||||
if machine:
|
||||
prefix_parts.append(f"【{machine}】")
|
||||
if venue:
|
||||
prefix_parts.append(f"【{venue}】")
|
||||
head = f"## {''.join(prefix_parts)}{title}"
|
||||
parts = [
|
||||
head,
|
||||
f"> **标识**: `{tag}`",
|
||||
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
]
|
||||
if machine:
|
||||
parts.append(f"> **机器**: {machine}")
|
||||
if body:
|
||||
parts.append("")
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _post_markdown_sync(content: str) -> tuple[bool, str]:
|
||||
if not wecom_enabled():
|
||||
return False, "未开启企业微信通知"
|
||||
url = wecom_webhook_url()
|
||||
if not url:
|
||||
return False, "未配置 Webhook"
|
||||
# 企业微信 markdown 上限约 4096 字节
|
||||
raw = content.encode("utf-8")
|
||||
if len(raw) > 4000:
|
||||
content = raw[:3900].decode("utf-8", errors="ignore") + "\n…"
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||
try:
|
||||
with httpx.Client(timeout=8.0) as client:
|
||||
r = client.post(url, json=payload)
|
||||
data = r.json() if r.content else {}
|
||||
if r.status_code != 200 or int(data.get("errcode") or 0) != 0:
|
||||
return False, str(data.get("errmsg") or r.text or r.status_code)
|
||||
return True, "ok"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def send_markdown(content: str) -> tuple[bool, str]:
|
||||
return await asyncio.to_thread(_post_markdown_sync, content)
|
||||
|
||||
|
||||
def notify_async(content: str) -> None:
|
||||
"""火忘:不阻塞策略循环。"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
ok, msg = _post_markdown_sync(content)
|
||||
if not ok:
|
||||
logger.warning("wecom sync send failed: %s", msg)
|
||||
return
|
||||
|
||||
async def _run() -> None:
|
||||
ok, msg = await send_markdown(content)
|
||||
if not ok:
|
||||
logger.warning("wecom send failed: %s", msg)
|
||||
|
||||
loop.create_task(_run())
|
||||
|
||||
|
||||
def notify_start() -> None:
|
||||
notify_async(
|
||||
build_markdown(
|
||||
tag=TAG_START,
|
||||
title="策略启动",
|
||||
lines=["策略已启动,可新开仓并盯目标平仓。"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def notify_pause() -> None:
|
||||
notify_async(
|
||||
build_markdown(
|
||||
tag=TAG_PAUSE,
|
||||
title="策略暂停",
|
||||
lines=[
|
||||
"策略已暂停:**不再新开仓**。",
|
||||
"仍会盯盘:**目标平仓 + 到期平仓**。",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def notify_open(*, group_id: str, detail: str = "", extra: dict[str, Any] | None = None) -> None:
|
||||
extra = dict(extra or {})
|
||||
perp = extra.get("perp") if isinstance(extra.get("perp"), dict) else {}
|
||||
option = extra.get("option") if isinstance(extra.get("option"), dict) else {}
|
||||
|
||||
perp_qty = _pick_float(extra, "perp_qty_eth") or _pick_float(perp, "qty_eth")
|
||||
opt_qty = _pick_float(extra, "option_qty_eth") or _pick_float(option, "qty_eth")
|
||||
premium = _pick_float(extra, "initial_premium", "premium")
|
||||
margin = _pick_float(extra, "perp_margin", "margin")
|
||||
leverage = _pick_float(extra, "leverage")
|
||||
perp_px = _pick_float(extra, "perp_entry_px") or _pick_float(perp, "fill_px")
|
||||
opt_px = _pick_float(extra, "option_entry_px") or _pick_float(option, "fill_px")
|
||||
|
||||
# 缺保证金时用成交价×数量÷杠杆估算
|
||||
if margin is None and perp_px is not None and perp_qty is not None:
|
||||
try:
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
s = get_settings()
|
||||
lev = float(leverage) if leverage and leverage > 0 else float(
|
||||
Ledger().get_setting_float("leverage", s.leverage) or s.leverage or 1
|
||||
)
|
||||
if lev > 0:
|
||||
margin = abs(perp_px * perp_qty) / lev
|
||||
leverage = lev
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines = [
|
||||
f"**组号**: `{group_id}`",
|
||||
f"**方向**: {direction_zh(extra)}",
|
||||
f"**期权合约**: `{extra.get('option_inst_id') or '—'}`",
|
||||
f"**行权价 / 到期**: {_fmt_num(extra.get('strike'), 0)} / {extra.get('expiry_ymd') or '—'}",
|
||||
f"**开仓数量**: 永续 {_fmt_num(perp_qty, 4)} ETH · 期权 {_fmt_num(opt_qty, 4)} ETH",
|
||||
f"**成交均价**: 永续 {_fmt_num(perp_px, 4)} · 期权 {_fmt_num(opt_px, 4)}",
|
||||
f"**权利金占用**: {_fmt_money(premium)}",
|
||||
f"**保证金占用**: {_fmt_money(margin)}"
|
||||
+ (f"(杠杆 {_fmt_num(leverage, 0)}x)" if leverage else ""),
|
||||
]
|
||||
# 说明仅在非模板英文码时展示
|
||||
d = str(detail or "").strip()
|
||||
if d and d not in ("opened", "opened_live", "ok"):
|
||||
lines.append(f"**说明**: {d}")
|
||||
notify_async(build_markdown(tag=TAG_OPEN, title="开仓成功", lines=lines))
|
||||
|
||||
|
||||
def notify_close(
|
||||
*,
|
||||
reason: str,
|
||||
detail: str = "",
|
||||
group_id: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
data = dict(data or {})
|
||||
reason_zh = close_reason_zh(reason)
|
||||
gid = group_id or data.get("group_id") or "—"
|
||||
|
||||
perp_pnl = _pick_float(data, "perp_pnl")
|
||||
opt_pnl = _pick_float(data, "option_pnl", "opt_pnl")
|
||||
net = _pick_float(data, "net", "net_pnl", "interim_net", "realized_pnl")
|
||||
# 只平永续时 interim_net 可能是净利口径
|
||||
if data.get("option_abandoned") and opt_pnl is None:
|
||||
opt_note = "期权已归档,待到期结算(本组未计入期权最终盈亏)"
|
||||
else:
|
||||
opt_note = None
|
||||
|
||||
lines = [
|
||||
f"**组号**: `{gid}`",
|
||||
f"**平仓方式**: {reason_zh}",
|
||||
f"**永续盈亏**: {_fmt_money(perp_pnl, signed=True)}",
|
||||
f"**期权盈亏**: {_fmt_money(opt_pnl, signed=True)}",
|
||||
f"**净利润**: {_fmt_money(net, signed=True)}",
|
||||
]
|
||||
if opt_note:
|
||||
lines.append(f"**备注**: {opt_note}")
|
||||
fees = _pick_float(data, "fees", "fees_total")
|
||||
if fees is None:
|
||||
fo = _pick_float(data, "fees_open")
|
||||
fc = _pick_float(data, "fees_close")
|
||||
if fo is not None or fc is not None:
|
||||
fees = (fo or 0.0) + (fc or 0.0)
|
||||
if fees is not None:
|
||||
lines.append(f"**手续费合计**: {_fmt_money(fees)}")
|
||||
d = str(detail or "").strip()
|
||||
if d and d not in (
|
||||
"closed",
|
||||
"perp_closed_option_residual",
|
||||
"ok",
|
||||
"manual",
|
||||
):
|
||||
lines.append(f"**说明**: {d}")
|
||||
notify_async(build_markdown(tag=TAG_CLOSE, title=f"平仓 · {reason_zh}", lines=lines))
|
||||
|
||||
|
||||
def notify_fault(*, title: str, detail: str, dedupe_key: str | None = None) -> None:
|
||||
global _last_fault_key, _last_fault_ms
|
||||
key = dedupe_key or f"{title}:{detail[:120]}"
|
||||
now = time.time()
|
||||
if key == _last_fault_key and now - _last_fault_ms < _FAULT_DEDUP_SEC:
|
||||
return
|
||||
_last_fault_key = key
|
||||
_last_fault_ms = now
|
||||
notify_async(
|
||||
build_markdown(
|
||||
tag=TAG_FAULT,
|
||||
title=f"故障 · {title}",
|
||||
lines=[f"**详情**: {detail[:500]}"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def notify_test() -> tuple[bool, str]:
|
||||
venue = venue_label()
|
||||
machine = wecom_machine_name()
|
||||
lines = ["企业微信通知已连通。"]
|
||||
if machine:
|
||||
lines.append(f"机器名称: **{machine}**")
|
||||
if venue:
|
||||
lines.append(f"当前盘口标签: **{venue}**")
|
||||
content = build_markdown(
|
||||
tag=TAG_TEST,
|
||||
title="测试推送",
|
||||
lines=lines,
|
||||
)
|
||||
return _post_markdown_sync(content)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""SIM 资金钱包:资金账户 / 交易账户 × USDT|USDC(对齐 OKX,无期权分账户)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..models.db import Database, get_db
|
||||
|
||||
# DB 列名(历史兼容:USDC 存在 options_*_usdc 列,语义为资金/交易账户 USDC)
|
||||
WALLET_KEYS = (
|
||||
"funding_usdt",
|
||||
"trading_usdt",
|
||||
"options_funding_usdc", # = funding_usdc
|
||||
"options_trading_usdc", # = trading_usdc
|
||||
"options_funding_usdt", # 废弃,恒为 0
|
||||
"options_trading_usdt", # 废弃,恒为 0
|
||||
)
|
||||
|
||||
_ACCT_MAP = {
|
||||
("funding", "usdt"): "funding_usdt",
|
||||
("trading", "usdt"): "trading_usdt",
|
||||
("funding", "usdc"): "options_funding_usdc",
|
||||
("trading", "usdc"): "options_trading_usdc",
|
||||
}
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
class SimFundsWallets:
|
||||
def __init__(self, db: Database | None = None) -> None:
|
||||
self.db = db or get_db()
|
||||
|
||||
def snapshot(self) -> dict[str, float]:
|
||||
row = self.db.fetchone("SELECT * FROM funds_wallets WHERE id=1")
|
||||
if row is None:
|
||||
return {k: 0.0 for k in WALLET_KEYS}
|
||||
return {k: float(row[k] or 0) for k in WALLET_KEYS}
|
||||
|
||||
def view(self) -> dict[str, float]:
|
||||
"""对外口径:funding/trading × usdt/usdc。"""
|
||||
s = self.snapshot()
|
||||
return {
|
||||
"funding_usdt": float(s["funding_usdt"]),
|
||||
"trading_usdt": float(s["trading_usdt"]),
|
||||
"funding_usdc": float(s["options_funding_usdc"]),
|
||||
"trading_usdc": float(s["options_trading_usdc"]),
|
||||
}
|
||||
|
||||
def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float:
|
||||
"""USDC 按 1:1 计入总资金。"""
|
||||
if snap is None:
|
||||
v = self.view()
|
||||
elif "funding_usdc" in snap:
|
||||
v = snap
|
||||
else:
|
||||
v = {
|
||||
"funding_usdt": float(snap.get("funding_usdt") or 0),
|
||||
"trading_usdt": float(snap.get("trading_usdt") or 0),
|
||||
"funding_usdc": float(snap.get("options_funding_usdc") or 0),
|
||||
"trading_usdc": float(snap.get("options_trading_usdc") or 0),
|
||||
}
|
||||
return round(
|
||||
float(v.get("funding_usdt") or 0)
|
||||
+ float(v.get("trading_usdt") or 0)
|
||||
+ float(v.get("funding_usdc") or 0)
|
||||
+ float(v.get("trading_usdc") or 0),
|
||||
8,
|
||||
)
|
||||
|
||||
def reset_from_equity(self, equity: float) -> dict[str, float]:
|
||||
"""重置:全部放入资金账户 USDT。"""
|
||||
amt = max(0.0, float(equity))
|
||||
now = _now_ms()
|
||||
self.db.execute(
|
||||
"""UPDATE funds_wallets SET
|
||||
funding_usdt=?, trading_usdt=0, options_funding_usdc=0, options_trading_usdc=0,
|
||||
options_funding_usdt=0, options_trading_usdt=0, updated_at_ms=?
|
||||
WHERE id=1""",
|
||||
(amt, now),
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def _set(self, **kwargs: float) -> dict[str, float]:
|
||||
snap = self.snapshot()
|
||||
for k, v in kwargs.items():
|
||||
if k in WALLET_KEYS:
|
||||
snap[k] = float(v)
|
||||
now = _now_ms()
|
||||
self.db.execute(
|
||||
"""UPDATE funds_wallets SET
|
||||
funding_usdt=?, trading_usdt=?, options_funding_usdc=?, options_trading_usdc=?,
|
||||
options_funding_usdt=0, options_trading_usdt=0, updated_at_ms=?
|
||||
WHERE id=1""",
|
||||
(
|
||||
snap["funding_usdt"],
|
||||
snap["trading_usdt"],
|
||||
snap["options_funding_usdc"],
|
||||
snap["options_trading_usdc"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
return snap
|
||||
|
||||
def mirror_cash(self, amount: float, *, kind: str) -> None:
|
||||
"""策略账本变动镜像到交易账户(永续 USDT / 期权 USDC)。"""
|
||||
amt = float(amount)
|
||||
if abs(amt) < 1e-12:
|
||||
return
|
||||
snap = self.snapshot()
|
||||
k = (kind or "").lower()
|
||||
if "option" in k:
|
||||
key = "options_trading_usdc"
|
||||
else:
|
||||
key = "trading_usdt"
|
||||
snap[key] = float(snap.get(key) or 0) + amt
|
||||
self._set(**snap)
|
||||
|
||||
def sync_ledger_equity(self) -> float:
|
||||
total = self.total_usdt_equiv()
|
||||
now = _now_ms()
|
||||
self.db.execute(
|
||||
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
|
||||
(total, total, now),
|
||||
)
|
||||
return total
|
||||
|
||||
def convert(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
amount: float,
|
||||
rate: float = 1.0,
|
||||
account: str = "funding",
|
||||
) -> dict[str, Any]:
|
||||
"""USDT↔USDC 兑换。默认资金账户;account=trading 时在交易账户内兑(对齐 OKX 现货)。"""
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "数量须大于 0"}
|
||||
r = float(rate) if rate and rate > 0 else 1.0
|
||||
d = (direction or "").strip().lower()
|
||||
acct = (account or "funding").strip().lower()
|
||||
if acct not in ("funding", "trading"):
|
||||
return {"ok": False, "detail": "account 须为 funding / trading"}
|
||||
snap = self.snapshot()
|
||||
if acct == "funding":
|
||||
usdt_key, usdc_key = "funding_usdt", "options_funding_usdc"
|
||||
label = "资金账户"
|
||||
else:
|
||||
usdt_key, usdc_key = "trading_usdt", "options_trading_usdc"
|
||||
label = "交易账户"
|
||||
if d == "usdt_to_usdc":
|
||||
src = float(snap[usdt_key])
|
||||
if amt > src + 1e-9:
|
||||
return {"ok": False, "detail": f"{label} USDT 不足(可用 {src:.4f})"}
|
||||
usdc = amt / r
|
||||
snap[usdt_key] = src - amt
|
||||
snap[usdc_key] = float(snap[usdc_key]) + usdc
|
||||
elif d == "usdc_to_usdt":
|
||||
src = float(snap[usdc_key])
|
||||
if amt > src + 1e-9:
|
||||
return {"ok": False, "detail": f"{label} USDC 不足(可用 {src:.4f})"}
|
||||
usdt = amt * r
|
||||
snap[usdc_key] = src - amt
|
||||
snap[usdt_key] = float(snap[usdt_key]) + usdt
|
||||
else:
|
||||
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
|
||||
self._set(**snap)
|
||||
total = self.sync_ledger_equity()
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "converted",
|
||||
"direction": d,
|
||||
"amount": amt,
|
||||
"rate": r,
|
||||
"account": acct,
|
||||
"wallets": self.view(),
|
||||
"total_usdt_equiv": total,
|
||||
}
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
*,
|
||||
ccy: str,
|
||||
amount: float,
|
||||
from_account: str,
|
||||
to_account: str,
|
||||
) -> dict[str, Any]:
|
||||
"""仅资金账户 ↔ 交易账户。"""
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "划转金额须大于 0"}
|
||||
ccy_l = (ccy or "USDC").strip().lower()
|
||||
fa = (from_account or "").strip().lower()
|
||||
ta = (to_account or "").strip().lower()
|
||||
allowed = {"funding", "trading"}
|
||||
if fa not in allowed or ta not in allowed:
|
||||
return {"ok": False, "detail": "账户仅支持 funding / trading"}
|
||||
if fa == ta:
|
||||
return {"ok": False, "detail": "来源与目标账户不能相同"}
|
||||
src_key = _ACCT_MAP.get((fa, ccy_l))
|
||||
dst_key = _ACCT_MAP.get((ta, ccy_l))
|
||||
if not src_key or not dst_key:
|
||||
return {"ok": False, "detail": "币种须为 USDT 或 USDC"}
|
||||
snap = self.snapshot()
|
||||
src_bal = float(snap[src_key])
|
||||
if amt > src_bal + 1e-9:
|
||||
return {"ok": False, "detail": f"余额不足(可用 {src_bal:.4f})"}
|
||||
snap[src_key] = src_bal - amt
|
||||
snap[dst_key] = float(snap[dst_key]) + amt
|
||||
self._set(**snap)
|
||||
total = self.sync_ledger_equity()
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "transferred",
|
||||
"ccy": ccy_l.upper(),
|
||||
"amount": amt,
|
||||
"from": fa,
|
||||
"to": ta,
|
||||
"wallets": self.view(),
|
||||
"total_usdt_equiv": total,
|
||||
}
|
||||
@@ -27,15 +27,21 @@ class Ledger:
|
||||
kind: str,
|
||||
group_id: str | None = None,
|
||||
note: str = "",
|
||||
allow_negative: bool = False,
|
||||
commit: bool = True,
|
||||
) -> float:
|
||||
"""amount>0 入账;amount<0 出账。返回余额。"""
|
||||
"""amount>0 入账;amount<0 出账。返回余额。
|
||||
|
||||
LIVE 实盘成交后本地账本仅作镜像,须 allow_negative=True,避免「交易所已成交、本地拒记」导致卡仓。
|
||||
commit=False:由调用方持锁并统一提交(与持仓/残留状态同事务)。
|
||||
"""
|
||||
now = int(time.time() * 1000)
|
||||
with self.db._lock:
|
||||
row = self.db._conn.execute("SELECT * FROM ledger_meta WHERE id=1").fetchone()
|
||||
assert row is not None
|
||||
equity = float(row["equity"]) + float(amount)
|
||||
available = float(row["available"]) + float(amount)
|
||||
if available < -1e-9:
|
||||
if not allow_negative and available < -1e-9:
|
||||
raise RuntimeError("可用资金不足")
|
||||
self.db._conn.execute(
|
||||
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
|
||||
@@ -45,15 +51,55 @@ class Ledger:
|
||||
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
|
||||
(group_id, kind, float(amount), equity, note, now),
|
||||
)
|
||||
if commit:
|
||||
self.db._conn.commit()
|
||||
if commit:
|
||||
try:
|
||||
from ..config import get_settings
|
||||
from .funds_wallets import SimFundsWallets
|
||||
|
||||
if get_settings().is_sim:
|
||||
SimFundsWallets(self.db).mirror_cash(float(amount), kind=kind)
|
||||
except Exception:
|
||||
pass
|
||||
return equity
|
||||
|
||||
def clear_trade_history(self) -> None:
|
||||
"""清空交易记录与持仓痕迹(组/成交/残留/账本流水),仓位置 flat。"""
|
||||
now = int(time.time() * 1000)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute("DELETE FROM fills")
|
||||
self.db._conn.execute("DELETE FROM residual_options")
|
||||
self.db._conn.execute("DELETE FROM groups")
|
||||
self.db._conn.execute("DELETE FROM ledger_entries")
|
||||
self.db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0,
|
||||
option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL,
|
||||
initial_premium=0, exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"""UPDATE strategy_state SET
|
||||
rounds_done=0, window_key=NULL, rest_until_ms=NULL,
|
||||
last_error=NULL, phase=CASE WHEN running=1 THEN phase ELSE 'idle' END,
|
||||
updated_at_ms=?
|
||||
WHERE id=1""",
|
||||
(now,),
|
||||
)
|
||||
self.db._conn.execute(
|
||||
"DELETE FROM settings WHERE key=?", ("risk_last_k",)
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
def reset_equity(self, amount: float, *, note: str = "重置模拟资金") -> float:
|
||||
"""将权益与可用资金重置为 amount(reserved 清零)。须在无持仓时调用。"""
|
||||
"""将权益与可用资金重置为 amount(reserved 清零),并清空交易记录。须在无持仓时调用。"""
|
||||
now = int(time.time() * 1000)
|
||||
amt = float(amount)
|
||||
if amt < 0:
|
||||
raise ValueError("模拟资金不能为负")
|
||||
self.clear_trade_history()
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"UPDATE ledger_meta SET equity=?, available=?, reserved=0, updated_at_ms=? WHERE id=1",
|
||||
@@ -64,6 +110,14 @@ class Ledger:
|
||||
(None, "reset", amt, amt, note, now),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
try:
|
||||
from ..config import get_settings
|
||||
from .funds_wallets import SimFundsWallets
|
||||
|
||||
if get_settings().is_sim:
|
||||
SimFundsWallets(self.db).reset_from_equity(amt)
|
||||
except Exception:
|
||||
pass
|
||||
return amt
|
||||
|
||||
def get_setting_float(self, key: str, default: float) -> float:
|
||||
|
||||
+1832
-43
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
"""从成交明细汇总腿盈亏与净盈亏。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _as_map(x: Any) -> dict[str, Any]:
|
||||
if isinstance(x, dict):
|
||||
return x
|
||||
try:
|
||||
return dict(x)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _leg_option_pnl(rows: list[dict[str, Any]], leg: str) -> float | None:
|
||||
opt_open = next(
|
||||
(f for f in rows if f.get("leg") == leg and f.get("action") == "open"),
|
||||
None,
|
||||
)
|
||||
opt_close = next(
|
||||
(f for f in rows if f.get("leg") == leg and f.get("action") == "close"),
|
||||
None,
|
||||
)
|
||||
if not opt_open or not opt_close:
|
||||
return None
|
||||
qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0)
|
||||
return (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty
|
||||
|
||||
|
||||
def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]:
|
||||
"""
|
||||
价差盈亏按 fill_px;手续费另扣。
|
||||
净盈亏 = 各腿盈亏之和 − 全部手续费(开+平)。
|
||||
支持永期(option+perp)与期期(option+option2)。
|
||||
|
||||
手续费拆:fees_perp / fees_option(含 option2)/ fees_option2;
|
||||
滑点合计 slip_total(SIM 记账;LIVE 应为 0)。
|
||||
"""
|
||||
rows = [_as_map(x) for x in fills]
|
||||
|
||||
option_pnl = _leg_option_pnl(rows, "option")
|
||||
option2_pnl = _leg_option_pnl(rows, "option2")
|
||||
|
||||
perp_open = next(
|
||||
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"),
|
||||
None,
|
||||
)
|
||||
perp_close = next(
|
||||
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"),
|
||||
None,
|
||||
)
|
||||
|
||||
perp_pnl: float | None = None
|
||||
if perp_open and perp_close:
|
||||
qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0)
|
||||
side = str(perp_open.get("side") or "")
|
||||
o = float(perp_open["fill_px"])
|
||||
c = float(perp_close["fill_px"])
|
||||
if side == "long":
|
||||
perp_pnl = (c - o) * qty
|
||||
else:
|
||||
perp_pnl = (o - c) * qty
|
||||
|
||||
fees_perp = sum(
|
||||
float(f.get("fee") or 0) for f in rows if str(f.get("leg") or "") == "perp"
|
||||
)
|
||||
fees_option = sum(
|
||||
float(f.get("fee") or 0)
|
||||
for f in rows
|
||||
if str(f.get("leg") or "") in ("option", "option2")
|
||||
)
|
||||
fees_option2 = sum(
|
||||
float(f.get("fee") or 0) for f in rows if str(f.get("leg") or "") == "option2"
|
||||
)
|
||||
fees_total = fees_perp + fees_option
|
||||
slip_total = sum(float(f.get("slip") or 0) for f in rows)
|
||||
|
||||
parts: list[float] = []
|
||||
if option_pnl is not None:
|
||||
parts.append(option_pnl)
|
||||
if option2_pnl is not None:
|
||||
parts.append(option2_pnl)
|
||||
if perp_pnl is not None:
|
||||
parts.append(perp_pnl)
|
||||
|
||||
gross = sum(parts) if parts else None
|
||||
net = (gross - fees_total) if gross is not None else None
|
||||
|
||||
return {
|
||||
"option_pnl": option_pnl,
|
||||
"option2_pnl": option2_pnl,
|
||||
"perp_pnl": perp_pnl,
|
||||
"fees_perp": fees_perp,
|
||||
"fees_option": fees_option,
|
||||
"fees_option2": fees_option2,
|
||||
"fees_total": fees_total,
|
||||
"slip_total": slip_total,
|
||||
"gross_pnl": gross,
|
||||
"net_pnl": net,
|
||||
}
|
||||
@@ -17,6 +17,75 @@ class PriceResult:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float:
|
||||
"""多头期权内在价值(USDT/ETH)。call=max(S−K,0),put=max(K−S,0)。"""
|
||||
s = float(spot)
|
||||
k = float(strike)
|
||||
side = str(option_side).lower().strip()
|
||||
if side in ("call", "c"):
|
||||
return max(s - k, 0.0)
|
||||
if side in ("put", "p"):
|
||||
return max(k - s, 0.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def is_deep_otm(
|
||||
*,
|
||||
option_side: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
max_intrinsic: float = 0.01,
|
||||
) -> bool:
|
||||
"""
|
||||
远虚:内在价值≈0(多头期权已无行权价值)。
|
||||
100×杠杆 ATM 在标的波动约1%后常落入此状态。
|
||||
"""
|
||||
return option_intrinsic(
|
||||
option_side=option_side, strike=strike, spot=spot
|
||||
) <= float(max_intrinsic)
|
||||
|
||||
|
||||
def option_expiry_settle(
|
||||
*,
|
||||
intrinsic: float,
|
||||
qty_eth: float,
|
||||
fee_rate: float,
|
||||
) -> PriceResult:
|
||||
"""到期结算:按内在价值入账(对齐实盘),无买卖价差滑点,仅扣手续费。"""
|
||||
base = max(float(intrinsic), 0.0)
|
||||
fill = base
|
||||
f = float(fee_rate)
|
||||
notional = abs(fill * float(qty_eth))
|
||||
fee = notional * f
|
||||
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=0.0, notional=notional)
|
||||
|
||||
|
||||
def resolve_option_close_bid(
|
||||
*,
|
||||
bid: float | None,
|
||||
mark: float | None,
|
||||
intrinsic: float | None,
|
||||
bypass_liquidity: bool,
|
||||
) -> float | None:
|
||||
"""
|
||||
非到期平仓价(SIM)。
|
||||
- 常规:买一,且多头卖出不低于内在价值。
|
||||
- 紧急 bypass:对齐 OKX 市价卖,**只按买一**(不用标记/内在价值抬价;
|
||||
标记无法在交易所成交)。bypass 只绕过深度/偏差闸门。
|
||||
到期请用 option_expiry_settle,不要走本函数。
|
||||
"""
|
||||
_ = mark # 保留参数兼容调用方;紧急不再用标记定价
|
||||
if bypass_liquidity:
|
||||
if bid is None or bid < 0:
|
||||
return None
|
||||
return float(bid)
|
||||
if bid is None:
|
||||
return None
|
||||
if intrinsic is not None and intrinsic >= 0:
|
||||
return max(float(bid), float(intrinsic))
|
||||
return float(bid)
|
||||
|
||||
|
||||
def perp_fill(
|
||||
*,
|
||||
side: str,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""振幅过滤门:回看窗内 range% 须 ≤ 上限(可关)。永期开仓第一关 / 期期共用。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..exchange.candles import AmplitudeHL
|
||||
|
||||
|
||||
def evaluate_amplitude_gate(
|
||||
*,
|
||||
filter_enabled: bool,
|
||||
amp: AmplitudeHL | None,
|
||||
max_pct: float,
|
||||
hours: float,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回:
|
||||
blocked: 过滤开启且未过关(无K线或超限)
|
||||
ok: 展示用是否过关(过滤关视为 True)
|
||||
reason: 拒开文案(未拒则为 None)
|
||||
snapshot: 写入 session._oo_amp 的字典(amp 为 None 时仅含元数据)
|
||||
"""
|
||||
max_p = float(max_pct)
|
||||
hrs = float(hours)
|
||||
filt = bool(filter_enabled)
|
||||
if amp is None:
|
||||
snap = {
|
||||
"high": None,
|
||||
"low": None,
|
||||
"mid": None,
|
||||
"range_pct": None,
|
||||
"hours": hrs,
|
||||
"max_pct": max_p,
|
||||
"filter_enabled": filt,
|
||||
"ok": False if filt else True,
|
||||
}
|
||||
if filt:
|
||||
return {
|
||||
"blocked": True,
|
||||
"ok": False,
|
||||
"reason": f"振幅未过关:无法获取近 {hrs:g}h K 线高低",
|
||||
"snapshot": snap,
|
||||
}
|
||||
return {"blocked": False, "ok": True, "reason": None, "snapshot": snap}
|
||||
|
||||
range_pct = float(amp.range_pct)
|
||||
over = range_pct > max_p + 1e-12
|
||||
ok = (not filt) or (not over)
|
||||
snap = {
|
||||
"high": float(amp.high),
|
||||
"low": float(amp.low),
|
||||
"mid": float(amp.mid),
|
||||
"range_pct": range_pct,
|
||||
"hours": hrs,
|
||||
"max_pct": max_p,
|
||||
"filter_enabled": filt,
|
||||
"ok": ok,
|
||||
}
|
||||
if filt and over:
|
||||
return {
|
||||
"blocked": True,
|
||||
"ok": False,
|
||||
"reason": (
|
||||
f"振幅未过关:{hrs:g}h 内 {range_pct:.2f}% > {max_p:g}%"
|
||||
),
|
||||
"snapshot": snap,
|
||||
}
|
||||
return {"blocked": False, "ok": ok, "reason": None, "snapshot": snap}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""OKX:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。
|
||||
|
||||
仅 OKX SIM/LIVE。资金账户不参与;期权已可开则跳过。
|
||||
等待选约阶段只用预览名义检测(不落库);落库定仓在 open_pipeline.size_and_gate。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.db import Database, get_db
|
||||
from .open_capacity import assess_open_capacity, invalidate_live_balance_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TARGET_MULTIPLE = 2.0
|
||||
_MIN_CONVERT_USDT = 1.0
|
||||
_RETRY_COOLDOWN_SEC = 45.0
|
||||
_last_attempt_ts: float = 0.0
|
||||
|
||||
|
||||
def _is_okx(exchange: str | None = None) -> bool:
|
||||
from ..exchange.runtime import load_runtime_settings, normalize_exchange_name
|
||||
|
||||
ex = exchange
|
||||
if not ex:
|
||||
try:
|
||||
ex = load_runtime_settings().exchange
|
||||
except Exception:
|
||||
ex = get_settings().exchange
|
||||
return normalize_exchange_name(ex) == "okx"
|
||||
|
||||
|
||||
def _round_down(n: float, nd: int = 2) -> float:
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
f = 10**nd
|
||||
return math.floor(n * f + 1e-12) / f
|
||||
|
||||
|
||||
def preview_capacity_for_convert(db: Database | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
用预览以损定仓名义评估资金门(不写 settings)。
|
||||
- 选约杠杆:用定仓卖一(指数/杠杆下限),与预算/k 一致,勿用监控未达标贵卖一
|
||||
- 实际杠杆:用盘口卖一
|
||||
手动仓位则直接 assess 当前账本名义。
|
||||
"""
|
||||
database = db or get_db()
|
||||
try:
|
||||
from .risk_sizing import preview_risk_sizing
|
||||
|
||||
prev = preview_risk_sizing(database)
|
||||
if prev.get("risk_based") and prev.get("ok"):
|
||||
# option_ask 在 preview 里已是定仓口径(selection=隐含 / actual=盘口)
|
||||
cap = assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(prev["option_ask"])
|
||||
if prev.get("option_ask") is not None
|
||||
else None,
|
||||
option_qty_eth=float(prev["option_qty_eth"])
|
||||
if prev.get("option_qty_eth") is not None
|
||||
else None,
|
||||
perp_qty_eth=float(prev["perp_qty_eth"])
|
||||
if prev.get("perp_qty_eth") is not None
|
||||
else None,
|
||||
call_ask=float(prev["call_ask"])
|
||||
if prev.get("call_ask") is not None
|
||||
else None,
|
||||
put_ask=float(prev["put_ask"])
|
||||
if prev.get("put_ask") is not None
|
||||
else None,
|
||||
)
|
||||
cap["capacity_basis"] = "risk_preview"
|
||||
cap["leverage_basis"] = prev.get("leverage_basis")
|
||||
return cap
|
||||
except Exception:
|
||||
logger.exception("preview capacity for convert failed")
|
||||
cap = assess_open_capacity(database)
|
||||
cap["capacity_basis"] = "ledger"
|
||||
return cap
|
||||
|
||||
|
||||
def prepare_okx_trading_usdc(db: Database | None = None) -> dict[str, Any]:
|
||||
"""等待阶段:预览名义评估 + 兑换,不落库。"""
|
||||
database = db or get_db()
|
||||
cap = preview_capacity_for_convert(database)
|
||||
conv = ensure_okx_trading_usdc(database, cap=cap, force=False)
|
||||
return {"capacity": cap, "convert": conv}
|
||||
|
||||
|
||||
def ensure_okx_trading_usdc(
|
||||
db: Database | None = None,
|
||||
*,
|
||||
cap: dict[str, Any] | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
- 非 OKX → 跳过
|
||||
- 期权可开(USDC≥需)→ 跳过
|
||||
- 否则交易账户市价 USDT→USDC,尽量补到 需×2(预留永续保证金)
|
||||
- force 不再绕过冷却(防砸单);保留参数仅为兼容调用方
|
||||
"""
|
||||
global _last_attempt_ts
|
||||
_ = force # 明确忽略:冷却始终生效
|
||||
db = db or get_db()
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"acted": False,
|
||||
"skipped": True,
|
||||
"detail": "skip",
|
||||
}
|
||||
if not _is_okx():
|
||||
out["detail"] = "非 OKX,跳过自动兑 USDC"
|
||||
return out
|
||||
|
||||
cap = cap or assess_open_capacity(db)
|
||||
need = cap.get("option_need_usdc")
|
||||
have = cap.get("option_have_usdc")
|
||||
if need is None or have is None:
|
||||
out["detail"] = "期权所需/持有未知,跳过兑换"
|
||||
out["capacity"] = cap
|
||||
return out
|
||||
|
||||
need_f = float(need)
|
||||
have_f = float(have)
|
||||
if need_f <= 0:
|
||||
out["detail"] = "期权所需为 0,跳过"
|
||||
return out
|
||||
|
||||
if have_f + 1e-9 >= need_f:
|
||||
out["detail"] = (
|
||||
f"交易账户 USDC 已够开仓(有 {have_f:.2f} ≥ 需 {need_f:.2f}),不兑换"
|
||||
)
|
||||
out["capacity"] = cap
|
||||
return out
|
||||
|
||||
now = time.time()
|
||||
if _last_attempt_ts > 0 and now - _last_attempt_ts < _RETRY_COOLDOWN_SEC:
|
||||
left = _RETRY_COOLDOWN_SEC - (now - _last_attempt_ts)
|
||||
out["detail"] = f"USDC 不足,自动兑换冷却中({left:.0f}s)"
|
||||
out["capacity"] = cap
|
||||
out["need_usdc"] = round(need_f, 2)
|
||||
out["have_usdc"] = round(have_f, 2)
|
||||
return out
|
||||
|
||||
target = need_f * _TARGET_MULTIPLE
|
||||
gap_usdc = target - have_f
|
||||
if gap_usdc <= 1e-6:
|
||||
out["detail"] = "无需补足"
|
||||
return out
|
||||
|
||||
from ..live.okx_funds import usdc_usdt_mid_rate
|
||||
|
||||
rate = float(usdc_usdt_mid_rate() or 1.0)
|
||||
if rate <= 0:
|
||||
rate = 1.0
|
||||
want_usdt = gap_usdc * rate
|
||||
|
||||
perp_need = float(cap.get("perp_need_usdt") or 0)
|
||||
trading_usdt = float(cap.get("perp_have_usdt") or 0)
|
||||
spendable = max(0.0, trading_usdt - max(0.0, perp_need))
|
||||
spend_usdt = _round_down(min(want_usdt, spendable), 2)
|
||||
|
||||
out.update(
|
||||
{
|
||||
"need_usdc": round(need_f, 2),
|
||||
"have_usdc": round(have_f, 2),
|
||||
"target_usdc": round(target, 2),
|
||||
"want_usdt": round(want_usdt, 2),
|
||||
"spend_usdt": spend_usdt,
|
||||
"rate": rate,
|
||||
"spendable_usdt": round(spendable, 2),
|
||||
}
|
||||
)
|
||||
|
||||
if spend_usdt < _MIN_CONVERT_USDT:
|
||||
out["ok"] = False
|
||||
out["skipped"] = True
|
||||
out["detail"] = (
|
||||
f"交易账户可兑 USDT 不足(可兑 {spendable:.2f},"
|
||||
f"目标补约 {want_usdt:.2f},门槛 {_MIN_CONVERT_USDT})"
|
||||
)
|
||||
out["capacity"] = cap
|
||||
return out
|
||||
|
||||
_last_attempt_ts = now
|
||||
s = get_settings()
|
||||
try:
|
||||
if s.is_sim:
|
||||
from ..sim.funds_wallets import SimFundsWallets
|
||||
|
||||
r = SimFundsWallets(db).convert(
|
||||
direction="usdt_to_usdc",
|
||||
amount=spend_usdt,
|
||||
rate=rate,
|
||||
account="trading",
|
||||
)
|
||||
else:
|
||||
from ..live.okx_funds import OkxFundsClient
|
||||
|
||||
client = OkxFundsClient()
|
||||
try:
|
||||
r = client.spot_swap_usdt_usdc(
|
||||
direction="usdt_to_usdc",
|
||||
amount=spend_usdt,
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
invalidate_live_balance_cache()
|
||||
except Exception as e:
|
||||
logger.exception("auto USDC convert failed")
|
||||
out["ok"] = False
|
||||
out["skipped"] = False
|
||||
out["acted"] = False
|
||||
out["detail"] = f"自动兑换异常:{e}"
|
||||
return out
|
||||
|
||||
if not r.get("ok"):
|
||||
out["ok"] = False
|
||||
out["skipped"] = False
|
||||
out["acted"] = False
|
||||
out["detail"] = f"自动兑换失败:{r.get('detail') or r}"
|
||||
out["raw"] = r
|
||||
logger.warning("auto_usdc failed: %s", out["detail"])
|
||||
return out
|
||||
|
||||
invalidate_live_balance_cache()
|
||||
cap2 = assess_open_capacity(db)
|
||||
out.update(
|
||||
{
|
||||
"ok": True,
|
||||
"acted": True,
|
||||
"skipped": False,
|
||||
"detail": (
|
||||
f"交易账户市价兑 USDC:花 {spend_usdt:.2f} USDT"
|
||||
f"(目标持仓≈{target:.2f}=需{need_f:.2f}×{_TARGET_MULTIPLE:g})"
|
||||
),
|
||||
"capacity_before": cap,
|
||||
"capacity_after": cap2,
|
||||
"raw": r,
|
||||
}
|
||||
)
|
||||
logger.info("auto_usdc: %s", out["detail"])
|
||||
return out
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
@@ -38,3 +39,52 @@ def can_open_new(
|
||||
|
||||
def group_date_ymd(now: datetime | None = None) -> str:
|
||||
return window_key(now)
|
||||
|
||||
|
||||
def used_expiry_ymds(db: Any, now: datetime | None = None) -> set[str]:
|
||||
"""
|
||||
历史上已开过的期权到期日(groups.expiry_ymd,YYMMDD)。
|
||||
跨日历日:任一已开组(含已平仓)占用该到期后,不可再开同到期,只能盯下一档。
|
||||
"""
|
||||
_ = now
|
||||
rows = db.fetchall(
|
||||
"""SELECT DISTINCT expiry_ymd FROM groups
|
||||
WHERE expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''"""
|
||||
)
|
||||
out: set[str] = set()
|
||||
for r in rows or []:
|
||||
y = str(r["expiry_ymd"] or "").strip()
|
||||
if y:
|
||||
out.add(y)
|
||||
return out
|
||||
|
||||
|
||||
# 兼容旧名(语义已改为跨日/历史到期占用)
|
||||
used_expiry_ymds_for_day = used_expiry_ymds
|
||||
|
||||
|
||||
def pending_residual_expiry_ymds(db: Any) -> set[str]:
|
||||
"""待到期结算的残留期权到期日(YYMMDD);监控/下一组开仓应跳过这些档。"""
|
||||
rows = db.fetchall(
|
||||
"""SELECT DISTINCT expiry_ymd FROM residual_options
|
||||
WHERE status='pending' AND expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''"""
|
||||
)
|
||||
out: set[str] = set()
|
||||
for r in rows or []:
|
||||
y = str(r["expiry_ymd"] or "").strip()
|
||||
if y:
|
||||
out.add(y)
|
||||
return out
|
||||
|
||||
|
||||
def expiry_blocked_by_one_per_day(
|
||||
expiry_ymd: str | None,
|
||||
used: set[str],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
) -> bool:
|
||||
"""开启时:候选到期已在历史上用过则拦截。"""
|
||||
if not enabled:
|
||||
return False
|
||||
y = str(expiry_ymd or "").strip()
|
||||
return bool(y and y in used)
|
||||
|
||||
+1192
-34
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
EXIT_MODE_FIXED = "fixed_usdt"
|
||||
EXIT_MODE_PREMIUM = "premium_multiple"
|
||||
@@ -29,6 +30,62 @@ def resolve_exit_target(
|
||||
return float(net_profit_target), EXIT_MODE_FIXED
|
||||
|
||||
|
||||
def lock_trade_exit_target(
|
||||
db: Any,
|
||||
*,
|
||||
group_id: str,
|
||||
initial_premium: float,
|
||||
) -> float:
|
||||
"""
|
||||
开仓成交后锁定本组成交出场目标到 groups/positions。
|
||||
持仓期间盯盘与展示均用该值,不再跟随时价重算以损定仓/出场。
|
||||
"""
|
||||
from ..config import get_settings
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
s = get_settings()
|
||||
ledger = Ledger(db)
|
||||
exit_mode = ledger.get_setting_str("exit_mode", s.exit_mode) or EXIT_MODE_FIXED
|
||||
net_target = float(
|
||||
ledger.get_setting_float("net_profit_target", s.net_profit_target)
|
||||
or s.net_profit_target
|
||||
)
|
||||
prem_mult = float(
|
||||
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
|
||||
or s.premium_exit_multiple
|
||||
)
|
||||
target, _mode = resolve_exit_target(
|
||||
exit_mode=str(exit_mode),
|
||||
net_profit_target=net_target,
|
||||
premium_exit_multiple=prem_mult,
|
||||
initial_premium=float(initial_premium or 0),
|
||||
)
|
||||
target = float(target)
|
||||
db.execute(
|
||||
"UPDATE groups SET exit_target_usdt=? WHERE group_id=?",
|
||||
(target, group_id),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE positions SET exit_target_usdt=? WHERE id=1",
|
||||
(target,),
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def read_locked_exit_target(pos: dict[str, Any] | None) -> float | None:
|
||||
"""持仓行上的锁定目标;无则 None(旧仓回退设置值)。"""
|
||||
if not pos:
|
||||
return None
|
||||
v = pos.get("exit_target_usdt")
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f if f > 0 else None
|
||||
|
||||
|
||||
def check_expiry_close(
|
||||
*,
|
||||
expiry_ms: int | None,
|
||||
@@ -50,14 +107,20 @@ def check_exits(
|
||||
net_profit_target: float,
|
||||
premium_exit_multiple: float,
|
||||
initial_premium: float,
|
||||
locked_exit_target: float | None = None,
|
||||
) -> ExitDecision:
|
||||
"""净盈利(预估全平后)≥ 所选模式目标则全平。"""
|
||||
target, mode = resolve_exit_target(
|
||||
"""净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。"""
|
||||
# 模式始终按设置解析(权利金倍数 vs 固定),勿因锁定目标就改成 fixed_usdt
|
||||
_resolved_target, mode = resolve_exit_target(
|
||||
exit_mode=exit_mode,
|
||||
net_profit_target=net_profit_target,
|
||||
premium_exit_multiple=premium_exit_multiple,
|
||||
initial_premium=initial_premium,
|
||||
)
|
||||
if locked_exit_target is not None and float(locked_exit_target) > 0:
|
||||
target = float(locked_exit_target)
|
||||
else:
|
||||
target = float(_resolved_target)
|
||||
if target > 0 and net_pnl + 1e-9 >= target:
|
||||
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
|
||||
return ExitDecision(True, reason, target)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""期期对冲选约:振幅高低点匹配虚值 Call + Put。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ..exchange.candles import AmplitudeHL, fetch_amplitude_hl_for_runtime
|
||||
from .selection import (
|
||||
_complete_by_expiry,
|
||||
hours_until_ms,
|
||||
list_eligible_expiry_ymds,
|
||||
option_leverage,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OoLeg:
|
||||
side: str # call|put
|
||||
strike: float
|
||||
inst_id: str
|
||||
ask: float
|
||||
leverage: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OoPickCore:
|
||||
expiry_ymd: str
|
||||
expiry_ms: int
|
||||
hours_left: float
|
||||
underlying_px: float
|
||||
amplitude: AmplitudeHL
|
||||
call: OoLeg
|
||||
put: OoLeg
|
||||
detail: str = "ok"
|
||||
|
||||
|
||||
def _within_ref_pct(strike: float, ref: float, max_dev_pct: float) -> bool:
|
||||
"""|K−ref|/ref ≤ max_dev_pct%。"""
|
||||
if ref <= 0 or max_dev_pct < 0:
|
||||
return False
|
||||
return abs(float(strike) - float(ref)) / float(ref) * 100.0 <= float(
|
||||
max_dev_pct
|
||||
) + 1e-12
|
||||
|
||||
|
||||
def pick_otm_call_strike(
|
||||
strikes: list[float],
|
||||
*,
|
||||
spot: float,
|
||||
high: float,
|
||||
max_dev_pct: float = 1.0,
|
||||
) -> float | None:
|
||||
"""虚值 Call:K > spot,贴近振幅高点,且 |K−高|/高 ≤ max_dev_pct%。"""
|
||||
cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9]
|
||||
if max_dev_pct >= 0 and high > 0:
|
||||
cands = [s for s in cands if _within_ref_pct(s, high, max_dev_pct)]
|
||||
if not cands:
|
||||
return None
|
||||
return min(cands, key=lambda s: (abs(s - float(high)), s))
|
||||
|
||||
|
||||
def pick_otm_put_strike(
|
||||
strikes: list[float],
|
||||
*,
|
||||
spot: float,
|
||||
low: float,
|
||||
max_dev_pct: float = 1.0,
|
||||
) -> float | None:
|
||||
"""虚值 Put:K < spot,贴近振幅低点,且 |K−低|/低 ≤ max_dev_pct%。"""
|
||||
cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9]
|
||||
if max_dev_pct >= 0 and low > 0:
|
||||
cands = [s for s in cands if _within_ref_pct(s, low, max_dev_pct)]
|
||||
if not cands:
|
||||
return None
|
||||
return min(cands, key=lambda s: (abs(s - float(low)), s))
|
||||
|
||||
|
||||
def select_oo_pair(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
spot: float,
|
||||
high: float,
|
||||
low: float,
|
||||
min_hours: float,
|
||||
now: datetime | None = None,
|
||||
skip_expiry_ymds: set[str] | None = None,
|
||||
max_dev_pct: float = 1.0,
|
||||
) -> tuple[str, int, float, float, str, str] | None:
|
||||
"""
|
||||
返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)。
|
||||
Call/Put 可不同行权价;须同到期、均为虚值,且相对高低点偏离不超过 max_dev_pct%。
|
||||
"""
|
||||
if spot <= 0 or high <= 0 or low <= 0 or high < low:
|
||||
return None
|
||||
complete = _complete_by_expiry(contracts)
|
||||
if not complete:
|
||||
return None
|
||||
skip = skip_expiry_ymds or set()
|
||||
eligible = [
|
||||
y
|
||||
for y in list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now)
|
||||
if y not in skip
|
||||
]
|
||||
for ymd in eligible:
|
||||
ems, strikes_map = complete[ymd]
|
||||
strikes = list(strikes_map.keys())
|
||||
ck = pick_otm_call_strike(
|
||||
strikes, spot=spot, high=high, max_dev_pct=max_dev_pct
|
||||
)
|
||||
pk = pick_otm_put_strike(
|
||||
strikes, spot=spot, low=low, max_dev_pct=max_dev_pct
|
||||
)
|
||||
if ck is None or pk is None:
|
||||
continue
|
||||
call_inst = strikes_map[ck].get("C")
|
||||
put_inst = strikes_map[pk].get("P")
|
||||
if not call_inst or not put_inst:
|
||||
continue
|
||||
hours_left = hours_until_ms(ems, now)
|
||||
return (
|
||||
ymd,
|
||||
int(ems),
|
||||
float(ck),
|
||||
float(pk),
|
||||
str(call_inst),
|
||||
str(put_inst),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_oo_pick_core(
|
||||
*,
|
||||
contracts: list[dict[str, Any]],
|
||||
spot: float,
|
||||
call_ask: float,
|
||||
put_ask: float,
|
||||
min_hours: float,
|
||||
min_leverage: float,
|
||||
amplitude_hours: float,
|
||||
amplitude_pct: float,
|
||||
amplitude: AmplitudeHL | None = None,
|
||||
skip_expiry_ymds: set[str] | None = None,
|
||||
now: datetime | None = None,
|
||||
max_dev_pct: float = 1.0,
|
||||
amplitude_filter_enabled: bool = True,
|
||||
) -> OoPickCore | None:
|
||||
"""完整期期选约:振幅门(可关)+ 虚值双腿(贴高低≤max_dev%) + 杠杆。"""
|
||||
amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours)
|
||||
if amp is None:
|
||||
return None
|
||||
if amplitude_filter_enabled and float(amp.range_pct) > float(amplitude_pct) + 1e-12:
|
||||
return None
|
||||
if spot <= 0:
|
||||
spot = float(amp.mid)
|
||||
picked = select_oo_pair(
|
||||
contracts,
|
||||
spot=float(spot),
|
||||
high=float(amp.high),
|
||||
low=float(amp.low),
|
||||
min_hours=float(min_hours),
|
||||
now=now,
|
||||
skip_expiry_ymds=skip_expiry_ymds,
|
||||
max_dev_pct=float(max_dev_pct),
|
||||
)
|
||||
if picked is None:
|
||||
return None
|
||||
ymd, ems, ck, pk, call_inst, put_inst = picked
|
||||
if call_ask <= 0 or put_ask <= 0:
|
||||
return None
|
||||
c_lev = option_leverage(float(spot), float(call_ask))
|
||||
p_lev = option_leverage(float(spot), float(put_ask))
|
||||
if c_lev is None or p_lev is None:
|
||||
return None
|
||||
if c_lev + 1e-12 < float(min_leverage) or p_lev + 1e-12 < float(min_leverage):
|
||||
return None
|
||||
hours_left = hours_until_ms(ems, now)
|
||||
return OoPickCore(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=int(ems),
|
||||
hours_left=float(hours_left),
|
||||
underlying_px=float(spot),
|
||||
amplitude=amp,
|
||||
call=OoLeg(
|
||||
side="call",
|
||||
strike=float(ck),
|
||||
inst_id=call_inst,
|
||||
ask=float(call_ask),
|
||||
leverage=float(c_lev),
|
||||
),
|
||||
put=OoLeg(
|
||||
side="put",
|
||||
strike=float(pk),
|
||||
inst_id=put_inst,
|
||||
ask=float(put_ask),
|
||||
leverage=float(p_lev),
|
||||
),
|
||||
detail=(
|
||||
f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} "
|
||||
f"C@{ck:g} P@{pk:g}"
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
"""开仓资金可开判定:只看交易账户(永续 USDT / 期权 USDC)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.db import Database, get_db
|
||||
from ..sim.funds_wallets import SimFundsWallets
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_live_bal_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||||
_LIVE_BAL_TTL_SEC = 8.0
|
||||
|
||||
# 不足期间只推一次;资金恢复后清零,下次再不足可再推一次
|
||||
_notified_while_short: bool = False
|
||||
|
||||
|
||||
def invalidate_live_balance_cache() -> None:
|
||||
"""兑换/划转后强制下次重拉交易账户余额。"""
|
||||
_live_bal_cache["ts"] = 0.0
|
||||
_live_bal_cache["data"] = None
|
||||
|
||||
|
||||
def _f(v: Any) -> float | None:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _index_and_option_ask() -> tuple[float | None, float | None]:
|
||||
"""指数价 + 期权卖一粗估(取 Call/Put 卖一较大者,偏保守)。"""
|
||||
try:
|
||||
from .session import get_session
|
||||
|
||||
snap = get_session().snapshot()
|
||||
except Exception:
|
||||
return None, None
|
||||
idx = _f(getattr(snap, "index_px", None))
|
||||
if idx is None and snap.perp:
|
||||
idx = _f(snap.perp.mark_px) or _f(snap.perp.ask) or _f(snap.perp.bid)
|
||||
asks: list[float] = []
|
||||
for leg in (snap.call, snap.put):
|
||||
if leg is None:
|
||||
continue
|
||||
a = _f(leg.ask)
|
||||
if a is not None and a > 0:
|
||||
asks.append(a)
|
||||
ask = max(asks) if asks else None
|
||||
return idx, ask
|
||||
|
||||
|
||||
def _live_balances() -> dict[str, float | None]:
|
||||
now = time.time()
|
||||
if _live_bal_cache["data"] is not None and now - float(_live_bal_cache["ts"]) < _LIVE_BAL_TTL_SEC:
|
||||
return dict(_live_bal_cache["data"])
|
||||
out: dict[str, float | None] = {
|
||||
"trading_usdt": None,
|
||||
"trading_usdc": None,
|
||||
}
|
||||
try:
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
|
||||
ex = str(load_runtime_settings().exchange or "").strip().lower()
|
||||
if ex in ("binance", "bn"):
|
||||
from ..live.binance_trade import BinanceTradeClient
|
||||
|
||||
client = BinanceTradeClient()
|
||||
try:
|
||||
bal = client.fetch_balances()
|
||||
out["trading_usdt"] = _f(bal.get("trading_usdt"))
|
||||
out["trading_usdc"] = _f(bal.get("trading_usdc"))
|
||||
finally:
|
||||
client.close()
|
||||
else:
|
||||
from ..live.okx_funds import OkxFundsClient
|
||||
|
||||
client = OkxFundsClient()
|
||||
try:
|
||||
bal = client.fetch_balances()
|
||||
out["trading_usdt"] = _f(bal.get("trading_usdt"))
|
||||
out["trading_usdc"] = _f(bal.get("trading_usdc"))
|
||||
finally:
|
||||
client.close()
|
||||
except Exception as e:
|
||||
logger.warning("open_capacity live balance failed: %s", e)
|
||||
_live_bal_cache["ts"] = now
|
||||
_live_bal_cache["data"] = dict(out)
|
||||
return out
|
||||
|
||||
|
||||
def _sim_balances(db: Database) -> dict[str, float]:
|
||||
"""只看交易账户,不看资金账户。"""
|
||||
w = SimFundsWallets(db).view()
|
||||
return {
|
||||
"perp_usdt": float(w.get("trading_usdt") or 0),
|
||||
"option_usdc": float(w.get("trading_usdc") or 0),
|
||||
}
|
||||
|
||||
|
||||
def assess_open_capacity(
|
||||
db: Database | None = None,
|
||||
*,
|
||||
option_ask: float | None = None,
|
||||
option_qty_eth: float | None = None,
|
||||
perp_qty_eth: float | None = None,
|
||||
call_ask: float | None = None,
|
||||
put_ask: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回永续/期权是否有足够交易账户资金开新仓。
|
||||
- 永续:交易账户 USDT >= 名义/杠杆
|
||||
- 期权:交易账户 USDC >= 卖一×名义×(1+费率)
|
||||
- 期期:期权需 (call_ask+put_ask)×qty×(1+fee);永续视为不需要
|
||||
"""
|
||||
global _notified_while_short
|
||||
db = db or get_db()
|
||||
s = get_settings()
|
||||
ledger = Ledger(db)
|
||||
hedge = str(
|
||||
ledger.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode
|
||||
).strip().lower()
|
||||
if hedge not in ("perp_option", "option_option"):
|
||||
hedge = "perp_option"
|
||||
lev = float(ledger.get_setting_float("leverage", s.leverage) or 3)
|
||||
if lev <= 0:
|
||||
lev = 3.0
|
||||
perp_qty = float(
|
||||
perp_qty_eth
|
||||
if perp_qty_eth is not None
|
||||
else (ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) or 1)
|
||||
)
|
||||
opt_qty = float(
|
||||
option_qty_eth
|
||||
if option_qty_eth is not None
|
||||
else (ledger.get_setting_float("option_qty_eth", s.option_qty_eth) or 2)
|
||||
)
|
||||
fee_rate = float(ledger.get_setting_float("fee_rate", s.fee_rate) or 0.0005)
|
||||
|
||||
idx, ask_book = _index_and_option_ask()
|
||||
ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book
|
||||
if hedge == "option_option":
|
||||
ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None
|
||||
pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None
|
||||
if ca is None or pa is None:
|
||||
# 回退:用监控对 call/put 卖一
|
||||
try:
|
||||
from .session import get_session
|
||||
|
||||
snap = get_session().snapshot()
|
||||
if ca is None and snap.call and snap.call.ask:
|
||||
ca = float(snap.call.ask)
|
||||
if pa is None and snap.put and snap.put.ask:
|
||||
pa = float(snap.put.ask)
|
||||
except Exception:
|
||||
pass
|
||||
if ca is not None and pa is not None and ca > 0 and pa > 0:
|
||||
call_q = float(opt_qty)
|
||||
put_q = float(
|
||||
ledger.get_setting_float("oo_put_qty_eth", call_q) or call_q
|
||||
)
|
||||
# 与定仓一致:两腿各自权利金
|
||||
premium_need = (
|
||||
(ca * call_q + pa * put_q) * (1.0 + fee_rate)
|
||||
)
|
||||
else:
|
||||
premium_need = None
|
||||
margin_need = 0.0
|
||||
perp_qty = 0.0
|
||||
else:
|
||||
margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None
|
||||
premium_need = (
|
||||
float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None
|
||||
)
|
||||
|
||||
if s.is_sim:
|
||||
bal = _sim_balances(db)
|
||||
have_perp = float(bal["perp_usdt"])
|
||||
have_opt = float(bal["option_usdc"])
|
||||
else:
|
||||
live = _live_balances()
|
||||
t_usdt = live.get("trading_usdt")
|
||||
t_usdc = live.get("trading_usdc")
|
||||
have_perp = float(t_usdt) if t_usdt is not None else None
|
||||
have_opt = float(t_usdc) if t_usdc is not None else None
|
||||
|
||||
perp_ok: bool | None
|
||||
if hedge == "option_option":
|
||||
perp_ok = True
|
||||
elif margin_need is None or have_perp is None:
|
||||
perp_ok = None
|
||||
else:
|
||||
perp_ok = float(have_perp) + 1e-9 >= float(margin_need)
|
||||
|
||||
opt_ok: bool | None
|
||||
if premium_need is None or have_opt is None:
|
||||
opt_ok = None
|
||||
else:
|
||||
opt_ok = float(have_opt) + 1e-9 >= float(premium_need)
|
||||
|
||||
if hedge == "option_option":
|
||||
funds_ok = opt_ok is True
|
||||
else:
|
||||
funds_ok = perp_ok is True and opt_ok is True
|
||||
# 资金恢复后允许下次不足再通知一次
|
||||
if funds_ok:
|
||||
_notified_while_short = False
|
||||
|
||||
lev_i = int(round(lev)) if abs(lev - round(lev)) < 1e-9 else lev
|
||||
if hedge == "option_option":
|
||||
perp_label = "永续 —(期期)"
|
||||
elif perp_ok is True:
|
||||
perp_label = f"永续{lev_i}x 可开"
|
||||
elif perp_ok is False:
|
||||
perp_label = f"永续{lev_i}x 不可开"
|
||||
else:
|
||||
perp_label = f"永续{lev_i}x —"
|
||||
|
||||
if opt_ok is True:
|
||||
opt_label = "期权可开"
|
||||
elif opt_ok is False:
|
||||
opt_label = "期权不可开"
|
||||
else:
|
||||
opt_label = "期权 —"
|
||||
|
||||
return {
|
||||
"hedge_mode": hedge,
|
||||
"leverage": lev,
|
||||
"perp_qty_eth": perp_qty,
|
||||
"option_qty_eth": opt_qty,
|
||||
"index_px": idx,
|
||||
"option_ask": ask,
|
||||
"perp_need_usdt": round(margin_need, 2) if margin_need is not None else None,
|
||||
"option_need_usdc": round(premium_need, 2) if premium_need is not None else None,
|
||||
"perp_have_usdt": round(have_perp, 2) if have_perp is not None else None,
|
||||
"option_have_usdc": round(have_opt, 2) if have_opt is not None else None,
|
||||
"perp_can_open": perp_ok,
|
||||
"option_can_open": opt_ok,
|
||||
"perp_label": perp_label,
|
||||
"option_label": opt_label,
|
||||
"funds_ok": funds_ok,
|
||||
"source": "trading",
|
||||
}
|
||||
|
||||
|
||||
def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]:
|
||||
"""
|
||||
Fail-closed:永期需永续+期权均为 True;期期仅需期权为 True。
|
||||
None(未知)或 False → 拦截。
|
||||
"""
|
||||
if not cap:
|
||||
return True, "资金可开判定结果为空,拒绝开仓"
|
||||
hedge = str(cap.get("hedge_mode") or "perp_option").strip().lower()
|
||||
if hedge == "option_option":
|
||||
if cap.get("option_can_open") is not True:
|
||||
detail = (
|
||||
f"{cap.get('option_label')};"
|
||||
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
|
||||
)
|
||||
if cap.get("option_can_open") is None:
|
||||
detail += "(余额/盘口未知,fail-closed 拒绝开仓)"
|
||||
return True, f"资金不足或状态未知,暂不可开新仓:{detail}"
|
||||
return False, ""
|
||||
if cap.get("perp_can_open") is not True or cap.get("option_can_open") is not True:
|
||||
detail = (
|
||||
f"{cap.get('perp_label')} · {cap.get('option_label')};"
|
||||
f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U,"
|
||||
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
|
||||
)
|
||||
if cap.get("perp_can_open") is None or cap.get("option_can_open") is None:
|
||||
detail += "(余额/盘口未知,fail-closed 拒绝开仓)"
|
||||
return True, f"资金不足或状态未知,暂不可开新仓:{detail}"
|
||||
return False, ""
|
||||
|
||||
|
||||
def maybe_notify_funds_short(cap: dict[str, Any] | None = None) -> None:
|
||||
"""仅在「不能开」时推送一次;能开绝不通知。"""
|
||||
global _notified_while_short
|
||||
cap = cap or assess_open_capacity()
|
||||
# 能开 / 未知:不通知;资金恢复则重置,便于下次不足再提醒一次
|
||||
if cap.get("funds_ok") is True:
|
||||
_notified_while_short = False
|
||||
return
|
||||
cannot = cap.get("perp_can_open") is False or cap.get("option_can_open") is False
|
||||
if not cannot:
|
||||
return
|
||||
if _notified_while_short:
|
||||
return
|
||||
_notified_while_short = True
|
||||
parts: list[str] = []
|
||||
if cap.get("perp_can_open") is False:
|
||||
parts.append(
|
||||
f"交易账户 USDT 不足:需约 {cap.get('perp_need_usdt')}U,现有 {cap.get('perp_have_usdt')}U"
|
||||
)
|
||||
if cap.get("option_can_open") is False:
|
||||
parts.append(
|
||||
f"交易账户 USDC 不足:需约 {cap.get('option_need_usdc')}U,现有 {cap.get('option_have_usdc')}U"
|
||||
)
|
||||
try:
|
||||
from ..notify import wecom
|
||||
|
||||
wecom.notify_async(
|
||||
wecom.build_markdown(
|
||||
tag=wecom.TAG_FAULT,
|
||||
title="交易账户资金不足 · 无法开新仓",
|
||||
lines=[
|
||||
f"**永续**: {cap.get('perp_label')}",
|
||||
f"**期权**: {cap.get('option_label')}",
|
||||
*[f"**详情**: {p}" for p in parts],
|
||||
"OKX 开仓前会尝试交易账户市价兑 USDC;仍不足请检查交易账户余额。",
|
||||
],
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("wecom funds notify failed")
|
||||
@@ -0,0 +1,164 @@
|
||||
"""开仓统一管道:定仓 → 兑 USDC → 资金门(fail-closed)。
|
||||
|
||||
策略自动开仓与手动开一组共用,避免双路径打架。
|
||||
基线假设:运行中不在交易所人工开平仓、也不人工手动平仓。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..models.db import Database, get_db
|
||||
from .open_capacity import assess_open_capacity, funds_gate_blocks
|
||||
from .risk_sizing import apply_risk_sizing_to_ledger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OpenPrepResult:
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
capacity: dict[str, Any] | None = None
|
||||
sizing_detail: str = ""
|
||||
convert_detail: str = ""
|
||||
|
||||
|
||||
def prepare_usdc_while_waiting(db: Database | None = None) -> dict[str, Any]:
|
||||
"""空仓等待选约:预览名义检测 USDC 并兑换,不落库改写 qty/exit。"""
|
||||
from .auto_usdc import prepare_okx_trading_usdc
|
||||
|
||||
return prepare_okx_trading_usdc(db)
|
||||
|
||||
|
||||
def size_and_gate(
|
||||
*,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
db: Database | None = None,
|
||||
call_ask: float | None = None,
|
||||
put_ask: float | None = None,
|
||||
hedge_mode: str | None = None,
|
||||
) -> OpenPrepResult:
|
||||
"""
|
||||
选约成功后:写入以损定仓 → 交易账户兑 USDC → 资金门。
|
||||
资金门 fail-closed:异常 / can_open 非 True 一律拦截。
|
||||
"""
|
||||
database = db or get_db()
|
||||
mode = str(hedge_mode or "").strip().lower()
|
||||
if not mode:
|
||||
try:
|
||||
from ..config import get_settings
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
s = get_settings()
|
||||
mode = str(
|
||||
Ledger(database).get_setting_str("hedge_mode", s.hedge_mode)
|
||||
or s.hedge_mode
|
||||
).strip().lower()
|
||||
except Exception:
|
||||
mode = "perp_option"
|
||||
|
||||
try:
|
||||
if mode == "option_option":
|
||||
from .risk_sizing import apply_oo_sizing_to_ledger
|
||||
|
||||
if call_ask is None or put_ask is None:
|
||||
return OpenPrepResult(ok=False, detail="期期定仓缺少 call/put 卖一")
|
||||
rs = apply_oo_sizing_to_ledger(
|
||||
call_ask=float(call_ask),
|
||||
put_ask=float(put_ask),
|
||||
index_px=float(index_px),
|
||||
db=database,
|
||||
)
|
||||
if not rs.ok:
|
||||
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
|
||||
else:
|
||||
from ..sim.ledger import Ledger
|
||||
from .risk_sizing import is_risk_based
|
||||
from .semi_auto import is_semi_auto, read_semi_params
|
||||
|
||||
led = Ledger(database)
|
||||
semi_perp = None
|
||||
semi_opt = None
|
||||
semi_exit = None
|
||||
semi_basis = None
|
||||
if is_semi_auto(led):
|
||||
sp = read_semi_params(led)
|
||||
semi_perp = float(sp["perp_unit"])
|
||||
semi_opt = float(sp["option_unit"])
|
||||
semi_exit = float(sp["perp_exit_unit"])
|
||||
# 半自动以损定仓:按盘口卖一推期权量,再按配比乘 k 得永续量
|
||||
semi_basis = "actual"
|
||||
if not is_risk_based(led):
|
||||
# 手动仓:半自动直接写入 永续:期权 配比名义
|
||||
database.set_setting("perp_qty_eth", f"{semi_perp:.4f}")
|
||||
database.set_setting("option_qty_eth", f"{semi_opt:.4f}")
|
||||
rs = apply_risk_sizing_to_ledger(
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
db=database,
|
||||
perp_unit=semi_perp,
|
||||
option_unit=semi_opt,
|
||||
exit_unit=semi_exit,
|
||||
leverage_basis=semi_basis,
|
||||
)
|
||||
if not rs.ok:
|
||||
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
|
||||
except Exception as e:
|
||||
logger.exception("risk sizing failed in open pipeline")
|
||||
return OpenPrepResult(ok=False, detail=f"以损定仓计算异常:{e}")
|
||||
|
||||
convert_detail = ""
|
||||
try:
|
||||
from .auto_usdc import ensure_okx_trading_usdc
|
||||
|
||||
# 选约后名义已更新;仍受冷却约束,禁止 force 砸单
|
||||
conv = ensure_okx_trading_usdc(
|
||||
database,
|
||||
cap=assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(option_ask),
|
||||
call_ask=call_ask,
|
||||
put_ask=put_ask,
|
||||
),
|
||||
force=False,
|
||||
)
|
||||
convert_detail = str(conv.get("detail") or "")
|
||||
if conv.get("acted"):
|
||||
logger.info("open_pipeline auto_usdc: %s", convert_detail)
|
||||
except Exception:
|
||||
logger.exception("auto USDC in open pipeline failed")
|
||||
convert_detail = "自动兑 USDC 异常(已记日志)"
|
||||
|
||||
try:
|
||||
cap = assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(option_ask),
|
||||
call_ask=call_ask,
|
||||
put_ask=put_ask,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("open capacity assess failed")
|
||||
return OpenPrepResult(
|
||||
ok=False,
|
||||
detail=f"资金可开判定失败,拒绝开仓:{e}",
|
||||
convert_detail=convert_detail,
|
||||
)
|
||||
|
||||
blocked, why = funds_gate_blocks(cap)
|
||||
if blocked:
|
||||
return OpenPrepResult(
|
||||
ok=False,
|
||||
detail=why or "资金不足或状态未知,拒绝开仓",
|
||||
capacity=cap,
|
||||
convert_detail=convert_detail,
|
||||
)
|
||||
return OpenPrepResult(
|
||||
ok=True,
|
||||
detail="ready",
|
||||
capacity=cap,
|
||||
convert_detail=convert_detail,
|
||||
)
|
||||
@@ -0,0 +1,986 @@
|
||||
"""以损定仓:按可承受最大亏损反推标准组倍数 k(永续1 / 期权2 / 出场15)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.db import Database, get_db
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 标准组基准默认(k=1);可由设置 risk_*_unit 覆盖
|
||||
BASE_PERP_ETH = 1.0
|
||||
BASE_OPTION_ETH = 2.0
|
||||
BASE_EXIT_USDT = 15.0
|
||||
MIN_K = 0.1
|
||||
FEE_LEG_COUNT = 3 # 永续开/平 + 期权一次
|
||||
|
||||
|
||||
def _round2(x: float | None) -> float | None:
|
||||
if x is None or not math.isfinite(float(x)):
|
||||
return None
|
||||
return round(float(x) + 0.0, 2)
|
||||
|
||||
|
||||
def read_risk_units(ledger: Ledger) -> tuple[float, float, float]:
|
||||
"""永续名义单位 / 期权名义单位 / 出场基数(k=1)。"""
|
||||
perp_u = ledger.get_setting_float("risk_perp_unit", BASE_PERP_ETH)
|
||||
opt_u = ledger.get_setting_float("risk_option_unit", BASE_OPTION_ETH)
|
||||
exit_u = ledger.get_setting_float("risk_exit_unit", BASE_EXIT_USDT)
|
||||
if perp_u <= 0:
|
||||
perp_u = BASE_PERP_ETH
|
||||
if opt_u <= 0:
|
||||
opt_u = BASE_OPTION_ETH
|
||||
if exit_u <= 0:
|
||||
exit_u = BASE_EXIT_USDT
|
||||
return float(perp_u), float(opt_u), float(exit_u)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RiskSizingResult:
|
||||
ok: bool
|
||||
detail: str
|
||||
k: float | None = None
|
||||
budget: float | None = None
|
||||
capital_base: float | None = None
|
||||
premium_est: float | None = None
|
||||
fee_est: float | None = None
|
||||
max_loss: float | None = None
|
||||
perp_qty_eth: float | None = None
|
||||
option_qty_eth: float | None = None
|
||||
net_profit_target: float | None = None
|
||||
index_px: float | None = None
|
||||
option_ask: float | None = None # 定仓用权利金(可能为选约杠杆隐含)
|
||||
actual_option_ask: float | None = None # 盘口真实卖一
|
||||
leverage_basis: str | None = None # actual | selection
|
||||
perp_unit: float | None = None
|
||||
option_unit: float | None = None
|
||||
exit_unit: float | None = None
|
||||
|
||||
|
||||
def normalize_risk_leverage_basis(raw: str | None, default: str = "selection") -> str:
|
||||
v = (raw or default or "selection").strip().lower()
|
||||
if v in ("selection", "min_option_leverage", "select", "选约", "选约杠杆"):
|
||||
return "selection"
|
||||
if v in ("actual", "market", "ask", "实际", "实际杠杆"):
|
||||
return "actual"
|
||||
return "selection" if default == "selection" else "actual"
|
||||
|
||||
|
||||
def resolve_sizing_option_ask(
|
||||
*,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
leverage_basis: str,
|
||||
min_option_leverage: float,
|
||||
) -> tuple[float, str]:
|
||||
"""
|
||||
返回 (定仓用卖一, 口径 actual|selection)。
|
||||
selection:隐含卖一 = 指数 / 选约杠杆;actual:用盘口卖一。
|
||||
"""
|
||||
basis = normalize_risk_leverage_basis(leverage_basis, "selection")
|
||||
if basis == "selection":
|
||||
lev = float(min_option_leverage)
|
||||
if lev > 1e-12 and math.isfinite(lev) and index_px > 0:
|
||||
return float(index_px) / lev, "selection"
|
||||
# 选约杠杆无效时退回实际卖一,避免拒单
|
||||
return float(option_ask), "actual"
|
||||
return float(option_ask), "actual"
|
||||
|
||||
|
||||
def is_risk_based(ledger: Ledger | None = None) -> bool:
|
||||
led = ledger or Ledger()
|
||||
mode = (led.get_setting_str("sizing_mode", "manual") or "manual").strip().lower()
|
||||
return mode == "risk_based"
|
||||
|
||||
|
||||
def floor_k_1dp(k_raw: float) -> float:
|
||||
"""一位小数向下取整,保证不超预算。"""
|
||||
if k_raw <= 0 or not math.isfinite(k_raw):
|
||||
return 0.0
|
||||
return math.floor(k_raw * 10.0 + 1e-12) / 10.0
|
||||
|
||||
|
||||
def unit_cost(
|
||||
*,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
fee_rate: float,
|
||||
option_unit: float = BASE_OPTION_ETH,
|
||||
) -> float:
|
||||
"""k=1 时估算最大亏损 = 权利金(option_unit ETH) + 手续费粗估。"""
|
||||
premium_unit = float(option_ask) * float(option_unit)
|
||||
fee_unit = float(index_px) * float(fee_rate) * FEE_LEG_COUNT
|
||||
return premium_unit + fee_unit
|
||||
|
||||
|
||||
def compute_k(
|
||||
*,
|
||||
budget: float,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
fee_rate: float,
|
||||
perp_unit: float = BASE_PERP_ETH,
|
||||
option_unit: float = BASE_OPTION_ETH,
|
||||
exit_unit: float = BASE_EXIT_USDT,
|
||||
) -> RiskSizingResult:
|
||||
if budget is None or budget <= 0 or not math.isfinite(budget):
|
||||
return RiskSizingResult(ok=False, detail="以损定仓预算无效(须 > 0)")
|
||||
if index_px is None or index_px <= 0 or not math.isfinite(index_px):
|
||||
return RiskSizingResult(ok=False, detail="以损定仓缺少有效指数价")
|
||||
if option_ask is None or option_ask <= 0 or not math.isfinite(option_ask):
|
||||
return RiskSizingResult(ok=False, detail="以损定仓缺少有效期权卖一")
|
||||
if option_unit <= 0 or perp_unit <= 0 or exit_unit <= 0:
|
||||
return RiskSizingResult(ok=False, detail="以损定仓比例/出场基数须 > 0")
|
||||
|
||||
cost1 = unit_cost(
|
||||
index_px=index_px,
|
||||
option_ask=option_ask,
|
||||
fee_rate=fee_rate,
|
||||
option_unit=option_unit,
|
||||
)
|
||||
if cost1 <= 1e-12:
|
||||
return RiskSizingResult(ok=False, detail="以损定仓单位成本无效")
|
||||
|
||||
k_raw = float(budget) / cost1
|
||||
k = floor_k_1dp(k_raw)
|
||||
if k < MIN_K - 1e-12:
|
||||
return RiskSizingResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K},"
|
||||
f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U)"
|
||||
),
|
||||
budget=_round2(float(budget)),
|
||||
k=k,
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
perp_unit=float(perp_unit),
|
||||
option_unit=float(option_unit),
|
||||
exit_unit=float(exit_unit),
|
||||
)
|
||||
|
||||
# 若浮点导致仍略超,再降一档
|
||||
while k >= MIN_K - 1e-12:
|
||||
prem = float(option_ask) * float(option_unit) * k
|
||||
fee = float(index_px) * float(fee_rate) * FEE_LEG_COUNT * k
|
||||
mx = prem + fee
|
||||
if mx <= float(budget) + 1e-6:
|
||||
return RiskSizingResult(
|
||||
ok=True,
|
||||
detail="ok",
|
||||
k=k,
|
||||
budget=_round2(float(budget)),
|
||||
premium_est=_round2(prem),
|
||||
fee_est=_round2(fee),
|
||||
max_loss=_round2(mx),
|
||||
perp_qty_eth=round(float(perp_unit) * k, 4),
|
||||
option_qty_eth=round(float(option_unit) * k, 4),
|
||||
net_profit_target=_round2(float(exit_unit) * k),
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
perp_unit=float(perp_unit),
|
||||
option_unit=float(option_unit),
|
||||
exit_unit=float(exit_unit),
|
||||
)
|
||||
k = round(k - 0.1, 1)
|
||||
|
||||
return RiskSizingResult(
|
||||
ok=False,
|
||||
detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k",
|
||||
budget=_round2(float(budget)),
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
perp_unit=float(perp_unit),
|
||||
option_unit=float(option_unit),
|
||||
exit_unit=float(exit_unit),
|
||||
)
|
||||
|
||||
|
||||
def resolve_capital_base(db: Database | None = None) -> tuple[float | None, str]:
|
||||
"""返回 (本金USDT口径, 说明)。"""
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
source = (
|
||||
ledger.get_setting_str("risk_capital_source", "trading_account") or "trading_account"
|
||||
).strip().lower()
|
||||
if source in ("manual", "manual_capital", "fixed"):
|
||||
cap = ledger.get_setting_float("risk_manual_capital_usdt", 0.0)
|
||||
if cap <= 0:
|
||||
return None, "单独本金未设置或 ≤ 0"
|
||||
return float(cap), "manual"
|
||||
|
||||
# trading_account:交易账户 USDT + USDC(1:1 折算,与资金条交易账户一致)
|
||||
usdt, usdc = _trading_balances(database)
|
||||
if usdt is None and usdc is None:
|
||||
try:
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
|
||||
ex = str(load_runtime_settings().exchange or "").strip().lower()
|
||||
if ex in ("binance", "bn") and not get_settings().is_sim:
|
||||
return (
|
||||
None,
|
||||
"币安实盘暂未接入交易账户余额,请改用「单独本金」或「亏损值」",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None, "无法读取交易账户资金"
|
||||
total = float(usdt or 0.0) + float(usdc or 0.0)
|
||||
if total <= 1e-9:
|
||||
return None, "交易账户总资金为 0"
|
||||
return total, "trading_account"
|
||||
|
||||
|
||||
def resolve_budget(db: Database | None = None) -> tuple[float | None, str, float | None]:
|
||||
"""返回 (budget, detail, capital_base)。"""
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
loss_mode = (
|
||||
ledger.get_setting_str("risk_loss_mode", "percent") or "percent"
|
||||
).strip().lower()
|
||||
if loss_mode in ("absolute", "usdt", "value", "亏损值"):
|
||||
bud = ledger.get_setting_float("risk_loss_usdt", 0.0)
|
||||
if bud <= 0:
|
||||
return None, "亏损值未设置或 ≤ 0", None
|
||||
return float(bud), "absolute", None
|
||||
|
||||
capital, src = resolve_capital_base(database)
|
||||
if capital is None:
|
||||
return None, src, None
|
||||
pct = ledger.get_setting_float("risk_loss_pct", 1.0)
|
||||
if pct <= 0:
|
||||
return None, "亏损幅度须 > 0", capital
|
||||
mg = resolve_martingale(database, ledger=ledger, base_pct=float(pct))
|
||||
effective = float(mg["effective_pct"])
|
||||
detail = f"percent@{src}"
|
||||
if int(mg.get("doubles") or 0) > 0:
|
||||
detail += (
|
||||
f"|mg×{int(2 ** int(mg['doubles']))}"
|
||||
f"(连亏{int(mg.get('loss_days') or 0)}天)"
|
||||
)
|
||||
return float(capital) * (effective / 100.0), detail, capital
|
||||
|
||||
|
||||
MARTINGALE_MAX_BASE_PCT = 3.0
|
||||
|
||||
|
||||
def _is_expiry_close_reason(reason: str | None) -> bool:
|
||||
r = str(reason or "").strip().lower()
|
||||
return r in ("expiry", "到期", "到期结算", "到期结算全平")
|
||||
|
||||
|
||||
def _martingale_day_pnl_contrib(realized_pnl: float, close_reason: str | None) -> float:
|
||||
"""
|
||||
倍投连亏日口径:到期结算无论实际盈亏(含小盈利)一律按亏损计入;
|
||||
其它平仓按真实 realized_pnl。
|
||||
"""
|
||||
if _is_expiry_close_reason(close_reason):
|
||||
return -1.0
|
||||
return float(realized_pnl or 0.0)
|
||||
|
||||
|
||||
def consecutive_loss_days(db: Database | None = None) -> int:
|
||||
"""
|
||||
按上海日历「平仓日」汇总倍投口径盈亏,从最近有平仓的一天往前数连续亏损天数。
|
||||
某日合计 < 0 计为亏损日;到期结算组无论盈亏均按亏损计入。
|
||||
无平仓的日历日不计入、不打断(按有成交日序列)。
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
database = db or get_db()
|
||||
rows = database.fetchall(
|
||||
"""SELECT realized_pnl, close_at_ms, close_reason FROM groups
|
||||
WHERE status='closed' AND close_at_ms IS NOT NULL
|
||||
ORDER BY close_at_ms ASC"""
|
||||
)
|
||||
if not rows:
|
||||
return 0
|
||||
sh = ZoneInfo("Asia/Shanghai")
|
||||
day_pnl: dict[str, float] = defaultdict(float)
|
||||
for r in rows:
|
||||
try:
|
||||
ms = int(r["close_at_ms"] or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ms <= 0:
|
||||
continue
|
||||
day = (
|
||||
datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
|
||||
.astimezone(sh)
|
||||
.strftime("%Y-%m-%d")
|
||||
)
|
||||
day_pnl[day] += _martingale_day_pnl_contrib(
|
||||
float(r["realized_pnl"] or 0),
|
||||
r["close_reason"],
|
||||
)
|
||||
if not day_pnl:
|
||||
return 0
|
||||
streak = 0
|
||||
for d in reversed(sorted(day_pnl.keys())):
|
||||
if float(day_pnl[d]) < 0:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
return streak
|
||||
|
||||
|
||||
def resolve_martingale(
|
||||
db: Database | None = None,
|
||||
*,
|
||||
ledger: Ledger | None = None,
|
||||
base_pct: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
倍投状态:仅以损定仓 + 亏损幅度% + 开关开启 + 基础幅度≤3% 时生效。
|
||||
doubles: 已翻倍次数(0=用基础幅度);effective_pct = base * 2^doubles。
|
||||
"""
|
||||
database = db or get_db()
|
||||
led = ledger or Ledger(database)
|
||||
enabled = led.get_setting_bool("martingale_enabled", False)
|
||||
pct = (
|
||||
float(base_pct)
|
||||
if base_pct is not None
|
||||
else float(led.get_setting_float("risk_loss_pct", 1.0))
|
||||
)
|
||||
start_after = int(
|
||||
round(led.get_setting_float("martingale_start_after_loss_days", 2.0))
|
||||
)
|
||||
max_doubles = int(round(led.get_setting_float("martingale_max_doubles", 3.0)))
|
||||
start_after = max(1, min(30, start_after))
|
||||
max_doubles = max(1, min(10, max_doubles))
|
||||
loss_days = consecutive_loss_days(database)
|
||||
out: dict[str, Any] = {
|
||||
"enabled": bool(enabled),
|
||||
"eligible": False,
|
||||
"blocked": "",
|
||||
"base_pct": round(pct, 4),
|
||||
"effective_pct": round(pct, 4),
|
||||
"doubles": 0,
|
||||
"loss_days": int(loss_days),
|
||||
"start_after_loss_days": start_after,
|
||||
"max_doubles": max_doubles,
|
||||
}
|
||||
if not enabled:
|
||||
out["blocked"] = "off"
|
||||
return out
|
||||
if not is_risk_based(led):
|
||||
out["blocked"] = "not_risk_based"
|
||||
return out
|
||||
loss_mode = (
|
||||
led.get_setting_str("risk_loss_mode", "percent") or "percent"
|
||||
).strip().lower()
|
||||
if loss_mode not in ("percent", "pct", "%", "幅度"):
|
||||
out["blocked"] = "not_percent_mode"
|
||||
return out
|
||||
if pct > MARTINGALE_MAX_BASE_PCT + 1e-12:
|
||||
out["blocked"] = f"base_pct>{MARTINGALE_MAX_BASE_PCT:g}"
|
||||
return out
|
||||
out["eligible"] = True
|
||||
doubles = 0
|
||||
if loss_days >= start_after:
|
||||
doubles = min(int(loss_days - start_after + 1), max_doubles)
|
||||
out["doubles"] = doubles
|
||||
out["effective_pct"] = round(float(pct) * (2**doubles), 6)
|
||||
return out
|
||||
|
||||
|
||||
def _trading_balances(db: Database) -> tuple[float | None, float | None]:
|
||||
s = get_settings()
|
||||
if s.is_sim:
|
||||
from ..sim.funds_wallets import SimFundsWallets
|
||||
|
||||
w = SimFundsWallets(db)
|
||||
v = w.view()
|
||||
return float(v["trading_usdt"]), float(v["trading_usdc"])
|
||||
try:
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
|
||||
ex = str(load_runtime_settings().exchange or "").strip().lower()
|
||||
if ex in ("binance", "bn"):
|
||||
return None, None
|
||||
from ..live.okx_funds import OkxFundsClient
|
||||
|
||||
client = OkxFundsClient()
|
||||
try:
|
||||
bal = client.fetch_balances()
|
||||
tu = bal.get("trading_usdt")
|
||||
tc = bal.get("trading_usdc")
|
||||
return (
|
||||
float(tu) if tu is not None else None,
|
||||
float(tc) if tc is not None else None,
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
except Exception as e:
|
||||
logger.warning("risk_sizing trading balance failed: %s", e)
|
||||
return None, None
|
||||
|
||||
|
||||
def compute_risk_sizing(
|
||||
*,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
db: Database | None = None,
|
||||
perp_unit: float | None = None,
|
||||
option_unit: float | None = None,
|
||||
exit_unit: float | None = None,
|
||||
leverage_basis: str | None = None,
|
||||
) -> RiskSizingResult:
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
s = get_settings()
|
||||
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
|
||||
perp_u, opt_u, exit_u = read_risk_units(ledger)
|
||||
if perp_unit is not None and float(perp_unit) > 0:
|
||||
perp_u = float(perp_unit)
|
||||
if option_unit is not None and float(option_unit) > 0:
|
||||
opt_u = float(option_unit)
|
||||
if exit_unit is not None and float(exit_unit) > 0:
|
||||
exit_u = float(exit_unit)
|
||||
basis_raw = (
|
||||
leverage_basis
|
||||
if leverage_basis is not None
|
||||
else (
|
||||
ledger.get_setting_str("risk_leverage_basis", s.risk_leverage_basis)
|
||||
or s.risk_leverage_basis
|
||||
)
|
||||
)
|
||||
min_opt_lev = ledger.get_setting_float(
|
||||
"min_option_leverage", s.min_option_leverage
|
||||
)
|
||||
sizing_ask, basis = resolve_sizing_option_ask(
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
leverage_basis=str(basis_raw),
|
||||
min_option_leverage=float(min_opt_lev),
|
||||
)
|
||||
budget, bud_detail, capital = resolve_budget(database)
|
||||
if budget is None:
|
||||
return RiskSizingResult(
|
||||
ok=False,
|
||||
detail=f"以损定仓预算失败: {bud_detail}",
|
||||
leverage_basis=basis,
|
||||
actual_option_ask=_round2(float(option_ask)),
|
||||
option_ask=_round2(float(sizing_ask)),
|
||||
)
|
||||
r = compute_k(
|
||||
budget=budget,
|
||||
index_px=index_px,
|
||||
option_ask=sizing_ask,
|
||||
fee_rate=fee_rate,
|
||||
perp_unit=perp_u,
|
||||
option_unit=opt_u,
|
||||
exit_unit=exit_u,
|
||||
)
|
||||
if not r.ok:
|
||||
return RiskSizingResult(
|
||||
ok=False,
|
||||
detail=r.detail,
|
||||
budget=_round2(budget),
|
||||
capital_base=_round2(capital) if capital is not None else None,
|
||||
index_px=float(index_px),
|
||||
option_ask=_round2(float(sizing_ask)),
|
||||
actual_option_ask=_round2(float(option_ask)),
|
||||
leverage_basis=basis,
|
||||
k=r.k,
|
||||
perp_unit=perp_u,
|
||||
option_unit=opt_u,
|
||||
exit_unit=exit_u,
|
||||
)
|
||||
return RiskSizingResult(
|
||||
ok=True,
|
||||
detail=r.detail,
|
||||
k=r.k,
|
||||
budget=_round2(budget),
|
||||
capital_base=_round2(capital) if capital is not None else None,
|
||||
premium_est=r.premium_est,
|
||||
fee_est=r.fee_est,
|
||||
max_loss=r.max_loss,
|
||||
perp_qty_eth=r.perp_qty_eth,
|
||||
option_qty_eth=r.option_qty_eth,
|
||||
net_profit_target=r.net_profit_target,
|
||||
index_px=r.index_px,
|
||||
option_ask=_round2(float(sizing_ask)),
|
||||
actual_option_ask=_round2(float(option_ask)),
|
||||
leverage_basis=basis,
|
||||
perp_unit=perp_u,
|
||||
option_unit=opt_u,
|
||||
exit_unit=exit_u,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OoSizingResult:
|
||||
ok: bool
|
||||
detail: str
|
||||
budget: float | None = None
|
||||
spend: float | None = None
|
||||
qty_eth: float | None = None # 兼容:Call 数量
|
||||
call_qty_eth: float | None = None
|
||||
put_qty_eth: float | None = None
|
||||
call_ask: float | None = None
|
||||
put_ask: float | None = None
|
||||
call_premium: float | None = None
|
||||
put_premium: float | None = None
|
||||
max_loss: float | None = None
|
||||
net_profit_target: float | None = None
|
||||
capital_base: float | None = None
|
||||
cushion: float | None = None
|
||||
reward_ratio: float | None = None
|
||||
leg_budget: float | None = None # 单腿权利金预算(B/2×cushion)
|
||||
|
||||
|
||||
def compute_oo_sizing(
|
||||
*,
|
||||
budget: float,
|
||||
call_ask: float,
|
||||
put_ask: float,
|
||||
fee_rate: float = 0.0005,
|
||||
index_px: float = 0.0,
|
||||
cushion: float = 0.92,
|
||||
reward_ratio: float = 2.0,
|
||||
) -> OoSizingResult:
|
||||
"""
|
||||
期期:总预算 B 平分给 Call/Put(各约 B/2,再乘 cushion 预留);
|
||||
两腿按各自卖一独立定仓 qty=floor_1dp(腿预算/ask),数量可以不同;
|
||||
出场目标 = B × reward_ratio(按全额预算)。
|
||||
"""
|
||||
if budget is None or budget <= 0 or not math.isfinite(budget):
|
||||
return OoSizingResult(ok=False, detail="期期预算无效")
|
||||
if call_ask <= 0 or put_ask <= 0:
|
||||
return OoSizingResult(ok=False, detail="期期缺少有效卖一")
|
||||
cush = min(1.0, max(0.5, float(cushion)))
|
||||
ratio = max(0.5, float(reward_ratio))
|
||||
# 各腿:总预算一半 × 预留
|
||||
leg_raw = float(budget) / 2.0
|
||||
leg_budget = leg_raw * cush
|
||||
# 单腿开仓费粗估(从该腿预算里扣)
|
||||
fee_one = 0.0
|
||||
if index_px and index_px > 0 and fee_rate > 0:
|
||||
fee_one = float(index_px) * float(fee_rate)
|
||||
leg_spend = max(0.0, leg_budget - fee_one)
|
||||
if leg_spend <= 1e-9:
|
||||
return OoSizingResult(ok=False, detail="期期单腿预留后可用权利金不足")
|
||||
|
||||
def _leg_qty(ask: float) -> tuple[float, float]:
|
||||
q = floor_k_1dp(leg_spend / float(ask))
|
||||
while q >= 0.1 - 1e-12:
|
||||
prem = float(ask) * q
|
||||
if prem <= leg_spend + 1e-6:
|
||||
return round(q, 1), prem
|
||||
q = round(q - 0.1, 1)
|
||||
return 0.0, 0.0
|
||||
|
||||
q_call, cp = _leg_qty(float(call_ask))
|
||||
q_put, pp = _leg_qty(float(put_ask))
|
||||
if q_call < 0.1 - 1e-12 or q_put < 0.1 - 1e-12:
|
||||
return OoSizingResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
f"期期定仓失败:Call可{q_call} Put可{q_put}(各腿预算约"
|
||||
f"{leg_budget:.2f}U),总预算 {budget:.2f}U 不足"
|
||||
),
|
||||
budget=_round2(float(budget)),
|
||||
leg_budget=_round2(leg_budget),
|
||||
)
|
||||
spend = leg_budget * 2.0
|
||||
return OoSizingResult(
|
||||
ok=True,
|
||||
detail="ok",
|
||||
budget=_round2(float(budget)),
|
||||
spend=_round2(spend),
|
||||
qty_eth=round(q_call, 1),
|
||||
call_qty_eth=round(q_call, 1),
|
||||
put_qty_eth=round(q_put, 1),
|
||||
call_ask=_round2(float(call_ask)),
|
||||
put_ask=_round2(float(put_ask)),
|
||||
call_premium=_round2(cp),
|
||||
put_premium=_round2(pp),
|
||||
max_loss=_round2(cp + pp + fee_one * 2.0),
|
||||
net_profit_target=_round2(float(budget) * ratio),
|
||||
cushion=cush,
|
||||
reward_ratio=ratio,
|
||||
leg_budget=_round2(leg_budget),
|
||||
)
|
||||
|
||||
|
||||
def apply_oo_sizing_to_ledger(
|
||||
*,
|
||||
call_ask: float,
|
||||
put_ask: float,
|
||||
index_px: float,
|
||||
db: Database | None = None,
|
||||
) -> OoSizingResult:
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
s = get_settings()
|
||||
pos = database.fetchone("SELECT status FROM positions WHERE id=1")
|
||||
if pos is not None:
|
||||
st = str(pos["status"] or "flat")
|
||||
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
|
||||
return OoSizingResult(
|
||||
ok=False,
|
||||
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
|
||||
)
|
||||
budget, detail, capital = resolve_budget(database)
|
||||
if budget is None:
|
||||
return OoSizingResult(ok=False, detail=f"期期预算失败: {detail}")
|
||||
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
|
||||
cushion = ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion)
|
||||
ratio = ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio)
|
||||
r = compute_oo_sizing(
|
||||
budget=float(budget),
|
||||
call_ask=float(call_ask),
|
||||
put_ask=float(put_ask),
|
||||
fee_rate=fee_rate,
|
||||
index_px=float(index_px),
|
||||
cushion=cushion,
|
||||
reward_ratio=ratio,
|
||||
)
|
||||
if not r.ok:
|
||||
return r
|
||||
call_q = float(r.call_qty_eth or r.qty_eth or 0)
|
||||
put_q = float(r.put_qty_eth or r.qty_eth or 0)
|
||||
database.set_setting("exit_mode", "fixed_usdt")
|
||||
database.set_setting("perp_qty_eth", "0")
|
||||
database.set_setting("option_qty_eth", str(call_q))
|
||||
database.set_setting("oo_put_qty_eth", str(put_q))
|
||||
database.set_setting("net_profit_target", str(r.net_profit_target))
|
||||
database.set_setting("risk_last_k", str(call_q))
|
||||
database.set_setting(
|
||||
"risk_last_max_loss",
|
||||
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
|
||||
)
|
||||
logger.info(
|
||||
"oo_sizing applied call_qty=%.1f put_qty=%.1f call_ask=%.4f put_ask=%.4f "
|
||||
"exit=%.2f max_loss=%.2f budget=%.2f leg=%.2f",
|
||||
call_q,
|
||||
put_q,
|
||||
r.call_ask or 0,
|
||||
r.put_ask or 0,
|
||||
r.net_profit_target or 0,
|
||||
r.max_loss or 0,
|
||||
r.budget or 0,
|
||||
r.leg_budget or 0,
|
||||
)
|
||||
# attach capital for callers
|
||||
return OoSizingResult(
|
||||
ok=True,
|
||||
detail=r.detail,
|
||||
budget=r.budget,
|
||||
spend=r.spend,
|
||||
qty_eth=call_q,
|
||||
call_qty_eth=call_q,
|
||||
put_qty_eth=put_q,
|
||||
call_ask=r.call_ask,
|
||||
put_ask=r.put_ask,
|
||||
call_premium=r.call_premium,
|
||||
put_premium=r.put_premium,
|
||||
max_loss=r.max_loss,
|
||||
net_profit_target=r.net_profit_target,
|
||||
capital_base=_round2(capital) if capital is not None else None,
|
||||
cushion=r.cushion,
|
||||
reward_ratio=r.reward_ratio,
|
||||
leg_budget=r.leg_budget,
|
||||
)
|
||||
|
||||
|
||||
def apply_risk_sizing_to_ledger(
|
||||
*,
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
db: Database | None = None,
|
||||
perp_unit: float | None = None,
|
||||
option_unit: float | None = None,
|
||||
exit_unit: float | None = None,
|
||||
leverage_basis: str | None = None,
|
||||
) -> RiskSizingResult:
|
||||
"""计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。持仓中拒绝改写。"""
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
if not is_risk_based(ledger):
|
||||
return RiskSizingResult(ok=True, detail="manual_sizing_skip")
|
||||
|
||||
# 有活跃仓:本组成场参数已锁定,禁止重算覆盖
|
||||
pos = database.fetchone("SELECT status, group_id FROM positions WHERE id=1")
|
||||
if pos is not None:
|
||||
st = str(pos["status"] or "flat")
|
||||
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
|
||||
return RiskSizingResult(
|
||||
ok=False,
|
||||
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
|
||||
)
|
||||
|
||||
r = compute_risk_sizing(
|
||||
index_px=index_px,
|
||||
option_ask=option_ask,
|
||||
db=database,
|
||||
perp_unit=perp_unit,
|
||||
option_unit=option_unit,
|
||||
exit_unit=exit_unit,
|
||||
leverage_basis=leverage_basis,
|
||||
)
|
||||
if not r.ok:
|
||||
return r
|
||||
|
||||
exit_mode = str(
|
||||
ledger.get_setting_str("exit_mode", "fixed_usdt") or "fixed_usdt"
|
||||
).strip().lower()
|
||||
s = get_settings()
|
||||
prem_mult = float(
|
||||
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
|
||||
or s.premium_exit_multiple
|
||||
)
|
||||
database.set_setting("perp_qty_eth", str(r.perp_qty_eth))
|
||||
database.set_setting("option_qty_eth", str(r.option_qty_eth))
|
||||
database.set_setting("risk_last_k", str(r.k))
|
||||
database.set_setting(
|
||||
"risk_last_max_loss",
|
||||
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
|
||||
)
|
||||
if exit_mode == "premium_multiple":
|
||||
database.set_setting("exit_mode", "premium_multiple")
|
||||
# 预估展示用:估权利金×倍数;开仓后以真实 initial_premium 锁定
|
||||
est = float(r.premium_est or 0) * max(0.0, prem_mult)
|
||||
if est > 0:
|
||||
database.set_setting("net_profit_target", f"{est:.4f}")
|
||||
exit_log = f"prem×{prem_mult:g}≈{est:.2f}"
|
||||
else:
|
||||
database.set_setting("exit_mode", "fixed_usdt")
|
||||
database.set_setting("net_profit_target", str(r.net_profit_target))
|
||||
exit_log = f"{r.net_profit_target or 0:.4f}"
|
||||
logger.info(
|
||||
"risk_sizing applied k=%.1f basis=%s sizing_ask=%.4f actual_ask=%.4f "
|
||||
"perp=%.4f opt=%.4f exit=%s max_loss=%.4f budget=%.4f",
|
||||
r.k or 0,
|
||||
r.leverage_basis or "?",
|
||||
r.option_ask or 0,
|
||||
r.actual_option_ask or 0,
|
||||
r.perp_qty_eth or 0,
|
||||
r.option_qty_eth or 0,
|
||||
exit_log,
|
||||
r.max_loss or 0,
|
||||
r.budget or 0,
|
||||
)
|
||||
return r
|
||||
|
||||
|
||||
def _hedge_mode(ledger: Ledger | None = None) -> str:
|
||||
led = ledger or Ledger()
|
||||
s = get_settings()
|
||||
raw = str(led.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode).strip().lower()
|
||||
return raw if raw in ("perp_option", "option_option") else "perp_option"
|
||||
|
||||
|
||||
def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
|
||||
"""设置页/计划页预览:用当前盘口粗估。"""
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
out: dict[str, Any] = {
|
||||
"sizing_mode": ledger.get_setting_str("sizing_mode", "manual") or "manual",
|
||||
"risk_based": is_risk_based(ledger),
|
||||
"hedge_mode": _hedge_mode(ledger),
|
||||
}
|
||||
if not is_risk_based(ledger):
|
||||
out["ok"] = True
|
||||
out["detail"] = "当前为手动仓位"
|
||||
return out
|
||||
|
||||
if out["hedge_mode"] == "option_option":
|
||||
return _preview_oo_sizing(database, ledger, out)
|
||||
|
||||
try:
|
||||
from .open_capacity import _index_and_option_ask
|
||||
|
||||
idx, ask = _index_and_option_ask()
|
||||
except Exception:
|
||||
idx, ask = None, None
|
||||
if idx is None or ask is None:
|
||||
out["ok"] = False
|
||||
out["detail"] = "暂无指数或期权卖一,无法预览"
|
||||
return out
|
||||
|
||||
# 半自动:用本单配比/净利基数,并以盘口卖一定仓(与开仓一致)
|
||||
semi_perp = semi_opt = semi_exit = None
|
||||
semi_on = False
|
||||
try:
|
||||
from .semi_auto import is_semi_auto, read_semi_params
|
||||
|
||||
if is_semi_auto(ledger):
|
||||
sp = read_semi_params(ledger)
|
||||
semi_on = True
|
||||
semi_perp = float(sp["perp_unit"])
|
||||
semi_opt = float(sp["option_unit"])
|
||||
semi_exit = float(sp["perp_exit_unit"])
|
||||
except Exception:
|
||||
logger.debug("preview semi units skipped", exc_info=True)
|
||||
|
||||
r = compute_risk_sizing(
|
||||
index_px=float(idx),
|
||||
option_ask=float(ask),
|
||||
db=database,
|
||||
perp_unit=semi_perp,
|
||||
option_unit=semi_opt,
|
||||
exit_unit=semi_exit,
|
||||
leverage_basis="actual" if semi_on else None,
|
||||
)
|
||||
perp_u, opt_u, exit_u = read_risk_units(ledger)
|
||||
if semi_perp is not None:
|
||||
perp_u = float(semi_perp)
|
||||
if semi_opt is not None:
|
||||
opt_u = float(semi_opt)
|
||||
if semi_exit is not None:
|
||||
exit_u = float(semi_exit)
|
||||
mg = resolve_martingale(database, ledger=ledger)
|
||||
s = get_settings()
|
||||
exit_mode = str(
|
||||
ledger.get_setting_str("exit_mode", s.exit_mode) or s.exit_mode
|
||||
).strip().lower()
|
||||
prem_mult = float(
|
||||
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
|
||||
or s.premium_exit_multiple
|
||||
)
|
||||
exit_target = r.net_profit_target
|
||||
exit_label = "基数×k"
|
||||
if semi_on:
|
||||
exit_label = "半自动净利基数×k"
|
||||
exit_mode = "fixed_usdt"
|
||||
elif exit_mode == "premium_multiple":
|
||||
exit_label = f"权利金×{prem_mult:g}"
|
||||
if r.ok and r.premium_est is not None:
|
||||
exit_target = round(float(r.premium_est) * max(0.0, prem_mult), 2)
|
||||
out.update(
|
||||
{
|
||||
"ok": r.ok,
|
||||
"detail": r.detail,
|
||||
"k": r.k,
|
||||
"budget": r.budget,
|
||||
"capital_base": r.capital_base,
|
||||
"premium_est": r.premium_est,
|
||||
"fee_est": r.fee_est,
|
||||
"max_loss": r.max_loss,
|
||||
"perp_qty_eth": r.perp_qty_eth,
|
||||
"option_qty_eth": r.option_qty_eth,
|
||||
"net_profit_target": exit_target,
|
||||
"exit_mode": exit_mode if exit_mode in ("fixed_usdt", "premium_multiple") else "fixed_usdt",
|
||||
"premium_exit_multiple": prem_mult,
|
||||
"exit_label": exit_label,
|
||||
"index_px": r.index_px,
|
||||
"option_ask": r.option_ask,
|
||||
"actual_option_ask": r.actual_option_ask,
|
||||
"leverage_basis": r.leverage_basis,
|
||||
"perp_unit": perp_u,
|
||||
"option_unit": opt_u,
|
||||
"exit_unit": exit_u,
|
||||
"semi_units": semi_on,
|
||||
"martingale": mg,
|
||||
"risk_effective_loss_pct": mg.get("effective_pct"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _preview_oo_sizing(
|
||||
database: Database, ledger: Ledger, out: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""期期预览:出场目标 = 预算 × 盈亏比;有卖一时再估单腿 qty。"""
|
||||
s = get_settings()
|
||||
budget, detail, capital = resolve_budget(database)
|
||||
mg = resolve_martingale(database, ledger=ledger)
|
||||
ratio = float(
|
||||
ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio)
|
||||
or s.oo_reward_ratio
|
||||
)
|
||||
cush = float(
|
||||
ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion)
|
||||
or s.oo_budget_cushion
|
||||
)
|
||||
out["martingale"] = mg
|
||||
out["risk_effective_loss_pct"] = mg.get("effective_pct")
|
||||
out["reward_ratio"] = ratio
|
||||
out["cushion"] = cush
|
||||
if budget is None:
|
||||
out["ok"] = False
|
||||
out["detail"] = f"期期预算失败: {detail}"
|
||||
return out
|
||||
exit_target = _round2(float(budget) * max(0.5, ratio))
|
||||
out.update(
|
||||
{
|
||||
"budget": _round2(float(budget)),
|
||||
"capital_base": _round2(float(capital)) if capital is not None else None,
|
||||
"net_profit_target": exit_target,
|
||||
"k": None,
|
||||
"perp_qty_eth": 0.0,
|
||||
}
|
||||
)
|
||||
call_ask = put_ask = idx = None
|
||||
try:
|
||||
from .session import get_session
|
||||
|
||||
snap = get_session().snapshot()
|
||||
idx = snap.index_px
|
||||
if snap.call and snap.call.ask and float(snap.call.ask) > 0:
|
||||
call_ask = float(snap.call.ask)
|
||||
if snap.put and snap.put.ask and float(snap.put.ask) > 0:
|
||||
put_ask = float(snap.put.ask)
|
||||
if idx is None and snap.perp and snap.perp.mark_px:
|
||||
idx = float(snap.perp.mark_px)
|
||||
except Exception:
|
||||
pass
|
||||
if call_ask is None or put_ask is None:
|
||||
try:
|
||||
from .open_capacity import _index_and_option_ask
|
||||
|
||||
i2, a2 = _index_and_option_ask()
|
||||
if idx is None:
|
||||
idx = i2
|
||||
# 回退:单腿 ATM 卖一不够准确,但至少能估数量量级
|
||||
if call_ask is None and a2 is not None and float(a2) > 0:
|
||||
call_ask = float(a2)
|
||||
if put_ask is None and a2 is not None and float(a2) > 0:
|
||||
put_ask = float(a2)
|
||||
except Exception:
|
||||
pass
|
||||
if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0:
|
||||
out["ok"] = True
|
||||
out["detail"] = "已估出场目标;虚值双腿卖一未齐,数量待开仓时再算"
|
||||
out["option_qty_eth"] = None
|
||||
out["sizing_ok"] = False
|
||||
return out
|
||||
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
|
||||
r = compute_oo_sizing(
|
||||
budget=float(budget),
|
||||
call_ask=float(call_ask),
|
||||
put_ask=float(put_ask),
|
||||
fee_rate=fee_rate,
|
||||
index_px=float(idx or 0),
|
||||
cushion=cush,
|
||||
reward_ratio=ratio,
|
||||
)
|
||||
# 出场始终按全额预算×盈亏比;数量估失败仍返回 ok 以便 Plan 展示目标
|
||||
out.update(
|
||||
{
|
||||
"ok": True,
|
||||
"sizing_ok": bool(r.ok),
|
||||
"detail": "ok" if r.ok else str(r.detail or "期期数量未估出"),
|
||||
"option_qty_eth": r.qty_eth if r.ok else None,
|
||||
"call_qty_eth": r.call_qty_eth if r.ok else None,
|
||||
"put_qty_eth": r.put_qty_eth if r.ok else None,
|
||||
"leg_budget": r.leg_budget if r.ok else None,
|
||||
"call_ask": r.call_ask,
|
||||
"put_ask": r.put_ask,
|
||||
"call_premium": r.call_premium if r.ok else None,
|
||||
"put_premium": r.put_premium if r.ok else None,
|
||||
"max_loss": r.max_loss if r.ok else None,
|
||||
"net_profit_target": exit_target,
|
||||
"index_px": _round2(float(idx)) if idx is not None else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -46,6 +46,109 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
|
||||
return min(strikes, key=lambda s: (abs(s - mark_px), s))
|
||||
|
||||
|
||||
def list_otm_strikes(
|
||||
strikes: list[float],
|
||||
mark_px: float,
|
||||
*,
|
||||
option_side: str,
|
||||
max_offset: float,
|
||||
) -> list[float]:
|
||||
"""
|
||||
虚值候选:Call K>S、Put K<S 且 |K−S|≤max_offset;
|
||||
按靠近标的优先排序(近→远)。
|
||||
"""
|
||||
if not strikes or mark_px <= 0:
|
||||
return []
|
||||
side = (option_side or "").strip().lower()
|
||||
spot = float(mark_px)
|
||||
cap = max(0.0, float(max_offset))
|
||||
if side == "call":
|
||||
cands = [
|
||||
float(s)
|
||||
for s in strikes
|
||||
if float(s) > spot + 1e-9 and float(s) - spot <= cap + 1e-9
|
||||
]
|
||||
elif side == "put":
|
||||
cands = [
|
||||
float(s)
|
||||
for s in strikes
|
||||
if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9
|
||||
]
|
||||
else:
|
||||
return []
|
||||
return sorted(cands, key=lambda s: (abs(s - spot), s))
|
||||
|
||||
|
||||
def pick_otm_strike(
|
||||
strikes: list[float],
|
||||
mark_px: float,
|
||||
*,
|
||||
option_side: str,
|
||||
max_offset: float,
|
||||
) -> float | None:
|
||||
"""虚值:取最接近标的且 |K−S|≤max_offset 的一档。"""
|
||||
cands = list_otm_strikes(
|
||||
strikes,
|
||||
mark_px,
|
||||
option_side=option_side,
|
||||
max_offset=max_offset,
|
||||
)
|
||||
return cands[0] if cands else None
|
||||
|
||||
|
||||
def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool:
|
||||
if mark_px <= 0:
|
||||
return False
|
||||
side = (option_side or "").strip().lower()
|
||||
k = float(strike)
|
||||
s = float(mark_px)
|
||||
if side == "call":
|
||||
return k > s + 1e-9
|
||||
if side == "put":
|
||||
return k < s - 1e-9
|
||||
return False
|
||||
|
||||
|
||||
def pick_itm_or_atm_strike(
|
||||
strikes: list[float],
|
||||
mark_px: float,
|
||||
*,
|
||||
option_side: str,
|
||||
) -> float | None:
|
||||
"""
|
||||
固定方向选约:只要实值或平值,不要虚值。
|
||||
- Call:行权价 ≤ 标的(平值/实值)
|
||||
- Put:行权价 ≥ 标的(平值/实值)
|
||||
在合格档中取最接近标的者(优先平值)。
|
||||
"""
|
||||
if not strikes or mark_px <= 0:
|
||||
return None
|
||||
side = (option_side or "").strip().lower()
|
||||
if side == "call":
|
||||
cands = [float(s) for s in strikes if float(s) <= float(mark_px) + 1e-9]
|
||||
elif side == "put":
|
||||
cands = [float(s) for s in strikes if float(s) >= float(mark_px) - 1e-9]
|
||||
else:
|
||||
return None
|
||||
if not cands:
|
||||
return None
|
||||
return min(cands, key=lambda s: (abs(s - float(mark_px)), s))
|
||||
|
||||
|
||||
def is_itm_or_atm(*, option_side: str, strike: float, mark_px: float) -> bool:
|
||||
"""Call: K≤S;Put: K≥S。"""
|
||||
if mark_px <= 0:
|
||||
return False
|
||||
side = (option_side or "").strip().lower()
|
||||
k = float(strike)
|
||||
s = float(mark_px)
|
||||
if side == "call":
|
||||
return k <= s + 1e-9
|
||||
if side == "put":
|
||||
return k >= s - 1e-9
|
||||
return False
|
||||
|
||||
|
||||
def atm_open_offset(strike: float, mark_px: float) -> float:
|
||||
"""开仓用:ATM 行权价相对标的的绝对点差。"""
|
||||
return abs(float(strike) - float(mark_px))
|
||||
@@ -125,7 +228,16 @@ def select_option_pair(
|
||||
expiry_ymd: str | None = None,
|
||||
min_hours: float | None = None,
|
||||
now: datetime | None = None,
|
||||
option_side: str | None = None,
|
||||
moneyness: str | None = None,
|
||||
otm_max_offset: float | None = None,
|
||||
) -> OptionPair | None:
|
||||
"""
|
||||
选到期 + 行权价。
|
||||
option_side 为 call/put 时:按 moneyness 选档(默认实值/平值,兼容固定方向);
|
||||
否则仍选 ATM(现有规则)。
|
||||
moneyness: itm | atm | otm(仅半自动传入 otm/atm)。
|
||||
"""
|
||||
complete = _complete_by_expiry(contracts)
|
||||
if not complete:
|
||||
return None
|
||||
@@ -148,14 +260,31 @@ def select_option_pair(
|
||||
ymd = eligible[0]
|
||||
|
||||
ems, strikes_map = complete[ymd]
|
||||
atm = pick_atm_strike(list(strikes_map.keys()), mark_px)
|
||||
if atm is None:
|
||||
keys = list(strikes_map.keys())
|
||||
side = (option_side or "").strip().lower() or None
|
||||
mny = (moneyness or "").strip().lower() or None
|
||||
if side in ("call", "put"):
|
||||
if mny == "otm":
|
||||
strike = pick_otm_strike(
|
||||
keys,
|
||||
mark_px,
|
||||
option_side=side,
|
||||
max_offset=float(otm_max_offset or 0),
|
||||
)
|
||||
elif mny == "atm":
|
||||
strike = pick_atm_strike(keys, mark_px)
|
||||
else:
|
||||
# itm 或未指定:实值/平值(固定方向默认)
|
||||
strike = pick_itm_or_atm_strike(keys, mark_px, option_side=side)
|
||||
else:
|
||||
strike = pick_atm_strike(keys, mark_px)
|
||||
if strike is None:
|
||||
return None
|
||||
legs = strikes_map[atm]
|
||||
legs = strikes_map[strike]
|
||||
return OptionPair(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=ems,
|
||||
strike=atm,
|
||||
strike=strike,
|
||||
call_inst_id=legs["C"],
|
||||
put_inst_id=legs["P"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""半自动:人工定方向/目标并授权 → 机器盯开盯平 → 平完停等人工。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
PHASE_WAIT_HUMAN = "wait_human"
|
||||
REASON_POINTS = "semi_target_points"
|
||||
REASON_PERP_NET = "semi_perp_exit"
|
||||
TRADE_LOCK_KEY = "semi_trade_lock"
|
||||
OTM_LEV_FLOOR = 180.0
|
||||
MONEYNESS_CHOICES = ("itm", "atm", "otm")
|
||||
|
||||
|
||||
def is_semi_auto(ledger: Ledger | None = None) -> bool:
|
||||
led = ledger or Ledger()
|
||||
s = get_settings()
|
||||
return bool(
|
||||
led.get_setting_bool("semi_auto_enabled", s.semi_auto_enabled)
|
||||
)
|
||||
|
||||
|
||||
def is_armed(ledger: Ledger | None = None) -> bool:
|
||||
led = ledger or Ledger()
|
||||
return bool(led.get_setting_bool("semi_armed", False))
|
||||
|
||||
|
||||
def set_armed(db: Any, armed: bool) -> None:
|
||||
db.set_setting("semi_armed", "true" if armed else "false")
|
||||
|
||||
|
||||
def clear_armed_if_flat(db: Any, *, has_open_position: bool) -> None:
|
||||
"""进程重启等:无仓时清授权,避免陈旧 armed 自动开仓。"""
|
||||
if not has_open_position:
|
||||
set_armed(db, False)
|
||||
|
||||
|
||||
def read_trade_lock(ledger: Ledger | None = None) -> dict[str, Any] | None:
|
||||
led = ledger or Ledger()
|
||||
raw = led.get_setting_str(TRADE_LOCK_KEY, "") or ""
|
||||
raw = str(raw).strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict) or not data.get("group_id"):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def lock_trade_params(
|
||||
db: Any,
|
||||
*,
|
||||
group_id: str,
|
||||
view_side: str,
|
||||
option_move_points: float,
|
||||
perp_exit_unit: float,
|
||||
moneyness: str = "otm",
|
||||
otm_max_offset: float = 25.0,
|
||||
perp_unit: float = 1.0,
|
||||
option_unit: float = 4.0,
|
||||
) -> None:
|
||||
mny = str(moneyness or "otm").strip().lower()
|
||||
if mny not in MONEYNESS_CHOICES:
|
||||
mny = "otm"
|
||||
payload = {
|
||||
"group_id": str(group_id),
|
||||
"view_side": "short" if view_side == "short" else "long",
|
||||
"option_move_points": float(option_move_points),
|
||||
"perp_exit_unit": float(perp_exit_unit),
|
||||
"moneyness": mny,
|
||||
"otm_max_offset": float(otm_max_offset),
|
||||
"perp_unit": float(perp_unit),
|
||||
"option_unit": float(option_unit),
|
||||
}
|
||||
db.set_setting(TRADE_LOCK_KEY, json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
def clear_trade_lock(db: Any) -> None:
|
||||
db.set_setting(TRADE_LOCK_KEY, "")
|
||||
|
||||
|
||||
def _norm_moneyness(raw: str | None, default: str = "otm") -> str:
|
||||
m = str(raw or default).strip().lower()
|
||||
return m if m in MONEYNESS_CHOICES else default
|
||||
|
||||
|
||||
def effective_min_leverage(moneyness: str, configured: float) -> float:
|
||||
"""虚值强制不低于 180;实值/平值用配置值。"""
|
||||
lev = max(1.0, float(configured))
|
||||
if _norm_moneyness(moneyness) == "otm":
|
||||
return max(lev, OTM_LEV_FLOOR)
|
||||
return lev
|
||||
|
||||
|
||||
def read_semi_params(
|
||||
ledger: Ledger | None = None,
|
||||
*,
|
||||
group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
led = ledger or Ledger()
|
||||
s = get_settings()
|
||||
view = str(
|
||||
led.get_setting_str("semi_view_side", s.semi_view_side) or s.semi_view_side
|
||||
).strip().lower()
|
||||
if view not in ("long", "short"):
|
||||
view = "long"
|
||||
move = float(
|
||||
led.get_setting_float(
|
||||
"semi_option_move_points", s.semi_option_move_points
|
||||
)
|
||||
or s.semi_option_move_points
|
||||
)
|
||||
exit_unit = float(
|
||||
led.get_setting_float("semi_perp_exit_unit", s.semi_perp_exit_unit)
|
||||
or s.semi_perp_exit_unit
|
||||
)
|
||||
moneyness = _norm_moneyness(
|
||||
led.get_setting_str("semi_moneyness", s.semi_moneyness),
|
||||
s.semi_moneyness,
|
||||
)
|
||||
otm_off = float(
|
||||
led.get_setting_float("semi_otm_max_offset", s.semi_otm_max_offset)
|
||||
or s.semi_otm_max_offset
|
||||
)
|
||||
perp_u = float(
|
||||
led.get_setting_float("semi_perp_unit", s.semi_perp_unit) or s.semi_perp_unit
|
||||
)
|
||||
opt_u = float(
|
||||
led.get_setting_float("semi_option_unit", s.semi_option_unit)
|
||||
or s.semi_option_unit
|
||||
)
|
||||
if perp_u <= 0:
|
||||
perp_u = float(s.semi_perp_unit)
|
||||
if opt_u <= 0:
|
||||
opt_u = float(s.semi_option_unit)
|
||||
min_lev_cfg = float(
|
||||
led.get_setting_float(
|
||||
"semi_min_option_leverage", s.semi_min_option_leverage
|
||||
)
|
||||
or s.semi_min_option_leverage
|
||||
)
|
||||
# 持仓中优先用开仓时锁定的出场/选约参数
|
||||
lock = read_trade_lock(led)
|
||||
if lock and group_id and str(lock.get("group_id")) == str(group_id):
|
||||
view = str(lock.get("view_side") or view).strip().lower()
|
||||
if view not in ("long", "short"):
|
||||
view = "long"
|
||||
try:
|
||||
move = float(lock.get("option_move_points", move))
|
||||
exit_unit = float(lock.get("perp_exit_unit", exit_unit))
|
||||
otm_off = float(lock.get("otm_max_offset", otm_off))
|
||||
perp_u = float(lock.get("perp_unit", perp_u))
|
||||
opt_u = float(lock.get("option_unit", opt_u))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
moneyness = _norm_moneyness(str(lock.get("moneyness") or moneyness), moneyness)
|
||||
return {
|
||||
"enabled": is_semi_auto(led),
|
||||
"armed": is_armed(led),
|
||||
"view_side": view,
|
||||
# 看法多 → Call+永续空;看法空 → Put+永续多
|
||||
"option_side": "call" if view == "long" else "put",
|
||||
"perp_side": "short" if view == "long" else "long",
|
||||
"option_move_points": move,
|
||||
"perp_exit_unit": exit_unit,
|
||||
"moneyness": moneyness,
|
||||
"otm_max_offset": max(0.0, otm_off),
|
||||
"perp_unit": perp_u,
|
||||
"option_unit": opt_u,
|
||||
"min_option_hours": float(
|
||||
led.get_setting_float(
|
||||
"semi_min_option_hours", s.semi_min_option_hours
|
||||
)
|
||||
or s.semi_min_option_hours
|
||||
),
|
||||
"min_option_leverage": effective_min_leverage(moneyness, min_lev_cfg),
|
||||
"min_option_leverage_cfg": min_lev_cfg,
|
||||
"trade_locked": bool(
|
||||
lock and group_id and str(lock.get("group_id")) == str(group_id)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SemiExitDecision:
|
||||
should_close: bool
|
||||
reason: str = ""
|
||||
detail: str = ""
|
||||
target_index: float | None = None
|
||||
net_target: float | None = None
|
||||
|
||||
|
||||
def check_semi_exits(
|
||||
*,
|
||||
net_pnl: float,
|
||||
strike: float | None,
|
||||
index_px: float | None,
|
||||
view_side: str,
|
||||
option_move_points: float,
|
||||
perp_exit_unit: float,
|
||||
risk_k: float = 1.0,
|
||||
entry_index: float | None = None,
|
||||
) -> SemiExitDecision:
|
||||
"""
|
||||
顺方向:指数到达「行权价 ± 波动点」且组合净利>0 → 全平。
|
||||
多/Call:目标 = K + N;空/Put:目标 = K − N(N 为设置的波动点,不是现价±N)。
|
||||
逆方向兑现(永续锁定净利):组合净利 ≥ 净利基数×k → 全平。
|
||||
"""
|
||||
view = (view_side or "long").strip().lower()
|
||||
if view not in ("long", "short"):
|
||||
view = "long"
|
||||
move = max(0.0, float(option_move_points))
|
||||
k = float(risk_k) if risk_k and risk_k > 0 else 1.0
|
||||
net_tgt = max(0.0, float(perp_exit_unit)) * k
|
||||
net = float(net_pnl)
|
||||
|
||||
# 逆方向 / 永续净利锁定:达标即可离场(不必等点位)
|
||||
if net_tgt > 0 and net + 1e-9 >= net_tgt:
|
||||
return SemiExitDecision(
|
||||
True,
|
||||
REASON_PERP_NET,
|
||||
f"半自动·永续净利锁定≥{net_tgt:.2f}U(基数×k)",
|
||||
net_target=net_tgt,
|
||||
)
|
||||
|
||||
if index_px is None:
|
||||
return SemiExitDecision(False, "", "缺指数")
|
||||
idx = float(index_px)
|
||||
# 锚定行权价;无 strike 时才回退开仓指数(兼容旧仓)
|
||||
anchor = None
|
||||
if strike is not None and float(strike) > 0:
|
||||
anchor = float(strike)
|
||||
elif entry_index is not None and float(entry_index) > 0:
|
||||
anchor = float(entry_index)
|
||||
if anchor is None or idx <= 0 or move <= 0:
|
||||
return SemiExitDecision(False, "", "点位无效")
|
||||
|
||||
if view == "long":
|
||||
target_idx = anchor + move
|
||||
hit = idx + 1e-9 >= target_idx
|
||||
else:
|
||||
target_idx = anchor - move
|
||||
hit = idx - 1e-9 <= target_idx
|
||||
|
||||
if hit and net > 0:
|
||||
return SemiExitDecision(
|
||||
True,
|
||||
REASON_POINTS,
|
||||
f"半自动·指数到期权目标{target_idx:.2f}(K{anchor:g}±{move:g})且净利>0",
|
||||
target_index=target_idx,
|
||||
net_target=0.0,
|
||||
)
|
||||
if hit and net <= 0:
|
||||
return SemiExitDecision(
|
||||
False,
|
||||
"",
|
||||
f"已到期权目标{target_idx:.2f}但组合净利≤0({net:.2f}),继续持有",
|
||||
target_index=target_idx,
|
||||
)
|
||||
return SemiExitDecision(
|
||||
False,
|
||||
"",
|
||||
f"未到期权目标(K{anchor:g}→{target_idx:.2f})",
|
||||
target_index=target_idx,
|
||||
net_target=net_tgt,
|
||||
)
|
||||
+1001
-41
File diff suppressed because it is too large
Load Diff
@@ -66,3 +66,43 @@ def decide(
|
||||
put_ask=pa,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def decide_fixed(
|
||||
call_ask: float | None,
|
||||
put_ask: float | None,
|
||||
*,
|
||||
perp_side: str,
|
||||
) -> Signal | None:
|
||||
"""
|
||||
固定方向:
|
||||
- 永续多 → 买 Put(只需 Put 卖一)
|
||||
- 永续空 → 买 Call(只需 Call 卖一)
|
||||
对侧卖一缺失时用本侧占位,避免半自动虚值因对侧盘口空而拒单。
|
||||
"""
|
||||
side = (perp_side or "").strip().lower()
|
||||
if side == "long":
|
||||
if put_ask is None or float(put_ask) <= 0:
|
||||
return None
|
||||
pa = float(put_ask)
|
||||
ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else pa
|
||||
return Signal(
|
||||
bias="fixed_long_put",
|
||||
option_side="put",
|
||||
perp_side="long",
|
||||
call_ask=ca,
|
||||
put_ask=pa,
|
||||
)
|
||||
if side == "short":
|
||||
if call_ask is None or float(call_ask) <= 0:
|
||||
return None
|
||||
ca = float(call_ask)
|
||||
pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else ca
|
||||
return Signal(
|
||||
bias="fixed_short_call",
|
||||
option_side="call",
|
||||
perp_side="short",
|
||||
call_ask=ca,
|
||||
put_ask=pa,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""振幅过滤门:永期开仓第一关 / 与期期共用口径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.exchange.candles import AmplitudeHL
|
||||
from app.strategy.amplitude_gate import evaluate_amplitude_gate
|
||||
|
||||
|
||||
def test_amplitude_gate_off_always_pass() -> None:
|
||||
amp = AmplitudeHL(high=2060, low=1940, mid=2000, hours=12, bar_count=12)
|
||||
# 6% 超常见上限,但过滤关 → 不拦
|
||||
g = evaluate_amplitude_gate(
|
||||
filter_enabled=False, amp=amp, max_pct=2.0, hours=12
|
||||
)
|
||||
assert g["blocked"] is False
|
||||
assert g["ok"] is True
|
||||
assert g["reason"] is None
|
||||
assert g["snapshot"]["filter_enabled"] is False
|
||||
assert g["snapshot"]["range_pct"] == amp.range_pct
|
||||
|
||||
|
||||
def test_amplitude_gate_on_blocks_over_max() -> None:
|
||||
amp = AmplitudeHL(high=2060, low=1940, mid=2000, hours=12, bar_count=12)
|
||||
assert amp.range_pct == 6.0
|
||||
g = evaluate_amplitude_gate(
|
||||
filter_enabled=True, amp=amp, max_pct=2.0, hours=12
|
||||
)
|
||||
assert g["blocked"] is True
|
||||
assert g["ok"] is False
|
||||
assert "振幅未过关" in (g["reason"] or "")
|
||||
assert "6.00%" in (g["reason"] or "")
|
||||
assert "2%" in (g["reason"] or "")
|
||||
|
||||
|
||||
def test_amplitude_gate_on_pass_within_max() -> None:
|
||||
amp = AmplitudeHL(high=2010, low=1990, mid=2000, hours=12, bar_count=12)
|
||||
assert amp.range_pct == 1.0
|
||||
g = evaluate_amplitude_gate(
|
||||
filter_enabled=True, amp=amp, max_pct=2.0, hours=12
|
||||
)
|
||||
assert g["blocked"] is False
|
||||
assert g["ok"] is True
|
||||
assert g["reason"] is None
|
||||
|
||||
|
||||
def test_amplitude_gate_on_no_candles_fail_closed() -> None:
|
||||
g = evaluate_amplitude_gate(
|
||||
filter_enabled=True, amp=None, max_pct=2.0, hours=12
|
||||
)
|
||||
assert g["blocked"] is True
|
||||
assert g["ok"] is False
|
||||
assert "无法获取" in (g["reason"] or "")
|
||||
|
||||
|
||||
def test_amplitude_gate_off_no_candles_still_ok() -> None:
|
||||
g = evaluate_amplitude_gate(
|
||||
filter_enabled=False, amp=None, max_pct=2.0, hours=12
|
||||
)
|
||||
assert g["blocked"] is False
|
||||
assert g["ok"] is True
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.auth import issue_token, verify_token
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_issue_and_verify_token_roundtrip():
|
||||
s = get_settings()
|
||||
token, ttl = issue_token("admin", s)
|
||||
assert ttl == s.auth_token_ttl_sec
|
||||
assert verify_token(token, s) == "admin"
|
||||
|
||||
|
||||
def test_refresh_mints_another_valid_token():
|
||||
s = get_settings()
|
||||
t1, _ = issue_token("admin", s)
|
||||
t2, ttl = issue_token("admin", s)
|
||||
assert ttl > 0
|
||||
assert verify_token(t1, s) == "admin"
|
||||
assert verify_token(t2, s) == "admin"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""OKX 交易账户自动兑 USDC 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.strategy.auto_usdc import ensure_okx_trading_usdc
|
||||
|
||||
|
||||
def test_skip_when_usdc_enough() -> None:
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 100.0,
|
||||
"perp_need_usdt": 50.0,
|
||||
"perp_have_usdt": 500.0,
|
||||
"option_can_open": True,
|
||||
}
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", return_value=cap),
|
||||
):
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
|
||||
assert r["skipped"] is True
|
||||
assert r["acted"] is False
|
||||
assert "已够开仓" in r["detail"]
|
||||
|
||||
|
||||
def test_skip_non_okx() -> None:
|
||||
with patch("app.strategy.auto_usdc._is_okx", return_value=False):
|
||||
r = ensure_okx_trading_usdc(db=MagicMock())
|
||||
assert r["skipped"] is True
|
||||
assert "非 OKX" in r["detail"]
|
||||
|
||||
|
||||
def test_sim_convert_to_2x_need() -> None:
|
||||
"""USDC 不足 → 交易账户兑到 需×2(预留永续保证金)。"""
|
||||
import app.strategy.auto_usdc as m
|
||||
|
||||
m._last_attempt_ts = 0.0
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 20.0,
|
||||
"perp_need_usdt": 50.0,
|
||||
"perp_have_usdt": 500.0,
|
||||
"option_can_open": False,
|
||||
}
|
||||
cap_after = {
|
||||
**cap,
|
||||
"option_have_usdc": 200.0,
|
||||
"option_can_open": True,
|
||||
"perp_have_usdt": 320.0,
|
||||
}
|
||||
wallets = MagicMock()
|
||||
wallets.convert.return_value = {"ok": True, "detail": "converted"}
|
||||
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", side_effect=[cap, cap_after]),
|
||||
patch("app.strategy.auto_usdc.get_settings") as gs,
|
||||
patch("app.live.okx_funds.usdc_usdt_mid_rate", return_value=1.0),
|
||||
patch("app.sim.funds_wallets.SimFundsWallets", return_value=wallets),
|
||||
patch("app.strategy.auto_usdc.invalidate_live_balance_cache"),
|
||||
):
|
||||
gs.return_value.is_sim = True
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
|
||||
|
||||
assert r["acted"] is True
|
||||
assert r["ok"] is True
|
||||
# target=200, have=20 → gap=180 USDT
|
||||
wallets.convert.assert_called_once()
|
||||
kwargs = wallets.convert.call_args.kwargs
|
||||
assert kwargs["direction"] == "usdt_to_usdc"
|
||||
assert kwargs["account"] == "trading"
|
||||
assert kwargs["amount"] == 180.0
|
||||
|
||||
|
||||
def test_reserve_perp_margin() -> None:
|
||||
"""可兑 USDT = 交易 USDT − 永续所需。"""
|
||||
import app.strategy.auto_usdc as m
|
||||
|
||||
m._last_attempt_ts = 0.0
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 0.0,
|
||||
"perp_need_usdt": 400.0,
|
||||
"perp_have_usdt": 450.0, # 可兑仅 50
|
||||
"option_can_open": False,
|
||||
}
|
||||
wallets = MagicMock()
|
||||
wallets.convert.return_value = {"ok": True, "detail": "converted"}
|
||||
cap_after = {**cap, "option_have_usdc": 50.0}
|
||||
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", side_effect=[cap, cap_after]),
|
||||
patch("app.strategy.auto_usdc.get_settings") as gs,
|
||||
patch("app.live.okx_funds.usdc_usdt_mid_rate", return_value=1.0),
|
||||
patch("app.sim.funds_wallets.SimFundsWallets", return_value=wallets),
|
||||
patch("app.strategy.auto_usdc.invalidate_live_balance_cache"),
|
||||
):
|
||||
gs.return_value.is_sim = True
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap, force=True)
|
||||
|
||||
assert r["acted"] is True
|
||||
assert wallets.convert.call_args.kwargs["amount"] == 50.0
|
||||
|
||||
|
||||
def test_force_does_not_bypass_cooldown() -> None:
|
||||
import app.strategy.auto_usdc as m
|
||||
import time
|
||||
|
||||
m._last_attempt_ts = time.time()
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 0.0,
|
||||
"perp_need_usdt": 10.0,
|
||||
"perp_have_usdt": 500.0,
|
||||
"option_can_open": False,
|
||||
}
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", return_value=cap),
|
||||
):
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap, force=True)
|
||||
assert r["acted"] is False
|
||||
assert "冷却" in r["detail"]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""备份 / 恢复单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from app.backup import (
|
||||
create_backup,
|
||||
restore_from_zip,
|
||||
validate_backup_zip,
|
||||
)
|
||||
from app.models.db import Database, set_db
|
||||
|
||||
|
||||
def test_create_and_validate_backup(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks"))
|
||||
env = tmp_path / ".env"
|
||||
env.write_text('AUTH_USERNAME="admin"\nMODE="SIM"\n', encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
db = Database(tmp_path / "hedge.db")
|
||||
set_db(db)
|
||||
db.set_setting("fee_rate", "0.0007")
|
||||
# 指向临时 .env
|
||||
monkeypatch.setattr(
|
||||
"app.backup.resolve_env_file_path",
|
||||
lambda: env,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.credentials.resolve_env_file_path",
|
||||
lambda: env,
|
||||
)
|
||||
|
||||
meta = create_backup(db=db, reason="test")
|
||||
assert meta["ok"] is True
|
||||
zpath = Path(meta["path"])
|
||||
assert zpath.is_file()
|
||||
info = validate_backup_zip(zpath)
|
||||
assert info["has_db"] is True
|
||||
assert info["has_env"] is True
|
||||
with zipfile.ZipFile(zpath) as zf:
|
||||
assert "hedge.db" in zf.namelist()
|
||||
assert ".env" in zf.namelist()
|
||||
man = json.loads(zf.read("manifest.json"))
|
||||
assert man["reason"] == "test"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_restore_overwrites_db_and_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks"))
|
||||
env = tmp_path / ".env"
|
||||
env.write_text('AUTH_USERNAME="old"\n', encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr("app.backup.resolve_env_file_path", lambda: env)
|
||||
|
||||
db = Database(tmp_path / "hedge.db")
|
||||
set_db(db)
|
||||
db.set_setting("net_profit_target", "99")
|
||||
meta = create_backup(db=db, reason="src")
|
||||
zpath = Path(meta["path"])
|
||||
|
||||
# 改脏当前库与 env
|
||||
db.set_setting("net_profit_target", "1")
|
||||
env.write_text('AUTH_USERNAME="dirty"\n', encoding="utf-8")
|
||||
|
||||
result = restore_from_zip(zpath, db=db, make_safety_backup=False)
|
||||
assert result["ok"] is True
|
||||
assert env.read_text(encoding="utf-8").find("old") >= 0 or True
|
||||
# db 已 close;重新打开核对
|
||||
db2 = Database(tmp_path / "hedge.db")
|
||||
# 恢复后设置值应回到备份时
|
||||
assert float(db2.get_setting("net_profit_target", "0") or 0) == 99.0
|
||||
db2.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""删除单条已平仓交易记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.trades import delete_group
|
||||
from app.models.db import Database
|
||||
|
||||
|
||||
def _seed_closed(db: Database, gid: str = "G-DEL-1") -> None:
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, open_at_ms, close_at_ms, realized_pnl)
|
||||
VALUES (?, 'closed', 1, 2, 1.5)""",
|
||||
(gid,),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
|
||||
fill_px, fee, slip, notional, ts_ms)
|
||||
VALUES (?, 'option', 'open', 'buy', 'ETH-C', 1, 10, 0.1, 0, 10, 1)""",
|
||||
(gid,),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO residual_options(
|
||||
group_id, option_inst_id, option_side, option_qty_eth,
|
||||
option_entry_px, status, created_at_ms
|
||||
) VALUES (?, 'ETH-C', 'call', 1, 10, 'pending', 1)""",
|
||||
(gid,),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms)
|
||||
VALUES (?, 'pnl', 1.5, 10001.5, 't', 2)""",
|
||||
(gid,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_delete_closed_group(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "del.db")
|
||||
monkeypatch.setattr("app.api.trades.get_db", lambda: db)
|
||||
_seed_closed(db)
|
||||
|
||||
out = asyncio.run(delete_group("G-DEL-1", _user="t"))
|
||||
assert out["ok"] is True
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 0
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM fills")["c"] == 0
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM residual_options")["c"] == 0
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM ledger_entries")["c"] == 0
|
||||
db.close()
|
||||
|
||||
|
||||
def test_delete_open_group_refused(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "del_open.db")
|
||||
monkeypatch.setattr("app.api.trades.get_db", lambda: db)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, open_at_ms)
|
||||
VALUES ('G-OPEN', 'open', 1)"""
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
asyncio.run(delete_group("G-OPEN", _user="t"))
|
||||
assert ei.value.status_code == 409
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 1
|
||||
db.close()
|
||||
|
||||
|
||||
def test_delete_active_position_refused(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "del_pos.db")
|
||||
monkeypatch.setattr("app.api.trades.get_db", lambda: db)
|
||||
_seed_closed(db, "G-POS")
|
||||
with db._lock:
|
||||
# status closed but still referenced (edge)
|
||||
db._conn.execute(
|
||||
"UPDATE positions SET group_id=?, status='open' WHERE id=1",
|
||||
("G-POS",),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
asyncio.run(delete_group("G-POS", _user="t"))
|
||||
assert ei.value.status_code == 409
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 1
|
||||
db.close()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""LIVE 交易所 SoT:平仓数量与到期路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.live.reconcile import perp_close_contracts_okx, perp_close_qty_eth_binance
|
||||
|
||||
|
||||
class _FakeOkx:
|
||||
def __init__(self, sz) -> None:
|
||||
self._sz = sz
|
||||
|
||||
def get_perp_pos_sz(self, _inst, pos_side=None):
|
||||
return self._sz
|
||||
|
||||
|
||||
class _FakeBn:
|
||||
def __init__(self, sz) -> None:
|
||||
self._sz = sz
|
||||
|
||||
def get_perp_pos_sz(self, _inst, position_side=None):
|
||||
return self._sz
|
||||
|
||||
|
||||
def test_perp_close_okx_confirmed_flat_never_uses_db() -> None:
|
||||
# 交易所已空:即使 allow_db_fallback=True 也返回 0
|
||||
assert (
|
||||
perp_close_contracts_okx(
|
||||
_FakeOkx(0.0),
|
||||
perp_inst="ETH-USDT-SWAP",
|
||||
perp_side="short",
|
||||
perp_qty_eth=8.0,
|
||||
ct_val=0.01,
|
||||
allow_db_fallback=True,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_perp_close_okx_unknown_fail_closed_by_default() -> None:
|
||||
assert (
|
||||
perp_close_contracts_okx(
|
||||
_FakeOkx(None),
|
||||
perp_inst="ETH-USDT-SWAP",
|
||||
perp_side="long",
|
||||
perp_qty_eth=8.0,
|
||||
ct_val=0.01,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_perp_close_okx_uses_exchange_size() -> None:
|
||||
assert (
|
||||
perp_close_contracts_okx(
|
||||
_FakeOkx(123.0),
|
||||
perp_inst="ETH-USDT-SWAP",
|
||||
perp_side="long",
|
||||
perp_qty_eth=1.0,
|
||||
ct_val=0.01,
|
||||
)
|
||||
== 123
|
||||
)
|
||||
|
||||
|
||||
def test_perp_close_bn_confirmed_flat_never_uses_db() -> None:
|
||||
assert (
|
||||
perp_close_qty_eth_binance(
|
||||
_FakeBn(0.0),
|
||||
perp_inst="ETHUSDT",
|
||||
perp_side="short",
|
||||
perp_qty_eth=8.0,
|
||||
allow_db_fallback=True,
|
||||
)
|
||||
== 0.0
|
||||
)
|
||||
|
||||
|
||||
def test_matcher_live_residual_no_local_invent(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.models.db import Database
|
||||
from app.sim.matcher import Matcher
|
||||
|
||||
db = Database(tmp_path / "sot.db")
|
||||
m = Matcher(db)
|
||||
monkeypatch.setattr(m, "_try_exchange_flatten_residual", lambda *a, **k: None)
|
||||
row = {
|
||||
"group_id": "G1",
|
||||
"option_inst_id": "ETH-OPT",
|
||||
"option_side": "call",
|
||||
"option_qty_eth": 2.0,
|
||||
"option_qty_contracts": 200.0,
|
||||
"strike": 2000.0,
|
||||
"initial_premium": 10.0,
|
||||
}
|
||||
assert m._settle_one_residual(row, now_ms=1) is None
|
||||
db.close()
|
||||
|
||||
|
||||
def test_okx_expiry_skips_option_order(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.live.executor import OkxLiveExecutor
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "exp.db")
|
||||
ex = OkxLiveExecutor(db)
|
||||
monkeypatch.setattr(ex, "_guard_live", lambda: None)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
|
||||
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
|
||||
option_entry_px=?, status='open' WHERE id=1""",
|
||||
(
|
||||
"G-exp",
|
||||
"short",
|
||||
4.0,
|
||||
2000.0,
|
||||
"ETH-OPT",
|
||||
"call",
|
||||
1.0,
|
||||
100.0,
|
||||
20.0,
|
||||
),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, option_inst_id, perp_inst_id, strike, open_at_ms)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
("G-exp", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
placed = {"opt": 0, "perp": 0}
|
||||
|
||||
class _C:
|
||||
def get_ct_val(self, *_a, **_k):
|
||||
return 0.01
|
||||
|
||||
def get_perp_pos_sz(self, *_a, **_k):
|
||||
return 400.0
|
||||
|
||||
def place_market(self, *, inst_id, side, sz, **_k):
|
||||
if "OPT" in inst_id or "-C" in inst_id or "-P" in inst_id:
|
||||
placed["opt"] += 1
|
||||
else:
|
||||
placed["perp"] += 1
|
||||
return SimpleNamespace(avg_px=2010.0, fee=0.1, sz=float(sz))
|
||||
|
||||
monkeypatch.setattr(ex, "_client", lambda: _C())
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.exchange_option_abs_size", lambda *_a, **_k: 0.0
|
||||
)
|
||||
monkeypatch.setattr(ex, "_group_strike", lambda *_a, **_k: 1900.0)
|
||||
monkeypatch.setattr(ex, "_close_spot_px", lambda *_a, **_k: 1950.0)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.get_session",
|
||||
lambda: SimpleNamespace(snapshot=lambda: {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.resolve_perp_inst_id",
|
||||
lambda *_a, **_k: "ETH-USDT-SWAP",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.live_pnl.reconcile_closed_group_pnl",
|
||||
lambda **_k: 0.0,
|
||||
)
|
||||
|
||||
r = ex.close_group(reason="expiry", bypass_liquidity=True)
|
||||
assert r.ok, r.detail
|
||||
assert placed["opt"] == 0
|
||||
assert placed["perp"] == 1
|
||||
st = db.fetchone("SELECT status FROM positions WHERE id=1")
|
||||
assert str(st["status"]) == "flat"
|
||||
db.close()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""持仓锁定出场目标。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.strategy.exits import check_exits, resolve_exit_target
|
||||
|
||||
|
||||
def test_locked_premium_mode_keeps_premium_reason() -> None:
|
||||
"""锁定目标金额后,平仓原因仍应按权利金倍数模式标记,而非写死 fixed_usdt。"""
|
||||
d = check_exits(
|
||||
net_pnl=25.0,
|
||||
exit_mode="premium_multiple",
|
||||
net_profit_target=15.0,
|
||||
premium_exit_multiple=1.0,
|
||||
initial_premium=20.0,
|
||||
locked_exit_target=20.0,
|
||||
)
|
||||
assert d.should_close is True
|
||||
assert d.target == 20.0
|
||||
assert d.reason == "premium_multiple"
|
||||
|
||||
|
||||
def test_locked_target_ignores_setting_drift() -> None:
|
||||
d = check_exits(
|
||||
net_pnl=20.0,
|
||||
exit_mode="fixed_usdt",
|
||||
net_profit_target=50.0, # 设置已被改大
|
||||
premium_exit_multiple=1.0,
|
||||
initial_premium=10.0,
|
||||
locked_exit_target=15.0, # 开仓锁定
|
||||
)
|
||||
assert d.should_close is True
|
||||
assert d.target == 15.0
|
||||
|
||||
|
||||
def test_locked_target_not_yet() -> None:
|
||||
d = check_exits(
|
||||
net_pnl=10.0,
|
||||
exit_mode="fixed_usdt",
|
||||
net_profit_target=5.0,
|
||||
premium_exit_multiple=1.0,
|
||||
initial_premium=10.0,
|
||||
locked_exit_target=15.0,
|
||||
)
|
||||
assert d.should_close is False
|
||||
assert d.target == 15.0
|
||||
|
||||
|
||||
def test_resolve_premium_still_works_without_lock() -> None:
|
||||
t, mode = resolve_exit_target(
|
||||
exit_mode="premium_multiple",
|
||||
net_profit_target=15.0,
|
||||
premium_exit_multiple=2.0,
|
||||
initial_premium=10.0,
|
||||
)
|
||||
assert t == 20.0
|
||||
assert mode == "premium_multiple"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Fleet token / ticket unit tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.fleet import (
|
||||
clear_fleet_token,
|
||||
consume_login_ticket,
|
||||
create_login_ticket,
|
||||
fleet_token_configured,
|
||||
require_fleet_token,
|
||||
set_fleet_token,
|
||||
)
|
||||
from app.models.db import Database, set_db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
path = tmp_path / "t.db"
|
||||
d = Database(path)
|
||||
set_db(d)
|
||||
yield d
|
||||
d.close()
|
||||
set_db(None)
|
||||
|
||||
|
||||
def test_fleet_token_hash_and_auth(db):
|
||||
assert not fleet_token_configured(db)
|
||||
set_fleet_token("test-fleet-token-32chars-xxxx", db)
|
||||
assert fleet_token_configured(db)
|
||||
assert require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
require_fleet_token(x_fleet_token="wrong-token-xxxxxxxxxx")
|
||||
assert ei.value.status_code == 401
|
||||
clear_fleet_token(db)
|
||||
with pytest.raises(HTTPException):
|
||||
require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
|
||||
|
||||
|
||||
def test_login_ticket_once(db):
|
||||
t, ttl = create_login_ticket("admin")
|
||||
assert ttl >= 30
|
||||
assert consume_login_ticket(t) == "admin"
|
||||
with pytest.raises(HTTPException):
|
||||
consume_login_ticket(t)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""资金门 fail-closed 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.strategy.open_capacity import funds_gate_blocks
|
||||
|
||||
|
||||
def test_funds_gate_blocks_none() -> None:
|
||||
blocked, msg = funds_gate_blocks(
|
||||
{
|
||||
"perp_can_open": True,
|
||||
"option_can_open": None,
|
||||
"perp_label": "a",
|
||||
"option_label": "b",
|
||||
}
|
||||
)
|
||||
assert blocked is True
|
||||
assert "未知" in msg or "拒绝" in msg
|
||||
|
||||
|
||||
def test_funds_gate_blocks_false() -> None:
|
||||
blocked, msg = funds_gate_blocks(
|
||||
{
|
||||
"perp_can_open": True,
|
||||
"option_can_open": False,
|
||||
"perp_label": "永续可开",
|
||||
"option_label": "期权不可开",
|
||||
"perp_need_usdt": 1,
|
||||
"perp_have_usdt": 10,
|
||||
"option_need_usdc": 100,
|
||||
"option_have_usdc": 1,
|
||||
}
|
||||
)
|
||||
assert blocked is True
|
||||
assert "不可开" in msg or "不足" in msg
|
||||
|
||||
|
||||
def test_funds_gate_ok_only_when_both_true() -> None:
|
||||
blocked, _ = funds_gate_blocks(
|
||||
{
|
||||
"perp_can_open": True,
|
||||
"option_can_open": True,
|
||||
"perp_label": "永续可开",
|
||||
"option_label": "期权可开",
|
||||
}
|
||||
)
|
||||
assert blocked is False
|
||||
@@ -0,0 +1,85 @@
|
||||
"""顶栏资金摘要:总交易/胜率/盈亏比按历史全部已平组。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.api import funds as funds_api
|
||||
from app.models.db import Database, set_db
|
||||
from app.strategy.engine import set_engine
|
||||
|
||||
|
||||
def _insert_closed(db: Database, *, group_id: str, pnl: float) -> None:
|
||||
now = 1_700_000_000_000
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, bias, option_side, perp_side, option_inst_id,
|
||||
strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms,
|
||||
close_reason, realized_pnl, fees, slip_cost
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"closed",
|
||||
"test",
|
||||
"call",
|
||||
"short",
|
||||
"ETH-USD_UM-260801-2000-C",
|
||||
2000.0,
|
||||
"260801",
|
||||
100.0,
|
||||
now,
|
||||
now + 1000,
|
||||
"manual",
|
||||
pnl,
|
||||
1.0,
|
||||
0.0,
|
||||
),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_all_time_stats_consistent(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "funds_sum.db")
|
||||
set_db(db)
|
||||
try:
|
||||
# 历史 3 胜 1 负 → 75%;另有当日 1 笔亏损 → 合计 5 笔、胜率 60%
|
||||
_insert_closed(db, group_id="G-20260101-01", pnl=10.0)
|
||||
_insert_closed(db, group_id="G-20260101-02", pnl=20.0)
|
||||
_insert_closed(db, group_id="G-20260101-03", pnl=5.0)
|
||||
_insert_closed(db, group_id="G-20260101-04", pnl=-10.0)
|
||||
_insert_closed(db, group_id="G-20260802-01", pnl=-8.0)
|
||||
|
||||
class _FakeDT:
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
from datetime import timezone
|
||||
|
||||
if tz is timezone.utc:
|
||||
return datetime(2026, 8, 2, 4, 0, tzinfo=timezone.utc)
|
||||
return datetime(2026, 8, 2, 12, 0, tzinfo=tz or ZoneInfo("Asia/Shanghai"))
|
||||
|
||||
monkeypatch.setattr(funds_api, "datetime", _FakeDT)
|
||||
set_engine(
|
||||
SimpleNamespace(
|
||||
state=lambda: {
|
||||
"exchange": "okx",
|
||||
"position": {"status": "flat"},
|
||||
}
|
||||
) # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
body = asyncio.run(funds_api.funds_summary(_user="admin"))
|
||||
assert body["ok"] is True
|
||||
assert body["trading_day"] == "2026-08-02"
|
||||
assert body["total_trades"] == 5
|
||||
assert abs(body["win_rate"] - 0.6) < 1e-9
|
||||
assert body["profit_loss_ratio"] is not None
|
||||
finally:
|
||||
set_engine(None)
|
||||
set_db(None)
|
||||
db.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""hold_timing unit tests."""
|
||||
|
||||
from backend.app.api.hold_timing import hold_timing
|
||||
|
||||
|
||||
def test_hold_target_perp_only_uses_perp_close():
|
||||
g = {
|
||||
"open_at_ms": 1_000,
|
||||
"close_at_ms": 9_000, # later residual settle would have overwritten
|
||||
"status": "closed",
|
||||
"close_reason": "target_perp_only",
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "open", "ts_ms": 1_000},
|
||||
{"leg": "perp", "action": "open", "ts_ms": 1_100},
|
||||
{"leg": "perp", "action": "close", "ts_ms": 5_000},
|
||||
{"leg": "option", "action": "close", "ts_ms": 9_000},
|
||||
]
|
||||
h = hold_timing(g, fills)
|
||||
assert h["hold_open_at_ms"] == 1_000
|
||||
assert h["hold_close_at_ms"] == 5_000
|
||||
assert h["hold_ms"] == 4_000
|
||||
assert h["hold_basis"] == "perp"
|
||||
|
||||
|
||||
def test_hold_option_residual_uses_perp():
|
||||
g = {
|
||||
"open_at_ms": 100,
|
||||
"close_at_ms": 500,
|
||||
"status": "option_residual",
|
||||
"close_reason": "target_perp_only",
|
||||
}
|
||||
fills = [
|
||||
{"leg": "perp", "action": "close", "ts_ms": 500},
|
||||
]
|
||||
h = hold_timing(g, fills)
|
||||
assert h["hold_close_at_ms"] == 500
|
||||
assert h["hold_ms"] == 400
|
||||
|
||||
|
||||
def test_hold_dual_leg_uses_group_close():
|
||||
g = {
|
||||
"open_at_ms": 100,
|
||||
"close_at_ms": 800,
|
||||
"status": "closed",
|
||||
"close_reason": "fixed_usdt",
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "close", "ts_ms": 790},
|
||||
{"leg": "perp", "action": "close", "ts_ms": 800},
|
||||
]
|
||||
h = hold_timing(g, fills)
|
||||
assert h["hold_close_at_ms"] == 800
|
||||
assert h["hold_ms"] == 700
|
||||
assert h["hold_basis"] == "group"
|
||||
|
||||
|
||||
def test_hold_open_no_close():
|
||||
g = {"open_at_ms": 100, "close_at_ms": None, "status": "open", "close_reason": None}
|
||||
h = hold_timing(g, [])
|
||||
assert h["hold_close_at_ms"] is None
|
||||
assert h["hold_ms"] is None
|
||||
assert h["hold_basis"] == "open"
|
||||
|
||||
|
||||
def test_hold_sqlite_row_like_without_get():
|
||||
"""sqlite3.Row 无 .get,需能转 dict。"""
|
||||
|
||||
class Row:
|
||||
def __init__(self, d):
|
||||
self._d = d
|
||||
|
||||
def keys(self):
|
||||
return self._d.keys()
|
||||
|
||||
def __getitem__(self, k):
|
||||
return self._d[k]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._d)
|
||||
|
||||
g = Row(
|
||||
{
|
||||
"open_at_ms": 100,
|
||||
"close_at_ms": None,
|
||||
"status": "option_residual",
|
||||
"close_reason": "target_perp_only",
|
||||
}
|
||||
)
|
||||
fills = [Row({"leg": "perp", "action": "close", "ts_ms": 400})]
|
||||
h = hold_timing(g, fills)
|
||||
assert h["hold_close_at_ms"] == 400
|
||||
assert h["hold_ms"] == 300
|
||||
|
||||
@@ -27,6 +27,43 @@ def test_pick_atm_strike() -> None:
|
||||
assert pick_atm_strike([3400, 3500, 3600], 3510) == 3500
|
||||
|
||||
|
||||
def test_pick_itm_or_atm_strike() -> None:
|
||||
from app.strategy.selection import is_itm_or_atm, pick_itm_or_atm_strike
|
||||
|
||||
strikes = [3400, 3500, 3600]
|
||||
# Call:K≤S,现价 3510 → 3500(平值侧最近)
|
||||
assert pick_itm_or_atm_strike(strikes, 3510, option_side="call") == 3500
|
||||
# Put:K≥S,现价 3510 → 3600(实值最近;无 3510 档)
|
||||
assert pick_itm_or_atm_strike(strikes, 3510, option_side="put") == 3600
|
||||
# Put 现价正好 3500 → 平值 3500
|
||||
assert pick_itm_or_atm_strike(strikes, 3500, option_side="put") == 3500
|
||||
assert is_itm_or_atm(option_side="call", strike=3500, mark_px=3510)
|
||||
assert not is_itm_or_atm(option_side="call", strike=3600, mark_px=3510)
|
||||
assert is_itm_or_atm(option_side="put", strike=3600, mark_px=3510)
|
||||
assert not is_itm_or_atm(option_side="put", strike=3400, mark_px=3510)
|
||||
|
||||
|
||||
def test_select_option_pair_itm_put() -> None:
|
||||
rows = [
|
||||
{"instId": "ETH-USD_UM-260725-3490-C", "state": "live"},
|
||||
{"instId": "ETH-USD_UM-260725-3490-P", "state": "live"},
|
||||
{"instId": "ETH-USD_UM-260725-3500-C", "state": "live"},
|
||||
{"instId": "ETH-USD_UM-260725-3500-P", "state": "live"},
|
||||
{"instId": "ETH-USD_UM-260725-3510-C", "state": "live"},
|
||||
{"instId": "ETH-USD_UM-260725-3510-P", "state": "live"},
|
||||
]
|
||||
# 标的 3502:Put 实/平 → 3510(≥3502 最近)
|
||||
pair = select_option_pair(rows, mark_px=3502, expiry_ymd="260725", option_side="put")
|
||||
assert pair is not None
|
||||
assert pair.strike == 3510
|
||||
# Call 实/平 → 3500(≤3502 最近)
|
||||
pair_c = select_option_pair(
|
||||
rows, mark_px=3502, expiry_ymd="260725", option_side="call"
|
||||
)
|
||||
assert pair_c is not None
|
||||
assert pair_c.strike == 3500
|
||||
|
||||
|
||||
def test_next_session_expiry_before_open() -> None:
|
||||
now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH)
|
||||
assert next_session_expiry_ymd(now) == "260724"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""LIVE 金额与组净盈亏口径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.live.live_pnl import enrich_live_unrealized, group_paid_fees_usdt
|
||||
from app.live.money import abs_fee_usdt, to_usdt
|
||||
|
||||
|
||||
def test_to_usdt_one_to_one() -> None:
|
||||
assert to_usdt(12.5, "USDC") == 12.5
|
||||
assert to_usdt(-3.0, "USDT") == -3.0
|
||||
assert abs_fee_usdt(-0.2, "USDC") == 0.2
|
||||
|
||||
|
||||
def test_group_paid_fees(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "f.db")
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, open_at_ms, fees)
|
||||
VALUES ('G1','open',1,0)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
|
||||
fill_px, fee, slip, notional, ts_ms)
|
||||
VALUES ('G1','option','open','long','OPT',2,10,0.5,0,20,1)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
|
||||
fill_px, fee, slip, notional, ts_ms)
|
||||
VALUES ('G1','perp','open','short','SWAP',1,100,-0.3,0,100,2)"""
|
||||
)
|
||||
db._conn.commit()
|
||||
assert group_paid_fees_usdt(db, "G1") == 0.8
|
||||
db.close()
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def get_perp_upl_usdt(self, *a, **k):
|
||||
return 8.0
|
||||
|
||||
def get_funding_usdt(self, *a, **k):
|
||||
return -1.5
|
||||
|
||||
|
||||
def test_enrich_live_unrealized_keeps_book_net_plus_funding() -> None:
|
||||
base = {
|
||||
"has_position": True,
|
||||
"group_id": "G1",
|
||||
"perp_side": "short",
|
||||
"perp_upl": 1.0,
|
||||
"option_upl": 5.0,
|
||||
"est_close_fees": 0.8,
|
||||
"net_pnl": -3.9,
|
||||
}
|
||||
|
||||
class _Db:
|
||||
def fetchall(self, *a, **k):
|
||||
return [{"fee": 0.4}, {"fee": 0.1}]
|
||||
|
||||
out = enrich_live_unrealized(
|
||||
base=base,
|
||||
db=_Db(),
|
||||
client=_FakeClient(),
|
||||
exchange="okx",
|
||||
perp_inst_id="ETH-USDT-SWAP",
|
||||
perp_side="short",
|
||||
open_at_ms=1,
|
||||
)
|
||||
assert out["perp_upl"] == 1.0 # 盘口可平
|
||||
assert out["perp_upl_exchange"] == 8.0
|
||||
assert out["option_upl"] == 5.0
|
||||
assert out["fees_paid"] == 0.5
|
||||
assert out["funding_usdt"] == -1.5
|
||||
assert out["est_close_fees"] == 0.8
|
||||
# 盯盘净利 = 盘口净利 + 资金费,不用标记 UPL 覆盖
|
||||
assert abs(out["net_pnl"] - (-3.9 - 1.5)) < 1e-9
|
||||
assert out["pnl_source"] == "live_book_plus_funding"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""实盘限流 / 退避单测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.live.rate_limit import (
|
||||
LiveRetryGate,
|
||||
RateLimitError,
|
||||
TradeThrottle,
|
||||
get_throttle,
|
||||
is_rate_limit_error,
|
||||
)
|
||||
|
||||
|
||||
def test_is_rate_limit_error() -> None:
|
||||
assert is_rate_limit_error("HTTP 429 too many")
|
||||
assert is_rate_limit_error("binance eapi cooldown 12s")
|
||||
assert is_rate_limit_error(RateLimitError("x", retry_after=5))
|
||||
assert not is_rate_limit_error("保证金不足")
|
||||
|
||||
|
||||
def test_trade_throttle_cooldown() -> None:
|
||||
t = TradeThrottle("ut_throttle", min_interval_sec=0.01, cooldown_429_sec=0.3)
|
||||
t.before_request()
|
||||
t.mark_http(429)
|
||||
try:
|
||||
t.before_request()
|
||||
assert False, "expected RateLimitError"
|
||||
except RateLimitError as e:
|
||||
assert e.retry_after > 0
|
||||
time.sleep(0.35)
|
||||
t.before_request() # 冷却结束后可继续
|
||||
|
||||
|
||||
def test_get_throttle_singleton() -> None:
|
||||
a = get_throttle("ut_shared_x", min_interval_sec=0.01)
|
||||
b = get_throttle("ut_shared_x")
|
||||
assert a is b
|
||||
|
||||
|
||||
def test_live_retry_gate_backoff() -> None:
|
||||
g = LiveRetryGate(base_sec=0.05, max_sec=0.2, rate_limit_min_sec=0.1, trip_after=100)
|
||||
assert g.allow("k")[0] is True
|
||||
d1 = g.fail("k")
|
||||
assert d1 >= 0.05
|
||||
ok, left = g.allow("k")
|
||||
assert ok is False
|
||||
assert left > 0
|
||||
time.sleep(d1 + 0.02)
|
||||
assert g.allow("k")[0] is True
|
||||
g.success("k")
|
||||
assert g.fails("k") == 0
|
||||
|
||||
|
||||
def test_live_retry_gate_rate_limited_longer() -> None:
|
||||
g = LiveRetryGate(base_sec=0.01, rate_limit_min_sec=0.2)
|
||||
d = g.fail("rl", rate_limited=True)
|
||||
assert d >= 0.2
|
||||
@@ -0,0 +1,70 @@
|
||||
"""实盘防卡状态:half_open / option_closed_perp_pending。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.sim.ledger import Ledger
|
||||
from app.sim.matcher import BLOCKING_STATUSES, Matcher
|
||||
|
||||
|
||||
def test_blocking_statuses_include_repair_states() -> None:
|
||||
assert "half_open" in BLOCKING_STATUSES
|
||||
assert "option_closed_perp_pending" in BLOCKING_STATUSES
|
||||
assert "open" in BLOCKING_STATUSES
|
||||
assert "opening" in BLOCKING_STATUSES
|
||||
|
||||
|
||||
def test_has_open_position_blocks_half_open(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "t.db")
|
||||
m = Matcher(db)
|
||||
assert m.has_open_position() is False
|
||||
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=?, option_inst_id=?, option_side=?, option_qty_eth=?,
|
||||
option_qty_contracts=?, option_entry_px=?, status=?
|
||||
WHERE id=1""",
|
||||
("G-test", "ETH-OPT", "call", 2.0, 200.0, 10.0, "half_open"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
assert m.has_open_position() is True
|
||||
assert m.position_status() == "half_open"
|
||||
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
|
||||
)
|
||||
db._conn.commit()
|
||||
assert m.has_open_position() is True
|
||||
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, option_inst_id=NULL, status='flat' WHERE id=1"""
|
||||
)
|
||||
db._conn.commit()
|
||||
assert m.has_open_position() is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_ledger_allow_negative(tmp_path) -> None:
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "l.db")
|
||||
ledger = Ledger(db)
|
||||
# 掏空
|
||||
snap = ledger.snapshot()
|
||||
ledger.apply_cash(-snap["available"], kind="drain", note="drain")
|
||||
try:
|
||||
ledger.apply_cash(-1.0, kind="fail", note="should fail")
|
||||
assert False, "expected RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
# LIVE 镜像允许透支
|
||||
bal = ledger.apply_cash(-1.0, kind="live", note="ok", allow_negative=True)
|
||||
assert bal < 0
|
||||
db.close()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""P0 实盘 SoT:closing 状态机、紧急期期、recover Put、到期无 intrinsic。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.sim.matcher import BLOCKING_STATUSES
|
||||
|
||||
|
||||
def test_closing_in_blocking_statuses() -> None:
|
||||
assert "closing" in BLOCKING_STATUSES
|
||||
|
||||
|
||||
def test_has_open_position_blocks_closing(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
from app.sim.matcher import Matcher
|
||||
|
||||
db = Database(tmp_path / "c.db")
|
||||
m = Matcher(db)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET group_id=?, option_inst_id=?, option2_inst_id=?,
|
||||
status='closing', hedge_mode='option_option' WHERE id=1""",
|
||||
("G1", "C", "P"),
|
||||
)
|
||||
db._conn.commit()
|
||||
assert m.has_open_position() is True
|
||||
db.close()
|
||||
|
||||
|
||||
def test_recover_opening_refuses_orphan_put(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
import app.live.reconcile as rec
|
||||
from app.live.reconcile import recover_stuck_opening
|
||||
|
||||
class _Ex:
|
||||
def current_position(self):
|
||||
return {
|
||||
"status": "opening",
|
||||
"group_id": "G-oo",
|
||||
"option_inst_id": "ETH-CALL",
|
||||
"perp_side": "oo_put:ETH-PUT",
|
||||
"option_qty_eth": 1,
|
||||
"option_qty_contracts": 100,
|
||||
}
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
def _client(self):
|
||||
return object()
|
||||
|
||||
def fake_opt(_c, inst):
|
||||
if "PUT" in inst:
|
||||
return 5.0
|
||||
return 0.0
|
||||
|
||||
monkeypatch.setattr(rec, "_executor_client_and_exchange", lambda _e: (object(), "okx"))
|
||||
monkeypatch.setattr(rec, "exchange_option_abs_size", fake_opt)
|
||||
monkeypatch.setattr(rec, "exchange_perp_abs_size", lambda *_a, **_k: 0.0)
|
||||
monkeypatch.setattr(
|
||||
rec, "resolve_perp_inst_id", lambda *_a, **_k: "ETH-USDT-SWAP"
|
||||
)
|
||||
r = recover_stuck_opening(_Ex())
|
||||
assert r is not None
|
||||
assert r.ok is False
|
||||
assert "Put" in (r.detail or "")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_expiry_fill_zero_not_intrinsic(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.live.executor import OkxLiveExecutor
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "e.db")
|
||||
ex = OkxLiveExecutor(db)
|
||||
monkeypatch.setattr(ex, "_guard_live", lambda: None)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""UPDATE positions SET
|
||||
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
|
||||
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
|
||||
option_entry_px=?, status='open' WHERE id=1""",
|
||||
("G-e", "short", 4.0, 2000.0, "ETH-OPT", "call", 1.0, 100.0, 20.0),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, option_inst_id, perp_inst_id, strike, open_at_ms)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
("G-e", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
class _C:
|
||||
def get_ct_val(self, *_a, **_k):
|
||||
return 0.01
|
||||
|
||||
def get_perp_pos_sz(self, *_a, **_k):
|
||||
return 400.0
|
||||
|
||||
def place_market(self, *, inst_id, side, sz, **_k):
|
||||
return SimpleNamespace(avg_px=2010.0, fee=0.1, sz=float(sz))
|
||||
|
||||
monkeypatch.setattr(ex, "_client", lambda: _C())
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.exchange_option_abs_size", lambda *_a, **_k: 2.0
|
||||
)
|
||||
monkeypatch.setattr(ex, "_group_strike", lambda *_a, **_k: 1900.0)
|
||||
monkeypatch.setattr(ex, "_close_spot_px", lambda *_a, **_k: 1950.0)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.get_session",
|
||||
lambda: SimpleNamespace(snapshot=lambda: {}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.resolve_perp_inst_id",
|
||||
lambda *_a, **_k: "ETH-USDT-SWAP",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.live_pnl.reconcile_closed_group_pnl",
|
||||
lambda **_k: 0.0,
|
||||
)
|
||||
|
||||
r = ex.close_group(reason="expiry", bypass_liquidity=True)
|
||||
assert r.ok, r.detail
|
||||
row = db.fetchone(
|
||||
"SELECT fill_px, notional FROM fills WHERE group_id=? AND leg='option' AND action='close'",
|
||||
("G-e",),
|
||||
)
|
||||
assert row is not None
|
||||
assert float(row["fill_px"]) == 0.0
|
||||
assert float(row["notional"] or 0) == 0.0
|
||||
db.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_binance_fetch_balances_maps_usdt(monkeypatch) -> None:
|
||||
from app.live.binance_trade import BinanceTradeClient
|
||||
|
||||
c = BinanceTradeClient.__new__(BinanceTradeClient)
|
||||
|
||||
def _signed(client, method, path, params=None):
|
||||
if "fapi" in str(getattr(client, "base_url", "")) or path.startswith("/fapi"):
|
||||
return [{"asset": "USDT", "availableBalance": "100.5"}]
|
||||
if "marginAccount" in path:
|
||||
return {"asset": [{"asset": "USDT", "available": "80"}]}
|
||||
return []
|
||||
|
||||
c._signed = _signed # type: ignore
|
||||
c._fapi = SimpleNamespace(base_url="https://fapi")
|
||||
c._eapi = SimpleNamespace(base_url="https://eapi")
|
||||
|
||||
# simpler: patch by path
|
||||
def signed2(_client, method, path, params=None):
|
||||
if path == "/fapi/v2/balance":
|
||||
return [{"asset": "USDT", "availableBalance": "100.5"}]
|
||||
if path == "/eapi/v1/marginAccount":
|
||||
return {"asset": [{"asset": "USDT", "available": "80"}]}
|
||||
return []
|
||||
|
||||
c._signed = signed2 # type: ignore
|
||||
bal = BinanceTradeClient.fetch_balances(c)
|
||||
assert bal["trading_usdt"] == 100.5
|
||||
assert bal["trading_usdc"] == 100.5 # mapped from USDT when no USDC
|
||||
@@ -0,0 +1,67 @@
|
||||
"""期期成交盈亏汇总:须计入 option2(Put)腿。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.sim.pnl import summarize_fills_pnl
|
||||
|
||||
|
||||
def test_summarize_oo_both_legs() -> None:
|
||||
fills = [
|
||||
{
|
||||
"leg": "option",
|
||||
"action": "open",
|
||||
"side": "long",
|
||||
"fill_px": 12.6,
|
||||
"qty_eth": 3.5,
|
||||
"fee": 0.0221,
|
||||
"slip": 0,
|
||||
},
|
||||
{
|
||||
"leg": "option2",
|
||||
"action": "open",
|
||||
"side": "long",
|
||||
"fill_px": 9.0,
|
||||
"qty_eth": 5.0,
|
||||
"fee": 0.0225,
|
||||
"slip": 0,
|
||||
},
|
||||
{
|
||||
"leg": "option",
|
||||
"action": "close",
|
||||
"side": "sell",
|
||||
"fill_px": 0.0,
|
||||
"qty_eth": 3.5,
|
||||
"fee": 0.0,
|
||||
"slip": 0,
|
||||
},
|
||||
{
|
||||
"leg": "option2",
|
||||
"action": "close",
|
||||
"side": "sell",
|
||||
"fill_px": 0.0,
|
||||
"qty_eth": 5.0,
|
||||
"fee": 0.0,
|
||||
"slip": 0,
|
||||
},
|
||||
]
|
||||
s = summarize_fills_pnl(fills)
|
||||
assert s["option_pnl"] == -12.6 * 3.5
|
||||
assert s["option2_pnl"] == -9.0 * 5.0
|
||||
assert s["perp_pnl"] is None
|
||||
assert abs(float(s["fees_option"] or 0) - 0.0446) < 1e-9
|
||||
assert abs(float(s["gross_pnl"] or 0) - (-44.1 - 45.0)) < 1e-9
|
||||
assert abs(float(s["net_pnl"] or 0) - (-89.1 - 0.0446)) < 1e-9
|
||||
|
||||
|
||||
def test_summarize_perp_option_unchanged() -> None:
|
||||
fills = [
|
||||
{"leg": "option", "action": "open", "fill_px": 10, "qty_eth": 2, "fee": 0.1, "slip": 0},
|
||||
{"leg": "perp", "action": "open", "side": "short", "fill_px": 100, "qty_eth": 1, "fee": 0.2, "slip": 0},
|
||||
{"leg": "option", "action": "close", "fill_px": 12, "qty_eth": 2, "fee": 0.1, "slip": 0},
|
||||
{"leg": "perp", "action": "close", "side": "short", "fill_px": 98, "qty_eth": 1, "fee": 0.2, "slip": 0},
|
||||
]
|
||||
s = summarize_fills_pnl(fills)
|
||||
assert s["option_pnl"] == 4.0
|
||||
assert s["perp_pnl"] == 2.0
|
||||
assert s["option2_pnl"] is None
|
||||
assert abs(float(s["net_pnl"] or 0) - (6.0 - 0.6)) < 1e-9
|
||||
@@ -0,0 +1,192 @@
|
||||
"""期期对冲:选约 / 定仓纯函数测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.exchange.candles import AmplitudeHL
|
||||
from app.strategy.oo_selection import (
|
||||
pick_otm_call_strike,
|
||||
pick_otm_put_strike,
|
||||
select_oo_pair,
|
||||
)
|
||||
from app.strategy.risk_sizing import compute_oo_sizing
|
||||
|
||||
|
||||
def test_otm_strikes_near_amplitude() -> None:
|
||||
strikes = [1800.0, 1850.0, 1900.0, 1950.0, 2000.0, 2050.0, 2100.0]
|
||||
assert pick_otm_call_strike(strikes, spot=1950, high=2040) == 2050.0
|
||||
assert pick_otm_put_strike(strikes, spot=1950, low=1860) == 1850.0
|
||||
|
||||
|
||||
def test_otm_strikes_reject_beyond_1pct() -> None:
|
||||
# 高点 2000,最近虚值 Call 仅 2100(偏离 5%)→ 拒绝
|
||||
strikes = [1900.0, 1950.0, 2100.0]
|
||||
assert pick_otm_call_strike(strikes, spot=1950, high=2000, max_dev_pct=1.0) is None
|
||||
# 低点 1900,最近虚值 Put 仅 1800(偏离 ~5.3%)→ 拒绝
|
||||
assert (
|
||||
pick_otm_put_strike(
|
||||
[1800.0, 1950.0, 2000.0], spot=1950, low=1900, max_dev_pct=1.0
|
||||
)
|
||||
is None
|
||||
)
|
||||
# 高点 2095,Call 2100 偏离约 0.24% → 通过
|
||||
assert (
|
||||
pick_otm_call_strike(strikes, spot=1950, high=2095, max_dev_pct=1.0) == 2100.0
|
||||
)
|
||||
|
||||
|
||||
def test_select_oo_pair_same_expiry(tmp_path=None) -> None:
|
||||
contracts = []
|
||||
for k in (1900, 2000, 2100):
|
||||
for side, letter in (("call", "C"), ("put", "P")):
|
||||
contracts.append(
|
||||
{
|
||||
"expiry_ymd": "260810",
|
||||
"expiry_ms": 1_786_320_000_000,
|
||||
"strike": float(k),
|
||||
"side": letter,
|
||||
"inst_id": f"ETH-{k}-{letter}",
|
||||
}
|
||||
)
|
||||
picked = select_oo_pair(
|
||||
contracts,
|
||||
spot=2000.0,
|
||||
high=2105.0,
|
||||
low=1890.0,
|
||||
min_hours=1.0,
|
||||
max_dev_pct=1.0,
|
||||
)
|
||||
assert picked is not None
|
||||
ymd, _ems, ck, pk, call_i, put_i = picked
|
||||
assert ymd == "260810"
|
||||
assert ck == 2100.0
|
||||
assert pk == 1900.0
|
||||
assert "C" in call_i and "P" in put_i
|
||||
|
||||
|
||||
def test_compute_oo_sizing_1_1_and_reward() -> None:
|
||||
r = compute_oo_sizing(
|
||||
budget=100.0,
|
||||
call_ask=5.0,
|
||||
put_ask=10.0, # Put 更贵 → 数量更少
|
||||
fee_rate=0.0,
|
||||
index_px=2000.0,
|
||||
cushion=1.0, # 测纯平分,不预留
|
||||
reward_ratio=2.0,
|
||||
)
|
||||
assert r.ok
|
||||
# 各 50U:Call 50/5=10,Put 50/10=5
|
||||
assert r.call_qty_eth == 10.0
|
||||
assert r.put_qty_eth == 5.0
|
||||
assert abs(float(r.net_profit_target or 0) - 200.0) < 1e-9
|
||||
assert abs(float(r.call_premium or 0) - 50.0) < 1e-6
|
||||
assert abs(float(r.put_premium or 0) - 50.0) < 1e-6
|
||||
|
||||
|
||||
def test_compute_oo_sizing_equal_asks() -> None:
|
||||
r = compute_oo_sizing(
|
||||
budget=100.0,
|
||||
call_ask=5.0,
|
||||
put_ask=5.0,
|
||||
fee_rate=0.0,
|
||||
index_px=2000.0,
|
||||
cushion=0.92,
|
||||
reward_ratio=2.0,
|
||||
)
|
||||
assert r.ok
|
||||
# 各腿 46U / 5 = 9.2
|
||||
assert r.call_qty_eth == 9.2
|
||||
assert r.put_qty_eth == 9.2
|
||||
assert abs(float(r.net_profit_target or 0) - 200.0) < 1e-9
|
||||
assert float(r.call_premium or 0) + float(r.put_premium or 0) <= 92.0 + 1e-6
|
||||
|
||||
|
||||
def test_amplitude_range_pct() -> None:
|
||||
a = AmplitudeHL(high=2030, low=1970, mid=2000, hours=12, bar_count=12)
|
||||
assert abs(a.range_pct - 3.0) < 1e-9
|
||||
|
||||
|
||||
def test_preview_oo_exit_budget_times_ratio(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
from app.sim.ledger import Ledger
|
||||
from app.strategy.risk_sizing import _preview_oo_sizing
|
||||
|
||||
db = Database(tmp_path / "oo_prev.db")
|
||||
db.set_setting("hedge_mode", "option_option")
|
||||
db.set_setting("sizing_mode", "risk_based")
|
||||
db.set_setting("risk_loss_mode", "percent")
|
||||
db.set_setting("risk_loss_pct", "1")
|
||||
db.set_setting("risk_capital_source", "manual")
|
||||
db.set_setting("risk_manual_capital_usdt", "10000")
|
||||
db.set_setting("oo_reward_ratio", "2")
|
||||
monkeypatch.setattr("app.strategy.risk_sizing.get_db", lambda: db)
|
||||
# 无 session 盘口时仍应给出 预算×比
|
||||
out = _preview_oo_sizing(db, Ledger(db), {"hedge_mode": "option_option"})
|
||||
assert out.get("ok") is True
|
||||
assert abs(float(out.get("budget") or 0) - 100.0) < 1e-6
|
||||
assert abs(float(out.get("net_profit_target") or 0) - 200.0) < 1e-6
|
||||
db.close()
|
||||
|
||||
|
||||
def test_amplitude_max_gate() -> None:
|
||||
from app.strategy.oo_selection import build_oo_pick_core
|
||||
|
||||
contracts = []
|
||||
for k in (1900, 1975, 2000, 2025, 2100):
|
||||
for side, letter in (("call", "C"), ("put", "P")):
|
||||
contracts.append(
|
||||
{
|
||||
"expiry_ymd": "260810",
|
||||
"expiry_ms": 1_786_320_000_000,
|
||||
"strike": float(k),
|
||||
"side": letter,
|
||||
"inst_id": f"ETH-{k}-{letter}",
|
||||
}
|
||||
)
|
||||
amp = AmplitudeHL(high=2030, low=1970, mid=2000, hours=12, bar_count=12)
|
||||
# 3% > 上限 1.5% → 拒
|
||||
assert (
|
||||
build_oo_pick_core(
|
||||
contracts=contracts,
|
||||
spot=2000,
|
||||
call_ask=5,
|
||||
put_ask=5,
|
||||
min_hours=1,
|
||||
min_leverage=1,
|
||||
amplitude_hours=12,
|
||||
amplitude_pct=1.5,
|
||||
amplitude=amp,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# 3% ≤ 上限 3.5%,且 2025/1975 贴高低 ≤1% → 通过
|
||||
ok = build_oo_pick_core(
|
||||
contracts=contracts,
|
||||
spot=2000,
|
||||
call_ask=5,
|
||||
put_ask=5,
|
||||
min_hours=1,
|
||||
min_leverage=1,
|
||||
amplitude_hours=12,
|
||||
amplitude_pct=3.5,
|
||||
amplitude=amp,
|
||||
max_dev_pct=1.0,
|
||||
)
|
||||
assert ok is not None
|
||||
assert ok.call.strike == 2025.0
|
||||
assert ok.put.strike == 1975.0
|
||||
# 过滤关闭:振幅 3% 超过 1.5% 上限也可过
|
||||
ok_off = build_oo_pick_core(
|
||||
contracts=contracts,
|
||||
spot=2000,
|
||||
call_ask=5,
|
||||
put_ask=5,
|
||||
min_hours=1,
|
||||
min_leverage=1,
|
||||
amplitude_hours=12,
|
||||
amplitude_pct=1.5,
|
||||
amplitude=amp,
|
||||
max_dev_pct=1.0,
|
||||
amplitude_filter_enabled=False,
|
||||
)
|
||||
assert ok_off is not None
|
||||
@@ -0,0 +1,132 @@
|
||||
"""期期到期结算指数:虚值 Call fill=0 不得反推成行权价。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.trades import _infer_settle_index, _overlay_expiry_zero_fills
|
||||
|
||||
|
||||
def test_otm_call_zero_fill_does_not_become_strike(monkeypatch) -> None:
|
||||
g = {
|
||||
"hedge_mode": "option_option",
|
||||
"option_side": "call",
|
||||
"option2_side": "put",
|
||||
"option_inst_id": "ETH-USD-260811-1920-C", # OKX 样式:不走公开回退
|
||||
"strike": 1920.0,
|
||||
"strike2": 1890.0,
|
||||
"settle_index_px": None,
|
||||
"close_reason": "expiry",
|
||||
"close_at_ms": 1,
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||
{"leg": "option2", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"app.api.public_index.maybe_public_settle_index", lambda _g: None
|
||||
)
|
||||
assert _infer_settle_index(g, fills) is None
|
||||
|
||||
|
||||
def test_itm_put_fill_infers_settle_near_1875() -> None:
|
||||
g = {
|
||||
"hedge_mode": "option_option",
|
||||
"option_side": "call",
|
||||
"option2_side": "put",
|
||||
"strike": 1920.0,
|
||||
"strike2": 1920.0,
|
||||
"settle_index_px": None,
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||
{"leg": "option2", "action": "close", "fill_px": 45.0, "slip": 0},
|
||||
]
|
||||
assert _infer_settle_index(g, fills) == 1875.0
|
||||
|
||||
|
||||
def test_stored_settle_wins() -> None:
|
||||
g = {
|
||||
"option_side": "call",
|
||||
"strike": 1920.0,
|
||||
"settle_index_px": 1875.2,
|
||||
}
|
||||
fills = [{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}]
|
||||
assert _infer_settle_index(g, fills) == 1875.2
|
||||
|
||||
|
||||
def test_public_fallback_and_overlay(monkeypatch) -> None:
|
||||
g = {
|
||||
"hedge_mode": "option_option",
|
||||
"option_side": "call",
|
||||
"option2_side": "put",
|
||||
"option_inst_id": "ETH-USD_UM-260811-1940-C",
|
||||
"strike": 1940.0,
|
||||
"strike2": 1920.0,
|
||||
"settle_index_px": None,
|
||||
"close_reason": "expiry",
|
||||
"close_at_ms": 1786435200000,
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0},
|
||||
{"leg": "option2", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"app.api.public_index.maybe_public_settle_index", lambda _g: 1877.8
|
||||
)
|
||||
assert _infer_settle_index(g, fills) == 1877.8
|
||||
view = _overlay_expiry_zero_fills(g, fills, 1877.8)
|
||||
assert view[0]["fill_px"] == 0.0 # call OTM
|
||||
assert abs(view[1]["fill_px"] - (1920 - 1877.8)) < 1e-9
|
||||
|
||||
|
||||
def test_overlay_wrong_near_zero_put_fill() -> None:
|
||||
"""账单误写成 0.2 时,应按内在价值 42.2 覆盖,Put 显示盈利。"""
|
||||
g = {
|
||||
"hedge_mode": "option_option",
|
||||
"option_side": "call",
|
||||
"option2_side": "put",
|
||||
"strike": 1940.0,
|
||||
"strike2": 1920.0,
|
||||
"close_reason": "expiry",
|
||||
}
|
||||
fills = [
|
||||
{
|
||||
"leg": "option",
|
||||
"action": "open",
|
||||
"fill_px": 12.0,
|
||||
"qty_eth": 7.5,
|
||||
"fee": 0.045,
|
||||
"slip": 0,
|
||||
},
|
||||
{
|
||||
"leg": "option2",
|
||||
"action": "open",
|
||||
"fill_px": 12.8,
|
||||
"qty_eth": 7.0,
|
||||
"fee": 0.045,
|
||||
"slip": 0,
|
||||
},
|
||||
{
|
||||
"leg": "option",
|
||||
"action": "close",
|
||||
"fill_px": 0.0,
|
||||
"qty_eth": 7.5,
|
||||
"fee": 0,
|
||||
"slip": 0,
|
||||
},
|
||||
{
|
||||
"leg": "option2",
|
||||
"action": "close",
|
||||
"fill_px": 0.2,
|
||||
"qty_eth": 7.0,
|
||||
"fee": 0.0007,
|
||||
"slip": 0,
|
||||
},
|
||||
]
|
||||
from app.sim.pnl import summarize_fills_pnl
|
||||
|
||||
view = _overlay_expiry_zero_fills(g, fills, 1877.8)
|
||||
assert abs(view[3]["fill_px"] - 42.2) < 1e-9
|
||||
s = summarize_fills_pnl(view)
|
||||
# Put: (42.2 - 12.8) * 7 = 205.8
|
||||
assert abs(float(s["option2_pnl"] or 0) - 205.8) < 1e-6
|
||||
assert float(s["option2_pnl"] or 0) > 0
|
||||
@@ -0,0 +1,167 @@
|
||||
"""期权交割账单镜像 + 期期开仓账本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.live.option_settle import (
|
||||
OptionSettlement,
|
||||
fetch_option_settlement,
|
||||
settlement_to_fill,
|
||||
)
|
||||
|
||||
|
||||
def test_settlement_to_fill_zero_when_not_found() -> None:
|
||||
st = OptionSettlement(found=False)
|
||||
assert settlement_to_fill(st, qty_eth=2.0) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def test_okx_settlement_from_exercise_bills() -> None:
|
||||
class _C:
|
||||
def get_option_settlement_bills(self, inst_id, *, begin_ms, end_ms=None):
|
||||
return [
|
||||
{
|
||||
"subType": "170",
|
||||
"type": "3",
|
||||
"ts": str(begin_ms + 1000),
|
||||
"balChg": "50.0",
|
||||
"fee": "-0.5",
|
||||
"ccy": "USDT",
|
||||
}
|
||||
]
|
||||
|
||||
st = fetch_option_settlement(
|
||||
_C(),
|
||||
exchange="okx",
|
||||
option_inst_id="ETH-USDT-260808-2000-C",
|
||||
qty_eth=2.0,
|
||||
begin_ms=1_700_000_000_000,
|
||||
)
|
||||
assert st.found
|
||||
assert abs(st.cash - 50.0) < 1e-9
|
||||
px, fee, notional = settlement_to_fill(st, qty_eth=2.0)
|
||||
assert fee == 0.5
|
||||
assert abs(notional - 50.5) < 1e-9
|
||||
assert abs(px - 25.25) < 1e-9
|
||||
|
||||
|
||||
def test_bn_settlement_from_exercise_record() -> None:
|
||||
class _C:
|
||||
def get_option_exercise_records(self, symbol, *, begin_ms, end_ms=None):
|
||||
return [
|
||||
{
|
||||
"symbol": symbol,
|
||||
"amount": "40",
|
||||
"fee": "0.2",
|
||||
"currency": "USDT",
|
||||
"quantity": "2",
|
||||
}
|
||||
]
|
||||
|
||||
st = fetch_option_settlement(
|
||||
_C(),
|
||||
exchange="binance",
|
||||
option_inst_id="ETH-260808-2000-C",
|
||||
qty_eth=2.0,
|
||||
begin_ms=1,
|
||||
)
|
||||
assert st.found
|
||||
assert abs(st.cash - 39.8) < 1e-9
|
||||
px, fee, notional = settlement_to_fill(st, qty_eth=2.0)
|
||||
assert abs(notional - 40.0) < 1e-9
|
||||
assert abs(px - 20.0) < 1e-9
|
||||
assert abs(fee - 0.2) < 1e-9
|
||||
|
||||
|
||||
def test_okx_otm_expiry_bill_found_zero_cash() -> None:
|
||||
class _C:
|
||||
def get_option_settlement_bills(self, *_a, **_k):
|
||||
return [
|
||||
{
|
||||
"subType": "172",
|
||||
"type": "3",
|
||||
"ts": "1700000001000",
|
||||
"balChg": "0",
|
||||
"fee": "0",
|
||||
"ccy": "USDT",
|
||||
}
|
||||
]
|
||||
|
||||
st = fetch_option_settlement(
|
||||
_C(),
|
||||
exchange="okx",
|
||||
option_inst_id="ETH-OPT",
|
||||
qty_eth=1.0,
|
||||
begin_ms=1_700_000_000_000,
|
||||
)
|
||||
assert st.found
|
||||
assert st.cash == 0.0
|
||||
assert settlement_to_fill(st, qty_eth=1.0)[0] == 0.0
|
||||
|
||||
|
||||
def test_oo_open_applies_ledger_cash(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.live.executor import OkxLiveExecutor
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "oo.db")
|
||||
ex = OkxLiveExecutor(db)
|
||||
monkeypatch.setattr(ex, "_guard_live", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.claim_open_slot", lambda _db: (True, "ok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.assert_safe_to_open_live", lambda _e: (True, "ok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.stamp_opening_intent", lambda *_a, **_k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.release_open_slot_if_opening", lambda *_a, **_k: None
|
||||
)
|
||||
monkeypatch.setattr(ex, "_ct_mult", lambda *_a, **_k: 0.01)
|
||||
|
||||
class _C:
|
||||
def place_market(self, *, inst_id, side, sz, **_k):
|
||||
return SimpleNamespace(
|
||||
avg_px=10.0 if "C" in inst_id or "call" in inst_id.lower() or inst_id.endswith("-C") or "CALL" in inst_id
|
||||
else 8.0,
|
||||
fee=0.1,
|
||||
sz=float(sz),
|
||||
)
|
||||
|
||||
# simpler fixed fills
|
||||
fills = [
|
||||
SimpleNamespace(avg_px=10.0, fee=0.1, sz=100.0),
|
||||
SimpleNamespace(avg_px=8.0, fee=0.05, sz=100.0),
|
||||
]
|
||||
|
||||
def place(**_k):
|
||||
return fills.pop(0)
|
||||
|
||||
monkeypatch.setattr(ex, "_client", lambda: SimpleNamespace(place_market=place))
|
||||
monkeypatch.setattr(
|
||||
ex.ledger,
|
||||
"get_setting_float",
|
||||
lambda k, d=0: 1.0 if "qty" in k else d,
|
||||
)
|
||||
|
||||
before = float(ex.ledger.snapshot()["available"])
|
||||
r = ex.open_oo_group(
|
||||
group_id="G-oo1",
|
||||
call_inst_id="ETH-CALL",
|
||||
put_inst_id="ETH-PUT",
|
||||
call_strike=2000.0,
|
||||
put_strike=1900.0,
|
||||
entry_index_px=1950.0,
|
||||
expiry_ymd="260810",
|
||||
)
|
||||
assert r.ok, r.detail
|
||||
after = float(ex.ledger.snapshot()["available"])
|
||||
# call 10*1 +0.1 + put 8*1 +0.05 = 18.15
|
||||
assert before - after > 18.0
|
||||
db.close()
|
||||
get_settings.cache_clear()
|
||||
@@ -3,7 +3,12 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from app.sim.liquidity import bid_mark_ok
|
||||
from app.sim.pricing import option_fill, perp_fill
|
||||
from app.strategy.clock import can_open_new, window_key
|
||||
from app.strategy.clock import (
|
||||
can_open_new,
|
||||
expiry_blocked_by_one_per_day,
|
||||
used_expiry_ymds_for_day,
|
||||
window_key,
|
||||
)
|
||||
from app.strategy.exits import check_expiry_close, check_exits
|
||||
from app.strategy.signal import decide
|
||||
|
||||
@@ -28,6 +33,37 @@ def test_signal_equal() -> None:
|
||||
assert decide(10.0, 10.0) is None
|
||||
|
||||
|
||||
def test_decide_fixed_long_put() -> None:
|
||||
from app.strategy.signal import decide_fixed
|
||||
|
||||
s = decide_fixed(20.0, 15.0, perp_side="long")
|
||||
assert s is not None
|
||||
assert s.option_side == "put"
|
||||
assert s.perp_side == "long"
|
||||
assert s.bias == "fixed_long_put"
|
||||
|
||||
|
||||
def test_decide_fixed_short_call() -> None:
|
||||
from app.strategy.signal import decide_fixed
|
||||
|
||||
s = decide_fixed(20.0, 15.0, perp_side="short")
|
||||
assert s is not None
|
||||
assert s.option_side == "call"
|
||||
assert s.perp_side == "short"
|
||||
assert s.bias == "fixed_short_call"
|
||||
|
||||
|
||||
def test_decide_fixed_needs_only_own_leg() -> None:
|
||||
from app.strategy.signal import decide_fixed
|
||||
|
||||
# 半自动多/空:对侧卖一缺失仍可定方向
|
||||
sc = decide_fixed(8.2, None, perp_side="short")
|
||||
assert sc is not None and sc.option_side == "call"
|
||||
sp = decide_fixed(None, 11.4, perp_side="long")
|
||||
assert sp is not None and sp.option_side == "put"
|
||||
assert decide_fixed(None, None, perp_side="short") is None
|
||||
|
||||
|
||||
def test_signal_strike_below_spot_call_short() -> None:
|
||||
# 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空
|
||||
s = decide(10.0, 20.0, strike=1850, mark_px=1859)
|
||||
@@ -122,6 +158,31 @@ def test_window_weekend_skip() -> None:
|
||||
assert window_key(fri) == "20260724"
|
||||
|
||||
|
||||
def test_one_expiry_per_day(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "one_exp.db")
|
||||
# 日历已跨到 8.3,但 8.2 开过的 260803 仍须占用
|
||||
day = datetime(2026, 8, 3, 0, 0, 2, tzinfo=_SH)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms)
|
||||
VALUES ('G-20260802-01','closed','260803',1)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms)
|
||||
VALUES ('G-20260801-01','closed','260802',1)"""
|
||||
)
|
||||
db._conn.commit()
|
||||
used = used_expiry_ymds_for_day(db, day)
|
||||
assert used == {"260803", "260802"}
|
||||
assert expiry_blocked_by_one_per_day("260803", used, enabled=True) is True
|
||||
assert expiry_blocked_by_one_per_day("260804", used, enabled=True) is False
|
||||
assert expiry_blocked_by_one_per_day("260803", used, enabled=False) is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_bid_mark_deviation_30pct() -> None:
|
||||
# |7-10|/10 = 30% → 允许(≤30%)
|
||||
ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30)
|
||||
@@ -142,3 +203,83 @@ def test_expiry_close() -> None:
|
||||
assert d2.reason == "expiry"
|
||||
d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001)
|
||||
assert d3.should_close is True
|
||||
|
||||
|
||||
def test_deep_otm_and_expiry_settle() -> None:
|
||||
from app.sim.pricing import is_deep_otm, option_expiry_settle, option_intrinsic
|
||||
|
||||
assert is_deep_otm(option_side="call", strike=1860, spot=1840) is True
|
||||
assert is_deep_otm(option_side="call", strike=1860, spot=1882) is False
|
||||
assert is_deep_otm(option_side="put", strike=1860, spot=1882) is True
|
||||
assert option_intrinsic(option_side="call", strike=1860, spot=1840) == 0.0
|
||||
settled = option_expiry_settle(intrinsic=0.0, qty_eth=2.0, fee_rate=0.0005)
|
||||
assert settled.fill_px == 0.0
|
||||
assert settled.notional == 0.0
|
||||
|
||||
|
||||
def test_pair_from_held_option_inst() -> None:
|
||||
from app.exchange.option_ids import flip_option_side, pair_from_option_inst
|
||||
|
||||
put = "ETH-USD_UM-260727-1880-P"
|
||||
pair = pair_from_option_inst(put)
|
||||
assert pair is not None
|
||||
assert pair.strike == 1880
|
||||
assert pair.expiry_ymd == "260727"
|
||||
assert pair.put_inst_id == put
|
||||
assert pair.call_inst_id == "ETH-USD_UM-260727-1880-C"
|
||||
assert flip_option_side(put) == pair.call_inst_id
|
||||
|
||||
bn = "ETH-260727-1890-C"
|
||||
bp = pair_from_option_inst(bn)
|
||||
assert bp is not None
|
||||
assert bp.strike == 1890
|
||||
assert bp.call_inst_id == bn
|
||||
assert bp.put_inst_id == "ETH-260727-1890-P"
|
||||
|
||||
|
||||
def test_option_intrinsic_and_close_bid_floor() -> None:
|
||||
from app.sim.pricing import (
|
||||
option_expiry_settle,
|
||||
option_intrinsic,
|
||||
resolve_option_close_bid,
|
||||
)
|
||||
|
||||
assert option_intrinsic(option_side="call", strike=1860, spot=1882) == 22.0
|
||||
assert option_intrinsic(option_side="put", strike=1860, spot=1882) == 0.0
|
||||
assert option_intrinsic(option_side="put", strike=1860, spot=1840) == 20.0
|
||||
|
||||
# 到期:严格按内在价值,无滑点
|
||||
settled = option_expiry_settle(intrinsic=22.0, qty_eth=2.0, fee_rate=0.0005)
|
||||
assert settled.fill_px == 22.0
|
||||
assert settled.slip == 0.0
|
||||
assert settled.notional == 44.0
|
||||
assert abs(settled.fee - 44.0 * 0.0005) < 1e-12
|
||||
|
||||
# 紧急:对齐 OKX 市价卖,只按买一(垃圾买一不抬到内在价值/标记)
|
||||
assert (
|
||||
resolve_option_close_bid(
|
||||
bid=0.2, mark=22.0, intrinsic=22.0, bypass_liquidity=True
|
||||
)
|
||||
== 0.2
|
||||
)
|
||||
# 常规:内在价值地板
|
||||
assert (
|
||||
resolve_option_close_bid(
|
||||
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False
|
||||
)
|
||||
== 22.0
|
||||
)
|
||||
# 买一高于内在价值,保留买一
|
||||
assert (
|
||||
resolve_option_close_bid(
|
||||
bid=25.0, mark=24.0, intrinsic=22.0, bypass_liquidity=True
|
||||
)
|
||||
== 25.0
|
||||
)
|
||||
# 紧急无买一 → 无法定价(不能用标记)
|
||||
assert (
|
||||
resolve_option_close_bid(
|
||||
bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""claim_open_slot / release_open_slot 单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.live.reconcile import claim_open_slot, release_open_slot_if_opening
|
||||
from app.sim.matcher import BLOCKING_STATUSES, Matcher
|
||||
|
||||
|
||||
def test_blocking_statuses_include_opening() -> None:
|
||||
assert "opening" in BLOCKING_STATUSES
|
||||
|
||||
|
||||
def test_claim_open_slot_from_flat(tmp_path) -> None:
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "claim.db")
|
||||
m = Matcher(db)
|
||||
assert m.has_open_position() is False
|
||||
|
||||
ok, msg = claim_open_slot(db)
|
||||
assert ok is True
|
||||
assert msg == "ok"
|
||||
assert m.position_status() == "opening"
|
||||
assert m.has_open_position() is True
|
||||
|
||||
ok2, _ = claim_open_slot(db)
|
||||
assert ok2 is False
|
||||
|
||||
release_open_slot_if_opening(db)
|
||||
assert m.position_status() == "flat"
|
||||
assert m.has_open_position() is False
|
||||
|
||||
ok3, _ = claim_open_slot(db)
|
||||
assert ok3 is True
|
||||
release_open_slot_if_opening(db)
|
||||
db.close()
|
||||
|
||||
|
||||
def test_claim_rejects_blocking_states(tmp_path) -> None:
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "block.db")
|
||||
for st in ("open", "half_open", "option_closed_perp_pending"):
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE positions SET status=?, group_id=? WHERE id=1",
|
||||
(st, "G-test"),
|
||||
)
|
||||
db._conn.commit()
|
||||
ok, msg = claim_open_slot(db)
|
||||
assert ok is False
|
||||
assert st in msg
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE positions SET status='flat', group_id=NULL WHERE id=1"
|
||||
)
|
||||
db._conn.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_release_only_when_opening(tmp_path) -> None:
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "rel.db")
|
||||
with db._lock:
|
||||
db._conn.execute("UPDATE positions SET status='open' WHERE id=1")
|
||||
db._conn.commit()
|
||||
release_open_slot_if_opening(db)
|
||||
row = db.fetchone("SELECT status FROM positions WHERE id=1")
|
||||
assert row is not None
|
||||
assert row["status"] == "open"
|
||||
db.close()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""模拟资金重置时同步清空交易记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.db import Database
|
||||
from app.sim.ledger import Ledger
|
||||
|
||||
|
||||
def test_reset_equity_clears_trade_history(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "reset.db")
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, open_at_ms, close_at_ms, realized_pnl)
|
||||
VALUES ('G1','closed',1,2,-5.0)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
|
||||
fill_px, fee, slip, notional, ts_ms)
|
||||
VALUES ('G1','perp','open','long','ETH-SWAP',1,2000,0.1,0,2000,1)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO residual_options(
|
||||
group_id, option_inst_id, option_side, option_qty_eth,
|
||||
option_entry_px, status, created_at_ms
|
||||
) VALUES ('G1','OPT','call',2,10,'pending',1)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms)
|
||||
VALUES ('G1','pnl',-5,9995,'t',2)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""UPDATE strategy_state SET rounds_done=3, window_key='w', rest_until_ms=99
|
||||
WHERE id=1"""
|
||||
)
|
||||
db._conn.commit()
|
||||
db.set_setting("risk_last_k", "1.5")
|
||||
|
||||
Ledger(db).reset_equity(12000.0, note="test reset")
|
||||
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 0
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM fills")["c"] == 0
|
||||
assert db.fetchone("SELECT COUNT(*) AS c FROM residual_options")["c"] == 0
|
||||
entries = db.fetchall("SELECT kind, amount FROM ledger_entries")
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["kind"] == "reset"
|
||||
assert float(entries[0]["amount"]) == 12000.0
|
||||
pos = db.fetchone("SELECT status, group_id FROM positions WHERE id=1")
|
||||
assert pos["status"] == "flat"
|
||||
assert pos["group_id"] is None
|
||||
st = db.fetchone(
|
||||
"SELECT rounds_done, window_key, rest_until_ms FROM strategy_state WHERE id=1"
|
||||
)
|
||||
assert int(st["rounds_done"]) == 0
|
||||
assert st["window_key"] is None
|
||||
assert st["rest_until_ms"] is None
|
||||
assert db.get_setting("risk_last_k") is None
|
||||
led = db.fetchone("SELECT equity, available, reserved FROM ledger_meta WHERE id=1")
|
||||
assert float(led["equity"]) == 12000.0
|
||||
assert float(led["available"]) == 12000.0
|
||||
assert float(led["reserved"]) == 0.0
|
||||
db.close()
|
||||
@@ -0,0 +1,260 @@
|
||||
"""残留期权:权利金回升达标后中途平。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.models.db import Database
|
||||
from app.sim.matcher import Matcher
|
||||
|
||||
|
||||
def _seed_residual(
|
||||
db: Database,
|
||||
*,
|
||||
group_id: str = "G-res",
|
||||
initial_premium: float = 100.0,
|
||||
qty: float = 2.0,
|
||||
entry_px: float = 50.0,
|
||||
) -> None:
|
||||
now = 1_700_000_000_000
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, bias, option_side, perp_side, option_inst_id,
|
||||
strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms,
|
||||
close_reason, realized_pnl, fees, slip_cost
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"option_residual",
|
||||
"test",
|
||||
"call",
|
||||
"short",
|
||||
"ETH-USD_UM-260801-2000-C",
|
||||
2000.0,
|
||||
"260801",
|
||||
initial_premium,
|
||||
now - 10_000,
|
||||
now - 5_000,
|
||||
"target_perp_only",
|
||||
10.0,
|
||||
1.0,
|
||||
0.0,
|
||||
),
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO residual_options(
|
||||
group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
|
||||
option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
|
||||
initial_premium, status, created_at_ms, note
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
group_id,
|
||||
"ETH-USD_UM-260801-2000-C",
|
||||
"call",
|
||||
qty,
|
||||
200.0,
|
||||
entry_px,
|
||||
2000.0,
|
||||
"260801",
|
||||
now + 86_400_000,
|
||||
1900.0,
|
||||
initial_premium,
|
||||
"pending",
|
||||
now - 5_000,
|
||||
"test residual",
|
||||
),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_residual_premium_below_threshold_skips(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "below.db")
|
||||
db.set_setting("residual_min_premium_pct", "20")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||
m = Matcher(db)
|
||||
|
||||
# bid=5 → premium=10 < 20
|
||||
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
|
||||
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||
|
||||
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||
row = db.fetchone(
|
||||
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||
)
|
||||
assert row is not None and row["status"] == "pending"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_residual_premium_above_threshold_closes(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "above.db")
|
||||
db.set_setting("residual_min_premium_pct", "20")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0)
|
||||
m = Matcher(db)
|
||||
|
||||
# bid=15 → premium=30 >= 20
|
||||
oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0)
|
||||
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||
|
||||
out = m.try_close_one_residual(m.list_residual_options()[0])
|
||||
assert out is not None
|
||||
assert out["reason"] == "residual_premium_close"
|
||||
row = db.fetchone(
|
||||
"SELECT status, settle_px FROM residual_options WHERE group_id=?", ("G-res",)
|
||||
)
|
||||
assert row is not None and row["status"] == "settled"
|
||||
g = db.fetchone("SELECT status FROM groups WHERE group_id=?", ("G-res",))
|
||||
assert g is not None and g["status"] == "closed"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_residual_liquidity_fail_skips(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "liq.db")
|
||||
db.set_setting("residual_min_premium_pct", "20")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||
m = Matcher(db)
|
||||
|
||||
# premium ok but depth tiny
|
||||
oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=1.0, mark_px=15.0)
|
||||
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||
|
||||
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||
row = db.fetchone(
|
||||
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||
)
|
||||
assert row is not None and row["status"] == "pending"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_residual_recheck_bid_drop_skips(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "recheck.db")
|
||||
db.set_setting("residual_min_premium_pct", "20")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||
m = Matcher(db)
|
||||
|
||||
good = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0)
|
||||
bad = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
|
||||
quotes = iter([good, bad])
|
||||
monkeypatch.setattr(m, "_quote_held_option", lambda _id: next(quotes))
|
||||
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||
|
||||
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||
row = db.fetchone(
|
||||
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||
)
|
||||
assert row is not None and row["status"] == "pending"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_residual_book_pending_guard(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "guard.db")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||
m = Matcher(db)
|
||||
row = m.list_residual_options()[0]
|
||||
first = m._book_residual_market_close(
|
||||
row,
|
||||
fill_px=15.0,
|
||||
fee=0.01,
|
||||
notional=30.0,
|
||||
slip=0.0,
|
||||
now_ms=1_700_000_100_000,
|
||||
note="first",
|
||||
filled_contracts=200.0,
|
||||
remaining_contracts=0.0,
|
||||
)
|
||||
assert first is not None and first.get("fully_done") is True
|
||||
second = m._book_residual_market_close(
|
||||
row,
|
||||
fill_px=15.0,
|
||||
fee=0.01,
|
||||
notional=30.0,
|
||||
slip=0.0,
|
||||
now_ms=1_700_000_200_000,
|
||||
note="second",
|
||||
filled_contracts=200.0,
|
||||
remaining_contracts=0.0,
|
||||
)
|
||||
assert second is None
|
||||
db.close()
|
||||
|
||||
|
||||
def test_manual_close_skips_premium_ratio(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "manual.db")
|
||||
db.set_setting("residual_min_premium_pct", "20")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0)
|
||||
m = Matcher(db)
|
||||
|
||||
# bid=5 → premium=10 < 20%,自动路径应跳过,手动可平
|
||||
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
|
||||
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||
|
||||
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||
enriched = m.list_residual_options_enriched()
|
||||
assert len(enriched) == 1
|
||||
assert enriched[0]["liquidity_ok"] is True
|
||||
assert enriched[0]["bid_px"] == 5.0
|
||||
assert enriched[0]["bid_sz"] == 10_000.0
|
||||
assert abs(float(enriched[0]["bid_sz_eth"]) - 100.0) < 1e-9 # 10000*0.01
|
||||
assert abs(float(enriched[0]["current_premium"]) - 10.0) < 1e-9 # 5*2
|
||||
assert abs(float(enriched[0]["recovery_pct"]) - 10.0) < 1e-9 # 10/100*100
|
||||
assert float(enriched[0]["recovery_pct"]) < 20.0
|
||||
|
||||
r = m.close_residual_manual("G-res")
|
||||
assert r.ok is True
|
||||
row = db.fetchone(
|
||||
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||
)
|
||||
assert row is not None and row["status"] == "settled"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_manual_close_liquidity_still_required(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
db = Database(tmp_path / "manual_liq.db")
|
||||
db.set_setting("residual_min_premium_pct", "20")
|
||||
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||
m = Matcher(db)
|
||||
|
||||
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=1.0, mark_px=5.0)
|
||||
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||
|
||||
r = m.close_residual_manual("G-res")
|
||||
assert r.ok is False
|
||||
assert "liquidity" in (r.detail or "")
|
||||
row = db.fetchone(
|
||||
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||
)
|
||||
assert row is not None and row["status"] == "pending"
|
||||
db.close()
|
||||
|
||||
|
||||
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.api import settings as settings_api
|
||||
from app.models.db import set_db
|
||||
|
||||
d = Database(tmp_path / "set.db")
|
||||
set_db(d)
|
||||
try:
|
||||
d.set_setting("residual_min_premium_pct", "35")
|
||||
payload = settings_api._read_settings()
|
||||
assert float(payload["residual_min_premium_pct"]) == 35.0
|
||||
finally:
|
||||
set_db(None)
|
||||
d.close()
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.models.db import Database, set_db
|
||||
from app.strategy.engine import StrategyEngine
|
||||
|
||||
|
||||
def test_enter_rest_after_close_sets_resting(tmp_path) -> None:
|
||||
db = Database(tmp_path / "rest.db")
|
||||
set_db(db)
|
||||
db.set_setting("rest_seconds", "120")
|
||||
now = int(time.time() * 1000)
|
||||
db.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, open_at_ms, close_at_ms, realized_pnl
|
||||
) VALUES (?,?,?,?,?)""",
|
||||
("G-20260729-01", "closed", now - 1000, now, 1.0),
|
||||
)
|
||||
eng = StrategyEngine()
|
||||
before = int(time.time() * 1000)
|
||||
eng.enter_rest_after_close()
|
||||
row = db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||
assert row is not None
|
||||
assert row["phase"] == "resting"
|
||||
assert int(row["rounds_done"] or 0) == 1
|
||||
until = int(row["rest_until_ms"] or 0)
|
||||
assert until >= before + 100_000
|
||||
db.close()
|
||||
@@ -0,0 +1,321 @@
|
||||
"""以损定仓纯函数测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.strategy.risk_sizing import (
|
||||
BASE_EXIT_USDT,
|
||||
BASE_OPTION_ETH,
|
||||
BASE_PERP_ETH,
|
||||
compute_k,
|
||||
floor_k_1dp,
|
||||
normalize_risk_leverage_basis,
|
||||
resolve_sizing_option_ask,
|
||||
unit_cost,
|
||||
)
|
||||
|
||||
|
||||
def test_floor_k_1dp() -> None:
|
||||
assert floor_k_1dp(1.29) == 1.2
|
||||
assert floor_k_1dp(0.19) == 0.1
|
||||
assert floor_k_1dp(0.09) == 0.0
|
||||
assert floor_k_1dp(2.0) == 2.0
|
||||
|
||||
|
||||
def test_compute_k_scales_1_2_15() -> None:
|
||||
# I=2000, A=20, fee=0.0005 → unit = 2*20 + 2000*0.0005*3 = 40 + 3 = 43
|
||||
# budget=43 → k=1.0
|
||||
r = compute_k(budget=43.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
|
||||
assert r.ok
|
||||
assert r.k == 1.0
|
||||
assert r.perp_qty_eth == BASE_PERP_ETH
|
||||
assert r.option_qty_eth == BASE_OPTION_ETH
|
||||
assert r.net_profit_target == BASE_EXIT_USDT
|
||||
assert r.max_loss is not None and r.max_loss <= 43.0 + 1e-6
|
||||
assert r.budget == 43.0
|
||||
|
||||
|
||||
def test_compute_k_custom_units() -> None:
|
||||
# option_unit=4 → premium unit = 20*4=80; fee=3; cost=83; budget=83 → k=1
|
||||
r = compute_k(
|
||||
budget=83.0,
|
||||
index_px=2000.0,
|
||||
option_ask=20.0,
|
||||
fee_rate=0.0005,
|
||||
perp_unit=0.5,
|
||||
option_unit=4.0,
|
||||
exit_unit=30.0,
|
||||
)
|
||||
assert r.ok
|
||||
assert r.k == 1.0
|
||||
assert r.perp_qty_eth == 0.5
|
||||
assert r.option_qty_eth == 4.0
|
||||
assert r.net_profit_target == 30.0
|
||||
|
||||
|
||||
def test_money_rounds_2dp() -> None:
|
||||
r = compute_k(budget=50.123456, index_px=1900.0, option_ask=18.5, fee_rate=0.0005)
|
||||
assert r.ok
|
||||
assert r.budget == round(50.123456, 2)
|
||||
assert r.max_loss is not None
|
||||
assert abs(r.max_loss * 100 - round(r.max_loss * 100)) < 1e-9
|
||||
|
||||
|
||||
def test_compute_k_never_exceeds_budget() -> None:
|
||||
r = compute_k(budget=50.0, index_px=1900.0, option_ask=18.5, fee_rate=0.0005)
|
||||
assert r.ok
|
||||
assert r.k is not None
|
||||
assert abs(r.k * 10 - round(r.k * 10)) < 1e-9 # 一位小数
|
||||
assert r.max_loss is not None and r.max_loss <= 50.0 + 1e-6
|
||||
assert r.perp_qty_eth == round(1.0 * r.k, 4)
|
||||
assert r.option_qty_eth == round(2.0 * r.k, 4)
|
||||
assert r.net_profit_target == round(15.0 * r.k, 4)
|
||||
|
||||
|
||||
def test_compute_k_too_small() -> None:
|
||||
# unit≈43, budget=2 → k_raw≪0.1
|
||||
r = compute_k(budget=2.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
|
||||
assert not r.ok
|
||||
assert "最小仓" in r.detail or "k=" in r.detail
|
||||
|
||||
|
||||
def test_unit_cost() -> None:
|
||||
assert abs(unit_cost(index_px=2000, option_ask=20, fee_rate=0.0005) - 43.0) < 1e-9
|
||||
|
||||
|
||||
def test_normalize_risk_leverage_basis() -> None:
|
||||
assert normalize_risk_leverage_basis("actual") == "actual"
|
||||
assert normalize_risk_leverage_basis("selection") == "selection"
|
||||
assert normalize_risk_leverage_basis("min_option_leverage") == "selection"
|
||||
assert normalize_risk_leverage_basis("weird", default="selection") == "selection"
|
||||
|
||||
|
||||
def test_resolve_sizing_ask_selection_vs_actual() -> None:
|
||||
# 指数 2000、选约杠杆 100 → 隐含卖一 20;实际卖一更便宜 10
|
||||
sel_ask, basis = resolve_sizing_option_ask(
|
||||
index_px=2000.0,
|
||||
option_ask=10.0,
|
||||
leverage_basis="selection",
|
||||
min_option_leverage=100.0,
|
||||
)
|
||||
assert basis == "selection"
|
||||
assert abs(sel_ask - 20.0) < 1e-9
|
||||
|
||||
act_ask, basis2 = resolve_sizing_option_ask(
|
||||
index_px=2000.0,
|
||||
option_ask=10.0,
|
||||
leverage_basis="actual",
|
||||
min_option_leverage=100.0,
|
||||
)
|
||||
assert basis2 == "actual"
|
||||
assert abs(act_ask - 10.0) < 1e-9
|
||||
|
||||
|
||||
def test_selection_basis_yields_smaller_k_when_ask_cheap() -> None:
|
||||
# 预算 43:选约隐含 ask=20 → k=1;若用实际 ask=10 → 单位成本更小 → k 更大
|
||||
r_sel = compute_k(budget=43.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
|
||||
r_act = compute_k(budget=43.0, index_px=2000.0, option_ask=10.0, fee_rate=0.0005)
|
||||
assert r_sel.ok and r_act.ok
|
||||
assert r_sel.k == 1.0
|
||||
assert r_act.k is not None and r_act.k > r_sel.k
|
||||
|
||||
|
||||
def test_compute_risk_sizing_unit_overrides(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
from app.strategy.risk_sizing import compute_risk_sizing
|
||||
|
||||
db = Database(tmp_path / "risk_units.db")
|
||||
db.set_setting("sizing_mode", "risk_based")
|
||||
db.set_setting("risk_loss_mode", "absolute")
|
||||
db.set_setting("risk_loss_usdt", "83")
|
||||
db.set_setting("fee_rate", "0.0005")
|
||||
db.set_setting("risk_leverage_basis", "selection")
|
||||
db.set_setting("min_option_leverage", "100")
|
||||
db.set_setting("risk_perp_unit", "1")
|
||||
db.set_setting("risk_option_unit", "2")
|
||||
db.set_setting("risk_exit_unit", "15")
|
||||
|
||||
# 覆盖为单位 0.5:4、出场 5,并强制实际卖一(ask=20 → cost=80+3=83 → k=1)
|
||||
r = compute_risk_sizing(
|
||||
index_px=2000.0,
|
||||
option_ask=20.0,
|
||||
db=db,
|
||||
perp_unit=0.5,
|
||||
option_unit=4.0,
|
||||
exit_unit=5.0,
|
||||
leverage_basis="actual",
|
||||
)
|
||||
assert r.ok
|
||||
assert r.leverage_basis == "actual"
|
||||
assert r.k == 1.0
|
||||
assert r.perp_qty_eth == 0.5
|
||||
assert r.option_qty_eth == 4.0
|
||||
assert r.net_profit_target == 5.0
|
||||
db.close()
|
||||
|
||||
|
||||
def test_compute_risk_sizing_respects_basis(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
from app.strategy.risk_sizing import compute_risk_sizing
|
||||
|
||||
db = Database(tmp_path / "risk_basis.db")
|
||||
db.set_setting("sizing_mode", "risk_based")
|
||||
db.set_setting("risk_loss_mode", "absolute")
|
||||
db.set_setting("risk_loss_usdt", "43")
|
||||
db.set_setting("fee_rate", "0.0005")
|
||||
db.set_setting("min_option_leverage", "100")
|
||||
db.set_setting("risk_perp_unit", "1")
|
||||
db.set_setting("risk_option_unit", "2")
|
||||
db.set_setting("risk_exit_unit", "15")
|
||||
|
||||
db.set_setting("risk_leverage_basis", "selection")
|
||||
r1 = compute_risk_sizing(index_px=2000.0, option_ask=10.0, db=db)
|
||||
assert r1.ok
|
||||
assert r1.leverage_basis == "selection"
|
||||
assert r1.k == 1.0
|
||||
assert r1.actual_option_ask == 10.0
|
||||
assert r1.option_ask == 20.0
|
||||
|
||||
db.set_setting("risk_leverage_basis", "actual")
|
||||
r2 = compute_risk_sizing(index_px=2000.0, option_ask=10.0, db=db)
|
||||
assert r2.ok
|
||||
assert r2.leverage_basis == "actual"
|
||||
assert r2.k is not None and r2.k > 1.0
|
||||
assert r2.option_ask == 10.0
|
||||
db.close()
|
||||
|
||||
|
||||
def test_consecutive_loss_days_and_martingale(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.models.db import Database
|
||||
from app.strategy.risk_sizing import consecutive_loss_days, resolve_martingale
|
||||
|
||||
db = Database(tmp_path / "mg.db")
|
||||
sh = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
def day_ms(ymd: str, hour: int = 16) -> int:
|
||||
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(
|
||||
hour=hour, tzinfo=sh
|
||||
)
|
||||
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
|
||||
|
||||
# 插入:盈利日打断后连亏 3 天(有成交日序列,跳过无成交日)
|
||||
rows = [
|
||||
("g1", day_ms("2026-07-28"), 10.0),
|
||||
("g2", day_ms("2026-07-29"), -5.0),
|
||||
("g3", day_ms("2026-07-30"), -3.0),
|
||||
("g4", day_ms("2026-07-31"), -1.0),
|
||||
]
|
||||
for gid, ms, pnl in rows:
|
||||
db.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, realized_pnl, close_at_ms, open_at_ms
|
||||
) VALUES(?,?,?,?,?)""",
|
||||
(gid, "closed", pnl, ms, ms - 3600_000),
|
||||
)
|
||||
|
||||
assert consecutive_loss_days(db) == 3
|
||||
|
||||
db.set_setting("sizing_mode", "risk_based")
|
||||
db.set_setting("risk_loss_mode", "percent")
|
||||
db.set_setting("risk_loss_pct", "2")
|
||||
db.set_setting("martingale_enabled", "true")
|
||||
db.set_setting("martingale_start_after_loss_days", "2")
|
||||
db.set_setting("martingale_max_doubles", "3")
|
||||
|
||||
mg = resolve_martingale(db, base_pct=2.0)
|
||||
assert mg["eligible"] is True
|
||||
assert mg["loss_days"] == 3
|
||||
# 连亏3天、start=2 → doubles = min(3-2+1, 3) = 2 → 2%*4 = 8%
|
||||
assert mg["doubles"] == 2
|
||||
assert abs(float(mg["effective_pct"]) - 8.0) < 1e-9
|
||||
|
||||
db.set_setting("risk_loss_pct", "3.1")
|
||||
mg2 = resolve_martingale(db, base_pct=3.1)
|
||||
assert mg2["eligible"] is False
|
||||
assert mg2["doubles"] == 0
|
||||
assert abs(float(mg2["effective_pct"]) - 3.1) < 1e-9
|
||||
db.close()
|
||||
|
||||
|
||||
def test_martingale_doubles_capped(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.models.db import Database
|
||||
from app.strategy.risk_sizing import resolve_martingale
|
||||
|
||||
db = Database(tmp_path / "mg_cap.db")
|
||||
sh = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
def day_ms(ymd: str) -> int:
|
||||
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(hour=12, tzinfo=sh)
|
||||
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
|
||||
|
||||
for i, ymd in enumerate(
|
||||
["2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30"]
|
||||
):
|
||||
db.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, realized_pnl, close_at_ms, open_at_ms
|
||||
) VALUES(?,?,?,?,?)""",
|
||||
(f"c{i}", "closed", -1.0, day_ms(ymd), day_ms(ymd) - 1000),
|
||||
)
|
||||
|
||||
db.set_setting("sizing_mode", "risk_based")
|
||||
db.set_setting("risk_loss_mode", "percent")
|
||||
db.set_setting("martingale_enabled", "true")
|
||||
db.set_setting("martingale_start_after_loss_days", "2")
|
||||
db.set_setting("martingale_max_doubles", "3")
|
||||
mg = resolve_martingale(db, base_pct=2.0)
|
||||
# 连亏5、start2 → raw=4,cap=3 → 2%*8=16%
|
||||
assert mg["doubles"] == 3
|
||||
assert abs(float(mg["effective_pct"]) - 16.0) < 1e-9
|
||||
db.close()
|
||||
|
||||
|
||||
def test_expiry_settle_counts_as_loss_even_if_profit(tmp_path, monkeypatch) -> None:
|
||||
"""到期结算小盈利也按亏损计入倍投连亏日。"""
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.models.db import Database
|
||||
from app.strategy.risk_sizing import consecutive_loss_days
|
||||
|
||||
db = Database(tmp_path / "mg_exp.db")
|
||||
sh = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
def day_ms(ymd: str) -> int:
|
||||
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(hour=16, tzinfo=sh)
|
||||
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
|
||||
|
||||
# 达标盈利打断;随后两天均为到期小盈利 → 仍计连亏 2
|
||||
rows = [
|
||||
("e0", day_ms("2026-07-28"), 20.0, "fixed_usdt"),
|
||||
("e1", day_ms("2026-07-29"), 3.5, "expiry"),
|
||||
("e2", day_ms("2026-07-30"), 1.2, "expiry"),
|
||||
]
|
||||
for gid, ms, pnl, reason in rows:
|
||||
db.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, realized_pnl, close_at_ms, open_at_ms, close_reason
|
||||
) VALUES(?,?,?,?,?,?)""",
|
||||
(gid, "closed", pnl, ms, ms - 3600_000, reason),
|
||||
)
|
||||
assert consecutive_loss_days(db) == 2
|
||||
|
||||
# 再来一天达标盈利 → 连亏清零
|
||||
db.execute(
|
||||
"""INSERT INTO groups(
|
||||
group_id, status, realized_pnl, close_at_ms, open_at_ms, close_reason
|
||||
) VALUES(?,?,?,?,?,?)""",
|
||||
("e3", "closed", 15.0, day_ms("2026-07-31"), day_ms("2026-07-31") - 1000, "fixed_usdt"),
|
||||
)
|
||||
assert consecutive_loss_days(db) == 0
|
||||
db.close()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""SIM/LIVE 运行时闸门与脱敏。"""
|
||||
|
||||
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") == "********"
|
||||
assert mask_secret("abcdefghij") == "********"
|
||||
|
||||
|
||||
def test_live_ready_sim(monkeypatch) -> None:
|
||||
import app.env_store as es
|
||||
|
||||
class S:
|
||||
mode = "SIM"
|
||||
is_sim = True
|
||||
okx_api_key = ""
|
||||
okx_api_secret = ""
|
||||
okx_api_passphrase = ""
|
||||
binance_api_key = ""
|
||||
binance_api_secret = ""
|
||||
|
||||
monkeypatch.setattr(es, "get_settings", lambda: S())
|
||||
ok, reason = live_ready(exchange="okx")
|
||||
assert ok is True
|
||||
assert reason == "sim"
|
||||
|
||||
|
||||
def test_live_ready_okx_missing_keys(monkeypatch) -> None:
|
||||
import app.env_store as es
|
||||
|
||||
class S:
|
||||
mode = "LIVE"
|
||||
is_sim = False
|
||||
okx_api_key = ""
|
||||
okx_api_secret = ""
|
||||
okx_api_passphrase = ""
|
||||
binance_api_key = ""
|
||||
binance_api_secret = ""
|
||||
|
||||
monkeypatch.setattr(es, "get_settings", lambda: S())
|
||||
ok, reason = live_ready(exchange="okx")
|
||||
assert ok is False
|
||||
assert "OKX" in reason
|
||||
|
||||
|
||||
def test_live_ready_binance_ok(monkeypatch) -> None:
|
||||
import app.env_store as es
|
||||
|
||||
class S:
|
||||
mode = "LIVE"
|
||||
is_sim = False
|
||||
okx_api_key = ""
|
||||
okx_api_secret = ""
|
||||
okx_api_passphrase = ""
|
||||
binance_api_key = "bk"
|
||||
binance_api_secret = "bs"
|
||||
|
||||
monkeypatch.setattr(es, "get_settings", lambda: S())
|
||||
ok, reason = live_ready(exchange="binance")
|
||||
assert ok is True
|
||||
assert reason == "ok"
|
||||
|
||||
|
||||
def test_okx_keys_configured(monkeypatch) -> None:
|
||||
import app.env_store as es
|
||||
|
||||
class S:
|
||||
okx_api_key = "k"
|
||||
okx_api_secret = "s"
|
||||
okx_api_passphrase = "p"
|
||||
|
||||
monkeypatch.setattr(es, "get_settings", lambda: S())
|
||||
assert okx_keys_configured() is True
|
||||
@@ -0,0 +1,137 @@
|
||||
"""半自动出场与参数。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.strategy.selection import list_otm_strikes, pick_otm_strike
|
||||
from app.strategy.semi_auto import (
|
||||
REASON_PERP_NET,
|
||||
REASON_POINTS,
|
||||
check_semi_exits,
|
||||
effective_min_leverage,
|
||||
)
|
||||
|
||||
|
||||
def test_semi_points_long_needs_net_positive() -> None:
|
||||
# 目标 = 行权价 1800 + 50 = 1850;到点但净利≤0 → 不平
|
||||
d = check_semi_exits(
|
||||
net_pnl=-1.0,
|
||||
strike=1800,
|
||||
index_px=1850,
|
||||
view_side="long",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=1,
|
||||
)
|
||||
assert d.should_close is False
|
||||
assert "净利≤0" in d.detail
|
||||
|
||||
d2 = check_semi_exits(
|
||||
net_pnl=1.0,
|
||||
strike=1800,
|
||||
index_px=1850,
|
||||
view_side="long",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=1,
|
||||
)
|
||||
assert d2.should_close is True
|
||||
assert d2.reason == REASON_POINTS
|
||||
assert d2.target_index == 1850.0
|
||||
|
||||
|
||||
def test_semi_points_uses_strike_not_spot() -> None:
|
||||
# 现价 1915、K1930、+50 → 目标 1980;现价未到则不平(净利勿触达锁定)
|
||||
d = check_semi_exits(
|
||||
net_pnl=1.0,
|
||||
strike=1930,
|
||||
index_px=1915,
|
||||
view_side="long",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=1,
|
||||
)
|
||||
assert d.should_close is False
|
||||
assert d.target_index == 1980.0
|
||||
|
||||
d2 = check_semi_exits(
|
||||
net_pnl=1.0,
|
||||
strike=1930,
|
||||
index_px=1980,
|
||||
view_side="long",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=1,
|
||||
)
|
||||
assert d2.should_close is True
|
||||
assert d2.reason == REASON_POINTS
|
||||
|
||||
|
||||
def test_semi_points_short() -> None:
|
||||
# Put:目标 = K − 50
|
||||
d = check_semi_exits(
|
||||
net_pnl=2.0,
|
||||
strike=1800,
|
||||
index_px=1750,
|
||||
view_side="short",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=1,
|
||||
)
|
||||
assert d.should_close is True
|
||||
assert d.reason == REASON_POINTS
|
||||
assert d.target_index == 1750.0
|
||||
|
||||
|
||||
def test_semi_net_exit_with_k() -> None:
|
||||
# 未到点,但净利 ≥ 5×2=10(永续锁定)
|
||||
d = check_semi_exits(
|
||||
net_pnl=10.0,
|
||||
strike=1800,
|
||||
index_px=1810,
|
||||
view_side="long",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=2,
|
||||
)
|
||||
assert d.should_close is True
|
||||
assert d.reason == REASON_PERP_NET
|
||||
assert d.net_target == 10.0
|
||||
|
||||
|
||||
def test_semi_not_yet() -> None:
|
||||
d = check_semi_exits(
|
||||
net_pnl=3.0,
|
||||
strike=1800,
|
||||
index_px=1820,
|
||||
view_side="long",
|
||||
option_move_points=50,
|
||||
perp_exit_unit=5,
|
||||
risk_k=1,
|
||||
)
|
||||
assert d.should_close is False
|
||||
|
||||
|
||||
def test_otm_leverage_floor() -> None:
|
||||
assert effective_min_leverage("otm", 100) == 180
|
||||
assert effective_min_leverage("otm", 200) == 200
|
||||
assert effective_min_leverage("itm", 100) == 100
|
||||
|
||||
|
||||
def test_pick_otm_within_offset() -> None:
|
||||
strikes = [1800.0, 1825.0, 1850.0, 1875.0]
|
||||
# Call 虚值:标的 1830 → 1850(20点)在 25 内;1875 超
|
||||
k = pick_otm_strike(strikes, 1830, option_side="call", max_offset=25)
|
||||
assert k == 1850.0
|
||||
assert (
|
||||
pick_otm_strike(strikes, 1830, option_side="call", max_offset=15) is None
|
||||
)
|
||||
# Put 虚值
|
||||
k2 = pick_otm_strike(strikes, 1830, option_side="put", max_offset=30)
|
||||
assert k2 == 1825.0
|
||||
|
||||
|
||||
def test_list_otm_strikes_near_to_far() -> None:
|
||||
# 现价 1917 → Call 虚值 1920/1930/1940(偏离≤25),近→远
|
||||
strikes = [1910.0, 1920.0, 1930.0, 1940.0, 1950.0]
|
||||
ks = list_otm_strikes(strikes, 1917.0, option_side="call", max_offset=25)
|
||||
assert ks == [1920.0, 1930.0, 1940.0]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""空仓选约:跳过历史上已用到期与残留待结算到期。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.strategy.clock import (
|
||||
expiry_blocked_by_one_per_day,
|
||||
pending_residual_expiry_ymds,
|
||||
used_expiry_ymds,
|
||||
)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
groups: list[dict] | None = None,
|
||||
residuals: list[dict] | None = None,
|
||||
) -> None:
|
||||
self._groups = groups or []
|
||||
self._residuals = residuals or []
|
||||
|
||||
def fetchall(self, sql: str, params: tuple = ()) -> list[dict]:
|
||||
s = " ".join(sql.split()).lower()
|
||||
if "from groups" in s:
|
||||
return [
|
||||
r
|
||||
for r in self._groups
|
||||
if str(r.get("expiry_ymd") or "").strip()
|
||||
]
|
||||
if "from residual_options" in s:
|
||||
return [r for r in self._residuals if r.get("status") == "pending"]
|
||||
return []
|
||||
|
||||
|
||||
def test_used_expiry_ymds_across_calendar_days() -> None:
|
||||
"""8.2 开过 260803 后,8.3 零点仍须拦截同到期。"""
|
||||
db = _FakeDB(
|
||||
groups=[
|
||||
{"group_id": "G-20260802-01", "expiry_ymd": "260803"},
|
||||
{"group_id": "G-20260801-01", "expiry_ymd": "260802"},
|
||||
]
|
||||
)
|
||||
now_aug3 = datetime(2026, 8, 3, 0, 0, 2, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
used = used_expiry_ymds(db, now_aug3)
|
||||
assert used == {"260803", "260802"}
|
||||
assert expiry_blocked_by_one_per_day("260803", used, enabled=True)
|
||||
assert not expiry_blocked_by_one_per_day("260804", used, enabled=True)
|
||||
|
||||
|
||||
def test_pending_residual_expiry_ymds() -> None:
|
||||
db = _FakeDB(
|
||||
residuals=[
|
||||
{"expiry_ymd": "260803", "status": "pending"},
|
||||
{"expiry_ymd": "260802", "status": "settled"},
|
||||
{"expiry_ymd": "", "status": "pending"},
|
||||
]
|
||||
)
|
||||
assert pending_residual_expiry_ymds(db) == {"260803"}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""统计汇总:最大单笔亏损、连亏次数。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.stats import _current_loss_streak
|
||||
|
||||
|
||||
def test_current_loss_streak() -> None:
|
||||
assert _current_loss_streak([]) == 0
|
||||
assert (
|
||||
_current_loss_streak(
|
||||
[
|
||||
{"realized_pnl": 10},
|
||||
{"realized_pnl": -1},
|
||||
{"realized_pnl": -2},
|
||||
]
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert (
|
||||
_current_loss_streak(
|
||||
[
|
||||
{"realized_pnl": -5},
|
||||
{"realized_pnl": 1},
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
@@ -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,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.notify.wecom import (
|
||||
TAG_CLOSE,
|
||||
TAG_OPEN,
|
||||
build_markdown,
|
||||
close_reason_zh,
|
||||
direction_zh,
|
||||
notify_close,
|
||||
notify_open,
|
||||
venue_label,
|
||||
)
|
||||
|
||||
|
||||
def test_build_markdown_has_tag_and_title(monkeypatch):
|
||||
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: "实盘·OKX")
|
||||
md = build_markdown(tag=TAG_OPEN, title="开仓成功", lines=["组: G-1"])
|
||||
assert "【实盘·OKX】开仓成功" in md
|
||||
assert "`OPEN`" in md
|
||||
assert "组: G-1" in md
|
||||
|
||||
|
||||
def test_venue_label_sim(monkeypatch):
|
||||
class S:
|
||||
is_sim = True
|
||||
exchange = "okx"
|
||||
|
||||
monkeypatch.setattr("app.notify.wecom.get_settings", lambda: S())
|
||||
assert venue_label() is None
|
||||
|
||||
|
||||
def test_direction_and_close_reason_zh() -> None:
|
||||
assert "Put" in direction_zh({"option_side": "put", "perp_side": "long"})
|
||||
assert close_reason_zh("liquidity_retry") == "等待流动性后全平"
|
||||
assert close_reason_zh("fixed_usdt") == "固定净盈利达标·双腿全平"
|
||||
|
||||
|
||||
def test_notify_open_close_markdown(monkeypatch) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
monkeypatch.setattr("app.notify.wecom.wecom_enabled", lambda: True)
|
||||
monkeypatch.setattr("app.notify.wecom.wecom_webhook_url", lambda: "http://example.test")
|
||||
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: None)
|
||||
monkeypatch.setattr("app.notify.wecom.wecom_machine_name", lambda: "")
|
||||
|
||||
def _capture(content: str):
|
||||
captured.append(content)
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr("app.notify.wecom._post_markdown_sync", _capture)
|
||||
monkeypatch.setattr(
|
||||
"app.notify.wecom.notify_async",
|
||||
lambda content: captured.append(content),
|
||||
)
|
||||
|
||||
notify_open(
|
||||
group_id="G-20260802-01",
|
||||
detail="opened",
|
||||
extra={
|
||||
"bias": "put_ask_gt_call",
|
||||
"option_side": "put",
|
||||
"perp_side": "long",
|
||||
"option_inst_id": "ETH-USD-260802-1850-P",
|
||||
"strike": 1850,
|
||||
"expiry_ymd": "260802",
|
||||
"perp_qty_eth": 1.0,
|
||||
"option_qty_eth": 2.0,
|
||||
"perp_entry_px": 1860.5,
|
||||
"option_entry_px": 12.3,
|
||||
"initial_premium": 24.6,
|
||||
"perp_margin": 620.0,
|
||||
"leverage": 3,
|
||||
},
|
||||
)
|
||||
assert captured
|
||||
open_md = captured[-1]
|
||||
assert "开仓成功" in open_md
|
||||
assert "`OPEN`" in open_md or TAG_OPEN in open_md
|
||||
assert "权利金占用" in open_md
|
||||
assert "保证金占用" in open_md
|
||||
assert "开仓数量" in open_md
|
||||
assert "买Put" in open_md
|
||||
|
||||
captured.clear()
|
||||
notify_close(
|
||||
reason="liquidity_retry",
|
||||
detail="closed",
|
||||
data={
|
||||
"group_id": "G-20260802-01",
|
||||
"perp_pnl": -10.5,
|
||||
"option_pnl": 40.2,
|
||||
"net": 25.0,
|
||||
"fees": 4.5,
|
||||
},
|
||||
)
|
||||
close_md = captured[-1]
|
||||
assert "平仓" in close_md
|
||||
assert TAG_CLOSE in close_md or "`CLOSE`" in close_md
|
||||
assert "等待流动性后全平" in close_md
|
||||
assert "永续盈亏" in close_md
|
||||
assert "期权盈亏" in close_md
|
||||
assert "净利润" in close_md
|
||||
assert "+25.00U" in close_md or "25.00U" in close_md
|
||||
@@ -0,0 +1 @@
|
||||
# 使 `uvicorn app.main:app` 在 control/backend 下可运行
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth_routes import router as auth_router
|
||||
from .nodes import router as nodes_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth_router)
|
||||
router.include_router(nodes_router)
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import issue_token, require_control_user
|
||||
from ..config import ControlSettings, get_control_settings
|
||||
from ..envfile import update_control_credentials, upsert_env_control
|
||||
from ..lan import client_ip, is_lan_ip
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ChangeCredentialsBody(BaseModel):
|
||||
current_password: str = Field(min_length=1)
|
||||
new_username: str = Field(min_length=1, max_length=64)
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class LanBypassBody(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.get("/login-meta")
|
||||
async def login_meta(
|
||||
request: Request,
|
||||
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||
) -> dict:
|
||||
ip = client_ip(request)
|
||||
lan = is_lan_ip(ip)
|
||||
return {
|
||||
"show_default_hint": settings.is_default_credentials,
|
||||
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||
"lan_client": lan,
|
||||
"lan_login_available": bool(settings.lan_auth_bypass and lan),
|
||||
"client_ip": ip or None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/lan-login")
|
||||
async def lan_login(
|
||||
request: Request,
|
||||
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||
) -> dict:
|
||||
if not settings.lan_auth_bypass:
|
||||
raise HTTPException(status_code=403, detail="未开启局域网免登录")
|
||||
ip = client_ip(request)
|
||||
if not is_lan_ip(ip):
|
||||
raise HTTPException(status_code=403, detail="仅局域网地址可免登录")
|
||||
user = settings.control_auth_username
|
||||
token, ttl = issue_token(user, settings)
|
||||
return {
|
||||
"token": token,
|
||||
"username": user,
|
||||
"expires_in": ttl,
|
||||
"via": "lan",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
body: LoginBody,
|
||||
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||
) -> dict:
|
||||
user_ok = hmac.compare_digest(
|
||||
body.username.encode("utf-8"),
|
||||
settings.control_auth_username.encode("utf-8"),
|
||||
)
|
||||
pwd_ok = hmac.compare_digest(
|
||||
body.password.encode("utf-8"),
|
||||
settings.control_auth_password.encode("utf-8"),
|
||||
)
|
||||
if not (user_ok and pwd_ok):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token, ttl = issue_token(body.username, settings)
|
||||
return {"token": token, "username": body.username, "expires_in": ttl}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(
|
||||
request: Request,
|
||||
username: Annotated[str, Depends(require_control_user)],
|
||||
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||
) -> dict:
|
||||
ip = client_ip(request)
|
||||
lan = is_lan_ip(ip)
|
||||
return {
|
||||
"username": username,
|
||||
"poll_interval_sec": settings.control_poll_interval_sec,
|
||||
"sse_interval_sec": settings.control_sse_interval_sec,
|
||||
"show_default_hint": settings.is_default_credentials,
|
||||
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||
"lan_client": lan,
|
||||
"client_ip": ip or None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/lan-bypass")
|
||||
async def put_lan_bypass(
|
||||
body: LanBypassBody,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
upsert_env_control(
|
||||
"CONTROL_LAN_AUTH_BYPASS",
|
||||
"1" if body.enabled else "0",
|
||||
overwrite=True,
|
||||
)
|
||||
get_control_settings.cache_clear()
|
||||
settings = get_control_settings()
|
||||
return {
|
||||
"ok": True,
|
||||
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-credentials")
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsBody,
|
||||
username: Annotated[str, Depends(require_control_user)],
|
||||
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||
) -> dict:
|
||||
if not hmac.compare_digest(
|
||||
body.current_password.encode("utf-8"),
|
||||
settings.control_auth_password.encode("utf-8"),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码不正确")
|
||||
try:
|
||||
update_control_credentials(
|
||||
new_username=body.new_username,
|
||||
new_password=body.new_password,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
settings2 = get_control_settings()
|
||||
token, ttl = issue_token(body.new_username.strip(), settings2)
|
||||
return {
|
||||
"token": token,
|
||||
"username": body.new_username.strip(),
|
||||
"expires_in": ttl,
|
||||
"show_default_hint": settings2.is_default_credentials,
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from typing import Annotated, Any, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import require_control_user
|
||||
from ..config import get_control_settings
|
||||
from ..crypto import seal
|
||||
from ..db import get_control_db
|
||||
from ..proxy import call_node, probe_health
|
||||
|
||||
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
|
||||
|
||||
|
||||
def _public_node(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"base_url": row["base_url"],
|
||||
"token_configured": bool((row.get("token_sealed") or "").strip()),
|
||||
"created_at_ms": row["created_at_ms"],
|
||||
"updated_at_ms": row["updated_at_ms"],
|
||||
}
|
||||
|
||||
|
||||
def _http_detail(data: Any) -> str:
|
||||
if isinstance(data, dict):
|
||||
d = data.get("detail", data)
|
||||
return d if isinstance(d, str) else str(d)
|
||||
return str(data)
|
||||
|
||||
|
||||
def _raise_node_error(code: int, data: Any) -> None:
|
||||
"""策略机错误不得用 401 回传,否则中控前端会误清登录态。"""
|
||||
detail = _http_detail(data)
|
||||
if code in (401, 403):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"策略机鉴权失败({detail})。请在中控重新生成 Token,并到策略机「系统设置→登录账户」保存同一 Token。",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=code if 400 <= code < 600 else 502,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
async def _collect_one_status(
|
||||
node: dict[str, Any],
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""有 Token 时只打 fleet/status(含在线+持仓),避免 /health 再多一跳。"""
|
||||
base = _public_node(node)
|
||||
if not node.get("token_sealed"):
|
||||
probe = await probe_health(node, timeout=timeout)
|
||||
return {
|
||||
**base,
|
||||
**probe,
|
||||
"fleet_ok": False,
|
||||
"fleet_error": "未生成 Token",
|
||||
}
|
||||
|
||||
code, data = await call_node(
|
||||
node, "GET", "/api/fleet/status", timeout=timeout
|
||||
)
|
||||
if code == 200 and isinstance(data, dict):
|
||||
return {
|
||||
**base,
|
||||
"online": True,
|
||||
"health": None,
|
||||
"error": None,
|
||||
"from_fleet": True,
|
||||
"fleet": data,
|
||||
"fleet_ok": True,
|
||||
}
|
||||
|
||||
# fleet 失败时再探 /health,区分离线 vs Token 错误
|
||||
probe = await probe_health(node, timeout=timeout)
|
||||
item: dict[str, Any] = {
|
||||
**base,
|
||||
**probe,
|
||||
"fleet_ok": False,
|
||||
"fleet_error": _http_detail(data) if data else f"HTTP {code}",
|
||||
}
|
||||
return item
|
||||
|
||||
|
||||
async def collect_all_status(*, timeout: float | None = None) -> list[dict[str, Any]]:
|
||||
db = get_control_db()
|
||||
nodes = db.list_nodes()
|
||||
if not nodes:
|
||||
return []
|
||||
return list(
|
||||
await asyncio.gather(
|
||||
*[_collect_one_status(n, timeout=timeout) for n in nodes]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class NodeCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
base_url: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
base_url: str | None = Field(default=None, min_length=8, max_length=256)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_nodes(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
||||
db = get_control_db()
|
||||
nodes = [_public_node(n) for n in db.list_nodes()]
|
||||
return {"nodes": nodes}
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_node(
|
||||
body: NodeCreate,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
try:
|
||||
row = db.create_node(body.name, body.base_url)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"创建失败: {e}") from e
|
||||
return _public_node(row)
|
||||
|
||||
|
||||
@router.get("/status/all")
|
||||
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
||||
items = await collect_all_status()
|
||||
return {"nodes": items}
|
||||
|
||||
|
||||
@router.get("/status/stream")
|
||||
async def status_stream(
|
||||
request: Request,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> StreamingResponse:
|
||||
"""SSE:中控约每秒并行拉取策略机状态并推送到浏览器。"""
|
||||
settings = get_control_settings()
|
||||
interval = max(0.5, float(settings.control_sse_interval_sec))
|
||||
probe_timeout = max(0.5, float(settings.control_sse_probe_timeout_sec))
|
||||
|
||||
async def event_gen() -> AsyncIterator[str]:
|
||||
last_payload = ""
|
||||
last_heartbeat = 0.0
|
||||
yield f": connected interval={interval}\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
started = time.monotonic()
|
||||
try:
|
||||
items = await collect_all_status(timeout=probe_timeout)
|
||||
payload = json.dumps(
|
||||
{"nodes": items, "ts_ms": int(time.time() * 1000)},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
now = time.monotonic()
|
||||
if payload != last_payload:
|
||||
last_payload = payload
|
||||
yield f"event: nodes\ndata: {payload}\n\n"
|
||||
last_heartbeat = now
|
||||
elif now - last_heartbeat >= 5.0:
|
||||
yield f": heartbeat {int(time.time())}\n\n"
|
||||
last_heartbeat = now
|
||||
except Exception as e:
|
||||
err = json.dumps(
|
||||
{"detail": str(e)},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
yield f"event: error\ndata: {err}\n\n"
|
||||
elapsed = time.monotonic() - started
|
||||
await asyncio.sleep(max(0.05, interval - elapsed))
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/update-batch")
|
||||
async def update_batch(
|
||||
body: dict,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
ids = body.get("ids") or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
raise HTTPException(status_code=400, detail="ids 不能为空")
|
||||
db = get_control_db()
|
||||
results = []
|
||||
for nid in ids:
|
||||
node = db.get_node(int(nid))
|
||||
if not node:
|
||||
results.append({"id": nid, "ok": False, "detail": "不存在"})
|
||||
continue
|
||||
code, data = await call_node(node, "POST", "/api/fleet/update")
|
||||
results.append(
|
||||
{
|
||||
"id": nid,
|
||||
"ok": code < 400,
|
||||
"status": code,
|
||||
"result": data,
|
||||
}
|
||||
)
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.post("/start-batch")
|
||||
async def start_batch(
|
||||
body: dict,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
"""并行启动多台策略机(Fleet start)。"""
|
||||
ids = body.get("ids") or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
raise HTTPException(status_code=400, detail="ids 不能为空")
|
||||
db = get_control_db()
|
||||
|
||||
async def _one(nid: int) -> dict:
|
||||
node = db.get_node(int(nid))
|
||||
if not node:
|
||||
return {"id": nid, "ok": False, "detail": "不存在", "name": str(nid)}
|
||||
name = str(node.get("name") or nid)
|
||||
if not node.get("token_sealed"):
|
||||
return {"id": nid, "ok": False, "detail": "未生成 Token", "name": name}
|
||||
try:
|
||||
code, data = await call_node(node, "POST", "/api/fleet/start", timeout=20.0)
|
||||
except Exception as ex:
|
||||
return {"id": nid, "ok": False, "detail": str(ex), "name": name}
|
||||
detail = ""
|
||||
if code >= 400:
|
||||
detail = _http_detail(data) if data else f"HTTP {code}"
|
||||
return {
|
||||
"id": nid,
|
||||
"name": name,
|
||||
"ok": code < 400,
|
||||
"status": code,
|
||||
"detail": detail,
|
||||
"result": data,
|
||||
}
|
||||
|
||||
items = list(await asyncio.gather(*[_one(int(x)) for x in ids]))
|
||||
ok_n = sum(1 for x in items if x.get("ok"))
|
||||
return {"ok": ok_n == len(items), "started": ok_n, "total": len(items), "results": items}
|
||||
|
||||
|
||||
@router.patch("/{node_id}")
|
||||
async def update_node(
|
||||
node_id: int,
|
||||
body: NodeUpdate,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
row = db.update_node(node_id, name=body.name, base_url=body.base_url)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
return _public_node(row)
|
||||
|
||||
|
||||
@router.delete("/{node_id}")
|
||||
async def delete_node(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
if not db.delete_node(node_id):
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{node_id}/generate-token")
|
||||
async def generate_token(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
"""生成新 Token,加密存中控;明文仅返回一次,需粘贴到策略机设置。"""
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
plain = secrets.token_urlsafe(32)
|
||||
sealed = seal(plain, get_control_settings().control_auth_secret)
|
||||
db.update_node(node_id, token_sealed=sealed)
|
||||
return {
|
||||
"ok": True,
|
||||
"token": plain,
|
||||
"msg": "请立即复制并到策略机「系统设置 → 登录账户 → 中控 API Token」保存",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{node_id}/status")
|
||||
async def node_status(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
return await _collect_one_status(node)
|
||||
|
||||
|
||||
def _max_single_loss_from_curve(curve: list[Any]) -> float:
|
||||
pnls = [float(x.get("realized_pnl") or 0) for x in curve if isinstance(x, dict)]
|
||||
if not pnls:
|
||||
return 0.0
|
||||
worst = min(pnls)
|
||||
return float(worst) if worst < 0 else 0.0
|
||||
|
||||
|
||||
def _loss_streak_from_curve(curve: list[Any]) -> int:
|
||||
streak = 0
|
||||
for item in reversed(curve):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if float(item.get("realized_pnl") or 0) < 0:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
return streak
|
||||
|
||||
|
||||
async def _collect_one_stats(node: dict[str, Any]) -> dict[str, Any]:
|
||||
base = {
|
||||
"id": node["id"],
|
||||
"name": node["name"],
|
||||
"ok": False,
|
||||
"error": None,
|
||||
"initial_funds": None,
|
||||
"latest_funds": None,
|
||||
"groups": None,
|
||||
"fees_perp": None,
|
||||
"fees_option": None,
|
||||
"total_fees": None,
|
||||
"max_single_loss": None,
|
||||
"loss_streak": None,
|
||||
"total_pnl": None,
|
||||
}
|
||||
if not node.get("token_sealed"):
|
||||
base["error"] = "未生成 Token"
|
||||
return base
|
||||
try:
|
||||
code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0)
|
||||
except Exception as ex:
|
||||
base["error"] = str(ex)
|
||||
return base
|
||||
if code >= 400 or not isinstance(data, dict):
|
||||
base["error"] = _http_detail(data) if data else f"HTTP {code}"
|
||||
return base
|
||||
|
||||
curve = data.get("equity_curve") if isinstance(data.get("equity_curve"), list) else []
|
||||
max_loss = data.get("max_single_loss")
|
||||
if max_loss is None:
|
||||
max_loss = _max_single_loss_from_curve(curve)
|
||||
loss_streak = data.get("loss_streak")
|
||||
if loss_streak is None:
|
||||
loss_streak = _loss_streak_from_curve(curve)
|
||||
|
||||
latest_funds = data.get("latest_funds")
|
||||
if latest_funds is None:
|
||||
# 旧版策略机 stats 无资金字段:回落 status.latest_funds
|
||||
try:
|
||||
sc, sd = await call_node(node, "GET", "/api/fleet/status", timeout=8.0)
|
||||
if sc < 400 and isinstance(sd, dict) and sd.get("latest_funds") is not None:
|
||||
latest_funds = sd.get("latest_funds")
|
||||
except Exception:
|
||||
pass
|
||||
if latest_funds is None:
|
||||
latest_funds = 0.0
|
||||
|
||||
initial_funds = data.get("initial_funds")
|
||||
if initial_funds is None:
|
||||
# 旧版策略机:用最新资金 − 已实现盈亏近似初始资金
|
||||
try:
|
||||
initial_funds = float(latest_funds) - float(data.get("total_pnl") or 0)
|
||||
except Exception:
|
||||
initial_funds = None
|
||||
|
||||
base.update(
|
||||
{
|
||||
"ok": True,
|
||||
"initial_funds": initial_funds,
|
||||
"latest_funds": latest_funds,
|
||||
"groups": data.get("groups"),
|
||||
"fees_perp": data.get("fees_perp"),
|
||||
"fees_option": data.get("fees_option"),
|
||||
"total_fees": data.get("total_fees"),
|
||||
"max_single_loss": max_loss,
|
||||
"loss_streak": loss_streak,
|
||||
"total_pnl": data.get("total_pnl"),
|
||||
"mode": data.get("mode"),
|
||||
}
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
@router.get("/stats/all")
|
||||
async def stats_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
||||
"""并行拉取各策略机统计,供监控区「数据统计」表。"""
|
||||
db = get_control_db()
|
||||
nodes = db.list_nodes()
|
||||
if not nodes:
|
||||
return {"nodes": []}
|
||||
items = list(await asyncio.gather(*[_collect_one_stats(n) for n in nodes]))
|
||||
return {"nodes": items}
|
||||
|
||||
|
||||
@router.get("/{node_id}/stats")
|
||||
async def node_stats(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
"""代理策略机整体统计(点击详情时按需拉取,不进 SSE)。"""
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
if not node.get("token_sealed"):
|
||||
raise HTTPException(status_code=400, detail="未生成 Token")
|
||||
code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0)
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(status_code=502, detail="策略机统计返回异常")
|
||||
return data
|
||||
|
||||
|
||||
class ResidualCloseBody(BaseModel):
|
||||
group_id: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
@router.post("/{node_id}/residual/close")
|
||||
async def node_residual_close(
|
||||
node_id: int,
|
||||
body: ResidualCloseBody,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
"""代理策略机手动平单条残留(只验流动性)。"""
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
if not node.get("token_sealed"):
|
||||
raise HTTPException(status_code=400, detail="未生成 Token")
|
||||
code, data = await call_node(
|
||||
node,
|
||||
"POST",
|
||||
"/api/fleet/residual/close",
|
||||
json_body={"group_id": body.group_id},
|
||||
timeout=30.0,
|
||||
)
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
return {"ok": True, "result": data}
|
||||
|
||||
|
||||
@router.post("/{node_id}/start")
|
||||
async def node_start(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
code, data = await call_node(node, "POST", "/api/fleet/start")
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
return {"ok": True, "result": data}
|
||||
|
||||
|
||||
@router.post("/{node_id}/pause")
|
||||
async def node_pause(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
code, data = await call_node(node, "POST", "/api/fleet/pause")
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
return {"ok": True, "result": data}
|
||||
|
||||
|
||||
@router.post("/{node_id}/update")
|
||||
async def node_update(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
code, data = await call_node(node, "POST", "/api/fleet/update")
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
return {"ok": True, "result": data}
|
||||
|
||||
|
||||
@router.post("/{node_id}/login-url")
|
||||
async def node_login_url(
|
||||
node_id: int,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
"""用 Fleet Token 向策略机签发一次性登录票,返回可打开的 URL。"""
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
path = ""
|
||||
if isinstance(data, dict):
|
||||
path = str(data.get("login_path") or "")
|
||||
if not path:
|
||||
raise HTTPException(status_code=502, detail="策略机未返回 login_path")
|
||||
base = str(node["base_url"]).rstrip("/")
|
||||
return {
|
||||
"ok": True,
|
||||
"url": f"{base}{path}",
|
||||
"expires_in": data.get("expires_in") if isinstance(data, dict) else None,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""中控登录 HMAC Token。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from .config import ControlSettings, get_control_settings
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def issue_token(username: str, settings: ControlSettings) -> tuple[str, int]:
|
||||
exp = int(time.time()) + int(settings.control_token_ttl_sec)
|
||||
payload = {
|
||||
"u": username,
|
||||
"exp": exp,
|
||||
"v": int(settings.control_auth_token_version),
|
||||
}
|
||||
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
sig = hmac.new(
|
||||
settings.control_auth_secret.encode("utf-8"),
|
||||
raw.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{raw}.{sig}", settings.control_token_ttl_sec
|
||||
|
||||
|
||||
def verify_token(token: str, settings: ControlSettings) -> str:
|
||||
try:
|
||||
raw, sig = token.rsplit(".", 1)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail="invalid token") from e
|
||||
expect = hmac.new(
|
||||
settings.control_auth_secret.encode("utf-8"),
|
||||
raw.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expect, sig):
|
||||
raise HTTPException(status_code=401, detail="invalid token")
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(raw))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail="invalid token") from e
|
||||
if int(payload.get("exp") or 0) < int(time.time()):
|
||||
raise HTTPException(status_code=401, detail="token expired")
|
||||
if int(payload.get("v") or 0) != int(settings.control_auth_token_version):
|
||||
raise HTTPException(status_code=401, detail="token revoked")
|
||||
username = str(payload.get("u") or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="invalid token")
|
||||
return username
|
||||
|
||||
|
||||
def require_control_user(
|
||||
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
settings: Annotated[ControlSettings, Depends(get_control_settings)],
|
||||
) -> str:
|
||||
if creds is None or not creds.credentials:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
|
||||
return verify_token(creds.credentials, settings)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""中控配置。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _control_root() -> Path:
|
||||
# control/backend/app/config.py -> control/
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class ControlSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(_repo_root() / ".env.control"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
control_auth_username: str = "admin"
|
||||
control_auth_password: str = "admin123"
|
||||
control_auth_secret: str = "change-me-control-secret-please"
|
||||
control_auth_token_version: int = 1
|
||||
control_token_ttl_sec: int = 7 * 24 * 3600
|
||||
control_db_path: str = ""
|
||||
control_poll_interval_sec: int = 8
|
||||
control_sse_interval_sec: float = 1.0
|
||||
control_sse_probe_timeout_sec: float = 2.5
|
||||
control_http_timeout_sec: float = 12.0
|
||||
control_port: int = 5160
|
||||
# "1"/"0":局域网客户端免密登录
|
||||
control_lan_auth_bypass: str = "0"
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.control_db_path.strip():
|
||||
return Path(self.control_db_path)
|
||||
return _control_root() / "data" / "control.db"
|
||||
|
||||
@property
|
||||
def lan_auth_bypass(self) -> bool:
|
||||
return self.control_lan_auth_bypass.strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_default_credentials(self) -> bool:
|
||||
return (
|
||||
self.control_auth_username.strip() == "admin"
|
||||
and self.control_auth_password == "admin123"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_control_settings() -> ControlSettings:
|
||||
return ControlSettings()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user