Initial fundamental dashboard

This commit is contained in:
2026-06-08 20:55:08 +08:00
commit a8b79df953
19 changed files with 2019 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
node_modules/
logs/
outputs/
缓存/
备份/
.DS_Store
Thumbs.db
+56
View File
@@ -0,0 +1,56 @@
# 商品期货基本面评分面板
本仓库用于根据本地 Excel 数据生成商品期货基本面评分 HTML 面板。
## 文件说明
- `基本面评分面板.html`:当前已生成的可视化面板,可直接打开查看。
- `generate_fundamental_dashboard.py`:底层计算与 HTML 生成脚本。
- `refresh_dashboard_server.py`:本地刷新服务,支持在页面点击“刷新数据”后重新运行生成脚本。
- `wind数据汇总.xlsx`Wind 导出的原始数据。
- `钢联数据汇总.xlsx`:钢联导出的原始数据。
- `基本面指标确认.xlsx`:各品种各维度使用的指标与公式。
- `月差拼接数据.xlsx``月差/`:月差相关资料。
## 更新数据后重新生成
将新的 Excel 数据覆盖到同名文件后,在仓库根目录运行:
```powershell
python generate_fundamental_dashboard.py
```
运行完成后打开 `基本面评分面板.html` 即可查看更新后的面板。
## 使用页面内“刷新数据”按钮
如果希望在浏览器页面中点击“刷新数据”自动重新计算,请先启动本地服务:
```powershell
python refresh_dashboard_server.py
```
然后用浏览器打开终端输出的地址,通常是:
```text
http://127.0.0.1:8765/基本面评分面板.html
```
通过该地址打开时,页面内“刷新数据”按钮会调用 `generate_fundamental_dashboard.py` 并刷新页面。
## Python 依赖
脚本依赖:
```text
pandas
numpy
openpyxl
```
如本机缺少依赖,可安装:
```powershell
pip install pandas numpy openpyxl
```
+931
View File
@@ -0,0 +1,931 @@
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 >= latest_year - 8]
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
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"]]))),
"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"><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="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 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));
document.getElementById("generated").textContent = DATA.generated_at;
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"; 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 = 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 => ({{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}}[m])); }}
function render() {{
const list = rankedList(DATA.commodities.filter(matches));
const scored = list.filter(c => c.total != null);
document.getElementById("avgScore").textContent = scored.length ? fmt(scored.reduce((s,c)=>s+c.total,0)/scored.length) : "--";
document.getElementById("coverage").textContent = `当前筛选覆盖 ${{scored.length}} 个有可用数据的品种;${{includeDemand.checked ? "需求参与总分" : "需求不参与总分"}};月差当前暂无映射。`;
document.getElementById("strong").innerHTML = topChips(scored, false) || `<span class="hint">暂无可用数据</span>`;
document.getElementById("weak").innerHTML = topChips(scored, true) || `<span class="hint">暂无可用数据</span>`;
rowsEl.style.display = activeView === "dashboard" ? "block" : "none";
historyView.classList.toggle("active", activeView === "history");
rowsEl.innerHTML = list.length ? list.map(renderCommodity).join("") : `<div class="empty">没有匹配的品种</div>`;
for (const cell of document.querySelectorAll("[data-chart]")) {{
try {{
drawChart(cell, JSON.parse(cell.dataset.chart));
}} catch (err) {{
cell.innerHTML = `<text x="260" y="115" text-anchor="middle" font-size="14" fill="#667085">绘图参数异常</text>`;
}}
}}
if (activeView === "history") drawHistoryChart(list.filter(c => c.total != null));
}}
function renderCommodity(c) {{
const dimCards = [...Object.entries(c.dims).map(([d,v]) => renderDim(d,v)), renderMonthSpread()].join("");
const chartDims = dimSel.value === "全部" || dimSel.value === "月差" ? ["利润","产量","库存","需求"] : [dimSel.value];
const charts = chartDims.filter(d => d !== "月差").map(d => renderChartCell(c, d, c.dims[d])).join("");
return `<section class="commodity">
<div class="side">${{esc(c.name)}}</div>
<div class="body">
<div class="scoreline"><div class="total"><span>#${{c.displayRank||"-"}}</span><span class="num">${{fmt(c.total)}}</span><span>总分</span></div>${{dimCards}}</div>
<div class="detail">季节性口径:最新值相对同期正常区间;库存反向,其余正向。有效维度:${{c.valid}}/${{c.denom}} 板块:${{esc(c.board)}}</div>
<div class="charts">${{charts}}</div>
</div>
</section>`;
}}
function renderDim(dim, v) {{
const color = dimColors[dim] || "#64748b";
const ok = v && v.score != null;
return `<div class="dimbox ${{ok?"":"bad"}}" style="border-left-color:${{color}}">
<div class="dimname"><span>${{esc(dim)}}</span><span class="dimscore" style="color:${{color}}">${{fmt(v?.score)}}</span></div>
<div class="dimhint">${{esc(ok ? v.direction : "缺数据/未配置")}}</div>
</div>`;
}}
function renderMonthSpread() {{
return `<div class="dimbox bad" style="border-left-color:#94a3b8"><div class="dimname"><span>月差</span><span class="dimscore">--</span></div><div class="dimhint">未配置</div></div>`;
}}
function renderChartCell(c, dim, v) {{
const title = `${{esc(c.name)}}${{esc(dim)}}`;
if (!v || !v.years) return `<div class="chartcell"><div class="chart-title">${{title}}</div><div class="metric-title">${{esc(v?.expr || "未配置")}}</div><div class="empty">缺少可绘制数据</div></div>`;
return `<div class="chartcell">
<div class="chart-title">${{title}}</div>
<div class="metric-title">${{esc(v.expr)}}</div>
<div class="legend">${{Object.keys(v.years).map(y=>`<span style="--c:${{yearColor(y, v.years)}}">${{y}}</span>`).join("")}}</div>
<svg data-chart='${{esc(JSON.stringify(v.years))}}' viewBox="0 0 520 230" preserveAspectRatio="none"></svg>
</div>`;
}}
function parseDay(md) {{
const [m,d] = md.split("-").map(Number);
const date = new Date(2024, m-1, d);
const start = new Date(2024,0,1);
return Math.round((date-start)/86400000)+1;
}}
function chartParams() {{
return {{
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
}};
}}
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 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]) => {{
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]) => {{
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 = trimmed(rawPoints.filter(p => circularDiff(p.day, day) <= params.window).map(p=>p.value), params.trim);
if (samples.length < 4) continue;
const bm = mean(samples);
const bs = std(samples);
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(bm - params.k * bs), z(bm + params.k * bs), z(bm)]);
}}
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()
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import http.server
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
PORT = 8765
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def do_POST(self):
if self.path != "/refresh":
self.send_error(404)
return
proc = subprocess.run(
[sys.executable, str(BASE_DIR / "generate_fundamental_dashboard.py")],
cwd=str(BASE_DIR),
text=True,
capture_output=True,
)
body = (proc.stdout or "") + (proc.stderr or "")
self.send_response(200 if proc.returncode == 0 else 500)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8", errors="replace"))
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
print(f"http://127.0.0.1:{PORT}/基本面评分面板.html")
server.serve_forever()
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+132
View File
@@ -0,0 +1,132 @@
---
name: A-期货日级数据
description: 通过 mysql_dll2.dll 连接 MySQL 数据库并执行 SQL 查询。当用户需要查询 MySQL 数据库、执行 SQL 语句、获取数据库中的数据时触发。 触发关键词包括:查询数据库、查合约、查日线、查K线、查结算价、查持仓量、查成交量、查保证金、 查品种数据、读取数据库、SQL查询、查某品种某合约、查期货数据、从数据库取数据、db查询、查纯碱、查螺纹等。 注意:此 Skill 依赖 mysql_dll2.dll 文件,仅限 Windows 平台使用。数据库编号固定为 1。
disable: true
allowed-tools:
---
.dll 连接 MySQL 数据库并执行 SQL 查询。当用户需要查询 MySQL 数据库、执行 SQL 语句、获取数据库中的数据时触发。
触发关键词包括:查询数据库、查合约、查日线、查K线、查结算价、查持仓量、查成交量、查保证金、
查品种数据、读取数据库、SQL查询、查某品种某合约、查期货数据、从数据库取数据、db查询、查纯碱、查螺纹等。
注意:此 Skill 依赖 mysql_dll2.dll 文件,仅限 Windows 平台使用。数据库编号固定为 1。
allowed-tools:
disable: true
---
# A-期货日级数据
## 用途
把自然语言翻译成 SQL 执行,返回原始数据。不生成汇总、不分析、不联想。
---
## 数据库
- **db_index:固定为 1**,无需询问用户
---
## 调用方式
```python
import sys
sys.path.insert(0, r"<skill_scripts_dir>")
from mysql_client import MysqlDLLClient
db = MysqlDLLClient()
result = db.query(1, "SELECT * FROM `contract_day` WHERE p_code = ?", ["sa"])
print(result)
db.close()
```
---
## 字段说明(references/field_mapping.md
| 英文字段名 | 中文含义 | 字段类型 |
|-----------|---------|---------|
| contract | 合约 | varchar(10) |
| open | 开盘价 | float |
| high | 高(最高价) | float |
| low | 低(最低价) | float |
| close | 收(收盘价) | float |
| volume | 成交(成交量) | float |
| oi | 持仓(持仓量) | float |
| p_code | 品种 | varchar(4) |
| jys | 交易所 | varchar(6) |
| times | 时间 | datetime |
### 品种 vs 合约
| 概念 | 字段 | 说明 | 示例 |
|------|------|------|------|
| 品种 | `p_code` | 品种代码,拼音小写 | `sa`(纯碱)、`rb`(螺纹钢) |
| 合约 | `contract` | 品种 + 交割月份 | `sa605`(纯碱2026年5月交割) |
> 郑商所(CZCE)合约月份为3位,如 `SR501`、`MA509`。
---
## 查询示例
**查所有品种某时间段数据(直接一条SQL搞定,不需要先查品种)**
```sql
SELECT * FROM contract_day WHERE times >= '2026-01-01' AND times <= '2026-12-31'
```
**查纯碱26年所有合约数据**
```
p_code = 'SA'
时间范围: times >= '2026-01-01' AND times <= '2026-12-31'
```
**查纯碱605合约数据**
```
contract = 'SA605'
```
**查螺纹钢26年所有数据**
```
p_code = 'RB'
时间范围: times >= '2026-01-01' AND times <= '2026-12-31'
```
---
## 执行规则
1. **直接执行,不询问确认。**
2. **只返回数据,不生成汇总、不分析。**
3. **db_index 固定为 1。**
4. **参数化查询**,所有条件用 `?` 占位符传参。
5. **查所有品种时直接一条 SQL**,不需要先 `SELECT DISTINCT p_code` 再逐条查。
---
## 依赖文件
```
scripts/
└── mysql_client.py
lib/
└── mysql_dll/
├── mysql_dll2.dll
├── jsoncpp.dll
├── libcrypto-1_1-x64.dll
├── libmysql.dll
├── libssl-1_1-x64.dll
└── mysqlcppconn-9-vs14.dll
```
DLL 由 `mysql_client.py` 自动加载,无需手动配置。
---
## 品种代码对照(references/variety_mapping.md
详见 `references/variety_mapping.md`,包含所有交易所品种代码。
@@ -0,0 +1,29 @@
# 数据库字段中英文对照表
## 品种 vs 合约
| 概念 | 字段 | 说明 | 示例 |
|------|------|------|------|
| **品种** | `p_code` | 品种代码,拼音小写,代表品种分类 | `rb`(螺纹钢)、`cu`(铜) |
| **合约** | `contract` | 具体合约代码 = 品种 + 交割月份 | `rb2506`(螺纹钢2025年6月交割) |
> 郑商所(CZCE)合约月份为3位,如 `SR501`、`MA509`。
## 字段说明
| 英文字段名 | 中文注释 | 字段类型 |
|-----------|---------|---------|
| contract | 合约 | varchar(10) |
| open | 开盘价 | float |
| high | 高(最高价) | float |
| low | 低(最低价) | float |
| close | 收(收盘价) | float |
| volume | 成交(成交量) | float |
| oi | 持仓(持仓量) | float |
| p_code | 品种 | varchar(4) |
| jys | 交易所 | varchar(6) |
| times | 时间 | datetime |
@@ -0,0 +1,119 @@
# 期货品种中英文对照表
## 中金所(CFFEX
| p_code(查询用) | 中文名称 | 合约位数 | 备注 |
|-----------------|---------|---------|------|
| if / IF | 沪深300股指期货 | 4位 | 如 IF2506 |
| ih / IH | 上证50股指期货 | 4位 | 如 IH2506 |
| im / IM | 中证1000股指期货 | 4位 | 如 IM2506 |
| ic / IC | 中证500股指期货 | 4位 | 如 IC2506 |
| tf / TF | 5年国债期货 | 4位 | 如 TF2506 |
| t / T | 10年国债期货 | 4位 | 如 T2506 |
| ts / TS | 2年国债期货 | 4位 | 如 TS2506 |
| tl / TL | 30年国债期货 | 4位 | 如 TL2506 |
---
## 上海期货交易所(SHFE
| p_code | 中文名称 | 合约位数 | 示例 |
|--------|---------|---------|------|
| cu | 铜 | 4位 | cu2506 |
| al | 铝 | 4位 | al2506 |
| zn | 锌 | 4位 | zn2506 |
| pb | 铅 | 4位 | pb2506 |
| ni | 镍 | 4位 | ni2506 |
| sn | 锡 | 4位 | sn2506 |
| au | 黄金 | 4位 | au2506 |
| ag | 白银 | 4位 | ag2506 |
| rb | 螺纹钢 | 4位 | rb2506 |
| wr | 线材 | 4位 | wr2506 |
| hc | 热轧卷板 | 4位 | hc2506 |
| ss | 不锈钢 | 4位 | ss2506 |
| fu | 燃料油 | 4位 | fu2506 |
| bu | 沥青 | 4位 | bu2506 |
| ru | 天然橡胶 | 4位 | ru2506 |
| sp | 纸浆 | 4位 | sp2506 |
| br | 丁二烯橡胶 | 4位 | br2506 |
---
## 大连商品交易所(DCE
| p_code | 中文名称 | 合约位数 | 示例 |
|--------|---------|---------|------|
| a | 豆一 | 4位 | a2506 |
| b | 豆二 | 4位 | b2506 |
| m | 豆粕 | 4位 | m2506 |
| y | 豆油 | 4位 | y2506 |
| p | 棕榈油 | 4位 | p2506 |
| c | 玉米 | 4位 | c2506 |
| cs | 玉米淀粉 | 4位 | cs2506 |
| jd | 鸡蛋 | 4位 | jd2506 |
| l | 聚乙烯 | 4位 | l2506 |
| v | 聚氯乙烯 | 4位 | v2506 |
| pp | 聚丙烯 | 4位 | pp2506 |
| j | 焦炭 | 4位 | j2506 |
| jm | 焦煤 | 4位 | jm2506 |
| i | 铁矿石 | 4位 | i2506 |
| eg | 乙二醇 | 4位 | eg2506 |
| rr | 粳米 | 4位 | rr2506 |
| eb | 苯乙烯 | 4位 | eb2506 |
| pg | 液化石油气 | 4位 | pg2506 |
| lh | 生猪 | 4位 | lh2506 |
---
## 郑州商品交易所(CZCE
> ⚠️ **郑商所合约特殊规则:合约月份只有 3 位数字**
> 例如:SR501(白糖2501年1月),MA509(甲醇2509年9月)
> 年份只取个位数,与其他交易所4位不同!
| p_code | 中文名称 | 合约格式 | 示例 |
|--------|---------|---------|------|
| SR | 白糖 | 品种+3位 | SR501 |
| CF | 棉花 | 品种+3位 | CF501 |
| MA | 甲醇 | 品种+3位 | MA509 |
| TA | PTA | 品种+3位 | TA509 |
| OI | 菜籽油 | 品种+3位 | OI509 |
| RM | 菜籽粕 | 品种+3位 | RM509 |
| RS | 菜籽 | 品种+3位 | RS509 |
| WH | 强麦 | 品种+3位 | WH509 |
| PM | 普麦 | 品种+3位 | PM509 |
| RI | 早籼稻 | 品种+3位 | RI509 |
| JR | 粳稻 | 品种+3位 | JR509 |
| LR | 晚籼稻 | 品种+3位 | LR509 |
| AP | 苹果 | 品种+3位 | AP509 |
| CJ | 红枣 | 品种+3位 | CJ509 |
| SF | 硅铁 | 品种+3位 | SF509 |
| SM | 锰硅 | 品种+3位 | SM509 |
| ZC | 动力煤 | 品种+3位 | ZC509 |
| FG | 玻璃 | 品种+3位 | FG509 |
| SA | 纯碱 | 品种+3位 | SA509 |
| UR | 尿素 | 品种+3位 | UR509 |
| PF | 短纤 | 品种+3位 | PF509 |
| PX | 对二甲苯 | 品种+3位 | PX509 |
| PR | 丙烯 | 品种+3位 | PR509 |
| SH | 烧碱 | 品种+3位 | SH509 |
---
## 上海国际能源交易中心(INE
| p_code | 中文名称 | 合约位数 | 示例 |
|--------|---------|---------|------|
| sc | 原油 | 4位 | sc2506 |
| lu | 低硫燃料油 | 4位 | lu2506 |
| nr | 20号胶 | 4位 | nr2506 |
| bc | 国际铜 | 4位 | bc2506 |
---
## 广州期货交易所(GFEX
| p_code | 中文名称 | 合约位数 | 示例 |
|--------|---------|---------|------|
| si | 工业硅 | 4位 | si2506 |
| lc | 碳酸锂 | 4位 | lc2506 |
@@ -0,0 +1,172 @@
import ctypes
import json
from pathlib import Path
import time
class MysqlDLLClient:
"""
MySQL DLL 调用客户端
用于调用 mysql_dll2.dll 执行 SQL
Example
-------
db = MysqlDLLClient()
data = db.query(
7,
"select * from min15_a_jq where times>=? and times<=?",
["2025-12-24 00:00:00", "2025-12-26 00:00:00"]
)
print(data)
"""
def __init__(self, dll_path=None):
"""
初始化 DLL
Parameters
----------
dll_path : str | Path
dll路径,默认当前目录 mysql_dll2.dll
"""
if dll_path is None:
root = Path(__file__).resolve().parent.parent
dll_path = root / "lib/mysql_dll/mysql_dll2.dll"
self.dll = ctypes.WinDLL(str(dll_path))
self._init_functions()
time.sleep(1)
ret = self.dll.InitDBConfig()
if ret != 1:
raise RuntimeError("服务器授权失败")
# ---------------------------------------------------------
# 初始化 DLL 函数声明
# ---------------------------------------------------------
def _init_functions(self):
# 初始化数据库配置
self.dll.InitDBConfig.argtypes = []
self.dll.InitDBConfig.restype = ctypes.c_int
# 关闭 DLL
self.dll.ShutdownDLL.argtypes = []
self.dll.ShutdownDLL.restype = None
# QuerySQL_Auto
self.dll.QuerySQL_Auto.argtypes = [
ctypes.c_int, # db_index
ctypes.c_char_p, # sql
ctypes.c_char_p, # params
ctypes.POINTER(ctypes.c_void_p), # out_buf
ctypes.POINTER(ctypes.c_int) # out_size
]
self.dll.QuerySQL_Auto.restype = ctypes.c_int
# FreeBuffer
self.dll.FreeBuffer.argtypes = [ctypes.c_void_p]
self.dll.FreeBuffer.restype = None
# ---------------------------------------------------------
# 参数转换
# ---------------------------------------------------------
def _build_params(self, params):
"""
参数列表转 DLL 参数格式
["2025-01-01","2025-01-02"]
->
b"s:2025-01-01|s:2025-01-02"
"""
if not params:
return b""
arr = []
for p in params:
if isinstance(p, str):
arr.append(f"s:{p}")
elif isinstance(p, int):
arr.append(f"i:{p}")
elif isinstance(p, float):
arr.append(f"f:{p}")
else:
raise TypeError(f"不支持的参数类型: {type(p)}")
return "|".join(arr).encode()
# ---------------------------------------------------------
# 查询
# ---------------------------------------------------------
def query(self, db_index, sql, params=None):
"""
执行 SQL 查询
Parameters
----------
db_index : int
数据库编号
sql : str
SQL语句
params : list
SQL参数
Returns
-------
str
查询结果(JSON 或文本)
"""
if isinstance(sql, str):
sql = sql.encode()
params_bytes = self._build_params(params)
out_buf = ctypes.c_void_p()
out_size = ctypes.c_int()
ret = self.dll.QuerySQL_Auto(
db_index,
sql,
params_bytes,
ctypes.byref(out_buf),
ctypes.byref(out_size)
)
if ret != 0:
raise RuntimeError("SQL执行失败")
try:
data = ctypes.string_at(out_buf.value, out_size.value)
return json.loads(data.decode())
finally:
self.dll.FreeBuffer(out_buf)
# ---------------------------------------------------------
# 关闭
# ---------------------------------------------------------
def close(self):
"""关闭 DLL"""
if self.dll:
self.dll.ShutdownDLL()
# ---------------------------------------------------------
# with 支持
# ---------------------------------------------------------
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
Binary file not shown.
Binary file not shown.