From 1092816359176669b5c3e1ac34133f0bd0045a5d Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 7 Jul 2026 00:38:16 +0800 Subject: [PATCH] feat: review entry reason picker uses dropdown instead of prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modal select for trade record review, expand options to two-level labels (反转/启动A), and accept legacy short labels on save. Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 9 +- crypto_monitor_gate/app.py | 9 +- crypto_monitor_okx/app.py | 10 ++- lib/common/static/instance_page.css | 10 +++ lib/common/static/instance_ui.js | 86 +++++++++++++++++++ .../templates/embed_boot_scripts.html | 13 ++- lib/instance/templates/embed_shell.html | 2 +- lib/instance/templates/index.html | 15 +++- lib/trade/entry_model_lib.py | 52 ++++++++--- tests/test_entry_model_lib.py | 16 +++- 10 files changed, 188 insertions(+), 34 deletions(-) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 85947af..17b4cf5 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1131,15 +1131,16 @@ STATS_SEGMENT_DEFS = ( ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) def normalize_entry_reason(raw, custom_text=None): - v = str(raw or "").strip() - return v if v in ENTRY_REASON_OPTIONS else "" + from lib.trade.entry_model_lib import normalize_review_entry_reason + + return normalize_review_entry_reason(raw, ENTRY_REASON_OPTIONS) def entry_reason_valid_for_storage(s): t = str(s or "").strip() if not t: return True - return t in ENTRY_REASON_OPTIONS + return bool(normalize_entry_reason(t)) def normalize_early_exit_trigger(raw): @@ -9552,7 +9553,7 @@ def api_trade_record_review_update(): s = str(payload.get("reviewed_entry_reason") or "").strip() if s and not entry_reason_valid_for_storage(s): return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 - reviewed_entry_reason_update = s or None + reviewed_entry_reason_update = normalize_entry_reason(s) or None conn = get_db() row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 452fcda..36fca09 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -1121,15 +1121,16 @@ STATS_SEGMENT_DEFS = ( ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) def normalize_entry_reason(raw, custom_text=None): - v = str(raw or "").strip() - return v if v in ENTRY_REASON_OPTIONS else "" + from lib.trade.entry_model_lib import normalize_review_entry_reason + + return normalize_review_entry_reason(raw, ENTRY_REASON_OPTIONS) def entry_reason_valid_for_storage(s): t = str(s or "").strip() if not t: return True - return t in ENTRY_REASON_OPTIONS + return bool(normalize_entry_reason(t)) def normalize_early_exit_trigger(raw): @@ -9415,7 +9416,7 @@ def api_trade_record_review_update(): s = str(payload.get("reviewed_entry_reason") or "").strip() if s and not entry_reason_valid_for_storage(s): return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 - reviewed_entry_reason_update = s or None + reviewed_entry_reason_update = normalize_entry_reason(s) or None conn = get_db() row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone() diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 6d73586..7bc84d9 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -1121,8 +1121,9 @@ STATS_SEGMENT_DEFS = ( ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) def normalize_entry_reason(raw, custom_text=None): - v = str(raw or "").strip() - return v if v in ENTRY_REASON_OPTIONS else "" + from lib.trade.entry_model_lib import normalize_review_entry_reason + + return normalize_review_entry_reason(raw, ENTRY_REASON_OPTIONS) def normalize_early_exit_trigger(raw): @@ -8933,9 +8934,10 @@ def api_trade_record_review_update(): reviewed_entry_reason_update = _MISSING_ER if "reviewed_entry_reason" in payload: s = str(payload.get("reviewed_entry_reason") or "").strip() - if s and not normalize_entry_reason(s): + norm = normalize_entry_reason(s) if s else None + if s and not norm: return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 - reviewed_entry_reason_update = s or None + reviewed_entry_reason_update = norm conn = get_db() row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index 384540d..9f00c12 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -208,6 +208,16 @@ .tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} .tpsl-modal-submit{background:#2d6a4f;color:#fff} .tpsl-modal-cancel{background:#3a3f52;color:#ddd} + .review-entry-reason-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9100;align-items:center;justify-content:center;padding:16px} + .review-entry-reason-backdrop.open{display:flex} + .review-entry-reason-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(480px,100%);max-height:90vh;overflow:auto} + .review-entry-reason-modal h3{margin:0 0 8px;font-size:1rem;color:#fff} + .review-entry-reason-hint{margin:0 0 12px;font-size:.82rem;color:#9aa3c7;line-height:1.45} + .review-entry-reason-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #3a4a66;background:#121726;color:#e8ecff;font-size:.9rem} + .review-entry-reason-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .review-entry-reason-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .review-entry-reason-ok{background:#2d6a4f;color:#fff} + .review-entry-reason-cancel{background:#3a3f52;color:#ddd} .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} .pos-label{font-size:.72rem;color:#7d8799} diff --git a/lib/common/static/instance_ui.js b/lib/common/static/instance_ui.js index d7fb1da..ae30c6c 100644 --- a/lib/common/static/instance_ui.js +++ b/lib/common/static/instance_ui.js @@ -282,6 +282,91 @@ el.style.display = html ? "flex" : "none"; } + function promptReviewEntryReason(options, currentValue) { + const opts = Array.isArray(options) ? options : []; + const cur = String(currentValue == null ? "" : currentValue).trim(); + return new Promise(function (resolve) { + const backdrop = document.createElement("div"); + backdrop.className = "review-entry-reason-backdrop open"; + const modal = document.createElement("div"); + modal.className = "review-entry-reason-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + + const title = document.createElement("h3"); + title.textContent = "开仓类型"; + modal.appendChild(title); + + const hint = document.createElement("p"); + hint.className = "review-entry-reason-hint"; + hint.textContent = "请选择下拉选项之一;选「不改该项」则保留原值。"; + modal.appendChild(hint); + + const select = document.createElement("select"); + select.className = "review-entry-reason-select"; + const emptyOpt = document.createElement("option"); + emptyOpt.value = ""; + emptyOpt.textContent = "(不改该项)"; + select.appendChild(emptyOpt); + + const seen = new Set([""]); + if (cur && opts.indexOf(cur) < 0) { + const curOpt = document.createElement("option"); + curOpt.value = cur; + curOpt.textContent = cur + "(当前)"; + select.appendChild(curOpt); + seen.add(cur); + } + opts.forEach(function (opt) { + const v = String(opt || "").trim(); + if (!v || seen.has(v)) return; + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + select.appendChild(o); + seen.add(v); + }); + if (cur) select.value = cur; + modal.appendChild(select); + + const actions = document.createElement("div"); + actions.className = "review-entry-reason-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "review-entry-reason-cancel"; + cancelBtn.textContent = "取消"; + const okBtn = document.createElement("button"); + okBtn.type = "button"; + okBtn.className = "review-entry-reason-ok"; + okBtn.textContent = "确定"; + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + function cleanup(result) { + document.removeEventListener("keydown", onKey); + backdrop.remove(); + resolve(result); + } + function onKey(ev) { + if (ev.key === "Escape") cleanup(null); + } + cancelBtn.addEventListener("click", function () { + cleanup(null); + }); + backdrop.addEventListener("click", function (ev) { + if (ev.target === backdrop) cleanup(null); + }); + okBtn.addEventListener("click", function () { + cleanup(select.value); + }); + document.addEventListener("keydown", onKey); + select.focus(); + }); + } + function openTradeRecordDetailModal(tr) { const row = parseTradeRecordRow(tr); if (!row) return; @@ -327,5 +412,6 @@ clearDetailActions: clearDetailActions, clearJournalDetailImages: clearJournalDetailImages, setJournalDetailImages: setJournalDetailImages, + promptReviewEntryReason: promptReviewEntryReason, }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 09a8009..938778f 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -155,6 +155,13 @@ function toggleReviewMode(){ function editTradeRecordReview(t){ if(!t) return; + const pickEntryReason = function(){ + if(window.InstanceUI && typeof InstanceUI.promptReviewEntryReason === "function"){ + return InstanceUI.promptReviewEntryReason(JOURNAL_ENTRY_REASON_OPTIONS, t.effective_entry_reason || ""); + } + const entryHint = "开仓类型(下拉选项之一,留空=不改该项)"; + return Promise.resolve(prompt(entryHint, String(t.effective_entry_reason || ""))); + }; const opened = prompt("开仓时间(YYYY-MM-DD HH:MM:SS)", normalizeBeijingDatetimeString(t.opened_at || "")); if(opened === null) return; const closed = prompt("平仓时间(YYYY-MM-DD HH:MM:SS)", normalizeBeijingDatetimeString(t.closed_at || "")); @@ -168,9 +175,8 @@ function editTradeRecordReview(t){ const result = prompt("结果(止盈/止损/保本止盈/移动止盈/手动平仓/时间平仓)", String(t.result || "")); if(result === null) return; const note = prompt("备注(可空)", String(t.miss_reason || "")) ?? ""; - const entryHint = "开仓类型(下拉选项之一,留空=不改该项)"; - const entryIn = prompt(entryHint, String(t.effective_entry_reason || "")); - if(entryIn === null) return; + pickEntryReason().then(function(entryIn){ + if(entryIn === null) return; const payload = { id: t.id, reviewed_opened_at: normalizeBeijingDatetimeString(opened), @@ -198,6 +204,7 @@ function editTradeRecordReview(t){ reloadInstancePage(); }) .catch(()=>alert("核对保存请求失败")); + }); } diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 633ca4a..f703116 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -116,7 +116,7 @@ - + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 570c199..a60dfc7 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -555,7 +555,7 @@ - + @@ -719,6 +719,13 @@ function toggleReviewMode(){ function editTradeRecordReview(t){ if(!t) return; + const pickEntryReason = function(){ + if(window.InstanceUI && typeof InstanceUI.promptReviewEntryReason === "function"){ + return InstanceUI.promptReviewEntryReason(JOURNAL_ENTRY_REASON_OPTIONS, t.effective_entry_reason || ""); + } + const entryHint = "开仓类型(下拉选项之一,留空=不改该项)"; + return Promise.resolve(prompt(entryHint, String(t.effective_entry_reason || ""))); + }; const opened = prompt("开仓时间(YYYY-MM-DD HH:MM:SS)", normalizeBeijingDatetimeString(t.opened_at || "")); if(opened === null) return; const closed = prompt("平仓时间(YYYY-MM-DD HH:MM:SS)", normalizeBeijingDatetimeString(t.closed_at || "")); @@ -732,9 +739,8 @@ function editTradeRecordReview(t){ const result = prompt("结果(止盈/止损/保本止盈/移动止盈/手动平仓/时间平仓)", String(t.result || "")); if(result === null) return; const note = prompt("备注(可空)", String(t.miss_reason || "")) ?? ""; - const entryHint = "开仓类型(下拉选项之一,留空=不改该项)"; - const entryIn = prompt(entryHint, String(t.effective_entry_reason || "")); - if(entryIn === null) return; + pickEntryReason().then(function(entryIn){ + if(entryIn === null) return; const payload = { id: t.id, reviewed_opened_at: normalizeBeijingDatetimeString(opened), @@ -762,6 +768,7 @@ function editTradeRecordReview(t){ window.location.href = `${window.location.pathname}?_ts=${Date.now()}`; }) .catch(()=>alert("核对保存请求失败")); + }); } diff --git a/lib/trade/entry_model_lib.py b/lib/trade/entry_model_lib.py index da64e44..4b84de4 100644 --- a/lib/trade/entry_model_lib.py +++ b/lib/trade/entry_model_lib.py @@ -48,13 +48,7 @@ ENTRY_CATEGORY_LABELS: dict[str, str] = { ENTRY_CATEGORY_SWING: "波段", } -TREND_DIV_ENTRY_REASON_LABELS: Tuple[str, ...] = ( - "启动A", - "启动B", - "大分歧A", - "大分歧B", - "小分歧", -) +TRADE_STYLE_FALLBACK_ENTRY_REASONS: Tuple[str, ...] = ("趋势单", "波段单") INTRADAY_LEGACY_TREND_ENTRY_REASONS: Tuple[str, ...] = ( "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", @@ -281,23 +275,55 @@ def trade_style_label_zh(trade_style: str) -> str: return "波段单" if (trade_style or "").strip().lower() == "swing" else "趋势单" +def trend_div_entry_reason_display_options() -> Tuple[str, ...]: + return tuple(entry_model_display_label(code) for code, _, _, _, _ in _ENTRY_SPECS) + + +def intraday_entry_reason_display_options() -> Tuple[str, ...]: + return tuple(entry_model_display_label(code) for code, _, _, _, _ in _INTRADAY_ENTRY_SPECS) + + +def normalize_review_entry_reason(raw: Optional[str], allowed: Sequence[str]) -> str: + """复盘/核对开仓类型:允许两级展示名,兼容旧短标签。""" + s = (raw or "").strip() + if not s: + return "" + allowed_set = frozenset(allowed) + if s in allowed_set: + return s + disp = format_entry_type_display(s) + if disp in allowed_set: + return disp + code = normalize_entry_model_code(s) + if code: + disp2 = entry_model_display_label(code) + if disp2 in allowed_set: + return disp2 + return "" + + def trend_manual_entry_reason_count(policy: TradePolicy) -> int: if is_intraday_trading_profile(policy): - return len(INTRADAY_LEGACY_TREND_ENTRY_REASONS) - return len(TREND_DIV_ENTRY_REASON_LABELS) + return len(INTRADAY_LEGACY_TREND_ENTRY_REASONS) + len(intraday_entry_reason_display_options()) + return len(trend_div_entry_reason_display_options()) + len(TRADE_STYLE_FALLBACK_ENTRY_REASONS) def build_trend_div_entry_reason_options( strategy_options: Sequence[str], ) -> Tuple[str, ...]: - return TREND_DIV_ENTRY_REASON_LABELS + tuple(strategy_options) + return trend_div_entry_reason_display_options() + TRADE_STYLE_FALLBACK_ENTRY_REASONS + tuple(strategy_options) def build_intraday_entry_reason_options( key_options: Sequence[str], strategy_options: Sequence[str], ) -> Tuple[str, ...]: - return INTRADAY_LEGACY_TREND_ENTRY_REASONS + tuple(key_options) + tuple(strategy_options) + return ( + INTRADAY_LEGACY_TREND_ENTRY_REASONS + + intraday_entry_reason_display_options() + + tuple(key_options) + + tuple(strategy_options) + ) def entry_reason_options_for_policy( @@ -468,3 +494,7 @@ def migrate_entry_model_columns(conn) -> None: conn.execute(f"ALTER TABLE {table} ADD COLUMN entry_model TEXT") except Exception: pass + + +# 兼容旧引用:现为两级展示文案 +TREND_DIV_ENTRY_REASON_LABELS = trend_div_entry_reason_display_options() diff --git a/tests/test_entry_model_lib.py b/tests/test_entry_model_lib.py index 89b47a3..871c851 100644 --- a/tests/test_entry_model_lib.py +++ b/tests/test_entry_model_lib.py @@ -35,7 +35,7 @@ class TestEntryModelLib(unittest.TestCase): } ) self.assertTrue(is_intraday_trading_profile(policy)) - self.assertEqual(trend_manual_entry_reason_count(policy), 5) + self.assertEqual(trend_manual_entry_reason_count(policy), 7) def test_trend_div_profile_alt(self): policy = load_trade_policy( @@ -45,7 +45,7 @@ class TestEntryModelLib(unittest.TestCase): } ) self.assertFalse(is_intraday_trading_profile(policy)) - self.assertEqual(trend_manual_entry_reason_count(policy), 5) + self.assertEqual(trend_manual_entry_reason_count(policy), 7) def test_entry_model_maps_trade_style(self): self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_LAUNCH_A), "trend") @@ -144,9 +144,19 @@ class TestEntryModelLib(unittest.TestCase): def test_build_trend_div_journal_options(self): opts = build_trend_div_entry_reason_options(("趋势回调",)) - self.assertEqual(opts[:5], ("启动A", "启动B", "大分歧A", "大分歧B", "小分歧")) + self.assertEqual(opts[:5], ("反转/启动A", "反转/启动B", "顺势/大分歧A", "顺势/大分歧B", "波段单/小分歧")) + self.assertIn("趋势单", opts) + self.assertIn("波段单", opts) self.assertIn("趋势回调", opts) + def test_normalize_review_entry_reason(self): + from lib.trade.entry_model_lib import normalize_review_entry_reason + + allowed = build_trend_div_entry_reason_options(()) + self.assertEqual(normalize_review_entry_reason("反转/启动A", allowed), "反转/启动A") + self.assertEqual(normalize_review_entry_reason("启动A", allowed), "反转/启动A") + self.assertEqual(normalize_review_entry_reason("趋势单", allowed), "趋势单") + if __name__ == "__main__": unittest.main()