feat(hub): add AI coach page with daily summary and chat

Aggregate four-account trades via hub_ai module and /api/hub/trades/today; store sessions in JSON; default OpenAI config matches instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-06 23:51:36 +08:00
parent 4fad5696df
commit cee641ba5d
23 changed files with 1557 additions and 14 deletions
+2
View File
@@ -16,6 +16,8 @@
**/.env.bak **/.env.bak
**/.env.local **/.env.local
manual_trading_hub/hub_settings.json manual_trading_hub/hub_settings.json
manual_trading_hub/hub_ai_summaries.json
manual_trading_hub/hub_ai_chat.json
manual_trading_hub/data/ manual_trading_hub/data/
# 数据库与上传(运行时生成) # 数据库与上传(运行时生成)
+42
View File
@@ -401,6 +401,48 @@ def register_hub_routes(app):
) )
) )
@app.route("/api/hub/trades/today")
@_hub_auth_required
def api_hub_trades_today():
"""中控 AI:当日已平仓记录(按实例交易日)。"""
from hub_trades_lib import (
current_trading_day,
fetch_trades_for_trading_day,
summarize_trades,
)
c = _ctx()
get_db = c.get("get_db")
if not get_db:
return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500
day_arg = (request.args.get("trading_day") or request.args.get("date") or "").strip()[:10]
try:
import os
reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8")
except ValueError:
reset_hour = 8
trading_day = day_arg or current_trading_day(reset_hour=reset_hour)
conn = get_db()
try:
trades = fetch_trades_for_trading_day(
conn,
trading_day,
row_to_dict_fn=c.get("row_to_dict"),
)
finally:
conn.close()
stats = summarize_trades(trades)
return jsonify(
{
"ok": True,
"trading_day": trading_day,
"trading_day_reset_hour": reset_hour,
"trades": trades,
"stats": stats,
}
)
@app.route("/api/hub/ohlcv") @app.route("/api/hub/ohlcv")
@_hub_auth_required @_hub_auth_required
def api_hub_ohlcv(): def api_hub_ohlcv():
+120
View File
@@ -0,0 +1,120 @@
"""各实例当日平仓记录查询(供 hub_bridge /api/hub/trades/today 与中控 AI 聚合)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str:
"""与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日。"""
if dt.hour < reset_hour:
dt = dt - timedelta(days=1)
return dt.strftime("%Y-%m-%d")
def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> str:
return trading_day_from_dt(now or datetime.now(), reset_hour)
def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict:
if row is None:
return {}
if row_to_dict:
try:
return dict(row_to_dict(row))
except Exception:
pass
try:
keys = row.keys() if hasattr(row, "keys") else ()
if keys:
return {k: row[k] for k in keys}
except Exception:
pass
try:
return dict(row)
except Exception:
return {}
def fetch_trades_for_trading_day(
conn,
trading_day: str,
*,
row_to_dict_fn: Optional[Callable] = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""返回指定交易日的已平仓记录(优先 session_date,否则 closed_at 日期)。"""
day = (trading_day or "").strip()[:10]
if not day:
return []
lim = max(1, min(int(limit or 200), 500))
rows = conn.execute(
f"""
SELECT symbol, exchange_symbol, direction, result, pnl_amount,
closed_at, opened_at, session_date, monitor_type,
actual_rr, planned_rr, trade_style, entry_reason
FROM trade_records
WHERE (
(session_date IS NOT NULL AND TRIM(session_date) != '' AND session_date = ?)
OR (
(session_date IS NULL OR TRIM(session_date) = '')
AND closed_at IS NOT NULL AND TRIM(closed_at) != ''
AND substr(closed_at, 1, 10) = ?
)
)
AND result IS NOT NULL AND TRIM(result) != ''
ORDER BY COALESCE(closed_at, opened_at) ASC
LIMIT ?
""",
(day, day, lim),
).fetchall()
out: list[dict[str, Any]] = []
for row in rows:
d = _row_dict(row, row_to_dict_fn)
try:
pnl = float(d.get("pnl_amount") or 0)
except (TypeError, ValueError):
pnl = 0.0
out.append(
{
"symbol": d.get("symbol"),
"exchange_symbol": d.get("exchange_symbol"),
"direction": d.get("direction"),
"result": d.get("result"),
"pnl_amount": round(pnl, 4),
"closed_at": d.get("closed_at"),
"opened_at": d.get("opened_at"),
"session_date": d.get("session_date"),
"monitor_type": d.get("monitor_type"),
"actual_rr": d.get("actual_rr"),
"planned_rr": d.get("planned_rr"),
"trade_style": d.get("trade_style"),
"entry_reason": d.get("entry_reason"),
}
)
return out
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
"""单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
total_pnl = 0.0
win = loss = flat = 0
for t in trades or []:
try:
pnl = float(t.get("pnl_amount") or 0)
except (TypeError, ValueError):
pnl = 0.0
total_pnl += pnl
if pnl > 1e-9:
win += 1
elif pnl < -1e-9:
loss += 1
else:
flat += 1
return {
"closed_count": len(trades or []),
"win_count": win,
"loss_count": loss,
"flat_count": flat,
"total_pnl_u": round(total_pnl, 4),
}
+17
View File
@@ -80,3 +80,20 @@ HUB_TRUST_LAN=true
# EXCHANGE=binance # EXCHANGE=binance
# PORT=15200 # PORT=15200
# HOST=127.0.0.1 # 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 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama
AI_PROVIDER=openai
OPENAI_API_BASE=https://op.bz121.com/v1
OPENAI_API_KEY=你的密钥
OPENAI_MODEL=gemma4:e4b
# 本机 OllamaAI_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
+62
View File
@@ -0,0 +1,62 @@
# 中控 AI 教练说明
中控 **AI 教练**`/ai`)与四实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON。
## 能力
| 功能 | 说明 |
|------|------|
| **今日总结** | 聚合四户(含未启用 →「未监控」)当日平仓、持仓浮盈亏、连接状态;语气偏冷、台账式 |
| **AI 聊天** | 单会话直到点「新开对话」;口语化、安慰体贴、轻修正;注入监控快照与今日总结摘要 |
## 存储
`hub_settings.json` 同目录(`manual_trading_hub/`):
- `hub_ai_summaries.json` — 历史总结
- `hub_ai_chat.json` — 聊天会话(`active_session_id` 指向当前会话)
升级 / 迁移时请一并备份(见 [本地数据迁移到云端.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)(实例侧)。
+3 -2
View File
@@ -1,6 +1,6 @@
# 复盘系统中控(manual_trading_hub # 复盘系统中控(manual_trading_hub
> **完整说明**[使用说明.md](./使用说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.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)。 多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。
@@ -12,6 +12,7 @@
|------|------| |------|------|
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) | | 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) | | 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md) |
| 紧急全平 | 单户 / 全局市价减仓 | | 紧急全平 | 单户 / 全局市价减仓 |
| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) | | 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) |
| Web 登录 | `.env``HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) | | Web 登录 | `.env``HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
@@ -22,7 +23,7 @@
## 架构 ## 架构
``` ```
浏览器 → hub.py (:5100) 监控 / 行情 / 设置 / 登录 浏览器 → hub.py (:5100) 监控 / 行情 / **AI 教练** / 设置 / 登录
├→ agent.py × N (:1520015203) 持仓、全平 ├→ agent.py × N (:1520015203) 持仓、全平
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合 └→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
``` ```
+12 -1
View File
@@ -77,7 +77,7 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护 # 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on") HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260603-hub-board-sse" HUB_BUILD = "20260606-hub-ai"
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8")) HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45")) HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
@@ -362,11 +362,22 @@ def root_redirect():
@app.get("/monitor") @app.get("/monitor")
@app.get("/market") @app.get("/market")
@app.get("/ai")
@app.get("/settings") @app.get("/settings")
def shell_pages(): def shell_pages():
return _shell_page() return _shell_page()
def _all_exchanges_for_ai() -> list:
"""AI 聚合用:含未启用账户(标记未监控)。"""
return list(load_settings().get("exchanges") or [])
from hub_ai.routes import create_hub_ai_router
app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai))
@app.get("/trade") @app.get("/trade")
def trade_removed_redirect(): def trade_removed_redirect():
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
+1
View File
@@ -0,0 +1 @@
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)。"""
+88
View File
@@ -0,0 +1,88 @@
"""中控 AI:单会话聊天(直到用户点击新开)。"""
from __future__ import annotations
from typing import Any
from hub_ai.client import generate_text, model_label
from hub_ai.config import CHAT_MAX_HISTORY_TURNS, CHAT_TEMPERATURE
from hub_ai.context import build_daily_context, format_chat_context_brief
from hub_ai.prompts import CHAT_SYSTEM, build_chat_user_prompt
from hub_ai.store import (
append_chat_message,
create_new_session,
ensure_active_session,
get_active_session,
load_chat_store,
summary_excerpt_for_chat,
)
def _history_lines(messages: list[dict], max_turns: int = CHAT_MAX_HISTORY_TURNS) -> 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 "搭档"
lines.append(f"{role}{m.get('content') or ''}")
return "\n".join(lines)
def get_chat_state() -> dict[str, Any]:
store = load_chat_store()
session = get_active_session()
return {
"active_session_id": store.get("active_session_id"),
"session": session,
"model": model_label(),
}
def start_new_chat(*, trading_day: str) -> dict:
session = create_new_session(trading_day=trading_day)
return {"ok": True, "session": session, "model": model_label()}
def send_chat_message(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
) -> dict[str, Any]:
text = (message or "").strip()
if not text:
return {"ok": False, "msg": "消息不能为空"}
ctx = build_daily_context(exchanges, trading_day=trading_day)
day = ctx["trading_day"]
session = ensure_active_session(trading_day=day)
sid = session["id"]
history = _history_lines(session.get("messages") or [])
append_chat_message(sid, "user", text)
brief_ctx = format_chat_context_brief(ctx)
excerpt = summary_excerpt_for_chat(day)
user_prompt = build_chat_user_prompt(
context_text=brief_ctx,
trading_day=day,
summary_excerpt=excerpt,
history_lines=history,
user_message=text,
)
reply = generate_text(
system=CHAT_SYSTEM,
user=user_prompt,
temperature=CHAT_TEMPERATURE,
)
if reply.startswith("AI 调用失败"):
return {"ok": False, "msg": reply, "session_id": sid}
session = append_chat_message(sid, "assistant", reply)
return {
"ok": True,
"trading_day": day,
"session": session,
"reply": reply,
"model": model_label(),
}
+20
View File
@@ -0,0 +1,20 @@
"""中控 AI 模型调用(共用 ai_client 配置,逻辑独立)。"""
from __future__ import annotations
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from ai_client import ai_generate, ai_provider_label # noqa: E402
def model_label() -> str:
return ai_provider_label()
def generate_text(*, system: str, user: str, temperature: float) -> str:
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
return ai_generate(prompt, temperature=temperature)
+33
View File
@@ -0,0 +1,33 @@
"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)。"""
from __future__ import annotations
import os
HUB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SUMMARY_TEMPERATURE = 0.15
CHAT_TEMPERATURE = 0.5
CHAT_MAX_HISTORY_TURNS = 20
SUMMARY_RETENTION_DAYS = 90
CHAT_SESSION_RETENTION_DAYS = 60
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
+278
View File
@@ -0,0 +1,278 @@
"""中控 AI:四户数据聚合为结构化上下文。"""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any, Callable, Optional
import httpx
from hub_ai.config import hub_agent_timeout, hub_flask_timeout, trading_day_reset_hour
from hub_trades_lib import current_trading_day, summarize_trades
def _hub_token() -> str:
return (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
def _hub_headers() -> dict[str, str]:
tok = _hub_token()
return {"X-Hub-Token": tok} if tok else {}
def _agent_headers() -> dict[str, str]:
tok = (os.getenv("CONTROL_TOKEN") or os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
return {"X-Control-Token": tok} if tok else {}
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 _position_float_pnl(pos: dict) -> float:
for key in ("unrealized_pnl", "unrealizedPnl", "upnl"):
v = _safe_float(pos.get(key))
if v is not None:
return v
return 0.0
def _collect_open_issues(
*,
monitored: bool,
agent_ok: bool,
flask_ok: bool,
positions: list,
hub_mon: Optional[dict],
day_pnl: float,
) -> list[str]:
issues: list[str] = []
if not monitored:
return issues
if not agent_ok:
issues.append("Agent 连接异常")
if not flask_ok:
issues.append("Flask 监控连接异常")
if day_pnl < -0.01:
issues.append(f"当日平仓亏损 {day_pnl:.2f}U")
float_pnl = sum(_position_float_pnl(p) for p in positions if isinstance(p, dict))
if float_pnl < -0.5:
issues.append(f"当前浮亏 {float_pnl:.2f}U")
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
orders = hub_mon.get("orders") or []
trends = hub_mon.get("trends") or []
if positions and not orders and not trends:
issues.append("交易所有持仓但无本地 active 监控/趋势计划")
return issues
def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> dict[str, Any]:
name = ex.get("name") or ex.get("key") or ex.get("id")
key = ex.get("key") or ""
enabled = bool(ex.get("enabled"))
env_disabled = bool(ex.get("env_disabled"))
monitored = enabled and not env_disabled
base: dict[str, Any] = {
"id": ex.get("id"),
"key": key,
"name": name,
"enabled": enabled,
"env_disabled": env_disabled,
"status": "未监控" if not monitored else "已监控",
"trades": [],
"trade_stats": summarize_trades([]),
"positions": [],
"float_pnl_u": 0.0,
"balance_usdt": None,
"issues": [],
"agent_ok": False,
"flask_ok": False,
"hub_monitor": None,
"active_orders": 0,
"active_trends": 0,
}
if not monitored:
base["issues"] = []
return base
agent_url = (ex.get("agent_url") or "").rstrip("/")
flask_url = (ex.get("flask_url") or "").rstrip("/")
agent_body = None
if agent_url:
try:
r = client.get(
f"{agent_url}/status",
headers=_agent_headers(),
timeout=hub_agent_timeout(),
)
if r.status_code == 200:
agent_body = r.json()
base["agent_ok"] = True
except Exception as exc:
base["issues"].append(f"Agent: {exc}")
if isinstance(agent_body, dict):
base["balance_usdt"] = _safe_float(agent_body.get("balance_usdt"))
positions = agent_body.get("positions") or []
if isinstance(positions, list):
base["positions"] = positions
base["float_pnl_u"] = round(
sum(_position_float_pnl(p) for p in positions if isinstance(p, dict)), 4
)
hub_mon = None
if flask_url:
try:
r = client.get(
f"{flask_url}/api/hub/trades/today",
headers=_hub_headers(),
params={"trading_day": trading_day},
timeout=hub_flask_timeout(),
)
if r.status_code == 200:
trades_body = r.json()
if isinstance(trades_body, dict) and trades_body.get("ok"):
base["trades"] = trades_body.get("trades") or []
base["trade_stats"] = trades_body.get("stats") or summarize_trades(base["trades"])
base["flask_ok"] = True
except Exception as exc:
base["issues"].append(f"成交接口: {exc}")
try:
r = client.get(
f"{flask_url}/api/hub/monitor",
headers=_hub_headers(),
timeout=hub_flask_timeout(),
)
if r.status_code == 200:
hub_mon = r.json()
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
base["hub_monitor"] = hub_mon
base["flask_ok"] = True
base["active_orders"] = len(hub_mon.get("orders") or [])
base["active_trends"] = len(hub_mon.get("trends") or [])
except Exception as exc:
if "成交接口" not in str(base["issues"]):
base["issues"].append(f"监控接口: {exc}")
if monitored and not base["agent_ok"] and not base["flask_ok"]:
base["status"] = "连接异常"
elif base["issues"]:
base["status"] = "已监控·需关注"
day_pnl = float((base.get("trade_stats") or {}).get("total_pnl_u") or 0)
base["issues"].extend(
_collect_open_issues(
monitored=monitored,
agent_ok=base["agent_ok"],
flask_ok=base["flask_ok"],
positions=base["positions"],
hub_mon=hub_mon if isinstance(hub_mon, dict) else None,
day_pnl=day_pnl,
)
)
base["issues"] = list(dict.fromkeys(base["issues"]))
return base
def build_daily_context(
exchanges: list[dict],
*,
trading_day: Optional[str] = None,
) -> dict[str, Any]:
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
accounts: list[dict] = []
with httpx.Client() as client:
for ex in exchanges or []:
accounts.append(_fetch_account_bundle(client, ex, day))
total_closed_pnl = 0.0
total_closed = total_win = total_loss = 0
total_float = 0.0
for ac in accounts:
if ac.get("status") == "未监控":
continue
st = ac.get("trade_stats") or {}
total_closed_pnl += float(st.get("total_pnl_u") or 0)
total_closed += int(st.get("closed_count") or 0)
total_win += int(st.get("win_count") or 0)
total_loss += int(st.get("loss_count") or 0)
total_float += float(ac.get("float_pnl_u") or 0)
totals = {
"trading_day": day,
"total_pnl_u": round(total_closed_pnl, 4),
"closed_count": total_closed,
"win_count": total_win,
"loss_count": total_loss,
"float_pnl_u": round(total_float, 4),
}
payload = {"trading_day": day, "totals": totals, "accounts": accounts}
text = format_context_text(payload)
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
return {"trading_day": day, "totals": totals, "accounts": accounts, "text": text, "context_hash": digest}
def format_context_text(payload: dict) -> str:
lines = []
totals = payload.get("totals") or {}
lines.append(
f"【合计】交易日 {totals.get('trading_day')} | "
f"平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')}| "
f"监控户浮盈亏合计 {totals.get('float_pnl_u')}U"
)
lines.append("")
for ac in payload.get("accounts") or []:
st = ac.get("trade_stats") or {}
lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---")
lines.append(f"状态:{ac.get('status')}")
if ac.get("status") == "未监控":
lines.append("")
continue
lines.append(
f"当日平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U "
f"(胜{st.get('win_count')}/负{st.get('loss_count')}"
)
lines.append(f"合约可用余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT")
lines.append(f"当前持仓浮盈亏:{ac.get('float_pnl_u')}U | 下单监控 {ac.get('active_orders')} | 趋势计划 {ac.get('active_trends')}")
positions = ac.get("positions") or []
if positions:
lines.append("持仓:")
for p in positions[:8]:
if not isinstance(p, dict):
continue
sym = p.get("symbol") or "?"
side = p.get("side") or "?"
contracts = p.get("contracts") or p.get("size") or "?"
upnl = _position_float_pnl(p)
lines.append(f" - {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
trades = ac.get("trades") or []
if trades:
lines.append("当日平仓明细:")
for t in trades[:15]:
lines.append(
f" - {t.get('symbol')} {t.get('direction')} {t.get('result')} "
f"{t.get('pnl_amount')}U @ {t.get('closed_at') or '?'}"
)
issues = ac.get("issues") or []
if issues:
lines.append("关注点:" + "".join(issues))
lines.append("")
return "\n".join(lines).strip()
def format_chat_context_brief(payload: dict, max_chars: int = 2500) -> str:
text = format_context_text(payload)
if len(text) <= max_chars:
return text
return text[: max_chars - 3].rstrip() + "..."
+74
View File
@@ -0,0 +1,74 @@
"""中控 AI 提示词(与实例 ai_review 分离)。"""
SUMMARY_SYSTEM = """
你是多账户加密货币合约交易的台账助手只根据用户提供的结构化数据输出中文 Markdown语气克制偏冷客观像值班记录
硬性规则
- 只能陈述数据中明确出现的数字与事实禁止编造成交止损扛单行情预测
- 未监控的账户必须标注未监控不得臆测其盈亏
- 连接失败或数据缺失的账户如实写明不要猜测
- 不要用安慰说教建议口吻那些属于聊天功能
- 禁止夸张词致命崩溃灾难等
输出格式Markdown标题必须一致
**今日交易总结{trading_day}**
**1. 总览**
- **合计盈亏(U)**
- **平仓笔数** / /
- **当前持仓浮盈亏(U)**仅汇总已监控且有数据的账户
**2. 分户明细**
每个账户一行账户名 | 状态(已监控/未监控/连接异常) | 当日平仓盈亏 | 笔数 | 当前浮盈亏 | 备注
**3. 需关注**
仅有依据时列出某户当日亏损最大浮亏偏大Flask/Agent 异常有持仓但无本地监控等若无则写
**4. 数据说明**
列出数据缺口某户未启用接口失败等
""".strip()
CHAT_SYSTEM = """
你是和用户一起盯盘的老搭档交易员熟悉他多个交易所账户的分工用中文口语化短句交流
语气要求
- 先理解对方的压力和情绪再轻轻帮他把事想清楚安慰体贴
- 可以指出执行或心态上的偏差点但用商量陪伴的口吻绝不用教育训诫上课列清单式说教
- 不要第1点第2点你应该不要作为你的教练我必须
- 不预测涨跌不保证收益不替用户做决定
- 只能依据提供的监控与交易数据说话看不到的就说我这边看不到你可以去 xx 实例页确认
若附带今日总结摘要可自然引用但保持口语不要复读整份报告
""".strip()
def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
return f"""
交易日{trading_day}
以下为中控聚合的多账户数据含未监控账户标记
{context_text}
""".strip()
def build_chat_user_prompt(
*,
context_text: str,
trading_day: str,
summary_excerpt: str,
history_lines: str,
user_message: str,
) -> str:
parts = [
f"【交易日】{trading_day}",
"【当前多账户快照】",
context_text.strip() or "(无监控数据)",
]
if summary_excerpt.strip():
parts.extend(["【今日总结摘要(供参考)】", summary_excerpt.strip()])
if history_lines.strip():
parts.extend(["【此前对话】", history_lines.strip()])
parts.extend(["【用户现在说】", user_message.strip()])
return "\n\n".join(parts)
+108
View File
@@ -0,0 +1,108 @@
"""中控 AI FastAPI 路由。"""
from __future__ import annotations
from typing import Callable
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from hub_ai.chat import get_chat_state, send_chat_message, start_new_chat
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.summary import generate_daily_summary
from hub_trades_lib import current_trading_day
class ChatSendBody(BaseModel):
message: str = ""
trading_day: str = ""
class SummaryGenerateBody(BaseModel):
trading_day: str = ""
force: bool = False
class ChatNewBody(BaseModel):
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)
@router.post("/chat/send")
def api_ai_chat_send(body: ChatSendBody):
exchanges = load_all_exchanges()
result = send_chat_message(
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
+195
View File
@@ -0,0 +1,195 @@
"""中控 AIJSON 持久化(与 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
def create_new_session(*, trading_day: str, title: str = "新对话") -> dict:
store = load_chat_store()
session = {
"id": uuid.uuid4().hex,
"trading_day": trading_day,
"title": title,
"created_at": _now_str(),
"updated_at": _now_str(),
"messages": [],
}
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 append_chat_message(session_id: str, role: str, content: str) -> 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()}
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 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() + "..."
+69
View File
@@ -0,0 +1,69 @@
"""中控 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
from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt
from hub_ai.store import append_summary, get_latest_summary, list_summaries
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"]
if not force:
latest = get_latest_summary(day)
if latest and latest.get("context_hash") == ctx.get("context_hash"):
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(ctx["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 = {
"totals": ctx.get("totals"),
"by_account": {
str(ac.get("key") or ac.get("id")): {
"name": ac.get("name"),
"status": ac.get("status"),
"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"),
"issues": ac.get("issues") or [],
}
for ac in ctx.get("accounts") or []
},
}
row = append_summary(
trading_day=day,
content_md=content,
model=model_label(),
context_hash=ctx.get("context_hash") or "",
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)
+129
View File
@@ -3342,3 +3342,132 @@ html[data-theme="light"] button.danger {
background: rgba(201, 53, 82, 0.1); background: rgba(201, 53, 82, 0.1);
border-color: rgba(201, 53, 82, 0.45); border-color: rgba(201, 53, 82, 0.45);
} }
/* --- Hub AI 教练 --- */
.ai-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 960px) {
.ai-layout {
grid-template-columns: 1fr;
}
}
.ai-panel {
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: 14px 16px;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ai-panel-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-panel-head h2 {
margin: 0;
font-size: 1rem;
font-family: var(--display);
letter-spacing: 0.04em;
}
.ai-panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.ai-stats-row {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-size: 0.82rem;
color: var(--muted);
}
.ai-stat-chip {
padding: 4px 8px;
border-radius: 6px;
background: var(--inset-surface);
border: 1px solid var(--border-soft);
}
.ai-stat-chip strong {
color: var(--text);
margin-right: 4px;
}
.ai-md-body {
flex: 1;
overflow: auto;
max-height: min(62vh, 640px);
padding: 12px;
border-radius: 8px;
background: var(--inset-surface);
border: 1px solid var(--border-soft);
font-size: 0.86rem;
line-height: 1.55;
color: var(--text);
}
.ai-placeholder {
color: var(--muted);
margin: 0;
}
.ai-chat-panel {
min-height: 520px;
}
.ai-chat-messages {
flex: 1;
overflow: auto;
max-height: min(52vh, 520px);
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 4px;
}
.ai-bubble {
max-width: 92%;
padding: 10px 12px;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.ai-bubble-user {
align-self: flex-end;
background: var(--accent-dim);
border: 1px solid var(--border);
}
.ai-bubble-assistant {
align-self: flex-start;
background: var(--inset-surface);
border: 1px solid var(--border-soft);
}
.ai-chat-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: end;
}
.ai-chat-form textarea {
width: 100%;
resize: vertical;
min-height: 72px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
color: var(--text);
font-family: var(--font);
font-size: 0.88rem;
}
.ai-chat-form textarea:focus {
outline: none;
border-color: var(--accent);
}
+181
View File
@@ -623,12 +623,14 @@
const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings"; if (p.includes("settings")) return "settings";
if (p.includes("market")) return "market"; if (p.includes("market")) return "market";
if (p.includes("/ai")) return "ai";
return "monitor"; return "monitor";
} }
function pageElementId(page) { function pageElementId(page) {
if (page === "settings") return "page-settings"; if (page === "settings") return "page-settings";
if (page === "market") return "page-market"; if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
return "page-monitor"; return "page-monitor";
} }
@@ -648,6 +650,7 @@
if (page === "monitor") startMonitorPoll(); if (page === "monitor") startMonitorPoll();
else stopMonitorPoll(); else stopMonitorPoll();
if (page === "settings") loadSettingsUI(); if (page === "settings") loadSettingsUI();
if (page === "ai") loadAiPage();
if (page === "market" && window.hubMarketChart) { if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init(); window.hubMarketChart.init();
} else if (window.hubMarketChart) { } else if (window.hubMarketChart) {
@@ -2935,6 +2938,184 @@
showToast("已添加一行,请填写 URL 后点「保存设置」"); showToast("已添加一行,请填写 URL 后点「保存设置」");
}; };
let aiMeta = null;
let aiSummaryLoading = false;
let aiChatLoading = false;
function renderAiMarkdown(text) {
const esc = (s) =>
String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return esc(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\n/g, "<br>");
}
function renderAiSummaryStats(snapshot) {
const el = document.getElementById("ai-summary-stats");
if (!el) return;
if (!snapshot || !snapshot.totals) {
el.innerHTML = "";
return;
}
const t = snapshot.totals;
const pnl = Number(t.total_pnl_u);
const pnlCls = pnl > 0 ? "pos" : pnl < 0 ? "neg" : "";
el.innerHTML = [
`<span class="ai-stat-chip"><strong>交易日</strong>${esc(t.trading_day || "—")}</span>`,
`<span class="ai-stat-chip ${pnlCls}"><strong>平仓盈亏</strong>${fmt(pnl, 2)}U</span>`,
`<span class="ai-stat-chip"><strong>笔数</strong>${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0}</span>`,
`<span class="ai-stat-chip"><strong>浮盈亏</strong>${fmt(Number(t.float_pnl_u), 2)}U</span>`,
].join("");
}
function renderAiChatMessages(session) {
const box = document.getElementById("ai-chat-messages");
const title = document.getElementById("ai-chat-title");
if (!box) return;
const msgs = (session && session.messages) || [];
if (title) {
title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天";
}
if (!msgs.length) {
box.innerHTML =
'<p class="ai-placeholder">随便聊:行情、心态、纪律、执行都行。我会先看四户监控数据,用搭档口吻回你。</p>';
return;
}
box.innerHTML = msgs
.map((m) => {
const role = m.role === "user" ? "user" : "assistant";
return `<div class="ai-bubble ai-bubble-${role}">${esc(m.content || "")}</div>`;
})
.join("");
box.scrollTop = box.scrollHeight;
}
async function loadAiMeta() {
const r = await apiFetch("/api/ai/meta");
aiMeta = await r.json();
const sm = document.getElementById("ai-summary-meta");
const cm = document.getElementById("ai-chat-meta");
const label = aiMeta && aiMeta.model ? aiMeta.model : "";
if (sm) sm.textContent = label;
if (cm) cm.textContent = label;
return aiMeta;
}
async function loadAiSummary() {
const body = document.getElementById("ai-summary-body");
try {
const r = await apiFetch("/api/ai/summary");
const j = await r.json();
const latest = j.latest;
if (latest && latest.content_md) {
if (body) body.innerHTML = renderAiMarkdown(latest.content_md);
renderAiSummaryStats(latest.stats_snapshot);
const sm = document.getElementById("ai-summary-meta");
if (sm && latest.generated_at) {
sm.textContent = `${j.model || ""} · ${latest.generated_at}`;
}
}
} catch (e) {
if (body) body.innerHTML = `<p class="ai-placeholder">${esc(String(e))}</p>`;
}
}
async function loadAiChatSession() {
const r = await apiFetch("/api/ai/chat/session");
const j = await r.json();
renderAiChatMessages(j.session);
}
async function loadAiPage() {
await loadAiMeta();
await Promise.all([loadAiSummary(), loadAiChatSession()]);
}
async function generateAiSummary() {
if (aiSummaryLoading) return;
aiSummaryLoading = true;
const btn = document.getElementById("btn-ai-summary");
const body = document.getElementById("ai-summary-body");
if (btn) btn.disabled = true;
if (body) body.innerHTML = '<p class="ai-placeholder">正在聚合四户数据并生成总结…</p>';
try {
const r = await apiFetch("/api/ai/summary/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "生成失败");
if (!j.ok && j.detail) throw new Error(j.detail);
const sum = j.summary;
if (sum && sum.content_md && body) {
body.innerHTML = renderAiMarkdown(sum.content_md);
renderAiSummaryStats(sum.stats_snapshot);
}
showToast(j.cached ? "已是最新上下文,返回缓存总结" : "今日总结已生成");
await loadAiSummary();
} catch (e) {
showToast(String(e), true);
if (body) body.innerHTML = `<p class="ai-placeholder">${esc(String(e))}</p>`;
} finally {
aiSummaryLoading = false;
if (btn) btn.disabled = false;
}
}
async function newAiChat() {
try {
const r = await apiFetch("/api/ai/chat/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const j = await r.json();
renderAiChatMessages(j.session);
showToast("已开始新对话");
} catch (e) {
showToast(String(e), true);
}
}
async function sendAiChat(ev) {
if (ev) ev.preventDefault();
if (aiChatLoading) return;
const input = document.getElementById("ai-chat-input");
const text = (input && input.value || "").trim();
if (!text) return;
aiChatLoading = true;
const btn = document.getElementById("btn-ai-chat-send");
if (btn) btn.disabled = true;
try {
const r = await apiFetch("/api/ai/chat/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
if (j.detail) throw new Error(j.detail);
if (input) input.value = "";
renderAiChatMessages(j.session);
} catch (e) {
showToast(String(e), true);
} finally {
aiChatLoading = false;
if (btn) btn.disabled = false;
}
}
const aiSummaryBtn = document.getElementById("btn-ai-summary");
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat();
const aiChatForm = document.getElementById("ai-chat-form");
if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat);
initTpslModal(); initTpslModal();
initInstanceFrame(); initInstanceFrame();
initFullscreen(); initFullscreen();
+39 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260604-hub-theme4" /> <link rel="stylesheet" href="/assets/app.css?v=20260606-hub-ai" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -46,6 +46,7 @@
<nav class="top-nav"> <nav class="top-nav">
<a href="/monitor" id="nav-monitor">监控区</a> <a href="/monitor" id="nav-monitor">监控区</a>
<a href="/market" id="nav-market">行情区</a> <a href="/market" id="nav-market">行情区</a>
<a href="/ai" id="nav-ai">AI 教练</a>
<a href="/settings" id="nav-settings">系统设置</a> <a href="/settings" id="nav-settings">系统设置</a>
</nav> </nav>
<button type="button" id="btn-logout" class="ghost" title="退出登录">退出</button> <button type="button" id="btn-logout" class="ghost" title="退出登录">退出</button>
@@ -203,6 +204,42 @@
</div> </div>
</div> </div>
<div id="page-ai" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">AI</span> 教练</h1>
<p class="page-desc">四户今日总结 · 口语化陪聊(单会话,点「新开对话」清空上下文)</p>
</div>
<div class="ai-layout">
<section class="ai-panel ai-summary-panel">
<div class="ai-panel-head">
<h2>今日总结</h2>
<div class="ai-panel-actions">
<span id="ai-summary-meta" class="toolbar-meta"></span>
<button type="button" id="btn-ai-summary" class="primary">生成今日总结</button>
</div>
</div>
<div id="ai-summary-stats" class="ai-stats-row" aria-live="polite"></div>
<div id="ai-summary-body" class="ai-md-body">
<p class="ai-placeholder">点击「生成今日总结」聚合四户平仓与持仓数据(未启用账户显示「未监控」)。</p>
</div>
</section>
<section class="ai-panel ai-chat-panel">
<div class="ai-panel-head">
<h2 id="ai-chat-title">聊天</h2>
<div class="ai-panel-actions">
<span id="ai-chat-meta" class="toolbar-meta"></span>
<button type="button" id="btn-ai-chat-new" class="ghost">新开对话</button>
</div>
</div>
<div id="ai-chat-messages" class="ai-chat-messages" aria-live="polite"></div>
<form id="ai-chat-form" class="ai-chat-form">
<textarea id="ai-chat-input" rows="3" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
</form>
</section>
</div>
</div>
<div id="page-settings" class="page hidden"> <div id="page-settings" class="page hidden">
<div class="page-head"> <div class="page-head">
<h1><span class="head-tag">CFG</span> 系统设置</h1> <h1><span class="head-tag">CFG</span> 系统设置</h1>
@@ -250,6 +287,6 @@
<div id="toast"></div> <div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script> <script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
<script src="/assets/app.js?v=20260604-iframe-theme-flash"></script> <script src="/assets/app.js?v=20260606-hub-ai"></script>
</body> </body>
</html> </html>
+19 -6
View File
@@ -10,6 +10,7 @@
浏览器 浏览器
├─ /monitor 监控区(持仓、关键位、趋势计划、全平) ├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
├─ /market 行情区(K 线、技术指标、持仓价格线) ├─ /market 行情区(K 线、技术指标、持仓价格线)
├─ /ai AI 教练(四户今日总结 + 聊天)
└─ /settings 系统设置(hub_settings.json └─ /settings 系统设置(hub_settings.json
中控 hub.py(默认 :5100 中控 hub.py(默认 :5100
@@ -177,7 +178,19 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
数据经中控 → 各实例 `GET /api/hub/ohlcv``hub_ohlcv_lib`)。升级 hub 与四实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新** 数据经中控 → 各实例 `GET /api/hub/ohlcv``hub_ohlcv_lib`)。升级 hub 与四实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**
### 4.3 系统设置 `/settings` ### 4.3 AI 教练 `/ai`
| 功能 | 说明 |
|------|------|
| **今日总结** | 聚合四户当日平仓(`trade_records`)、持仓浮盈亏、连接状态;**未启用**账户标注 **未监控**;语气偏冷、台账式 |
| **生成** | 点「生成今日总结」;结果写入 `hub_ai_summaries.json`(同目录备份) |
| **聊天** | **单会话**持续对话,直到点 **「新开对话」**;口语化、安慰体贴、轻修正(非说教) |
| **模型** | 与四实例相同 `.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**。 **可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`)。保存后监控区立即使用新 URL/启用状态,**无需重启 hub**。
@@ -195,7 +208,7 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
| id | 与 `HUB_DISABLED_IDS`、全平 API 路径中的 id 对应;新增户勿与已有 id 重复 | | id | 与 `HUB_DISABLED_IDS`、全平 API 路径中的 id 对应;新增户勿与已有 id 重复 |
- **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效。 - **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效。
- **添加交易所**:见下文 §4.4(须先自建 Flask + agent,再在中控登记)。 - **添加交易所**:见下文 §4.5(须先自建 Flask + agent,再在中控登记)。
- **删**:从列表移除(保存后生效)。 - **删**:从列表移除(保存后生效)。
#### 能力与「策略交易」的关系(重要) #### 能力与「策略交易」的关系(重要)
@@ -210,11 +223,11 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
--- ---
### 4.4 增加账户(例如再挂一个 Gate ### 4.5 增加账户(例如再挂一个 Gate
中控 **不会** 自动启动进程,也 **不** 保存交易所 API Key。新增一户 = **复制/新建一套实例目录 + 独立 `.env` + 新端口 Flask/agent + 在中控登记一行** 中控 **不会** 自动启动进程,也 **不** 保存交易所 API Key。新增一户 = **复制/新建一套实例目录 + 独立 `.env` + 新端口 Flask/agent + 在中控登记一行**
#### 4.4.1 端口勿冲突(示例) #### 4.5.1 端口勿冲突(示例)
| 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` | | 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` |
|------|----------------|------------------|--------------| |------|----------------|------------------|--------------|
@@ -224,7 +237,7 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
`agent``PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001、OKX 5004、中控 5100 等占用端口相同。 `agent``PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001、OKX 5004、中控 5100 等占用端口相同。
#### 4.4.2 新建实例目录 #### 4.5.2 新建实例目录
1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可)。 1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可)。
2. 在新目录:`cp .env.example .env`,至少修改: 2. 在新目录:`cp .env.example .env`,至少修改:
@@ -243,7 +256,7 @@ pm2 start ecosystem.config.cjs
验收:`curl http://127.0.0.1:5005/login` 能开页;`curl http://127.0.0.1:15204/status` 返回 `ok` 验收:`curl http://127.0.0.1:5005/login` 能开页;`curl http://127.0.0.1:15204/status` 返回 `ok`
#### 4.4.3 在中控登记 #### 4.5.3 在中控登记
1. 打开 **系统设置****添加交易所**(或手改 `manual_trading_hub/hub_settings.json`)。 1. 打开 **系统设置****添加交易所**(或手改 `manual_trading_hub/hub_settings.json`)。
2. 填写 **Flask URL**、**Agent URL**、**id**(如 `4`)、**显示名**。 2. 填写 **Flask URL**、**Agent URL**、**id**(如 `4`)、**显示名**。
@@ -29,6 +29,8 @@
| 路径 | 内容 | | 路径 | 内容 |
|------|------| |------|------|
| `manual_trading_hub/hub_settings.json` | 账户 URL、启用状态、能力勾选(网页「系统设置」保存的文件) | | `manual_trading_hub/hub_settings.json` | 账户 URL、启用状态、能力勾选(网页「系统设置」保存的文件) |
| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结(`/ai` |
| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 |
### 不要直接覆盖拷贝(需在云上重写) ### 不要直接覆盖拷贝(需在云上重写)
@@ -103,6 +105,8 @@ for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_
done done
cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true 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
``` ```
--- ---
+4 -3
View File
@@ -199,7 +199,8 @@ bash scripts/run_hub.sh
1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD`,用 `HUB_USERNAME` / `HUB_PASSWORD` 登录。 1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD`,用 `HUB_USERNAME` / `HUB_PASSWORD` 登录。
2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓;Flask 已起时有关键位/趋势信息。 2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓;Flask 已起时有关键位/趋势信息。
3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线;升级后强刷浏览器,详见 [行情区说明.md](./行情区说明.md)。 3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线;升级后强刷浏览器,详见 [行情区说明.md](./行情区说明.md)。
4. **http://127.0.0.1:5100/settings** — 保存后生成 `hub_settings.json`(增加第五户、Gate 子账户等见 [使用说明.md §4.4](./使用说明.md#44-增加账户例如再挂一个-gate) 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_*` 网页做下单、关键位、趋势;中控**不提供**下单表单。 5. 监控卡片 **「实例」** — 在各 `crypto_monitor_*` 网页做下单、关键位、趋势;中控**不提供**下单表单。
**命令行验收**(推荐): **命令行验收**(推荐):
@@ -277,8 +278,8 @@ pm2 restart ecosystem.config.cjs
# 若只改了中控:pm2 restart manual-trading-hub # 若只改了中控:pm2 restart manual-trading-hub
``` ```
- **`hub_settings.json`**、**`.env`** 不在 Git 中,`git pull` 不会覆盖。 - **`hub_settings.json`**、**`hub_ai_summaries.json`**、**`hub_ai_chat.json`**、**`.env`** 不在 Git 中,`git pull` 不会覆盖。
- 升级前可备份:`cp hub_settings.json hub_settings.json.bak``cp .env .env.bak` - 升级前可备份:`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。 **升级后自检**`curl -s http://127.0.0.1:5100/api/ping` 须含 `"trade_ui":false`。若仍见 `api_trade_key` 报错,说明代码未更新或未重启,见 [常见问题.md](./常见问题.md) §1。
+57
View File
@@ -0,0 +1,57 @@
"""hub_trades_lib 单元测试。"""
from __future__ import annotations
import sqlite3
import unittest
from hub_trades_lib import fetch_trades_for_trading_day, summarize_trades, trading_day_from_dt
from datetime import datetime
class HubTradesLibTest(unittest.TestCase):
def test_trading_day_reset(self):
dt = datetime(2026, 6, 6, 7, 30, 0)
self.assertEqual(trading_day_from_dt(dt, 8), "2026-06-05")
dt2 = datetime(2026, 6, 6, 8, 0, 0)
self.assertEqual(trading_day_from_dt(dt2, 8), "2026-06-06")
def test_fetch_and_summarize(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
"""CREATE TABLE trade_records (
symbol TEXT, exchange_symbol TEXT, direction TEXT, result TEXT,
pnl_amount REAL, closed_at TEXT, opened_at TEXT, session_date TEXT,
monitor_type TEXT, actual_rr REAL, planned_rr REAL, trade_style TEXT, entry_reason TEXT
)"""
)
conn.execute(
"INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
"ONDO/USDT",
"ONDO/USDT:USDT",
"short",
"止损",
-0.5,
"2026-06-06 10:00:00",
"2026-06-06 09:00:00",
"2026-06-06",
"趋势回调",
None,
None,
"trend",
"",
),
)
conn.commit()
rows = fetch_trades_for_trading_day(conn, "2026-06-06")
self.assertEqual(len(rows), 1)
stats = summarize_trades(rows)
self.assertEqual(stats["closed_count"], 1)
self.assertEqual(stats["loss_count"], 1)
self.assertAlmostEqual(stats["total_pnl_u"], -0.5)
conn.close()
if __name__ == "__main__":
unittest.main()