feat(hub-ai): paste screenshots in chat and include position TP/SL in coach context

Let users paste images into AI chat with removable pending attachments, and feed exchange/monitor stop-loss and take-profit into trading coach snapshots so replies reflect actual protection on open positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-14 01:53:24 +08:00
parent 42c06c0f38
commit 28a23008f3
5 changed files with 389 additions and 45 deletions
+202 -18
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
from datetime import datetime, timedelta
from typing import Any, Optional
@@ -188,6 +189,179 @@ def _format_monitor_sections(hub_mon: Optional[dict]) -> dict[str, list[str]]:
return out
_SL_TP_COMBO_RE = re.compile(r"SL=([\d.eE+-]+).*TP=([\d.eE+-]+)", re.I)
def _norm_symbol(sym: str) -> str:
s = (sym or "").strip().upper()
if "/" in s:
s = s.split(":")[0].split("/")[0]
return s
def _symbols_match(a: str, b: str) -> bool:
na, nb = _norm_symbol(a), _norm_symbol(b)
return bool(na and nb and na == nb)
def _pick_tpsl_from_cond(cond: list) -> tuple[Optional[float], Optional[float]]:
sl = tp = None
if not cond:
return sl, tp
sl_o = tp_o = combo = None
for o in cond:
if not isinstance(o, dict):
continue
lbl = str(o.get("label") or "")
if "止盈止损" in lbl:
combo = o
elif lbl.startswith("止损"):
sl_o = o
elif lbl.startswith("止盈"):
tp_o = o
if combo:
lbl = str(combo.get("label") or "")
m = _SL_TP_COMBO_RE.search(lbl)
if m:
sl = _safe_float(m.group(1))
tp = _safe_float(m.group(2))
if sl_o and sl is None:
sl = _safe_float(sl_o.get("trigger_price"))
if tp_o and tp is None:
tp = _safe_float(tp_o.get("trigger_price"))
if sl is None:
for o in cond:
if not isinstance(o, dict):
continue
lbl = str(o.get("label") or "")
if "止损" in lbl and "止盈止损" not in lbl:
sl = _safe_float(o.get("trigger_price"))
if sl is not None:
break
if tp is None:
for o in cond:
if not isinstance(o, dict):
continue
lbl = str(o.get("label") or "")
if lbl.startswith("止盈") or ("止盈" in lbl and "止盈止损" not in lbl):
tp = _safe_float(o.get("trigger_price"))
if tp is not None:
break
return sl, tp
def _pick_tpsl_from_exchange_tpsl(et: Any) -> tuple[Optional[float], Optional[float]]:
if not isinstance(et, dict):
return None, None
sl = tp = None
slot_sl = et.get("sl")
slot_tp = et.get("tp")
if isinstance(slot_sl, dict):
sl = _safe_float(slot_sl.get("trigger_price"))
if isinstance(slot_tp, dict):
tp = _safe_float(slot_tp.get("trigger_price"))
return sl, tp
def _find_plan_tpsl_for_position(
symbol: str,
side: str,
hub_mon: Optional[dict],
) -> tuple[Optional[float], Optional[float], bool]:
"""匹配本地监控/趋势计划:sl, tp, tp_is_program_monitored。"""
if not isinstance(hub_mon, dict):
return None, None, False
side_l = (side or "").lower()
for o in hub_mon.get("orders") or []:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
if (o.get("direction") or "").lower() != side_l:
continue
return (
_safe_float(o.get("stop_loss")),
_safe_float(o.get("take_profit")),
False,
)
for t in hub_mon.get("trends") or []:
if not isinstance(t, dict):
continue
if not _symbols_match(symbol, t.get("symbol") or ""):
continue
if (t.get("direction") or "").lower() != side_l:
continue
plan_tp = t.get("take_profit")
tp = _safe_float(plan_tp) if plan_tp not in (None, "") else None
return _safe_float(t.get("stop_loss")), tp, tp is None
return None, None, False
def _resolve_position_tpsl(pos: dict, hub_mon: Optional[dict]) -> dict[str, Any]:
cond = pos.get("conditional_orders") or []
cond_sl, cond_tp = _pick_tpsl_from_cond(cond)
et_sl, et_tp = _pick_tpsl_from_exchange_tpsl(pos.get("exchange_tpsl"))
plan_sl, plan_tp, tp_monitored = _find_plan_tpsl_for_position(
str(pos.get("symbol") or ""),
str(pos.get("side") or ""),
hub_mon,
)
sl = cond_sl if cond_sl is not None else et_sl if et_sl is not None else plan_sl
tp_note = ""
tp: Optional[float] = None
if tp_monitored and cond_tp is None and et_tp is None:
tp_note = "程序监控"
else:
tp = cond_tp if cond_tp is not None else et_tp if et_tp is not None else plan_tp
if sl is not None and tp is not None and sl == tp:
tp = None
return {"sl": sl, "tp": tp, "tp_note": tp_note}
def _format_position_detail_line(pos: dict, hub_mon: Optional[dict]) -> str:
sym = pos.get("symbol") or "?"
side = pos.get("side") or "?"
contracts = pos.get("contracts") or pos.get("size") or "?"
upnl = _position_float_pnl(pos)
entry = _safe_float(pos.get("entry_price"))
tpsl = _resolve_position_tpsl(pos, hub_mon)
parts = [f"{sym} {side} 张数{contracts}"]
if entry is not None:
parts.append(f"入场{entry:g}")
if tpsl["sl"] is not None:
parts.append(f"止损{tpsl['sl']:g}")
else:
parts.append("止损=未检测到")
if tpsl["tp_note"]:
parts.append(f"止盈={tpsl['tp_note']}")
elif tpsl["tp"] is not None:
parts.append(f"止盈{tpsl['tp']:g}")
else:
parts.append("止盈=未检测到")
parts.append(f"浮盈亏{upnl:.4f}U")
return " - " + " ".join(parts)
def _enrich_positions_exchange_tpsl(
positions: list,
price_snap: Optional[dict],
hub_mon: Optional[dict],
) -> None:
if not positions:
return
try:
from hub import _merge_flask_exchange_tpsl
_merge_flask_exchange_tpsl(
{"agent": {"positions": positions}},
price_snap if isinstance(price_snap, dict) else None,
hub_mon if isinstance(hub_mon, dict) else None,
)
except Exception:
pass
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 ""
@@ -251,6 +425,7 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
base["float_pnl_u"] = round(sum(_position_float_pnl(p) for p in open_positions), 4)
hub_mon = None
price_snap = None
prev_day = previous_trading_day(trading_day)
if flask_url:
try:
@@ -322,6 +497,23 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
if "成交接口" not in str(base["issues"]):
base["issues"].append(f"监控接口: {exc}")
try:
r = client.get(
f"{flask_url}/api/price_snapshot",
headers=_hub_headers(),
timeout=hub_flask_timeout(),
)
if r.status_code == 200:
body = r.json()
if isinstance(body, dict):
price_snap = body
base["flask_ok"] = True
except Exception:
pass
if base["positions"]:
_enrich_positions_exchange_tpsl(base["positions"], price_snap, hub_mon)
if monitored and not base["agent_ok"] and not base["flask_ok"]:
base["status"] = "连接异常"
elif base["issues"]:
@@ -498,16 +690,13 @@ def format_context_text(payload: dict) -> str:
for row in mon["orders"][:8]:
lines.append(f" - {row}")
positions = ac.get("positions") or []
hub_mon = ac.get("hub_monitor")
if positions:
lines.append("持仓明细(交易所实盘):")
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")
lines.append(_format_position_detail_line(p, hub_mon))
lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
)
@@ -589,16 +778,13 @@ def format_summary_context_text(payload: dict) -> str:
for row in mon["orders"][:8]:
lines.append(f" - {row}")
positions = ac.get("positions") or []
hub_mon = ac.get("hub_monitor")
if positions:
lines.append("持仓明细(交易所实盘):")
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")
lines.append(_format_position_detail_line(p, hub_mon))
lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
)
@@ -714,7 +900,7 @@ def format_chat_position_overview(payload: dict) -> str:
)
lines = [
head,
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。",
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位。",
]
for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控":
@@ -748,7 +934,7 @@ def format_chat_context_slim(payload: dict) -> str:
f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')}| "
f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | 浮盈亏 {totals.get('float_pnl_u')}U",
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓。",
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓。持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致)。",
]
for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控":
@@ -780,13 +966,11 @@ def format_chat_context_slim(payload: dict) -> str:
if len(trades) > 4:
lines.append(f" · …共{len(trades)}笔今日平仓")
positions = ac.get("positions") or []
hub_mon = ac.get("hub_monitor")
for p in positions[:4]:
if not isinstance(p, dict):
continue
sym = p.get("symbol") or "?"
side = p.get("side") or "?"
upnl = _position_float_pnl(p)
lines.append(f" · 持仓 {sym} {side} 浮盈亏{upnl:.4f}U")
lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}")
return "\n".join(lines)
+1
View File
@@ -51,6 +51,7 @@ CHAT_SYSTEM = """
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。
- **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态、悔单、某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字。
- **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行。
- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」。仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损。趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈。
- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明。
""".strip()