feat: 复盘四周期手动上传,移除 AI 识别预填
固定 5m/15m/1h/4h 四槽截图上传与详情四宫格展示;自动 K 线默认关闭且与手动上传互斥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+41
-117
@@ -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_note,early_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()
|
||||
|
||||
Reference in New Issue
Block a user