feat: 品种推荐与下单显示主力合约
推荐列表展示当前主力代码;下单品种支持中文/代码搜索并按交易所分组选择主力合约。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+16
-5
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user