From 58e9c8f85e2078307311419d54e2ac073e737f6a Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 22 Jul 2026 23:28:16 +0800 Subject: [PATCH] Feed options positions and playbook brief into trading coach. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coach context previously omitted options_snapshot details; also inject a short 执行手册 summary each turn. Co-authored-by: Cursor --- manual_trading_hub/AI教练说明.md | 2 +- manual_trading_hub/hub_ai/chat.py | 6 + manual_trading_hub/hub_ai/context.py | 124 ++++++++++++++++++-- manual_trading_hub/hub_ai/playbook_brief.py | 28 +++++ manual_trading_hub/hub_ai/prompts.py | 12 +- tests/test_hub_ai_options_playbook.py | 97 +++++++++++++++ 6 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 manual_trading_hub/hub_ai/playbook_brief.py create mode 100644 tests/test_hub_ai_options_playbook.py diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md index 12732b7..edba636 100644 --- a/manual_trading_hub/AI教练说明.md +++ b/manual_trading_hub/AI教练说明.md @@ -6,7 +6,7 @@ | 功能 | 说明 | |------|------| -| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) | +| **交易教练** | 口语化陪聊;注入三户监控快照(**含 OKX 期权持仓明细**)、执行手册短摘要与今日总结摘要(后台自动生成,不在页面展示) | | **普通聊天** | 不绑交易数据,适合闲聊,答疑 | | **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) | | **会话历史** | 右侧列表:切换,删除;消息一键复制 | diff --git a/manual_trading_hub/hub_ai/chat.py b/manual_trading_hub/hub_ai/chat.py index a49a213..1923504 100644 --- a/manual_trading_hub/hub_ai/chat.py +++ b/manual_trading_hub/hub_ai/chat.py @@ -25,6 +25,7 @@ from hub_ai.context import ( format_chat_context_for_chat, format_chat_position_overview, ) +from hub_ai.playbook_brief import format_playbook_brief_for_chat from hub_ai.prompts import ( CHAT_GENERAL_SYSTEM, CHAT_SYSTEM, @@ -217,6 +218,10 @@ def send_chat_message( ctx = build_chat_context(exchanges, trading_day=day) day = ctx["trading_day"] brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count) + # 首轮带完整手册摘要;续聊缩短,避免挤占对话上下文 + playbook = format_playbook_brief_for_chat( + max_chars=1200 if prior_count <= 0 else 700 + ) user_prompt = build_chat_user_prompt( context_text=brief_ctx, trading_day=day, @@ -225,6 +230,7 @@ def send_chat_message( history_lines=history_tail, user_message=user_for_prompt, attachment_note=str(parsed.get("attachment_note") or ""), + playbook_brief=playbook, ) if parsed.get("text_append"): user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000) diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index f7e9ce0..4141778 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -86,7 +86,81 @@ def _filter_open_positions(positions: list) -> list[dict]: def _account_open_position_count(ac: dict) -> int: - return len(_filter_open_positions(ac.get("positions") or [])) + perp = len(_filter_open_positions(ac.get("positions") or [])) + opt = int(ac.get("options_open_position_count") or 0) + if opt <= 0: + opt = len(_iter_options_position_dicts(ac)) + return perp + opt + + +def _iter_options_position_dicts(ac: dict) -> list[dict]: + snap = ac.get("options_snapshot") + if not isinstance(snap, dict): + return [] + if snap.get("ok") is False or snap.get("enabled") is False: + return [] + out: list[dict] = [] + for p in snap.get("positions") or []: + if not isinstance(p, dict): + continue + inst = str(p.get("inst_id") or p.get("instId") or "").strip() + if not inst: + continue + out.append(p) + return out + + +def _format_options_position_detail_line(p: dict) -> str: + inst = p.get("inst_id") or p.get("instId") or "?" + opt_type = (p.get("opt_type") or p.get("optType") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") + src = _options_source_label(p) + sheets = p.get("pos") + if sheets is None: + sheets = p.get("sheets") + if sheets is None: + sheets = p.get("contracts") + if sheets is None: + sheets = "?" + parts = [f"期权 {inst} {label}", f"来源{src}", f"张数{sheets}"] + paid = _safe_float(p.get("premium_paid")) + if paid is not None: + parts.append(f"权利金{paid:g}U") + net: Optional[float] = None + try: + from lib.options.options_positions_lib import net_pnl_from_display_row + + net = net_pnl_from_display_row(p) + except Exception: + net = None + if net is None: + net = _safe_float(p.get("net_pnl")) + if net is None: + net = _safe_float(p.get("upl")) + if net is not None: + parts.append(f"净盈亏{net:.4f}U") + tgt = _options_target_monitor_text(p) + if tgt and tgt not in ("—", "-", ""): + parts.append(f"目标{tgt}") + return " - " + " ".join(parts) + + +def _append_options_position_lines(lines: list[str], ac: dict, *, limit: int = 6, indent: str = " - ") -> None: + rows = _iter_options_position_dicts(ac) + if not rows: + return + if indent.startswith(" "): + # chat slim: already under account bullet + for p in rows[:limit]: + lines.append(f" · {_format_options_position_detail_line(p).lstrip(' - ')}") + if len(rows) > limit: + lines.append(f" · …共{len(rows)}笔期权持仓") + return + lines.append("期权持仓明细(交易所实盘,含目标位若已挂):") + for p in rows[:limit]: + lines.append(_format_options_position_detail_line(p)) + if len(rows) > limit: + lines.append(f" - …共{len(rows)}笔期权持仓") def _monitor_counts(ac: dict) -> dict[str, int]: @@ -788,7 +862,9 @@ def format_context_text(payload: dict) -> str: lines.append( 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)} 仓 | " + f"实盘持仓 {totals.get('open_position_count', 0)} 仓" + f"(永续{totals.get('perpetual_open_position_count', totals.get('open_position_count', 0))}/" + f"期权{totals.get('options_open_position_count', 0)}) | " f"浮盈亏 {totals.get('float_pnl_u')}U | " f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | " f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}" @@ -855,6 +931,7 @@ def format_context_text(payload: dict) -> str: if not isinstance(p, dict): continue lines.append(_format_position_detail_line(p, hub_mon)) + _append_options_position_lines(lines, ac, limit=8) lines.append( f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" ) @@ -885,7 +962,9 @@ def format_summary_context_text(payload: dict) -> str: lines.append( 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)} 仓 | " + f"实盘持仓 {totals.get('open_position_count', 0)} 仓" + f"(永续{totals.get('perpetual_open_position_count', totals.get('open_position_count', 0))}/" + f"期权{totals.get('options_open_position_count', 0)}) | " f"浮盈亏 {totals.get('float_pnl_u')}U | " f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | " f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}" @@ -943,6 +1022,7 @@ def format_summary_context_text(payload: dict) -> str: if not isinstance(p, dict): continue lines.append(_format_position_detail_line(p, hub_mon)) + _append_options_position_lines(lines, ac, limit=8) lines.append( f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" ) @@ -1289,21 +1369,30 @@ def collect_closed_trades_snapshot( def format_chat_position_overview(payload: dict) -> str: totals = payload.get("totals") or {} total_open = int(totals.get("open_position_count") or 0) + opt_total = int(totals.get("options_open_position_count") or 0) + perp_total = int( + totals.get("perpetual_open_position_count") + if totals.get("perpetual_open_position_count") is not None + else max(0, total_open - opt_total) + ) if total_open <= 0: head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓).浮盈亏 0U 表示无持仓,不是「有仓但不动」." else: head = ( - f"【实盘持仓总览】监控户合计 {total_open} 仓," + f"【实盘持仓总览】监控户合计 {total_open} 仓" + f"(永续{perp_total}/期权{opt_total})," f"浮盈亏合计 {totals.get('float_pnl_u')}U." ) lines = [ head, - "【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.", + "【区分】只有带「持仓明细/交易所实盘/期权持仓」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.", ] for ac in payload.get("accounts") or []: if ac.get("status") == "未监控": continue n = int(ac.get("open_position_count") or _account_open_position_count(ac)) + opt_n = int(ac.get("options_open_position_count") or len(_iter_options_position_dicts(ac))) + perp_n = len(_filter_open_positions(ac.get("positions") or [])) mc = _monitor_counts(ac) mon_parts = [] if mc["trends"]: @@ -1319,8 +1408,11 @@ def format_chat_position_overview(payload: dict) -> str: lines.append(f"- {ac.get('name')}:空仓{mon_txt}") else: lines.append( - f"- {ac.get('name')}:{n}仓 浮盈亏{ac.get('float_pnl_u')}U{mon_txt}" + f"- {ac.get('name')}:{n}仓(永续{perp_n}/期权{opt_n}) " + f"浮盈亏{ac.get('float_pnl_u')}U{mon_txt}" ) + for p in _iter_options_position_dicts(ac)[:4]: + lines.append(f" · {_format_options_position_detail_line(p).lstrip(' - ')}") return "\n".join(lines) @@ -1328,11 +1420,19 @@ def format_chat_context_slim(payload: dict) -> str: """聊天专用:不含 180 日资金曲线与昨日平仓明细,避免挤占对话上下文.""" totals = payload.get("totals") or {} day = totals.get("trading_day") + opt_total = int(totals.get("options_open_position_count") or 0) + perp_total = int( + totals.get("perpetual_open_position_count") + if totals.get("perpetual_open_position_count") is not None + else max(0, int(totals.get("open_position_count") or 0) - opt_total) + ) lines = [ 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", - "【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓.持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致).", + f"实盘持仓 {totals.get('open_position_count', 0)} 仓" + f"(永续{perp_total}/期权{opt_total}) | 浮盈亏 {totals.get('float_pnl_u')}U", + "【说明】持仓=交易所实盘(含期权);趋势/关键位/监控单=本地计划,不等于已开仓." + "永续行「止损/止盈」=条件单或监控计划价;期权行含合约/来源/权利金/净盈亏/目标位.", ] for ac in payload.get("accounts") or []: if ac.get("status") == "未监控": @@ -1340,7 +1440,12 @@ def format_chat_context_slim(payload: dict) -> str: continue st = ac.get("trade_stats") or {} open_n = int(ac.get("open_position_count") or _account_open_position_count(ac)) - pos_txt = "空仓" if open_n <= 0 else f"{open_n}仓 浮盈亏{ac.get('float_pnl_u')}U" + opt_n = int(ac.get("options_open_position_count") or len(_iter_options_position_dicts(ac))) + perp_n = len(_filter_open_positions(ac.get("positions") or [])) + if open_n <= 0: + pos_txt = "空仓" + else: + pos_txt = f"{open_n}仓(永续{perp_n}/期权{opt_n}) 浮盈亏{ac.get('float_pnl_u')}U" mc = _monitor_counts(ac) mon = [] if mc["trends"]: @@ -1369,6 +1474,7 @@ def format_chat_context_slim(payload: dict) -> str: if not isinstance(p, dict): continue lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}") + _append_options_position_lines(lines, ac, limit=6, indent=" · ") return "\n".join(lines) diff --git a/manual_trading_hub/hub_ai/playbook_brief.py b/manual_trading_hub/hub_ai/playbook_brief.py new file mode 100644 index 0000000..a47a38d --- /dev/null +++ b/manual_trading_hub/hub_ai/playbook_brief.py @@ -0,0 +1,28 @@ +"""交易教练用的执行手册短摘要(来源 docs/交易执行手册-期权与Gate.md).""" +from __future__ import annotations + +from pathlib import Path + +from lib.paths import REPO_ROOT + +# 控制 token:保持简短;手册大改时同步修订本摘要. +_PLAYBOOK_BRIEF = """【用户策略执行手册·摘要】(来源:docs/交易执行手册-期权与Gate.md) +一句话:横盘对冲(可偏置实值);突破用一天期权;假破确认后小仓合约加强;先过方向/空间/值不值得;期权不手平;一位置两次,错完收工;单笔小亏、组合回撤可控. +分工:OKX 期权=主业;Gate 合约=辅业;其它账户暂不做.同一时段尽量只让一边说话. +入场三类:①横盘较久→期期对冲(一天 Call+Put,总权利金约10U),期间一般不开 Gate;②方向/空间/值不值得过关且结构突破→一天期权方向单,默认不上合约;③已有突破期权后出现反向假破确认→Gate 小仓加强(加重暴露,按一笔故事控风险). +仓位(总资约800U):单笔期权约10U且一次一仓;期期对冲合计约10U;Gate 保证金约50U×约10x,止损约5U,单笔最亏约≤10U;日最坏约≤20U. +期权纪律:不手动平仓,只认规则止盈或到期(紧急手平非策略样本);默认一天期,尽量北京时间16:00后开次日到期. +Gate 纪律:只做很明确位置;同一位置最多两次机会(结构突破/假突破);两次都错→当日收工. +教练用法:对照上述纪律讨论执行与心态;勿另造策略或鼓励期权手平/超仓.""" + + +def playbook_md_path() -> Path: + return REPO_ROOT / "docs" / "交易执行手册-期权与Gate.md" + + +def format_playbook_brief_for_chat(max_chars: int = 1200) -> str: + """返回注入交易教练上下文的短摘要.""" + text = _PLAYBOOK_BRIEF.strip() + if len(text) <= max_chars: + return text + return text[: max(200, max_chars - 1)].rstrip() + "…" diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py index 2e64b90..9578a8a 100644 --- a/manual_trading_hub/hub_ai/prompts.py +++ b/manual_trading_hub/hub_ai/prompts.py @@ -44,10 +44,12 @@ CHAT_SYSTEM = """ - 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」. - 不预测涨跌,不保证收益,不替用户做决定. - 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」. -- **持仓判定**:只有快照里「实盘持仓总览 / 持仓明细 / 交易所实盘」才算已开仓;「空仓 / 0 仓」就是没仓位.浮盈亏 0U 且空仓时,不要说「还有仓」「卡着不动」. +- **持仓判定**:只有快照里「实盘持仓总览 / 持仓明细 / 交易所实盘 / 期权持仓」才算已开仓;「空仓 / 0 仓」就是没仓位.浮盈亏 0U 且空仓时,不要说「还有仓」「卡着不动」. +- **期权持仓**:快照中「期权 …」行与永续同样是实盘;须分开提及.期权净盈亏/目标位以快照为准. - **监控单 ≠ 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓. - 用户口述与快照冲突时,以快照为准并口语说明「我这边看到是空仓/有N仓」. - 若附带「今日总结摘要」,那是较早生成的缓存,**实盘持仓以【当前多账户快照】里的「实盘持仓总览」为准**,摘要里若提到持仓可能已过时. +- 若附带【用户策略执行手册·摘要】,须按该纪律理解账户分工与离场规则(如期权通常不手平、Gate 一位置两次等);勿另造策略或鼓励违反摘要纪律. - 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清. - **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字. - **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行. @@ -143,12 +145,20 @@ def build_chat_user_prompt( history_lines: str = "", user_message: str, attachment_note: str = "", + playbook_brief: str = "", ) -> str: parts = [f"【交易日】{trading_day}"] if rolling_summary.strip(): parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()]) elif history_lines.strip(): parts.extend(["【最近对话】", history_lines.strip()]) + if playbook_brief.strip(): + parts.extend( + [ + "【用户策略执行手册·摘要(须知悉分工与纪律)】", + playbook_brief.strip(), + ] + ) parts.extend([ "【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】", context_text.strip() or "(无监控数据)", diff --git a/tests/test_hub_ai_options_playbook.py b/tests/test_hub_ai_options_playbook.py new file mode 100644 index 0000000..ff22016 --- /dev/null +++ b/tests/test_hub_ai_options_playbook.py @@ -0,0 +1,97 @@ +"""hub_ai:期权持仓进教练上下文 + 执行手册摘要.""" +from __future__ import annotations + +import unittest + +from hub_ai.context import ( + format_chat_context_for_chat, + format_chat_context_slim, + format_chat_position_overview, +) +from hub_ai.playbook_brief import format_playbook_brief_for_chat +from hub_ai.prompts import build_chat_user_prompt + + +def _sample_payload(): + return { + "totals": { + "trading_day": "2026-07-22", + "total_pnl_u": 0, + "closed_count": 0, + "win_count": 0, + "loss_count": 0, + "float_pnl_u": 1.25, + "open_position_count": 1, + "options_open_position_count": 1, + "perpetual_open_position_count": 0, + }, + "accounts": [ + { + "name": "OKX_趋势", + "key": "okx", + "status": "已监控", + "open_position_count": 1, + "options_open_position_count": 1, + "float_pnl_u": 1.25, + "funding_usdt": 100, + "trading_usdt": 50, + "trade_stats": {"total_pnl_u": 0, "closed_count": 0, "win_count": 0, "loss_count": 0}, + "positions": [], + "trades": [], + "monitor_lines": {}, + "options_snapshot": { + "ok": True, + "enabled": True, + "positions": [ + { + "inst_id": "ETH-USD-260723-3500-C", + "opt_type": "C", + "pos": 1, + "premium_paid": 8.5, + "source_label": "纯期权", + "source": "option", + "upl": 1.25, + "target_monitor_text": "目标 3600", + } + ], + }, + } + ], + } + + +class HubAiOptionsPlaybookTests(unittest.TestCase): + def test_chat_slim_includes_options_line(self): + text = format_chat_context_slim(_sample_payload()) + self.assertIn("期权 ETH-USD-260723-3500-C Call", text) + self.assertIn("永续0/期权1", text) + self.assertIn("权利金8.5U", text) + + def test_overview_lists_options(self): + text = format_chat_position_overview(_sample_payload()) + self.assertIn("期权1", text) + self.assertIn("ETH-USD-260723-3500-C", text) + + def test_chat_bundle_keeps_options(self): + text = format_chat_context_for_chat(_sample_payload(), max_chars=8000) + self.assertIn("ETH-USD-260723-3500-C", text) + self.assertIn("期权", text) + + def test_playbook_brief_injected(self): + brief = format_playbook_brief_for_chat() + self.assertIn("执行手册", brief) + self.assertIn("OKX", brief) + self.assertIn("不手动平仓", brief) + prompt = build_chat_user_prompt( + context_text="快照", + trading_day="2026-07-22", + summary_excerpt="", + user_message="今天怎么样", + playbook_brief=brief, + ) + self.assertIn("用户策略执行手册", prompt) + self.assertIn("不手动平仓", prompt) + + +if __name__ == "__main__": + unittest.main()