feat: review entry reason picker uses dropdown instead of prompt

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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-07 00:38:16 +08:00
parent f580e4542d
commit 1092816359
10 changed files with 188 additions and 34 deletions
+5 -4
View File
@@ -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()
+5 -4
View File
@@ -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()
+6 -4
View File
@@ -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()
+10
View File
@@ -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}
+86
View File
@@ -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);
+10 -3
View File
@@ -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("核对保存请求失败"));
});
}
+1 -1
View File
@@ -116,7 +116,7 @@
</div>
</div>
<script src="/static/instance_ui.js?v=6"></script>
<script src="/static/instance_ui.js?v=7"></script>
<script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script>
+11 -4
View File
@@ -555,7 +555,7 @@
</div>
</div>
<script src="/static/instance_ui.js?v=6"></script>
<script src="/static/instance_ui.js?v=7"></script>
<script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script>
@@ -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("核对保存请求失败"));
});
}
+41 -11
View File
@@ -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()
+13 -3
View File
@@ -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()