From 8a1918fc8d72d0caf1c0abd729795b0e94158275 Mon Sep 17 00:00:00 2001 From: Lxy Date: Sat, 20 Jun 2026 11:42:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=8D=E7=9B=98?= =?UTF-8?q?=E8=AE=A1=E5=88=92bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/analysis_db.py | 25 +++ app/analysis_models.py | 4 + app/services/plan_generator.py | 44 +++++- app/static/futures_analysis.html | 101 ++++++++++++- app/static/futures_analysis.js | 224 +++++++++++++++++++++++---- app/static/review_plan.css | 236 +++++++++++++++++++++++++++++ app/static/review_plan.html | 71 ++++++++- app/static/review_plan.js | 251 +++++++++++++++++++------------ data/buffer.db | Bin 3354624 -> 3354624 bytes data/futures_analysis.db | Bin 7913472 -> 8151040 bytes 10 files changed, 824 insertions(+), 132 deletions(-) diff --git a/app/analysis_db.py b/app/analysis_db.py index f530dfc..50b2af6 100644 --- a/app/analysis_db.py +++ b/app/analysis_db.py @@ -42,3 +42,28 @@ def init_analysis_db(): SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2, ) AnalysisBase.metadata.create_all(bind=analysis_engine) + + # 迁移:为已有表添加新增列(SQLite 不支持 IF NOT EXISTS,用异常处理) + _migrate_add_columns() + + +def _migrate_add_columns(): + """为已有表添加新增列(兼容旧数据库)""" + import sqlite3 + conn = sqlite3.connect(str(ANALYSIS_DB_PATH)) + cursor = conn.cursor() + + migrations = [ + ("symbol_scores_v2", "data_date", "VARCHAR(16)"), + ("review_plans_v2", "actual_data_date", "VARCHAR(16)"), + ("review_plans_v2", "data_date_matches", "INTEGER"), + ] + + for table, column, col_type in migrations: + try: + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") + except sqlite3.OperationalError: + pass # 列已存在 + + conn.commit() + conn.close() diff --git a/app/analysis_models.py b/app/analysis_models.py index 1e3b834..e3609ab 100644 --- a/app/analysis_models.py +++ b/app/analysis_models.py @@ -200,6 +200,8 @@ class SymbolScoreV2(AnalysisBase): s2 = Column(Float, nullable=True, comment="支撑位2") # 排名 rank = Column(Integer, nullable=True, comment="综合排名") + # 实际数据日期 + data_date = Column(String(16), nullable=True, comment="实际数据日期 YYYY-MM-DD(日线最后一根K线的日期)") def __repr__(self): return f"" @@ -267,6 +269,8 @@ class ReviewPlanV2(AnalysisBase): neutral_count = Column(Integer, nullable=True, comment="震荡品种数") opportunity_count = Column(Integer, nullable=True, comment="交易机会数") risk_warnings = Column(JSON, nullable=True, comment="风险提示列表") + actual_data_date = Column(String(16), nullable=True, comment="实际数据日期 YYYY-MM-DD") + data_date_matches = Column(Integer, nullable=True, comment="数据日期是否与复盘日期一致 1=是 0=否") created_at = Column(DateTime, nullable=False, default=datetime.now) def __repr__(self): diff --git a/app/services/plan_generator.py b/app/services/plan_generator.py index 6a87d18..55be07b 100644 --- a/app/services/plan_generator.py +++ b/app/services/plan_generator.py @@ -3,6 +3,7 @@ V2 交易计划生成器 - 5维度综合评分 + 多周期共振分析 """ import json import logging +from collections import Counter from pathlib import Path from typing import Dict, List, Optional, Tuple from datetime import datetime @@ -54,6 +55,21 @@ def load_symbols_config() -> Dict[str, str]: return data.get("futures", {}) +def _get_candle_date(candle: dict) -> Optional[str]: + """从K线数据中提取日期字符串 (YYYY-MM-DD)""" + dt_str = candle.get("datetime") or candle.get("time") + if not dt_str: + return None + try: + if isinstance(dt_str, str): + dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00")) + else: + dt = dt_str + return dt.strftime("%Y-%m-%d") + except Exception: + return None + + def _get_candle_values(candles: List[dict]) -> Tuple[list, list, list, list, list]: """从K线列表提取 OHLCV""" opens, highs, lows, closes, volumes = [], [], [], [], [] @@ -322,6 +338,9 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict: prev_close = d_closes[-2] if len(d_closes) >= 2 else today_close today_volume = d_volumes[-1] + # 提取实际数据日期(日线最后一根K线的日期) + data_date = _get_candle_date(daily[-1]) + # 近5日均量 recent_vols = d_volumes[-6:-1] if len(d_volumes) >= 6 else d_volumes[:-1] avg_vol_5 = sum(recent_vols) / len(recent_vols) if recent_vols else today_volume @@ -364,6 +383,7 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict: raw_scores.append({ "symbol": symbol, "name": name, + "data_date": data_date, "close_price": today_close, "prev_close": prev_close, "high_price": today_high, @@ -488,6 +508,12 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict: bear_count = sum(1 for s in raw_scores if s["trend_score"] < 30) neutral_count = len(raw_scores) - bull_count - bear_count + # 统计实际数据日期(取出现最多的日期作为基准数据日期) + date_counts = Counter(s.get("data_date") for s in raw_scores if s.get("data_date")) + actual_data_date = date_counts.most_common(1)[0][0] if date_counts else review_date_str + # 判断数据日期是否与复盘日期一致 + data_date_matches = (actual_data_date == review_date_str) + # 核心结论 top3 = raw_scores[:3] top_names = [f"{s['symbol']} {s['name']}" for s in top3] @@ -516,10 +542,18 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict: f"量比超2.5倍,需确认量能可持续性" ) + # 构建数据基准描述 + if data_date_matches: + data_basis = f"{actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析" + else: + data_basis = f"复盘日期: {review_date_str} | 数据截至: {actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析" + return { "review_date": review_date_str, "week_day": week_day, - "data_basis": f"{review_date_str} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析", + "actual_data_date": actual_data_date, + "data_date_matches": data_date_matches, + "data_basis": data_basis, "core_conclusion": core_conclusion, "bull_count": bull_count, "bear_count": bear_count, @@ -617,6 +651,8 @@ def save_plan_to_db(db: Session, plan_data: dict) -> int: existing.neutral_count = plan_data.get("neutral_count") existing.opportunity_count = plan_data.get("opportunity_count") existing.risk_warnings = plan_data.get("risk_warnings") + existing.actual_data_date = plan_data.get("actual_data_date") + existing.data_date_matches = 1 if plan_data.get("data_date_matches") else 0 else: review_plan = ReviewPlanV2( review_date=review_date_str, @@ -628,6 +664,8 @@ def save_plan_to_db(db: Session, plan_data: dict) -> int: neutral_count=plan_data.get("neutral_count"), opportunity_count=plan_data.get("opportunity_count"), risk_warnings=plan_data.get("risk_warnings"), + actual_data_date=plan_data.get("actual_data_date"), + data_date_matches=1 if plan_data.get("data_date_matches") else 0, ) db.add(review_plan) db.flush() @@ -667,6 +705,7 @@ def save_plan_to_db(db: Session, plan_data: dict) -> int: s1=pivots.get("s1"), s2=pivots.get("s2"), rank=s.get("rank"), + data_date=s.get("data_date"), ) db.add(score_record) @@ -736,6 +775,8 @@ def get_plan_data(db: Session, review_plan_id: int) -> Optional[dict]: "review_date": plan_meta.review_date, "week_day": plan_meta.week_day, "data_basis": plan_meta.data_basis, + "actual_data_date": plan_meta.actual_data_date, + "data_date_matches": bool(plan_meta.data_date_matches), "core_conclusion": plan_meta.core_conclusion, "bull_count": plan_meta.bull_count, "bear_count": plan_meta.bear_count, @@ -755,6 +796,7 @@ def _score_to_dict(s: SymbolScoreV2) -> dict: "id": s.id, "symbol": s.symbol, "name": s.name, + "data_date": s.data_date, "close_price": s.close_price, "prev_close": s.prev_close, "high_price": s.high_price, diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html index 6770c41..0fa493a 100644 --- a/app/static/futures_analysis.html +++ b/app/static/futures_analysis.html @@ -410,6 +410,45 @@ .rv-tag-bear { background: rgba(255,59,48,0.12); color: var(--color-up); } .rv-tag-neutral { background: #F5F5F7; color: var(--text-tertiary); } + /* 列表表格样式(重点关注/规避) */ + .rv-list-table { width: 100%; border-collapse: collapse; font-size: 13px; } + .rv-list-table th { background: #F5F5F7; padding: 12px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; } + .rv-list-table td { padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); } + .rv-list-table tbody tr { cursor: pointer; transition: background 0.15s; } + .rv-yellow-table tbody tr:hover { background: rgba(255,149,0,0.05); } + .rv-red-table tbody tr:hover { background: rgba(255,59,48,0.05); } + + /* 排序控件 */ + .rv-sort-controls { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .rv-sort-btn { background: #fff; color: var(--text-secondary); border: 1px solid rgba(0,0,0,0.08); border-radius: 9999px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s; } + .rv-sort-btn:hover { background: #F5F5F7; border-color: var(--color-brand); } + .rv-sort-btn.active { background: var(--color-brand); color: #fff; border-color: var(--color-brand); } + + /* 弹窗样式 */ + .rv-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(8px); z-index: 2000; display: none; align-items: center; justify-content: center; } + .rv-modal-overlay.show { display: flex; } + .rv-modal { background: #fff; border-radius: 20px; width: 90%; max-width: 700px; max-height: 85vh; overflow-y: auto; box-shadow: 0 25px 50px rgba(0,0,0,0.25); animation: rv-modal-in 0.25s ease; } + @keyframes rv-modal-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } + .rv-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); } + .rv-modal-title { font-size: 18px; font-weight: 700; color: var(--text-primary); } + .rv-modal-close { width: 32px; height: 32px; border-radius: 50%; border: none; background: #F5F5F7; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); transition: all 0.2s; } + .rv-modal-close:hover { background: #E5E5E7; } + .rv-modal-body { padding: 20px 24px; } + .rv-modal-section { margin-bottom: 20px; } + .rv-modal-section-title { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; } + .rv-modal-metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } + .rv-modal-metric { background: #F5F5F7; border-radius: 10px; padding: 12px; text-align: center; } + .rv-modal-metric .mm-label { font-size: 11px; color: var(--text-tertiary); display: block; margin-bottom: 4px; } + .rv-modal-metric .mm-val { font-size: 16px; font-weight: 700; color: var(--text-primary); } + .rv-modal-plan-box { background: #F5F5F7; border-radius: 12px; padding: 14px; } + .rv-modal-plan-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; } + .rv-modal-plan-row .mp-label { color: var(--text-tertiary); } + .rv-modal-plan-row .mp-val { font-weight: 600; color: var(--text-primary); } + .rv-modal-plan-row .mp-val.stop { color: var(--color-up); } + .rv-modal-plan-row .mp-val.target { color: var(--color-down); } + .rv-modal-pivots { display: flex; flex-wrap: wrap; gap: 8px; } + .rv-data-date-tag { display: inline-block; font-size: 10px; color: var(--text-tertiary); background: #F5F5F7; padding: 2px 6px; border-radius: 4px; margin-left: 4px; } + .rv-ranking-table { width: 100%; border-collapse: collapse; font-size: 13px; } .rv-ranking-table th { background: #F5F5F7; padding: 12px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; text-transform: uppercase; } .rv-ranking-table td { padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); } @@ -744,18 +783,61 @@ @@ -101,8 +118,25 @@

