feat: 复盘四周期手动上传,移除 AI 识别预填

固定 5m/15m/1h/4h 四槽截图上传与详情四宫格展示;自动 K 线默认关闭且与手动上传互斥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-05 21:57:41 +08:00
parent eec57610dc
commit 88fe4bbe56
18 changed files with 644 additions and 621 deletions
+41 -117
View File
@@ -75,6 +75,13 @@ from lib.strategy.strategy_trade_labels import (
trade_record_monitor_type as resolve_trade_record_monitor_type,
trend_plan_id_from_monitor_row,
)
from lib.instance.journal_images_lib import (
enrich_journal_api_item,
images_json_dumps,
journal_image_paths,
primary_journal_image,
save_journal_slot_uploads,
)
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
@@ -1147,75 +1154,6 @@ def journal_exit_reason_stored(trigger, note):
return t
def ai_extract_journal_from_image(image_b64):
prompt = """
你是交易复盘信息提取助手请从截图中提取可识别字段并只输出 JSON不要 markdown不要解释
要求
1) 仅输出一个 JSON 对象
2) 时间输出为 YYYY-MM-DDTHH:MM用于 HTML datetime-local无法识别填空字符串
3) 不要猜测主观原因early_exit_note仅手工平仓note 默认留空除非图中明确写出
4) 允许字段为空
5) entry_reason优先从下列完整字符串中选一个一字不差若无法归类则可将简述写入 entry_reason保存时也可选表单其他手写
- 趋势多头4h大结构突破前进场确认条件三次探顶5m收敛不创新低
- 趋势空头4h大结构突破前进场确认条件三次探底5m收敛不创新高
- 趋势多头小分歧低吸入场左侧确认条件二次探底
- 趋势空头小分歧高吸入场左侧确认条件二次探顶
- 波段单5m顺势突破确认条件2根k线+成交量放大+4h同向+日成交量前20
6) early_exit_trigger 只能从下列取值中选一个无法识别则填空字符串止盈保本止盈移动止盈时间平仓手动平仓止损其他
7) 若触发为手动平仓early_exit_note 必须写出图中可见的补充说明其他触发类型 early_exit_note 留空
8) 若图中有无法归类的离场说明原文可放进 early_exit_noteearly_exit_trigger 其他或留空
JSON 字段
{
"open_datetime": "",
"close_datetime": "",
"coin": "",
"tf": "",
"pnl": "",
"expect_rr": "",
"real_rr": "",
"entry_reason": "",
"early_exit_trigger": "",
"early_exit_note": "",
"early_exit_reason": "",
"note": ""
}
""".strip()
try:
raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1)
if raw.startswith("AI 调用失败"):
return {}
data = _extract_json_object(raw) or {}
if not isinstance(data, dict):
data = {}
trig_in = data.get("early_exit_trigger")
note_in = data.get("early_exit_note")
legacy_reason = str(data.get("early_exit_reason") or "").strip()
out = {
"open_datetime": str(data.get("open_datetime") or "").strip(),
"close_datetime": str(data.get("close_datetime") or "").strip(),
"coin": str(data.get("coin") or "").strip(),
"tf": str(data.get("tf") or "").strip(),
"pnl": str(data.get("pnl") or "").strip(),
"expect_rr": str(data.get("expect_rr") or "").strip(),
"real_rr": str(data.get("real_rr") or "").strip(),
"entry_reason": normalize_entry_reason(data.get("entry_reason")),
"early_exit_trigger": normalize_early_exit_trigger(trig_in),
"early_exit_note": str(note_in or "").strip(),
"early_exit_reason": legacy_reason,
"note": str(data.get("note") or "").strip(),
}
if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason:
out["early_exit_note"] = legacy_reason
if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason:
out["early_exit_note"] = legacy_reason
if out["early_exit_trigger"] != "手动平仓":
out["early_exit_note"] = ""
out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"])
return out
except Exception:
return None
# 初始化数据库(支持多空方向)
def init_db():
conn = sqlite3.connect(DB_PATH)
@@ -1465,6 +1403,9 @@ def init_db():
try:
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
except: pass
try:
c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
except: pass
@@ -8871,7 +8812,7 @@ def export_journal_entries():
rows = conn.execute(
"SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason,"
"expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues,"
"post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC"
"post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC"
).fetchall()
conn.close()
head = [
@@ -8895,6 +8836,7 @@ def export_journal_entries():
"new_trade_while_occupied",
"note",
"image",
"images_json",
"created_at",
]
data = [tuple(r[h] for h in head) for r in rows]
@@ -9196,16 +9138,16 @@ def add_journal():
early_exit_raw = "" if early_exit_trigger == "手动平仓" else ""
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note)
exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
image_filename = None
uploaded_tmp = None
entry_id = uuid.uuid4().hex
file = request.files.get("screenshot")
if file and file.filename:
ext = os.path.splitext(file.filename)[1]
image_filename = f"{uuid.uuid4().hex}{ext}"
save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename))
file.save(save_path)
uploaded_tmp = image_filename
manual_images = save_journal_slot_uploads(
request.files,
entry_id,
app.config["UPLOAD_FOLDER"],
secure_filename_fn=secure_filename,
)
images_json_str = images_json_dumps(manual_images)
image_filename = primary_journal_image(manual_images)
has_manual_uploads = bool(manual_images)
mood_issues = ",".join(request.form.getlist("mood_issues"))
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
@@ -9219,7 +9161,10 @@ def add_journal():
except Exception:
pass
want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
want_exchange_chart = (
not has_manual_uploads
and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
)
chart_msg = None
if want_exchange_chart and ORDER_CHART_ENABLED:
coin = (d.get("coin") or "").strip().upper()
@@ -9259,17 +9204,9 @@ def add_journal():
if saved:
image_filename = saved
chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)}{journal_limit}根):/static/images/{saved}"
if uploaded_tmp:
try:
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
if os.path.exists(old_path):
os.remove(old_path)
except Exception:
pass
else:
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。"
except Exception as e:
image_filename = uploaded_tmp
chart_msg = f"自动生成K线图失败:{str(e)}"
conn = get_db()
@@ -9278,8 +9215,8 @@ def add_journal():
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note,
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
new_trade_while_occupied, note, image)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
new_trade_while_occupied, note, image, images_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
entry_id,
normalize_bj_datetime_storage(d.get("open_datetime")),
@@ -9290,7 +9227,8 @@ def add_journal():
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text,
early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
None, None, None, mood_issues,
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename,
images_json_str,
)
)
from lib.trade.account_risk_lib import on_journal_saved
@@ -9326,41 +9264,27 @@ def api_journals():
conn.close()
result = []
for r in rows:
item = row_to_dict(r)
item = enrich_journal_api_item(row_to_dict(r))
item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x]
result.append(item)
return jsonify(result)
@app.route("/api/journal_prefill", methods=["POST"])
@login_required
def api_journal_prefill():
file = request.files.get("screenshot")
if not file or not file.filename:
return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400
try:
raw = file.read()
if not raw:
return jsonify({"ok": False, "msg": "截图为空"}), 400
image_b64 = base64.b64encode(raw).decode("utf-8")
except Exception as e:
return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400
parsed = ai_extract_journal_from_image(image_b64)
if parsed is None:
return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500
return jsonify({"ok": True, "data": parsed})
@app.route("/delete_journal/<jid>", methods=["POST"])
@login_required
def delete_journal(jid):
conn = get_db()
row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone()
if row and row["image"]:
img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"])
if os.path.exists(img_path):
os.remove(img_path)
row = conn.execute(
"SELECT image, images_json FROM journal_entries WHERE id=?",
(jid,),
).fetchone()
if row:
for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]):
try:
if os.path.exists(img_path):
os.remove(img_path)
except Exception:
pass
conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,))
conn.commit()
conn.close()