From aad88a9e98987e06b53bb353d4ecf105b7fcf731 Mon Sep 17 00:00:00 2001
From: dekun
Date: Fri, 26 Jun 2026 01:21:53 +0800
Subject: [PATCH] Add industry filter to recommendations and fix verify button
width.
Show category, turnover, and per-industry counts; clarify volume is in lots. Prevent trade-save button from stretching full column width.
Co-authored-by: Cursor
---
install_trading.py | 3 +-
product_recommend.py | 19 +++++++-
recommend_store.py | 22 ++++++++-
recommend_trend.py | 1 +
static/css/trade.css | 4 ++
static/js/trade.js | 105 ++++++++++++++++++++++++++++++++++++++++---
symbols.py | 22 +++++++++
templates/base.html | 2 +-
templates/trade.html | 20 +++++++--
9 files changed, 184 insertions(+), 14 deletions(-)
diff --git a/install_trading.py b/install_trading.py
index ef6e091..3063822 100644
--- a/install_trading.py
+++ b/install_trading.py
@@ -62,7 +62,7 @@ from strategy.strategy_roll_lib import preview_roll
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached
from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND
-from symbols import ths_to_codes, resolve_main_contract, PRODUCTS
+from symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES
from trading_context import (
TRADING_MODE_LIVE,
TRADING_MODE_SIM,
@@ -1126,6 +1126,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
pending_order_timeout_min=get_pending_order_timeout_min(get_setting),
recommend_rows=rec_cache.get("rows") or [],
recommend_updated_at=rec_cache.get("updated_at"),
+ product_categories=PRODUCT_CATEGORIES,
)
finally:
conn.close()
diff --git a/product_recommend.py b/product_recommend.py
index c37b083..e5f726c 100644
--- a/product_recommend.py
+++ b/product_recommend.py
@@ -9,11 +9,23 @@ from typing import Callable, Optional
from contract_specs import get_contract_spec
from fee_specs import calc_fee_breakdown
from recommend_trend import analyze_product_daily, sort_recommend_by_trend
-from symbols import PRODUCTS
+from symbols import PRODUCTS, product_category
logger = logging.getLogger(__name__)
+def _attach_turnover(row: dict) -> None:
+ """成交额 = 昨日成交量(手) × 昨收 × 合约乘数。"""
+ try:
+ vol = float(row.get("volume") or 0)
+ price = float(row.get("prev_close") or row.get("price") or 0)
+ mult = float(row.get("mult") or 0)
+ except (TypeError, ValueError):
+ return
+ if vol > 0 and price > 0 and mult > 0:
+ row["turnover"] = round(vol * price * mult, 2)
+
+
def _letters_from_ths(ths_code: str) -> str:
import re
m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip())
@@ -34,6 +46,7 @@ def assess_product_for_capital(
ths = product.get("ths") or ""
name = product.get("name") or ths
exchange = product.get("exchange") or ""
+ category = product.get("category") or product_category(ths)
spec = get_contract_spec(ths + "8888")
mult = spec["mult"]
margin_rate = spec["margin_rate"]
@@ -47,6 +60,7 @@ def assess_product_for_capital(
"ths": ths,
"name": name,
"exchange": exchange,
+ "category": category,
"status": "no_price",
"status_label": "暂无行情",
"min_capital_one_lot": None,
@@ -87,6 +101,7 @@ def assess_product_for_capital(
"ths": ths,
"name": name,
"exchange": exchange,
+ "category": category,
"price": round(p, 4),
"mult": mult,
"tick_size": tick,
@@ -129,6 +144,7 @@ def list_product_recommendations(
row["main_code"] = main_code
if main_code:
row.update(analyze_product_daily(main_code))
+ _attach_turnover(row)
return row
except Exception as exc:
logger.warning("recommend product failed %s: %s", ths, exc)
@@ -136,6 +152,7 @@ def list_product_recommendations(
"ths": ths,
"name": product.get("name") or ths,
"exchange": product.get("exchange") or "",
+ "category": product.get("category") or product_category(ths),
"status": "no_price",
"status_label": "计算失败",
"main_code": "",
diff --git a/recommend_store.py b/recommend_store.py
index 758c176..b9f745c 100644
--- a/recommend_store.py
+++ b/recommend_store.py
@@ -8,8 +8,9 @@ from datetime import datetime
from typing import Callable, Optional
from fee_specs import ensure_fee_rates_schema
-from product_recommend import list_product_recommendations
+from product_recommend import _attach_turnover, list_product_recommendations
from recommend_trend import sort_recommend_by_trend
+from symbols import product_category
logger = logging.getLogger(__name__)
@@ -53,6 +54,18 @@ def rows_missing_daily_stats(rows: list[dict]) -> bool:
return any("gap" not in r for r in rows)
+def rows_missing_category(rows: list[dict]) -> bool:
+ if not rows:
+ return False
+ return any("category" not in r for r in rows)
+
+
+def rows_missing_turnover(rows: list[dict]) -> bool:
+ if not rows:
+ return False
+ return any("turnover" not in r for r in rows)
+
+
def recommend_cache_needs_refresh(
cached: dict,
*,
@@ -68,6 +81,10 @@ def recommend_cache_needs_refresh(
return True
if rows_missing_daily_stats(rows):
return True
+ if rows_missing_category(rows):
+ return True
+ if rows_missing_turnover(rows):
+ return True
if float(capital or 0) > 0 and not rows:
return True
return False
@@ -128,6 +145,9 @@ def enrich_recommend_rows(
elif lots < 1 and status in ("ok", "margin_ok"):
row["status"] = "blocked"
row["status_label"] = "资金不足"
+ if not row.get("category"):
+ row["category"] = product_category(row.get("ths") or "")
+ _attach_turnover(row)
enriched.append(row)
return enriched
diff --git a/recommend_trend.py b/recommend_trend.py
index ffc1e37..3885c09 100644
--- a/recommend_trend.py
+++ b/recommend_trend.py
@@ -123,6 +123,7 @@ def compute_daily_quote_stats(bars: list) -> dict:
"yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None,
"yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None,
"volume": int(vol) if vol is not None else None,
+ "volume_unit": "lot",
}
diff --git a/static/css/trade.css b/static/css/trade.css
index 5e813af..2e5b115 100644
--- a/static/css/trade.css
+++ b/static/css/trade.css
@@ -52,6 +52,10 @@
.rec-sort-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .65rem;margin-bottom:.55rem;font-size:.78rem}
.rec-sort-bar label{color:var(--text-muted);white-space:nowrap}
.rec-sort-bar select{padding:.35rem .5rem;font-size:.78rem;min-width:7rem}
+.rec-stats{
+ font-size:.78rem;color:var(--text-muted);margin-bottom:.45rem;line-height:1.5;
+}
+.rec-stats strong{color:var(--accent);font-weight:600}
.rec-sort-dir-btn{
border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);
padding:.3rem .55rem;border-radius:6px;cursor:pointer;font-size:.78rem;min-width:2rem;
diff --git a/static/js/trade.js b/static/js/trade.js
index 3f4ef8f..58c3ad3 100644
--- a/static/js/trade.js
+++ b/static/js/trade.js
@@ -33,8 +33,11 @@
var recRowsRaw = [];
var recSortKey = 'trend';
var recSortDesc = true;
- var REC_SORT_CACHE = 'qihuo_rec_sort_v1';
- var REC_COLSPAN = 14;
+ var recIndustryFilter = '';
+ var REC_SORT_CACHE = 'qihuo_rec_sort_v2';
+ var REC_INDUSTRY_CACHE = 'qihuo_rec_industry_v1';
+ var REC_COLSPAN = 16;
+ var productCategories = window.PRODUCT_CATEGORIES || [];
var POS_CACHE_KEY = 'qihuo_trading_live_v3';
function runWhenReady(fn) {
@@ -1107,6 +1110,10 @@
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() {
@@ -1115,11 +1122,62 @@
} 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) {
+ 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 (recIndustryFilter) {
+ parts.push('筛选 ' + shown + ' / 共 ' + total + ' 个品种');
+ } else {
+ parts.push('共 ' + total + ' 个品种');
+ }
+ 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 };
@@ -1165,6 +1223,15 @@
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);
@@ -1206,7 +1273,10 @@
function renderRecommendRows(rows) {
if (!recommendList) return;
if (!rows.length) {
- recommendList.innerHTML = '| 当前资金下暂无推荐品种(每日后台刷新) |
';
+ var emptyMsg = recIndustryFilter
+ ? '当前行业下暂无推荐品种'
+ : '当前资金下暂无推荐品种(每日后台刷新)';
+ recommendList.innerHTML = '| ' + emptyMsg + ' |
';
return;
}
recommendList.innerHTML = rows.map(function (r) {
@@ -1217,6 +1287,7 @@
'' +
'| ' + (r.name || '') + ' ' + (r.main_code || r.ths || '') + ' | ' +
'' + (r.exchange || '') + ' | ' +
+ '' + (r.category || '—') + ' | ' +
'' + trendBadgeHtml(r) + ' | ' +
'' + gapBadgeHtml(r) + ' | ' +
'' + (r.price != null ? r.price : '—') + ' | ' +
@@ -1225,6 +1296,7 @@
'' + changeCellHtml(r) + ' | ' +
'' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + ' | ' +
'' + fmtRecVolume(r.volume) + ' | ' +
+ '' + fmtRecTurnover(r.turnover) + ' | ' +
'' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' (柜台)' : '') : '—') + ' | ' +
'' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + ' | ' +
'' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + ' | ' +
@@ -1234,6 +1306,13 @@
}).join('');
}
+ function renderRecommendTable() {
+ var filtered = filterRecommendRows(recRowsRaw);
+ var sorted = sortRecommendRows(filtered);
+ updateRecStats(recRowsRaw, sorted);
+ renderRecommendRows(sorted);
+ }
+
function renderRecommendations(data) {
if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
@@ -1247,9 +1326,10 @@
recRowsRaw = rows.slice();
if (!rows.length) {
recommendList.innerHTML = '
| 当前资金下暂无推荐品种(每日后台刷新) |
';
+ updateRecStats([], []);
return;
}
- renderRecommendRows(sortRecommendRows(recRowsRaw));
+ renderRecommendTable();
}
function initRecommendSortControls() {
@@ -1257,11 +1337,19 @@
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();
- renderRecommendRows(sortRecommendRows(recRowsRaw));
+ renderRecommendTable();
});
}
if (btn) {
@@ -1269,9 +1357,10 @@
recSortDesc = !recSortDesc;
saveRecSortPrefs();
syncRecSortUi();
- renderRecommendRows(sortRecommendRows(recRowsRaw));
+ renderRecommendTable();
});
}
+ if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw));
}
function connectRecommendStream() {
@@ -1373,6 +1462,10 @@
initCtpOnLoad();
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); })
diff --git a/symbols.py b/symbols.py
index 0ba344e..154812e 100644
--- a/symbols.py
+++ b/symbols.py
@@ -65,6 +65,28 @@ PRODUCTS = [
{"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"},
]
+PRODUCT_CATEGORY_MAP = {
+ "ag": "贵金属", "au": "贵金属",
+ "cu": "有色金属", "al": "有色金属", "zn": "有色金属", "pb": "有色金属", "ni": "有色金属", "sn": "有色金属",
+ "rb": "黑色金属", "hc": "黑色金属", "ss": "黑色金属", "i": "黑色金属", "j": "黑色金属", "jm": "黑色金属",
+ "SF": "黑色金属", "SM": "黑色金属",
+ "sc": "能源化工", "fu": "能源化工", "bu": "能源化工", "ru": "能源化工", "sp": "能源化工",
+ "l": "能源化工", "pp": "能源化工", "v": "能源化工", "eg": "能源化工", "eb": "能源化工", "pg": "能源化工",
+ "MA": "能源化工", "TA": "能源化工", "SA": "能源化工", "UR": "能源化工", "FG": "能源化工",
+ "m": "农产品", "y": "农产品", "p": "农产品", "c": "农产品", "cs": "农产品", "jd": "农产品", "lh": "农产品",
+ "RM": "农产品", "OI": "农产品", "SR": "农产品", "CF": "农产品", "AP": "农产品", "CJ": "农产品", "PK": "农产品",
+ "IF": "金融期货", "IH": "金融期货", "IC": "金融期货", "IM": "金融期货",
+}
+PRODUCT_CATEGORIES = ["贵金属", "有色金属", "黑色金属", "能源化工", "农产品", "金融期货"]
+
+for _p in PRODUCTS:
+ _p["category"] = PRODUCT_CATEGORY_MAP.get(_p["ths"], "其他")
+
+
+def product_category(ths: str) -> str:
+ return PRODUCT_CATEGORY_MAP.get((ths or "").strip(), "其他")
+
+
EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"]
_MAIN_CACHE: dict[str, tuple[float, dict]] = {}
_CACHE_TTL = 300
diff --git a/templates/base.html b/templates/base.html
index fbafc14..aec68af 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -433,7 +433,7 @@
.records-trade-card{overflow:visible}
.records-trade-card .card-body{overflow:visible}
.trade-actions{display:flex;gap:.35rem;flex-wrap:nowrap;align-items:center;min-width:230px;white-space:nowrap}
- .trade-actions a,.trade-actions button{flex-shrink:0;white-space:nowrap;font-size:.72rem;padding:.3rem .55rem;border-radius:6px;text-decoration:none;border:none;cursor:pointer}
+ .trade-actions a,.trade-actions button{flex-shrink:0;white-space:nowrap;font-size:.72rem;padding:.3rem .55rem;border-radius:6px;text-decoration:none;border:none;cursor:pointer;width:auto;min-width:0}
.btn-fill{background:var(--dir-bg);color:var(--accent)}
.btn-verify{background:var(--nav-active);color:#fff}
.btn-verify:disabled{opacity:.45;cursor:not-allowed}
diff --git a/templates/trade.html b/templates/trade.html
index c88cb8f..c6a3d44 100644
--- a/templates/trade.html
+++ b/templates/trade.html
@@ -121,8 +121,16 @@
保证金优先读取 CTP 柜台合约信息。
{% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}
- 走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。支持按走势/跳空/成交量/振幅排序。
+ 走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选。
+
+
+