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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("核对保存请求失败"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("核对保存请求失败"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user