diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index 67f4763..e176931 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -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) diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py index c2912b0..d9ba396 100644 --- a/manual_trading_hub/hub_ai/prompts.py +++ b/manual_trading_hub/hub_ai/prompts.py @@ -51,6 +51,7 @@ CHAT_SYSTEM = """ - 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。 - **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态、悔单、某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字。 - **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行。 +- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」。仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损。趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈。 - 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明。 """.strip() diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index a95e23c..426dc72 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -4467,14 +4467,8 @@ body.hub-page-ai #page-ai { width: 100%; } - body.hub-page-ai .ai-chat-files-label { - flex: 1; - min-width: 0; - font-size: 0.68rem; - } - - body.hub-page-ai .ai-chat-files-label:empty { - display: none; + body.hub-page-ai .ai-chat-pending-list { + width: 100%; } body.hub-page-ai .ai-chat-upload-btn, @@ -5140,15 +5134,58 @@ body.hub-page-ai #page-ai { border-color: var(--accent); color: var(--accent); } -.ai-chat-files-label { - flex: 1; - min-width: 0; +.ai-chat-pending-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.ai-chat-pending-list[hidden] { + display: none; +} +.ai-chat-pending-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 2px 4px 2px 8px; + border-radius: 999px; font-size: 0.72rem; + color: var(--text); + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-chat-pending-kind { + flex-shrink: 0; + font-size: 0.65rem; color: var(--muted); +} +.ai-chat-pending-name { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ai-chat-pending-del { + flex-shrink: 0; + min-width: 22px; + min-height: 22px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 0.95rem; + line-height: 1; + cursor: pointer; +} +.ai-chat-pending-del:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); +} +.ai-chat-pending-del:disabled { + opacity: 0.45; + cursor: not-allowed; +} .ai-chat-form textarea { width: 100%; resize: none; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index db773f8..a101fd2 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3517,6 +3517,111 @@ let aiChatSessionCache = null; let aiChatSessionsCache = []; let aiSelectedBotMode = "trading"; + const AI_CHAT_MAX_ATTACHMENTS = 3; + let aiChatPendingFiles = []; + + function aiChatFileKind(file) { + return file && file.type && file.type.startsWith("image/") ? "image" : "text"; + } + + function isValidAiChatFile(file) { + if (!file) return false; + if (file.type && file.type.startsWith("image/")) return true; + const mime = (file.type || "").toLowerCase(); + if (["text/plain", "text/markdown", "application/json"].includes(mime)) return true; + const name = (file.name || "").toLowerCase(); + return ( + name.endsWith(".txt") || + name.endsWith(".md") || + name.endsWith(".markdown") || + name.endsWith(".json") + ); + } + + function syncAiChatFileInput() { + const fileInput = document.getElementById("ai-chat-files"); + if (!fileInput || typeof DataTransfer === "undefined") return; + const dt = new DataTransfer(); + aiChatPendingFiles.forEach((f) => dt.items.add(f)); + fileInput.files = dt.files; + } + + function renderAiChatPendingAttachments() { + const box = document.getElementById("ai-chat-pending"); + if (!box) return; + if (!aiChatPendingFiles.length) { + box.innerHTML = ""; + box.hidden = true; + return; + } + box.hidden = false; + box.innerHTML = aiChatPendingFiles + .map((f, idx) => { + const kind = aiChatFileKind(f); + const icon = kind === "image" ? "图" : "文"; + return ( + `` + + `${icon}` + + `${esc(f.name || "附件")}` + + `` + + `` + ); + }) + .join(""); + } + + function addAiChatPendingFiles(files) { + const incoming = Array.isArray(files) ? files : []; + if (!incoming.length) return; + let added = 0; + for (const file of incoming) { + if (aiChatPendingFiles.length >= AI_CHAT_MAX_ATTACHMENTS) { + showToast(`最多 ${AI_CHAT_MAX_ATTACHMENTS} 个附件`, true); + break; + } + if (!isValidAiChatFile(file)) { + showToast(`${file.name || "文件"}: 不支持的类型(仅图片或 txt/md/json)`, true); + continue; + } + aiChatPendingFiles.push(file); + added += 1; + } + if (!added) return; + syncAiChatFileInput(); + renderAiChatPendingAttachments(); + } + + function removeAiChatPendingFile(index) { + if (index < 0 || index >= aiChatPendingFiles.length) return; + aiChatPendingFiles.splice(index, 1); + syncAiChatFileInput(); + renderAiChatPendingAttachments(); + } + + function clearAiChatPendingFiles() { + aiChatPendingFiles = []; + syncAiChatFileInput(); + renderAiChatPendingAttachments(); + } + + function handleAiChatPaste(ev) { + if (aiChatLoading) return; + const clipboard = ev.clipboardData; + if (!clipboard || !clipboard.items) return; + const imageFiles = []; + for (const item of clipboard.items) { + if (!item.type || !item.type.startsWith("image/")) continue; + const blob = item.getAsFile(); + if (!blob) continue; + const sub = (item.type.split("/")[1] || "png").toLowerCase(); + const ext = sub === "jpeg" ? "jpg" : sub; + const name = `screenshot-${Date.now()}.${ext}`; + imageFiles.push(new File([blob], name, { type: item.type })); + } + if (!imageFiles.length) return; + ev.preventDefault(); + addAiChatPendingFiles(imageFiles); + } function renderHubMarkdown(text) { const raw = String(text || ""); @@ -3558,8 +3663,8 @@ if (input) { input.placeholder = m === "general" - ? "随便聊点什么,不绑交易数据…" - : "聊聊行情、心态、纪律、执行…"; + ? "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图" + : "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图"; } } @@ -3658,8 +3763,8 @@ if (showPlaceholder) { const hint = botMode === "general" - ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。" - : "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片或文档。"; + ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。可粘贴截图或上传附件。" + : "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。"; box.innerHTML = `

