e5a586f903
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
2046 lines
85 KiB
JavaScript
2046 lines
85 KiB
JavaScript
/* 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 posFastPollTimer = null;
|
||
var posFastPollCount = 0;
|
||
var lastPosRowCount = 0;
|
||
var selectedMaxLots = null;
|
||
var recommendMaxByProduct = {};
|
||
var recommendMaxByCode = {};
|
||
var recRowsRaw = [];
|
||
var recMeta = {};
|
||
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);
|
||
if (cfg.capital != null) {
|
||
var capEl = document.getElementById('cap-display');
|
||
if (capEl) capEl.textContent = Number(cfg.capital).toFixed(2);
|
||
}
|
||
if (cfg.bootstrap_live && (cfg.bootstrap_live.rows || cfg.bootstrap_live.capital != null)) {
|
||
window.__BOOTSTRAP_LIVE__ = cfg.bootstrap_live;
|
||
}
|
||
} 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 hasRows = data.rows && data.rows.length;
|
||
var hasOrders = data.active_orders && data.active_orders.length;
|
||
if (!hasRows && !hasOrders && data.capital == null) {
|
||
return;
|
||
}
|
||
if (!hasRows && lastPosRowCount > 0) {
|
||
var prev = loadPosCache();
|
||
if (prev && prev.rows && prev.rows.length) {
|
||
data = Object.assign({}, data, { rows: prev.rows });
|
||
}
|
||
}
|
||
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 = '<div class="empty-hint text-muted">' + hint + '</div>';
|
||
return;
|
||
}
|
||
orderList.innerHTML = '<div class="empty-hint">暂无委托。</div>';
|
||
return;
|
||
}
|
||
orderList.innerHTML = orders.map(buildPendingOrderCard).join('');
|
||
bindPendingDismiss(orderList);
|
||
bindCancelOpenButtons(orderList);
|
||
bindCancelOrderButtons(orderList);
|
||
}
|
||
|
||
function findPosCardByKey(key) {
|
||
if (!list || !key) return null;
|
||
var cards = list.querySelectorAll('.pos-card[data-pos-key]');
|
||
for (var i = 0; i < cards.length; i++) {
|
||
if (cards[i].getAttribute('data-pos-key') === key) return cards[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function applyPositionQuotes(data) {
|
||
if (!data || !data.quotes || !list) return;
|
||
data.quotes.forEach(function (q) {
|
||
var card = findPosCardByKey(q.key || q.position_key);
|
||
if (!card) return;
|
||
var markEl = card.querySelector('.pos-q-mark');
|
||
var pnlEl = card.querySelector('.pos-q-pnl');
|
||
var pnlWrap = card.querySelector('.pos-q-pnl-wrap');
|
||
if (markEl && q.mark_price != null) markEl.textContent = fmtNum(q.mark_price);
|
||
if (pnlEl && q.float_pnl != null) {
|
||
var pnl = q.float_pnl;
|
||
pnlEl.textContent = (pnl >= 0 ? '+' : '') + fmtNum(pnl) + ' 元';
|
||
if (pnlWrap) {
|
||
pnlWrap.classList.remove('pnl-pos', 'pnl-neg');
|
||
if (pnl > 0) pnlWrap.classList.add('pnl-pos');
|
||
else if (pnl < 0) pnlWrap.classList.add('pnl-neg');
|
||
}
|
||
}
|
||
var closeBtn = card.querySelector('[data-close]');
|
||
if (closeBtn && q.mark_price != null) {
|
||
try {
|
||
var payload = JSON.parse(decodeURIComponent(closeBtn.getAttribute('data-close')));
|
||
payload.mark_price = q.mark_price;
|
||
closeBtn.setAttribute('data-close', encodeURIComponent(JSON.stringify(payload)));
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopPosFastPoll() {
|
||
if (posFastPollTimer) {
|
||
clearInterval(posFastPollTimer);
|
||
posFastPollTimer = null;
|
||
}
|
||
posFastPollCount = 0;
|
||
}
|
||
|
||
function startPosFastPoll() {
|
||
if (posFastPollTimer) return;
|
||
posFastPollCount = 0;
|
||
posFastPollTimer = setInterval(function () {
|
||
pollPositions();
|
||
posFastPollCount += 1;
|
||
if (posFastPollCount >= 90) stopPosFastPoll();
|
||
}, 1000);
|
||
}
|
||
|
||
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 = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
|
||
startPosFastPoll();
|
||
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 = '<div class="empty-hint text-loss">' + err + '</div>';
|
||
return;
|
||
}
|
||
if (!ctpAutoConnectEnabled) {
|
||
var offHint = (data.ctp_status && data.ctp_status.disabled_hint) ||
|
||
'CTP 自动连接已关闭,请在系统设置中开启';
|
||
list.innerHTML = '<div class="empty-hint text-muted">' + offHint + '</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = '<div class="empty-hint">CTP 未连接,后台自动连接中…</div>';
|
||
if (ctpAutoConnectEnabled) refreshCtpStatusPassive();
|
||
return;
|
||
}
|
||
var syncing = data.sync_state === 'syncing';
|
||
var hadPos = lastPosRowCount > 0 || !!list.querySelector('.pos-card');
|
||
var marginOpen = Number(data.margin_used) > 0;
|
||
var riskActive = data.risk_status && Number(data.risk_status.active_count) > 0;
|
||
if (syncing || hadPos || marginOpen || riskActive) {
|
||
if (syncBadge) {
|
||
syncBadge.hidden = false;
|
||
syncBadge.textContent = data.sync_label || '持仓同步中…';
|
||
syncBadge.className = 'sync-badge text-accent';
|
||
}
|
||
startPosFastPoll();
|
||
return;
|
||
}
|
||
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
|
||
lastPosRowCount = 0;
|
||
syncPositionListScroll(0);
|
||
return;
|
||
}
|
||
lastPosRowCount = rows.length;
|
||
stopPosFastPoll();
|
||
list.innerHTML = rows.map(buildPosCard).join('');
|
||
syncPositionListScroll(rows.length);
|
||
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 || '') +
|
||
' · 距开盘 <strong>' + fmtCountdown(c.secs_to_open - elapsed) + '</strong>';
|
||
} else if (c.in_session) {
|
||
if (c.secs_to_break != null) {
|
||
html += ' · 距' + (c.break_label || '休盘') + ' <strong>' +
|
||
fmtCountdown(c.secs_to_break - elapsed) + '</strong>';
|
||
}
|
||
if (c.secs_to_close != null) {
|
||
html += ' · 距' + (c.close_label || '收盘') + ' <strong>' +
|
||
fmtCountdown(c.secs_to_close - elapsed) + '</strong>';
|
||
}
|
||
}
|
||
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 分钟检查连接 · 不自动强制断开'
|
||
: '自动连接已关闭 · 盘前 30 分钟及交易时段仍会按计划连接 · 断开请手动操作';
|
||
}
|
||
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);
|
||
pollPositions();
|
||
return new Promise(function (resolve) {
|
||
setTimeout(function () { resolve(tick()); }, 800);
|
||
});
|
||
}
|
||
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 =
|
||
'<strong>' + (data.name || sym) + '</strong> 精度 ' + m.price_precision +
|
||
' 位 · 每跳 <strong class="text-accent">' + m.tick_value_total + '</strong> 元(' + 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();
|
||
});
|
||
}
|
||
|
||
/** 只读 CTP 状态;连接由 qihuo-ctp 后台 worker 负责,前端不发起 connect。 */
|
||
function refreshCtpStatusPassive() {
|
||
if (ctpConnected || ctpConnecting) return;
|
||
var now = Date.now();
|
||
if (now - lastCtpReconnectAt < 8000) return;
|
||
lastCtpReconnectAt = now;
|
||
fetch('/api/ctp/status')
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (d) {
|
||
var st = d.status || {};
|
||
syncCtpBadgeFromStatus(st);
|
||
if (st.connected) {
|
||
showCtpError('');
|
||
pollPositions();
|
||
startPosFastPoll();
|
||
} else if (st.connecting) {
|
||
updateCtpBadge(false, true);
|
||
startPosFastPoll();
|
||
} else if (st.last_error) {
|
||
showCtpError(st.last_error);
|
||
} else if (st.disabled_hint) {
|
||
showCtpError(st.disabled_hint);
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
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 ?
|
||
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
|
||
return (
|
||
'<div class="pos-pending-item ' + cls + '">' +
|
||
'<span>' + (p.label || '挂单') + '</span>' +
|
||
'<span class="pos-pending-right">' +
|
||
'<strong>' + fmtNum(p.price) + '</strong> · ' + (p.lots || 1) + ' 手' +
|
||
dismissBtn +
|
||
'</span>' +
|
||
'</div>'
|
||
);
|
||
}).join('');
|
||
return '<div class="pos-pending-orders"><div class="pending-title">止盈止损监控</div>' + rows + '</div>';
|
||
}
|
||
|
||
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('<span class="text-profit">止损监控中</span>');
|
||
} else if (row.stop_loss != null) {
|
||
parts.push('<span class="text-muted">止损已设</span>');
|
||
}
|
||
if (!row.trailing_be) {
|
||
if (row.tp_order_active || row.tp_monitoring) {
|
||
parts.push('<span class="text-profit">止盈监控中</span>');
|
||
} else if (row.take_profit != null) {
|
||
parts.push('<span class="text-muted">止盈已设</span>');
|
||
}
|
||
}
|
||
if (!parts.length) return '<span class="text-muted">未设置</span>';
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function trailingStatusHtml(row) {
|
||
if (row.trailing_be) {
|
||
return '<span class="text-accent">已开启' +
|
||
(row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '</span>';
|
||
}
|
||
return '<span class="text-muted">未开启</span>';
|
||
}
|
||
|
||
function syncPositionListScroll(count) {
|
||
if (!list) return;
|
||
var n = count != null ? count : list.querySelectorAll('.pos-card[data-pos-key]').length;
|
||
var isDesktopTablet = !document.documentElement.classList.contains('layout-phone')
|
||
&& document.documentElement.dataset.layout !== 'phone'
|
||
&& document.documentElement.dataset.mobile !== '1';
|
||
list.classList.toggle('pos-list-many', isDesktopTablet && n > 3);
|
||
}
|
||
|
||
function breakevenBadgeHtml(row) {
|
||
if (row.breakeven_locked) {
|
||
return ' <span class="badge profit pos-be-badge">已保本</span>';
|
||
}
|
||
if ((row.trailing_r_locked || 0) >= 1) {
|
||
return ' <span class="badge profit pos-be-badge">已保本</span>';
|
||
}
|
||
if (row.stop_loss == null || row.entry_price == null) return '';
|
||
var entry = Number(row.entry_price);
|
||
var sl = Number(row.stop_loss);
|
||
if (isNaN(entry) || isNaN(sl) || entry <= 0) return '';
|
||
var tick = Number(row.tick_size) || Math.max(Math.abs(entry) * 1e-6, 0.01);
|
||
var beMult = 2;
|
||
var dir = (row.direction || 'long').toString().toLowerCase();
|
||
var expectedBe = dir === 'short' ? entry - beMult * tick : entry + beMult * tick;
|
||
var tol = beMult * tick + tick * 0.05;
|
||
if (Math.abs(sl - expectedBe) <= tol) {
|
||
return ' <span class="badge profit pos-be-badge">已保本</span>';
|
||
}
|
||
var buf = tick * Math.max(2, beMult);
|
||
if (Math.abs(sl - entry) > buf + tick) return '';
|
||
if (dir === 'short' ? sl <= entry + tick * 0.05 : sl >= entry - tick * 0.05) {
|
||
return ' <span class="badge profit pos-be-badge">已保本</span>';
|
||
}
|
||
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 ? ' <span class="badge planned pos-main-badge">主力</span>' : '';
|
||
var beBadge = breakevenBadgeHtml(row);
|
||
var inner = name + mainBadge;
|
||
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
|
||
inner += ' <span class="text-accent">' + code + '</span>' + beBadge;
|
||
} else if (!name && code) {
|
||
inner = '<span class="text-accent">' + code + '</span>' + beBadge;
|
||
} else {
|
||
inner += beBadge;
|
||
}
|
||
if (marketNavEnabled && code) {
|
||
var href = '/market?symbol=' + encodeURIComponent(code) + '&period=15m';
|
||
inner = '<a href="' + href + '" class="pos-market-link" title="查看 15 分 K 线">' + inner + '</a>';
|
||
}
|
||
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 = '<button type="button" class="pos-close-btn' + (cancelAllowed ? '' : ' is-session-off') + '"' +
|
||
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
|
||
' data-cancel-open="' + row.monitor_id + '">撤单</button>';
|
||
} else if (row.order_id || row.vt_order_id) {
|
||
cancelBtn = '<button type="button" class="pos-close-btn' + (cancelAllowed ? '' : ' is-session-off') + '"' +
|
||
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
|
||
' data-cancel-order="' + encodeURIComponent(row.vt_order_id || row.order_id) + '">撤单</button>';
|
||
}
|
||
}
|
||
var pendingLabel = row.source_label || '挂单中';
|
||
var isCloseOrder = pendingLabel.indexOf('平仓') >= 0;
|
||
var metaLine =
|
||
'状态 <strong class="text-accent">' + pendingLabel + '</strong>' +
|
||
' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' +
|
||
(!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
|
||
(row.stop_loss != null || (!row.trailing_be && row.take_profit != null) ? ' · ' + slTpStatusHtml(row) : '') +
|
||
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
|
||
(!isCloseOrder ? ' · <span class="text-muted">约 ' + remainMin + ' 分钟内未成交自动撤单</span>' : '');
|
||
return (
|
||
'<div class="pos-card is-pending">' +
|
||
'<div class="pos-card-head"><div><div class="title">' + posSymbolTitleHtml(row,
|
||
' <span class="badge dir">' + dirBadge + '</span>' +
|
||
' <span class="badge pending">' + (isCloseOrder ? '平仓委托' : '挂单中') + '</span>') + '</div>' +
|
||
'<div class="text-muted pos-symbol-sub">' + posSymbolSubHtml(row) + '</div></div>' +
|
||
'<div class="pos-card-actions">' + cancelBtn + '</div></div>' +
|
||
'<div class="pos-card-meta pos-card-meta-line">' + metaLine + '</div>' +
|
||
'<div class="pos-metrics">' +
|
||
'<div class="cell"><label>委托手数</label><div><strong>' + row.lots + ' 手</strong></div></div>' +
|
||
'<div class="cell"><label>委托价</label><div>' + fmtNum(orderPx) + '</div></div>' +
|
||
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
|
||
'<div class="cell pnl-pending"><label>状态</label><div class="text-accent">等待成交</div></div>' +
|
||
'<div class="cell"><label>提交时间</label><div>' + (openT || '--') + '</div></div>' +
|
||
'</div>' +
|
||
buildPendingHtml(row.pending_orders) +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
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) ?
|
||
'<button type="button" class="pos-dismiss-btn pos-sl-btn" data-sl-tp="' +
|
||
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,
|
||
trailing_be: !!row.trailing_be
|
||
})) + '">设置止盈止损</button>' : '';
|
||
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 ?
|
||
'<button type="button" class="pos-order-btn pos-entrust-btn" data-edit-sl-tp="' + editPayload + '">委托</button>' : '';
|
||
var orderBtn = '';
|
||
if (row.monitor_id && (row.stop_loss != null || row.take_profit != null) && row.can_place_orders) {
|
||
orderBtn = '<button type="button" class="pos-order-btn" data-place-orders="' + row.monitor_id + '">清理旧挂单</button>';
|
||
}
|
||
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 ?
|
||
'<button type="button" class="pos-close-btn' + (closeAllowed ? '' : ' is-session-off') + '" ' +
|
||
(closeAllowed ? '' : 'disabled title="不在交易时间段"') +
|
||
' data-close="' + closePayload + '">平仓</button>' : '';
|
||
var actionBtns = (entrustBtn || orderBtn || closeBtn) ?
|
||
'<div class="pos-card-actions">' + entrustBtn + orderBtn + closeBtn + '</div>' : '';
|
||
var metaLine =
|
||
'来源 <strong>' + (row.source_label || 'CTP') + '</strong>' +
|
||
(!row.trailing_be && row.rr_ratio != null ? ' · 盈亏比 <strong>' + row.rr_ratio + ':1</strong>' : '') +
|
||
' · 止损金额 <strong class="text-loss">' +
|
||
(row.risk_amount != null ? fmtNum(row.risk_amount) + ' 元' : '--') + '</strong>' +
|
||
(!row.trailing_be ?
|
||
(' · 盈利金额 <strong class="text-profit">' +
|
||
(row.reward_amount != null ? fmtNum(row.reward_amount) + ' 元' : '--') + '</strong>') : '') +
|
||
' · ' + slTpStatusHtml(row) +
|
||
(row.trailing_be ? ' · 移动保本 ' + trailingStatusHtml(row) : '') +
|
||
(slTpBtn ? ' · ' + slTpBtn : '') +
|
||
(function () {
|
||
if (row.order_state === 'pending' || !row.monitor_id) return '';
|
||
if (ctpConnecting) {
|
||
return ' · <span class="text-muted">CTP 连接中…</span>';
|
||
}
|
||
if (row.sync_pending) {
|
||
return ' · <span class="text-muted">同步柜台中…</span>';
|
||
}
|
||
return '';
|
||
}());
|
||
var feeLabel = row.fee_source === 'ctp' ? '已扣手续费(柜台)' : '已扣手续费';
|
||
var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金';
|
||
var openLabel = '开仓';
|
||
var rowKey = row.key || row.position_key || '';
|
||
return (
|
||
'<div class="pos-card" data-pos-key="' + rowKey + '">' +
|
||
'<div class="pos-card-head"><div><div class="title">' + posSymbolTitleHtml(row,
|
||
' <span class="badge dir">' + dirBadge + '</span>') + '</div>' +
|
||
'<div class="text-muted pos-symbol-sub">' + posSymbolSubHtml(row) + '</div></div>' +
|
||
actionBtns + '</div>' +
|
||
'<div class="pos-card-meta pos-card-meta-line">' + metaLine + '</div>' +
|
||
'<div class="pos-metrics">' +
|
||
'<div class="cell"><label>手数</label><div><strong>' + row.lots + ' 手</strong></div></div>' +
|
||
'<div class="cell"><label>均价</label><div class="pos-q-entry">' + fmtNum(row.entry_price) + '</div></div>' +
|
||
'<div class="cell"><label>当前价格</label><div class="pos-q-mark">' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
|
||
'<div class="cell"><label>' + marginLabel + '</label><div>' + (row.margin != null ? fmtNum(row.margin) + ' 元' : '--') + '</div></div>' +
|
||
'<div class="cell"><label>仓位占比</label><div>' + (row.position_pct != null ? fmtNum(row.position_pct) + '%' : '--') + '</div></div>' +
|
||
'<div class="cell pos-q-pnl-wrap ' + pnlClass + '"><label>浮盈亏</label><div class="pos-q-pnl">' + pnlText + '</div></div>' +
|
||
'<div class="cell"><label>' + feeLabel + '</label><div>' + (row.est_fee != null ? fmtNum(row.est_fee) + ' 元' : '--') + '</div></div>' +
|
||
'<div class="cell"><label>' + openLabel + '</label><div>' + (openT || '--') + '</div></div>' +
|
||
'<div class="cell"><label>持仓</label><div>' + (row.holding_duration || '--') + '</div></div>' +
|
||
'</div>' + buildPendingHtml(row.pending_orders) +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
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 = '<div class="empty-hint text-loss">持仓加载失败</div>';
|
||
}
|
||
});
|
||
}
|
||
|
||
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.addEventListener('position_quotes', function (ev) {
|
||
try {
|
||
applyPositionQuotes(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, meta) {
|
||
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 (meta && meta.margin_used > 0) {
|
||
parts.push(
|
||
'持仓占用 <strong>' + fmtNum(meta.margin_used) + '</strong> 元,' +
|
||
'剩余额度 <strong class="text-accent">' + fmtNum(meta.margin_budget_remaining) + '</strong> 元'
|
||
);
|
||
}
|
||
if (recIndustryFilter) {
|
||
parts.push('筛选 <strong>' + shown + '</strong> / 共 ' + total + ' 个品种');
|
||
} else {
|
||
parts.push('共 <strong>' + total + '</strong> 个品种');
|
||
}
|
||
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 '<span class="' + cls + '">' + txt + '</span>';
|
||
}
|
||
|
||
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 '<span class="badge trend-badge ' + cls + '"' + title + '>' + prefix + label + '</span>';
|
||
}
|
||
|
||
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 '<span class="badge gap-badge ' + cls + '"' + title + '>' + label + '</span>';
|
||
}
|
||
|
||
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' ? ' <span class="text-muted">(柜台)</span>' : '';
|
||
}
|
||
|
||
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 ? ' <span class="night-session-tag">夜盘</span>' : '';
|
||
if (marketNavEnabled && r.main_code) {
|
||
var href = '/market?symbol=' + encodeURIComponent(r.main_code) + '&period=d';
|
||
return (
|
||
'<td><a href="' + href + '" class="rec-market-link" title="查看日线 K 线">' +
|
||
'<strong' + nameCls + '>' + name + nightTag + '</strong> ' +
|
||
'<span class="text-accent">' + r.main_code + '</span></a></td>'
|
||
);
|
||
}
|
||
return (
|
||
'<td><strong' + nameCls + '>' + name + nightTag + '</strong> ' +
|
||
'<span class="text-accent">' + code + '</span></td>'
|
||
);
|
||
}
|
||
|
||
function renderRecommendRows(rows) {
|
||
if (!recommendList) return;
|
||
if (!rows.length) {
|
||
var emptyMsg = recIndustryFilter
|
||
? '当前行业下暂无推荐品种'
|
||
: '当前资金下暂无推荐品种(每日后台刷新)';
|
||
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">' + emptyMsg + '</td></tr>';
|
||
return;
|
||
}
|
||
recommendList.innerHTML = rows.map(function (r) {
|
||
var rowCls = 'rec-' + (r.status || '');
|
||
if (r.trend_transition) rowCls += ' rec-trend-break';
|
||
return (
|
||
'<tr class="' + rowCls + '">' +
|
||
recSymbolCellHtml(r) +
|
||
'<td>' + (r.exchange || '') + '</td>' +
|
||
'<td>' + (r.category || '—') + '</td>' +
|
||
'<td>' + trendBadgeHtml(r) + '</td>' +
|
||
'<td>' + gapBadgeHtml(r) + '</td>' +
|
||
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
|
||
'<td>' + (r.prev_close != null ? r.prev_close : '—') + '</td>' +
|
||
'<td>' + (r.today_open != null ? r.today_open : '—') + '</td>' +
|
||
'<td>' + changeCellHtml(r) + '</td>' +
|
||
'<td>' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '</td>' +
|
||
'<td>' + fmtRecVolume(r.volume) + '</td>' +
|
||
'<td>' + fmtRecTurnover(r.turnover) + '</td>' +
|
||
'<td>' + fmtRecNum(r.mult) + recSpecSuffix(r) + '</td>' +
|
||
'<td>' + fmtRecNum(r.tick_size) + recSpecSuffix(r) + '</td>' +
|
||
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' <span class="text-muted">(柜台)</span>' : '') : '—') + '</td>' +
|
||
'<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' +
|
||
'<td>' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '</td>' +
|
||
'<td><span class="badge ' + (r.status === 'ok' ? 'profit' : 'planned') + '">' + (r.status_label || '') + '</span></td>' +
|
||
'</tr>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
function renderRecommendTable() {
|
||
var filtered = filterRecommendRows(recRowsRaw);
|
||
var sorted = sortRecommendRows(filtered);
|
||
updateRecStats(recRowsRaw, sorted, recMeta);
|
||
renderRecommendRows(sorted);
|
||
}
|
||
|
||
function renderRecommendations(data) {
|
||
if (!recommendList || !data) return;
|
||
updateRecommendMaxMaps(data);
|
||
recMeta = {
|
||
margin_used: data.margin_used || 0,
|
||
margin_budget_remaining: data.margin_budget_remaining,
|
||
margin_budget_total: data.margin_budget_total
|
||
};
|
||
var recCap = document.getElementById('rec-capital');
|
||
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
|
||
var recMarginHint = document.getElementById('rec-margin-hint');
|
||
if (recMarginHint) {
|
||
if (recMeta.margin_used > 0) {
|
||
recMarginHint.textContent = ' · 已扣持仓占用 ' + fmtNum(recMeta.margin_used) + ' 元';
|
||
} else {
|
||
recMarginHint.textContent = '';
|
||
}
|
||
}
|
||
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 = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
||
updateRecStats([], [], recMeta);
|
||
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), recMeta);
|
||
}
|
||
|
||
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();
|
||
startPosFastPoll();
|
||
} else if (st.connecting) {
|
||
startPosFastPoll();
|
||
waitForCtpConnected(90000);
|
||
} else if (ctpAutoConnectEnabled && !(st.login_cooldown_sec > 0)) {
|
||
refreshCtpStatusPassive();
|
||
startPosFastPoll();
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
function cleanupTradePage() {
|
||
stopPosFastPoll();
|
||
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 bootData = window.__BOOTSTRAP_LIVE__;
|
||
if (bootData) {
|
||
applyPositionsData(bootData);
|
||
savePosCache(bootData);
|
||
} else {
|
||
var cached = loadPosCache();
|
||
if (cached && ((cached.rows && cached.rows.length) || cached.capital != null)) {
|
||
if (cached.ctp_status) {
|
||
cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false });
|
||
}
|
||
applyPositionsData(cached);
|
||
}
|
||
}
|
||
pollPositions();
|
||
connectPositionStream();
|
||
bindSlTpModal();
|
||
initCtpOnLoad();
|
||
updateSessionUi();
|
||
updateRRDisplay();
|
||
setTimeout(function () {
|
||
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 () {});
|
||
scheduleQuote();
|
||
scheduleAutoCalc();
|
||
}, 400);
|
||
}
|
||
|
||
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);
|
||
})();
|