接入 SimNow 模拟盘与期货下单、策略及品种推荐功能。

新增 vnpy CTP 桥接、以损定仓/固定张数、趋势回调与滚仓策略、按资金推荐品种及交易风控;模拟盘走 SimNow,实盘预留期货公司配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 10:04:37 +08:00
parent 9c0e5d9c57
commit 6e423eebfb
30 changed files with 2789 additions and 60 deletions
+20
View File
@@ -0,0 +1,20 @@
.trade-page{max-width:720px;margin:0 auto}
.trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1rem}
.trade-order-card{padding:1.25rem}
.trade-tabs{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem}
.trade-tabs span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem}
.trade-tabs a{color:var(--text-muted);text-decoration:none}
.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem}
.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)}
.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:1rem 0}
.trade-btn{border:none;border-radius:8px;padding:.75rem .35rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.15rem;color:#fff;font-weight:600}
.trade-btn .btn-price{font-size:1.1rem}
.trade-btn .btn-label{font-size:.85rem}
.trade-btn .btn-sub{font-size:.68rem;opacity:.85;font-weight:400}
.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)}
.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)}
.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)}
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border)}
.trade-footer strong{color:var(--accent)}
.rec-blocked td{opacity:.55}
.rec-ok td:first-child{font-weight:600}
+101 -37
View File
@@ -242,41 +242,59 @@
function getDataZoom(c, preserve) {
var defStart = getDefaultZoomStart();
var zoom = [
{
type: 'inside',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
var xZoom = {
type: 'inside',
id: 'dzInsideX',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
minSpan: 2,
};
var yZoom = {
type: 'inside',
id: 'dzInsideY',
yAxisIndex: [0],
orient: 'vertical',
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
preventDefaultMouseMove: true,
};
var slider = {
type: 'slider',
id: 'dzSlider',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
height: 22,
bottom: 4,
borderColor: c.grid,
backgroundColor: c.bg,
fillerColor: c.area,
handleStyle: { color: c.sliderFill },
dataBackground: {
lineStyle: { color: c.grid, opacity: 0.35 },
areaStyle: { color: c.area },
},
{
type: 'slider',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
height: 22,
bottom: 4,
borderColor: c.grid,
backgroundColor: c.bg,
fillerColor: c.area,
handleStyle: { color: c.sliderFill },
dataBackground: {
lineStyle: { color: c.grid },
areaStyle: { color: c.area },
},
textStyle: { color: c.text, fontSize: 10 },
},
];
textStyle: { color: c.text, fontSize: 10 },
filterMode: 'none',
brushSelect: false,
};
var zoom = [xZoom, yZoom, slider];
if (preserve && chart) {
var opt = chart.getOption();
if (opt && opt.dataZoom) {
opt.dataZoom.forEach(function (z, i) {
if (zoom[i] && z.start != null && z.end != null) {
zoom[i].start = z.start;
zoom[i].end = z.end;
opt.dataZoom.forEach(function (z) {
if (!z.id) return;
var target = zoom.find(function (t) { return t.id === z.id; });
if (target && z.start != null && z.end != null) {
target.start = z.start;
target.end = z.end;
}
});
}
@@ -284,6 +302,11 @@
return zoom;
}
function isFollowingLatest() {
var z = getZoomRange();
return z.end >= 98;
}
function mapSeriesData(bars, values, gapDay) {
if (!gapDay) return values;
return bars.map(function (b, i) {
@@ -303,6 +326,7 @@
var times = bars.map(function (b) { return b.time; });
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
var gapDay = chartOpts.gapDay;
var followLatest = preserveZoom && isFollowingLatest();
var dataZoom = getDataZoom(c, preserveZoom);
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
var vIdx = visibleIndices(bars, zoom);
@@ -326,6 +350,7 @@
boundaryGap: gapDay ? false : true,
axisLabel: { color: c.text, fontSize: 10 },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
var xAxis1 = {
type: xAxisType,
@@ -333,6 +358,7 @@
boundaryGap: gapDay ? false : true,
axisLabel: { show: false },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
if (!gapDay) {
xAxis0.data = times;
@@ -344,14 +370,27 @@
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
dataZoom: dataZoom,
grid: grids,
xAxis: [xAxis0, xAxis1],
yAxis: [
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
{
scale: true,
gridIndex: 0,
splitLine: { show: false },
axisLabel: { color: c.text },
},
{
scale: true,
gridIndex: 1,
splitLine: { show: false },
axisLabel: { color: c.text, fontSize: 10 },
splitNumber: 2,
},
],
};
if (!preserveZoom) {
base.dataZoom = dataZoom;
}
var series = [];
var mainMark = {
@@ -465,7 +504,12 @@
};
}
chart.setOption(Object.assign(base, { series: series }), true);
if (preserveZoom) {
chart.setOption(Object.assign(base, { series: series }), false);
} else {
chart.setOption(Object.assign(base, { series: series }), true);
dataZoomBound = false;
}
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
chart.setOption({
@@ -478,6 +522,22 @@
} : { show: false },
});
if (followLatest) {
var span = zoom.end - zoom.start;
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 0,
start: Math.max(0, 100 - span),
end: 100,
});
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 2,
start: Math.max(0, 100 - span),
end: 100,
});
}
bindDataZoomHL();
}
@@ -656,12 +716,16 @@
if (start === 0) end = newSpan;
else start = end - newSpan;
}
chart.dispatchAction({ type: 'dataZoom', start: start, end: end });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end });
}
function resetDataZoom() {
if (!chart) return;
chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 });
var start = getDefaultZoomStart();
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 });
}
function bindPeriodTabs() {
+76
View File
@@ -0,0 +1,76 @@
(function () {
var trendPayload = null;
function jsonPost(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
}).then(function (r) { return r.json(); });
}
function formData(form) {
var fd = new FormData(form);
var o = {};
fd.forEach(function (v, k) { o[k] = v; });
return o;
}
var trendForm = document.getElementById('trend-form');
var btnPreview = document.getElementById('btn-trend-preview');
var btnExec = document.getElementById('btn-trend-exec');
var previewEl = document.getElementById('trend-preview');
if (btnPreview && trendForm) {
btnPreview.addEventListener('click', function () {
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
if (!d.ok) { previewEl.textContent = d.error || '预览失败'; btnExec.hidden = true; return; }
trendPayload = formData(trendForm);
previewEl.textContent = JSON.stringify(d.plan, null, 2);
btnExec.hidden = false;
});
});
}
if (btnExec) {
btnExec.addEventListener('click', function () {
if (!trendPayload) return;
jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
}
var rollForm = document.getElementById('roll-form');
var btnRollP = document.getElementById('btn-roll-preview');
var btnRollE = document.getElementById('btn-roll-exec');
var rollPrev = document.getElementById('roll-preview');
if (btnRollP && rollForm) {
btnRollP.addEventListener('click', function () {
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
if (!d.ok) { rollPrev.textContent = d.error; btnRollE.hidden = true; return; }
rollPrev.textContent = JSON.stringify(d.preview, null, 2);
btnRollE.hidden = false;
});
});
}
if (btnRollE && rollForm) {
btnRollE.addEventListener('click', function () {
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
}
var btnStop = document.getElementById('btn-trend-stop');
if (btnStop) {
btnStop.addEventListener('click', function () {
var pid = document.querySelector('#trend-stop-form input[name=plan_id]');
jsonPost('/api/strategy/trend/stop', { plan_id: pid ? pid.value : 0 }).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
}
})();
+127
View File
@@ -0,0 +1,127 @@
(function () {
var symInput = document.getElementById('trade-symbol');
var lotsInput = document.getElementById('trade-lots');
var priceInput = document.getElementById('trade-price');
var footer = document.getElementById('trade-footer');
var slInput = document.getElementById('trade-sl');
var tpInput = document.getElementById('trade-tp');
var debounceTimer;
function selectedSymbol() {
return (symInput && symInput.value || '').trim();
}
function refreshQuote() {
var sym = selectedSymbol();
var lots = lotsInput ? lotsInput.value : '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;
if (priceInput && !priceInput.dataset.manual && data.price) {
priceInput.value = data.price;
}
var px = data.price != null ? data.price : '—';
['px-long', 'px-short'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = px;
});
var ml = document.getElementById('max-long');
var ms = document.getElementById('max-short');
if (ml) ml.textContent = '≤' + (data.max_open_long || '—');
if (ms) ms.textContent = '≤' + (data.max_open_short || '—');
document.getElementById('pos-long').textContent = '≤' + (data.pos_long || 0);
document.getElementById('pos-short').textContent = '≤' + (data.pos_short || 0);
if (footer && data.metrics) {
var m = data.metrics;
footer.innerHTML =
'<p><strong>' + (data.name || sym) + '</strong> ' + (data.footer_text || '') + '</p>' +
'<p>价格精度 <strong>' + m.price_precision + '</strong> 位 · ' +
'最小变动 <strong>' + m.tick_size + '</strong> · ' +
'每跳 <strong>' + m.tick_value_per_lot + '</strong> 元/手 · ' +
'当前 <strong>' + lots + '</strong> 手每跳合计 <strong class="text-accent">' + m.tick_value_total + '</strong> 元</p>' +
(m.margin_total ? '<p class="text-muted">预估保证金约 ' + m.margin_total + ' 元</p>' : '');
}
}).catch(function () {});
}
function scheduleRefresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshQuote, 400);
}
if (symInput) symInput.addEventListener('input', scheduleRefresh);
if (lotsInput) lotsInput.addEventListener('input', scheduleRefresh);
if (priceInput) {
priceInput.addEventListener('input', function () {
priceInput.dataset.manual = '1';
});
}
function postOrder(offset, direction) {
var sym = selectedSymbol();
if (!sym) { alert('请选择品种'); return; }
var body = {
symbol: sym,
offset: offset,
direction: direction,
lots: parseInt(lotsInput.value, 10) || 1,
price: parseFloat(priceInput.value) || 0,
stop_loss: slInput ? parseFloat(slInput.value) : null,
take_profit: tpInput ? parseFloat(tpInput.value) : null
};
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) { alert(data.error || '下单失败'); return; }
alert('已提交 ' + (data.lots || '') + ' 手');
location.reload();
});
}
var btnLong = document.getElementById('btn-open-long');
var btnShort = document.getElementById('btn-open-short');
var btnCloseL = document.getElementById('btn-close-long');
var btnCloseS = document.getElementById('btn-close-short');
if (btnLong) btnLong.addEventListener('click', function () { postOrder('open', 'long'); });
if (btnShort) btnShort.addEventListener('click', function () { postOrder('open', 'short'); });
if (btnCloseL) btnCloseL.addEventListener('click', function () { postOrder('close', 'long'); });
if (btnCloseS) btnCloseS.addEventListener('click', function () { postOrder('close', 'short'); });
var btnConnect = document.getElementById('btn-ctp-connect');
if (btnConnect) {
btnConnect.addEventListener('click', function () {
btnConnect.disabled = true;
btnConnect.textContent = '连接中…';
fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) { alert(d.error || '连接失败'); return; }
location.reload();
})
.finally(function () {
btnConnect.disabled = false;
btnConnect.textContent = '连接 CTP';
});
});
}
setInterval(function () {
fetch('/api/account_snapshot').then(function (r) { return r.json(); }).then(function (d) {
var cap = document.getElementById('cap-display');
if (cap && d.capital != null) cap.textContent = Number(d.capital).toFixed(2);
var badge = document.getElementById('risk-badge');
if (badge && d.risk_status) badge.textContent = d.risk_status.status_label;
var ctpBadge = document.getElementById('ctp-badge');
if (ctpBadge && d.ctp_status) {
ctpBadge.textContent = d.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (d.ctp_status.connected ? 'profit' : 'planned');
}
}).catch(function () {});
}, 5000);
scheduleRefresh();
})();