feat: two-level entry model UI for trend accounts (reversal/trend/swing)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-06 19:02:18 +08:00
parent eb6788091d
commit ff1bcd69d2
14 changed files with 289 additions and 146 deletions
+3
View File
@@ -1,4 +1,7 @@
.order-trade-style-hint{font-size:.78rem;color:#8fc8ff;margin-left:4px;white-space:nowrap}
.order-entry-model-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em}
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
+107 -23
View File
@@ -1,38 +1,125 @@
(function (global) {
function tradeStyleForSelect(sel) {
if (!sel || !sel.value) return "trend";
var opt = sel.options[sel.selectedIndex];
if (opt && opt.getAttribute) {
var ds = opt.getAttribute("data-trade-style");
if (ds === "swing" || ds === "trend") return ds;
}
function categoriesData() {
return global.ORDER_ENTRY_MODEL_CATEGORIES || [];
}
function codeToCategoryMap() {
return global.ORDER_ENTRY_MODEL_CODE_TO_CATEGORY || {};
}
function tradeStyleForCode(code) {
var map = global.ORDER_ENTRY_MODEL_TRADE_STYLE || {};
return map[sel.value] || "trend";
return map[code] || "trend";
}
function findOption(catKey, code) {
var cats = categoriesData();
for (var i = 0; i < cats.length; i++) {
if (cats[i].key !== catKey) continue;
var opts = cats[i].options || [];
for (var j = 0; j < opts.length; j++) {
if (opts[j].code === code) return opts[j];
}
}
return null;
}
function rebuildEntryModelSubSelect(preserveCode) {
var catSel = document.getElementById("order-entry-category");
var modelSel = document.getElementById("order-entry-model");
if (!catSel || !modelSel) return;
var catKey = catSel.value;
var cats = categoriesData();
var cat = null;
for (var i = 0; i < cats.length; i++) {
if (cats[i].key === catKey) {
cat = cats[i];
break;
}
}
modelSel.innerHTML = "";
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "类型";
modelSel.appendChild(placeholder);
if (!cat || !cat.options || !cat.options.length) {
modelSel.disabled = true;
modelSel.value = "";
syncOrderEntryModelTradeStyle();
return;
}
modelSel.disabled = false;
var pick = preserveCode || "";
for (var k = 0; k < cat.options.length; k++) {
var o = cat.options[k];
var opt = document.createElement("option");
opt.value = o.code;
opt.textContent = o.label;
if (o.trade_style) opt.setAttribute("data-trade-style", o.trade_style);
if (o.help) opt.title = o.help;
modelSel.appendChild(opt);
}
if (pick && findOption(catKey, pick)) {
modelSel.value = pick;
} else if (cat.options.length === 1) {
modelSel.value = cat.options[0].code;
} else {
modelSel.value = "";
}
syncOrderEntryModelTradeStyle();
}
function syncOrderEntryModelTradeStyle() {
var sel = document.getElementById("order-entry-model");
var modelSel = document.getElementById("order-entry-model");
var hidden = document.getElementById("order-trade-style-hidden");
var hint = document.getElementById("order-trade-style-hint");
if (!sel || !hidden) return;
if (!modelSel || !hidden) return;
var labels = { trend: "趋势单", swing: "波段单" };
var ts = tradeStyleForSelect(sel);
var code = modelSel.value || "";
var ts = tradeStyleForCode(code);
hidden.value = ts;
if (hint) hint.textContent = labels[ts] || ts;
}
function initOrderEntryModelSelect(root) {
var scope = root && root.querySelector ? root : document;
var sel =
var catSel =
scope === document
? document.getElementById("order-entry-category")
: scope.querySelector("#order-entry-category");
var modelSel =
scope === document
? document.getElementById("order-entry-model")
: scope.querySelector("#order-entry-model");
if (!sel) return;
if (sel.dataset.entryModelWired !== "1") {
sel.dataset.entryModelWired = "1";
sel.addEventListener("change", syncOrderEntryModelTradeStyle);
if (!catSel || !modelSel) return;
if (catSel.dataset.entryModelWired !== "1") {
catSel.dataset.entryModelWired = "1";
catSel.addEventListener("change", function () {
rebuildEntryModelSubSelect("");
});
}
syncOrderEntryModelTradeStyle();
if (modelSel.dataset.entryModelWired !== "1") {
modelSel.dataset.entryModelWired = "1";
modelSel.addEventListener("change", syncOrderEntryModelTradeStyle);
}
var presetCode = modelSel.getAttribute("data-preset-code") || modelSel.value || "";
if (presetCode) {
var catMap = codeToCategoryMap();
var catKey = catMap[presetCode];
if (catKey) {
catSel.value = catKey;
rebuildEntryModelSubSelect(presetCode);
return;
}
}
rebuildEntryModelSubSelect("");
}
global.paintOrderLeverageHint = function (leverage) {
@@ -51,12 +138,9 @@
global.initOrderEntryModelSelect = initOrderEntryModelSelect;
global.syncOrderEntryModelTradeStyle = syncOrderEntryModelTradeStyle;
global.rebuildEntryModelSubSelect = rebuildEntryModelSubSelect;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
initOrderEntryModelSelect();
});
} else {
document.addEventListener("DOMContentLoaded", function () {
initOrderEntryModelSelect();
}
});
})(typeof window !== "undefined" ? window : globalThis);
@@ -1,52 +1,5 @@
<script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const ORDER_ENTRY_MODEL_TRADE_STYLE = {{ entry_model_trade_style_map | tojson }};
function tradeStyleForEntryModelSelect(sel){
if(!sel || !sel.value) return "trend";
var opt = sel.options[sel.selectedIndex];
if(opt){
var ds = opt.getAttribute("data-trade-style");
if(ds === "swing" || ds === "trend") return ds;
}
return (ORDER_ENTRY_MODEL_TRADE_STYLE && ORDER_ENTRY_MODEL_TRADE_STYLE[sel.value]) || "trend";
}
function syncOrderEntryModelTradeStyle(){
var sel = document.getElementById("order-entry-model");
var hidden = document.getElementById("order-trade-style-hidden");
var hint = document.getElementById("order-trade-style-hint");
if(!sel || !hidden) return;
var labels = { trend: "趋势单", swing: "波段单" };
var ts = tradeStyleForEntryModelSelect(sel);
hidden.value = ts;
if(hint) hint.textContent = labels[ts] || ts;
}
function paintOrderLeverageHint(leverage){
var hidden = document.getElementById("order-leverage");
var hint = document.getElementById("order-leverage-hint");
if(!hidden && !hint) return;
var lev = parseInt(leverage, 10);
if(!Number.isFinite(lev) || lev <= 0){
if(hint) hint.textContent = "杠杆 —";
if(hidden) hidden.value = "";
return;
}
if(hidden) hidden.value = String(lev);
if(hint) hint.textContent = "杠杆 " + lev + "x";
}
function initOrderEntryModelSelect(root){
var scope = (root && root.querySelector) ? root : document;
var sel = scope.querySelector ? scope.querySelector("#order-entry-model") : document.getElementById("order-entry-model");
if(!sel) return;
if(sel.dataset.entryModelWired !== "1"){
sel.dataset.entryModelWired = "1";
sel.addEventListener("change", syncOrderEntryModelTradeStyle);
}
syncOrderEntryModelTradeStyle();
}
window.syncOrderEntryModelTradeStyle = syncOrderEntryModelTradeStyle;
window.paintOrderLeverageHint = paintOrderLeverageHint;
window.initOrderEntryModelSelect = initOrderEntryModelSelect;
function reloadInstancePage(){
if(document.body && document.body.getAttribute("data-embed-shell") === "1" && window.InstanceEmbed){
+6 -1
View File
@@ -122,7 +122,12 @@
<script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
<script src="/static/form_submit_guard.js?v=2"></script>
<script src="/static/order_entry_model.js?v=3"></script>
<script>
const ORDER_ENTRY_MODEL_TRADE_STYLE = {{ entry_model_trade_style_map | tojson }};
const ORDER_ENTRY_MODEL_CATEGORIES = {{ entry_model_categories | tojson }};
const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | tojson }};
</script>
<script src="/static/order_entry_model.js?v=4"></script>
<script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/strategy_roll.js?v=6"></script>
+6 -2
View File
@@ -554,13 +554,17 @@
<script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
<script src="/static/form_submit_guard.js?v=2"></script>
<script src="/static/order_entry_model.js?v=3"></script>
<script>
const ORDER_ENTRY_MODEL_TRADE_STYLE = {{ entry_model_trade_style_map | tojson }};
const ORDER_ENTRY_MODEL_CATEGORIES = {{ entry_model_categories | tojson }};
const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | tojson }};
</script>
<script src="/static/order_entry_model.js?v=4"></script>
<script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/strategy_roll.js?v=6"></script>
<script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const ORDER_ENTRY_MODEL_TRADE_STYLE = {{ entry_model_trade_style_map | tojson }};
function validateJournalEntryReason(){
const form = document.getElementById("journal-form");
@@ -1,14 +1,19 @@
{# 趋势户:开仓类型 → 自动 trade_style;日内户:仍选手动 trend/swing #}
{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:仍选手动 trend/swing #}
{% macro order_entry_type_fields() -%}
{% if order_entry_profile == 'trend_div' %}
<select name="entry_model" id="order-entry-model" required title="大分歧A/B为趋势单,小分歧为波段单" onchange="syncOrderEntryModelTradeStyle()">
<option value="">开仓类型</option>
{% for opt in entry_model_options %}
<option value="{{ opt.code }}" data-trade-style="{{ opt.trade_style }}" title="{{ opt.help }}">{{ opt.label }}</option>
{% endfor %}
</select>
<input type="hidden" name="trade_style" id="order-trade-style-hidden" value="trend">
<span id="order-trade-style-hint" class="order-trade-style-hint" title="由开仓类型自动设定">趋势单</span>
<div class="order-entry-model-row">
<select id="order-entry-category" class="order-entry-category" required title="反转 / 顺势 / 波段" aria-label="开仓性质">
<option value="">性质</option>
{% for cat in entry_model_categories %}
<option value="{{ cat.key }}">{{ cat.label }}</option>
{% endfor %}
</select>
<select name="entry_model" id="order-entry-model" class="order-entry-model-sub" required disabled title="启动A/B、大分歧A/B、小分歧" aria-label="开仓类型">
<option value="">类型</option>
</select>
<input type="hidden" name="trade_style" id="order-trade-style-hidden" value="trend">
<span id="order-trade-style-hint" class="order-trade-style-hint" title="由开仓类型自动设定">趋势单</span>
</div>
{% else %}
<select name="trade_style" required>
<option value="trend">趋势单</option>
@@ -10,7 +10,7 @@
<input name="real_rr" class="journal-field-num" placeholder="实际RR">
</div>
<div class="form-grid journal-form-row2">
<select name="entry_reason" id="journal-entry-reason" class="journal-field-entry-reason" required title="大分歧A/B/小分歧或策略项">
<select name="entry_reason" id="journal-entry-reason" class="journal-field-entry-reason" required title="启动A/B、大分歧A/B小分歧或策略项">
<option value="">开仓类型(必选)</option>
{% for er in entry_reason_options %}
<option value="{{ er }}">{{ er }}</option>
+99 -13
View File
@@ -1,4 +1,4 @@
"""大分歧 / 小分歧开仓类型(趋势户);日内户沿用 trade_style单独 profile。"""
"""趋势户开仓类型:反转·启动 / 顺势·大分歧 / 波段·小分歧;日内户单独 profile。"""
from __future__ import annotations
from dataclasses import dataclass
@@ -9,19 +9,35 @@ from lib.trade.trade_policy_lib import TradePolicy
PROFILE_TREND_DIV = "trend_div"
PROFILE_INTRADAY = "intraday"
ENTRY_CATEGORY_REVERSAL = "reversal"
ENTRY_CATEGORY_TREND = "trend"
ENTRY_CATEGORY_SWING = "swing"
ENTRY_MODEL_LAUNCH_A = "launch_a"
ENTRY_MODEL_LAUNCH_B = "launch_b"
ENTRY_MODEL_BIG_DIV_A = "big_div_a"
ENTRY_MODEL_BIG_DIV_B = "big_div_b"
ENTRY_MODEL_SMALL_DIV = "small_div"
VALID_ENTRY_MODEL_CODES = frozenset(
{
ENTRY_MODEL_LAUNCH_A,
ENTRY_MODEL_LAUNCH_B,
ENTRY_MODEL_BIG_DIV_A,
ENTRY_MODEL_BIG_DIV_B,
ENTRY_MODEL_SMALL_DIV,
}
)
ENTRY_CATEGORY_LABELS: dict[str, str] = {
ENTRY_CATEGORY_REVERSAL: "反转",
ENTRY_CATEGORY_TREND: "顺势",
ENTRY_CATEGORY_SWING: "波段",
}
TREND_DIV_ENTRY_REASON_LABELS: Tuple[str, ...] = (
"启动A",
"启动B",
"大分歧A",
"大分歧B",
"小分歧",
@@ -35,31 +51,56 @@ INTRADAY_LEGACY_TREND_ENTRY_REASONS: Tuple[str, ...] = (
"波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20",
)
_ENTRY_SPECS: Tuple[Tuple[str, str, str, str], ...] = (
# code, label, category, trade_style, help
_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = (
(
ENTRY_MODEL_LAUNCH_A,
"启动A",
ENTRY_CATEGORY_REVERSAL,
"trend",
"反转链结构内:摸参考极值前小收敛,或 B 失败后 5m N 字试仓(不单列)",
),
(
ENTRY_MODEL_LAUNCH_B,
"启动B",
ENTRY_CATEGORY_REVERSAL,
"trend",
"第二次到参考极值附近、无小收敛时的实体突破",
),
(
ENTRY_MODEL_BIG_DIV_A,
"大分歧A",
ENTRY_CATEGORY_TREND,
"trend",
"突破前收敛小结构,不创新低企稳(空:不创新高)",
"主升已确立:突破前收敛,不创新低企稳(空:不创新高)",
),
(
ENTRY_MODEL_BIG_DIV_B,
"大分歧B",
ENTRY_CATEGORY_TREND,
"trend",
"结构突破确认后入场",
"主升已确立:结构实体突破确认后入场",
),
(
ENTRY_MODEL_SMALL_DIV,
"小分歧",
ENTRY_CATEGORY_SWING,
"swing",
"二次探底 N 字突破,或 5m 三均线重新多头(空:二次探顶 / 空头均线)",
"主升已确立:二次探底 N 字或 5m 三均线重新多头(空:二次探顶 / 空头均线)",
),
)
_CODE_TO_LABEL = {code: label for code, label, _, _ in _ENTRY_SPECS}
_CODE_TO_STYLE = {code: style for code, _, style, _ in _ENTRY_SPECS}
_LABEL_TO_CODE = {label: code for code, label, _, _ in _ENTRY_SPECS}
_CODE_TO_HELP = {code: help for code, _, _, help in _ENTRY_SPECS}
_CODE_TO_LABEL = {code: label for code, label, _, _, _ in _ENTRY_SPECS}
_CODE_TO_STYLE = {code: style for code, _, _, style, _ in _ENTRY_SPECS}
_CODE_TO_CATEGORY = {code: cat for code, _, cat, _, _ in _ENTRY_SPECS}
_LABEL_TO_CODE = {label: code for code, label, _, _, _ in _ENTRY_SPECS}
_CODE_TO_HELP = {code: help for code, _, _, _, help in _ENTRY_SPECS}
_CATEGORY_ORDER: Tuple[str, ...] = (
ENTRY_CATEGORY_REVERSAL,
ENTRY_CATEGORY_TREND,
ENTRY_CATEGORY_SWING,
)
_INTRADAY_WHITELIST = frozenset({"BTC", "ETH"})
@@ -68,6 +109,7 @@ _INTRADAY_WHITELIST = frozenset({"BTC", "ETH"})
class EntryModelOption:
code: str
label: str
category: str
trade_style: str
help: str
@@ -87,11 +129,43 @@ def order_entry_profile(policy: TradePolicy) -> str:
def entry_model_options() -> Tuple[EntryModelOption, ...]:
return tuple(
EntryModelOption(code=code, label=label, trade_style=style, help=help)
for code, label, style, help in _ENTRY_SPECS
EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help)
for code, label, cat, style, help in _ENTRY_SPECS
)
def entry_model_categories() -> list[dict[str, Any]]:
"""两级 UI:反转 / 顺势 / 波段 → 子选项。"""
opts = entry_model_options()
out: list[dict[str, Any]] = []
for cat_key in _CATEGORY_ORDER:
children = [
{
"code": o.code,
"label": o.label,
"trade_style": o.trade_style,
"help": o.help,
}
for o in opts
if o.category == cat_key
]
if not children:
continue
out.append(
{
"key": cat_key,
"label": ENTRY_CATEGORY_LABELS.get(cat_key, cat_key),
"options": children,
}
)
return out
def entry_model_category(code: Optional[str]) -> str:
c = normalize_entry_model_code(code)
return _CODE_TO_CATEGORY.get(c, "")
def normalize_entry_model_code(raw: Optional[str]) -> str:
v = (raw or "").strip().lower()
if v in VALID_ENTRY_MODEL_CODES:
@@ -160,7 +234,7 @@ def parse_manual_order_style_fields(
entry_model = normalize_entry_model_code(form.get("entry_model"))
if not entry_model:
return "", None, "请选择开仓类型(大分歧A / 大分歧B / 小分歧"
return "", None, "请选择开仓类型(反转 / 顺势 / 波段"
trade_style = trade_style_for_entry_model(entry_model)
return trade_style, entry_model, None
@@ -197,6 +271,10 @@ def enrich_entry_model_display(item: dict) -> dict:
if code:
item["entry_model"] = code
item["entry_model_label"] = entry_model_label(code)
cat = entry_model_category(code)
if cat:
item["entry_model_category"] = cat
item["entry_model_category_label"] = ENTRY_CATEGORY_LABELS.get(cat, "")
else:
item.setdefault("entry_model_label", "")
return item
@@ -209,10 +287,18 @@ def order_entry_template_context(policy: TradePolicy) -> dict:
"order_entry_profile": profile,
"intraday_discipline": profile == PROFILE_INTRADAY,
"entry_model_options": [
{"code": o.code, "label": o.label, "trade_style": o.trade_style, "help": o.help}
{
"code": o.code,
"label": o.label,
"category": o.category,
"trade_style": o.trade_style,
"help": o.help,
}
for o in opts
],
"entry_model_categories": entry_model_categories(),
"entry_model_trade_style_map": {o.code: o.trade_style for o in opts},
"entry_model_code_to_category": {o.code: o.category for o in opts},
}