feat(hub): add AI coach page with daily summary and chat
Aggregate four-account trades via hub_ai module and /api/hub/trades/today; store sessions in JSON; default OpenAI config matches instances. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"""中控 AI:四户数据聚合为结构化上下文。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hub_ai.config import hub_agent_timeout, hub_flask_timeout, trading_day_reset_hour
|
||||
from hub_trades_lib import current_trading_day, summarize_trades
|
||||
|
||||
|
||||
def _hub_token() -> str:
|
||||
return (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def _hub_headers() -> dict[str, str]:
|
||||
tok = _hub_token()
|
||||
return {"X-Hub-Token": tok} if tok else {}
|
||||
|
||||
|
||||
def _agent_headers() -> dict[str, str]:
|
||||
tok = (os.getenv("CONTROL_TOKEN") or os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
|
||||
return {"X-Control-Token": tok} if tok else {}
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _position_float_pnl(pos: dict) -> float:
|
||||
for key in ("unrealized_pnl", "unrealizedPnl", "upnl"):
|
||||
v = _safe_float(pos.get(key))
|
||||
if v is not None:
|
||||
return v
|
||||
return 0.0
|
||||
|
||||
|
||||
def _collect_open_issues(
|
||||
*,
|
||||
monitored: bool,
|
||||
agent_ok: bool,
|
||||
flask_ok: bool,
|
||||
positions: list,
|
||||
hub_mon: Optional[dict],
|
||||
day_pnl: float,
|
||||
) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if not monitored:
|
||||
return issues
|
||||
if not agent_ok:
|
||||
issues.append("Agent 连接异常")
|
||||
if not flask_ok:
|
||||
issues.append("Flask 监控连接异常")
|
||||
if day_pnl < -0.01:
|
||||
issues.append(f"当日平仓亏损 {day_pnl:.2f}U")
|
||||
float_pnl = sum(_position_float_pnl(p) for p in positions if isinstance(p, dict))
|
||||
if float_pnl < -0.5:
|
||||
issues.append(f"当前浮亏 {float_pnl:.2f}U")
|
||||
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
|
||||
orders = hub_mon.get("orders") or []
|
||||
trends = hub_mon.get("trends") or []
|
||||
if positions and not orders and not trends:
|
||||
issues.append("交易所有持仓但无本地 active 监控/趋势计划")
|
||||
return issues
|
||||
|
||||
|
||||
def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> dict[str, Any]:
|
||||
name = ex.get("name") or ex.get("key") or ex.get("id")
|
||||
key = ex.get("key") or ""
|
||||
enabled = bool(ex.get("enabled"))
|
||||
env_disabled = bool(ex.get("env_disabled"))
|
||||
monitored = enabled and not env_disabled
|
||||
|
||||
base: dict[str, Any] = {
|
||||
"id": ex.get("id"),
|
||||
"key": key,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"env_disabled": env_disabled,
|
||||
"status": "未监控" if not monitored else "已监控",
|
||||
"trades": [],
|
||||
"trade_stats": summarize_trades([]),
|
||||
"positions": [],
|
||||
"float_pnl_u": 0.0,
|
||||
"balance_usdt": None,
|
||||
"issues": [],
|
||||
"agent_ok": False,
|
||||
"flask_ok": False,
|
||||
"hub_monitor": None,
|
||||
"active_orders": 0,
|
||||
"active_trends": 0,
|
||||
}
|
||||
if not monitored:
|
||||
base["issues"] = []
|
||||
return base
|
||||
|
||||
agent_url = (ex.get("agent_url") or "").rstrip("/")
|
||||
flask_url = (ex.get("flask_url") or "").rstrip("/")
|
||||
agent_body = None
|
||||
if agent_url:
|
||||
try:
|
||||
r = client.get(
|
||||
f"{agent_url}/status",
|
||||
headers=_agent_headers(),
|
||||
timeout=hub_agent_timeout(),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
agent_body = r.json()
|
||||
base["agent_ok"] = True
|
||||
except Exception as exc:
|
||||
base["issues"].append(f"Agent: {exc}")
|
||||
|
||||
if isinstance(agent_body, dict):
|
||||
base["balance_usdt"] = _safe_float(agent_body.get("balance_usdt"))
|
||||
positions = agent_body.get("positions") or []
|
||||
if isinstance(positions, list):
|
||||
base["positions"] = positions
|
||||
base["float_pnl_u"] = round(
|
||||
sum(_position_float_pnl(p) for p in positions if isinstance(p, dict)), 4
|
||||
)
|
||||
|
||||
hub_mon = None
|
||||
if flask_url:
|
||||
try:
|
||||
r = client.get(
|
||||
f"{flask_url}/api/hub/trades/today",
|
||||
headers=_hub_headers(),
|
||||
params={"trading_day": trading_day},
|
||||
timeout=hub_flask_timeout(),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
trades_body = r.json()
|
||||
if isinstance(trades_body, dict) and trades_body.get("ok"):
|
||||
base["trades"] = trades_body.get("trades") or []
|
||||
base["trade_stats"] = trades_body.get("stats") or summarize_trades(base["trades"])
|
||||
base["flask_ok"] = True
|
||||
except Exception as exc:
|
||||
base["issues"].append(f"成交接口: {exc}")
|
||||
|
||||
try:
|
||||
r = client.get(
|
||||
f"{flask_url}/api/hub/monitor",
|
||||
headers=_hub_headers(),
|
||||
timeout=hub_flask_timeout(),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
hub_mon = r.json()
|
||||
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
|
||||
base["hub_monitor"] = hub_mon
|
||||
base["flask_ok"] = True
|
||||
base["active_orders"] = len(hub_mon.get("orders") or [])
|
||||
base["active_trends"] = len(hub_mon.get("trends") or [])
|
||||
except Exception as exc:
|
||||
if "成交接口" not in str(base["issues"]):
|
||||
base["issues"].append(f"监控接口: {exc}")
|
||||
|
||||
if monitored and not base["agent_ok"] and not base["flask_ok"]:
|
||||
base["status"] = "连接异常"
|
||||
elif base["issues"]:
|
||||
base["status"] = "已监控·需关注"
|
||||
|
||||
day_pnl = float((base.get("trade_stats") or {}).get("total_pnl_u") or 0)
|
||||
base["issues"].extend(
|
||||
_collect_open_issues(
|
||||
monitored=monitored,
|
||||
agent_ok=base["agent_ok"],
|
||||
flask_ok=base["flask_ok"],
|
||||
positions=base["positions"],
|
||||
hub_mon=hub_mon if isinstance(hub_mon, dict) else None,
|
||||
day_pnl=day_pnl,
|
||||
)
|
||||
)
|
||||
base["issues"] = list(dict.fromkeys(base["issues"]))
|
||||
return base
|
||||
|
||||
|
||||
def build_daily_context(
|
||||
exchanges: list[dict],
|
||||
*,
|
||||
trading_day: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
day = (trading_day or "").strip()[:10] or current_trading_day(
|
||||
reset_hour=trading_day_reset_hour()
|
||||
)
|
||||
accounts: list[dict] = []
|
||||
with httpx.Client() as client:
|
||||
for ex in exchanges or []:
|
||||
accounts.append(_fetch_account_bundle(client, ex, day))
|
||||
|
||||
total_closed_pnl = 0.0
|
||||
total_closed = total_win = total_loss = 0
|
||||
total_float = 0.0
|
||||
for ac in accounts:
|
||||
if ac.get("status") == "未监控":
|
||||
continue
|
||||
st = ac.get("trade_stats") or {}
|
||||
total_closed_pnl += float(st.get("total_pnl_u") or 0)
|
||||
total_closed += int(st.get("closed_count") or 0)
|
||||
total_win += int(st.get("win_count") or 0)
|
||||
total_loss += int(st.get("loss_count") or 0)
|
||||
total_float += float(ac.get("float_pnl_u") or 0)
|
||||
|
||||
totals = {
|
||||
"trading_day": day,
|
||||
"total_pnl_u": round(total_closed_pnl, 4),
|
||||
"closed_count": total_closed,
|
||||
"win_count": total_win,
|
||||
"loss_count": total_loss,
|
||||
"float_pnl_u": round(total_float, 4),
|
||||
}
|
||||
payload = {"trading_day": day, "totals": totals, "accounts": accounts}
|
||||
text = format_context_text(payload)
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
return {"trading_day": day, "totals": totals, "accounts": accounts, "text": text, "context_hash": digest}
|
||||
|
||||
|
||||
def format_context_text(payload: dict) -> str:
|
||||
lines = []
|
||||
totals = payload.get("totals") or {}
|
||||
lines.append(
|
||||
f"【合计】交易日 {totals.get('trading_day')} | "
|
||||
f"平仓盈亏 {totals.get('total_pnl_u')}U | "
|
||||
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
|
||||
f"监控户浮盈亏合计 {totals.get('float_pnl_u')}U"
|
||||
)
|
||||
lines.append("")
|
||||
for ac in payload.get("accounts") or []:
|
||||
st = ac.get("trade_stats") or {}
|
||||
lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---")
|
||||
lines.append(f"状态:{ac.get('status')}")
|
||||
if ac.get("status") == "未监控":
|
||||
lines.append("")
|
||||
continue
|
||||
lines.append(
|
||||
f"当日平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U "
|
||||
f"(胜{st.get('win_count')}/负{st.get('loss_count')})"
|
||||
)
|
||||
lines.append(f"合约可用余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT")
|
||||
lines.append(f"当前持仓浮盈亏:{ac.get('float_pnl_u')}U | 下单监控 {ac.get('active_orders')} | 趋势计划 {ac.get('active_trends')}")
|
||||
positions = ac.get("positions") or []
|
||||
if positions:
|
||||
lines.append("持仓:")
|
||||
for p in positions[:8]:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
sym = p.get("symbol") or "?"
|
||||
side = p.get("side") or "?"
|
||||
contracts = p.get("contracts") or p.get("size") or "?"
|
||||
upnl = _position_float_pnl(p)
|
||||
lines.append(f" - {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
|
||||
trades = ac.get("trades") or []
|
||||
if trades:
|
||||
lines.append("当日平仓明细:")
|
||||
for t in trades[:15]:
|
||||
lines.append(
|
||||
f" - {t.get('symbol')} {t.get('direction')} {t.get('result')} "
|
||||
f"{t.get('pnl_amount')}U @ {t.get('closed_at') or '?'}"
|
||||
)
|
||||
issues = ac.get("issues") or []
|
||||
if issues:
|
||||
lines.append("关注点:" + ";".join(issues))
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def format_chat_context_brief(payload: dict, max_chars: int = 2500) -> str:
|
||||
text = format_context_text(payload)
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[: max_chars - 3].rstrip() + "..."
|
||||
Reference in New Issue
Block a user