Compare commits
198 Commits
e3671d9798
..
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 |
@@ -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 — 独立自动对冲模拟盘
|
# eth_hedge_sim — 独立自动对冲(SIM 本地撮合 / LIVE 实盘)
|
||||||
# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。
|
# 复制为 .env 后按需填写。真密钥不上库。
|
||||||
|
|
||||||
|
# SIM | LIVE(设置页可改;切 LIVE 须二次确认)
|
||||||
MODE=SIM
|
MODE=SIM
|
||||||
ENV_NAME=test
|
ENV_NAME=test
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
@@ -17,7 +18,7 @@ AUTH_PASSWORD=admin123
|
|||||||
AUTH_SECRET=change-me-eth-hedge-sim-secret
|
AUTH_SECRET=change-me-eth-hedge-sim-secret
|
||||||
AUTH_TOKEN_TTL_SEC=604800
|
AUTH_TOKEN_TTL_SEC=604800
|
||||||
|
|
||||||
# OKX(SIM 阶段公共盘口可不填 Key)
|
# OKX(SIM 公共盘口可不填;LIVE 下单必填)
|
||||||
OKX_API_KEY=
|
OKX_API_KEY=
|
||||||
OKX_API_SECRET=
|
OKX_API_SECRET=
|
||||||
OKX_API_PASSPHRASE=
|
OKX_API_PASSPHRASE=
|
||||||
@@ -26,6 +27,10 @@ OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public
|
|||||||
# 云上一般直连留空;本机受限时再填代理
|
# 云上一般直连留空;本机受限时再填代理
|
||||||
OKX_HTTP_PROXY=
|
OKX_HTTP_PROXY=
|
||||||
|
|
||||||
|
# 币安私有交易密钥(可落盘;实盘下单后续接入)
|
||||||
|
BINANCE_API_KEY=
|
||||||
|
BINANCE_API_SECRET=
|
||||||
|
|
||||||
# 币安公共行情(SIM)
|
# 币安公共行情(SIM)
|
||||||
BINANCE_FAPI_BASE=https://fapi.binance.com
|
BINANCE_FAPI_BASE=https://fapi.binance.com
|
||||||
BINANCE_EAPI_BASE=https://eapi.binance.com
|
BINANCE_EAPI_BASE=https://eapi.binance.com
|
||||||
@@ -50,3 +55,9 @@ CLOSE_BID_MARK_MAX_PCT=30
|
|||||||
REST_SECONDS=300
|
REST_SECONDS=300
|
||||||
PERP_QTY_ETH=1
|
PERP_QTY_ETH=1
|
||||||
OPTION_QTY_ETH=2
|
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 / secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.control
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# python
|
# python
|
||||||
@@ -18,6 +19,10 @@ backend/data/*.db
|
|||||||
backend/data/*.sqlite
|
backend/data/*.sqlite
|
||||||
backend/data/*.sqlite3
|
backend/data/*.sqlite3
|
||||||
!backend/data/.gitkeep
|
!backend/data/.gitkeep
|
||||||
|
control/data/*
|
||||||
|
!control/data/.gitkeep
|
||||||
|
control/frontend/node_modules/
|
||||||
|
control/frontend/dist/
|
||||||
|
|
||||||
# frontend
|
# frontend
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|||||||
@@ -1,26 +1,45 @@
|
|||||||
# eth_hedge_sim
|
# 比特骆驼自动化对冲系统(eth_hedge_sim)
|
||||||
|
|
||||||
独立自动对冲**模拟盘**:OKX 实盘只读行情 + 本地虚拟资金撮合。
|
**产品名:比特骆驼自动化对冲系统**
|
||||||
|
工程仓库 / 目录名:`eth_hedge_sim`(与产品名分离,部署路径不变)。
|
||||||
|
|
||||||
|
独立自动对冲系统:OKX / 币安行情 + 本地 SIM 撮合,可选 LIVE 实盘。
|
||||||
与现网 `crypto_monitor` **无代码、进程、密钥共用**。
|
与现网 `crypto_monitor` **无代码、进程、密钥共用**。
|
||||||
|
|
||||||
仓库:<https://git.bz121.com/dekun/eth_hedge_sim.git>
|
仓库:<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)
|
- [商业化与授权方案](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`)
|
- 反代:[`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 地址配置项
|
- 可在「系统设置」修改用户名/密码;前端固定同源 API,无 API 地址配置项
|
||||||
|
- **本地中控**:`http://<局域网IP>:5160`(PM2: `eth-hedge-control`);说明见 [中控 Fleet 说明](docs/中控Fleet说明.md)
|
||||||
|
|
||||||
## 一键部署 / 更新(禁止 scp 传代码)
|
## 一键部署 / 更新(禁止 scp 传代码)
|
||||||
|
|
||||||
服务器目录:`/opt/eth_hedge_sim`
|
目标机:**Ubuntu 22.04**,目录 `/opt/eth_hedge_sim`。
|
||||||
更新方式:**只允许 `git pull`**,然后构建并 `pm2 startOrReload` 本项目进程。
|
更新方式:**只允许 `git pull`**,然后构建并 `pm2 startOrReload` 本项目进程。
|
||||||
|
|
||||||
### 推荐:curl 一键管理器(菜单)
|
### 推荐:curl 一键管理器(菜单)
|
||||||
@@ -31,9 +50,11 @@ curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/mana
|
|||||||
|
|
||||||
菜单:
|
菜单:
|
||||||
|
|
||||||
1. 一键部署
|
1. 一键部署策略机
|
||||||
2. 一键卸载(仅停删 `eth-hedge-api`,移走目录并备份 `.env`)
|
2. 一键部署中控机
|
||||||
3. 更新(`git pull` + 构建 + reload)
|
3. 一键卸载
|
||||||
|
4. 更新策略机
|
||||||
|
5. 更新中控机
|
||||||
0. 退出
|
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
|
bash /opt/eth_hedge_sim/deploy/manage.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 本机触发远程更新
|
### 从开发机触发远程更新(可选)
|
||||||
|
|
||||||
|
在能 SSH 到 Ubuntu 服务器的机器上:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install paramiko
|
pip install paramiko
|
||||||
set DEPLOY_PASS=*** # Windows PowerShell: $env:DEPLOY_PASS='***'
|
export DEPLOY_PASS='***'
|
||||||
python scripts/deploy_remote.py
|
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
|
```bash
|
||||||
python -m venv .venv
|
cd /opt/eth_hedge_sim
|
||||||
.venv\Scripts\activate
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
copy .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
python scripts/smoke_market.py
|
python scripts/smoke_market.py
|
||||||
cd backend
|
cd backend
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 5155 --reload
|
uvicorn app.main:app --host 0.0.0.0 --port 5155 --reload
|
||||||
|
|
||||||
# 另开终端
|
# 另开终端
|
||||||
cd frontend
|
cd /opt/eth_hedge_sim/frontend
|
||||||
npm ci
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -76,3 +101,4 @@ npm run dev
|
|||||||
- 永续市价;期权只吃买卖一;滑点 = 1×手续费
|
- 永续市价;期权只吃买卖一;滑点 = 1×手续费
|
||||||
- 仓位:永续 1 ETH,期权 2 ETH 名义
|
- 仓位:永续 1 ETH,期权 2 ETH 名义
|
||||||
- 部署更新只用 git pull,不用 scp
|
- 部署更新只用 git pull,不用 scp
|
||||||
|
- 部署环境固定为 Ubuntu 22.04
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .auth_routes import router as auth_router
|
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 .market import router as market_router
|
||||||
from .plan import router as plan_router
|
from .plan import router as plan_router
|
||||||
from .settings import router as settings_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(plan_router)
|
||||||
router.include_router(trades_router)
|
router.include_router(trades_router)
|
||||||
router.include_router(stats_router)
|
router.include_router(stats_router)
|
||||||
|
router.include_router(funds_router)
|
||||||
router.include_router(settings_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]:
|
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
|
||||||
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
|
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
|
||||||
payload = {"u": username, "exp": exp}
|
payload = {
|
||||||
|
"u": username,
|
||||||
|
"exp": exp,
|
||||||
|
"v": int(settings.auth_token_version),
|
||||||
|
}
|
||||||
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||||
sig = hmac.new(
|
sig = hmac.new(
|
||||||
settings.auth_secret.encode("utf-8"),
|
settings.auth_secret.encode("utf-8"),
|
||||||
@@ -70,6 +74,8 @@ def verify_token(token: str, settings: Settings) -> str:
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
|
||||||
if int(payload.get("exp") or 0) < int(time.time()):
|
if int(payload.get("exp") or 0) < int(time.time()):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
|
||||||
|
if int(payload.get("v") or 0) != int(settings.auth_token_version):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token revoked")
|
||||||
username = str(payload.get("u") or "")
|
username = str(payload.get("u") or "")
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
|
||||||
|
|||||||
+122
-14
@@ -1,28 +1,93 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..config import Settings, get_settings
|
from ..config import Settings, get_settings
|
||||||
from ..credentials import get_credentials, update_credentials
|
from ..credentials import get_credentials, update_credentials, upsert_env_file
|
||||||
from .auth import LoginRequest, LoginResponse, issue_token, require_user
|
from .auth import LoginRequest, LoginResponse, issue_token, require_user
|
||||||
|
from .fleet import consume_login_ticket
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
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):
|
class ChangeCredentialsRequest(BaseModel):
|
||||||
current_password: str = Field(min_length=1)
|
current_password: str = Field(min_length=1)
|
||||||
new_username: str = Field(min_length=1, max_length=64)
|
new_username: str = Field(min_length=1, max_length=64)
|
||||||
new_password: str = Field(min_length=4, max_length=128)
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
xff = request.headers.get("x-forwarded-for") or ""
|
||||||
|
if xff.strip():
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
if request.client:
|
||||||
|
return request.client.host or "unknown"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_login(ip: str, settings: Settings) -> None:
|
||||||
|
now = time.time()
|
||||||
|
window = float(settings.login_window_sec)
|
||||||
|
max_n = int(settings.login_max_attempts)
|
||||||
|
hits = [t for t in _login_hits[ip] if now - t < window]
|
||||||
|
_login_hits[ip] = hits
|
||||||
|
if len(hits) >= max_n:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"登录过于频繁,请 {int(window)} 秒后再试",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/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)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
|
async def login(
|
||||||
|
body: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
) -> LoginResponse:
|
||||||
|
ip = _client_ip(request)
|
||||||
|
_rate_limit_login(ip, settings)
|
||||||
user, pwd = get_credentials()
|
user, pwd = get_credentials()
|
||||||
if body.username != user or body.password != pwd:
|
user_ok = hmac.compare_digest(
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
body.username.encode("utf-8"), user.encode("utf-8")
|
||||||
|
)
|
||||||
|
pwd_ok = hmac.compare_digest(
|
||||||
|
body.password.encode("utf-8"), pwd.encode("utf-8")
|
||||||
|
)
|
||||||
|
if not (user_ok and pwd_ok):
|
||||||
|
_login_hits[ip].append(time.time())
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误"
|
||||||
|
)
|
||||||
|
_login_hits.pop(ip, None)
|
||||||
token, ttl = issue_token(body.username, settings)
|
token, ttl = issue_token(body.username, settings)
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -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")
|
@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 {
|
return {
|
||||||
"username": username,
|
"username": username,
|
||||||
"env_name": settings.env_name,
|
"env_name": settings.env_name,
|
||||||
"mode": settings.mode,
|
"mode": settings.mode,
|
||||||
"sim": settings.is_sim,
|
"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)
|
@router.post("/change-credentials", response_model=LoginResponse)
|
||||||
async def change_credentials(
|
async def change_credentials(
|
||||||
body: ChangeCredentialsRequest,
|
body: ChangeCredentialsRequest,
|
||||||
@@ -50,17 +145,30 @@ async def change_credentials(
|
|||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
) -> LoginResponse:
|
) -> LoginResponse:
|
||||||
_cur_user, cur_pwd = get_credentials()
|
_cur_user, cur_pwd = get_credentials()
|
||||||
if body.current_password != cur_pwd:
|
if not hmac.compare_digest(
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
|
body.current_password.encode("utf-8"), cur_pwd.encode("utf-8")
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
update_credentials(new_username=body.new_username, new_password=body.new_password)
|
update_credentials(
|
||||||
|
new_username=body.new_username, new_password=body.new_password
|
||||||
|
)
|
||||||
|
# 作废旧 token
|
||||||
|
new_ver = int(settings.auth_token_version) + 1
|
||||||
|
upsert_env_file("AUTH_TOKEN_VERSION", str(new_ver))
|
||||||
|
get_settings.cache_clear()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
raise HTTPException(
|
||||||
token, ttl = issue_token(body.new_username.strip(), settings)
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||||
|
) from e
|
||||||
|
settings2 = get_settings()
|
||||||
|
token, ttl = issue_token(body.new_username.strip(), settings2)
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
username=body.new_username.strip(),
|
username=body.new_username.strip(),
|
||||||
expires_in=ttl,
|
expires_in=ttl,
|
||||||
env_name=settings.env_name,
|
env_name=settings2.env_name,
|
||||||
mode=settings.mode,
|
mode=settings2.mode,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ..market import get_gateway
|
from ..market import get_gateway
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
@@ -18,6 +18,21 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
|
|||||||
return snap
|
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")
|
@router.post("/realign")
|
||||||
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
|
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
|
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
|
||||||
|
|||||||
+88
-2
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
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 ..strategy import get_engine
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
|
|
||||||
@@ -28,3 +31,86 @@ async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict:
|
|||||||
@router.post("/emergency-close")
|
@router.post("/emergency-close")
|
||||||
async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict:
|
async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
return await get_engine().emergency_close()
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..config import get_settings
|
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 (
|
from ..exchange.runtime import (
|
||||||
load_runtime_settings,
|
load_runtime_settings,
|
||||||
normalize_exchange_name,
|
normalize_exchange_name,
|
||||||
@@ -26,16 +33,57 @@ KEYS = (
|
|||||||
"net_profit_target",
|
"net_profit_target",
|
||||||
"premium_exit_multiple",
|
"premium_exit_multiple",
|
||||||
"rest_seconds",
|
"rest_seconds",
|
||||||
|
"live_order_interval_sec",
|
||||||
"skip_weekends",
|
"skip_weekends",
|
||||||
|
"one_expiry_per_day",
|
||||||
"initial_equity",
|
"initial_equity",
|
||||||
"leverage",
|
"leverage",
|
||||||
|
"perp_margin_mode",
|
||||||
"min_option_hours",
|
"min_option_hours",
|
||||||
"min_option_leverage",
|
"min_option_leverage",
|
||||||
"atm_open_offset_enabled",
|
"atm_open_offset_enabled",
|
||||||
"max_atm_open_offset",
|
"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",
|
"close_bid_mark_max_pct",
|
||||||
|
"residual_min_premium_pct",
|
||||||
|
"residual_close_check_sec",
|
||||||
"perp_qty_eth",
|
"perp_qty_eth",
|
||||||
"option_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)
|
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)
|
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
|
||||||
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
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
|
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)
|
initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000)
|
||||||
leverage: float | None = Field(default=None, ge=1, le=125)
|
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_hours: float | None = Field(default=None, ge=1, le=720)
|
||||||
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
||||||
atm_open_offset_enabled: bool | None = None
|
atm_open_offset_enabled: bool | None = None
|
||||||
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
|
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)
|
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)
|
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)
|
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)$")
|
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:
|
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")
|
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:
|
def _read_settings() -> dict:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
@@ -89,13 +193,37 @@ def _read_settings() -> dict:
|
|||||||
"rest_seconds": int(
|
"rest_seconds": int(
|
||||||
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
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(
|
"skip_weekends": _as_bool(
|
||||||
db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends
|
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(
|
"initial_equity": float(
|
||||||
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
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),
|
"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(
|
"min_option_hours": float(
|
||||||
db.get_setting("min_option_hours", str(s.min_option_hours))
|
db.get_setting("min_option_hours", str(s.min_option_hours))
|
||||||
or 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))
|
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
|
||||||
or 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(
|
"close_bid_mark_max_pct": float(
|
||||||
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
|
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
|
||||||
or 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(
|
"perp_qty_eth": float(
|
||||||
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
||||||
),
|
),
|
||||||
"option_qty_eth": float(
|
"option_qty_eth": float(
|
||||||
db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth
|
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,
|
"exchange": rt.exchange,
|
||||||
"perp_inst_id": rt.perp_inst_id,
|
"perp_inst_id": rt.perp_inst_id,
|
||||||
"option_inst_family": rt.option_inst_family,
|
"option_inst_family": rt.option_inst_family,
|
||||||
@@ -145,8 +505,13 @@ async def put_strategy_settings(
|
|||||||
db = get_db()
|
db = get_db()
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
data = body.model_dump(exclude_none=True)
|
data = body.model_dump(exclude_none=True)
|
||||||
|
# 授权态只经 /api/plan/semi/arm;设置接口不可伪造 armed=true
|
||||||
|
data.pop("semi_armed", None)
|
||||||
equity_to_apply: float | None = None
|
equity_to_apply: float | None = None
|
||||||
switch_to: str | 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:
|
if "exchange" in data:
|
||||||
new_ex = normalize_exchange_name(str(data.pop("exchange")))
|
new_ex = normalize_exchange_name(str(data.pop("exchange")))
|
||||||
@@ -174,6 +539,189 @@ async def put_strategy_settings(
|
|||||||
)
|
)
|
||||||
equity_to_apply = new_eq
|
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():
|
for k, v in data.items():
|
||||||
if k in KEYS:
|
if k in KEYS:
|
||||||
db.set_setting(k, str(v))
|
db.set_setting(k, str(v))
|
||||||
@@ -194,4 +742,211 @@ async def put_strategy_settings(
|
|||||||
detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}",
|
detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}",
|
||||||
) from 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()
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from ..env_store import live_ready
|
||||||
|
from ..live import get_executor
|
||||||
from ..market import get_gateway
|
from ..market import get_gateway
|
||||||
from ..models.db import get_db
|
from ..models.db import get_db
|
||||||
from ..sim.ledger import Ledger
|
from ..sim.ledger import Ledger
|
||||||
from ..sim.matcher import Matcher
|
|
||||||
from ..strategy.clock import can_open_new, window_key
|
from ..strategy.clock import can_open_new, window_key
|
||||||
from ..strategy.group import next_group_id
|
from ..strategy.group import next_group_id
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
@@ -29,7 +30,7 @@ async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict:
|
|||||||
|
|
||||||
@router.get("/position")
|
@router.get("/position")
|
||||||
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
|
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
m = Matcher()
|
m = get_executor()
|
||||||
return {"position": m.current_position(), "unrealized": m.unrealized()}
|
return {"position": m.current_position(), "unrealized": m.unrealized()}
|
||||||
|
|
||||||
|
|
||||||
@@ -38,10 +39,28 @@ async def sim_open_group(
|
|||||||
_user: Annotated[str, Depends(require_user)],
|
_user: Annotated[str, Depends(require_user)],
|
||||||
body: ManualOpenBody | None = None,
|
body: ManualOpenBody | None = None,
|
||||||
) -> dict:
|
) -> 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="有未平仓,禁止开下一组")
|
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||||
s = get_settings()
|
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):
|
if not can_open_new(skip_weekends=skip_weekends):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
@@ -54,6 +73,22 @@ async def sim_open_group(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
detail="无合格期权:请检查剩余时长、ATM开仓偏差(若已开启)与杠杆(现价/卖一)",
|
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
|
force = (body.force_option_side if body else None) or None
|
||||||
if force in ("call", "put"):
|
if force in ("call", "put"):
|
||||||
@@ -78,14 +113,34 @@ async def sim_open_group(
|
|||||||
option_inst = (
|
option_inst = (
|
||||||
pick.pair.call_inst_id if option_side == "call" else pick.pair.put_inst_id
|
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()
|
wkey = window_key()
|
||||||
db = get_db()
|
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(
|
count = len(
|
||||||
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
||||||
)
|
)
|
||||||
gid = next_group_id(count)
|
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,
|
group_id=gid,
|
||||||
bias=bias,
|
bias=bias,
|
||||||
option_side=option_side,
|
option_side=option_side,
|
||||||
@@ -97,6 +152,24 @@ async def sim_open_group(
|
|||||||
)
|
)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
raise HTTPException(status_code=400, detail=r.detail)
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
**(r.data or {}),
|
**(r.data or {}),
|
||||||
@@ -110,9 +183,32 @@ async def sim_open_group(
|
|||||||
|
|
||||||
@router.post("/close-group")
|
@router.post("/close-group")
|
||||||
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
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:
|
if not r.ok and not r.liquidity_wait:
|
||||||
raise HTTPException(status_code=400, detail=r.detail)
|
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 {
|
return {
|
||||||
"ok": r.ok,
|
"ok": r.ok,
|
||||||
"liquidity_wait": r.liquidity_wait,
|
"liquidity_wait": r.liquidity_wait,
|
||||||
|
|||||||
@@ -1,24 +1,59 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
from ..models.db import get_db
|
from ..models.db import get_db
|
||||||
|
from ..sim.pnl import summarize_fills_pnl
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
def _current_loss_streak(curve: list[dict[str, Any]]) -> int:
|
||||||
async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
|
"""从最近已平仓组往前数连续亏损次数。"""
|
||||||
db = get_db()
|
streak = 0
|
||||||
rows = db.fetchall("SELECT * FROM groups WHERE status='closed'")
|
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)
|
n = len(rows)
|
||||||
wins = sum(1 for r in rows if float(r["realized_pnl"] or 0) > 0)
|
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_pnl = sum(float(r["realized_pnl"] or 0) for r in rows)
|
||||||
total_fees = sum(float(r["fees"] or 0) for r in rows)
|
pnls = [float(r["realized_pnl"] or 0) for r in rows]
|
||||||
total_slip = sum(float(r["slip_cost"] 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] = {}
|
reasons: dict[str, int] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
k = str(r["close_reason"] or "unknown")
|
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))
|
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 {
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"show_slip": mode == "SIM",
|
||||||
"groups": n,
|
"groups": n,
|
||||||
"wins": wins,
|
"wins": wins,
|
||||||
"win_rate": (wins / n) if n else 0.0,
|
"win_rate": (wins / n) if n else 0.0,
|
||||||
"total_pnl": total_pnl,
|
"total_pnl": total_pnl,
|
||||||
|
"fees_perp": fees_perp,
|
||||||
|
"fees_option": fees_option,
|
||||||
"total_fees": total_fees,
|
"total_fees": total_fees,
|
||||||
"total_slip": total_slip,
|
"total_slip": total_slip if mode == "SIM" else 0.0,
|
||||||
"close_reasons": reasons,
|
"close_reasons": reasons,
|
||||||
"equity_curve": curve,
|
"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 fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..models.db import get_db
|
from ..models.db import get_db
|
||||||
|
from ..sim.pnl import summarize_fills_pnl
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
|
from .hold_timing import hold_timing
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/trades", tags=["trades"])
|
router = APIRouter(prefix="/api/trades", tags=["trades"])
|
||||||
|
|
||||||
@@ -14,16 +16,463 @@ def _row(r: Any) -> dict:
|
|||||||
return dict(r)
|
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")
|
@router.get("/groups")
|
||||||
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
|
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
rows = get_db().fetchall(
|
db = get_db()
|
||||||
"SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200"
|
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}")
|
@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()
|
db = get_db()
|
||||||
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
||||||
if g is None:
|
if g is None:
|
||||||
@@ -31,4 +480,66 @@ async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user
|
|||||||
fills = db.fetchall(
|
fills = db.fetchall(
|
||||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
"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_password: str = "admin123"
|
||||||
auth_secret: str = "change-me-eth-hedge-sim-secret"
|
auth_secret: str = "change-me-eth-hedge-sim-secret"
|
||||||
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
|
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
|
||||||
|
auth_token_version: int = 1 # 改密时递增,作废旧 token
|
||||||
|
disable_api_docs: bool = True # 生产默认关闭 /docs
|
||||||
|
login_max_attempts: int = 8
|
||||||
|
login_window_sec: int = 300
|
||||||
|
|
||||||
okx_api_key: str = ""
|
okx_api_key: str = ""
|
||||||
okx_api_secret: str = ""
|
okx_api_secret: str = ""
|
||||||
@@ -33,6 +37,10 @@ class Settings(BaseSettings):
|
|||||||
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
|
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
|
||||||
okx_http_proxy: str = ""
|
okx_http_proxy: str = ""
|
||||||
|
|
||||||
|
# 币安私有交易密钥(LIVE 真下单:fapi 永续 + eapi 期权)
|
||||||
|
binance_api_key: str = ""
|
||||||
|
binance_api_secret: str = ""
|
||||||
|
|
||||||
# 币安公共行情(SIM 只读)
|
# 币安公共行情(SIM 只读)
|
||||||
binance_fapi_base: str = "https://fapi.binance.com"
|
binance_fapi_base: str = "https://fapi.binance.com"
|
||||||
binance_eapi_base: str = "https://eapi.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)
|
net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT)
|
||||||
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
|
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
|
||||||
rest_seconds: int = 300
|
rest_seconds: int = 300
|
||||||
|
live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒)
|
||||||
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
|
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
|
||||||
|
# 每个到期只开一次(跨日):历史上已开过某 expiry_ymd 后不可再开同到期,只能盯下一档
|
||||||
|
one_expiry_per_day: bool = True
|
||||||
leverage: float = 3.0 # 永续杠杆
|
leverage: float = 3.0 # 永续杠杆
|
||||||
|
# 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cash(OKX 逐仓/现金)
|
||||||
|
perp_margin_mode: str = "cross"
|
||||||
min_option_hours: float = 12.0 # 期权最小剩余小时
|
min_option_hours: float = 12.0 # 期权最小剩余小时
|
||||||
min_option_leverage: float = 100.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 偏差限制开关(默认关)
|
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
|
||||||
max_atm_open_offset: float = 3.0 # 开启后:|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 # 平仓:买一相对标记最大偏差%
|
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
|
perp_qty_eth: float = 1.0
|
||||||
option_qty_eth: float = 2.0
|
option_qty_eth: float = 2.0
|
||||||
db_path: str = "" # empty -> backend/data/hedge.db
|
db_path: str = "" # empty -> backend/data/hedge.db
|
||||||
|
|
||||||
|
# 企业微信群机器人
|
||||||
|
wecom_enabled: bool = False
|
||||||
|
wecom_webhook_url: str = ""
|
||||||
|
wecom_machine_name: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_sim(self) -> bool:
|
def is_sim(self) -> bool:
|
||||||
return self.mode.strip().upper() != "LIVE"
|
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"]
|
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:
|
def _ensure_loaded() -> None:
|
||||||
global _username, _password
|
global _username, _password
|
||||||
if _username is not None and _password is not None:
|
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:
|
def upsert_env_file(key: str, value: str) -> Path | None:
|
||||||
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
|
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
|
||||||
|
if "\n" in value or "\r" in value:
|
||||||
|
raise ValueError(f"{key} 值不能包含换行")
|
||||||
paths = _env_paths()
|
paths = _env_paths()
|
||||||
target = next((p for p in paths if p.is_file()), paths[0])
|
target = next((p for p in paths if p.is_file()), paths[0])
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
text = target.read_text(encoding="utf-8") if target.is_file() else ""
|
text = target.read_text(encoding="utf-8") if target.is_file() else ""
|
||||||
line = f"{key}={value}"
|
# 简单引号,避免空格/特殊字符破坏解析
|
||||||
|
safe = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
line = f'{key}="{safe}"'
|
||||||
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
|
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
|
||||||
if pattern.search(text):
|
if pattern.search(text):
|
||||||
text = pattern.sub(line, text)
|
text = pattern.sub(line, text)
|
||||||
@@ -51,6 +61,10 @@ def upsert_env_file(key: str, value: str) -> Path | None:
|
|||||||
text += "\n"
|
text += "\n"
|
||||||
text += line + "\n"
|
text += line + "\n"
|
||||||
target.write_text(text, encoding="utf-8")
|
target.write_text(text, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
target.chmod(0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
strike: float
|
||||||
call_inst_id: str
|
call_inst_id: str
|
||||||
put_inst_id: str
|
put_inst_id: str
|
||||||
|
put_strike: float | None = None # 期期:Put 行权价;None=与 strike 同(ATM)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"expiry_ymd": self.expiry_ymd,
|
"expiry_ymd": self.expiry_ymd,
|
||||||
"expiry_ms": self.expiry_ms,
|
"expiry_ms": self.expiry_ms,
|
||||||
"strike": self.strike,
|
"strike": self.strike,
|
||||||
|
"call_strike": float(self.strike),
|
||||||
|
"put_strike": put_k,
|
||||||
"call_inst_id": self.call_inst_id,
|
"call_inst_id": self.call_inst_id,
|
||||||
"put_inst_id": self.put_inst_id,
|
"put_inst_id": self.put_inst_id,
|
||||||
}
|
}
|
||||||
@@ -88,7 +96,26 @@ def _open_bias(
|
|||||||
call: Quote | None,
|
call: Quote | None,
|
||||||
put: Quote | None,
|
put: Quote | None,
|
||||||
) -> str:
|
) -> 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
|
mark = None
|
||||||
if index_px is not None and index_px > 0:
|
if index_px is not None and index_px > 0:
|
||||||
mark = float(index_px)
|
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 → 合约号/盘口错乱(期权一直 -/-)
|
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
|
||||||
from .exchange.runtime import load_runtime_settings
|
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()
|
settings = load_runtime_settings()
|
||||||
engine = StrategyEngine()
|
engine = StrategyEngine()
|
||||||
set_engine(engine)
|
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()
|
engine.ensure_loop()
|
||||||
|
|
||||||
|
from .backup import start_auto_backup_task
|
||||||
|
|
||||||
|
start_auto_backup_task()
|
||||||
|
|
||||||
session = bootstrap_session(settings)
|
session = bootstrap_session(settings)
|
||||||
try:
|
try:
|
||||||
await session.start()
|
await session.start()
|
||||||
logger.info(
|
logger.info(
|
||||||
"exchange=%s strategy session started (SIM)",
|
"exchange=%s strategy session started mode=%s",
|
||||||
settings.exchange,
|
settings.exchange,
|
||||||
|
"SIM" if get_settings().is_sim else "LIVE",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("strategy session failed to start")
|
logger.exception("strategy session failed to start")
|
||||||
@@ -60,6 +100,9 @@ async def lifespan(app: FastAPI):
|
|||||||
await engine._task
|
await engine._task
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
from .backup import stop_auto_backup_task
|
||||||
|
|
||||||
|
await stop_auto_backup_task()
|
||||||
await session.stop()
|
await session.stop()
|
||||||
from .exchange import set_exchange
|
from .exchange import set_exchange
|
||||||
from .strategy.session import set_session
|
from .strategy.session import set_session
|
||||||
@@ -72,15 +115,18 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="eth_hedge_sim",
|
title="比特骆驼自动化对冲系统",
|
||||||
version="0.3.0",
|
version="0.3.1",
|
||||||
description="ETH 自动对冲模拟盘 P1/P2",
|
description="比特骆驼自动化对冲系统(eth_hedge_sim)",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
docs_url=None if get_settings().disable_api_docs else "/docs",
|
||||||
|
redoc_url=None if get_settings().disable_api_docs else "/redoc",
|
||||||
|
openapi_url=None if get_settings().disable_api_docs else "/openapi.json",
|
||||||
)
|
)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=[],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -127,6 +173,41 @@ _DIST = resolve_frontend_dist()
|
|||||||
if (_DIST / "assets").is_dir():
|
if (_DIST / "assets").is_dir():
|
||||||
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
|
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("/")
|
@app.get("/")
|
||||||
async def index_page():
|
async def index_page():
|
||||||
@@ -146,6 +227,7 @@ async def index_page():
|
|||||||
@app.get("/stats")
|
@app.get("/stats")
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
|
@app.get("/fleet-login")
|
||||||
async def spa_pages(full_path: str = ""):
|
async def spa_pages(full_path: str = ""):
|
||||||
index = _DIST / "index.html"
|
index = _DIST / "index.html"
|
||||||
if not index.exists():
|
if not index.exists():
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def select_option_pair(
|
|||||||
expiry_ymd: str | None = None,
|
expiry_ymd: str | None = None,
|
||||||
min_hours: float | None = None,
|
min_hours: float | None = None,
|
||||||
now=None,
|
now=None,
|
||||||
|
option_side: str | None = None,
|
||||||
) -> OptionPair | None:
|
) -> OptionPair | None:
|
||||||
contracts = normalize_contracts(instruments)
|
contracts = normalize_contracts(instruments)
|
||||||
return _select_pair(
|
return _select_pair(
|
||||||
@@ -48,6 +49,7 @@ def select_option_pair(
|
|||||||
expiry_ymd=expiry_ymd,
|
expiry_ymd=expiry_ymd,
|
||||||
min_hours=min_hours,
|
min_hours=min_hours,
|
||||||
now=now,
|
now=now,
|
||||||
|
option_side=option_side,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,18 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
expiry_ymd TEXT,
|
expiry_ymd TEXT,
|
||||||
entry_index_px REAL,
|
entry_index_px REAL,
|
||||||
initial_premium REAL DEFAULT 0,
|
initial_premium REAL DEFAULT 0,
|
||||||
|
exit_target_usdt REAL,
|
||||||
open_at_ms INTEGER,
|
open_at_ms INTEGER,
|
||||||
close_at_ms INTEGER,
|
close_at_ms INTEGER,
|
||||||
close_reason TEXT,
|
close_reason TEXT,
|
||||||
realized_pnl REAL DEFAULT 0,
|
realized_pnl REAL DEFAULT 0,
|
||||||
fees REAL DEFAULT 0,
|
fees REAL DEFAULT 0,
|
||||||
slip_cost 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 (
|
CREATE TABLE IF NOT EXISTS fills (
|
||||||
@@ -58,6 +63,7 @@ CREATE TABLE IF NOT EXISTS fills (
|
|||||||
slip REAL NOT NULL,
|
slip REAL NOT NULL,
|
||||||
notional REAL NOT NULL,
|
notional REAL NOT NULL,
|
||||||
ts_ms INTEGER NOT NULL,
|
ts_ms INTEGER NOT NULL,
|
||||||
|
exec_mode TEXT,
|
||||||
FOREIGN KEY(group_id) REFERENCES groups(group_id)
|
FOREIGN KEY(group_id) REFERENCES groups(group_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,6 +80,7 @@ CREATE TABLE IF NOT EXISTS positions (
|
|||||||
option_entry_px REAL,
|
option_entry_px REAL,
|
||||||
entry_index_px REAL,
|
entry_index_px REAL,
|
||||||
initial_premium REAL DEFAULT 0,
|
initial_premium REAL DEFAULT 0,
|
||||||
|
exit_target_usdt REAL,
|
||||||
status TEXT
|
status TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,6 +104,38 @@ CREATE TABLE IF NOT EXISTS strategy_state (
|
|||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
updated_at_ms INTEGER NOT NULL
|
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.execute("PRAGMA journal_mode=WAL;")
|
||||||
self._conn.executescript(_SCHEMA)
|
self._conn.executescript(_SCHEMA)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
self._migrate_columns()
|
||||||
self._ensure_seed()
|
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:
|
def close(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._conn.close()
|
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,?)",
|
"INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)",
|
||||||
(now,),
|
(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 = {
|
defaults = {
|
||||||
"fee_rate": str(s.fee_rate),
|
"fee_rate": str(s.fee_rate),
|
||||||
"initial_equity": str(s.initial_equity),
|
"initial_equity": str(s.initial_equity),
|
||||||
@@ -154,13 +243,19 @@ class Database:
|
|||||||
"net_profit_target": str(s.net_profit_target),
|
"net_profit_target": str(s.net_profit_target),
|
||||||
"premium_exit_multiple": str(s.premium_exit_multiple),
|
"premium_exit_multiple": str(s.premium_exit_multiple),
|
||||||
"rest_seconds": str(s.rest_seconds),
|
"rest_seconds": str(s.rest_seconds),
|
||||||
|
"live_order_interval_sec": str(s.live_order_interval_sec),
|
||||||
"skip_weekends": str(s.skip_weekends),
|
"skip_weekends": str(s.skip_weekends),
|
||||||
|
"one_expiry_per_day": str(s.one_expiry_per_day),
|
||||||
"max_rounds": str(s.max_rounds),
|
"max_rounds": str(s.max_rounds),
|
||||||
"leverage": str(s.leverage),
|
"leverage": str(s.leverage),
|
||||||
|
"perp_margin_mode": str(s.perp_margin_mode),
|
||||||
"min_option_hours": str(s.min_option_hours),
|
"min_option_hours": str(s.min_option_hours),
|
||||||
"min_option_leverage": str(s.min_option_leverage),
|
"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),
|
"atm_open_offset_enabled": str(s.atm_open_offset_enabled),
|
||||||
"max_atm_open_offset": str(s.max_atm_open_offset),
|
"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),
|
"close_bid_mark_max_pct": str(s.close_bid_mark_max_pct),
|
||||||
"perp_qty_eth": str(s.perp_qty_eth),
|
"perp_qty_eth": str(s.perp_qty_eth),
|
||||||
"option_qty_eth": str(s.option_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,
|
kind: str,
|
||||||
group_id: str | None = None,
|
group_id: str | None = None,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
|
allow_negative: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""amount>0 入账;amount<0 出账。返回余额。"""
|
"""amount>0 入账;amount<0 出账。返回余额。
|
||||||
|
|
||||||
|
LIVE 实盘成交后本地账本仅作镜像,须 allow_negative=True,避免「交易所已成交、本地拒记」导致卡仓。
|
||||||
|
commit=False:由调用方持锁并统一提交(与持仓/残留状态同事务)。
|
||||||
|
"""
|
||||||
now = int(time.time() * 1000)
|
now = int(time.time() * 1000)
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
row = self.db._conn.execute("SELECT * FROM ledger_meta WHERE id=1").fetchone()
|
row = self.db._conn.execute("SELECT * FROM ledger_meta WHERE id=1").fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
equity = float(row["equity"]) + float(amount)
|
equity = float(row["equity"]) + float(amount)
|
||||||
available = float(row["available"]) + float(amount)
|
available = float(row["available"]) + float(amount)
|
||||||
if available < -1e-9:
|
if not allow_negative and available < -1e-9:
|
||||||
raise RuntimeError("可用资金不足")
|
raise RuntimeError("可用资金不足")
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
|
"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 (?,?,?,?,?,?)",
|
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
|
||||||
(group_id, kind, float(amount), equity, note, now),
|
(group_id, kind, float(amount), equity, note, now),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
self.db._conn.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
|
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:
|
def reset_equity(self, amount: float, *, note: str = "重置模拟资金") -> float:
|
||||||
"""将权益与可用资金重置为 amount(reserved 清零)。须在无持仓时调用。"""
|
"""将权益与可用资金重置为 amount(reserved 清零),并清空交易记录。须在无持仓时调用。"""
|
||||||
now = int(time.time() * 1000)
|
now = int(time.time() * 1000)
|
||||||
amt = float(amount)
|
amt = float(amount)
|
||||||
if amt < 0:
|
if amt < 0:
|
||||||
raise ValueError("模拟资金不能为负")
|
raise ValueError("模拟资金不能为负")
|
||||||
|
self.clear_trade_history()
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"UPDATE ledger_meta SET equity=?, available=?, reserved=0, updated_at_ms=? WHERE id=1",
|
"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),
|
(None, "reset", amt, amt, note, now),
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
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
|
return amt
|
||||||
|
|
||||||
def get_setting_float(self, key: str, default: float) -> float:
|
def get_setting_float(self, key: str, default: float) -> float:
|
||||||
|
|||||||
+1708
-24
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,
|
||||||
|
}
|
||||||
+24
-12
@@ -29,6 +29,22 @@ def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float:
|
|||||||
return 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(
|
def option_expiry_settle(
|
||||||
*,
|
*,
|
||||||
intrinsic: float,
|
intrinsic: float,
|
||||||
@@ -52,21 +68,17 @@ def resolve_option_close_bid(
|
|||||||
bypass_liquidity: bool,
|
bypass_liquidity: bool,
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
"""
|
"""
|
||||||
非到期平仓用买一价;多头卖出不得低于内在价值(SIM)。
|
非到期平仓价(SIM)。
|
||||||
紧急 bypass:max(买一, 标记, 内在价值)。
|
- 常规:买一,且多头卖出不低于内在价值。
|
||||||
|
- 紧急 bypass:对齐 OKX 市价卖,**只按买一**(不用标记/内在价值抬价;
|
||||||
|
标记无法在交易所成交)。bypass 只绕过深度/偏差闸门。
|
||||||
到期请用 option_expiry_settle,不要走本函数。
|
到期请用 option_expiry_settle,不要走本函数。
|
||||||
"""
|
"""
|
||||||
candidates: list[float] = []
|
_ = mark # 保留参数兼容调用方;紧急不再用标记定价
|
||||||
if bid is not None and bid >= 0:
|
|
||||||
candidates.append(float(bid))
|
|
||||||
if bypass_liquidity and mark is not None and mark >= 0:
|
|
||||||
candidates.append(float(mark))
|
|
||||||
if intrinsic is not None and intrinsic >= 0:
|
|
||||||
candidates.append(float(intrinsic))
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
if bypass_liquidity:
|
if bypass_liquidity:
|
||||||
return max(candidates)
|
if bid is None or bid < 0:
|
||||||
|
return None
|
||||||
|
return float(bid)
|
||||||
if bid is None:
|
if bid is None:
|
||||||
return None
|
return None
|
||||||
if intrinsic is not None and intrinsic >= 0:
|
if intrinsic is not None and intrinsic >= 0:
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
_SH = ZoneInfo("Asia/Shanghai")
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
@@ -38,3 +39,52 @@ def can_open_new(
|
|||||||
|
|
||||||
def group_date_ymd(now: datetime | None = None) -> str:
|
def group_date_ymd(now: datetime | None = None) -> str:
|
||||||
return window_key(now)
|
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
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
EXIT_MODE_FIXED = "fixed_usdt"
|
EXIT_MODE_FIXED = "fixed_usdt"
|
||||||
EXIT_MODE_PREMIUM = "premium_multiple"
|
EXIT_MODE_PREMIUM = "premium_multiple"
|
||||||
@@ -29,6 +30,62 @@ def resolve_exit_target(
|
|||||||
return float(net_profit_target), EXIT_MODE_FIXED
|
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(
|
def check_expiry_close(
|
||||||
*,
|
*,
|
||||||
expiry_ms: int | None,
|
expiry_ms: int | None,
|
||||||
@@ -50,14 +107,20 @@ def check_exits(
|
|||||||
net_profit_target: float,
|
net_profit_target: float,
|
||||||
premium_exit_multiple: float,
|
premium_exit_multiple: float,
|
||||||
initial_premium: float,
|
initial_premium: float,
|
||||||
|
locked_exit_target: float | None = None,
|
||||||
) -> ExitDecision:
|
) -> ExitDecision:
|
||||||
"""净盈利(预估全平后)≥ 所选模式目标则全平。"""
|
"""净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。"""
|
||||||
target, mode = resolve_exit_target(
|
# 模式始终按设置解析(权利金倍数 vs 固定),勿因锁定目标就改成 fixed_usdt
|
||||||
|
_resolved_target, mode = resolve_exit_target(
|
||||||
exit_mode=exit_mode,
|
exit_mode=exit_mode,
|
||||||
net_profit_target=net_profit_target,
|
net_profit_target=net_profit_target,
|
||||||
premium_exit_multiple=premium_exit_multiple,
|
premium_exit_multiple=premium_exit_multiple,
|
||||||
initial_premium=initial_premium,
|
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:
|
if target > 0 and net_pnl + 1e-9 >= target:
|
||||||
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
|
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
|
||||||
return ExitDecision(True, reason, target)
|
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))
|
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:
|
def atm_open_offset(strike: float, mark_px: float) -> float:
|
||||||
"""开仓用:ATM 行权价相对标的的绝对点差。"""
|
"""开仓用:ATM 行权价相对标的的绝对点差。"""
|
||||||
return abs(float(strike) - float(mark_px))
|
return abs(float(strike) - float(mark_px))
|
||||||
@@ -125,7 +228,16 @@ def select_option_pair(
|
|||||||
expiry_ymd: str | None = None,
|
expiry_ymd: str | None = None,
|
||||||
min_hours: float | None = None,
|
min_hours: float | None = None,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
|
option_side: str | None = None,
|
||||||
|
moneyness: str | None = None,
|
||||||
|
otm_max_offset: float | None = None,
|
||||||
) -> OptionPair | None:
|
) -> OptionPair | None:
|
||||||
|
"""
|
||||||
|
选到期 + 行权价。
|
||||||
|
option_side 为 call/put 时:按 moneyness 选档(默认实值/平值,兼容固定方向);
|
||||||
|
否则仍选 ATM(现有规则)。
|
||||||
|
moneyness: itm | atm | otm(仅半自动传入 otm/atm)。
|
||||||
|
"""
|
||||||
complete = _complete_by_expiry(contracts)
|
complete = _complete_by_expiry(contracts)
|
||||||
if not complete:
|
if not complete:
|
||||||
return None
|
return None
|
||||||
@@ -148,14 +260,31 @@ def select_option_pair(
|
|||||||
ymd = eligible[0]
|
ymd = eligible[0]
|
||||||
|
|
||||||
ems, strikes_map = complete[ymd]
|
ems, strikes_map = complete[ymd]
|
||||||
atm = pick_atm_strike(list(strikes_map.keys()), mark_px)
|
keys = list(strikes_map.keys())
|
||||||
if atm is None:
|
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
|
return None
|
||||||
legs = strikes_map[atm]
|
legs = strikes_map[strike]
|
||||||
return OptionPair(
|
return OptionPair(
|
||||||
expiry_ymd=ymd,
|
expiry_ymd=ymd,
|
||||||
expiry_ms=ems,
|
expiry_ms=ems,
|
||||||
strike=atm,
|
strike=strike,
|
||||||
call_inst_id=legs["C"],
|
call_inst_id=legs["C"],
|
||||||
put_inst_id=legs["P"],
|
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,
|
put_ask=pa,
|
||||||
)
|
)
|
||||||
return None
|
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
|
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:
|
def test_next_session_expiry_before_open() -> None:
|
||||||
now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH)
|
now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH)
|
||||||
assert next_session_expiry_ymd(now) == "260724"
|
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.liquidity import bid_mark_ok
|
||||||
from app.sim.pricing import option_fill, perp_fill
|
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.exits import check_expiry_close, check_exits
|
||||||
from app.strategy.signal import decide
|
from app.strategy.signal import decide
|
||||||
|
|
||||||
@@ -28,6 +33,37 @@ def test_signal_equal() -> None:
|
|||||||
assert decide(10.0, 10.0) is 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:
|
def test_signal_strike_below_spot_call_short() -> None:
|
||||||
# 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空
|
# 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空
|
||||||
s = decide(10.0, 20.0, strike=1850, mark_px=1859)
|
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"
|
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:
|
def test_bid_mark_deviation_30pct() -> None:
|
||||||
# |7-10|/10 = 30% → 允许(≤30%)
|
# |7-10|/10 = 30% → 允许(≤30%)
|
||||||
ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30)
|
ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30)
|
||||||
@@ -144,6 +205,38 @@ def test_expiry_close() -> None:
|
|||||||
assert d3.should_close is True
|
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:
|
def test_option_intrinsic_and_close_bid_floor() -> None:
|
||||||
from app.sim.pricing import (
|
from app.sim.pricing import (
|
||||||
option_expiry_settle,
|
option_expiry_settle,
|
||||||
@@ -162,14 +255,14 @@ def test_option_intrinsic_and_close_bid_floor() -> None:
|
|||||||
assert settled.notional == 44.0
|
assert settled.notional == 44.0
|
||||||
assert abs(settled.fee - 44.0 * 0.0005) < 1e-12
|
assert abs(settled.fee - 44.0 * 0.0005) < 1e-12
|
||||||
|
|
||||||
# 紧急垃圾买一 0.2,内在价值 22 → 抬到 22
|
# 紧急:对齐 OKX 市价卖,只按买一(垃圾买一不抬到内在价值/标记)
|
||||||
assert (
|
assert (
|
||||||
resolve_option_close_bid(
|
resolve_option_close_bid(
|
||||||
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=True
|
bid=0.2, mark=22.0, intrinsic=22.0, bypass_liquidity=True
|
||||||
)
|
)
|
||||||
== 22.0
|
== 0.2
|
||||||
)
|
)
|
||||||
# 常规也有内在价值地板
|
# 常规:内在价值地板
|
||||||
assert (
|
assert (
|
||||||
resolve_option_close_bid(
|
resolve_option_close_bid(
|
||||||
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False
|
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False
|
||||||
@@ -183,10 +276,10 @@ def test_option_intrinsic_and_close_bid_floor() -> None:
|
|||||||
)
|
)
|
||||||
== 25.0
|
== 25.0
|
||||||
)
|
)
|
||||||
# bypass 无买一,用标记与内在价值
|
# 紧急无买一 → 无法定价(不能用标记)
|
||||||
assert (
|
assert (
|
||||||
resolve_option_close_bid(
|
resolve_option_close_bid(
|
||||||
bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True
|
bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True
|
||||||
)
|
)
|
||||||
== 22.0
|
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