86a6081090
Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
6.2 KiB
Python
181 lines
6.2 KiB
Python
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(四所共用)。"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import uuid
|
||
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
||
|
||
from journal_chart_lib import (
|
||
JOURNAL_CHART_ANCHOR_CLOSE,
|
||
JOURNAL_CHART_DEFAULT_LIMIT,
|
||
JOURNAL_CHART_DEFAULT_TF1,
|
||
JOURNAL_CHART_DEFAULT_TF2,
|
||
normalize_chart_timeframe,
|
||
)
|
||
|
||
|
||
def _journal_nz(v: Any, default: str = "无") -> str:
|
||
if v is None:
|
||
return default
|
||
s = str(v).strip()
|
||
return s if s else default
|
||
|
||
|
||
def _row_get(row: Any, key: str, default: Any = None) -> Any:
|
||
"""兼容 dict 与 sqlite3.Row(Row 无 .get 方法)。"""
|
||
if row is None:
|
||
return default
|
||
getter = getattr(row, "get", None)
|
||
if callable(getter):
|
||
return getter(key, default)
|
||
try:
|
||
keys = row.keys() if hasattr(row, "keys") else ()
|
||
if key in keys:
|
||
return row[key]
|
||
except Exception:
|
||
pass
|
||
try:
|
||
return row[key]
|
||
except (KeyError, TypeError, IndexError):
|
||
return default
|
||
|
||
|
||
def journal_row_lines_for_ai(
|
||
idx: int,
|
||
row: Any,
|
||
*,
|
||
include_hold_duration: bool = True,
|
||
) -> str:
|
||
"""把 journal 字段拼成给 AI 的文本;四所日复盘/周复盘共用。"""
|
||
lines = [
|
||
(
|
||
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
|
||
f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U "
|
||
f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} "
|
||
f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}"
|
||
),
|
||
f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}",
|
||
f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}",
|
||
]
|
||
if include_hold_duration:
|
||
lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}")
|
||
ee_bits = [
|
||
_journal_nz(_row_get(row, "early_exit")),
|
||
_journal_nz(_row_get(row, "early_exit_reason")),
|
||
_journal_nz(_row_get(row, "early_exit_trigger")),
|
||
_journal_nz(_row_get(row, "early_exit_note")),
|
||
]
|
||
if any(x != "无" for x in ee_bits):
|
||
lines.append(
|
||
" 提前离场记录:"
|
||
f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
|
||
)
|
||
mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}"
|
||
mood_score = _row_get(row, "mood_score")
|
||
if mood_score is not None:
|
||
mood_bits += f" | 自评心态分:{mood_score}"
|
||
lines.append(f" {mood_bits}")
|
||
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无":
|
||
lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}")
|
||
if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "无":
|
||
lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}")
|
||
if _journal_nz(_row_get(row, "note")) != "无":
|
||
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def collect_images_for_ai_review(
|
||
rows: Sequence,
|
||
upload_folder: str,
|
||
*,
|
||
build_chart_if_missing: Optional[Callable] = None,
|
||
) -> List[str]:
|
||
"""
|
||
收集传给视觉模型的本地图片路径。
|
||
- 优先 journal_entries.image 已存附图;
|
||
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
||
"""
|
||
paths: List[str] = []
|
||
seen = set()
|
||
upload_folder = os.path.abspath(upload_folder or "")
|
||
for row in rows or []:
|
||
candidate = None
|
||
try:
|
||
keys = row.keys() if hasattr(row, "keys") else []
|
||
except Exception:
|
||
keys = []
|
||
img = row["image"] if "image" in keys else None
|
||
if img:
|
||
candidate = os.path.join(upload_folder, str(img).strip())
|
||
elif build_chart_if_missing:
|
||
try:
|
||
candidate = build_chart_if_missing(row)
|
||
except Exception:
|
||
candidate = None
|
||
if not candidate:
|
||
continue
|
||
candidate = os.path.abspath(candidate)
|
||
if os.path.isfile(candidate) and candidate not in seen:
|
||
seen.add(candidate)
|
||
paths.append(candidate)
|
||
return paths
|
||
|
||
|
||
def build_journal_ai_chart_path(
|
||
row,
|
||
upload_folder: str,
|
||
*,
|
||
order_chart_enabled: bool,
|
||
normalize_exchange_symbol_fn: Callable[[str], str],
|
||
generate_chart_fn: Callable,
|
||
local_datetime_to_ms_fn: Callable[[str], Optional[int]],
|
||
now_ts_ms_fn: Callable[[], int],
|
||
) -> Optional[str]:
|
||
"""无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。"""
|
||
if not order_chart_enabled:
|
||
return None
|
||
try:
|
||
keys = row.keys() if hasattr(row, "keys") else []
|
||
except Exception:
|
||
return None
|
||
coin = (row["coin"] if "coin" in keys else "") or ""
|
||
coin = str(coin).strip()
|
||
if not coin:
|
||
return None
|
||
try:
|
||
symbol = normalize_exchange_symbol_fn(coin)
|
||
except Exception:
|
||
return None
|
||
open_dt = row["open_datetime"] if "open_datetime" in keys else ""
|
||
close_dt = row["close_datetime"] if "close_datetime" in keys else ""
|
||
entry_ms = local_datetime_to_ms_fn(open_dt)
|
||
exit_ms = local_datetime_to_ms_fn(close_dt)
|
||
if not entry_ms:
|
||
return None
|
||
row_tf = row["tf"] if "tf" in keys else ""
|
||
tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1
|
||
tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h"
|
||
row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8]
|
||
marker = {
|
||
"entry_ts_ms": entry_ms,
|
||
"exit_ts_ms": exit_ms,
|
||
"chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE,
|
||
"now_ts_ms": int(now_ts_ms_fn()),
|
||
}
|
||
fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png"
|
||
saved = generate_chart_fn(
|
||
symbol,
|
||
f"AI复盘 {coin}",
|
||
timeframes=[tf1, tf2],
|
||
limit=JOURNAL_CHART_DEFAULT_LIMIT,
|
||
out_dir=upload_folder,
|
||
filename=fname,
|
||
marker_payload=marker,
|
||
marker_timeframes={tf1, tf2},
|
||
layout="vertical",
|
||
)
|
||
if not saved:
|
||
return None
|
||
path = os.path.join(upload_folder, saved)
|
||
return path if os.path.isfile(path) else None
|