Expand recommend table with gap, daily stats, and client-side sorting
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +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 recommend_trend import analyze_product_daily, sort_recommend_by_trend
|
||||||
from symbols import PRODUCTS
|
from symbols import PRODUCTS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -128,7 +128,7 @@ 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:
|
if main_code:
|
||||||
row.update(analyze_product_trend(main_code))
|
row.update(analyze_product_daily(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)
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ def rows_missing_trend(rows: list[dict]) -> bool:
|
|||||||
return any("trend" not in r for r in rows)
|
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(
|
def recommend_cache_needs_refresh(
|
||||||
cached: dict,
|
cached: dict,
|
||||||
*,
|
*,
|
||||||
@@ -59,6 +66,8 @@ def recommend_cache_needs_refresh(
|
|||||||
return True
|
return True
|
||||||
if rows_missing_trend(rows):
|
if rows_missing_trend(rows):
|
||||||
return True
|
return True
|
||||||
|
if rows_missing_daily_stats(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
|
||||||
|
|||||||
+124
-7
@@ -64,6 +64,68 @@ def _direction_from_closes(bars: list) -> str:
|
|||||||
return TREND_RANGE
|
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:
|
def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict:
|
||||||
"""根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。"""
|
"""根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。"""
|
||||||
empty = {
|
empty = {
|
||||||
@@ -120,6 +182,7 @@ def _normalize_daily_bars(raw: list) -> list:
|
|||||||
"h": float(row[2]),
|
"h": float(row[2]),
|
||||||
"l": float(row[3]),
|
"l": float(row[3]),
|
||||||
"c": float(row[4]),
|
"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"):
|
elif isinstance(row, dict) and row.get("d"):
|
||||||
out.append({
|
out.append({
|
||||||
@@ -128,6 +191,7 @@ def _normalize_daily_bars(raw: list) -> list:
|
|||||||
"h": float(row.get("h", 0) or 0),
|
"h": float(row.get("h", 0) or 0),
|
||||||
"l": float(row.get("l", 0) or 0),
|
"l": float(row.get("l", 0) or 0),
|
||||||
"c": float(row.get("c", 0) or 0),
|
"c": float(row.get("c", 0) or 0),
|
||||||
|
"v": float(row.get("v", 0) or 0),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -181,17 +245,70 @@ def fetch_week_daily_bars(
|
|||||||
return bars[-DAILY_LOOKBACK:] if bars else []
|
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(
|
def analyze_product_trend(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
*,
|
*,
|
||||||
fetch_fn: Callable[[str, str], list] | None = None,
|
fetch_fn: Callable[[str, str], list] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""拉取主力合约一周日线并分析走势。"""
|
return analyze_product_daily(symbol, fetch_fn=fetch_fn)
|
||||||
sym = (symbol or "").strip()
|
|
||||||
if not sym:
|
|
||||||
return analyze_daily_trend([])
|
GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1}
|
||||||
bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn)
|
TREND_SORT_RANK = {
|
||||||
return analyze_daily_trend(bars)
|
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:
|
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]:
|
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)
|
||||||
|
|||||||
@@ -49,6 +49,18 @@
|
|||||||
.trend-badge{font-size:.72rem;white-space:nowrap}
|
.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-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}
|
.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}
|
#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}
|
||||||
|
|||||||
+145
-12
@@ -30,6 +30,11 @@
|
|||||||
var selectedMaxLots = null;
|
var selectedMaxLots = null;
|
||||||
var recommendMaxByProduct = {};
|
var recommendMaxByProduct = {};
|
||||||
var recommendMaxByCode = {};
|
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';
|
var POS_CACHE_KEY = 'qihuo_trading_live_v3';
|
||||||
|
|
||||||
function runWhenReady(fn) {
|
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 '<span class="' + cls + '">' + txt + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function trendBadgeHtml(r) {
|
function trendBadgeHtml(r) {
|
||||||
var label = r.trend_label || '';
|
var label = r.trend_label || '';
|
||||||
if (!label || label === '—') return '—';
|
if (!label || label === '—') return '—';
|
||||||
@@ -992,18 +1075,23 @@
|
|||||||
return '<span class="badge trend-badge ' + cls + '"' + title + '>' + prefix + label + '</span>';
|
return '<span class="badge trend-badge ' + cls + '"' + title + '>' + prefix + label + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRecommendations(data) {
|
function gapBadgeHtml(r) {
|
||||||
if (!recommendList || !data) return;
|
var label = r.gap_label || '';
|
||||||
updateRecommendMaxMaps(data);
|
if (!label || label === '—') return '—';
|
||||||
var recCap = document.getElementById('rec-capital');
|
var cls = 'planned';
|
||||||
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
|
if (r.gap === 'up') cls = 'profit';
|
||||||
var recUpdated = document.getElementById('rec-updated');
|
else if (r.gap === 'down') cls = 'loss';
|
||||||
if (recUpdated && data.updated_at) {
|
var title = '';
|
||||||
recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at;
|
if (r.gap_pct != null && r.gap !== 'none') {
|
||||||
|
title = ' title="跳空 ' + (Number(r.gap_pct) > 0 ? '+' : '') + r.gap_pct + '%"';
|
||||||
}
|
}
|
||||||
var rows = data.rows || [];
|
return '<span class="badge gap-badge ' + cls + '"' + title + '>' + label + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecommendRows(rows) {
|
||||||
|
if (!recommendList) return;
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
recommendList.innerHTML = '<tr><td colspan="10" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recommendList.innerHTML = rows.map(function (r) {
|
recommendList.innerHTML = rows.map(function (r) {
|
||||||
@@ -1015,9 +1103,13 @@
|
|||||||
'<td><strong' + nameCls + '>' + (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>' + trendBadgeHtml(r) + '</td>' +
|
||||||
|
'<td>' + gapBadgeHtml(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.prev_close != null ? r.prev_close : '—') + '</td>' +
|
||||||
'<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' +
|
'<td>' + (r.today_open != null ? r.today_open : '—') + '</td>' +
|
||||||
|
'<td>' + changeCellHtml(r) + '</td>' +
|
||||||
|
'<td>' + (r.yesterday_amplitude_pct != null ? r.yesterday_amplitude_pct + '%' : '—') + '</td>' +
|
||||||
|
'<td>' + fmtRecVolume(r.volume) + '</td>' +
|
||||||
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' <span class="text-muted">(柜台)</span>' : '') : '—') + '</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.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' +
|
||||||
'<td>' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '</td>' +
|
'<td>' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '</td>' +
|
||||||
@@ -1027,6 +1119,46 @@
|
|||||||
}).join('');
|
}).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 = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
|
||||||
|
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() {
|
function connectRecommendStream() {
|
||||||
if (recommendSource) { recommendSource.close(); recommendSource = null; }
|
if (recommendSource) { recommendSource.close(); recommendSource = null; }
|
||||||
recommendSource = new EventSource('/api/recommend/stream');
|
recommendSource = new EventSource('/api/recommend/stream');
|
||||||
@@ -1125,6 +1257,7 @@
|
|||||||
connectPositionStream();
|
connectPositionStream();
|
||||||
initCtpOnLoad();
|
initCtpOnLoad();
|
||||||
connectRecommendStream();
|
connectRecommendStream();
|
||||||
|
initRecommendSortControls();
|
||||||
fetch('/api/recommend/list')
|
fetch('/api/recommend/list')
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) { if (data.ok) renderRecommendations(data); })
|
.then(function (data) { if (data.ok) renderRecommendations(data); })
|
||||||
|
|||||||
+32
-7
@@ -121,13 +121,24 @@
|
|||||||
保证金优先读取 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>
|
<p class="trend-hint">走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。支持按走势/跳空/成交量/振幅排序。</p>
|
||||||
|
<div class="rec-sort-bar">
|
||||||
|
<label for="rec-sort-key">排序</label>
|
||||||
|
<select id="rec-sort-key">
|
||||||
|
<option value="trend" selected>走势</option>
|
||||||
|
<option value="gap">跳空</option>
|
||||||
|
<option value="volume">成交量</option>
|
||||||
|
<option value="amplitude">振幅</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="rec-sort-dir-btn" id="rec-sort-dir" title="切换升序/降序">↓</button>
|
||||||
|
</div>
|
||||||
<div class="trade-table-wrap">
|
<div class="trade-table-wrap">
|
||||||
<table class="trade-table">
|
<table class="trade-table" id="recommend-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>昨日收盘</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>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -144,9 +155,23 @@
|
|||||||
</span>
|
</span>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r.gap_label and r.gap_label != '—' %}
|
||||||
|
<span class="badge gap-badge {% if r.gap == 'up' %}profit{% elif r.gap == 'down' %}loss{% else %}planned{% endif %}"{% if r.gap_pct %} title="跳空 {{ '%+.2f'|format(r.gap_pct) }}%"{% endif %}>{{ r.gap_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.prev_close is not none %}{{ r.prev_close }}{% else %}—{% endif %}</td>
|
||||||
<td>{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}</td>
|
<td>{% if r.today_open is not none %}{{ r.today_open }}{% else %}—{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if r.yesterday_change is not none %}
|
||||||
|
<span class="{% if r.yesterday_change > 0 %}rec-change-up{% elif r.yesterday_change < 0 %}rec-change-down{% endif %}">
|
||||||
|
{{ '%+.4f'|format(r.yesterday_change) }}{% if r.yesterday_change_pct is not none %} ({{ '%+.2f'|format(r.yesterday_change_pct) }}%){% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</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.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} <span class="text-muted">(柜台)</span>{% endif %}{% 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.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>
|
<td>{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}</td>
|
||||||
@@ -154,7 +179,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="10" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
<tr><td colspan="14" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user