"""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