${hint}

`; return; } @@ -3690,6 +3795,9 @@ const input = document.getElementById("ai-chat-input"); if (btn) btn.disabled = busy; if (input) input.disabled = busy; + document.querySelectorAll(".ai-chat-pending-del").forEach((el) => { + el.disabled = busy; + }); } async function loadAiChatSession() { @@ -3857,12 +3965,13 @@ if (ev) ev.preventDefault(); if (aiChatLoading) return; const input = document.getElementById("ai-chat-input"); - const fileInput = document.getElementById("ai-chat-files"); - const fileLabel = document.getElementById("ai-chat-files-label"); const text = (input && input.value || "").trim(); - const files = fileInput && fileInput.files ? Array.from(fileInput.files) : []; + const files = aiChatPendingFiles.slice(); if (!text && !files.length) return; - const pendingAttachments = files.map((f) => ({ name: f.name, kind: f.type.startsWith("image/") ? "image" : "text" })); + const pendingAttachments = files.map((f) => ({ + name: f.name, + kind: aiChatFileKind(f), + })); const savedText = text; if (input) input.value = ""; setAiChatBusy(true); @@ -3882,8 +3991,7 @@ aiChatSessionsCache = j.sessions || aiChatSessionsCache; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); - if (fileInput) fileInput.value = ""; - if (fileLabel) fileLabel.textContent = ""; + clearAiChatPendingFiles(); if (j.attachment_warnings && j.attachment_warnings.length) { showToast(j.attachment_warnings.join(";"), true); } @@ -3901,11 +4009,25 @@ } const aiChatFiles = document.getElementById("ai-chat-files"); - const aiChatFilesLabel = document.getElementById("ai-chat-files-label"); - if (aiChatFiles && aiChatFilesLabel) { + if (aiChatFiles) { aiChatFiles.addEventListener("change", () => { - const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : []; - aiChatFilesLabel.textContent = names.length ? names.join("、") : ""; + const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : []; + addAiChatPendingFiles(picked); + aiChatFiles.value = ""; + }); + } + const aiChatInput = document.getElementById("ai-chat-input"); + if (aiChatInput) { + aiChatInput.addEventListener("paste", handleAiChatPaste); + } + const aiChatPending = document.getElementById("ai-chat-pending"); + if (aiChatPending) { + aiChatPending.addEventListener("click", (ev) => { + const btn = ev.target.closest("[data-pending-del]"); + if (!btn || aiChatLoading) return; + ev.preventDefault(); + const idx = Number(btn.getAttribute("data-pending-del")); + if (!Number.isNaN(idx)) removeAiChatPendingFile(idx); }); } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 3c46877..7cc4b17 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -561,13 +561,13 @@
- + +
-