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()