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>
This commit is contained in:
dekun
2026-07-17 16:18:13 +08:00
commit 53863559f4
608 changed files with 162764 additions and 0 deletions
+112
View File
@@ -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
+66
View File
@@ -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)(实例侧).
+105
View File
@@ -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 (:1520015202) 持仓,全平
└→ 各 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`(1520015202),密钥来自**该目录 `.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) | 环境变量模板 |
+22
View File
@@ -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`,回滚代码不会自动删除该文件.
+910
View File
@@ -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 错开;中控默认聚合 1520015202)
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-")),
};
+66
View File
@@ -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,
},
},
],
};
+34
View File
@@ -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
+846
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)."""
+161
View File
@@ -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(),
}
+101
View File
@@ -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,
}
+275
View File
@@ -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 [],
}
+42
View File
@@ -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,
)
+57
View File
@@ -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
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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",
]
+244
View File
@@ -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)
+200
View File
@@ -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
+302
View File
@@ -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() + "..."
+95
View File
@@ -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)
+125
View File
@@ -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(),
}
+14
View File
@@ -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() + ""
+165
View File
@@ -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()
+280
View File
@@ -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()
+140
View File
@@ -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())
+169
View File
@@ -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()
+48
View File
@@ -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)
+148
View File
@@ -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()
+757
View File
@@ -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}
+182
View File
@@ -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
+53
View File
@@ -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
+8
View File
@@ -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}"
}
+55
View File
@@ -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
+90
View File
@@ -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"
+48
View File
@@ -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}"
+28
View File
@@ -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 版本与三所启动顺序 |
+136
View File
@@ -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")]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+250
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function escAttr(s) {
return esc(s).replace(/'/g, "&#39;");
}
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);
});
});
})();
+642
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;");
}
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 () {},
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+730
View File
@@ -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%;
}
}
+459
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
},
};
})();
+529
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
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 };
})();
+125
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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() {},
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+17
View File
@@ -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"
}
]
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -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>
+169
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();
+772
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();
+284
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();
+182
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();
+71
View File
@@ -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);
+194
View File
@@ -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);
+61
View File
@@ -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,50005004,1520015202)**不对公网直连**.
相关文档:
- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`,图片,`hub_settings` 与恢复步骤
- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照,SSO 行为
- [部署文档.md](./部署文档.md) — PM2,依赖安装,日常运维
- [使用说明.md](./使用说明.md) — 中控功能说明
- [常见问题.md](./常见问题.md) — 故障排查
- 环境变量模板:[.env.example](./.env.example)
---
## 一,服务器硬件与系统
| 项目 | 建议 |
|------|------|
| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G8G 更稳** |
| 系统 | **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**(Lets 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 | 1520015202 | **否**,必须 **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**(Lets 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,50005004,1520015202
- [ ] 三实例 `.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).
+84
View File
@@ -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 进程名).
+518
View File
@@ -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 | 1520015202(仅本机) |
### 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**(Lets 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).
+354
View File
@@ -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) | 中控环境变量模板 |
+85
View File
@@ -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` | 单元测试 |
+50
View File
@@ -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`(见脚本内注释).
+130
View File
@@ -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_*` 等变量 |
+94
View File
@@ -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` 后生效
+336
View File
@@ -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 + 1520015202) ← 本目录一条 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 换行 |