规避品种

-
- +
+ + + + + + + + + + + + + + + + + +
#品种收盘价涨跌幅综合评分振幅%量比方向数据日期
@@ -110,6 +144,13 @@

全品种排名

+
+ 排序: + + + + +
@@ -119,10 +160,13 @@ - - + + + - + + + @@ -166,6 +210,19 @@ + + + diff --git a/app/static/review_plan.js b/app/static/review_plan.js index 6d38cc2..5d38d35 100644 --- a/app/static/review_plan.js +++ b/app/static/review_plan.js @@ -26,6 +26,8 @@ function setupEventListeners() { document.getElementById('btn-clear').addEventListener('click', function () { handleClear(); }); + + setupSortButtons(); } // ==================== 生成复盘 ==================== @@ -191,11 +193,18 @@ function renderHero(meta) { const el = document.getElementById('hero-section'); if (!el) return; + // 当数据日期与复盘日期不一致时,显示提示 + let dataDateNotice = ''; + if (meta.actual_data_date && !meta.data_date_matches) { + dataDateNotice = `
⚠ 当前页面收盘价、涨跌幅为 ${meta.actual_data_date}(最近交易日)数据,非 ${meta.review_date} 当日数据
`; + } + el.innerHTML = `

${meta.review_date} ${meta.week_day || ''}

