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:
@@ -16,6 +16,8 @@
|
||||
**/.env.bak
|
||||
**/.env.local
|
||||
manual_trading_hub/hub_settings.json
|
||||
manual_trading_hub/hub_ai_summaries.json
|
||||
manual_trading_hub/hub_ai_chat.json
|
||||
manual_trading_hub/data/
|
||||
|
||||
# 数据库与上传(运行时生成)
|
||||
|
||||
@@ -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")
|
||||
@_hub_auth_required
|
||||
def api_hub_ohlcv():
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -80,3 +80,20 @@ HUB_TRUST_LAN=true
|
||||
# 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 提供方: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
|
||||
|
||||
@@ -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)(实例侧)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 复盘系统中控(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)。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|------|------|
|
||||
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
|
||||
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
|
||||
| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
|
||||
| 紧急全平 | 单户 / 全局市价减仓 |
|
||||
| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) |
|
||||
| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
|
||||
@@ -22,7 +23,7 @@
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 → hub.py (:5100) 监控 / 行情 / 设置 / 登录
|
||||
浏览器 → hub.py (:5100) 监控 / 行情 / **AI 教练** / 设置 / 登录
|
||||
├→ agent.py × N (:15200~15203) 持仓、全平
|
||||
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
|
||||
```
|
||||
|
||||
@@ -77,7 +77,7 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
|
||||
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
|
||||
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
|
||||
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_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
||||
@@ -362,11 +362,22 @@ def root_redirect():
|
||||
|
||||
@app.get("/monitor")
|
||||
@app.get("/market")
|
||||
@app.get("/ai")
|
||||
@app.get("/settings")
|
||||
def shell_pages():
|
||||
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")
|
||||
def trade_removed_redirect():
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)。"""
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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() + "..."
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,195 @@
|
||||
"""中控 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
|
||||
|
||||
|
||||
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() + "..."
|
||||
@@ -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)
|
||||
@@ -3342,3 +3342,132 @@ html[data-theme="light"] button.danger {
|
||||
background: rgba(201, 53, 82, 0.1);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -623,12 +623,14 @@
|
||||
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
||||
if (p.includes("settings")) return "settings";
|
||||
if (p.includes("market")) return "market";
|
||||
if (p.includes("/ai")) return "ai";
|
||||
return "monitor";
|
||||
}
|
||||
|
||||
function pageElementId(page) {
|
||||
if (page === "settings") return "page-settings";
|
||||
if (page === "market") return "page-market";
|
||||
if (page === "ai") return "page-ai";
|
||||
return "page-monitor";
|
||||
}
|
||||
|
||||
@@ -648,6 +650,7 @@
|
||||
if (page === "monitor") startMonitorPoll();
|
||||
else stopMonitorPoll();
|
||||
if (page === "settings") loadSettingsUI();
|
||||
if (page === "ai") loadAiPage();
|
||||
if (page === "market" && window.hubMarketChart) {
|
||||
window.hubMarketChart.init();
|
||||
} else if (window.hubMarketChart) {
|
||||
@@ -2935,6 +2938,184 @@
|
||||
showToast("已添加一行,请填写 URL 后点「保存设置」");
|
||||
};
|
||||
|
||||
let aiMeta = null;
|
||||
let aiSummaryLoading = false;
|
||||
let aiChatLoading = false;
|
||||
|
||||
function renderAiMarkdown(text) {
|
||||
const esc = (s) =>
|
||||
String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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();
|
||||
initInstanceFrame();
|
||||
initFullscreen();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<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'" />
|
||||
<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>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -46,6 +46,7 @@
|
||||
<nav class="top-nav">
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/ai" id="nav-ai">AI 教练</a>
|
||||
<a href="/settings" id="nav-settings">系统设置</a>
|
||||
</nav>
|
||||
<button type="button" id="btn-logout" class="ghost" title="退出登录">退出</button>
|
||||
@@ -203,6 +204,42 @@
|
||||
</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 class="page-head">
|
||||
<h1><span class="head-tag">CFG</span> 系统设置</h1>
|
||||
@@ -250,6 +287,6 @@
|
||||
<div id="toast"></div>
|
||||
<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/app.js?v=20260604-iframe-theme-flash"></script>
|
||||
<script src="/assets/app.js?v=20260606-hub-ai"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
浏览器
|
||||
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
|
||||
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
||||
├─ /ai AI 教练(四户今日总结 + 聊天)
|
||||
└─ /settings 系统设置(hub_settings.json)
|
||||
|
||||
中控 hub.py(默认 :5100)
|
||||
@@ -177,7 +178,19 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
|
||||
|
||||
数据经中控 → 各实例 `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**。
|
||||
|
||||
@@ -195,7 +208,7 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
|
||||
| id | 与 `HUB_DISABLED_IDS`、全平 API 路径中的 id 对应;新增户勿与已有 id 重复 |
|
||||
|
||||
- **保存设置**:写入 `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 + 在中控登记一行**。
|
||||
|
||||
#### 4.4.1 端口勿冲突(示例)
|
||||
#### 4.5.1 端口勿冲突(示例)
|
||||
|
||||
| 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` |
|
||||
|------|----------------|------------------|--------------|
|
||||
@@ -224,7 +237,7 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
|
||||
|
||||
`agent` 的 `PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001、OKX 5004、中控 5100 等占用端口相同。
|
||||
|
||||
#### 4.4.2 新建实例目录
|
||||
#### 4.5.2 新建实例目录
|
||||
|
||||
1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可)。
|
||||
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`。
|
||||
|
||||
#### 4.4.3 在中控登记
|
||||
#### 4.5.3 在中控登记
|
||||
|
||||
1. 打开 **系统设置** → **添加交易所**(或手改 `manual_trading_hub/hub_settings.json`)。
|
||||
2. 填写 **Flask URL**、**Agent URL**、**id**(如 `4`)、**显示名**。
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
| 路径 | 内容 |
|
||||
|------|------|
|
||||
| `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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +199,8 @@ 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/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_*` 网页做下单、关键位、趋势;中控**不提供**下单表单。
|
||||
|
||||
**命令行验收**(推荐):
|
||||
@@ -277,8 +278,8 @@ pm2 restart ecosystem.config.cjs
|
||||
# 若只改了中控:pm2 restart manual-trading-hub
|
||||
```
|
||||
|
||||
- **`hub_settings.json`**、**`.env`** 不在 Git 中,`git pull` 不会覆盖。
|
||||
- 升级前可备份:`cp hub_settings.json hub_settings.json.bak`、`cp .env .env.bak`。
|
||||
- **`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。
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user