恢复下单界面并排布局,品种推荐数据库缓存与 SSE 推送。

期货下单与持仓监控左右并排,推荐按资金过滤存库,后台刷新并通过 EventSource 推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 10:41:26 +08:00
parent 38a38cb51d
commit 709801305f
7 changed files with 480 additions and 161 deletions
+2
View File
@@ -309,7 +309,9 @@ def init_db():
ensure_kline_tables(conn) ensure_kline_tables(conn)
init_strategy_tables(conn) init_strategy_tables(conn)
from risk.account_risk_lib import ensure_account_risk_schema from risk.account_risk_lib import ensure_account_risk_schema
from recommend_store import ensure_recommend_tables
ensure_account_risk_schema(conn) ensure_account_risk_schema(conn)
ensure_recommend_tables(conn)
conn.commit() conn.commit()
conn.close() conn.close()
+73 -5
View File
@@ -5,10 +5,11 @@ import json
from datetime import datetime from datetime import datetime
from typing import Any, Callable from typing import Any, Callable
from flask import flash, jsonify, redirect, render_template, request, url_for from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context
from contract_specs import calc_position_metrics, get_contract_spec from contract_specs import calc_position_metrics, get_contract_spec
from fee_specs import calc_fee_breakdown from fee_specs import calc_fee_breakdown
from kline_stream import sse_format
from position_sizing import ( from position_sizing import (
MODE_FIXED, MODE_FIXED,
MODE_RISK, MODE_RISK,
@@ -16,7 +17,8 @@ from position_sizing import (
calc_order_tick_metrics, calc_order_tick_metrics,
normalize_sizing_mode, normalize_sizing_mode,
) )
from product_recommend import list_product_recommendations from recommend_store import load_recommend_cache, refresh_recommend_cache
from recommend_stream import recommend_hub, start_recommend_worker
from risk.account_risk_lib import ( from risk.account_risk_lib import (
assert_can_open, assert_can_open,
get_risk_status, get_risk_status,
@@ -300,6 +302,7 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe
).fetchone()["n"] ).fetchone()["n"]
conn.commit() conn.commit()
sizing = get_sizing_mode(get_setting) sizing = get_sizing_mode(get_setting)
rec_cache = load_recommend_cache(conn)
return render_template( return render_template(
"trade.html", "trade.html",
trading_mode=mode, trading_mode=mode,
@@ -314,6 +317,8 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe
sizing_mode=sizing, sizing_mode=sizing,
sizing_mode_label="以损定仓" if sizing == MODE_RISK else "固定张数", sizing_mode_label="以损定仓" if sizing == MODE_RISK else "固定张数",
risk_percent=get_risk_percent(get_setting), risk_percent=get_risk_percent(get_setting),
recommend_rows=rec_cache.get("rows") or [],
recommend_updated_at=rec_cache.get("updated_at"),
) )
finally: finally:
conn.close() conn.close()
@@ -626,10 +631,61 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe
@app.route("/api/recommend/list") @app.route("/api/recommend/list")
@login_required @login_required
def api_recommend_list(): def api_recommend_list():
"""只读数据库缓存,不在请求时拉行情。"""
conn = get_db() conn = get_db()
capital = _capital(conn) try:
conn.close() payload = load_recommend_cache(conn)
return jsonify({"ok": True, "capital": capital, "rows": list_product_recommendations(capital, _main_price)}) return jsonify({"ok": True, **payload})
finally:
conn.close()
@app.route("/api/recommend/stream")
@login_required
def api_recommend_stream():
from queue import Empty
def generate():
q = recommend_hub.subscribe()
try:
conn = get_db()
try:
payload = load_recommend_cache(conn)
finally:
conn.close()
yield sse_format("recommend", {"ok": True, **payload})
while True:
try:
msg = q.get(timeout=25)
yield sse_format(msg["event"], msg["data"])
except Empty:
yield ": heartbeat\n\n"
finally:
recommend_hub.unsubscribe(q)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/recommend/refresh", methods=["POST"])
@login_required
def api_recommend_refresh():
"""手动触发一次后台刷新(仍写入数据库)。"""
conn = get_db()
try:
init_strategy_tables(conn)
capital = _capital(conn)
rows = refresh_recommend_cache(conn, capital, _main_price)
payload = load_recommend_cache(conn)
recommend_hub.broadcast("recommend", {"ok": True, **payload})
return jsonify({"ok": True, "count": len(rows), **payload})
finally:
conn.close()
@app.route("/api/strategy/trend/preview", methods=["POST"]) @app.route("/api/strategy/trend/preview", methods=["POST"])
@login_required @login_required
@@ -946,3 +1002,15 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe
reduce_cooloff_after_journal(conn, trading_day=trading_day_label()) reduce_cooloff_after_journal(conn, trading_day=trading_day_label())
app._risk_review_hook = hook_review_mood app._risk_review_hook = hook_review_mood
from db_conn import DB_PATH
def _init_tables(conn):
init_strategy_tables(conn)
start_recommend_worker(
db_path=DB_PATH,
get_capital_fn=_capital,
price_fn=_main_price,
init_tables_fn=_init_tables,
)
+66
View File
@@ -0,0 +1,66 @@
"""品种推荐:计算、按资金过滤、SQLite 缓存。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Callable, Optional
from product_recommend import list_product_recommendations
RECOMMEND_CACHE_SQL = """
CREATE TABLE IF NOT EXISTS product_recommend_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
capital REAL NOT NULL DEFAULT 0,
rows_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT
)
"""
def ensure_recommend_tables(conn) -> None:
conn.execute(RECOMMEND_CACHE_SQL)
def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
"""仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。"""
return [r for r in rows if r.get("status") in ("ok", "margin_ok")]
def refresh_recommend_cache(
conn,
capital: float,
price_fn: Callable[[str], Optional[float]],
) -> list[dict]:
"""后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn)
all_rows = list_product_recommendations(capital, price_fn)
rows = filter_affordable_recommendations(all_rows)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"""INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
capital=excluded.capital,
rows_json=excluded.rows_json,
updated_at=excluded.updated_at""",
(float(capital or 0), json.dumps(rows, ensure_ascii=False), now),
)
conn.commit()
return rows
def load_recommend_cache(conn) -> dict:
"""优先从数据库读取推荐列表。"""
ensure_recommend_tables(conn)
row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone()
if not row:
return {"capital": 0.0, "rows": [], "updated_at": None}
try:
rows = json.loads(row["rows_json"] or "[]")
except (TypeError, ValueError, json.JSONDecodeError):
rows = []
return {
"capital": float(row["capital"] or 0),
"rows": rows if isinstance(rows, list) else [],
"updated_at": row["updated_at"],
}
+79
View File
@@ -0,0 +1,79 @@
"""品种推荐 SSE 推送与后台刷新。"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from typing import Callable, Optional
from db_conn import connect_db
from kline_stream import sse_format
from recommend_store import load_recommend_cache, refresh_recommend_cache
logger = logging.getLogger(__name__)
REFRESH_INTERVAL_SEC = 60
class RecommendStreamHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._subs: list[queue.Queue] = []
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=8)
with self._lock:
self._subs.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
try:
self._subs.remove(q)
except ValueError:
pass
def broadcast(self, event: str, data: dict) -> None:
msg = {"event": event, "data": data}
with self._lock:
subs = list(self._subs)
for q in subs:
try:
q.put_nowait(msg)
except queue.Full:
pass
recommend_hub = RecommendStreamHub()
def start_recommend_worker(
*,
db_path: str,
get_capital_fn: Callable,
price_fn: Callable[[str], Optional[float]],
init_tables_fn: Callable | None = None,
interval: int = REFRESH_INTERVAL_SEC,
) -> None:
"""后台定时刷新推荐并推送给 SSE 订阅者。"""
def _loop() -> None:
while True:
try:
conn = connect_db(db_path)
try:
if init_tables_fn:
init_tables_fn(conn)
capital = float(get_capital_fn(conn) or 0)
refresh_recommend_cache(conn, capital, price_fn)
payload = load_recommend_cache(conn)
finally:
conn.close()
recommend_hub.broadcast("recommend", {"ok": True, **payload})
except Exception as exc:
logger.warning("recommend worker failed: %s", exc)
time.sleep(max(15, interval))
threading.Thread(target=_loop, daemon=True, name="recommend-worker").start()
+26 -8
View File
@@ -1,14 +1,32 @@
.trade-page{max-width:1100px;margin:0 auto} .trade-page{max-width:1200px;margin:0 auto}
.trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1.25rem} .trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1.25rem}
.trade-dashboard{display:flex;flex-direction:column;gap:1.25rem} .trade-dashboard{display:flex;flex-direction:column;gap:1.25rem}
.trade-card{margin-bottom:0} .trade-row-split{display:grid;grid-template-columns:1fr 1fr;gap:1.25rem;align-items:stretch}
.trade-card h2{margin-bottom:.65rem} .trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column}
.trade-order-status{display:grid;gap:.55rem;margin:.75rem 0;padding:.85rem 1rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.85rem} .trade-card h2{margin-bottom:.65rem;flex-shrink:0}
.trade-card .card-body{flex:1;min-height:0}
.trade-card-full{margin-bottom:0}
.trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem}
.trade-order-status-compact{margin-top:0}
.trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem} .trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem}
.trade-order-status .trend-active{padding-top:.35rem;border-top:1px dashed var(--card-border)} .trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem}
.trade-order-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem 1rem;margin-top:1rem} .trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)}
.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);margin-top:1rem} .trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:.75rem 0}
.trade-btn{border:none;border-radius:8px;padding:.65rem .3rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.12rem;color:#fff;font-weight:600}
.trade-btn .btn-price{font-size:1rem}
.trade-btn .btn-label{font-size:.82rem}
.trade-btn .btn-sub{font-size:.66rem;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:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem}
.trade-footer strong{color:var(--accent)} .trade-footer strong{color:var(--accent)}
.rec-blocked td{opacity:.55} .rec-blocked td{opacity:.55}
.rec-ok td:first-child{font-weight:600} .rec-ok td:first-child{font-weight:600}
#positions .card-body{max-height:520px} #positions .card-body{max-height:480px;overflow-y:auto}
@media (max-width:900px){
.trade-row-split{grid-template-columns:1fr}
#positions .card-body{max-height:360px}
.trade-btn-row{grid-template-columns:repeat(2,1fr)}
}
+152 -104
View File
@@ -1,7 +1,15 @@
(function () { (function () {
var list = document.getElementById('position-live-list'); var list = document.getElementById('position-live-list');
var recommendList = document.getElementById('recommend-list'); var recommendList = document.getElementById('recommend-list');
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 pollTimer = null; var pollTimer = null;
var recommendSource = null;
var quoteTimer = null;
function runWhenReady(fn) { function runWhenReady(fn) {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -16,104 +24,120 @@
return Number(v).toFixed(digits === undefined ? 2 : digits); return Number(v).toFixed(digits === undefined ? 2 : digits);
} }
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 pl = document.getElementById('pos-long');
var ps = document.getElementById('pos-short');
if (pl) pl.textContent = '≤' + (data.pos_long || 0);
if (ps) ps.textContent = '≤' + (data.pos_short || 0);
if (footer && data.metrics) {
var m = data.metrics;
var hint = footer.querySelector('.hint');
var extra =
'<p><strong>' + (data.name || sym) + '</strong> 精度 <strong>' + m.price_precision +
'</strong> 位 · 每跳 <strong class="text-accent">' + m.tick_value_total + '</strong> 元(' + lots + ' 手)</p>';
if (hint) {
hint.insertAdjacentHTML('afterend', extra);
var olds = footer.querySelectorAll('p:not(.hint):not(.text-loss)');
for (var i = 0; i < olds.length - 1; i++) olds[i].remove();
}
}
}).catch(function () {});
}
function scheduleQuote() {
clearTimeout(quoteTimer);
quoteTimer = setTimeout(refreshQuote, 400);
}
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 || '') + ' 手');
pollPositions();
refreshQuote();
});
}
function buildPosCard(row) { function buildPosCard(row) {
var pnlClass = ''; var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : '');
if (row.float_pnl > 0) pnlClass = 'pnl-pos';
if (row.float_pnl < 0) pnlClass = 'pnl-neg';
var pnlText = '--'; var pnlText = '--';
if (row.float_pnl != null) { if (row.float_pnl != null) {
var sign = row.float_pnl >= 0 ? '+' : ''; var sign = row.float_pnl >= 0 ? '+' : '';
pnlText = sign + fmtNum(row.float_pnl) + '元'; pnlText = sign + fmtNum(row.float_pnl) + '元';
if (row.float_pct != null) { if (row.float_pct != null) pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)';
pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)';
}
} }
var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--'; var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--';
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空');
var closeBtn = ''; var closeBtn = '';
if (row.close_url) { if (row.close_url) {
closeBtn = closeBtn = '<form method="post" action="' + row.close_url + '" style="display:inline" onsubmit="return confirm(\'确认平仓?\')">' +
'<form method="post" action="' + row.close_url + '" style="display:inline" onsubmit="return confirm(\'确认平仓?\')">' +
'<button type="submit" class="btn-del pos-del">平仓</button></form>'; '<button type="submit" class="btn-del pos-del">平仓</button></form>';
} else if (row.can_close) { } else if (row.can_close) {
closeBtn = closeBtn = '<button type="button" class="btn-del pos-del" data-close=\'' + JSON.stringify({
'<button type="button" class="btn-del pos-del" data-close=\'' + JSON.stringify({ source: row.source, symbol_code: row.symbol_code, direction: row.direction,
source: row.source, lots: row.lots, mark_price: row.mark_price, monitor_id: row.monitor_id || null
symbol_code: row.symbol_code, }) + '\'>平仓</button>';
direction: row.direction,
lots: row.lots,
mark_price: row.mark_price,
monitor_id: row.monitor_id || null
}) + '\'>平仓</button>';
} }
var metaParts = ['来源 <strong>' + (row.source_label || row.source) + '</strong>'];
if (row.risk_pct != null) {
metaParts.push('风险 <strong>' + fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元</strong>');
}
if (row.tick_value_total != null) {
metaParts.push('每跳 <strong>' + fmtNum(row.tick_value_total) + '元</strong>');
}
var slTp =
'<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' +
'<div class="cell"><label>止盈</label><div>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</div></div>';
var footerParts = ['张数 ' + row.lots];
if (row.margin != null) footerParts.push('保证金 ' + fmtNum(row.margin) + '元');
if (row.position_pct != null) footerParts.push('仓位占比 ' + fmtNum(row.position_pct) + '%');
if (openT) footerParts.push('开仓 ' + openT);
if (row.holding_duration) footerParts.push('持仓 ' + row.holding_duration);
if (row.est_fee != null) footerParts.push('手续费(估) ' + fmtNum(row.est_fee) + '元');
return ( return (
'<div class="pos-card" data-key="' + (row.key || '') + '">' + '<div class="pos-card" data-key="' + (row.key || '') + '">' +
'<div class="pos-card-head">' + '<div class="pos-card-head"><div><div class="title">' + row.symbol + ' <span class="badge dir">' + dirBadge + '</span></div></div>' + closeBtn + '</div>' +
'<div><div class="title">' + row.symbol + ' <span class="badge dir">' + dirBadge + '</span></div>' + '<div class="pos-card-meta">来源 <strong>' + (row.source_label || row.source) + '</strong></div>' +
(row.symbol_code && row.symbol_code !== row.symbol ? '<div class="text-muted" style="font-size:.72rem;margin-top:.15rem">' + row.symbol_code + '</div>' : '') +
'</div>' + closeBtn + '</div>' +
'<div class="pos-card-meta">' + metaParts.join(' · ') + '</div>' +
'<div class="pos-metrics">' + '<div class="pos-metrics">' +
'<div class="cell"><label>成交价</label><div>' + fmtNum(row.entry_price) + '</div></div>' + '<div class="cell"><label>成交价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
slTp + '<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' +
'<div class="cell"><label>盈亏比</label><div>' + rr + '</div></div>' + '<div class="cell"><label>盈</label><div>' + (row.take_profit != null ? fmtNum(row.take_profit) : '--') + '</div></div>' +
'<div class="cell"><label>标记价</label><div>' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '</div></div>' + '<div class="cell"><label>浮盈亏</label><div class="' + pnlClass + '">' + pnlText + '</div></div>' +
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' + '</div><div class="pos-footer"><span>张数 ' + row.lots + '</span></div></div>'
(row.est_fee != null ?
'<div class="cell"><label>预估手续费</label><div>' + fmtNum(row.est_fee) + '元</div></div>' +
'<div class="cell ' + (row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : '')) + '">' +
'<label>扣费后</label><div>' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '</div></div>'
: '') +
'</div>' +
'<div class="pos-footer">' + footerParts.map(function (s) { return '<span>' + s + '</span>'; }).join('') + '</div>' +
'</div>'
); );
} }
function closePosition(payload) { function closePosition(payload) {
var price = payload.mark_price; var price = payload.mark_price;
if (!price || price <= 0) { if (!price || price <= 0) { alert('无法获取现价'); return; }
alert('无法获取现价,请稍后重试');
return;
}
if (!confirm('确认以 ' + price + ' 限价平仓 ' + payload.lots + ' 手?')) return; if (!confirm('确认以 ' + price + ' 限价平仓 ' + payload.lots + ' 手?')) return;
fetch('/api/trading/close', { fetch('/api/trading/close', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload)
source: payload.source,
symbol_code: payload.symbol_code,
direction: payload.direction,
lots: payload.lots,
price: price,
monitor_id: payload.monitor_id
})
}).then(function (r) { return r.json(); }).then(function (d) { }).then(function (r) { return r.json(); }).then(function (d) {
if (!d.ok) { alert(d.error || '平仓失败'); return; } if (!d.ok) { alert(d.error || '平仓失败'); return; }
pollPositions(); pollPositions();
}).catch(function () { alert('平仓请求失败'); }); });
} }
function pollPositions() { function pollPositions() {
@@ -140,7 +164,7 @@
} }
var rows = data.rows || []; var rows = data.rows || [];
if (!rows.length) { if (!rows.length) {
list.innerHTML = '<div class="empty-hint">暂无持仓。请通过上方「期货下单 → 策略交易」开仓,或连接 CTP 同步柜台持仓。</div>'; list.innerHTML = '<div class="empty-hint">暂无持仓。可在左侧下单,或通过策略交易开仓。</div>';
return; return;
} }
list.innerHTML = rows.map(buildPosCard).join(''); list.innerHTML = rows.map(buildPosCard).join('');
@@ -152,53 +176,76 @@
}) })
.catch(function () { .catch(function () {
if (list.innerHTML.indexOf('pos-card') < 0) { if (list.innerHTML.indexOf('pos-card') < 0) {
list.innerHTML = '<div class="empty-hint text-loss">加载失败,请刷新页面</div>'; list.innerHTML = '<div class="empty-hint text-loss">持仓加载失败</div>';
} }
}); });
} }
function badgeClass(status) { function badgeClass(status) {
if (status === 'ok') return 'profit'; if (status === 'ok') return 'profit';
if (status === 'blocked') return 'loss';
return 'planned'; return 'planned';
} }
function buildRecommendRow(r) { function renderRecommendations(data) {
return ( if (!recommendList || !data) return;
'<tr class="rec-' + (r.status || '') + '">' + var recCap = document.getElementById('rec-capital');
'<td><strong>' + (r.name || '') + '</strong> <span class="text-muted">' + (r.ths || '') + '</span></td>' + if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
'<td>' + (r.exchange || '') + '</td>' + var recUpd = document.getElementById('rec-updated');
'<td>' + (r.price != null ? r.price : '—') + '</td>' + if (recUpd && data.updated_at) recUpd.textContent = '更新 ' + data.updated_at;
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' + var rows = data.rows || [];
'<td>' + (r.min_capital_one_lot != null ? r.min_capital_one_lot : '—') + '</td>' + if (!rows.length) {
'<td><span class="badge ' + badgeClass(r.status) + '">' + (r.status_label || '') + '</span></td>' + recommendList.innerHTML = '<tr><td colspan="6" class="empty-hint">当前资金下暂无推荐品种</td></tr>';
'</tr>' return;
); }
recommendList.innerHTML = rows.map(function (r) {
return (
'<tr class="rec-' + (r.status || '') + '">' +
'<td><strong>' + (r.name || '') + '</strong> <span class="text-muted">' + (r.ths || '') + '</span></td>' +
'<td>' + (r.exchange || '') + '</td>' +
'<td class="rec-price">' + (r.price != null ? r.price : '—') + '</td>' +
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' +
'<td>' + (r.min_capital_one_lot != null ? r.min_capital_one_lot : '—') + '</td>' +
'<td><span class="badge ' + badgeClass(r.status) + '">' + (r.status_label || '') + '</span></td>' +
'</tr>'
);
}).join('');
} }
function loadRecommendations() { function connectRecommendStream() {
if (!recommendList) return; if (recommendSource) {
fetch('/api/recommend/list') recommendSource.close();
.then(function (r) { recommendSource = null;
if (!r.ok) throw new Error('HTTP ' + r.status); }
return r.json(); recommendSource = new EventSource('/api/recommend/stream');
}) recommendSource.addEventListener('recommend', function (ev) {
.then(function (data) { try {
if (!data.ok) throw new Error(data.error || 'load failed'); renderRecommendations(JSON.parse(ev.data));
var recCap = document.getElementById('rec-capital'); } catch (e) { /* ignore */ }
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); });
var rows = data.rows || []; recommendSource.onerror = function () {
if (!rows.length) { if (recommendSource) {
recommendList.innerHTML = '<tr><td colspan="6" class="empty-hint">暂无推荐数据</td></tr>'; recommendSource.close();
return; recommendSource = null;
} }
recommendList.innerHTML = rows.map(buildRecommendRow).join(''); setTimeout(connectRecommendStream, 5000);
}) };
.catch(function () {
recommendList.innerHTML = '<tr><td colspan="6" class="empty-hint text-loss">品种推荐加载失败,请刷新页面</td></tr>';
});
} }
if (symInput) symInput.addEventListener('input', scheduleQuote);
if (lotsInput) lotsInput.addEventListener('input', scheduleQuote);
if (priceInput) {
priceInput.addEventListener('input', function () { priceInput.dataset.manual = '1'; });
}
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'); var btnConnect = document.getElementById('btn-ctp-connect');
if (btnConnect) { if (btnConnect) {
btnConnect.addEventListener('click', function () { btnConnect.addEventListener('click', function () {
@@ -219,7 +266,8 @@
runWhenReady(function () { runWhenReady(function () {
pollPositions(); pollPositions();
loadRecommendations(); connectRecommendStream();
pollTimer = setInterval(pollPositions, 3000); pollTimer = setInterval(pollPositions, 3000);
scheduleQuote();
}); });
})(); })();
+82 -44
View File
@@ -19,60 +19,81 @@
</div> </div>
<div class="trade-dashboard"> <div class="trade-dashboard">
<div class="card trade-card" id="order"> <div class="trade-row-split">
<h2>期货下单</h2> <div class="card trade-card" id="order">
<div class="card-body"> <h2>期货下单</h2>
<p class="hint">开仓、加仓由<strong>程序</strong>在「策略交易」中执行,经 CTP 自动报单至 SimNow / 期货公司柜台。</p> <div class="card-body">
<div class="trade-order-status"> <div class="trade-order-status trade-order-status-compact">
<div class="status-row"> <div class="status-row">
<span class="text-muted">计仓模式</span> <span class="text-muted">计仓</span>
<strong>{{ sizing_mode_label }}</strong> <strong>{{ sizing_mode_label }}</strong>
{% if sizing_mode == 'risk' %} {% if sizing_mode == 'risk' %}<span class="text-muted">· {{ risk_percent }}%</span>{% endif %}
<span class="text-muted">· 单笔风险 {{ risk_percent }}%</span> <span class="text-muted">· 监控 {{ monitor_count }}</span>
{% endif %} </div>
</div> </div>
<div class="status-row">
<span class="text-muted">风控状态</span> <div class="trade-input-row">
<strong class="{% if risk_status.can_trade %}text-profit{% else %}text-loss{% endif %}">{{ risk_status.status_label }}</strong> <div class="symbol-wrap trade-field">
<label class="text-label">品种</label>
<input type="text" id="trade-symbol" class="symbol-input" placeholder="主力合约 rb2610" autocomplete="off">
<div class="symbol-dropdown"></div>
<div class="symbol-selected" id="sym-selected"></div>
</div>
<div class="trade-field">
<label class="text-label">手数</label>
<input type="number" id="trade-lots" min="1" step="1" value="1">
</div>
<div class="trade-field">
<label class="text-label">价格</label>
<input type="number" id="trade-price" step="any" placeholder="限价">
</div>
</div> </div>
<div class="status-row">
<span class="text-muted">程序监控</span> <div id="risk-fields" class="trade-risk-row" {% if sizing_mode != 'risk' %}hidden{% endif %}>
<strong>{{ monitor_count }}</strong> <div class="trade-field"><label class="text-label">止损</label><input type="number" id="trade-sl" step="any"></div>
{% if roll_count %}<span class="text-muted">· 滚仓组 {{ roll_count }}</span>{% endif %} <div class="trade-field"><label class="text-label">止盈</label><input type="number" id="trade-tp" step="any"></div>
</div> </div>
{% if active_trend %}
<div class="status-row trend-active"> <div class="trade-btn-row">
<span class="text-muted">趋势回调</span> <button type="button" class="trade-btn long" id="btn-open-long">
<strong>#{{ active_trend.id }} {{ active_trend.symbol }} {{ '多' if active_trend.direction=='long' else '空' }}</strong> <span class="btn-price" id="px-long"></span>
<span class="text-muted">已开 {{ active_trend.lots_open or 0 }}/{{ active_trend.target_lots }} 手</span> <span class="btn-label">加多</span>
</button>
<button type="button" class="trade-btn lock" id="btn-open-short">
<span class="btn-price" id="px-short"></span>
<span class="btn-label">加空</span>
</button>
<button type="button" class="trade-btn close" id="btn-close-long">
<span class="btn-label">平多</span>
<span class="btn-sub" id="pos-long">≤0</span>
</button>
<button type="button" class="trade-btn close" id="btn-close-short">
<span class="btn-label">平空</span>
<span class="btn-sub" id="pos-short">≤0</span>
</button>
</div>
<div class="trade-footer" id="trade-footer">
<p class="hint">程序报单经 CTP 进入柜台;策略自动化请用 <a href="{{ url_for('strategy_page') }}">策略交易</a></p>
{% if ctp_status.last_error %}<p class="text-loss" style="font-size:.78rem;margin-top:.35rem">{{ ctp_status.last_error }}</p>{% endif %}
</div> </div>
{% endif %}
</div> </div>
{% if not ctp_status.connected %} </div>
<p class="hint text-accent" style="margin-top:.75rem">请先连接 CTP,程序报单才会进入柜台。</p>
{% endif %} <div class="card trade-card" id="positions">
{% if ctp_status.last_error %} <h2>持仓监控</h2>
<p class="text-loss" style="font-size:.78rem;margin-top:.5rem">{{ ctp_status.last_error }}</p> <div class="card-body card-scroll" id="position-live-list">
{% endif %} <div class="empty-hint">加载中…</div>
<div class="trade-order-actions">
<a href="{{ url_for('strategy_page') }}" class="btn-primary">前往策略交易</a>
<a href="{{ url_for('strategy_records_page') }}" class="text-muted" style="font-size:.82rem">策略记录 →</a>
</div> </div>
</div> </div>
</div> </div>
<div class="card trade-card" id="positions"> <div class="card trade-card trade-card-full" id="recommend">
<h2>持仓监控</h2>
<div class="card-body card-scroll" id="position-live-list">
<div class="empty-hint">加载中…</div>
</div>
</div>
<div class="card trade-card" id="recommend">
<h2>品种推荐</h2> <h2>品种推荐</h2>
<div class="card-body"> <div class="card-body">
<p class="hint">当前权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong> 元筛选 <p class="hint">按权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong> 元筛选,仅显示可开 1 手的品种。
灰色为保证金不足,优先展示可开 1 手且风险规则较友好的品种。</p> {% if recommend_updated_at %}<span class="text-muted">更新 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">后台刷新中…</span>{% endif %}
</p>
<div class="trade-table-wrap"> <div class="trade-table-wrap">
<table class="trade-table"> <table class="trade-table">
<thead> <thead>
@@ -81,7 +102,20 @@
</tr> </tr>
</thead> </thead>
<tbody id="recommend-list"> <tbody id="recommend-list">
<tr><td colspan="6" class="empty-hint">品种推荐加载中…</td></tr> {% if recommend_rows %}
{% for r in recommend_rows %}
<tr class="rec-{{ r.status }}">
<td><strong>{{ r.name }}</strong> <span class="text-muted">{{ r.ths }}</span></td>
<td>{{ r.exchange }}</td>
<td class="rec-price" data-ths="{{ r.ths }}">{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>
<td>{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}</td>
<td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="6" class="empty-hint">等待后台推送推荐…</td></tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -91,5 +125,9 @@
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script>
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
window.TRADE_RISK_PERCENT = {{ risk_percent }};
</script>
<script src="{{ url_for('static', filename='js/trade.js') }}"></script> <script src="{{ url_for('static', filename='js/trade.js') }}"></script>
{% endblock %} {% endblock %}