diff --git a/install_trading.py b/install_trading.py index 4218e9f..16b65a4 100644 --- a/install_trading.py +++ b/install_trading.py @@ -166,6 +166,33 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "symbol_is_main": bool(meta.get("is_main")), } + def _breakeven_locked( + *, + entry: Optional[float], + stop_loss: Optional[float], + direction: str, + tick_size: Optional[float] = None, + trailing_r_locked: int = 0, + ) -> bool: + if int(trailing_r_locked or 0) >= 1: + return True + if entry is None or stop_loss is None: + return False + try: + entry_f = float(entry) + sl_f = float(stop_loss) + except (TypeError, ValueError): + return False + if entry_f <= 0: + return False + tick = float(tick_size or 0) or max(abs(entry_f) * 1e-6, 0.01) + buf = tick * max(2, get_trailing_be_tick_buffer(get_setting)) + d = (direction or "long").strip().lower() + near = abs(sl_f - entry_f) <= buf + tick + if d == "long": + return near and sl_f >= entry_f - tick * 0.05 + return near and sl_f <= entry_f + tick * 0.05 + def _schedule_recommend_refresh() -> None: from db_conn import DB_PATH @@ -1208,6 +1235,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "pending_orders": pending_for_row, "trailing_be": bool(mon.get("trailing_be")) if mon else False, "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, + "breakeven_locked": _breakeven_locked( + entry=entry, + stop_loss=sl, + direction=direction, + tick_size=tick.get("tick_size"), + trailing_r_locked=int(mon.get("trailing_r_locked") or 0) if mon else 0, + ), } def _compose_pending_row( diff --git a/static/css/dashboard.css b/static/css/dashboard.css index 5d54b24..df6186a 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -80,6 +80,7 @@ align-items: baseline; flex-wrap: wrap; gap: 0.35rem; + width: 100%; } .dash-risk-doc-ref { @@ -262,3 +263,187 @@ padding: 0.35rem 0.4rem; } } + +/* ---- 风控折叠 / 平板两行 ---- */ +.dash-section-toggle { + cursor: pointer; + user-select: none; +} + +.dash-section-toggle-label { + margin-right: 0.15rem; +} + +.dash-toggle-icon { + margin-left: auto; + font-size: 0.68rem; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.dashboard-risk-card.is-expanded .dash-toggle-icon { + transform: rotate(180deg); +} + +html:is([data-layout="phone"], [data-layout="tablet"], .layout-phone, .layout-tablet) +.dashboard-risk-card:not(.is-expanded) .dash-risk-body { + display: none; +} + +html[data-layout="tablet"] .dashboard-risk-grid, +html.layout-tablet .dashboard-risk-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + overflow-x: visible; +} + +html[data-layout="tablet"] .dashboard-risk-item, +html.layout-tablet .dashboard-risk-item { + flex: none; + min-width: 0; + border-right: 1px solid var(--table-border); + border-bottom: 1px solid var(--table-border); +} + +html[data-layout="tablet"] .dashboard-risk-item:nth-child(7n), +html.layout-tablet .dashboard-risk-item:nth-child(7n) { + border-right: none; +} + +html[data-layout="tablet"] .dashboard-risk-item:nth-last-child(-n+6), +html.layout-tablet .dashboard-risk-item:nth-last-child(-n+6) { + border-bottom: none; +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow-x: visible; +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item { + flex: none; + min-width: 0; + border-right: 1px solid var(--table-border); + border-bottom: 1px solid var(--table-border); +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item:nth-child(2n) { + border-right: none; +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item:nth-last-child(-n+1) { + border-bottom: none; +} + +/* ---- 持仓 / 关键位:桌面平板最多 3 行后滚动 ---- */ +html:not([data-layout="phone"]):not(.layout-phone) .dash-pos-table-wrap, +html:not([data-layout="phone"]):not(.layout-phone) .dash-keys-table-wrap { + max-height: 12rem; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +html:not([data-layout="phone"]):not(.layout-phone) .dash-pos-table-wrap thead th, +html:not([data-layout="phone"]):not(.layout-phone) .dash-keys-table-wrap thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--card-inner); +} + +/* ---- 手机简要列表 ---- */ +.dash-mobile-list { + display: none; +} + +html:is([data-layout="phone"], .layout-phone) .dash-mobile-list { + display: block; +} + +html:is([data-layout="phone"], .layout-phone) .dash-pos-table-wrap, +html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap { + display: none; +} + +.dash-mobile-item { + display: block; + width: 100%; + text-align: left; + border: 1px solid var(--table-border); + border-radius: 10px; + background: var(--card-inner); + padding: 0.65rem 0.75rem; + margin-bottom: 0.5rem; + cursor: pointer; + color: inherit; + font: inherit; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.dash-mobile-item:hover, +.dash-mobile-item:focus-visible { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 1px rgba(76, 194, 255, 0.15); +} + +.dash-mobile-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + margin-bottom: 0.3rem; +} + +.dash-mobile-item-title { + min-width: 0; + flex: 1; +} + +.dash-mobile-item-meta { + font-size: 0.74rem; + color: var(--text-muted); + line-height: 1.45; +} + +.dash-mobile-item-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.35rem; + font-size: 0.78rem; +} + +.dash-mobile-chevron { + font-size: 0.72rem; + color: var(--accent); + white-space: nowrap; +} + +.dash-mobile-empty { + padding: 0.85rem 0.35rem; + text-align: center; + color: var(--text-muted); + font-size: 0.82rem; +} + +.dash-be-badge { + font-size: 0.66rem; + vertical-align: middle; +} + +.dash-detail-modal { + max-width: 520px; + width: 100%; +} + +.dash-detail-modal .modal-grid .item.wide { + grid-column: 1 / -1; +} + +.dash-detail-modal .modal-actions { + margin-top: 1rem; + text-align: right; +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 9c7e67b..00a7420 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -15,14 +15,124 @@ var updatedEl = document.getElementById('dash-updated'); var riskReasonEl = document.getElementById('dash-risk-reason'); var riskGridEl = document.getElementById('dash-risk-grid'); + var riskCardEl = document.getElementById('dash-risk-card'); + var riskToggleEl = document.getElementById('dash-risk-toggle'); + var riskBodyEl = document.getElementById('dash-risk-body'); + var posMobileList = document.getElementById('dash-pos-mobile-list'); + var keysMobileList = document.getElementById('dash-keys-mobile-list'); + var detailModal = document.getElementById('dash-detail-modal'); + var detailTitleEl = document.getElementById('dash-detail-title'); + var detailGridEl = document.getElementById('dash-detail-grid'); + var detailCloseBtn = document.getElementById('dash-detail-close'); var pollTimer = null; var positionSource = null; var posRowCache = {}; + var posRenderSig = ''; + var posMobileCache = {}; + var keysMobileCache = {}; + var lastPosRows = []; + var lastKeyRows = []; var lastKeyIds = ''; var lastCloseHeadId = null; var lastRiskPayload = null; + function isPhoneLayout() { + var root = document.documentElement; + return root.dataset.layout === 'phone' || root.classList.contains('layout-phone') + || root.dataset.mobile === '1'; + } + + function isTabletLayout() { + var root = document.documentElement; + return root.dataset.layout === 'tablet' || root.classList.contains('layout-tablet'); + } + + function shouldCollapseRiskDefault() { + return isPhoneLayout() || isTabletLayout(); + } + + function syncRiskCollapseUi() { + if (!riskCardEl || !riskToggleEl) return; + var expanded = riskCardEl.classList.contains('is-expanded'); + riskToggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + } + + function initRiskToggle() { + if (!riskCardEl || !riskToggleEl) return; + if (shouldCollapseRiskDefault()) { + riskCardEl.classList.remove('is-expanded'); + } else { + riskCardEl.classList.add('is-expanded'); + } + syncRiskCollapseUi(); + function toggleRisk() { + riskCardEl.classList.toggle('is-expanded'); + syncRiskCollapseUi(); + } + riskToggleEl.addEventListener('click', function (ev) { + if (ev.target && ev.target.closest('.dash-risk-doc-link')) return; + toggleRisk(); + }); + riskToggleEl.addEventListener('keydown', function (ev) { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + toggleRisk(); + } + }); + } + + function isBreakevenLocked(row) { + if (!row) return false; + if (row.breakeven_locked) return true; + if ((row.trailing_r_locked || 0) >= 1) return true; + if (row.stop_loss == null || row.entry_price == null) return false; + var entry = Number(row.entry_price); + var sl = Number(row.stop_loss); + if (isNaN(entry) || isNaN(sl) || entry <= 0) return false; + var tick = Number(row.tick_size) || Math.max(Math.abs(entry) * 1e-6, 0.01); + var buf = tick * 2.5; + var dir = (row.direction || 'long').toString().toLowerCase(); + if (Math.abs(sl - entry) > buf + tick) return false; + return dir === 'short' ? sl <= entry + tick * 0.05 : sl >= entry - tick * 0.05; + } + + function breakevenBadgeHtml(row) { + return isBreakevenLocked(row) + ? ' 已保本' : ''; + } + + function openDetailModal(title, items) { + if (!detailModal || !detailGridEl || !detailTitleEl) return; + detailTitleEl.textContent = title || '详情'; + detailGridEl.innerHTML = (items || []).map(function (it) { + var cls = it.wide ? ' item wide' : ' item'; + return ( + '