Files
jibenmian-dashboard/generate_fundamental_dashboard.py
T

993 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import html
import json
import math
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
BASE_DIR = Path(__file__).resolve().parent
CONFIRM_FILE = BASE_DIR / "基本面指标确认.xlsx"
WIND_FILE = BASE_DIR / "wind数据汇总.xlsx"
STEEL_FILE = BASE_DIR / "钢联数据汇总.xlsx"
OUTPUT_FILE = BASE_DIR / "基本面评分面板.html"
DIMENSIONS = ["利润", "产量", "库存", "需求"]
INVERSE_DIMS = {"库存"}
DIM_COLORS = {
"利润": "#2563eb",
"产量": "#dc2626",
"库存": "#1d4ed8",
"需求": "#0f9f6e",
"月差": "#64748b",
}
DEFAULT_BAND_WINDOW = 15
DEFAULT_BAND_K = 3.0
DEFAULT_BAND_TRIM = 0.10
HISTORY_START_DEFAULT = "2026-01-01"
BOARD_MAP = {
"黑色": ["焦煤", "焦炭", "铁矿", "螺纹", "热卷", "硅铁", "锰硅", "不锈钢"],
"建材": ["玻璃", "纯碱", "PVC", "原木"],
"有色": ["沪铜", "沪铝", "沪锌", "沪铅", "沪镍", "沪锡", "氧化铝", "铝合金", "工业硅", "多晶硅", "碳酸锂", "国际铜"],
"贵金属": ["沪金", "沪银", "", ""],
"能源": ["原油", "液化气", "燃油", "LU燃油", "沥青", "动力煤", "欧线"],
"煤化工": ["尿素", "甲醇"],
"油化工": ["塑料", "聚丙烯", "丙烯", "纯苯", "苯乙烯", "对二甲苯", "PTA", "乙二醇", "短纤", "瓶片", "烧碱", "丁二烯"],
"农产品": ["玉米", "淀粉", "豆一", "豆二", "豆粕", "豆油", "菜籽油", "菜籽粕", "棕榈油", "鸡蛋", "生猪", "棉花", "白糖", "苹果", "红枣", "花生", "棉纱"],
"软商品": ["纸浆", "双胶纸", "橡胶", "20号胶", "BR橡胶"],
"金融": ["IF", "IC", "IM", "IH", "二年债", "五年债", "十年债", "三十年债"],
}
@dataclass
class Metric:
name: str
source: str
unit: str
freq: str
series: pd.Series
def clean_name(value: Any) -> str:
if pd.isna(value):
return ""
text = str(value).strip()
text = text.replace("", "(").replace("", ")")
text = text.replace("", ";").replace("", ",")
return re.sub(r"\s+", "", text)
def read_metrics(path: Path, sheet: str, name_row: int, unit_row: int, freq_row: int, data_row: int, source: str) -> list[Metric]:
raw = pd.read_excel(path, sheet_name=sheet, header=None)
dates = pd.to_datetime(raw.iloc[data_row:, 0], errors="coerce")
metrics: list[Metric] = []
for col in range(1, raw.shape[1]):
name = clean_name(raw.iat[name_row, col])
if not name:
continue
values = pd.to_numeric(raw.iloc[data_row:, col], errors="coerce")
frame = pd.DataFrame({"date": dates, "value": values}).dropna(subset=["date", "value"])
if frame.empty:
continue
series = frame.drop_duplicates("date").set_index("date")["value"].sort_index()
if series.empty:
continue
metrics.append(
Metric(
name=name,
source=source,
unit=str(raw.iat[unit_row, col]) if pd.notna(raw.iat[unit_row, col]) else "",
freq=str(raw.iat[freq_row, col]) if pd.notna(raw.iat[freq_row, col]) else "",
series=series,
)
)
return metrics
def build_metric_index(metrics: list[Metric]) -> tuple[dict[str, Metric], list[str]]:
grouped: dict[str, list[Metric]] = {}
for metric in metrics:
grouped.setdefault(metric.name, []).append(metric)
selected: dict[str, Metric] = {}
for name, items in grouped.items():
selected[name] = max(items, key=lambda m: (m.series.index.max(), m.series.notna().sum()))
return selected, sorted(selected.keys(), key=len, reverse=True)
def normalize_expr(expr: Any) -> str:
if pd.isna(expr):
return ""
text = str(expr).strip()
text = text.replace("", "(").replace("", ")")
text = text.replace("", ";").replace("", ",")
text = re.sub(r"\s+", "", text)
text = re.sub(r"(?i)\bSum\s*\(", "(", text)
text = text.replace(";", "+")
return text
def parse_expression(expr: Any, metric_index: dict[str, Metric], metric_names: list[str]) -> tuple[str, dict[str, Metric], list[str]]:
text = normalize_expr(expr)
if not text:
return "", {}, []
used: dict[str, Metric] = {}
token_expr = text
for idx, name in enumerate(metric_names):
if name and name in token_expr:
token = f"m{idx}"
token_expr = token_expr.replace(name, token)
used[token] = metric_index[name]
leftovers = re.sub(r"m\d+", " ", token_expr)
leftovers = re.sub(r"[0-9eE\.\+\-\*/\(\)\s,]+", " ", leftovers)
missing = [x.strip() for x in re.split(r"\s+", leftovers) if x.strip()]
return token_expr, used, missing
def eval_expression(expr: Any, metric_index: dict[str, Metric], metric_names: list[str]) -> tuple[pd.Series | None, list[str], list[Metric]]:
token_expr, used, missing = parse_expression(expr, metric_index, metric_names)
if not token_expr or not used:
return None, missing, []
local_env = {token: metric.series for token, metric in used.items()}
try:
value = eval(token_expr, {"__builtins__": {}}, local_env)
except Exception:
return None, missing or [token_expr], list(used.values())
if isinstance(value, (int, float, np.number)):
return None, missing, list(used.values())
series = pd.to_numeric(value, errors="coerce").dropna().sort_index()
return (series if not series.empty else None), missing, list(used.values())
def circular_day_diff(day_values: np.ndarray, target_day: int) -> np.ndarray:
diff = np.abs(day_values - target_day)
return np.minimum(diff, 366 - diff)
def trimmed_values(values: pd.Series, trim: float = DEFAULT_BAND_TRIM) -> pd.Series:
values = pd.to_numeric(values, errors="coerce").dropna().sort_values()
if values.empty or trim <= 0:
return values
cut = int(len(values) * trim)
if len(values) - cut * 2 < 4:
return values
return values.iloc[cut : len(values) - cut]
def seasonal_band_sample(history: pd.Series, day: int, window: int = DEFAULT_BAND_WINDOW) -> tuple[pd.Series, int]:
history = pd.to_numeric(history, errors="coerce").dropna().sort_index()
if history.empty:
return history, window
days = history.index.dayofyear.to_numpy()
sample = history[circular_day_diff(days, day) <= window]
used_window = window
if len(sample) < 8:
sample = history[circular_day_diff(days, day) <= 30]
used_window = 30
if len(sample) < 8:
sample = history
used_window = 366
return sample, used_window
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)
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
if not math.isfinite(low) or not math.isfinite(high):
return None
if high == low:
raw_score = 100.0 if latest_value >= high else 0.0
elif latest_value >= high:
raw_score = 100.0
elif latest_value <= low:
raw_score = 0.0
else:
raw_score = 100.0 * (latest_value - low) / (high - low)
score = 100.0 - raw_score if inverse else raw_score
return {
"score": max(0.0, min(100.0, score)),
"band_low": low,
"band_high": high,
"band_mean": mean,
"band_window": used_window,
"band_k": DEFAULT_BAND_K,
"band_trim": DEFAULT_BAND_TRIM,
}
def seasonal_percentile(series: pd.Series) -> dict[str, Any] | None:
series = pd.to_numeric(series, errors="coerce").dropna().sort_index()
if series.empty:
return None
latest_date = series.index.max()
latest_value = float(series.loc[latest_date])
history = series[series.index < latest_date].dropna()
if history.empty:
return None
day = int(latest_date.dayofyear)
day_diff = np.abs(history.index.dayofyear - day)
sample = history[day_diff <= 15]
window = 15
if len(sample) < 8:
sample = history[day_diff <= 30]
window = 30
if len(sample) < 8:
sample = history
window = 366
less = float((sample < latest_value).sum())
equal = float((sample == latest_value).sum())
raw_pct = 100.0 * (less + 0.5 * equal) / len(sample)
latest_year = int(latest_date.year)
yearly: dict[str, list[tuple[str, float]]] = {}
clipped = series[series.index.year >= 2021]
for dt, val in clipped.items():
yearly.setdefault(str(int(dt.year)), []).append((dt.strftime("%m-%d"), float(val)))
return {
"latest_date": latest_date.strftime("%Y-%m-%d"),
"latest_value": latest_value,
"raw_percentile": round(raw_pct, 1),
"sample_size": int(len(sample)),
"window": window,
"years": yearly,
"chart_transform": "linear_z_score_in_browser",
}
def commodity_boards(name: str) -> list[str]:
boards = [board for board, names in BOARD_MAP.items() if name in names]
return boards or ["其他"]
def score_series_at(series: pd.Series, date: pd.Timestamp, inverse: bool = False) -> float | None:
series = pd.to_numeric(series, errors="coerce").dropna().sort_index()
available = series[series.index <= date]
if available.empty:
return None
value_date = available.index.max()
value = float(available.loc[value_date])
history = series[series.index < value_date].dropna()
if history.empty:
return None
band = band_score(value, history, int(value_date.dayofyear), inverse)
if band:
return float(band["score"])
sample, _ = seasonal_band_sample(history, int(value_date.dayofyear))
if sample.empty:
return None
less = float((sample < value).sum())
equal = float((sample == value).sum())
raw = 100.0 * (less + 0.5 * equal) / len(sample)
return 100.0 - raw if inverse else raw
def build_score_history(dim_series: dict[str, pd.Series]) -> list[dict[str, Any]]:
available = [s.dropna().sort_index() for s in dim_series.values() if s is not None and not s.dropna().empty]
if not available:
return []
latest = max(s.index.max() for s in available)
start = pd.Timestamp(HISTORY_START_DEFAULT)
if latest < start:
start = min(s.index.min() for s in available)
dates = pd.date_range(start=start, end=latest, freq="D")
history: list[dict[str, Any]] = []
for date in dates:
scores: dict[str, float] = {}
for dim, series in dim_series.items():
score = score_series_at(series, date, dim in INVERSE_DIMS)
if score is not None and math.isfinite(score):
scores[dim] = score
if not scores:
continue
dims_with_demand = [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
total_no_demand = float(np.mean([scores[d] for d in dims_without_demand])) if dims_without_demand 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,
}
)
return history
def make_records() -> dict[str, Any]:
metrics = []
metrics.extend(read_metrics(WIND_FILE, "wind", 1, 3, 2, 8, "Wind"))
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("")
commodities: list[dict[str, Any]] = []
for idx, row in confirm.iterrows():
name = str(row["品种"]).strip()
if not name:
continue
dims: dict[str, Any] = {}
dim_series: dict[str, pd.Series] = {}
valid_scores: list[float] = []
for dim in DIMENSIONS:
series, missing, used_metrics = eval_expression(row.get(dim, ""), metric_index, metric_names)
if series is not None:
dim_series[dim] = series
stat = seasonal_percentile(series) if series is not None else None
if stat:
latest_dt = pd.Timestamp(stat["latest_date"])
history = series[series.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"]
score = 100.0 - raw if dim in INVERSE_DIMS else raw
stat["score"] = round(score, 1)
stat["direction"] = "低库存更优,按正常区间反向计分" if dim in INVERSE_DIMS else "高于正常区间更优"
stat["expr"] = str(row.get(dim, "")).strip()
stat["metrics"] = [
{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq}
for m in used_metrics
]
valid_scores.append(float(score))
dims[dim] = stat or {
"score": None,
"raw_percentile": None,
"direction": "缺数据",
"expr": str(row.get(dim, "")).strip(),
"missing": missing,
"metrics": [{"name": m.name, "source": m.source, "unit": m.unit, "freq": m.freq} for m in used_metrics],
}
total = round(float(np.mean(valid_scores)), 1) if valid_scores else None
boards = commodity_boards(name)
commodities.append(
{
"rank": None,
"name": name,
"board": " / ".join(boards),
"boards": boards,
"total": total,
"valid_count": len(valid_scores),
"dims": dims,
"scoreHistory": build_score_history(dim_series),
}
)
ranked = sorted([c for c in commodities if c["total"] is not None], key=lambda x: x["total"], reverse=True)
for i, item in enumerate(ranked, 1):
item["rank"] = i
no_score = [c for c in commodities if c["total"] is None]
commodities = ranked + no_score
completion_total = len(commodities)
completion_count = sum(
all(c["dims"].get(dim, {}).get("years") for dim in DIMENSIONS)
for c in commodities
)
return {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"source_files": {
"wind": WIND_FILE.name,
"steel": STEEL_FILE.name,
"confirm": CONFIRM_FILE.name,
},
"dimensions": DIMENSIONS + ["月差"],
"boards": sorted(set(board for c in commodities for board in c.get("boards", [c["board"]]))),
"completion": {
"count": completion_count,
"total": completion_total,
"ratio": round(100.0 * completion_count / completion_total, 1) if completion_total else 0,
},
"commodities": commodities,
}
def html_template(data: dict[str, Any]) -> str:
payload = json.dumps(data, ensure_ascii=False, allow_nan=False)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>基本面评分面板</title>
<style>
:root {{
--yellow:#fff200; --blue:#1d4ed8; --red:#dc2626; --green:#059669;
--line:#d8dee8; --text:#172033; --muted:#667085; --bg:#f5f7fb;
}}
* {{ box-sizing: border-box; }}
body {{ margin:0; font-family: "Microsoft YaHei", Arial, sans-serif; color:var(--text); background:var(--bg); }}
.topbar {{ position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:12px; padding:7px 14px; background:var(--yellow); border-bottom:1px solid #d4c900; box-shadow:0 1px 4px rgba(0,0,0,.12); }}
.brand {{ font-weight:800; margin-right:auto; }}
.ctrl {{ display:flex; align-items:center; gap:6px; font-size:14px; white-space:nowrap; }}
input, select, button {{ height:32px; border:1px solid #b9c3d4; border-radius:4px; background:white; padding:0 10px; font-size:14px; }}
input {{ width:340px; }}
input.mini {{ width:64px; }}
input[type="checkbox"] {{ width:16px; height:16px; padding:0; }}
button {{ cursor:pointer; font-weight:700; color:#475467; }}
.tabbtn.active {{ color:#1d4ed8; border-color:#1d4ed8; }}
.stamp {{ margin-left:auto; font-size:13px; white-space:nowrap; }}
.summary {{ display:grid; grid-template-columns:280px 1fr 1fr; gap:12px; padding:12px 14px 4px; }}
.card {{ background:white; border:1px solid var(--line); border-radius:7px; padding:12px; box-shadow:0 1px 5px rgba(16,24,40,.05); }}
.kpi-title {{ font-weight:800; color:#344054; margin-bottom:5px; }}
.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.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; }}
.rows {{ margin:0 14px 24px; background:white; border:1px solid var(--line); }}
.history-view {{ display:none; margin:0 14px 24px; background:white; border:1px solid var(--line); padding:12px; }}
.history-view.active {{ display:block; }}
.history-tools {{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px; }}
.history-tools input {{ width:150px; }}
.history-legend {{ display:flex; flex-wrap:wrap; gap:5px 10px; max-height:92px; overflow:auto; padding:6px 0 8px; border-top:1px solid var(--line); }}
.history-legend-item {{ display:inline-flex; align-items:center; gap:4px; min-width:70px; font-size:12px; color:#475467; cursor:pointer; user-select:none; }}
.history-legend-item::before {{ content:""; width:18px; height:2px; background:var(--c); display:inline-block; }}
.history-legend-item.active {{ font-weight:900; color:#111827; }}
.history-chart-wrap {{ position:relative; border-top:1px solid var(--line); }}
#historySvg {{ height:620px; }}
.history-line {{ cursor:pointer; transition:opacity .12s ease, stroke-width .12s ease; }}
.history-tooltip {{ position:absolute; display:none; min-width:260px; max-width:760px; padding:8px 10px; border:1px solid #cfd6e0; border-radius:6px; background:rgba(255,255,255,.96); box-shadow:0 8px 22px rgba(15,23,42,.15); font-size:12px; color:#344054; pointer-events:none; z-index:3; }}
.history-tooltip .date {{ font-weight:900; color:#111827; margin-bottom:5px; }}
.history-tooltip .grid {{ display:grid; grid-template-columns:repeat(3, minmax(150px, 1fr)); gap:1px 12px; }}
.history-tooltip .row {{ display:flex; justify-content:space-between; gap:12px; line-height:1.45; white-space:nowrap; }}
.history-tooltip .name {{ color:var(--c); font-weight:700; }}
.commodity {{ display:grid; grid-template-columns:48px minmax(0,1fr); border-top:1px solid var(--line); }}
.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; }}
.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; }}
.dimbox.bad {{ opacity:.65; border-left-color:#94a3b8; }}
.dimname {{ font-size:13px; color:#475467; font-weight:800; display:flex; justify-content:space-between; }}
.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)); }}
.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; }}
.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; }}
.axis {{ stroke:#cfd6e0; stroke-width:1; }}
.grid {{ stroke:#e5e7eb; stroke-width:1; }}
.legend {{ display:flex; flex-wrap:wrap; justify-content:center; gap:8px; min-height:21px; font-size:12px; color:#475467; }}
.legend span::before {{ content:""; display:inline-block; width:18px; height:2px; margin-right:4px; vertical-align:middle; background:var(--c); }}
.empty {{ padding:24px; text-align:center; color:#667085; }}
@media (max-width: 1200px) {{
.summary {{ grid-template-columns:1fr; }}
.scoreline {{ grid-template-columns:1fr 1fr 1fr; }}
.charts {{ grid-template-columns:1fr 1fr; }}
input {{ width:220px; }}
}}
@media (max-width: 760px) {{
.topbar {{ flex-wrap:wrap; }}
.stamp {{ margin-left:0; }}
.scoreline {{ grid-template-columns:1fr; }}
.charts {{ grid-template-columns:1fr; }}
.commodity {{ grid-template-columns:34px minmax(0,1fr); }}
}}
</style>
</head>
<body>
<div class="topbar">
<label class="ctrl">品种 <input id="search" placeholder="搜索品种或指标"></label>
<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">去尾 <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>
<button type="button" class="tabbtn active" id="dashboardTab">面板</button>
<button type="button" class="tabbtn" id="historyTab">评分走势</button>
<button type="button" id="refreshData">刷新数据</button>
<button type="button" id="reset">重置</button>
<div class="stamp">完成度:<span id="completion"></span></div>
</div>
<section class="summary">
<div class="card">
<div class="kpi-title">基本面总分</div>
<div class="kpi-number" id="avgScore">--</div>
<div class="hint" id="coverage">当前筛选覆盖 -- 个有可用数据的品种;月差当前暂无映射,未计入总分。</div>
</div>
<div class="card">
<div class="kpi-title">基本面较强</div>
<div class="chips" id="strong"></div>
</div>
<div class="card">
<div class="kpi-title">基本面较弱</div>
<div class="chips" id="weak"></div>
</div>
</section>
<div class="note">维度得分按同期正常波动区间计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。阴影带参数只影响图表展示,总分使用默认 ±15 天、3 倍标准差、去尾 10%。</div>
<main class="rows" id="rows"></main>
<section class="history-view" id="historyView">
<div class="history-tools">
<strong>各品种基本面得分变化</strong>
<label class="ctrl">开始 <input id="historyStart" type="date" value="2026-01-01"></label>
<label class="ctrl">结束 <input id="historyEnd" type="date"></label>
</div>
<div class="history-legend" id="historyLegend"></div>
<div class="history-chart-wrap" id="historyChartWrap">
<svg id="historySvg" viewBox="0 0 1600 620" preserveAspectRatio="none"></svg>
<div class="history-tooltip" id="historyTooltip"></div>
</div>
</section>
<script>
const DATA = {payload};
const OLDER_COLORS = ["#1976d2","#65a30d","#a21caf","#9ca3af","#0f9f9f","#374151","#7c3aed"];
function yearColor(year, years) {{
const ordered = Object.keys(years).map(Number).sort((a,b)=>b-a);
const rank = ordered.indexOf(Number(year));
if (rank === 0) return "#ef4444";
if (rank === 1) return "#f97316";
return OLDER_COLORS[(rank - 2 + OLDER_COLORS.length) % OLDER_COLORS.length];
}}
const dimColors = {json.dumps(DIM_COLORS, ensure_ascii=False)};
const dims = DATA.dimensions;
const search = document.getElementById("search");
const dimSel = document.getElementById("dim");
const boardSel = document.getElementById("board");
const bandWindow = document.getElementById("bandWindow");
const bandK = document.getElementById("bandK");
const bandTrim = document.getElementById("bandTrim");
const chartStartYear = document.getElementById("chartStartYear");
const includeDemand = document.getElementById("includeDemand");
const dashboardTab = document.getElementById("dashboardTab");
const historyTab = document.getElementById("historyTab");
const rowsEl = document.getElementById("rows");
const historyView = document.getElementById("historyView");
const historyStart = document.getElementById("historyStart");
const historyEnd = document.getElementById("historyEnd");
const historyLegend = document.getElementById("historyLegend");
const historyTooltip = document.getElementById("historyTooltip");
const historyChartWrap = document.getElementById("historyChartWrap");
const refreshData = document.getElementById("refreshData");
for (const d of dims) dimSel.append(new Option(d, d));
for (const b of DATA.boards) boardSel.append(new Option(b, b));
const maxChartYear = new Date(DATA.generated_at.slice(0,10)).getFullYear();
for (let y=2018; y<=maxChartYear; y++) chartStartYear.append(new Option(String(y), String(y)));
chartStartYear.value = chartStartYear.querySelector('option[value="2021"]') ? "2021" : chartStartYear.options[0]?.value;
document.getElementById("completion").textContent = `${{DATA.completion.count}}/${{DATA.completion.total}}${{DATA.completion.ratio.toFixed(1)}}%`;
function todayString() {{
const d = new Date();
return `${{d.getFullYear()}}-${{String(d.getMonth()+1).padStart(2,"0")}}-${{String(d.getDate()).padStart(2,"0")}}`;
}}
function majorityHistoryEndDate() {{
const counts = new Map();
for (const c of DATA.commodities) {{
const last = (c.scoreHistory || []).map(p => p.date).sort().at(-1);
if (last) counts.set(last, (counts.get(last) || 0) + 1);
}}
let best = "", bestCount = -1;
for (const [date, count] of counts) {{
if (count > bestCount || (count === bestCount && date > best)) {{
best = date; bestCount = count;
}}
}}
return best || todayString();
}}
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(); }};
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;
[bandWindow, bandK, bandTrim].forEach(input => {{
input.addEventListener("keydown", ev => {{
if (ev.key === "Enter") {{
ev.preventDefault();
ev.stopPropagation();
input.blur();
render();
}}
}});
}});
function fmt(v, n=1) {{ return v == null || Number.isNaN(v) ? "--" : Number(v).toFixed(n); }}
function matches(c) {{
const q = search.value.trim().toLowerCase();
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 (!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 computeTotal(c) {{
const keys = activeDims();
const values = keys.map(d => c.dims[d]?.score).filter(v => v != null && Number.isFinite(Number(v))).map(Number);
return {{total: values.length ? values.reduce((s,v)=>s+v,0)/values.length : null, valid: values.length, denom: keys.length}};
}}
function rankedList(list) {{
const mapped = list.map(c => Object.assign({{}}, c, computeTotal(c)));
const scored = mapped.filter(c => c.total != null).sort((a,b)=>b.total-a.total);
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 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>`;
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>`;
for (const cell of document.querySelectorAll("[data-chart]")) {{
try {{
drawChart(cell, JSON.parse(cell.dataset.chart));
}} catch (err) {{
cell.innerHTML = `<text x="260" y="115" text-anchor="middle" font-size="14" fill="#667085">绘图参数异常</text>`;
}}
}}
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("");
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>
</div>
</section>`;
}}
function renderDim(dim, v) {{
const color = dimColors[dim] || "#64748b";
const ok = v && v.score != null;
return `<div class="dimbox ${{ok?"":"bad"}}" style="border-left-color:${{color}}">
<div class="dimname"><span>${{esc(dim)}}</span><span class="dimscore" style="color:${{color}}">${{fmt(v?.score)}}</span></div>
<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>`;
return `<div class="chartcell">
<div class="chart-title">${{title}}</div>
<div class="metric-title">${{esc(v.expr)}}</div>
<div class="legend">${{Object.keys(v.years).map(y=>`<span style="--c:${{yearColor(y, v.years)}}">${{y}}</span>`).join("")}}</div>
<svg data-chart='${{esc(JSON.stringify(v.years))}}' 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);
const start = new Date(2024,0,1);
return Math.round((date-start)/86400000)+1;
}}
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)),
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
startYear: Number(chartStartYear.value) || 2021,
excludeYear: maxChartYear,
}};
}}
function mean(arr) {{ return arr.reduce((s,v)=>s+v,0) / arr.length; }}
function std(arr) {{
if (!arr.length) return 0;
const m = mean(arr);
return Math.sqrt(arr.reduce((s,v)=>s+(v-m)*(v-m),0) / arr.length);
}}
function circularDiff(a, b) {{
const d = Math.abs(a - b);
return Math.min(d, 366 - d);
}}
function trimmed(arr, trim) {{
const sorted = arr.filter(Number.isFinite).sort((a,b)=>a-b);
if (!sorted.length || trim <= 0) return sorted;
const cut = Math.floor(sorted.length * trim);
if (sorted.length - cut * 2 < 4) return sorted;
return sorted.slice(cut, sorted.length - cut);
}}
function quantile(sorted, q) {{
if (!sorted.length) return NaN;
const pos = (sorted.length - 1) * Math.max(0, Math.min(1, q));
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}}
function robustAnnualSeasonValues(points) {{
const byYear = new Map();
for (const p of points) {{
if (!byYear.has(p.year)) byYear.set(p.year, []);
byYear.get(p.year).push(p.value);
}}
const annual = [...byYear.values()].map(values => {{
const sorted = values.filter(Number.isFinite).sort((a,b)=>a-b);
return quantile(sorted, 0.50);
}}).filter(Number.isFinite).sort((a,b)=>a-b);
if (annual.length < 4) return annual;
const center = quantile(annual, 0.50);
const keepCount = Math.max(3, Math.ceil(annual.length * 0.65));
return annual
.map(value => ({{value, distance: Math.abs(value - center)}}))
.sort((a,b)=>a.distance-b.distance)
.slice(0, keepCount)
.map(x=>x.value)
.sort((a,b)=>a-b);
}}
function smoothBand(band, radius) {{
if (!band.length || radius <= 0) return band;
const n = band.length;
radius = Math.min(radius, Math.max(0, Math.floor((n - 1) / 2)));
if (radius <= 0) return band;
const smoothed = band.map((p, i) => {{
let low = 0, high = 0, mid = 0, wsum = 0;
for (let offset = -radius; offset <= radius; offset++) {{
const idx = ((i + offset) % n + n) % n;
const weight = radius + 1 - Math.abs(offset);
low += band[idx][1] * weight;
high += band[idx][2] * weight;
mid += band[idx][3] * weight;
wsum += weight;
}}
low /= wsum; high /= wsum; mid /= wsum;
if (low > high) {{
const tmp = low; low = high; high = tmp;
}}
mid = Math.max(low, Math.min(high, mid));
return [p[0], low, high, mid];
}});
return smoothed;
}}
function buildNormalizedChart(years) {{
const params = chartParams();
const rawPoints = [];
Object.entries(years).forEach(([year, pts]) => {{
if (Number(year) < params.startYear) return;
pts.forEach(p => {{
const value = Number(p[1]);
if (Number.isFinite(value)) rawPoints.push({{year, md:p[0], day:parseDay(p[0]), value}});
}});
}});
const rawValues = rawPoints.map(p=>p.value);
if (!rawValues.length) return {{years:{{}}, band:[]}};
const m = mean(rawValues);
let s = std(rawValues);
if (!Number.isFinite(s) || s === 0) s = 1;
const z = v => (v - m) / s;
const normYears = {{}};
Object.entries(years).forEach(([year, pts]) => {{
if (Number(year) < params.startYear) return;
normYears[year] = pts.map(p => [p[0], z(Number(p[1]))]).filter(p => Number.isFinite(p[1]));
}});
const band = [];
for (let day=1; day<=366; day++) {{
const samples = robustAnnualSeasonValues(rawPoints.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear && circularDiff(p.day, day) <= params.window));
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;
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, z(low), z(high), z(mid)]);
}}
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
return {{years:normYears, band:smoothBand(band, smoothRadius)}};
}}
function drawChart(svg, years) {{
const normalized = buildNormalizedChart(years);
years = normalized.years;
const band = 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);
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>`;
}}
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]));
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]));
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>`;
}});
svg.innerHTML = out;
}}
function refreshFromLocalProgram() {{
const command = `& 'C:\\Users\\cjn_w\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe' generate_fundamental_dashboard.py`;
fetch("http://127.0.0.1:8765/refresh", {{method:"POST"}})
.then(r => r.ok ? r.text() : Promise.reject(new Error("refresh service unavailable")))
.then(() => location.reload())
.catch(async () => {{
try {{ await navigator.clipboard.writeText(command); }} catch (e) {{}}
alert("浏览器不能直接运行本地 Python。已尝试复制刷新命令;若启用本地刷新服务,此按钮会自动运行底层计算并刷新页面。");
}});
}}
function historyColor(i) {{
const palette = ["#ef4444","#f97316","#1976d2","#65a30d","#a21caf","#0f9f9f","#374151","#9ca3af","#7c3aed","#0891b2","#be123c","#4d7c0f"];
return palette[i % palette.length];
}}
function drawHistoryChart(list) {{
const svg = document.getElementById("historySvg");
historyTooltip.style.display = "none";
const start = historyStart.value || "2026-01-01";
const end = historyEnd.value || todayString();
const useDemand = includeDemand.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])
.filter(p => p[1] != null && Number.isFinite(Number(p[1])));
return {{name:c.name, points:pts}};
}}).filter(s => s.points.length >= 2);
const W=1600,H=620,L=54,R=18,T=28,B=42;
if (!series.length) {{
svg.innerHTML = `<text x="800" y="300" text-anchor="middle" font-size="18" fill="#667085">暂无可绘制数据</text>`;
historyLegend.innerHTML = "";
return;
}}
const minDate = new Date(start).getTime();
const maxDate = new Date(end).getTime();
const span = Math.max(1, maxDate - minDate);
const x = d => L + (new Date(d).getTime() - minDate) / span * (W-L-R);
const y = v => T + (100 - v) / 100 * (H-T-B);
let out = "";
for (let i=0;i<=5;i++) {{
const val = i * 20, yy = y(val);
out += `<line class="grid" x1="${{L}}" y1="${{yy}}" x2="${{W-R}}" y2="${{yy}}"></line><text x="8" y="${{yy+4}}" font-size="12" fill="#667085">${{val}}</text>`;
}}
const ticks = 6;
for (let i=0;i<=ticks;i++) {{
const t = minDate + span * i / ticks;
const d = new Date(t);
const label = `${{d.getFullYear()}}-${{String(d.getMonth()+1).padStart(2,"0")}}`;
const xx = L + (W-L-R) * i / ticks;
out += `<line class="grid" x1="${{xx}}" y1="${{T}}" x2="${{xx}}" y2="${{H-B}}"></line><text x="${{xx-22}}" y="${{H-12}}" 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>`;
series.forEach((s,i) => {{
const d = s.points.map((p,j)=>`${{j?'L':'M'}}${{x(p[0]).toFixed(1)}},${{y(Number(p[1])).toFixed(1)}}`).join(" ");
out += `<path class="history-line" data-i="${{i}}" d="${{d}}" fill="none" stroke="${{historyColor(i)}}" stroke-width="1.6" opacity="0.68" vector-effect="non-scaling-stroke"><title>${{esc(s.name)}}</title></path>`;
}});
out += `<g class="history-crosshair" style="display:none"><line id="historyGuideX" x1="${{L}}" y1="${{T}}" x2="${{L}}" y2="${{H-B}}" stroke="#64748b" stroke-width="1" stroke-dasharray="4 4" opacity="0.75" vector-effect="non-scaling-stroke"></line><line id="historyGuideY" x1="${{L}}" y1="${{T}}" x2="${{W-R}}" y2="${{T}}" stroke="#64748b" stroke-width="1" stroke-dasharray="4 4" opacity="0.75" vector-effect="non-scaling-stroke"></line></g>`;
svg.innerHTML = out;
historyLegend.innerHTML = series.map((s,i)=>`<span class="history-legend-item" data-i="${{i}}" style="--c:${{historyColor(i)}}">${{esc(s.name)}}</span>`).join("");
function setHighlight(idx) {{
svg.querySelectorAll(".history-line").forEach(path => {{
const active = idx == null || Number(path.dataset.i) === idx;
path.setAttribute("opacity", active ? "0.95" : "0.12");
path.setAttribute("stroke-width", active ? "3.2" : "1.1");
}});
historyLegend.querySelectorAll(".history-legend-item").forEach(item => item.classList.toggle("active", idx != null && Number(item.dataset.i) === idx));
}}
historyLegend.querySelectorAll(".history-legend-item").forEach(item => {{
item.onmouseenter = () => setHighlight(Number(item.dataset.i));
item.onmouseleave = () => setHighlight(null);
}});
svg.querySelectorAll(".history-line").forEach(path => {{
path.onmouseenter = () => setHighlight(Number(path.dataset.i));
path.onmouseleave = () => setHighlight(null);
}});
const crosshair = svg.querySelector(".history-crosshair");
const guideX = svg.querySelector("#historyGuideX");
const guideY = svg.querySelector("#historyGuideY");
function nearestPoint(points, targetTime) {{
let best = null, bestDist = Infinity;
for (const p of points) {{
const dist = Math.abs(new Date(p[0]).getTime() - targetTime);
if (dist < bestDist) {{ best = p; bestDist = dist; }}
}}
return best;
}}
svg.onmousemove = ev => {{
const rect = svg.getBoundingClientRect();
const px = (ev.clientX - rect.left) / rect.width * W;
const clamped = Math.max(L, Math.min(W-R, px));
const targetTime = minDate + (clamped - L) / (W-L-R) * span;
const date = new Date(targetTime);
const dateText = `${{date.getFullYear()}}-${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
const rows = series.map((s,i) => {{
const p = nearestPoint(s.points, targetTime);
return p ? {{name:s.name, score:Number(p[1]), color:historyColor(i), date:p[0]}} : null;
}}).filter(Boolean).sort((a,b)=>b.score-a.score);
const py = (ev.clientY - rect.top) / rect.height * H;
const guideYValue = Math.max(0, Math.min(100, 100 - ((py - T) / (H - T - B)) * 100));
const nearest = rows.length ? rows.reduce((best, r) => Math.abs(Number(r.score) - guideYValue) < Math.abs(Number(best.score) - guideYValue) ? r : best, rows[0]) : null;
if (crosshair) {{
crosshair.style.display = "block";
guideX.setAttribute("x1", clamped.toFixed(1));
guideX.setAttribute("x2", clamped.toFixed(1));
const gy = y(guideYValue);
guideY.setAttribute("y1", gy.toFixed(1));
guideY.setAttribute("y2", gy.toFixed(1));
}}
historyTooltip.innerHTML = `<div class="date">${{dateText}} 光标得分 ${{fmt(guideYValue)}}${{nearest ? " 近线 " + esc(nearest.name) + " " + fmt(nearest.score) : ""}}</div><div class="grid">` + rows.map(r => `<div class="row"><span class="name" style="--c:${{r.color}}; color:${{r.color}}">${{esc(r.name)}}</span><span>${{fmt(r.score)}}</span></div>`).join("") + `</div>`;
const wrap = historyChartWrap.getBoundingClientRect();
let left = ev.clientX - wrap.left + 14;
let top = ev.clientY - wrap.top + 14;
historyTooltip.style.display = "block";
const tw = historyTooltip.offsetWidth || 260;
const th = historyTooltip.offsetHeight || 360;
if (left + tw > wrap.width) left = ev.clientX - wrap.left - tw - 14;
if (top + th > wrap.height) top = Math.max(8, wrap.height - th - 8);
historyTooltip.style.left = `${{Math.max(8,left)}}px`;
historyTooltip.style.top = `${{Math.max(8,top)}}px`;
}};
svg.onmouseleave = () => {{
historyTooltip.style.display = "none";
if (crosshair) crosshair.style.display = "none";
setHighlight(null);
}};
}}
render();
</script>
</body>
</html>"""
def main() -> None:
data = make_records()
OUTPUT_FILE.write_text(html_template(data), encoding="utf-8")
scored = [c for c in data["commodities"] if c["total"] is not None]
print(f"Wrote {OUTPUT_FILE}")
print(f"Scored commodities: {len(scored)} / {len(data['commodities'])}")
if scored:
print("Top 5:", ", ".join(f"{c['name']} {c['total']}" for c in scored[:5]))
print("Bottom 5:", ", ".join(f"{c['name']} {c['total']}" for c in scored[-5:]))
if __name__ == "__main__":
main()