Files
jibenmian-dashboard/generate_fundamental_dashboard.py

1213 lines
59 KiB
Python
Raw Permalink 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
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 = ["利润", "产量", "库存", "需求", "月差"]
INVERSE_DIMS = {"库存"}
DIM_COLORS = {
"利润": "#2563eb",
"产量": "#dc2626",
"库存": "#1d4ed8",
"需求": "#0f9f6e",
"月差": "#9333ea",
}
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 = sample.sort_values()
if len(sample) < 4:
return None
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:
raw_score = 100.0 if latest_value >= high else -100.0
elif latest_value >= high:
raw_score = 100.0
elif latest_value <= low:
raw_score = -100.0
else:
raw_score = 200.0 * (latest_value - low) / (high - low) - 100.0
score = -raw_score if inverse else raw_score
return {
"score": max(-100.0, min(100.0, score)),
"band_low": low,
"band_high": high,
"band_mean": mid,
"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)
raw_mapped = raw * 2.0 - 100.0
return -raw_mapped if inverse else raw_mapped
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_spread = [d for d in DIMENSIONS if d in scores]
dims_without_demand = [d for d in DIMENSIONS if d != "需求" and d in scores]
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
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("")
# 从 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()
if not name:
continue
dims: dict[str, Any] = {}
dim_series: dict[str, pd.Series] = {}
valid_scores: list[float] = []
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
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"]
raw_mapped = raw * 2.0 - 100.0
score = -raw_mapped if dim in INVERSE_DIMS else raw_mapped
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],
}
# 月差维度:从 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
raw_mapped = raw * 2.0 - 100.0
score = -raw_mapped if dim in INVERSE_DIMS else raw_mapped
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(
{
"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,
"spread_source": "MySQL contract_day (spread_from_db.py)",
},
"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: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; }}
.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(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; }}
.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(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; }}
.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; }}
.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; }}
.spread-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">带宽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>
<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 分制:高于季节性正常区间上沿为 +100,低于下沿为 -100,区间内线性映射;0 分表示处于正常中位水平。正得分偏利多,负得分偏利空;库存反向。调整顶部「带宽K」可实时改变阴影带宽度和得分。</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 inverseDims = new Set({json.dumps(list(INVERSE_DIMS), ensure_ascii=False)});
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; 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 = document.getElementById("includeSpread").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 !== "全部" && !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() {{ 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;
return v?.score;
}}
function scoreCommodity(c) {{
const next = Object.assign({{}}, c);
next.dims = Object.fromEntries(Object.entries(c.dims).map(([d, v]) => {{
const score = currentDimScore(d, v);
return [d, Object.assign({{}}, v, {{score: score == null ? null : score}})];
}}));
return Object.assign(next, computeTotal(next));
}}
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(scoreCommodity);
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, 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("includeSpread").checked ? "月差参与总分" : "月差不参与总分"}}。`;
const sortedAll = scored.slice().sort((a,b)=>b.total-a.total);
const strongList = sortedAll.filter(c => c.total > 0);
const weakList = sortedAll.filter(c => c.total < 0).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>`;
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))].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>
${{charts ? `<div class="charts">${{charts}}</div>` : ""}}
${{spreadCharts ? `<div class="spread-charts">${{spreadCharts}}</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 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))}}' data-dim='${{esc(dim)}}' 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);
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(12, 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 annualMedianValues(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);
}}
return [...byYear.entries()].map(([year, values]) => {{
const sorted = values.filter(Number.isFinite).sort((a,b)=>a-b);
return {{year:Number(year), value:quantile(sorted, 0.50)}};
}}).filter(x => Number.isFinite(x.value)).sort((a,b)=>a.year-b.year);
}}
function trendLikeAnnualLevel(points) {{
const annual = annualMedianValues(points);
if (annual.length < 4) return false;
const xs = annual.map(x=>x.year);
const ys = annual.map(x=>x.value);
const mx = mean(xs), my = mean(ys);
const cov = xs.reduce((s,x,i)=>s+(x-mx)*(ys[i]-my),0);
const vx = xs.reduce((s,x)=>s+(x-mx)*(x-mx),0);
const vy = ys.reduce((s,y)=>s+(y-my)*(y-my),0);
const corr = vx > 0 && vy > 0 ? cov / Math.sqrt(vx * vy) : 0;
return Math.abs(corr) >= 0.70;
}}
function robustAnnualSeasonValues(points, useRobust=true) {{
const annual = annualMedianValues(points).map(x=>x.value).sort((a,b)=>a-b);
if (annual.length < 4) return annual;
if (!useRobust) 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 rawPointsFromYears(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:Number(year), md:p[0], day:parseDay(p[0]), value}});
}});
}});
return rawPoints;
}}
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 band = [];
for (let day=1; day<=366; day++) {{
let samples = bandUniverse.filter(p => circularDiff(p.day, day) <= params.window).map(p => p.value).filter(Number.isFinite);
if (samples.length < 3) continue;
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]);
}}
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
return smoothBand(band, smoothRadius);
}}
function latestRawPoint(years) {{
const points = rawPointsFromYears(years);
if (!points.length) return null;
return points.sort((a,b) => a.year-b.year || a.day-b.day).at(-1);
}}
function scoreFromCurrentBand(v, inverse=false) {{
if (!v || !v.years) return null;
const latest = latestRawPoint(v.years);
if (!latest) return null;
const rawBand = buildRawBand(v.years);
const row = rawBand.find(p => p[0] === latest.md);
if (!row) return null;
let low = Number(row[1]), high = Number(row[2]);
if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
if (low > high) {{ const tmp = low; low = high; high = tmp; }}
let rawScore;
if (high === low) rawScore = latest.value >= high ? 100 : -100;
else if (latest.value >= high) rawScore = 100;
else if (latest.value <= low) rawScore = -100;
else rawScore = 200 * (latest.value - low) / (high - low) - 100;
const score = inverse ? -rawScore : rawScore;
return Math.max(-100, Math.min(100, score));
}}
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 = rawPointsFromYears(years);
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 = buildRawBand(years, rawPoints).map(p => [p[0], z(p[1]), z(p[2]), z(p[3])]);
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);
let chartYears = normalized.years;
let chartBand = normalized.band;
const W=520,H=230,L=44,R=8,T=16,B=30;
// 自定义横坐标映射
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 invertY = svg.dataset.dim === "库存";
const y = invertY ? v => T + (v-min)/(max-min)*(H-T-B) : 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=invertY ? min+i*(max-min)/4 : 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>`;
}}
// 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 (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(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>`;
}});
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 useSpread = document.getElementById("includeSpread").checked;
const series = list.map(c => {{
const pts = (c.scoreHistory || [])
.filter(p => p.date >= start && p.date <= end)
.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);
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 yMin = -100, yMax = 100;
const y = v => T + (yMax - v) / (yMax - yMin) * (H-T-B);
let out = "";
for (let i=0;i<=4;i++) {{
const val = yMin + i * (yMax - yMin) / 4, yy = y(val);
const isZero = val === 0;
out += `<line class="${{isZero ? 'axis' : 'grid'}}" x1="${{L}}" y1="${{yy}}" x2="${{W-R}}" y2="${{yy}}"></line><text x="8" y="${{yy+4}}" font-size="12" fill="${{isZero ? '#374151' : '#667085'}}" font-weight="${{isZero ? 'bold' : 'normal'}}">${{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(-100, Math.min(100, 100 - ((py - T) / (H - T - B)) * 200));
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()