From b641a4eaa02c80476c0ac2bc32a323d9a0f5214b Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 17:36:31 +0800 Subject: [PATCH] Expand recommend table with gap, daily stats, and client-side sorting Co-authored-by: Cursor --- product_recommend.py | 4 +- recommend_store.py | 9 +++ recommend_trend.py | 131 ++++++++++++++++++++++++++++++++++-- static/css/trade.css | 12 ++++ static/js/trade.js | 157 +++++++++++++++++++++++++++++++++++++++---- templates/trade.html | 39 +++++++++-- 6 files changed, 324 insertions(+), 28 deletions(-) diff --git a/product_recommend.py b/product_recommend.py index 1a1274c..c37b083 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -8,7 +8,7 @@ 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_trend, sort_recommend_by_trend +from recommend_trend import analyze_product_daily, sort_recommend_by_trend from symbols import PRODUCTS logger = logging.getLogger(__name__) @@ -128,7 +128,7 @@ def list_product_recommendations( main_code = (quote.get("ths_code") or "").strip() row["main_code"] = main_code if main_code: - row.update(analyze_product_trend(main_code)) + row.update(analyze_product_daily(main_code)) return row except Exception as exc: logger.warning("recommend product failed %s: %s", ths, exc) diff --git a/recommend_store.py b/recommend_store.py index 115ca22..758c176 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -46,6 +46,13 @@ def rows_missing_trend(rows: list[dict]) -> bool: return any("trend" not in r for r in rows) +def rows_missing_daily_stats(rows: list[dict]) -> bool: + """缓存是否为旧版(缺少跳空/量价字段)。""" + if not rows: + return False + return any("gap" not in r for r in rows) + + def recommend_cache_needs_refresh( cached: dict, *, @@ -59,6 +66,8 @@ def recommend_cache_needs_refresh( return True if rows_missing_trend(rows): return True + if rows_missing_daily_stats(rows): + return True if float(capital or 0) > 0 and not rows: return True return False diff --git a/recommend_trend.py b/recommend_trend.py index 8bc515a..ffc1e37 100644 --- a/recommend_trend.py +++ b/recommend_trend.py @@ -64,6 +64,68 @@ def _direction_from_closes(bars: list) -> str: return TREND_RANGE +def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]: + o, h, l, c = _bar_ohlc(bar) + v = float(bar.get("v") or bar.get("volume") or 0) + return o, h, l, c, v + + +def compute_daily_quote_stats(bars: list) -> dict: + """从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。""" + empty = { + "gap": "", + "gap_label": "—", + "gap_pct": None, + "prev_close": None, + "today_open": None, + "yesterday_change": None, + "yesterday_change_pct": None, + "yesterday_amplitude_pct": None, + "volume": None, + } + if len(bars) < 2: + return empty + + t_o, _, _, _, t_v = _bar_ohlcv(bars[-1]) + y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2]) + if y_c <= 0: + return empty + + prev_close = round(y_c, 4) + today_open = round(t_o, 4) if t_o > 0 else None + + gap, gap_label, gap_pct = "none", "否", 0.0 + if today_open is not None and today_open > y_c: + gap, gap_label = "up", "跳空高开" + gap_pct = (today_open - y_c) / y_c * 100 + elif today_open is not None and today_open < y_c: + gap, gap_label = "down", "跳空低开" + gap_pct = (today_open - y_c) / y_c * 100 + + if len(bars) >= 3: + _, _, _, p_c, _ = _bar_ohlcv(bars[-3]) + base = p_c if p_c > 0 else y_o + else: + base = y_o if y_o > 0 else y_c + + y_change = y_c - base if base > 0 else None + y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None + y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None + vol = y_v if y_v > 0 else (t_v if t_v > 0 else None) + + return { + "gap": gap, + "gap_label": gap_label, + "gap_pct": round(gap_pct, 2) if gap != "none" else 0.0, + "prev_close": prev_close, + "today_open": today_open, + "yesterday_change": round(y_change, 4) if y_change is not None else None, + "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, + } + + def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict: """根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。""" empty = { @@ -120,6 +182,7 @@ def _normalize_daily_bars(raw: list) -> list: "h": float(row[2]), "l": float(row[3]), "c": float(row[4]), + "v": float(row[5]) if len(row) > 5 and row[5] else 0.0, }) elif isinstance(row, dict) and row.get("d"): out.append({ @@ -128,6 +191,7 @@ def _normalize_daily_bars(raw: list) -> list: "h": float(row.get("h", 0) or 0), "l": float(row.get("l", 0) or 0), "c": float(row.get("c", 0) or 0), + "v": float(row.get("v", 0) or 0), }) return out @@ -181,17 +245,70 @@ def fetch_week_daily_bars( return bars[-DAILY_LOOKBACK:] if bars else [] +def analyze_product_daily( + symbol: str, + *, + fetch_fn: Callable[[str, str], list] | None = None, +) -> dict: + """拉取主力合约一周日线:走势 + 跳空/量价统计。""" + sym = (symbol or "").strip() + if not sym: + out = analyze_daily_trend([]) + out.update(compute_daily_quote_stats([])) + return out + bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn) + out = analyze_daily_trend(bars) + out.update(compute_daily_quote_stats(bars)) + return out + + def analyze_product_trend( symbol: str, *, fetch_fn: Callable[[str, str], list] | None = None, ) -> dict: - """拉取主力合约一周日线并分析走势。""" - sym = (symbol or "").strip() - if not sym: - return analyze_daily_trend([]) - bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn) - return analyze_daily_trend(bars) + return analyze_product_daily(symbol, fetch_fn=fetch_fn) + + +GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1} +TREND_SORT_RANK = { + TREND_BREAK_LONG: 0, + TREND_BREAK_SHORT: 0, + TREND_LONG: 1, + TREND_SHORT: 2, + TREND_RANGE: 3, + "": 9, +} + + +def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple: + """可排序字段:trend / gap / volume / amplitude。""" + key = (sort_by or "trend").strip().lower() + if key == "gap": + primary = GAP_SORT_RANK.get(row.get("gap") or "", -1) + secondary = abs(float(row.get("gap_pct") or 0)) + elif key == "volume": + primary = float(row.get("volume") or 0) + secondary = 0.0 + elif key == "amplitude": + primary = float(row.get("yesterday_amplitude_pct") or 0) + secondary = 0.0 + else: + primary = TREND_SORT_RANK.get(row.get("trend") or "", 9) + secondary = -(int(row.get("max_lots") or 0)) + + if desc: + return (-primary, -secondary, row.get("name") or "") + return (primary, secondary, row.get("name") or "") + + +def sort_recommend_rows( + rows: list[dict], + *, + sort_by: str = "trend", + desc: bool = True, +) -> list[dict]: + return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc)) def trend_sort_key(row: dict) -> tuple: @@ -213,4 +330,4 @@ def trend_sort_key(row: dict) -> tuple: def sort_recommend_by_trend(rows: list[dict]) -> list[dict]: - return sorted(rows, key=trend_sort_key) + return sort_recommend_rows(rows, sort_by="trend", desc=True) diff --git a/static/css/trade.css b/static/css/trade.css index 202efe0..e2d19f0 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -49,6 +49,18 @@ .trend-badge{font-size:.72rem;white-space:nowrap} .trend-badge.break{color:var(--accent);font-weight:700;border:1px solid var(--accent);background:rgba(56,189,248,.12)} .trend-hint{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 .65rem;line-height:1.5} +.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-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; +} +.rec-sort-dir-btn:hover{border-color:var(--accent);color:var(--accent)} +.gap-badge{font-size:.72rem} +.rec-change-up{color:var(--profit)} +.rec-change-down{color:var(--loss)} +#recommend .trade-table-wrap{max-height:min(70vh,520px)} #positions .card-body.card-scroll{flex:1;max-height:none;overflow-y:auto} .pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)} .pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem} diff --git a/static/js/trade.js b/static/js/trade.js index f3b2120..da4e45c 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -30,6 +30,11 @@ var selectedMaxLots = null; var recommendMaxByProduct = {}; var recommendMaxByCode = {}; + var recRowsRaw = []; + var recSortKey = 'trend'; + var recSortDesc = true; + var REC_SORT_CACHE = 'qihuo_rec_sort_v1'; + var REC_COLSPAN = 14; var POS_CACHE_KEY = 'qihuo_trading_live_v3'; function runWhenReady(fn) { @@ -979,6 +984,84 @@ }; } + function loadRecSortPrefs() { + try { + var raw = sessionStorage.getItem(REC_SORT_CACHE); + if (!raw) return; + var p = JSON.parse(raw); + if (p.key) recSortKey = p.key; + if (typeof p.desc === 'boolean') recSortDesc = p.desc; + } catch (e) { /* ignore */ } + } + + function saveRecSortPrefs() { + try { + sessionStorage.setItem(REC_SORT_CACHE, JSON.stringify({ key: recSortKey, desc: recSortDesc })); + } catch (e) { /* ignore */ } + } + + function syncRecSortUi() { + var sel = document.getElementById('rec-sort-key'); + var btn = document.getElementById('rec-sort-dir'); + if (sel) sel.value = recSortKey; + if (btn) btn.textContent = recSortDesc ? '↓' : '↑'; + } + + var TREND_SORT_RANK = { break_long: 0, break_short: 0, long: 1, short: 2, range: 3, '': 9 }; + var GAP_SORT_RANK = { up: 2, down: 1, none: 0, '': -1 }; + + function sortRecommendRows(rows) { + var list = (rows || []).slice(); + var key = recSortKey || 'trend'; + var desc = recSortDesc; + list.sort(function (a, b) { + var av, bv, as, bs; + if (key === 'gap') { + av = GAP_SORT_RANK[a.gap || ''] !== undefined ? GAP_SORT_RANK[a.gap || ''] : -1; + bv = GAP_SORT_RANK[b.gap || ''] !== undefined ? GAP_SORT_RANK[b.gap || ''] : -1; + as = Math.abs(Number(a.gap_pct) || 0); + bs = Math.abs(Number(b.gap_pct) || 0); + } else if (key === 'volume') { + av = Number(a.volume) || 0; + bv = Number(b.volume) || 0; + as = bs = 0; + } else if (key === 'amplitude') { + av = Number(a.yesterday_amplitude_pct) || 0; + bv = Number(b.yesterday_amplitude_pct) || 0; + as = bs = 0; + } else { + av = TREND_SORT_RANK[a.trend || ''] !== undefined ? TREND_SORT_RANK[a.trend || ''] : 9; + bv = TREND_SORT_RANK[b.trend || ''] !== undefined ? TREND_SORT_RANK[b.trend || ''] : 9; + as = Number(a.max_lots) || 0; + bs = Number(b.max_lots) || 0; + } + if (av !== bv) return desc ? bv - av : av - bv; + if (as !== bs) return desc ? bs - as : as - bs; + return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); + }); + return list; + } + + function fmtRecVolume(v) { + if (v === null || v === undefined) return '—'; + var n = Number(v); + if (!isFinite(n)) return '—'; + if (n >= 10000) return (n / 10000).toFixed(1) + '万'; + return String(Math.round(n)); + } + + function changeCellHtml(r) { + if (r.yesterday_change == null) return '—'; + var ch = Number(r.yesterday_change); + var cls = ch > 0 ? 'rec-change-up' : (ch < 0 ? 'rec-change-down' : ''); + var txt = (ch > 0 ? '+' : '') + ch; + if (r.yesterday_change_pct != null) { + var pct = Number(r.yesterday_change_pct); + txt += ' (' + (pct > 0 ? '+' : '') + pct + '%)'; + } + return '' + txt + ''; + } + function trendBadgeHtml(r) { var label = r.trend_label || ''; if (!label || label === '—') return '—'; @@ -992,18 +1075,23 @@ return '' + prefix + label + ''; } - function renderRecommendations(data) { - if (!recommendList || !data) return; - updateRecommendMaxMaps(data); - var recCap = document.getElementById('rec-capital'); - if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); - var recUpdated = document.getElementById('rec-updated'); - if (recUpdated && data.updated_at) { - recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at; + function gapBadgeHtml(r) { + var label = r.gap_label || ''; + if (!label || label === '—') return '—'; + var cls = 'planned'; + if (r.gap === 'up') cls = 'profit'; + else if (r.gap === 'down') cls = 'loss'; + var title = ''; + if (r.gap_pct != null && r.gap !== 'none') { + title = ' title="跳空 ' + (Number(r.gap_pct) > 0 ? '+' : '') + r.gap_pct + '%"'; } - var rows = data.rows || []; + return '' + label + ''; + } + + function renderRecommendRows(rows) { + if (!recommendList) return; if (!rows.length) { - recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; + recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; return; } recommendList.innerHTML = rows.map(function (r) { @@ -1015,9 +1103,13 @@ '' + (r.name || '') + ' ' + (r.main_code || r.ths || '') + '' + '' + (r.exchange || '') + '' + '' + trendBadgeHtml(r) + '' + + '' + gapBadgeHtml(r) + '' + '' + (r.price != null ? r.price : '—') + '' + - '' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '' + - '' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '' + + '' + (r.prev_close != null ? r.prev_close : '—') + '' + + '' + (r.today_open != null ? r.today_open : '—') + '' + + '' + changeCellHtml(r) + '' + + '' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '' + + '' + fmtRecVolume(r.volume) + '' + '' + (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 : '—') + '' + @@ -1027,6 +1119,46 @@ }).join(''); } + function renderRecommendations(data) { + if (!recommendList || !data) return; + updateRecommendMaxMaps(data); + var recCap = document.getElementById('rec-capital'); + if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2); + var recUpdated = document.getElementById('rec-updated'); + if (recUpdated && data.updated_at) { + recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at; + } + var rows = data.rows || []; + recRowsRaw = rows.slice(); + if (!rows.length) { + recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; + return; + } + renderRecommendRows(sortRecommendRows(recRowsRaw)); + } + + function initRecommendSortControls() { + loadRecSortPrefs(); + syncRecSortUi(); + var sel = document.getElementById('rec-sort-key'); + var btn = document.getElementById('rec-sort-dir'); + if (sel) { + sel.addEventListener('change', function () { + recSortKey = sel.value || 'trend'; + saveRecSortPrefs(); + renderRecommendRows(sortRecommendRows(recRowsRaw)); + }); + } + if (btn) { + btn.addEventListener('click', function () { + recSortDesc = !recSortDesc; + saveRecSortPrefs(); + syncRecSortUi(); + renderRecommendRows(sortRecommendRows(recRowsRaw)); + }); + } + } + function connectRecommendStream() { if (recommendSource) { recommendSource.close(); recommendSource = null; } recommendSource = new EventSource('/api/recommend/stream'); @@ -1125,6 +1257,7 @@ connectPositionStream(); initCtpOnLoad(); connectRecommendStream(); + initRecommendSortControls(); fetch('/api/recommend/list') .then(function (r) { return r.json(); }) .then(function (data) { if (data.ok) renderRecommendations(data); }) diff --git a/templates/trade.html b/templates/trade.html index 36e4088..f490c86 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -121,13 +121,24 @@ 保证金优先读取 CTP 柜台合约信息。 {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}

-

走势说明:拉取近一周日线;最近三天 K 线高低价重叠度 ≥ 70% 判为震荡,否则按收盘方向判多头/空头;前段震荡、近期突破则标为转多/转空并优先排列。

+

走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。支持按走势/跳空/成交量/振幅排序。

+
+ + + +
- +
- - + + + @@ -144,9 +155,23 @@ {% else %}—{% endif %} + - - + + + + + @@ -154,7 +179,7 @@ {% endfor %} {% else %} - + {% endif %}
品种交易所走势参考价参考止损参考止盈品种交易所走势是否跳空参考价昨日收盘今日开盘昨日涨跌昨日振幅成交量 1手保证金1手手续费最大手数状态
+ {% if r.gap_label and r.gap_label != '—' %} + {{ r.gap_label }} + {% else %}—{% endif %} + {% if r.price %}{{ r.price }}{% else %}—{% endif %}{% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% else %}—{% endif %}{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}{% if r.prev_close is not none %}{{ r.prev_close }}{% else %}—{% endif %}{% if r.today_open is not none %}{{ r.today_open }}{% else %}—{% endif %} + {% if r.yesterday_change is not none %} + + {{ '%+.4f'|format(r.yesterday_change) }}{% if r.yesterday_change_pct is not none %} ({{ '%+.2f'|format(r.yesterday_change_pct) }}%){% endif %} + + {% else %}—{% endif %} + {% if r.yesterday_amplitude_pct is not none %}{{ '%.2f'|format(r.yesterday_amplitude_pct) }}%{% else %}—{% endif %}{% if r.volume is not none %}{{ r.volume }}{% else %}—{% endif %} {% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} (柜台){% endif %}{% else %}—{% endif %} {% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %} {% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}
等待今日后台刷新推荐…
等待今日后台刷新推荐…