From 04b6f5e72dd95159496322037b4e984bf9949b07 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 17:25:34 +0800 Subject: [PATCH] Add daily trend status to product recommendations with breakout priority Co-authored-by: Cursor --- product_recommend.py | 7 +- recommend_store.py | 11 +++ recommend_trend.py | 158 +++++++++++++++++++++++++++++++++++++++++++ static/css/trade.css | 4 ++ static/js/trade.js | 23 ++++++- templates/trade.html | 16 +++-- 6 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 recommend_trend.py diff --git a/product_recommend.py b/product_recommend.py index b6c6a6e..1a1274c 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -8,6 +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 symbols import PRODUCTS logger = logging.getLogger(__name__) @@ -126,6 +127,8 @@ 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)) return row except Exception as exc: logger.warning("recommend product failed %s: %s", ths, exc) @@ -141,6 +144,4 @@ def list_product_recommendations( with ThreadPoolExecutor(max_workers=10) as pool: rows = list(pool.map(_one, PRODUCTS)) - order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3} - rows.sort(key=lambda r: (order.get(r["status"], 9), -(r.get("max_lots") or 0))) - return rows + return sort_recommend_by_trend(rows) diff --git a/recommend_store.py b/recommend_store.py index d4bbaf0..115ca22 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -9,6 +9,7 @@ from typing import Callable, Optional from fee_specs import ensure_fee_rates_schema from product_recommend import list_product_recommendations +from recommend_trend import sort_recommend_by_trend logger = logging.getLogger(__name__) @@ -38,6 +39,13 @@ def rows_missing_max_lots(rows: list[dict]) -> bool: return any("max_lots" not in r for r in rows) +def rows_missing_trend(rows: list[dict]) -> bool: + """缓存是否为旧版(缺少走势字段)。""" + if not rows: + return False + return any("trend" not in r for r in rows) + + def recommend_cache_needs_refresh( cached: dict, *, @@ -49,6 +57,8 @@ def recommend_cache_needs_refresh( rows = cached.get("rows") or [] if rows_missing_max_lots(rows): return True + if rows_missing_trend(rows): + return True if float(capital or 0) > 0 and not rows: return True return False @@ -214,6 +224,7 @@ def recommend_payload( rows, cap, max_margin_pct=pct, trading_mode=trading_mode, ) rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots) + rows = sort_recommend_by_trend(rows) payload["rows"] = rows payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap) return payload diff --git a/recommend_trend.py b/recommend_trend.py new file mode 100644 index 0000000..491162a --- /dev/null +++ b/recommend_trend.py @@ -0,0 +1,158 @@ +"""品种推荐:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。""" +from __future__ import annotations + +import logging +from typing import Callable, Optional + +from kline_chart import fetch_sina_klines + +logger = logging.getLogger(__name__) + +DAILY_LOOKBACK = 7 +OVERLAP_WINDOW = 3 +OVERLAP_RANGE_THRESHOLD = 0.70 + +TREND_LONG = "long" +TREND_SHORT = "short" +TREND_RANGE = "range" +TREND_BREAK_LONG = "break_long" +TREND_BREAK_SHORT = "break_short" + + +def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]: + o = float(bar.get("o") or bar.get("open") or 0) + h = float(bar.get("h") or bar.get("high") or o) + l = float(bar.get("l") or bar.get("low") or o) + c = float(bar.get("c") or bar.get("close") or o) + return o, h, l, c + + +def kline_overlap_ratio(bars: list) -> float: + """三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。""" + if len(bars) < OVERLAP_WINDOW: + return 0.0 + chunk = bars[-OVERLAP_WINDOW:] + lows, highs = [], [] + for bar in chunk: + _, h, l, _ = _bar_ohlc(bar) + if h <= 0 and l <= 0: + continue + lows.append(l) + highs.append(h) + if len(lows) < OVERLAP_WINDOW: + return 0.0 + overlap = max(0.0, min(highs) - max(lows)) + union = max(highs) - min(lows) + if union <= 0: + return 1.0 if overlap > 0 else 0.0 + return overlap / union + + +def _direction_from_closes(bars: list) -> str: + if len(bars) < 2: + return TREND_RANGE + closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0] + if len(closes) < 2: + return TREND_RANGE + if closes[-1] > closes[0]: + return TREND_LONG + if closes[-1] < closes[0]: + return TREND_SHORT + return TREND_RANGE + + +def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict: + """根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。""" + empty = { + "trend": "", + "trend_label": "—", + "trend_transition": False, + "trend_overlap_pct": None, + "trend_prev_overlap_pct": None, + } + if len(bars) < OVERLAP_WINDOW: + return empty + + recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars + curr_overlap = kline_overlap_ratio(recent) + prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0 + + curr_range = curr_overlap >= overlap_threshold + prev_range = prev_overlap >= overlap_threshold + + if curr_range: + trend, label = TREND_RANGE, "震荡" + transition = False + else: + direction = _direction_from_closes(recent[-OVERLAP_WINDOW:]) + if direction == TREND_LONG: + trend, label = TREND_LONG, "多头" + elif direction == TREND_SHORT: + trend, label = TREND_SHORT, "空头" + else: + trend, label = TREND_RANGE, "震荡" + transition = prev_range and trend in (TREND_LONG, TREND_SHORT) + if transition: + if trend == TREND_LONG: + trend, label = TREND_BREAK_LONG, "转多" + else: + trend, label = TREND_BREAK_SHORT, "转空" + + return { + "trend": trend, + "trend_label": label, + "trend_transition": transition, + "trend_overlap_pct": round(curr_overlap * 100, 1), + "trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None, + } + + +def fetch_week_daily_bars( + symbol: str, + *, + fetch_fn: Callable[[str, str], list] | None = None, +) -> list: + fn = fetch_fn or fetch_sina_klines + try: + bars = fn(symbol, "d") or [] + except Exception as exc: + logger.debug("fetch week daily failed %s: %s", symbol, exc) + return [] + if not bars: + return [] + return bars[-DAILY_LOOKBACK:] + + +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) + + +def trend_sort_key(row: dict) -> tuple: + """转多/转空优先,其次多头/空头,震荡靠后。""" + trend = (row.get("trend") or "").strip() + priority = { + TREND_BREAK_LONG: 0, + TREND_BREAK_SHORT: 0, + TREND_LONG: 1, + TREND_SHORT: 1, + TREND_RANGE: 2, + } + status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3} + return ( + priority.get(trend, 3), + status_order.get(row.get("status") or "", 9), + -(int(row.get("max_lots") or 0)), + ) + + +def sort_recommend_by_trend(rows: list[dict]) -> list[dict]: + return sorted(rows, key=trend_sort_key) diff --git a/static/css/trade.css b/static/css/trade.css index 753d395..202efe0 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -45,6 +45,10 @@ .trade-footer strong{color:var(--accent)} .rec-blocked td{opacity:.55} .rec-ok td:first-child{font-weight:600} +.rec-trend-break td:first-child .trend-name{font-weight:700} +.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} #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 f2cb456..f3b2120 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -979,6 +979,19 @@ }; } + function trendBadgeHtml(r) { + var label = r.trend_label || ''; + if (!label || label === '—') return '—'; + var cls = 'planned'; + if (r.trend === 'break_long' || r.trend === 'break_short') cls = 'break'; + else if (r.trend === 'long') cls = 'profit'; + else if (r.trend === 'short') cls = 'loss'; + var title = ''; + if (r.trend_overlap_pct != null) title = ' title="近3日重叠 ' + r.trend_overlap_pct + '%"'; + var prefix = r.trend_transition ? '★ ' : ''; + return '' + prefix + label + ''; + } + function renderRecommendations(data) { if (!recommendList || !data) return; updateRecommendMaxMaps(data); @@ -990,14 +1003,18 @@ } var rows = data.rows || []; if (!rows.length) { - recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; + recommendList.innerHTML = '当前资金下暂无推荐品种(每日后台刷新)'; return; } recommendList.innerHTML = rows.map(function (r) { + var rowCls = 'rec-' + (r.status || ''); + if (r.trend_transition) rowCls += ' rec-trend-break'; + var nameCls = r.trend_transition ? ' class="trend-name"' : ''; return ( - '' + - '' + (r.name || '') + ' ' + (r.main_code || r.ths || '') + '' + + '' + + '' + (r.name || '') + ' ' + (r.main_code || r.ths || '') + '' + '' + (r.exchange || '') + '' + + '' + trendBadgeHtml(r) + '' + '' + (r.price != null ? r.price : '—') + '' + '' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '' + '' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '' + diff --git a/templates/trade.html b/templates/trade.html index 37b2052..36e4088 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -121,11 +121,12 @@ 保证金优先读取 CTP 柜台合约信息。 {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}

+

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

- + @@ -133,9 +134,16 @@ {% if recommend_rows %} {% for r in recommend_rows %} - - + + + @@ -146,7 +154,7 @@ {% endfor %} {% else %} - + {% endif %}
品种交易所参考价品种交易所走势参考价 参考止损参考止盈 1手保证金1手手续费最大手数状态
{{ r.name }} {{ r.main_code or r.ths }}
{{ r.name }} {{ r.main_code or r.ths }} {{ r.exchange }} + {% if r.trend_label and r.trend_label != '—' %} + + {% if r.trend_transition %}★ {% endif %}{{ r.trend_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 %}
等待今日后台刷新推荐…
等待今日后台刷新推荐…