Normalize fullwidth punctuation to ASCII across codebase.
Add scripts/normalize_ambiguous_unicode.py; fix corrupted patch_instance_theme_templates.py. Preserves curly quotes in string literals; removes Git homoglyph warnings on .env.example. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+45
-45
@@ -1,7 +1,7 @@
|
||||
"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一。
|
||||
"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一.
|
||||
|
||||
配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env),
|
||||
若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空。
|
||||
配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env),
|
||||
若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -142,7 +142,7 @@ def _openai_chat_completion(
|
||||
) -> Tuple[str, str]:
|
||||
api_key = _openai_api_key()
|
||||
if not api_key:
|
||||
return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error"
|
||||
return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -164,7 +164,7 @@ def _openai_chat_completion(
|
||||
data = r.json()
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
return "AI 生成失败:响应无 choices", "error"
|
||||
return "AI 生成失败:响应无 choices", "error"
|
||||
choice = choices[0] or {}
|
||||
msg = choice.get("message") or {}
|
||||
text = _openai_message_text(msg)
|
||||
@@ -187,7 +187,7 @@ def _openai_chat_completion(
|
||||
if text2:
|
||||
return text2, str((choices2[0] or {}).get("finish_reason") or finish)
|
||||
if not text:
|
||||
return "AI 生成失败:空内容", finish or "error"
|
||||
return "AI 生成失败:空内容", finish or "error"
|
||||
return text, finish
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ def ai_generate(
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int | None = None,
|
||||
) -> str:
|
||||
"""统一文本生成;失败时返回以「AI 调用失败」开头的说明。"""
|
||||
"""统一文本生成;失败时返回以「AI 调用失败」开头的说明."""
|
||||
images = _collect_images(image_paths, images_b64)
|
||||
try:
|
||||
if _use_openai():
|
||||
@@ -271,17 +271,17 @@ def ai_generate(
|
||||
except Exception:
|
||||
pass
|
||||
prov = "OpenAI" if _use_openai() else "Ollama"
|
||||
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
|
||||
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
|
||||
except Exception as e:
|
||||
prov = "OpenAI" if _use_openai() else "Ollama"
|
||||
return f"AI 调用失败({prov}):{str(e)}"
|
||||
return f"AI 调用失败({prov}):{str(e)}"
|
||||
|
||||
|
||||
_CHAT_CONTINUE_USER = (
|
||||
"你上一条回复在中途截断了。请从断点处继续写完,不要重复已写内容,"
|
||||
"保持同一语气;编号列表每条单独一行。"
|
||||
"你上一条回复在中途截断了.请从断点处继续写完,不要重复已写内容,"
|
||||
"保持同一语气;编号列表每条单独一行."
|
||||
)
|
||||
_CHAT_END_CHARS = "。!?.!?\"」』))>】"
|
||||
_CHAT_END_CHARS = ".!?.!?\"」』))>】"
|
||||
_INCOMPLETE_TAIL_RE = re.compile(
|
||||
r"(不会|不能|没有|会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|"
|
||||
r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$"
|
||||
@@ -300,7 +300,7 @@ def _looks_truncated(text: str) -> bool:
|
||||
return True
|
||||
if re.search(r"\d+\.\s*$", t):
|
||||
return True
|
||||
return t[-1] not in ",、,;;::\n"
|
||||
return t[-1] not in ",,,;;::\n"
|
||||
|
||||
|
||||
def _should_continue(reason: str, full_text: str) -> bool:
|
||||
@@ -313,16 +313,16 @@ def _chat_continue_message(full_text: str) -> str:
|
||||
tail = full_text[-500:] if len(full_text) > 500 else full_text
|
||||
return (
|
||||
f"{_CHAT_CONTINUE_USER}\n\n"
|
||||
f"已写到最后这几句:\n「{tail}」\n\n"
|
||||
f"请从断点接着写完。不要重复前文;最后一句话必须以句号、问号或感叹号结束。"
|
||||
f"已写到最后这几句:\n「{tail}」\n\n"
|
||||
f"请从断点接着写完.不要重复前文;最后一句话必须以句号,问号或感叹号结束."
|
||||
)
|
||||
|
||||
|
||||
def _chat_continue_system(system: str) -> str:
|
||||
return (
|
||||
f"{system.strip()}\n\n"
|
||||
"【续写模式】只输出断点后的剩余内容,不要重复前文;"
|
||||
"列表每条单独一行;必须以句号、问号或感叹号收尾。"
|
||||
"【续写模式】只输出断点后的剩余内容,不要重复前文;"
|
||||
"列表每条单独一行;必须以句号,问号或感叹号收尾."
|
||||
)
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ def ai_generate_chat(
|
||||
max_tokens: int = 8192,
|
||||
max_continuations: int = 4,
|
||||
) -> str:
|
||||
"""聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文)。"""
|
||||
"""聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文)."""
|
||||
images = _collect_images(None, images_b64)
|
||||
max_rounds = max(1, int(max_continuations) + 1)
|
||||
try:
|
||||
@@ -377,7 +377,7 @@ def ai_generate_chat(
|
||||
{"role": "assistant", "content": full},
|
||||
{"role": "user", "content": _chat_continue_message(full)},
|
||||
]
|
||||
return "".join(parts).strip() or "AI 生成失败:空内容"
|
||||
return "".join(parts).strip() or "AI 生成失败:空内容"
|
||||
|
||||
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
|
||||
parts: list[str] = []
|
||||
@@ -405,7 +405,7 @@ def ai_generate_chat(
|
||||
full = "".join(parts)
|
||||
if not _should_continue(reason, full) or attempt >= max_rounds - 1:
|
||||
break
|
||||
return "".join(parts).strip() or "AI 生成失败:空内容"
|
||||
return "".join(parts).strip() or "AI 生成失败:空内容"
|
||||
except requests.HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
@@ -413,46 +413,46 @@ def ai_generate_chat(
|
||||
except Exception:
|
||||
pass
|
||||
prov = "OpenAI" if _use_openai() else "Ollama"
|
||||
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
|
||||
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
|
||||
except Exception as e:
|
||||
prov = "OpenAI" if _use_openai() else "Ollama"
|
||||
return f"AI 调用失败({prov}):{str(e)}"
|
||||
return f"AI 调用失败({prov}):{str(e)}"
|
||||
|
||||
|
||||
def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
|
||||
n_img = len(image_paths or [])
|
||||
period_label = "周" if "周" in str(period_title) else "日"
|
||||
attach_note = (
|
||||
f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节。】\n\n"
|
||||
f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节.】\n\n"
|
||||
if n_img
|
||||
else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」。】\n\n"
|
||||
else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n"
|
||||
)
|
||||
prompt = f"""
|
||||
你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。
|
||||
你是一位专业交易教练.下面是用户的{period_title}交易记录,请做简洁,可执行的复盘(中文).
|
||||
|
||||
【硬性规则 — 必须遵守】
|
||||
- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。
|
||||
- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。
|
||||
- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。
|
||||
- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。
|
||||
- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。
|
||||
- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。
|
||||
- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损,是否扛单,亏损是否扩大,图上具体结构/进出场点位等记录里**没有**的信息.
|
||||
- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价,是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」.
|
||||
- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等.
|
||||
- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据.
|
||||
- 禁止用语:人身攻击,夸张定性(如「致命伤」「灾难」);语气克制,对事不对人.
|
||||
- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事.
|
||||
|
||||
【输出格式 — Markdown,必须严格遵守】
|
||||
- 第一行:**交易复盘报告({period_label}度)**
|
||||
- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名):
|
||||
【输出格式 — Markdown,必须严格遵守】
|
||||
- 第一行:**交易复盘报告({period_label}度)**
|
||||
- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名):
|
||||
**1. 📊 总体盈亏结构**
|
||||
**2. 🧠 心态与执行**
|
||||
**3. 🏷️ 行为标签**
|
||||
**4. ✅ 改进建议**
|
||||
**5. 📈 图表分析**
|
||||
- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.`
|
||||
- 第1节至少包含:**笔数/盈亏**、**风险回报比**、**总结**
|
||||
- 第2节至少包含:**得分**(1–10)、**依据**(对应记录字段)
|
||||
- 第5节至少包含:**趋势确认**、**执行路径**(记录不足则写明)
|
||||
- 语气简洁,少形容词;不要输出代码块、不要表格
|
||||
- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.`
|
||||
- 第1节至少包含:**笔数/盈亏**,**风险回报比**,**总结**
|
||||
- 第2节至少包含:**得分**(1–10),**依据**(对应记录字段)
|
||||
- 第5节至少包含:**趋势确认**,**执行路径**(记录不足则写明)
|
||||
- 语气简洁,少形容词;不要输出代码块,不要表格
|
||||
|
||||
交易记录:
|
||||
交易记录:
|
||||
{trades_text}
|
||||
""".strip()
|
||||
return attach_note + ai_generate(prompt, image_paths=image_paths, temperature=0.2)
|
||||
@@ -460,12 +460,12 @@ def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
|
||||
|
||||
def ai_short_advice(prompt_text: str) -> str:
|
||||
prompt = f"""
|
||||
你是交易风控助理。请用中文给出**最多 3 条**提醒,要求:
|
||||
你是交易风控助理.请用中文给出**最多 3 条**提醒,要求:
|
||||
- 每条不超过 25 个字
|
||||
- 语气克制、具体、可执行
|
||||
- 不要输出 Markdown,不要编号前缀以外的废话
|
||||
- 语气克制,具体,可执行
|
||||
- 不要输出 Markdown,不要编号前缀以外的废话
|
||||
|
||||
场景:
|
||||
场景:
|
||||
{prompt_text}
|
||||
""".strip()
|
||||
return ai_generate(prompt, temperature=0.2)
|
||||
@@ -478,7 +478,7 @@ def ai_provider_label() -> str:
|
||||
|
||||
|
||||
def ai_config_status() -> dict:
|
||||
"""调试用:当前进程内读到的 AI 配置(不含密钥明文)。"""
|
||||
"""调试用:当前进程内读到的 AI 配置(不含密钥明文)."""
|
||||
key = _openai_api_key()
|
||||
return {
|
||||
"provider": _ai_provider(),
|
||||
|
||||
+178
-178
@@ -1,178 +1,178 @@
|
||||
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
||||
|
||||
from lib.instance.journal_chart_lib import (
|
||||
JOURNAL_CHART_ANCHOR_CLOSE,
|
||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||
JOURNAL_CHART_DEFAULT_TF1,
|
||||
JOURNAL_CHART_DEFAULT_TF2,
|
||||
normalize_chart_timeframe,
|
||||
)
|
||||
from lib.instance.journal_images_lib import journal_image_paths
|
||||
|
||||
|
||||
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, "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.images_json / image 已存附图(含多周期手动上传);
|
||||
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
||||
"""
|
||||
paths: List[str] = []
|
||||
seen = set()
|
||||
upload_folder = os.path.abspath(upload_folder or "")
|
||||
for row in rows or []:
|
||||
row_paths = journal_image_paths(row, upload_folder)
|
||||
if row_paths:
|
||||
for candidate in row_paths:
|
||||
if candidate not in seen:
|
||||
seen.add(candidate)
|
||||
paths.append(candidate)
|
||||
continue
|
||||
if 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
|
||||
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
||||
|
||||
from lib.instance.journal_chart_lib import (
|
||||
JOURNAL_CHART_ANCHOR_CLOSE,
|
||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||
JOURNAL_CHART_DEFAULT_TF1,
|
||||
JOURNAL_CHART_DEFAULT_TF2,
|
||||
normalize_chart_timeframe,
|
||||
)
|
||||
from lib.instance.journal_images_lib import journal_image_paths
|
||||
|
||||
|
||||
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, "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.images_json / image 已存附图(含多周期手动上传);
|
||||
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图.
|
||||
"""
|
||||
paths: List[str] = []
|
||||
seen = set()
|
||||
upload_folder = os.path.abspath(upload_folder or "")
|
||||
for row in rows or []:
|
||||
row_paths = journal_image_paths(row, upload_folder)
|
||||
if row_paths:
|
||||
for candidate in row_paths:
|
||||
if candidate not in seen:
|
||||
seen.add(candidate)
|
||||
paths.append(candidate)
|
||||
continue
|
||||
if 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
|
||||
|
||||
Reference in New Issue
Block a user