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()
|
||||
|
||||
@@ -702,11 +702,12 @@
|
||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||
<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>
|
||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||
</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">
|
||||
<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 线图并作为截图
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
@@ -734,9 +735,6 @@
|
||||
</select>
|
||||
</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">
|
||||
<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 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)">
|
||||
</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/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
@@ -1430,61 +1430,6 @@ function fillJournalFromTrade(t){
|
||||
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(){
|
||||
const form = document.getElementById("journal-form");
|
||||
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,
|
||||
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,
|
||||
@@ -1131,75 +1138,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)
|
||||
@@ -1454,6 +1392,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
|
||||
@@ -8730,7 +8671,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 = [
|
||||
@@ -8754,6 +8695,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]
|
||||
@@ -9036,16 +8978,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", ""))
|
||||
@@ -9059,7 +9001,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()
|
||||
@@ -9099,17 +9044,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 是否安装、Gate 网络/代理是否正常。"
|
||||
except Exception as e:
|
||||
image_filename = uploaded_tmp
|
||||
chart_msg = f"自动生成K线图失败:{str(e)}"
|
||||
|
||||
conn = get_db()
|
||||
@@ -9118,8 +9055,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")),
|
||||
@@ -9130,7 +9067,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
|
||||
@@ -9166,41 +9104,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()
|
||||
|
||||
@@ -669,11 +669,12 @@
|
||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||
<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>
|
||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||
</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">
|
||||
<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 线图并作为截图
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
@@ -701,9 +702,6 @@
|
||||
</select>
|
||||
</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">
|
||||
<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 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)">
|
||||
</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/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
@@ -1397,61 +1397,6 @@ function fillJournalFromTrade(t){
|
||||
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(){
|
||||
const form = document.getElementById("journal-form");
|
||||
if(!form) return;
|
||||
|
||||
+41
-117
@@ -76,6 +76,13 @@ from lib.strategy.strategy_trade_labels import (
|
||||
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.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,
|
||||
@@ -1124,75 +1131,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 只能从下列完整字符串中选一个(一字不差;截图无法归类则填空字符串):
|
||||
- 趋势多头: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)
|
||||
@@ -1444,6 +1382,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
|
||||
@@ -8279,7 +8220,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 = [
|
||||
@@ -8303,6 +8244,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]
|
||||
@@ -8567,16 +8509,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", ""))
|
||||
@@ -8590,7 +8532,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()
|
||||
@@ -8630,17 +8575,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 是否安装、OKX 网络/代理是否正常。"
|
||||
except Exception as e:
|
||||
image_filename = uploaded_tmp
|
||||
chart_msg = f"自动生成K线图失败:{str(e)}"
|
||||
|
||||
conn = get_db()
|
||||
@@ -8649,8 +8586,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")),
|
||||
@@ -8661,7 +8598,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
|
||||
@@ -8697,41 +8635,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()
|
||||
|
||||
@@ -698,11 +698,12 @@
|
||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||
<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>
|
||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||
</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">
|
||||
<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 线图并作为截图
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
@@ -730,9 +731,6 @@
|
||||
</select>
|
||||
</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">
|
||||
<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 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)">
|
||||
</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/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
@@ -1426,61 +1426,6 @@ function fillJournalFromTrade(t){
|
||||
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(){
|
||||
const form = document.getElementById("journal-form");
|
||||
if(!form) return;
|
||||
|
||||
+16
-16
@@ -12,6 +12,7 @@ from lib.instance.journal_chart_lib import (
|
||||
JOURNAL_CHART_DEFAULT_TF2,
|
||||
normalize_chart_timeframe,
|
||||
)
|
||||
from lib.instance.journal_images_lib import journal_image_paths
|
||||
|
||||
|
||||
def _journal_nz(v: Any, default: str = "无") -> str:
|
||||
@@ -92,32 +93,31 @@ def collect_images_for_ai_review(
|
||||
) -> List[str]:
|
||||
"""
|
||||
收集传给视觉模型的本地图片路径。
|
||||
- 优先 journal_entries.image 已存附图;
|
||||
- 优先 journal_entries.images_json / image 已存附图(含多周期手动上传);
|
||||
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
||||
"""
|
||||
paths: List[str] = []
|
||||
seen = set()
|
||||
upload_folder = os.path.abspath(upload_folder or "")
|
||||
for row in rows or []:
|
||||
candidate = None
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else []
|
||||
except Exception:
|
||||
keys = []
|
||||
img = row["image"] if "image" in keys else None
|
||||
if img:
|
||||
candidate = os.path.join(upload_folder, str(img).strip())
|
||||
elif build_chart_if_missing:
|
||||
row_paths = journal_image_paths(row, upload_folder)
|
||||
if row_paths:
|
||||
for candidate in row_paths:
|
||||
if candidate not in seen:
|
||||
seen.add(candidate)
|
||||
paths.append(candidate)
|
||||
continue
|
||||
if build_chart_if_missing:
|
||||
try:
|
||||
candidate = build_chart_if_missing(row)
|
||||
except Exception:
|
||||
candidate = None
|
||||
if not candidate:
|
||||
continue
|
||||
candidate = os.path.abspath(candidate)
|
||||
if os.path.isfile(candidate) and candidate not in seen:
|
||||
seen.add(candidate)
|
||||
paths.append(candidate)
|
||||
if not candidate:
|
||||
continue
|
||||
candidate = os.path.abspath(candidate)
|
||||
if os.path.isfile(candidate) and candidate not in seen:
|
||||
seen.add(candidate)
|
||||
paths.append(candidate)
|
||||
return paths
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
const root = document.getElementById("embed-page-root") || document;
|
||||
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) {
|
||||
|
||||
@@ -1659,3 +1659,108 @@ html[data-theme="light"] .symbol-live-price--ok {
|
||||
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>");
|
||||
}
|
||||
|
||||
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) {
|
||||
const body = document.getElementById("detailBody");
|
||||
if (!body) return;
|
||||
@@ -67,16 +137,7 @@
|
||||
}
|
||||
setJournalDetailBody(o, formatExitLine);
|
||||
clearDetailActions();
|
||||
const imgEl = document.getElementById("detailImage");
|
||||
if (imgEl) {
|
||||
if (o.image) {
|
||||
imgEl.src = `/static/images/${o.image}`;
|
||||
imgEl.style.display = "block";
|
||||
} else {
|
||||
imgEl.src = "";
|
||||
imgEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
setJournalDetailImages(o);
|
||||
if (typeof setDetailModalFullscreen === "function") {
|
||||
setDetailModalFullscreen(false);
|
||||
}
|
||||
@@ -265,5 +326,7 @@
|
||||
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
|
||||
openTradeRecordDetailModal: openTradeRecordDetailModal,
|
||||
clearDetailActions: clearDetailActions,
|
||||
clearJournalDetailImages: clearJournalDetailImages,
|
||||
setJournalDetailImages: setJournalDetailImages,
|
||||
};
|
||||
})(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",
|
||||
"manual_order_rr_preview.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",
|
||||
"instance_page.css": "text/css; 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("已填入下方复盘表单,请手动补充主观原因。");
|
||||
}
|
||||
|
||||
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(){
|
||||
const form = document.getElementById("journal-form");
|
||||
if(!form) return;
|
||||
|
||||
@@ -345,11 +345,12 @@
|
||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||
<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>
|
||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||
</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">
|
||||
<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 线图并作为截图
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
@@ -377,9 +378,6 @@
|
||||
</select>
|
||||
</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">
|
||||
<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 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)">
|
||||
</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/time_close_ui.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