${meta.data_basis || ''}
+ ${dataDateNotice}
${meta.core_conclusion || ''}
`; showSection('hero-section'); @@ -264,8 +273,11 @@ function renderGreenSection(plans, scores) { const el = document.getElementById('green-section'); if (!el) return; + const grid = document.getElementById('green-grid'); + if (!grid) return; + if (!plans || plans.length === 0) { - el.innerHTML = `
🟢 交易机会(绿色区)
暂无交易机会
`; + grid.innerHTML = `
暂无交易机会
`; showSection('green-section'); return; } @@ -277,16 +289,17 @@ function renderGreenSection(plans, scores) { const s = scoreMap[plan.symbol] || {}; const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short'; const dirText = plan.direction === 'long' ? '做多' : '做空'; + const dataDateTag = s.data_date ? `${s.data_date}` : ''; return ` -
+
- ${plan.symbol} ${plan.name || ''} + ${plan.symbol} ${plan.name || ''} ${plan.composite_score}
方向${dirText}
-
收盘价${formatPrice(s.close_price)}
+
收盘价${formatPrice(s.close_price)} ${dataDateTag}
涨跌幅${formatChange(s.change_pct)}
量比${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}
@@ -306,142 +319,164 @@ function renderGreenSection(plans, scores) {
`; }).join(''); - el.innerHTML = ` -
🟢 交易机会(绿色区)
-
${cards}
- `; + grid.innerHTML = cards; showSection('green-section'); } // ==================== 黄色关注区 ==================== function renderYellowSection(scores) { - const el = document.getElementById('yellow-section'); - if (!el) return; + const tbody = document.getElementById('yellow-list-body'); + if (!tbody) return; const yellowItems = (scores || []).filter(s => s.category === 'yellow'); if (yellowItems.length === 0) { - el.innerHTML = `
🟡 关注列表(黄色区)
暂无关注品种
`; + tbody.innerHTML = `
`; showSection('yellow-section'); return; } - const items = yellowItems.map(s => ` -
-
- ${s.symbol} ${s.name || ''} - ${directionTag(s.direction_tag)} -
-
- ${scoreBar(s.composite_score)} -
-
- ${s.composite_score} - ${formatChange(s.change_pct)} -
-
+ tbody.innerHTML = yellowItems.map((s, i) => ` + + + + + + + + + + + `).join(''); - el.innerHTML = ` -
🟡 关注列表(黄色区)
-
${items}
- `; showSection('yellow-section'); } // ==================== 红色风险区 ==================== function renderRedSection(scores) { - const el = document.getElementById('red-section'); - if (!el) return; + const tbody = document.getElementById('red-list-body'); + if (!tbody) return; const redItems = (scores || []).filter(s => s.category === 'red'); if (redItems.length === 0) { - el.innerHTML = `
🔴 规避列表(红色区)
暂无规避品种
`; + tbody.innerHTML = ``; showSection('red-section'); return; } - const items = redItems.map(s => ` -
-
- ${s.symbol} ${s.name || ''} - ${directionTag(s.direction_tag)} -
-
- ${scoreBar(s.composite_score)} -
-
- ${s.composite_score} - ${formatChange(s.change_pct)} -
-
+ tbody.innerHTML = redItems.map((s, i) => ` + + + + + + + + + + + `).join(''); - el.innerHTML = ` -
🔴 规避列表(红色区)
-
${items}
- `; showSection('red-section'); } -// ==================== 排名表格 ==================== +// ==================== 排名表格(支持多列排序) ==================== + +let currentSortField = 'composite_score'; +let currentSortOrder = 'desc'; function renderRankingTable(scores) { - const el = document.getElementById('ranking-table'); - if (!el) return; + const table = document.getElementById('ranking-table'); + if (!table) return; + + const tbody = table.querySelector('tbody'); + if (!tbody) return; if (!scores || scores.length === 0) { - el.innerHTML = `
📊 综合排名
暂无排名数据
`; - showSection('ranking-table'); + tbody.innerHTML = ``; + showSection('table-section'); return; } - const rows = scores.map((s, i) => { - const rank = s.rank || (i + 1); + // 排序 + const sorted = [...scores].sort((a, b) => { + let va = a[currentSortField]; + let vb = b[currentSortField]; + + // 方向排序特殊处理 + if (currentSortField === 'direction_tag') { + const dirOrder = { '强多': 1, '偏多': 2, '多头共振': 1, '震荡': 3, '偏空': 4, '强空': 5, '空头共振': 5 }; + va = dirOrder[va] || 3; + vb = dirOrder[vb] || 3; + } + + if (va == null) va = -Infinity; + if (vb == null) vb = -Infinity; + + if (currentSortOrder === 'asc') return va > vb ? 1 : va < vb ? -1 : 0; + return va < vb ? 1 : va > vb ? -1 : 0; + }); + + tbody.innerHTML = sorted.map((s, i) => { + const rank = i + 1; const rankClass = rank <= 3 ? 'rank-top' : ''; return ` - + - - + + - + `; }).join(''); - el.innerHTML = ` -
📊 综合排名
-
-
品种 最新价 涨跌幅成交量持仓量综合评分振幅%量比 趋势信号方向活跃度数据日期
暂无关注品种
${i + 1}${s.symbol} ${s.name || ''}${formatPrice(s.close_price)}${formatChange(s.change_pct)}
${scoreBar(s.composite_score)}${s.composite_score}
${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}${directionTag(s.direction_tag)}${s.data_date || '-'}
暂无规避品种
${i + 1}${s.symbol} ${s.name || ''}${formatPrice(s.close_price)}${formatChange(s.change_pct)}
${scoreBar(s.composite_score)}${s.composite_score}
${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}${directionTag(s.direction_tag)}${s.data_date || '-'}
暂无排名数据
${rank}${s.symbol}${s.name || ''}${s.symbol} ${s.name || ''} ${formatPrice(s.close_price)}${formatChange(s.change_pct)}
${scoreBar(s.composite_score)}${s.composite_score}
${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}${formatChange(s.change_pct)} ${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'} ${s.trend_score != null ? s.trend_score : '-'} ${directionTag(s.direction_tag)} ${s.activity_score != null ? s.activity_score : '-'}${s.data_date || '-'}
- - - - - - - - - - - - - - - - ${rows} -
#代码名称收盘价综合评分振幅%涨跌幅量比趋势方向活跃度
-
- `; - showSection('ranking-table'); + showSection('table-section'); +} + +function setupSortButtons() { + const controls = document.getElementById('sort-controls'); + if (!controls) return; + + controls.addEventListener('click', function (e) { + const btn = e.target.closest('.sort-btn'); + if (!btn) return; + + const field = btn.dataset.sort; + const defaultOrder = btn.dataset.order || 'desc'; + + // 切换排序方向 + if (currentSortField === field) { + currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc'; + } else { + currentSortField = field; + currentSortOrder = defaultOrder; + } + + // 更新按钮状态 + controls.querySelectorAll('.sort-btn').forEach(b => { + b.classList.remove('active'); + b.textContent = b.textContent.replace(/ [▲▼]$/, ''); + }); + btn.classList.add('active'); + const arrow = currentSortOrder === 'desc' ? ' ▼' : ' ▲'; + btn.textContent = btn.textContent + arrow; + + // 重新渲染 + if (currentPlanData) { + renderRankingTable(currentPlanData.scores); + } + }); } // ==================== 品种详情卡片 ==================== @@ -471,7 +506,7 @@ function renderDetails(scores) { ${s.composite_score}
-
收盘价${formatPrice(s.close_price)}
+
收盘价${formatPrice(s.close_price)}${s.data_date ? ' ' + s.data_date + '' : ''}
振幅${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}
涨跌幅${formatChange(s.change_pct)}
量比${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}
@@ -543,20 +578,51 @@ function renderSectors(sectors) { function showDetail(symbol, name) { const scores = currentPlanData ? currentPlanData.scores : []; + const plans = currentPlanData ? currentPlanData.plans : []; const s = scores.find(x => x.symbol === symbol); if (!s) return; + // 查找对应的交易计划 + const plan = plans.find(p => p.symbol === symbol); + const modal = document.getElementById('detail-modal'); if (!modal) return; document.getElementById('modal-title').textContent = `${symbol} ${name || s.name || ''}`; const body = document.getElementById('modal-body'); + + // 交易计划部分(仅绿色品种有) + let planHtml = ''; + if (plan) { + const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short'; + const dirText = plan.direction === 'long' ? '做多' : '做空'; + planHtml = ` + `; + } + + const pivotTags = []; + if (s.pivot != null) pivotTags.push(`Pivot ${formatPrice(s.pivot)}`); + if (s.r1 != null) pivotTags.push(`R1 ${formatPrice(s.r1)}`); + if (s.r2 != null) pivotTags.push(`R2 ${formatPrice(s.r2)}`); + if (s.s1 != null) pivotTags.push(`S1 ${formatPrice(s.s1)}`); + if (s.s2 != null) pivotTags.push(`S2 ${formatPrice(s.s2)}`); + body.innerHTML = ` + ${planHtml}