Feed options positions and playbook brief into trading coach.

Coach context previously omitted options_snapshot details; also inject a short 执行手册 summary each turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-22 23:28:16 +08:00
parent eb0eddbc9d
commit 58e9c8f85e
6 changed files with 258 additions and 11 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
| 功能 | 说明 |
|------|------|
| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
| **交易教练** | 口语化陪聊;注入三户监控快照(**含 OKX 期权持仓明细**)、执行手册短摘要与今日总结摘要(后台自动生成,不在页面展示) |
| **普通聊天** | 不绑交易数据,适合闲聊,答疑 |
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
| **会话历史** | 右侧列表:切换,删除;消息一键复制 |
+6
View File
@@ -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)
+115 -9
View File
@@ -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)
@@ -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() + ""
+11 -1
View File
@@ -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 "(无监控数据)",
+97
View File
@@ -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()