From bfbd6879d61293b82110b25b1aec4737b8542745 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 23 Jun 2026 19:25:01 +0800 Subject: [PATCH] Add AI trading supervisor with WeChat push and daily session Proactive monitoring for manual/hub closes and new opens prevents overtrading via in-app alerts, configurable WeChat links, and supervisor chat. Co-authored-by: Cursor --- manual_trading_hub/AI教练说明.md | 3 +- manual_trading_hub/hub.py | 65 +- manual_trading_hub/hub_ai/prompts.py | 53 ++ manual_trading_hub/hub_ai/routes.py | 32 + manual_trading_hub/hub_ai/store.py | 3 +- manual_trading_hub/hub_ai/supervisor.py | 111 +++ manual_trading_hub/hub_ai/supervisor_store.py | 101 +++ manual_trading_hub/hub_supervisor_cache.py | 148 ++++ manual_trading_hub/hub_supervisor_lib.py | 632 ++++++++++++++++++ manual_trading_hub/settings_store.py | 8 +- manual_trading_hub/static/app.css | 32 +- manual_trading_hub/static/app.js | 284 +++++++- manual_trading_hub/static/index.html | 48 +- manual_trading_hub/交易监管说明.md | 84 +++ tests/test_hub_supervisor_lib.py | 138 ++++ 15 files changed, 1699 insertions(+), 43 deletions(-) create mode 100644 manual_trading_hub/hub_ai/supervisor.py create mode 100644 manual_trading_hub/hub_ai/supervisor_store.py create mode 100644 manual_trading_hub/hub_supervisor_cache.py create mode 100644 manual_trading_hub/hub_supervisor_lib.py create mode 100644 manual_trading_hub/交易监管说明.md create mode 100644 tests/test_hub_supervisor_lib.py diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md index b81e3ce..bf8ce13 100644 --- a/manual_trading_hub/AI教练说明.md +++ b/manual_trading_hub/AI教练说明.md @@ -8,9 +8,10 @@ |------|------| | **交易教练** | 口语化陪聊;注入四户监控快照与今日总结摘要(后台自动生成,不在页面展示) | | **普通聊天** | 不绑交易数据,适合闲聊、答疑 | +| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) | | **会话历史** | 右侧列表:切换、删除;消息一键复制 | -页面仅保留 **交易教练 / 普通聊天** 两个机器人和聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。 +页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。 ## 存储 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 6f910a4..9d7e5f9 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -95,6 +95,7 @@ from settings_store import ( env_force_disabled_ids, load_settings, normalize_display_prefs, + normalize_supervisor_settings, save_settings, ) from hub_web_auth import ( @@ -119,6 +120,10 @@ from urllib.parse import urlencode from hub_board_cache import HUB_BOARD_POLL_INTERVAL, board_store from hub_dashboard_cache import dashboard_store from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC +from hub_supervisor_cache import supervisor_store +from hub_supervisor_lib import process_supervisor_tick, set_supervisor_notify_hook +from hub_ai.supervisor import make_supervisor_ai_reply_fn +from hub_ai.config import trading_day_reset_hour from hub_chart_cache import ( HUB_CHART_POLL_INTERVAL, HUB_CHART_WATCH_TTL_SEC, @@ -301,6 +306,7 @@ async def _run_board_aggregate() -> dict: def _schedule_board_refresh() -> None: board_store.request_refresh() dashboard_store.request_refresh() + supervisor_store.request_refresh() async def _run_archive_sync_once() -> dict: @@ -496,11 +502,28 @@ async def _archive_sync_loop() -> None: pass +async def _run_supervisor_tick() -> dict: + dash = dashboard_store.snapshot_dict() + board = board_store.snapshot_dict() + settings = load_settings() + ai_fn = make_supervisor_ai_reply_fn(_all_exchanges_for_ai()) + return await asyncio.to_thread( + process_supervisor_tick, + dash if dash.get("ok") is not False else None, + board if board.get("ok") is not False else None, + settings, + reset_hour=trading_day_reset_hour(), + ai_reply_fn=ai_fn, + ) + + @asynccontextmanager async def _hub_lifespan(_app: FastAPI): global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task + set_supervisor_notify_hook(supervisor_store.bump) await board_store.start(_run_board_aggregate) await dashboard_store.start(_run_dashboard_aggregate) + await supervisor_store.start(_run_supervisor_tick) await chart_poll_store.start(_run_chart_poll) _archive_sync_stop = asyncio.Event() _archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync") @@ -530,8 +553,10 @@ async def _hub_lifespan(_app: FastAPI): _volume_rank_task = None _volume_rank_stop = None await chart_poll_store.stop() + await supervisor_store.stop() await dashboard_store.stop() await board_store.stop() + set_supervisor_notify_hook(None) app = FastAPI(title="复盘系统中控", docs_url=None, redoc_url=None, lifespan=_hub_lifespan) @@ -737,6 +762,7 @@ async def _run_dashboard_aggregate() -> dict: def _schedule_dashboard_refresh() -> None: dashboard_store.request_refresh() + supervisor_store.request_refresh() @app.get("/api/dashboard/daily") @@ -775,6 +801,27 @@ async def api_dashboard_refresh(): return {"ok": True, "dashboard_version": dashboard_store.version} +@app.get("/api/ai/supervisor/stream") +async def api_supervisor_stream(): + from fastapi.responses import StreamingResponse + + return StreamingResponse( + supervisor_store.iter_sse(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/api/ai/supervisor/refresh") +async def api_supervisor_refresh(): + supervisor_store.request_refresh() + return {"ok": True, "supervisor_version": supervisor_store.version} + + @app.get("/trade") def trade_removed_redirect(): from fastapi.responses import RedirectResponse @@ -797,9 +844,22 @@ class SettingsDisplayBody(BaseModel): show_nav_calculator: bool = True +class SupervisorSettingsBody(BaseModel): + enabled: bool = True + wechat_webhook: str = "" + wechat_link_base: str = "" + wechat_prefix: str = "【交易监管】" + wechat_on_program_tp_sl: bool = True + manual_close_daily_warn: int = 2 + interval_warn_minutes: int = 15 + freq_30m_count: int = 2 + reopen_after_close_minutes: int = 30 + + class SettingsBody(BaseModel): exchanges: list[dict] = Field(default_factory=list) display: SettingsDisplayBody | None = None + supervisor: SupervisorSettingsBody | None = None @app.post("/api/settings") @@ -817,7 +877,10 @@ def api_save_settings(body: SettingsBody): display = normalize_display_prefs(existing.get("display")) if body.display is not None: display = normalize_display_prefs(body.display.model_dump()) - save_settings({"version": 1, "exchanges": to_save, "display": display}) + supervisor = normalize_supervisor_settings(existing.get("supervisor")) + if body.supervisor is not None: + supervisor = normalize_supervisor_settings(body.supervisor.model_dump()) + save_settings({"version": 1, "exchanges": to_save, "display": display, "supervisor": supervisor}) return {"ok": True, "settings": load_settings()} diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py index d9ba396..f4d3940 100644 --- a/manual_trading_hub/hub_ai/prompts.py +++ b/manual_trading_hub/hub_ai/prompts.py @@ -174,6 +174,59 @@ ARCHIVE_QUOTE_REVIEW_INSTRUCTION = """ """.strip() +SUPERVISOR_SYSTEM = """ +你是交易监管值班员,职责是防止过度交易与频繁手动操作。用中文、短句、克制语气。 + +规则: +- 只依据提供的结构化事件与账户快照说话;禁止预测涨跌、保证收益。 +- **手动平仓、中控平仓、新开仓**:指出频率、间隔、是否偏急;提醒休息,不训斥。 +- **程序止盈/程序止损**:肯定按计划执行,鼓励保持纪律,提醒别立刻反手再开。 +- 不替用户做决定,不暗示绕过实例冷静期/日冻结。 +- 每次 1~3 句,必须写完整;禁止长清单和「第1点第2点」。 +- 实例已进入冷静期/日冻结时,明确说明状态,建议暂停手动开平。 +""".strip() + + +def build_supervisor_ai_prompt( + *, + context_text: str, + trading_day: str, + event: dict, + warnings: list[dict], +) -> str: + warn_lines = "\n".join(f"- {w.get('message')}" for w in (warnings or []) if w.get("message")) + parts = [ + f"【交易日】{trading_day}", + "【监管事件】", + str(event or {}), + "【当前多账户快照】", + (context_text or "(无)").strip(), + ] + if warn_lines.strip(): + parts.extend(["【已触发频率警告】", warn_lines.strip()]) + parts.append("请给出 1~3 句监管评语:") + return "\n\n".join(parts) + + +def build_supervisor_chat_prompt( + *, + context_text: str, + trading_day: str, + history_lines: str, + user_message: str, +) -> str: + parts = [f"【交易日】{trading_day}"] + if history_lines.strip(): + parts.extend(["【今日监管对话】", history_lines.strip()]) + parts.extend([ + "【当前多账户快照】", + (context_text or "(无)").strip(), + "【用户现在说】", + user_message.strip(), + ]) + return "\n\n".join(parts) + + def build_archive_quote_review_prompt( *, quote_date: str, diff --git a/manual_trading_hub/hub_ai/routes.py b/manual_trading_hub/hub_ai/routes.py index 90849fb..1367a90 100644 --- a/manual_trading_hub/hub_ai/routes.py +++ b/manual_trading_hub/hub_ai/routes.py @@ -19,8 +19,11 @@ from hub_ai.client import model_label from hub_ai.config import trading_day_reset_hour from hub_ai.context import build_daily_context from hub_ai.store import get_latest_summary, list_summaries +from hub_ai.supervisor import send_supervisor_chat +from hub_ai.supervisor_store import get_supervisor_session_state from hub_ai.summary import generate_daily_summary from hub_trades_lib import current_trading_day +from settings_store import normalize_supervisor_settings class ChatSendBody(BaseModel): @@ -47,6 +50,11 @@ class ArchiveQuoteChatBody(BaseModel): content: str = "" +class SupervisorChatBody(BaseModel): + message: str = "" + trading_day: str = "" + + def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter: router = APIRouter(prefix="/api/ai", tags=["hub-ai"]) @@ -165,4 +173,28 @@ def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败") return result + @router.get("/supervisor/session") + def api_ai_supervisor_session(trading_day: str = ""): + day = _day(trading_day) + return get_supervisor_session_state(day) + + @router.get("/supervisor/rules") + def api_ai_supervisor_rules(): + from settings_store import load_settings + + cfg = normalize_supervisor_settings(load_settings().get("supervisor")) + return {"ok": True, "supervisor": cfg} + + @router.post("/supervisor/chat/send") + def api_ai_supervisor_chat_send(body: SupervisorChatBody = SupervisorChatBody()): + exchanges = load_all_exchanges() + result = send_supervisor_chat( + exchanges, + body.message, + trading_day=_day(body.trading_day) if body.trading_day.strip() else None, + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败") + return result + return router diff --git a/manual_trading_hub/hub_ai/store.py b/manual_trading_hub/hub_ai/store.py index 1906ed5..321a43a 100644 --- a/manual_trading_hub/hub_ai/store.py +++ b/manual_trading_hub/hub_ai/store.py @@ -142,7 +142,8 @@ def get_active_session() -> Optional[dict]: CHAT_BOT_TRADING = "trading" CHAT_BOT_GENERAL = "general" -CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL}) +CHAT_BOT_SUPERVISOR = "supervisor" +CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL, CHAT_BOT_SUPERVISOR}) def _normalize_bot_mode(raw: Any) -> str: diff --git a/manual_trading_hub/hub_ai/supervisor.py b/manual_trading_hub/hub_ai/supervisor.py new file mode 100644 index 0000000..f717374 --- /dev/null +++ b/manual_trading_hub/hub_ai/supervisor.py @@ -0,0 +1,111 @@ +"""交易监管:AI 评语与用户回聊。""" +from __future__ import annotations + +from typing import Any, Optional + +from hub_ai.client import generate_text, model_label +from hub_ai.config import ( + CHAT_MAX_OUTPUT_TOKENS, + CHAT_TEMPERATURE, + trading_day_reset_hour, +) +from hub_ai.context import build_chat_context, format_chat_context_for_chat +from hub_ai.prompts import SUPERVISOR_SYSTEM, build_supervisor_ai_prompt, build_supervisor_chat_prompt +from hub_ai.supervisor_store import ( + append_supervisor_ai_message, + ensure_supervisor_session, + get_supervisor_session_state, +) +from hub_ai.store import append_chat_message +from hub_trades_lib import current_trading_day + + +def generate_supervisor_ai_reply( + *, + event: dict, + warnings: list[dict], + trading_day: str, + session_id: str, + exchanges: list[dict], +) -> str: + ctx = build_chat_context(exchanges, trading_day=trading_day) + brief = format_chat_context_for_chat(ctx, max_chars=6000) + user_prompt = build_supervisor_ai_prompt( + context_text=brief, + trading_day=trading_day, + event=event, + warnings=warnings, + ) + return generate_text( + system=SUPERVISOR_SYSTEM, + user=user_prompt, + temperature=min(0.35, CHAT_TEMPERATURE), + max_tokens=min(512, CHAT_MAX_OUTPUT_TOKENS), + max_continuations=1, + ) + + +def make_supervisor_ai_reply_fn(exchanges: list[dict]): + def _fn(*, event: dict, warnings: list[dict], trading_day: str, session_id: str) -> str: + return generate_supervisor_ai_reply( + event=event, + warnings=warnings or [], + trading_day=trading_day, + session_id=session_id, + exchanges=exchanges, + ) + + return _fn + + +def send_supervisor_chat( + exchanges: list[dict], + message: str, + *, + trading_day: str | None = None, +) -> dict[str, Any]: + text = (message or "").strip() + if not text: + return {"ok": False, "msg": "消息不能为空"} + day = (trading_day or "").strip()[:10] or current_trading_day( + reset_hour=trading_day_reset_hour() + ) + session = ensure_supervisor_session(day) + sid = str(session.get("id") or "") + prior = session.get("messages") or [] + ctx = build_chat_context(exchanges, trading_day=day) + brief = format_chat_context_for_chat(ctx, max_chars=6000) + recent = [] + for m in prior[-8:]: + role = m.get("role") + if role not in ("user", "assistant", "system"): + continue + label = {"user": "用户", "assistant": "监管", "system": "系统"}.get(role, role) + recent.append(f"{label}:{str(m.get('content') or '').strip()}") + user_prompt = build_supervisor_chat_prompt( + context_text=brief, + trading_day=day, + history_lines="\n".join(recent), + user_message=text, + ) + reply = generate_text( + system=SUPERVISOR_SYSTEM, + user=user_prompt, + temperature=min(0.4, CHAT_TEMPERATURE), + max_tokens=min(768, CHAT_MAX_OUTPUT_TOKENS), + max_continuations=1, + ) + if not reply or reply.strip().startswith("AI "): + return {"ok": False, "msg": reply or "AI 生成失败", "session_id": sid} + append_chat_message(sid, "user", text) + session = append_supervisor_ai_message(sid, reply.strip()) + state = get_supervisor_session_state(day) + return { + "ok": True, + "trading_day": day, + "session": session, + "reply": reply.strip(), + "model": model_label(), + "message_count": state.get("message_count"), + "unread_system": state.get("unread_system"), + } diff --git a/manual_trading_hub/hub_ai/supervisor_store.py b/manual_trading_hub/hub_ai/supervisor_store.py new file mode 100644 index 0000000..3f2616f --- /dev/null +++ b/manual_trading_hub/hub_ai/supervisor_store.py @@ -0,0 +1,101 @@ +"""交易监管专用会话(今日长会话,bot_mode=supervisor)。""" +from __future__ import annotations + +from typing import Any, Optional + +from hub_ai.store import ( + CHAT_BOT_SUPERVISOR, + append_chat_message, + load_chat_store, + save_chat_store, +) + + +def _supervisor_title(trading_day: str) -> str: + return f"今日监管 {trading_day}" + + +def find_supervisor_session(trading_day: str) -> Optional[dict]: + day = (trading_day or "").strip()[:10] + store = load_chat_store() + for s in store.get("sessions") or []: + if str(s.get("bot_mode") or "") != CHAT_BOT_SUPERVISOR: + continue + if str(s.get("trading_day") or "") == day: + return s + return None + + +def ensure_supervisor_session(trading_day: str) -> dict: + day = (trading_day or "").strip()[:10] + existing = find_supervisor_session(day) + if existing: + return existing + store = load_chat_store() + from datetime import datetime + import uuid + + session = { + "id": uuid.uuid4().hex, + "trading_day": day, + "title": _supervisor_title(day), + "bot_mode": CHAT_BOT_SUPERVISOR, + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "messages": [], + "rolling_summary": "", + "supervisor_locked": True, + } + store.setdefault("sessions", []).append(session) + save_chat_store(store) + return session + + +def append_supervisor_system_message( + session_id: str, + content: str, + *, + event_type: str = "", + level: str = "info", +) -> dict: + store = load_chat_store() + target = None + for s in store.get("sessions") or []: + if str(s.get("id")) == str(session_id): + target = s + break + if not target: + raise KeyError("session_not_found") + from datetime import datetime + + msg = { + "role": "system", + "content": (content or "").strip(), + "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "event_type": event_type, + "level": level, + } + target.setdefault("messages", []).append(msg) + target["updated_at"] = msg["at"] + save_chat_store(store) + return target + + +def append_supervisor_ai_message(session_id: str, content: str) -> dict: + return append_chat_message(session_id, "assistant", content) + + +def get_supervisor_session_state(trading_day: str) -> dict[str, Any]: + from hub_ai.client import model_label + + session = ensure_supervisor_session(trading_day) + msgs = session.get("messages") or [] + unread = sum(1 for m in msgs if m.get("role") == "system" and not m.get("read")) + return { + "ok": True, + "session": session, + "trading_day": trading_day, + "message_count": len(msgs), + "unread_system": unread, + "model": model_label(), + } diff --git a/manual_trading_hub/hub_supervisor_cache.py b/manual_trading_hub/hub_supervisor_cache.py new file mode 100644 index 0000000..76dad87 --- /dev/null +++ b/manual_trading_hub/hub_supervisor_cache.py @@ -0,0 +1,148 @@ +"""交易监管:后台扫描 + SSE 版本通知。""" +from __future__ import annotations + +import asyncio +import json +import os +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +SUPERVISOR_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30")) +SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25")) + +TickFn = Callable[[], Awaitable[dict[str, Any]]] + + +class SupervisorStore: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.version = 0 + self.last_result: dict[str, Any] | None = None + self.last_error: str | None = None + self._subscribers: list[asyncio.Queue[str | None]] = [] + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + self._refresh = asyncio.Event() + self._tick_fn: TickFn | None = None + + async def start(self, tick_fn: TickFn) -> None: + if self._task and not self._task.done(): + return + self._tick_fn = tick_fn + self._stop.clear() + self._task = asyncio.create_task(self._loop(), name="hub-supervisor-poll") + + async def stop(self) -> None: + self._stop.set() + self._refresh.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._broadcast(close=True) + + def request_refresh(self) -> None: + self._refresh.set() + + def bump(self) -> None: + self.version += 1 + self._broadcast() + + def event_dict(self) -> dict[str, Any]: + r = self.last_result or {} + return { + "supervisor_version": self.version, + "ok": r.get("ok", True), + "events": r.get("events", 0), + "trading_day": r.get("trading_day"), + "session_id": r.get("session_id"), + "error": self.last_error, + } + + async def _loop(self) -> None: + assert self._tick_fn is not None + while not self._stop.is_set(): + await self._tick_once(self._tick_fn) + if self._stop.is_set(): + break + self._refresh.clear() + sleep_task = asyncio.create_task(asyncio.sleep(SUPERVISOR_POLL_INTERVAL_SEC)) + refresh_task = asyncio.create_task(self._refresh.wait()) + done, pending = await asyncio.wait( + {sleep_task, refresh_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + + async def _tick_once(self, tick_fn: TickFn) -> None: + async with self._lock: + try: + result = await tick_fn() + if not isinstance(result, dict): + result = {"ok": False, "msg": "invalid_tick"} + except Exception as e: + result = {"ok": False, "msg": str(e)} + self.last_error = str(e) + else: + self.last_error = None if result.get("ok") is not False else str( + result.get("msg") or "tick_failed" + ) + self.last_result = result + if int(result.get("events") or 0) > 0: + self.version += 1 + self._broadcast() + + def _broadcast(self, *, close: bool = False) -> None: + dead: list[asyncio.Queue[str | None]] = [] + payload = None if close else json.dumps(self.event_dict(), ensure_ascii=False) + for q in self._subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(payload) + except asyncio.QueueFull: + dead.append(q) + except Exception: + dead.append(q) + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + async def iter_sse(self) -> AsyncIterator[str]: + q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32) + self._subscribers.append(q) + try: + yield _sse_frame(self.event_dict()) + while True: + try: + raw = await asyncio.wait_for(q.get(), timeout=SUPERVISOR_SSE_HEARTBEAT_SEC) + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + continue + if raw is None: + break + try: + data = json.loads(raw) + except Exception: + data = self.event_dict() + yield _sse_frame(data) + finally: + if q in self._subscribers: + self._subscribers.remove(q) + + +def _sse_frame(data: dict[str, Any]) -> str: + body = json.dumps(data, ensure_ascii=False) + return f"event: supervisor\ndata: {body}\n\n" + + +supervisor_store = SupervisorStore() diff --git a/manual_trading_hub/hub_supervisor_lib.py b/manual_trading_hub/hub_supervisor_lib.py new file mode 100644 index 0000000..7ac2e9a --- /dev/null +++ b/manual_trading_hub/hub_supervisor_lib.py @@ -0,0 +1,632 @@ +"""交易监管:事件分类、频率规则、会话消息与企业微信推送。""" +from __future__ import annotations + +import json +import os +import threading +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Optional + +from hub_trades_lib import current_trading_day, parse_dt_for_trading_day + +HUB_DIR = Path(__file__).resolve().parent +STATE_PATH = HUB_DIR / "hub_supervisor_state.json" + +PROGRAM_RESULTS = frozenset({"止盈", "止损", "保本止盈", "移动止盈"}) +MANUAL_CLOSE_RESULTS = frozenset({"手动平仓"}) +HUB_CLOSE_RESULTS = frozenset({"强制清仓"}) +WEAK_RESULTS = frozenset({"外部平仓", "时间平仓"}) + +EVENT_OPEN = "open" +EVENT_MANUAL_CLOSE = "manual_close" +EVENT_HUB_CLOSE = "hub_close" +EVENT_PROGRAM_TP = "program_tp" +EVENT_PROGRAM_SL = "program_sl" +EVENT_EXTERNAL = "external" +EVENT_FREQ_WARN = "freq_warn" + +DEFAULT_SUPERVISOR = { + "enabled": True, + "wechat_webhook": "", + "wechat_link_base": "http://127.0.0.1:5100/ai?mode=supervisor", + "wechat_prefix": "【交易监管】", + "wechat_on_program_tp_sl": True, + "manual_close_daily_warn": 2, + "interval_warn_minutes": 15, + "freq_30m_count": 2, + "reopen_after_close_minutes": 30, +} + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _load_json(path: Path, default: dict) -> dict: + if not path.is_file(): + return dict(default) + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + return loaded + except Exception: + pass + return dict(default) + + +def normalize_supervisor_settings(raw: dict | None) -> dict: + out = dict(DEFAULT_SUPERVISOR) + env_webhook = (os.getenv("SUPERVISOR_WECHAT_WEBHOOK") or "").strip() + env_link = (os.getenv("SUPERVISOR_WECHAT_LINK") or "").strip() + if env_webhook: + out["wechat_webhook"] = env_webhook + if env_link: + out["wechat_link_base"] = env_link + if not isinstance(raw, dict): + return out + for key in DEFAULT_SUPERVISOR: + if key not in raw: + continue + val = raw.get(key) + if key == "enabled" or key == "wechat_on_program_tp_sl": + out[key] = bool(val) + elif key in ("manual_close_daily_warn", "freq_30m_count"): + try: + out[key] = max(1, int(val)) + except (TypeError, ValueError): + pass + elif key in ("interval_warn_minutes", "reopen_after_close_minutes"): + try: + out[key] = max(1, int(val)) + except (TypeError, ValueError): + pass + elif isinstance(val, str): + out[key] = val.strip() + return out + + +def load_supervisor_state() -> dict: + data = _load_json(STATE_PATH, {"version": 1, "trading_day": "", "processed": [], "positions": {}, "stats": {}}) + data.setdefault("version", 1) + data.setdefault("processed", []) + data.setdefault("positions", {}) + data.setdefault("stats", {}) + return data + + +def save_supervisor_state(data: dict) -> None: + processed = list(data.get("processed") or []) + if len(processed) > 500: + processed = processed[-500:] + data["processed"] = processed + _atomic_write(STATE_PATH, data) + + +def _trade_event_id(trade: dict) -> str: + return "|".join( + [ + str(trade.get("account_name") or trade.get("account_key") or ""), + str(trade.get("symbol") or ""), + str(trade.get("closed_at") or ""), + str(trade.get("result") or ""), + str(trade.get("pnl_amount") or ""), + ] + ) + + +def classify_close_result(result: str) -> str: + r = (result or "").strip() + if r in PROGRAM_RESULTS: + if r == "止损": + return EVENT_PROGRAM_SL + return EVENT_PROGRAM_TP + if r in MANUAL_CLOSE_RESULTS: + return EVENT_MANUAL_CLOSE + if r in HUB_CLOSE_RESULTS: + return EVENT_HUB_CLOSE + if r in WEAK_RESULTS: + return EVENT_EXTERNAL + return EVENT_EXTERNAL + + +def is_supervised_event(event_type: str) -> bool: + return event_type in (EVENT_OPEN, EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) + + +def is_program_event(event_type: str) -> bool: + return event_type in (EVENT_PROGRAM_TP, EVENT_PROGRAM_SL) + + +def _position_contracts(pos: dict) -> float: + for key in ("contracts", "contracts_signed", "size"): + try: + v = pos.get(key) + if v is not None and v != "": + return abs(float(v)) + except (TypeError, ValueError): + continue + return 0.0 + + +def collect_position_keys(board_payload: dict | None) -> dict[str, dict]: + out: dict[str, dict] = {} + rows = (board_payload or {}).get("rows") or [] + for row in rows: + if not isinstance(row, dict): + continue + ex_id = str(row.get("id") or row.get("key") or "") + ex_name = str(row.get("name") or row.get("key") or ex_id) + ag = row.get("agent") or {} + for p in ag.get("positions") or []: + if not isinstance(p, dict): + continue + if _position_contracts(p) < 1e-12: + continue + sym = str(p.get("symbol") or "") + side = str(p.get("side") or "").lower() or "long" + key = f"{ex_id}|{sym}|{side}" + out[key] = { + "exchange_id": ex_id, + "exchange_name": ex_name, + "symbol": sym, + "side": side, + "contracts": _position_contracts(p), + } + return out + + +def detect_new_opens( + prev_positions: dict[str, dict], + curr_positions: dict[str, dict], +) -> list[dict]: + events = [] + for key, info in curr_positions.items(): + if key in prev_positions: + continue + events.append({"event_type": EVENT_OPEN, "event_id": f"open:{key}:{_now_str()[:16]}", **info}) + return events + + +def detect_new_closes( + prev_processed: set[str], + closed_trades: list[dict], +) -> list[dict]: + events = [] + for trade in closed_trades or []: + if not isinstance(trade, dict): + continue + eid = _trade_event_id(trade) + if eid in prev_processed: + continue + event_type = classify_close_result(str(trade.get("result") or "")) + events.append( + { + "event_type": event_type, + "event_id": f"close:{eid}", + "account_name": trade.get("account_name"), + "symbol": trade.get("symbol"), + "direction": trade.get("direction"), + "result": trade.get("result"), + "pnl_amount": trade.get("pnl_amount"), + "closed_at": trade.get("closed_at"), + } + ) + return events + + +def _parse_event_dt(raw: Any) -> Optional[datetime]: + return parse_dt_for_trading_day(raw) + + +def _supervised_close_times(stats: dict, trading_day: str) -> list[datetime]: + rows = (stats.get(trading_day) or {}).get("supervised_closes") or [] + out = [] + for item in rows: + if isinstance(item, dict): + dt = _parse_event_dt(item.get("closed_at") or item.get("at")) + else: + dt = _parse_event_dt(item) + if dt: + out.append(dt) + out.sort() + return out + + +def _record_supervised_event(stats: dict, trading_day: str, event: dict) -> None: + day_stats = stats.setdefault(trading_day, {}) + et = str(event.get("event_type") or "") + if et == EVENT_OPEN: + opens = list(day_stats.get("supervised_opens") or []) + opens.append({"at": _now_str(), "symbol": event.get("symbol")}) + day_stats["supervised_opens"] = opens[-50:] + return + if et not in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + return + closes = list(day_stats.get("supervised_closes") or []) + closes.append( + { + "at": _now_str(), + "closed_at": event.get("closed_at"), + "event_type": et, + "pnl_amount": event.get("pnl_amount"), + } + ) + day_stats["supervised_closes"] = closes[-50:] + + +def evaluate_frequency_warnings( + *, + trading_day: str, + event: dict, + stats: dict, + settings: dict, +) -> list[dict]: + if not is_supervised_event(str(event.get("event_type") or "")): + return [] + warnings: list[dict] = [] + day_stats = stats.setdefault(trading_day, {}) + closes = _supervised_close_times(stats, trading_day) + now = datetime.now() + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + evt_dt = _parse_event_dt(event.get("closed_at")) or now + closes = closes + [evt_dt] + closes.sort() + open_count = len(day_stats.get("supervised_opens") or []) + close_count = len(day_stats.get("supervised_closes") or []) + if event.get("event_type") == EVENT_OPEN: + open_count += 1 + elif event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + close_count += 1 + + interval_min = int(settings.get("interval_warn_minutes") or 15) + daily_warn = int(settings.get("manual_close_daily_warn") or 2) + freq_30m = int(settings.get("freq_30m_count") or 2) + reopen_min = int(settings.get("reopen_after_close_minutes") or 30) + + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) and len(closes) >= 2: + prev = closes[-2] + cur = closes[-1] + gap = (cur - prev).total_seconds() / 60.0 + if gap < interval_min: + warnings.append( + { + "rule": "INTERVAL_SHORT", + "message": f"两笔手动/中控平间隔仅 {int(gap)} 分钟(阈值 {interval_min} 分钟)", + } + ) + + recent_closes = [t for t in closes if (now - t).total_seconds() <= 30 * 60] + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) and len(recent_closes) >= freq_30m: + warnings.append( + { + "rule": "FREQ_30M", + "message": f"30 分钟内手动/中控平已达 {len(recent_closes)} 笔(阈值 {freq_30m} 笔)", + } + ) + + supervised_total = open_count + close_count + if supervised_total >= daily_warn and event.get("event_type") in ( + EVENT_MANUAL_CLOSE, + EVENT_HUB_CLOSE, + EVENT_OPEN, + ): + if close_count >= daily_warn: + warnings.append( + { + "rule": "DAILY_COUNT", + "message": f"今日手动/中控平 {close_count} 笔(阈值 {daily_warn} 笔),注意过度交易", + } + ) + + if event.get("event_type") == EVENT_OPEN and closes: + last_close = closes[-1] + gap_open = (now - last_close).total_seconds() / 60.0 + if gap_open < reopen_min: + warnings.append( + { + "rule": "REOPEN_FAST", + "message": f"距上一笔手动/中控平仅 {int(gap_open)} 分钟又新开仓(阈值 {reopen_min} 分钟)", + } + ) + + loss_streak = 0 + for item in reversed((stats.get(trading_day) or {}).get("supervised_closes") or []): + try: + pnl = float((item or {}).get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + if pnl < 0: + loss_streak += 1 + else: + break + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + try: + pnl = float(event.get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + if pnl < 0: + loss_streak += 1 + else: + loss_streak = 0 + if loss_streak >= 2 and event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + warnings.append( + { + "rule": "LOSS_STREAK", + "message": f"连续 {loss_streak} 笔手动/中控亏损,先停一停", + } + ) + + deduped = [] + seen = set() + for w in warnings: + key = w.get("rule") + if key in seen: + continue + seen.add(key) + deduped.append(w) + return deduped + + +def event_tag(event_type: str) -> str: + return { + EVENT_OPEN: "监管·开仓", + EVENT_MANUAL_CLOSE: "监管·手动平", + EVENT_HUB_CLOSE: "监管·中控平", + EVENT_PROGRAM_TP: "监管·程序止盈", + EVENT_PROGRAM_SL: "监管·程序止损", + EVENT_EXTERNAL: "监管·外部平", + EVENT_FREQ_WARN: "监管·频率", + }.get(event_type, "监管") + + +def build_system_message(event: dict, *, trading_day: str, warnings: list[dict] | None = None) -> str: + tag = event_tag(str(event.get("event_type") or "")) + ex = event.get("exchange_name") or event.get("account_name") or "—" + sym = event.get("symbol") or "—" + lines = [f"[{tag}] {ex} · {sym}"] + et = event.get("event_type") + if et == EVENT_OPEN: + side = event.get("side") or event.get("direction") or "" + if side: + lines.append(f"方向:{side}") + elif et in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE, EVENT_PROGRAM_TP, EVENT_PROGRAM_SL, EVENT_EXTERNAL): + res = event.get("result") or "" + pnl = event.get("pnl_amount") + if pnl is not None: + lines.append(f"结果 {res} · 盈亏 {pnl}U") + else: + lines.append(f"结果 {res}") + if event.get("closed_at"): + lines.append(f"平仓时间 {event.get('closed_at')}") + for w in warnings or []: + lines.append(f"⚠ {w.get('message')}") + lines.append(f"交易日 {trading_day}") + return "\n".join(lines) + + +def build_wechat_body( + event: dict, + *, + trading_day: str, + link_base: str, + system_text: str, +) -> str: + link = (link_base or "").strip() + if link: + sep = "&" if "?" in link else "?" + link = f"{link}{sep}day={trading_day}" + body = system_text.replace("\n", "\n") + if link: + body += f"\n详情:{link}" + return body + + +def should_send_wechat(event: dict, settings: dict) -> bool: + if not settings.get("enabled", True): + return False + webhook = (settings.get("wechat_webhook") or "").strip() + if not webhook or "replace-me" in webhook.lower(): + return False + et = str(event.get("event_type") or "") + if is_program_event(et): + return bool(settings.get("wechat_on_program_tp_sl", True)) + if et == EVENT_EXTERNAL: + return False + return True + + +def send_supervisor_wechat( + event: dict, + *, + trading_day: str, + settings: dict, + system_text: str, +) -> bool: + if not should_send_wechat(event, settings): + return False + from wechat_notify_lib import send_wechat_webhook + + prefix = (settings.get("wechat_prefix") or "【交易监管】").strip() + body = build_wechat_body( + event, + trading_day=trading_day, + link_base=str(settings.get("wechat_link_base") or ""), + system_text=system_text, + ) + return bool( + send_wechat_webhook( + str(settings.get("wechat_webhook") or ""), + body, + prefix=prefix, + ) + ) + + +_notify_hook: Optional[Callable[[], None]] = None + + +def set_supervisor_notify_hook(fn: Optional[Callable[[], None]]) -> None: + global _notify_hook + _notify_hook = fn + + +def _fire_notify() -> None: + if _notify_hook: + try: + _notify_hook() + except Exception: + pass + + +def process_supervisor_tick( + dashboard_payload: dict | None, + board_payload: dict | None, + settings_root: dict | None, + *, + reset_hour: int = 8, + ai_reply_fn: Optional[Callable[..., str]] = None, +) -> dict[str, Any]: + """单次监管扫描:对比快照、写会话、推微信、可选 AI 评语。""" + from hub_ai.supervisor_store import ( + append_supervisor_ai_message, + append_supervisor_system_message, + ensure_supervisor_session, + ) + + sup_cfg = normalize_supervisor_settings((settings_root or {}).get("supervisor")) + if not sup_cfg.get("enabled", True): + return {"ok": True, "skipped": True, "reason": "disabled"} + + dash = dashboard_payload or {} + trading_day = str(dash.get("trading_day") or current_trading_day(reset_hour=reset_hour)) + state = load_supervisor_state() + if str(state.get("trading_day") or "") != trading_day: + state = { + "version": 1, + "trading_day": trading_day, + "processed": [], + "positions": {}, + "stats": {trading_day: state.get("stats", {}).get(trading_day, {})}, + } + + processed = set(str(x) for x in (state.get("processed") or [])) + stats = dict(state.get("stats") or {}) + prev_positions = dict(state.get("positions") or {}) + curr_positions = collect_position_keys(board_payload) + closed_trades = dash.get("closed_trades") or [] + + if not state.get("initialized"): + for trade in closed_trades: + if isinstance(trade, dict): + processed.add(f"close:{_trade_event_id(trade)}") + state["trading_day"] = trading_day + state["processed"] = list(processed) + state["positions"] = curr_positions + state["initialized"] = True + save_supervisor_state(state) + return {"ok": True, "events": 0, "seeded": True, "trading_day": trading_day} + + raw_events = detect_new_opens(prev_positions, curr_positions) + detect_new_closes( + processed, closed_trades + ) + if not raw_events: + state["positions"] = curr_positions + save_supervisor_state(state) + return {"ok": True, "events": 0} + + session = ensure_supervisor_session(trading_day) + session_id = str(session.get("id") or "") + handled = 0 + + for event in raw_events: + eid = str(event.get("event_id") or uuid.uuid4().hex) + if eid in processed: + continue + et = str(event.get("event_type") or "") + if et == EVENT_EXTERNAL: + processed.add(eid) + continue + + warnings = evaluate_frequency_warnings( + trading_day=trading_day, + event=event, + stats=stats, + settings=sup_cfg, + ) + if is_supervised_event(et): + _record_supervised_event(stats, trading_day, event) + + system_text = build_system_message(event, trading_day=trading_day, warnings=warnings) + append_supervisor_system_message( + session_id, + system_text, + event_type=et, + level="warn" if warnings else "info", + ) + send_supervisor_wechat( + event, + trading_day=trading_day, + settings=sup_cfg, + system_text=system_text, + ) + for w in warnings: + warn_event = { + "event_type": EVENT_FREQ_WARN, + "event_id": f"warn:{eid}:{w.get('rule')}", + **event, + "warn_message": w.get("message"), + } + warn_text = f"[{event_tag(EVENT_FREQ_WARN)}] {w.get('message')}" + append_supervisor_system_message( + session_id, + warn_text, + event_type=EVENT_FREQ_WARN, + level="warn", + ) + send_supervisor_wechat( + warn_event, + trading_day=trading_day, + settings=sup_cfg, + system_text=warn_text, + ) + + if ai_reply_fn and et != EVENT_EXTERNAL: + evt_snapshot = dict(event) + evt_warnings = list(warnings) + + def _ai_bg() -> None: + try: + reply = ai_reply_fn( + event=evt_snapshot, + warnings=evt_warnings, + trading_day=trading_day, + session_id=session_id, + ) + if reply and reply.strip(): + append_supervisor_ai_message(session_id, reply.strip()) + _fire_notify() + except Exception: + pass + + threading.Thread(target=_ai_bg, daemon=True).start() + + processed.add(eid) + handled += 1 + + state["trading_day"] = trading_day + state["processed"] = list(processed) + state["positions"] = curr_positions + state["stats"] = stats + save_supervisor_state(state) + if handled: + _fire_notify() + return {"ok": True, "events": handled, "trading_day": trading_day, "session_id": session_id} diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index 35faa56..bf9e604 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -9,6 +9,8 @@ from pathlib import Path DIR = Path(__file__).resolve().parent SETTINGS_PATH = DIR / "hub_settings.json" +from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings + DEFAULT_DISPLAY = { "show_account_pnl": True, "show_nav_funds": True, @@ -98,6 +100,7 @@ def load_settings() -> dict: except Exception: pass data["display"] = normalize_display_prefs(data.get("display")) + data["supervisor"] = normalize_supervisor_settings(data.get("supervisor")) force_off = env_force_disabled_ids() for ex in data.get("exchanges") or []: if str(ex.get("id")) in force_off: @@ -109,8 +112,11 @@ def load_settings() -> dict: def save_settings(data: dict) -> None: + payload = dict(data) + payload["display"] = normalize_display_prefs(payload.get("display")) + payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor")) SETTINGS_PATH.write_text( - json.dumps(data, ensure_ascii=False, indent=2), + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index e99d041..9bc648d 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -4541,6 +4541,7 @@ body.hub-page-ai #page-ai { body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-panel, body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-panel, body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel { display: flex; flex: 1 1 auto; @@ -4551,12 +4552,14 @@ body.hub-page-ai #page-ai { } body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-history-panel, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel { + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-history-panel { display: none !important; } body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-main, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main { + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-main { display: flex; flex: 1 1 auto; min-height: 0; @@ -5182,6 +5185,31 @@ body.hub-page-ai #page-ai { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); } +.ai-chat-history-badge.supervisor { + color: #c27803; + border-color: color-mix(in srgb, #c27803 45%, var(--border-soft)); +} +.ai-msg-row-system { + justify-content: flex-start; +} +.ai-bubble-system { + background: color-mix(in srgb, var(--surface-2) 88%, #c27803 12%); + border: 1px solid color-mix(in srgb, var(--border-soft) 70%, #c27803 30%); + font-size: 0.92rem; + white-space: pre-wrap; +} +.ai-bubble-warn { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border-soft)); +} +.ai-chat-history-panel.hidden { + display: none !important; +} +.ai-chat-new-btn.hidden { + display: none !important; +} +.supervisor-settings-grid { + margin-top: 0.75rem; +} .ai-chat-history-del { min-width: 28px; min-height: 28px; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 8e7d608..f86842f 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -82,6 +82,28 @@ syncNavVisibility(data); } + function syncSupervisorSettingsUI(data) { + const s = (data && data.supervisor) || {}; + const enabled = document.getElementById("supervisor-enabled"); + const prog = document.getElementById("supervisor-wechat-program"); + const webhook = document.getElementById("supervisor-wechat-webhook"); + const link = document.getElementById("supervisor-wechat-link"); + const prefix = document.getElementById("supervisor-wechat-prefix"); + const daily = document.getElementById("supervisor-daily-warn"); + const interval = document.getElementById("supervisor-interval-warn"); + const freq30 = document.getElementById("supervisor-freq-30m"); + const reopen = document.getElementById("supervisor-reopen-min"); + if (enabled) enabled.checked = s.enabled !== false; + if (prog) prog.checked = s.wechat_on_program_tp_sl !== false; + if (webhook) webhook.value = s.wechat_webhook || ""; + if (link) link.value = s.wechat_link_base || ""; + if (prefix) prefix.value = s.wechat_prefix || "【交易监管】"; + if (daily) daily.value = Number(s.manual_close_daily_warn) || 2; + if (interval) interval.value = Number(s.interval_warn_minutes) || 15; + if (freq30) freq30.value = Number(s.freq_30m_count) || 2; + if (reopen) reopen.value = Number(s.reopen_after_close_minutes) || 30; + } + function positionTableHeadHtml(compact) { const pnlTh = showAccountPnlPref() ? "浮盈" : ""; const cls = compact ? " data-table data-table-positions" : ""; @@ -1085,6 +1107,7 @@ syncHubAiMobileViewport(); if (page === "monitor") startMonitorPoll(); else stopMonitorPoll(); + if (page !== "ai") closeSupervisorStream(); if (page === "dashboard" && window.hubDashboardPage) { window.hubDashboardPage.init(); } else if (window.hubDashboardPage && window.hubDashboardPage.destroy) { @@ -1506,7 +1529,22 @@ } const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab"; - const AI_MOBILE_CHAT_TABS = new Set(["trading", "general"]); + const AI_MOBILE_CHAT_TABS = new Set(["trading", "general", "supervisor"]); + let aiSupervisorSessionCache = null; + let supervisorEventSource = null; + let localSupervisorVersion = 0; + let supervisorReconnectTimer = null; + + function isSupervisorMode() { + return aiSelectedBotMode === "supervisor"; + } + + function normalizeAiBotMode(mode) { + const m = (mode || "").trim().toLowerCase(); + if (m === "general") return "general"; + if (m === "supervisor") return "supervisor"; + return "trading"; + } function normalizeAiMobileTab(tab) { const raw = (tab || "").trim().toLowerCase(); @@ -1540,6 +1578,11 @@ }); if (AI_MOBILE_CHAT_TABS.has(active)) { updateAiBotTabs(active); + if (active === "supervisor") { + void loadAiSupervisorSession().then(() => connectSupervisorStream()); + } else { + closeSupervisorStream(); + } scrollAiChatToEnd(); } if (active === "history") { @@ -1556,8 +1599,16 @@ const tab = btn.dataset.aiTab || "trading"; if (tab === "new") { const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); - const botMode = prev === "general" ? "general" : "trading"; - void newAiChat(botMode); + const botMode = prev === "general" ? "general" : prev === "supervisor" ? "supervisor" : "trading"; + if (botMode === "supervisor") { + void switchToSupervisorMode(); + } else { + void newAiChat(botMode); + } + return; + } + if (tab === "supervisor") { + void switchToSupervisorMode(); return; } localStorage.setItem(AI_MOBILE_TAB_KEY, tab); @@ -3745,6 +3796,7 @@ loadMacroCalendarUI(); loadSettings().then((data) => { syncDisplayPrefsUI(data); + syncSupervisorSettingsUI(data); renderSettingsList(data); }); } @@ -3785,6 +3837,15 @@ const archiveCb = document.getElementById("pref-show-nav-archive"); const aiCb = document.getElementById("pref-show-nav-ai"); const calcCb = document.getElementById("pref-show-nav-calculator"); + const supEnabled = document.getElementById("supervisor-enabled"); + const supProg = document.getElementById("supervisor-wechat-program"); + const supWebhook = document.getElementById("supervisor-wechat-webhook"); + const supLink = document.getElementById("supervisor-wechat-link"); + const supPrefix = document.getElementById("supervisor-wechat-prefix"); + const supDaily = document.getElementById("supervisor-daily-warn"); + const supInterval = document.getElementById("supervisor-interval-warn"); + const supFreq30 = document.getElementById("supervisor-freq-30m"); + const supReopen = document.getElementById("supervisor-reopen-min"); return { version: 1, display: { @@ -3796,6 +3857,17 @@ show_nav_ai: aiCb ? !!aiCb.checked : true, show_nav_calculator: calcCb ? !!calcCb.checked : true, }, + supervisor: { + enabled: supEnabled ? !!supEnabled.checked : true, + wechat_webhook: supWebhook ? supWebhook.value.trim() : "", + wechat_link_base: supLink ? supLink.value.trim() : "", + wechat_prefix: supPrefix ? supPrefix.value.trim() : "【交易监管】", + wechat_on_program_tp_sl: supProg ? !!supProg.checked : true, + manual_close_daily_warn: supDaily ? Number(supDaily.value) || 2 : 2, + interval_warn_minutes: supInterval ? Number(supInterval.value) || 15 : 15, + freq_30m_count: supFreq30 ? Number(supFreq30.value) || 2 : 2, + reopen_after_close_minutes: supReopen ? Number(supReopen.value) || 30 : 30, + }, exchanges: rows.map((card) => { const caps = []; if (card.querySelector(".cap-key").checked) caps.push("key"); @@ -3830,6 +3902,7 @@ if (j.settings) { settingsCache = j.settings; syncDisplayPrefsUI(j.settings); + syncSupervisorSettingsUI(j.settings); renderSettingsList(j.settings); loadSettingsMetaLine(); } else { @@ -4036,19 +4109,26 @@ } function updateAiBotTabs(mode) { - const m = mode === "general" ? "general" : "trading"; + const m = normalizeAiBotMode(mode); aiSelectedBotMode = m; document.querySelectorAll(".ai-bot-tab").forEach((btn) => { - const on = (btn.dataset.bot || "trading") === m; + const on = normalizeAiBotMode(btn.dataset.bot || "trading") === m; btn.classList.toggle("is-active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); + const newBtn = document.getElementById("btn-ai-chat-new"); + if (newBtn) newBtn.classList.toggle("hidden", m === "supervisor"); + const histPanel = document.querySelector(".ai-chat-history-panel"); + if (histPanel) histPanel.classList.toggle("hidden", m === "supervisor"); const input = document.getElementById("ai-chat-input"); if (input) { - input.placeholder = - m === "general" - ? "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图" - : "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图"; + if (m === "general") { + input.placeholder = "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"; + } else if (m === "supervisor") { + input.placeholder = "回应监管提醒、说说为什么又开了一单…"; + } else { + input.placeholder = "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图"; + } } } @@ -4090,20 +4170,33 @@ function renderAiChatRow(role, content, extraClass, attachments, rowOpts) { const opts = rowOpts || {}; - const botMode = opts.botMode === "general" ? "general" : "trading"; + const botMode = normalizeAiBotMode(opts.botMode || aiSelectedBotMode); const isUser = role === "user"; - const label = isUser ? "主人" : botMode === "general" ? "助手" : "交易教练"; - const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach"; - const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant"; + const isSystem = role === "system"; + let label = "主人"; + if (isSystem) label = "监管"; + else if (!isUser) label = botMode === "general" ? "助手" : botMode === "supervisor" ? "监管AI" : "交易教练"; + const rowCls = isUser + ? "ai-msg-row-user" + : isSystem + ? "ai-msg-row-system" + : "ai-msg-row-coach"; + const bubbleCls = isUser + ? "ai-bubble-user" + : isSystem + ? "ai-bubble-system" + : "ai-bubble-assistant"; const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking"); const isError = !isUser && + !isSystem && !isThinking && /^(AI 调用失败|AI 生成失败)/.test(String(content || "").trim()); const mdKey = - !isUser && !isThinking && opts.cacheKey ? String(opts.cacheKey) : ""; - const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "", mdKey); - const mdCls = !isUser && !isThinking ? " ai-result-md" : ""; + !isUser && !isSystem && !isThinking && opts.cacheKey ? String(opts.cacheKey) : ""; + const bubbleInner = + isUser || isThinking || isSystem ? esc(content || "") : renderHubMarkdown(content || "", mdKey); + const mdCls = !isUser && !isSystem && !isThinking ? " ai-result-md" : ""; const attList = Array.isArray(attachments) ? attachments : []; const attHtml = attList.length ? `
${attList @@ -4129,15 +4222,20 @@ const box = document.getElementById("ai-chat-messages"); const title = document.getElementById("ai-chat-title"); if (!box) return; - const msgs = (session && session.messages) || []; - const botMode = (session && session.bot_mode) || aiSelectedBotMode || "trading"; + const activeSession = isSupervisorMode() ? aiSupervisorSessionCache || session : session; + const msgs = (activeSession && activeSession.messages) || []; + const botMode = normalizeAiBotMode((activeSession && activeSession.bot_mode) || aiSelectedBotMode); if (title) { - const modeLabel = botMode === "general" ? "普通聊天" : "交易教练"; - const sessionTitle = session && session.title ? String(session.title) : ""; + const modeLabel = + botMode === "general" ? "普通聊天" : botMode === "supervisor" ? "交易监管" : "交易教练"; + const sessionTitle = activeSession && activeSession.title ? String(activeSession.title) : ""; if (isMobileAiLayout()) { - title.textContent = sessionTitle && sessionTitle !== "新对话" - ? sessionTitle - : modeLabel; + title.textContent = + botMode === "supervisor" + ? sessionTitle || "今日监管" + : sessionTitle && sessionTitle !== "新对话" + ? sessionTitle + : modeLabel; } else { title.textContent = sessionTitle ? `${modeLabel} · ${sessionTitle}` @@ -4150,21 +4248,24 @@ const hint = botMode === "general" ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。可粘贴截图或上传附件。" - : "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。"; + : botMode === "supervisor" + ? "今日监管为长会话:手动/中控开平仓与新开仓会自动推送;程序止盈止损会鼓励性提醒。可直接回复继续聊。" + : "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。"; box.innerHTML = `

${hint}

`; return; } - const sessionId = session && session.id ? String(session.id) : "local"; + const sessionId = activeSession && activeSession.id ? String(activeSession.id) : "local"; let html = msgs - .map((m, idx) => - renderAiChatRow( - m.role === "user" ? "user" : "assistant", + .map((m, idx) => { + const role = m.role === "user" ? "user" : m.role === "system" ? "system" : "assistant"; + return renderAiChatRow( + role, m.content || "", - null, + m.level === "warn" ? "ai-bubble-warn" : null, m.attachments, { botMode, msgIdx: idx, cacheKey: sessionId + ":" + idx } - ) - ) + ); + }) .join(""); if (options.pendingUser) { html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments); @@ -4187,6 +4288,66 @@ }); } + async function loadAiSupervisorSession() { + const r = await apiFetch("/api/ai/supervisor/session"); + const j = await r.json(); + aiSupervisorSessionCache = j.session || null; + if (isSupervisorMode()) { + renderAiChatMessages(aiSupervisorSessionCache); + } + updateAiBotTabs("supervisor"); + return j; + } + + async function switchToSupervisorMode() { + updateAiBotTabs("supervisor"); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, "supervisor"); + applyAiMobileTab("supervisor"); + } + try { + await loadAiSupervisorSession(); + connectSupervisorStream(); + scrollAiChatToEnd(); + } catch (e) { + showToast(String(e), true); + } + } + + function closeSupervisorStream() { + if (supervisorEventSource) { + supervisorEventSource.close(); + supervisorEventSource = null; + } + if (supervisorReconnectTimer) { + clearTimeout(supervisorReconnectTimer); + supervisorReconnectTimer = null; + } + } + + function connectSupervisorStream() { + closeSupervisorStream(); + if (currentPage() !== "ai" || !isSupervisorMode()) return; + supervisorEventSource = new EventSource("/api/ai/supervisor/stream"); + supervisorEventSource.addEventListener("supervisor", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.supervisor_version) || 0; + if (ver !== localSupervisorVersion) { + localSupervisorVersion = ver; + void loadAiSupervisorSession(); + } + } catch (_) {} + }); + supervisorEventSource.onerror = () => { + closeSupervisorStream(); + if (supervisorReconnectTimer) clearTimeout(supervisorReconnectTimer); + supervisorReconnectTimer = setTimeout(() => { + if (currentPage() === "ai" && isSupervisorMode()) connectSupervisorStream(); + }, 8000); + }; + } + async function loadAiChatSession() { const r = await apiFetch("/api/ai/chat/session"); const j = await r.json(); @@ -4313,8 +4474,15 @@ async function loadAiPage() { applyAiMobileTab(); - await loadAiChatSession(); - await consumeArchiveQuoteAiPending(); + const params = new URLSearchParams(window.location.search || ""); + const modeParam = (params.get("mode") || "").trim().toLowerCase(); + if (modeParam === "supervisor") { + await switchToSupervisorMode(); + } else { + closeSupervisorStream(); + await loadAiChatSession(); + await consumeArchiveQuoteAiPending(); + } const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) { const input = document.getElementById("ai-chat-input"); @@ -4325,7 +4493,8 @@ } async function newAiChat(botMode) { - const mode = botMode === "general" ? "general" : "trading"; + const mode = normalizeAiBotMode(botMode); + if (mode !== "supervisor") closeSupervisorStream(); try { const r = await apiFetch("/api/ai/chat/new", { method: "POST", @@ -4342,7 +4511,13 @@ localStorage.setItem(AI_MOBILE_TAB_KEY, mode); applyAiMobileTab(mode); } - showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话"); + showToast( + mode === "general" + ? "已开始普通聊天" + : mode === "supervisor" + ? "已打开今日监管" + : "已开始交易教练对话" + ); } catch (e) { showToast(String(e), true); } @@ -4353,6 +4528,38 @@ if (aiChatLoading) return; const input = document.getElementById("ai-chat-input"); const text = (input && input.value || "").trim(); + if (isSupervisorMode()) { + if (!text) return; + const savedText = text; + if (input) input.value = ""; + setAiChatBusy(true); + renderAiChatMessages(aiSupervisorSessionCache, { + pendingUser: text, + thinking: true, + }); + try { + const r = await apiFetch("/api/ai/supervisor/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 || "发送失败"); + aiSupervisorSessionCache = j.session || null; + renderAiChatMessages(aiSupervisorSessionCache); + } catch (e) { + showToast(String(e), true); + if (input && savedText) input.value = savedText; + try { + await loadAiSupervisorSession(); + } catch (_) { + renderAiChatMessages(aiSupervisorSessionCache); + } + } finally { + setAiChatBusy(false); + } + return; + } const files = aiChatPendingFiles.slice(); if (!text && !files.length) return; const pendingAttachments = files.map((f) => ({ @@ -4463,7 +4670,12 @@ if (btn._aiBotBound) return; btn._aiBotBound = true; btn.addEventListener("click", () => { - const mode = btn.getAttribute("data-bot") || "trading"; + const mode = normalizeAiBotMode(btn.getAttribute("data-bot") || "trading"); + if (mode === "supervisor") { + void switchToSupervisorMode(); + return; + } + closeSupervisorStream(); newAiChat(mode); }); }); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index e9e3bea..f783803 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -648,11 +648,12 @@ @@ -902,6 +904,50 @@
+
+

交易监管 · 企业微信

+

+ 与四所实例策略通知独立;手动/中控开平仓与新开仓会推送至此 Webhook。链接可在下方单独修改。 +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/manual_trading_hub/交易监管说明.md b/manual_trading_hub/交易监管说明.md new file mode 100644 index 0000000..058a177 --- /dev/null +++ b/manual_trading_hub/交易监管说明.md @@ -0,0 +1,84 @@ +# 交易监管(AI 教练) + +中控 **交易监管** 用于防止过度交易与频繁手动操作:在 **手动/中控开平仓** 与 **新开仓** 时自动推送至 **今日监管长会话**,并可选 **企业微信** 提醒;程序止盈/止损按「正常执行」鼓励,不计入频繁交易统计。 + +入口:**AI 教练**(`/ai`)→ Tab **交易监管**,或微信链接(在系统设置中配置)。 + +## 监管范围 + +| 类型 | 识别 | 页内推送 | 微信(P0) | 频率统计 | +|------|------|----------|------------|----------| +| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ | +| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ | +| 新开仓 | 监控板持仓 diff(0→有仓 / 新合约) | ✓ | ✓ | ✓ | +| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ | +| 程序止损 | 止损 | ✓ | 可选 | ✗ | +| 外部平仓 | 外部平仓、时间平仓 | ✗ | ✗ | ✗ | + +频率规则(间隔过短、30 分钟笔数、日笔数、连亏、平后快开)**只对手动/中控开平** 叠加 `[监管·频率]` 警告。 + +## 会话 + +- 每个交易日 **一条长会话**(`bot_mode: supervisor`,标题 `今日监管 YYYY-MM-DD`)。 +- 系统消息(`role: system`)+ AI 短评(`assistant`)+ 用户回复(`user`)同线程。 +- 与 **交易教练 / 普通聊天** 分离;监管会话不支持「新开对话」。 + +## 系统设置 + +路径:**系统设置** → **交易监管 · 企业微信**(写入 `hub_settings.json` → `supervisor`)。 + +| 字段 | 说明 | +|------|------| +| `enabled` | 总开关 | +| `wechat_webhook` | **监管专用** 企业微信机器人(与四所实例 `.env` 的 `WECHAT_WEBHOOK` 独立) | +| `wechat_link_base` | 微信消息末尾跳转链接(**可单独修改**,如 `https://域名/ai?mode=supervisor`) | +| `wechat_prefix` | 消息前缀,默认 `【交易监管】` | +| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 | +| `manual_close_daily_warn` | 日手动平警告阈值(默认 2) | +| `interval_warn_minutes` | 两笔手动/中控平最短间隔(默认 15 分钟) | +| `freq_30m_count` | 30 分钟内笔数阈值(默认 2) | +| `reopen_after_close_minutes` | 手动平后再开仓警告间隔(默认 30 分钟) | + +`.env` 兜底(设置页保存优先): + +```env +SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=... +SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor +SUPERVISOR_POLL_INTERVAL_SEC=30 +``` + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/ai/supervisor/session` | 今日监管会话 | +| GET | `/api/ai/supervisor/stream` | SSE 版本推送 | +| POST | `/api/ai/supervisor/chat/send` | 用户回聊(JSON `{ "message": "..." }`) | +| GET | `/api/ai/supervisor/rules` | 当前阈值 | +| POST | `/api/ai/supervisor/refresh` | 立即扫描 | + +## 存储 + +| 文件 | 内容 | +|------|------| +| `hub_supervisor_state.json` | 已处理事件、持仓快照、频率统计 | +| `hub_ai_chat.json` | 监管会话(`bot_mode: supervisor`) | +| `hub_settings.json` | `supervisor` 配置节 | + +**首次启用** 会对当前交易日已有平仓做 **种子同步**(不补发历史推送),避免部署瞬间刷屏。 + +## 与实例风控 + +实例 `account_risk_lib`(冷静期 / 日冻结)为 **硬拦截**;监管为 **软提醒 + 陪聊**,不绕过实例开仓限制。 + +## 代码位置 + +| 模块 | 路径 | +|------|------| +| 规则与推送 | `hub_supervisor_lib.py` | +| 后台扫描 | `hub_supervisor_cache.py` | +| 会话 | `hub_ai/supervisor_store.py` | +| AI 评语/回聊 | `hub_ai/supervisor.py` | +| 提示词 | `hub_ai/prompts.py` → `SUPERVISOR_SYSTEM` | + +部署后重启中控:`pm2 restart manual-trading-hub`(或你的 hub 进程名)。 diff --git a/tests/test_hub_supervisor_lib.py b/tests/test_hub_supervisor_lib.py new file mode 100644 index 0000000..07ee81d --- /dev/null +++ b/tests/test_hub_supervisor_lib.py @@ -0,0 +1,138 @@ +"""hub_supervisor_lib 单元测试。""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +import hub_supervisor_lib as sup + + +@pytest.fixture +def state_path(tmp_path, monkeypatch): + p = tmp_path / "hub_supervisor_state.json" + monkeypatch.setattr(sup, "STATE_PATH", p) + return p + + +def test_classify_close_result(): + assert sup.classify_close_result("手动平仓") == sup.EVENT_MANUAL_CLOSE + assert sup.classify_close_result("强制清仓") == sup.EVENT_HUB_CLOSE + assert sup.classify_close_result("止盈") == sup.EVENT_PROGRAM_TP + assert sup.classify_close_result("止损") == sup.EVENT_PROGRAM_SL + assert sup.classify_close_result("外部平仓") == sup.EVENT_EXTERNAL + + +def test_detect_new_opens(): + prev = {"0|ETH/USDT|long": {"symbol": "ETH/USDT"}} + curr = { + "0|ETH/USDT|long": {"symbol": "ETH/USDT"}, + "1|BTC/USDT|short": {"symbol": "BTC/USDT", "exchange_name": "OKX"}, + } + events = sup.detect_new_opens(prev, curr) + assert len(events) == 1 + assert events[0]["event_type"] == sup.EVENT_OPEN + assert events[0]["symbol"] == "BTC/USDT" + + +def test_detect_new_closes_dedup(): + trades = [ + { + "account_name": "OKX", + "symbol": "ETH/USDT", + "result": "手动平仓", + "pnl_amount": -5, + "closed_at": "2026-06-14 10:00:00", + } + ] + eid = f"close:{sup._trade_event_id(trades[0])}" + events = sup.detect_new_closes(set(), trades) + assert len(events) == 1 + assert events[0]["event_type"] == sup.EVENT_MANUAL_CLOSE + assert sup.detect_new_closes({eid}, trades) == [] + + +def test_evaluate_frequency_warnings_interval(): + stats = { + "2026-06-14": { + "supervised_closes": [ + {"closed_at": "2026-06-14 09:50:00", "pnl_amount": -1}, + ], + "supervised_opens": [], + } + } + event = { + "event_type": sup.EVENT_MANUAL_CLOSE, + "closed_at": "2026-06-14 10:00:00", + "pnl_amount": -2, + } + settings = sup.normalize_supervisor_settings({}) + warnings = sup.evaluate_frequency_warnings( + trading_day="2026-06-14", + event=event, + stats=stats, + settings=settings, + ) + rules = {w["rule"] for w in warnings} + assert "INTERVAL_SHORT" in rules + + +def test_process_supervisor_tick_seeds_without_events(state_path, monkeypatch, tmp_path): + chat_path = tmp_path / "hub_ai_chat.json" + monkeypatch.setattr("hub_ai.store.CHAT_PATH", chat_path) + + dash = { + "ok": True, + "trading_day": "2026-06-14", + "closed_trades": [ + { + "account_name": "Binance", + "symbol": "ETH/USDT", + "result": "手动平仓", + "pnl_amount": 1, + "closed_at": "2026-06-14 08:30:00", + } + ], + } + board = {"ok": True, "rows": []} + settings = {"supervisor": sup.normalize_supervisor_settings({"enabled": True, "wechat_webhook": ""})} + + r1 = sup.process_supervisor_tick(dash, board, settings, ai_reply_fn=None) + assert r1.get("seeded") is True + assert r1.get("events") == 0 + + r2 = sup.process_supervisor_tick(dash, board, settings, ai_reply_fn=None) + assert r2.get("events") == 0 + + dash2 = dict(dash) + dash2["closed_trades"] = dash["closed_trades"] + [ + { + "account_name": "Binance", + "symbol": "BTC/USDT", + "result": "手动平仓", + "pnl_amount": -3, + "closed_at": "2026-06-14 11:00:00", + } + ] + r3 = sup.process_supervisor_tick(dash2, board, settings, ai_reply_fn=None) + assert r3.get("events") == 1 + assert chat_path.is_file() + data = json.loads(chat_path.read_text(encoding="utf-8")) + sessions = [s for s in data.get("sessions") or [] if s.get("bot_mode") == "supervisor"] + assert sessions + msgs = sessions[0].get("messages") or [] + assert any(m.get("role") == "system" for m in msgs) + + +def test_normalize_supervisor_settings_env(monkeypatch): + monkeypatch.setenv("SUPERVISOR_WECHAT_WEBHOOK", "https://example.com/hook") + monkeypatch.setenv("SUPERVISOR_WECHAT_LINK", "https://hub.example/ai?mode=supervisor") + cfg = sup.normalize_supervisor_settings({}) + assert cfg["wechat_webhook"] == "https://example.com/hook" + assert cfg["wechat_link_base"] == "https://hub.example/ai?mode=supervisor"