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 = '
走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。支持按走势/跳空/成交量/振幅排序。
+走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选。
+