feat: 品种推荐与下单显示主力合约

推荐列表展示当前主力代码;下单品种支持中文/代码搜索并按交易所分组选择主力合约。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 12:56:06 +08:00
parent 3ba3be6035
commit aea9aca472
7 changed files with 94 additions and 41 deletions
+16 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from datetime import datetime from datetime import datetime
from typing import Any, Callable from typing import Any, Callable, Optional
from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context
@@ -69,7 +69,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
def _capital(conn) -> float: def _capital(conn) -> float:
return get_account_capital(conn, get_setting) return get_account_capital(conn, get_setting)
def _main_price(product_ths: str): def _main_quote(product_ths: str) -> Optional[dict]:
for p in PRODUCTS: for p in PRODUCTS:
if p["ths"] == product_ths: if p["ths"] == product_ths:
main = resolve_main_contract(p) main = resolve_main_contract(p)
@@ -77,8 +77,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return None return None
sym = main.get("ths_code") or "" sym = main.get("ths_code") or ""
codes = ths_to_codes(sym) codes = ths_to_codes(sym)
price = None
if codes: if codes:
return fetch_price(sym, codes.get("market_code", ""), codes.get("sina_code", "")) price = fetch_price(
sym,
codes.get("market_code", ""),
codes.get("sina_code", ""),
)
return {
"ths_code": sym,
"price": price,
"display": main.get("display") or sym,
"name": main.get("name") or p.get("name"),
}
return None return None
def _ctp_account(mode: str) -> dict: def _ctp_account(mode: str) -> dict:
@@ -670,7 +681,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
try: try:
init_strategy_tables(conn) init_strategy_tables(conn)
capital = _capital(conn) capital = _capital(conn)
rows = refresh_recommend_cache(conn, capital, _main_price) rows = refresh_recommend_cache(conn, capital, _main_quote)
payload = load_recommend_cache(conn) payload = load_recommend_cache(conn)
recommend_hub.broadcast("recommend", {"ok": True, **payload}) recommend_hub.broadcast("recommend", {"ok": True, **payload})
return jsonify({"ok": True, "count": len(rows), **payload}) return jsonify({"ok": True, "count": len(rows), **payload})
@@ -1001,7 +1012,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
start_recommend_worker( start_recommend_worker(
db_path=DB_PATH, db_path=DB_PATH,
get_capital_fn=_capital, get_capital_fn=_capital,
price_fn=_main_price, quote_fn=_main_quote,
init_tables_fn=_init_tables, init_tables_fn=_init_tables,
) )
start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
+9 -5
View File
@@ -79,18 +79,22 @@ def assess_product_for_capital(
def list_product_recommendations( def list_product_recommendations(
capital: float, capital: float,
price_fn: Callable[[str], Optional[float]], quote_fn: Callable[[str], Optional[dict]],
*, *,
max_position_pct: float = 50.0, max_position_pct: float = 50.0,
) -> list[dict]: ) -> list[dict]:
"""扫描全部品种并排序:推荐 > 可开 > 不足。""" """扫描全部品种并排序:推荐 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
def _one(product: dict) -> dict: def _one(product: dict) -> dict:
ths = product["ths"] ths = product["ths"]
main_code = price_fn(ths) quote = quote_fn(ths) or {}
return assess_product_for_capital( price = quote.get("price")
product, capital, main_code, max_position_pct=max_position_pct row = assess_product_for_capital(
product, capital, price, max_position_pct=max_position_pct
) )
main_code = (quote.get("ths_code") or "").strip()
row["main_code"] = main_code
return row
with ThreadPoolExecutor(max_workers=10) as pool: with ThreadPoolExecutor(max_workers=10) as pool:
rows = list(pool.map(_one, PRODUCTS)) rows = list(pool.map(_one, PRODUCTS))
+2 -2
View File
@@ -29,11 +29,11 @@ def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
def refresh_recommend_cache( def refresh_recommend_cache(
conn, conn,
capital: float, capital: float,
price_fn: Callable[[str], Optional[float]], quote_fn: Callable[[str], Optional[dict]],
) -> list[dict]: ) -> list[dict]:
"""后台拉行情、筛选并写入数据库。""" """后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn) ensure_recommend_tables(conn)
all_rows = list_product_recommendations(capital, price_fn) all_rows = list_product_recommendations(capital, quote_fn)
rows = filter_affordable_recommendations(all_rows) rows = filter_affordable_recommendations(all_rows)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute( conn.execute(
+1 -1
View File
@@ -53,7 +53,7 @@ def start_recommend_worker(
*, *,
db_path: str, db_path: str,
get_capital_fn: Callable, get_capital_fn: Callable,
price_fn: Callable[[str], Optional[float]], price_fn: Callable[[str], Optional[dict]],
init_tables_fn: Callable | None = None, init_tables_fn: Callable | None = None,
interval: int = REFRESH_INTERVAL_SEC, interval: int = REFRESH_INTERVAL_SEC,
) -> None: ) -> None:
+51 -23
View File
@@ -19,15 +19,26 @@
return hay.indexOf(qLower) >= 0; return hay.indexOf(qLower) >= 0;
} }
function groupedHasMatch(groups, qLower) {
if (!qLower) return true;
return groups.some(function (group) {
return group.items.some(function (item) {
return itemMatchesQuery(item, qLower);
});
});
}
function initSymbolInput(wrapper) { function initSymbolInput(wrapper) {
const input = wrapper.querySelector('.symbol-input'); const input = wrapper.querySelector('.symbol-input');
const hiddenThs = wrapper.querySelector('input[name="symbol"]'); const hiddenThs = wrapper.querySelector('input[name="symbol"]')
|| wrapper.querySelector('.symbol-ths-code');
const hiddenName = wrapper.querySelector('input[name="symbol_name"]'); const hiddenName = wrapper.querySelector('input[name="symbol_name"]');
const hiddenMarket = wrapper.querySelector('input[name="market_code"]'); const hiddenMarket = wrapper.querySelector('input[name="market_code"]');
const hiddenSina = wrapper.querySelector('input[name="sina_code"]'); const hiddenSina = wrapper.querySelector('input[name="sina_code"]');
const dropdown = wrapper.querySelector('.symbol-dropdown'); const dropdown = wrapper.querySelector('.symbol-dropdown');
const selectedEl = wrapper.querySelector('.symbol-selected'); const selectedEl = wrapper.querySelector('.symbol-selected');
const isMarketPicker = wrapper.classList.contains('market-symbol-wrap'); const isMarketPicker = wrapper.classList.contains('market-symbol-wrap');
const useMainsPicker = isMarketPicker || wrapper.classList.contains('symbol-mains');
let timer = null; let timer = null;
let abortCtrl = null; let abortCtrl = null;
const cache = new Map(); const cache = new Map();
@@ -41,15 +52,13 @@
function selectItem(item) { function selectItem(item) {
const label = formatInputLabel(item); const label = formatInputLabel(item);
input.value = label; input.value = label;
hiddenThs.value = item.ths_code; if (hiddenThs) hiddenThs.value = item.ths_code;
hiddenName.value = item.name; if (hiddenName) hiddenName.value = item.name;
if (hiddenMarket) hiddenMarket.value = item.market_code || ''; if (hiddenMarket) hiddenMarket.value = item.market_code || '';
if (hiddenSina) hiddenSina.value = item.sina_code || ''; if (hiddenSina) hiddenSina.value = item.sina_code || '';
selectedEl.textContent = formatSub(item); if (selectedEl) selectedEl.textContent = formatSub(item);
hideDropdown(); hideDropdown();
if (isMarketPicker) { input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item, bubbles: true }));
input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item }));
}
} }
function buildOptionEl(item) { function buildOptionEl(item) {
@@ -107,9 +116,19 @@
dropdown.classList.add('show'); dropdown.classList.add('show');
} }
function showMarketMains(filterQ) { function showMarketMains(filterQ, onEmpty) {
const q = (filterQ || '').trim();
const qLower = q.toLowerCase();
if (mainsCache) { if (mainsCache) {
renderGrouped(mainsCache, filterQ); if (!q || groupedHasMatch(mainsCache, qLower)) {
renderGrouped(mainsCache, q);
return;
}
if (typeof onEmpty === 'function') {
onEmpty(q);
return;
}
renderGrouped(mainsCache, q);
return; return;
} }
if (mainsLoading) { if (mainsLoading) {
@@ -124,7 +143,7 @@
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (groups) { .then(function (groups) {
mainsCache = groups; mainsCache = groups;
renderGrouped(groups, filterQ); showMarketMains(filterQ, onEmpty);
}) })
.catch(function () { .catch(function () {
hideDropdown(); hideDropdown();
@@ -157,15 +176,25 @@
}); });
} }
function handleQuery(q) {
if (useMainsPicker) {
showMarketMains(q, function (query) {
search(query);
});
} else {
search(q);
}
}
input.addEventListener('input', function () { input.addEventListener('input', function () {
hiddenThs.value = ''; if (hiddenThs) hiddenThs.value = '';
hiddenName.value = ''; if (hiddenName) hiddenName.value = '';
if (hiddenMarket) hiddenMarket.value = ''; if (hiddenMarket) hiddenMarket.value = '';
if (hiddenSina) hiddenSina.value = ''; if (hiddenSina) hiddenSina.value = '';
selectedEl.textContent = ''; if (selectedEl) selectedEl.textContent = '';
const q = input.value.trim(); const q = input.value.trim();
if (!q) { if (!q) {
if (isMarketPicker) { if (useMainsPicker) {
showMarketMains(''); showMarketMains('');
} else { } else {
hideDropdown(); hideDropdown();
@@ -174,11 +203,7 @@
} }
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(function () { timer = setTimeout(function () {
if (isMarketPicker) { handleQuery(q);
showMarketMains(q);
} else {
search(q);
}
}, 120); }, 120);
}); });
@@ -188,11 +213,13 @@
input.addEventListener('focus', function () { input.addEventListener('focus', function () {
const q = input.value.trim(); const q = input.value.trim();
if (isMarketPicker) { if (useMainsPicker) {
showMarketMains(q); showMarketMains(q, function (query) {
if (query) search(query);
});
return; return;
} }
if (q && !hiddenThs.value) { if (q && hiddenThs && !hiddenThs.value) {
search(q); search(q);
} }
}); });
@@ -205,7 +232,8 @@
if (!form.querySelector('.symbol-wrap')) return; if (!form.querySelector('.symbol-wrap')) return;
if (form.id === 'market-form') return; if (form.id === 'market-form') return;
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
const ths = form.querySelector('input[name="symbol"]'); const ths = form.querySelector('input[name="symbol"]')
|| form.querySelector('.symbol-ths-code');
const market = form.querySelector('input[name="market_code"]'); const market = form.querySelector('input[name="market_code"]');
if (ths && !ths.value.trim()) { if (ths && !ths.value.trim()) {
e.preventDefault(); e.preventDefault();
+11 -2
View File
@@ -34,6 +34,9 @@
} }
function selectedSymbol() { 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(); return (symInput && symInput.value || '').trim();
} }
@@ -389,7 +392,7 @@
recommendList.innerHTML = rows.map(function (r) { recommendList.innerHTML = rows.map(function (r) {
return ( return (
'<tr class="rec-' + (r.status || '') + '">' + '<tr class="rec-' + (r.status || '') + '">' +
'<td><strong>' + (r.name || '') + '</strong> <span class="text-muted">' + (r.ths || '') + '</span></td>' + '<td><strong>' + (r.name || '') + '</strong> <span class="text-accent">' + (r.main_code || r.ths || '') + '</span></td>' +
'<td>' + (r.exchange || '') + '</td>' + '<td>' + (r.exchange || '') + '</td>' +
'<td>' + (r.price != null ? r.price : '—') + '</td>' + '<td>' + (r.price != null ? r.price : '—') + '</td>' +
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' + '<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' +
@@ -419,7 +422,13 @@
}); });
}); });
if (symInput) symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); }); if (symInput) {
symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); });
symInput.addEventListener('symbol-selected', function () {
scheduleQuote();
scheduleAutoCalc();
});
}
if (lotsInput) lotsInput.addEventListener('input', scheduleQuote); if (lotsInput) lotsInput.addEventListener('input', scheduleQuote);
if (slInput) slInput.addEventListener('input', scheduleAutoCalc); if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc); if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
+4 -3
View File
@@ -37,9 +37,10 @@
<div class="trade-form-rows"> <div class="trade-form-rows">
<div class="trade-form-line line-3"> <div class="trade-form-line line-3">
<div class="symbol-wrap trade-field"> <div class="symbol-wrap trade-field symbol-mains">
<label class="text-label">品种</label> <label class="text-label">品种</label>
<input type="text" id="trade-symbol" class="symbol-input" placeholder="主力合约 rb2610" autocomplete="off"> <input type="text" id="trade-symbol" class="symbol-input" placeholder="输入中文或代码,选择主力合约" autocomplete="off">
<input type="hidden" id="trade-symbol-code" class="symbol-ths-code">
<div class="symbol-dropdown"></div> <div class="symbol-dropdown"></div>
<div class="symbol-selected" id="sym-selected"></div> <div class="symbol-selected" id="sym-selected"></div>
</div> </div>
@@ -120,7 +121,7 @@
{% if recommend_rows %} {% if recommend_rows %}
{% for r in recommend_rows %} {% for r in recommend_rows %}
<tr class="rec-{{ r.status }}"> <tr class="rec-{{ r.status }}">
<td><strong>{{ r.name }}</strong> <span class="text-muted">{{ r.ths }}</span></td> <td><strong>{{ r.name }}</strong> <span class="text-accent">{{ r.main_code or r.ths }}</span></td>
<td>{{ r.exchange }}</td> <td>{{ r.exchange }}</td>
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td> <td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td> <td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>