965 lines
48 KiB
Python
965 lines
48 KiB
Python
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>NO.5 可更新数据 - 基本面评分面板</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">
|
||
<div class="brand">NO.5 可更新数据</div>
|
||
<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 class="tabbtn active" id="dashboardTab">面板</button>
|
||
<button class="tabbtn" id="historyTab">评分走势</button>
|
||
<button id="refreshData">刷新数据</button>
|
||
<button id="reset">重置</button>
|
||
<div class="stamp">完成度:<span id="completion"></span> 核心修正:库存反向计分 生成时间:<span id="generated"></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("generated").textContent = DATA.generated_at;
|
||
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;
|
||
|
||
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 => ({{"&":"&","<":"<",">":">",'"':""","'":"'"}}[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 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 = rawPoints.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear && circularDiff(p.day, day) <= params.window).map(p=>p.value).filter(Number.isFinite).sort((a,b)=>a-b);
|
||
if (samples.length < 4) 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()
|