Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,112 @@
|
||||
# =============================================================================
|
||||
# 中控 hub.py / 子代理 agent.py 环境变量模板(可提交 Git)
|
||||
# 使用:cp .env.example .env 后填入真实值;启动前由 shell export 或 dotenv 加载
|
||||
# 云服务器完整说明:见 云服务器部署说明.md
|
||||
# =============================================================================
|
||||
|
||||
# hub.py 监听
|
||||
HUB_HOST=0.0.0.0
|
||||
HUB_PORT=5100
|
||||
# 仅本机访问可改为 127.0.0.1,并设 HUB_TRUST_LAN=false
|
||||
|
||||
# 与三实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串
|
||||
# 中控 → 各 Flask:请求头 X-Hub-Token
|
||||
# 中控 → 各子代理:请求头 X-Control-Token(与 HUB_BRIDGE_TOKEN 同值;agent 优先读 HUB_BRIDGE_TOKEN)
|
||||
# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效,单次使用)
|
||||
# HUB_BRIDGE_TOKEN=your-long-random-token
|
||||
# HUB_SSO_TTL_SEC=7200
|
||||
|
||||
# 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉)
|
||||
# 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1
|
||||
HUB_DISABLED_IDS=
|
||||
|
||||
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
|
||||
HUB_TRUST_LAN=true
|
||||
|
||||
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
|
||||
# HUB_ALLOW_PUBLIC=true
|
||||
|
||||
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
|
||||
HUB_USERNAME=admin
|
||||
HUB_PASSWORD=admin123
|
||||
# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接)
|
||||
# HUB_SESSION_SECRET=another-long-random-string
|
||||
# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookie;http://内网IP:5100 仍可登录
|
||||
# HUB_COOKIE_SECURE=true
|
||||
# 登录保持天数(默认 7)
|
||||
# HUB_SESSION_DAYS=7
|
||||
|
||||
# 本地导航 / 门户 iframe 嵌入中控(默认 true)
|
||||
# HUB_ALLOW_EMBED=true
|
||||
# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制
|
||||
# HUB_EMBED_ORIGINS=http://192.168.8.6:5070,https://hub.example.com
|
||||
|
||||
# 三实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署)
|
||||
# APP_ALLOW_HUB_EMBED=true
|
||||
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
||||
# HTTPS 跨子域 iframe 时三实例还须 APP_COOKIE_SECURE=true(见 crypto_monitor_*/.env.example)
|
||||
|
||||
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
||||
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
||||
# HUB_PUBLIC_ORIGIN=http://192.168.1.100
|
||||
# 反代:各实例 flask_url 建议直接写 https 域名,可不设此项
|
||||
# HUB_PUBLIC_HOST=192.168.1.100
|
||||
# HUB_PUBLIC_SCHEME=http
|
||||
|
||||
# 三实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输)
|
||||
# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=...
|
||||
|
||||
# 监控区:hub 后台每 N 秒聚合一次,浏览器经 SSE 收版本号再拉快照(默认 5 秒)
|
||||
# HUB_BOARD_POLL_INTERVAL=5
|
||||
# 单次聚合超时(秒,默认 agent 8 / flask 10 / board 45)
|
||||
# HUB_AGENT_TIMEOUT=8
|
||||
# HUB_FLASK_TIMEOUT=10
|
||||
# HUB_BOARD_TIMEOUT=45
|
||||
# 为 false 时不拉各实例 /api/price_snapshot(关键位门控简化为「-」,首屏明显更快)
|
||||
# HUB_BOARD_KEY_PRICES=true
|
||||
|
||||
# ---------- 行情区 K 线库(data/hub_kline.db,默认保留 15 天)----------
|
||||
# HUB_KLINE_RETENTION_DAYS=15
|
||||
# HUB_KLINE_DB_PATH=/opt/crypto_monitor_user/manual_trading_hub/data/hub_kline.db
|
||||
# 行情区后台轮询 + SSE(对齐监控区 board)
|
||||
# HUB_CHART_POLL_INTERVAL=5
|
||||
# HUB_CHART_POSITION_TIMEFRAME=5m
|
||||
# HUB_CHART_WATCH_TTL_SEC=45
|
||||
# HUB_CHART_MAX_SERIES_PER_TICK=24
|
||||
|
||||
# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)---
|
||||
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
|
||||
# CONTROL_TOKEN=your-long-random-token
|
||||
# EXCHANGE=binance
|
||||
# PORT=15200
|
||||
# HOST=127.0.0.1
|
||||
|
||||
# ---------- 中控 AI 教练(/ai,模块 hub_ai/,存 hub_ai_*.json)----------
|
||||
# 与三实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama)
|
||||
# 详见 manual_trading_hub/AI教练说明.md 与仓库根 AI复盘与模型配置说明.md
|
||||
AI_TIMEOUT_SECONDS=120
|
||||
# AI 教练聊天(默认:输出 8192 token,续写 4 次,快照约 2 万字符,历史单条 1500 字)
|
||||
# CHAT_MAX_OUTPUT_TOKENS=8192
|
||||
# CHAT_MAX_CONTINUATIONS=4
|
||||
# CHAT_CONTEXT_MAX_CHARS=20000
|
||||
# CHAT_SUMMARY_EXCERPT_MAX_CHARS=2000
|
||||
# CHAT_HISTORY_MAX_CHARS_PER_MSG=1500
|
||||
# CHAT_AI_TIMEOUT_SECONDS=300
|
||||
|
||||
# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama)
|
||||
AI_PROVIDER=openai
|
||||
OPENAI_API_BASE=https://op.bz121.com/v1
|
||||
OPENAI_API_KEY=你的密钥
|
||||
OPENAI_MODEL=gemma4:e4b
|
||||
# 本机 Ollama(AI_PROVIDER=ollama 时使用)
|
||||
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||
|
||||
# 交易日切分(与三实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期)
|
||||
TRADING_DAY_RESET_HOUR=8
|
||||
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
|
||||
# HUB_FUND_HISTORY_DAYS=180
|
||||
# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT)
|
||||
# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal
|
||||
# 资金概况:曲线与回撤统计起始交易日
|
||||
HUB_FUND_HISTORY_START_DAY=2026-06-09
|
||||
@@ -0,0 +1,66 @@
|
||||
# 中控 AI 教练说明
|
||||
|
||||
中控 **AI 教练**(`/ai`)与三实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON.
|
||||
|
||||
## 能力
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
|
||||
| **普通聊天** | 不绑交易数据,适合闲聊,答疑 |
|
||||
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
|
||||
| **会话历史** | 右侧列表:切换,删除;消息一键复制 |
|
||||
|
||||
页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成.
|
||||
|
||||
## 存储
|
||||
|
||||
与 `hub_settings.json` 同目录(`manual_trading_hub/`):
|
||||
|
||||
- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留)
|
||||
- `hub_ai_chat.json` — 聊天会话(`active_session_id`,多会话,`bot_mode`)
|
||||
|
||||
升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md)).
|
||||
|
||||
## 模型配置
|
||||
|
||||
在 **`manual_trading_hub/.env`** 配置,**变量名与三实例完全相同**;中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`,**默认也是 OpenAI 兼容网关**(`AI_PROVIDER=openai`),与你在三所 `.env` 里配的那套一致即可.
|
||||
|
||||
**推荐(与三实例默认一致):**
|
||||
|
||||
```env
|
||||
AI_PROVIDER=openai
|
||||
OPENAI_API_BASE=https://op.bz121.com/v1
|
||||
OPENAI_API_KEY=你的密钥
|
||||
OPENAI_MODEL=gemma4:e4b
|
||||
|
||||
# 本机 Ollama 备用(仅当 AI_PROVIDER=ollama 时生效)
|
||||
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||
```
|
||||
|
||||
改走本机无限制模型时,将 `AI_PROVIDER=ollama`,并填好 `OLLAMA_API` / `AI_MODEL`;`OPENAI_*` 可保留不动.
|
||||
|
||||
总结与聊天使用**同一模型**(同一套 `OPENAI_MODEL` 或 `AI_MODEL`);总结 temperature≈0.15,聊天≈0.5.
|
||||
|
||||
可选:`TRADING_DAY_RESET_HOUR=8`(与实例一致,定义「今日」交易日).
|
||||
|
||||
## 依赖接口
|
||||
|
||||
中控通过 HTTP 拉取各实例:
|
||||
|
||||
- `GET /api/hub/monitor`(已有)
|
||||
- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD`(`hub_bridge` 注册,需三实例更新代码并重启)
|
||||
|
||||
子代理 `GET /status` 提供持仓与余额.
|
||||
|
||||
## 与实例 AI 复盘的分工
|
||||
|
||||
| | 中控 AI 教练 | 实例 AI 复盘 |
|
||||
|--|-------------|-------------|
|
||||
| 入口 | `/ai` | 各所 `/records` |
|
||||
| 数据 | 三户聚合 | 单户 `journal_entries` |
|
||||
| 语气 | 聊天搭档 | 结构化教练报告 |
|
||||
| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
|
||||
|
||||
详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧).
|
||||
@@ -0,0 +1,105 @@
|
||||
# 复盘系统中控(manual_trading_hub)
|
||||
|
||||
> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **数据看板**:[数据看板说明.md](./数据看板说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md)
|
||||
|
||||
多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**.人工下单,关键位,**策略交易**(`/strategy`),复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**).**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate).
|
||||
|
||||
---
|
||||
|
||||
## 当前能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 监控区 | 持仓,余额,关键位摘要,趋势计划,机器人单(只读) |
|
||||
| 资金概况 | 总/分户资金(资金户+交易户),180 日曲线,最大回撤 |
|
||||
| **数据看板** | 三户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) |
|
||||
| 行情区 | K 线(多周期,本地缓存,技术指标,从监控跳转持仓线) |
|
||||
| **AI 教练** | 交易教练 + 普通聊天,会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
|
||||
| 紧急全平 | 单户 / 全局市价减仓 |
|
||||
| 系统设置 | `hub_settings.json` 管理 URL,启用,**监控关键位 / 监控趋势计划**(不控制策略交易页) |
|
||||
| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
|
||||
| ~~下单区~~ | **已移除**(避免与实例重复,减少故障面) |
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录
|
||||
├→ agent.py × N (:15200~15202) 持仓,全平
|
||||
└→ 各 Flask (:5000/5001/5004) /api/hub/monitor 只读聚合
|
||||
```
|
||||
|
||||
- 账户列表:**系统设置** 或默认 `settings_store.py`(不再使用环境变量 `HUB_AGENTS`).
|
||||
- 三实例须注册 **hub_bridge**(仓库根 `hub_bridge.py`);PM2 建议 `PYTHONPATH=..`.
|
||||
|
||||
---
|
||||
|
||||
## 快速启动(Linux / PM2)
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# 编辑 .env:HUB_PASSWORD,HUB_BRIDGE_TOKEN,HUB_PUBLIC_ORIGIN 等
|
||||
|
||||
pm2 start ecosystem.config.cjs # 3 agent + hub
|
||||
pm2 save
|
||||
|
||||
bash scripts/verify_hub_deploy.sh
|
||||
curl -s http://127.0.0.1:5100/api/ping
|
||||
```
|
||||
|
||||
浏览器:`http://<本机IP>:5100/monitor`(行情 `/market`;已设密码则先 `/login`).
|
||||
|
||||
---
|
||||
|
||||
## 中控 `.env` 要点
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `HUB_PASSWORD` / `HUB_USERNAME` | 非空密码即启用登录 |
|
||||
| `HUB_BRIDGE_TOKEN` | 与三实例一致 |
|
||||
| `HUB_DISABLED_IDS` | 默认 `1` 关闭 OKX |
|
||||
| `HUB_PUBLIC_ORIGIN` | 其它设备打开复盘/实例外链(替换 127.0.0.1) |
|
||||
| `HUB_COOKIE_SECURE` | HTTPS 反代建议 `true` |
|
||||
|
||||
详见 [.env.example](./.env.example).
|
||||
|
||||
---
|
||||
|
||||
## 子代理(agent)
|
||||
|
||||
每所策略目录单独进程,`EXCHANGE` + `PORT`(15200~15202),密钥来自**该目录 `.env`**.PM2 经 `scripts/run_agent.sh` 启动(自动 `source .env`,去 CRLF).
|
||||
|
||||
| PORT | 目录 |
|
||||
|------|------|
|
||||
| 15200 | crypto_monitor_binance |
|
||||
| 15201 | crypto_monitor_okx |
|
||||
| 15202 | crypto_monitor_gate |
|
||||
|
||||
---
|
||||
|
||||
## 运维脚本
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|------|------|
|
||||
| [scripts/fix_hub_deps.sh](./scripts/fix_hub_deps.sh) | 安装/更新 venv 依赖 |
|
||||
| [scripts/verify_hub_deploy.sh](./scripts/verify_hub_deploy.sh) | 验收代码版本与 ping |
|
||||
| [scripts/fix_env_crlf.sh](./scripts/fix_env_crlf.sh) | 修复 .env 的 Windows 换行 |
|
||||
| [scripts/pm2_hub.sh](./scripts/pm2_hub.sh) | PM2 启停 hub+agent |
|
||||
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 |
|
||||
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 |
|
||||
|
||||
---
|
||||
|
||||
## 文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [使用说明.md](./使用说明.md) | 页面,API,环境变量,日常流程 |
|
||||
| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键,拉取逻辑 |
|
||||
| [部署文档.md](./部署文档.md) | Ubuntu,PM2,反代,升级 |
|
||||
| [常见问题.md](./常见问题.md) | 已遇到问题与处理 |
|
||||
| [.env.example](./.env.example) | 环境变量模板 |
|
||||
@@ -0,0 +1,22 @@
|
||||
# 更新前快照(行情区 + K 线库)
|
||||
|
||||
> 行情区使用说明见 [行情区说明.md](./行情区说明.md).
|
||||
|
||||
更新前已打 Git 标签,回滚方式:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user # 或你的仓库路径
|
||||
git fetch --tags
|
||||
git checkout snapshot/pre-hub-market-20260528
|
||||
# 恢复后重启:
|
||||
pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate
|
||||
```
|
||||
|
||||
回到最新主线:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
K 线数据库(不纳入 Git):`manual_trading_hub/data/hub_kline.db`,回滚代码不会自动删除该文件.
|
||||
@@ -0,0 +1,910 @@
|
||||
"""
|
||||
子账户极轻代理:GET /status,挂单/条件单查询与撤销,POST /emergency/close-all,POST /emergency/close-position,仅监听 127.0.0.1.
|
||||
|
||||
与仓库内三个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
|
||||
EXCHANGE=binance → crypto_monitor_binance(BINANCE_*)
|
||||
EXCHANGE=okx → crypto_monitor_okx(OKX_*)
|
||||
EXCHANGE=gate → crypto_monitor_gate(GATE_*)
|
||||
|
||||
环境变量:
|
||||
EXCHANGE binance(默认)| okx | gate
|
||||
PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15202)
|
||||
HOST 默认 127.0.0.1
|
||||
HUB_BRIDGE_TOKEN 与中控一致;请求头 X-Control-Token(优先于已废弃的 CONTROL_TOKEN)
|
||||
|
||||
Binance:BINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODE;BINANCE_MARGIN_MODE
|
||||
OKX:OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE;OKX_TD_MODE;OKX_POS_MODE
|
||||
Gate:GATE_API_KEY / GATE_API_SECRET;GATE_TD_MODE;GATE_POS_MODE
|
||||
|
||||
代理与主项目一致时可设:BINANCE_SOCKS_PROXY / OKX_SOCKS_PROXY / GATE_SOCKS_PROXY(或 HTTP(S)_PROXY).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
from lib.hub.hub_ohlcv_lib import format_price_by_tick, price_tick_from_market
|
||||
from lib.hub.hub_position_metrics import (
|
||||
parse_position_entry_price,
|
||||
parse_position_mark_price,
|
||||
parse_position_unrealized_pnl,
|
||||
resolve_position_display_upnl,
|
||||
)
|
||||
|
||||
import ccxt
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from exchange_orders import (
|
||||
attach_orders_to_positions,
|
||||
cancel_order as hub_cancel_order,
|
||||
cancel_orders_for_symbol,
|
||||
list_open_orders,
|
||||
replace_position_tpsl,
|
||||
symbols_match,
|
||||
)
|
||||
|
||||
HOST = os.getenv("HOST", "127.0.0.1")
|
||||
PORT = int(os.getenv("PORT", "15200"))
|
||||
CONTROL_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
||||
|
||||
_raw_ex = (os.getenv("EXCHANGE") or "binance").strip().lower()
|
||||
if _raw_ex in ("binance", "bnb", "ba"):
|
||||
EXCHANGE_KIND = "binance"
|
||||
elif _raw_ex in ("okx", "okex"):
|
||||
EXCHANGE_KIND = "okx"
|
||||
elif _raw_ex in ("gate", "gateio"):
|
||||
EXCHANGE_KIND = "gate"
|
||||
else:
|
||||
EXCHANGE_KIND = "binance"
|
||||
|
||||
# —— Binance ——
|
||||
_bin_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
|
||||
BINANCE_POSITION_MODE = "hedge" if _bin_pos in ("hedge", "dual", "double", "hedged") else "oneway"
|
||||
_bin_margin = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
|
||||
BINANCE_DEFAULT_MARGIN_MODE = "cross" if _bin_margin in ("cross", "cross_margin") else "isolated"
|
||||
|
||||
# —— OKX ——
|
||||
OKX_TD_MODE = (os.getenv("OKX_TD_MODE") or "cross").strip()
|
||||
_okx_pos = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower()
|
||||
OKX_POS_MODE = "hedge" if _okx_pos in ("hedge", "long_short_mode", "dual") else "net"
|
||||
|
||||
# —— Gate ——
|
||||
_gate_td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
|
||||
GATE_DEFAULT_MARGIN_MODE = "cross" if _gate_td in ("cross", "cross_margin") else "isolated"
|
||||
_gate_pos = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
|
||||
GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single"
|
||||
|
||||
app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None)
|
||||
_ccxt_ex: Any = None
|
||||
_markets_loaded = False
|
||||
|
||||
|
||||
def _socks_proxy_url(prefix: str) -> str:
|
||||
return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip()
|
||||
|
||||
|
||||
def _http_https_proxy(prefix: str) -> dict[str, str] | None:
|
||||
http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip()
|
||||
https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip()
|
||||
socks = _socks_proxy_url(prefix)
|
||||
if socks:
|
||||
return {"http": socks, "https": socks}
|
||||
if http or https:
|
||||
return {"http": http, "https": https}
|
||||
return None
|
||||
|
||||
|
||||
def _attach_proxies(ex: Any, prefix: str) -> None:
|
||||
p = _http_https_proxy(prefix)
|
||||
if p:
|
||||
ex.proxies = p
|
||||
|
||||
|
||||
def _make_exchange() -> Any:
|
||||
if EXCHANGE_KIND == "binance":
|
||||
key = (os.getenv("BINANCE_API_KEY") or "").strip()
|
||||
secret = (os.getenv("BINANCE_API_SECRET") or "").strip()
|
||||
if not key or not secret:
|
||||
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET")
|
||||
ex = ccxt.binance(
|
||||
{
|
||||
"apiKey": key,
|
||||
"secret": secret,
|
||||
"enableRateLimit": True,
|
||||
"options": {
|
||||
"defaultType": "swap",
|
||||
# ccxt 默认 fetch_balance 走现货;与监控项目一致,固定为 U 本位合约钱包
|
||||
"fetchBalance": {"defaultType": "swap"},
|
||||
"defaultMarginMode": BINANCE_DEFAULT_MARGIN_MODE,
|
||||
"adjustForTimeDifference": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
_attach_proxies(ex, "BINANCE")
|
||||
return ex
|
||||
|
||||
if EXCHANGE_KIND == "okx":
|
||||
key = (os.getenv("OKX_API_KEY") or "").strip()
|
||||
secret = (os.getenv("OKX_API_SECRET") or "").strip()
|
||||
password = (os.getenv("OKX_API_PASSPHRASE") or "").strip()
|
||||
if not key or not secret or not password:
|
||||
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE")
|
||||
ex = ccxt.okx(
|
||||
{
|
||||
"apiKey": key,
|
||||
"secret": secret,
|
||||
"password": password,
|
||||
"enableRateLimit": True,
|
||||
"options": {
|
||||
"defaultType": "swap",
|
||||
"hedged": OKX_POS_MODE == "hedge",
|
||||
},
|
||||
}
|
||||
)
|
||||
_attach_proxies(ex, "OKX")
|
||||
return ex
|
||||
|
||||
# gate
|
||||
key = (os.getenv("GATE_API_KEY") or "").strip()
|
||||
secret = (os.getenv("GATE_API_SECRET") or "").strip()
|
||||
if not key or not secret:
|
||||
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET")
|
||||
from lib.exchange.gate_ccxt_lib import gate_ccxt_class
|
||||
|
||||
ex = gate_ccxt_class()(
|
||||
{
|
||||
"apiKey": key,
|
||||
"secret": secret,
|
||||
"enableRateLimit": True,
|
||||
"options": {
|
||||
"defaultType": "swap",
|
||||
"defaultMarginMode": GATE_DEFAULT_MARGIN_MODE,
|
||||
},
|
||||
}
|
||||
)
|
||||
_attach_proxies(ex, "GATE")
|
||||
return ex
|
||||
|
||||
|
||||
def get_exchange() -> Any:
|
||||
global _ccxt_ex
|
||||
if _ccxt_ex is None:
|
||||
_ccxt_ex = _make_exchange()
|
||||
return _ccxt_ex
|
||||
|
||||
|
||||
def _ensure_markets() -> None:
|
||||
global _markets_loaded
|
||||
if not _markets_loaded:
|
||||
get_exchange().load_markets()
|
||||
_markets_loaded = True
|
||||
|
||||
|
||||
def _check_token(x_control_token: str | None) -> None:
|
||||
if not CONTROL_TOKEN:
|
||||
return
|
||||
if (x_control_token or "").strip() != CONTROL_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="invalid token")
|
||||
|
||||
|
||||
def _position_mode_label() -> str:
|
||||
if EXCHANGE_KIND == "binance":
|
||||
return BINANCE_POSITION_MODE
|
||||
if EXCHANGE_KIND == "okx":
|
||||
return OKX_POS_MODE
|
||||
return GATE_POS_MODE
|
||||
|
||||
|
||||
def _close_param_candidates_binance(direction: str) -> list[dict[str, Any]]:
|
||||
ps = "LONG" if direction == "long" else "SHORT"
|
||||
hedge_ro = {"positionSide": ps, "reduceOnly": True}
|
||||
hedge_plain = {"positionSide": ps}
|
||||
oneway_ro = {"reduceOnly": True}
|
||||
oneway_plain: dict[str, Any] = {}
|
||||
if BINANCE_POSITION_MODE == "hedge":
|
||||
return [hedge_ro, hedge_plain, oneway_ro, oneway_plain]
|
||||
return [oneway_ro, oneway_plain, hedge_ro, hedge_plain]
|
||||
|
||||
|
||||
def _close_param_candidates_okx(direction: str) -> list[dict[str, Any]]:
|
||||
base: dict[str, Any] = {"tdMode": OKX_TD_MODE}
|
||||
out: list[dict[str, Any]] = []
|
||||
if OKX_POS_MODE == "hedge":
|
||||
ps = "long" if direction == "long" else "short"
|
||||
out.extend(
|
||||
[
|
||||
{**base, "posSide": ps, "reduceOnly": True},
|
||||
{**base, "posSide": ps},
|
||||
]
|
||||
)
|
||||
out.extend([{**base, "reduceOnly": True}, dict(base)])
|
||||
return out
|
||||
|
||||
|
||||
def _close_param_candidates_gate(_direction: str) -> list[dict[str, Any]]:
|
||||
return [{"reduceOnly": True}, {}]
|
||||
|
||||
|
||||
def _close_param_candidates(direction: str) -> list[dict[str, Any]]:
|
||||
if EXCHANGE_KIND == "binance":
|
||||
return _close_param_candidates_binance(direction)
|
||||
if EXCHANGE_KIND == "okx":
|
||||
return _close_param_candidates_okx(direction)
|
||||
return _close_param_candidates_gate(direction)
|
||||
|
||||
|
||||
def _retryable_close_err(msg: str) -> bool:
|
||||
s = (msg or "").lower()
|
||||
if "-4061" in s:
|
||||
return True
|
||||
if "-1106" in s and "reduceonly" in s:
|
||||
return True
|
||||
if "reduceonly" in s or "reduce only" in s:
|
||||
return True
|
||||
if "position side" in s or "positionside" in s or "pos side" in s:
|
||||
return True
|
||||
if "dual side" in s or "position mode" in s:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _position_contracts(p: dict[str, Any]) -> float:
|
||||
raw = p.get("contracts")
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
info = p.get("info") or {}
|
||||
for k in ("positionAmt", "positionamt", "pos", "size"):
|
||||
if k in info:
|
||||
try:
|
||||
v = float(info[k])
|
||||
if v != 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _position_side(p: dict[str, Any], contracts: float) -> str:
|
||||
s = (p.get("side") or "").lower()
|
||||
if s in ("long", "short"):
|
||||
return s
|
||||
if contracts > 0:
|
||||
return "long"
|
||||
if contracts < 0:
|
||||
return "short"
|
||||
return "long"
|
||||
|
||||
|
||||
def _cancel_symbol_orders(ex: Any, sym: str) -> None:
|
||||
try:
|
||||
ex.cancel_all_orders(sym, params={})
|
||||
except Exception:
|
||||
pass
|
||||
if EXCHANGE_KIND != "binance":
|
||||
return
|
||||
try:
|
||||
m = ex.market(sym)
|
||||
cid = m.get("id")
|
||||
if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"):
|
||||
ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class EmergencyClosePositionBody(BaseModel):
|
||||
symbol: str
|
||||
side: str
|
||||
|
||||
|
||||
class CancelOrderBody(BaseModel):
|
||||
symbol: str
|
||||
order_id: str
|
||||
channel: str = "regular"
|
||||
|
||||
|
||||
class CancelSymbolOrdersBody(BaseModel):
|
||||
symbol: str
|
||||
scope: str = "all" # all | conditional | limit
|
||||
|
||||
|
||||
class PlaceTpslBody(BaseModel):
|
||||
symbol: str
|
||||
side: str # long | short
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
contracts: float | None = None
|
||||
|
||||
|
||||
def _close_position_market(
|
||||
ex: Any, sym: str, side: str, contracts: float
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""市价平掉指定合约,方向;返回 (closed_info, error_message)."""
|
||||
side_n = (side or "").strip().lower()
|
||||
if side_n not in ("long", "short"):
|
||||
return None, f"无效方向: {side}"
|
||||
close_side = "sell" if side_n == "long" else "buy"
|
||||
direction = side_n
|
||||
try:
|
||||
amt = float(ex.amount_to_precision(sym, abs(float(contracts))))
|
||||
except Exception:
|
||||
amt = abs(float(contracts))
|
||||
if amt <= 0:
|
||||
return None, f"{sym}: 可平张数为 0"
|
||||
order_resp = None
|
||||
last_err: Exception | None = None
|
||||
for params in _close_param_candidates(direction):
|
||||
try:
|
||||
order_resp = ex.create_order(sym, "market", close_side, amt, None, params)
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if _retryable_close_err(str(e)):
|
||||
continue
|
||||
return None, f"{sym}: {e}"
|
||||
if order_resp is None:
|
||||
return None, f"{sym}: {last_err or '下单失败'}"
|
||||
_cancel_symbol_orders(ex, sym)
|
||||
return (
|
||||
{"symbol": sym, "side": side_n, "amount": amt, "order_id": order_resp.get("id")},
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _is_local(host: str | None) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
h = host.lower()
|
||||
return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1")
|
||||
|
||||
|
||||
def _finite_or_none(x: Any) -> float | None:
|
||||
try:
|
||||
f = float(x)
|
||||
return f if math.isfinite(f) else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _position_price_fmt(ex: Any, symbol: str, price: float | None) -> tuple[float | None, str | None, float | None]:
|
||||
"""返回 (原价, 交易所精度字符串, price_tick)."""
|
||||
if price is None or price <= 0 or not symbol:
|
||||
return None, None, None
|
||||
tick: float | None = None
|
||||
try:
|
||||
ex.load_markets()
|
||||
unified = ex.market(symbol)["symbol"]
|
||||
tick = price_tick_from_market(ex, unified)
|
||||
px_str = str(ex.price_to_precision(unified, price))
|
||||
return _finite_or_none(float(px_str)), px_str, tick
|
||||
except Exception:
|
||||
return price, format_price_by_tick(price, tick), tick
|
||||
|
||||
|
||||
def _position_entry_price(p: dict[str, Any]) -> float | None:
|
||||
"""三所 ccxt 持仓统一解析开仓均价(Binance/OKX/Gate 字段名不一致)."""
|
||||
return parse_position_entry_price(p)
|
||||
|
||||
|
||||
def _position_contract_size(ex: Any, symbol: str) -> float:
|
||||
try:
|
||||
market = ex.market((symbol or "").strip())
|
||||
cs = float(market.get("contractSize") or 1)
|
||||
return cs if cs > 0 else 1.0
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _position_mark_price(p: dict[str, Any]) -> float | None:
|
||||
"""三所 ccxt 持仓统一解析标记价(与实例 parse_ccxt_position_metrics 一致)."""
|
||||
return parse_position_mark_price(p)
|
||||
|
||||
|
||||
def _ticker_mark_price(ex: Any, symbol: str) -> float | None:
|
||||
"""持仓行无 mark 时,用 ticker 补标记价(last/mark)."""
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return None
|
||||
try:
|
||||
t = ex.fetch_ticker(sym)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(t, dict):
|
||||
return None
|
||||
info = t.get("info") if isinstance(t.get("info"), dict) else {}
|
||||
for key in (
|
||||
t.get("mark"),
|
||||
t.get("last"),
|
||||
t.get("close"),
|
||||
info.get("markPrice"),
|
||||
info.get("mark_price"),
|
||||
info.get("markPx"),
|
||||
):
|
||||
px = _finite_or_none(key)
|
||||
if px is not None and px > 0:
|
||||
return px
|
||||
return None
|
||||
|
||||
|
||||
def _extract_usdt_total(balance: dict[str, Any]) -> float | None:
|
||||
"""从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)."""
|
||||
usdt_info = balance.get("USDT") or {}
|
||||
if not isinstance(usdt_info, dict):
|
||||
usdt_info = {}
|
||||
total_map = balance.get("total") or {}
|
||||
if not isinstance(total_map, dict):
|
||||
total_map = {}
|
||||
free_map = balance.get("free") or {}
|
||||
if not isinstance(free_map, dict):
|
||||
free_map = {}
|
||||
total = usdt_info.get("total")
|
||||
if total is None:
|
||||
total = usdt_info.get("equity")
|
||||
if total is None:
|
||||
total = total_map.get("USDT")
|
||||
if total is None:
|
||||
total = usdt_info.get("free")
|
||||
if total is None:
|
||||
total = free_map.get("USDT")
|
||||
try:
|
||||
return float(total) if total is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _binance_futures_usdt_asset_row(balance: Any) -> dict[str, Any] | None:
|
||||
"""U 本位合约 fetch_balance(type=swap) 的 info.assets 中 USDT 一行(与币安合约后台口径一致)."""
|
||||
if not isinstance(balance, dict):
|
||||
return None
|
||||
info = balance.get("info")
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
assets = info.get("assets")
|
||||
if not isinstance(assets, list):
|
||||
return None
|
||||
for a in assets:
|
||||
if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT":
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _binance_swap_usdt_total(ex: Any) -> float | None:
|
||||
"""仅 U 本位永续合约账户 USDT(显式 type=swap,不用现货余额)."""
|
||||
try:
|
||||
bal = ex.fetch_balance({"type": "swap"})
|
||||
except Exception:
|
||||
return None
|
||||
row = _binance_futures_usdt_asset_row(bal)
|
||||
if row:
|
||||
for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"):
|
||||
x = row.get(k)
|
||||
if x is not None and str(x).strip() != "":
|
||||
try:
|
||||
fv = float(x)
|
||||
if fv >= 0:
|
||||
return fv
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
v = _extract_usdt_total(bal)
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def local_only(request: Request, call_next):
|
||||
if request.client and not _is_local(request.client.host):
|
||||
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "exchange": EXCHANGE_KIND}
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
def status(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
|
||||
try:
|
||||
return _status_inner(x_control_token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"status: {e}",
|
||||
"exchange": EXCHANGE_KIND,
|
||||
"balance_usdt": None,
|
||||
"positions": [],
|
||||
"total_unrealized_pnl": None,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
def _status_inner(x_control_token: str | None) -> Any:
|
||||
_check_token(x_control_token)
|
||||
try:
|
||||
ex = get_exchange()
|
||||
except RuntimeError as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"exchange": EXCHANGE_KIND,
|
||||
"balance_usdt": None,
|
||||
"positions": [],
|
||||
"total_unrealized_pnl": None,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
try:
|
||||
_ensure_markets()
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"load_markets: {e}",
|
||||
"exchange": EXCHANGE_KIND,
|
||||
"balance_usdt": None,
|
||||
"positions": [],
|
||||
"total_unrealized_pnl": None,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
balance_usdt: float | None = None
|
||||
try:
|
||||
if EXCHANGE_KIND == "binance":
|
||||
balance_usdt = _binance_swap_usdt_total(ex)
|
||||
else:
|
||||
bal = ex.fetch_balance()
|
||||
u = bal.get("USDT") or {}
|
||||
if isinstance(u, dict) and u.get("total") is not None:
|
||||
balance_usdt = _finite_or_none(u["total"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
positions_out: list[dict[str, Any]] = []
|
||||
total_upnl = 0.0
|
||||
try:
|
||||
raw = ex.fetch_positions() or []
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"exchange": EXCHANGE_KIND,
|
||||
"balance_usdt": balance_usdt,
|
||||
"positions": [],
|
||||
"total_unrealized_pnl": None,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
c = _position_contracts(p)
|
||||
if abs(c) < 1e-12:
|
||||
continue
|
||||
sym = p.get("symbol") or ""
|
||||
side = _position_side(p, c)
|
||||
entry_f = _position_entry_price(p)
|
||||
mark_f = _position_mark_price(p)
|
||||
if mark_f is None and sym:
|
||||
mark_f = _ticker_mark_price(ex, sym)
|
||||
cs = _position_contract_size(ex, sym) if sym else 1.0
|
||||
exchange_upnl = parse_position_unrealized_pnl(p)
|
||||
upnl_f = resolve_position_display_upnl(
|
||||
side,
|
||||
entry_f,
|
||||
mark_f,
|
||||
abs(c),
|
||||
cs,
|
||||
exchange_upnl,
|
||||
)
|
||||
if upnl_f is None:
|
||||
upnl_f = 0.0
|
||||
total_upnl += upnl_f
|
||||
notional = p.get("notional")
|
||||
try:
|
||||
notional_f = float(notional) if notional is not None else None
|
||||
except (TypeError, ValueError):
|
||||
notional_f = None
|
||||
_, entry_fmt, price_tick = _position_price_fmt(ex, sym, entry_f)
|
||||
_, mark_fmt, mark_tick = _position_price_fmt(ex, sym, mark_f)
|
||||
if price_tick is None and mark_tick is not None:
|
||||
price_tick = mark_tick
|
||||
positions_out.append(
|
||||
{
|
||||
"symbol": sym,
|
||||
"side": side,
|
||||
"contracts": abs(c),
|
||||
"contracts_signed": c,
|
||||
"notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None,
|
||||
"unrealized_pnl": _finite_or_none(upnl_f),
|
||||
"entry_price": entry_f,
|
||||
"entry_price_fmt": entry_fmt,
|
||||
"mark_price": mark_f,
|
||||
"mark_price_fmt": mark_fmt,
|
||||
"contract_size": _finite_or_none(cs),
|
||||
"price_tick": _finite_or_none(price_tick) if price_tick is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
orders_fetch_error: str | None = None
|
||||
try:
|
||||
attach_orders_to_positions(
|
||||
positions_out,
|
||||
list_open_orders(ex, EXCHANGE_KIND, None),
|
||||
)
|
||||
except Exception as e:
|
||||
orders_fetch_error = str(e)
|
||||
for p in positions_out:
|
||||
p.setdefault("conditional_orders", [])
|
||||
p.setdefault("regular_orders", [])
|
||||
|
||||
try:
|
||||
pm = _position_mode_label()
|
||||
except Exception:
|
||||
pm = EXCHANGE_KIND
|
||||
out = {
|
||||
"ok": True,
|
||||
"exchange": EXCHANGE_KIND,
|
||||
"balance_usdt": balance_usdt,
|
||||
"positions": positions_out,
|
||||
"total_unrealized_pnl": _finite_or_none(total_upnl),
|
||||
"position_mode": pm,
|
||||
}
|
||||
if orders_fetch_error:
|
||||
out["orders_fetch_error"] = orders_fetch_error
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/open-orders")
|
||||
def open_orders(
|
||||
symbol: str = "",
|
||||
x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
|
||||
):
|
||||
_check_token(x_control_token)
|
||||
try:
|
||||
ex = get_exchange()
|
||||
_ensure_markets()
|
||||
sym = (symbol or "").strip() or None
|
||||
orders = list_open_orders(ex, EXCHANGE_KIND, sym)
|
||||
return {"ok": True, "exchange": EXCHANGE_KIND, "symbol": sym, "orders": orders}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": str(e), "exchange": EXCHANGE_KIND, "orders": []},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/orders/cancel")
|
||||
def cancel_one_order(
|
||||
body: CancelOrderBody,
|
||||
x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
|
||||
):
|
||||
_check_token(x_control_token)
|
||||
sym = (body.symbol or "").strip()
|
||||
oid = (body.order_id or "").strip()
|
||||
if not sym or not oid:
|
||||
raise HTTPException(status_code=400, detail="symbol 与 order_id 必填")
|
||||
try:
|
||||
ex = get_exchange()
|
||||
_ensure_markets()
|
||||
hub_cancel_order(ex, EXCHANGE_KIND, sym, oid, body.channel or "regular")
|
||||
return {"ok": True, "exchange": EXCHANGE_KIND, "cancelled": {"symbol": sym, "order_id": oid}}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": str(e), "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/orders/cancel-symbol")
|
||||
def cancel_symbol_orders(
|
||||
body: CancelSymbolOrdersBody,
|
||||
x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
|
||||
):
|
||||
_check_token(x_control_token)
|
||||
sym = (body.symbol or "").strip()
|
||||
if not sym:
|
||||
raise HTTPException(status_code=400, detail="symbol 必填")
|
||||
scope = (body.scope or "all").strip().lower()
|
||||
if scope not in ("all", "conditional", "limit"):
|
||||
raise HTTPException(status_code=400, detail="scope 须为 all / conditional / limit")
|
||||
try:
|
||||
ex = get_exchange()
|
||||
_ensure_markets()
|
||||
n = cancel_orders_for_symbol(ex, EXCHANGE_KIND, sym, scope=scope)
|
||||
return {"ok": True, "exchange": EXCHANGE_KIND, "cancelled_count": n, "scope": scope}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": str(e), "exchange": EXCHANGE_KIND, "cancelled_count": 0},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/orders/place-tpsl")
|
||||
def place_tpsl_orders(
|
||||
body: PlaceTpslBody,
|
||||
x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
|
||||
):
|
||||
"""先撤该合约全部条件单,再挂止盈+止损(与三实例策略逻辑一致)."""
|
||||
_check_token(x_control_token)
|
||||
sym = (body.symbol or "").strip()
|
||||
side = (body.side or "").strip().lower()
|
||||
if not sym or side not in ("long", "short"):
|
||||
raise HTTPException(status_code=400, detail="symbol 与 side(long/short) 必填")
|
||||
try:
|
||||
sl = float(body.stop_loss)
|
||||
tp = float(body.take_profit)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail="stop_loss / take_profit 须为数字") from e
|
||||
try:
|
||||
ex = get_exchange()
|
||||
_ensure_markets()
|
||||
amt = body.contracts
|
||||
if amt is None or float(amt) <= 0:
|
||||
raw = ex.fetch_positions() or []
|
||||
found = None
|
||||
for p in raw:
|
||||
psym = p.get("symbol") or ""
|
||||
if not symbols_match(sym, psym):
|
||||
continue
|
||||
c = abs(float(p.get("contracts") or 0))
|
||||
if c <= 0:
|
||||
continue
|
||||
ps = (p.get("side") or "").lower()
|
||||
if ps and ps != side:
|
||||
continue
|
||||
found = c
|
||||
break
|
||||
if found is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"未找到持仓 {sym} {side}", "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
amt = found
|
||||
info = replace_position_tpsl(ex, EXCHANGE_KIND, sym, side, float(amt), sl, tp)
|
||||
return {"ok": True, "exchange": EXCHANGE_KIND, "placed": info}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": str(e), "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/emergency/close-all")
|
||||
def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
|
||||
_check_token(x_control_token)
|
||||
try:
|
||||
ex = get_exchange()
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
try:
|
||||
_ensure_markets()
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"load_markets: {e}", "closed": [], "errors": [str(e)], "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
errors: list[str] = []
|
||||
closed: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
raw = ex.fetch_positions() or []
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e
|
||||
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
c = _position_contracts(p)
|
||||
if abs(c) < 1e-12:
|
||||
continue
|
||||
sym = p.get("symbol")
|
||||
if not sym:
|
||||
continue
|
||||
side = _position_side(p, c)
|
||||
info, err = _close_position_market(ex, sym, side, abs(c))
|
||||
if err:
|
||||
errors.append(err)
|
||||
elif info:
|
||||
closed.append(info)
|
||||
time.sleep(0.05)
|
||||
|
||||
return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND}
|
||||
|
||||
|
||||
@app.post("/emergency/close-position")
|
||||
def emergency_close_position(
|
||||
body: EmergencyClosePositionBody,
|
||||
x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
|
||||
):
|
||||
_check_token(x_control_token)
|
||||
sym = (body.symbol or "").strip()
|
||||
want_side = (body.side or "").strip().lower()
|
||||
if not sym:
|
||||
raise HTTPException(status_code=400, detail="symbol 不能为空")
|
||||
if want_side not in ("long", "short"):
|
||||
raise HTTPException(status_code=400, detail="side 须为 long 或 short")
|
||||
try:
|
||||
ex = get_exchange()
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
try:
|
||||
_ensure_markets()
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"load_markets: {e}",
|
||||
"closed": None,
|
||||
"exchange": EXCHANGE_KIND,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
try:
|
||||
raw = ex.fetch_positions() or []
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e
|
||||
|
||||
matched = None
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if not symbols_match(sym, (p.get("symbol") or "").strip()):
|
||||
continue
|
||||
c = _position_contracts(p)
|
||||
if abs(c) < 1e-12:
|
||||
continue
|
||||
side = _position_side(p, c)
|
||||
if side != want_side:
|
||||
continue
|
||||
matched = (sym, side, abs(c))
|
||||
break
|
||||
|
||||
if not matched:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"未找到持仓: {sym} {want_side}",
|
||||
"closed": None,
|
||||
"exchange": EXCHANGE_KIND,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
sym, side, c = matched
|
||||
info, err = _close_position_market(ex, sym, side, c)
|
||||
if err:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": err, "closed": None, "exchange": EXCHANGE_KIND},
|
||||
status_code=200,
|
||||
)
|
||||
return {"ok": True, "closed": info, "errors": [], "exchange": EXCHANGE_KIND}
|
||||
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=HOST, port=PORT, log_level="warning", access_log=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
# 快速开始
|
||||
|
||||
## 系统是什么
|
||||
|
||||
**中控**聚合三所(币安 / OKX / Gate)的持仓、委托、关键位与趋势计划,并提供资金曲线、行情、复盘与 AI 教练。**实际下单、关键位配置、策略执行、交易复盘**在各交易所**实例网页**完成。
|
||||
|
||||
```
|
||||
浏览器 → 中控(默认 :5100)
|
||||
├─ 监控区 / 资金 / 行情 / 内照明心 …
|
||||
└─ 点击「下单 / 期权」→ 实例页(内嵌或新标签)
|
||||
实例 Flask(币安 :5001 · Gate :5000 · OKX :5004)
|
||||
```
|
||||
|
||||
## 日常推荐流程
|
||||
|
||||
1. 打开 **监控区**,查看三所持仓、浮盈亏、关键位与趋势计划。
|
||||
2. 需要操作某所时,点该卡片 **「下单」**(或 **「期权」**)进入实例页。
|
||||
3. 复盘与统计:中控 **内照明心**、**数据看板**,或实例 **交易记录与复盘**。
|
||||
4. 事前计划:中控 **开仓计划**;策略 playbook:中控 **策略说明**(与实例「策略交易」不同,见下文)。
|
||||
|
||||
## 三账户默认对应
|
||||
|
||||
| 账户 | 实例端口 | 常见能力 |
|
||||
|------|----------|----------|
|
||||
| 币安 | 5001 | 关键位 + 趋势 |
|
||||
| OKX | 5004 | 关键位 + 趋势 + **期权**(需在设置勾选「监控期权」) |
|
||||
| Gate | 5000 | 关键位 + 趋势 |
|
||||
|
||||
## 两个容易混淆的名称
|
||||
|
||||
| 名称 | 在哪里 | 是什么 |
|
||||
|------|--------|--------|
|
||||
| **策略说明** | 中控顶栏 | 各所策略文档与开仓检查清单 |
|
||||
| **策略交易** | 实例顶栏 | 趋势回调 / 顺势加仓等自动化功能 |
|
||||
@@ -0,0 +1,26 @@
|
||||
# 中控导航说明
|
||||
|
||||
顶栏各页面用途如下(可在 **系统设置 → 显示与导航** 中隐藏不需要的 Tab)。
|
||||
|
||||
| 导航 | 用途 |
|
||||
|------|------|
|
||||
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
|
||||
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
|
||||
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
|
||||
| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) |
|
||||
| **使用说明** | 本页:中控与实例怎么用 |
|
||||
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
|
||||
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
|
||||
| **内照明心** | 复盘语录、归档交易、永久 5m K 线 |
|
||||
| **数据看板** | 当日 KPI、分户卡片、SSE 刷新 |
|
||||
| **AI 教练** | 交易教练对话、监管推送(需配置密钥) |
|
||||
| **系统日志** | 中控与三实例 PM2 日志(排错用,非部署说明) |
|
||||
| **系统设置** | 中控密码、导航显示、交易所地址、监控能力勾选 |
|
||||
|
||||
## 手机端(≤720px)
|
||||
|
||||
底栏固定四项:**监控 / 行情 / 计算 / AI**;其余入口进 **更多**。电脑与平板仍用顶栏,布局不变。
|
||||
|
||||
## 默认首页
|
||||
|
||||
登录后默认进入 **监控区**(`/monitor`)。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 监控区与实例入口
|
||||
|
||||
## 监控卡片里有什么
|
||||
|
||||
每张交易所卡片通常包含:
|
||||
|
||||
- **资金行**:资金账户 / 交易账户 / 浮动盈亏(可在设置关闭)
|
||||
- **永续持仓**:表格摘要;点击标题栏 **进入全屏** 可看完整持仓卡片
|
||||
- **OKX 期权**(勾选「监控期权」后):永续与期权分块;全屏时期权也以卡片展示(与实例期权页字段一致,只读)
|
||||
- **关键位 / 下单监控 / 趋势回调 / 顺势加仓**:只读摘要(数据来自实例)
|
||||
|
||||
## 全屏模式
|
||||
|
||||
点击卡片标题栏(或移动端卡片主体)进入 **全屏**:
|
||||
|
||||
- 永续:每币种一张持仓卡,可 **委托 / 平仓**(非日内纪律模式)
|
||||
- 期权(OKX):只读卡片,含权利金、标记价、买盘深度等
|
||||
- 下方:关键位、下单监控、趋势与滚仓区块
|
||||
|
||||
按 `Esc` 或 **返回监控** 退出全屏。
|
||||
|
||||
## 打开实例(SSO)
|
||||
|
||||
监控卡片或全屏顶栏按钮:
|
||||
|
||||
| 按钮 | 进入实例页 | 说明 |
|
||||
|------|------------|------|
|
||||
| **打开实例** | 实盘下单 | 新浏览器标签 |
|
||||
| **下单** | 实盘下单 | 中控内 iframe |
|
||||
| **监控位** | 关键位监控 | |
|
||||
| **复盘** | 交易记录与复盘 | |
|
||||
| **期权** | 期权页 | 仅 OKX 且勾选监控期权 |
|
||||
|
||||
实例地址在 **系统设置 → 交易所** 配置 `flask_url`;未配置时不会出现上述按钮。
|
||||
|
||||
## 常见操作
|
||||
|
||||
| 操作 | 位置 |
|
||||
|------|------|
|
||||
| 紧急全平 | 卡片 **全平**(日内纪律账户可能禁用) |
|
||||
| 改止盈止损 | 持仓行 **委托** 或全屏卡片 |
|
||||
| 撤条件单 | 监控区条件单列表 |
|
||||
| 停止趋势计划 | 趋势回调区块 **停止 / 保本** 等 |
|
||||
@@ -0,0 +1,30 @@
|
||||
# 实例页导航说明
|
||||
|
||||
从监控区 **下单 / 打开实例** 进入后,实例顶栏常见 Tab 如下(部分可在实例 **系统设置 → 导航显示** 中隐藏)。
|
||||
|
||||
| Tab | 用途 |
|
||||
|-----|------|
|
||||
| **关键位监控** | 配置 5m 门禁关键位,可选自动下单 |
|
||||
| **实盘下单** | 人工下单、下单监控、预估盈亏比 |
|
||||
| **策略交易** | 趋势回调、顺势加仓计划(自动化) |
|
||||
| **策略交易记录** | 上述策略的执行历史 |
|
||||
| **交易记录与复盘** | 平仓记录、日记、AI 复盘 |
|
||||
| **统计分析** | 按周期汇总盈亏 |
|
||||
| **期权** | OKX 期权链、持仓、买一平仓(OKX 且已启用) |
|
||||
| **风控说明** | 只读展示当前风控相关 env |
|
||||
| **env配置** | 修改运行参数(中文标签) |
|
||||
| **系统设置** | 实例登录密码、导航 Tab 开关等 |
|
||||
|
||||
## 与中控的分工
|
||||
|
||||
| 在中控做 | 在实例做 |
|
||||
|----------|----------|
|
||||
| 看三所持仓汇总、全平 | 下单、改单、平仓 |
|
||||
| 看关键位 / 趋势摘要 | 新建 / 修改关键位与策略 |
|
||||
| 内照明心、数据看板 | 交易记录详情、日记 |
|
||||
| 开仓计划 | 策略交易执行 |
|
||||
| OKX 期权只读监控 | 期权开仓、买一平仓 |
|
||||
|
||||
## iframe 内操作
|
||||
|
||||
在中控 iframe 打开实例时,顶栏有 **返回监控 / 刷新 / 新标签打开**,无需重复登录。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 设置与配置说明
|
||||
|
||||
## 三层配置,不要混用
|
||||
|
||||
| 层级 | 入口 | 管什么 |
|
||||
|------|------|--------|
|
||||
| **中控系统设置** | 中控 `/settings` | 中控密码、顶栏显示、交易所 URL、监控能力(关键位/趋势/期权)、宏观日历、备份 |
|
||||
| **实例系统设置** | 实例 `/settings` | 实例登录密码、实例顶栏 Tab 显示 |
|
||||
| **实例 env配置** | 实例 `/env_config` | 交易参数(止损比例、风控开关等) |
|
||||
|
||||
改 env 后通常需 **重启对应实例 PM2** 才完全生效;具体字段含义见实例 env 页说明或仓库 `docs/env配置说明.md`(无需在中控内阅读)。
|
||||
|
||||
## 中控 · 显示与导航
|
||||
|
||||
可隐藏不常用的顶栏 Tab(**监控区**、**系统设置** 无法隐藏)。
|
||||
|
||||
## 中控 · 交易所
|
||||
|
||||
每项需配置:
|
||||
|
||||
- **flask_url**:实例 HTTP 地址(中控聚合与打开实例用)
|
||||
- **agent_url**:子代理地址(持仓与全平)
|
||||
- **capabilities**:勾选 **关键位 / 趋势 / 期权** 决定监控区展示哪些块
|
||||
|
||||
## 实例 · 导航显示
|
||||
|
||||
固定保留:**关键位监控、实盘下单、系统设置**。其余 Tab 可按需开关。
|
||||
|
||||
## 使用向常见问题
|
||||
|
||||
**监控卡片没有「下单」按钮**
|
||||
→ 检查该所 `flask_url` 是否填写且实例可访问。
|
||||
|
||||
**OKX 看不到期权**
|
||||
→ 中控设置勾选「监控期权」,且实例已启用期权模块。
|
||||
|
||||
**策略说明 vs 策略交易**
|
||||
→ 前者在中控,是文档;后者在实例,是自动化功能。
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 仅子代理(一般不单独用;默认请 pm2 start ecosystem.config.cjs 一次起 hub+agent)
|
||||
*
|
||||
* 若只想重启子代理,不动中控:
|
||||
* pm2 restart manual-agent-binance manual-agent-gate ...
|
||||
*/
|
||||
const main = require("./ecosystem.config.cjs");
|
||||
|
||||
module.exports = {
|
||||
apps: main.apps.filter((a) => String(a.name).startsWith("manual-agent-")),
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* PM2:中控 hub + 三路子代理 agent(一次启动全部)
|
||||
*
|
||||
* 前置:
|
||||
* cd manual_trading_hub
|
||||
* source .venv/bin/activate && pip install -r requirements.txt
|
||||
* cp .env.example .env
|
||||
*
|
||||
* 启动(hub + 全部 agent):
|
||||
* pm2 start ecosystem.config.cjs
|
||||
* pm2 save && pm2 startup
|
||||
*
|
||||
* 仅中控:pm2 start ecosystem.config.cjs --only manual-trading-hub
|
||||
* 仅某 agent:pm2 start ecosystem.config.cjs --only manual-agent-binance
|
||||
*
|
||||
* 快捷:bash scripts/pm2_hub.sh start
|
||||
*/
|
||||
const path = require("path");
|
||||
|
||||
const HUB_DIR = __dirname;
|
||||
const REPO_ROOT = path.join(HUB_DIR, "..");
|
||||
const RUN_HUB = path.join(HUB_DIR, "scripts", "run_hub.sh");
|
||||
const RUN_AGENT = path.join(HUB_DIR, "scripts", "run_agent.sh");
|
||||
|
||||
function agentApp(name, exchangeDir, exchange, port) {
|
||||
return {
|
||||
name,
|
||||
cwd: path.join(REPO_ROOT, exchangeDir),
|
||||
script: RUN_AGENT,
|
||||
interpreter: "bash",
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: "400M",
|
||||
restart_delay: 3000,
|
||||
max_restarts: 15,
|
||||
merge_logs: true,
|
||||
env: {
|
||||
EXCHANGE: exchange,
|
||||
PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
PYTHONPATH: REPO_ROOT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
agentApp("manual-agent-binance", "crypto_monitor_binance", "binance", 15200),
|
||||
agentApp("manual-agent-okx", "crypto_monitor_okx", "okx", 15201),
|
||||
agentApp("manual-agent-gate", "crypto_monitor_gate", "gate", 15202),
|
||||
{
|
||||
name: "manual-trading-hub",
|
||||
cwd: HUB_DIR,
|
||||
script: RUN_HUB,
|
||||
interpreter: "bash",
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: "512M",
|
||||
env: {
|
||||
PYTHONPATH: REPO_ROOT,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
"""加载 manual_trading_hub/.env(Windows 直接 python hub.py 时也需要)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HUB_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load_hub_dotenv() -> None:
|
||||
path = HUB_DIR / ".env"
|
||||
if not path.is_file():
|
||||
return
|
||||
raw_bytes = path.read_bytes()
|
||||
text = ""
|
||||
for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"):
|
||||
try:
|
||||
text = raw_bytes.decode(enc)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not text:
|
||||
text = raw_bytes.decode("utf-8", errors="ignore")
|
||||
text = text.replace("\x00", "")
|
||||
for line in text.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or raw.startswith("#") or "=" not in raw:
|
||||
continue
|
||||
key, value = raw.split("=", 1)
|
||||
clean_key = key.strip().lstrip("\ufeff")
|
||||
if not clean_key.replace("_", "").isalnum():
|
||||
continue
|
||||
clean_value = value.strip().strip('"').strip("'")
|
||||
os.environ[clean_key] = clean_value
|
||||
@@ -0,0 +1,846 @@
|
||||
"""
|
||||
中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销;挂止盈止损(先撤条件单再挂).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lib.exchange.okx_orders_lib import fetch_okx_all_open_orders
|
||||
from lib.hub.hub_symbol_lib import symbols_match
|
||||
|
||||
|
||||
def _coerce_float(*values) -> float | None:
|
||||
for v in values:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _symbol_base_coin(symbol: str) -> str:
|
||||
"""ZEC/USDT:USDT,ZEC-USDT-SWAP 等统一为标的币 ZEC."""
|
||||
s = (symbol or "").strip().upper()
|
||||
if not s:
|
||||
return ""
|
||||
if "-SWAP" in s:
|
||||
s = s.replace("-SWAP", "")
|
||||
if "-" in s:
|
||||
return s.split("-", 1)[0]
|
||||
if "/" in s:
|
||||
return s.split("/", 1)[0]
|
||||
if ":" in s:
|
||||
return s.split(":", 1)[0]
|
||||
return s
|
||||
|
||||
|
||||
def _order_type_str(order: dict) -> str:
|
||||
info = order.get("info") or {}
|
||||
if isinstance(info, dict):
|
||||
for key in ("orderType", "type", "origType", "algoType", "ordType"):
|
||||
val = info.get(key)
|
||||
if val:
|
||||
return str(val).upper()
|
||||
return str(order.get("type") or "").upper()
|
||||
|
||||
|
||||
def _is_conditional_type(typ: str) -> bool:
|
||||
t = (typ or "").upper()
|
||||
if not t:
|
||||
return False
|
||||
keys = ("STOP", "TAKE_PROFIT", "TRAIL", "TRIGGER", "CONDITIONAL", "OCO")
|
||||
return any(k in t for k in keys)
|
||||
|
||||
|
||||
def _order_label(typ: str, side: str, reduce_only: bool | None) -> str:
|
||||
t = (typ or "").upper()
|
||||
side_l = (side or "").lower()
|
||||
parts = []
|
||||
if "TAKE_PROFIT" in t:
|
||||
parts.append("止盈")
|
||||
elif "STOP" in t:
|
||||
parts.append("止损")
|
||||
elif "LIMIT" in t:
|
||||
parts.append("限价")
|
||||
elif "MARKET" in t:
|
||||
parts.append("市价")
|
||||
else:
|
||||
parts.append(typ or "委托")
|
||||
if side_l == "buy":
|
||||
parts.append("买入")
|
||||
elif side_l == "sell":
|
||||
parts.append("卖出")
|
||||
if reduce_only:
|
||||
parts.append("·只减仓")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _normalize_raw_order(order: dict, *, channel: str) -> dict[str, Any] | None:
|
||||
if not isinstance(order, dict):
|
||||
return None
|
||||
info = order.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
oid = order.get("id") or info.get("algoId") or info.get("orderId") or info.get("ordId")
|
||||
if oid is None:
|
||||
return None
|
||||
sym = str(order.get("symbol") or info.get("symbol") or info.get("instId") or "")
|
||||
typ = _order_type_str(order)
|
||||
side = str(order.get("side") or info.get("side") or "").lower()
|
||||
reduce_only = order.get("reduceOnly")
|
||||
if reduce_only is None:
|
||||
reduce_only = info.get("reduceOnly")
|
||||
try:
|
||||
reduce_only = bool(reduce_only) if reduce_only is not None else None
|
||||
except (TypeError, ValueError):
|
||||
reduce_only = None
|
||||
sl_trig = _coerce_float(info.get("slTriggerPx"), order.get("stopLossPrice"))
|
||||
tp_trig = _coerce_float(info.get("tpTriggerPx"), order.get("takeProfitPrice"))
|
||||
trig = _coerce_float(
|
||||
order.get("stopPrice"),
|
||||
order.get("triggerPrice"),
|
||||
info.get("triggerPrice"),
|
||||
info.get("stopPrice"),
|
||||
info.get("triggerPx"),
|
||||
sl_trig,
|
||||
tp_trig,
|
||||
)
|
||||
price = _coerce_float(order.get("price"), info.get("price"), info.get("ordPx"))
|
||||
amt = _coerce_float(order.get("amount"), order.get("remaining"), info.get("quantity"), info.get("origQty"), info.get("sz"))
|
||||
category = "conditional" if _is_conditional_type(typ) or channel == "algo" else "limit"
|
||||
label = _order_label(typ, side, reduce_only)
|
||||
if sl_trig is not None and tp_trig is not None:
|
||||
label = f"止盈止损 SL={sl_trig:g} TP={tp_trig:g}"
|
||||
elif sl_trig is not None:
|
||||
label = f"止损 {sl_trig:g}"
|
||||
elif tp_trig is not None:
|
||||
label = f"止盈 {tp_trig:g}"
|
||||
return {
|
||||
"id": str(oid),
|
||||
"symbol": sym,
|
||||
"channel": channel,
|
||||
"category": category,
|
||||
"label": label,
|
||||
"type": typ,
|
||||
"side": side,
|
||||
"amount": amt,
|
||||
"trigger_price": trig,
|
||||
"price": price,
|
||||
"reduce_only": reduce_only,
|
||||
"status": str(order.get("status") or info.get("status") or "open"),
|
||||
}
|
||||
|
||||
|
||||
def _okx_normalize_orders(raw: dict, channel: str) -> list[dict[str, Any]]:
|
||||
"""OKX 算法单常一笔同时含 SL+TP,拆成两条供中控「交易所止盈止损」展示."""
|
||||
n = _normalize_raw_order(dict(raw), channel=channel)
|
||||
if not n:
|
||||
return []
|
||||
info = raw.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
sl_trig = _coerce_float(info.get("slTriggerPx"), raw.get("stopLossPrice"))
|
||||
tp_trig = _coerce_float(info.get("tpTriggerPx"), raw.get("takeProfitPrice"))
|
||||
if sl_trig is None or tp_trig is None or sl_trig == tp_trig:
|
||||
return [n]
|
||||
base_id = n["id"]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for role, px, lbl in (
|
||||
("sl", sl_trig, f"止损 {sl_trig:g}"),
|
||||
("tp", tp_trig, f"止盈 {tp_trig:g}"),
|
||||
):
|
||||
row = dict(n)
|
||||
row["id"] = f"{base_id}:{role}"
|
||||
row["algo_id"] = base_id
|
||||
row["label"] = lbl
|
||||
row["trigger_price"] = px
|
||||
row["category"] = "conditional"
|
||||
row["channel"] = channel
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _okx_algo_order_id(order_id: str) -> str:
|
||||
oid = str(order_id or "")
|
||||
if ":" in oid:
|
||||
return oid.split(":", 1)[0]
|
||||
return oid
|
||||
|
||||
|
||||
def _binance_list(ex: Any, symbol: str | None) -> list[dict]:
|
||||
ex.load_markets()
|
||||
out: list[dict] = []
|
||||
symbols: list[str] = []
|
||||
if symbol:
|
||||
try:
|
||||
symbols = [ex.market(symbol)["symbol"]]
|
||||
except Exception:
|
||||
symbols = [symbol]
|
||||
else:
|
||||
symbols = []
|
||||
try:
|
||||
for p in ex.fetch_positions() or []:
|
||||
sym = p.get("symbol")
|
||||
if sym:
|
||||
symbols.append(sym)
|
||||
except Exception:
|
||||
pass
|
||||
if symbol and not symbols:
|
||||
symbols = [symbol]
|
||||
|
||||
def collect(ex_sym: str) -> None:
|
||||
market = ex.market(ex_sym)
|
||||
contract_id = market.get("id")
|
||||
try:
|
||||
for o in ex.fetch_open_orders(ex_sym) or []:
|
||||
item = dict(o)
|
||||
item["_channel"] = "regular"
|
||||
n = _normalize_raw_order(item, channel="regular")
|
||||
if n:
|
||||
out.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if contract_id and hasattr(ex, "fapiPrivateGetOpenAlgoOrders"):
|
||||
raw = ex.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id})
|
||||
items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or [])
|
||||
for info in items or []:
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
wrapped = {
|
||||
"id": info.get("algoId") or info.get("orderId"),
|
||||
"symbol": ex_sym,
|
||||
"info": info,
|
||||
"type": info.get("orderType") or info.get("type"),
|
||||
"side": (info.get("side") or "").lower(),
|
||||
"amount": info.get("quantity") or info.get("origQty"),
|
||||
"stopPrice": info.get("triggerPrice") or info.get("stopPrice"),
|
||||
"reduceOnly": info.get("reduceOnly"),
|
||||
}
|
||||
n = _normalize_raw_order(wrapped, channel="algo")
|
||||
if n:
|
||||
out.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if symbols:
|
||||
seen = set()
|
||||
for s in symbols:
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
collect(s)
|
||||
return out
|
||||
|
||||
|
||||
def _okx_list(ex: Any, symbol: str | None) -> list[dict]:
|
||||
ex.load_markets()
|
||||
out: list[dict] = []
|
||||
symbols: list[str] = []
|
||||
if symbol:
|
||||
try:
|
||||
symbols = [ex.market(symbol)["symbol"]]
|
||||
except Exception:
|
||||
symbols = [symbol]
|
||||
else:
|
||||
try:
|
||||
for p in ex.fetch_positions() or []:
|
||||
sym = p.get("symbol")
|
||||
if sym:
|
||||
symbols.append(sym)
|
||||
except Exception:
|
||||
pass
|
||||
if symbol and not symbols:
|
||||
symbols = [symbol]
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for sym in symbols:
|
||||
try:
|
||||
for o in fetch_okx_all_open_orders(ex, sym):
|
||||
ch = "algo" if _is_conditional_type(_order_type_str(o)) else "regular"
|
||||
for n in _okx_normalize_orders(dict(o), channel=ch):
|
||||
key = (n["id"], n.get("channel") or ch)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _gate_extract_trigger_rule(info: dict) -> int | None:
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
trig = info.get("trigger")
|
||||
if isinstance(trig, dict) and trig.get("rule") is not None:
|
||||
try:
|
||||
return int(trig["rule"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return int(info.get("rule"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _gate_tpsl_role_from_rule(rule: int | None, direction: str) -> str | None:
|
||||
if rule is None:
|
||||
return None
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "long":
|
||||
return "sl" if rule == 2 else ("tp" if rule == 1 else None)
|
||||
return "sl" if rule == 1 else ("tp" if rule == 2 else None)
|
||||
|
||||
|
||||
def _gate_trigger_params(ex: Any) -> dict:
|
||||
p = {"type": "swap", "trigger": True}
|
||||
try:
|
||||
ex.load_unified_status()
|
||||
if ex.options.get("unifiedAccount"):
|
||||
p["unifiedAccount"] = True
|
||||
except Exception:
|
||||
pass
|
||||
return p
|
||||
|
||||
|
||||
def _gate_list(ex: Any, symbol: str | None) -> list[dict]:
|
||||
ex.load_markets()
|
||||
out: list[dict] = []
|
||||
symbols: list[str] = []
|
||||
if symbol:
|
||||
try:
|
||||
symbols = [ex.market(symbol)["symbol"]]
|
||||
except Exception:
|
||||
symbols = [symbol]
|
||||
else:
|
||||
try:
|
||||
for p in ex.fetch_positions() or []:
|
||||
sym = p.get("symbol")
|
||||
if sym:
|
||||
symbols.append(sym)
|
||||
except Exception:
|
||||
pass
|
||||
if symbol and not symbols:
|
||||
symbols = [symbol]
|
||||
trig_params = _gate_trigger_params(ex)
|
||||
seen = set()
|
||||
for sym in symbols:
|
||||
if sym in seen:
|
||||
continue
|
||||
seen.add(sym)
|
||||
try:
|
||||
for o in ex.fetch_open_orders(sym) or []:
|
||||
n = _normalize_raw_order(dict(o), channel="regular")
|
||||
if n:
|
||||
out.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for o in ex.fetch_open_orders(sym, params=trig_params) or []:
|
||||
item = dict(o)
|
||||
item["type"] = item.get("type") or "trigger"
|
||||
n = _normalize_raw_order(item, channel="algo")
|
||||
if n:
|
||||
info = o.get("info") if isinstance(o.get("info"), dict) else {}
|
||||
rule = _gate_extract_trigger_rule(info)
|
||||
if rule is not None:
|
||||
n["gate_trigger_rule"] = rule
|
||||
out.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def list_open_orders(ex: Any, exchange_kind: str, symbol: str | None = None) -> list[dict]:
|
||||
kind = (exchange_kind or "binance").lower()
|
||||
if kind == "binance":
|
||||
orders = _binance_list(ex, symbol)
|
||||
elif kind == "okx":
|
||||
orders = _okx_list(ex, symbol)
|
||||
else:
|
||||
orders = _gate_list(ex, symbol)
|
||||
if symbol:
|
||||
orders = [o for o in orders if symbols_match(symbol, o.get("symbol") or "")]
|
||||
# 去重 id+channel
|
||||
seen: set[tuple[str, str]] = set()
|
||||
uniq: list[dict] = []
|
||||
for o in orders:
|
||||
key = (o["id"], o["channel"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
uniq.append(o)
|
||||
return uniq
|
||||
|
||||
|
||||
def _enrich_gate_conditional_labels(cond: list[dict], side: str) -> None:
|
||||
"""Gate 仓位类触发单在 ccxt 中常显示为「市价·只减仓」,按 trigger.rule 标为止盈/止损."""
|
||||
direction = (side or "long").strip().lower()
|
||||
for o in cond:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
if (o.get("label") or "").startswith(("止盈", "止损")):
|
||||
continue
|
||||
role = _gate_tpsl_role_from_rule(o.get("gate_trigger_rule"), direction)
|
||||
trig = o.get("trigger_price")
|
||||
if not role or trig is None:
|
||||
continue
|
||||
try:
|
||||
trig_f = float(trig)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
prefix = "止损" if role == "sl" else "止盈"
|
||||
o["label"] = f"{prefix} {trig_f:g}"
|
||||
|
||||
|
||||
def attach_orders_to_positions(positions: list[dict], orders: list[dict]) -> None:
|
||||
for p in positions:
|
||||
sym = p.get("symbol") or ""
|
||||
matched = [o for o in orders if symbols_match(sym, o.get("symbol") or "")]
|
||||
cond = [o for o in matched if o.get("category") == "conditional"]
|
||||
_enrich_gate_conditional_labels(cond, p.get("side") or "long")
|
||||
from lib.hub.hub_order_sync_lib import dedupe_conditional_orders_by_role
|
||||
|
||||
p["conditional_orders"] = dedupe_conditional_orders_by_role(cond)
|
||||
p["regular_orders"] = [o for o in matched if o.get("category") != "conditional"]
|
||||
|
||||
|
||||
def cancel_order(
|
||||
ex: Any,
|
||||
exchange_kind: str,
|
||||
symbol: str,
|
||||
order_id: str,
|
||||
channel: str = "regular",
|
||||
) -> None:
|
||||
kind = (exchange_kind or "binance").lower()
|
||||
ex.load_markets()
|
||||
market = ex.market(symbol)
|
||||
unified = market["symbol"]
|
||||
ch = (channel or "regular").lower()
|
||||
if kind == "binance" and ch == "algo":
|
||||
contract_id = market.get("id")
|
||||
if contract_id and hasattr(ex, "fapiPrivateDeleteAlgoOrder"):
|
||||
ex.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": str(order_id)})
|
||||
return
|
||||
params = None
|
||||
if kind == "gate" and ch == "algo":
|
||||
params = _gate_trigger_params(ex)
|
||||
elif kind == "okx" and ch == "algo":
|
||||
params = {"stop": True}
|
||||
oid = _okx_algo_order_id(order_id) if kind == "okx" else str(order_id)
|
||||
ex.cancel_order(oid, unified, params)
|
||||
|
||||
|
||||
def cancel_orders_for_symbol(
|
||||
ex: Any,
|
||||
exchange_kind: str,
|
||||
symbol: str,
|
||||
*,
|
||||
scope: str = "all",
|
||||
) -> int:
|
||||
"""scope: all | conditional | limit"""
|
||||
orders = list_open_orders(ex, exchange_kind, symbol)
|
||||
if scope == "conditional":
|
||||
orders = [o for o in orders if o.get("category") == "conditional"]
|
||||
elif scope == "limit":
|
||||
orders = [o for o in orders if o.get("category") != "conditional"]
|
||||
n = 0
|
||||
for o in orders:
|
||||
try:
|
||||
cancel_order(ex, exchange_kind, symbol, o["id"], o.get("channel") or "regular")
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[cancel_orders_for_symbol] {exchange_kind} {symbol} id={o.get('id')}: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return n
|
||||
|
||||
|
||||
def _binance_cancel_algo_open(ex: Any, symbol: str) -> None:
|
||||
try:
|
||||
market = ex.market(symbol)
|
||||
cid = market.get("id")
|
||||
if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"):
|
||||
ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _binance_trigger_params() -> dict[str, Any]:
|
||||
wt = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper()
|
||||
if wt not in ("CONTRACT_PRICE", "MARK_PRICE"):
|
||||
wt = "CONTRACT_PRICE"
|
||||
return {"workingType": wt}
|
||||
|
||||
|
||||
def _binance_place_tp_sl(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
position_mode: str = "hedge",
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
market = ex.market(symbol)
|
||||
if not market.get("swap"):
|
||||
raise RuntimeError("仅支持永续合约")
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
amt = float(ex.amount_to_precision(symbol, float(amount)))
|
||||
if amt <= 0:
|
||||
raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
|
||||
sl_px = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_px = ex.price_to_precision(symbol, float(take_profit))
|
||||
common = dict(_binance_trigger_params())
|
||||
if (position_mode or "hedge").lower() in ("hedge", "dual", "double", "hedged"):
|
||||
common["positionSide"] = "LONG" if direction == "long" else "SHORT"
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.create_order(
|
||||
symbol, "STOP_MARKET", close_side, amt, None, dict(common, stopPrice=sl_px)
|
||||
)
|
||||
time.sleep(0.05)
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"TAKE_PROFIT_MARKET",
|
||||
close_side,
|
||||
amt,
|
||||
None,
|
||||
dict(common, stopPrice=tp_px),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
cancel_orders_for_symbol(ex, "binance", symbol, scope="conditional")
|
||||
_binance_cancel_algo_open(ex, symbol)
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"Binance 未接受止盈/止损:{last_err}")
|
||||
|
||||
|
||||
def _okx_order_params(
|
||||
direction: str,
|
||||
*,
|
||||
reduce_only: bool,
|
||||
pos_mode: str,
|
||||
td_mode: str,
|
||||
for_algo_tpsl: bool = False,
|
||||
) -> dict:
|
||||
params: dict[str, Any] = {"tdMode": td_mode or "cross"}
|
||||
if (pos_mode or "hedge").lower() in ("hedge", "long_short_mode", "dual"):
|
||||
ps = "long" if direction == "long" else "short"
|
||||
params["posSide"] = ps
|
||||
params["positionSide"] = ps
|
||||
# OKX 条件/OCO 算法单勿带 reduceOnly,否则可能被当市价减仓立即成交
|
||||
if reduce_only and not for_algo_tpsl:
|
||||
params["reduceOnly"] = True
|
||||
return params
|
||||
|
||||
|
||||
def _okx_place_tp_sl(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
pos_mode: str = "hedge",
|
||||
td_mode: str = "cross",
|
||||
) -> None:
|
||||
"""OKX 永续:一笔 OCO 算法单挂止盈+止损(勿 reduceOnly + 分两笔 market)."""
|
||||
ex.load_markets()
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
amt = float(ex.amount_to_precision(symbol, float(amount)))
|
||||
if amt <= 0:
|
||||
raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
|
||||
base = _okx_order_params(
|
||||
direction,
|
||||
reduce_only=False,
|
||||
pos_mode=pos_mode,
|
||||
td_mode=td_mode,
|
||||
for_algo_tpsl=True,
|
||||
)
|
||||
sl_px = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_px = ex.price_to_precision(symbol, float(take_profit))
|
||||
order_params = {
|
||||
**base,
|
||||
"stopLossPrice": float(sl_px),
|
||||
"takeProfitPrice": float(tp_px),
|
||||
"tpOrdPx": "-1",
|
||||
"slOrdPx": "-1",
|
||||
}
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.create_order(symbol, "oco", close_side, amt, None, order_params)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
cancel_orders_for_symbol(ex, "okx", symbol, scope="conditional")
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}")
|
||||
|
||||
|
||||
def _gate_tpsl_env() -> tuple[bool, int, int, str]:
|
||||
use_pos = (os.getenv("GATE_TPSL_USE_POSITION_ORDER") or "true").lower() in ("1", "true", "yes")
|
||||
exp = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400)))
|
||||
pt = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0"))
|
||||
if pt < 0 or pt > 2:
|
||||
pt = 0
|
||||
pos_mode = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
|
||||
return use_pos, exp, pt, pos_mode
|
||||
|
||||
|
||||
def _gate_place_tp_sl_position(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
pos_mode: str,
|
||||
price_type: int,
|
||||
expiration: int,
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
market = ex.market(symbol)
|
||||
if not market.get("swap"):
|
||||
raise RuntimeError("仅支持永续合约")
|
||||
settle = market["settleId"]
|
||||
contract = market["id"]
|
||||
order_type = "close-long-position" if direction == "long" else "close-short-position"
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
sl_rule, tp_rule = (2, 1) if close_side == "sell" else (1, 2)
|
||||
initial: dict[str, Any] = {
|
||||
"contract": contract,
|
||||
"size": 0,
|
||||
"price": "0",
|
||||
"close": True,
|
||||
"reduce_only": True,
|
||||
"tif": "ioc",
|
||||
"text": "api",
|
||||
}
|
||||
if pos_mode in ("hedge", "dual", "double"):
|
||||
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
|
||||
# Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false
|
||||
initial["close"] = False
|
||||
sl_s = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_s = ex.price_to_precision(symbol, float(take_profit))
|
||||
|
||||
def _payload(trigger_price: str, rule: int) -> dict:
|
||||
trig: dict[str, Any] = {
|
||||
"strategy_type": 0,
|
||||
"price_type": price_type,
|
||||
"price": trigger_price,
|
||||
"rule": rule,
|
||||
}
|
||||
if expiration > 0:
|
||||
trig["expiration"] = expiration
|
||||
return {
|
||||
"settle": settle,
|
||||
"initial": dict(initial),
|
||||
"trigger": trig,
|
||||
"order_type": order_type,
|
||||
}
|
||||
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
|
||||
try:
|
||||
ex.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule))
|
||||
except Exception:
|
||||
# 保留已挂止损,仅放弃本次 TP
|
||||
raise
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"Gate 仓位类止盈/止损未接受:{last_err}")
|
||||
|
||||
|
||||
def _gate_place_tp_sl_legacy(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> None:
|
||||
ex.load_markets()
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
base = {"reduceOnly": True}
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amount,
|
||||
None,
|
||||
dict(base, stopLossPrice=float(stop_loss)),
|
||||
)
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amount,
|
||||
None,
|
||||
dict(base, takeProfitPrice=float(take_profit)),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError(f"Gate 条件止盈/止损未接受:{last_err}")
|
||||
|
||||
|
||||
def _gate_td_mode_cross() -> bool:
|
||||
td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
|
||||
return td in ("cross", "cross_margin")
|
||||
|
||||
|
||||
def _gate_last_price(ex: Any, symbol: str) -> float | None:
|
||||
ex.load_markets()
|
||||
unified = ex.market(symbol)["symbol"]
|
||||
try:
|
||||
t = ex.fetch_ticker(unified)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(t, dict):
|
||||
return None
|
||||
info = t.get("info") if isinstance(t.get("info"), dict) else {}
|
||||
for key in ("last", "mark", "close", "index_price"):
|
||||
v = t.get(key) if key in t else info.get(key)
|
||||
try:
|
||||
f = float(v)
|
||||
if f > 0:
|
||||
return f
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _gate_clamp_tpsl_prices(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Gate price_orders:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 trigger<last.
|
||||
"""
|
||||
last = _gate_last_price(ex, symbol)
|
||||
if last is None or last <= 0:
|
||||
return float(stop_loss), float(take_profit)
|
||||
ex.load_markets()
|
||||
unified = ex.market(symbol)["symbol"]
|
||||
gap_pct = float(os.getenv("GATE_TPSL_LAST_PRICE_GAP_PCT", "0.05") or "0.05")
|
||||
gap = max(0.0, gap_pct) / 100.0
|
||||
if gap <= 0:
|
||||
gap = 0.0005
|
||||
sl = float(stop_loss)
|
||||
tp = float(take_profit)
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
if sl <= last:
|
||||
sl = float(ex.price_to_precision(unified, last * (1 + gap)))
|
||||
if tp >= last:
|
||||
tp = float(ex.price_to_precision(unified, last * (1 - gap)))
|
||||
else:
|
||||
if sl >= last:
|
||||
sl = float(ex.price_to_precision(unified, last * (1 - gap)))
|
||||
if tp <= last:
|
||||
tp = float(ex.price_to_precision(unified, last * (1 + gap)))
|
||||
return sl, tp
|
||||
|
||||
|
||||
def _gate_place_tp_sl(
|
||||
ex: Any,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> None:
|
||||
use_pos, exp, pt, pos_mode = _gate_tpsl_env()
|
||||
pos_err: Exception | None = None
|
||||
if use_pos:
|
||||
try:
|
||||
_gate_place_tp_sl_position(
|
||||
ex, symbol, direction, stop_loss, take_profit,
|
||||
pos_mode=pos_mode, price_type=pt, expiration=exp,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
pos_err = e
|
||||
if _gate_td_mode_cross():
|
||||
raise RuntimeError(
|
||||
f"Gate 仓位类止盈/止损未接受(全仓不支持 ccxt 条件单回退):{pos_err}"
|
||||
) from e
|
||||
try:
|
||||
_gate_place_tp_sl_legacy(ex, symbol, direction, amount, stop_loss, take_profit)
|
||||
except Exception as legacy_err:
|
||||
if pos_err is not None:
|
||||
raise RuntimeError(
|
||||
f"Gate 仓位类止盈/止损未接受:{pos_err};条件单回退亦失败:{legacy_err}"
|
||||
) from legacy_err
|
||||
raise
|
||||
|
||||
|
||||
def replace_position_tpsl(
|
||||
ex: Any,
|
||||
exchange_kind: str,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
amount: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
先撤销该合约全部条件单,再挂止盈+止损.与三实例策略页逻辑对齐(读各目录 .env 中 GATE_/BINANCE_/OKX_ 参数).
|
||||
"""
|
||||
kind = (exchange_kind or "binance").lower()
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
raise ValueError("direction 须为 long 或 short")
|
||||
sl = float(stop_loss)
|
||||
tp = float(take_profit)
|
||||
if sl <= 0 or tp <= 0:
|
||||
raise ValueError("止损,止盈价格须大于 0")
|
||||
ex.load_markets()
|
||||
cancelled = cancel_orders_for_symbol(ex, kind, symbol, scope="conditional")
|
||||
if kind == "binance":
|
||||
_binance_cancel_algo_open(ex, symbol)
|
||||
time.sleep(0.08)
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
raise ValueError("持仓数量无效")
|
||||
if kind == "binance":
|
||||
pm = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
|
||||
_binance_place_tp_sl(ex, symbol, direction, amt, sl, tp, position_mode=pm)
|
||||
elif kind == "okx":
|
||||
pm = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower()
|
||||
td = (os.getenv("OKX_TD_MODE") or "cross").strip()
|
||||
_okx_place_tp_sl(ex, symbol, direction, amt, sl, tp, pos_mode=pm, td_mode=td)
|
||||
else:
|
||||
sl, tp = _gate_clamp_tpsl_prices(ex, symbol, direction, sl, tp)
|
||||
_gate_place_tp_sl(ex, symbol, direction, amt, sl, tp)
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"amount": amt,
|
||||
"stop_loss": sl,
|
||||
"take_profit": tp,
|
||||
"cancelled_conditional": cancelled,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)."""
|
||||
@@ -0,0 +1,161 @@
|
||||
"""内照明心复盘语录 → 交易教练点评."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hub_ai.client import generate_text, model_label
|
||||
from hub_ai.rolling_summary import refresh_session_rolling_summary
|
||||
from hub_ai.text_util import clip_text, is_ai_error_reply
|
||||
from hub_ai.config import (
|
||||
CHAT_MAX_CONTINUATIONS,
|
||||
CHAT_MAX_OUTPUT_TOKENS,
|
||||
CHAT_TEMPERATURE,
|
||||
CHAT_USER_MESSAGE_MAX_CHARS,
|
||||
)
|
||||
from hub_ai.prompts import CHAT_SYSTEM, build_archive_quote_review_prompt
|
||||
from hub_ai.store import (
|
||||
CHAT_BOT_TRADING,
|
||||
append_chat_message,
|
||||
create_new_session,
|
||||
delete_chat_session,
|
||||
get_active_session,
|
||||
list_chat_sessions,
|
||||
)
|
||||
from lib.hub.hub_symbol_archive_lib import list_daily_trades
|
||||
|
||||
|
||||
def _tag_label(tag: str) -> str:
|
||||
t = (tag or "").strip().lower()
|
||||
if t == "sick":
|
||||
return "犯病"
|
||||
if t == "emotion":
|
||||
return "情绪化"
|
||||
return t or "—"
|
||||
|
||||
|
||||
def _fmt_pnl(v: Any) -> str:
|
||||
try:
|
||||
n = float(v or 0)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
sign = "+" if n > 0 else ""
|
||||
return f"{sign}{n:.2f}U"
|
||||
|
||||
|
||||
def _fmt_pct(v: Any) -> str:
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
return f"{n:.1f}%"
|
||||
|
||||
|
||||
def _fmt_rr(v: Any) -> str:
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
return f"{n:.2f}:1"
|
||||
|
||||
|
||||
def format_archive_trades_for_ai(payload: dict[str, Any]) -> str:
|
||||
trades = payload.get("trades") or []
|
||||
stats = payload.get("stats") or {}
|
||||
lines = [
|
||||
(
|
||||
f"统计:开仓 {int(stats.get('open_count') or 0)} 笔,"
|
||||
f"盈利 {int(stats.get('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)},"
|
||||
f"平均盈利 {_fmt_pnl(stats.get('avg_win'))},平均亏损 {_fmt_pnl(stats.get('avg_loss'))},"
|
||||
f"胜率 {_fmt_pct(stats.get('win_rate'))},盈亏比 {_fmt_rr(stats.get('profit_loss_ratio'))},"
|
||||
f"最大盈利 {_fmt_pnl(stats.get('max_win'))},最大亏损 {_fmt_pnl(stats.get('max_loss'))},"
|
||||
f"犯病 {int(stats.get('sick_count') or 0)} 笔,"
|
||||
f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))},"
|
||||
f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}"
|
||||
)
|
||||
]
|
||||
if not trades:
|
||||
lines.append("(该日无交易记录)")
|
||||
return "\n".join(lines)
|
||||
max_rows = 50
|
||||
if len(trades) > max_rows:
|
||||
lines.append(f"(共 {len(trades)} 笔,以下展示最近 {max_rows} 笔)")
|
||||
for i, t in enumerate(trades[:max_rows], 1):
|
||||
ex = str(t.get("exchange_key") or t.get("account_exchange_key") or "—")
|
||||
sym = str(t.get("symbol") or "—")
|
||||
direction = str(t.get("direction") or "—")
|
||||
opened = str(t.get("opened_at") or "—")
|
||||
closed = str(t.get("closed_at") or "—")
|
||||
hold = str(t.get("hold_minutes_text") or t.get("hold_minutes") or "—")
|
||||
result = str(t.get("result") or "—")
|
||||
pnl = _fmt_pnl(t.get("pnl_amount"))
|
||||
entry = str(t.get("entry_type") or t.get("entry_reason") or t.get("monitor_type") or "—")
|
||||
tag = _tag_label(str(t.get("behavior_tag") or ""))
|
||||
note = clip_text(str(t.get("note") or "").strip(), 80)
|
||||
line = (
|
||||
f"{i}. {ex} | {sym} | {direction} | 开仓类型 {entry} | "
|
||||
f"开 {opened} | 平 {closed} | 持仓 {hold} | 结果 {result} | "
|
||||
f"盈亏 {pnl} | 标签 {tag}"
|
||||
)
|
||||
if note:
|
||||
line += f" | 备注 {note}"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def send_archive_quote_review(
|
||||
*,
|
||||
quote_date: str,
|
||||
content: str,
|
||||
) -> dict[str, Any]:
|
||||
text = (content or "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "msg": "语录内容不能为空"}
|
||||
day = (quote_date or "").strip()[:10]
|
||||
if not day:
|
||||
return {"ok": False, "msg": "语录日期无效"}
|
||||
|
||||
session = create_new_session(
|
||||
trading_day=day,
|
||||
title=f"复盘 {day}",
|
||||
bot_mode=CHAT_BOT_TRADING,
|
||||
)
|
||||
sid = session["id"]
|
||||
|
||||
archive_payload = list_daily_trades(trading_day=day, period="today")
|
||||
archive_trades_text = format_archive_trades_for_ai(archive_payload)
|
||||
user_for_prompt = clip_text(text, CHAT_USER_MESSAGE_MAX_CHARS)
|
||||
|
||||
user_prompt = build_archive_quote_review_prompt(
|
||||
quote_date=day,
|
||||
archive_trades_text=archive_trades_text,
|
||||
user_message=user_for_prompt,
|
||||
)
|
||||
reply = generate_text(
|
||||
system=CHAT_SYSTEM,
|
||||
user=user_prompt,
|
||||
temperature=CHAT_TEMPERATURE,
|
||||
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
|
||||
max_continuations=CHAT_MAX_CONTINUATIONS,
|
||||
)
|
||||
if is_ai_error_reply(reply):
|
||||
delete_chat_session(sid)
|
||||
return {"ok": False, "msg": reply}
|
||||
|
||||
append_chat_message(sid, "user", text)
|
||||
session = append_chat_message(sid, "assistant", reply)
|
||||
refresh_session_rolling_summary(
|
||||
sid,
|
||||
prior_summary="",
|
||||
user_text=text,
|
||||
assistant_text=reply,
|
||||
bot_mode=CHAT_BOT_TRADING,
|
||||
)
|
||||
session = get_active_session() or session
|
||||
return {
|
||||
"ok": True,
|
||||
"trading_day": day,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"reply": reply,
|
||||
"model": model_label(),
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""中控 AI 聊天附件解析."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from hub_ai.config import (
|
||||
CHAT_MAX_ATTACHMENTS,
|
||||
CHAT_MAX_IMAGE_BYTES,
|
||||
CHAT_MAX_TEXT_FILE_BYTES,
|
||||
)
|
||||
|
||||
IMAGE_MIMES = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
}
|
||||
TEXT_MIMES = {
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
}
|
||||
|
||||
|
||||
def _guess_mime(filename: str, content_type: str) -> str:
|
||||
ct = (content_type or "").split(";")[0].strip().lower()
|
||||
if ct:
|
||||
return ct
|
||||
name = (filename or "").lower()
|
||||
if name.endswith(".png"):
|
||||
return "image/png"
|
||||
if name.endswith((".jpg", ".jpeg")):
|
||||
return "image/jpeg"
|
||||
if name.endswith(".webp"):
|
||||
return "image/webp"
|
||||
if name.endswith(".gif"):
|
||||
return "image/gif"
|
||||
if name.endswith((".md", ".markdown")):
|
||||
return "text/markdown"
|
||||
if name.endswith(".txt"):
|
||||
return "text/plain"
|
||||
if name.endswith(".json"):
|
||||
return "application/json"
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def parse_chat_attachments(raw_files: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""
|
||||
raw_files: [{filename, content_type, data: bytes}]
|
||||
返回 images_b64, attachment_note, attachment_meta, text_append
|
||||
"""
|
||||
images_b64: list[str] = []
|
||||
meta: list[dict] = []
|
||||
notes: list[str] = []
|
||||
text_blocks: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for item in (raw_files or [])[:CHAT_MAX_ATTACHMENTS]:
|
||||
name = str(item.get("filename") or "file")
|
||||
data = item.get("data") or b""
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
errors.append(f"{name}: 无效数据")
|
||||
continue
|
||||
mime = _guess_mime(name, str(item.get("content_type") or ""))
|
||||
size = len(data)
|
||||
if mime in IMAGE_MIMES:
|
||||
if size > CHAT_MAX_IMAGE_BYTES:
|
||||
errors.append(f"{name}: 图片超过 {CHAT_MAX_IMAGE_BYTES // 1024 // 1024}MB")
|
||||
continue
|
||||
images_b64.append(base64.b64encode(bytes(data)).decode("ascii"))
|
||||
meta.append({"name": name, "kind": "image", "mime": mime, "size": size})
|
||||
notes.append(f"图片 {name}")
|
||||
continue
|
||||
if mime in TEXT_MIMES or name.lower().endswith((".txt", ".md", ".markdown", ".json")):
|
||||
if size > CHAT_MAX_TEXT_FILE_BYTES:
|
||||
errors.append(f"{name}: 文本超过 {CHAT_MAX_TEXT_FILE_BYTES // 1024}KB")
|
||||
continue
|
||||
try:
|
||||
text = bytes(data).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
errors.append(f"{name}: 非 UTF-8 文本")
|
||||
continue
|
||||
text_blocks.append(f"--- 附件 {name} ---\n{text.strip()}")
|
||||
meta.append({"name": name, "kind": "text", "mime": mime, "size": size})
|
||||
notes.append(f"文档 {name}")
|
||||
continue
|
||||
errors.append(f"{name}: 不支持的类型(仅图片或 txt/md/json)")
|
||||
|
||||
attachment_note = ";".join(notes) if notes else ""
|
||||
if errors:
|
||||
attachment_note = (attachment_note + ";" if attachment_note else "") + ";".join(errors)
|
||||
text_append = "\n\n".join(text_blocks)
|
||||
return {
|
||||
"images_b64": images_b64,
|
||||
"attachment_note": attachment_note,
|
||||
"attachment_meta": meta,
|
||||
"text_append": text_append,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""中控 AI:单会话聊天(直到用户点击新开)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from hub_ai.attachments import parse_chat_attachments
|
||||
from hub_ai.client import generate_text, model_label
|
||||
from hub_ai.config import (
|
||||
CHAT_CONTEXT_MAX_CHARS,
|
||||
CHAT_FOLLOWUP_CONTEXT_MAX_CHARS,
|
||||
CHAT_HISTORY_MAX_CHARS_PER_MSG,
|
||||
CHAT_MAX_CONTINUATIONS,
|
||||
CHAT_MAX_HISTORY_TURNS,
|
||||
CHAT_MAX_OUTPUT_TOKENS,
|
||||
CHAT_PROMPT_MAX_CHARS,
|
||||
CHAT_SUMMARY_EXCERPT_MAX_CHARS,
|
||||
CHAT_TEMPERATURE,
|
||||
CHAT_USER_MESSAGE_MAX_CHARS,
|
||||
trading_day_reset_hour,
|
||||
)
|
||||
from lib.hub.hub_trades_lib import current_trading_day
|
||||
from hub_ai.context import (
|
||||
build_chat_context,
|
||||
format_chat_context_for_chat,
|
||||
format_chat_position_overview,
|
||||
)
|
||||
from hub_ai.prompts import (
|
||||
CHAT_GENERAL_SYSTEM,
|
||||
CHAT_SYSTEM,
|
||||
build_chat_user_prompt,
|
||||
build_general_chat_user_prompt,
|
||||
)
|
||||
from hub_ai.rolling_summary import refresh_session_rolling_summary
|
||||
from hub_ai.store import (
|
||||
CHAT_BOT_GENERAL,
|
||||
CHAT_BOT_TRADING,
|
||||
append_chat_message,
|
||||
create_new_session,
|
||||
delete_chat_session,
|
||||
ensure_active_session,
|
||||
get_active_session,
|
||||
list_chat_sessions,
|
||||
load_chat_store,
|
||||
set_active_session,
|
||||
summary_excerpt_for_chat,
|
||||
)
|
||||
from hub_ai.text_util import clip_text, is_ai_error_reply
|
||||
|
||||
|
||||
def _is_ai_error_reply(text: str) -> bool:
|
||||
return is_ai_error_reply(text)
|
||||
|
||||
|
||||
def _clip_text(text: str, max_chars: int) -> str:
|
||||
return clip_text(text, max_chars)
|
||||
|
||||
|
||||
def _history_lines(
|
||||
messages: list[dict],
|
||||
max_turns: int = CHAT_MAX_HISTORY_TURNS,
|
||||
*,
|
||||
max_chars_per_msg: int = CHAT_HISTORY_MAX_CHARS_PER_MSG,
|
||||
total_max_chars: int | None = None,
|
||||
) -> str:
|
||||
rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")]
|
||||
rows = rows[-max_turns * 2 :]
|
||||
lines = []
|
||||
for m in rows:
|
||||
role = "用户" if m.get("role") == "user" else "搭档"
|
||||
content = str(m.get("content") or "").strip()
|
||||
if m.get("role") == "assistant" and _is_ai_error_reply(content):
|
||||
continue
|
||||
att = m.get("attachments") or []
|
||||
if att:
|
||||
names = ",".join(str(a.get("name") or "附件") for a in att[:3])
|
||||
content = f"{content} [附件: {names}]".strip()
|
||||
content = _clip_text(content, max_chars_per_msg)
|
||||
if content:
|
||||
lines.append(f"{role}:{content}")
|
||||
if total_max_chars and total_max_chars > 0:
|
||||
while lines and len("\n".join(lines)) > total_max_chars:
|
||||
lines.pop(0)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _trading_context_bundle(ctx: dict[str, Any], *, prior_count: int) -> tuple[str, str]:
|
||||
day = str(ctx.get("trading_day") or (ctx.get("totals") or {}).get("trading_day") or "")
|
||||
if prior_count <= 0:
|
||||
brief = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS)
|
||||
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS)
|
||||
return brief, excerpt
|
||||
totals = ctx.get("totals") or {}
|
||||
overview = format_chat_position_overview(ctx)
|
||||
slim = (
|
||||
f"【续聊快照 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
|
||||
f"笔数 {totals.get('closed_count')} | "
|
||||
f"持仓 {totals.get('open_position_count', 0)} 仓 | "
|
||||
f"浮盈亏 {totals.get('float_pnl_u')}U"
|
||||
)
|
||||
brief = _clip_text(overview + "\n" + slim, CHAT_FOLLOWUP_CONTEXT_MAX_CHARS)
|
||||
return brief, ""
|
||||
|
||||
|
||||
def _history_budget(*sizes: int) -> int:
|
||||
used = sum(int(s or 0) for s in sizes) + 2200
|
||||
return max(1200, CHAT_PROMPT_MAX_CHARS - used)
|
||||
|
||||
|
||||
def _prompt_memory(session: dict, prior_msgs: list[dict]) -> tuple[str, str]:
|
||||
"""续聊优先用滚动摘要;旧会话无摘要时仅带最近 1 轮兜底."""
|
||||
rolling = str(session.get("rolling_summary") or "").strip()
|
||||
if rolling:
|
||||
return rolling, ""
|
||||
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
|
||||
if prior_count <= 0:
|
||||
return "", ""
|
||||
tail = _history_lines(
|
||||
prior_msgs,
|
||||
max_turns=1,
|
||||
max_chars_per_msg=CHAT_HISTORY_MAX_CHARS_PER_MSG,
|
||||
)
|
||||
return "", tail
|
||||
|
||||
|
||||
def get_chat_state() -> dict[str, Any]:
|
||||
store = load_chat_store()
|
||||
session = get_active_session()
|
||||
if session:
|
||||
session.setdefault("bot_mode", CHAT_BOT_TRADING)
|
||||
session.setdefault("rolling_summary", "")
|
||||
return {
|
||||
"active_session_id": store.get("active_session_id"),
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict:
|
||||
session = create_new_session(trading_day=trading_day, bot_mode=bot_mode)
|
||||
return {
|
||||
"ok": True,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def switch_chat_session(session_id: str) -> dict[str, Any]:
|
||||
session = set_active_session(session_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def remove_chat_session(session_id: str) -> dict[str, Any]:
|
||||
deleted, new_active = delete_chat_session(session_id)
|
||||
if not deleted:
|
||||
return {"ok": False, "msg": "session_not_found"}
|
||||
session = get_active_session()
|
||||
return {
|
||||
"ok": True,
|
||||
"active_session_id": new_active,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def send_chat_message(
|
||||
exchanges: list[dict],
|
||||
message: str,
|
||||
*,
|
||||
trading_day: str | None = None,
|
||||
raw_attachments: Optional[list[dict]] = None,
|
||||
) -> dict[str, Any]:
|
||||
text = (message or "").strip()
|
||||
parsed = parse_chat_attachments(raw_attachments or [])
|
||||
if parsed.get("errors") and not text and not parsed.get("images_b64"):
|
||||
return {"ok": False, "msg": ";".join(parsed["errors"])}
|
||||
if not text and not parsed.get("images_b64") and not parsed.get("text_append"):
|
||||
return {"ok": False, "msg": "消息不能为空"}
|
||||
|
||||
user_visible = text
|
||||
if parsed.get("text_append"):
|
||||
user_visible = (user_visible + "\n\n" + parsed["text_append"]).strip()
|
||||
if not user_visible and parsed.get("attachment_note"):
|
||||
user_visible = f"(上传了 {parsed['attachment_note']})"
|
||||
|
||||
day = (trading_day or "").strip()[:10] or current_trading_day(
|
||||
reset_hour=trading_day_reset_hour()
|
||||
)
|
||||
session = ensure_active_session(trading_day=day)
|
||||
sid = session["id"]
|
||||
prior_rolling = str(session.get("rolling_summary") or "")
|
||||
prior_msgs = session.get("messages") or []
|
||||
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
|
||||
user_for_prompt = _clip_text(text or user_visible, CHAT_USER_MESSAGE_MAX_CHARS)
|
||||
rolling_summary, history_tail = _prompt_memory(session, prior_msgs)
|
||||
|
||||
bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower()
|
||||
if bot_mode == CHAT_BOT_GENERAL:
|
||||
user_prompt = build_general_chat_user_prompt(
|
||||
rolling_summary=rolling_summary,
|
||||
history_lines=history_tail,
|
||||
user_message=user_for_prompt,
|
||||
attachment_note=str(parsed.get("attachment_note") or ""),
|
||||
)
|
||||
if parsed.get("text_append"):
|
||||
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
|
||||
system_prompt = CHAT_GENERAL_SYSTEM
|
||||
else:
|
||||
ctx = build_chat_context(exchanges, trading_day=day)
|
||||
day = ctx["trading_day"]
|
||||
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count)
|
||||
user_prompt = build_chat_user_prompt(
|
||||
context_text=brief_ctx,
|
||||
trading_day=day,
|
||||
summary_excerpt=excerpt,
|
||||
rolling_summary=rolling_summary,
|
||||
history_lines=history_tail,
|
||||
user_message=user_for_prompt,
|
||||
attachment_note=str(parsed.get("attachment_note") or ""),
|
||||
)
|
||||
if parsed.get("text_append"):
|
||||
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
|
||||
system_prompt = CHAT_SYSTEM
|
||||
|
||||
reply = generate_text(
|
||||
system=system_prompt,
|
||||
user=user_prompt,
|
||||
temperature=CHAT_TEMPERATURE,
|
||||
images_b64=parsed.get("images_b64") or None,
|
||||
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
|
||||
max_continuations=CHAT_MAX_CONTINUATIONS,
|
||||
)
|
||||
if _is_ai_error_reply(reply):
|
||||
return {"ok": False, "msg": reply, "session_id": sid}
|
||||
|
||||
append_chat_message(
|
||||
sid,
|
||||
"user",
|
||||
user_visible,
|
||||
attachments=parsed.get("attachment_meta") or [],
|
||||
)
|
||||
session = append_chat_message(sid, "assistant", reply)
|
||||
summary_kwargs = {
|
||||
"session_id": sid,
|
||||
"prior_summary": prior_rolling,
|
||||
"user_text": user_visible,
|
||||
"assistant_text": reply,
|
||||
"bot_mode": bot_mode,
|
||||
}
|
||||
|
||||
def _refresh_summary_bg() -> None:
|
||||
try:
|
||||
refresh_session_rolling_summary(**summary_kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_refresh_summary_bg, daemon=True).start()
|
||||
session = get_active_session() or session
|
||||
return {
|
||||
"ok": True,
|
||||
"trading_day": day,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"reply": reply,
|
||||
"model": model_label(),
|
||||
"attachment_warnings": parsed.get("errors") or [],
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""中控 AI 模型调用(共用 ai_client 配置,逻辑独立)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from lib.ai.ai_client import ai_generate, ai_generate_chat, ai_provider_label # noqa: E402
|
||||
|
||||
|
||||
def model_label() -> str:
|
||||
return ai_provider_label()
|
||||
|
||||
|
||||
def generate_text(
|
||||
*,
|
||||
system: str,
|
||||
user: str,
|
||||
temperature: float,
|
||||
images_b64: Optional[Sequence[str]] = None,
|
||||
max_tokens: int | None = None,
|
||||
max_continuations: int = 3,
|
||||
) -> str:
|
||||
if max_tokens is not None and max_tokens > 0:
|
||||
return ai_generate_chat(
|
||||
system=system,
|
||||
user=user,
|
||||
temperature=temperature,
|
||||
images_b64=images_b64,
|
||||
max_tokens=int(max_tokens),
|
||||
max_continuations=max_continuations,
|
||||
)
|
||||
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
|
||||
return ai_generate(
|
||||
prompt,
|
||||
temperature=temperature,
|
||||
images_b64=images_b64,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
HUB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _int_env(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(key, str(default)) or default)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
SUMMARY_TEMPERATURE = 0.15
|
||||
CHAT_TEMPERATURE = 0.5
|
||||
CHAT_MAX_HISTORY_TURNS = _int_env("CHAT_MAX_HISTORY_TURNS", 16)
|
||||
CHAT_MAX_OUTPUT_TOKENS = _int_env("CHAT_MAX_OUTPUT_TOKENS", 8192)
|
||||
CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 4)
|
||||
CHAT_CONTEXT_MAX_CHARS = _int_env("CHAT_CONTEXT_MAX_CHARS", 12_000)
|
||||
CHAT_FOLLOWUP_CONTEXT_MAX_CHARS = _int_env("CHAT_FOLLOWUP_CONTEXT_MAX_CHARS", 4500)
|
||||
CHAT_PROMPT_MAX_CHARS = _int_env("CHAT_PROMPT_MAX_CHARS", 28_000)
|
||||
CHAT_USER_MESSAGE_MAX_CHARS = _int_env("CHAT_USER_MESSAGE_MAX_CHARS", 3500)
|
||||
CHAT_SUMMARY_EXCERPT_MAX_CHARS = _int_env("CHAT_SUMMARY_EXCERPT_MAX_CHARS", 1200)
|
||||
CHAT_HISTORY_MAX_CHARS_PER_MSG = _int_env("CHAT_HISTORY_MAX_CHARS_PER_MSG", 900)
|
||||
CHAT_ROLLING_SUMMARY_MAX_CHARS = _int_env("CHAT_ROLLING_SUMMARY_MAX_CHARS", 900)
|
||||
CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS = _int_env("CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS", 512)
|
||||
CHAT_ROLLING_SUMMARY_TEMPERATURE = 0.2
|
||||
SUMMARY_RETENTION_DAYS = 90
|
||||
CHAT_SESSION_RETENTION_DAYS = 60
|
||||
FUND_HISTORY_DAYS = 180
|
||||
CHAT_MAX_ATTACHMENTS = 3
|
||||
CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
||||
CHAT_MAX_TEXT_FILE_BYTES = 200 * 1024
|
||||
CHAT_CONTEXT_CACHE_TTL_SEC = _int_env("CHAT_CONTEXT_CACHE_TTL_SEC", 45)
|
||||
|
||||
|
||||
def trading_day_reset_hour() -> int:
|
||||
try:
|
||||
return int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8")
|
||||
except ValueError:
|
||||
return 8
|
||||
|
||||
|
||||
def hub_flask_timeout() -> float:
|
||||
try:
|
||||
return float(os.getenv("HUB_FLASK_TIMEOUT", "10") or "10")
|
||||
except ValueError:
|
||||
return 10.0
|
||||
|
||||
|
||||
def hub_agent_timeout() -> float:
|
||||
try:
|
||||
return float(os.getenv("HUB_AGENT_TIMEOUT", "8") or "8")
|
||||
except ValueError:
|
||||
return 8.0
|
||||
@@ -0,0 +1,18 @@
|
||||
"""中控 AI:分户资金快照(委托 hub_fund_history_lib,保留 180 交易日)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.hub.hub_fund_history_lib import (
|
||||
FUND_HISTORY_DAYS,
|
||||
format_fund_history_text,
|
||||
get_fund_history,
|
||||
record_fund_snapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FUND_HISTORY_DAYS",
|
||||
"format_fund_history_text",
|
||||
"get_fund_history",
|
||||
"record_fund_snapshot",
|
||||
]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""中控 AI 提示词(与实例 ai_review 分离)."""
|
||||
|
||||
SUMMARY_SYSTEM = """
|
||||
你是多账户加密货币合约交易的台账助手.只根据用户提供的结构化数据输出中文 Markdown,语气克制,偏冷,客观,像值班记录.
|
||||
|
||||
硬性规则:
|
||||
- 只能陈述数据中明确出现的数字与事实;禁止编造成交,止损,扛单,行情预测.
|
||||
- 上下文仅含「今日」一个交易日的平仓,持仓与监控;不得引用昨日,历史走势或数据里未出现的账户名.
|
||||
- 未监控的账户必须标注「未监控」,不得臆测其盈亏.
|
||||
- 连接失败或数据缺失的账户如实写明,不要猜测.
|
||||
- 趋势回调计划,顺势加仓,关键位监控,进行中的下单监控:仅据数据列示,无则写「无」.
|
||||
- 第1~4节保持客观台账;**第5节操作建议**可基于当日资金账户/交易账户余额,仓位与监控单,给出简短,可执行的资金与仓位安排建议(仍禁止预测涨跌,保证收益).
|
||||
- 禁止输出 pipe 分隔的 Markdown 表格或「详细数据支持」附录;禁止夸张词(致命,崩溃,灾难等).
|
||||
|
||||
输出格式(Markdown,标题必须一致):
|
||||
**今日交易总结({trading_day})**
|
||||
|
||||
**1. 总览**
|
||||
- **合计盈亏(U)**:今日平仓合计 …
|
||||
- **平仓笔数**:今日 …(胜 / 负 / 平)
|
||||
- **当前持仓浮盈亏(U)**:…
|
||||
- **资金合计**:资金账户 … / 交易账户 …(仅已监控且有数据账户)
|
||||
|
||||
**2. 分户明细**
|
||||
中控页面会自动渲染分户表格,本节不要输出 pipe 分隔行或 Markdown 表格;可写一句「见下表」或直接留空.
|
||||
|
||||
**3. 需关注**
|
||||
仅有依据时列出(亏损,浮亏,监控/趋势/关键位异常,资金缺口等);若无则写「无」.
|
||||
|
||||
**4. 数据说明**
|
||||
列出数据缺口(某户未启用,接口失败等).
|
||||
|
||||
**5. 操作建议**
|
||||
基于各户当日资金账户与交易账户余额,持仓与监控单,给出 2~5 条简短建议(如:是否需要从资金账户补充交易账户,哪户风险敞口偏高等).无依据则写「暂无」.
|
||||
""".strip()
|
||||
|
||||
|
||||
CHAT_SYSTEM = """
|
||||
你是和用户一起盯盘的老搭档交易员,熟悉他多个交易所账户的分工.用中文,口语化,短句交流.
|
||||
|
||||
语气要求:
|
||||
- 先理解对方的压力和情绪,再轻轻帮他把事想清楚(安慰,体贴).
|
||||
- 可以指出执行或心态上的偏差点,但用商量,陪伴的口吻,绝不用教育,训诫,上课,列清单式说教.
|
||||
- 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」.
|
||||
- 不预测涨跌,不保证收益,不替用户做决定.
|
||||
- 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」.
|
||||
- **持仓判定**:只有快照里「实盘持仓总览 / 持仓明细 / 交易所实盘」才算已开仓;「空仓 / 0 仓」就是没仓位.浮盈亏 0U 且空仓时,不要说「还有仓」「卡着不动」.
|
||||
- **监控单 ≠ 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓.
|
||||
- 用户口述与快照冲突时,以快照为准并口语说明「我这边看到是空仓/有N仓」.
|
||||
- 若附带「今日总结摘要」,那是较早生成的缓存,**实盘持仓以【当前多账户快照】里的「实盘持仓总览」为准**,摘要里若提到持仓可能已过时.
|
||||
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清.
|
||||
- **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字.
|
||||
- **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行.
|
||||
- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」.仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损.趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈.
|
||||
- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明.
|
||||
""".strip()
|
||||
|
||||
|
||||
def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
|
||||
return f"""
|
||||
交易日(今日):{trading_day}
|
||||
|
||||
以下为中控聚合的多账户数据(仅今日平仓,持仓,趋势回调/顺势加仓/关键位/监控单):
|
||||
|
||||
{context_text}
|
||||
""".strip()
|
||||
|
||||
|
||||
CHAT_GENERAL_SYSTEM = """
|
||||
你是简洁,友好的中文助手,陪用户闲聊,答疑,整理思路.
|
||||
|
||||
规则:
|
||||
- 口语化,自然,不要列清单式说教,不要「作为 AI 我必须…」.
|
||||
- 用户未主动聊交易时,不要主动扯合约,仓位,盈亏,盯盘.
|
||||
- 你没有接入用户的交易账户数据;不要编造持仓,资金或监控状态.若被问到交易事实,说明这边看不到实盘,建议去中控监控区或实例页查看.
|
||||
- 若用户上传图片或文档,结合可见内容回应;看不清的直说.
|
||||
- 接续【对话核心摘要】,不要重复开场白;回复须写完整,以句号/问号/感叹号收尾.
|
||||
""".strip()
|
||||
|
||||
|
||||
ROLLING_SUMMARY_TRADING_SYSTEM = """
|
||||
你是交易教练的对话记录员.把「此前摘要」与「本轮用户+教练回复」压成一条极短中文摘要.
|
||||
|
||||
要求:
|
||||
- 120~280 字,纯文本一段,不要标题,不要列表,不要寒暄.
|
||||
- 只保留:用户情绪/困扰,涉及的交易事实,教练核心建议,已达成的共识,待跟进事项.
|
||||
- 禁止编造未出现的信息;数字与账户名须来自原文.
|
||||
""".strip()
|
||||
|
||||
|
||||
ROLLING_SUMMARY_GENERAL_SYSTEM = """
|
||||
你是对话记录员.把「此前摘要」与「本轮用户+助手回复」压成一条极短中文摘要.
|
||||
|
||||
要求:
|
||||
- 100~240 字,纯文本一段,不要标题,不要列表.
|
||||
- 只保留:话题,用户诉求,助手给出的关键信息,待跟进事项.
|
||||
""".strip()
|
||||
|
||||
|
||||
def build_rolling_summary_user_prompt(
|
||||
*,
|
||||
prior_summary: str,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if prior_summary.strip():
|
||||
parts.extend(["【此前摘要】", prior_summary.strip()])
|
||||
parts.extend([
|
||||
"【本轮用户】",
|
||||
user_text.strip() or "(空)",
|
||||
"【本轮教练/助手】",
|
||||
assistant_text.strip() or "(空)",
|
||||
"请输出更新后的对话核心摘要:",
|
||||
])
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_general_chat_user_prompt(
|
||||
*,
|
||||
rolling_summary: str = "",
|
||||
history_lines: str = "",
|
||||
user_message: str,
|
||||
attachment_note: str = "",
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if rolling_summary.strip():
|
||||
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
|
||||
elif history_lines.strip():
|
||||
parts.extend(["【最近对话】", history_lines.strip()])
|
||||
if attachment_note.strip():
|
||||
parts.extend(["【用户附件说明】", attachment_note.strip()])
|
||||
parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()])
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_chat_user_prompt(
|
||||
*,
|
||||
context_text: str,
|
||||
trading_day: str,
|
||||
summary_excerpt: str,
|
||||
rolling_summary: str = "",
|
||||
history_lines: str = "",
|
||||
user_message: str,
|
||||
attachment_note: str = "",
|
||||
) -> str:
|
||||
parts = [f"【交易日】{trading_day}"]
|
||||
if rolling_summary.strip():
|
||||
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
|
||||
elif history_lines.strip():
|
||||
parts.extend(["【最近对话】", history_lines.strip()])
|
||||
parts.extend([
|
||||
"【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】",
|
||||
context_text.strip() or "(无监控数据)",
|
||||
])
|
||||
if summary_excerpt.strip():
|
||||
parts.extend([
|
||||
"【今日总结摘要(可能滞后,持仓以快照为准)】",
|
||||
summary_excerpt.strip(),
|
||||
])
|
||||
if attachment_note.strip():
|
||||
parts.extend(["【用户附件说明】", attachment_note.strip()])
|
||||
parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()])
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
ARCHIVE_QUOTE_REVIEW_INSTRUCTION = """
|
||||
【任务】用户从内照明心提交了一条复盘语录,并附上该交易日的档案交易记录(界面「复盘语录」下方也会展示当日已平仓明细).
|
||||
请结合语录与交易记录:
|
||||
1) 帮他核对自述与操作事实是否一致;
|
||||
2) 指出心态,纪律,执行上的偏差点(若有);
|
||||
3) 给出可落地的改进建议.
|
||||
语气沿用交易教练:体贴,口语,短句,不用说教式清单;不预测涨跌,不保证收益.
|
||||
""".strip()
|
||||
|
||||
|
||||
SUPERVISOR_SYSTEM = """
|
||||
你是交易监管值班员,职责是防止过度交易与频繁手动操作.用中文,短句,克制语气.
|
||||
|
||||
规则:
|
||||
- 只依据提供的结构化事件与账户快照说话;禁止预测涨跌,保证收益.
|
||||
- **手动平仓,中控平仓,新开仓**:指出频率,间隔,是否偏急;提醒休息,不训斥.
|
||||
- **程序止盈/程序止损**:肯定按计划执行,鼓励保持纪律,提醒别立刻反手再开.
|
||||
- 不替用户做决定,不暗示绕过实例冷静期/日冻结.
|
||||
- 每次 1~3 句,必须写完整;禁止长清单和「第1点第2点」.
|
||||
- 实例已进入冷静期/日冻结时,明确说明状态,建议暂停手动开平.
|
||||
""".strip()
|
||||
|
||||
|
||||
def build_supervisor_ai_prompt(
|
||||
*,
|
||||
context_text: str,
|
||||
trading_day: str,
|
||||
event: dict,
|
||||
warnings: list[dict],
|
||||
) -> str:
|
||||
warn_lines = "\n".join(f"- {w.get('message')}" for w in (warnings or []) if w.get("message"))
|
||||
parts = [
|
||||
f"【交易日】{trading_day}",
|
||||
"【监管事件】",
|
||||
str(event or {}),
|
||||
"【当前多账户快照】",
|
||||
(context_text or "(无)").strip(),
|
||||
]
|
||||
if warn_lines.strip():
|
||||
parts.extend(["【已触发频率警告】", warn_lines.strip()])
|
||||
parts.append("请给出 1~3 句监管评语:")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_supervisor_chat_prompt(
|
||||
*,
|
||||
context_text: str,
|
||||
trading_day: str,
|
||||
history_lines: str,
|
||||
user_message: str,
|
||||
) -> str:
|
||||
parts = [f"【交易日】{trading_day}"]
|
||||
if history_lines.strip():
|
||||
parts.extend(["【今日监管对话】", history_lines.strip()])
|
||||
parts.extend([
|
||||
"【当前多账户快照】",
|
||||
(context_text or "(无)").strip(),
|
||||
"【用户现在说】",
|
||||
user_message.strip(),
|
||||
])
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_archive_quote_review_prompt(
|
||||
*,
|
||||
quote_date: str,
|
||||
archive_trades_text: str,
|
||||
user_message: str,
|
||||
) -> str:
|
||||
parts = [
|
||||
f"【复盘交易日】{quote_date}",
|
||||
ARCHIVE_QUOTE_REVIEW_INSTRUCTION,
|
||||
"【该日交易记录(内照明心档案,与界面「当日已平仓」一致)】",
|
||||
(archive_trades_text or "(该日无交易记录)").strip(),
|
||||
"【用户复盘语录(对话框已展示,请优先回应)】",
|
||||
user_message.strip(),
|
||||
]
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""聊天滚动摘要:每轮后压缩历史,续聊只带摘要 + 当前消息."""
|
||||
from __future__ import annotations
|
||||
|
||||
from hub_ai.text_util import clip_text, is_ai_error_reply
|
||||
from hub_ai.client import generate_text
|
||||
from hub_ai.config import (
|
||||
CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS,
|
||||
CHAT_ROLLING_SUMMARY_MAX_CHARS,
|
||||
CHAT_ROLLING_SUMMARY_TEMPERATURE,
|
||||
)
|
||||
from hub_ai.prompts import (
|
||||
ROLLING_SUMMARY_GENERAL_SYSTEM,
|
||||
ROLLING_SUMMARY_TRADING_SYSTEM,
|
||||
build_rolling_summary_user_prompt,
|
||||
)
|
||||
from hub_ai.store import CHAT_BOT_GENERAL, update_session_rolling_summary
|
||||
|
||||
|
||||
def refresh_session_rolling_summary(
|
||||
session_id: str,
|
||||
*,
|
||||
prior_summary: str,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
bot_mode: str,
|
||||
) -> str:
|
||||
"""合并旧摘要与本轮对话,生成新的短摘要并写入会话."""
|
||||
user_clip = clip_text(user_text, 1200)
|
||||
assistant_clip = clip_text(assistant_text, 1800)
|
||||
if not user_clip and not assistant_clip:
|
||||
summary = clip_text(prior_summary, CHAT_ROLLING_SUMMARY_MAX_CHARS)
|
||||
update_session_rolling_summary(session_id, summary)
|
||||
return summary
|
||||
|
||||
system = (
|
||||
ROLLING_SUMMARY_GENERAL_SYSTEM
|
||||
if (bot_mode or "").strip().lower() == CHAT_BOT_GENERAL
|
||||
else ROLLING_SUMMARY_TRADING_SYSTEM
|
||||
)
|
||||
raw = generate_text(
|
||||
system=system,
|
||||
user=build_rolling_summary_user_prompt(
|
||||
prior_summary=prior_summary,
|
||||
user_text=user_clip,
|
||||
assistant_text=assistant_clip,
|
||||
),
|
||||
temperature=CHAT_ROLLING_SUMMARY_TEMPERATURE,
|
||||
max_tokens=CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS,
|
||||
max_continuations=1,
|
||||
)
|
||||
if is_ai_error_reply(raw):
|
||||
fallback = _fallback_summary(prior_summary, user_clip, assistant_clip)
|
||||
update_session_rolling_summary(session_id, fallback)
|
||||
return fallback
|
||||
|
||||
summary = clip_text(raw, CHAT_ROLLING_SUMMARY_MAX_CHARS)
|
||||
update_session_rolling_summary(session_id, summary)
|
||||
return summary
|
||||
|
||||
|
||||
def _fallback_summary(prior: str, user_text: str, assistant_text: str) -> str:
|
||||
parts: list[str] = []
|
||||
if prior.strip():
|
||||
parts.append(prior.strip())
|
||||
if user_text.strip():
|
||||
parts.append(f"用户:{clip_text(user_text, 200)}")
|
||||
if assistant_text.strip():
|
||||
parts.append(f"教练:{clip_text(assistant_text, 280)}")
|
||||
return clip_text("\n".join(parts), CHAT_ROLLING_SUMMARY_MAX_CHARS)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""中控 AI FastAPI 路由."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hub_ai.archive_quote import send_archive_quote_review
|
||||
from hub_ai.chat import (
|
||||
get_chat_state,
|
||||
remove_chat_session,
|
||||
send_chat_message,
|
||||
start_new_chat,
|
||||
switch_chat_session,
|
||||
)
|
||||
from hub_ai.client import model_label
|
||||
from hub_ai.config import trading_day_reset_hour
|
||||
from hub_ai.context import build_daily_context
|
||||
from hub_ai.store import get_latest_summary, list_summaries
|
||||
from hub_ai.supervisor import send_supervisor_chat
|
||||
from hub_ai.supervisor_store import get_supervisor_session_state
|
||||
from hub_ai.summary import generate_daily_summary
|
||||
from lib.hub.hub_trades_lib import current_trading_day
|
||||
from settings_store import normalize_supervisor_settings
|
||||
|
||||
|
||||
class ChatSendBody(BaseModel):
|
||||
message: str = ""
|
||||
trading_day: str = ""
|
||||
|
||||
|
||||
class SummaryGenerateBody(BaseModel):
|
||||
trading_day: str = ""
|
||||
force: bool = False
|
||||
|
||||
|
||||
class ChatNewBody(BaseModel):
|
||||
trading_day: str = ""
|
||||
bot_mode: str = "trading"
|
||||
|
||||
|
||||
class ChatSwitchBody(BaseModel):
|
||||
session_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ArchiveQuoteChatBody(BaseModel):
|
||||
quote_date: str = ""
|
||||
content: str = ""
|
||||
|
||||
|
||||
class SupervisorChatBody(BaseModel):
|
||||
message: str = ""
|
||||
trading_day: str = ""
|
||||
|
||||
|
||||
def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/ai", tags=["hub-ai"])
|
||||
|
||||
def _day(raw: str = "") -> str:
|
||||
d = (raw or "").strip()[:10]
|
||||
return d or current_trading_day(reset_hour=trading_day_reset_hour())
|
||||
|
||||
@router.get("/meta")
|
||||
def api_ai_meta():
|
||||
return {
|
||||
"ok": True,
|
||||
"model": model_label(),
|
||||
"trading_day_reset_hour": trading_day_reset_hour(),
|
||||
"trading_day": current_trading_day(reset_hour=trading_day_reset_hour()),
|
||||
"storage": {
|
||||
"summaries": "hub_ai_summaries.json",
|
||||
"chat": "hub_ai_chat.json",
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/context")
|
||||
def api_ai_context(trading_day: str = ""):
|
||||
exchanges = load_all_exchanges()
|
||||
ctx = build_daily_context(exchanges, trading_day=_day(trading_day))
|
||||
return {"ok": True, **ctx}
|
||||
|
||||
@router.get("/summary")
|
||||
def api_ai_summary_list(trading_day: str = ""):
|
||||
day = _day(trading_day) if trading_day.strip() else ""
|
||||
items = list_summaries(trading_day=day or None, limit=20)
|
||||
latest = get_latest_summary(_day(trading_day)) if trading_day.strip() else (
|
||||
items[0] if items else None
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"trading_day": _day(trading_day) if trading_day.strip() else None,
|
||||
"summaries": items,
|
||||
"latest": latest,
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
@router.post("/summary/generate")
|
||||
def api_ai_summary_generate(body: SummaryGenerateBody = SummaryGenerateBody()):
|
||||
exchanges = load_all_exchanges()
|
||||
result = generate_daily_summary(
|
||||
exchanges,
|
||||
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
|
||||
force=bool(body.force),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "生成失败")
|
||||
result.pop("context", None)
|
||||
return result
|
||||
|
||||
@router.get("/chat/session")
|
||||
def api_ai_chat_session():
|
||||
state = get_chat_state()
|
||||
return {"ok": True, **state, "model": model_label()}
|
||||
|
||||
@router.post("/chat/new")
|
||||
def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
|
||||
day = _day(body.trading_day)
|
||||
return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading")
|
||||
|
||||
@router.post("/chat/switch")
|
||||
def api_ai_chat_switch(body: ChatSwitchBody):
|
||||
try:
|
||||
return switch_chat_session(body.session_id.strip())
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
@router.delete("/chat/session/{session_id}")
|
||||
def api_ai_chat_delete(session_id: str):
|
||||
result = remove_chat_session(session_id.strip())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
return result
|
||||
|
||||
@router.post("/chat/archive-quote")
|
||||
def api_ai_chat_archive_quote(body: ArchiveQuoteChatBody = Body(...)):
|
||||
result = send_archive_quote_review(
|
||||
quote_date=body.quote_date,
|
||||
content=body.content,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
|
||||
return result
|
||||
|
||||
@router.post("/chat/send")
|
||||
async def api_ai_chat_send(
|
||||
message: str = Form(""),
|
||||
trading_day: str = Form(""),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
):
|
||||
exchanges = load_all_exchanges()
|
||||
raw_attachments = []
|
||||
for f in files or []:
|
||||
if not f or not f.filename:
|
||||
continue
|
||||
data = await f.read()
|
||||
raw_attachments.append(
|
||||
{
|
||||
"filename": f.filename,
|
||||
"content_type": f.content_type or "",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
send_chat_message,
|
||||
exchanges,
|
||||
message,
|
||||
trading_day=_day(trading_day) if trading_day.strip() else None,
|
||||
raw_attachments=raw_attachments,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
|
||||
return result
|
||||
|
||||
@router.get("/supervisor/session")
|
||||
def api_ai_supervisor_session(trading_day: str = ""):
|
||||
day = _day(trading_day)
|
||||
return get_supervisor_session_state(day)
|
||||
|
||||
@router.get("/supervisor/rules")
|
||||
def api_ai_supervisor_rules():
|
||||
from settings_store import load_settings
|
||||
|
||||
cfg = normalize_supervisor_settings(load_settings().get("supervisor"))
|
||||
return {"ok": True, "supervisor": cfg}
|
||||
|
||||
@router.post("/supervisor/chat/send")
|
||||
def api_ai_supervisor_chat_send(body: SupervisorChatBody = SupervisorChatBody()):
|
||||
exchanges = load_all_exchanges()
|
||||
result = send_supervisor_chat(
|
||||
exchanges,
|
||||
body.message,
|
||||
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
|
||||
return result
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,302 @@
|
||||
"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hub_ai.config import CHAT_SESSION_RETENTION_DAYS, SUMMARY_RETENTION_DAYS
|
||||
|
||||
HUB_DIR = Path(__file__).resolve().parent.parent
|
||||
SUMMARIES_PATH = HUB_DIR / "hub_ai_summaries.json"
|
||||
CHAT_PATH = HUB_DIR / "hub_ai_chat.json"
|
||||
|
||||
|
||||
def _now_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _load_json(path: Path, default: dict) -> dict:
|
||||
if not path.is_file():
|
||||
return dict(default)
|
||||
try:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
return loaded
|
||||
except Exception:
|
||||
pass
|
||||
return dict(default)
|
||||
|
||||
|
||||
def _prune_summaries(items: list, *, keep_days: int) -> list:
|
||||
cutoff = (datetime.now() - timedelta(days=max(1, keep_days))).strftime("%Y-%m-%d")
|
||||
out = [x for x in items if str(x.get("trading_day") or "") >= cutoff]
|
||||
return out[-500:]
|
||||
|
||||
|
||||
def _prune_chat_sessions(sessions: list, *, keep_days: int) -> list:
|
||||
cutoff_dt = datetime.now() - timedelta(days=max(1, keep_days))
|
||||
out = []
|
||||
for s in sessions:
|
||||
ts = str(s.get("updated_at") or s.get("created_at") or "")
|
||||
try:
|
||||
dt = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
out.append(s)
|
||||
continue
|
||||
if dt >= cutoff_dt:
|
||||
out.append(s)
|
||||
return out[-50:]
|
||||
|
||||
|
||||
def load_summaries_store() -> dict:
|
||||
return _load_json(SUMMARIES_PATH, {"version": 1, "summaries": []})
|
||||
|
||||
|
||||
def save_summaries_store(data: dict) -> None:
|
||||
summaries = _prune_summaries(
|
||||
list(data.get("summaries") or []),
|
||||
keep_days=SUMMARY_RETENTION_DAYS,
|
||||
)
|
||||
_atomic_write(SUMMARIES_PATH, {"version": 1, "summaries": summaries})
|
||||
|
||||
|
||||
def append_summary(
|
||||
*,
|
||||
trading_day: str,
|
||||
content_md: str,
|
||||
model: str,
|
||||
context_hash: str,
|
||||
stats_snapshot: dict,
|
||||
) -> dict:
|
||||
store = load_summaries_store()
|
||||
row = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"trading_day": trading_day,
|
||||
"generated_at": _now_str(),
|
||||
"model": model,
|
||||
"context_hash": context_hash,
|
||||
"content_md": content_md,
|
||||
"stats_snapshot": stats_snapshot,
|
||||
}
|
||||
store.setdefault("summaries", []).append(row)
|
||||
save_summaries_store(store)
|
||||
return row
|
||||
|
||||
|
||||
def list_summaries(*, trading_day: Optional[str] = None, limit: int = 30) -> list[dict]:
|
||||
store = load_summaries_store()
|
||||
items = list(store.get("summaries") or [])
|
||||
if trading_day:
|
||||
items = [x for x in items if str(x.get("trading_day")) == trading_day]
|
||||
items.sort(key=lambda x: str(x.get("generated_at") or ""), reverse=True)
|
||||
return items[: max(1, min(limit, 100))]
|
||||
|
||||
|
||||
def get_latest_summary(trading_day: str) -> Optional[dict]:
|
||||
rows = list_summaries(trading_day=trading_day, limit=1)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def load_chat_store() -> dict:
|
||||
default = {"version": 1, "sessions": [], "active_session_id": None}
|
||||
data = _load_json(CHAT_PATH, default)
|
||||
data.setdefault("version", 1)
|
||||
data.setdefault("sessions", [])
|
||||
return data
|
||||
|
||||
|
||||
def save_chat_store(data: dict) -> None:
|
||||
sessions = _prune_chat_sessions(
|
||||
list(data.get("sessions") or []),
|
||||
keep_days=CHAT_SESSION_RETENTION_DAYS,
|
||||
)
|
||||
active = data.get("active_session_id")
|
||||
ids = {str(s.get("id")) for s in sessions}
|
||||
if active and str(active) not in ids:
|
||||
active = sessions[-1]["id"] if sessions else None
|
||||
_atomic_write(
|
||||
CHAT_PATH,
|
||||
{"version": 1, "sessions": sessions, "active_session_id": active},
|
||||
)
|
||||
|
||||
|
||||
def get_active_session() -> Optional[dict]:
|
||||
store = load_chat_store()
|
||||
sid = store.get("active_session_id")
|
||||
for s in store.get("sessions") or []:
|
||||
if str(s.get("id")) == str(sid):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
CHAT_BOT_TRADING = "trading"
|
||||
CHAT_BOT_GENERAL = "general"
|
||||
CHAT_BOT_SUPERVISOR = "supervisor"
|
||||
CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL, CHAT_BOT_SUPERVISOR})
|
||||
|
||||
|
||||
def _normalize_bot_mode(raw: Any) -> str:
|
||||
mode = (raw or CHAT_BOT_TRADING).strip().lower()
|
||||
return mode if mode in CHAT_BOT_MODES else CHAT_BOT_TRADING
|
||||
|
||||
|
||||
def create_new_session(
|
||||
*,
|
||||
trading_day: str,
|
||||
title: str = "新对话",
|
||||
bot_mode: str = CHAT_BOT_TRADING,
|
||||
) -> dict:
|
||||
store = load_chat_store()
|
||||
session = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"trading_day": trading_day,
|
||||
"title": title,
|
||||
"bot_mode": _normalize_bot_mode(bot_mode),
|
||||
"created_at": _now_str(),
|
||||
"updated_at": _now_str(),
|
||||
"messages": [],
|
||||
"rolling_summary": "",
|
||||
}
|
||||
store.setdefault("sessions", []).append(session)
|
||||
store["active_session_id"] = session["id"]
|
||||
save_chat_store(store)
|
||||
return session
|
||||
|
||||
|
||||
def ensure_active_session(*, trading_day: str) -> dict:
|
||||
active = get_active_session()
|
||||
if active:
|
||||
return active
|
||||
return create_new_session(trading_day=trading_day)
|
||||
|
||||
|
||||
def update_session_rolling_summary(session_id: str, summary: str) -> dict:
|
||||
store = load_chat_store()
|
||||
target = None
|
||||
for s in store.get("sessions") or []:
|
||||
if str(s.get("id")) == str(session_id):
|
||||
target = s
|
||||
break
|
||||
if not target:
|
||||
raise KeyError("session_not_found")
|
||||
target["rolling_summary"] = str(summary or "").strip()
|
||||
target["updated_at"] = _now_str()
|
||||
store["active_session_id"] = target["id"]
|
||||
save_chat_store(store)
|
||||
return target
|
||||
|
||||
|
||||
def append_chat_message(
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
*,
|
||||
attachments: Optional[list] = None,
|
||||
) -> dict:
|
||||
store = load_chat_store()
|
||||
sessions = store.get("sessions") or []
|
||||
target = None
|
||||
for s in sessions:
|
||||
if str(s.get("id")) == str(session_id):
|
||||
target = s
|
||||
break
|
||||
if not target:
|
||||
raise KeyError("session_not_found")
|
||||
msg = {"role": role, "content": content.strip(), "at": _now_str()}
|
||||
if attachments:
|
||||
msg["attachments"] = list(attachments)
|
||||
target.setdefault("messages", []).append(msg)
|
||||
target["updated_at"] = _now_str()
|
||||
if role == "user" and (target.get("title") in (None, "", "新对话")):
|
||||
title = content.strip().replace("\n", " ")[:24]
|
||||
if title:
|
||||
target["title"] = title
|
||||
store["active_session_id"] = target["id"]
|
||||
save_chat_store(store)
|
||||
return target
|
||||
|
||||
|
||||
def _session_list_item(s: dict, *, active_id: Optional[str]) -> dict:
|
||||
msgs = s.get("messages") or []
|
||||
preview = ""
|
||||
for m in reversed(msgs):
|
||||
if m.get("role") == "user":
|
||||
preview = str(m.get("content") or "").replace("\n", " ")[:48]
|
||||
break
|
||||
if not preview and msgs:
|
||||
last = msgs[-1]
|
||||
preview = str(last.get("content") or "").replace("\n", " ")[:48]
|
||||
sid = str(s.get("id") or "")
|
||||
return {
|
||||
"id": sid,
|
||||
"title": s.get("title") or "新对话",
|
||||
"bot_mode": _normalize_bot_mode(s.get("bot_mode")),
|
||||
"trading_day": s.get("trading_day"),
|
||||
"created_at": s.get("created_at"),
|
||||
"updated_at": s.get("updated_at"),
|
||||
"message_count": len(msgs),
|
||||
"preview": preview,
|
||||
"is_active": sid and sid == str(active_id or ""),
|
||||
}
|
||||
|
||||
|
||||
def list_chat_sessions(*, limit: int = 50) -> list[dict]:
|
||||
store = load_chat_store()
|
||||
active_id = store.get("active_session_id")
|
||||
sessions = list(store.get("sessions") or [])
|
||||
for s in sessions:
|
||||
s.setdefault("bot_mode", CHAT_BOT_TRADING)
|
||||
sessions.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True)
|
||||
return [_session_list_item(s, active_id=active_id) for s in sessions[: max(1, min(limit, 100))]]
|
||||
|
||||
|
||||
def set_active_session(session_id: str) -> dict:
|
||||
store = load_chat_store()
|
||||
target = None
|
||||
for s in store.get("sessions") or []:
|
||||
if str(s.get("id")) == str(session_id):
|
||||
target = s
|
||||
break
|
||||
if not target:
|
||||
raise KeyError("session_not_found")
|
||||
target.setdefault("bot_mode", CHAT_BOT_TRADING)
|
||||
store["active_session_id"] = target["id"]
|
||||
save_chat_store(store)
|
||||
return target
|
||||
|
||||
|
||||
def delete_chat_session(session_id: str) -> tuple[bool, Optional[str]]:
|
||||
store = load_chat_store()
|
||||
sessions = list(store.get("sessions") or [])
|
||||
new_sessions = [s for s in sessions if str(s.get("id")) != str(session_id)]
|
||||
if len(new_sessions) == len(sessions):
|
||||
return False, None
|
||||
active = store.get("active_session_id")
|
||||
new_active = active
|
||||
if str(active) == str(session_id):
|
||||
new_active = new_sessions[0]["id"] if new_sessions else None
|
||||
store["sessions"] = new_sessions
|
||||
store["active_session_id"] = new_active
|
||||
save_chat_store(store)
|
||||
return True, new_active
|
||||
|
||||
|
||||
def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str:
|
||||
latest = get_latest_summary(trading_day)
|
||||
if not latest:
|
||||
return ""
|
||||
text = str(latest.get("content_md") or "").strip()
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[: max_chars - 3].rstrip() + "..."
|
||||
@@ -0,0 +1,95 @@
|
||||
"""中控 AI:今日总结生成."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hub_ai.client import generate_text, model_label
|
||||
from hub_ai.context import (
|
||||
build_daily_context,
|
||||
collect_closed_trades_snapshot,
|
||||
format_account_remark,
|
||||
format_summary_context_text,
|
||||
summary_context_hash,
|
||||
)
|
||||
from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt
|
||||
from hub_ai.store import append_summary, get_latest_summary, list_summaries
|
||||
|
||||
|
||||
def _stats_snapshot_from_ctx(ctx: dict) -> dict:
|
||||
day = ctx.get("trading_day")
|
||||
accounts = ctx.get("accounts") or []
|
||||
return {
|
||||
"totals": ctx.get("totals"),
|
||||
"closed_trades": collect_closed_trades_snapshot(accounts, today=day),
|
||||
"by_account": {
|
||||
str(ac.get("key") or ac.get("id")): {
|
||||
"key": ac.get("key"),
|
||||
"name": ac.get("name"),
|
||||
"status": ac.get("status"),
|
||||
"funding_usdt": ac.get("funding_usdt"),
|
||||
"trading_usdt": ac.get("trading_usdt"),
|
||||
"available_trading_usdt": ac.get("available_trading_usdt"),
|
||||
"pnl_u": (ac.get("trade_stats") or {}).get("total_pnl_u"),
|
||||
"closed_count": (ac.get("trade_stats") or {}).get("closed_count"),
|
||||
"float_pnl_u": ac.get("float_pnl_u"),
|
||||
"remark": format_account_remark(ac),
|
||||
"monitor_lines": ac.get("monitor_lines") or {},
|
||||
"issues": ac.get("issues") or [],
|
||||
}
|
||||
for ac in accounts
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_daily_summary(
|
||||
exchanges: list[dict],
|
||||
*,
|
||||
trading_day: str | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
ctx = build_daily_context(exchanges, trading_day=trading_day)
|
||||
day = ctx["trading_day"]
|
||||
summary_payload = {
|
||||
"trading_day": day,
|
||||
"totals": ctx.get("totals"),
|
||||
"accounts": ctx.get("accounts"),
|
||||
}
|
||||
summary_text = format_summary_context_text(summary_payload)
|
||||
digest = summary_context_hash(summary_payload)
|
||||
if not force:
|
||||
latest = get_latest_summary(day)
|
||||
if latest and latest.get("context_hash") == digest:
|
||||
return {
|
||||
"ok": True,
|
||||
"cached": True,
|
||||
"trading_day": day,
|
||||
"summary": latest,
|
||||
"model": latest.get("model") or model_label(),
|
||||
}
|
||||
|
||||
system = SUMMARY_SYSTEM.replace("{trading_day}", day)
|
||||
user = build_summary_user_prompt(summary_text, day)
|
||||
content = generate_text(system=system, user=user, temperature=0.15)
|
||||
if content.startswith("AI 调用失败"):
|
||||
return {"ok": False, "msg": content, "trading_day": day}
|
||||
|
||||
stats_snapshot = _stats_snapshot_from_ctx(ctx)
|
||||
row = append_summary(
|
||||
trading_day=day,
|
||||
content_md=content,
|
||||
model=model_label(),
|
||||
context_hash=digest,
|
||||
stats_snapshot=stats_snapshot,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"cached": False,
|
||||
"trading_day": day,
|
||||
"summary": row,
|
||||
"model": model_label(),
|
||||
"context": ctx,
|
||||
}
|
||||
|
||||
|
||||
def summary_list(trading_day: str | None = None) -> list[dict]:
|
||||
return list_summaries(trading_day=trading_day)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""交易监管:AI 评语与用户回聊."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from lib.ai.ai_client import ai_generate # noqa: E402
|
||||
|
||||
from hub_ai.client import generate_text, model_label
|
||||
from hub_ai.config import (
|
||||
CHAT_MAX_OUTPUT_TOKENS,
|
||||
CHAT_TEMPERATURE,
|
||||
trading_day_reset_hour,
|
||||
)
|
||||
from hub_ai.context import build_chat_context, format_chat_context_for_chat, format_chat_position_overview
|
||||
from hub_ai.prompts import SUPERVISOR_SYSTEM, build_supervisor_ai_prompt, build_supervisor_chat_prompt
|
||||
from hub_ai.supervisor_store import (
|
||||
append_supervisor_ai_message,
|
||||
ensure_supervisor_session,
|
||||
get_supervisor_session_state,
|
||||
)
|
||||
from hub_ai.store import append_chat_message
|
||||
from hub_ai.text_util import is_ai_error_reply
|
||||
from hub_supervisor_lib import build_supervisor_fallback_reply
|
||||
from lib.hub.hub_trades_lib import current_trading_day
|
||||
|
||||
SUPERVISOR_AI_MAX_TOKENS = 320
|
||||
|
||||
|
||||
def generate_supervisor_ai_reply(
|
||||
*,
|
||||
event: dict,
|
||||
warnings: list[dict],
|
||||
trading_day: str,
|
||||
session_id: str,
|
||||
exchanges: list[dict],
|
||||
) -> str:
|
||||
ctx = build_chat_context(exchanges, trading_day=trading_day)
|
||||
brief = format_chat_position_overview(ctx) + "\n" + format_chat_context_for_chat(
|
||||
ctx, max_chars=2400
|
||||
)
|
||||
user_prompt = build_supervisor_ai_prompt(
|
||||
context_text=brief,
|
||||
trading_day=trading_day,
|
||||
event=event,
|
||||
warnings=warnings,
|
||||
)
|
||||
prompt = f"{SUPERVISOR_SYSTEM.strip()}\n\n---\n\n{user_prompt.strip()}"
|
||||
text = ai_generate(prompt, temperature=0.35, max_tokens=SUPERVISOR_AI_MAX_TOKENS)
|
||||
text = str(text or "").strip()
|
||||
if not text or is_ai_error_reply(text):
|
||||
return build_supervisor_fallback_reply(event, warnings)
|
||||
return text
|
||||
|
||||
|
||||
def make_supervisor_ai_reply_fn(exchanges: list[dict]):
|
||||
def _fn(*, event: dict, warnings: list[dict], trading_day: str, session_id: str) -> str:
|
||||
return generate_supervisor_ai_reply(
|
||||
event=event,
|
||||
warnings=warnings or [],
|
||||
trading_day=trading_day,
|
||||
session_id=session_id,
|
||||
exchanges=exchanges,
|
||||
)
|
||||
|
||||
return _fn
|
||||
|
||||
|
||||
def send_supervisor_chat(
|
||||
exchanges: list[dict],
|
||||
message: str,
|
||||
*,
|
||||
trading_day: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
text = (message or "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "msg": "消息不能为空"}
|
||||
day = (trading_day or "").strip()[:10] or current_trading_day(
|
||||
reset_hour=trading_day_reset_hour()
|
||||
)
|
||||
session = ensure_supervisor_session(day)
|
||||
sid = str(session.get("id") or "")
|
||||
prior = session.get("messages") or []
|
||||
ctx = build_chat_context(exchanges, trading_day=day)
|
||||
brief = format_chat_context_for_chat(ctx, max_chars=6000)
|
||||
recent = []
|
||||
for m in prior[-8:]:
|
||||
role = m.get("role")
|
||||
if role not in ("user", "assistant", "system"):
|
||||
continue
|
||||
label = {"user": "用户", "assistant": "监管", "system": "系统"}.get(role, role)
|
||||
recent.append(f"{label}:{str(m.get('content') or '').strip()}")
|
||||
user_prompt = build_supervisor_chat_prompt(
|
||||
context_text=brief,
|
||||
trading_day=day,
|
||||
history_lines="\n".join(recent),
|
||||
user_message=text,
|
||||
)
|
||||
reply = generate_text(
|
||||
system=SUPERVISOR_SYSTEM,
|
||||
user=user_prompt,
|
||||
temperature=min(0.4, CHAT_TEMPERATURE),
|
||||
max_tokens=min(768, CHAT_MAX_OUTPUT_TOKENS),
|
||||
max_continuations=1,
|
||||
)
|
||||
reply = str(reply or "").strip()
|
||||
if not reply or is_ai_error_reply(reply):
|
||||
return {"ok": False, "msg": "AI 暂时不可用,请稍后再试", "session_id": sid}
|
||||
append_chat_message(sid, "user", text)
|
||||
session = append_supervisor_ai_message(sid, reply)
|
||||
state = get_supervisor_session_state(day)
|
||||
return {
|
||||
"ok": True,
|
||||
"trading_day": day,
|
||||
"session": session,
|
||||
"reply": reply,
|
||||
"model": model_label(),
|
||||
"message_count": state.get("message_count"),
|
||||
"unread_system": state.get("unread_system"),
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""交易监管专用会话(今日长会话,bot_mode=supervisor)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from hub_ai.store import (
|
||||
CHAT_BOT_SUPERVISOR,
|
||||
append_chat_message,
|
||||
load_chat_store,
|
||||
save_chat_store,
|
||||
)
|
||||
|
||||
|
||||
def _supervisor_title(trading_day: str) -> str:
|
||||
return f"今日监管 {trading_day}"
|
||||
|
||||
|
||||
def find_supervisor_session(trading_day: str) -> Optional[dict]:
|
||||
day = (trading_day or "").strip()[:10]
|
||||
store = load_chat_store()
|
||||
for s in store.get("sessions") or []:
|
||||
if str(s.get("bot_mode") or "") != CHAT_BOT_SUPERVISOR:
|
||||
continue
|
||||
if str(s.get("trading_day") or "") == day:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def ensure_supervisor_session(trading_day: str) -> dict:
|
||||
day = (trading_day or "").strip()[:10]
|
||||
existing = find_supervisor_session(day)
|
||||
if existing:
|
||||
return existing
|
||||
store = load_chat_store()
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
session = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"trading_day": day,
|
||||
"title": _supervisor_title(day),
|
||||
"bot_mode": CHAT_BOT_SUPERVISOR,
|
||||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"messages": [],
|
||||
"rolling_summary": "",
|
||||
"supervisor_locked": True,
|
||||
}
|
||||
store.setdefault("sessions", []).append(session)
|
||||
save_chat_store(store)
|
||||
return session
|
||||
|
||||
|
||||
def append_supervisor_system_message(
|
||||
session_id: str,
|
||||
content: str,
|
||||
*,
|
||||
event_type: str = "",
|
||||
level: str = "info",
|
||||
) -> dict:
|
||||
store = load_chat_store()
|
||||
target = None
|
||||
for s in store.get("sessions") or []:
|
||||
if str(s.get("id")) == str(session_id):
|
||||
target = s
|
||||
break
|
||||
if not target:
|
||||
raise KeyError("session_not_found")
|
||||
from datetime import datetime
|
||||
|
||||
msg = {
|
||||
"role": "system",
|
||||
"content": (content or "").strip(),
|
||||
"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"event_type": event_type,
|
||||
"level": level,
|
||||
}
|
||||
target.setdefault("messages", []).append(msg)
|
||||
target["updated_at"] = msg["at"]
|
||||
save_chat_store(store)
|
||||
return target
|
||||
|
||||
|
||||
def append_supervisor_ai_message(session_id: str, content: str) -> dict:
|
||||
return append_chat_message(session_id, "assistant", content)
|
||||
|
||||
|
||||
def get_supervisor_session_state(trading_day: str) -> dict[str, Any]:
|
||||
from hub_ai.client import model_label
|
||||
|
||||
session = ensure_supervisor_session(trading_day)
|
||||
msgs = session.get("messages") or []
|
||||
unread = sum(1 for m in msgs if m.get("role") == "system" and not m.get("read"))
|
||||
return {
|
||||
"ok": True,
|
||||
"session": session,
|
||||
"trading_day": trading_day,
|
||||
"message_count": len(msgs),
|
||||
"unread_system": unread,
|
||||
"model": model_label(),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""中控 AI 文本小工具."""
|
||||
|
||||
|
||||
def is_ai_error_reply(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
return t.startswith("AI 调用失败") or t.startswith("AI 生成失败")
|
||||
|
||||
|
||||
def clip_text(text: str, max_chars: int) -> str:
|
||||
s = str(text or "").strip()
|
||||
limit = max(200, int(max_chars or 0))
|
||||
if len(s) <= limit:
|
||||
return s
|
||||
return s[: limit - 1].rstrip() + "…"
|
||||
@@ -0,0 +1,165 @@
|
||||
"""监控区 board:后台定时聚合,内存快照,SSE 版本通知."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
HUB_BOARD_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5"))
|
||||
HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25"))
|
||||
|
||||
BuildFn = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class MonitorBoardStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self.version = 0
|
||||
self.payload: dict[str, Any] | None = None
|
||||
self.aggregating = False
|
||||
self.last_error: str | None = None
|
||||
self._subscribers: list[asyncio.Queue[str | None]] = []
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._refresh = asyncio.Event()
|
||||
self._build_fn: BuildFn | None = None
|
||||
|
||||
async def start(self, build_fn: BuildFn) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._build_fn = build_fn
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._loop(), name="hub-board-poll")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def snapshot_dict(self) -> dict[str, Any]:
|
||||
p = self.payload or {}
|
||||
rows = p.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
return {
|
||||
"ok": p.get("ok", True) if self.payload else False,
|
||||
"board_version": self.version,
|
||||
"rows": rows,
|
||||
"totals": p.get("totals") if isinstance(p.get("totals"), dict) else None,
|
||||
"updated_at": p.get("updated_at"),
|
||||
"aggregating": self.aggregating,
|
||||
"error": self.last_error or p.get("error"),
|
||||
"msg": p.get("msg"),
|
||||
"poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
|
||||
}
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
p = self.payload or {}
|
||||
return {
|
||||
"board_version": self.version,
|
||||
"updated_at": p.get("updated_at"),
|
||||
"aggregating": self.aggregating,
|
||||
"ok": p.get("ok", True) if self.payload else False,
|
||||
"error": self.last_error or p.get("error"),
|
||||
}
|
||||
|
||||
async def _loop(self) -> None:
|
||||
assert self._build_fn is not None
|
||||
while not self._stop.is_set():
|
||||
await self._aggregate_once(self._build_fn)
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(HUB_BOARD_POLL_INTERVAL))
|
||||
refresh_task = asyncio.create_task(self._refresh.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{sleep_task, refresh_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
async def _aggregate_once(self, build_fn: BuildFn) -> None:
|
||||
async with self._lock:
|
||||
self.aggregating = True
|
||||
self._broadcast()
|
||||
try:
|
||||
result = await build_fn()
|
||||
if not isinstance(result, dict):
|
||||
result = {"ok": False, "msg": "聚合返回无效", "rows": []}
|
||||
except Exception as e:
|
||||
result = {"ok": False, "msg": str(e), "rows": [], "error": "aggregate_failed"}
|
||||
async with self._lock:
|
||||
self.version += 1
|
||||
prev_rows = (self.payload or {}).get("rows") if isinstance(self.payload, dict) else None
|
||||
if result.get("ok") is False and isinstance(prev_rows, list) and prev_rows:
|
||||
result = {**result, "rows": prev_rows}
|
||||
self.payload = result
|
||||
self.last_error = None if result.get("ok") is not False else (
|
||||
str(result.get("msg") or result.get("error") or "aggregate_failed")
|
||||
)
|
||||
self.aggregating = False
|
||||
self._broadcast()
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
dead: list[asyncio.Queue[str | None]] = []
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(None if close else json.dumps(self.event_dict(), ensure_ascii=False))
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(json.dumps(self.event_dict(), ensure_ascii=False))
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
async def iter_sse(self) -> AsyncIterator[str]:
|
||||
q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(q.get(), timeout=HUB_BOARD_SSE_HEARTBEAT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: board\ndata: {body}\n\n"
|
||||
|
||||
|
||||
board_store = MonitorBoardStore()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""行情区 K 线:后台轮询订阅 + SSE 推送尾部 K 线(对齐监控区 board)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hub_board_cache import board_store
|
||||
|
||||
HUB_CHART_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5"))
|
||||
HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25"))
|
||||
HUB_CHART_WATCH_TTL_SEC = float(os.getenv("HUB_CHART_WATCH_TTL_SEC", "45"))
|
||||
HUB_CHART_POSITION_TIMEFRAME = (os.getenv("HUB_CHART_POSITION_TIMEFRAME", "5m") or "5m").strip()
|
||||
HUB_CHART_MAX_SERIES_PER_TICK = max(1, int(os.getenv("HUB_CHART_MAX_SERIES_PER_TICK", "24")))
|
||||
HUB_CHART_SSE_TAIL_BARS = max(5, min(int(os.getenv("HUB_CHART_SSE_TAIL_BARS", "30")), 120))
|
||||
|
||||
PollFn = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
def series_key(exchange_key: str, symbol: str, timeframe: str) -> str:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = (timeframe or "").strip()
|
||||
return f"{ex_k}|{sym}|{tf}"
|
||||
|
||||
|
||||
def parse_series_key(key: str) -> tuple[str, str, str] | None:
|
||||
parts = (key or "").split("|")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
ex_k, sym, tf = parts[0].strip().lower(), parts[1].strip().upper(), parts[2].strip()
|
||||
if not ex_k or not sym or not tf:
|
||||
return None
|
||||
return ex_k, sym, tf
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesState:
|
||||
version: int = 0
|
||||
updated_at: str | None = None
|
||||
fetched: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ChartPollStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self.version = 0
|
||||
self.updated_at: str | None = None
|
||||
self.polling = False
|
||||
self.last_error: str | None = None
|
||||
self._watch_until: dict[str, float] = {}
|
||||
self._position_keys: set[str] = set()
|
||||
self._series: dict[str, SeriesState] = {}
|
||||
self._push_tails: dict[str, dict[str, Any]] = {}
|
||||
self._subscribers: list[asyncio.Queue[str | None]] = []
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._refresh = asyncio.Event()
|
||||
self._poll_fn: PollFn | None = None
|
||||
|
||||
async def start(self, poll_fn: PollFn) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._poll_fn = poll_fn
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._loop(), name="hub-chart-poll")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def touch_watch(self, exchange_key: str, symbol: str, timeframe: str) -> str:
|
||||
key = series_key(exchange_key, symbol, timeframe)
|
||||
self._watch_until[key] = time.monotonic() + HUB_CHART_WATCH_TTL_SEC
|
||||
return key
|
||||
|
||||
def clear_watch(self, exchange_key: str, symbol: str, timeframe: str) -> None:
|
||||
key = series_key(exchange_key, symbol, timeframe)
|
||||
self._watch_until.pop(key, None)
|
||||
|
||||
def sync_positions_from_rows(self, rows: list[Any]) -> None:
|
||||
keys: set[str] = set()
|
||||
tf = HUB_CHART_POSITION_TIMEFRAME
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
ex_key = str(row.get("key") or row.get("exchange_key") or "").strip().lower()
|
||||
if not ex_key:
|
||||
ex_id = str(row.get("id") or "").strip()
|
||||
if ex_id:
|
||||
ex_key = ex_id.lower()
|
||||
if not ex_key:
|
||||
continue
|
||||
ag = row.get("agent") if isinstance(row.get("agent"), dict) else {}
|
||||
if ag.get("ok") is False:
|
||||
continue
|
||||
for pos in ag.get("positions") or []:
|
||||
if not isinstance(pos, dict):
|
||||
continue
|
||||
sym = str(pos.get("symbol") or "").strip().upper()
|
||||
if sym:
|
||||
keys.add(series_key(ex_key, sym, tf))
|
||||
self._position_keys = keys
|
||||
|
||||
def active_series_keys(self) -> list[str]:
|
||||
now = time.monotonic()
|
||||
watch = {k for k, until in self._watch_until.items() if until > now}
|
||||
merged = self._position_keys | watch
|
||||
return sorted(merged)[:HUB_CHART_MAX_SERIES_PER_TICK]
|
||||
|
||||
def series_event_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key, st in self._series.items():
|
||||
out[key] = {
|
||||
"series_version": st.version,
|
||||
"updated_at": st.updated_at,
|
||||
"fetched": st.fetched,
|
||||
"error": st.error,
|
||||
}
|
||||
return out
|
||||
|
||||
def event_dict(self, *, tails: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"chart_version": self.version,
|
||||
"updated_at": self.updated_at,
|
||||
"polling": self.polling,
|
||||
"ok": self.last_error is None,
|
||||
"error": self.last_error,
|
||||
"series": self.series_event_dict(),
|
||||
"poll_interval_sec": HUB_CHART_POLL_INTERVAL,
|
||||
"position_timeframe": HUB_CHART_POSITION_TIMEFRAME,
|
||||
"push_tails": True,
|
||||
}
|
||||
tail_map = tails if tails is not None else self._push_tails
|
||||
if tail_map:
|
||||
out["tails"] = tail_map
|
||||
return out
|
||||
|
||||
def series_version(self, exchange_key: str, symbol: str, timeframe: str) -> int:
|
||||
key = series_key(exchange_key, symbol, timeframe)
|
||||
st = self._series.get(key)
|
||||
return st.version if st else 0
|
||||
|
||||
async def _loop(self) -> None:
|
||||
assert self._poll_fn is not None
|
||||
while not self._stop.is_set():
|
||||
await self._poll_once(self._poll_fn)
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(HUB_CHART_POLL_INTERVAL))
|
||||
refresh_task = asyncio.create_task(self._refresh.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{sleep_task, refresh_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
async def _poll_once(self, poll_fn: PollFn) -> None:
|
||||
async with self._lock:
|
||||
self.polling = True
|
||||
self._broadcast()
|
||||
try:
|
||||
snap = board_store.snapshot_dict()
|
||||
rows = snap.get("rows") if isinstance(snap, dict) else []
|
||||
if isinstance(rows, list):
|
||||
self.sync_positions_from_rows(rows)
|
||||
result = await poll_fn()
|
||||
if not isinstance(result, dict):
|
||||
result = {"ok": False, "msg": "chart poll 返回无效"}
|
||||
except Exception as e:
|
||||
result = {"ok": False, "msg": str(e), "error": "chart_poll_failed"}
|
||||
async with self._lock:
|
||||
self.version += 1
|
||||
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
self.last_error = None if result.get("ok") is not False else (
|
||||
str(result.get("msg") or result.get("error") or "chart_poll_failed")
|
||||
)
|
||||
self.polling = False
|
||||
self._broadcast()
|
||||
|
||||
def note_series_result(
|
||||
self,
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
*,
|
||||
ok: bool,
|
||||
fetched: int = 0,
|
||||
error: str | None = None,
|
||||
candles: list[dict[str, Any]] | None = None,
|
||||
price_tick: Any = None,
|
||||
) -> None:
|
||||
key = series_key(exchange_key, symbol, timeframe)
|
||||
st = self._series.setdefault(key, SeriesState())
|
||||
st.version += 1
|
||||
st.updated_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
st.fetched = int(fetched or 0)
|
||||
st.error = error if not ok else None
|
||||
if ok and candles:
|
||||
tail = list(candles[-HUB_CHART_SSE_TAIL_BARS :])
|
||||
if tail:
|
||||
self._push_tails[key] = {
|
||||
"series_version": st.version,
|
||||
"updated_at": st.updated_at,
|
||||
"fetched": st.fetched,
|
||||
"candles": tail,
|
||||
"price_tick": price_tick,
|
||||
}
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
dead: list[asyncio.Queue[str | None]] = []
|
||||
tails_snap = dict(self._push_tails)
|
||||
self._push_tails.clear()
|
||||
payload = None if close else json.dumps(self.event_dict(tails=tails_snap), ensure_ascii=False)
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
async def iter_sse(self) -> AsyncIterator[str]:
|
||||
q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(q.get(), timeout=HUB_CHART_SSE_HEARTBEAT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: chart\ndata: {body}\n\n"
|
||||
|
||||
|
||||
chart_poll_store = ChartPollStore()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""中控数据看板:三户当日总览(无 AI,纯数据聚合)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from hub_ai.context import (
|
||||
build_daily_context,
|
||||
collect_closed_trades_snapshot,
|
||||
format_account_remark,
|
||||
format_dashboard_account_detail,
|
||||
)
|
||||
from hub_ai.config import trading_day_reset_hour
|
||||
from lib.hub.hub_trades_lib import current_trading_day
|
||||
|
||||
LOSS_ALERT_PCT = 5.0
|
||||
# 与监控区 board 默认 5s 对齐,看板持仓来源跟监控同步.
|
||||
DASHBOARD_POLL_INTERVAL_SEC = float(os.getenv("DASHBOARD_POLL_INTERVAL_SEC", "5"))
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _account_capital_base(ac: dict) -> Optional[float]:
|
||||
funding = _safe_float(ac.get("funding_usdt"))
|
||||
trading = _safe_float(ac.get("trading_usdt"))
|
||||
if funding is not None and trading is not None:
|
||||
return funding + trading
|
||||
if funding is not None:
|
||||
return funding
|
||||
if trading is not None:
|
||||
return trading
|
||||
return None
|
||||
|
||||
|
||||
def _options_layout_enabled(ac: dict) -> bool:
|
||||
if str(ac.get("key") or "").lower() != "okx":
|
||||
return False
|
||||
snap = ac.get("options_snapshot")
|
||||
if isinstance(snap, dict) and snap.get("enabled") is not False:
|
||||
return True
|
||||
return ac.get("options_funding_usdt") is not None or ac.get("options_trading_usdt") is not None
|
||||
|
||||
|
||||
def _perpetual_float_pnl_u(ac: dict) -> Optional[float]:
|
||||
try:
|
||||
total = float(ac.get("float_pnl_u") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0.0
|
||||
opt = ac.get("options_float_pnl_u")
|
||||
if opt is None:
|
||||
return round(total, 4)
|
||||
try:
|
||||
return round(total - float(opt), 4)
|
||||
except (TypeError, ValueError):
|
||||
return round(total, 4)
|
||||
|
||||
|
||||
def _enrich_account_row(ac: dict) -> dict:
|
||||
st = ac.get("trade_stats") or {}
|
||||
capital = _account_capital_base(ac)
|
||||
day_pnl = float(st.get("total_pnl_u") or 0)
|
||||
loss_pct: Optional[float] = None
|
||||
loss_alert = False
|
||||
if capital is not None and capital > 0 and day_pnl < -1e-9:
|
||||
loss_pct = round(abs(day_pnl) / capital * 100.0, 2)
|
||||
loss_alert = loss_pct >= LOSS_ALERT_PCT
|
||||
return {
|
||||
"id": ac.get("id"),
|
||||
"key": ac.get("key"),
|
||||
"name": ac.get("name"),
|
||||
"status": ac.get("status"),
|
||||
"monitored": ac.get("status") != "未监控",
|
||||
"funding_usdt": ac.get("funding_usdt"),
|
||||
"trading_usdt": ac.get("trading_usdt"),
|
||||
"perpetual_funding_usdt": ac.get("perpetual_funding_usdt"),
|
||||
"perpetual_trading_usdt": ac.get("perpetual_trading_usdt"),
|
||||
"options_funding_usdt": ac.get("options_funding_usdt"),
|
||||
"options_trading_usdt": ac.get("options_trading_usdt"),
|
||||
"options_float_pnl_u": ac.get("options_float_pnl_u"),
|
||||
"options_open_position_count": ac.get("options_open_position_count"),
|
||||
"options_layout": _options_layout_enabled(ac),
|
||||
"perpetual_float_pnl_u": _perpetual_float_pnl_u(ac),
|
||||
"capital_total_usdt": round(capital, 4) if capital is not None else None,
|
||||
"available_trading_usdt": ac.get("available_trading_usdt"),
|
||||
"pnl_u": st.get("total_pnl_u"),
|
||||
"closed_count": st.get("closed_count"),
|
||||
"win_count": st.get("win_count"),
|
||||
"loss_count": st.get("loss_count"),
|
||||
"float_pnl_u": ac.get("float_pnl_u"),
|
||||
"open_position_count": ac.get("open_position_count"),
|
||||
"remark": format_account_remark(ac),
|
||||
**format_dashboard_account_detail(ac),
|
||||
"issues": ac.get("issues") or [],
|
||||
"daily_loss_pct": loss_pct,
|
||||
"loss_alert": loss_alert,
|
||||
}
|
||||
|
||||
|
||||
def build_dashboard_payload(
|
||||
exchanges: list[dict],
|
||||
*,
|
||||
trading_day: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ctx = build_daily_context(exchanges, trading_day=trading_day)
|
||||
day = ctx["trading_day"]
|
||||
accounts_raw = ctx.get("accounts") or []
|
||||
accounts = [
|
||||
_enrich_account_row(ac)
|
||||
for ac in accounts_raw
|
||||
if ac.get("status") != "未监控"
|
||||
]
|
||||
closed_trades = collect_closed_trades_snapshot(
|
||||
[ac for ac in accounts_raw if ac.get("status") != "未监控"],
|
||||
today=day,
|
||||
)
|
||||
loss_alert_count = sum(1 for ac in accounts if ac.get("loss_alert"))
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {
|
||||
"ok": True,
|
||||
"updated_at": now,
|
||||
"trading_day": day,
|
||||
"totals": ctx.get("totals"),
|
||||
"accounts": accounts,
|
||||
"closed_trades": closed_trades,
|
||||
"loss_alert_pct_threshold": LOSS_ALERT_PCT,
|
||||
"loss_alert_count": loss_alert_count,
|
||||
"poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
|
||||
}
|
||||
|
||||
|
||||
def default_trading_day() -> str:
|
||||
return current_trading_day(reset_hour=trading_day_reset_hour())
|
||||
@@ -0,0 +1,169 @@
|
||||
"""数据看板:后台定时聚合,内存快照,SSE 版本通知."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC
|
||||
|
||||
HUB_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_DASHBOARD_SSE_HEARTBEAT_SEC", "25"))
|
||||
|
||||
BuildFn = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class DashboardStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self.version = 0
|
||||
self.payload: dict[str, Any] | None = None
|
||||
self.aggregating = False
|
||||
self.last_error: str | None = None
|
||||
self._subscribers: list[asyncio.Queue[str | None]] = []
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._refresh = asyncio.Event()
|
||||
self._build_fn: BuildFn | None = None
|
||||
|
||||
async def start(self, build_fn: BuildFn) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._build_fn = build_fn
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._loop(), name="hub-dashboard-poll")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def snapshot_dict(self) -> dict[str, Any]:
|
||||
p = dict(self.payload or {})
|
||||
if not p:
|
||||
return {
|
||||
"ok": False,
|
||||
"dashboard_version": self.version,
|
||||
"aggregating": self.aggregating,
|
||||
"error": self.last_error,
|
||||
"poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
|
||||
}
|
||||
return {
|
||||
**p,
|
||||
"dashboard_version": self.version,
|
||||
"aggregating": self.aggregating,
|
||||
"error": self.last_error or p.get("error"),
|
||||
"poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
|
||||
}
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
p = self.payload or {}
|
||||
return {
|
||||
"dashboard_version": self.version,
|
||||
"updated_at": p.get("updated_at"),
|
||||
"aggregating": self.aggregating,
|
||||
"ok": p.get("ok", True) if self.payload else False,
|
||||
"error": self.last_error or p.get("error"),
|
||||
}
|
||||
|
||||
async def _loop(self) -> None:
|
||||
assert self._build_fn is not None
|
||||
while not self._stop.is_set():
|
||||
await self._aggregate_once(self._build_fn)
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(DASHBOARD_POLL_INTERVAL_SEC))
|
||||
refresh_task = asyncio.create_task(self._refresh.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{sleep_task, refresh_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
async def _aggregate_once(self, build_fn: BuildFn) -> None:
|
||||
async with self._lock:
|
||||
self.aggregating = True
|
||||
self._broadcast()
|
||||
try:
|
||||
result = await build_fn()
|
||||
if not isinstance(result, dict):
|
||||
result = {"ok": False, "msg": "聚合返回无效"}
|
||||
except Exception as e:
|
||||
result = {"ok": False, "msg": str(e), "error": "aggregate_failed"}
|
||||
async with self._lock:
|
||||
self.version += 1
|
||||
prev = self.payload if isinstance(self.payload, dict) else None
|
||||
if result.get("ok") is False and prev and prev.get("ok"):
|
||||
self.payload = prev
|
||||
self.last_error = str(result.get("msg") or result.get("error") or "aggregate_failed")
|
||||
else:
|
||||
self.payload = result
|
||||
self.last_error = None if result.get("ok") is not False else (
|
||||
str(result.get("msg") or result.get("error") or "aggregate_failed")
|
||||
)
|
||||
self.aggregating = False
|
||||
self._broadcast()
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
dead: list[asyncio.Queue[str | None]] = []
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(None if close else json.dumps(self.event_dict(), ensure_ascii=False))
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(json.dumps(self.event_dict(), ensure_ascii=False))
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
async def iter_sse(self) -> AsyncIterator[str]:
|
||||
q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(q.get(), timeout=HUB_DASHBOARD_SSE_HEARTBEAT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: dashboard\ndata: {body}\n\n"
|
||||
|
||||
|
||||
dashboard_store = DashboardStore()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""中控 .env 读写与 PM2 重启."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, load_env_file_into_environ
|
||||
from lib.env.shared_env_lib import (
|
||||
apply_ai_env_to_all,
|
||||
build_ai_env_payload,
|
||||
restart_instances_then_hub_pm2,
|
||||
)
|
||||
from lib.instance.instance_pm2_lib import schedule_pm2_restart
|
||||
|
||||
HUB_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def hub_env_path() -> str:
|
||||
return str(HUB_DIR / ".env")
|
||||
|
||||
|
||||
def update_hub_credentials(*, new_password: str, new_username: str | None = None) -> list[str]:
|
||||
updates: dict[str, str] = {"HUB_PASSWORD": new_password}
|
||||
if new_username:
|
||||
updates["HUB_USERNAME"] = new_username
|
||||
path = hub_env_path()
|
||||
changed = apply_env_updates(path, updates)
|
||||
if changed:
|
||||
load_env_file_into_environ(path)
|
||||
return changed
|
||||
|
||||
|
||||
def get_hub_ai_env_payload() -> dict[str, Any]:
|
||||
return build_ai_env_payload(hub_env_path())
|
||||
|
||||
|
||||
def save_hub_ai_env(updates: dict[str, str]) -> dict[str, Any]:
|
||||
return apply_ai_env_to_all(updates)
|
||||
|
||||
|
||||
def restart_all_pm2() -> dict[str, Any]:
|
||||
return restart_instances_then_hub_pm2()
|
||||
|
||||
|
||||
def restart_hub_pm2() -> dict[str, Any]:
|
||||
app_name = (os.getenv("PM2_APP_NAME") or "").strip() or "manual-trading-hub"
|
||||
return schedule_pm2_restart(app_name)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""交易监管:后台扫描 + SSE 版本通知."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
SUPERVISOR_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30"))
|
||||
SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25"))
|
||||
|
||||
TickFn = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class SupervisorStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self.version = 0
|
||||
self.last_result: dict[str, Any] | None = None
|
||||
self.last_error: str | None = None
|
||||
self._subscribers: list[asyncio.Queue[str | None]] = []
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._refresh = asyncio.Event()
|
||||
self._tick_fn: TickFn | None = None
|
||||
|
||||
async def start(self, tick_fn: TickFn) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._tick_fn = tick_fn
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._loop(), name="hub-supervisor-poll")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._refresh.set()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._broadcast(close=True)
|
||||
|
||||
def request_refresh(self) -> None:
|
||||
self._refresh.set()
|
||||
|
||||
def bump(self) -> None:
|
||||
self.version += 1
|
||||
self._broadcast()
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
r = self.last_result or {}
|
||||
return {
|
||||
"supervisor_version": self.version,
|
||||
"ok": r.get("ok", True),
|
||||
"events": r.get("events", 0),
|
||||
"trading_day": r.get("trading_day"),
|
||||
"session_id": r.get("session_id"),
|
||||
"error": self.last_error,
|
||||
}
|
||||
|
||||
async def _loop(self) -> None:
|
||||
assert self._tick_fn is not None
|
||||
while not self._stop.is_set():
|
||||
await self._tick_once(self._tick_fn)
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._refresh.clear()
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(SUPERVISOR_POLL_INTERVAL_SEC))
|
||||
refresh_task = asyncio.create_task(self._refresh.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{sleep_task, refresh_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
async def _tick_once(self, tick_fn: TickFn) -> None:
|
||||
async with self._lock:
|
||||
try:
|
||||
result = await tick_fn()
|
||||
if not isinstance(result, dict):
|
||||
result = {"ok": False, "msg": "invalid_tick"}
|
||||
except Exception as e:
|
||||
result = {"ok": False, "msg": str(e)}
|
||||
self.last_error = str(e)
|
||||
else:
|
||||
self.last_error = None if result.get("ok") is not False else str(
|
||||
result.get("msg") or "tick_failed"
|
||||
)
|
||||
self.last_result = result
|
||||
if int(result.get("events") or 0) > 0:
|
||||
self.version += 1
|
||||
self._broadcast()
|
||||
|
||||
def _broadcast(self, *, close: bool = False) -> None:
|
||||
dead: list[asyncio.Queue[str | None]] = []
|
||||
payload = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
async def iter_sse(self) -> AsyncIterator[str]:
|
||||
q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
|
||||
self._subscribers.append(q)
|
||||
try:
|
||||
yield _sse_frame(self.event_dict())
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(q.get(), timeout=SUPERVISOR_SSE_HEARTBEAT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = self.event_dict()
|
||||
yield _sse_frame(data)
|
||||
finally:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
|
||||
def _sse_frame(data: dict[str, Any]) -> str:
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: supervisor\ndata: {body}\n\n"
|
||||
|
||||
|
||||
supervisor_store = SupervisorStore()
|
||||
@@ -0,0 +1,757 @@
|
||||
"""交易监管:事件分类,频率规则,会话消息与企业微信推送."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.hub.hub_trades_lib import current_trading_day, parse_dt_for_trading_day
|
||||
|
||||
HUB_DIR = Path(__file__).resolve().parent
|
||||
STATE_PATH = HUB_DIR / "hub_supervisor_state.json"
|
||||
|
||||
PROGRAM_RESULTS = frozenset({"止盈", "止损", "保本止盈", "移动止盈"})
|
||||
MANUAL_CLOSE_RESULTS = frozenset({"手动平仓"})
|
||||
HUB_CLOSE_RESULTS = frozenset({"强制清仓"})
|
||||
WEAK_RESULTS = frozenset({"外部平仓", "时间平仓"})
|
||||
|
||||
EVENT_OPEN = "open"
|
||||
EVENT_MANUAL_CLOSE = "manual_close"
|
||||
EVENT_HUB_CLOSE = "hub_close"
|
||||
EVENT_PROGRAM_TP = "program_tp"
|
||||
EVENT_PROGRAM_SL = "program_sl"
|
||||
EVENT_EXTERNAL = "external"
|
||||
EVENT_FREQ_WARN = "freq_warn"
|
||||
|
||||
DEFAULT_SUPERVISOR = {
|
||||
"enabled": True,
|
||||
"wechat_webhook": "",
|
||||
"wechat_link_base": "http://127.0.0.1:5100/ai?mode=supervisor",
|
||||
"wechat_prefix": "【交易监管】",
|
||||
"wechat_on_program_tp_sl": True,
|
||||
"manual_close_daily_warn": 2,
|
||||
"interval_warn_minutes": 15,
|
||||
"freq_30m_count": 2,
|
||||
"reopen_after_close_minutes": 30,
|
||||
}
|
||||
|
||||
|
||||
def _now_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _load_json(path: Path, default: dict) -> dict:
|
||||
if not path.is_file():
|
||||
return dict(default)
|
||||
try:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
return loaded
|
||||
except Exception:
|
||||
pass
|
||||
return dict(default)
|
||||
|
||||
|
||||
def normalize_supervisor_settings(raw: dict | None) -> dict:
|
||||
out = dict(DEFAULT_SUPERVISOR)
|
||||
env_webhook = (os.getenv("SUPERVISOR_WECHAT_WEBHOOK") or "").strip()
|
||||
env_link = (os.getenv("SUPERVISOR_WECHAT_LINK") or "").strip()
|
||||
if env_webhook:
|
||||
out["wechat_webhook"] = env_webhook
|
||||
if env_link:
|
||||
out["wechat_link_base"] = env_link
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for key in DEFAULT_SUPERVISOR:
|
||||
if key not in raw:
|
||||
continue
|
||||
val = raw.get(key)
|
||||
if key == "enabled" or key == "wechat_on_program_tp_sl":
|
||||
out[key] = bool(val)
|
||||
elif key in ("manual_close_daily_warn", "freq_30m_count"):
|
||||
try:
|
||||
out[key] = max(1, int(val))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif key in ("interval_warn_minutes", "reopen_after_close_minutes"):
|
||||
try:
|
||||
out[key] = max(1, int(val))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif isinstance(val, str):
|
||||
out[key] = val.strip()
|
||||
return out
|
||||
|
||||
|
||||
def load_supervisor_state() -> dict:
|
||||
data = _load_json(STATE_PATH, {"version": 1, "trading_day": "", "processed": [], "positions": {}, "stats": {}})
|
||||
data.setdefault("version", 1)
|
||||
data.setdefault("processed", [])
|
||||
data.setdefault("positions", {})
|
||||
data.setdefault("stats", {})
|
||||
return data
|
||||
|
||||
|
||||
def save_supervisor_state(data: dict) -> None:
|
||||
processed = list(data.get("processed") or [])
|
||||
if len(processed) > 500:
|
||||
processed = processed[-500:]
|
||||
data["processed"] = processed
|
||||
_atomic_write(STATE_PATH, data)
|
||||
|
||||
|
||||
def _trade_event_id(trade: dict) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
str(trade.get("account_name") or trade.get("account_key") or ""),
|
||||
str(trade.get("symbol") or ""),
|
||||
str(trade.get("closed_at") or ""),
|
||||
str(trade.get("result") or ""),
|
||||
str(trade.get("pnl_amount") or ""),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def classify_close_result(result: str) -> str:
|
||||
r = (result or "").strip()
|
||||
if r in PROGRAM_RESULTS:
|
||||
if r == "止损":
|
||||
return EVENT_PROGRAM_SL
|
||||
return EVENT_PROGRAM_TP
|
||||
if r in MANUAL_CLOSE_RESULTS:
|
||||
return EVENT_MANUAL_CLOSE
|
||||
if r in HUB_CLOSE_RESULTS:
|
||||
return EVENT_HUB_CLOSE
|
||||
if r in WEAK_RESULTS:
|
||||
return EVENT_EXTERNAL
|
||||
return EVENT_EXTERNAL
|
||||
|
||||
|
||||
def is_supervised_event(event_type: str) -> bool:
|
||||
return event_type in (EVENT_OPEN, EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE)
|
||||
|
||||
|
||||
def is_program_event(event_type: str) -> bool:
|
||||
return event_type in (EVENT_PROGRAM_TP, EVENT_PROGRAM_SL)
|
||||
|
||||
|
||||
def _normalize_position_symbol(sym: str) -> str:
|
||||
"""统一合约名,避免 ZEC/USDT 与 ZEC/USDT:USDT 被当成两笔持仓."""
|
||||
s = (sym or "").strip().upper()
|
||||
if not s:
|
||||
return ""
|
||||
if s.endswith(":USDT") and "/" in s:
|
||||
return s.rsplit(":", 1)[0]
|
||||
return s
|
||||
|
||||
|
||||
def _position_key(exchange_id: str, symbol: str, side: str) -> str:
|
||||
sym = _normalize_position_symbol(symbol)
|
||||
sd = (side or "long").strip().lower() or "long"
|
||||
return f"{exchange_id}|{sym}|{sd}"
|
||||
|
||||
|
||||
def _position_contracts(pos: dict) -> float:
|
||||
for key in ("contracts", "contracts_signed", "size"):
|
||||
try:
|
||||
v = pos.get(key)
|
||||
if v is not None and v != "":
|
||||
return abs(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
def collect_position_keys(board_payload: dict | None) -> dict[str, dict]:
|
||||
out: dict[str, dict] = {}
|
||||
rows = (board_payload or {}).get("rows") or []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
ex_id = str(row.get("id") or row.get("key") or "")
|
||||
ex_name = str(row.get("name") or row.get("key") or ex_id)
|
||||
ag = row.get("agent") or {}
|
||||
for p in ag.get("positions") or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if _position_contracts(p) < 1e-12:
|
||||
continue
|
||||
sym = str(p.get("symbol") or "")
|
||||
side = str(p.get("side") or "").lower() or "long"
|
||||
key = _position_key(ex_id, sym, side)
|
||||
out[key] = {
|
||||
"exchange_id": ex_id,
|
||||
"exchange_name": ex_name,
|
||||
"symbol": sym,
|
||||
"side": side,
|
||||
"contracts": _position_contracts(p),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _board_agent_snapshot_ready(board_payload: dict | None) -> bool:
|
||||
"""监控板各启用账户 agent 快照已就绪(避免空板先入库导致后续持仓误判为新开)."""
|
||||
if not isinstance(board_payload, dict) or board_payload.get("ok") is False:
|
||||
return False
|
||||
rows = board_payload.get("rows") or []
|
||||
if not rows:
|
||||
return False
|
||||
seen = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if row.get("enabled") is False:
|
||||
continue
|
||||
ag = row.get("agent")
|
||||
if not isinstance(ag, dict):
|
||||
return False
|
||||
seen += 1
|
||||
return seen > 0
|
||||
|
||||
|
||||
def _entry_contracts(entry: dict | None) -> float:
|
||||
if not isinstance(entry, dict):
|
||||
return 0.0
|
||||
try:
|
||||
return float(entry.get("contracts") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def detect_new_opens(
|
||||
prev_positions: dict[str, dict],
|
||||
curr_positions: dict[str, dict],
|
||||
) -> list[dict]:
|
||||
"""仅当某合约从空仓变为有仓时视为新开(已有持仓不加仓不算)."""
|
||||
events = []
|
||||
for key, info in curr_positions.items():
|
||||
curr_c = _entry_contracts(info)
|
||||
if curr_c < 1e-12:
|
||||
continue
|
||||
prev_c = _entry_contracts(prev_positions.get(key))
|
||||
if prev_c >= 1e-12:
|
||||
continue
|
||||
events.append({"event_type": EVENT_OPEN, "event_id": f"open:{key}:{_now_str()[:16]}", **info})
|
||||
return events
|
||||
|
||||
|
||||
def detect_new_closes(
|
||||
prev_processed: set[str],
|
||||
closed_trades: list[dict],
|
||||
) -> list[dict]:
|
||||
events = []
|
||||
for trade in closed_trades or []:
|
||||
if not isinstance(trade, dict):
|
||||
continue
|
||||
eid = _trade_event_id(trade)
|
||||
if eid in prev_processed:
|
||||
continue
|
||||
event_type = classify_close_result(str(trade.get("result") or ""))
|
||||
events.append(
|
||||
{
|
||||
"event_type": event_type,
|
||||
"event_id": f"close:{eid}",
|
||||
"account_name": trade.get("account_name"),
|
||||
"symbol": trade.get("symbol"),
|
||||
"direction": trade.get("direction"),
|
||||
"result": trade.get("result"),
|
||||
"pnl_amount": trade.get("pnl_amount"),
|
||||
"closed_at": trade.get("closed_at"),
|
||||
}
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _parse_event_dt(raw: Any) -> Optional[datetime]:
|
||||
return parse_dt_for_trading_day(raw)
|
||||
|
||||
|
||||
def _supervised_close_times(stats: dict, trading_day: str) -> list[datetime]:
|
||||
rows = (stats.get(trading_day) or {}).get("supervised_closes") or []
|
||||
out = []
|
||||
for item in rows:
|
||||
if isinstance(item, dict):
|
||||
dt = _parse_event_dt(item.get("closed_at") or item.get("at"))
|
||||
else:
|
||||
dt = _parse_event_dt(item)
|
||||
if dt:
|
||||
out.append(dt)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def _record_supervised_event(stats: dict, trading_day: str, event: dict) -> None:
|
||||
day_stats = stats.setdefault(trading_day, {})
|
||||
et = str(event.get("event_type") or "")
|
||||
if et == EVENT_OPEN:
|
||||
opens = list(day_stats.get("supervised_opens") or [])
|
||||
opens.append({"at": _now_str(), "symbol": event.get("symbol")})
|
||||
day_stats["supervised_opens"] = opens[-50:]
|
||||
return
|
||||
if et not in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE):
|
||||
return
|
||||
closes = list(day_stats.get("supervised_closes") or [])
|
||||
closes.append(
|
||||
{
|
||||
"at": _now_str(),
|
||||
"closed_at": event.get("closed_at"),
|
||||
"event_type": et,
|
||||
"pnl_amount": event.get("pnl_amount"),
|
||||
}
|
||||
)
|
||||
day_stats["supervised_closes"] = closes[-50:]
|
||||
|
||||
|
||||
def evaluate_frequency_warnings(
|
||||
*,
|
||||
trading_day: str,
|
||||
event: dict,
|
||||
stats: dict,
|
||||
settings: dict,
|
||||
) -> list[dict]:
|
||||
if not is_supervised_event(str(event.get("event_type") or "")):
|
||||
return []
|
||||
warnings: list[dict] = []
|
||||
day_stats = stats.setdefault(trading_day, {})
|
||||
closes = _supervised_close_times(stats, trading_day)
|
||||
now = datetime.now()
|
||||
if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE):
|
||||
evt_dt = _parse_event_dt(event.get("closed_at")) or now
|
||||
closes = closes + [evt_dt]
|
||||
closes.sort()
|
||||
open_count = len(day_stats.get("supervised_opens") or [])
|
||||
close_count = len(day_stats.get("supervised_closes") or [])
|
||||
if event.get("event_type") == EVENT_OPEN:
|
||||
open_count += 1
|
||||
elif event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE):
|
||||
close_count += 1
|
||||
|
||||
interval_min = int(settings.get("interval_warn_minutes") or 15)
|
||||
daily_warn = int(settings.get("manual_close_daily_warn") or 2)
|
||||
freq_30m = int(settings.get("freq_30m_count") or 2)
|
||||
reopen_min = int(settings.get("reopen_after_close_minutes") or 30)
|
||||
|
||||
if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) and len(closes) >= 2:
|
||||
prev = closes[-2]
|
||||
cur = closes[-1]
|
||||
gap = (cur - prev).total_seconds() / 60.0
|
||||
if gap < interval_min:
|
||||
warnings.append(
|
||||
{
|
||||
"rule": "INTERVAL_SHORT",
|
||||
"message": f"两笔手动/中控平间隔仅 {int(gap)} 分钟(阈值 {interval_min} 分钟)",
|
||||
}
|
||||
)
|
||||
|
||||
recent_closes = [t for t in closes if (now - t).total_seconds() <= 30 * 60]
|
||||
if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) and len(recent_closes) >= freq_30m:
|
||||
warnings.append(
|
||||
{
|
||||
"rule": "FREQ_30M",
|
||||
"message": f"30 分钟内手动/中控平已达 {len(recent_closes)} 笔(阈值 {freq_30m} 笔)",
|
||||
}
|
||||
)
|
||||
|
||||
supervised_total = open_count + close_count
|
||||
if supervised_total >= daily_warn and event.get("event_type") in (
|
||||
EVENT_MANUAL_CLOSE,
|
||||
EVENT_HUB_CLOSE,
|
||||
EVENT_OPEN,
|
||||
):
|
||||
if close_count >= daily_warn:
|
||||
warnings.append(
|
||||
{
|
||||
"rule": "DAILY_COUNT",
|
||||
"message": f"今日手动/中控平 {close_count} 笔(阈值 {daily_warn} 笔),注意过度交易",
|
||||
}
|
||||
)
|
||||
|
||||
if event.get("event_type") == EVENT_OPEN and closes:
|
||||
last_close = closes[-1]
|
||||
gap_open = (now - last_close).total_seconds() / 60.0
|
||||
if gap_open < reopen_min:
|
||||
warnings.append(
|
||||
{
|
||||
"rule": "REOPEN_FAST",
|
||||
"message": f"距上一笔手动/中控平仅 {int(gap_open)} 分钟又新开仓(阈值 {reopen_min} 分钟)",
|
||||
}
|
||||
)
|
||||
|
||||
loss_streak = 0
|
||||
for item in reversed((stats.get(trading_day) or {}).get("supervised_closes") or []):
|
||||
try:
|
||||
pnl = float((item or {}).get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pnl = 0.0
|
||||
if pnl < 0:
|
||||
loss_streak += 1
|
||||
else:
|
||||
break
|
||||
if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE):
|
||||
try:
|
||||
pnl = float(event.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pnl = 0.0
|
||||
if pnl < 0:
|
||||
loss_streak += 1
|
||||
else:
|
||||
loss_streak = 0
|
||||
if loss_streak >= 2 and event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE):
|
||||
warnings.append(
|
||||
{
|
||||
"rule": "LOSS_STREAK",
|
||||
"message": f"连续 {loss_streak} 笔手动/中控亏损,先停一停",
|
||||
}
|
||||
)
|
||||
|
||||
deduped = []
|
||||
seen = set()
|
||||
for w in warnings:
|
||||
key = w.get("rule")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(w)
|
||||
return deduped
|
||||
|
||||
|
||||
def event_tag(event_type: str) -> str:
|
||||
return {
|
||||
EVENT_OPEN: "监管·开仓",
|
||||
EVENT_MANUAL_CLOSE: "监管·手动平",
|
||||
EVENT_HUB_CLOSE: "监管·中控平",
|
||||
EVENT_PROGRAM_TP: "监管·程序止盈",
|
||||
EVENT_PROGRAM_SL: "监管·程序止损",
|
||||
EVENT_EXTERNAL: "监管·外部平",
|
||||
EVENT_FREQ_WARN: "监管·频率",
|
||||
}.get(event_type, "监管")
|
||||
|
||||
|
||||
def _fmt_pnl_u(pnl: Any) -> str:
|
||||
try:
|
||||
v = float(pnl)
|
||||
sign = "+" if v > 0 else ""
|
||||
return f"{sign}{v:.4f}".rstrip("0").rstrip(".") + "U"
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def build_supervisor_fallback_reply(event: dict, warnings: list[dict] | None = None) -> str:
|
||||
"""AI 不可用或返回空时的短评语(不展示错误文案)."""
|
||||
et = str(event.get("event_type") or "")
|
||||
sym = str(event.get("symbol") or "—")
|
||||
ex = str(event.get("exchange_name") or event.get("account_name") or "").strip()
|
||||
pnl_txt = _fmt_pnl_u(event.get("pnl_amount"))
|
||||
warn = (warnings or [])[:1]
|
||||
warn_txt = str(warn[0].get("message") or "").strip() if warn else ""
|
||||
|
||||
if et == EVENT_PROGRAM_SL:
|
||||
base = f"{sym} 程序止损"
|
||||
if pnl_txt:
|
||||
base += f"({pnl_txt})"
|
||||
base += ",按计划出场是纪律.先歇一会儿,别急着马上再开."
|
||||
elif et == EVENT_PROGRAM_TP:
|
||||
base = f"{sym} 程序止盈"
|
||||
if pnl_txt:
|
||||
base += f"({pnl_txt})"
|
||||
base += ",执行不错.保持节奏,别立刻反手再开一单."
|
||||
elif et == EVENT_OPEN:
|
||||
who = f"{ex} " if ex else ""
|
||||
base = f"看到 {who}新开 {sym}.动手前确认是不是计划内,别因为上一笔情绪再开."
|
||||
elif et == EVENT_HUB_CLOSE:
|
||||
base = f"中控平了 {sym}"
|
||||
if pnl_txt:
|
||||
base += f"({pnl_txt})"
|
||||
base += "."
|
||||
base += f" {warn_txt}" if warn_txt else " 停一停,别连着手痒."
|
||||
elif et == EVENT_MANUAL_CLOSE:
|
||||
base = f"手动平了 {sym}"
|
||||
if pnl_txt:
|
||||
base += f"({pnl_txt})"
|
||||
base += "."
|
||||
base += f" {warn_txt}" if warn_txt else " 想好再开下一单."
|
||||
elif et == EVENT_FREQ_WARN:
|
||||
base = warn_txt or "今日操作偏频繁,先休息一会儿."
|
||||
else:
|
||||
base = "收到.确认是否按计划执行,别连续加码."
|
||||
return base.strip()[:320]
|
||||
|
||||
|
||||
def build_system_message(event: dict, *, trading_day: str, warnings: list[dict] | None = None) -> str:
|
||||
tag = event_tag(str(event.get("event_type") or ""))
|
||||
ex = event.get("exchange_name") or event.get("account_name") or "—"
|
||||
sym = event.get("symbol") or "—"
|
||||
lines = [f"[{tag}] {ex} · {sym}"]
|
||||
et = event.get("event_type")
|
||||
if et == EVENT_OPEN:
|
||||
side = event.get("side") or event.get("direction") or ""
|
||||
if side:
|
||||
lines.append(f"方向:{side}")
|
||||
elif et in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE, EVENT_PROGRAM_TP, EVENT_PROGRAM_SL, EVENT_EXTERNAL):
|
||||
res = event.get("result") or ""
|
||||
pnl = event.get("pnl_amount")
|
||||
if pnl is not None:
|
||||
lines.append(f"结果 {res} · 盈亏 {pnl}U")
|
||||
else:
|
||||
lines.append(f"结果 {res}")
|
||||
if event.get("closed_at"):
|
||||
lines.append(f"平仓时间 {event.get('closed_at')}")
|
||||
for w in warnings or []:
|
||||
lines.append(f"⚠ {w.get('message')}")
|
||||
lines.append(f"交易日 {trading_day}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_wechat_body(
|
||||
event: dict,
|
||||
*,
|
||||
trading_day: str,
|
||||
link_base: str,
|
||||
system_text: str,
|
||||
) -> str:
|
||||
link = (link_base or "").strip()
|
||||
if link:
|
||||
sep = "&" if "?" in link else "?"
|
||||
link = f"{link}{sep}day={trading_day}"
|
||||
body = system_text.replace("\n", "\n")
|
||||
if link:
|
||||
body += f"\n详情:{link}"
|
||||
return body
|
||||
|
||||
|
||||
def should_send_wechat(event: dict, settings: dict) -> bool:
|
||||
if not settings.get("enabled", True):
|
||||
return False
|
||||
webhook = (settings.get("wechat_webhook") or "").strip()
|
||||
if not webhook or "replace-me" in webhook.lower():
|
||||
return False
|
||||
et = str(event.get("event_type") or "")
|
||||
if is_program_event(et):
|
||||
return bool(settings.get("wechat_on_program_tp_sl", True))
|
||||
if et == EVENT_EXTERNAL:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def send_supervisor_wechat(
|
||||
event: dict,
|
||||
*,
|
||||
trading_day: str,
|
||||
settings: dict,
|
||||
system_text: str,
|
||||
) -> bool:
|
||||
if not should_send_wechat(event, settings):
|
||||
return False
|
||||
from lib.common.wechat_notify_lib import send_wechat_webhook
|
||||
|
||||
prefix = (settings.get("wechat_prefix") or "【交易监管】").strip()
|
||||
body = build_wechat_body(
|
||||
event,
|
||||
trading_day=trading_day,
|
||||
link_base=str(settings.get("wechat_link_base") or ""),
|
||||
system_text=system_text,
|
||||
)
|
||||
return bool(
|
||||
send_wechat_webhook(
|
||||
str(settings.get("wechat_webhook") or ""),
|
||||
body,
|
||||
prefix=prefix,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_notify_hook: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def set_supervisor_notify_hook(fn: Optional[Callable[[], None]]) -> None:
|
||||
global _notify_hook
|
||||
_notify_hook = fn
|
||||
|
||||
|
||||
def _fire_notify() -> None:
|
||||
if _notify_hook:
|
||||
try:
|
||||
_notify_hook()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def process_supervisor_tick(
|
||||
dashboard_payload: dict | None,
|
||||
board_payload: dict | None,
|
||||
settings_root: dict | None,
|
||||
*,
|
||||
reset_hour: int = 8,
|
||||
ai_reply_fn: Optional[Callable[..., str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""单次监管扫描:对比快照,写会话,推微信,可选 AI 评语."""
|
||||
from hub_ai.supervisor_store import (
|
||||
append_supervisor_ai_message,
|
||||
append_supervisor_system_message,
|
||||
ensure_supervisor_session,
|
||||
)
|
||||
|
||||
sup_cfg = normalize_supervisor_settings((settings_root or {}).get("supervisor"))
|
||||
if not sup_cfg.get("enabled", True):
|
||||
return {"ok": True, "skipped": True, "reason": "disabled"}
|
||||
|
||||
dash = dashboard_payload or {}
|
||||
trading_day = str(dash.get("trading_day") or current_trading_day(reset_hour=reset_hour))
|
||||
state = load_supervisor_state()
|
||||
if str(state.get("trading_day") or "") != trading_day:
|
||||
state = {
|
||||
"version": 1,
|
||||
"trading_day": trading_day,
|
||||
"processed": [],
|
||||
"positions": {},
|
||||
"stats": {trading_day: state.get("stats", {}).get(trading_day, {})},
|
||||
"positions_baseline_ready": False,
|
||||
}
|
||||
|
||||
processed = set(str(x) for x in (state.get("processed") or []))
|
||||
stats = dict(state.get("stats") or {})
|
||||
prev_positions = dict(state.get("positions") or {})
|
||||
curr_positions = collect_position_keys(board_payload)
|
||||
closed_trades = dash.get("closed_trades") or []
|
||||
board_ready = _board_agent_snapshot_ready(board_payload)
|
||||
|
||||
if not state.get("positions_baseline_ready"):
|
||||
for trade in closed_trades:
|
||||
if isinstance(trade, dict):
|
||||
processed.add(f"close:{_trade_event_id(trade)}")
|
||||
if not board_ready:
|
||||
state["trading_day"] = trading_day
|
||||
state["processed"] = list(processed)
|
||||
save_supervisor_state(state)
|
||||
return {"ok": True, "events": 0, "waiting_board": True, "trading_day": trading_day}
|
||||
state["trading_day"] = trading_day
|
||||
state["processed"] = list(processed)
|
||||
state["positions"] = curr_positions
|
||||
state["positions_baseline_ready"] = True
|
||||
state["initialized"] = True
|
||||
save_supervisor_state(state)
|
||||
return {
|
||||
"ok": True,
|
||||
"events": 0,
|
||||
"seeded": True,
|
||||
"trading_day": trading_day,
|
||||
"positions": len(curr_positions),
|
||||
}
|
||||
|
||||
raw_events = detect_new_opens(prev_positions, curr_positions) + detect_new_closes(
|
||||
processed, closed_trades
|
||||
)
|
||||
if not raw_events:
|
||||
state["positions"] = curr_positions
|
||||
save_supervisor_state(state)
|
||||
return {"ok": True, "events": 0}
|
||||
|
||||
session = ensure_supervisor_session(trading_day)
|
||||
session_id = str(session.get("id") or "")
|
||||
handled = 0
|
||||
|
||||
for event in raw_events:
|
||||
eid = str(event.get("event_id") or uuid.uuid4().hex)
|
||||
if eid in processed:
|
||||
continue
|
||||
et = str(event.get("event_type") or "")
|
||||
if et == EVENT_EXTERNAL:
|
||||
processed.add(eid)
|
||||
continue
|
||||
|
||||
warnings = evaluate_frequency_warnings(
|
||||
trading_day=trading_day,
|
||||
event=event,
|
||||
stats=stats,
|
||||
settings=sup_cfg,
|
||||
)
|
||||
if is_supervised_event(et):
|
||||
_record_supervised_event(stats, trading_day, event)
|
||||
|
||||
system_text = build_system_message(event, trading_day=trading_day, warnings=warnings)
|
||||
append_supervisor_system_message(
|
||||
session_id,
|
||||
system_text,
|
||||
event_type=et,
|
||||
level="warn" if warnings else "info",
|
||||
)
|
||||
send_supervisor_wechat(
|
||||
event,
|
||||
trading_day=trading_day,
|
||||
settings=sup_cfg,
|
||||
system_text=system_text,
|
||||
)
|
||||
for w in warnings:
|
||||
warn_event = {
|
||||
"event_type": EVENT_FREQ_WARN,
|
||||
"event_id": f"warn:{eid}:{w.get('rule')}",
|
||||
**event,
|
||||
"warn_message": w.get("message"),
|
||||
}
|
||||
warn_text = f"[{event_tag(EVENT_FREQ_WARN)}] {w.get('message')}"
|
||||
append_supervisor_system_message(
|
||||
session_id,
|
||||
warn_text,
|
||||
event_type=EVENT_FREQ_WARN,
|
||||
level="warn",
|
||||
)
|
||||
send_supervisor_wechat(
|
||||
warn_event,
|
||||
trading_day=trading_day,
|
||||
settings=sup_cfg,
|
||||
system_text=warn_text,
|
||||
)
|
||||
|
||||
if ai_reply_fn and et != EVENT_EXTERNAL:
|
||||
evt_snapshot = dict(event)
|
||||
evt_warnings = list(warnings)
|
||||
|
||||
def _ai_bg() -> None:
|
||||
try:
|
||||
reply = ai_reply_fn(
|
||||
event=evt_snapshot,
|
||||
warnings=evt_warnings,
|
||||
trading_day=trading_day,
|
||||
session_id=session_id,
|
||||
)
|
||||
from hub_ai.text_util import is_ai_error_reply
|
||||
|
||||
text = str(reply or "").strip()
|
||||
if not text or is_ai_error_reply(text):
|
||||
text = build_supervisor_fallback_reply(evt_snapshot, evt_warnings)
|
||||
if text:
|
||||
append_supervisor_ai_message(session_id, text)
|
||||
_fire_notify()
|
||||
except Exception:
|
||||
try:
|
||||
fb = build_supervisor_fallback_reply(evt_snapshot, evt_warnings)
|
||||
if fb:
|
||||
append_supervisor_ai_message(session_id, fb)
|
||||
_fire_notify()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_ai_bg, daemon=True).start()
|
||||
|
||||
processed.add(eid)
|
||||
handled += 1
|
||||
|
||||
state["trading_day"] = trading_day
|
||||
state["processed"] = list(processed)
|
||||
state["positions"] = curr_positions
|
||||
state["stats"] = stats
|
||||
save_supervisor_state(state)
|
||||
if handled:
|
||||
_fire_notify()
|
||||
return {"ok": True, "events": handled, "trading_day": trading_day, "session_id": session_id}
|
||||
@@ -0,0 +1,182 @@
|
||||
"""中控 Web 登录:HUB_USERNAME + HUB_PASSWORD 配置后启用会话 Cookie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from secrets import compare_digest
|
||||
|
||||
SESSION_COOKIE = "hub_sess"
|
||||
SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400)
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin123"
|
||||
|
||||
|
||||
def _env_username() -> str:
|
||||
return (os.getenv("HUB_USERNAME") or "").strip()
|
||||
|
||||
|
||||
def _env_password() -> str:
|
||||
raw = (os.getenv("HUB_PASSWORD") or "").strip()
|
||||
return raw or DEFAULT_PASSWORD
|
||||
|
||||
|
||||
def password_required() -> bool:
|
||||
"""默认启用登录(admin / admin123,可通过 .env 覆盖)."""
|
||||
return True
|
||||
|
||||
|
||||
def expected_username() -> str:
|
||||
return _env_username() or DEFAULT_USERNAME
|
||||
|
||||
|
||||
def verify_credentials(username: str, password: str) -> bool:
|
||||
u_ok = compare_digest(expected_username(), (username or "").strip())
|
||||
p_ok = compare_digest(_env_password(), (password or "").strip())
|
||||
return u_ok and p_ok
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""兼容旧调用:仅校验密码,用户名用默认值."""
|
||||
return verify_credentials(expected_username(), password)
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
raw = (os.getenv("HUB_SESSION_SECRET") or "").strip()
|
||||
if not raw:
|
||||
raw = "|".join(p for p in [_env_username(), _env_password()] if p) or "hub-dev-insecure"
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(text: str) -> bytes:
|
||||
pad = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + pad)
|
||||
|
||||
|
||||
def create_session_token(username: str | None = None) -> str:
|
||||
payload = {
|
||||
"exp": int(time.time()) + SESSION_MAX_AGE_SEC,
|
||||
"v": 2,
|
||||
"u": (username or expected_username()).strip(),
|
||||
}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
sig = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{body}.{sig}"
|
||||
|
||||
|
||||
def validate_session_token(token: str | None) -> bool:
|
||||
if not token or "." not in token:
|
||||
return False
|
||||
body, sig = token.rsplit(".", 1)
|
||||
expected = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not compare_digest(expected, sig):
|
||||
return False
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body))
|
||||
except Exception:
|
||||
return False
|
||||
exp = int(payload.get("exp") or 0)
|
||||
if exp <= int(time.time()):
|
||||
return False
|
||||
sess_user = (payload.get("u") or "").strip()
|
||||
if sess_user and not compare_digest(sess_user, expected_username()):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cookie_secure_env_enabled() -> bool:
|
||||
"""是否在 .env 中启用「HTTPS 时带 Secure Cookie」策略."""
|
||||
return (os.getenv("HUB_COOKIE_SECURE") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def cookie_secure_for_request(request) -> bool:
|
||||
"""
|
||||
仅在实际 HTTPS 访问时设置 Secure Cookie.
|
||||
这样可同时支持:域名 HTTPS 反代 + 内网 http://IP:5100 登录.
|
||||
"""
|
||||
if not cookie_secure_env_enabled():
|
||||
return False
|
||||
proto = (
|
||||
(request.headers.get("x-forwarded-proto") or request.url.scheme or "http")
|
||||
.split(",")[0]
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
return proto == "https"
|
||||
|
||||
|
||||
def embed_allowed() -> bool:
|
||||
"""允许被本地导航等页面 iframe 嵌入(默认开启,内网场景)."""
|
||||
return (os.getenv("HUB_ALLOW_EMBED") or "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def embed_frame_ancestors() -> str:
|
||||
"""CSP frame-ancestors;默认 *,可设 HUB_EMBED_ORIGINS=http://192.168.8.6:5070"""
|
||||
raw = (os.getenv("HUB_EMBED_ORIGINS") or "*").strip()
|
||||
if raw == "*":
|
||||
return "*"
|
||||
origins = [o.strip() for o in raw.split(",") if o.strip()]
|
||||
return " ".join(origins) if origins else "*"
|
||||
|
||||
|
||||
def set_session_cookie(response, request, token: str, *, embed: bool = False) -> None:
|
||||
"""
|
||||
embed=True:LocalNav 等跨站 iframe 嵌入时须 SameSite=None + Secure(仅 HTTPS 有效).
|
||||
"""
|
||||
secure = cookie_secure_for_request(request)
|
||||
samesite = "lax"
|
||||
if embed:
|
||||
secure = True
|
||||
samesite = "none"
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite=samesite,
|
||||
path="/",
|
||||
max_age=SESSION_MAX_AGE_SEC,
|
||||
secure=secure,
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response, request, *, embed: bool = False) -> None:
|
||||
secure = cookie_secure_for_request(request)
|
||||
samesite = "lax"
|
||||
if embed:
|
||||
secure = True
|
||||
samesite = "none"
|
||||
response.delete_cookie(
|
||||
SESSION_COOKIE,
|
||||
path="/",
|
||||
secure=secure,
|
||||
samesite=samesite,
|
||||
)
|
||||
|
||||
|
||||
def is_public_path(path: str, method: str) -> bool:
|
||||
p = (path or "").split("?")[0].rstrip("/") or "/"
|
||||
if p.startswith("/assets"):
|
||||
return True
|
||||
if p in ("/login", "/embed-auth", "/api/auth/login", "/api/auth/status", "/api/ping"):
|
||||
return True
|
||||
if p == "/api/auth/logout" and method.upper() == "POST":
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
OKX 挂单聚合(子代理本地副本,避免依赖仓库根 PYTHONPATH).
|
||||
普通委托 + 算法单 conditional / oco / trigger.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _order_dedupe_key(order: dict) -> str:
|
||||
info = order.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
return str(order.get("id") or info.get("algoId") or info.get("ordId") or "")
|
||||
|
||||
|
||||
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
|
||||
"""合并 OKX 普通挂单与算法挂单(去重)."""
|
||||
if not exchange_symbol:
|
||||
return []
|
||||
ex.load_markets()
|
||||
sym = exchange_symbol
|
||||
try:
|
||||
sym = ex.market(exchange_symbol)["symbol"]
|
||||
except Exception:
|
||||
pass
|
||||
seen: set[str] = set()
|
||||
out: list[dict] = []
|
||||
|
||||
def add_batch(batch: list | None) -> None:
|
||||
for o in batch or []:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
k = _order_dedupe_key(o)
|
||||
if not k or k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
out.append(o)
|
||||
|
||||
try:
|
||||
add_batch(ex.fetch_open_orders(sym))
|
||||
except Exception:
|
||||
pass
|
||||
for params in (
|
||||
{"ordType": "conditional"},
|
||||
{"ordType": "oco"},
|
||||
{"trigger": True},
|
||||
):
|
||||
try:
|
||||
add_batch(ex.fetch_open_orders(sym, params=dict(params)))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.110,<1
|
||||
uvicorn[standard]>=0.27,<1
|
||||
python-multipart>=0.0.9,<1
|
||||
httpx>=0.27,<1
|
||||
ccxt>=4.2,<5
|
||||
PySocks>=1.7,<2
|
||||
psutil>=5.9,<8
|
||||
# 可选:服务端 pip install markdown 后渲染更完整;无则使用内置轻量渲染
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 检查三路子代理端口与 /status(在服务器上运行)
|
||||
set -e
|
||||
|
||||
check_one() {
|
||||
local name="$1" port="$2"
|
||||
echo "=== ${name} :${port} ==="
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tlnp 2>/dev/null | grep ":${port} " && echo " 端口: 已被占用" || echo " 端口: 空闲"
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
local body
|
||||
body=$(curl -sf --max-time 8 "http://127.0.0.1:${port}/status" 2>/dev/null) || {
|
||||
echo " /status: 无法连接(agent 未启动或崩溃)"
|
||||
return
|
||||
}
|
||||
echo " /status: ${body:0:200}"
|
||||
if echo "${body}" | grep -q '"ok":true'; then
|
||||
echo " 结果: OK"
|
||||
else
|
||||
echo " 结果: ok=false,见上 JSON"
|
||||
fi
|
||||
else
|
||||
echo " (未安装 curl,跳过 HTTP 检测)"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
check_one "binance" 15200
|
||||
check_one "okx" 15201
|
||||
check_one "gate" 15202
|
||||
|
||||
echo "PM2 状态:"
|
||||
pm2 status 2>/dev/null | grep -E 'manual-agent|manual-trading' || true
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# 去掉各目录 .env 的 Windows 换行符(解决 PM2 agent errored: $'\r': command not found)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
dirs=(
|
||||
"${REPO}/manual_trading_hub"
|
||||
"${REPO}/crypto_monitor_binance"
|
||||
"${REPO}/crypto_monitor_okx"
|
||||
"${REPO}/crypto_monitor_gate"
|
||||
)
|
||||
|
||||
fixed=0
|
||||
for d in "${dirs[@]}"; do
|
||||
f="${d}/.env"
|
||||
if [[ ! -f "${f}" ]]; then
|
||||
echo "跳过(无文件): ${f}"
|
||||
continue
|
||||
fi
|
||||
if grep -q $'\r' "${f}" 2>/dev/null; then
|
||||
sed -i 's/\r$//' "${f}"
|
||||
echo "已修复 CRLF: ${f}"
|
||||
fixed=$((fixed + 1))
|
||||
else
|
||||
echo "已是 LF: ${f}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "完成,共修复 ${fixed} 个 .env."
|
||||
echo "请重启子代理: cd ${REPO}/manual_trading_hub && pm2 restart manual-agent-gate manual-agent-binance manual-agent-okx"
|
||||
echo "或: bash scripts/pm2_hub.sh restart"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# 修复中控缺 python-multipart 等问题
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
if [[ ! -d .venv ]]; then
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
source .venv/bin/activate
|
||||
pip install -U pip
|
||||
pip install -r requirements.txt
|
||||
echo "OK: $(python -c 'import multipart; print("python-multipart", multipart.__version__)' 2>/dev/null || pip show python-multipart | head -1)"
|
||||
echo "Hub ping (需 hub 已启动): curl -s http://127.0.0.1:5100/api/ping"
|
||||
@@ -0,0 +1,16 @@
|
||||
# shellcheck shell=bash
|
||||
# 供 run_agent.sh / run_hub.sh source:加载 .env 并去掉 Windows CRLF($'\r')
|
||||
load_dotenv_file() {
|
||||
local f="$1"
|
||||
if [[ ! -f "${f}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
set -a
|
||||
set +e
|
||||
# shellcheck disable=SC1090
|
||||
. <(sed 's/\r$//' "${f}")
|
||||
local rc=$?
|
||||
set -e
|
||||
set +a
|
||||
return "${rc}"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅子代理 PM2(中控请用 scripts/pm2_hub.sh 或 ecosystem.config.cjs 一次起全部)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
ECO="${HUB_DIR}/ecosystem.agents.config.cjs"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: bash scripts/pm2_agents.sh <start|stop|restart|status|logs|delete>
|
||||
|
||||
一般请用: bash scripts/pm2_hub.sh start (hub + agent 一起)
|
||||
|
||||
本脚本仅操作 3 路子代理(不含中控)
|
||||
|
||||
仅启动币安: pm2 start ecosystem.agents.config.cjs --only manual-agent-binance
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
|
||||
if ! command -v pm2 >/dev/null 2>&1; then
|
||||
echo "未找到 pm2,请先: npm install -g pm2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${HUB_DIR}"
|
||||
|
||||
case "${cmd}" in
|
||||
start)
|
||||
pm2 start "${ECO}"
|
||||
pm2 save 2>/dev/null || true
|
||||
;;
|
||||
stop)
|
||||
pm2 stop manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null || true
|
||||
;;
|
||||
restart)
|
||||
pm2 restart manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null \
|
||||
|| pm2 start "${ECO}"
|
||||
;;
|
||||
status)
|
||||
pm2 status
|
||||
;;
|
||||
logs)
|
||||
pm2 logs manual-agent-binance manual-agent-okx manual-agent-gate --lines 100
|
||||
;;
|
||||
delete)
|
||||
pm2 delete manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# 中控 + 子代理 统一 PM2 快捷脚本
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
ECO="${HUB_DIR}/ecosystem.config.cjs"
|
||||
|
||||
# 与 ecosystem.config.cjs 中 name 一致
|
||||
PM2_NAMES=(
|
||||
manual-agent-binance
|
||||
manual-agent-okx
|
||||
manual-agent-gate
|
||||
manual-trading-hub
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: bash scripts/pm2_hub.sh <start|stop|restart|status|logs|delete>
|
||||
|
||||
start 启动 ecosystem.config.cjs(3 路子代理 + 中控,已存在则 restart 全部)
|
||||
stop 停止全部
|
||||
restart 重启全部
|
||||
status pm2 status
|
||||
logs 全部相关进程日志
|
||||
delete 从 PM2 列表移除全部
|
||||
|
||||
仅中控: pm2 start ecosystem.config.cjs --only manual-trading-hub
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
|
||||
if ! command -v pm2 >/dev/null 2>&1; then
|
||||
echo "未找到 pm2,请先: npm install -g pm2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ECO}" ]]; then
|
||||
echo "未找到 ${ECO}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${HUB_DIR}"
|
||||
|
||||
_any_running() {
|
||||
local n
|
||||
for n in "${PM2_NAMES[@]}"; do
|
||||
if pm2 describe "${n}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
case "${cmd}" in
|
||||
start)
|
||||
if _any_running; then
|
||||
pm2 restart "${ECO}"
|
||||
echo "已重启:hub + 全部 agent"
|
||||
else
|
||||
pm2 start "${ECO}"
|
||||
echo "已启动:hub + 全部 agent(共 ${#PM2_NAMES[@]} 个进程)"
|
||||
fi
|
||||
pm2 save 2>/dev/null || true
|
||||
;;
|
||||
stop)
|
||||
pm2 stop "${PM2_NAMES[@]}" 2>/dev/null || echo "部分或全部进程未在运行"
|
||||
;;
|
||||
restart)
|
||||
if _any_running; then
|
||||
pm2 restart "${ECO}"
|
||||
else
|
||||
pm2 start "${ECO}"
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
pm2 status
|
||||
;;
|
||||
logs)
|
||||
pm2 logs "${PM2_NAMES[@]}" --lines 100
|
||||
;;
|
||||
delete)
|
||||
pm2 delete "${PM2_NAMES[@]}" 2>/dev/null || echo "部分或全部进程不存在"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅重启失败的子代理(保留 hub / 已 online 的 agent)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
ECO="${HUB_DIR}/ecosystem.config.cjs"
|
||||
|
||||
cd "${HUB_DIR}"
|
||||
chmod +x scripts/run_agent.sh scripts/run_hub.sh 2>/dev/null || true
|
||||
|
||||
AGENTS=(manual-agent-binance manual-agent-okx manual-agent-gate)
|
||||
|
||||
for n in "${AGENTS[@]}"; do
|
||||
pm2 delete "${n}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
pm2 start "${ECO}" --only manual-agent-binance
|
||||
pm2 start "${ECO}" --only manual-agent-okx
|
||||
pm2 start "${ECO}" --only manual-agent-gate
|
||||
|
||||
pm2 save 2>/dev/null || true
|
||||
echo "已重建 binance / okx / gate 子代理,请执行: bash scripts/check_agents.sh"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# PM2 子代理入口:在策略目录(cwd)加载 .env 后启动 agent.py
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
# shellcheck source=lib_load_dotenv.sh
|
||||
source "${HUB_DIR}/scripts/lib_load_dotenv.sh"
|
||||
|
||||
VENV_PY="${HUB_DIR}/.venv/bin/python"
|
||||
AGENT_PY="${HUB_DIR}/agent.py"
|
||||
|
||||
_PM2_EXCHANGE="${EXCHANGE:-}"
|
||||
_PM2_PORT="${PORT:-}"
|
||||
_PM2_HOST="${HOST:-}"
|
||||
|
||||
if [[ ! -x "${VENV_PY}" ]]; then
|
||||
echo "未找到 ${VENV_PY},请先在 manual_trading_hub: python3 -m venv .venv && pip install -r requirements.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
if grep -q $'\r' .env 2>/dev/null; then
|
||||
echo "警告: $(pwd)/.env 含 Windows 换行(CRLF),请在仓库根执行: bash manual_trading_hub/scripts/fix_env_crlf.sh" >&2
|
||||
fi
|
||||
if ! load_dotenv_file ".env"; then
|
||||
echo "错误: $(pwd)/.env 加载失败" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "警告: $(pwd) 下无 .env,agent 可能缺少 API 密钥" >&2
|
||||
fi
|
||||
|
||||
[[ -n "${_PM2_EXCHANGE}" ]] && export EXCHANGE="${_PM2_EXCHANGE}"
|
||||
[[ -n "${_PM2_PORT}" ]] && export PORT="${_PM2_PORT}"
|
||||
[[ -n "${_PM2_HOST}" ]] && export HOST="${_PM2_HOST}"
|
||||
|
||||
if command -v ss >/dev/null 2>&1 && [[ -n "${PORT:-}" ]]; then
|
||||
if ss -tln 2>/dev/null | grep -q ":${PORT} "; then
|
||||
echo "错误: 端口 ${PORT} 已被占用,agent 无法监听(exchange=${EXCHANGE:-?})" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "agent start: exchange=${EXCHANGE:-?} port=${PORT:-?} cwd=$(pwd)" >&2
|
||||
exec "${VENV_PY}" "${AGENT_PY}"
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# PM2 / 手动启动入口:加载 manual_trading_hub/.env 后运行 hub.py
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
cd "${HUB_DIR}"
|
||||
|
||||
# shellcheck source=lib_load_dotenv.sh
|
||||
source "${HUB_DIR}/scripts/lib_load_dotenv.sh"
|
||||
|
||||
VENV_PY="${HUB_DIR}/.venv/bin/python"
|
||||
if [[ ! -x "${VENV_PY}" ]]; then
|
||||
echo "未找到 ${VENV_PY},请先: python3 -m venv .venv && pip install -r requirements.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "${HUB_DIR}/.env" ]]; then
|
||||
load_dotenv_file "${HUB_DIR}/.env" || {
|
||||
echo "错误: ${HUB_DIR}/.env 加载失败" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo "run_hub: python=${VENV_PY} cwd=${HUB_DIR} PYTHONPATH=${PYTHONPATH}" >&2
|
||||
exec "${VENV_PY}" -u "${HUB_DIR}/hub.py"
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在服务器上检查中控是否为最新代码(无 api_trade_key,已装 multipart,进程可访问)
|
||||
set -euo pipefail
|
||||
HUB_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$HUB_DIR"
|
||||
|
||||
HUB_PORT=5100
|
||||
if [[ -f .env ]]; then
|
||||
p=$(grep -E '^HUB_PORT=' .env 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r" ')
|
||||
[[ -n "${p}" ]] && HUB_PORT="${p}"
|
||||
fi
|
||||
PING_URL="http://127.0.0.1:${HUB_PORT}/api/ping"
|
||||
|
||||
echo "=== hub.py 检查 ==="
|
||||
if grep -n 'def api_trade_key' hub.py 2>/dev/null; then
|
||||
echo "FAIL: 仍是旧版 hub.py(含 api_trade_key),请 git pull"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'HUB_BUILD' hub.py; then
|
||||
echo "FAIL: hub.py 缺少 HUB_BUILD 标记"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: 无 api_trade_key,含 HUB_BUILD"
|
||||
|
||||
echo "=== python-multipart ==="
|
||||
# shellcheck source=/dev/null
|
||||
source .venv/bin/activate
|
||||
python -c "import multipart; print('OK:', multipart.__version__)"
|
||||
|
||||
echo "=== 端口 ${HUB_PORT} ==="
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT},请 pm2 restart manual-trading-hub"
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
netstat -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT}"
|
||||
else
|
||||
echo "(跳过端口检查)"
|
||||
fi
|
||||
|
||||
echo "=== PM2 manual-trading-hub ==="
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
pm2 describe manual-trading-hub 2>/dev/null | grep -E 'status|restarts|uptime|script path' || pm2 list | grep -i hub || true
|
||||
fi
|
||||
|
||||
echo "=== GET ${PING_URL} ==="
|
||||
HTTP_CODE=$(curl -sS -o /tmp/hub_ping_body.txt -w "%{http_code}" "${PING_URL}" || echo "000")
|
||||
echo "HTTP ${HTTP_CODE}"
|
||||
cat /tmp/hub_ping_body.txt
|
||||
echo ""
|
||||
if [[ "${HTTP_CODE}" == "200" ]]; then
|
||||
python -m json.tool /tmp/hub_ping_body.txt
|
||||
if grep -q '20260521-no-trade-ui' /tmp/hub_ping_body.txt; then
|
||||
echo "OK: build 正确"
|
||||
else
|
||||
echo "WARN: build 字段不是 20260521-no-trade-ui,请 pm2 restart manual-trading-hub"
|
||||
fi
|
||||
else
|
||||
echo "FAIL: ping 未返回 200.常见原因:进程未启动或崩溃."
|
||||
echo " 执行: pm2 restart manual-trading-hub && sleep 2 && bash scripts/verify_hub_deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,42 @@
|
||||
# 中控与子代理 · 后台常驻(Ubuntu)
|
||||
|
||||
**唯一推荐方式:PM2.**
|
||||
|
||||
请仅使用 PM2 托管 `hub.py` 与 `agent.py`,勿与 nohup 等方式重复启动同一端口.
|
||||
|
||||
---
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp -n .env.example .env # 首次
|
||||
|
||||
pm2 start ecosystem.config.cjs
|
||||
pm2 save
|
||||
pm2 list
|
||||
```
|
||||
|
||||
一条 `ecosystem.config.cjs` 会拉起 **4 个子代理 + 1 个 hub**.
|
||||
|
||||
---
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pm2 logs manual-trading-hub
|
||||
pm2 restart manual-trading-hub
|
||||
pm2 restart all
|
||||
bash scripts/verify_hub_deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 详细说明
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [../部署文档.md](../部署文档.md) | 端口,反代,故障排查 |
|
||||
| [../../docs/ubuntu-server.md](../../docs/ubuntu-server.md) | Python / Node / PM2 版本与三所启动顺序 |
|
||||
@@ -0,0 +1,136 @@
|
||||
"""中控交易所配置(hub_settings.json)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DIR = Path(__file__).resolve().parent
|
||||
SETTINGS_PATH = DIR / "hub_settings.json"
|
||||
_REPO_ROOT = DIR.parent
|
||||
|
||||
import sys
|
||||
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
if str(DIR) not in sys.path:
|
||||
sys.path.insert(0, str(DIR))
|
||||
|
||||
from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings
|
||||
from lib.hub.hub_backup_lib import normalize_backup_settings
|
||||
|
||||
DEFAULT_DISPLAY = {
|
||||
"show_account_pnl": True,
|
||||
"show_nav_funds": True,
|
||||
"show_nav_dashboard": True,
|
||||
"show_nav_plan": True,
|
||||
"show_nav_archive": True,
|
||||
"show_nav_quotes": True,
|
||||
"show_nav_ai": True,
|
||||
"show_nav_calculator": True,
|
||||
"show_nav_strategy": True,
|
||||
"show_nav_help": True,
|
||||
"show_nav_logs": True,
|
||||
}
|
||||
|
||||
DEFAULT_EXCHANGES = [
|
||||
{
|
||||
"id": "0",
|
||||
"key": "binance",
|
||||
"name": "币安 · crypto_monitor_binance",
|
||||
"agent_url": "http://127.0.0.1:15200",
|
||||
"flask_url": "http://127.0.0.1:5001",
|
||||
"review_url": "http://127.0.0.1:5001/records",
|
||||
"enabled": True,
|
||||
"capabilities": ["key", "trend"],
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"key": "okx",
|
||||
"name": "OKX · crypto_monitor_okx",
|
||||
"agent_url": "http://127.0.0.1:15201",
|
||||
"flask_url": "http://127.0.0.1:5004",
|
||||
"review_url": "http://127.0.0.1:5004/records",
|
||||
"enabled": True,
|
||||
"capabilities": ["key", "trend", "options"],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"key": "gate",
|
||||
"name": "Gate · crypto_monitor_gate",
|
||||
"agent_url": "http://127.0.0.1:15202",
|
||||
"flask_url": "http://127.0.0.1:5000",
|
||||
"review_url": "http://127.0.0.1:5000/records",
|
||||
"enabled": True,
|
||||
"capabilities": ["key", "trend"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _ids_from_csv(raw: str | None) -> set[str]:
|
||||
if not raw or not str(raw).strip():
|
||||
return set()
|
||||
return {x.strip() for x in str(raw).split(",") if x.strip()}
|
||||
|
||||
|
||||
def env_force_disabled_ids() -> set[str]:
|
||||
# 未设置时默认不强制关闭任何账户;要用旧行为可设 HUB_DISABLED_IDS=1
|
||||
raw = (os.getenv("HUB_DISABLED_IDS") or "").strip()
|
||||
return _ids_from_csv(raw)
|
||||
|
||||
|
||||
def normalize_display_prefs(raw: dict | None) -> dict:
|
||||
out = dict(DEFAULT_DISPLAY)
|
||||
if isinstance(raw, dict):
|
||||
for key in DEFAULT_DISPLAY:
|
||||
if key in raw:
|
||||
out[key] = bool(raw.get(key))
|
||||
return out
|
||||
|
||||
|
||||
def load_settings() -> dict:
|
||||
data = {
|
||||
"exchanges": [dict(x) for x in DEFAULT_EXCHANGES],
|
||||
"version": 1,
|
||||
"display": dict(DEFAULT_DISPLAY),
|
||||
}
|
||||
if SETTINGS_PATH.is_file():
|
||||
try:
|
||||
loaded = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict) and isinstance(loaded.get("exchanges"), list):
|
||||
data = loaded
|
||||
except Exception:
|
||||
pass
|
||||
data["display"] = normalize_display_prefs(data.get("display"))
|
||||
data["supervisor"] = normalize_supervisor_settings(data.get("supervisor"))
|
||||
data["backup"] = normalize_backup_settings(data.get("backup"))
|
||||
force_off = env_force_disabled_ids()
|
||||
for ex in data.get("exchanges") or []:
|
||||
if str(ex.get("id")) in force_off:
|
||||
ex["enabled"] = False
|
||||
ex["env_disabled"] = True
|
||||
else:
|
||||
ex.setdefault("env_disabled", False)
|
||||
if ex.get("key") == "okx":
|
||||
caps = list(ex.get("capabilities") or [])
|
||||
if "options" not in caps:
|
||||
caps.append("options")
|
||||
ex["capabilities"] = caps
|
||||
return data
|
||||
|
||||
|
||||
def save_settings(data: dict) -> None:
|
||||
payload = dict(data)
|
||||
payload["display"] = normalize_display_prefs(payload.get("display"))
|
||||
payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor"))
|
||||
payload["backup"] = normalize_backup_settings(payload.get("backup"))
|
||||
SETTINGS_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def enabled_exchanges(data: dict | None = None) -> list[dict]:
|
||||
data = data or load_settings()
|
||||
return [x for x in data.get("exchanges") or [] if x.get("enabled")]
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 系统设置 · 备份与恢复
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-settings");
|
||||
if (!page) return;
|
||||
|
||||
const elAuto = document.getElementById("backup-auto-enabled");
|
||||
const elHour = document.getElementById("backup-auto-hour");
|
||||
const elRetention = document.getElementById("backup-retention-days");
|
||||
const elIncludeEnv = document.getElementById("backup-include-env");
|
||||
const elIncludeImages = document.getElementById("backup-include-images");
|
||||
const elRoot = document.getElementById("backup-root");
|
||||
const elStatus = document.getElementById("backup-status-line");
|
||||
const elList = document.getElementById("backup-list");
|
||||
const elRun = document.getElementById("backup-run-now");
|
||||
const elRestoreFile = document.getElementById("backup-restore-file");
|
||||
const elRestoreBtn = document.getElementById("backup-restore-upload-btn");
|
||||
|
||||
let settingsCache = null;
|
||||
let statusCache = null;
|
||||
|
||||
function fmtBytes(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v < 0) return "—";
|
||||
if (v < 1024) return v + " B";
|
||||
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
|
||||
return (v / (1024 * 1024)).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
if (!elStatus) return;
|
||||
elStatus.textContent = msg || "";
|
||||
elStatus.className = "backup-status-line" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function collectBackupFromUI() {
|
||||
return {
|
||||
auto_enabled: !!(elAuto && elAuto.checked),
|
||||
auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)),
|
||||
retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)),
|
||||
include_env: !!(elIncludeEnv && elIncludeEnv.checked),
|
||||
include_exchange_images: !!(elIncludeImages && elIncludeImages.checked),
|
||||
backup_root: (elRoot && elRoot.value || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function syncBackupUI(data) {
|
||||
const b = (data && data.backup) || {};
|
||||
if (elAuto) elAuto.checked = b.auto_enabled !== false;
|
||||
if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0;
|
||||
if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30;
|
||||
if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false;
|
||||
if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images;
|
||||
if (elRoot) elRoot.value = b.backup_root || "";
|
||||
}
|
||||
|
||||
function renderBackupList(status) {
|
||||
if (!elList) return;
|
||||
const rows = (status && status.backups) || [];
|
||||
const state = (status && status.state) || {};
|
||||
const root = (status && status.backup_root) || "";
|
||||
let html = '<div class="backup-meta">';
|
||||
html += '<div>目录:<code>' + esc(root) + '</code></div>';
|
||||
if (state.last_backup_at) {
|
||||
html += '<div>上次备份:' + esc(state.last_backup_at) + '(' + esc(state.last_trigger || "") + ")</div>";
|
||||
}
|
||||
if (state.last_auto_at) {
|
||||
html += '<div>上次自动:' + esc(state.last_auto_at) + "</div>";
|
||||
}
|
||||
if (state.last_restore_at) {
|
||||
html += '<div>上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
|
||||
}
|
||||
html += "</div>";
|
||||
if (!rows.length) {
|
||||
html += '<p class="backup-empty">暂无备份文件</p>';
|
||||
elList.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
html += '<table class="backup-table"><thead><tr><th>文件</th><th>大小</th><th>时间</th><th></th></tr></thead><tbody>';
|
||||
rows.forEach(function (row) {
|
||||
html +=
|
||||
"<tr><td>" +
|
||||
esc(row.name) +
|
||||
"</td><td>" +
|
||||
fmtBytes(row.size) +
|
||||
"</td><td>" +
|
||||
esc(row.modified_at || "") +
|
||||
'</td><td class="backup-row-actions">' +
|
||||
'<a class="ghost" href="/api/backup/download/' +
|
||||
encodeURIComponent(row.name) +
|
||||
'" download>下载</a> ' +
|
||||
'<button type="button" class="danger backup-restore-local" data-name="' +
|
||||
escAttr(row.name) +
|
||||
'">恢复</button></td></tr>';
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
elList.innerHTML = html;
|
||||
elList.querySelectorAll(".backup-restore-local").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
restoreLocal(btn.getAttribute("data-name"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadSettingsData() {
|
||||
const r = await fetch("/api/settings", { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("加载设置失败");
|
||||
settingsCache = await r.json();
|
||||
syncBackupUI(settingsCache);
|
||||
}
|
||||
|
||||
async function loadBackupStatus() {
|
||||
const r = await fetch("/api/backup/status", { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("加载备份状态失败");
|
||||
statusCache = await r.json();
|
||||
renderBackupList(statusCache);
|
||||
}
|
||||
|
||||
async function saveBackupSettings() {
|
||||
if (!settingsCache) await loadSettingsData();
|
||||
const body = {
|
||||
exchanges: settingsCache.exchanges || [],
|
||||
display: settingsCache.display,
|
||||
supervisor: settingsCache.supervisor,
|
||||
backup: collectBackupFromUI(),
|
||||
};
|
||||
const r = await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error("保存失败");
|
||||
settingsCache = (await r.json()).settings || settingsCache;
|
||||
syncBackupUI(settingsCache);
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("备份设置已保存");
|
||||
}
|
||||
|
||||
async function runBackupNow() {
|
||||
setStatus("备份中…");
|
||||
const r = await fetch("/api/backup/run", { method: "POST", credentials: "same-origin" });
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "备份失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("完成:" + (data.file || "") + "(" + fmtBytes(data.size) + ")");
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("备份完成");
|
||||
}
|
||||
|
||||
async function restoreLocal(name) {
|
||||
if (!name) return;
|
||||
if (!window.confirm("确认从服务器备份 " + name + " 恢复?\n恢复前会自动做 pre-restore 快照并重启 PM2.")) return;
|
||||
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
|
||||
setStatus("恢复中…");
|
||||
const r = await fetch("/api/backup/restore-local", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name, confirm: "RESTORE" }),
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "恢复失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
|
||||
}
|
||||
|
||||
async function restoreUpload() {
|
||||
const file = elRestoreFile && elRestoreFile.files && elRestoreFile.files[0];
|
||||
if (!file) {
|
||||
setStatus("请选择 .zip 备份文件", true);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确认上传并恢复 " + file.name + "?\n恢复前会自动做 pre-restore 快照并重启 PM2.")) return;
|
||||
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
|
||||
setStatus("上传并恢复中…");
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("confirm", "RESTORE");
|
||||
const r = await fetch("/api/backup/restore", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: fd,
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "恢复失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
|
||||
if (elRestoreFile) elRestoreFile.value = "";
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
|
||||
}
|
||||
|
||||
window.initBackupSettingsUI = async function () {
|
||||
try {
|
||||
await loadSettingsData();
|
||||
await loadBackupStatus();
|
||||
setStatus("");
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
}
|
||||
};
|
||||
|
||||
if (elRun) elRun.addEventListener("click", function () {
|
||||
runBackupNow().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
|
||||
if (elRestoreBtn) elRestoreBtn.addEventListener("click", function () {
|
||||
restoreUpload().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
|
||||
page.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".settings-section-save[data-settings-section='backup']");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
saveBackupSettings().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* 中控策略计算器:趋势回调 / 滚仓历史测算
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-calculator");
|
||||
if (!page) return;
|
||||
|
||||
let inited = false;
|
||||
const marketCache = {};
|
||||
let calculatorExchanges = [];
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """);
|
||||
}
|
||||
|
||||
function num(id) {
|
||||
const el = $(id);
|
||||
if (!el) return null;
|
||||
const n = Number(el.value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function text(id) {
|
||||
const el = $(id);
|
||||
if (!el) return "";
|
||||
return String(el.value || "").trim();
|
||||
}
|
||||
|
||||
function fmt(v, digits) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return esc(v);
|
||||
if (digits != null) return n.toFixed(digits);
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** 去掉尾部多余 0,用于乘数/精度展示 */
|
||||
function fmtTrim(v, maxDigits) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return esc(v);
|
||||
let s = maxDigits != null ? n.toFixed(maxDigits) : String(n);
|
||||
if (s.includes(".")) s = s.replace(/\.?0+$/, "");
|
||||
return s;
|
||||
}
|
||||
|
||||
function fmtU(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "";
|
||||
return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss";
|
||||
}
|
||||
|
||||
function decimalsFromMarket(data) {
|
||||
if (!data || !data.market) return { price: 4, amount: 4 };
|
||||
return {
|
||||
price: Number(data.market.price_decimals),
|
||||
amount: Number(data.market.amount_decimals),
|
||||
};
|
||||
}
|
||||
|
||||
function fmtMarketInfo(market, err) {
|
||||
if (err) {
|
||||
return '<span class="calc-market-err">' + esc(err) + "</span>";
|
||||
}
|
||||
if (!market) return "—";
|
||||
const inst = market.exchange_name ? esc(market.exchange_name) + " · " : "";
|
||||
const parts = [
|
||||
inst + "<strong>" + esc(market.display_symbol || market.base || "") + "</strong> 永续",
|
||||
"合约 " + esc(market.exchange_symbol || ""),
|
||||
"乘数 " + fmtTrim(market.contract_size, 8),
|
||||
"价格精度 " + fmtTrim(market.price_tick != null ? market.price_tick : Math.pow(10, -(market.price_decimals || 0))),
|
||||
"张数精度 " + fmtTrim(Math.pow(10, -(market.amount_decimals || 0))),
|
||||
];
|
||||
if (market.min_amount != null) {
|
||||
parts.push("最小张数 " + fmtTrim(market.min_amount, market.amount_decimals));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function applyMarketSteps(prefix, market) {
|
||||
const pxStep =
|
||||
market && market.price_tick != null && Number(market.price_tick) > 0
|
||||
? String(market.price_tick)
|
||||
: market && market.price_decimals != null
|
||||
? String(Math.pow(10, -Number(market.price_decimals)))
|
||||
: "any";
|
||||
const amtStep =
|
||||
market && market.amount_decimals != null
|
||||
? String(Math.pow(10, -Number(market.amount_decimals)))
|
||||
: "any";
|
||||
page.querySelectorAll("#" + prefix + "-form input[type='number']").forEach(function (el) {
|
||||
if (el.classList.contains("calc-roll-leg-add") || el.classList.contains("calc-roll-leg-stop")) {
|
||||
el.step = pxStep;
|
||||
return;
|
||||
}
|
||||
if (el.id === prefix + "-capital" || el.id === prefix + "-risk" || el.id === prefix + "-leverage") {
|
||||
return;
|
||||
}
|
||||
if (el.id === prefix + "-dca-legs" || el.id === prefix + "-legs-done") {
|
||||
return;
|
||||
}
|
||||
el.step = pxStep;
|
||||
});
|
||||
page.querySelectorAll(".calc-roll-leg-add, .calc-roll-leg-stop").forEach(function (el) {
|
||||
el.step = pxStep;
|
||||
});
|
||||
void amtStep;
|
||||
}
|
||||
|
||||
async function refreshMarket(prefix) {
|
||||
const exchangeEl = $(prefix + "-exchange");
|
||||
const baseEl = $(prefix + "-base");
|
||||
const infoEl = $(prefix + "-market-info");
|
||||
if (!exchangeEl || !baseEl || !infoEl) return null;
|
||||
const exchangeId = exchangeEl.value || (calculatorExchanges[0] && calculatorExchanges[0].id) || "0";
|
||||
const base = text(prefix + "-base") || "ETH";
|
||||
const cacheKey = exchangeId + ":" + base.toUpperCase();
|
||||
infoEl.innerHTML = "加载合约信息…";
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/calculator/market?exchange_id=" +
|
||||
encodeURIComponent(exchangeId) +
|
||||
"&base=" +
|
||||
encodeURIComponent(base),
|
||||
{ credentials: "same-origin" }
|
||||
);
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
infoEl.innerHTML = fmtMarketInfo(null, j.msg || "加载失败");
|
||||
marketCache[prefix] = null;
|
||||
return null;
|
||||
}
|
||||
marketCache[prefix] = j.data;
|
||||
marketCache[cacheKey] = j.data;
|
||||
infoEl.innerHTML = fmtMarketInfo(j.data, null);
|
||||
applyMarketSteps(prefix, j.data);
|
||||
return j.data;
|
||||
} catch (err) {
|
||||
infoEl.innerHTML = fmtMarketInfo(null, String(err));
|
||||
marketCache[prefix] = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fillExchangeSelect(selectEl, selectedId) {
|
||||
if (!selectEl) return;
|
||||
selectEl.innerHTML = "";
|
||||
if (!calculatorExchanges.length) {
|
||||
selectEl.innerHTML = '<option value="">无已启用交易所</option>';
|
||||
return;
|
||||
}
|
||||
calculatorExchanges.forEach(function (ex) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(ex.id);
|
||||
opt.textContent = ex.name || ex.key || ex.id;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
const want = selectedId != null ? String(selectedId) : String(calculatorExchanges[0].id);
|
||||
if ([].some.call(selectEl.options, function (o) { return o.value === want; })) {
|
||||
selectEl.value = want;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCalculatorExchanges() {
|
||||
try {
|
||||
const r = await fetch("/api/calculator/exchanges", { credentials: "same-origin" });
|
||||
const j = await r.json();
|
||||
calculatorExchanges = (j.ok && j.data) || [];
|
||||
} catch (_err) {
|
||||
calculatorExchanges = [];
|
||||
}
|
||||
fillExchangeSelect($("calc-trend-exchange"));
|
||||
fillExchangeSelect($("calc-roll-exchange"));
|
||||
}
|
||||
|
||||
function fmtRefreshTime() {
|
||||
const d = new Date();
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
const s = String(d.getSeconds()).padStart(2, "0");
|
||||
return h + ":" + m + ":" + s;
|
||||
}
|
||||
|
||||
async function refreshPage() {
|
||||
const btn = $("calc-btn-refresh");
|
||||
const status = $("calc-refresh-status");
|
||||
const trendId = $("calc-trend-exchange") && $("calc-trend-exchange").value;
|
||||
const rollId = $("calc-roll-exchange") && $("calc-roll-exchange").value;
|
||||
if (btn) btn.disabled = true;
|
||||
if (status) status.textContent = "刷新中…";
|
||||
Object.keys(marketCache).forEach(function (k) {
|
||||
delete marketCache[k];
|
||||
});
|
||||
try {
|
||||
await loadCalculatorExchanges();
|
||||
fillExchangeSelect($("calc-trend-exchange"), trendId);
|
||||
fillExchangeSelect($("calc-roll-exchange"), rollId);
|
||||
await Promise.all([refreshMarket("calc-trend"), refreshMarket("calc-roll")]);
|
||||
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = "刷新失败";
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindMarket(prefix) {
|
||||
const exchangeEl = $(prefix + "-exchange");
|
||||
const baseEl = $(prefix + "-base");
|
||||
if (!exchangeEl || !baseEl) return;
|
||||
const run = function () {
|
||||
void refreshMarket(prefix);
|
||||
};
|
||||
if (!exchangeEl._calcMarketBound) {
|
||||
exchangeEl._calcMarketBound = true;
|
||||
exchangeEl.addEventListener("change", run);
|
||||
}
|
||||
if (!baseEl._calcMarketBound) {
|
||||
baseEl._calcMarketBound = true;
|
||||
baseEl.addEventListener("change", run);
|
||||
baseEl.addEventListener("blur", run);
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
function syncTrendAddLabel() {
|
||||
const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long";
|
||||
const lab = $("calc-trend-add-label");
|
||||
if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价";
|
||||
}
|
||||
|
||||
function renderTrendTable(rows, dec) {
|
||||
if (!rows || !rows.length) {
|
||||
return '<p class="calc-empty">无档位数据</p>';
|
||||
}
|
||||
const px = dec.price != null ? dec.price : 4;
|
||||
const amt = dec.amount != null ? dec.amount : 4;
|
||||
let html =
|
||||
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
|
||||
"<th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利</th><th>止损金额</th><th>盈亏比</th>" +
|
||||
"</tr></thead><tbody>";
|
||||
rows.forEach(function (r) {
|
||||
html +=
|
||||
"<tr>" +
|
||||
"<td>" +
|
||||
esc(r.label) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.price, px) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.contracts, amt) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.avg_entry, px) +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(r.profit_u) +
|
||||
'">' +
|
||||
fmtU(r.profit_u) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtU(r.risk_u) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
(r.rr != null ? fmt(r.rr, 2) + ":1" : "—") +
|
||||
"</td>" +
|
||||
"</tr>";
|
||||
});
|
||||
html += "</tbody></table></div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderTrendResult(data) {
|
||||
const box = $("calc-trend-result");
|
||||
if (!box) return;
|
||||
const dec = decimalsFromMarket(data);
|
||||
box.classList.remove("hidden");
|
||||
box.innerHTML =
|
||||
'<div class="calc-summary">' +
|
||||
"<div><span>合约</span><strong>" +
|
||||
esc((data.market && data.market.display_symbol) || "—") +
|
||||
"</strong></div>" +
|
||||
"<div><span>计划保证金</span><strong>" +
|
||||
fmt(data.plan_margin_u, 2) +
|
||||
"U</strong></div>" +
|
||||
"<div><span>止损预算</span><strong>" +
|
||||
fmt(data.risk_budget_u, 2) +
|
||||
"U</strong></div>" +
|
||||
"<div><span>总张数</span><strong>" +
|
||||
fmt(data.target_contracts, dec.amount) +
|
||||
"</strong></div>" +
|
||||
"<div><span>首仓张数</span><strong>" +
|
||||
fmt(data.first_contracts, dec.amount) +
|
||||
"</strong></div>" +
|
||||
'<div><span>首仓止盈盈利</span><strong class="' +
|
||||
pnlClass(data.first_profit_u) +
|
||||
'">' +
|
||||
fmtU(data.first_profit_u) +
|
||||
"</strong></div>" +
|
||||
"<div><span>首仓盈亏比</span><strong>" +
|
||||
(data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") +
|
||||
"</strong></div>" +
|
||||
"</div>" +
|
||||
renderTrendTable(data.rows, dec);
|
||||
}
|
||||
|
||||
function renderRollResult(data) {
|
||||
const box = $("calc-roll-result");
|
||||
if (!box) return;
|
||||
const dec = decimalsFromMarket(data);
|
||||
const px = dec.price != null ? dec.price : 4;
|
||||
const amt = dec.amount != null ? dec.amount : 4;
|
||||
box.classList.remove("hidden");
|
||||
let table =
|
||||
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
|
||||
"<th>阶段</th><th>入场/加仓价</th><th>统一止损</th><th>本次张数</th><th>累计张数</th><th>均价</th><th>打到止损总亏</th><th>止盈盈利</th><th>盈亏比</th>" +
|
||||
"</tr></thead><tbody>";
|
||||
(data.rows || []).forEach(function (r) {
|
||||
const tag = r.already_done ? ' <span class="calc-done-tag">已完成</span>' : "";
|
||||
table +=
|
||||
"<tr>" +
|
||||
"<td>" +
|
||||
esc(r.label) +
|
||||
tag +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.entry_or_add_price, px) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.stop_loss, px) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.add_contracts, amt) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.total_contracts, amt) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmt(r.avg_entry, px) +
|
||||
"</td>" +
|
||||
'<td class="calc-pnl-loss">' +
|
||||
fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(r.profit_at_tp_u) +
|
||||
'">' +
|
||||
fmtU(r.profit_at_tp_u) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
(r.rr != null ? fmt(r.rr, 2) + ":1" : "—") +
|
||||
"</td>" +
|
||||
"</tr>";
|
||||
});
|
||||
table += "</tbody></table></div>";
|
||||
box.innerHTML =
|
||||
'<div class="calc-summary">' +
|
||||
"<div><span>合约</span><strong>" +
|
||||
esc((data.market && data.market.display_symbol) || "—") +
|
||||
"</strong></div>" +
|
||||
"<div><span>单次风险预算</span><strong>" +
|
||||
fmt(data.risk_budget_u, 2) +
|
||||
"U</strong></div>" +
|
||||
"<div><span>首仓张数(自动)</span><strong>" +
|
||||
fmt(data.first_contracts, amt) +
|
||||
"</strong></div>" +
|
||||
"<div><span>最终累计张数</span><strong>" +
|
||||
fmt(data.final_contracts, amt) +
|
||||
"</strong></div>" +
|
||||
"<div><span>最终均价</span><strong>" +
|
||||
fmt(data.final_avg_entry, px) +
|
||||
"</strong></div>" +
|
||||
'<div><span>最终止盈盈利</span><strong class="' +
|
||||
pnlClass(data.final_profit_at_tp_u) +
|
||||
'">' +
|
||||
fmtU(data.final_profit_at_tp_u) +
|
||||
"</strong></div>" +
|
||||
"<div><span>最终盈亏比</span><strong>" +
|
||||
(data.final_rr != null ? fmt(data.final_rr, 2) + ":1" : "—") +
|
||||
"</strong></div>" +
|
||||
"</div>" +
|
||||
table;
|
||||
}
|
||||
|
||||
const MAX_ROLL_LEGS = 3;
|
||||
let rollLegCount = 0;
|
||||
|
||||
function maxRollLegsAllowed() {
|
||||
const done = num("calc-roll-legs-done") || 0;
|
||||
return Math.max(0, MAX_ROLL_LEGS - done);
|
||||
}
|
||||
|
||||
function syncRollAddBtn() {
|
||||
const btn = $("calc-roll-add-leg");
|
||||
if (!btn) return;
|
||||
btn.disabled = rollLegCount >= maxRollLegsAllowed();
|
||||
}
|
||||
|
||||
function rollLegRowHtml(index) {
|
||||
const step = (marketCache["calc-roll"] && marketCache["calc-roll"].price_tick) || "any";
|
||||
return (
|
||||
'<div class="calc-roll-leg" data-leg-index="' +
|
||||
index +
|
||||
'">' +
|
||||
'<div class="calc-roll-leg-title">滚仓 ' +
|
||||
index +
|
||||
"</div>" +
|
||||
'<div class="calc-roll-leg-grid">' +
|
||||
'<label class="calc-field"><span>加仓价</span><input type="number" class="calc-roll-leg-add" min="0" step="' +
|
||||
esc(step) +
|
||||
'" required /></label>' +
|
||||
'<label class="calc-field"><span>新统一止损</span><input type="number" class="calc-roll-leg-stop" min="0" step="' +
|
||||
esc(step) +
|
||||
'" required /></label>' +
|
||||
"</div>" +
|
||||
'<button type="button" class="ghost danger calc-roll-leg-remove">删除</button>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function renumberRollLegs() {
|
||||
const list = $("calc-roll-legs-list");
|
||||
if (!list) return;
|
||||
const rows = list.querySelectorAll(".calc-roll-leg");
|
||||
rollLegCount = rows.length;
|
||||
rows.forEach(function (row, i) {
|
||||
row.setAttribute("data-leg-index", String(i + 1));
|
||||
const title = row.querySelector(".calc-roll-leg-title");
|
||||
if (title) title.textContent = "滚仓 " + (i + 1);
|
||||
});
|
||||
syncRollAddBtn();
|
||||
}
|
||||
|
||||
function addRollLegRow() {
|
||||
if (rollLegCount >= maxRollLegsAllowed()) return;
|
||||
const list = $("calc-roll-legs-list");
|
||||
if (!list) return;
|
||||
list.insertAdjacentHTML("beforeend", rollLegRowHtml(rollLegCount + 1));
|
||||
rollLegCount += 1;
|
||||
syncRollAddBtn();
|
||||
}
|
||||
|
||||
function collectRollLegs() {
|
||||
const legs = [];
|
||||
document.querySelectorAll(".calc-roll-leg").forEach(function (row) {
|
||||
const addEl = row.querySelector(".calc-roll-leg-add");
|
||||
const stopEl = row.querySelector(".calc-roll-leg-stop");
|
||||
const ap = addEl && addEl.value !== "" ? Number(addEl.value) : null;
|
||||
const sl = stopEl && stopEl.value !== "" ? Number(stopEl.value) : null;
|
||||
if (ap == null || sl == null || !Number.isFinite(ap) || !Number.isFinite(sl)) return;
|
||||
legs.push({ add_price: ap, new_stop_loss: sl });
|
||||
});
|
||||
return legs;
|
||||
}
|
||||
|
||||
function bindRollLegsUI() {
|
||||
const addBtn = $("calc-roll-add-leg");
|
||||
const list = $("calc-roll-legs-list");
|
||||
const doneInput = $("calc-roll-legs-done");
|
||||
if (addBtn && !addBtn._bound) {
|
||||
addBtn._bound = true;
|
||||
addBtn.addEventListener("click", addRollLegRow);
|
||||
}
|
||||
if (list && !list._bound) {
|
||||
list._bound = true;
|
||||
list.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest(".calc-roll-leg-remove");
|
||||
if (!btn) return;
|
||||
const row = btn.closest(".calc-roll-leg");
|
||||
if (row) row.remove();
|
||||
renumberRollLegs();
|
||||
});
|
||||
}
|
||||
if (doneInput && !doneInput._bound) {
|
||||
doneInput._bound = true;
|
||||
doneInput.addEventListener("change", function () {
|
||||
while (rollLegCount > maxRollLegsAllowed()) {
|
||||
const rows = list && list.querySelectorAll(".calc-roll-leg");
|
||||
if (rows && rows.length) rows[rows.length - 1].remove();
|
||||
rollLegCount = list ? list.querySelectorAll(".calc-roll-leg").length : 0;
|
||||
}
|
||||
syncRollAddBtn();
|
||||
});
|
||||
}
|
||||
syncRollAddBtn();
|
||||
}
|
||||
|
||||
function showErr(boxId, msg) {
|
||||
const box = $(boxId);
|
||||
if (!box) return;
|
||||
box.classList.remove("hidden");
|
||||
box.innerHTML = '<p class="calc-error">' + esc(msg || "计算失败") + "</p>";
|
||||
}
|
||||
|
||||
async function submitTrend(e) {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long",
|
||||
exchange_id: ($("calc-trend-exchange") && $("calc-trend-exchange").value) || "0",
|
||||
base: text("calc-trend-base") || "ETH",
|
||||
capital_usdt: num("calc-trend-capital"),
|
||||
risk_percent: num("calc-trend-risk"),
|
||||
leverage: num("calc-trend-leverage"),
|
||||
entry_price: num("calc-trend-entry"),
|
||||
stop_loss: num("calc-trend-sl"),
|
||||
add_upper: num("calc-trend-add-upper"),
|
||||
take_profit: num("calc-trend-tp"),
|
||||
dca_legs: num("calc-trend-dca-legs") || 5,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/calculator/trend", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
showErr("calc-trend-result", j.msg || "计算失败");
|
||||
return;
|
||||
}
|
||||
renderTrendResult(j.data);
|
||||
} catch (err) {
|
||||
showErr("calc-trend-result", String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoll(e) {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long",
|
||||
exchange_id: ($("calc-roll-exchange") && $("calc-roll-exchange").value) || "0",
|
||||
base: text("calc-roll-base") || "ETH",
|
||||
capital_usdt: num("calc-roll-capital"),
|
||||
risk_percent: num("calc-roll-risk"),
|
||||
entry_price: num("calc-roll-entry"),
|
||||
stop_loss: num("calc-roll-sl"),
|
||||
take_profit: num("calc-roll-tp"),
|
||||
add_legs: collectRollLegs(),
|
||||
legs_done: num("calc-roll-legs-done") || 0,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/calculator/roll", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
showErr("calc-roll-result", j.msg || "计算失败");
|
||||
return;
|
||||
}
|
||||
renderRollResult(j.data);
|
||||
} catch (err) {
|
||||
showErr("calc-roll-result", String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function applyCalcTab(tab) {
|
||||
const t = tab === "roll" ? "roll" : "trend";
|
||||
const layout = page.querySelector(".calc-layout");
|
||||
if (layout) layout.setAttribute("data-calc-tab", t);
|
||||
page.querySelectorAll(".calc-m-tab").forEach(function (btn) {
|
||||
const on = (btn.getAttribute("data-calc-tab") || "") === t;
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
try {
|
||||
sessionStorage.setItem("hub_calc_tab", t);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function bindCalcTabs() {
|
||||
page.querySelectorAll(".calc-m-tab").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
applyCalcTab(btn.getAttribute("data-calc-tab") || "trend");
|
||||
});
|
||||
});
|
||||
let saved = "trend";
|
||||
try {
|
||||
saved = sessionStorage.getItem("hub_calc_tab") || "trend";
|
||||
} catch (e) {
|
||||
saved = "trend";
|
||||
}
|
||||
applyCalcTab(saved);
|
||||
}
|
||||
|
||||
async function bindOnce() {
|
||||
if (inited) return;
|
||||
inited = true;
|
||||
await loadCalculatorExchanges();
|
||||
const trendForm = $("calc-trend-form");
|
||||
const rollForm = $("calc-roll-form");
|
||||
const dirSel = $("calc-trend-direction");
|
||||
if (trendForm) trendForm.addEventListener("submit", submitTrend);
|
||||
if (rollForm) rollForm.addEventListener("submit", submitRoll);
|
||||
if (dirSel) {
|
||||
dirSel.addEventListener("change", syncTrendAddLabel);
|
||||
syncTrendAddLabel();
|
||||
}
|
||||
bindRollLegsUI();
|
||||
bindMarket("calc-trend");
|
||||
bindMarket("calc-roll");
|
||||
bindCalcTabs();
|
||||
const refreshBtn = $("calc-btn-refresh");
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", function () {
|
||||
void refreshPage();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.hubCalculatorPage = {
|
||||
init: function () {
|
||||
if (inited) {
|
||||
void refreshPage();
|
||||
return;
|
||||
}
|
||||
void bindOnce();
|
||||
},
|
||||
refresh: refreshPage,
|
||||
destroy: function () {},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,730 @@
|
||||
/* 数据看板 — 随中控亮/暗主题,卡片柔光 */
|
||||
body.hub-page-dashboard {
|
||||
--dash-card-bg: var(--panel);
|
||||
--dash-card-border: var(--border-soft);
|
||||
--dash-card-glow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--dash-section-bg: var(--panel);
|
||||
--dash-muted: var(--muted);
|
||||
--dash-text: var(--text);
|
||||
--dash-accent: var(--accent);
|
||||
--dash-ok: var(--green);
|
||||
--dash-warn: var(--red);
|
||||
}
|
||||
|
||||
html[data-theme="light"] body.hub-page-dashboard {
|
||||
--dash-card-glow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.04),
|
||||
0 8px 24px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] body.hub-page-dashboard {
|
||||
--dash-card-glow:
|
||||
0 4px 18px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.hub-page-dashboard .page#page-dashboard {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dash-bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 85% 65% at 50% 0%, #000 15%, transparent 72%);
|
||||
}
|
||||
|
||||
.dash-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.dash-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dash-head h1 {
|
||||
font-size: clamp(1.35rem, 2.5vw, 1.75rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
margin: 0;
|
||||
color: var(--dash-text);
|
||||
}
|
||||
|
||||
.dash-head-tag {
|
||||
display: inline-block;
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.65rem;
|
||||
color: var(--dash-accent);
|
||||
border: 1px solid var(--dash-card-border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.dash-head-meta {
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--dash-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dash-head-meta strong {
|
||||
color: var(--dash-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-pulse-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dash-ok);
|
||||
margin-right: 6px;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dash-ok) 25%, transparent);
|
||||
animation: dash-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dash-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.dash-kpi-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dash-kpi-summary {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
padding: 10px 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--dash-card-bg);
|
||||
border: 1px solid var(--dash-card-border);
|
||||
box-shadow: var(--dash-card-glow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-kpi-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
padding: 4px 8px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dash-kpi-item + .dash-kpi-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 18%;
|
||||
bottom: 18%;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--dash-card-border) 85%, transparent);
|
||||
}
|
||||
|
||||
.dash-kpi,
|
||||
.dash-section,
|
||||
.dash-ac-card {
|
||||
box-shadow: var(--dash-card-glow);
|
||||
}
|
||||
|
||||
.dash-kpi {
|
||||
position: relative;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--dash-card-bg);
|
||||
border: 1px solid var(--dash-card-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-kpi-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--dash-muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dash-kpi-value {
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--dash-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dash-kpi-value.pos {
|
||||
color: var(--dash-ok);
|
||||
}
|
||||
|
||||
.dash-kpi-value.neg {
|
||||
color: var(--dash-warn);
|
||||
}
|
||||
|
||||
.dash-kpi-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--dash-muted);
|
||||
}
|
||||
|
||||
.dash-alert-banner {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--dash-warn) 45%, var(--dash-card-border));
|
||||
background: color-mix(in srgb, var(--dash-warn) 8%, var(--dash-card-bg));
|
||||
font-size: 0.85rem;
|
||||
box-shadow: var(--dash-card-glow);
|
||||
}
|
||||
|
||||
.dash-alert-banner.is-on {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dash-alert-banner strong {
|
||||
color: var(--dash-warn);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dash-section {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--dash-card-border);
|
||||
background: var(--dash-section-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--dash-card-border);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dash-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-section-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dash-ac-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dash-ac-card {
|
||||
position: relative;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--dash-card-border);
|
||||
background: var(--dash-card-bg);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.dash-ac-card.is-alert {
|
||||
border-color: color-mix(in srgb, var(--dash-warn) 55%, var(--dash-card-border));
|
||||
box-shadow:
|
||||
var(--dash-card-glow),
|
||||
0 0 0 1px color-mix(in srgb, var(--dash-warn) 18%, transparent);
|
||||
}
|
||||
|
||||
.dash-ac-card.is-unmon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dash-ac-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dash-ac-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: var(--dash-text);
|
||||
}
|
||||
|
||||
.dash-ac-top-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-ac-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-ac-badge.alert {
|
||||
color: #fff;
|
||||
background: var(--dash-warn);
|
||||
}
|
||||
|
||||
.dash-ac-badge.ok {
|
||||
color: var(--dash-accent);
|
||||
border: 1px solid var(--dash-card-border);
|
||||
background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg));
|
||||
}
|
||||
|
||||
.dash-ac-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 12px;
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.dash-ac-metrics-3col {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dash-ac-section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dash-accent);
|
||||
margin-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent);
|
||||
}
|
||||
|
||||
.dash-ac-section-label:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dash-ac-metric-empty {
|
||||
visibility: hidden;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dash-ac-total-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dash-card-border);
|
||||
background: color-mix(in srgb, var(--dash-accent) 6%, var(--dash-card-bg));
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.dash-ac-total-row span {
|
||||
color: var(--dash-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.dash-ac-total-row strong {
|
||||
color: var(--dash-text);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.dash-ac-card-options .dash-ac-remark {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent);
|
||||
}
|
||||
|
||||
.dash-options-block {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dash-options-block .dash-ac-section-label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dash-options-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dash-options-table th,
|
||||
.dash-options-table td {
|
||||
font-size: 0.68rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-ac-card-pos-only {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash-ac-pos-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dash-pos-block .dash-ac-section-label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dash-pos-source {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dash-pos-source.is-hedge {
|
||||
color: #fbbf24;
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.dash-pos-source.is-roll {
|
||||
color: #6ee7b7;
|
||||
background: rgba(16, 185, 129, 0.16);
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.dash-pos-source.is-trend {
|
||||
color: #93c5fd;
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.dash-pos-source.is-order {
|
||||
color: #c4b5fd;
|
||||
background: rgba(139, 92, 246, 0.18);
|
||||
border: 1px solid rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
|
||||
.dash-pos-source.is-key {
|
||||
color: #fdba74;
|
||||
background: rgba(249, 115, 22, 0.16);
|
||||
border: 1px solid rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.dash-pos-source.is-none {
|
||||
color: var(--dash-muted);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
}
|
||||
|
||||
.dash-target-monitor {
|
||||
color: var(--dash-muted);
|
||||
}
|
||||
|
||||
.dash-target-monitor.is-on {
|
||||
color: #4ade80;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-pos-source.is-perp {
|
||||
color: #93c5fd;
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.dash-pos-source.is-opt {
|
||||
color: #c4b5fd;
|
||||
background: rgba(139, 92, 246, 0.18);
|
||||
border: 1px solid rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
|
||||
.dash-pos-table th,
|
||||
.dash-pos-table td {
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-ac-metrics-3col .dash-ac-metric {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dash-ac-metrics-3col .dash-ac-metric span,
|
||||
.dash-ac-metrics-3col .dash-ac-metric strong {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dash-ac-metric span {
|
||||
display: block;
|
||||
color: var(--dash-muted);
|
||||
font-size: 0.65rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.dash-ac-metric strong {
|
||||
color: var(--dash-text);
|
||||
}
|
||||
|
||||
.dash-ac-metric strong.pos {
|
||||
color: var(--dash-ok);
|
||||
}
|
||||
|
||||
.dash-ac-metric strong.neg {
|
||||
color: var(--dash-warn);
|
||||
}
|
||||
|
||||
.dash-loss-bar {
|
||||
margin-top: 10px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--dash-muted) 18%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-loss-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--dash-warn);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.dash-ac-remark {
|
||||
margin-top: 10px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--dash-muted);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dash-ac-monitor-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dash-monitor-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-monitor-chip.dash-monitor-key {
|
||||
color: #b8a0ff;
|
||||
background: rgba(123, 97, 255, 0.18);
|
||||
border-color: rgba(123, 97, 255, 0.42);
|
||||
}
|
||||
|
||||
.dash-monitor-chip.dash-monitor-order {
|
||||
color: var(--dash-accent);
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
border-color: rgba(0, 212, 255, 0.38);
|
||||
}
|
||||
|
||||
.dash-monitor-chip.dash-monitor-trend {
|
||||
color: var(--dash-ok);
|
||||
background: rgba(0, 255, 157, 0.1);
|
||||
border-color: rgba(0, 255, 157, 0.38);
|
||||
}
|
||||
|
||||
.dash-monitor-chip.dash-monitor-roll {
|
||||
color: #ffb020;
|
||||
background: rgba(255, 176, 32, 0.14);
|
||||
border-color: rgba(255, 176, 32, 0.42);
|
||||
}
|
||||
|
||||
.dash-ac-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--dash-card-border);
|
||||
background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg));
|
||||
color: var(--dash-accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-ac-expand-btn:hover {
|
||||
border-color: var(--dash-accent);
|
||||
background: color-mix(in srgb, var(--dash-accent) 14%, var(--dash-card-bg));
|
||||
}
|
||||
|
||||
.dash-ac-positions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dash-ac-remark-line {
|
||||
margin: 0;
|
||||
padding: 3px 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--dash-card-border) 65%, transparent);
|
||||
}
|
||||
|
||||
.dash-ac-remark-line:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.dash-ac-remark-mon {
|
||||
color: var(--dash-muted);
|
||||
}
|
||||
|
||||
.dash-ac-remark-pos {
|
||||
color: var(--dash-text);
|
||||
}
|
||||
|
||||
.dash-ac-remark-pos .pos,
|
||||
.dash-ac-remark-pos .neg {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-ac-remark-pos .pos {
|
||||
color: var(--dash-ok);
|
||||
}
|
||||
|
||||
.dash-ac-remark-pos .neg {
|
||||
color: var(--dash-warn);
|
||||
}
|
||||
|
||||
.dash-ac-remark-empty {
|
||||
color: var(--dash-muted);
|
||||
}
|
||||
|
||||
.dash-ac-remark-issue {
|
||||
color: var(--dash-warn);
|
||||
}
|
||||
|
||||
html[data-theme="light"] .dash-monitor-chip.dash-monitor-key {
|
||||
color: #5b4fc7;
|
||||
background: rgba(91, 79, 199, 0.1);
|
||||
border-color: rgba(91, 79, 199, 0.28);
|
||||
}
|
||||
|
||||
html[data-theme="light"] .dash-monitor-chip.dash-monitor-trend {
|
||||
background: rgba(10, 143, 92, 0.1);
|
||||
border-color: rgba(10, 143, 92, 0.28);
|
||||
}
|
||||
|
||||
.dash-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: min(52vh, 480px);
|
||||
}
|
||||
|
||||
.dash-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.dash-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
background: var(--inset-surface);
|
||||
color: var(--dash-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--dash-card-border);
|
||||
}
|
||||
|
||||
.dash-table td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--dash-card-border);
|
||||
color: var(--dash-text);
|
||||
}
|
||||
|
||||
.dash-table tr:hover td {
|
||||
background: color-mix(in srgb, var(--dash-accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.dash-table tr.is-alert-row td {
|
||||
background: color-mix(in srgb, var(--dash-warn) 10%, transparent);
|
||||
}
|
||||
|
||||
.dash-table .pos {
|
||||
color: var(--dash-ok);
|
||||
}
|
||||
|
||||
.dash-table .neg {
|
||||
color: var(--dash-warn);
|
||||
}
|
||||
|
||||
.dash-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--dash-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dash-status {
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--dash-muted);
|
||||
}
|
||||
|
||||
.dash-status.err {
|
||||
color: var(--dash-warn);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dash-ac-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dash-head-meta {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁).
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-dashboard");
|
||||
if (!page) return;
|
||||
|
||||
let dashEventSource = null;
|
||||
let dashReconnectTimer = null;
|
||||
let localDashVersion = 0;
|
||||
let inited = false;
|
||||
let loading = false;
|
||||
|
||||
const elStatus = document.getElementById("dash-status");
|
||||
const elBanner = document.getElementById("dash-alert-banner");
|
||||
const elBannerText = document.getElementById("dash-alert-banner-text");
|
||||
const elKpi = document.getElementById("dash-kpi-row");
|
||||
const elAccounts = document.getElementById("dash-accounts");
|
||||
const elTrades = document.getElementById("dash-trades-body");
|
||||
const elUpdated = document.getElementById("dash-updated-at");
|
||||
const elDay = document.getElementById("dash-trading-day");
|
||||
const btnRefresh = document.getElementById("dash-btn-refresh");
|
||||
|
||||
function fmt(n, d) {
|
||||
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
|
||||
return Number(n).toFixed(d == null ? 2 : d);
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
|
||||
return n > 0 ? "pos" : "neg";
|
||||
}
|
||||
|
||||
function pnlSigned(v, digits) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
const abs = fmt(Math.abs(n), digits);
|
||||
if (Math.abs(n) < 1e-9) return `${abs}U`;
|
||||
return `${n > 0 ? "+" : "-"}${abs}U`;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
if (!elStatus) return;
|
||||
elStatus.textContent = msg || "";
|
||||
elStatus.className = "dash-status" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function renderKpi(totals) {
|
||||
if (!elKpi || !totals) return;
|
||||
const closed = Number(totals.total_pnl_u);
|
||||
const floating = Number(totals.float_pnl_u);
|
||||
const funding = totals.total_funding_usdt;
|
||||
const trading = totals.total_trading_usdt;
|
||||
const funds =
|
||||
funding != null && trading != null ? Number(funding) + Number(trading) : NaN;
|
||||
const totalPos = Number(totals.open_position_count) || 0;
|
||||
const optPos = Number(totals.options_open_position_count) || 0;
|
||||
const perpPos =
|
||||
totals.perpetual_open_position_count != null
|
||||
? Number(totals.perpetual_open_position_count) || 0
|
||||
: Math.max(0, totalPos - optPos);
|
||||
const items = [
|
||||
kpiItem("交易日", esc(totals.trading_day || "—")),
|
||||
kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"),
|
||||
kpiItem("总持仓数量", `${totalPos}`),
|
||||
kpiItem("期权持仓", `${optPos}`),
|
||||
kpiItem("永续持仓", `${perpPos}`),
|
||||
kpiItem("平仓数量", `${totals.closed_count || 0}`),
|
||||
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)),
|
||||
kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)),
|
||||
];
|
||||
elKpi.innerHTML = `<div class="dash-kpi-summary">${items.join("")}</div>`;
|
||||
}
|
||||
|
||||
function kpiItem(label, value, valCls) {
|
||||
return `<div class="dash-kpi-item">
|
||||
<div class="dash-kpi-label">${esc(label)}</div>
|
||||
<div class="dash-kpi-value ${valCls || ""}">${value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMonitorCountChips(counts) {
|
||||
const mc = counts || {};
|
||||
const chips = [];
|
||||
const keys = Number(mc.keys) || 0;
|
||||
const orders = Number(mc.orders) || 0;
|
||||
const trends = Number(mc.trends) || 0;
|
||||
const rolls = Number(mc.rolls) || 0;
|
||||
if (keys > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-key">关键位 ${keys}</span>`);
|
||||
if (orders > 0) {
|
||||
chips.push(`<span class="dash-monitor-chip dash-monitor-order">下单监控 ${orders}</span>`);
|
||||
}
|
||||
if (trends > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-trend">趋势回调 ${trends}</span>`);
|
||||
if (rolls > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-roll">顺势加仓 ${rolls}</span>`);
|
||||
return chips;
|
||||
}
|
||||
|
||||
function dashOptionsExpiryCd(expMs) {
|
||||
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||
if (!ms) return "—";
|
||||
return `<span class="opt-expiry-cd" data-opt-exp-ms="${esc(ms)}">—</span>`;
|
||||
}
|
||||
|
||||
function shortDashInst(instId) {
|
||||
const s = String(instId || "");
|
||||
if (s.length <= 18) return s;
|
||||
return s.slice(0, 8) + "…" + s.slice(-6);
|
||||
}
|
||||
|
||||
function accountPerpLines(ac) {
|
||||
const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : [];
|
||||
if (ac && ac.options_layout) {
|
||||
return positions.filter((ln) => (ln && ln.kind) !== "options");
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function accountHasOpenPositions(ac) {
|
||||
const perp = accountPerpLines(ac);
|
||||
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
|
||||
return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0);
|
||||
}
|
||||
|
||||
function sourceBadgeClass(source) {
|
||||
const s = String(source || "");
|
||||
if (s.indexOf("对冲") >= 0) return "is-hedge";
|
||||
if (s.indexOf("纯期权") >= 0 || s === "期权") return "is-opt";
|
||||
if (s.indexOf("顺势") >= 0) return "is-roll";
|
||||
if (s.indexOf("趋势") >= 0) return "is-trend";
|
||||
if (s.indexOf("关键位") >= 0) return "is-key";
|
||||
if (s.indexOf("下单") >= 0) return "is-order";
|
||||
return "is-none";
|
||||
}
|
||||
|
||||
function renderDashboardPerpTable(lines) {
|
||||
const rows = Array.isArray(lines) ? lines : [];
|
||||
if (!rows.length) return "";
|
||||
const body = rows
|
||||
.map((ln) => {
|
||||
const source = String((ln && ln.source) || "—");
|
||||
const symbol = esc((ln && (ln.symbol || ln.text)) || "—");
|
||||
const side = esc((ln && ln.side) || "—");
|
||||
const contracts =
|
||||
ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—";
|
||||
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
|
||||
return `<tr>
|
||||
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
|
||||
<td>${symbol}</td>
|
||||
<td>${side}</td>
|
||||
<td>${contracts}</td>
|
||||
<td class="${pnlClass(pnl)}">${Number.isFinite(pnl) ? pnlSigned(pnl, 2) : "—"}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="dash-pos-block">
|
||||
<div class="dash-ac-section-label">永续持仓</div>
|
||||
<div class="dash-table-wrap">
|
||||
<table class="dash-table dash-pos-table">
|
||||
<thead><tr>
|
||||
<th>来源</th><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th>
|
||||
</tr></thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function optionsNetPnl(p) {
|
||||
if (!p || typeof p !== "object") return null;
|
||||
if (p.net_pnl != null && Number.isFinite(Number(p.net_pnl))) return Number(p.net_pnl);
|
||||
const preview = p.close_preview || {};
|
||||
if (preview.estimated_pnl != null && Number.isFinite(Number(preview.estimated_pnl))) {
|
||||
return Number(preview.estimated_pnl);
|
||||
}
|
||||
if (preview.total_received != null && p.premium_paid != null) {
|
||||
const n = Number(preview.total_received) - Number(p.premium_paid);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderDashboardOptionsTable(positions) {
|
||||
const pos = Array.isArray(positions) ? positions : [];
|
||||
if (!pos.length) return "";
|
||||
const rows = pos
|
||||
.map((p) => {
|
||||
const optType =
|
||||
(p.opt_type || "").toUpperCase() === "C"
|
||||
? "Call"
|
||||
: (p.opt_type || "").toUpperCase() === "P"
|
||||
? "Put"
|
||||
: p.opt_type || "—";
|
||||
const source = String(p.source_label || p.source || "纯期权");
|
||||
const target = String(p.target_monitor_text || "—");
|
||||
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
|
||||
const net = optionsNetPnl(p);
|
||||
return `<tr>
|
||||
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
|
||||
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
||||
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
|
||||
<td><span class="${targetCls}">${esc(target)}</span></td>
|
||||
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="dash-pos-block dash-options-block">
|
||||
<div class="dash-ac-section-label">期权持仓</div>
|
||||
<div class="dash-table-wrap dash-options-table-wrap">
|
||||
<table class="dash-table dash-options-table">
|
||||
<thead><tr>
|
||||
<th>来源</th><th>合约</th><th>类型</th><th>到期倒计时</th><th>指数</th><th>目标监控</th><th>净盈亏</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAccountPositions(ac) {
|
||||
const perpLines = accountPerpLines(ac);
|
||||
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
|
||||
const issues = Array.isArray(ac && ac.issues) ? ac.issues : [];
|
||||
const chips = renderMonitorCountChips((ac && ac.monitor_counts) || {});
|
||||
const monitorRow = chips.length
|
||||
? `<div class="dash-ac-monitor-row">${chips.join("")}</div>`
|
||||
: "";
|
||||
const perpHtml = renderDashboardPerpTable(perpLines);
|
||||
const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : "";
|
||||
const issueHtml = issues
|
||||
.map((text) => `<div class="dash-ac-remark-line dash-ac-remark-issue">${esc(text)}</div>`)
|
||||
.join("");
|
||||
return `${monitorRow}${perpHtml}${optionsHtml}${issueHtml}`;
|
||||
}
|
||||
|
||||
function bindDashboardExpand() {
|
||||
if (!elAccounts) return;
|
||||
elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const id = btn.getAttribute("data-dash-ex-id");
|
||||
if (id && window.hubOpenMonitorExpand) window.hubOpenMonitorExpand(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAccounts(accounts, threshold) {
|
||||
const rows = (Array.isArray(accounts) ? accounts : []).filter(accountHasOpenPositions);
|
||||
if (!rows.length) {
|
||||
elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>';
|
||||
return;
|
||||
}
|
||||
elAccounts.innerHTML = rows
|
||||
.map((ac) => {
|
||||
const alert = !!ac.loss_alert;
|
||||
const unmon = !ac.monitored;
|
||||
const lossPct = Number(ac.daily_loss_pct);
|
||||
const barW =
|
||||
alert && Number.isFinite(lossPct)
|
||||
? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100)
|
||||
: 0;
|
||||
const badge = alert
|
||||
? `<span class="dash-ac-badge alert">单日亏损 ≥${threshold}%</span>`
|
||||
: `<span class="dash-ac-badge ok">${esc(ac.status || "—")}</span>`;
|
||||
const exId = ac && ac.id != null ? String(ac.id) : "";
|
||||
const expandBtn = exId
|
||||
? `<button type="button" class="dash-ac-expand-btn" data-dash-ex-id="${esc(exId)}" title="放大查看监控详情" aria-label="放大查看监控详情">` +
|
||||
`<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M15 3h6v6h-2V6.41l-7.29 7.3-1.42-1.42 7.3-7.29H15V3zM3 9h2v10h10v2H3V9z"/></svg>` +
|
||||
`</button>`
|
||||
: "";
|
||||
const lossBar =
|
||||
alert && barW > 0
|
||||
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>`
|
||||
: "";
|
||||
const cardCls = ac.options_layout ? " dash-ac-card-options" : "";
|
||||
return `<article class="dash-ac-card dash-ac-card-pos-only${cardCls}${alert ? " is-alert" : ""}${unmon ? " is-unmon" : ""}">
|
||||
<div class="dash-ac-top">
|
||||
<div class="dash-ac-name">${esc(ac.name || "—")}</div>
|
||||
<div class="dash-ac-top-actions">${badge}${expandBtn}</div>
|
||||
</div>
|
||||
${lossBar}
|
||||
<div class="dash-ac-pos-body">${renderAccountPositions(ac)}</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
bindDashboardExpand();
|
||||
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
|
||||
OptionsExpiryCountdown.ensureTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrades(trades, accounts) {
|
||||
if (!elTrades) return;
|
||||
const rows = Array.isArray(trades) ? trades : [];
|
||||
if (!rows.length) {
|
||||
elTrades.innerHTML = '<div class="dash-empty">今日暂无平仓</div>';
|
||||
return;
|
||||
}
|
||||
const alertNames = new Set(
|
||||
(accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || ""))
|
||||
);
|
||||
const body = rows
|
||||
.map((t) => {
|
||||
const pnl = Number(t.pnl_amount);
|
||||
const rowAlert = alertNames.has(String(t.account_name || ""));
|
||||
return `<tr class="${rowAlert ? "is-alert-row" : ""}">
|
||||
<td>${esc(t.trading_day || "—")}</td>
|
||||
<td>${esc(t.account_name || "—")}</td>
|
||||
<td>${esc(t.symbol || "—")}</td>
|
||||
<td>${esc(t.direction || "—")}</td>
|
||||
<td>${esc(t.result || "—")}</td>
|
||||
<td class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</td>
|
||||
<td>${esc(t.closed_at || "—")}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
elTrades.innerHTML = `<div class="dash-table-wrap"><table class="dash-table">
|
||||
<thead><tr>
|
||||
<th>交易日</th><th>账户</th><th>合约</th><th>方向</th><th>结果</th><th>盈亏</th><th>时间</th>
|
||||
</tr></thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
function renderPayload(data) {
|
||||
const totals = data.totals || {};
|
||||
const threshold = Number(data.loss_alert_pct_threshold) || 5;
|
||||
const alertCount = Number(data.loss_alert_count) || 0;
|
||||
if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—";
|
||||
if (elUpdated) elUpdated.textContent = data.updated_at || "—";
|
||||
renderKpi(totals);
|
||||
renderAccounts(data.accounts, threshold);
|
||||
renderTrades(data.closed_trades, data.accounts);
|
||||
if (elBanner && elBannerText) {
|
||||
if (alertCount > 0) {
|
||||
const names = (data.accounts || [])
|
||||
.filter((a) => a.loss_alert)
|
||||
.map((a) => a.name)
|
||||
.join(",");
|
||||
elBanner.classList.add("is-on");
|
||||
elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`;
|
||||
} else {
|
||||
elBanner.classList.remove("is-on");
|
||||
elBannerText.textContent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDashboardSnapshot(opts) {
|
||||
const options = opts || {};
|
||||
if (loading && !options.force) return;
|
||||
loading = true;
|
||||
if (!options.silent) setStatus("同步中…");
|
||||
try {
|
||||
const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" });
|
||||
if (r.status === 401) {
|
||||
location.href = "/login?next=" + encodeURIComponent(location.pathname);
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
if (!data.ok) throw new Error(data.detail || data.msg || data.error || "加载失败");
|
||||
const ver = Number(data.dashboard_version) || 0;
|
||||
if (ver) localDashVersion = ver;
|
||||
renderPayload(data);
|
||||
const sec = Number(data.poll_interval_sec) || 5;
|
||||
setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`);
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDashboardStream() {
|
||||
if (dashEventSource) {
|
||||
dashEventSource.close();
|
||||
dashEventSource = null;
|
||||
}
|
||||
if (dashReconnectTimer) {
|
||||
clearTimeout(dashReconnectTimer);
|
||||
dashReconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connectDashboardStream() {
|
||||
closeDashboardStream();
|
||||
dashEventSource = new EventSource("/api/dashboard/stream");
|
||||
dashEventSource.addEventListener("dashboard", (ev) => {
|
||||
try {
|
||||
const st = JSON.parse(ev.data || "{}");
|
||||
const ver = Number(st.dashboard_version) || 0;
|
||||
if (ver && ver !== localDashVersion) {
|
||||
void fetchDashboardSnapshot({ silent: true });
|
||||
} else if (st.aggregating) {
|
||||
setStatus("后台聚合中…");
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
dashEventSource.onerror = () => {
|
||||
closeDashboardStream();
|
||||
setStatus("SSE 断开,8s 后重连…", true);
|
||||
dashReconnectTimer = setTimeout(() => {
|
||||
if (inited) {
|
||||
connectDashboardStream();
|
||||
void fetchDashboardSnapshot({ silent: true });
|
||||
}
|
||||
}, 8000);
|
||||
};
|
||||
}
|
||||
|
||||
async function requestDashboardRefresh() {
|
||||
try {
|
||||
await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" });
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function startLive() {
|
||||
void fetchDashboardSnapshot();
|
||||
connectDashboardStream();
|
||||
}
|
||||
|
||||
function stopLive() {
|
||||
closeDashboardStream();
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
if (btnRefresh) {
|
||||
btnRefresh.addEventListener("click", () => {
|
||||
void requestDashboardRefresh();
|
||||
void fetchDashboardSnapshot({ force: true });
|
||||
});
|
||||
}
|
||||
|
||||
window.hubDashboardPage = {
|
||||
init() {
|
||||
inited = true;
|
||||
startLive();
|
||||
},
|
||||
destroy() {
|
||||
inited = false;
|
||||
stopLive();
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* 中控资金概况:总资金曲线,分户资金与回撤(资金户+交易户,不含浮盈).
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-funds");
|
||||
if (!page) return;
|
||||
|
||||
const elStatus = document.getElementById("funds-status");
|
||||
const elTotal = document.getElementById("funds-total-usdt");
|
||||
const elDdU = document.getElementById("funds-total-dd-u");
|
||||
const elDdPct = document.getElementById("funds-total-dd-pct");
|
||||
const elDelta = document.getElementById("funds-total-delta");
|
||||
const elPeriod = document.getElementById("funds-total-period");
|
||||
const elPeriodSub = document.getElementById("funds-total-period-sub");
|
||||
const elPeriodBanner = document.getElementById("funds-period-delta");
|
||||
const elPeriodPct = document.getElementById("funds-period-pct");
|
||||
const elDayChip = document.getElementById("funds-day-chip");
|
||||
const elPnlBanner = document.getElementById("funds-pnl-banner");
|
||||
const elMeta = document.getElementById("funds-meta");
|
||||
const elDescBody = document.getElementById("funds-desc-body");
|
||||
const elChartSub = document.getElementById("funds-chart-sub");
|
||||
const elChartHost = document.getElementById("funds-chart-total");
|
||||
const elAccounts = document.getElementById("funds-accounts");
|
||||
const elBtnRefresh = document.getElementById("funds-btn-refresh");
|
||||
|
||||
const elFs = document.getElementById("funds-fullscreen");
|
||||
const elFsBackdrop = document.getElementById("funds-fs-backdrop");
|
||||
const elFsClose = document.getElementById("funds-fs-close");
|
||||
const elFsTitle = document.getElementById("funds-fs-title");
|
||||
const elFsSub = document.getElementById("funds-fs-sub");
|
||||
const elFsTotal = document.getElementById("funds-fs-total");
|
||||
const elFsFunding = document.getElementById("funds-fs-funding");
|
||||
const elFsTrading = document.getElementById("funds-fs-trading");
|
||||
const elFsDelta = document.getElementById("funds-fs-delta");
|
||||
const elFsDd = document.getElementById("funds-fs-dd");
|
||||
const elFsChartHost = document.getElementById("funds-fs-chart");
|
||||
|
||||
let chart = null;
|
||||
let lineSeries = null;
|
||||
let fsChart = null;
|
||||
let fsLineSeries = null;
|
||||
let inited = false;
|
||||
let loading = false;
|
||||
let lastOverview = null;
|
||||
let fsAccountKey = "";
|
||||
|
||||
function fmt(n, d) {
|
||||
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
|
||||
return Number(n).toFixed(d == null ? 2 : d);
|
||||
}
|
||||
|
||||
function fmtDelta(n) {
|
||||
if (n == null || !Number.isFinite(Number(n))) return "—";
|
||||
const v = Number(n);
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return sign + v.toFixed(2) + " U";
|
||||
}
|
||||
|
||||
function fmtPct(n) {
|
||||
if (n == null || !Number.isFinite(Number(n))) return "—";
|
||||
const v = Number(n);
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return sign + v.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function deltaClass(n) {
|
||||
if (!Number.isFinite(Number(n))) return "";
|
||||
if (Number(n) > 0) return "pos";
|
||||
if (Number(n) < 0) return "neg";
|
||||
return "";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
if (!elStatus) return;
|
||||
elStatus.textContent = msg || "";
|
||||
elStatus.className = "funds-status" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function seriesToChartData(series) {
|
||||
return (series || [])
|
||||
.filter(function (p) {
|
||||
return p && p.day && Number.isFinite(Number(p.total_usdt));
|
||||
})
|
||||
.map(function (p) {
|
||||
return { time: String(p.day), value: Number(p.total_usdt) };
|
||||
});
|
||||
}
|
||||
|
||||
function destroyChart() {
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
lineSeries = null;
|
||||
}
|
||||
if (elChartHost) elChartHost.innerHTML = "";
|
||||
}
|
||||
|
||||
function destroyFsChart() {
|
||||
if (fsChart) {
|
||||
fsChart.remove();
|
||||
fsChart = null;
|
||||
fsLineSeries = null;
|
||||
}
|
||||
if (elFsChartHost) elFsChartHost.innerHTML = "";
|
||||
}
|
||||
|
||||
function chartPalette() {
|
||||
const light = document.documentElement.getAttribute("data-theme") === "light";
|
||||
return light
|
||||
? { bg: "#eef4fa", text: "#4a6078", border: "#c5d4e4", line: "#006e9a", top: "#006e9a44" }
|
||||
: { bg: "#060a14", text: "#6b8aa8", border: "#1a2840", line: "#00d4ff", top: "#00d4ff55" };
|
||||
}
|
||||
|
||||
function createAreaChart(host) {
|
||||
const p = chartPalette();
|
||||
const c = LightweightCharts.createChart(host, {
|
||||
layout: {
|
||||
background: { color: p.bg },
|
||||
textColor: p.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: p.border, visible: true },
|
||||
horzLines: { color: p.border, visible: true },
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: p.border,
|
||||
scaleMargins: { top: 0.08, bottom: 0.08 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: p.border,
|
||||
timeVisible: true,
|
||||
fixLeftEdge: true,
|
||||
fixRightEdge: true,
|
||||
},
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true },
|
||||
handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true },
|
||||
});
|
||||
const s = c.addAreaSeries({
|
||||
lineColor: p.line,
|
||||
topColor: p.top || p.line + "44",
|
||||
bottomColor: p.line + "08",
|
||||
lineWidth: 2,
|
||||
priceFormat: { type: "price", precision: 2, minMove: 0.01 },
|
||||
});
|
||||
function syncSize() {
|
||||
if (!c || !host) return;
|
||||
const w = Math.max(host.clientWidth || 0, 1);
|
||||
const h = Math.max(host.clientHeight || 0, 200);
|
||||
c.applyOptions({ width: w, height: h });
|
||||
}
|
||||
new ResizeObserver(function () {
|
||||
syncSize();
|
||||
}).observe(host);
|
||||
syncSize();
|
||||
return { chart: c, series: s };
|
||||
}
|
||||
|
||||
function ensureChart() {
|
||||
if (!elChartHost || !window.LightweightCharts) return;
|
||||
if (chart) return;
|
||||
const built = createAreaChart(elChartHost);
|
||||
chart = built.chart;
|
||||
lineSeries = built.series;
|
||||
}
|
||||
|
||||
function ensureFsChart() {
|
||||
if (!elFsChartHost || !window.LightweightCharts) return;
|
||||
if (fsChart) return;
|
||||
const built = createAreaChart(elFsChartHost);
|
||||
fsChart = built.chart;
|
||||
fsLineSeries = built.series;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function accountStatus(ac) {
|
||||
if (!ac || !ac.monitored) return { text: "未监控", cls: "" };
|
||||
if (ac.data_ok) return { text: "已监控", cls: "is-ok" };
|
||||
return { text: "余额未齐", cls: "" };
|
||||
}
|
||||
|
||||
function renderAccounts(accounts) {
|
||||
if (!elAccounts) return;
|
||||
if (!accounts || !accounts.length) {
|
||||
elAccounts.innerHTML = '<p class="funds-empty">暂无账户配置</p>';
|
||||
return;
|
||||
}
|
||||
elAccounts.innerHTML = accounts
|
||||
.map(function (ac) {
|
||||
const monitored = !!ac.monitored;
|
||||
const offCls = monitored ? "" : " is-off";
|
||||
const st = accountStatus(ac);
|
||||
const clickable = monitored ? "" : ' disabled aria-disabled="true"';
|
||||
const name = ac.name || ac.key || "—";
|
||||
const total =
|
||||
monitored && ac.data_ok ? fmt(ac.total_usdt, 2) + " U" : "—";
|
||||
const funding =
|
||||
monitored && ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—";
|
||||
const trading =
|
||||
monitored && ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—";
|
||||
const optFunding =
|
||||
monitored && ac.options_funding_usdt != null ? fmt(ac.options_funding_usdt, 2) + " U" : "";
|
||||
const optTrading =
|
||||
monitored && ac.options_trading_usdt != null ? fmt(ac.options_trading_usdt, 2) + " U" : "";
|
||||
const optLine =
|
||||
optFunding || optTrading
|
||||
? '<div><span class="k">期权户</span><span class="v">' +
|
||||
(optFunding || "—") +
|
||||
" / " +
|
||||
(optTrading || "—") +
|
||||
"</span></div>"
|
||||
: "";
|
||||
const dd = ac.drawdown || {};
|
||||
const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
|
||||
const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
|
||||
const deltaCls = deltaClass(ac.day_delta_usdt);
|
||||
const deltaText = monitored ? fmtDelta(ac.day_delta_usdt) : "—";
|
||||
const periodCls = deltaClass(ac.period_delta_usdt);
|
||||
const periodText = monitored ? fmtDelta(ac.period_delta_usdt) : "—";
|
||||
return (
|
||||
'<button type="button" class="funds-ac-card' +
|
||||
offCls +
|
||||
'" data-key="' +
|
||||
esc(ac.key || "") +
|
||||
'"' +
|
||||
clickable +
|
||||
' title="' +
|
||||
esc(monitored ? "点击查看 " + name + " 资金曲线" : "未监控,不参与合计") +
|
||||
'">' +
|
||||
'<div class="funds-ac-head">' +
|
||||
'<h3 class="funds-ac-name">' +
|
||||
esc(name) +
|
||||
"</h3>" +
|
||||
'<span class="funds-ac-badge ' +
|
||||
st.cls +
|
||||
'">' +
|
||||
st.text +
|
||||
"</span>" +
|
||||
"</div>" +
|
||||
'<div class="funds-ac-total">' +
|
||||
'<span class="k">总资金</span>' +
|
||||
'<span class="v">' +
|
||||
total +
|
||||
"</span>" +
|
||||
"</div>" +
|
||||
'<div class="funds-ac-stats">' +
|
||||
'<div><span class="k">资金户</span><span class="v">' +
|
||||
funding +
|
||||
"</span></div>" +
|
||||
'<div><span class="k">交易户</span><span class="v">' +
|
||||
trading +
|
||||
"</span></div>" +
|
||||
optLine +
|
||||
'<div><span class="k">累计盈亏</span><span class="v ' +
|
||||
periodCls +
|
||||
'">' +
|
||||
periodText +
|
||||
"</span></div>" +
|
||||
'<div><span class="k">较昨日</span><span class="v ' +
|
||||
deltaCls +
|
||||
'">' +
|
||||
deltaText +
|
||||
"</span></div>" +
|
||||
'<div><span class="k">最大回撤</span><span class="v">' +
|
||||
ddU +
|
||||
" / " +
|
||||
ddPct +
|
||||
"</span></div>" +
|
||||
"</div>" +
|
||||
(monitored
|
||||
? '<div class="funds-ac-foot">点击查看资金曲线</div>'
|
||||
: "") +
|
||||
"</button>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
elAccounts.querySelectorAll(".funds-ac-card:not(.is-off)").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
openAccountFullscreen(btn.getAttribute("data-key"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findAccount(key) {
|
||||
const accounts = (lastOverview && lastOverview.accounts) || [];
|
||||
return accounts.find(function (ac) {
|
||||
return String(ac.key || "") === String(key || "");
|
||||
});
|
||||
}
|
||||
|
||||
function closeAccountFullscreen() {
|
||||
fsAccountKey = "";
|
||||
destroyFsChart();
|
||||
if (elFs) {
|
||||
elFs.classList.add("hidden");
|
||||
elFs.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
document.body.classList.remove("funds-fullscreen-open");
|
||||
}
|
||||
|
||||
function openAccountFullscreen(key) {
|
||||
const ac = findAccount(key);
|
||||
if (!ac || !ac.monitored) return;
|
||||
fsAccountKey = String(key || "");
|
||||
const dd = ac.drawdown || {};
|
||||
const meta = lastOverview || {};
|
||||
if (elFsTitle) elFsTitle.textContent = ac.name || ac.key || "—";
|
||||
if (elFsSub) {
|
||||
const parts = [
|
||||
"资金户 + 交易户 + 期权户(USDC≈USDT,不含浮盈)",
|
||||
"交易日 " + (meta.trading_day || "—"),
|
||||
"自 " + (meta.history_start_day || "—") + " 起",
|
||||
];
|
||||
elFsSub.textContent = parts.join(" · ");
|
||||
}
|
||||
if (elFsTotal) {
|
||||
elFsTotal.textContent =
|
||||
ac.data_ok && ac.total_usdt != null ? fmt(ac.total_usdt, 2) + " U" : "—";
|
||||
}
|
||||
if (elFsFunding) {
|
||||
elFsFunding.textContent =
|
||||
ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—";
|
||||
}
|
||||
if (elFsTrading) {
|
||||
elFsTrading.textContent =
|
||||
ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—";
|
||||
}
|
||||
if (elFsDelta) {
|
||||
elFsDelta.textContent = fmtDelta(ac.day_delta_usdt);
|
||||
elFsDelta.className = "v " + deltaClass(ac.day_delta_usdt);
|
||||
}
|
||||
if (elFsDd) {
|
||||
const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
|
||||
const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
|
||||
elFsDd.textContent = ddU + " / " + ddPct;
|
||||
}
|
||||
if (elFs) {
|
||||
elFs.classList.remove("hidden");
|
||||
elFs.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("funds-fullscreen-open");
|
||||
}
|
||||
destroyFsChart();
|
||||
const pts = seriesToChartData(ac.series || []);
|
||||
if (pts.length) {
|
||||
ensureFsChart();
|
||||
if (fsLineSeries) {
|
||||
fsLineSeries.setData(pts);
|
||||
fsChart.timeScale().fitContent();
|
||||
}
|
||||
requestAnimationFrame(function () {
|
||||
if (fsChart && elFsChartHost) {
|
||||
fsChart.applyOptions({
|
||||
width: elFsChartHost.clientWidth,
|
||||
height: elFsChartHost.clientHeight,
|
||||
});
|
||||
fsChart.timeScale().fitContent();
|
||||
}
|
||||
});
|
||||
} else if (elFsChartHost) {
|
||||
elFsChartHost.innerHTML =
|
||||
'<p class="funds-empty">暂无历史曲线,请保持监控板运行以积累快照</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDesc(data) {
|
||||
const start = (data && data.history_start_day) || "—";
|
||||
const keep = (data && data.keep_days) || 180;
|
||||
const hour = data && data.reset_hour != null ? data.reset_hour : 8;
|
||||
if (elDescBody) {
|
||||
elDescBody.textContent =
|
||||
"总资金 = 各监控户(永续资金账户 + 交易账户 + 期权账户,USDC 按 1:1 计入 USDT);自 " +
|
||||
start +
|
||||
" 起按北京时间 " +
|
||||
hour +
|
||||
":00 交易日切日快照,最多保留 " +
|
||||
keep +
|
||||
" 天.起算日由环境变量 HUB_FUND_HISTORY_START_DAY 配置.";
|
||||
}
|
||||
if (elChartSub) {
|
||||
elChartSub.textContent = keep + " TRADING DAYS";
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverview(data) {
|
||||
lastOverview = data;
|
||||
renderDesc(data);
|
||||
const totals = data.totals || {};
|
||||
const dd = totals.drawdown || {};
|
||||
if (elTotal) {
|
||||
elTotal.textContent =
|
||||
totals.total_usdt != null ? fmt(totals.total_usdt, 2) + " U" : "—";
|
||||
}
|
||||
if (elDdU) elDdU.textContent = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
|
||||
if (elDdPct) {
|
||||
elDdPct.textContent = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
|
||||
}
|
||||
if (elDelta) {
|
||||
elDelta.textContent = fmtDelta(totals.day_delta_usdt);
|
||||
elDelta.className = "funds-stat-val " + deltaClass(totals.day_delta_usdt);
|
||||
}
|
||||
const periodCls = deltaClass(totals.period_delta_usdt);
|
||||
if (elPeriod) {
|
||||
elPeriod.textContent = fmtDelta(totals.period_delta_usdt);
|
||||
elPeriod.className = "funds-stat-val " + periodCls;
|
||||
}
|
||||
if (elPeriodSub) {
|
||||
const startDay = data.history_start_day || "—";
|
||||
const pct = fmtPct(totals.period_delta_pct);
|
||||
elPeriodSub.textContent =
|
||||
pct !== "—"
|
||||
? "自 " + startDay + " · " + pct
|
||||
: "自 " + startDay + " 起相对起点";
|
||||
}
|
||||
if (elPeriodBanner) {
|
||||
elPeriodBanner.textContent = fmtDelta(totals.period_delta_usdt);
|
||||
elPeriodBanner.className = "funds-pnl-value " + periodCls;
|
||||
}
|
||||
if (elPeriodPct) {
|
||||
elPeriodPct.textContent = fmtPct(totals.period_delta_pct);
|
||||
elPeriodPct.className = "funds-pnl-pct " + periodCls;
|
||||
}
|
||||
if (elDayChip) {
|
||||
elDayChip.textContent = fmtDelta(totals.day_delta_usdt);
|
||||
elDayChip.className = "funds-pnl-side-val " + deltaClass(totals.day_delta_usdt);
|
||||
}
|
||||
if (elPnlBanner) {
|
||||
elPnlBanner.className =
|
||||
"funds-pnl-banner" + (periodCls ? " is-" + periodCls : "");
|
||||
}
|
||||
if (elMeta) {
|
||||
const parts = [
|
||||
"交易日 " + (data.trading_day || "—"),
|
||||
"切日 " + (data.reset_hour != null ? data.reset_hour : 8) + ":00 北京",
|
||||
"自 " + (data.history_start_day || "—") + " 起",
|
||||
"最多 " + (data.keep_days || 180) + " 交易日",
|
||||
];
|
||||
if (data.updated_at) parts.push("刷新 " + data.updated_at);
|
||||
if (totals.live_known_count != null) {
|
||||
parts.push("合计含 " + totals.live_known_count + " 户");
|
||||
}
|
||||
elMeta.textContent = parts.join(" · ");
|
||||
}
|
||||
ensureChart();
|
||||
if (lineSeries) {
|
||||
const pts = seriesToChartData(totals.series || []);
|
||||
if (pts.length) {
|
||||
lineSeries.setData(pts);
|
||||
chart.timeScale().fitContent();
|
||||
} else {
|
||||
lineSeries.setData([]);
|
||||
}
|
||||
}
|
||||
renderAccounts(data.accounts || []);
|
||||
// 分户卡片渲染后高度会变,补一次尺寸,避免 1080p 一屏布局下曲线被裁切
|
||||
if (chart && elChartHost) {
|
||||
requestAnimationFrame(function () {
|
||||
if (!chart || !elChartHost) return;
|
||||
chart.applyOptions({
|
||||
width: Math.max(elChartHost.clientWidth || 0, 1),
|
||||
height: Math.max(elChartHost.clientHeight || 0, 200),
|
||||
});
|
||||
chart.timeScale().fitContent();
|
||||
});
|
||||
}
|
||||
if (fsAccountKey) {
|
||||
const ac = findAccount(fsAccountKey);
|
||||
if (ac && ac.monitored) openAccountFullscreen(fsAccountKey);
|
||||
else closeAccountFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
setStatus("加载中…");
|
||||
try {
|
||||
const r = await fetch("/api/hub/fund-overview", { credentials: "same-origin" });
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
setStatus(j.detail || j.msg || "加载失败", true);
|
||||
return;
|
||||
}
|
||||
renderOverview(j);
|
||||
setStatus("");
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
if (elBtnRefresh) elBtnRefresh.addEventListener("click", load);
|
||||
if (elFsBackdrop) elFsBackdrop.addEventListener("click", closeAccountFullscreen);
|
||||
if (elFsClose) elFsClose.addEventListener("click", closeAccountFullscreen);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && fsAccountKey) closeAccountFullscreen();
|
||||
});
|
||||
document.addEventListener("hub-theme-change", function () {
|
||||
destroyChart();
|
||||
destroyFsChart();
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!page || page.classList.contains("hidden")) return;
|
||||
if (!inited) {
|
||||
bind();
|
||||
inited = true;
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
closeAccountFullscreen();
|
||||
destroyChart();
|
||||
}
|
||||
|
||||
window.hubFundsPage = { init: init, destroy: destroy, reload: load };
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 使用说明:中控 docs/help MD 章节.
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-help");
|
||||
if (!page) return;
|
||||
|
||||
const tocEl = document.getElementById("help-toc-nav");
|
||||
const statusEl = document.getElementById("help-load-status");
|
||||
const docBody = document.getElementById("help-doc-body");
|
||||
const docSource = document.getElementById("help-doc-source");
|
||||
|
||||
let sectionsMeta = [];
|
||||
let activeKey = "quickstart";
|
||||
let cache = {};
|
||||
let bound = false;
|
||||
|
||||
async function apiFetch(url) {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.ok) throw new Error((data && data.msg) || r.statusText || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function sectionFromHash() {
|
||||
const h = (window.location.hash || "").replace(/^#/, "").trim().toLowerCase();
|
||||
if (!h) return null;
|
||||
return sectionsMeta.some((s) => s.key === h) ? h : null;
|
||||
}
|
||||
|
||||
function setHash(key) {
|
||||
const next = `#${key}`;
|
||||
if (window.location.hash !== next) {
|
||||
history.replaceState(null, "", `/help${next}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderToc() {
|
||||
if (!tocEl) return;
|
||||
tocEl.innerHTML = sectionsMeta
|
||||
.map(
|
||||
(s) =>
|
||||
`<button type="button" class="help-toc-item${s.key === activeKey ? " is-active" : ""}" data-key="${esc(s.key)}">${esc(s.label)}</button>`
|
||||
)
|
||||
.join("");
|
||||
tocEl.querySelectorAll(".help-toc-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const key = btn.getAttribute("data-key");
|
||||
if (!key || key === activeKey) return;
|
||||
activeKey = key;
|
||||
setHash(key);
|
||||
renderToc();
|
||||
void loadSection(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection(data) {
|
||||
if (docBody) docBody.innerHTML = data.content_html || "";
|
||||
if (docSource) {
|
||||
docSource.textContent = data.md_source ? `来源: ${data.md_source}` : "";
|
||||
}
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
}
|
||||
|
||||
async function loadSection(key) {
|
||||
if (cache[key]) {
|
||||
renderSection(cache[key]);
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = "加载中…";
|
||||
try {
|
||||
const data = await apiFetch(`/api/help/${encodeURIComponent(key)}`);
|
||||
cache[key] = data;
|
||||
renderSection(data);
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
if (docBody) docBody.innerHTML = `<p class="muted">${esc(err.message || "加载失败")}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const data = await apiFetch("/api/help/meta");
|
||||
sectionsMeta = data.sections || [];
|
||||
const fromHash = sectionFromHash();
|
||||
if (fromHash) activeKey = fromHash;
|
||||
else if (sectionsMeta.length && !sectionsMeta.some((s) => s.key === activeKey)) {
|
||||
activeKey = sectionsMeta[0].key;
|
||||
}
|
||||
renderToc();
|
||||
await loadSection(activeKey);
|
||||
if (!sectionFromHash() && activeKey) setHash(activeKey);
|
||||
}
|
||||
|
||||
function bindOnce() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
window.addEventListener("hashchange", () => {
|
||||
const key = sectionFromHash();
|
||||
if (!key || key === activeKey) return;
|
||||
activeKey = key;
|
||||
renderToc();
|
||||
void loadSection(key);
|
||||
});
|
||||
}
|
||||
|
||||
window.hubHelpPage = {
|
||||
init() {
|
||||
bindOnce();
|
||||
void loadMeta().catch((err) => {
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
if (docBody) docBody.innerHTML = `<p class="muted">${esc(err.message || "加载失败")}</p>`;
|
||||
});
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 162 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#22d3ee"/>
|
||||
<stop offset="100%" stop-color="#34d399"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="url(#g)" stroke-width="12"/>
|
||||
<path d="M120 320 L200 248 L280 272 L392 168" fill="none" stroke="url(#g)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="392" cy="168" r="18" fill="#34d399"/>
|
||||
<rect x="168" y="268" width="28" height="64" rx="6" fill="#f87171"/>
|
||||
<line x1="182" y1="248" x2="182" y2="340" stroke="#f87171" stroke-width="10" stroke-linecap="round"/>
|
||||
<rect x="268" y="220" width="28" height="96" rx="6" fill="#34d399"/>
|
||||
<line x1="282" y1="200" x2="282" y2="340" stroke="#34d399" stroke-width="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "复盘系统中控",
|
||||
"short_name": "中控",
|
||||
"description": "三所交易监控与行情中控",
|
||||
"start_url": "/monitor",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0e18",
|
||||
"theme_color": "#0b0e18",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script src="/assets/theme.js?v=20260604-hub-theme4"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0e18" />
|
||||
<meta name="apple-mobile-web-app-title" content="中控" />
|
||||
<link rel="icon" href="/assets/icons/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/assets/icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/assets/icons/manifest.webmanifest" />
|
||||
<title>登录 · 复盘系统中控</title>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260604-hub-theme4" />
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-bg" aria-hidden="true"></div>
|
||||
<div class="login-theme-bar">
|
||||
<div class="theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-panel">
|
||||
<div class="login-brand">
|
||||
<span class="brand-mark"></span>
|
||||
<div>
|
||||
<div class="login-title">复盘系统中控</div>
|
||||
<div class="login-sub">CRYPTO MONITOR · COMMAND</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="login-form" class="login-form" autocomplete="off">
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<input type="text" name="username" id="login-username" required autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input type="password" name="password" id="login-password" required autocomplete="new-password" />
|
||||
</label>
|
||||
<button type="submit" class="primary login-submit" id="login-submit">进入系统</button>
|
||||
<p id="login-err" class="login-err" hidden></p>
|
||||
<p id="login-hint" class="login-foot" hidden></p>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById("login-form");
|
||||
const err = document.getElementById("login-err");
|
||||
const hint = document.getElementById("login-hint");
|
||||
const submitBtn = document.getElementById("login-submit");
|
||||
const userInput = document.getElementById("login-username");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "/monitor";
|
||||
const inFrame = window.self !== window.top;
|
||||
const isHttps = location.protocol === "https:";
|
||||
|
||||
if (inFrame) {
|
||||
hint.hidden = false;
|
||||
hint.textContent = isHttps
|
||||
? "嵌入模式:登录成功后将自动写入会话."
|
||||
: "嵌入模式需 HTTPS 中控;HTTP 时请用本地导航工具栏「中控登录」.";
|
||||
}
|
||||
|
||||
function showErr(msg) {
|
||||
err.textContent = msg;
|
||||
err.hidden = false;
|
||||
}
|
||||
|
||||
function gotoAfterLogin(token, dest) {
|
||||
const target = dest.startsWith("/") ? dest : "/monitor";
|
||||
if (inFrame) {
|
||||
if (!token) {
|
||||
showErr("登录响应缺少 session_token,请升级云端 hub 或使用本地导航「中控登录」.");
|
||||
return;
|
||||
}
|
||||
if (!isHttps) {
|
||||
showErr("跨站 iframe 登录需要 HTTPS 中控;请改用本地导航「中控登录」按钮.");
|
||||
return;
|
||||
}
|
||||
const q = new URLSearchParams({ token, next: target });
|
||||
const embedUrl = "/embed-auth?" + q.toString();
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "hub:login-ok",
|
||||
session_token: token,
|
||||
next: target,
|
||||
embed_auth_url: location.origin + embedUrl,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
} catch (_) {}
|
||||
submitBtn.textContent = "跳转中…";
|
||||
submitBtn.disabled = true;
|
||||
location.replace(embedUrl);
|
||||
return;
|
||||
}
|
||||
location.href = target;
|
||||
}
|
||||
|
||||
fetch("/api/auth/status")
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (!s.required || s.logged_in) gotoAfterLogin(null, next);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
submitBtn.disabled = true;
|
||||
const oldLabel = submitBtn.textContent;
|
||||
submitBtn.textContent = "登录中…";
|
||||
const username = userInput.value.trim();
|
||||
const password = document.getElementById("login-password").value;
|
||||
const headers = { "Content-Type": "application/json", Accept: "application/json" };
|
||||
if (inFrame) headers["X-Hub-Embed"] = "1";
|
||||
try {
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
let j = {};
|
||||
try {
|
||||
j = await r.json();
|
||||
} catch (_) {
|
||||
j = {};
|
||||
}
|
||||
if (r.ok && j.ok) {
|
||||
gotoAfterLogin(j.session_token || null, next);
|
||||
if (!inFrame) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = oldLabel;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r.status === 403) {
|
||||
showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
|
||||
} else {
|
||||
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
|
||||
}
|
||||
} catch (ex) {
|
||||
showErr("网络错误:" + ex);
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = oldLabel;
|
||||
});
|
||||
})();
|
||||
if (window.HubTheme && typeof HubTheme.initToggleUI === "function") {
|
||||
HubTheme.initToggleUI();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 系统日志:三所 + 中控 PM2 stdout/stderr.
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-logs");
|
||||
if (!page) return;
|
||||
|
||||
const tabsEl = document.getElementById("hub-logs-tabs");
|
||||
const statusEl = document.getElementById("hub-logs-status");
|
||||
const outEl = document.getElementById("hub-logs-out");
|
||||
const errEl = document.getElementById("hub-logs-err");
|
||||
const btnRefresh = document.getElementById("hub-logs-btn-refresh");
|
||||
const btnPause = document.getElementById("hub-logs-btn-pause");
|
||||
|
||||
const POLL_MS = 4000;
|
||||
let activeKey = "binance";
|
||||
let tabsMeta = [];
|
||||
let pollTimer = null;
|
||||
let paused = false;
|
||||
let loading = false;
|
||||
let bound = false;
|
||||
|
||||
async function apiFetch(url) {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
const ct = (r.headers.get("content-type") || "").toLowerCase();
|
||||
if (ct.includes("application/json")) {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error((data && (data.msg || data.detail)) || r.statusText || "请求失败");
|
||||
return data;
|
||||
}
|
||||
if (!r.ok) throw new Error(r.statusText || "请求失败");
|
||||
return r;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function wasScrolledToBottom(el) {
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}
|
||||
|
||||
function setPreText(el, text, stickBottom) {
|
||||
if (!el) return;
|
||||
const atBottom = stickBottom || wasScrolledToBottom(el);
|
||||
el.textContent = text || "(暂无日志)";
|
||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, isErr) {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = text || "";
|
||||
statusEl.classList.toggle("is-err", !!isErr);
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
if (!tabsEl) return;
|
||||
tabsEl.innerHTML = tabsMeta
|
||||
.map(
|
||||
(t) =>
|
||||
`<button type="button" class="hub-logs-tab${t.key === activeKey ? " is-active" : ""}" role="tab" aria-selected="${t.key === activeKey}" data-key="${esc(t.key)}">${esc(t.label)}</button>`
|
||||
)
|
||||
.join("");
|
||||
tabsEl.querySelectorAll(".hub-logs-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const key = btn.getAttribute("data-key");
|
||||
if (!key || key === activeKey) return;
|
||||
activeKey = key;
|
||||
renderTabs();
|
||||
void loadLogs(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const meta = await apiFetch("/api/system-logs/meta");
|
||||
tabsMeta = Array.isArray(meta.targets) ? meta.targets : [];
|
||||
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
|
||||
activeKey = tabsMeta[0].key;
|
||||
}
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
async function loadLogs(force) {
|
||||
if (loading && !force) return;
|
||||
loading = true;
|
||||
try {
|
||||
const data = await apiFetch(`/api/system-logs/${encodeURIComponent(activeKey)}?lines=200`);
|
||||
setPreText(outEl, data.out || "", true);
|
||||
setPreText(errEl, data.err || "", true);
|
||||
const ts = data.updated_at ? new Date(data.updated_at * 1000) : new Date();
|
||||
const hh = String(ts.getHours()).padStart(2, "0");
|
||||
const mm = String(ts.getMinutes()).padStart(2, "0");
|
||||
const ss = String(ts.getSeconds()).padStart(2, "0");
|
||||
const missing = [];
|
||||
if (!data.out_exists) missing.push("实时");
|
||||
if (!data.err_exists) missing.push("报错");
|
||||
const hint = missing.length ? ` · ${missing.join("/")}日志文件暂无` : "";
|
||||
setStatus(`已更新 ${hh}:${mm}:${ss}${hint}`, false);
|
||||
} catch (e) {
|
||||
setStatus(e.message || "加载失败", true);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
if (paused) return;
|
||||
pollTimer = window.setInterval(() => {
|
||||
void loadLogs(false);
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
if (btnRefresh) {
|
||||
btnRefresh.addEventListener("click", () => {
|
||||
void loadLogs(true);
|
||||
});
|
||||
}
|
||||
if (btnPause) {
|
||||
btnPause.addEventListener("click", () => {
|
||||
paused = !paused;
|
||||
btnPause.textContent = paused ? "继续刷新" : "暂停刷新";
|
||||
btnPause.classList.toggle("is-paused", paused);
|
||||
if (paused) stopPoll();
|
||||
else startPoll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindControls();
|
||||
paused = false;
|
||||
if (btnPause) {
|
||||
btnPause.textContent = "暂停刷新";
|
||||
btnPause.classList.remove("is-paused");
|
||||
}
|
||||
setStatus("加载中…", false);
|
||||
try {
|
||||
await loadMeta();
|
||||
await loadLogs(true);
|
||||
startPoll();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "初始化失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
stopPoll();
|
||||
setStatus("", false);
|
||||
}
|
||||
|
||||
window.hubLogsPage = { init, destroy };
|
||||
})();
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* 开仓计划:新建 / 进行中 / 历史 / 胜率统计
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-plan");
|
||||
if (!page) return;
|
||||
|
||||
let meta = null;
|
||||
let activePlans = [];
|
||||
let archivedPlans = [];
|
||||
let statsPeriod = "all";
|
||||
let statsDim = "symbol";
|
||||
let statsDateFrom = "";
|
||||
let statsDateTo = "";
|
||||
let editingPlanId = null;
|
||||
let inited = false;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function toast(msg, isErr) {
|
||||
const el = $("toast");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = isErr ? "err" : "ok";
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(function () {
|
||||
el.className = "";
|
||||
el.textContent = "";
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const r = await fetch(path, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||
let data = {};
|
||||
try {
|
||||
data = await r.json();
|
||||
} catch (_e) {
|
||||
data = {};
|
||||
}
|
||||
if (!r.ok) {
|
||||
const detail = (data && data.detail) || r.statusText || "请求失败";
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return y + "-" + m + "-" + day;
|
||||
}
|
||||
|
||||
function exchangeLabel(key) {
|
||||
const ex = (meta && meta.exchanges) || [];
|
||||
const row = ex.find(function (e) {
|
||||
return String(e.key) === String(key);
|
||||
});
|
||||
return (row && row.name) || key || "—";
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return String(v);
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function fillSelect(el, options, valueKey, labelKey) {
|
||||
if (!el) return;
|
||||
el.innerHTML = "";
|
||||
(options || []).forEach(function (opt) {
|
||||
const o = document.createElement("option");
|
||||
if (typeof opt === "string") {
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
} else {
|
||||
o.value = opt[valueKey];
|
||||
o.textContent = opt[labelKey];
|
||||
}
|
||||
el.appendChild(o);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDirectionRadios(container, name, selected) {
|
||||
if (!container || !meta) return;
|
||||
container.innerHTML = "";
|
||||
(meta.directions || []).forEach(function (d) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "plan-radio-label";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = name;
|
||||
input.value = d.value;
|
||||
if (d.value === selected) input.checked = true;
|
||||
label.appendChild(input);
|
||||
label.appendChild(document.createTextNode(" " + d.label));
|
||||
container.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function bindMetaToCreateForm() {
|
||||
fillSelect($("plan-create-exchange"), meta.exchanges, "key", "name");
|
||||
fillSelect($("plan-create-type"), meta.plan_types, "value", "label");
|
||||
fillSelect($("plan-create-trend-tf"), meta.trend_timeframes);
|
||||
fillSelect($("plan-create-entry-tf"), meta.entry_timeframes);
|
||||
renderDirectionRadios($("plan-create-direction"), "plan-direction", "long");
|
||||
const dateEl = $("plan-create-date");
|
||||
if (dateEl && !dateEl.value) dateEl.value = todayIso();
|
||||
}
|
||||
|
||||
function planSummaryLine(p) {
|
||||
return (
|
||||
esc(p.symbol) +
|
||||
" · " +
|
||||
esc(exchangeLabel(p.exchange_key)) +
|
||||
" · " +
|
||||
esc(p.direction_label || p.direction) +
|
||||
" · " +
|
||||
esc(p.plan_type_label || p.plan_type)
|
||||
);
|
||||
}
|
||||
|
||||
function schemeOptionsHtml(selected) {
|
||||
let html = '<option value="">请选择</option>';
|
||||
(meta.entry_schemes || []).forEach(function (s) {
|
||||
html +=
|
||||
'<option value="' +
|
||||
esc(s.value) +
|
||||
'"' +
|
||||
(selected === s.value ? " selected" : "") +
|
||||
">" +
|
||||
esc(s.label) +
|
||||
"</option>";
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderActiveList() {
|
||||
const host = $("plan-active-list");
|
||||
const cnt = $("plan-active-count");
|
||||
if (!host) return;
|
||||
if (cnt) cnt.textContent = activePlans.length ? activePlans.length + " 条" : "";
|
||||
if (!activePlans.length) {
|
||||
host.innerHTML = '<p class="plan-empty">暂无进行中的计划</p>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = activePlans
|
||||
.map(function (p) {
|
||||
return (
|
||||
'<article class="plan-active-card" data-id="' +
|
||||
esc(p.id) +
|
||||
'">' +
|
||||
'<div class="plan-active-head">' +
|
||||
'<div class="plan-active-title">' +
|
||||
planSummaryLine(p) +
|
||||
"</div>" +
|
||||
'<div class="plan-active-actions">' +
|
||||
'<button type="button" class="ghost plan-btn-edit" data-id="' +
|
||||
esc(p.id) +
|
||||
'">修改</button>' +
|
||||
'<button type="button" class="ghost plan-btn-del" data-id="' +
|
||||
esc(p.id) +
|
||||
'">删除</button>' +
|
||||
"</div></div>" +
|
||||
'<div class="plan-active-meta">' +
|
||||
esc(p.plan_date) +
|
||||
" · 趋势 " +
|
||||
esc(p.trend_timeframe) +
|
||||
" / 入场 " +
|
||||
esc(p.entry_timeframe) +
|
||||
"</div>" +
|
||||
'<div class="plan-active-levels">目标 ' +
|
||||
esc(p.target_level || "—") +
|
||||
" · 区间 " +
|
||||
esc(p.current_range || "—") +
|
||||
"</div>" +
|
||||
(p.note ? '<div class="plan-active-note">' + esc(p.note) + "</div>" : "") +
|
||||
'<div class="plan-scheme-row">' +
|
||||
'<label class="plan-field plan-field-inline plan-field-scheme"><span>入场方案</span>' +
|
||||
'<select class="plan-active-scheme" data-id="' +
|
||||
esc(p.id) +
|
||||
'">' +
|
||||
schemeOptionsHtml(p.entry_scheme || "") +
|
||||
"</select></label>" +
|
||||
"</div>" +
|
||||
'<div class="plan-close-row">' +
|
||||
'<label class="plan-field plan-field-inline"><span>结果</span>' +
|
||||
'<select class="plan-close-result" data-id="' +
|
||||
esc(p.id) +
|
||||
'"><option value="">—</option>' +
|
||||
(meta.results || [])
|
||||
.map(function (r) {
|
||||
return (
|
||||
'<option value="' +
|
||||
esc(r.value) +
|
||||
'"' +
|
||||
(p.result === r.value ? " selected" : "") +
|
||||
">" +
|
||||
esc(r.label) +
|
||||
"</option>"
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
"</select></label>" +
|
||||
'<label class="plan-field plan-field-inline"><span>盈亏</span>' +
|
||||
'<input class="plan-close-pnl" data-id="' +
|
||||
esc(p.id) +
|
||||
'" type="number" step="any" placeholder="U(可选)" value="' +
|
||||
(p.pnl_amount != null ? esc(p.pnl_amount) : "") +
|
||||
'" /></label>' +
|
||||
'<button type="button" class="primary plan-btn-archive" data-id="' +
|
||||
esc(p.id) +
|
||||
'">填写结果并归档</button>' +
|
||||
"</div></article>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderHistoryList() {
|
||||
const host = $("plan-history-list");
|
||||
const cnt = $("plan-history-count");
|
||||
if (!host) return;
|
||||
if (cnt) cnt.textContent = archivedPlans.length ? archivedPlans.length + " 条" : "";
|
||||
if (!archivedPlans.length) {
|
||||
host.innerHTML = '<p class="plan-empty">暂无历史计划</p>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = archivedPlans
|
||||
.map(function (p) {
|
||||
const pnlTxt = fmtPnl(p.pnl_amount);
|
||||
const resCls = p.result === "win" ? "plan-res-win" : "plan-res-loss";
|
||||
return (
|
||||
'<button type="button" class="plan-history-row" data-id="' +
|
||||
esc(p.id) +
|
||||
'">' +
|
||||
'<span class="plan-history-date">' +
|
||||
esc(p.plan_date) +
|
||||
"</span>" +
|
||||
'<span class="plan-history-main">' +
|
||||
esc(p.symbol) +
|
||||
" · " +
|
||||
esc(exchangeLabel(p.exchange_key)) +
|
||||
"</span>" +
|
||||
'<span class="plan-history-scheme">' +
|
||||
esc(p.entry_scheme_label || p.entry_scheme) +
|
||||
"</span>" +
|
||||
'<span class="plan-history-result ' +
|
||||
resCls +
|
||||
'">' +
|
||||
esc(p.result_label || p.result) +
|
||||
(pnlTxt ? " " + esc(pnlTxt) : "") +
|
||||
"</span></button>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderStatsTable(stats) {
|
||||
const host = $("plan-stats-table");
|
||||
const labelEl = $("plan-stats-label");
|
||||
if (labelEl) labelEl.textContent = (stats && stats.period_label) || "";
|
||||
if (!host) return;
|
||||
const items = (stats && stats.items) || [];
|
||||
if (!items.length) {
|
||||
host.innerHTML = '<p class="plan-empty">该范围内暂无已归档且有结果的计划</p>';
|
||||
return;
|
||||
}
|
||||
const dimLabel =
|
||||
stats.dimension === "trend_tf"
|
||||
? "趋势周期"
|
||||
: stats.dimension === "entry_scheme"
|
||||
? "入场方案"
|
||||
: "币种";
|
||||
let rows = items
|
||||
.map(function (it) {
|
||||
return (
|
||||
"<tr><td>" +
|
||||
esc(it.label || it.key) +
|
||||
"</td><td>" +
|
||||
(it.total || 0) +
|
||||
"</td><td>" +
|
||||
(it.win_count || 0) +
|
||||
"</td><td>" +
|
||||
(it.loss_count || 0) +
|
||||
"</td><td>" +
|
||||
(it.win_rate != null ? it.win_rate + "%" : "—") +
|
||||
"</td></tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
host.innerHTML =
|
||||
'<table class="plan-stats-table"><thead><tr>' +
|
||||
"<th>" +
|
||||
esc(dimLabel) +
|
||||
"</th><th>计划数</th><th>盈利</th><th>亏损</th><th>胜率</th>" +
|
||||
"</tr></thead><tbody>" +
|
||||
rows +
|
||||
"</tbody></table>";
|
||||
}
|
||||
|
||||
function statsQuery() {
|
||||
const q = new URLSearchParams();
|
||||
q.set("dimension", statsDim);
|
||||
q.set("period", statsPeriod);
|
||||
if (statsPeriod === "range") {
|
||||
if (statsDateFrom) q.set("date_from", statsDateFrom);
|
||||
if (statsDateTo) q.set("date_to", statsDateTo);
|
||||
}
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const data = await api("/api/entry-plans/meta");
|
||||
meta = data;
|
||||
bindMetaToCreateForm();
|
||||
}
|
||||
|
||||
async function loadActive() {
|
||||
const data = await api("/api/entry-plans?status=active");
|
||||
activePlans = data.plans || [];
|
||||
renderActiveList();
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const data = await api("/api/entry-plans?status=archived");
|
||||
archivedPlans = data.plans || [];
|
||||
renderHistoryList();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const data = await api("/api/entry-plans/stats?" + statsQuery());
|
||||
renderStatsTable(data.stats || {});
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadActive(), loadHistory(), loadStats()]);
|
||||
}
|
||||
|
||||
function fmtRefreshTime() {
|
||||
const d = new Date();
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
const s = String(d.getSeconds()).padStart(2, "0");
|
||||
return h + ":" + m + ":" + s;
|
||||
}
|
||||
|
||||
async function refreshPage() {
|
||||
const btn = $("plan-btn-refresh");
|
||||
const status = $("plan-refresh-status");
|
||||
if (btn) btn.disabled = true;
|
||||
if (status) status.textContent = "刷新中…";
|
||||
try {
|
||||
await loadMeta();
|
||||
await refreshAll();
|
||||
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
|
||||
} catch (e) {
|
||||
toast(e.message || "刷新失败", true);
|
||||
if (status) status.textContent = "刷新失败";
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function readCreateForm() {
|
||||
const dir = document.querySelector('input[name="plan-direction"]:checked');
|
||||
return {
|
||||
plan_date: ($("plan-create-date") && $("plan-create-date").value) || "",
|
||||
exchange_key: ($("plan-create-exchange") && $("plan-create-exchange").value) || "",
|
||||
symbol: ($("plan-create-symbol") && $("plan-create-symbol").value) || "",
|
||||
plan_type: ($("plan-create-type") && $("plan-create-type").value) || "",
|
||||
trend_timeframe: ($("plan-create-trend-tf") && $("plan-create-trend-tf").value) || "",
|
||||
entry_timeframe: ($("plan-create-entry-tf") && $("plan-create-entry-tf").value) || "",
|
||||
direction: (dir && dir.value) || "",
|
||||
target_level: ($("plan-create-target") && $("plan-create-target").value) || "",
|
||||
current_range: ($("plan-create-range") && $("plan-create-range").value) || "",
|
||||
note: ($("plan-create-note") && $("plan-create-note").value) || "",
|
||||
};
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
const form = $("plan-create-form");
|
||||
if (form) form.reset();
|
||||
bindMetaToCreateForm();
|
||||
if ($("plan-create-date")) $("plan-create-date").value = todayIso();
|
||||
}
|
||||
|
||||
function openDetailModal(plan) {
|
||||
const modal = $("plan-detail-modal");
|
||||
const body = $("plan-detail-body");
|
||||
const title = $("plan-detail-title");
|
||||
if (!modal || !body || !plan) return;
|
||||
if (title) title.textContent = plan.symbol + " · " + (plan.result_label || "计划");
|
||||
const rows = [
|
||||
["日期", plan.plan_date],
|
||||
["交易所", exchangeLabel(plan.exchange_key)],
|
||||
["币种", plan.symbol],
|
||||
["类型", plan.plan_type_label],
|
||||
["趋势周期", plan.trend_timeframe],
|
||||
["入场周期", plan.entry_timeframe],
|
||||
["方向", plan.direction_label],
|
||||
["目标位", plan.target_level || "—"],
|
||||
["当前区间", plan.current_range || "—"],
|
||||
["入场方案", plan.entry_scheme_label],
|
||||
["结果", plan.result_label || "—"],
|
||||
["盈亏", fmtPnl(plan.pnl_amount) || "—"],
|
||||
["备注", plan.note || "—"],
|
||||
];
|
||||
body.innerHTML = rows
|
||||
.map(function (pair) {
|
||||
return (
|
||||
'<div class="plan-detail-row"><span class="plan-detail-k">' +
|
||||
esc(pair[0]) +
|
||||
'</span><span class="plan-detail-v">' +
|
||||
esc(pair[1]) +
|
||||
"</span></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
modal.classList.remove("hidden");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
const modal = $("plan-detail-modal");
|
||||
if (!modal) return;
|
||||
modal.classList.add("hidden");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function buildEditFormHtml(p) {
|
||||
const dirs = (meta.directions || [])
|
||||
.map(function (d) {
|
||||
return (
|
||||
'<label class="plan-radio-label"><input type="radio" name="edit-direction" value="' +
|
||||
esc(d.value) +
|
||||
'"' +
|
||||
(p.direction === d.value ? " checked" : "") +
|
||||
" /> " +
|
||||
esc(d.label) +
|
||||
"</label>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
function opts(list, key, valKey, labelKey) {
|
||||
return (list || [])
|
||||
.map(function (o) {
|
||||
const v = typeof o === "string" ? o : o[valKey];
|
||||
const lbl = typeof o === "string" ? o : o[labelKey];
|
||||
return (
|
||||
'<option value="' +
|
||||
esc(v) +
|
||||
'"' +
|
||||
(String(p[key]) === String(v) ? " selected" : "") +
|
||||
">" +
|
||||
esc(lbl) +
|
||||
"</option>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return (
|
||||
'<div class="plan-form-grid">' +
|
||||
'<label class="plan-field"><span>日期</span><input name="plan_date" type="date" value="' +
|
||||
esc(p.plan_date) +
|
||||
'" required /></label>' +
|
||||
'<label class="plan-field"><span>交易所</span><select name="exchange_key" required>' +
|
||||
opts(meta.exchanges, "exchange_key", "key", "name") +
|
||||
"</select></label>" +
|
||||
'<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' +
|
||||
esc(p.symbol) +
|
||||
'" required /></label>' +
|
||||
'<label class="plan-field"><span>类型</span><select name="plan_type" required>' +
|
||||
opts(meta.plan_types, "plan_type", "value", "label") +
|
||||
"</select></label>" +
|
||||
'<label class="plan-field"><span>趋势周期</span><select name="trend_timeframe" required>' +
|
||||
opts(meta.trend_timeframes, "trend_timeframe") +
|
||||
"</select></label>" +
|
||||
'<label class="plan-field"><span>入场周期</span><select name="entry_timeframe" required>' +
|
||||
opts(meta.entry_timeframes, "entry_timeframe") +
|
||||
"</select></label>" +
|
||||
'<label class="plan-field plan-field-full"><span>方向</span><span class="plan-radio-row">' +
|
||||
dirs +
|
||||
"</span></label>" +
|
||||
'<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' +
|
||||
esc(p.target_level || "") +
|
||||
'" /></label>' +
|
||||
'<label class="plan-field"><span>当前区间</span><input name="current_range" type="text" value="' +
|
||||
esc(p.current_range || "") +
|
||||
'" /></label>' +
|
||||
'<label class="plan-field plan-field-full"><span>入场方案</span><select name="entry_scheme" required>' +
|
||||
opts(meta.entry_schemes, "entry_scheme", "value", "label") +
|
||||
"</select></label>" +
|
||||
'<label class="plan-field plan-field-full"><span>备注</span><textarea name="note" rows="2">' +
|
||||
esc(p.note || "") +
|
||||
"</textarea></label>" +
|
||||
"</div>" +
|
||||
'<div class="modal-actions"><button type="button" class="ghost" data-plan-edit-close>取消</button>' +
|
||||
'<button type="submit" class="primary">保存修改</button></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function openEditModal(plan) {
|
||||
const modal = $("plan-edit-modal");
|
||||
const form = $("plan-edit-form");
|
||||
if (!modal || !form || !plan) return;
|
||||
editingPlanId = plan.id;
|
||||
form.innerHTML = buildEditFormHtml(plan);
|
||||
modal.classList.remove("hidden");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
const modal = $("plan-edit-modal");
|
||||
if (!modal) return;
|
||||
editingPlanId = null;
|
||||
modal.classList.add("hidden");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function readEditForm(form) {
|
||||
const fd = new FormData(form);
|
||||
const dir = form.querySelector('input[name="edit-direction"]:checked');
|
||||
return {
|
||||
plan_date: fd.get("plan_date") || "",
|
||||
exchange_key: fd.get("exchange_key") || "",
|
||||
symbol: fd.get("symbol") || "",
|
||||
plan_type: fd.get("plan_type") || "",
|
||||
trend_timeframe: fd.get("trend_timeframe") || "",
|
||||
entry_timeframe: fd.get("entry_timeframe") || "",
|
||||
direction: (dir && dir.value) || "",
|
||||
target_level: fd.get("target_level") || "",
|
||||
current_range: fd.get("current_range") || "",
|
||||
entry_scheme: fd.get("entry_scheme") || "",
|
||||
note: fd.get("note") || "",
|
||||
};
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const refreshBtn = $("plan-btn-refresh");
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", function () {
|
||||
void refreshPage();
|
||||
});
|
||||
}
|
||||
|
||||
const createForm = $("plan-create-form");
|
||||
if (createForm) {
|
||||
createForm.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
api("/api/entry-plans", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(readCreateForm()),
|
||||
})
|
||||
.then(function () {
|
||||
toast("计划已加入进行中");
|
||||
resetCreateForm();
|
||||
return refreshAll();
|
||||
})
|
||||
.catch(function (e) {
|
||||
toast(e.message || "保存失败", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const activeList = $("plan-active-list");
|
||||
if (activeList) {
|
||||
activeList.addEventListener("click", function (ev) {
|
||||
const t = ev.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
const id = t.getAttribute("data-id");
|
||||
if (!id) return;
|
||||
if (t.classList.contains("plan-btn-del")) {
|
||||
if (!window.confirm("确定删除该进行中的计划?")) return;
|
||||
api("/api/entry-plans/" + id, { method: "DELETE" })
|
||||
.then(function () {
|
||||
toast("已删除");
|
||||
return refreshAll();
|
||||
})
|
||||
.catch(function (e) {
|
||||
toast(e.message || "删除失败", true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (t.classList.contains("plan-btn-edit")) {
|
||||
const plan = activePlans.find(function (p) {
|
||||
return String(p.id) === String(id);
|
||||
});
|
||||
if (plan) openEditModal(plan);
|
||||
return;
|
||||
}
|
||||
if (t.classList.contains("plan-btn-archive")) {
|
||||
const card = t.closest(".plan-active-card");
|
||||
const resultEl = card && card.querySelector('.plan-close-result[data-id="' + id + '"]');
|
||||
const pnlEl = card && card.querySelector('.plan-close-pnl[data-id="' + id + '"]');
|
||||
const schemeEl = card && card.querySelector('.plan-active-scheme[data-id="' + id + '"]');
|
||||
const result = resultEl && resultEl.value;
|
||||
if (!result) {
|
||||
toast("请先选择结果(盈/亏)", true);
|
||||
return;
|
||||
}
|
||||
const scheme = schemeEl && schemeEl.value;
|
||||
if (!scheme) {
|
||||
toast("请先选择入场方案(根据实际进场填写)", true);
|
||||
return;
|
||||
}
|
||||
const payload = { result: result, entry_scheme: scheme };
|
||||
const pnlRaw = pnlEl && pnlEl.value;
|
||||
if (pnlRaw !== "" && pnlRaw != null) payload.pnl_amount = Number(pnlRaw);
|
||||
api("/api/entry-plans/" + id, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function () {
|
||||
toast("已归档");
|
||||
return refreshAll();
|
||||
})
|
||||
.catch(function (e) {
|
||||
toast(e.message || "归档失败", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
activeList.addEventListener("change", function (ev) {
|
||||
const t = ev.target;
|
||||
if (!(t instanceof HTMLElement) || !t.classList.contains("plan-active-scheme")) return;
|
||||
const id = t.getAttribute("data-id");
|
||||
const scheme = t.value;
|
||||
if (!id || !scheme) return;
|
||||
api("/api/entry-plans/" + id, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entry_scheme: scheme }),
|
||||
})
|
||||
.then(function () {
|
||||
toast("入场方案已保存");
|
||||
return loadActive();
|
||||
})
|
||||
.catch(function (e) {
|
||||
toast(e.message || "保存失败", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const historyList = $("plan-history-list");
|
||||
if (historyList) {
|
||||
historyList.addEventListener("click", function (ev) {
|
||||
const row = ev.target.closest(".plan-history-row");
|
||||
if (!row) return;
|
||||
const id = row.getAttribute("data-id");
|
||||
const plan = archivedPlans.find(function (p) {
|
||||
return String(p.id) === String(id);
|
||||
});
|
||||
if (plan) openDetailModal(plan);
|
||||
else {
|
||||
api("/api/entry-plans/" + id).then(function (data) {
|
||||
openDetailModal(data.plan);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-plan-modal-close]").forEach(function (el) {
|
||||
el.addEventListener("click", closeDetailModal);
|
||||
});
|
||||
document.querySelectorAll("[data-plan-edit-close]").forEach(function (el) {
|
||||
el.addEventListener("click", closeEditModal);
|
||||
});
|
||||
|
||||
const editForm = $("plan-edit-form");
|
||||
if (editForm) {
|
||||
editForm.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!editingPlanId) return;
|
||||
api("/api/entry-plans/" + editingPlanId, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(readEditForm(editForm)),
|
||||
})
|
||||
.then(function () {
|
||||
toast("已保存");
|
||||
closeEditModal();
|
||||
return refreshAll();
|
||||
})
|
||||
.catch(function (e) {
|
||||
toast(e.message || "保存失败", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const periodTabs = $("plan-stats-period-tabs");
|
||||
if (periodTabs) {
|
||||
periodTabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".plan-period-btn");
|
||||
if (!btn) return;
|
||||
statsPeriod = btn.getAttribute("data-period") || "all";
|
||||
periodTabs.querySelectorAll(".plan-period-btn").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b === btn);
|
||||
});
|
||||
const rangeWrap = $("plan-stats-range-wrap");
|
||||
if (rangeWrap) rangeWrap.classList.toggle("hidden", statsPeriod !== "range");
|
||||
loadStats().catch(function (e) {
|
||||
toast(e.message || "统计加载失败", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const dimTabs = $("plan-stats-dim-tabs");
|
||||
if (dimTabs) {
|
||||
dimTabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".plan-dim-btn");
|
||||
if (!btn) return;
|
||||
statsDim = btn.getAttribute("data-dim") || "symbol";
|
||||
dimTabs.querySelectorAll(".plan-dim-btn").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b === btn);
|
||||
});
|
||||
loadStats().catch(function (e) {
|
||||
toast(e.message || "统计加载失败", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
["plan-stats-date-from", "plan-stats-date-to"].forEach(function (id) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.addEventListener("change", function () {
|
||||
statsDateFrom = ($("plan-stats-date-from") && $("plan-stats-date-from").value) || "";
|
||||
statsDateTo = ($("plan-stats-date-to") && $("plan-stats-date-to").value) || "";
|
||||
if (statsPeriod === "range") {
|
||||
loadStats().catch(function (e) {
|
||||
toast(e.message || "统计加载失败", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (inited) {
|
||||
await refreshPage();
|
||||
return;
|
||||
}
|
||||
inited = true;
|
||||
bindEvents();
|
||||
try {
|
||||
await loadMeta();
|
||||
await refreshAll();
|
||||
const status = $("plan-refresh-status");
|
||||
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
|
||||
} catch (e) {
|
||||
toast(e.message || "加载失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {}
|
||||
|
||||
window.hubPlanPage = { init: init, refresh: refreshPage, destroy: destroy };
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 语录博客流:按交易日分组 · 截断展开 · 当日盈亏摘要 · AI 复盘跳转.
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-quotes");
|
||||
if (!page) return;
|
||||
|
||||
const elFeed = document.getElementById("quotes-feed");
|
||||
const elStatus = document.getElementById("quotes-status");
|
||||
const elBtnRefresh = document.getElementById("quotes-btn-refresh");
|
||||
const elLinkArchive = document.getElementById("quotes-link-archive");
|
||||
|
||||
const RECENT_LIMIT = 20;
|
||||
const PREVIEW_LEN = 140;
|
||||
const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai";
|
||||
|
||||
let quotes = [];
|
||||
let dayStats = {};
|
||||
let expanded = {};
|
||||
let inited = false;
|
||||
let loading = false;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
return fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
if (elStatus) elStatus.textContent = text || "";
|
||||
}
|
||||
|
||||
function findQuote(id) {
|
||||
return (
|
||||
quotes.find(function (q) {
|
||||
return String(q.id) === String(id);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "";
|
||||
return n > 0 ? "pnl-pos" : "pnl-neg";
|
||||
}
|
||||
|
||||
function fmtWinRate(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
function daySummaryHtml(day, st) {
|
||||
if (!st) {
|
||||
return '<span class="quotes-day-summary muted">当日统计加载中…</span>';
|
||||
}
|
||||
const openN = Number(st.open_count) || 0;
|
||||
const pnl = st.pnl_total;
|
||||
return (
|
||||
'<span class="quotes-day-summary">' +
|
||||
openN +
|
||||
" 笔 · 盈亏 <span class=\"" +
|
||||
pnlClass(pnl) +
|
||||
'">' +
|
||||
esc(fmtPnl(pnl)) +
|
||||
"</span> · 胜率 " +
|
||||
esc(fmtWinRate(st.win_rate)) +
|
||||
"</span>"
|
||||
);
|
||||
}
|
||||
|
||||
function previewText(raw) {
|
||||
const text = String(raw || "").trim();
|
||||
if (text.length <= PREVIEW_LEN) return { text: text, truncated: false };
|
||||
return { text: text.slice(0, PREVIEW_LEN).trim() + "…", truncated: true };
|
||||
}
|
||||
|
||||
function groupByDay(rows) {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rows.forEach(function (q) {
|
||||
const day = String(q.quote_date || "").slice(0, 10) || "—";
|
||||
if (!map[day]) {
|
||||
map[day] = [];
|
||||
order.push(day);
|
||||
}
|
||||
map[day].push(q);
|
||||
});
|
||||
return { map: map, order: order };
|
||||
}
|
||||
|
||||
function renderFeed() {
|
||||
if (!elFeed) return;
|
||||
if (!quotes.length) {
|
||||
elFeed.innerHTML =
|
||||
'<p class="quotes-empty">暂无复盘语录.可在「内照明心 → 复盘语录」中添加.</p>';
|
||||
return;
|
||||
}
|
||||
const grouped = groupByDay(quotes);
|
||||
elFeed.innerHTML = grouped.order
|
||||
.map(function (day) {
|
||||
const list = grouped.map[day] || [];
|
||||
const cards = list
|
||||
.map(function (q) {
|
||||
const id = String(q.id);
|
||||
const full = String(q.content || "").trim();
|
||||
const isOpen = !!expanded[id];
|
||||
const prev = previewText(full);
|
||||
const showExpand = prev.truncated;
|
||||
const body = isOpen || !showExpand ? full : prev.text;
|
||||
return (
|
||||
'<article class="quotes-card' +
|
||||
(isOpen ? " is-expanded" : "") +
|
||||
'" data-id="' +
|
||||
esc(id) +
|
||||
'">' +
|
||||
'<div class="quotes-card-body">' +
|
||||
esc(body) +
|
||||
"</div>" +
|
||||
'<div class="quotes-card-actions">' +
|
||||
(showExpand
|
||||
? '<button type="button" class="ghost quotes-expand-btn" data-id="' +
|
||||
esc(id) +
|
||||
'">' +
|
||||
(isOpen ? "收起" : "展开") +
|
||||
"</button>"
|
||||
: "") +
|
||||
'<button type="button" class="ghost quotes-ai-btn" data-id="' +
|
||||
esc(id) +
|
||||
'">AI 复盘</button>' +
|
||||
"</div></article>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return (
|
||||
'<section class="quotes-day-group" data-day="' +
|
||||
esc(day) +
|
||||
'">' +
|
||||
'<header class="quotes-day-head">' +
|
||||
'<h2 class="quotes-day-title">' +
|
||||
esc(day) +
|
||||
"</h2>" +
|
||||
daySummaryHtml(day, dayStats[day]) +
|
||||
"</header>" +
|
||||
'<div class="quotes-day-cards">' +
|
||||
cards +
|
||||
"</div></section>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
elFeed.querySelectorAll(".quotes-expand-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const id = btn.getAttribute("data-id");
|
||||
expanded[id] = !expanded[id];
|
||||
renderFeed();
|
||||
});
|
||||
});
|
||||
elFeed.querySelectorAll(".quotes-ai-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
startQuoteAiChat(btn.getAttribute("data-id"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startQuoteAiChat(quoteId) {
|
||||
const q = findQuote(quoteId);
|
||||
const content = q && String(q.content || "").trim();
|
||||
if (!q || !content) {
|
||||
setStatus("语录内容为空,无法发起 AI 对话");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
ARCHIVE_QUOTE_AI_KEY,
|
||||
JSON.stringify({
|
||||
quote_date: q.quote_date || "",
|
||||
content: content,
|
||||
})
|
||||
);
|
||||
} catch (_) {
|
||||
setStatus("无法保存跳转数据");
|
||||
return;
|
||||
}
|
||||
if (typeof window.hubNavigateTo === "function") {
|
||||
window.hubNavigateTo("/ai");
|
||||
return;
|
||||
}
|
||||
location.href = "/ai";
|
||||
}
|
||||
|
||||
async function loadDayStats(days) {
|
||||
const uniq = [];
|
||||
const seen = {};
|
||||
(days || []).forEach(function (d) {
|
||||
const day = String(d || "").slice(0, 10);
|
||||
if (!day || day === "—" || seen[day]) return;
|
||||
seen[day] = true;
|
||||
uniq.push(day);
|
||||
});
|
||||
await Promise.all(
|
||||
uniq.map(async function (day) {
|
||||
if (dayStats[day]) return;
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
q.set("period", "today");
|
||||
q.set("trading_day", day);
|
||||
const r = await apiFetch("/api/archive/daily-trades?" + q.toString());
|
||||
const j = await r.json();
|
||||
if (r.ok) {
|
||||
dayStats[day] = j.stats || { open_count: 0, pnl_total: 0, win_rate: null };
|
||||
} else {
|
||||
dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null };
|
||||
}
|
||||
} catch (_) {
|
||||
dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function loadQuotes() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
setStatus("加载语录…");
|
||||
try {
|
||||
const r = await apiFetch("/api/archive/quotes");
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
setStatus(j.detail || "加载失败");
|
||||
return;
|
||||
}
|
||||
quotes = (j.quotes || []).slice(0, RECENT_LIMIT);
|
||||
const days = quotes.map(function (q) {
|
||||
return q.quote_date;
|
||||
});
|
||||
renderFeed();
|
||||
await loadDayStats(days);
|
||||
renderFeed();
|
||||
setStatus("最近 " + quotes.length + " 条 · " + new Date().toLocaleTimeString());
|
||||
} catch (e) {
|
||||
setStatus(String(e && e.message ? e.message : e) || "加载失败");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadQuotes);
|
||||
if (elLinkArchive) {
|
||||
elLinkArchive.addEventListener("click", function (ev) {
|
||||
if (typeof window.hubNavigateTo === "function") {
|
||||
ev.preventDefault();
|
||||
window.hubNavigateTo("/archive");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!page || page.classList.contains("hidden")) return;
|
||||
if (!inited) {
|
||||
bindEvents();
|
||||
inited = true;
|
||||
}
|
||||
await loadQuotes();
|
||||
}
|
||||
|
||||
function destroy() {}
|
||||
|
||||
window.hubQuotesPage = { init: init, destroy: destroy };
|
||||
})();
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 策略说明:三所 MD + 开仓检查清单 JSON.
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-strategy");
|
||||
if (!page) return;
|
||||
|
||||
const tabsEl = document.getElementById("strategy-tabs");
|
||||
const statusEl = document.getElementById("strategy-load-status");
|
||||
const docBody = document.getElementById("strategy-doc-body");
|
||||
const docSource = document.getElementById("strategy-doc-source");
|
||||
const docCard = page.querySelector(".strategy-doc-card");
|
||||
const checklistCard = page.querySelector(".strategy-checklist-card");
|
||||
const checklistTitle = document.getElementById("strategy-checklist-title");
|
||||
const checklistBody = document.getElementById("strategy-checklist-body");
|
||||
const footnotesEl = document.getElementById("strategy-checklist-footnotes");
|
||||
const btnPrintDoc = document.getElementById("strategy-btn-print-doc");
|
||||
const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist");
|
||||
const btnDownload = document.getElementById("strategy-btn-download");
|
||||
|
||||
let activeKey = "binance";
|
||||
let tabsMeta = [];
|
||||
let cache = {};
|
||||
let bound = false;
|
||||
let heightSyncRaf = 0;
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
|
||||
const ct = (r.headers.get("content-type") || "").toLowerCase();
|
||||
if (ct.includes("application/json")) {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error((data && data.msg) || r.statusText || "请求失败");
|
||||
return data;
|
||||
}
|
||||
if (!r.ok) throw new Error(r.statusText || "请求失败");
|
||||
return r;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function syncDocCardHeight() {
|
||||
if (!docCard || !checklistCard || window.matchMedia("(max-width: 960px)").matches) {
|
||||
if (docCard) docCard.style.height = "";
|
||||
return;
|
||||
}
|
||||
docCard.style.height = `${checklistCard.offsetHeight}px`;
|
||||
}
|
||||
|
||||
function scheduleHeightSync() {
|
||||
if (heightSyncRaf) cancelAnimationFrame(heightSyncRaf);
|
||||
heightSyncRaf = requestAnimationFrame(() => {
|
||||
heightSyncRaf = 0;
|
||||
syncDocCardHeight();
|
||||
});
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
if (!tabsEl) return;
|
||||
tabsEl.innerHTML = tabsMeta
|
||||
.map(
|
||||
(t) =>
|
||||
`<button type="button" class="strategy-tab${t.key === activeKey ? " is-active" : ""}" role="tab" aria-selected="${t.key === activeKey}" data-key="${esc(t.key)}">${esc(t.label)}</button>`
|
||||
)
|
||||
.join("");
|
||||
tabsEl.querySelectorAll(".strategy-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const key = btn.getAttribute("data-key");
|
||||
if (!key || key === activeKey) return;
|
||||
activeKey = key;
|
||||
renderTabs();
|
||||
void loadExchange(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderChecklist(checklist) {
|
||||
const cl = checklist || {};
|
||||
const title = cl.title || "开仓检查清单";
|
||||
if (checklistTitle) checklistTitle.textContent = title;
|
||||
if (!checklistBody) return;
|
||||
const groups = cl.groups || [];
|
||||
if (!groups.length) {
|
||||
checklistBody.innerHTML = '<p class="strategy-empty">暂无检查清单</p>';
|
||||
} else {
|
||||
checklistBody.innerHTML = groups
|
||||
.map((grp) => {
|
||||
const items = (grp.items || [])
|
||||
.map((item) => `<li><span class="strategy-check-box" aria-hidden="true">☐</span>${esc(item)}</li>`)
|
||||
.join("");
|
||||
return `<div class="strategy-check-group"><h4>${esc(grp.title || "")}</h4><ul>${items}</ul></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (footnotesEl) {
|
||||
const notes = cl.footnotes || [];
|
||||
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
|
||||
footnotesEl.classList.toggle("hidden", !notes.length);
|
||||
}
|
||||
scheduleHeightSync();
|
||||
}
|
||||
|
||||
function renderPayload(data) {
|
||||
if (docBody) docBody.innerHTML = data.strategy_html || "";
|
||||
if (docSource) {
|
||||
const ver = data.version ? ` · ${data.version}` : "";
|
||||
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
|
||||
}
|
||||
renderChecklist(data.checklist);
|
||||
scheduleHeightSync();
|
||||
}
|
||||
|
||||
async function loadExchange(key) {
|
||||
if (statusEl) statusEl.textContent = "加载中…";
|
||||
try {
|
||||
let data = cache[key];
|
||||
if (!data) {
|
||||
data = await apiFetch(`/api/strategy/${encodeURIComponent(key)}`);
|
||||
cache[key] = data;
|
||||
}
|
||||
renderPayload(data);
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = String(e);
|
||||
if (docBody) docBody.innerHTML = "";
|
||||
if (checklistBody) checklistBody.innerHTML = "";
|
||||
scheduleHeightSync();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const meta = await apiFetch("/api/strategy/meta");
|
||||
tabsMeta = meta.exchanges || [];
|
||||
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
|
||||
activeKey = tabsMeta[0].key;
|
||||
}
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
function printSection(mode) {
|
||||
const part = mode === "checklist" ? "checklist" : "doc";
|
||||
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
|
||||
const w = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (!w) {
|
||||
if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览";
|
||||
}
|
||||
}
|
||||
|
||||
function bindActions() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
if (btnPrintDoc) btnPrintDoc.addEventListener("click", () => printSection("doc"));
|
||||
if (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist"));
|
||||
if (btnDownload) {
|
||||
btnDownload.addEventListener("click", () => {
|
||||
window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`;
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", scheduleHeightSync);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindActions();
|
||||
try {
|
||||
await loadMeta();
|
||||
await loadExchange(activeKey);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
window.removeEventListener("resize", scheduleHeightSync);
|
||||
}
|
||||
|
||||
window.hubStrategyPage = { init, destroy };
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
/** 中控主题:暗色(默认)/ 亮色,localStorage hub-theme */
|
||||
(function (global) {
|
||||
const KEY = "hub-theme";
|
||||
const META = { dark: "#0b0e18", light: "#d4dde8" };
|
||||
|
||||
function normalize(theme) {
|
||||
return theme === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function get() {
|
||||
try {
|
||||
return normalize(localStorage.getItem(KEY));
|
||||
} catch (_) {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastThemeToInstances() {
|
||||
const msg = { type: "hub-theme-sync", theme: get() };
|
||||
document.querySelectorAll("iframe#instance-frame, iframe.instance-frame").forEach((frame) => {
|
||||
try {
|
||||
if (frame.contentWindow) frame.contentWindow.postMessage(msg, "*");
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
const t = normalize(theme);
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", t);
|
||||
try {
|
||||
localStorage.setItem(KEY, t);
|
||||
} catch (_) {}
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute("content", META[t]);
|
||||
root.style.colorScheme = t;
|
||||
document.dispatchEvent(new CustomEvent("hub-theme-change", { detail: { theme: t } }));
|
||||
broadcastThemeToInstances();
|
||||
return t;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
return apply(get() === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
function syncToggleUI(root) {
|
||||
const scope = root || document;
|
||||
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
||||
const on = btn.getAttribute("data-theme-value") === get();
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function initToggleUI(root) {
|
||||
const scope = root || document;
|
||||
syncToggleUI(scope);
|
||||
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
||||
if (btn.dataset.themeBound === "1") return;
|
||||
btn.dataset.themeBound = "1";
|
||||
btn.addEventListener("click", () => {
|
||||
apply(btn.getAttribute("data-theme-value"));
|
||||
syncToggleUI(scope);
|
||||
});
|
||||
});
|
||||
document.addEventListener("hub-theme-change", () => syncToggleUI(scope));
|
||||
}
|
||||
|
||||
apply(get());
|
||||
global.HubTheme = { KEY, get, apply, toggle, syncToggleUI, initToggleUI };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? "0" + n : String(n);
|
||||
}
|
||||
|
||||
function formatCountdown(sec) {
|
||||
const s = Math.max(0, parseInt(sec, 10) || 0);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
|
||||
}
|
||||
|
||||
function isForceCloseActive(wrap) {
|
||||
if (!wrap) return false;
|
||||
const raw =
|
||||
wrap.dataset.forceCloseActive ||
|
||||
wrap.getAttribute("data-force-close-active") ||
|
||||
"";
|
||||
return raw === "1" || raw === "true";
|
||||
}
|
||||
|
||||
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
|
||||
const cb = document.getElementById(checkboxId);
|
||||
const sel = document.getElementById(selectId);
|
||||
const wrap = wrapId ? document.getElementById(wrapId) : null;
|
||||
if (!cb || !sel) return;
|
||||
function sync() {
|
||||
const on = !!cb.checked;
|
||||
sel.disabled = false;
|
||||
sel.tabIndex = 0;
|
||||
if (wrap) wrap.classList.toggle("is-disabled", !on);
|
||||
}
|
||||
sel.addEventListener("mousedown", function (ev) {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
sel.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
cb.addEventListener("change", sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
function paintCountdownEl(cd, rem, active) {
|
||||
if (!cd) return;
|
||||
if (active) {
|
||||
cd.textContent = "执行中";
|
||||
return;
|
||||
}
|
||||
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
|
||||
}
|
||||
|
||||
function paintOrderTimeClose(order) {
|
||||
if (!order || order.id == null) return;
|
||||
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
|
||||
const cd = document.getElementById("order-time-close-cd-" + order.id);
|
||||
if (!wrap || !cd) return;
|
||||
const enabled = !!(order.time_close_enabled || order.time_close_at_ms);
|
||||
if (!enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const hours = order.time_close_hours;
|
||||
const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓");
|
||||
const labelEl = wrap.querySelector(".pos-time-close-label");
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
let rem =
|
||||
order.time_close_remaining_sec != null
|
||||
? Number(order.time_close_remaining_sec)
|
||||
: null;
|
||||
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
|
||||
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
|
||||
}
|
||||
paintCountdownEl(cd, rem, false);
|
||||
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
|
||||
}
|
||||
|
||||
function paintOrderForceClose(order) {
|
||||
if (!order || order.id == null) return;
|
||||
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
|
||||
const cd = document.getElementById("order-force-close-cd-" + order.id);
|
||||
if (!wrap || !cd) return;
|
||||
const enabled = !!order.force_close_enabled;
|
||||
if (!enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const label = order.force_close_label || "强制清仓";
|
||||
const labelEl = wrap.querySelector(".pos-force-close-label");
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
let rem =
|
||||
order.force_close_remaining_sec != null
|
||||
? Number(order.force_close_remaining_sec)
|
||||
: null;
|
||||
const atMs = order.force_close_at_ms;
|
||||
if ((rem == null || !Number.isFinite(rem)) && atMs) {
|
||||
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
|
||||
}
|
||||
const active = !!order.force_close_active;
|
||||
paintCountdownEl(cd, rem, active);
|
||||
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
|
||||
wrap.dataset.forceCloseActive = active ? "1" : "0";
|
||||
}
|
||||
|
||||
function paintForceCloseHeader(state) {
|
||||
const wrap = document.getElementById("force-close-header-badge");
|
||||
if (!wrap) return;
|
||||
if (!state || !state.enabled) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
const label = state.label || "强制清仓";
|
||||
const labelPrefix = label + " 已开启 · ";
|
||||
let prefixNode = wrap.querySelector(".force-close-header-prefix");
|
||||
if (!prefixNode) {
|
||||
wrap.textContent = "";
|
||||
prefixNode = document.createElement("span");
|
||||
prefixNode.className = "force-close-header-prefix";
|
||||
prefixNode.textContent = labelPrefix;
|
||||
wrap.appendChild(prefixNode);
|
||||
const cd = document.createElement("span");
|
||||
cd.className = "force-close-header-cd";
|
||||
wrap.appendChild(cd);
|
||||
} else {
|
||||
prefixNode.textContent = labelPrefix;
|
||||
}
|
||||
const cd = wrap.querySelector(".force-close-header-cd");
|
||||
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
|
||||
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
|
||||
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
|
||||
}
|
||||
paintCountdownEl(cd, rem, !!state.active);
|
||||
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
|
||||
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
|
||||
}
|
||||
|
||||
function tickLocalCountdowns() {
|
||||
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
|
||||
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
|
||||
const cd = wrap.querySelector(".pos-time-close-cd");
|
||||
if (!cd) return;
|
||||
const closeAt = Number(closeAtRaw);
|
||||
if (!closeAt) return;
|
||||
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
|
||||
cd.textContent = formatCountdown(rem);
|
||||
});
|
||||
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
|
||||
const closeAtRaw =
|
||||
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
|
||||
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
|
||||
if (!cd) return;
|
||||
const closeAt = Number(closeAtRaw);
|
||||
if (!closeAt) return;
|
||||
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
|
||||
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
|
||||
});
|
||||
}
|
||||
|
||||
function paintOrders(orders) {
|
||||
(orders || []).forEach(function (order) {
|
||||
paintOrderTimeClose(order);
|
||||
paintOrderForceClose(order);
|
||||
});
|
||||
}
|
||||
|
||||
function syncKeyTimeCloseVisibility(show) {
|
||||
const wrap = document.getElementById("key-time-close-wrap");
|
||||
if (!wrap) return;
|
||||
wrap.style.display = show ? "inline-flex" : "none";
|
||||
}
|
||||
|
||||
global.TimeCloseUI = {
|
||||
bindTimeCloseForm: bindTimeCloseForm,
|
||||
paintOrderTimeClose: paintOrderTimeClose,
|
||||
paintOrderForceClose: paintOrderForceClose,
|
||||
paintForceCloseHeader: paintForceCloseHeader,
|
||||
paintOrders: paintOrders,
|
||||
tickLocalCountdowns: tickLocalCountdowns,
|
||||
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
|
||||
formatCountdown: formatCountdown,
|
||||
};
|
||||
|
||||
if (!global.__timeCloseCountdownTimer) {
|
||||
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,61 @@
|
||||
"""将 127.0.0.1 服务地址转为浏览器可访问的外链(内网 IP 或域名)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
|
||||
def public_origin() -> tuple[str, str] | None:
|
||||
"""
|
||||
从环境变量读取对外 Origin.
|
||||
HUB_PUBLIC_ORIGIN=http://192.168.1.10 或 HUB_PUBLIC_HOST=192.168.1.10
|
||||
"""
|
||||
raw = (os.getenv("HUB_PUBLIC_ORIGIN") or os.getenv("HUB_PUBLIC_HOST") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if "://" in raw:
|
||||
p = urlparse(raw)
|
||||
scheme = (p.scheme or "http").strip()
|
||||
host = (p.hostname or "").strip()
|
||||
if not host:
|
||||
return None
|
||||
return scheme, host
|
||||
scheme = (os.getenv("HUB_PUBLIC_SCHEME") or "http").strip() or "http"
|
||||
host = raw.split("/")[0].split(":")[0].strip()
|
||||
return (scheme, host) if host else None
|
||||
|
||||
|
||||
def browser_url(internal_url: str | None) -> str:
|
||||
"""
|
||||
中控本机请求仍用 internal_url;返回给前端,复盘链接用本函数.
|
||||
若未配置 HUB_PUBLIC_* 或原 URL 已是非本机地址,则原样返回.
|
||||
"""
|
||||
if not internal_url or not str(internal_url).strip():
|
||||
return ""
|
||||
u = str(internal_url).strip()
|
||||
origin = public_origin()
|
||||
if not origin:
|
||||
return u
|
||||
scheme_pub, host_pub = origin
|
||||
try:
|
||||
p = urlparse(u)
|
||||
except Exception:
|
||||
return u
|
||||
if not p.scheme or not p.netloc:
|
||||
return u
|
||||
host = (p.hostname or "").lower()
|
||||
if host not in _LOCAL_HOSTS and not host.startswith("::ffff:127.0.0.1"):
|
||||
return u
|
||||
port = p.port
|
||||
netloc = f"{host_pub}:{port}" if port else host_pub
|
||||
return urlunparse((scheme_pub, netloc, p.path or "", p.params, p.query, p.fragment))
|
||||
|
||||
|
||||
def default_review_url(flask_url: str | None) -> str:
|
||||
base = browser_url((flask_url or "").rstrip("/"))
|
||||
if not base:
|
||||
return ""
|
||||
return f"{base}/records"
|
||||
@@ -0,0 +1,289 @@
|
||||
# 云服务器部署说明
|
||||
|
||||
本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与三实例的推荐配置:硬件,软件,防火墙,宝塔反代,环境变量,PM2 启动与验收.
|
||||
|
||||
云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100,5000~5004,15200~15202)**不对公网直连**.
|
||||
|
||||
相关文档:
|
||||
|
||||
- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`,图片,`hub_settings` 与恢复步骤
|
||||
- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照,SSO 行为
|
||||
- [部署文档.md](./部署文档.md) — PM2,依赖安装,日常运维
|
||||
- [使用说明.md](./使用说明.md) — 中控功能说明
|
||||
- [常见问题.md](./常见问题.md) — 故障排查
|
||||
- 环境变量模板:[.env.example](./.env.example)
|
||||
|
||||
---
|
||||
|
||||
## 一,服务器硬件与系统
|
||||
|
||||
| 项目 | 建议 |
|
||||
|------|------|
|
||||
| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G~8G 更稳** |
|
||||
| 系统 | **Ubuntu 22.04 / 24.04**(项目文档按 Linux 编写) |
|
||||
| 磁盘 | **20G+**;日志,SQLite,上传图片会占空间 |
|
||||
| 网络 | 需能访问各交易所 API;若走代理,在对应 `crypto_monitor_*/.env` 配置 `OKX_SOCKS_PROXY`,`BINANCE_SOCKS_PROXY` 等 |
|
||||
|
||||
---
|
||||
|
||||
## 二,软件环境
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip git curl
|
||||
|
||||
# 进程守护(推荐)
|
||||
sudo npm i -g pm2
|
||||
```
|
||||
|
||||
**宝塔面板(可选但推荐)**:安装 **Nginx**,用于反向代理与 **SSL**(Let’s Encrypt).
|
||||
|
||||
Python 虚拟环境(分开安装,互不替代):
|
||||
|
||||
| 目录 | 用途 |
|
||||
|------|------|
|
||||
| `manual_trading_hub/.venv` | 中控 `hub.py` + 子代理 `agent.py` |
|
||||
| `crypto_monitor_binance/.venv` | 币安 Flask |
|
||||
| `crypto_monitor_okx/.venv` | OKX Flask |
|
||||
| `crypto_monitor_gate/.venv` | Gate Flask |
|
||||
| `crypto_monitor_gate/.venv` | Gate Flask |
|
||||
|
||||
各实例 `ecosystem.config.cjs` 一般已设置 **`PYTHONPATH=..`**(仓库根),以便加载 `hub_bridge.py`,`hub_auth.py` 等.
|
||||
|
||||
---
|
||||
|
||||
## 三,网络与端口(云上最重要)
|
||||
|
||||
**原则:公网只暴露 Nginx 的 80/443;Flask 与 agent 只监听本机.**
|
||||
|
||||
| 服务 | 本机端口(示例) | 是否对公网开放 |
|
||||
|------|------------------|----------------|
|
||||
| 中控 hub | 5100 | **否** → 仅 `https://hub.你的域名` 反代 |
|
||||
| 币安 Flask | 5001 | **否** → `https://binance.你的域名` |
|
||||
| OKX Flask | 5004 | **否** → `https://okx.你的域名` |
|
||||
| Gate Flask | 5000 | **否** → `https://gate.你的域名` |
|
||||
| 子代理 agent | 15200~15202 | **否**,必须 **127.0.0.1** |
|
||||
|
||||
### 云厂商安全组 / 系统防火墙
|
||||
|
||||
- **放行**:`80`,`443`(给宝塔/Nginx)
|
||||
- **不要放行**:`5100`,`5000`~`5004`,`15200`~`15202`(除非临时本机调试,用完即关)
|
||||
|
||||
---
|
||||
|
||||
## 四,域名与宝塔反代
|
||||
|
||||
为 **中控 + 每个要对外打开的实例** 各建一个站点(子域名示例):
|
||||
|
||||
| 站点(浏览器访问) | 反代目标 |
|
||||
|--------------------|----------|
|
||||
| `https://hub.example.com` | `http://127.0.0.1:5100` |
|
||||
| `https://okx.example.com` | `http://127.0.0.1:5004` |
|
||||
| `https://binance.example.com` | `http://127.0.0.1:5001` |
|
||||
| `https://gate.example.com` | `http://127.0.0.1:5000` |
|
||||
|
||||
### 宝塔操作要点
|
||||
|
||||
1. 每个域名 → **网站** → **反向代理** → 目标 `http://127.0.0.1:对应端口`.
|
||||
2. 申请 **SSL**(Let’s Encrypt),强制 HTTPS.
|
||||
3. **不要**再给实例站加一层宝塔「访问密码」(会与 Flask `/login` 重复);直链鉴权用下文 **`APP_USERNAME` / `APP_PASSWORD`**.
|
||||
4. Nginx 建议保留常见代理头(宝塔默认通常已带):
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
中控请求实例 `/api/hub/*` 时会带 **`X-Hub-Token`**,一般无需额外配置.
|
||||
|
||||
---
|
||||
|
||||
## 五,环境变量(必配)
|
||||
|
||||
### 5.1 中控 `manual_trading_hub/.env`
|
||||
|
||||
```env
|
||||
HUB_HOST=0.0.0.0
|
||||
HUB_PORT=5100
|
||||
|
||||
# 与三实例 .env 完全相同(API + SSO 签名)
|
||||
HUB_BRIDGE_TOKEN=请填一长串随机字符
|
||||
|
||||
# 中控网页登录(公网务必设置)
|
||||
HUB_USERNAME=admin
|
||||
HUB_PASSWORD=强密码
|
||||
HUB_SESSION_SECRET=另一串随机字符
|
||||
|
||||
# 中控为 HTTPS 时建议 true
|
||||
HUB_COOKIE_SECURE=true
|
||||
|
||||
# 公网用域名访问中控(宝塔反代)时必设其一:
|
||||
# HUB_ALLOW_PUBLIC=true (推荐:反代 + 中控密码)
|
||||
# 或反代目标必须是 http://127.0.0.1:5100 且可保持 HUB_TRUST_LAN=false
|
||||
HUB_ALLOW_PUBLIC=true
|
||||
HUB_TRUST_LAN=false
|
||||
|
||||
# 从中控打开实例的 SSO 链接有效期(秒),默认 7200 = 2 小时
|
||||
HUB_SSO_TTL_SEC=7200
|
||||
|
||||
# 各实例 hub_settings 里 flask_url 已写 https 域名时,一般可不设
|
||||
# HUB_PUBLIC_ORIGIN=https://hub.example.com
|
||||
```
|
||||
|
||||
完整项见 [`.env.example`](./.env.example).
|
||||
|
||||
### 5.2 三个实例 `crypto_monitor_*/.env`
|
||||
|
||||
每个目录都要有(**直链** `https://okx.域名` 时用这套登录网页):
|
||||
|
||||
```env
|
||||
# 各所 API 密钥(按交易所填写)
|
||||
# APP_PORT=5004
|
||||
|
||||
# 与中控 manual_trading_hub/.env 中 HUB_BRIDGE_TOKEN 完全一致
|
||||
HUB_BRIDGE_TOKEN=与中控相同
|
||||
|
||||
# 三实例建议统一(直链登录用)
|
||||
APP_USERNAME=统一用户名
|
||||
APP_PASSWORD=统一强密码
|
||||
|
||||
# 云服务器切勿开启(会跳过网页登录):
|
||||
# APP_AUTH_DISABLED=true
|
||||
```
|
||||
|
||||
### 5.3 子代理
|
||||
|
||||
- `CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同.
|
||||
- 由 PM2 在对应 `crypto_monitor_*` 目录启动,`run_agent.sh` 加载该目录 `.env`.
|
||||
- 只监听 **127.0.0.1:1520x**,不映射到公网.
|
||||
|
||||
---
|
||||
|
||||
## 六,中控「系统设置」`hub_settings.json`
|
||||
|
||||
在网页 **系统设置** 保存,或编辑 `manual_trading_hub/hub_settings.json`.
|
||||
|
||||
云上 **`flask_url` 必须写浏览器能打开的 HTTPS 域名**(不要写 `127.0.0.1`,除非配合 `HUB_PUBLIC_ORIGIN` 做替换):
|
||||
|
||||
| 字段 | 云上填法 | 说明 |
|
||||
|------|----------|------|
|
||||
| `flask_url` | `https://okx.example.com` | 用户浏览器,SSO 打开实例 |
|
||||
| `agent_url` | `http://127.0.0.1:15201` | 仅中控本机访问子代理 |
|
||||
| `enabled` | 按需 | 不参与监控的户可关 |
|
||||
| `capabilities` | 按需 | `key` / `trend` 等 |
|
||||
|
||||
**同机部署的两种写法(二选一):**
|
||||
|
||||
1. **推荐**:每个实例 `flask_url` 直接写该实例的 `https://子域名`.
|
||||
2. **备选**:`flask_url` 写 `http://127.0.0.1:5004`,中控 `.env` 设 `HUB_PUBLIC_ORIGIN=https://okx.example.com`(适合共用一个 IP,靠端口区分时).
|
||||
|
||||
`agent_url` 始终用 **`http://127.0.0.1:1520x`**.
|
||||
|
||||
---
|
||||
|
||||
## 七,PM2 启动顺序
|
||||
|
||||
代码路径示例:`/opt/crypto_monitor_user/`(按实际替换).
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user
|
||||
|
||||
# 1)三个实例 Flask(各目录 ecosystem.config.cjs,进程名以你机器为准)
|
||||
cd crypto_monitor_okx && pm2 start ecosystem.config.cjs
|
||||
cd ../crypto_monitor_binance && pm2 start ecosystem.config.cjs
|
||||
cd ../crypto_monitor_gate && pm2 start ecosystem.config.cjs
|
||||
|
||||
# 2)中控 + 三个子代理(一条拉起 4 个进程:hub + 3 agent)
|
||||
cd ../manual_trading_hub
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # 编辑填入真实值
|
||||
chmod +x scripts/run_hub.sh scripts/run_agent.sh
|
||||
pm2 start ecosystem.config.cjs
|
||||
pm2 save
|
||||
pm2 startup # 按提示执行 sudo 命令后再 pm2 save
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
bash scripts/pm2_hub.sh start
|
||||
```
|
||||
|
||||
### PM2 进程一览
|
||||
|
||||
| 进程名 | 说明 |
|
||||
|--------|------|
|
||||
| `manual-trading-hub` | 中控 :5100 |
|
||||
| `manual-agent-binance` | :15200 |
|
||||
| `manual-agent-okx` | :15201 |
|
||||
| `manual-agent-gate` | :15202 |
|
||||
| `crypto_*`(各目录自定) | 各 Flask `APP_PORT` |
|
||||
|
||||
不用 OKX 时可在 `.env` 设 `HUB_DISABLED_IDS=1`,或 `pm2 stop manual-agent-okx`.
|
||||
|
||||
---
|
||||
|
||||
## 八,访问与登录(云上行为)
|
||||
|
||||
| 访问方式 | 地址示例 | 需要什么 |
|
||||
|----------|----------|----------|
|
||||
| 中控监控 | `https://hub.example.com/monitor` | **中控** `HUB_USERNAME` / `HUB_PASSWORD` |
|
||||
| 中控点「实例 / 策略交易 / 复盘」 | 自动打开 `https://okx.example.com/hub-sso?...` | 已登中控即可;**2 小时内,单次** SSO,**免输**实例密码 |
|
||||
| 浏览器直链实例 | `https://okx.example.com` | 实例 **`APP_USERNAME` / `APP_PASSWORD`**(`/login`) |
|
||||
|
||||
SSO 复用 **`HUB_BRIDGE_TOKEN`** 签名,详见 [局域网与反代部署说明.md §五](./局域网与反代部署说明.md).
|
||||
|
||||
---
|
||||
|
||||
## 九,安全建议(云服务器必看)
|
||||
|
||||
1. **SSH**:密钥登录,关闭密码登录;必要时改 SSH 端口.
|
||||
2. **`HUB_BRIDGE_TOKEN`**:足够长,随机;勿提交 Git,勿写进前端页面.
|
||||
3. **交易所 API Key**:仅放在各实例 `.env`;权限尽量最小化(勿随意开提币).
|
||||
4. **中控**:公网必须设 `HUB_PASSWORD`;`HUB_TRUST_LAN=false`.
|
||||
5. **实例**:云上 **`APP_AUTH_DISABLED` 必须为 false**(或未设置).
|
||||
6. **备份**:定期备份各实例数据库 / SQLite 与 `hub_settings.json`.
|
||||
7. **`.env` 换行**:Linux 上勿用 Windows CRLF;可用 `bash scripts/fix_env_crlf.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 十,部署后验收清单
|
||||
|
||||
- [ ] `https://hub.你的域名` 能打开并登录中控
|
||||
- [ ] 监控卡片有持仓/余额(子代理在线)
|
||||
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页,直接进入
|
||||
- [ ] 隐身窗口直开 `https://okx.你的域名` → 出现 **`/login`**,统一账号密码可进
|
||||
- [ ] `pm2 status`:hub,4×agent,用到的 `crypto_*` 均为 online
|
||||
- [ ] 云安全组 **未** 对公网开放 5100,5000~5004,15200~15202
|
||||
- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
|
||||
- [ ] 实例启动日志无长期 `[hub_bridge] ImportError`
|
||||
|
||||
---
|
||||
|
||||
## 十一,常见问题速查
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 从中控打开仍要实例密码 | 见 [常见问题.md §4.3](./常见问题.md);检查 token,重启 Flask,`hub_settings` 的 `key` |
|
||||
| 监控无持仓 / 子代理不可用 | `curl http://127.0.0.1:15201/status`;查 `.env` CRLF,API 密钥 |
|
||||
| 复盘/实例链接是 127.0.0.1 | `flask_url` 改为 https 域名,或设 `HUB_PUBLIC_ORIGIN` |
|
||||
| 仅 Gate 子代理反复重启 | `.env` CRLF:`bash manual_trading_hub/scripts/fix_env_crlf.sh` |
|
||||
|
||||
---
|
||||
|
||||
## 十二,与局域网部署的区别(简要)
|
||||
|
||||
| 项目 | 云服务器 | 局域网 |
|
||||
|------|----------|--------|
|
||||
| 对外地址 | `https://子域名` | `http://内网IP:端口` |
|
||||
| `flask_url` | 写 **域名** | 写 **内网 IP:端口** |
|
||||
| 防火墙 | 只开 80/443 | 内网可开 5100,500x |
|
||||
| SSL | 必须(宝塔证书) | 通常 HTTP 即可 |
|
||||
| `HUB_COOKIE_SECURE` | 建议 `true` | HTTP 时用 `false` |
|
||||
|
||||
局域网详细步骤见 [局域网与反代部署说明.md §三](./局域网与反代部署说明.md).
|
||||
@@ -0,0 +1,84 @@
|
||||
# 交易监管(AI 教练)
|
||||
|
||||
中控 **交易监管** 用于防止过度交易与频繁手动操作:在 **手动/中控开平仓** 与 **新开仓** 时自动推送至 **今日监管长会话**,并可选 **企业微信** 提醒;程序止盈/止损按「正常执行」鼓励,不计入频繁交易统计.
|
||||
|
||||
入口:**AI 教练**(`/ai`)→ Tab **交易监管**,或微信链接(在系统设置中配置).
|
||||
|
||||
## 监管范围
|
||||
|
||||
| 类型 | 识别 | 页内推送 | 微信(P0) | 频率统计 |
|
||||
|------|------|----------|------------|----------|
|
||||
| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ |
|
||||
| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ |
|
||||
| 新开仓 | 监控板持仓 diff(0→有仓 / 新合约) | ✓ | ✓ | ✓ |
|
||||
| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ |
|
||||
| 程序止损 | 止损 | ✓ | 可选 | ✗ |
|
||||
| 外部平仓 | 外部平仓,时间平仓 | ✗ | ✗ | ✗ |
|
||||
|
||||
频率规则(间隔过短,30 分钟笔数,日笔数,连亏,平后快开)**只对手动/中控开平** 叠加 `[监管·频率]` 警告.
|
||||
|
||||
## 会话
|
||||
|
||||
- 每个交易日 **一条长会话**(`bot_mode: supervisor`,标题 `今日监管 YYYY-MM-DD`).
|
||||
- 系统消息(`role: system`)+ AI 短评(`assistant`)+ 用户回复(`user`)同线程.
|
||||
- 与 **交易教练 / 普通聊天** 分离;监管会话不支持「新开对话」.
|
||||
|
||||
## 系统设置
|
||||
|
||||
路径:**系统设置** → **交易监管 · 企业微信**(写入 `hub_settings.json` → `supervisor`).
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `enabled` | 总开关 |
|
||||
| `wechat_webhook` | **监管专用** 企业微信机器人(与三所实例 `.env` 的 `WECHAT_WEBHOOK` 独立) |
|
||||
| `wechat_link_base` | 微信消息末尾跳转链接(**可单独修改**,如 `https://域名/ai?mode=supervisor`) |
|
||||
| `wechat_prefix` | 消息前缀,默认 `【交易监管】` |
|
||||
| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 |
|
||||
| `manual_close_daily_warn` | 日手动平警告阈值(默认 2) |
|
||||
| `interval_warn_minutes` | 两笔手动/中控平最短间隔(默认 15 分钟) |
|
||||
| `freq_30m_count` | 30 分钟内笔数阈值(默认 2) |
|
||||
| `reopen_after_close_minutes` | 手动平后再开仓警告间隔(默认 30 分钟) |
|
||||
|
||||
`.env` 兜底(设置页保存优先):
|
||||
|
||||
```env
|
||||
SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...
|
||||
SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor
|
||||
SUPERVISOR_POLL_INTERVAL_SEC=30
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/ai/supervisor/session` | 今日监管会话 |
|
||||
| GET | `/api/ai/supervisor/stream` | SSE 版本推送 |
|
||||
| POST | `/api/ai/supervisor/chat/send` | 用户回聊(JSON `{ "message": "..." }`) |
|
||||
| GET | `/api/ai/supervisor/rules` | 当前阈值 |
|
||||
| POST | `/api/ai/supervisor/refresh` | 立即扫描 |
|
||||
|
||||
## 存储
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `hub_supervisor_state.json` | 已处理事件,持仓快照,频率统计 |
|
||||
| `hub_ai_chat.json` | 监管会话(`bot_mode: supervisor`) |
|
||||
| `hub_settings.json` | `supervisor` 配置节 |
|
||||
|
||||
**首次启用** 会对当前交易日已有平仓做 **种子同步**(不补发历史推送),避免部署瞬间刷屏.
|
||||
|
||||
## 与实例风控
|
||||
|
||||
实例 `account_risk_lib`(冷静期 / 日冻结)为 **硬拦截**;监管为 **软提醒 + 陪聊**,不绕过实例开仓限制.
|
||||
|
||||
## 代码位置
|
||||
|
||||
| 模块 | 路径 |
|
||||
|------|------|
|
||||
| 规则与推送 | `hub_supervisor_lib.py` |
|
||||
| 后台扫描 | `hub_supervisor_cache.py` |
|
||||
| 会话 | `hub_ai/supervisor_store.py` |
|
||||
| AI 评语/回聊 | `hub_ai/supervisor.py` |
|
||||
| 提示词 | `hub_ai/prompts.py` → `SUPERVISOR_SYSTEM` |
|
||||
|
||||
部署后重启中控:`pm2 restart manual-trading-hub`(或你的 hub 进程名).
|
||||
@@ -0,0 +1,518 @@
|
||||
# 多账户交易中控 — 使用说明
|
||||
|
||||
本文档说明 **manual_trading_hub** 的架构,启动方式,界面操作与故障排查.中控聚合三所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **资金概况**,**行情区 K 线** 与 **内照明心(复盘语录 + 永久 K 线)**;**人工下单,关键位,策略交易(趋势回调 / 顺势加仓),交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**).资金概况见 **[资金概况说明.md](./资金概况说明.md)**;行情区细则见 **[行情区说明.md](./行情区说明.md)**;内照明心见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
```
|
||||
浏览器
|
||||
├─ /funds 资金概况
|
||||
├─ /plan 开仓计划(计划录入 / 进行中 / 历史胜率)
|
||||
├─ /monitor 监控区(持仓,关键位,趋势计划,全平)
|
||||
├─ /market 行情区(K 线,技术指标,持仓价格线)
|
||||
├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线)
|
||||
├─ /funds 资金概况(总资金曲线,分户资金与回撤)
|
||||
├─ /dashboard 数据看板(三户当日总览,SSE 推送;见 [数据看板说明.md](./数据看板说明.md))
|
||||
├─ /ai AI 教练(交易教练 / 普通聊天;见 [AI教练说明.md](./AI教练说明.md))
|
||||
└─ /settings 系统设置(hub_settings.json)
|
||||
|
||||
中控 hub.py(默认 :5100)
|
||||
├─ HTTP → 子代理 agent.py × N(/status,/emergency/close-all)
|
||||
└─ HTTP → 各实例 Flask(/api/hub/monitor,/api/price_snapshot 等只读聚合)
|
||||
```
|
||||
|
||||
| 组件 | 职责 | 默认端口(可在设置页改) |
|
||||
|------|------|-------------------------|
|
||||
| **hub.py** | 聚合 UI,监控 API,全平 | `5100` |
|
||||
| **agent.py** | 交易所只读状态,挂单/条件单查询与撤销 + 紧急市价全平 | 币安 `15200`,OKX `15201`,Gate `15202` |
|
||||
| **crypto_monitor_*.app** | 策略库,关键位,人工单,趋势预览/执行 | 币安 `5001`,Gate `5000`,OKX `5004` |
|
||||
|
||||
### 1.1 三账户默认配置
|
||||
|
||||
| id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 |
|
||||
|----|------|-------|-------|------------------------|----------|
|
||||
| 0 | 币安 | :5001 | :15200 | 关键位 + 趋势 | 是 |
|
||||
| 1 | OKX | :5004 | :15201 | 关键位 + 趋势 | 是 |
|
||||
| 2 | Gate | :5000 | :15202 | 关键位 + 趋势 | 是 |
|
||||
|
||||
- **三所均已支持** 关键位,策略交易(趋势回调 + 顺势加仓);中控可同时勾 **监控关键位** + **监控趋势计划**(见 §4.2,§5).
|
||||
|
||||
### 1.2 实例侧改动(最小)
|
||||
|
||||
各 `crypto_monitor_*` 仅增加:
|
||||
|
||||
1. `login_required` 走 `hub_auth.request_allowed`(支持请求头 `X-Hub-Token`).
|
||||
2. 文件末尾 `hub_bridge.install_on_app(...)` 注册 `/api/hub/*`.
|
||||
|
||||
业务逻辑,数据库,复盘页面 **未改**;复盘请打开各实例 `/records`(设置里的「复盘链接」).
|
||||
|
||||
---
|
||||
|
||||
## 2. 环境准备
|
||||
|
||||
### 2.1 依赖安装
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2.2 鉴权令牌(推荐生产启用)
|
||||
|
||||
三实例 Flask 与中控,子代理需 **同一密钥**:
|
||||
|
||||
| 变量 | 作用 |
|
||||
|------|------|
|
||||
| `HUB_BRIDGE_TOKEN` | 中控 → Flask 使用头 `X-Hub-Token`;各实例 `hub_auth` 校验 |
|
||||
| `CONTROL_TOKEN` | 可与上相同;中控 → 子代理使用头 `X-Control-Token` |
|
||||
|
||||
中控 `hub.py` 会读取 `HUB_BRIDGE_TOKEN`,若无则回退 `CONTROL_TOKEN`.
|
||||
|
||||
**开发本机**可临时在各实例 `.env` 设 `APP_AUTH_DISABLED=true`,则 Flask 不校验令牌(仍建议子代理设 `CONTROL_TOKEN` 防误暴露).
|
||||
|
||||
### 2.3 强制关闭某账户
|
||||
|
||||
```bash
|
||||
# 在 manual_trading_hub/.env 中设置,或临时:
|
||||
export HUB_DISABLED_IDS=1 # 默认即关闭 OKX(id=1)
|
||||
```
|
||||
|
||||
与设置页「启用」取 **与** 关系:环境变量强制关闭时,网页勾选框会灰掉且无法启用.
|
||||
|
||||
### 2.4 Web 登录(反代公网强烈建议)
|
||||
|
||||
在 `manual_trading_hub/.env` 中配置:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `HUB_USERNAME` | 登录用户名;未设且已设密码时默认为 `admin` |
|
||||
| `HUB_PASSWORD` | **非空即启用登录**;所有页面与 API(除登录页,`/api/ping`,`/assets`)须先登录 |
|
||||
| `HUB_SESSION_SECRET` | 会话签名密钥(建议单独随机串) |
|
||||
| `HUB_COOKIE_SECURE` | 建议 `true`:仅 **HTTPS** 访问时 Cookie 带 Secure;**HTTP 内网 IP:5100 仍可登录** |
|
||||
| `HUB_SESSION_DAYS` | 登录保持天数,默认 `7` |
|
||||
|
||||
- 登录页:`http://<中控地址>:5100/login`
|
||||
- 顶栏 **退出** 清除会话.
|
||||
- **域名(HTTPS)** 与 **内网 IP(HTTP)** Cookie 不共用,需分别登录一次.
|
||||
|
||||
更多登录/Cookie 问题见 **[常见问题.md](./常见问题.md)** 第二节.
|
||||
|
||||
### 2.5 配置文件
|
||||
|
||||
- 路径:`manual_trading_hub/hub_settings.json`(在网页 **系统设置 → 保存设置** 后写入).
|
||||
- 未保存前使用 `settings_store.py` 内置默认三所地址.
|
||||
- 建议 **不要** 把含内网 IP 的 `hub_settings.json` 提交到公开仓库.
|
||||
- 环境变量模板:`manual_trading_hub/.env.example`;三实例模板中已补充 `HUB_BRIDGE_TOKEN` 说明.
|
||||
|
||||
---
|
||||
|
||||
## 3. 启动顺序(Ubuntu + PM2)
|
||||
|
||||
**原则**:代码在 **`/opt/crypto_monitor_user`**,先三实例 Flask,再中控(一条 PM2 含 3 agent + hub).环境见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**.
|
||||
|
||||
```bash
|
||||
# 三所 Flask(示例:币安;其余三所同理)
|
||||
cd /opt/crypto_monitor_user/crypto_monitor_binance
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# 中控 + 子代理
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
pm2 start ecosystem.config.cjs
|
||||
pm2 save
|
||||
```
|
||||
|
||||
浏览器(本机或反代):
|
||||
|
||||
- 监控区:`http://127.0.0.1:5100/monitor`
|
||||
- 行情区:`http://127.0.0.1:5100/market`
|
||||
- 内照明心:`http://127.0.0.1:5100/archive`
|
||||
- 资金概况:`http://127.0.0.1:5100/funds`
|
||||
- 系统设置:`http://127.0.0.1:5100/settings`
|
||||
|
||||
验收:
|
||||
|
||||
```bash
|
||||
bash /opt/crypto_monitor_user/manual_trading_hub/scripts/verify_hub_deploy.sh
|
||||
curl -s http://127.0.0.1:5100/api/ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面操作说明
|
||||
|
||||
Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配置统一品牌图),说明见 **[docs/shortcut-icon.md](../docs/shortcut-icon.md)**.
|
||||
|
||||
### 4.1 监控区 `/monitor`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **服务器状态** | 标题下方可折叠条(**默认收起**),摘要行显示 CPU/内存/硬盘;展开见四指标卡片(`GET /api/host/status`,每 5 秒刷新).**CPU 或内存 ≥85%** 时浏览器弹窗告警(降至 85% 以下后再次超标会再提示).依赖 `manual_trading_hub/.venv` 内 **psutil**(勿用系统 `pip`,见 [部署文档.md](./部署文档.md)).可选 `HUB_HOST_DISK_PATH` 指定监控磁盘 |
|
||||
| **2×2 主界面** | 三所信息**完整展示**:余额,持仓表,委托/平仓,折叠委托单,下单监控,关键位,趋势/加仓摘要 |
|
||||
| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**,**风险%**,**程序监控·止盈价**,**盈亏比**,与实例策略页一致);独立卡片:**关键位**,**下单监控**,**趋势回调**(单计划 **两列**:左=币种基本信息与 3×2 指标,右=**补仓计划明细**,底=**保本偏移%** 可编辑 + **保本移交** / **结束计划**(中控直接调实例,与 `/strategy` 一致),快照可用/计划保证金/杠杆),**顺势加仓** |
|
||||
| **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** |
|
||||
| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) |
|
||||
| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`,`cancel-symbol` |
|
||||
| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate 三所统一,逻辑与各实例 `.env` 参数一致) |
|
||||
| **平仓** | 持仓行「平仓」:仅平该方向仓位(子代理市价减仓) |
|
||||
| **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 |
|
||||
| **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) |
|
||||
| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) |
|
||||
| **实例 / 复盘** | 「实例」「策略交易」「复盘」经中控签发 **SSO 链接**(默认 2h,单次)打开,**免输**实例 `APP_USERNAME/PASSWORD`;直链实例 IP/域名仍走 `/login`.**云服务器**见 **[云服务器部署说明.md](./云服务器部署说明.md)**;局域网/反代见 **[局域网与反代部署说明.md](./局域网与反代部署说明.md)** |
|
||||
| **关键位列表** | 来自 `/api/hub/monitor` + `/api/price_snapshot`;Flask 未连通时卡片提示原因;**Gate 户**无关键位块 |
|
||||
| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 |
|
||||
| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id) |
|
||||
| **自动刷新** | 默认每 5 秒请求 `/api/monitor/board` |
|
||||
|
||||
持仓数据以 **子代理 ccxt** 为准;关键位/趋势/机器人单以 **Flask 数据库** 为准.若 Flask 未启动,卡片仍会显示 agent 持仓,但下方策略信息可能为空或报错.
|
||||
|
||||
### 4.2 行情区 `/market`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **K 线** | 选择已启用交易所 + 币种 + 周期;按需拉取,本地 `data/hub_kline.db` 缓存(默认保留 15 天) |
|
||||
| **周期** | `1m` `5m` `15m` `1h` `2h` `4h` `12h` `1d` `1w` |
|
||||
| **加载 / 强制刷新** | 普通加载优先缓存;强制刷新重拉并覆盖缓存 |
|
||||
| **从监控跳转** | 点击持仓合约名带入品种,并显示入场/止损/止盈/委托与 K 线价格线 |
|
||||
| **技术指标** | 可选 EMA 21/55,MACD,RSI |
|
||||
| **快捷键** | **`F`** 全屏/退出;全屏时 **`Esc`** 退出;数字键切换周期(见 [行情区说明.md](./行情区说明.md)) |
|
||||
| **自动刷新** | 约 5 秒更新最新 OHLCV |
|
||||
|
||||
数据经中控 → 各实例 `GET /api/hub/ohlcv`(`hub_ohlcv_lib`).升级 hub 与三实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**.
|
||||
|
||||
### 4.2.1 内照明心 `/archive`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **复盘语录** | 左栏按日添加/编辑;最多 100 条 |
|
||||
| **日期** | **本日 / 本周 / 本月 / 自选区间**(交易日 8:00 切日) |
|
||||
| **区间统计** | 总开仓,犯病次数与占比,盈亏,剔除犯病盈亏,各交易所分项 |
|
||||
| **筛选** | 盈利单,亏损单,犯病(仅过滤表格;统计栏不受此三项影响) |
|
||||
| **交易记录** | 区间内开仓列表;犯病行红色字体;可编辑备注与犯病标签 |
|
||||
| **K 线** | 默认折叠按需加载;独立库 `data/hub_symbol_archive.db`;仅存 **5m** 真源,**15m/1h/4h** 聚合 |
|
||||
| **建档** | 最早开仓向前 **30 天** 5m 种子;之后每 **4h** 增量(Hub 后台 + 可点「同步」) |
|
||||
| **视窗** | **持仓过程**(锚平仓)/ **进场决策**(锚开仓);支持时间输入跳转 |
|
||||
|
||||
与行情区 `hub_kline.db`(15 天滚动)**分离**,建档起 **只增不删**.细则见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**.
|
||||
|
||||
### 4.2.2 资金概况 `/funds`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **总资金** | 已监控账户的 **资金户 + 交易户** 合计(不含浮盈) |
|
||||
| **总曲线** | 自 **2026-06-09** 起,按北京时间交易日(默认 8:00 切日)每日一点,最多 **180** 天 |
|
||||
| **最大回撤** | 基于总资金余额曲线(非平仓盈亏回撤) |
|
||||
| **分户** | 每户资金/交易拆分,迷你曲线,分户回撤;**未监控** 不参与合计 |
|
||||
| **快照** | 监控板聚合成功时写入 `hub_fund_history.json` |
|
||||
|
||||
细则见 **[资金概况说明.md](./资金概况说明.md)**.
|
||||
|
||||
### 4.2.3 数据看板 `/dashboard`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **总览** | 交易日,平仓盈亏,笔数,浮盈亏,资金合计,持仓数 |
|
||||
| **分户** | 三户资金/交易账户,今日盈亏,浮盈亏;单日亏损 ≥ 资金合计 **5%** 高亮预警 |
|
||||
| **平仓明细** | 当日平仓流水表 |
|
||||
| **刷新** | 后台每 60s 聚合 + **SSE** 推送版本号;页面无整页轮询闪烁 |
|
||||
| **主题** | 跟随顶栏亮/暗主题,卡片柔光样式(非霓虹背景) |
|
||||
|
||||
细则见 **[数据看板说明.md](./数据看板说明.md)**.
|
||||
|
||||
### 4.3 AI 教练 `/ai`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **交易教练** | 口语化陪聊;后台注入三户监控快照(不在页面展示今日总结) |
|
||||
| **普通聊天** | 不绑交易数据 |
|
||||
| **会话** | 多会话历史(切换/删除),消息复制;点 **「新开对话」** 清空当前上下文 |
|
||||
| **模型** | 与三实例相同 `.env`(默认 `AI_PROVIDER=openai` + `OPENAI_*`;改 `ollama` 走本机),见 [AI教练说明.md](./AI教练说明.md) |
|
||||
| **与实例复盘** | 深度单笔 journal 复盘仍在各所 `/records`;中控不做重复 |
|
||||
|
||||
依赖三实例 `GET /api/hub/trades/today`(`hub_bridge`);升级代码后需 **重启三所 Flask**.
|
||||
|
||||
### 4.4 系统设置 `/settings`
|
||||
|
||||
**可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`).保存后监控区立即使用新 URL/启用状态,**无需重启 hub**.
|
||||
|
||||
**显示与导航**(`hub_settings.json` → `display`):
|
||||
|
||||
| 开关 | 说明 |
|
||||
|------|------|
|
||||
| 监控区资金/浮盈 | 关闭后监控卡片不显示资金户,交易户,浮盈亏列 |
|
||||
| 顶栏「资金概况」 | 关闭后隐藏导航;直接访问 `/funds` 会跳回监控区 |
|
||||
| 顶栏「数据看板」 | 关闭后隐藏导航;直接访问 `/dashboard` 会跳回监控区 |
|
||||
|
||||
**下单,关键位,策略交易**:请在监控卡片点击 **「实例」** 或 **「策略交易」**(SSO),进入各 `crypto_monitor_*` 网页(`/trade`,`/key_monitor`,`/strategy`,`/strategy/records` 等).中控 **不** 提供下单区;**策略交易记录** 仅在实例顶栏查看(见 [策略交易说明.md](../策略交易说明.md) §五).
|
||||
|
||||
| 列 | 含义 |
|
||||
|----|------|
|
||||
| 启用 | 是否参与监控与全局全平;被 `HUB_DISABLED_IDS` 锁定的无法勾选 |
|
||||
| 显示名 | 监控卡片标题 |
|
||||
| Flask URL | 实例根地址,如 `http://127.0.0.1:5001` |
|
||||
| Agent URL | 子代理根地址,如 `http://127.0.0.1:15200` |
|
||||
| 复盘链接 | 一般为 `{Flask}/records` |
|
||||
| **监控关键位** | 勾选后卡片展示 **关键位** 列表 + 门控价(读 Flask `/api/price_snapshot`) |
|
||||
| **监控趋势计划** | 勾选后卡片展示 **趋势回调** 运行中计划(`trend_pullback_plans` active) |
|
||||
| id | 与 `HUB_DISABLED_IDS`,全平 API 路径中的 id 对应;新增户勿与已有 id 重复 |
|
||||
|
||||
- **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效.
|
||||
- **添加交易所**:见下文 §4.5(须先自建 Flask + agent,再在中控登记).
|
||||
- **删**:从列表移除(保存后生效).
|
||||
|
||||
#### 能力与「策略交易」的关系(重要)
|
||||
|
||||
| 能力勾选 | 中控监控区 | 策略交易(趋势回调 / 顺势加仓) |
|
||||
|----------|------------|----------------------------------|
|
||||
| 监控关键位 | 显示关键位块 | **不控制**;在实例页 `/key_monitor` |
|
||||
| 监控趋势计划 | 显示趋势计划块 | **不控制**;在实例页 `/strategy` 左栏操作 |
|
||||
| 均未勾选 | 仅持仓,余额,机器人单 | 仍可在实例网页使用策略交易 |
|
||||
|
||||
三所 Flask 均已注册 `hub_bridge` 且 **`has_trend=true`**,勾选「监控趋势计划」后才会从 `/api/hub/monitor` 拉取趋势数据.修改勾选后 **保存即可**,须 **重启对应 Flask** 仅在你刚升级了 `hub_bridge` 相关代码时.
|
||||
|
||||
---
|
||||
|
||||
### 4.5 增加账户(例如再挂一个 Gate)
|
||||
|
||||
中控 **不会** 自动启动进程,也 **不** 保存交易所 API Key.新增一户 = **复制/新建一套实例目录 + 独立 `.env` + 新端口 Flask/agent + 在中控登记一行**.
|
||||
|
||||
#### 4.5.1 端口勿冲突(示例)
|
||||
|
||||
| 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` |
|
||||
|------|----------------|------------------|--------------|
|
||||
| Gate(已有) | `crypto_monitor_gate` | 5000 | 15202 |
|
||||
| **新增 Gate 子账户** | 复制为 `crypto_monitor_gate_2` 等 | **5005**(自定) | **15204**(自定) |
|
||||
|
||||
`agent` 的 `PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001,OKX 5004,中控 5100 等占用端口相同.
|
||||
|
||||
#### 4.5.2 新建实例目录
|
||||
|
||||
1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可).
|
||||
2. 在新目录:`cp .env.example .env`,至少修改:
|
||||
- `APP_PORT` → 新 Flask 端口(如 5005)
|
||||
- `DB_PATH` → 独立库(如 `crypto_gate2.db`),**勿**与其它实例共用 `crypto.db`
|
||||
- `GATE_API_KEY` / `GATE_API_SECRET` → **该子账户** 密钥
|
||||
- `HUB_BRIDGE_TOKEN` → 与中控,其它实例 **相同**
|
||||
3. 安装 venv 与依赖(`bash /opt/crypto_monitor_user/deploy/setup_env.sh --only gate` 或按 Gate 部署文档),启动:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/crypto_monitor_gate_2
|
||||
pm2 start ecosystem.config.cjs
|
||||
```
|
||||
|
||||
4. 在中控 `ecosystem.config.cjs` 增加对应 agent,或单独 `run_agent.sh` 配置后 `pm2 restart`(勿与已有 agent 端口冲突).
|
||||
|
||||
验收:`curl http://127.0.0.1:5005/login` 能开页;`curl http://127.0.0.1:15204/status` 返回 `ok`.
|
||||
|
||||
#### 4.5.3 在中控登记
|
||||
|
||||
1. 打开 **系统设置** → **添加交易所**(或手改 `manual_trading_hub/hub_settings.json`).
|
||||
2. 填写 **Flask URL**,**Agent URL**,**id**(如 `4`),**显示名**.
|
||||
3. 能力建议:
|
||||
- 训练/关键位户:**监控关键位** + **监控趋势计划**(若也要在中控看趋势计划);
|
||||
- 纯趋势户:只勾 **监控趋势计划**.
|
||||
4. 勾选 **启用** → **保存设置**.
|
||||
5. 在 **监控区** 应出现新卡片;点 **实例** 进入该户网页做下单与 **策略交易**.
|
||||
|
||||
PM2:仓库 `ecosystem.config.cjs` 默认只有三 agent;额外子账户需自行 `pm2 start` 或手工终端,与是否改 hub 源码无关.
|
||||
|
||||
---
|
||||
|
||||
## 5. 能力矩阵(监控展示,建议勾选)
|
||||
|
||||
| 账户 | 监控关键位 | 监控趋势计划 | 策略交易(实例页) |
|
||||
|------|:----------:|:--------------:|:------------------:|
|
||||
| 币安 | ✓ 建议 | ✓ 建议 | `/strategy` |
|
||||
| OKX | ✓ 建议 | ✓ 建议 | `/strategy` |
|
||||
| Gate | ✓ 建议 | ✓ 建议 | `/strategy` |
|
||||
| Gate | —(通常不勾) | ✓ | `/strategy` |
|
||||
|
||||
「建议」表示中控卡片展示对应块;**不勾** 仍可在该实例网页使用关键位或策略交易.
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTP API 摘要(中控)
|
||||
|
||||
访问控制:
|
||||
|
||||
- **IP**:默认允许本机与 RFC1918 私网(`HUB_TRUST_LAN=true`);公网 IP 直连返回 403.
|
||||
- **登录**:设置 `HUB_PASSWORD` 后须用户名+密码登录(`HUB_USERNAME`,未设时默认 `admin`);反代到公网时**务必设置**.
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/settings` | 读取配置 |
|
||||
| POST | `/api/settings` | 保存配置 |
|
||||
| GET | `/api/monitor/board` | 监控聚合 |
|
||||
| POST | `/api/close/{id}` | 单户全平 |
|
||||
| POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` |
|
||||
| GET | `/api/auth/status` | 是否需登录,是否已登录 |
|
||||
| POST | `/api/auth/login` | body `{"username":"...","password":"..."}` |
|
||||
| POST | `/api/auth/logout` | 退出 |
|
||||
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
|
||||
| GET | `/api/chart/meta` | 行情区:交易所,周期,limit |
|
||||
| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`,`symbol`,`timeframe`,可选 `refresh=1`) |
|
||||
| GET | `/api/hub/fund-overview` | 资金概况:总/分户资金,180 日曲线,回撤 |
|
||||
| GET | `/api/archive/meta` | 内照明心:周期,同步间隔 |
|
||||
| GET | `/api/archive/daily-trades` | 内照明心:区间交易与统计(`period` / `date_from` / `date_to`) |
|
||||
| GET | `/api/archive/quotes` | 内照明心:复盘语录 |
|
||||
| GET | `/api/archive/list` | 币种列表(筛选 query) |
|
||||
| GET | `/api/archive/detail` | 单币种交易时间线 |
|
||||
| GET | `/api/archive/ohlcv` | 档案 K 线视窗 |
|
||||
| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 犯病/情绪标签与备注 |
|
||||
| POST | `/api/archive/sync` | 立即同步三所交易与 K 线 |
|
||||
|
||||
已移除的 `/api/trade/*` 若被旧缓存页面请求,返回 **410** 并提示前往各实例网页.
|
||||
|
||||
实例侧(中控只读;下单/关键位/趋势在实例网页):
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `/api/hub/ping` | 连通与能力 |
|
||||
| `/api/hub/monitor` | 关键位,机器人单,趋势计划 |
|
||||
| `/api/hub/ohlcv` | 行情区 OHLCV(ccxt 拉取,供中控聚合缓存) |
|
||||
| `/api/hub/trades/archive` | 内照明心:近 N 天已平仓(`days` / `limit`) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 环境变量速查
|
||||
|
||||
### 中控 hub.py
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `HUB_HOST` | `0.0.0.0` | 监听地址 |
|
||||
| `HUB_PORT` | `5100` | 监听端口 |
|
||||
| `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` |
|
||||
| `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id |
|
||||
| `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 |
|
||||
| `HUB_USERNAME` | `admin` | 登录用户名(仅当已设密码时生效) |
|
||||
| `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 |
|
||||
| `HUB_SESSION_SECRET` | 用户名+密码 | 会话 Cookie 签名密钥 |
|
||||
| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true`(仅 HTTPS 发 Secure Cookie,HTTP 内网 IP 仍可登) |
|
||||
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
|
||||
| `HUB_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 |
|
||||
| `HUB_KLINE_DB_PATH` | `data/hub_kline.db` | K 线 SQLite 路径 |
|
||||
| `HUB_ARCHIVE_DB_PATH` | `data/hub_symbol_archive.db` | 内照明心永久 K 线库 |
|
||||
| `HUB_ARCHIVE_SYNC_INTERVAL_SEC` | `14400` | 档案 K 线后台同步间隔(秒) |
|
||||
| `HUB_ARCHIVE_TRADE_DAYS` | `365` | 同步交易记录回看天数 |
|
||||
| `HUB_ARCHIVE_TRADE_LIMIT` | `2000` | 单所同步交易条数上限 |
|
||||
|
||||
### 子代理 agent.py
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `EXCHANGE` | `binance` / `okx` / `gate` |
|
||||
| `PORT` / `HOST` | 监听 |
|
||||
| `CONTROL_TOKEN` | 与中控一致时必填头 `X-Control-Token` |
|
||||
|
||||
### 各实例 Flask
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `HUB_BRIDGE_TOKEN` | 与中控一致 |
|
||||
| `APP_AUTH_DISABLED` | `true` 时跳过登录与令牌(仅建议本机调试) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全与边界
|
||||
|
||||
1. **中控不下单**:开仓,关键位,趋势回调仅在各实例网页操作.
|
||||
2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认.
|
||||
3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道.
|
||||
4. **公网暴露 hub**:必须设置 `HUB_USERNAME` + `HUB_PASSWORD`;HTTPS 反代建议 `HUB_COOKIE_SECURE=true`;亦可 `HUB_HOST=127.0.0.1` 仅本机监听 + 反代.
|
||||
5. **复盘不在中控**:时间筛选,导出 CSV,编辑笔记仍在各实例 `/records`.
|
||||
6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错.
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障排查(速查)
|
||||
|
||||
完整实录(含 `api_trade_key`,`multipart`,git 版本,PM2 等)见 **[常见问题.md](./常见问题.md)**.
|
||||
|
||||
| 现象 | 可能原因 | 处理 |
|
||||
|------|----------|------|
|
||||
| 监控卡片「子代理不可用」 | agent 未启动或端口错 | 检查 Agent URL;`pm2 restart` agent |
|
||||
| 无关键位/趋势信息 | Flask 未起或 hub_bridge 未加载 | 启动 `crypto_*`;`curl .../api/hub/ping` |
|
||||
| 全平 401 | `CONTROL_TOKEN` 与中控不一致 | 与 `HUB_BRIDGE_TOKEN` 对齐 |
|
||||
| OKX 始终灰色 | `HUB_DISABLED_IDS=1` | 改掉环境变量并在设置页启用 |
|
||||
| 打开即跳转登录 | 已设 `HUB_PASSWORD` | 正常;访问 `/login` |
|
||||
| 域名能登,IP:5100 不能 | Secure Cookie + HTTP | 见常见问题 §2.1;或分别登录 |
|
||||
| 添加关键位报错 / SyntaxError | 旧前端或旧 hub 代码 | 强刷浏览器;`git pull` + `verify_hub_deploy.sh` |
|
||||
| `curl /api/ping` 非 JSON | hub 未启动 | `pm2 restart manual-trading-hub` |
|
||||
| K 线只有约 300 根 | 旧版未分页 | `git pull` 三实例 + hub,强制刷新 |
|
||||
| 12h 周期异常 | 无原生 12h 或旧缓存 | 强制刷新;见 [行情区说明.md](./行情区说明.md) |
|
||||
|
||||
**运维脚本**(在 `manual_trading_hub` 目录执行):
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|------|------|
|
||||
| `scripts/fix_hub_deps.sh` | 安装依赖(含 `python-multipart`) |
|
||||
| `scripts/verify_hub_deploy.sh` | 检查代码版本与 ping |
|
||||
| `scripts/fix_env_crlf.sh` | 修复 `.env` 的 CRLF 导致 agent 起不来 |
|
||||
|
||||
手动探测实例桥接:
|
||||
|
||||
```bash
|
||||
curl -sS -H "X-Hub-Token: 你的令牌" http://127.0.0.1:5001/api/hub/ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 与旧版 README 的差异
|
||||
|
||||
早期中控 **仅监控 + 全平**,使用环境变量 `HUB_AGENTS` 列表.当前版本改为:
|
||||
|
||||
- **hub_settings.json**(或内置默认)管理三所 URL 与能力;
|
||||
- **三页 UI**:监控 / 行情 / 设置;
|
||||
- 通过 **hub_bridge** 只读聚合监控数据.
|
||||
|
||||
子代理 `agent.py` 仍负责持仓与全平;`HUB_AGENTS` 环境变量在新版 hub 中 **不再使用**(以设置文件为准).
|
||||
|
||||
**PM2 守护**:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
pm2 start ecosystem.config.cjs # 一次启动 3 个 agent + manual-trading-hub
|
||||
pm2 save && pm2 startup
|
||||
```
|
||||
|
||||
快捷:`bash scripts/pm2_hub.sh start|restart|logs`(同样 hub+agent 一起).
|
||||
|
||||
更细的安装顺序,反代,验收见 **《部署文档.md》**;PM2 见 **[scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 11. 日常推荐流程
|
||||
|
||||
1. 启动三所 **agent** + **Flask**(OKX 按需).
|
||||
2. 启动 **hub.py**,打开监控区确认持仓与关键位门控正常.
|
||||
3. 看 K 线 → **行情区** 或监控区点击合约名跳转;异常图表点 **强制刷新**.
|
||||
4. 开仓,关键位,趋势 → 点击监控卡片「实例」进入对应 Flask.
|
||||
5. 复盘,导出记录 → 点击「复盘」进入 `/records`.
|
||||
6. 异常行情 → 单户全平或全局紧急全平.
|
||||
|
||||
增加账户步骤见 **§4.4**;无需改 `hub.py` 源码,但须该户 Flask 已 `git pull` 并 **重启**(`hub_bridge` + `has_trend` + `ohlcv`),且 agent 已部署.
|
||||
|
||||
---
|
||||
|
||||
## 12. 文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [使用说明.md](./使用说明.md) | 本文 |
|
||||
| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键,API |
|
||||
| [开仓计划说明.md](./开仓计划说明.md) | 计划录入,归档,胜率统计 |
|
||||
| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心,区间统计,永久 5m,建档与同步 |
|
||||
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
|
||||
| [常见问题.md](./常见问题.md) | 故障实录与排障 |
|
||||
| [README.md](./README.md) | 速览 |
|
||||
| [.env.example](./.env.example) | 环境变量模板 |
|
||||
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 |
|
||||
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu 环境总览 |
|
||||
@@ -0,0 +1,226 @@
|
||||
# 中控 · 局域网与反代部署说明
|
||||
|
||||
本文说明在 **局域网(IP + 端口)** 与 **宝塔/Nginx 反代(域名)** 两种场景下,如何配置中控与各实例,并实现:
|
||||
|
||||
- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码(SSO 临时链接,默认 **2 小时** 内有效,**单次使用**)
|
||||
- **浏览器直链** 实例地址(反代域名或 `http://IP:端口`)→ 进入 **`/login`**,输入统一 **`APP_USERNAME` / `APP_PASSWORD`**
|
||||
|
||||
SSO 签名复用 **`HUB_BRIDGE_TOKEN`**(与中控调实例 API 相同,三所 `.env` 与 `manual_trading_hub/.env` 保持一致).
|
||||
|
||||
**云服务器(VPS)** 的硬件,安全组,宝塔,环境变量与验收清单见 **[云服务器部署说明.md](./云服务器部署说明.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 一,两种访问方式对照
|
||||
|
||||
| 项目 | 局域网 | 反代(域名) |
|
||||
|------|--------|----------------|
|
||||
| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` |
|
||||
| 实例地址(浏览器) | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 |
|
||||
| `hub_settings` 里 `flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**(与浏览器一致) |
|
||||
| 中控本机调实例 API | 可与浏览器相同;同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名(需 Nginx 转发 `X-Hub-Token`) |
|
||||
| `HUB_PUBLIC_ORIGIN` | 若 `flask_url` 填 `127.0.0.1`,**必填** `http://内网IP` | 若 `flask_url` 已是完整域名,**可不设** |
|
||||
| 宝塔 | 可不装反代,直连端口 | 每实例一个站点 + SSL;中控单独站点 |
|
||||
| 直链登录 | 实例 `/login` | 实例 `/login` |
|
||||
| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 二,共用环境变量(必配)
|
||||
|
||||
### 2.1 中控 `manual_trading_hub/.env`
|
||||
|
||||
```bash
|
||||
HUB_BRIDGE_TOKEN=请填一长串随机字符
|
||||
HUB_USERNAME=admin # 中控登录(建议设置)
|
||||
HUB_PASSWORD=你的中控密码
|
||||
HUB_SSO_TTL_SEC=7200 # 可选,默认 7200 = 2 小时
|
||||
```
|
||||
|
||||
### 2.2 三个实例 `crypto_monitor_*/.env`
|
||||
|
||||
每个目录相同(**直链**时用这套登录实例网页):
|
||||
|
||||
```bash
|
||||
HUB_BRIDGE_TOKEN=与中控完全相同
|
||||
APP_USERNAME=统一用户名
|
||||
APP_PASSWORD=统一密码
|
||||
# 云上切勿 APP_AUTH_DISABLED=true
|
||||
```
|
||||
|
||||
### 2.3 子代理
|
||||
|
||||
`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同;子代理只监听 `127.0.0.1`,**不要**对公网暴露 `15200`~`15202`.
|
||||
|
||||
---
|
||||
|
||||
## 三,局域网部署(IP + 端口)
|
||||
|
||||
适用:家里/办公室内网,例如服务器 `192.168.8.6`.
|
||||
|
||||
### 3.1 端口约定(示例,以你实际为准)
|
||||
|
||||
| 服务 | 端口 |
|
||||
|------|------|
|
||||
| 中控 hub | 5100 |
|
||||
| OKX Flask | 5004 |
|
||||
| 币安 Flask | 5001 |
|
||||
| Gate | 5000 |
|
||||
| agent | 15200~15202(仅本机) |
|
||||
|
||||
### 3.2 系统设置 `hub_settings.json`(网页「系统设置」保存)
|
||||
|
||||
浏览器里你会打开的地址,应使用 **内网 IP**,不要用 `127.0.0.1`(否则别的电脑上的浏览器会连到你本机):
|
||||
|
||||
```json
|
||||
{
|
||||
"flask_url": "http://192.168.8.6:5004",
|
||||
"agent_url": "http://127.0.0.1:15201"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- **`flask_url`**:给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`**
|
||||
- **`agent_url`**:仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`**
|
||||
|
||||
各账户按上表改端口即可.
|
||||
|
||||
### 3.3 可选:`flask_url` 仍写 127.0.0.1 时
|
||||
|
||||
若坚持 `flask_url` 为 `http://127.0.0.1:5004`(仅 hub 与本机 Flask 同机),在中控 `.env` 增加:
|
||||
|
||||
```bash
|
||||
HUB_PUBLIC_ORIGIN=http://192.168.8.6
|
||||
```
|
||||
|
||||
中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`(端口保留).
|
||||
|
||||
### 3.4 访问方式
|
||||
|
||||
1. 中控:`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX,**无需**再输实例密码.
|
||||
2. 直链:`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`.
|
||||
|
||||
### 3.5 防火墙
|
||||
|
||||
内网自用:放行 `5100`,各 `APP_PORT`;**不要**对公网开放 agent 端口.
|
||||
|
||||
---
|
||||
|
||||
## 四,反代部署(域名 + 宝塔)
|
||||
|
||||
适用:云服务器,对外用 HTTPS 域名.
|
||||
|
||||
### 4.1 域名规划(示例)
|
||||
|
||||
| 站点 | 反代到 |
|
||||
|------|--------|
|
||||
| `hub.example.com` | `127.0.0.1:5100` |
|
||||
| `okx.example.com` | `127.0.0.1:5004` |
|
||||
| `binance.example.com` | `127.0.0.1:5001` |
|
||||
| `gate.example.com` | `127.0.0.1:5000` |
|
||||
|
||||
Flask / hub 进程仍只监听 **127.0.0.1** 或 `0.0.0.0` 本机端口,由 Nginx 对外提供 HTTPS.
|
||||
|
||||
### 4.2 宝塔操作要点
|
||||
|
||||
1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`.
|
||||
2. 申请 **SSL**(Let’s Encrypt).
|
||||
3. **不要**再给实例站加一层宝塔「访问密码」(避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可.
|
||||
4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需;确保代理头:
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
中控请求实例 API 时会带 **`X-Hub-Token`**,Nginx 默认会转发请求头,一般无需额外配置.
|
||||
|
||||
### 4.3 `hub_settings` 示例(反代)
|
||||
|
||||
```json
|
||||
{
|
||||
"flask_url": "https://okx.example.com",
|
||||
"agent_url": "http://127.0.0.1:15201"
|
||||
}
|
||||
```
|
||||
|
||||
- 浏览器与 SSO 链接使用 **`https://okx.example.com`**.
|
||||
- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url`;`flask_url` 用域名时,hub 会请求 `https://okx.example.com/api/...`(同机可通即可).
|
||||
|
||||
同机部署时也可:
|
||||
|
||||
- `flask_url`: `http://127.0.0.1:5004`
|
||||
- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com`
|
||||
|
||||
仅当**所有实例共用一个对外 IP,靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`;**每实例独立域名**时,请直接在 `flask_url` 写该实例域名.
|
||||
|
||||
### 4.4 中控 `.env`(反代建议)
|
||||
|
||||
```bash
|
||||
HUB_BRIDGE_TOKEN=...
|
||||
HUB_USERNAME=...
|
||||
HUB_PASSWORD=...
|
||||
HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启
|
||||
```
|
||||
|
||||
### 4.5 访问方式
|
||||
|
||||
1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统.
|
||||
2. 地址栏直接输入 `https://okx.example.com` → `/login` → 实例账号密码.
|
||||
|
||||
---
|
||||
|
||||
## 五,SSO 行为说明(2 小时)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 有效期 | 默认 **7200 秒(2 小时)**,`HUB_SSO_TTL_SEC` 可改 |
|
||||
| 单次使用 | 同一链接成功登录后 **不能再用**;需在中控重新点「打开实例」 |
|
||||
| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** |
|
||||
| 直链 | 无 token → 正常 **`/login`** |
|
||||
|
||||
---
|
||||
|
||||
## 六,部署与重启顺序
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user
|
||||
# 各实例
|
||||
pm2 restart crypto_okx crypto_binance crypto_gate # 名称以你为准
|
||||
|
||||
cd manual_trading_hub
|
||||
pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate
|
||||
```
|
||||
|
||||
改 `hub_settings` 或 `.env` 后重启 **hub + 对应实例 Flask**(`hub_bridge` 与 `/hub-sso` 在实例进程内).
|
||||
|
||||
---
|
||||
|
||||
## 七,验收清单
|
||||
|
||||
- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
|
||||
- [ ] 三实例 `APP_USERNAME` / `APP_PASSWORD` 一致
|
||||
- [ ] 局域网:`flask_url` 为 `http://IP:端口`;反代:`flask_url` 为 `https://域名`
|
||||
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页
|
||||
- [ ] 隐身窗口直链实例域名/IP → **有** `/login`
|
||||
- [ ] 复制「打开实例」完整 URL,用过一次后再开 → 失效并回到登录页
|
||||
|
||||
---
|
||||
|
||||
## 八,常见问题
|
||||
|
||||
**Q:从中控打开仍要登录?**
|
||||
- 检查实例是否已 `git pull` 并重启(需有 `/hub-sso`).
|
||||
- `HUB_BRIDGE_TOKEN` 是否三所一致.
|
||||
- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致(如 `okx`,`binance`,`gate`,`gate`).
|
||||
|
||||
**Q:直链也要登录中控?**
|
||||
- 不应.直链只走实例 `/login`.若跳到中控,检查是否点错链接或 Nginx 配错站点.
|
||||
|
||||
**Q:链接多久失效?**
|
||||
- 签发后 **2 小时**内且 **未使用过**;过期或已用需在中控重新点打开.
|
||||
|
||||
更多故障见 [常见问题.md](./常见问题.md),[部署文档.md](./部署文档.md).
|
||||
@@ -0,0 +1,354 @@
|
||||
# 中控与三实例 — 常见问题实录
|
||||
|
||||
本文档整理部署与运行 **manual_trading_hub**(复盘系统中控)及三所 `crypto_monitor_*` 时**实际遇到过**的问题与处理办法.操作步骤仍以 [使用说明.md](./使用说明.md),[部署文档.md](./部署文档.md) 为准.
|
||||
|
||||
---
|
||||
|
||||
## 一,中控进程与代码版本
|
||||
|
||||
### 1.1 PM2 日志仍出现 `api_trade_key`,`python-multipart` 断言
|
||||
|
||||
**现象**:`pm2 logs` 里报错 `File "hub.py", line 324, in api_trade_key` 或 `The python-multipart library must be installed`.
|
||||
|
||||
**原因**:
|
||||
|
||||
- 服务器上的 `hub.py` 仍是**旧版**(含已移除的「下单区」接口),或 pull 后**未重启** PM2,日志是历史残留.
|
||||
- 旧版「添加关键位」会 `request.form()`,未装 `python-multipart` 时直接 500.
|
||||
|
||||
**处理**:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user
|
||||
git pull
|
||||
|
||||
cd manual_trading_hub
|
||||
bash scripts/fix_hub_deps.sh # 安装 python-multipart 等
|
||||
bash scripts/verify_hub_deploy.sh # 应显示无 api_trade_key,含 HUB_BUILD
|
||||
|
||||
pm2 restart manual-trading-hub
|
||||
curl -s http://127.0.0.1:5100/api/ping
|
||||
```
|
||||
|
||||
**正常 ping**(无需登录)应含 `"build":"20260521-no-trade-ui"`,`"trade_ui":false`.
|
||||
|
||||
**说明**:当前版本**已移除中控下单区**;添加关键位,人工下单,趋势回调请在监控卡片点 **「实例」** 进入各 Flask 网页.浏览器请 **Ctrl+F5** 强刷,避免旧前端缓存仍请求 `/api/trade/key`.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `curl /api/ping` 返回 `{"detail":"未登录"}`
|
||||
|
||||
**原因**:早期版本未把 `/api/ping` 列入免登录白名单(已修复).
|
||||
|
||||
**处理**:`git pull` 后 `pm2 restart manual-trading-hub`;再测应直接返回 JSON,无需 Cookie.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 `verify_hub_deploy.sh` 报 `Expecting value: line 1 column 1`
|
||||
|
||||
**原因**:5100 端口无进程监听(hub 未启动或已崩溃),`curl` 拿到空响应.
|
||||
|
||||
**处理**:
|
||||
|
||||
```bash
|
||||
pm2 restart manual-trading-hub
|
||||
sleep 2
|
||||
pm2 logs manual-trading-hub --lines 30 --nostream
|
||||
ss -ltn | grep 5100
|
||||
bash scripts/verify_hub_deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 `bash scripts/fix_hub_deps.sh` 在仓库根目录找不到
|
||||
|
||||
**原因**:脚本在 `manual_trading_hub/scripts/` 下,不在 `/opt/crypto_monitor_user/scripts/`.
|
||||
|
||||
**处理**:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
bash scripts/fix_hub_deps.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二,登录与 Cookie(反代 / 域名 / 内网 IP)
|
||||
|
||||
### 2.1 设了密码后,域名能登录,`http://内网IP:5100` 不能
|
||||
|
||||
**原因**(最常见):
|
||||
|
||||
- `.env` 中 `HUB_COOKIE_SECURE=true`,且用 **HTTP** 访问 IP:5100 → 浏览器**不保存**带 `Secure` 的 Cookie,表现为登录成功后又跳回登录页.
|
||||
- **域名(HTTPS)** 与 **IP:5100(HTTP)** 是不同站点,Cookie **不共用**,需在 IP 上再登一次.
|
||||
|
||||
**处理**:
|
||||
|
||||
- 已支持:仅在实际 **HTTPS** 请求时发 `Secure` Cookie(读 `X-Forwarded-Proto`),HTTP 内网 IP 可正常登录.
|
||||
- 反代 Nginx 需传:`proxy_set_header X-Forwarded-Proto $scheme;`
|
||||
- 若仍异常:HTTPS 域名与 HTTP IP **分别登录**;或内网仅用 IP 时可注释 `HUB_COOKIE_SECURE`.
|
||||
|
||||
### 2.2 登录后接口仍 401
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 用户名密码 | `.env` 中 `HUB_USERNAME`(未设默认为 `admin`),`HUB_PASSWORD` |
|
||||
| 改密后 | 需重新登录;旧 Cookie 失效 |
|
||||
| 混用地址 | 不要用 A 浏览器标签登域名,B 标签指望 IP 已登录 |
|
||||
|
||||
### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去
|
||||
|
||||
**原因**:父页(如 `http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login` 时,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200,弹窗「登录成功」,但 iframe 仍无会话.
|
||||
|
||||
**处理**(中控 `git pull` 并重启 hub 后):
|
||||
|
||||
1. 登录接口会返回 `session_token`;父页应把 iframe 指向:
|
||||
`http://中控地址/embed-auth?token=会话token&next=/monitor`
|
||||
2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie.
|
||||
3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`.
|
||||
|
||||
`.env` 可选:
|
||||
|
||||
```env
|
||||
HUB_ALLOW_EMBED=true
|
||||
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三,监控区无数据 / 子代理异常
|
||||
|
||||
### 3.1 卡片「子代理不可用」或余额为 —
|
||||
|
||||
| 原因 | 处理 |
|
||||
|------|------|
|
||||
| agent 未启动 | `pm2 restart ecosystem.config.cjs` 或 `pm2 restart manual-agent-*` |
|
||||
| Agent URL 与端口不符 | 系统设置里应为 `http://127.0.0.1:15200` 等 |
|
||||
| PM2 未加载策略 `.env` | 须用 `run_agent.sh` 启动(会 `source` 各目录 `.env`),勿裸跑 `agent.py` |
|
||||
| `.env` 为 Windows CRLF | 日志 `$'\r': command not found` → `bash scripts/fix_env_crlf.sh` 后重启 |
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:15202/status | head -c 300
|
||||
```
|
||||
|
||||
应 `ok: true` 且有 `balance_usdt`.
|
||||
|
||||
### 3.3 Gate 子代理「一会正常,一会连不上」(仅 Gate 两户)
|
||||
|
||||
| 现象 | 说明 |
|
||||
|------|------|
|
||||
| 中控某所子代理红 | 本机对应 agent 端口在 PM2 重启间隙连不上 |
|
||||
| 日志 `$'\r': command not found` | `crypto_monitor_gate*` 的 `.env` 为 Windows CRLF |
|
||||
| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent |
|
||||
|
||||
**修复**(服务器):
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user
|
||||
sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate/.env
|
||||
bash manual_trading_hub/scripts/fix_env_crlf.sh
|
||||
cd manual_trading_hub && pm2 restart manual-agent-gate
|
||||
# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6)
|
||||
```
|
||||
|
||||
修好后 `pm2 describe manual-agent-gate` 的 **restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志.
|
||||
|
||||
**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`,API 权限,止损止盈价格是否合理(与各实例策略页相同 `.env` 参数).
|
||||
|
||||
### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404
|
||||
|
||||
| 原因 | 处理 |
|
||||
|------|------|
|
||||
| 对应 `crypto_*` Flask 未启动 | `pm2 restart crypto_gate` 等 |
|
||||
| 未注册 `hub_bridge` | 启动日志勿含 `[hub_bridge] ImportError`;仓库根需在 `PYTHONPATH`(各实例 `ecosystem.config.cjs` 已配 `PYTHONPATH=..`) |
|
||||
| 中控 `ModuleNotFoundError: hub_auth` | 确认仓库根存在 `/opt/crypto_monitor_user/hub_auth.py`(`git pull`);`run_hub.sh` / PM2 已设 `PYTHONPATH=仓库根`;`pm2 restart manual-trading-hub` |
|
||||
| `HUB_BRIDGE_TOKEN` 不一致 | 中控 `.env` 与三实例 `.env` 设相同令牌,或实例 `APP_AUTH_DISABLED=true`(仅建议本机) |
|
||||
|
||||
```bash
|
||||
curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping
|
||||
```
|
||||
|
||||
### 3.3 中控监控区打开慢,一直转圈
|
||||
|
||||
**原因(常见)**:
|
||||
|
||||
1. 首屏要等 **`/api/monitor/board`**:向 4 个子代理拉持仓/余额,并向 4 个 Flask 拉监控与(默认)关键位行情;任一实例慢或超时都会拖住整页.
|
||||
2. 旧版 hub 对每所 Flask **串行**请求,3 所 × 3 接口容易累计到十几秒;新版已改为**并行**(`git pull` 后 `pm2 restart manual-trading-hub`).
|
||||
3. 各实例 **`/api/price_snapshot`** 会调交易所接口(含全量持仓),最耗时;内网访问 Google 字体也会拖首屏渲染.
|
||||
4. 子代理 `/status` 里 `fetch_balance` / `fetch_positions` / 挂单列表走交易所 API,网络差时单次可达数秒.
|
||||
|
||||
**加快办法**:
|
||||
|
||||
```env
|
||||
# manual_trading_hub/.env
|
||||
HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot,关键位门控显示为「-」,首屏明显更快
|
||||
HUB_AGENT_TIMEOUT=6
|
||||
HUB_FLASK_TIMEOUT=8
|
||||
```
|
||||
|
||||
并确认三所 `crypto_*` 与 `manual-agent-*` 均为 **online**,避免等满超时.浏览器 **Ctrl+F5** 强刷静态资源(版本号含 `20260525-perf`).
|
||||
|
||||
---
|
||||
|
||||
## 四,云服务器 / 公网反代
|
||||
|
||||
**云服务器完整配置(安全组,宝塔,环境变量,PM2,验收)** 见 **[云服务器部署说明.md](./云服务器部署说明.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 五,复盘链接与公网反代
|
||||
|
||||
### 4.1 监控里点「复盘」打开的是本机 127.0.0.1
|
||||
|
||||
**原因**:未设 `HUB_PUBLIC_ORIGIN`,浏览器拿到的链接仍是 Flask 本机地址.
|
||||
|
||||
**处理**:`manual_trading_hub/.env` 增加(示例):
|
||||
|
||||
```env
|
||||
HUB_PUBLIC_ORIGIN=http://192.168.8.6
|
||||
```
|
||||
|
||||
或 `HUB_PUBLIC_HOST=192.168.8.6`.改后 `pm2 restart manual-trading-hub`.
|
||||
|
||||
**说明**:仅反代中控,三实例 Flask 仍只监听 127.0.0.1 时,其它电脑要能打开复盘,还须能访问各实例端口或单独反代.
|
||||
|
||||
### 4.2 只反代中控,不反代三实例
|
||||
|
||||
**可以**.中控聚合监控与全平;复盘,下单,关键位维护进各实例网页.实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`.
|
||||
|
||||
### 4.3 从中控「打开实例」仍要输密码
|
||||
|
||||
**完整说明**:[局域网与反代部署说明.md](./局域网与反代部署说明.md)
|
||||
|
||||
**常见原因**:
|
||||
|
||||
1. 三实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`).
|
||||
2. `HUB_BRIDGE_TOKEN` 与三实例 `.env` 不一致.
|
||||
3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx`,`gate`).
|
||||
4. **HTTPS 跨域 iframe**:中控与实例不同域名时,三实例须 `APP_COOKIE_SECURE=true`(使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`.
|
||||
5. **经本地导航打开中控**(LocalNav → 中控 iframe → 点实例):旧版会在中控内再嵌一层实例 iframe,Cookie 易失效.请升级 **LocalNav + 中控** 最新代码:点实例后由导航页直接打开实例,工具栏有「← 中控」;须配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`,三实例 `HUB_EMBED_PARENT_ORIGINS` 含本地导航地址(如 `http://192.168.8.6:5070`).
|
||||
6. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录).
|
||||
|
||||
**直链**:`http://IP:端口` 或 `https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(三所建议统一).
|
||||
|
||||
---
|
||||
|
||||
## 六,Gate / 复盘相关(实例侧)
|
||||
|
||||
### 5.1 Gate `/records` 或预览 500(`preview_created_at`)
|
||||
|
||||
**原因**:数据库缺列或查询未兼容旧库.
|
||||
|
||||
**处理**:`git pull` 后重启 `crypto_gate`;必要时在实例目录执行一次带 `init_db` 的启动或按该目录更新文档迁移.
|
||||
|
||||
### 5.2 中控监控区 Gate「无关键位」
|
||||
|
||||
**说明**:若系统设置未勾选「监控关键位」,中控不会展示关键位区块;策略交易仍在各实例 `/strategy` 操作.
|
||||
|
||||
---
|
||||
|
||||
## 七,环境与配置
|
||||
|
||||
### 6.1 OKX 默认不显示
|
||||
|
||||
`HUB_DISABLED_IDS=1`(默认关 OKX).要用 OKX:清空或改掉该变量,并在系统设置启用 id=1.
|
||||
|
||||
### 6.2 公网 IP 直连中控 403
|
||||
|
||||
`HUB_TRUST_LAN=true` 时仅允许本机 + RFC1918 私网(10/172.16/192.168).公网 IP 直连 5100 会被拒;应走 **Nginx 反代到 127.0.0.1:5100**.
|
||||
|
||||
### 4.4 浏览器显示 `{"detail":"forbidden"}`
|
||||
|
||||
**原因**:中控 `local_only` 中间件认为访问来源 IP 不允许(常见于云上 `HUB_TRUST_LAN=false` 且反代未指向 `127.0.0.1:5100`).
|
||||
|
||||
**处理**(二选一):
|
||||
|
||||
1. `manual_trading_hub/.env` 增加 **`HUB_ALLOW_PUBLIC=true`**(已设 `HUB_PASSWORD` 时推荐),`pm2 restart manual-trading-hub`.
|
||||
2. 宝塔反代目标改为 **`http://127.0.0.1:5100`**(不要用公网 IP:5100 作 upstream).
|
||||
|
||||
改后强刷浏览器再开 `/login`.
|
||||
|
||||
### 6.3 `.env` 修改不生效
|
||||
|
||||
PM2 须重启:`pm2 restart manual-trading-hub`(`run_hub.sh` 每次启动会重读 `.env`).
|
||||
|
||||
### 6.4 `hub_settings.json` 与 Git
|
||||
|
||||
网页「系统设置」保存生成,**一般不提交 Git**.`git pull` **不会覆盖** 该文件与 `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 八,功能边界(避免误用)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 中控下单区 | **已移除**;勿再在中控添加关键位/人工单/趋势预览 |
|
||||
| 中控能力 | 监控聚合,单户/全局紧急全平,系统设置,登录保护 |
|
||||
| 下单与关键位 | 各 `crypto_monitor_*` 原网页 |
|
||||
| 复盘 | 各实例 `/records`;中控仅「复盘」外链 |
|
||||
| 全平 | 市价减仓,不可撤销,操作前确认 |
|
||||
|
||||
---
|
||||
|
||||
## 九,推荐排障顺序
|
||||
|
||||
1. `git pull` → `manual_trading_hub` 下 `bash scripts/fix_hub_deps.sh` → `bash scripts/verify_hub_deploy.sh`
|
||||
2. `pm2 restart manual-trading-hub`(及 `ecosystem.config.cjs` 若 agent/Flask 也有问题)
|
||||
3. `curl http://127.0.0.1:5100/api/ping` → 确认 `build` 与 `trade_ui:false`
|
||||
4. 浏览器打开 `/login` 登录 → `/monitor` 强刷
|
||||
5. 逐项 `curl` 子代理 `/status`,Flask `/api/hub/ping`
|
||||
6. 仍不行则查 `pm2 logs manual-trading-hub`,`pm2 logs crypto_gate` 最近 50 行
|
||||
|
||||
---
|
||||
|
||||
## 十,行情区 K 线
|
||||
|
||||
### 10.1 只加载约 300 根(目标 1000)
|
||||
|
||||
**原因**:旧版 `hub_ohlcv_lib` 无 `since` 分页时,OKX/Gate 单次 API 常只返回 ~300 根.
|
||||
|
||||
**处理**:`git pull` 后重启 **hub + 三实例 Flask**,行情区点 **强制刷新**;浏览器强刷(`chart.js` 带版本号).
|
||||
|
||||
### 10.2 6h / 8h 周期错乱(已移除)
|
||||
|
||||
中控行情区 **已不再提供** `6h`,`8h`(以及 `3m`/`10m`/`20m`/`30m`).若 URL 或旧缓存仍带这些周期,会回退为 `5m`.请改用 `4h` / `12h` 等当前列表,见 [行情区说明.md](./行情区说明.md).
|
||||
|
||||
### 10.3 12h 数据异常
|
||||
|
||||
**原因**:部分交易所无原生 12h;或本地 `hub_kline.db` 存有升级前的错误缓存.
|
||||
|
||||
**处理**:强制刷新;仍异常可停 hub 后备份并删除 `manual_trading_hub/data/hub_kline.db` 再拉取.
|
||||
|
||||
### 10.4 快捷键无效
|
||||
|
||||
- 全屏请用 **`F`**(Win 下 Ctrl+空格常被输入法占用,已不作为全屏键).
|
||||
- 须在 **行情区** 页面且焦点不在币种输入框.
|
||||
- 升级后确认加载 `chart.js?v=...` 新版本.
|
||||
|
||||
---
|
||||
|
||||
## 十一,相关脚本
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|------|------|
|
||||
| `scripts/fix_hub_deps.sh` | 安装/更新中控 venv 依赖(含 python-multipart) |
|
||||
| `scripts/verify_hub_deploy.sh` | 检查代码版本,multipart,ping,PM2 状态 |
|
||||
| `scripts/fix_env_crlf.sh` | 去除各目录 `.env` 的 Windows 换行 |
|
||||
| `scripts/run_hub.sh` | PM2 启动 hub(加载 `.env`) |
|
||||
| `scripts/run_agent.sh` | PM2 启动 agent(加载策略目录 `.env`) |
|
||||
| `scripts/pm2_hub.sh` | 启停/日志 hub+agent 一体 |
|
||||
|
||||
---
|
||||
|
||||
## 十二,文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [使用说明.md](./使用说明.md) | 架构,页面,环境变量,API |
|
||||
| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键 |
|
||||
| [部署文档.md](./部署文档.md) | Ubuntu/PM2 安装与运维 |
|
||||
| [云服务器部署说明.md](./云服务器部署说明.md) | VPS 配置,安全组,宝塔,env,验收 |
|
||||
| [局域网与反代部署说明.md](./局域网与反代部署说明.md) | 内网 IP:端口 / 域名反代,SSO |
|
||||
| [README.md](./README.md) | 速览与快速启动 |
|
||||
| [.env.example](./.env.example) | 中控环境变量模板 |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 开仓计划
|
||||
|
||||
中控顶栏 **开仓计划**(`/plan`)用于记录开仓前的计划,跟踪进行中条目,并在填写结果后归档;支持按币种,趋势周期,入场方案统计胜率.
|
||||
|
||||
## 入口
|
||||
|
||||
- 顶栏:**资金概况** 与 **监控区** 之间 → **开仓计划**
|
||||
- 路由:`/plan`
|
||||
|
||||
## 页面结构
|
||||
|
||||
| 区域 | 功能 |
|
||||
|------|------|
|
||||
| 左侧 · 新建计划 | 填写计划字段,保存后进入「进行中」 |
|
||||
| 左侧 · 进行中 | 修改,删除,填写结果并归档 |
|
||||
| 右侧 · 计划历史 | 一行一条摘要,点击查看详情 |
|
||||
| 右侧 · 数据统计 | 胜率表(可切换维度与时间范围) |
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 日期 | 计划日期(日期选择器,可手输 `YYYY-MM-DD`) |
|
||||
| 交易所 | 三所:binance / okx / gate(来自 hub 已启用账户) |
|
||||
| 币种 | 输入 `BTC` 或 `BTC/USDT`,自动规范为 `XXX/USDT` |
|
||||
| 类型 | 趋势单 / 波段单 / 日内短线 |
|
||||
| 趋势周期 | 5m / 15m / 30m / 1h / 4h / 1d |
|
||||
| 入场周期 | 1m / 5m / 15m / 30m / 1h |
|
||||
| 方向 | 多 / 空 |
|
||||
| 目标位 | 文本 |
|
||||
| 当前区间 | 文本 |
|
||||
| 入场方案 | **仅进行中**填写:突破 / 假突破 / 箱体拐点(根据实际进场选择;归档前必选) |
|
||||
| 结果 | **仅进行中**可填:盈 / 亏;**必选其一才归档** |
|
||||
| 盈亏 | **可选**数字(U),不参与是否归档 |
|
||||
| 备注 | 文本 |
|
||||
|
||||
## 业务流程
|
||||
|
||||
1. **新建** → 状态 `active`(进行中),**不含入场方案**
|
||||
2. **进行中** → 选择/修改 **入场方案**(根据实际进场填写),可改备注,价位等
|
||||
3. **删除** → 仅 **未填结果** 的进行中计划可删
|
||||
4. **归档** → 在进行中选择 **盈/亏** 并点「填写结果并归档」→ 状态 `archived`,移入计划历史
|
||||
|
||||
## 数据统计
|
||||
|
||||
- **默认**:全部历史
|
||||
- **时间**:全部 / 本周 / 本月 / 自选区间
|
||||
- **维度 Tab**:币种 | 趋势周期 | 入场方案
|
||||
- **胜率**:盈利 ÷ (盈利 + 亏损),仅统计已归档且结果=盈/亏 的计划
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/entry-plans/meta` | 枚举项 + 交易所列表 |
|
||||
| GET | `/api/entry-plans?status=active\|archived` | 列表 |
|
||||
| GET | `/api/entry-plans/{id}` | 详情 |
|
||||
| POST | `/api/entry-plans` | 新建 |
|
||||
| PATCH | `/api/entry-plans/{id}` | 更新;写入 `result` 时自动归档 |
|
||||
| DELETE | `/api/entry-plans/{id}` | 删除(仅 active) |
|
||||
| GET | `/api/entry-plans/stats` | 统计;参数 `dimension`,`period`,`date_from`,`date_to` |
|
||||
|
||||
## 存储
|
||||
|
||||
- SQLite:`manual_trading_hub/data/hub_entry_plans.db`
|
||||
- 环境变量:`HUB_ENTRY_PLAN_DB_PATH`(可选自定义路径)
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pm2 restart manual-trading-hub
|
||||
```
|
||||
|
||||
浏览器访问 `/plan` 并 **Ctrl+F5** 强刷静态资源.
|
||||
|
||||
## 相关代码
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `hub_entry_plan_lib.py` | 库表,CRUD,统计 |
|
||||
| `manual_trading_hub/hub.py` | REST API |
|
||||
| `manual_trading_hub/static/plan.js` | 前端逻辑 |
|
||||
| `manual_trading_hub/static/index.html` | 页面 DOM |
|
||||
| `tests/test_hub_entry_plan_lib.py` | 单元测试 |
|
||||
@@ -0,0 +1,50 @@
|
||||
# 中控数据看板说明
|
||||
|
||||
入口:**`/dashboard`**(顶栏「数据看板」).
|
||||
|
||||
## 能力
|
||||
|
||||
| 区块 | 说明 |
|
||||
|------|------|
|
||||
| **总览 KPI** | 交易日,平仓盈亏,笔数,浮盈亏,资金合计,实盘持仓 |
|
||||
| **分户明细** | 三户资金/交易账户,今日盈亏,浮盈亏,备注;未启用显示「未监控」 |
|
||||
| **平仓明细** | 当日平仓流水(合约,方向,结果,盈亏,时间) |
|
||||
| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 |
|
||||
|
||||
纯数据聚合,**不调用 AI**.交易日口径与实例一致(`TRADING_DAY_RESET_HOUR`,默认 8 点).
|
||||
|
||||
## 刷新机制(SSE)
|
||||
|
||||
与监控区 board 类似,采用 **后台聚合 + SSE 推送版本号**:
|
||||
|
||||
1. `hub.py` 启动后 `dashboard_store` 每 **60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照.
|
||||
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`).
|
||||
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**.
|
||||
4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步.
|
||||
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合.
|
||||
|
||||
可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔).
|
||||
|
||||
## 主题与样式
|
||||
|
||||
- 跟随中控顶栏 **亮/暗主题**(`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量.
|
||||
- 卡片采用 **柔光阴影**(非霓虹渐变背景);亮色主题下为浅灰投影,暗色主题为轻微内高光.
|
||||
- 盈亏仍用绿/红语义色,与全局一致.
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/dashboard/daily` | 当前交易日快照(含 `dashboard_version`) |
|
||||
| GET | `/api/dashboard/stream` | SSE 版本推送 |
|
||||
| POST | `/api/dashboard/refresh` | 请求立即重聚合 |
|
||||
|
||||
`GET /api/ping` 含 `dashboard_version`,`dashboard_poll_interval_sec` 等字段.
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `hub_dashboard.py` — 聚合逻辑
|
||||
- `hub_dashboard_cache.py` — 后台轮询 + SSE
|
||||
- `static/dashboard.js` / `dashboard.css` — 前端
|
||||
|
||||
部署后 `git pull` 并 `pm2 restart manual-trading-hub`.
|
||||
@@ -0,0 +1,268 @@
|
||||
# 本地数据备份与迁移到云服务器
|
||||
|
||||
本文说明如何把 **本机** 上运行的 `crypto_monitor`(三实例 + 中控)的**业务数据**迁到 **云 VPS**,并正确改配置.
|
||||
**不迁移** 本机 Python 虚拟环境(`.venv`),云上重新 `pip install` 即可.
|
||||
|
||||
相关:[云服务器部署说明.md](./云服务器部署说明.md) · [部署文档.md](./部署文档.md)
|
||||
|
||||
---
|
||||
|
||||
## 一,要迁什么,不迁什么
|
||||
|
||||
### 必须迁移(业务数据)
|
||||
|
||||
| 路径(每个实例目录下) | 内容 |
|
||||
|------------------------|------|
|
||||
| `crypto.db`(或 `.env` 里 `DB_PATH` 指向的文件) | 监控单,关键位,交易记录,复盘,运行时开关等 **SQLite 全库** |
|
||||
| `static/images/`(或 `UPLOAD_DIR`) | 上传图,复盘截图等 |
|
||||
| `static/images/order_charts/`(或 `ORDER_CHART_DIR`) | 订单 K 线图(若开启) |
|
||||
|
||||
三个实例 **各有一份独立库**:
|
||||
|
||||
- `crypto_monitor_binance/crypto.db`
|
||||
- `crypto_monitor_okx/crypto.db`
|
||||
- `crypto_monitor_gate/crypto.db`
|
||||
- `crypto_monitor_gate/crypto.db`
|
||||
|
||||
### 中控额外迁移
|
||||
|
||||
| 路径 | 内容 |
|
||||
|------|------|
|
||||
| `manual_trading_hub/hub_settings.json` | 账户 URL,启用状态,能力勾选(网页「系统设置」保存的文件) |
|
||||
| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结(`/ai`) |
|
||||
| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 |
|
||||
|
||||
### 不要直接覆盖拷贝(需在云上重写)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| 各目录 `.env` | 含 API 密钥:可在云上**手工新建**,从本机抄密钥,但须改 **`flask_url`,代理,公网相关项**(见下文) |
|
||||
| `.venv/`,`__pycache__/` | 云上重建 |
|
||||
| PM2 日志 | 无需迁 |
|
||||
|
||||
### 可选
|
||||
|
||||
- 本机 `manual_trading_hub/.env` 里的 `HUB_BRIDGE_TOKEN`,`HUB_PASSWORD` 等:记下后在云上填入,**不要**把含密钥的 `.env` 发到公开网盘.
|
||||
|
||||
---
|
||||
|
||||
## 二,迁移前准备(本地)
|
||||
|
||||
### 1. 停服务(避免数据库半写入)
|
||||
|
||||
```bash
|
||||
# 本机:停中控与子代理
|
||||
cd manual_trading_hub
|
||||
pm2 stop manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate
|
||||
|
||||
# 本机:停三个 Flask(进程名以你 pm2 list 为准)
|
||||
pm2 stop crypto_okx crypto_binance crypto_gate
|
||||
# 或各目录 ecosystem 里的名字
|
||||
```
|
||||
|
||||
未用 PM2 时,结束对应 Python/Flask 进程后再备份.
|
||||
|
||||
### 2. 确认数据库文件位置
|
||||
|
||||
各实例目录下查看 `.env` 中 `DB_PATH`(默认 `crypto.db`).若存在 `crypto.db-wal`,`crypto.db-shm`,**必须先停服务** 再备份.
|
||||
|
||||
---
|
||||
|
||||
## 三,本地备份(推荐用自带脚本)
|
||||
|
||||
每个实例目录执行(会备份 **库 + static/images**):
|
||||
|
||||
```bash
|
||||
cd crypto_monitor_okx
|
||||
bash scripts/backup_data.sh
|
||||
# 默认输出到 /root/backups/crypto_monitor_okx/YYYY-MM-DD/
|
||||
# 本机可改环境变量:BACKUP_ROOT=~/crypto_backups bash scripts/backup_data.sh
|
||||
```
|
||||
|
||||
对 `crypto_monitor_binance`,`crypto_monitor_gate`,`crypto_monitor_gate` **各执行一次**.
|
||||
|
||||
脚本产物示例:
|
||||
|
||||
```text
|
||||
~/crypto_backups/crypto_monitor_okx/2026-05-21/
|
||||
crypto.db
|
||||
static_images.tar.gz
|
||||
manifest.txt
|
||||
```
|
||||
|
||||
### 手工打包(不用脚本时)
|
||||
|
||||
在仓库根目录示例:
|
||||
|
||||
```bash
|
||||
BACKUP=~/crypto_migrate_$(date +%Y%m%d)
|
||||
mkdir -p "$BACKUP"
|
||||
|
||||
for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate; do
|
||||
tar -czf "$BACKUP/${dir}.tar.gz" \
|
||||
-C "$dir" crypto.db static/images 2>/dev/null || \
|
||||
tar -czf "$BACKUP/${dir}.tar.gz" -C "$dir" crypto.db
|
||||
done
|
||||
|
||||
cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true
|
||||
cp manual_trading_hub/hub_ai_summaries.json "$BACKUP/" 2>/dev/null || true
|
||||
cp manual_trading_hub/hub_ai_chat.json "$BACKUP/" 2>/dev/null || true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四,上传到云服务器
|
||||
|
||||
在**你电脑**上(把 `USER`,`云IP` 换成实际值):
|
||||
|
||||
```bash
|
||||
# 打包整个备份目录
|
||||
tar -czf crypto_migrate.tar.gz -C ~ crypto_backups # 或你的 BACKUP 路径
|
||||
|
||||
scp crypto_migrate.tar.gz USER@云IP:/tmp/
|
||||
scp manual_trading_hub/hub_settings.json USER@云IP:/tmp/ # 若单独备份
|
||||
```
|
||||
|
||||
大文件可用 **rsync**(支持断点续传):
|
||||
|
||||
```bash
|
||||
rsync -avz --progress ~/crypto_backups/ USER@云IP:/tmp/crypto_backups/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五,云上恢复数据
|
||||
|
||||
假设代码已在 `/opt/crypto_monitor_user`(`git clone` 或 `rsync` 代码均可,**代码与数据分开**).
|
||||
|
||||
```bash
|
||||
ssh USER@云IP
|
||||
cd /opt/crypto_monitor_user
|
||||
|
||||
# 解压(若用 scp 单包)
|
||||
tar -xzf /tmp/crypto_migrate.tar.gz -C /tmp
|
||||
|
||||
# 按实例恢复(示例:OKX)
|
||||
pm2 stop crypto_okx 2>/dev/null || true
|
||||
cp /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/crypto.db crypto_monitor_okx/crypto.db
|
||||
tar -xzf /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/static_images.tar.gz -C crypto_monitor_okx/
|
||||
# 若 tar 里是 static/images 目录结构,确认解压后路径为 crypto_monitor_okx/static/images
|
||||
|
||||
# 对其余三所重复同样步骤
|
||||
```
|
||||
|
||||
恢复中控设置:
|
||||
|
||||
```bash
|
||||
cp /tmp/hub_settings.json manual_trading_hub/hub_settings.json
|
||||
# 或解压备份里带的 hub_settings.json
|
||||
```
|
||||
|
||||
**权限**(避免 Flask 写库失败):
|
||||
|
||||
```bash
|
||||
sudo chown -R 运行用户:运行用户 /opt/crypto_monitor_user/crypto_monitor_*/crypto.db
|
||||
sudo chown -R 运行用户:运行用户 /opt/crypto_monitor_user/crypto_monitor_*/static/images
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六,云上必须改的配置(比迁移本身更重要)
|
||||
|
||||
数据文件原样拷过去不够,**.env 与 hub_settings 要按云环境改**.
|
||||
|
||||
### 1. 各实例 `crypto_monitor_*/.env`
|
||||
|
||||
从本机**抄写** API 密钥等,并调整:
|
||||
|
||||
| 项 | 本地常见 | 云上建议 |
|
||||
|----|----------|----------|
|
||||
| `OKX_SOCKS_PROXY` 等 | `socks5h://127.0.0.1:1080` | **留空**(直连),除非云上仍访问不了交易所 |
|
||||
| `APP_AUTH_DISABLED` | 可能为 true(本机) | **false** 或未设置 |
|
||||
| `APP_USERNAME` / `APP_PASSWORD` | 可有 | 设统一强密码(直链登录) |
|
||||
| `HUB_BRIDGE_TOKEN` | 有 | 与中控 **完全一致** |
|
||||
|
||||
### 2. `manual_trading_hub/.env`
|
||||
|
||||
见 [云服务器部署说明.md](./云服务器部署说明.md):`HUB_PASSWORD`,`HUB_BRIDGE_TOKEN`,`HUB_COOKIE_SECURE=true` 等.
|
||||
|
||||
### 3. `hub_settings.json` 里的 URL
|
||||
|
||||
**必须**改成浏览器能打开的地址:
|
||||
|
||||
| 字段 | 云上 |
|
||||
|------|------|
|
||||
| `flask_url` | `https://okx.你的域名.com`(每实例不同子域) |
|
||||
| `agent_url` | `http://127.0.0.1:15201`(保持本机,勿写公网 IP) |
|
||||
|
||||
本机若是 `http://192.168.x.x:5004` 或 `http://127.0.0.1:5004`,上云后**一定要改**,否则「打开实例」会指错地址.
|
||||
|
||||
---
|
||||
|
||||
## 七,云上启动与验收
|
||||
|
||||
```bash
|
||||
# 依赖(各目录 venv + manual_trading_hub)
|
||||
# 见 云服务器部署说明.md,部署文档.md
|
||||
|
||||
cd /opt/crypto_monitor_user
|
||||
# 先三实例 Flask,再 manual_trading_hub ecosystem
|
||||
pm2 start ...
|
||||
pm2 save
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
- [ ] 各实例网页能登录,**交易记录 / 关键位 / 监控单** 与本地一致
|
||||
- [ ] 复盘图片能显示(`static/images` 路径正确)
|
||||
- [ ] 中控监控卡片能读到持仓;`hub_settings` 账户 URL 正确
|
||||
- [ ] 本机已 **停止** 或不再用同一 API Key 同时跑两套(避免重复下单)
|
||||
|
||||
---
|
||||
|
||||
## 八,迁移策略建议
|
||||
|
||||
### 方案 A:一次性切换(简单)
|
||||
|
||||
1. 本地停 PM2 → 备份 → 上传 → 云上恢复 → 改配置 → 只跑云端.
|
||||
2. 适合能接受 **短暂停机**(几十分钟).
|
||||
|
||||
### 方案 B:先云后停本地(稳一点)
|
||||
|
||||
1. 云上先部署代码,空库跑通;
|
||||
2. 临近切换时再备份本地**最新**库覆盖云上;
|
||||
3. 切换时刻停本地,启云上.
|
||||
4. 减少「备份到上线」之间的数据空窗.
|
||||
|
||||
### 注意
|
||||
|
||||
- **同一交易所 API Key 不要本地和云上同时自动交易**,以免重复挂单.
|
||||
- 迁移后第一次在云上打开,建议先看监控单,持仓是否与预期一致,再放开自动逻辑.
|
||||
|
||||
---
|
||||
|
||||
## 九,常见问题
|
||||
|
||||
**Q:只拷 `crypto.db` 不够吗?**
|
||||
- 复盘,上传相关功能还依赖 `static/images`;建议库 + 图片一起迁.
|
||||
|
||||
**Q:迁移后 OKX 监控单没了?**
|
||||
- 查是否拷错目录(三所各一个库),或恢复后用了空库路径(`DB_PATH` 不一致).
|
||||
|
||||
**Q:图片 404?**
|
||||
- 检查 `static/images` 是否解压到实例目录下;数据库里路径若为相对路径,一般与目录结构一致即可.
|
||||
|
||||
**Q:本地还用 SOCKS,云上要不要?**
|
||||
- 云上通常 **不需要** SSH 隧道;见 [云服务器部署说明.md](./云服务器部署说明.md) 与此前说明:直连稳定后去掉 `*_SOCKS_PROXY`.
|
||||
|
||||
---
|
||||
|
||||
## 十,相关脚本
|
||||
|
||||
各实例目录:
|
||||
|
||||
```bash
|
||||
bash scripts/backup_data.sh
|
||||
```
|
||||
|
||||
环境变量:`BACKUP_ROOT`,`BACKUP_RETENTION_DAYS`,`BACKUP_INSTANCE`(见脚本内注释).
|
||||
@@ -0,0 +1,130 @@
|
||||
# 行情区(K 线)说明
|
||||
|
||||
中控 **行情区** `/market` 提供多交易所 K 线查看:按需拉取,本地 SQLite 缓存,可选技术指标与持仓价格线.数据经各实例 Flask 的 `/api/hub/ohlcv`(底层 `hub_ohlcv_lib` + ccxt)获取.
|
||||
|
||||
相关代码:`manual_trading_hub/static/chart.js`,`hub_kline_store.py`(仓库根目录),`hub.py` 的 `/api/chart/*`.
|
||||
|
||||
---
|
||||
|
||||
## 1. 入口与导航
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| 顶栏 **行情区** | 打开 `/market` |
|
||||
| 监控区持仓 | 点击合约名(**打开行情区**)→ 跳转 `/market?exchange_key=...&symbol=...`,并带入入场/止损/止盈等标记(`sessionStorage`) |
|
||||
| 全屏工具条 | K 线全屏时可在顶部切换交易所,币种,周期并 **加载** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 支持的周期
|
||||
|
||||
下拉框与后端 `CHART_TIMEFRAMES` 一致:
|
||||
|
||||
| 周期 | 数字快捷键(分钟) |
|
||||
|------|-------------------|
|
||||
| 1m | `1`(稍停或 Enter 确认;连按 `1`→`5` 为 15m) |
|
||||
| 5m | `5` |
|
||||
| 15m | `15` |
|
||||
| 1h | `60` |
|
||||
| 2h | `120` |
|
||||
| 4h | `240` |
|
||||
| 12h | `720` |
|
||||
| 1d | `1440` |
|
||||
| 1w | `10080` |
|
||||
|
||||
- 快捷键仅在行情页,且焦点不在输入框/下拉框时生效.
|
||||
- **全屏**:按 **`F`** 切换;全屏时 **`Esc`** 退出.
|
||||
- 无效或已移除的周期(如 URL 带 `6h`)会回退为默认 **5m**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据拉取与本地库
|
||||
|
||||
| 项 | 说明 |
|
||||
|------|------|
|
||||
| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;Hub **后台轮询** 增量更新尾部 K 线 |
|
||||
| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`(不纳入 Git) |
|
||||
| **保留** | 默认 **15 天**(`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 |
|
||||
| **根数** | 日内周期约 **1000** 根;`1d` / `1w` 约 **500** 根 |
|
||||
| **刷新** | Hub 约 **5 秒** 轮询:① 监控区**有持仓**的合约(默认周期 `5m`)② 行情页 **watch** 的交易所+币种+周期(页面打开时每 25s 续期).浏览器经 **SSE** 收 `chart_version` 后拉 `/api/chart/ohlcv`.**加载** 读库;**强制刷新** 全量重拉 |
|
||||
| **分页** | OKX/Gate 等单次常限 ~300 根,中控会自动分页补全 |
|
||||
| **12h** | 若交易所无原生 12h 或 K 线间隔异常,会从 **1h** 聚合生成 |
|
||||
|
||||
环境变量(`manual_trading_hub/.env`):
|
||||
|
||||
```bash
|
||||
# HUB_KLINE_RETENTION_DAYS=15
|
||||
# HUB_KLINE_DB_PATH=/opt/crypto_monitor_user/manual_trading_hub/data/hub_kline.db
|
||||
# HUB_CHART_POLL_INTERVAL=5
|
||||
# HUB_CHART_POSITION_TIMEFRAME=5m
|
||||
# HUB_CHART_WATCH_TTL_SEC=45
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 图表功能
|
||||
|
||||
- **主图**:K 线 + 成交量(Lightweight Charts).
|
||||
- **价格轴**:「自动」切换是否跟随最新价缩放.
|
||||
- **技术指标**(可选勾选):EMA 21/55,MACD,RSI(含 30/70 参考线);副图自上而下为 MACD,RSI.
|
||||
- **持仓标记**(从监控跳转时):展示入场,止损,止盈,张数,**浮盈亏**(约 5 秒随监控快照刷新),委托摘要;K 线上绘制对应价格线.趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显.
|
||||
- **趋势保本移交**:移交到下单监控后,持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致(不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示.
|
||||
- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈).须已有有效止盈价(交易所条件单或计划止盈);仅改止损,不改止盈时止盈价沿用当前上下文.
|
||||
- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明).
|
||||
|
||||
---
|
||||
|
||||
## 5. HTTP API(中控)
|
||||
|
||||
须登录(与监控区相同,`/api/ping` 等白名单除外).
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/chart/meta` | 已启用交易所列表,周期列表,各周期 limit,保留天数 |
|
||||
| GET | `/api/chart/ohlcv` | 查询参数:`exchange_key`,`symbol`,`timeframe`,可选 `refresh=1` 强制刷新 |
|
||||
| POST | `/api/chart/watch` | 行情页订阅(JSON:`exchange_key`,`symbol`,`timeframe`),45s 内需续期 |
|
||||
| POST | `/api/chart/unwatch` | 离开行情页取消订阅 |
|
||||
| GET | `/api/chart/stream` | SSE:`event: chart`,含 `chart_version` 与各 `series` 版本 |
|
||||
| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 |
|
||||
|
||||
实例侧(中控转发):
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| GET | `/api/hub/ohlcv` | 各 `crypto_monitor_*` 经 `hub_bridge` 注册;参数 `symbol`,`timeframe`,`since_ms`,`limit` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 部署与升级注意
|
||||
|
||||
1. **hub** 与 **三实例 Flask** 均需 `git pull` 到含 `hub_ohlcv_lib.py`,`hub_kline_store.py` 的版本.
|
||||
2. 重启:`pm2 restart manual-trading-hub` 及 `crypto_binance`,`crypto_okx`,`crypto_gate`,``(名称以你环境为准).
|
||||
3. 浏览器 **强刷**(`chart.js` 带版本 query,避免旧前端缓存).
|
||||
4. 周期或拉取逻辑升级后,对异常图表点一次 **强制刷新**,必要时可删 `data/hub_kline.db` 后重拉(会丢失本地缓存,不影响策略库).
|
||||
|
||||
回滚标签说明见 [SNAPSHOT_ROLLBACK.md](./SNAPSHOT_ROLLBACK.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 只显示约 300 根 | `git pull` 实例与 hub,强制刷新;确认 `hub_ohlcv_lib` 已含分页逻辑 |
|
||||
| 12h 错乱或过少 | 强制刷新;Gate 等无原生 12h 时依赖 1h 聚合,需实例 OHLCV 正常 |
|
||||
| 周期下拉无某项 | 以当前 `CHART_TIMEFRAMES` 为准;已移除 3m/10m/20m/30m/6h/8h 等 |
|
||||
| 快捷键无效 | 确认在行情页;全屏用 **F**;数字键勿在币种输入框内按 |
|
||||
| 持仓线不显示 | 须从监控区点击合约进入;或清除标记后重新跳转 |
|
||||
|
||||
更多中控共性问题见 [常见问题.md](./常见问题.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. 文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [使用说明.md](./使用说明.md) | 中控总览(含行情区摘要) |
|
||||
| [行情区说明.md](./行情区说明.md) | 本文 |
|
||||
| [部署文档.md](./部署文档.md) | PM2 / 反代 / 验收 |
|
||||
| [.env.example](./.env.example) | `HUB_KLINE_*` 等变量 |
|
||||
@@ -0,0 +1,94 @@
|
||||
# 资金概况 — 使用说明
|
||||
|
||||
中控顶栏 **资金概况**(`/funds`)汇总三所账户的 **资金账户 + 交易账户** 余额,不含浮盈亏;未监控账户不参与合计,但仍会在分户列表中灰显展示.
|
||||
|
||||
---
|
||||
|
||||
## 1. 口径
|
||||
|
||||
| 项目 | 规则 |
|
||||
|------|------|
|
||||
| **单户总资金** | `资金账户 USDT + 交易账户 USDT` |
|
||||
| **总资金** | 所有 **已启用且未被环境强制关闭** 的账户之和 |
|
||||
| **未监控** | 设置页未勾选「启用」或 `HUB_DISABLED_IDS` 强制关闭 → **跳过合计** |
|
||||
| **缺数据** | 资金户,交易户任一侧缺失 → 该户当日快照 **跳过**(不估,不补 0) |
|
||||
| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切日,与三所统计一致 |
|
||||
| **曲线粒度** | 每个交易日 **1 个点** |
|
||||
| **统计起点** | 默认 **2026-06-09**(`HUB_FUND_HISTORY_START_DAY`);此前不记,不展示 |
|
||||
| **历史保留** | 自起点起最多 **180** 个交易日(`HUB_FUND_HISTORY_DAYS`) |
|
||||
| **最大回撤** | 基于 **总资金曲线**(分户同理),峰值到谷底的最大跌幅(U 与 %) |
|
||||
|
||||
> 与实例统计页「最大回撤」不同:实例统计来自 **平仓盈亏累计**;资金概况来自 **账户余额曲线**.
|
||||
|
||||
---
|
||||
|
||||
## 2. 页面说明
|
||||
|
||||
### 总览
|
||||
|
||||
- **总资金**:当前监控板最新一轮聚合的实时合计(资金户+交易户齐全才计入)
|
||||
- **累计盈亏**:相对统计起点(`HUB_FUND_HISTORY_START_DAY`)首个快照的总资金变动(U / %);顶栏大字绿涨红跌,一眼可看盈亏。含出入金影响,口径同权益曲线,不含浮盈
|
||||
- **较昨日**:相对上一交易日快照点的变动(U)
|
||||
- **最大回撤**:总资金历史曲线的峰值回撤(U / %)
|
||||
- **总资金曲线**:近 180 交易日
|
||||
|
||||
### 分户卡片
|
||||
|
||||
每户展示:总资金,资金户,交易户,最大回撤,迷你曲线.
|
||||
|
||||
- **已监控**:正常统计
|
||||
- **未监控**:显示「未参与合计」,无曲线
|
||||
- **余额未齐**:已监控但 API 未返回完整资金/交易户
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据从哪来
|
||||
|
||||
```
|
||||
监控板每 5 秒聚合(board_store)
|
||||
└→ 各实例 GET /api/hub/account
|
||||
funding_usdt / trading_usdt
|
||||
└→ 写入 hub_fund_history.json(按交易日去重更新当日)
|
||||
|
||||
资金概况页 GET /api/hub/fund-overview
|
||||
├→ 实时:读 board 缓存
|
||||
└→ 曲线/回撤:读 hub_fund_history.json
|
||||
```
|
||||
|
||||
- 存储文件:`manual_trading_hub/hub_fund_history.json`(不在 Git 中)
|
||||
- 旧 AI 快照 `hub_ai_fund_history.json` 会在首次读取时 **自动合并** 到新文件
|
||||
- AI 教练生成上下文时也会写入同日快照(与监控板共用逻辑)
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `HUB_FUND_HISTORY_DAYS` | `180` | 资金快照保留交易日数(与起点取较晚边界) |
|
||||
| `HUB_FUND_HISTORY_START_DAY` | `2026-06-09` | 曲线/回撤统计起始交易日 |
|
||||
| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点(北京),与三所 `.env` 建议一致 |
|
||||
| `HUB_BOARD_POLL_INTERVAL` | `5` | 监控聚合间隔(秒),影响快照刷新频率 |
|
||||
|
||||
---
|
||||
|
||||
## 5. API
|
||||
|
||||
`GET /api/hub/fund-overview`(需中控登录,与监控区相同)
|
||||
|
||||
返回字段概要:
|
||||
|
||||
- `totals.total_usdt` — 当前总资金
|
||||
- `totals.day_delta_usdt` — 较昨日变动
|
||||
- `totals.period_delta_usdt` / `period_delta_pct` / `start_usdt` — 相对曲线起点累计盈亏
|
||||
- `totals.series[]` — `{ day, total_usdt }` 总曲线
|
||||
- `totals.drawdown` — `{ peak_usdt, max_drawdown_u, max_drawdown_pct }`
|
||||
- `accounts[]` — 分户实时余额,曲线,回撤,日/累计变动,`monitored` 标记
|
||||
|
||||
---
|
||||
|
||||
## 6. 相关文档
|
||||
|
||||
- [使用说明.md](./使用说明.md) — 中控总览
|
||||
- [AI教练说明.md](./AI教练说明.md) — AI 上下文中的资金快照文本
|
||||
- [部署文档.md](./部署文档.md) — 重启 `manual-trading-hub` 后生效
|
||||
@@ -0,0 +1,336 @@
|
||||
# 多账户交易中控 — 部署文档(含 PM2)
|
||||
|
||||
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(复盘系统中控:监控区,系统设置,登录保护)的推荐步骤.
|
||||
|
||||
- 功能与界面:[使用说明.md](./使用说明.md)
|
||||
- **云服务器(VPS)完整配置**:[云服务器部署说明.md](./云服务器部署说明.md)
|
||||
- **本地备份并迁到云上**:[本地数据迁移到云端.md](./本地数据迁移到云端.md)
|
||||
- **局域网 IP:端口 / 反代域名,中控打开实例免登录**:[局域网与反代部署说明.md](./局域网与反代部署说明.md)
|
||||
- 故障实录:[常见问题.md](./常见问题.md)
|
||||
- 环境变量模板:[.env.example](./.env.example)
|
||||
|
||||
---
|
||||
|
||||
## 一,部署目标
|
||||
|
||||
| 组件 | 作用 | 默认监听 |
|
||||
|------|------|----------|
|
||||
| **hub.py** | 中控 Web + API | `0.0.0.0:5100` |
|
||||
| **agent.py × N** | 各账户持仓 / 紧急全平 | `127.0.0.1:15200`~`15202` |
|
||||
| **crypto_monitor_*.app** | 策略,关键位,下单逻辑 | 各目录 `.env` 的 `APP_PORT` |
|
||||
|
||||
- 账户列表与 URL 由 **`hub_settings.json`**(网页「系统设置」保存)或内置默认维护;**不再使用** `HUB_AGENTS`.
|
||||
- 三实例 Flask **无需为中控改业务代码**(已注册 `hub_bridge`);与中控并行运行.
|
||||
|
||||
---
|
||||
|
||||
## 二,前置条件
|
||||
|
||||
1. **Python 3.10+**,`python3-venv`,`pip`.
|
||||
2. **Node.js + npm**(用于安装 PM2):`sudo npm i -g pm2`.
|
||||
3. 各 `crypto_monitor_*` 目录已 **`cp .env.example .env`** 并填好 API 密钥.
|
||||
4. 端口无冲突:`5100`,`15200`~`15202`,各实例 `APP_PORT`(5000/5001/5004).
|
||||
5. 建议代码路径:`/opt/crypto_monitor_user/`(下文用此示例,请按实际路径替换).
|
||||
|
||||
---
|
||||
|
||||
## 三,安装中控依赖
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # 激活 venv(当前终端后续 pip/python 走虚拟环境)
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# 编辑 .env:HUB_PORT,HUB_DISABLED_IDS,公网时 HUB_BRIDGE_TOKEN 等
|
||||
chmod +x scripts/run_hub.sh scripts/run_agent.sh scripts/pm2_hub.sh scripts/pm2_agents.sh
|
||||
chmod +x scripts/fix_hub_deps.sh scripts/verify_hub_deploy.sh scripts/fix_env_crlf.sh
|
||||
deactivate # 可选;交给 PM2 时不必保持激活
|
||||
```
|
||||
|
||||
**依赖说明**:`requirements.txt` 含 **`python-multipart`**(FastAPI 表单解析;旧版若保留下单接口时需要),**`psutil`**(监控区服务器状态条).当前中控**已移除下单区**,但仍建议安装完整依赖.
|
||||
|
||||
说明:
|
||||
|
||||
- **安装依赖**:必须在 **`manual_trading_hub/.venv`** 内执行,勿对系统 Python 直接 `pip install`(Debian/Ubuntu 会报 `externally-managed-environment`).`source .venv/bin/activate` 后用 `pip install` 即可;不写 `activate` 时也可用 **`.venv/bin/pip install -r requirements.txt`**(效果相同).快捷:`bash scripts/fix_hub_deps.sh`.
|
||||
- **PM2 启动**:由 `scripts/run_hub.sh` 固定调用 **`.venv/bin/python`**,**不依赖**你是否在 shell 里激活过 venv.
|
||||
- **监控磁盘**:可选环境变量 **`HUB_HOST_DISK_PATH`**(如 `/opt/crypto_monitor_user`),未设时 Linux 为 `/`,Windows 为系统盘.
|
||||
|
||||
子代理也使用 **本目录 `.venv`** 里的 Python(与各策略 Flask 的 venv 可分开).
|
||||
|
||||
---
|
||||
|
||||
## 四,推荐启动顺序
|
||||
|
||||
```
|
||||
1. 各实例 Flask(APP_PORT) ← 各 crypto_monitor_* 目录 ecosystem.config.cjs
|
||||
2. 中控 + 子代理(5100 + 15200~15202) ← 本目录一条 PM2 命令同时启动
|
||||
```
|
||||
|
||||
**`ecosystem.config.cjs` 会一次拉起 3 个 agent + 1 个 hub**,无需再单独 `pm2 start` 子代理.
|
||||
|
||||
仅反代中控到公网时:Flask / agent 仍只监听 **127.0.0.1**;系统设置里 URL 填 `http://127.0.0.1:端口`.
|
||||
|
||||
---
|
||||
|
||||
## 五,PM2 托管(hub + agent 一起启动,推荐)
|
||||
|
||||
### 5.1 一条命令启动全部
|
||||
|
||||
| 文件 | 包含进程 |
|
||||
|------|----------|
|
||||
| `ecosystem.config.cjs` | `manual-agent-binance` / `okx` / `gate` + **`manual-trading-hub`** |
|
||||
|
||||
`run_hub.sh` 加载 **`manual_trading_hub/.env`** 后执行 `hub.py`;各 agent 经 **`run_agent.sh`** 在对应策略目录加载 **`.env`**(含 API 密钥),再执行 `agent.py`.
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
|
||||
pm2 start ecosystem.config.cjs # 4 个进程一起起
|
||||
pm2 save
|
||||
|
||||
# 或
|
||||
bash scripts/pm2_hub.sh start
|
||||
```
|
||||
|
||||
### 5.2 PM2 进程一览
|
||||
|
||||
| 进程名 | 工作目录 | 端口/说明 |
|
||||
|--------|----------|-----------|
|
||||
| manual-agent-binance | crypto_monitor_binance | agent `15200` |
|
||||
| manual-agent-okx | crypto_monitor_okx | agent `15201` |
|
||||
| manual-agent-gate | crypto_monitor_gate | agent `15202` |
|
||||
| manual-trading-hub | manual_trading_hub | hub `5100` |
|
||||
|
||||
OKX 子代理会启动;不用 OKX 可 `pm2 stop manual-agent-okx`.
|
||||
|
||||
### 5.3 常用运维命令
|
||||
|
||||
```bash
|
||||
pm2 status
|
||||
pm2 logs manual-trading-hub --lines 200
|
||||
pm2 restart ecosystem.config.cjs # 重启 hub + 全部 agent
|
||||
|
||||
bash scripts/pm2_hub.sh restart # 同上
|
||||
bash scripts/pm2_hub.sh stop
|
||||
bash scripts/pm2_hub.sh logs
|
||||
```
|
||||
|
||||
仅重启中控,不动 agent:
|
||||
|
||||
```bash
|
||||
pm2 restart manual-trading-hub
|
||||
```
|
||||
|
||||
仅重启子代理:
|
||||
|
||||
```bash
|
||||
pm2 restart manual-agent-binance manual-agent-gate manual-agent-okx
|
||||
# 或
|
||||
bash scripts/pm2_agents.sh restart
|
||||
```
|
||||
|
||||
### 5.4 开机自启
|
||||
|
||||
```bash
|
||||
pm2 save
|
||||
pm2 startup
|
||||
# 按终端提示执行一行 sudo 命令后,再 pm2 save
|
||||
```
|
||||
|
||||
### 5.5 与各实例 Flask 一起查看
|
||||
|
||||
```bash
|
||||
pm2 status
|
||||
# 示例同时存在:
|
||||
# manual-trading-hub,manual-agent-*
|
||||
# crypto_binance / crypto_gate …(各策略目录自有 ecosystem.config.cjs)
|
||||
```
|
||||
|
||||
### 5.6 Gate 子代理「一会能连,一会子代理不可用」(Windows `.env` 换行)
|
||||
|
||||
**现象**:Gate 卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现:
|
||||
|
||||
```text
|
||||
./.env: line 22: $'\r': command not found
|
||||
agent start: exchange=gate port=15202 ...
|
||||
```
|
||||
|
||||
**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env` 为 **CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**).
|
||||
|
||||
**处理**(在服务器仓库根执行):
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user
|
||||
sed -i 's/\r$//' crypto_monitor_gate/.env
|
||||
bash manual_trading_hub/scripts/fix_env_crlf.sh
|
||||
cd manual_trading_hub
|
||||
pm2 delete manual-agent-gate 2>/dev/null || true
|
||||
pm2 start ecosystem.config.cjs --only manual-agent-gate
|
||||
pm2 save
|
||||
curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true
|
||||
```
|
||||
|
||||
**预防**:`.env` 保存为 **LF**(勿在 Windows 记事本直接保存 CRLF).子代理须经 **`scripts/run_agent.sh`** 启动(内置去 CRLF 的 `load_dotenv_file`),勿裸跑 `python agent.py`.
|
||||
|
||||
详见 [常见问题.md](./常见问题.md) **§3.1**,**§3.3**.
|
||||
|
||||
---
|
||||
|
||||
## 六,手动启动(不用 PM2 时)
|
||||
|
||||
需**分别**起 agent 与 hub(与 PM2 合并启动不同):
|
||||
|
||||
```bash
|
||||
# 子代理:由 ecosystem.config.cjs 经 run_agent.sh 启动(勿用手动多终端)
|
||||
# 中控:
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
bash scripts/run_hub.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七,浏览器验收
|
||||
|
||||
1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD`,用 `HUB_USERNAME` / `HUB_PASSWORD` 登录.
|
||||
2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓;Flask 已起时有关键位/趋势信息.
|
||||
3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线;升级后强刷浏览器,详见 [行情区说明.md](./行情区说明.md).
|
||||
4. **http://127.0.0.1:5100/ai** — AI 教练(三户今日总结 + 聊天);`manual_trading_hub/.env` 配与三实例相同的 `AI_*` 变量,见 [AI教练说明.md](./AI教练说明.md).
|
||||
5. **http://127.0.0.1:5100/settings** — 保存后生成 `hub_settings.json`(增加第五户,Gate 子账户等见 [使用说明.md §4.5](./使用说明.md#45-增加账户例如再挂一个-gate)).
|
||||
5. 监控卡片 **「实例」** — 在各 `crypto_monitor_*` 网页做下单,关键位,趋势;中控**不提供**下单表单.
|
||||
|
||||
**命令行验收**(推荐):
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user/manual_trading_hub
|
||||
bash scripts/verify_hub_deploy.sh
|
||||
```
|
||||
|
||||
应看到:`OK: 无 api_trade_key`,`HTTP 200`,JSON 含 `"build":"20260521-no-trade-ui"`.
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:5100/api/ping
|
||||
curl -s http://127.0.0.1:15200/status | head -c 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八,仅反代中控到公网(实例不反代)
|
||||
|
||||
1. Nginx/Caddy 反代到 **`127.0.0.1:5100`**,配置 **HTTPS**.
|
||||
2. 反代需传递(登录 Cookie 正确识别 HTTPS):
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
3. `manual_trading_hub/.env` **务必设置**:
|
||||
```env
|
||||
HUB_USERNAME=你的用户名
|
||||
HUB_PASSWORD=你的强密码
|
||||
HUB_SESSION_SECRET=随机长串
|
||||
HUB_COOKIE_SECURE=true
|
||||
```
|
||||
4. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**(中控本机调 API).
|
||||
5. 三实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**.
|
||||
6. 子代理 **`HOST=127.0.0.1`**;防火墙勿对公网开放 `15200`~`15202`,各 `APP_PORT`.
|
||||
7. **复盘/实例外链**:`HUB_PUBLIC_ORIGIN=https://你的域名` 或内网 IP;否则其它设备点「复盘」会跳到 `127.0.0.1`.
|
||||
|
||||
**说明**:HTTPS 域名与 HTTP `内网IP:5100` Cookie **不共用**;内网访问 IP 需在 IP 地址再登录一次(见 [常见问题.md](./常见问题.md) §2.1).
|
||||
|
||||
---
|
||||
|
||||
## 九,环境变量(中控 `.env`)
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `HUB_HOST` | `0.0.0.0` | 监听地址 |
|
||||
| `HUB_PORT` | `5100` | 端口 |
|
||||
| `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) |
|
||||
| `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` |
|
||||
| `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接;如 `http://192.168.1.100`(**内网其它电脑访问中控时建议设置**) |
|
||||
| `HUB_BRIDGE_TOKEN` | 空 | 与三实例一致;公网建议配置 |
|
||||
| `HUB_USERNAME` | `admin` | Web 登录用户名 |
|
||||
| `HUB_PASSWORD` | 空 | 非空即启用登录 |
|
||||
| `HUB_SESSION_SECRET` | — | 会话签名 |
|
||||
| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true` |
|
||||
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
|
||||
|
||||
本地联调,实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN`;本机不设 `HUB_PASSWORD` 则无需登录页.
|
||||
|
||||
---
|
||||
|
||||
## 十,升级与回滚
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor_user
|
||||
git pull
|
||||
|
||||
cd manual_trading_hub
|
||||
bash scripts/fix_hub_deps.sh
|
||||
bash scripts/verify_hub_deploy.sh
|
||||
|
||||
pm2 restart ecosystem.config.cjs
|
||||
# 若只改了中控:pm2 restart manual-trading-hub
|
||||
```
|
||||
|
||||
- **`hub_settings.json`**,**`hub_ai_summaries.json`**,**`hub_ai_chat.json`**,**`.env`** 不在 Git 中,`git pull` 不会覆盖.
|
||||
- 升级前可备份:`cp hub_settings.json hub_settings.json.bak`,`cp hub_ai_*.json hub_ai_backup/`,`cp .env .env.bak`.
|
||||
|
||||
**升级后自检**:`curl -s http://127.0.0.1:5100/api/ping` 须含 `"trade_ui":false`.若仍见 `api_trade_key` 报错,说明代码未更新或未重启,见 [常见问题.md](./常见问题.md) §1.
|
||||
|
||||
---
|
||||
|
||||
## 十一,故障排查(速查)
|
||||
|
||||
**完整实录**见 **[常见问题.md](./常见问题.md)**.
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| PM2 启动后立刻退出 | `pm2 logs manual-trading-hub`;检查 `.venv`,`.env`,`run_hub.sh` |
|
||||
| `api_trade_key` / `python-multipart` | `git pull` → `bash scripts/fix_hub_deps.sh` → `verify_hub_deploy.sh` → 重启 hub |
|
||||
| `verify` ping 解析失败 | hub 未起:`pm2 restart manual-trading-hub` |
|
||||
| 余额显示 — | agent 未加载 `.env`;`fix_env_crlf.sh`;`run_agent.sh` |
|
||||
| agent `$'\r': command not found` | `bash scripts/fix_env_crlf.sh` |
|
||||
| 监控无持仓 | `curl http://127.0.0.1:15200/status` |
|
||||
| 无关键位 / 401 | 启动 Flask;核对 `HUB_BRIDGE_TOKEN` / `hub_bridge` |
|
||||
| 域名能登,IP 不能登 | 见常见问题 §2.1(Cookie / HTTP vs HTTPS) |
|
||||
| 公网访问中控 403 | 反代到 `127.0.0.1:5100`,勿公网直连 5100 |
|
||||
| 改 `.env` 不生效 | `pm2 restart manual-trading-hub` |
|
||||
|
||||
---
|
||||
|
||||
## 十二,进程托管说明
|
||||
|
||||
中控与子代理 **仅使用 PM2**(`ecosystem.config.cjs`).勿再用 screen / systemd / nohup 启动 `hub.py` 或 `agent.py`,以免端口冲突.
|
||||
环境要求见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 十三,安全清单
|
||||
|
||||
- [ ] 公网仅暴露反代端口,不暴露 Flask/agent 端口
|
||||
- [ ] 已设 `HUB_USERNAME` + `HUB_PASSWORD`(中控 Web 登录)
|
||||
- [ ] HTTPS 反代已设 `HUB_COOKIE_SECURE=true` 且传递 `X-Forwarded-Proto`
|
||||
- [ ] 公网已配置 `HUB_BRIDGE_TOKEN` + 实例关闭 `APP_AUTH_DISABLED`
|
||||
- [ ] API Key 最小权限;交易所 IP 白名单
|
||||
- [ ] 已告知操作人员「全局全平」不可撤销;中控**不在网页下单**
|
||||
|
||||
---
|
||||
|
||||
## 十四,文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [使用说明.md](./使用说明.md) | 功能,页面,API,环境变量 |
|
||||
| [常见问题.md](./常见问题.md) | 故障实录 |
|
||||
| [README.md](./README.md) | 速览 |
|
||||
| [.env.example](./.env.example) | 环境变量模板 |
|
||||
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻(唯一推荐) |
|
||||
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 |
|
||||
| `scripts/fix_hub_deps.sh` | 安装依赖 |
|
||||
| `scripts/verify_hub_deploy.sh` | 部署验收 |
|
||||
| `scripts/fix_env_crlf.sh` | 修复 .env 换行 |
|
||||