/* Copyright (c) 2025-2026 马建军. All rights reserved. * 专有软件 — 未经授权禁止复制、传播、转售。 * 详见 LICENSE.zh-CN.txt */ (function () { var sizingMode = 'fixed'; var list = document.getElementById('position-live-list'); var orderList = document.getElementById('order-live-list'); var syncBadge = document.getElementById('sync-badge'); var recommendList = document.getElementById('recommend-list'); var symInput = document.getElementById('trade-symbol'); var dirSelect = document.getElementById('trade-direction'); var lotsInput = document.getElementById('trade-lots'); var lotsCalc = document.getElementById('trade-lots-calc'); var priceInput = document.getElementById('trade-price'); var slInput = document.getElementById('trade-sl'); var tpInput = document.getElementById('trade-tp'); var marketHint = document.getElementById('market-hint'); var metricsHint = document.getElementById('trade-metrics-hint'); var recommendSource = null; var positionSource = null; var quoteTimer = null; var calcTimer = null; var lastQuotePrice = null; var priceType = 'limit'; var lastCtpReconnectAt = 0; var lastCtpUnreachableAt = 0; var lastCtpLoginBanAt = 0; var ctpReconnecting = false; var ctpConnectInflight = false; var isTradingSession = false; var hasSlTpMonitoring = false; var ctpConnected = false; var ctpConnecting = false; var ctpAutoConnectEnabled = true; var positionsRendered = false; var selectedMaxLots = null; var recommendMaxByProduct = {}; var recommendMaxByCode = {}; var recRowsRaw = []; var recSortKey = 'trend'; var recSortDesc = true; var recIndustryFilter = ''; var REC_SORT_CACHE = 'qihuo_rec_sort_v2'; var REC_INDUSTRY_CACHE = 'qihuo_rec_industry_v1'; var REC_COLSPAN = 18; var marketNavEnabled = false; var productCategories = []; var POS_CACHE_KEY = 'qihuo_trading_live_v5'; function loadTradeConfig() { var el = document.getElementById('trade-page-data'); if (!el) return; try { var cfg = JSON.parse(el.textContent); sizingMode = cfg.sizing_mode || 'fixed'; if (sizingMode === 'risk') sizingMode = 'amount'; ctpAutoConnectEnabled = cfg.ctp_auto_connect !== false; marketNavEnabled = !!cfg.market_nav_enabled; productCategories = cfg.product_categories || []; window.TRADE_FIXED_LOTS = cfg.fixed_lots; window.TRADE_FIXED_AMOUNT = cfg.fixed_amount; window.__RECOMMEND_ROWS__ = cfg.recommend_rows || []; if (cfg.session_clock) applySessionClock(cfg.session_clock); } catch (e) { /* ignore */ } } function fmtNum(v, digits) { if (v === null || v === undefined) return '--'; return Number(v).toFixed(digits === undefined ? 2 : digits); } function selectedSymbol() { var codeEl = document.getElementById('trade-symbol-code'); var code = codeEl && codeEl.value ? codeEl.value.trim() : ''; if (code) return code; return (symInput && symInput.value || '').trim(); } function isFixedMode() { return sizingMode === 'fixed'; } function isAmountMode() { return sizingMode === 'amount'; } function effectiveLots() { var v = parseInt(lotsCalc && lotsCalc.value, 10); return v > 0 ? v : 0; } function updateRecommendMaxMaps(data) { recommendMaxByProduct = {}; recommendMaxByCode = {}; (data && data.rows || []).forEach(function (r) { if (!r || r.max_lots <= 0) return; if (r.status !== 'ok' && r.status !== 'margin_ok') return; if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots; if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots; }); checkLotsLimit(); } function maxLotsForSymbol(sym) { if (selectedMaxLots > 0) return selectedMaxLots; var code = (sym || '').trim().toLowerCase(); if (!code) return 0; if (recommendMaxByCode[code]) return recommendMaxByCode[code]; var m = code.match(/^([a-z]+)/i); if (m && recommendMaxByProduct[m[1].toLowerCase()]) { return recommendMaxByProduct[m[1].toLowerCase()]; } return 0; } function checkLotsLimit() { var warn = document.getElementById('lots-warn'); if (!warn) return; var sym = selectedSymbol(); var maxLots = maxLotsForSymbol(sym); var lots = effectiveLots(); if (lastSizingInfo && lastSizingInfo.capped_by === 'margin' && lastSizingInfo.lots_by_risk > lots) { warn.hidden = false; warn.textContent = '以损定仓 ' + lastSizingInfo.lots_by_risk + ' 手,保证金上限 ' + (lastSizingInfo.max_margin_pct || '') + '% 收紧为 ' + lots + ' 手'; return; } if (maxLots > 0 && lots > maxLots) { warn.hidden = false; warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数'; } else { warn.hidden = true; warn.textContent = ''; } } function loadPosCache() { try { var raw = sessionStorage.getItem(POS_CACHE_KEY); if (!raw) return null; return JSON.parse(raw); } catch (e) { return null; } } function savePosCache(data) { try { if (!data) return; var connected = data.ctp_status && data.ctp_status.connected; if (!connected) { sessionStorage.removeItem(POS_CACHE_KEY); return; } if (!data.rows || !data.rows.length) { if (!data.active_orders || !data.active_orders.length) { sessionStorage.removeItem(POS_CACHE_KEY); return; } } sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data)); } catch (e) { /* quota */ } } function showCtpError(msg) { var hint = document.querySelector('.ctp-install-hint'); if (hint) hint.textContent = msg || ''; } function isCtpLoginBanError(msg) { return !!(msg && ( msg.indexOf('登录被禁止') >= 0 || msg.indexOf('连续登录失败') >= 0 || msg.indexOf('登录冷却') >= 0 || msg.indexOf('错误码 75') >= 0 )); } function isCtpUnreachableError(msg) { return !!(msg && (msg.indexOf('不可达') >= 0 || msg.indexOf('Connection refused') >= 0 || msg.indexOf('timed out') >= 0)); } function applyActiveOrders(orders, data) { if (!orderList) return; orders = orders || []; if (!orders.length) { var connected = data && data.ctp_status && data.ctp_status.connected; if (!connected) { var hint = (data && data.ctp_status && data.ctp_status.disabled_hint) || 'CTP 未连接,委托以柜台为准'; orderList.innerHTML = '
' + hint + '
'; return; } orderList.innerHTML = '
暂无委托。
'; return; } orderList.innerHTML = orders.map(buildPendingOrderCard).join(''); bindPendingDismiss(orderList); bindCancelOpenButtons(orderList); bindCancelOrderButtons(orderList); } function applyPositionsData(data) { if (!data) return; var cap = document.getElementById('cap-display'); if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); var connected = data.ctp_status && data.ctp_status.connected; var connecting = data.ctp_status && data.ctp_status.connecting; var cooldownSec = (data.ctp_status && data.ctp_status.login_cooldown_sec) || 0; if (cooldownSec > 0) connecting = false; ctpConnected = !!connected; ctpConnecting = !!connecting; isTradingSession = !!data.trading_session; if (data.session_clock) applySessionClock(data.session_clock); syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting }); if (data.ctp_status && typeof data.ctp_status.auto_connect_enabled === 'boolean') { ctpAutoConnectEnabled = data.ctp_status.auto_connect_enabled; updateCtpConnectButtonState(); } if (syncBadge) { if (data.sync_label && connected) { syncBadge.hidden = false; syncBadge.textContent = data.sync_label; syncBadge.className = 'sync-badge ' + (data.sync_state === 'syncing' ? 'text-accent' : 'text-muted'); } else { syncBadge.hidden = true; syncBadge.textContent = ''; } } if (!connected && !connecting && data.ctp_status && data.ctp_status.last_error) { showCtpError(data.ctp_status.last_error); if (isCtpLoginBanError(data.ctp_status.last_error)) { lastCtpLoginBanAt = Date.now(); } else if (isCtpUnreachableError(data.ctp_status.last_error)) { lastCtpUnreachableAt = Date.now(); } } else if (!connected && data.ctp_status && data.ctp_status.disabled_hint) { showCtpError(data.ctp_status.disabled_hint); } var riskBadge = document.getElementById('risk-badge'); if (riskBadge && data.risk_status) { riskBadge.textContent = data.risk_status.status_label || ''; riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); } applyActiveOrders(data.active_orders || [], data); if (!list) return; var rows = (data.rows || []).filter(function (row) { return row.order_state !== 'pending'; }); var seenKeys = {}; rows = rows.filter(function (row) { var k = row.key || row.position_key || ((row.symbol_code || '') + ':' + (row.direction || '')); if (seenKeys[k]) return false; seenKeys[k] = true; return true; }); hasSlTpMonitoring = rows.some(function (row) { return row.stop_loss != null || row.take_profit != null; }); updateSessionUi(); savePosCache(data); positionsRendered = true; if (!rows.length) { if (!connected) { if (connecting) { list.innerHTML = '
CTP 连接中,请稍候…
'; return; } if (cooldownSec > 0 || (data.ctp_status && data.ctp_status.last_error)) { var err = (data.ctp_status && data.ctp_status.last_error) || 'CTP 未连接'; list.innerHTML = '
' + err + '
'; return; } if (!ctpAutoConnectEnabled) { var offHint = (data.ctp_status && data.ctp_status.disabled_hint) || 'CTP 自动连接已关闭,请在系统设置中开启'; list.innerHTML = '
' + offHint + '
'; return; } list.innerHTML = '
CTP 未连接,正在尝试自动重连…
'; if (ctpAutoConnectEnabled) tryAutoCtpReconnect(); return; } list.innerHTML = '
暂无持仓。
'; return; } if (!connected && ctpAutoConnectEnabled) { tryAutoCtpReconnect(); } list.innerHTML = rows.map(buildPosCard).join(''); bindPendingDismiss(list); bindCancelOpenButtons(list); bindSlTpButtons(list); bindPlaceOrderButtons(list); list.querySelectorAll('[data-close]').forEach(function (btn) { btn.addEventListener('click', function () { closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn); }); }); } function schedulePositionPoll() { /* 持仓改由后台 SSE 推送,保留空函数兼容旧调用 */ } function updateSessionUi() { var btnOpen = document.getElementById('btn-open'); var sessionHint = document.getElementById('session-hint'); if (btnOpen) { btnOpen.disabled = !isTradingSession; btnOpen.classList.toggle('btn-session-off', !isTradingSession); } if (sessionHint) { sessionHint.hidden = !!isTradingSession; } } function entryPrice() { if (priceType === 'market') return lastQuotePrice; return parseFloat(priceInput && priceInput.value) || 0; } function isTrailingBeOn() { var el = document.getElementById('trailing-be'); return !!(el && el.checked); } function updateTrailingBeUi() { var on = isTrailingBeOn(); var tpField = document.getElementById('field-tp'); var hint = document.getElementById('trailing-be-hint'); if (tpField) tpField.classList.toggle('is-hidden', on); if (hint) hint.hidden = !on; if (on && tpInput) tpInput.value = ''; updateRRDisplay(); scheduleAutoCalc(); } function calcRR(direction, entry, sl, tp) { entry = parseFloat(entry); sl = parseFloat(sl); tp = parseFloat(tp); if (!entry || !sl || !tp) return null; var risk, reward; if (direction === 'long') { risk = entry - sl; reward = tp - entry; } else if (direction === 'short') { risk = sl - entry; reward = entry - tp; } else { return null; } if (risk <= 0 || reward <= 0) return null; return (reward / risk).toFixed(2); } function updateRRDisplay() { var el = document.getElementById('trade-rr-hint'); if (!el) return; var trailingOn = isTrailingBeOn(); var dir = dirSelect ? dirSelect.value : 'long'; var entry = entryPrice(); var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; var tp = trailingOn ? 0 : (tpInput && tpInput.value ? parseFloat(tpInput.value) : 0); var lots = effectiveLots(); var parts = []; if (!trailingOn) { var rr = calcRR(dir, entry, sl, tp); if (rr) parts.push('盈亏比 ' + rr + ':1'); } if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) { if (lastPreviewMetrics.risk_amount != null) { parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元'); } if (!trailingOn && lastPreviewMetrics.reward_amount != null && tp > 0) { parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元'); } } if (parts.length) { el.textContent = parts.join(' · '); el.hidden = false; } else { el.textContent = ''; el.hidden = true; } } var lastPreviewMetrics = null; var lastSizingInfo = null; var sessionClockBase = null; var sessionClockTickTimer = null; function fmtCountdown(secs) { secs = Math.max(0, parseInt(secs, 10) || 0); var h = Math.floor(secs / 3600); var m = Math.floor((secs % 3600) / 60); var s = secs % 60; if (h > 0) return h + '小时' + (m < 10 ? '0' : '') + m + '分' + (s < 10 ? '0' : '') + s + '秒'; if (m > 0) return m + '分' + (s < 10 ? '0' : '') + s + '秒'; return s + '秒'; } function fmtClockNow(d) { var mo = d.getMonth() + 1; var da = d.getDate(); var pad = function (n) { return n < 10 ? '0' + n : String(n); }; return pad(mo) + '-' + pad(da) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); } function tickSessionClock() { var base = sessionClockBase; if (!base || !base.clock) return; var c = base.clock; var elapsed = Math.floor((Date.now() - base.at) / 1000); var nowEl = document.getElementById('clock-now'); if (nowEl && c.now) { var t = new Date(String(c.now).replace(/-/g, '/')); if (!isNaN(t.getTime())) { t.setSeconds(t.getSeconds() + elapsed); nowEl.textContent = fmtClockNow(t); } } var statusEl = document.getElementById('clock-status'); if (statusEl) { statusEl.textContent = c.status_label || (c.in_session ? '交易时间段' : '非交易时间段'); statusEl.className = c.in_session ? 'text-profit' : 'text-muted'; } var detail = document.getElementById('clock-detail'); if (!detail) return; var html = ''; if (!c.in_session && c.secs_to_open != null) { html = ' · 下次' + (c.next_open_label || '开盘') + ' ' + (c.next_open_at || '') + ' · 距开盘 ' + fmtCountdown(c.secs_to_open - elapsed) + ''; } else if (c.in_session) { if (c.secs_to_break != null) { html += ' · 距' + (c.break_label || '休盘') + ' ' + fmtCountdown(c.secs_to_break - elapsed) + ''; } if (c.secs_to_close != null) { html += ' · 距' + (c.close_label || '收盘') + ' ' + fmtCountdown(c.secs_to_close - elapsed) + ''; } } detail.innerHTML = html; } function applySessionClock(clock) { if (!clock) return; sessionClockBase = { clock: clock, at: Date.now() }; tickSessionClock(); if (sessionClockTickTimer) clearInterval(sessionClockTickTimer); sessionClockTickTimer = setInterval(tickSessionClock, 1000); } function setPriceType(type) { priceType = type === 'market' ? 'market' : 'limit'; document.querySelectorAll('.price-tab').forEach(function (btn) { btn.classList.toggle('active', btn.getAttribute('data-type') === priceType); }); if (priceInput) { priceInput.disabled = priceType === 'market'; if (priceType === 'market' && lastQuotePrice) priceInput.value = lastQuotePrice; } if (marketHint) marketHint.hidden = priceType !== 'market'; updateRRDisplay(); } function syncCtpBadgeFromStatus(st) { if (!st) return; var connected = !!st.connected; var connecting = !!st.connecting; if ((st.login_cooldown_sec || 0) > 0) { connecting = false; } updateCtpBadge(connected, connecting); } function updateCtpConnectButtonState() { var btnConnect = document.getElementById('btn-ctp-connect'); var hint = document.getElementById('ctp-auto-hint'); if (hint) { hint.textContent = ctpAutoConnectEnabled ? '断线自动重连 · 开盘前 30 分钟自动连接' : 'CTP 自动连接已关闭(系统设置可开启)'; } if (btnConnect && !ctpAutoConnectEnabled) { btnConnect.disabled = true; btnConnect.title = '请先在系统设置 → CTP 连接 中开启自动连接'; } } function updateCtpBadge(connected, connecting) { var ctpBadge = document.getElementById('ctp-badge'); var btnConnect = document.getElementById('btn-ctp-connect'); if (ctpBadge) { if (!ctpAutoConnectEnabled && !connected) { ctpBadge.textContent = 'CTP 已关闭'; ctpBadge.className = 'badge planned'; } else if (connecting) { ctpBadge.textContent = 'CTP 连接中'; ctpBadge.className = 'badge planned'; } else { ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接'; ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned'); } } if (btnConnect) { if (!ctpAutoConnectEnabled) { btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP'; btnConnect.disabled = true; btnConnect.title = '请先在系统设置 → CTP 连接 中开启自动连接'; } else if (connecting) { btnConnect.textContent = '连接中…'; btnConnect.disabled = true; btnConnect.title = ''; } else { btnConnect.disabled = false; btnConnect.title = ''; btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP'; } } } function waitForCtpConnected(maxMs) { var deadline = Date.now() + (maxMs || 70000); function tick() { return fetch('/api/ctp/status') .then(function (r) { return r.json(); }) .then(function (d) { var st = d.status || {}; if (st.connected) { syncCtpBadgeFromStatus(st); showCtpError(''); if (d.account && d.account.available != null) { var avail = document.getElementById('avail-display'); if (avail) avail.textContent = Number(d.account.available).toFixed(2); } pollPositions(); return true; } if ((st.login_cooldown_sec || 0) > 0) { syncCtpBadgeFromStatus(st); if (st.last_error) showCtpError(st.last_error); return false; } if (st.connecting && Date.now() < deadline) { syncCtpBadgeFromStatus(st); return new Promise(function (resolve) { setTimeout(function () { resolve(tick()); }, 2000); }); } syncCtpBadgeFromStatus(st); if (st.last_error) { showCtpError(st.last_error); if (isCtpLoginBanError(st.last_error)) { lastCtpLoginBanAt = Date.now(); } else if (isCtpUnreachableError(st.last_error)) { lastCtpUnreachableAt = Date.now(); } } return false; }) .catch(function () { updateCtpBadge(false, false); return false; }); } return tick(); } function requestCtpConnect(force) { if (!force && !ctpAutoConnectEnabled) { showCtpError('CTP 自动连接已关闭,请在系统设置中开启'); return Promise.resolve({ ok: false, disabled: true }); } if (!force && ctpConnectInflight) { return Promise.resolve({}); } ctpConnectInflight = true; return fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force, auto: !force }) }) .then(function (r) { return r.json(); }) .then(function (d) { var st = d.status || {}; if (st.connected) { syncCtpBadgeFromStatus(st); showCtpError(''); pollPositions(); return d; } if ((st.login_cooldown_sec || 0) > 0 || d.cooldown) { syncCtpBadgeFromStatus(st); showCtpError(st.last_error || d.error || 'CTP 登录冷却中'); return d; } if (d.connecting || st.connecting) { updateCtpBadge(false, true); return waitForCtpConnected(70000).then(function (ok) { if (!ok && d.error) showCtpError(d.error); else if (!ok && st.last_error) showCtpError(st.last_error); return d; }); } if (d.disabled || st.auto_connect_enabled === false) { ctpAutoConnectEnabled = false; updateCtpConnectButtonState(); syncCtpBadgeFromStatus(st); showCtpError(st.disabled_hint || d.error || 'CTP 自动连接已关闭'); return d; } if (!d.ok) { syncCtpBadgeFromStatus(st); var err = d.error || st.last_error || '连接失败'; showCtpError(err); } return d; }) .catch(function () { updateCtpBadge(false, false); }) .finally(function () { ctpConnectInflight = false; }); } function refreshQuote() { var sym = selectedSymbol(); var lots = effectiveLots() || (isFixedMode() ? (window.TRADE_FIXED_LOTS || 1) : 1); if (!sym) return; fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots)) .then(function (r) { return r.json(); }) .then(function (data) { if (!data.ok) return; lastQuotePrice = data.price; if (priceType === 'market' && priceInput && data.price) { priceInput.value = data.price; } else if (priceInput && !priceInput.dataset.manual && data.price) { priceInput.value = data.price; } if (metricsHint && data.metrics) { var m = data.metrics; metricsHint.innerHTML = '' + (data.name || sym) + ' 精度 ' + m.price_precision + ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)'; } scheduleAutoCalc(); }).catch(function () {}); } function scheduleQuote() { clearTimeout(quoteTimer); quoteTimer = setTimeout(refreshQuote, 400); } function scheduleAutoCalc() { clearTimeout(calcTimer); calcTimer = setTimeout(autoCalcLots, 450); } function autoCalcLots() { if (!lotsCalc) return; var sym = selectedSymbol(); var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var sl = parseFloat(slInput && slInput.value) || 0; var tp = isTrailingBeOn() ? 0 : (parseFloat(tpInput && tpInput.value) || 0); if (isFixedMode()) { var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1; lotsCalc.value = String(fixedLots); if (lotsInput) lotsInput.value = String(fixedLots); if (!sym || !entry) { lastPreviewMetrics = null; updateRRDisplay(); checkLotsLimit(); return; } } else if (isAmountMode()) { if (!sym || !entry || !sl) { lotsCalc.value = ''; lotsCalc.placeholder = '填写止损后自动计算'; lastPreviewMetrics = null; updateRRDisplay(); checkLotsLimit(); return; } lotsCalc.placeholder = '计算中…'; } else { return; } fetch('/api/trade/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbol: sym, direction: dirSelect ? dirSelect.value : 'long', entry: entry, price: entry, stop_loss: sl, take_profit: tp }) }).then(function (r) { return r.json().then(function (data) { if (!r.ok && data && !data.error) data.error = '请求失败 ' + r.status; return data; }); }).then(function (data) { if (!data.ok) { if (isAmountMode()) { lotsCalc.value = ''; lotsCalc.placeholder = data.error || '无法计算'; } lastPreviewMetrics = null; lastSizingInfo = null; updateRRDisplay(); checkLotsLimit(); return; } lotsCalc.value = String(data.lots || ''); if (lotsInput) lotsInput.value = String(data.lots || ''); lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—'; lastPreviewMetrics = data.metrics || null; lastSizingInfo = data.sizing_info || null; updateRRDisplay(); checkLotsLimit(); scheduleQuote(); }).catch(function () { if (isAmountMode()) lotsCalc.placeholder = '计算失败'; lastPreviewMetrics = null; lastSizingInfo = null; updateRRDisplay(); }); } function tryAutoCtpReconnect() { if (!ctpAutoConnectEnabled) return; if (ctpReconnecting || ctpConnectInflight) return; var now = Date.now(); if (now - lastCtpReconnectAt < 60000) return; if (lastCtpLoginBanAt && now - lastCtpLoginBanAt < 2700000) return; if (lastCtpUnreachableAt && now - lastCtpUnreachableAt < 300000) return; lastCtpReconnectAt = now; ctpReconnecting = true; requestCtpConnect(false).finally(function () { ctpReconnecting = false; }); } function showOrderMsg(text, ok) { var el = document.getElementById('order-msg'); if (!el) return; if (!text) { el.hidden = true; el.textContent = ''; el.className = 'trade-order-msg'; return; } el.hidden = false; el.textContent = text; el.className = 'trade-order-msg ' + (ok ? 'ok' : 'err'); } function postOrder(offset) { var sym = selectedSymbol(); if (!sym) { showOrderMsg('请选择品种', false); return; } var direction = dirSelect ? dirSelect.value : 'long'; var price = entryPrice(); if (!price || price <= 0) { showOrderMsg('无法获取有效价格,请先填写或刷新行情', false); return; } var lots = effectiveLots(); var trailingBeEl = document.getElementById('trailing-be'); var trailingOn = !!(trailingBeEl && trailingBeEl.checked); if (offset === 'open') { if (!isTradingSession) { showOrderMsg('不在交易时间段', false); return; } if (trailingOn && !(slInput && slInput.value)) { showOrderMsg('开启移动保本须填写止损价', false); return; } if (isAmountMode() && lots <= 0) { showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false); return; } if (isFixedMode() && lots <= 0) { showOrderMsg('手数无效,请检查系统设置中的固定手数', false); return; } if (lots <= 0) { showOrderMsg('请填写有效手数', false); return; } var maxLots = maxLotsForSymbol(sym); if (maxLots > 0 && lots > maxLots) { showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false); return; } } var btnOpen = document.getElementById('btn-open'); if (btnOpen) { btnOpen.disabled = true; btnOpen.textContent = '开仓中…'; } showOrderMsg('开仓中…', true); var body = { symbol: sym, offset: offset, direction: direction, lots: lots, price: price, order_type: priceType, stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null, take_profit: (trailingOn || !(tpInput && tpInput.value)) ? null : parseFloat(tpInput.value), trailing_be: trailingOn }; fetch('/api/trade/order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) { return r.json(); }).then(function (data) { if (!data.ok) { showOrderMsg(data.error || '下单失败', false); return; } var msg = data.message || ( data.filled ? ('开仓成功 · ' + (data.lots || lots) + ' 手') : ('委托已提交 · ' + (data.lots || lots) + ' 手挂单中') ); showOrderMsg(msg, true); pollPositions(); refreshQuote(); setTimeout(function () { showOrderMsg(''); }, 4000); }).catch(function () { showOrderMsg('网络错误,请重试', false); }).finally(function () { if (btnOpen) { btnOpen.textContent = '开仓'; updateSessionUi(); } }); } function buildPendingHtml(items) { if (!items || !items.length) return ''; var rows = items.map(function (p) { var cls = p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp'); var dismissBtn = p.monitor_id ? '' : ''; return ( '
' + '' + (p.label || '挂单') + '' + '' + '' + fmtNum(p.price) + ' · ' + (p.lots || 1) + ' 手' + dismissBtn + '' + '
' ); }).join(''); return '
止盈止损监控
' + rows + '
'; } function dismissMonitor(monitorId, btn, opts) { opts = opts || {}; if (!monitorId) return; var isPending = !!opts.pending; if (isPending && !isTradingSession) { alert('不在交易时间段,无法撤单'); return; } var confirmMsg = isPending ? '撤销该开仓委托?(将向柜台发送撤单)' : '取消该本地止盈止损监控?(不影响柜台委托)'; if (!confirm(confirmMsg)) return; if (btn) { btn.disabled = true; btn.textContent = '取消中…'; } var url = isPending ? '/api/trading/monitor/cancel-open' : '/api/trading/monitor/dismiss'; fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monitor_id: monitorId }) }) .then(function (r) { return r.json(); }) .then(function (d) { if (!d.ok) throw new Error(d.error || '取消失败'); pollPositions(); }) .catch(function (e) { alert(e.message || '取消失败'); if (btn) { btn.disabled = false; btn.textContent = isPending ? '撤单' : '取消'; } }); } function bindCancelOpenButtons(root) { if (!root) return; root.querySelectorAll('[data-cancel-open]').forEach(function (btn) { btn.addEventListener('click', function () { if (!isTradingSession) { alert('不在交易时间段,无法撤单'); return; } dismissMonitor(parseInt(btn.getAttribute('data-cancel-open'), 10), btn, { pending: true }); }); }); } function bindPendingDismiss(root) { if (!root) return; root.querySelectorAll('[data-monitor-id]').forEach(function (btn) { btn.addEventListener('click', function () { var isPendingCancel = btn.getAttribute('data-pending-cancel') === '1'; dismissMonitor( parseInt(btn.getAttribute('data-monitor-id'), 10), btn, isPendingCancel ? { pending: true } : {} ); }); }); } function bindCancelOrderButtons(root) { if (!root) return; root.querySelectorAll('[data-cancel-order]').forEach(function (btn) { btn.addEventListener('click', function () { if (!isTradingSession) { alert('不在交易时间段,无法撤单'); return; } var orderId = decodeURIComponent(btn.getAttribute('data-cancel-order') || ''); if (!orderId) return; if (!confirm('撤销该柜台委托?')) return; btn.disabled = true; btn.textContent = '撤单中…'; fetch('/api/trading/order/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: orderId }) }).then(function (r) { return r.json(); }).then(function (d) { if (!d.ok) throw new Error(d.error || d.message || '撤单失败'); pollPositions(); }).catch(function (e) { alert(e.message || '撤单失败'); btn.disabled = false; btn.textContent = '撤单'; }); }); }); } function slTpStatusHtml(row) { var parts = []; if (row.sl_order_active || row.sl_monitoring) { parts.push('止损监控中'); } else if (row.stop_loss != null) { parts.push('止损已设'); } if (!row.trailing_be) { if (row.tp_order_active || row.tp_monitoring) { parts.push('止盈监控中'); } else if (row.take_profit != null) { parts.push('止盈已设'); } } if (!parts.length) return '未设置'; return parts.join(' · '); } function trailingStatusHtml(row) { if (row.trailing_be) { return '已开启' + (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + ''; } return '未开启'; } function posSymbolTitleHtml(row, extraBadges) { extraBadges = extraBadges || ''; var name = row.symbol_name || row.symbol || ''; var code = row.symbol_code || ''; var mainBadge = row.symbol_is_main ? ' 主力' : ''; var inner = name + mainBadge; if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { inner += ' ' + code + ''; } else if (!name && code) { inner = '' + code + ''; } if (marketNavEnabled && code) { var href = '/market?symbol=' + encodeURIComponent(code) + '&period=15m'; inner = '' + inner + ''; } return inner + extraBadges; } function posSymbolSubHtml(row) { if (row.symbol_exchange) return row.symbol_exchange; return row.symbol_code || ''; } function buildPendingOrderCard(row) { var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); var orderPx = row.order_price != null ? row.order_price : row.entry_price; var remainMin = row.pending_timeout_min != null ? row.pending_timeout_min : (row.auto_cancel_sec != null ? Math.max(1, Math.ceil(row.auto_cancel_sec / 60)) : 5); var cancelAllowed = row.cancel_allowed !== false && isTradingSession; var cancelBtn = ''; if (row.can_cancel_order) { if (row.monitor_id) { cancelBtn = ''; } else if (row.order_id || row.vt_order_id) { cancelBtn = ''; } } var pendingLabel = row.source_label || '挂单中'; var isCloseOrder = pendingLabel.indexOf('平仓') >= 0; var metaLine = '状态 ' + pendingLabel + '' + ' · 委托价 ' + fmtNum(orderPx) + '' + (!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + (row.stop_loss != null || (!row.trailing_be && row.take_profit != null) ? ' · ' + slTpStatusHtml(row) : '') + (row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') + (!isCloseOrder ? ' · 约 ' + remainMin + ' 分钟内未成交自动撤单' : ''); return ( '
' + '
' + posSymbolTitleHtml(row, ' ' + dirBadge + '' + ' ' + (isCloseOrder ? '平仓委托' : '挂单中') + '') + '
' + '
' + posSymbolSubHtml(row) + '
' + '
' + cancelBtn + '
' + '
' + metaLine + '
' + '
' + '
' + row.lots + ' 手
' + '
' + fmtNum(orderPx) + '
' + '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + '
等待成交
' + '
' + (openT || '--') + '
' + '
' + buildPendingHtml(row.pending_orders) + '
' ); } function buildPosCard(row) { if (row.order_state === 'pending') { return buildPendingOrderCard(row); } var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : ''); var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--'; var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); var closeAllowed = row.close_allowed !== false && isTradingSession; var slTpBtn = (!row.stop_loss && !row.take_profit && !row.trailing_be && row.can_close) ? '' : ''; var editPayload = encodeURIComponent(JSON.stringify({ symbol_code: row.symbol_code, direction: row.direction, lots: row.lots, entry_price: row.entry_price, monitor_id: row.monitor_id || null, stop_loss: row.stop_loss, take_profit: row.take_profit, trailing_be: !!row.trailing_be })); var entrustBtn = row.can_close ? '' : ''; var orderBtn = ''; if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) { orderBtn = ''; } var closePayload = encodeURIComponent(JSON.stringify({ source: row.source, symbol_code: row.symbol_code, direction: row.direction, lots: row.lots, mark_price: row.mark_price, monitor_id: row.monitor_id || null })); var closeBtn = row.can_close ? '' : ''; var actionBtns = (entrustBtn || orderBtn || closeBtn) ? '
' + entrustBtn + orderBtn + closeBtn + '
' : ''; var metaLine = '来源 ' + (row.source_label || 'CTP') + '' + (!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 ' + row.rr_ratio + ':1' : '') + ' · 止损金额 ' + (row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '' + (!row.trailing_be ? (' · 盈利金额 ' + (row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '') : '') + ' · ' + slTpStatusHtml(row) + (row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') + (slTpBtn ? ' · ' + slTpBtn : '') + (function () { if (row.order_state === 'pending' || !row.monitor_id) return ''; if (ctpConnecting) { return ' · CTP 连接中…'; } if (row.sync_pending) { return ' · 同步柜台中…'; } return ''; }()); var feeLabel = row.fee_source === 'ctp' ? '已扣手续费(柜台)' : '已扣手续费'; var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金'; var openLabel = '开仓'; return ( '
' + '
' + posSymbolTitleHtml(row, ' ' + dirBadge + '') + '
' + '
' + posSymbolSubHtml(row) + '
' + actionBtns + '
' + '
' + metaLine + '
' + '
' + '
' + row.lots + ' 手
' + '
' + fmtNum(row.entry_price) + '
' + '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + '
' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '
' + '
' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '
' + '
' + pnlText + '
' + '
' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '
' + '
' + (openT || '--') + '
' + '
' + (row.holding_duration || '--') + '
' + '
' + buildPendingHtml(row.pending_orders) + '
' ); } function placeMonitorOrders(monitorId, btn) { if (!monitorId) return; if (!confirm('清理该持仓在柜台残留的旧版止盈/止损挂单?')) return; if (btn) { btn.disabled = true; btn.textContent = '委托中…'; } fetch('/api/trading/monitor/place-orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monitor_id: monitorId }) }) .then(function (r) { return r.json(); }) .then(function (d) { if (!d.ok) throw new Error(d.error || d.message || '委托失败'); var msg = d.message || '委托已提交'; if (d.skipped && d.skipped.length) msg += '\n' + d.skipped.join('\n'); alert(msg); pollPositions(); }) .catch(function (e) { alert(e.message || '委托失败'); if (btn) { btn.disabled = false; btn.textContent = '委托'; } }); } function bindPlaceOrderButtons(root) { if (!root) return; root.querySelectorAll('[data-place-orders]').forEach(function (btn) { btn.addEventListener('click', function () { placeMonitorOrders(parseInt(btn.getAttribute('data-place-orders'), 10), btn); }); }); } var slTpModalState = { payload: null, btn: null, btnLabel: '设置止盈止损' }; function syncSlTpModalTrailingUi() { var trailingEl = document.getElementById('sl-tp-modal-trailing'); var tpWrap = document.getElementById('sl-tp-modal-tp-wrap'); var hint = document.getElementById('sl-tp-modal-trailing-hint'); var on = !!(trailingEl && trailingEl.checked); if (tpWrap) tpWrap.hidden = on; if (hint) hint.hidden = !on; if (on) { var tpInput = document.getElementById('sl-tp-modal-tp'); if (tpInput) tpInput.value = ''; } } function closeSlTpModal() { var mask = document.getElementById('sl-tp-modal'); if (mask) mask.classList.remove('show'); slTpModalState.payload = null; slTpModalState.btn = null; } function openSlTpModal(payload, btn, btnLabel) { var mask = document.getElementById('sl-tp-modal'); var title = document.getElementById('sl-tp-modal-title'); var slInput = document.getElementById('sl-tp-modal-sl'); var tpInput = document.getElementById('sl-tp-modal-tp'); var trailingEl = document.getElementById('sl-tp-modal-trailing'); if (!mask || !slInput) return; slTpModalState.payload = payload; slTpModalState.btn = btn || null; slTpModalState.btnLabel = btnLabel || '设置止盈止损'; if (title) title.textContent = slTpModalState.btnLabel; slInput.value = payload.stop_loss != null && payload.stop_loss !== '' ? String(payload.stop_loss) : ''; if (tpInput) { tpInput.value = payload.take_profit != null && payload.take_profit !== '' ? String(payload.take_profit) : ''; } if (trailingEl) trailingEl.checked = !!payload.trailing_be; syncSlTpModalTrailingUi(); mask.classList.add('show'); slInput.focus(); } function saveSlTpModal() { var payload = slTpModalState.payload; if (!payload) return; var btn = slTpModalState.btn; var btnLabel = slTpModalState.btnLabel; var slInput = document.getElementById('sl-tp-modal-sl'); var tpInput = document.getElementById('sl-tp-modal-tp'); var trailingEl = document.getElementById('sl-tp-modal-trailing'); var trailingOn = !!(trailingEl && trailingEl.checked); var slRaw = slInput && slInput.value ? slInput.value.trim() : ''; var tpRaw = trailingOn ? '' : (tpInput && tpInput.value ? tpInput.value.trim() : ''); var sl = slRaw ? parseFloat(slRaw) : null; var tp = tpRaw ? parseFloat(tpRaw) : null; if (trailingOn && (sl == null || isNaN(sl))) { alert('移动保本须填写止损价'); return; } if (sl == null && tp == null) { alert('请至少填写止损或止盈'); return; } if (btn) { btn.disabled = true; btn.textContent = '保存中…'; } var saveBtn = document.getElementById('sl-tp-modal-save'); if (saveBtn) saveBtn.disabled = true; fetch('/api/trading/monitor/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ symbol_code: payload.symbol_code, direction: payload.direction, lots: payload.lots, entry_price: payload.entry_price, monitor_id: payload.monitor_id || null, stop_loss: sl, take_profit: tp, trailing_be: trailingOn }) }) .then(function (r) { if (!r.ok) { return r.json().catch(function () { return {}; }).then(function (d) { throw new Error(d.error || ('HTTP ' + r.status)); }); } return r.json(); }) .then(function (d) { if (!d.ok) throw new Error(d.error || '保存失败'); closeSlTpModal(); pollPositions(); }) .catch(function (e) { var msg = e.message || '保存失败'; if (msg === 'Failed to fetch') msg = '网络请求失败,请检查服务是否运行'; alert(msg); if (btn) { btn.disabled = false; btn.textContent = btnLabel; } }) .finally(function () { if (saveBtn) saveBtn.disabled = false; }); } function bindSlTpModal() { var mask = document.getElementById('sl-tp-modal'); var trailingEl = document.getElementById('sl-tp-modal-trailing'); var cancelBtn = document.getElementById('sl-tp-modal-cancel'); var saveBtn = document.getElementById('sl-tp-modal-save'); if (trailingEl) trailingEl.addEventListener('change', syncSlTpModalTrailingUi); if (cancelBtn) cancelBtn.addEventListener('click', closeSlTpModal); if (saveBtn) saveBtn.addEventListener('click', saveSlTpModal); if (mask) { mask.addEventListener('click', function (e) { if (e.target === mask) closeSlTpModal(); }); } } function promptStopTakeProfit(payload, btn, btnLabel) { openSlTpModal(payload, btn, btnLabel || '设置止盈止损'); } function bindSlTpButtons(root) { if (!root) return; root.querySelectorAll('[data-sl-tp]').forEach(function (btn) { btn.addEventListener('click', function () { promptStopTakeProfit( JSON.parse(decodeURIComponent(btn.getAttribute('data-sl-tp'))), btn, '设置止盈止损' ); }); }); root.querySelectorAll('[data-edit-sl-tp]').forEach(function (btn) { btn.addEventListener('click', function () { promptStopTakeProfit( JSON.parse(decodeURIComponent(btn.getAttribute('data-edit-sl-tp'))), btn, '委托' ); }); }); } function closePosition(payload, btn) { if (!isTradingSession) { alert('不在交易时间段,无法平仓'); return; } function doClose(price) { if (!price || price <= 0) { alert('无法获取现价'); return; } if (!confirm('确认平仓 ' + payload.lots + ' 手?')) return; if (btn) { btn.disabled = true; btn.textContent = '平仓中…'; } fetch('/api/trading/close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({}, payload, { price: price })) }).then(function (r) { return r.json(); }).then(function (d) { if (!d.ok) { alert(d.error || '平仓失败'); if (btn) { btn.disabled = false; btn.textContent = '平仓'; } return; } if (btn) btn.textContent = '已平仓'; pollPositions(); }).catch(function () { if (btn) { btn.disabled = false; btn.textContent = '平仓'; } }); } if (payload.mark_price > 0) { doClose(payload.mark_price); return; } fetch('/api/trade/quote?symbol=' + encodeURIComponent(payload.symbol_code) + '&lots=' + payload.lots) .then(function (r) { return r.json(); }) .then(function (d) { doClose(d.price); }); } function pollPositions() { if (!list) return; fetch('/api/trading/live') .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { applyPositionsData(data); }) .catch(function () { if (!positionsRendered && list.innerHTML.indexOf('pos-card') < 0) { list.innerHTML = '
持仓加载失败
'; } }); } function connectPositionStream() { if (positionSource) { positionSource.close(); positionSource = null; } positionSource = new EventSource('/api/trading/stream'); positionSource.addEventListener('positions', function (ev) { try { applyPositionsData(JSON.parse(ev.data)); } catch (e) { /* ignore */ } }); positionSource.onerror = function () { if (positionSource) { positionSource.close(); positionSource = null; } setTimeout(connectPositionStream, 3000); }; } function loadRecSortPrefs() { try { var raw = sessionStorage.getItem(REC_SORT_CACHE); if (!raw) return; var p = JSON.parse(raw); if (p.key) recSortKey = p.key; if (typeof p.desc === 'boolean') recSortDesc = p.desc; } catch (e) { /* ignore */ } try { var ind = sessionStorage.getItem(REC_INDUSTRY_CACHE); if (ind != null) recIndustryFilter = ind; } catch (e2) { /* ignore */ } } function saveRecSortPrefs() { try { sessionStorage.setItem(REC_SORT_CACHE, JSON.stringify({ key: recSortKey, desc: recSortDesc })); } catch (e) { /* ignore */ } } function saveRecIndustryPref() { try { sessionStorage.setItem(REC_INDUSTRY_CACHE, recIndustryFilter || ''); } catch (e) { /* ignore */ } } function syncRecSortUi() { var sel = document.getElementById('rec-sort-key'); var btn = document.getElementById('rec-sort-dir'); var indSel = document.getElementById('rec-industry-filter'); if (sel) sel.value = recSortKey; if (btn) btn.textContent = recSortDesc ? '↓' : '↑'; if (indSel) indSel.value = recIndustryFilter || ''; } function filterRecommendRows(rows) { if (!recIndustryFilter) return (rows || []).slice(); return (rows || []).filter(function (r) { return (r.category || '') === recIndustryFilter; }); } function countByCategory(rows) { var counts = {}; (rows || []).forEach(function (r) { var cat = r.category || '其他'; counts[cat] = (counts[cat] || 0) + 1; }); return counts; } function updateRecStats(allRows, visibleRows) { var el = document.getElementById('rec-stats'); if (!el) return; var total = (allRows || []).length; var shown = (visibleRows || []).length; if (!total) { el.textContent = ''; return; } var parts = []; if (recIndustryFilter) { parts.push('筛选 ' + shown + ' / 共 ' + total + ' 个品种'); } else { parts.push('共 ' + total + ' 个品种'); } var order = productCategories.length ? productCategories.slice() : []; var counts = countByCategory(recIndustryFilter ? visibleRows : allRows); Object.keys(counts).forEach(function (k) { if (order.indexOf(k) < 0) order.push(k); }); var breakdown = order.filter(function (cat) { return counts[cat]; }).map(function (cat) { return cat + ' ' + counts[cat]; }); if (breakdown.length) parts.push(breakdown.join(' · ')); el.innerHTML = parts.join(' · '); } var TREND_SORT_RANK = { break_long: 0, break_short: 0, long: 1, short: 2, range: 3, '': 9 }; var GAP_SORT_RANK = { up: 2, down: 1, none: 0, '': -1 }; function sortRecommendRows(rows) { var list = (rows || []).slice(); var key = recSortKey || 'trend'; var desc = recSortDesc; list.sort(function (a, b) { var av, bv, as, bs; if (key === 'gap') { av = GAP_SORT_RANK[a.gap || ''] !== undefined ? GAP_SORT_RANK[a.gap || ''] : -1; bv = GAP_SORT_RANK[b.gap || ''] !== undefined ? GAP_SORT_RANK[b.gap || ''] : -1; as = Math.abs(Number(a.gap_pct) || 0); bs = Math.abs(Number(b.gap_pct) || 0); } else if (key === 'volume') { av = Number(a.volume) || 0; bv = Number(b.volume) || 0; as = bs = 0; } else if (key === 'amplitude') { av = Number(a.yesterday_amplitude_pct) || 0; bv = Number(b.yesterday_amplitude_pct) || 0; as = bs = 0; } else { av = TREND_SORT_RANK[a.trend || ''] !== undefined ? TREND_SORT_RANK[a.trend || ''] : 9; bv = TREND_SORT_RANK[b.trend || ''] !== undefined ? TREND_SORT_RANK[b.trend || ''] : 9; as = Number(a.max_lots) || 0; bs = Number(b.max_lots) || 0; } if (av !== bv) return desc ? bv - av : av - bv; if (as !== bs) return desc ? bs - as : as - bs; return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); }); return list; } function fmtRecVolume(v) { if (v === null || v === undefined) return '—'; var n = Number(v); if (!isFinite(n)) return '—'; if (n >= 10000) return (n / 10000).toFixed(1) + '万'; return String(Math.round(n)); } function fmtRecTurnover(v) { if (v === null || v === undefined) return '—'; var n = Number(v); if (!isFinite(n)) return '—'; if (n >= 1e8) return (n / 1e8).toFixed(2) + '亿'; if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; return String(Math.round(n)); } function changeCellHtml(r) { if (r.yesterday_change == null) return '—'; var ch = Number(r.yesterday_change); var cls = ch > 0 ? 'rec-change-up' : (ch < 0 ? 'rec-change-down' : ''); var txt = (ch > 0 ? '+' : '') + ch; if (r.yesterday_change_pct != null) { var pct = Number(r.yesterday_change_pct); txt += ' (' + (pct > 0 ? '+' : '') + pct + '%)'; } return '' + txt + ''; } function trendBadgeHtml(r) { var label = r.trend_label || ''; if (!label || label === '—') return '—'; var cls = 'planned'; if (r.trend === 'break_long' || r.trend === 'break_short') cls = 'break'; else if (r.trend === 'long') cls = 'profit'; else if (r.trend === 'short') cls = 'loss'; var title = ''; if (r.trend_overlap_pct != null) title = ' title="近3日重叠 ' + r.trend_overlap_pct + '%"'; var prefix = r.trend_transition ? '★ ' : ''; return '' + prefix + label + ''; } function gapBadgeHtml(r) { var label = r.gap_label || ''; if (!label || label === '—') return '—'; var cls = 'planned'; if (r.gap === 'up') cls = 'profit'; else if (r.gap === 'down') cls = 'loss'; var title = ''; if (r.gap_pct != null && r.gap !== 'none') { title = ' title="跳空 ' + (Number(r.gap_pct) > 0 ? '+' : '') + r.gap_pct + '%"'; } return '' + label + ''; } function fmtRecNum(v) { if (v == null || v === '') return '—'; var n = Number(v); if (!isFinite(n)) return '—'; return String(n); } function recSpecSuffix(r) { return r.spec_source === 'ctp' ? ' (柜台)' : ''; } function recSymbolCellHtml(r) { var code = r.main_code || r.ths || ''; var nameCls = r.trend_transition ? ' class="trend-name"' : ''; var name = r.name || ''; var nightTag = r.has_night_session ? ' 夜盘' : ''; if (marketNavEnabled && r.main_code) { var href = '/market?symbol=' + encodeURIComponent(r.main_code) + '&period=d'; return ( '' + '' + name + nightTag + ' ' + '' + r.main_code + '' ); } return ( '' + name + nightTag + ' ' + '' + code + '' ); } function renderRecommendRows(rows) { if (!recommendList) return; if (!rows.length) { var emptyMsg = recIndustryFilter ? '当前行业下暂无推荐品种' : '当前资金下暂无推荐品种(每日后台刷新)'; recommendList.innerHTML = '' + emptyMsg + ''; return; } recommendList.innerHTML = rows.map(function (r) { var rowCls = 'rec-' + (r.status || ''); if (r.trend_transition) rowCls += ' rec-trend-break'; return ( '' + recSymbolCellHtml(r) + '' + (r.exchange || '') + '' + '' + (r.category || '—') + '' + '' + trendBadgeHtml(r) + '' + '' + gapBadgeHtml(r) + '' + '' + (r.price != null ? r.price : '—') + '' + '' + (r.prev_close != null ? r.prev_close : '—') + '' + '' + (r.today_open != null ? r.today_open : '—') + '' + '' + changeCellHtml(r) + '' + '' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '' + '' + fmtRecVolume(r.volume) + '' + '' + fmtRecTurnover(r.turnover) + '' + '' + fmtRecNum(r.mult) + recSpecSuffix(r) + '' + '' + fmtRecNum(r.tick_size) + recSpecSuffix(r) + '' + '' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' (柜台)' : '') : '—') + '' + '' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '' + '' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '' + '' + (r.status_label || '') + '' + '' ); }).join(''); } function renderRecommendTable() { var filtered = filterRecommendRows(recRowsRaw); var sorted = sortRecommendRows(filtered); updateRecStats(recRowsRaw, sorted); renderRecommendRows(sorted); } function renderRecommendations(data) { if (!recommendList || !data) return; updateRecommendMaxMaps(data); var recCap = document.getElementById('rec-capital'); if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); var recUpdated = document.getElementById('rec-updated'); if (recUpdated && data.updated_at) { recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at; } var rows = data.rows || []; recRowsRaw = rows.slice(); if (!rows.length) { recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; updateRecStats([], []); return; } renderRecommendTable(); } function initRecommendSortControls() { loadRecSortPrefs(); syncRecSortUi(); var sel = document.getElementById('rec-sort-key'); var btn = document.getElementById('rec-sort-dir'); var indSel = document.getElementById('rec-industry-filter'); if (indSel) { indSel.addEventListener('change', function () { recIndustryFilter = indSel.value || ''; saveRecIndustryPref(); renderRecommendTable(); }); } if (sel) { sel.addEventListener('change', function () { recSortKey = sel.value || 'trend'; saveRecSortPrefs(); renderRecommendTable(); }); } if (btn) { btn.addEventListener('click', function () { recSortDesc = !recSortDesc; saveRecSortPrefs(); syncRecSortUi(); renderRecommendTable(); }); } if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw)); } function connectRecommendStream() { if (recommendSource) { recommendSource.close(); recommendSource = null; } recommendSource = new EventSource('/api/recommend/stream'); recommendSource.addEventListener('recommend', function (ev) { try { renderRecommendations(JSON.parse(ev.data)); } catch (e) { /* ignore */ } }); recommendSource.onerror = function () { if (recommendSource) { recommendSource.close(); recommendSource = null; } setTimeout(connectRecommendStream, 5000); }; } document.querySelectorAll('.price-tab').forEach(function (btn) { btn.addEventListener('click', function () { setPriceType(btn.getAttribute('data-type')); scheduleQuote(); }); }); if (symInput) { symInput.addEventListener('input', function () { selectedMaxLots = null; scheduleQuote(); scheduleAutoCalc(); checkLotsLimit(); }); symInput.addEventListener('symbol-selected', function (ev) { var item = ev.detail || {}; selectedMaxLots = item.max_lots > 0 ? item.max_lots : null; scheduleQuote(); scheduleAutoCalc(); checkLotsLimit(); }); } if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit); if (slInput) { slInput.addEventListener('input', function () { scheduleAutoCalc(); updateRRDisplay(); }); } if (tpInput) { tpInput.addEventListener('input', function () { scheduleAutoCalc(); updateRRDisplay(); }); } if (dirSelect) dirSelect.addEventListener('change', function () { scheduleAutoCalc(); updateRRDisplay(); }); var trailingBeEl = document.getElementById('trailing-be'); if (trailingBeEl) { trailingBeEl.addEventListener('change', updateTrailingBeUi); updateTrailingBeUi(); } if (priceInput) { priceInput.addEventListener('input', function () { if (priceType === 'limit') priceInput.dataset.manual = '1'; scheduleAutoCalc(); updateRRDisplay(); }); } var btnOpen = document.getElementById('btn-open'); if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); }); var btnConnect = document.getElementById('btn-ctp-connect'); if (btnConnect) { btnConnect.addEventListener('click', function () { requestCtpConnect(true); }); } function initCtpOnLoad() { fetch('/api/ctp/status') .then(function (r) { return r.json(); }) .then(function (d) { var st = d.status || {}; if (typeof st.auto_connect_enabled === 'boolean') { ctpAutoConnectEnabled = st.auto_connect_enabled; } updateCtpConnectButtonState(); syncCtpBadgeFromStatus(st); if (st.disabled_hint) { showCtpError(st.disabled_hint); } else if (st.last_error) { showCtpError(st.last_error); } if (st.connected) pollPositions(); }) .catch(function () {}); } function cleanupTradePage() { if (sessionClockTickTimer) { clearInterval(sessionClockTickTimer); sessionClockTickTimer = null; } if (positionSource) { positionSource.close(); positionSource = null; } if (recommendSource) { recommendSource.close(); recommendSource = null; } if (quoteTimer) { clearTimeout(quoteTimer); quoteTimer = null; } if (calcTimer) { clearTimeout(calcTimer); calcTimer = null; } } function bootTradePage() { loadTradeConfig(); if (!list && !orderList) return; updateCtpConnectButtonState(); setPriceType('limit'); if (isFixedMode() && lotsCalc) { lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); if (lotsInput) lotsInput.value = lotsCalc.value; } var cached = loadPosCache(); if (cached) { if (cached.ctp_status) { cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); } if (cached.ctp_status && cached.ctp_status.connected) { applyPositionsData(cached); } } pollPositions(); connectPositionStream(); bindSlTpModal(); initCtpOnLoad(); connectRecommendStream(); initRecommendSortControls(); if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) { recRowsRaw = window.__RECOMMEND_ROWS__.slice(); renderRecommendTable(); } fetch('/api/recommend/list') .then(function (r) { return r.json(); }) .then(function (data) { if (data.ok) renderRecommendations(data); }) .catch(function () {}); updateSessionUi(); updateRRDisplay(); scheduleQuote(); scheduleAutoCalc(); } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && list && !positionSource) { connectPositionStream(); } }); function startTradePage() { if (!document.querySelector('.trade-page')) return; bootTradePage(); } if (window.qihuoPageBoot) window.qihuoPageBoot(startTradePage, '.trade-page'); else if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', startTradePage); else startTradePage(); window.addEventListener('pagehide', cleanupTradePage); })();