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,
|
trade_record_monitor_type as resolve_trade_record_monitor_type,
|
||||||
trend_plan_id_from_monitor_row,
|
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 (
|
from lib.instance.journal_chart_lib import (
|
||||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||||
JOURNAL_CHART_DEFAULT_TF1,
|
JOURNAL_CHART_DEFAULT_TF1,
|
||||||
@@ -1147,75 +1154,6 @@ def journal_exit_reason_stored(trigger, note):
|
|||||||
return t
|
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():
|
def init_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -1465,6 +1403,9 @@ def init_db():
|
|||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
||||||
except: pass
|
except: pass
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT")
|
||||||
|
except: pass
|
||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
||||||
except: pass
|
except: pass
|
||||||
@@ -8871,7 +8812,7 @@ def export_journal_entries():
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason,"
|
"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,"
|
"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()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
head = [
|
head = [
|
||||||
@@ -8895,6 +8836,7 @@ def export_journal_entries():
|
|||||||
"new_trade_while_occupied",
|
"new_trade_while_occupied",
|
||||||
"note",
|
"note",
|
||||||
"image",
|
"image",
|
||||||
|
"images_json",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
data = [tuple(r[h] for h in head) for r in rows]
|
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_raw = "是" if early_exit_trigger == "手动平仓" else "否"
|
||||||
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note)
|
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)
|
exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
|
||||||
image_filename = None
|
|
||||||
uploaded_tmp = None
|
|
||||||
entry_id = uuid.uuid4().hex
|
entry_id = uuid.uuid4().hex
|
||||||
file = request.files.get("screenshot")
|
manual_images = save_journal_slot_uploads(
|
||||||
if file and file.filename:
|
request.files,
|
||||||
ext = os.path.splitext(file.filename)[1]
|
entry_id,
|
||||||
image_filename = f"{uuid.uuid4().hex}{ext}"
|
app.config["UPLOAD_FOLDER"],
|
||||||
save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename))
|
secure_filename_fn=secure_filename,
|
||||||
file.save(save_path)
|
)
|
||||||
uploaded_tmp = image_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"))
|
mood_issues = ",".join(request.form.getlist("mood_issues"))
|
||||||
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
||||||
@@ -9219,7 +9161,10 @@ def add_journal():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
chart_msg = None
|
||||||
if want_exchange_chart and ORDER_CHART_ENABLED:
|
if want_exchange_chart and ORDER_CHART_ENABLED:
|
||||||
coin = (d.get("coin") or "").strip().upper()
|
coin = (d.get("coin") or "").strip().upper()
|
||||||
@@ -9259,17 +9204,9 @@ def add_journal():
|
|||||||
if saved:
|
if saved:
|
||||||
image_filename = saved
|
image_filename = saved
|
||||||
chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{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:
|
else:
|
||||||
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。"
|
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
image_filename = uploaded_tmp
|
|
||||||
chart_msg = f"自动生成K线图失败:{str(e)}"
|
chart_msg = f"自动生成K线图失败:{str(e)}"
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -9278,8 +9215,8 @@ def add_journal():
|
|||||||
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
|
(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,
|
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,
|
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
|
||||||
new_trade_while_occupied, note, image)
|
new_trade_while_occupied, note, image, images_json)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
entry_id,
|
entry_id,
|
||||||
normalize_bj_datetime_storage(d.get("open_datetime")),
|
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,
|
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,
|
early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
|
||||||
None, None, None, mood_issues,
|
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
|
from lib.trade.account_risk_lib import on_journal_saved
|
||||||
@@ -9326,41 +9264,27 @@ def api_journals():
|
|||||||
conn.close()
|
conn.close()
|
||||||
result = []
|
result = []
|
||||||
for r in rows:
|
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]
|
item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x]
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return jsonify(result)
|
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"])
|
@app.route("/delete_journal/<jid>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_journal(jid):
|
def delete_journal(jid):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone()
|
row = conn.execute(
|
||||||
if row and row["image"]:
|
"SELECT image, images_json FROM journal_entries WHERE id=?",
|
||||||
img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"])
|
(jid,),
|
||||||
if os.path.exists(img_path):
|
).fetchone()
|
||||||
os.remove(img_path)
|
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.execute("DELETE FROM journal_entries WHERE id=?", (jid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -702,11 +702,12 @@
|
|||||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||||
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
|
||||||
</div>
|
</div>
|
||||||
|
{% from 'journal_upload_slots.html' import journal_upload_slots %}
|
||||||
|
{{ journal_upload_slots() }}
|
||||||
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true">
|
||||||
保存时自动生成 K 线图并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
@@ -734,9 +735,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
|
||||||
</div>
|
|
||||||
<div class="mood-grid" style="margin-top:8px">
|
<div class="mood-grid" style="margin-top:8px">
|
||||||
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||||
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||||
@@ -841,11 +839,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" id="detailBody"></div>
|
<div class="panel-body" id="detailBody"></div>
|
||||||
|
<div id="detailImages" class="journal-detail-images" style="display:none"></div>
|
||||||
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=4"></script>
|
<script src="/static/instance_ui.js?v=5"></script>
|
||||||
|
<script src="/static/journal_upload_slots.js?v=1"></script>
|
||||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
@@ -1430,61 +1430,6 @@ function fillJournalFromTrade(t){
|
|||||||
alert("已填入下方复盘表单,请手动补充主观原因。");
|
alert("已填入下方复盘表单,请手动补充主观原因。");
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefillJournalByImage(){
|
|
||||||
const fileInput = document.getElementById("journal-screenshot");
|
|
||||||
if(!fileInput || !fileInput.files || !fileInput.files.length){
|
|
||||||
alert("请先选择截图");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("screenshot", fileInput.files[0]);
|
|
||||||
fetch("/api/journal_prefill", { method: "POST", body: fd })
|
|
||||||
.then(r=>r.json())
|
|
||||||
.then(res=>{
|
|
||||||
if(!res.ok){
|
|
||||||
alert(res.msg || "AI识别失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = res.data || {};
|
|
||||||
setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime));
|
|
||||||
setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime));
|
|
||||||
setJournalField("coin", d.coin || "");
|
|
||||||
setJournalField("tf", d.tf || "");
|
|
||||||
setJournalField("pnl", d.pnl || "");
|
|
||||||
setJournalField("expect_rr", d.expect_rr || "");
|
|
||||||
setJournalField("real_rr", d.real_rr || "");
|
|
||||||
let entryReason = String(d.entry_reason || "").trim();
|
|
||||||
let customEr = "";
|
|
||||||
if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){
|
|
||||||
// keep
|
|
||||||
} else if(entryReason){
|
|
||||||
customEr = entryReason;
|
|
||||||
entryReason = JOURNAL_ENTRY_REASON_OTHER;
|
|
||||||
} else {
|
|
||||||
entryReason = "";
|
|
||||||
}
|
|
||||||
setJournalField("entry_reason", entryReason);
|
|
||||||
setJournalField("entry_reason_custom", customEr);
|
|
||||||
syncJournalEntryReasonOtherUi();
|
|
||||||
const trig = (d.early_exit_trigger || "").trim();
|
|
||||||
let noteEx = (d.early_exit_note || "").trim();
|
|
||||||
const legacy = (d.early_exit_reason || "").trim();
|
|
||||||
if(!noteEx && legacy && !trig){
|
|
||||||
const sp = splitLegacyEarlyExitReason(legacy);
|
|
||||||
setJournalField("early_exit_trigger", sp.trigger);
|
|
||||||
setJournalField("early_exit_note", sp.note);
|
|
||||||
} else {
|
|
||||||
setJournalField("early_exit_trigger", trig);
|
|
||||||
setJournalField("early_exit_note", noteEx);
|
|
||||||
}
|
|
||||||
setJournalField("note", d.note || "");
|
|
||||||
if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired();
|
|
||||||
recomputeJournalRealRr();
|
|
||||||
alert("已完成预填,请手动检查并补充原因");
|
|
||||||
})
|
|
||||||
.catch(()=>alert("AI识别请求失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recomputeJournalRealRr(){
|
function recomputeJournalRealRr(){
|
||||||
const form = document.getElementById("journal-form");
|
const form = document.getElementById("journal-form");
|
||||||
if(!form) return;
|
if(!form) return;
|
||||||
|
|||||||
+41
-117
@@ -76,6 +76,13 @@ from lib.strategy.strategy_trade_labels import (
|
|||||||
trade_record_monitor_type as resolve_trade_record_monitor_type,
|
trade_record_monitor_type as resolve_trade_record_monitor_type,
|
||||||
trend_plan_id_from_monitor_row,
|
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 (
|
from lib.instance.journal_chart_lib import (
|
||||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||||
JOURNAL_CHART_DEFAULT_TF1,
|
JOURNAL_CHART_DEFAULT_TF1,
|
||||||
@@ -1131,75 +1138,6 @@ def journal_exit_reason_stored(trigger, note):
|
|||||||
return t
|
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():
|
def init_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -1454,6 +1392,9 @@ def init_db():
|
|||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
||||||
except: pass
|
except: pass
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT")
|
||||||
|
except: pass
|
||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
||||||
except: pass
|
except: pass
|
||||||
@@ -8730,7 +8671,7 @@ def export_journal_entries():
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason,"
|
"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,"
|
"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()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
head = [
|
head = [
|
||||||
@@ -8754,6 +8695,7 @@ def export_journal_entries():
|
|||||||
"new_trade_while_occupied",
|
"new_trade_while_occupied",
|
||||||
"note",
|
"note",
|
||||||
"image",
|
"image",
|
||||||
|
"images_json",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
data = [tuple(r[h] for h in head) for r in rows]
|
data = [tuple(r[h] for h in head) for r in rows]
|
||||||
@@ -9036,16 +8978,16 @@ def add_journal():
|
|||||||
early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否"
|
early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否"
|
||||||
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note)
|
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)
|
exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
|
||||||
image_filename = None
|
|
||||||
uploaded_tmp = None
|
|
||||||
entry_id = uuid.uuid4().hex
|
entry_id = uuid.uuid4().hex
|
||||||
file = request.files.get("screenshot")
|
manual_images = save_journal_slot_uploads(
|
||||||
if file and file.filename:
|
request.files,
|
||||||
ext = os.path.splitext(file.filename)[1]
|
entry_id,
|
||||||
image_filename = f"{uuid.uuid4().hex}{ext}"
|
app.config["UPLOAD_FOLDER"],
|
||||||
save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename))
|
secure_filename_fn=secure_filename,
|
||||||
file.save(save_path)
|
)
|
||||||
uploaded_tmp = image_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"))
|
mood_issues = ",".join(request.form.getlist("mood_issues"))
|
||||||
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
||||||
@@ -9059,7 +9001,10 @@ def add_journal():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
chart_msg = None
|
||||||
if want_exchange_chart and ORDER_CHART_ENABLED:
|
if want_exchange_chart and ORDER_CHART_ENABLED:
|
||||||
coin = (d.get("coin") or "").strip().upper()
|
coin = (d.get("coin") or "").strip().upper()
|
||||||
@@ -9099,17 +9044,9 @@ def add_journal():
|
|||||||
if saved:
|
if saved:
|
||||||
image_filename = saved
|
image_filename = saved
|
||||||
chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{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:
|
else:
|
||||||
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。"
|
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
image_filename = uploaded_tmp
|
|
||||||
chart_msg = f"自动生成K线图失败:{str(e)}"
|
chart_msg = f"自动生成K线图失败:{str(e)}"
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -9118,8 +9055,8 @@ def add_journal():
|
|||||||
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
|
(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,
|
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,
|
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
|
||||||
new_trade_while_occupied, note, image)
|
new_trade_while_occupied, note, image, images_json)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
entry_id,
|
entry_id,
|
||||||
normalize_bj_datetime_storage(d.get("open_datetime")),
|
normalize_bj_datetime_storage(d.get("open_datetime")),
|
||||||
@@ -9130,7 +9067,8 @@ def add_journal():
|
|||||||
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text,
|
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,
|
early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
|
||||||
None, None, None, mood_issues,
|
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
|
from lib.trade.account_risk_lib import on_journal_saved
|
||||||
@@ -9166,41 +9104,27 @@ def api_journals():
|
|||||||
conn.close()
|
conn.close()
|
||||||
result = []
|
result = []
|
||||||
for r in rows:
|
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]
|
item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x]
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return jsonify(result)
|
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"])
|
@app.route("/delete_journal/<jid>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_journal(jid):
|
def delete_journal(jid):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone()
|
row = conn.execute(
|
||||||
if row and row["image"]:
|
"SELECT image, images_json FROM journal_entries WHERE id=?",
|
||||||
img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"])
|
(jid,),
|
||||||
if os.path.exists(img_path):
|
).fetchone()
|
||||||
os.remove(img_path)
|
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.execute("DELETE FROM journal_entries WHERE id=?", (jid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -669,11 +669,12 @@
|
|||||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||||
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
|
||||||
</div>
|
</div>
|
||||||
|
{% from 'journal_upload_slots.html' import journal_upload_slots %}
|
||||||
|
{{ journal_upload_slots() }}
|
||||||
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true">
|
||||||
保存时自动生成 K 线图并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
@@ -701,9 +702,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
|
||||||
</div>
|
|
||||||
<div class="mood-grid" style="margin-top:8px">
|
<div class="mood-grid" style="margin-top:8px">
|
||||||
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||||
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||||
@@ -808,11 +806,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" id="detailBody"></div>
|
<div class="panel-body" id="detailBody"></div>
|
||||||
|
<div id="detailImages" class="journal-detail-images" style="display:none"></div>
|
||||||
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=4"></script>
|
<script src="/static/instance_ui.js?v=5"></script>
|
||||||
|
<script src="/static/journal_upload_slots.js?v=1"></script>
|
||||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
@@ -1397,61 +1397,6 @@ function fillJournalFromTrade(t){
|
|||||||
alert("已填入下方复盘表单,请手动补充主观原因。");
|
alert("已填入下方复盘表单,请手动补充主观原因。");
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefillJournalByImage(){
|
|
||||||
const fileInput = document.getElementById("journal-screenshot");
|
|
||||||
if(!fileInput || !fileInput.files || !fileInput.files.length){
|
|
||||||
alert("请先选择截图");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("screenshot", fileInput.files[0]);
|
|
||||||
fetch("/api/journal_prefill", { method: "POST", body: fd })
|
|
||||||
.then(r=>r.json())
|
|
||||||
.then(res=>{
|
|
||||||
if(!res.ok){
|
|
||||||
alert(res.msg || "AI识别失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = res.data || {};
|
|
||||||
setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime));
|
|
||||||
setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime));
|
|
||||||
setJournalField("coin", d.coin || "");
|
|
||||||
setJournalField("tf", d.tf || "");
|
|
||||||
setJournalField("pnl", d.pnl || "");
|
|
||||||
setJournalField("expect_rr", d.expect_rr || "");
|
|
||||||
setJournalField("real_rr", d.real_rr || "");
|
|
||||||
let entryReason = String(d.entry_reason || "").trim();
|
|
||||||
let customEr = "";
|
|
||||||
if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){
|
|
||||||
// keep
|
|
||||||
} else if(entryReason){
|
|
||||||
customEr = entryReason;
|
|
||||||
entryReason = JOURNAL_ENTRY_REASON_OTHER;
|
|
||||||
} else {
|
|
||||||
entryReason = "";
|
|
||||||
}
|
|
||||||
setJournalField("entry_reason", entryReason);
|
|
||||||
setJournalField("entry_reason_custom", customEr);
|
|
||||||
syncJournalEntryReasonOtherUi();
|
|
||||||
const trig = (d.early_exit_trigger || "").trim();
|
|
||||||
let noteEx = (d.early_exit_note || "").trim();
|
|
||||||
const legacy = (d.early_exit_reason || "").trim();
|
|
||||||
if(!noteEx && legacy && !trig){
|
|
||||||
const sp = splitLegacyEarlyExitReason(legacy);
|
|
||||||
setJournalField("early_exit_trigger", sp.trigger);
|
|
||||||
setJournalField("early_exit_note", sp.note);
|
|
||||||
} else {
|
|
||||||
setJournalField("early_exit_trigger", trig);
|
|
||||||
setJournalField("early_exit_note", noteEx);
|
|
||||||
}
|
|
||||||
setJournalField("note", d.note || "");
|
|
||||||
if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired();
|
|
||||||
recomputeJournalRealRr();
|
|
||||||
alert("已完成预填,请手动检查并补充原因");
|
|
||||||
})
|
|
||||||
.catch(()=>alert("AI识别请求失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recomputeJournalRealRr(){
|
function recomputeJournalRealRr(){
|
||||||
const form = document.getElementById("journal-form");
|
const form = document.getElementById("journal-form");
|
||||||
if(!form) return;
|
if(!form) return;
|
||||||
|
|||||||
+41
-117
@@ -76,6 +76,13 @@ from lib.strategy.strategy_trade_labels import (
|
|||||||
trend_plan_id_from_monitor_row,
|
trend_plan_id_from_monitor_row,
|
||||||
)
|
)
|
||||||
from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders
|
from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders
|
||||||
|
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 (
|
from lib.instance.journal_chart_lib import (
|
||||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||||
JOURNAL_CHART_DEFAULT_TF1,
|
JOURNAL_CHART_DEFAULT_TF1,
|
||||||
@@ -1124,75 +1131,6 @@ def journal_exit_reason_stored(trigger, note):
|
|||||||
return t
|
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 只能从下列完整字符串中选一个(一字不差;截图无法归类则填空字符串):
|
|
||||||
- 趋势多头: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():
|
def init_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -1444,6 +1382,9 @@ def init_db():
|
|||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
||||||
except: pass
|
except: pass
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT")
|
||||||
|
except: pass
|
||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
||||||
except: pass
|
except: pass
|
||||||
@@ -8279,7 +8220,7 @@ def export_journal_entries():
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason,"
|
"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,"
|
"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()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
head = [
|
head = [
|
||||||
@@ -8303,6 +8244,7 @@ def export_journal_entries():
|
|||||||
"new_trade_while_occupied",
|
"new_trade_while_occupied",
|
||||||
"note",
|
"note",
|
||||||
"image",
|
"image",
|
||||||
|
"images_json",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
data = [tuple(r[h] for h in head) for r in rows]
|
data = [tuple(r[h] for h in head) for r in rows]
|
||||||
@@ -8567,16 +8509,16 @@ def add_journal():
|
|||||||
early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否"
|
early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否"
|
||||||
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note)
|
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)
|
exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
|
||||||
image_filename = None
|
|
||||||
uploaded_tmp = None
|
|
||||||
entry_id = uuid.uuid4().hex
|
entry_id = uuid.uuid4().hex
|
||||||
file = request.files.get("screenshot")
|
manual_images = save_journal_slot_uploads(
|
||||||
if file and file.filename:
|
request.files,
|
||||||
ext = os.path.splitext(file.filename)[1]
|
entry_id,
|
||||||
image_filename = f"{uuid.uuid4().hex}{ext}"
|
app.config["UPLOAD_FOLDER"],
|
||||||
save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename))
|
secure_filename_fn=secure_filename,
|
||||||
file.save(save_path)
|
)
|
||||||
uploaded_tmp = image_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"))
|
mood_issues = ",".join(request.form.getlist("mood_issues"))
|
||||||
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
||||||
@@ -8590,7 +8532,10 @@ def add_journal():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
chart_msg = None
|
||||||
if want_exchange_chart and ORDER_CHART_ENABLED:
|
if want_exchange_chart and ORDER_CHART_ENABLED:
|
||||||
coin = (d.get("coin") or "").strip().upper()
|
coin = (d.get("coin") or "").strip().upper()
|
||||||
@@ -8630,17 +8575,9 @@ def add_journal():
|
|||||||
if saved:
|
if saved:
|
||||||
image_filename = saved
|
image_filename = saved
|
||||||
chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{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:
|
else:
|
||||||
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、OKX 网络/代理是否正常。"
|
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、OKX 网络/代理是否正常。"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
image_filename = uploaded_tmp
|
|
||||||
chart_msg = f"自动生成K线图失败:{str(e)}"
|
chart_msg = f"自动生成K线图失败:{str(e)}"
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -8649,8 +8586,8 @@ def add_journal():
|
|||||||
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
|
(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,
|
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,
|
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
|
||||||
new_trade_while_occupied, note, image)
|
new_trade_while_occupied, note, image, images_json)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
entry_id,
|
entry_id,
|
||||||
normalize_bj_datetime_storage(d.get("open_datetime")),
|
normalize_bj_datetime_storage(d.get("open_datetime")),
|
||||||
@@ -8661,7 +8598,8 @@ def add_journal():
|
|||||||
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text,
|
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,
|
early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
|
||||||
None, None, None, mood_issues,
|
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
|
from lib.trade.account_risk_lib import on_journal_saved
|
||||||
@@ -8697,41 +8635,27 @@ def api_journals():
|
|||||||
conn.close()
|
conn.close()
|
||||||
result = []
|
result = []
|
||||||
for r in rows:
|
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]
|
item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x]
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return jsonify(result)
|
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"])
|
@app.route("/delete_journal/<jid>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_journal(jid):
|
def delete_journal(jid):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone()
|
row = conn.execute(
|
||||||
if row and row["image"]:
|
"SELECT image, images_json FROM journal_entries WHERE id=?",
|
||||||
img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"])
|
(jid,),
|
||||||
if os.path.exists(img_path):
|
).fetchone()
|
||||||
os.remove(img_path)
|
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.execute("DELETE FROM journal_entries WHERE id=?", (jid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -698,11 +698,12 @@
|
|||||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||||
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
|
||||||
</div>
|
</div>
|
||||||
|
{% from 'journal_upload_slots.html' import journal_upload_slots %}
|
||||||
|
{{ journal_upload_slots() }}
|
||||||
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true">
|
||||||
保存时自动生成 K 线图并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
@@ -730,9 +731,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
|
||||||
</div>
|
|
||||||
<div class="mood-grid" style="margin-top:8px">
|
<div class="mood-grid" style="margin-top:8px">
|
||||||
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||||
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||||
@@ -837,11 +835,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" id="detailBody"></div>
|
<div class="panel-body" id="detailBody"></div>
|
||||||
|
<div id="detailImages" class="journal-detail-images" style="display:none"></div>
|
||||||
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=4"></script>
|
<script src="/static/instance_ui.js?v=5"></script>
|
||||||
|
<script src="/static/journal_upload_slots.js?v=1"></script>
|
||||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
@@ -1426,61 +1426,6 @@ function fillJournalFromTrade(t){
|
|||||||
alert("已填入下方复盘表单,请手动补充主观原因。");
|
alert("已填入下方复盘表单,请手动补充主观原因。");
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefillJournalByImage(){
|
|
||||||
const fileInput = document.getElementById("journal-screenshot");
|
|
||||||
if(!fileInput || !fileInput.files || !fileInput.files.length){
|
|
||||||
alert("请先选择截图");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("screenshot", fileInput.files[0]);
|
|
||||||
fetch("/api/journal_prefill", { method: "POST", body: fd })
|
|
||||||
.then(r=>r.json())
|
|
||||||
.then(res=>{
|
|
||||||
if(!res.ok){
|
|
||||||
alert(res.msg || "AI识别失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = res.data || {};
|
|
||||||
setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime));
|
|
||||||
setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime));
|
|
||||||
setJournalField("coin", d.coin || "");
|
|
||||||
setJournalField("tf", d.tf || "");
|
|
||||||
setJournalField("pnl", d.pnl || "");
|
|
||||||
setJournalField("expect_rr", d.expect_rr || "");
|
|
||||||
setJournalField("real_rr", d.real_rr || "");
|
|
||||||
let entryReason = String(d.entry_reason || "").trim();
|
|
||||||
let customEr = "";
|
|
||||||
if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){
|
|
||||||
// keep
|
|
||||||
} else if(entryReason){
|
|
||||||
customEr = entryReason;
|
|
||||||
entryReason = JOURNAL_ENTRY_REASON_OTHER;
|
|
||||||
} else {
|
|
||||||
entryReason = "";
|
|
||||||
}
|
|
||||||
setJournalField("entry_reason", entryReason);
|
|
||||||
setJournalField("entry_reason_custom", customEr);
|
|
||||||
syncJournalEntryReasonOtherUi();
|
|
||||||
const trig = (d.early_exit_trigger || "").trim();
|
|
||||||
let noteEx = (d.early_exit_note || "").trim();
|
|
||||||
const legacy = (d.early_exit_reason || "").trim();
|
|
||||||
if(!noteEx && legacy && !trig){
|
|
||||||
const sp = splitLegacyEarlyExitReason(legacy);
|
|
||||||
setJournalField("early_exit_trigger", sp.trigger);
|
|
||||||
setJournalField("early_exit_note", sp.note);
|
|
||||||
} else {
|
|
||||||
setJournalField("early_exit_trigger", trig);
|
|
||||||
setJournalField("early_exit_note", noteEx);
|
|
||||||
}
|
|
||||||
setJournalField("note", d.note || "");
|
|
||||||
if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired();
|
|
||||||
recomputeJournalRealRr();
|
|
||||||
alert("已完成预填,请手动检查并补充原因");
|
|
||||||
})
|
|
||||||
.catch(()=>alert("AI识别请求失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recomputeJournalRealRr(){
|
function recomputeJournalRealRr(){
|
||||||
const form = document.getElementById("journal-form");
|
const form = document.getElementById("journal-form");
|
||||||
if(!form) return;
|
if(!form) return;
|
||||||
|
|||||||
+16
-16
@@ -12,6 +12,7 @@ from lib.instance.journal_chart_lib import (
|
|||||||
JOURNAL_CHART_DEFAULT_TF2,
|
JOURNAL_CHART_DEFAULT_TF2,
|
||||||
normalize_chart_timeframe,
|
normalize_chart_timeframe,
|
||||||
)
|
)
|
||||||
|
from lib.instance.journal_images_lib import journal_image_paths
|
||||||
|
|
||||||
|
|
||||||
def _journal_nz(v: Any, default: str = "无") -> str:
|
def _journal_nz(v: Any, default: str = "无") -> str:
|
||||||
@@ -92,32 +93,31 @@ def collect_images_for_ai_review(
|
|||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
收集传给视觉模型的本地图片路径。
|
收集传给视觉模型的本地图片路径。
|
||||||
- 优先 journal_entries.image 已存附图;
|
- 优先 journal_entries.images_json / image 已存附图(含多周期手动上传);
|
||||||
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
||||||
"""
|
"""
|
||||||
paths: List[str] = []
|
paths: List[str] = []
|
||||||
seen = set()
|
seen = set()
|
||||||
upload_folder = os.path.abspath(upload_folder or "")
|
upload_folder = os.path.abspath(upload_folder or "")
|
||||||
for row in rows or []:
|
for row in rows or []:
|
||||||
candidate = None
|
row_paths = journal_image_paths(row, upload_folder)
|
||||||
try:
|
if row_paths:
|
||||||
keys = row.keys() if hasattr(row, "keys") else []
|
for candidate in row_paths:
|
||||||
except Exception:
|
if candidate not in seen:
|
||||||
keys = []
|
seen.add(candidate)
|
||||||
img = row["image"] if "image" in keys else None
|
paths.append(candidate)
|
||||||
if img:
|
continue
|
||||||
candidate = os.path.join(upload_folder, str(img).strip())
|
if build_chart_if_missing:
|
||||||
elif build_chart_if_missing:
|
|
||||||
try:
|
try:
|
||||||
candidate = build_chart_if_missing(row)
|
candidate = build_chart_if_missing(row)
|
||||||
except Exception:
|
except Exception:
|
||||||
candidate = None
|
candidate = None
|
||||||
if not candidate:
|
if not candidate:
|
||||||
continue
|
continue
|
||||||
candidate = os.path.abspath(candidate)
|
candidate = os.path.abspath(candidate)
|
||||||
if os.path.isfile(candidate) and candidate not in seen:
|
if os.path.isfile(candidate) and candidate not in seen:
|
||||||
seen.add(candidate)
|
seen.add(candidate)
|
||||||
paths.append(candidate)
|
paths.append(candidate)
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,10 @@
|
|||||||
const root = document.getElementById("embed-page-root") || document;
|
const root = document.getElementById("embed-page-root") || document;
|
||||||
global.SymbolLivePrice.init(root);
|
global.SymbolLivePrice.init(root);
|
||||||
}
|
}
|
||||||
|
if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
|
||||||
|
const root = document.getElementById("embed-page-root") || document;
|
||||||
|
global.JournalUploadSlots.init(root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectFragment(html) {
|
function injectFragment(html) {
|
||||||
|
|||||||
@@ -1659,3 +1659,108 @@ html[data-theme="light"] .symbol-live-price--ok {
|
|||||||
border-color: #9ed4b8;
|
border-color: #9ed4b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 复盘:四周期截图上传 / 详情四宫格 ── */
|
||||||
|
.journal-upload-slots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px dashed rgba(130, 145, 190, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(12, 16, 32, 0.35);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-slot-label {
|
||||||
|
color: #9aa3c7;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-slot-input {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-slot-preview {
|
||||||
|
min-height: 72px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-slot-preview--filled {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-slot-thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 140px;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-upload-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #8892b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-detail-images {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px 14px;
|
||||||
|
border-top: 1px solid rgba(130, 145, 190, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-detail-img-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-detail-img-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9aa3c7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-detail-img-thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .journal-upload-slot {
|
||||||
|
background: #f6f8fb;
|
||||||
|
border-color: #c5d0de;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .journal-upload-slot-preview {
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .journal-detail-images {
|
||||||
|
border-top-color: #d0dae4;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .journal-detail-img-thumb {
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,76 @@
|
|||||||
return lines.join("<br>");
|
return lines.join("<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveJournalImages(o) {
|
||||||
|
if (Array.isArray(o.images) && o.images.length) return o.images;
|
||||||
|
if (o.image) return [{ tf: "", file: o.image }];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setJournalDetailImages(o) {
|
||||||
|
const grid = document.getElementById("detailImages");
|
||||||
|
const legacyImg = document.getElementById("detailImage");
|
||||||
|
const images = resolveJournalImages(o || {});
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
if (!images.length) {
|
||||||
|
grid.innerHTML = "";
|
||||||
|
grid.style.display = "none";
|
||||||
|
} else {
|
||||||
|
grid.innerHTML = images
|
||||||
|
.map(function (img) {
|
||||||
|
const tf = String(img.tf || "").trim();
|
||||||
|
const file = String(img.file || "").trim();
|
||||||
|
if (!file) return "";
|
||||||
|
const label = tf ? escapeHtml(tf) : "截图";
|
||||||
|
const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/");
|
||||||
|
return (
|
||||||
|
'<div class="journal-detail-img-cell">' +
|
||||||
|
'<span class="journal-detail-img-label">' +
|
||||||
|
label +
|
||||||
|
"</span>" +
|
||||||
|
'<img class="journal-detail-img-thumb" src="' +
|
||||||
|
src +
|
||||||
|
'" alt="' +
|
||||||
|
label +
|
||||||
|
'" onclick="showImage(this.src)">' +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
grid.style.display = "grid";
|
||||||
|
}
|
||||||
|
if (legacyImg) {
|
||||||
|
legacyImg.src = "";
|
||||||
|
legacyImg.style.display = "none";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyImg) {
|
||||||
|
if (images.length === 1) {
|
||||||
|
legacyImg.src = "/static/images/" + images[0].file;
|
||||||
|
legacyImg.style.display = "block";
|
||||||
|
} else {
|
||||||
|
legacyImg.src = "";
|
||||||
|
legacyImg.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearJournalDetailImages() {
|
||||||
|
const grid = document.getElementById("detailImages");
|
||||||
|
if (grid) {
|
||||||
|
grid.innerHTML = "";
|
||||||
|
grid.style.display = "none";
|
||||||
|
}
|
||||||
|
const legacyImg = document.getElementById("detailImage");
|
||||||
|
if (legacyImg) {
|
||||||
|
legacyImg.src = "";
|
||||||
|
legacyImg.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setJournalDetailBody(o, formatExitLine) {
|
function setJournalDetailBody(o, formatExitLine) {
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
@@ -67,16 +137,7 @@
|
|||||||
}
|
}
|
||||||
setJournalDetailBody(o, formatExitLine);
|
setJournalDetailBody(o, formatExitLine);
|
||||||
clearDetailActions();
|
clearDetailActions();
|
||||||
const imgEl = document.getElementById("detailImage");
|
setJournalDetailImages(o);
|
||||||
if (imgEl) {
|
|
||||||
if (o.image) {
|
|
||||||
imgEl.src = `/static/images/${o.image}`;
|
|
||||||
imgEl.style.display = "block";
|
|
||||||
} else {
|
|
||||||
imgEl.src = "";
|
|
||||||
imgEl.style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof setDetailModalFullscreen === "function") {
|
if (typeof setDetailModalFullscreen === "function") {
|
||||||
setDetailModalFullscreen(false);
|
setDetailModalFullscreen(false);
|
||||||
}
|
}
|
||||||
@@ -265,5 +326,7 @@
|
|||||||
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
|
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
|
||||||
openTradeRecordDetailModal: openTradeRecordDetailModal,
|
openTradeRecordDetailModal: openTradeRecordDetailModal,
|
||||||
clearDetailActions: clearDetailActions,
|
clearDetailActions: clearDetailActions,
|
||||||
|
clearJournalDetailImages: clearJournalDetailImages,
|
||||||
|
setJournalDetailImages: setJournalDetailImages,
|
||||||
};
|
};
|
||||||
})(typeof window !== "undefined" ? window : globalThis);
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 复盘表单:四周期截图槽位本地预览。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const previewUrls = new WeakMap();
|
||||||
|
|
||||||
|
function clearPreview(cell) {
|
||||||
|
if (!cell) return;
|
||||||
|
const prev = previewUrls.get(cell);
|
||||||
|
if (prev) {
|
||||||
|
URL.revokeObjectURL(prev);
|
||||||
|
previewUrls.delete(cell);
|
||||||
|
}
|
||||||
|
cell.innerHTML = "";
|
||||||
|
cell.classList.remove("journal-upload-slot-preview--filled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(cell, file, tf) {
|
||||||
|
clearPreview(cell);
|
||||||
|
if (!file || !cell) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
previewUrls.set(cell, url);
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = tf + " 预览";
|
||||||
|
img.className = "journal-upload-slot-thumb";
|
||||||
|
img.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof global.showImage === "function") {
|
||||||
|
global.showImage(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cell.appendChild(img);
|
||||||
|
cell.classList.add("journal-upload-slot-preview--filled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInput(input) {
|
||||||
|
if (!input || input.dataset.journalSlotBound === "1") return;
|
||||||
|
input.dataset.journalSlotBound = "1";
|
||||||
|
const tf = input.getAttribute("data-tf") || "";
|
||||||
|
const cell = input.closest(".journal-upload-slot");
|
||||||
|
const preview = cell ? cell.querySelector(".journal-upload-slot-preview") : null;
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (!file) {
|
||||||
|
clearPreview(preview);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderPreview(preview, file, tf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(root) {
|
||||||
|
const scope = root || document;
|
||||||
|
scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.JournalUploadSlots = { init: init };
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
init(document);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
init(document);
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -64,6 +64,7 @@ def install_instance_theme_static(app) -> None:
|
|||||||
"time_close_ui.js": "application/javascript; charset=utf-8",
|
"time_close_ui.js": "application/javascript; charset=utf-8",
|
||||||
"manual_order_rr_preview.js": "application/javascript; charset=utf-8",
|
"manual_order_rr_preview.js": "application/javascript; charset=utf-8",
|
||||||
"symbol_live_price.js": "application/javascript; charset=utf-8",
|
"symbol_live_price.js": "application/javascript; charset=utf-8",
|
||||||
|
"journal_upload_slots.js": "application/javascript; charset=utf-8",
|
||||||
"strategy_roll.js": "application/javascript; charset=utf-8",
|
"strategy_roll.js": "application/javascript; charset=utf-8",
|
||||||
"instance_page.css": "text/css; charset=utf-8",
|
"instance_page.css": "text/css; charset=utf-8",
|
||||||
"instance_embed.js": "application/javascript; charset=utf-8",
|
"instance_embed.js": "application/javascript; charset=utf-8",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""复盘记录:多周期截图上传、存储与读取(三所共用)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
|
||||||
|
JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
|
||||||
|
|
||||||
|
|
||||||
|
def journal_upload_field_name(tf: str) -> str:
|
||||||
|
return f"screenshot_{tf}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ext(filename: str) -> str:
|
||||||
|
ext = os.path.splitext(str(filename or ""))[1].lower()
|
||||||
|
return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png"
|
||||||
|
|
||||||
|
|
||||||
|
def save_journal_slot_uploads(
|
||||||
|
files,
|
||||||
|
entry_id: str,
|
||||||
|
upload_folder: str,
|
||||||
|
*,
|
||||||
|
secure_filename_fn: Callable[[str], str],
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]。"""
|
||||||
|
saved: List[Dict[str, str]] = []
|
||||||
|
if not entry_id or not upload_folder:
|
||||||
|
return saved
|
||||||
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
for tf in JOURNAL_UPLOAD_TFS:
|
||||||
|
f = files.get(journal_upload_field_name(tf)) if files else None
|
||||||
|
if not f or not getattr(f, "filename", None):
|
||||||
|
continue
|
||||||
|
ext = _safe_ext(f.filename)
|
||||||
|
fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}")
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
path = os.path.join(upload_folder, fname)
|
||||||
|
f.save(path)
|
||||||
|
saved.append({"tf": tf, "file": fname})
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_images_json(raw: Any) -> List[Dict[str, str]]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
data = raw
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = json.loads(str(raw))
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
out: List[Dict[str, str]] = []
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
tf = str(item.get("tf") or "").strip()
|
||||||
|
file = str(item.get("file") or "").strip()
|
||||||
|
if file:
|
||||||
|
out.append({"tf": tf, "file": file})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def primary_journal_image(
|
||||||
|
manual_images: Sequence[Mapping[str, str]],
|
||||||
|
*,
|
||||||
|
fallback: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if manual_images:
|
||||||
|
return str(manual_images[0].get("file") or "").strip() or None
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""API 输出:解析 images_json,兼容旧单图 image 字段。"""
|
||||||
|
images = parse_images_json(item.get("images_json"))
|
||||||
|
if not images and item.get("image"):
|
||||||
|
images = [{"tf": "", "file": str(item["image"]).strip()}]
|
||||||
|
item["images"] = images
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def journal_image_paths(row: Any, upload_folder: str) -> List[str]:
|
||||||
|
"""删除 / AI 附图:收集本条复盘所有本地图片路径(去重)。"""
|
||||||
|
upload_folder = os.path.abspath(upload_folder or "")
|
||||||
|
paths: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def _add(name: Optional[str]) -> None:
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
|
||||||
|
if os.path.isfile(p) and p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
paths.append(p)
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = row.keys() if hasattr(row, "keys") else ()
|
||||||
|
except Exception:
|
||||||
|
keys = ()
|
||||||
|
|
||||||
|
if "images_json" in keys and row["images_json"]:
|
||||||
|
for img in parse_images_json(row["images_json"]):
|
||||||
|
_add(img.get("file"))
|
||||||
|
if "image" in keys:
|
||||||
|
_add(row["image"])
|
||||||
|
return paths
|
||||||
@@ -583,61 +583,6 @@ function fillJournalFromTrade(t){
|
|||||||
alert("已填入下方复盘表单,请手动补充主观原因。");
|
alert("已填入下方复盘表单,请手动补充主观原因。");
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefillJournalByImage(){
|
|
||||||
const fileInput = document.getElementById("journal-screenshot");
|
|
||||||
if(!fileInput || !fileInput.files || !fileInput.files.length){
|
|
||||||
alert("请先选择截图");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("screenshot", fileInput.files[0]);
|
|
||||||
fetch("/api/journal_prefill", { method: "POST", body: fd })
|
|
||||||
.then(r=>r.json())
|
|
||||||
.then(res=>{
|
|
||||||
if(!res.ok){
|
|
||||||
alert(res.msg || "AI识别失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = res.data || {};
|
|
||||||
setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime));
|
|
||||||
setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime));
|
|
||||||
setJournalField("coin", d.coin || "");
|
|
||||||
setJournalField("tf", d.tf || "");
|
|
||||||
setJournalField("pnl", d.pnl || "");
|
|
||||||
setJournalField("expect_rr", d.expect_rr || "");
|
|
||||||
setJournalField("real_rr", d.real_rr || "");
|
|
||||||
let entryReason = String(d.entry_reason || "").trim();
|
|
||||||
let customEr = "";
|
|
||||||
if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){
|
|
||||||
// keep
|
|
||||||
} else if(entryReason){
|
|
||||||
customEr = entryReason;
|
|
||||||
entryReason = JOURNAL_ENTRY_REASON_OTHER;
|
|
||||||
} else {
|
|
||||||
entryReason = "";
|
|
||||||
}
|
|
||||||
setJournalField("entry_reason", entryReason);
|
|
||||||
setJournalField("entry_reason_custom", customEr);
|
|
||||||
syncJournalEntryReasonOtherUi();
|
|
||||||
const trig = (d.early_exit_trigger || "").trim();
|
|
||||||
let noteEx = (d.early_exit_note || "").trim();
|
|
||||||
const legacy = (d.early_exit_reason || "").trim();
|
|
||||||
if(!noteEx && legacy && !trig){
|
|
||||||
const sp = splitLegacyEarlyExitReason(legacy);
|
|
||||||
setJournalField("early_exit_trigger", sp.trigger);
|
|
||||||
setJournalField("early_exit_note", sp.note);
|
|
||||||
} else {
|
|
||||||
setJournalField("early_exit_trigger", trig);
|
|
||||||
setJournalField("early_exit_note", noteEx);
|
|
||||||
}
|
|
||||||
setJournalField("note", d.note || "");
|
|
||||||
if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired();
|
|
||||||
recomputeJournalRealRr();
|
|
||||||
alert("已完成预填,请手动检查并补充原因");
|
|
||||||
})
|
|
||||||
.catch(()=>alert("AI识别请求失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recomputeJournalRealRr(){
|
function recomputeJournalRealRr(){
|
||||||
const form = document.getElementById("journal-form");
|
const form = document.getElementById("journal-form");
|
||||||
if(!form) return;
|
if(!form) return;
|
||||||
|
|||||||
@@ -345,11 +345,12 @@
|
|||||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||||
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
|
||||||
</div>
|
</div>
|
||||||
|
{% from 'journal_upload_slots.html' import journal_upload_slots %}
|
||||||
|
{{ journal_upload_slots() }}
|
||||||
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true">
|
||||||
保存时自动生成 K 线图并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
@@ -377,9 +378,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
|
||||||
</div>
|
|
||||||
<div class="mood-grid" style="margin-top:8px">
|
<div class="mood-grid" style="margin-top:8px">
|
||||||
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||||
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||||
|
|||||||
@@ -111,11 +111,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" id="detailBody"></div>
|
<div class="panel-body" id="detailBody"></div>
|
||||||
|
<div id="detailImages" class="journal-detail-images" style="display:none"></div>
|
||||||
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=4"></script>
|
<script src="/static/instance_ui.js?v=5"></script>
|
||||||
|
<script src="/static/journal_upload_slots.js?v=1"></script>
|
||||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{# 复盘四周期截图槽位(须加载 journal_upload_slots.js) #}
|
||||||
|
{% macro journal_upload_slots() -%}
|
||||||
|
<div class="journal-upload-slots" id="journal-upload-slots">
|
||||||
|
{% for tf in ['5m', '15m', '1h', '4h'] %}
|
||||||
|
<label class="journal-upload-slot">
|
||||||
|
<span class="journal-upload-slot-label">{{ tf }}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="screenshot_{{ tf }}"
|
||||||
|
accept="image/*"
|
||||||
|
class="journal-upload-slot-input"
|
||||||
|
data-tf="{{ tf }}"
|
||||||
|
>
|
||||||
|
<div class="journal-upload-slot-preview" data-tf="{{ tf }}" aria-hidden="true"></div>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="sub journal-upload-hint">可只传部分周期;保存后按 5m / 15m / 1h / 4h 命名,详情页四宫格查看</p>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""journal_images_lib 单元测试。"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from lib.instance.journal_images_lib import (
|
||||||
|
JOURNAL_UPLOAD_TFS,
|
||||||
|
enrich_journal_api_item,
|
||||||
|
images_json_dumps,
|
||||||
|
journal_image_paths,
|
||||||
|
journal_upload_field_name,
|
||||||
|
parse_images_json,
|
||||||
|
primary_journal_image,
|
||||||
|
save_journal_slot_uploads,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFile:
|
||||||
|
def __init__(self, filename: str, data: bytes):
|
||||||
|
self.filename = filename
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def save(self, path: str) -> None:
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(self._data)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFiles:
|
||||||
|
def __init__(self, mapping):
|
||||||
|
self._mapping = mapping
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
return self._mapping.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalImagesLibTest(unittest.TestCase):
|
||||||
|
def test_field_names(self):
|
||||||
|
self.assertEqual(journal_upload_field_name("5m"), "screenshot_5m")
|
||||||
|
|
||||||
|
def test_save_slot_uploads_partial(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
files = _FakeFiles(
|
||||||
|
{
|
||||||
|
"screenshot_5m": _FakeFile("a.png", b"png5"),
|
||||||
|
"screenshot_1h": _FakeFile("b.jpg", b"jpg1"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
saved = save_journal_slot_uploads(
|
||||||
|
files,
|
||||||
|
"abc123",
|
||||||
|
tmp,
|
||||||
|
secure_filename_fn=lambda x: x,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(saved), 2)
|
||||||
|
self.assertEqual(saved[0]["tf"], "5m")
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(tmp, saved[0]["file"])))
|
||||||
|
self.assertEqual(saved[1]["tf"], "1h")
|
||||||
|
|
||||||
|
def test_parse_and_enrich(self):
|
||||||
|
raw = images_json_dumps([{"tf": "5m", "file": "journal_x_5m.png"}])
|
||||||
|
item = enrich_journal_api_item({"images_json": raw, "image": "legacy.png"})
|
||||||
|
self.assertEqual(len(item["images"]), 1)
|
||||||
|
self.assertEqual(item["images"][0]["tf"], "5m")
|
||||||
|
|
||||||
|
legacy = enrich_journal_api_item({"image": "only.png"})
|
||||||
|
self.assertEqual(legacy["images"][0]["file"], "only.png")
|
||||||
|
|
||||||
|
def test_journal_image_paths_dedupe(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "same.png")
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(b"x")
|
||||||
|
row = {
|
||||||
|
"image": "same.png",
|
||||||
|
"images_json": json.dumps([{"tf": "5m", "file": "same.png"}]),
|
||||||
|
}
|
||||||
|
paths = journal_image_paths(row, tmp)
|
||||||
|
self.assertEqual(len(paths), 1)
|
||||||
|
|
||||||
|
def test_primary_journal_image(self):
|
||||||
|
self.assertEqual(
|
||||||
|
primary_journal_image([{"tf": "5m", "file": "a.png"}]),
|
||||||
|
"a.png",
|
||||||
|
)
|
||||||
|
self.assertIsNone(primary_journal_image([]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user