feat: 月差模块独立+阴影带分位数法+季节性历史折线

- 新增 spread_from_db.py: 从MySQL动态计算月差,含标准月差对/主次月差对/历史同期序列
- 阴影带计算改为分位数+K缩放法: mid=Q50, low=Q(trim), high=Q(1-trim), widthScale=K/3
  上下沿各自取自数据分布,天然跟随季节性方向
- 月差图支持历史同期折线: 同一月份组合的多条历史合约对序列独立绘制
- 月差图滚动平均平滑(smooth_window=5): 消除价差固有日度抖动,不影响评分
- OI筛选仅用于确定面板展示的月差对,历史序列不过滤持仓
- 前端支持K值/trim实时调节,scoreFromCurrentBand动态计算得分
This commit is contained in:
2026-06-14 16:47:40 +08:00
parent 2c2ebd4c34
commit 94a230e485
3 changed files with 961 additions and 110 deletions
+210 -61
View File
@@ -12,22 +12,30 @@ from typing import Any
import numpy as np
import pandas as pd
try:
from spread_from_db import read_spread_from_db_batch, build_spread_seasonal_data, NAME_TO_PCODE as SPREAD_NAME_TO_PCODE
_HAS_SPREAD_DB = True
except ImportError:
_HAS_SPREAD_DB = False
BASE_DIR = Path(__file__).resolve().parent
CONFIRM_FILE = BASE_DIR / "基本面指标确认.xlsx"
WIND_FILE = BASE_DIR / "wind数据汇总.xlsx"
STEEL_FILE = BASE_DIR / "钢联数据汇总.xlsx"
SPREAD_FILE = BASE_DIR / "月差拼接数据.xlsx"
OUTPUT_FILE = BASE_DIR / "基本面评分面板.html"
DIMENSIONS = ["利润", "产量", "库存", "需求"]
DIMENSIONS = ["利润", "产量", "库存", "需求", "月差"]
INVERSE_DIMS = {"库存"}
DIM_COLORS = {
"利润": "#2563eb",
"产量": "#dc2626",
"库存": "#1d4ed8",
"需求": "#0f9f6e",
"月差": "#64748b",
"月差": "#9333ea",
}
DEFAULT_BAND_WINDOW = 15
DEFAULT_BAND_K = 3.0
DEFAULT_BAND_TRIM = 0.10
@@ -178,15 +186,15 @@ def seasonal_band_sample(history: pd.Series, day: int, window: int = DEFAULT_BAN
def band_score(latest_value: float, history: pd.Series, day: int, inverse: bool = False) -> dict[str, Any] | None:
sample, used_window = seasonal_band_sample(history, day)
sample = trimmed_values(sample)
sample = sample.sort_values()
if len(sample) < 4:
return None
mean = float(sample.mean())
std = float(sample.std(ddof=0))
if not math.isfinite(std):
std = 0.0
low = mean - DEFAULT_BAND_K * std
high = mean + DEFAULT_BAND_K * std
mid = float(sample.quantile(0.5))
q_low = float(sample.quantile(DEFAULT_BAND_TRIM))
q_high = float(sample.quantile(1 - DEFAULT_BAND_TRIM))
width_scale = DEFAULT_BAND_K / 3
low = mid - (mid - q_low) * width_scale
high = mid + (q_high - mid) * width_scale
if not math.isfinite(low) or not math.isfinite(high):
return None
if high == low:
@@ -202,7 +210,7 @@ def band_score(latest_value: float, history: pd.Series, day: int, inverse: bool
"score": max(0.0, min(100.0, score)),
"band_low": low,
"band_high": high,
"band_mean": mean,
"band_mean": mid,
"band_window": used_window,
"band_k": DEFAULT_BAND_K,
"band_trim": DEFAULT_BAND_TRIM,
@@ -292,15 +300,18 @@ def build_score_history(dim_series: dict[str, pd.Series]) -> list[dict[str, Any]
scores[dim] = score
if not scores:
continue
dims_with_demand = [d for d in DIMENSIONS if d in scores]
dims_with_spread = [d for d in DIMENSIONS if d in scores]
dims_without_demand = [d for d in DIMENSIONS if d != "需求" and d in scores]
total = float(np.mean([scores[d] for d in dims_with_demand])) if dims_with_demand else None
dims_no_demand_no_spread = [d for d in DIMENSIONS if d not in ("需求", "月差") and d in scores]
total = float(np.mean([scores[d] for d in dims_with_spread])) if dims_with_spread else None
total_no_demand = float(np.mean([scores[d] for d in dims_without_demand])) if dims_without_demand else None
total_no_demand_no_spread = float(np.mean([scores[d] for d in dims_no_demand_no_spread])) if dims_no_demand_no_spread else None
history.append(
{
"date": date.strftime("%Y-%m-%d"),
"total": round(total, 1) if total is not None else None,
"totalNoDemand": round(total_no_demand, 1) if total_no_demand is not None else None,
"totalNoDemandNoSpread": round(total_no_demand_no_spread, 1) if total_no_demand_no_spread is not None else None,
}
)
return history
@@ -312,6 +323,12 @@ def make_records() -> dict[str, Any]:
metrics.extend(read_metrics(STEEL_FILE, "钢联", 1, 2, 5, 10, "钢联"))
metric_index, metric_names = build_metric_index(metrics)
confirm = pd.read_excel(CONFIRM_FILE).fillna("")
# 从 MySQL 批量加载所有品种月差数据
if _HAS_SPREAD_DB:
all_variety_names = [str(n).strip() for n in confirm["品种"] if str(n).strip()]
spread_db = read_spread_from_db_batch(all_variety_names)
else:
spread_db = {}
commodities: list[dict[str, Any]] = []
for idx, row in confirm.iterrows():
name = str(row["品种"]).strip()
@@ -320,7 +337,7 @@ def make_records() -> dict[str, Any]:
dims: dict[str, Any] = {}
dim_series: dict[str, pd.Series] = {}
valid_scores: list[float] = []
for dim in DIMENSIONS:
for dim in ["利润", "产量", "库存", "需求"]:
series, missing, used_metrics = eval_expression(row.get(dim, ""), metric_index, metric_names)
if series is not None:
dim_series[dim] = series
@@ -351,6 +368,54 @@ def make_records() -> dict[str, Any]:
"missing": missing,
"metrics": [{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq} for m in used_metrics],
}
# 月差维度:从 MySQL contract_day 动态计算
dim = "月差"
spread_info = spread_db.get(name, {})
spread_series_db = spread_info.get("series")
if spread_series_db is not None and not spread_series_db.empty:
dim_series[dim] = spread_series_db
# 转换为 seasonal_percentile 兼容格式
stat = build_spread_seasonal_data(spread_series_db)
else:
stat = None
if stat:
latest_dt = pd.Timestamp(stat["latest_date"])
history = spread_series_db[spread_series_db.index < latest_dt].dropna()
band = band_score(stat["latest_value"], history, int(latest_dt.dayofyear), dim in INVERSE_DIMS)
if band:
score = band["score"]
stat.update(band)
else:
raw = stat["raw_percentile"] if stat.get("raw_percentile") is not None else 50.0
score = 100.0 - raw if dim in INVERSE_DIMS else raw
stat["score"] = round(score, 1)
stat["direction"] = "高于正常区间更优(近月升水=强势)"
stat["expr"] = "近月(top3 OI) - 远月(最远>1万手)"
# 月差对详情
pair_scores = spread_info.get("pair_scores", [])
stat["pair_scores"] = pair_scores
pair_desc = " / ".join(
f"{ps['pair_label']}:{ps.get('score', '--')}" for ps in pair_scores
) if pair_scores else ""
stat["metrics"] = [{
"name": f"月差对({len(pair_scores)}对)",
"source": "MySQL contract_day",
"unit": "",
"freq": "",
"detail": pair_desc,
}]
valid_scores.append(float(score))
dims[dim] = stat or {
"score": None,
"raw_percentile": None,
"direction": "缺数据" if not _HAS_SPREAD_DB else "月差数据未找到",
"expr": "近月(top3 OI) - 远月(最远>1万手)",
"missing": [] if _HAS_SPREAD_DB else ["spread_from_db 模块不可用"],
"metrics": [],
}
total = round(float(np.mean(valid_scores)), 1) if valid_scores else None
boards = commodity_boards(name)
commodities.append(
@@ -381,8 +446,9 @@ def make_records() -> dict[str, Any]:
"wind": WIND_FILE.name,
"steel": STEEL_FILE.name,
"confirm": CONFIRM_FILE.name,
"spread_source": "MySQL contract_day (spread_from_db.py)",
},
"dimensions": DIMENSIONS + ["月差"],
"dimensions": DIMENSIONS,
"boards": sorted(set(board for c in commodities for board in c.get("boards", [c["board"]]))),
"completion": {
"count": completion_count,
@@ -424,7 +490,7 @@ button {{ cursor:pointer; font-weight:700; color:#475467; }}
.kpi-number {{ font-size:42px; line-height:1; font-weight:900; color:#111827; }}
.hint {{ color:var(--muted); font-size:12px; line-height:1.55; }}
.chips {{ display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; }}
.chip {{ border:1px solid #4c84ff; color:#1d4ed8; border-radius:4px; padding:6px 8px; min-width:160px; display:flex; justify-content:space-between; font-size:13px; font-weight:700; background:#f8fbff; }}
.chip {{ border:1px solid #4c84ff; color:#1d4ed8; border-radius:4px; padding:3px 7px; min-width:0; display:flex; justify-content:space-between; align-items:center; font-size:12px; font-weight:600; background:#f8fbff; white-space:nowrap; }}
.chip.red {{ border-color:#ef4444; color:#dc2626; background:#fffafa; }}
.chip.good {{ border-color:#22c55e; color:#047857; background:#f6fffa; }}
.note {{ padding:4px 14px 10px; color:#64748b; font-size:12px; }}
@@ -449,7 +515,7 @@ button {{ cursor:pointer; font-weight:700; color:#475467; }}
.commodity:first-child {{ border-top:0; }}
.side {{ display:flex; align-items:center; justify-content:center; writing-mode:vertical-rl; font-weight:900; color:#12356b; background:#fbfdff; border-right:1px solid var(--line); letter-spacing:2px; }}
.body {{ min-width:0; }}
.scoreline {{ display:grid; grid-template-columns:160px repeat(5, minmax(130px, 1fr)); align-items:stretch; gap:8px; padding:8px 10px; border-bottom:1px solid var(--line); background:#fbfcff; }}
.scoreline {{ display:grid; grid-template-columns:160px repeat(5, minmax(120px, 1fr)); align-items:stretch; gap:8px; padding:8px 10px; border-bottom:1px solid var(--line); background:#fbfcff; }}
.total {{ display:flex; align-items:center; gap:8px; font-weight:900; color:var(--blue); white-space:nowrap; }}
.total .num {{ font-size:28px; }}
.dimbox {{ background:white; border-left:4px solid var(--blue); padding:5px 8px; min-height:42px; }}
@@ -458,9 +524,11 @@ button {{ cursor:pointer; font-weight:700; color:#475467; }}
.dimscore {{ font-size:13px; font-weight:900; color:#1d4ed8; }}
.dimhint {{ font-size:12px; color:#667085; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }}
.detail {{ padding:0 10px 8px; color:#667085; font-size:12px; }}
.charts {{ display:grid; grid-template-columns:repeat(4, minmax(260px,1fr)); }}
.charts {{ display:grid; grid-template-columns:repeat(4, minmax(220px,1fr)); }}
.spread-charts {{ display:grid; grid-template-columns:repeat(3, minmax(260px,1fr)); border-top:2px solid #9333ea; }}
.chartcell {{ min-width:0; border-top:1px solid var(--line); border-right:1px solid var(--line); padding:8px 10px 10px; }}
.chartcell:nth-child(4n) {{ border-right:0; }}
.charts .chartcell:nth-child(4n) {{ border-right:0; }}
.spread-charts .chartcell:nth-child(3n) {{ border-right:0; }}
.chart-title {{ text-align:center; font-size:18px; color:#475467; margin:2px 0 4px; }}
.metric-title {{ color:#667085; font-size:12px; min-height:32px; line-height:1.35; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }}
svg {{ width:100%; height:230px; display:block; }}
@@ -473,6 +541,7 @@ svg {{ width:100%; height:230px; display:block; }}
.summary {{ grid-template-columns:1fr; }}
.scoreline {{ grid-template-columns:1fr 1fr 1fr; }}
.charts {{ grid-template-columns:1fr 1fr; }}
.spread-charts {{ grid-template-columns:1fr 1fr; }}
input {{ width:220px; }}
}}
@media (max-width: 760px) {{
@@ -490,10 +559,11 @@ svg {{ width:100%; height:230px; display:block; }}
<label class="ctrl">维度 <select id="dim"><option>全部</option></select></label>
<label class="ctrl">板块 <select id="board"><option>全部</option></select></label>
<label class="ctrl">窗口 <input class="mini" id="bandWindow" type="number" min="3" max="90" step="1" value="15"></label>
<label class="ctrl">带宽 <input class="mini" id="bandK" type="number" min="0.5" max="6" step="0.5" value="3"></label>
<label class="ctrl">带宽K <input class="mini" id="bandK" type="number" min="0.5" max="12" step="0.5" value="3"></label>
<label class="ctrl">去尾 <input class="mini" id="bandTrim" type="number" min="0" max="40" step="5" value="10"></label>
<label class="ctrl">图始 <select id="chartStartYear"></select></label>
<label class="ctrl"><input id="includeDemand" type="checkbox" checked>需求计分</label>
<label class="ctrl"><input id="includeSpread" type="checkbox" checked>月差计分</label>
<button type="button" class="tabbtn active" id="dashboardTab">面板</button>
<button type="button" class="tabbtn" id="historyTab">评分走势</button>
<button type="button" id="refreshData">刷新数据</button>
@@ -504,7 +574,7 @@ svg {{ width:100%; height:230px; display:block; }}
<div class="card">
<div class="kpi-title">基本面总分</div>
<div class="kpi-number" id="avgScore">--</div>
<div class="hint" id="coverage">当前筛选覆盖 -- 个有可用数据的品种;月差当前暂无映射,未计入总分。</div>
<div class="hint" id="coverage">当前筛选覆盖 -- 个有可用数据的品种</div>
</div>
<div class="card">
<div class="kpi-title">基本面较强</div>
@@ -515,7 +585,7 @@ svg {{ width:100%; height:230px; display:block; }}
<div class="chips" id="weak"></div>
</div>
</section>
<div class="note">维度得分按当前阴影带范围计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。顶部阴影带参数同步影响维度分数和总分。</div>
<div class="note">维度得分按当前阴影带范围计算(均值±K×标准差):正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。调整顶部「带宽K」可实时改变阴影带宽度和得分。</div>
<main class="rows" id="rows"></main>
<section class="history-view" id="historyView">
<div class="history-tools">
@@ -586,11 +656,11 @@ function majorityHistoryEndDate() {{
}}
historyEnd.value = majorityHistoryEndDate();
let activeView = "dashboard";
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; chartStartYear.value="2021"; includeDemand.checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; chartStartYear.value="2021"; includeDemand.checked=true; document.getElementById("includeSpread").checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
dashboardTab.onclick = () => {{ activeView="dashboard"; dashboardTab.classList.add("active"); historyTab.classList.remove("active"); render(); }};
historyTab.onclick = () => {{ activeView="history"; historyTab.classList.add("active"); dashboardTab.classList.remove("active"); render(); }};
refreshData.onclick = refreshFromLocalProgram;
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = chartStartYear.onchange = includeDemand.onchange = historyStart.onchange = historyEnd.onchange = render;
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = chartStartYear.onchange = includeDemand.onchange = document.getElementById("includeSpread").onchange = historyStart.onchange = historyEnd.onchange = render;
[bandWindow, bandK, bandTrim].forEach(input => {{
input.addEventListener("keydown", ev => {{
if (ev.key === "Enter") {{
@@ -608,12 +678,12 @@ function matches(c) {{
const dim = dimSel.value;
const board = boardSel.value;
if (board !== "全部" && !(c.boards || [c.board]).includes(board)) return false;
if (dim !== "全部" && dim !== "月差" && !c.dims[dim]?.score && c.dims[dim]?.score !== 0) return false;
if (dim !== "全部" && !c.dims[dim]?.score && c.dims[dim]?.score !== 0) return false;
if (!q) return true;
const hay = [c.name, c.board, ...Object.values(c.dims).map(d => [d.expr, ...(d.metrics||[]).map(m=>m.name)].join(" "))].join(" ").toLowerCase();
return hay.includes(q);
}}
function activeDims() {{ return includeDemand.checked ? ["利润","产量","库存","需求"] : ["利润","产量","库存"]; }}
function activeDims() {{ const base = ["利润","产量","库存"]; if (includeDemand.checked) base.push("需求"); if (document.getElementById("includeSpread").checked) base.push("月差"); return base; }}
function currentDimScore(dim, v) {{
const dynamic = scoreFromCurrentBand(v, inverseDims.has(dim));
if (dynamic != null && Number.isFinite(dynamic)) return dynamic;
@@ -638,17 +708,21 @@ function rankedList(list) {{
scored.forEach((c,i)=>c.displayRank=i+1);
return scored.concat(mapped.filter(c => c.total == null));
}}
function topChips(list, reverse=false) {{
return list.slice().sort((a,b)=> reverse ? a.total-b.total : b.total-a.total).slice(0,6).map(c => `<div class="chip ${{reverse?"red":"good"}}"><span>${{c.displayRank||"-"}}. ${{esc(c.name)}}</span><span>${{fmt(c.total)}}</span></div>`).join("");
function topChips(list, cls) {{
return list.map(c => `<div class="chip ${{cls}}"><span>${{esc(c.name)}}</span><span style="margin-left:8px;">${{fmt(c.total)}}</span></div>`).join("");
}}
function esc(s) {{ return String(s ?? "").replace(/[&<>"']/g, m => ({{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}}[m])); }}
function render() {{
const list = rankedList(DATA.commodities.filter(matches));
const scored = list.filter(c => c.total != null);
document.getElementById("avgScore").textContent = scored.length ? fmt(scored.reduce((s,c)=>s+c.total,0)/scored.length) : "--";
document.getElementById("coverage").textContent = `当前筛选覆盖 ${{scored.length}} 个有可用数据的品种;${{includeDemand.checked ? "需求参与总分" : "需求不参与总分"}}月差当前暂无映射。`;
document.getElementById("strong").innerHTML = topChips(scored, false) || `<span class="hint">暂无可用数据</span>`;
document.getElementById("weak").innerHTML = topChips(scored, true) || `<span class="hint">暂无可用数据</span>`;
document.getElementById("coverage").textContent = `当前筛选覆盖 ${{scored.length}} 个有可用数据的品种;${{includeDemand.checked ? "需求参与总分" : "需求不参与总分"}}${{document.getElementById("includeSpread").checked ? "月差参与总分" : "月差不参与总分"}}。`;
const sortedAll = scored.slice().sort((a,b)=>b.total-a.total);
const splitIdx = Math.ceil(sortedAll.length / 2);
const strongList = sortedAll.slice(0, splitIdx);
const weakList = sortedAll.slice(splitIdx).reverse();
document.getElementById("strong").innerHTML = strongList.length ? topChips(strongList, "red") : `<span class="hint">暂无可用数据</span>`;
document.getElementById("weak").innerHTML = weakList.length ? topChips(weakList, "good") : `<span class="hint">暂无可用数据</span>`;
rowsEl.style.display = activeView === "dashboard" ? "block" : "none";
historyView.classList.toggle("active", activeView === "history");
rowsEl.innerHTML = list.length ? list.map(renderCommodity).join("") : `<div class="empty">没有匹配的品种</div>`;
@@ -662,15 +736,26 @@ function render() {{
if (activeView === "history") drawHistoryChart(list.filter(c => c.total != null));
}}
function renderCommodity(c) {{
const dimCards = [...Object.entries(c.dims).map(([d,v]) => renderDim(d,v)), renderMonthSpread()].join("");
const chartDims = dimSel.value === "全部" || dimSel.value === "月差" ? ["利润","产量","库存","需求"] : [dimSel.value];
const charts = chartDims.filter(d => d !== "月差").map(d => renderChartCell(c, d, c.dims[d])).join("");
const dimCards = [...Object.entries(c.dims).map(([d,v]) => renderDim(d,v))].join("");
const chartDims = dimSel.value === "全部" ? ["利润","产量","库存","需求"] : (dimSel.value === "月差" ? [] : [dimSel.value]);
const charts = chartDims.map(d => renderChartCell(c, d, c.dims[d])).join("");
const showSpread = dimSel.value === "全部" || dimSel.value === "月差";
let spreadCharts = "";
if (showSpread) {{
const ps = c.dims["月差"]?.pair_scores;
if (ps && ps.length) {{
spreadCharts = ps.filter(p => p.years).map((p, i) => renderPairChartCell(c, p, i)).join("");
}} else if (c.dims["月差"]?.years) {{
spreadCharts = renderChartCell(c, "月差", c.dims["月差"]);
}}
}}
return `<section class="commodity">
<div class="side">${{esc(c.name)}}</div>
<div class="body">
<div class="scoreline"><div class="total"><span>#${{c.displayRank||"-"}}</span><span class="num">${{fmt(c.total)}}</span><span>总分</span></div>${{dimCards}}</div>
<div class="detail">季节性口径:最新值相对同期正常区间;库存反向,其余正向。有效维度:${{c.valid}}/${{c.denom}} 板块:${{esc(c.board)}}</div>
<div class="charts">${{charts}}</div>
${{charts ? `<div class="charts">${{charts}}</div>` : ""}}
${{spreadCharts ? `<div class="spread-charts">${{spreadCharts}}</div>` : ""}}
</div>
</section>`;
}}
@@ -682,9 +767,6 @@ function renderDim(dim, v) {{
<div class="dimhint">${{esc(ok ? v.direction : "缺数据/未配置")}}</div>
</div>`;
}}
function renderMonthSpread() {{
return `<div class="dimbox bad" style="border-left-color:#94a3b8"><div class="dimname"><span>月差</span><span class="dimscore">--</span></div><div class="dimhint">未配置</div></div>`;
}}
function renderChartCell(c, dim, v) {{
const title = `${{esc(c.name)}}${{esc(dim)}}`;
if (!v || !v.years) return `<div class="chartcell"><div class="chart-title">${{title}}</div><div class="metric-title">${{esc(v?.expr || "未配置")}}</div><div class="empty">缺少可绘制数据</div></div>`;
@@ -695,6 +777,20 @@ function renderChartCell(c, dim, v) {{
<svg data-chart='${{esc(JSON.stringify(v.years))}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
</div>`;
}}
function renderPairChartCell(c, ps, idx) {{
const _pm = s => {{ const m = s.match(/\\d+$/); return m ? (parseInt(m[0]) % 100) : s; }};
const title = `${{esc(c.name)}}${{ps.pair_label.split(/\\s*-\\s*/).map(_pm).join("-")}}月差`;
const xStart = ps.x_start || "";
const xEnd = ps.x_end || "";
const yl = ps.year_labels || {{}};
const legendLabel = y => yl[y] || y;
return `<div class="chartcell">
<div class="chart-title">${{title}}</div>
<div class="metric-title">${{esc(ps.pair_label)}} 得分:${{fmt(ps.score)}}</div>
<div class="legend">${{Object.keys(ps.years).map(y=>`<span style="--c:${{yearColor(y, ps.years)}}">${{esc(legendLabel(y))}}</span>`).join("")}}</div>
<svg data-chart='${{esc(JSON.stringify(ps.years))}}' data-x-start='${{esc(xStart)}}' data-x-end='${{esc(xEnd)}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
</div>`;
}}
function parseDay(md) {{
const [m,d] = md.split("-").map(Number);
const date = new Date(2024, m-1, d);
@@ -704,7 +800,7 @@ function parseDay(md) {{
function chartParams() {{
return {{
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
k: Math.max(0.5, Math.min(12, Number(bandK.value) || 3)),
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
startYear: Number(chartStartYear.value) || 2021,
excludeYear: maxChartYear,
@@ -787,18 +883,17 @@ function buildRawBand(years, rawPoints=null) {{
const params = chartParams();
const points = rawPoints || rawPointsFromYears(years);
const bandUniverse = points.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
const trendLike = trendLikeAnnualLevel(bandUniverse);
const band = [];
for (let day=1; day<=366; day++) {{
const samples = robustAnnualSeasonValues(bandUniverse.filter(p => circularDiff(p.day, day) <= params.window), !trendLike);
let samples = bandUniverse.filter(p => circularDiff(p.day, day) <= params.window).map(p => p.value).filter(Number.isFinite);
if (samples.length < 3) continue;
const tail = Math.max(0.02, Math.min(0.45, params.trim || 0.10));
const mid = quantile(samples, 0.50);
let low = quantile(samples, tail);
let high = quantile(samples, 1 - tail);
const widthScale = Math.max(0.25, Math.min(2.5, params.k / 3));
low = mid - (mid - low) * widthScale;
high = mid + (high - mid) * widthScale;
samples.sort((a,b)=>a-b);
const mid = quantile(samples, 0.5);
const qLow = quantile(samples, params.trim);
const qHigh = quantile(samples, 1 - params.trim);
const widthScale = params.k / 3;
const low = mid - (mid - qLow) * widthScale;
const high = mid + (qHigh - mid) * widthScale;
const date = new Date(2024, 0, day);
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
band.push([md, low, high, mid]);
@@ -871,39 +966,85 @@ function buildNormalizedChart(years) {{
return {{years:normYears, band}};
}}
function drawChart(svg, years) {{
const xStartStr = svg.dataset.xStart || "";
const xEndStr = svg.dataset.xEnd || "";
const useCustomX = xStartStr && xEndStr;
const normalized = buildNormalizedChart(years);
years = normalized.years;
const band = normalized.band;
let chartYears = normalized.years;
let chartBand = normalized.band;
const W=520,H=230,L=44,R=8,T=16,B=30;
const bandValues = band.flatMap(p => [p[1], p[2]]).filter(Number.isFinite);
const values = Object.values(years).flat().map(p=>p[1]).filter(Number.isFinite).concat(bandValues);
// 自定义横坐标映射
let xStartDay, xEndDay, totalSpan, x;
if (useCustomX) {{
xStartDay = parseDay(xStartStr);
xEndDay = parseDay(xEndStr);
if (xStartDay <= xEndDay) totalSpan = xEndDay - xStartDay;
else totalSpan = (366 - xStartDay) + xEndDay;
if (totalSpan <= 0) totalSpan = 365;
x = d => {{ const day = parseDay(d); let off; if (day >= xStartDay) off = day - xStartDay; else off = (366 - xStartDay) + day; return L + off / totalSpan * (W-L-R); }};
}} else {{
x = d=> L + (parseDay(d)-1)/365*(W-L-R);
}}
// 自定义横坐标时,过滤超出生命周期范围的数据
if (useCustomX) {{
const visMin = L - 2, visMax = W - R + 2;
chartBand = chartBand.filter(p => x(p[0]) >= visMin && x(p[0]) <= visMax);
const filtYears = {{}};
Object.entries(chartYears).forEach(([yr, pts]) => {{
const f = pts.filter(p => x(p[0]) >= visMin && x(p[0]) <= visMax);
if (f.length) filtYears[yr] = f;
}});
chartYears = filtYears;
}}
// y轴范围
const bandValues = chartBand.flatMap(p => [p[1], p[2]]).filter(Number.isFinite);
const values = Object.values(chartYears).flat().map(p=>p[1]).filter(Number.isFinite).concat(bandValues);
if (!values.length) return;
let min=Math.min(...values), max=Math.max(...values);
if (min===max) {{ min-=1; max+=1; }}
const pad=(max-min)*0.12; min-=pad; max+=pad;
const x=d=> L + (parseDay(d)-1)/365*(W-L-R);
const y=v=> T + (max-v)/(max-min)*(H-T-B);
let out = "";
for (let i=0;i<=4;i++) {{
const yy=T+i*(H-T-B)/4, val=max-i*(max-min)/4;
out += `<line class="grid" x1="${{L}}" y1="${{yy}}" x2="${{W-R}}" y2="${{yy}}"></line><text x="4" y="${{yy+4}}" font-size="12" fill="#667085">${{val.toFixed(1)}}</text>`;
}}
for (const [label, day] of [["1/1",1],["4/1",92],["7/1",183],["10/1",275]]) {{
const xx=L+(day-1)/365*(W-L-R);
out += `<line class="grid" x1="${{xx}}" y1="${{T}}" x2="${{xx}}" y2="${{H-B}}"></line><text x="${{xx-10}}" y="${{H-9}}" font-size="12" fill="#667085">${{label}}</text>`;
// x轴刻度
if (useCustomX) {{
const tickMonths = [];
const sm = parseInt(xStartStr.split("-")[0]);
const em = parseInt(xEndStr.split("-")[0]);
let m = sm;
while (true) {{
tickMonths.push(m);
if (m === em) break;
m = m % 12 + 1;
if (tickMonths.length >= 12) break;
}}
for (const m of tickMonths) {{
const md = `${{String(m).padStart(2,"0")}}-15`;
const xx = x(md);
const label = `${{m}}月`;
out += `<line class="grid" x1="${{xx.toFixed(1)}}" y1="${{T}}" x2="${{xx.toFixed(1)}}" y2="${{H-B}}"></line><text x="${{(xx-10).toFixed(1)}}" y="${{H-9}}" font-size="12" fill="#667085">${{label}}</text>`;
}}
}} else {{
for (const [label, day] of [["1/1",1],["4/1",92],["7/1",183],["10/1",275]]) {{
const xx=L+(day-1)/365*(W-L-R);
out += `<line class="grid" x1="${{xx}}" y1="${{T}}" x2="${{xx}}" y2="${{H-B}}"></line><text x="${{xx-10}}" y="${{H-9}}" font-size="12" fill="#667085">${{label}}</text>`;
}}
}}
out += `<line class="axis" x1="${{L}}" y1="${{H-B}}" x2="${{W-R}}" y2="${{H-B}}"></line><line class="axis" x1="${{L}}" y1="${{T}}" x2="${{L}}" y2="${{H-B}}"></line>`;
if (band.length) {{
const sortedBand = band.slice().sort((a,b)=>parseDay(a[0])-parseDay(b[0]));
if (chartBand.length) {{
const sortedBand = chartBand.slice().sort((a,b)=>x(a[0])-x(b[0]));
const upper = sortedBand.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[2]).toFixed(1)}}`).join(" ");
const lower = sortedBand.slice().reverse().map(p=>`L${{x(p[0]).toFixed(1)}},${{y(p[1]).toFixed(1)}}`).join(" ");
out += `<path d="${{upper}} ${{lower}} Z" fill="#94a3b8" opacity="0.18"></path>`;
const mid = sortedBand.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[3]).toFixed(1)}}`).join(" ");
out += `<path d="${{mid}}" fill="none" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="4 4" opacity="0.7" vector-effect="non-scaling-stroke"></path>`;
}}
Object.entries(years).forEach(([year, pts]) => {{
const color=yearColor(year, years);
const sorted=pts.slice().sort((a,b)=>parseDay(a[0])-parseDay(b[0]));
Object.entries(chartYears).forEach(([year, pts]) => {{
const color=yearColor(year, chartYears);
const sorted=pts.slice().sort((a,b)=>x(a[0])-x(b[0]));
const d=sorted.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(p[1]).toFixed(1)}}`).join(" ");
out += `<path d="${{d}}" fill="none" stroke="${{color}}" stroke-width="2.2" vector-effect="non-scaling-stroke"></path>`;
}});
@@ -929,10 +1070,18 @@ function drawHistoryChart(list) {{
const start = historyStart.value || "2026-01-01";
const end = historyEnd.value || todayString();
const useDemand = includeDemand.checked;
const useSpread = document.getElementById("includeSpread").checked;
const series = list.map(c => {{
const pts = (c.scoreHistory || [])
.filter(p => p.date >= start && p.date <= end)
.map(p => [p.date, useDemand ? p.total : p.totalNoDemand])
.map(p => {{
let val;
if (useDemand && useSpread) val = p.total;
else if (useDemand && !useSpread) val = p.totalNoDemandNoSpread != null ? (p.totalNoDemand != null ? p.totalNoDemand : p.total) : p.totalNoDemand;
else if (!useDemand && useSpread) val = p.totalNoDemand;
else val = p.totalNoDemandNoSpread != null ? p.totalNoDemandNoSpread : p.totalNoDemand;
return [p.date, val];
}})
.filter(p => p[1] != null && Number.isFinite(Number(p[1])));
return {{name:c.name, points:pts}};
}}).filter(s => s.points.length >= 2);