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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 01:21:53 +08:00
parent e5f675c6ca
commit aad88a9e98
9 changed files with 184 additions and 14 deletions
+2 -1
View File
@@ -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()
+18 -1
View File
@@ -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": "",
+21 -1
View File
@@ -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
+1
View File
@@ -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",
}
+4
View File
@@ -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;
+99 -6
View File
@@ -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('筛选 <strong>' + shown + '</strong> / 共 ' + total + ' 个品种');
} else {
parts.push('共 <strong>' + total + '</strong> 个品种');
}
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 = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
var emptyMsg = recIndustryFilter
? '当前行业下暂无推荐品种'
: '当前资金下暂无推荐品种(每日后台刷新)';
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">' + emptyMsg + '</td></tr>';
return;
}
recommendList.innerHTML = rows.map(function (r) {
@@ -1217,6 +1287,7 @@
'<tr class="' + rowCls + '">' +
'<td><strong' + nameCls + '>' + (r.name || '') + '</strong> <span class="text-accent">' + (r.main_code || r.ths || '') + '</span></td>' +
'<td>' + (r.exchange || '') + '</td>' +
'<td>' + (r.category || '—') + '</td>' +
'<td>' + trendBadgeHtml(r) + '</td>' +
'<td>' + gapBadgeHtml(r) + '</td>' +
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
@@ -1225,6 +1296,7 @@
'<td>' + changeCellHtml(r) + '</td>' +
'<td>' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '</td>' +
'<td>' + fmtRecVolume(r.volume) + '</td>' +
'<td>' + fmtRecTurnover(r.turnover) + '</td>' +
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' <span class="text-muted">(柜台)</span>' : '') : '—') + '</td>' +
'<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' +
'<td>' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '</td>' +
@@ -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 = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
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); })
+22
View File
@@ -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
+1 -1
View File
@@ -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}
+16 -4
View File
@@ -121,8 +121,16 @@
保证金优先读取 CTP 柜台合约信息。
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
</p>
<p class="trend-hint">走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。支持按走势/跳空/成交量/振幅排序。</p>
<p class="trend-hint">走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选</p>
<div class="rec-stats" id="rec-stats"></div>
<div class="rec-sort-bar">
<label for="rec-industry-filter">行业</label>
<select id="rec-industry-filter">
<option value="" selected>全部</option>
{% for cat in product_categories %}
<option value="{{ cat }}">{{ cat }}</option>
{% endfor %}
</select>
<label for="rec-sort-key">排序</label>
<select id="rec-sort-key">
<option value="trend" selected>走势</option>
@@ -136,9 +144,9 @@
<table class="trade-table" id="recommend-table">
<thead>
<tr>
<th>品种</th><th>交易所</th><th>走势</th><th>是否跳空</th>
<th>品种</th><th>交易所</th><th>行业</th><th>走势</th><th>是否跳空</th>
<th>参考价</th><th>昨日收盘</th><th>今日开盘</th>
<th>昨日涨跌</th><th>昨日振幅</th><th>成交量</th>
<th>昨日涨跌</th><th>昨日振幅</th><th>成交量(手)</th><th>成交额</th>
<th>1手保证金</th><th>1手手续费</th><th>最大手数</th><th>状态</th>
</tr>
</thead>
@@ -148,6 +156,7 @@
<tr class="rec-{{ r.status }}{% if r.trend_transition %} rec-trend-break{% endif %}">
<td><strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong> <span class="text-accent">{{ r.main_code or r.ths }}</span></td>
<td>{{ r.exchange }}</td>
<td>{{ r.category or '—' }}</td>
<td>
{% if r.trend_label and r.trend_label != '—' %}
<span class="badge trend-badge {% if r.trend in ('break_long', 'break_short') %}break{% elif r.trend == 'long' %}profit{% elif r.trend == 'short' %}loss{% else %}planned{% endif %}" title="{% if r.trend_overlap_pct is not none %}近3日重叠 {{ r.trend_overlap_pct }}%{% endif %}">
@@ -172,6 +181,7 @@
</td>
<td>{% if r.yesterday_amplitude_pct is not none %}{{ '%.2f'|format(r.yesterday_amplitude_pct) }}%{% else %}—{% endif %}</td>
<td>{% if r.volume is not none %}{{ r.volume }}{% else %}—{% endif %}</td>
<td>{% if r.turnover is not none %}{{ '%.0f'|format(r.turnover) }}{% else %}—{% endif %}</td>
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} <span class="text-muted">(柜台)</span>{% endif %}{% else %}—{% endif %}</td>
<td>{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}</td>
<td>{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}</td>
@@ -179,7 +189,7 @@
</tr>
{% endfor %}
{% else %}
<tr><td colspan="14" class="empty-hint">等待今日后台刷新推荐…</td></tr>
<tr><td colspan="16" class="empty-hint">等待今日后台刷新推荐…</td></tr>
{% endif %}
</tbody>
</table>
@@ -193,6 +203,8 @@
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
window.TRADE_FIXED_LOTS = {{ fixed_lots|tojson }};
window.TRADE_FIXED_AMOUNT = {{ fixed_amount|tojson }};
window.PRODUCT_CATEGORIES = {{ product_categories | default([]) | tojson }};
window.__RECOMMEND_ROWS__ = {{ recommend_rows | default([]) | tojson }};
</script>
<script src="{{ url_for('static', filename='js/trade.js') }}?v={{ asset_v }}"></script>
{% endblock %}