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 <cursoragent@cursor.com>
This commit is contained in:
@@ -8,9 +8,10 @@
|
||||
|------|------|
|
||||
| **交易教练** | 口语化陪聊;注入四户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
|
||||
| **普通聊天** | 不绑交易数据,适合闲聊、答疑 |
|
||||
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
|
||||
| **会话历史** | 右侧列表:切换、删除;消息一键复制 |
|
||||
|
||||
页面仅保留 **交易教练 / 普通聊天** 两个机器人和聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。
|
||||
页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。
|
||||
|
||||
## 存储
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() ? "<th>浮盈</th>" : "";
|
||||
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";
|
||||
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
|
||||
? `<div class="ai-msg-attachments">${attList
|
||||
@@ -4129,13 +4222,18 @@
|
||||
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 !== "新对话"
|
||||
title.textContent =
|
||||
botMode === "supervisor"
|
||||
? sessionTitle || "今日监管"
|
||||
: sessionTitle && sessionTitle !== "新对话"
|
||||
? sessionTitle
|
||||
: modeLabel;
|
||||
} else {
|
||||
@@ -4150,21 +4248,24 @@
|
||||
const hint =
|
||||
botMode === "general"
|
||||
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。可粘贴截图或上传附件。"
|
||||
: botMode === "supervisor"
|
||||
? "今日监管为长会话:手动/中控开平仓与新开仓会自动推送;程序止盈止损会鼓励性提醒。可直接回复继续聊。"
|
||||
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。";
|
||||
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
|
||||
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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -648,11 +648,12 @@
|
||||
<div id="page-ai" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">AI</span> 教练</h1>
|
||||
<p class="page-desc">交易教练 / 普通聊天 · 右侧可回看历史会话</p>
|
||||
<p class="page-desc">交易教练 / 普通聊天 / 交易监管 · 右侧可回看历史会话</p>
|
||||
</div>
|
||||
<div class="ai-mobile-tabs" role="tablist" aria-label="AI 教练视图">
|
||||
<button type="button" class="ai-mobile-tab is-active" data-ai-tab="trading" role="tab" aria-selected="true">交易教练</button>
|
||||
<button type="button" class="ai-mobile-tab" data-ai-tab="general" role="tab" aria-selected="false">普通聊天</button>
|
||||
<button type="button" class="ai-mobile-tab" data-ai-tab="supervisor" role="tab" aria-selected="false">交易监管</button>
|
||||
<button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button>
|
||||
<button type="button" class="ai-mobile-tab ai-mobile-tab-action" data-ai-tab="new" role="tab" aria-selected="false" title="新开对话">新开</button>
|
||||
</div>
|
||||
@@ -662,6 +663,7 @@
|
||||
<div class="ai-bot-bar" role="tablist" aria-label="聊天机器人">
|
||||
<button type="button" class="ai-bot-tab is-active" data-bot="trading" role="tab" aria-selected="true">交易教练</button>
|
||||
<button type="button" class="ai-bot-tab" data-bot="general" role="tab" aria-selected="false">普通聊天</button>
|
||||
<button type="button" class="ai-bot-tab" data-bot="supervisor" role="tab" aria-selected="false">交易监管</button>
|
||||
</div>
|
||||
<button type="button" id="btn-ai-chat-new" class="primary ai-chat-new-btn">新开对话</button>
|
||||
</div>
|
||||
@@ -902,6 +904,50 @@
|
||||
</form>
|
||||
<div id="macro-event-list" class="macro-event-list"></div>
|
||||
</div>
|
||||
<div class="settings-supervisor-panel card">
|
||||
<h3 class="settings-display-title">交易监管 · 企业微信</h3>
|
||||
<p class="settings-display-hint">
|
||||
与四所实例策略通知独立;手动/中控开平仓与新开仓会推送至此 Webhook。链接可在下方单独修改。
|
||||
</p>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="supervisor-enabled" checked />
|
||||
启用交易监管推送
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="supervisor-wechat-program" checked />
|
||||
程序止盈/止损也发微信(鼓励向)
|
||||
</label>
|
||||
<div class="settings-grid supervisor-settings-grid">
|
||||
<div class="field field-wide">
|
||||
<label>企业微信 Webhook</label>
|
||||
<input id="supervisor-wechat-webhook" type="text" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>微信消息跳转链接(可改)</label>
|
||||
<input id="supervisor-wechat-link" type="text" placeholder="https://你的域名/ai?mode=supervisor" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>消息前缀</label>
|
||||
<input id="supervisor-wechat-prefix" type="text" value="【交易监管】" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>日手动平警告阈值</label>
|
||||
<input id="supervisor-daily-warn" type="number" min="1" step="1" value="2" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>最短两笔间隔(分钟)</label>
|
||||
<input id="supervisor-interval-warn" type="number" min="1" step="1" value="15" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>30 分钟内笔数阈值</label>
|
||||
<input id="supervisor-freq-30m" type="number" min="1" step="1" value="2" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>平仓后再开仓(分钟)</label>
|
||||
<input id="supervisor-reopen-min" type="number" min="1" step="1" value="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-settings-save" class="primary">保存设置</button>
|
||||
<button type="button" id="btn-settings-add">添加交易所</button>
|
||||
|
||||
@@ -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 进程名)。
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user