diff --git a/.gitignore b/.gitignore index cfab7ef..af2ff84 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ # 数据库与上传(运行时生成) diff --git a/hub_bridge.py b/hub_bridge.py index e50ab8b..9543709 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -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(): diff --git a/hub_trades_lib.py b/hub_trades_lib.py new file mode 100644 index 0000000..ba0bc37 --- /dev/null +++ b/hub_trades_lib.py @@ -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), + } diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 772c023..e5f8b71 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -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 diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md new file mode 100644 index 0000000..f3eb29b --- /dev/null +++ b/manual_trading_hub/AI教练说明.md @@ -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)(实例侧)。 diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md index 3d33213..23ae07e 100644 --- a/manual_trading_hub/README.md +++ b/manual_trading_hub/README.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 只读聚合 ``` diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index fb39a01..6254982 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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 diff --git a/manual_trading_hub/hub_ai/__init__.py b/manual_trading_hub/hub_ai/__init__.py new file mode 100644 index 0000000..07b3bda --- /dev/null +++ b/manual_trading_hub/hub_ai/__init__.py @@ -0,0 +1 @@ +"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)。""" diff --git a/manual_trading_hub/hub_ai/chat.py b/manual_trading_hub/hub_ai/chat.py new file mode 100644 index 0000000..111ba44 --- /dev/null +++ b/manual_trading_hub/hub_ai/chat.py @@ -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(), + } diff --git a/manual_trading_hub/hub_ai/client.py b/manual_trading_hub/hub_ai/client.py new file mode 100644 index 0000000..706a543 --- /dev/null +++ b/manual_trading_hub/hub_ai/client.py @@ -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) diff --git a/manual_trading_hub/hub_ai/config.py b/manual_trading_hub/hub_ai/config.py new file mode 100644 index 0000000..53f4986 --- /dev/null +++ b/manual_trading_hub/hub_ai/config.py @@ -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 diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py new file mode 100644 index 0000000..0492d28 --- /dev/null +++ b/manual_trading_hub/hub_ai/context.py @@ -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() + "..." diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py new file mode 100644 index 0000000..818607e --- /dev/null +++ b/manual_trading_hub/hub_ai/prompts.py @@ -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) diff --git a/manual_trading_hub/hub_ai/routes.py b/manual_trading_hub/hub_ai/routes.py new file mode 100644 index 0000000..958ea81 --- /dev/null +++ b/manual_trading_hub/hub_ai/routes.py @@ -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 diff --git a/manual_trading_hub/hub_ai/store.py b/manual_trading_hub/hub_ai/store.py new file mode 100644 index 0000000..26365c3 --- /dev/null +++ b/manual_trading_hub/hub_ai/store.py @@ -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() + "..." diff --git a/manual_trading_hub/hub_ai/summary.py b/manual_trading_hub/hub_ai/summary.py new file mode 100644 index 0000000..d2dd066 --- /dev/null +++ b/manual_trading_hub/hub_ai/summary.py @@ -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) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 88f95e7..db97ac6 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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); +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 93de5e4..a7164e8 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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, ">"); + return esc(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\n/g, "
"); + } + + 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 = [ + `交易日${esc(t.trading_day || "—")}`, + `平仓盈亏${fmt(pnl, 2)}U`, + `笔数${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0})`, + `浮盈亏${fmt(Number(t.float_pnl_u), 2)}U`, + ].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 = + '

随便聊:行情、心态、纪律、执行都行。我会先看四户监控数据,用搭档口吻回你。

'; + return; + } + box.innerHTML = msgs + .map((m) => { + const role = m.role === "user" ? "user" : "assistant"; + return `
${esc(m.content || "")}
`; + }) + .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 = `

${esc(String(e))}

`; + } + } + + 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 = '

正在聚合四户数据并生成总结…

'; + 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 = `

${esc(String(e))}

`; + } 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(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 30ba99d..8cb6c31 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -46,6 +46,7 @@ @@ -203,6 +204,42 @@ + +