Add daily trend status to product recommendations with breakout priority
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from typing import Callable, Optional
|
|||||||
|
|
||||||
from contract_specs import get_contract_spec
|
from contract_specs import get_contract_spec
|
||||||
from fee_specs import calc_fee_breakdown
|
from fee_specs import calc_fee_breakdown
|
||||||
|
from recommend_trend import analyze_product_trend, sort_recommend_by_trend
|
||||||
from symbols import PRODUCTS
|
from symbols import PRODUCTS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -126,6 +127,8 @@ def list_product_recommendations(
|
|||||||
)
|
)
|
||||||
main_code = (quote.get("ths_code") or "").strip()
|
main_code = (quote.get("ths_code") or "").strip()
|
||||||
row["main_code"] = main_code
|
row["main_code"] = main_code
|
||||||
|
if main_code:
|
||||||
|
row.update(analyze_product_trend(main_code))
|
||||||
return row
|
return row
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("recommend product failed %s: %s", ths, 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:
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||||
rows = list(pool.map(_one, PRODUCTS))
|
rows = list(pool.map(_one, PRODUCTS))
|
||||||
order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
|
return sort_recommend_by_trend(rows)
|
||||||
rows.sort(key=lambda r: (order.get(r["status"], 9), -(r.get("max_lots") or 0)))
|
|
||||||
return rows
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Callable, Optional
|
|||||||
|
|
||||||
from fee_specs import ensure_fee_rates_schema
|
from fee_specs import ensure_fee_rates_schema
|
||||||
from product_recommend import list_product_recommendations
|
from product_recommend import list_product_recommendations
|
||||||
|
from recommend_trend import sort_recommend_by_trend
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
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(
|
def recommend_cache_needs_refresh(
|
||||||
cached: dict,
|
cached: dict,
|
||||||
*,
|
*,
|
||||||
@@ -49,6 +57,8 @@ def recommend_cache_needs_refresh(
|
|||||||
rows = cached.get("rows") or []
|
rows = cached.get("rows") or []
|
||||||
if rows_missing_max_lots(rows):
|
if rows_missing_max_lots(rows):
|
||||||
return True
|
return True
|
||||||
|
if rows_missing_trend(rows):
|
||||||
|
return True
|
||||||
if float(capital or 0) > 0 and not rows:
|
if float(capital or 0) > 0 and not rows:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -214,6 +224,7 @@ def recommend_payload(
|
|||||||
rows, cap, max_margin_pct=pct, trading_mode=trading_mode,
|
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 = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots)
|
||||||
|
rows = sort_recommend_by_trend(rows)
|
||||||
payload["rows"] = rows
|
payload["rows"] = rows
|
||||||
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
|
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
.trade-footer strong{color:var(--accent)}
|
.trade-footer strong{color:var(--accent)}
|
||||||
.rec-blocked td{opacity:.55}
|
.rec-blocked td{opacity:.55}
|
||||||
.rec-ok td:first-child{font-weight:600}
|
.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}
|
#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{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}
|
.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem}
|
||||||
|
|||||||
+20
-3
@@ -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 '<span class="badge trend-badge ' + cls + '"' + title + '>' + prefix + label + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderRecommendations(data) {
|
function renderRecommendations(data) {
|
||||||
if (!recommendList || !data) return;
|
if (!recommendList || !data) return;
|
||||||
updateRecommendMaxMaps(data);
|
updateRecommendMaxMaps(data);
|
||||||
@@ -990,14 +1003,18 @@
|
|||||||
}
|
}
|
||||||
var rows = data.rows || [];
|
var rows = data.rows || [];
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
recommendList.innerHTML = '<tr><td colspan="9" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
recommendList.innerHTML = '<tr><td colspan="10" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recommendList.innerHTML = rows.map(function (r) {
|
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 (
|
return (
|
||||||
'<tr class="rec-' + (r.status || '') + '">' +
|
'<tr class="' + rowCls + '">' +
|
||||||
'<td><strong>' + (r.name || '') + '</strong> <span class="text-accent">' + (r.main_code || r.ths || '') + '</span></td>' +
|
'<td><strong' + nameCls + '>' + (r.name || '') + '</strong> <span class="text-accent">' + (r.main_code || r.ths || '') + '</span></td>' +
|
||||||
'<td>' + (r.exchange || '') + '</td>' +
|
'<td>' + (r.exchange || '') + '</td>' +
|
||||||
|
'<td>' + trendBadgeHtml(r) + '</td>' +
|
||||||
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
|
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
|
||||||
'<td>' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '</td>' +
|
'<td>' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '</td>' +
|
||||||
'<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' +
|
'<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' +
|
||||||
|
|||||||
+12
-4
@@ -121,11 +121,12 @@
|
|||||||
保证金优先读取 CTP 柜台合约信息。
|
保证金优先读取 CTP 柜台合约信息。
|
||||||
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="trend-hint">走势说明:拉取近一周日线;最近三天 K 线高低价重叠度 ≥ 70% 判为<strong>震荡</strong>,否则按收盘方向判<strong>多头</strong>/<strong>空头</strong>;前段震荡、近期突破则标为<strong>转多</strong>/<strong>转空</strong>并优先排列。</p>
|
||||||
<div class="trade-table-wrap">
|
<div class="trade-table-wrap">
|
||||||
<table class="trade-table">
|
<table class="trade-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<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>
|
<th>1手保证金</th><th>1手手续费</th><th>最大手数</th><th>状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -133,9 +134,16 @@
|
|||||||
<tbody id="recommend-list">
|
<tbody id="recommend-list">
|
||||||
{% if recommend_rows %}
|
{% if recommend_rows %}
|
||||||
{% for r in recommend_rows %}
|
{% for r in recommend_rows %}
|
||||||
<tr class="rec-{{ r.status }}">
|
<tr class="rec-{{ r.status }}{% if r.trend_transition %} rec-trend-break{% endif %}">
|
||||||
<td><strong>{{ r.name }}</strong> <span class="text-accent">{{ r.main_code or r.ths }}</span></td>
|
<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.exchange }}</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 %}">
|
||||||
|
{% if r.trend_transition %}★ {% endif %}{{ r.trend_label }}
|
||||||
|
</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||||
<td>{% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% else %}—{% endif %}</td>
|
<td>{% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% else %}—{% endif %}</td>
|
||||||
<td>{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}</td>
|
<td>{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}</td>
|
||||||
@@ -146,7 +154,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="9" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
<tr><td colspan="10" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user