Document dashboard handoff and dynamic band scoring

This commit is contained in:
2026-06-12 10:27:02 +08:00
parent b4558d2bde
commit 2c2ebd4c34
4 changed files with 248 additions and 85 deletions
+90 -26
View File
@@ -1,30 +1,34 @@
# 商品期货基本面评分面板
本仓库用于根据本地 Excel 数据生成商品期货基本面评分 HTML 面板。
这个项目用于把本地 Excel 原始数据生成为一个可直接打开的商品期货基本面评分 HTML 面板。面板当前围绕“低库存 + 高利润 + 高产量 + 高需求 + 高月差”的基本面强弱框架设计,其中库存维度反向计分,其余维度正向计分。
## 文件说明
## 快速开始
- `基本面评分面板.html`:当前已生成的可视化面板,可直接打开查看
- `generate_fundamental_dashboard.py`:底层计算与 HTML 生成脚本。
- `refresh_dashboard_server.py`:本地刷新服务,支持在页面点击“刷新数据”后重新运行生成脚本。
- `wind数据汇总.xlsx`Wind 导出的原始数据。
- `钢联数据汇总.xlsx`:钢联导出的原始数据。
- `基本面指标确认.xlsx`:各品种各维度使用的指标与公式。
- `月差拼接数据.xlsx``月差/`:月差相关资料。
1. 克隆仓库后进入项目根目录
2. 安装 Python 依赖:
## 更新数据后重新生成
```powershell
pip install -r requirements.txt
```
将新的 Excel 数据覆盖到同名文件后,在仓库根目录运行
3. 确认以下 Excel 文件在项目根目录,且文件名保持不变
```text
wind数据汇总.xlsx
钢联数据汇总.xlsx
基本面指标确认.xlsx
月差拼接数据.xlsx
```
4. 重新生成页面:
```powershell
python generate_fundamental_dashboard.py
```
运行完成后打开 `基本面评分面板.html` 即可查看更新后的面板
5. 打开 `基本面评分面板.html` 查看结果
## 使用页面“刷新数据”按钮
如果希望在浏览器页面中点击“刷新数据”自动重新计算,请先启动本地服务:
如果需要使用页面顶部的“刷新数据”按钮,请先启动本地刷新服务:
```powershell
python refresh_dashboard_server.py
@@ -36,21 +40,81 @@ python refresh_dashboard_server.py
http://127.0.0.1:8765/基本面评分面板.html
```
通过该地址打开时,页面内“刷新数据”按钮会调用 `generate_fundamental_dashboard.py` 并刷新页面。
## 核心文件
## Python 依赖
- `generate_fundamental_dashboard.py`:核心计算与 HTML 生成脚本。
- `基本面评分面板.html`:已生成的可视化面板,可直接打开。
- `refresh_dashboard_server.py`:本地刷新服务,供 HTML 页面按钮触发重新计算。
- `wind数据汇总.xlsx`Wind 导出的原始指标数据。
- `钢联数据汇总.xlsx`:钢联导出的原始指标数据。
- `基本面指标确认.xlsx`:品种和维度的指标选择方案,脚本按这里的公式取数。
- `月差拼接数据.xlsx``月差/`:月差相关资料,目前主面板中月差仍是预留/未完全映射状态。
- `requirements.txt`:运行脚本所需的 Python 依赖。
脚本依赖:
## 数据更新方式
```text
pandas
numpy
openpyxl
```
如本机缺少依赖,可安装:
后续更新数据时,只需要用同名 Excel 覆盖根目录下的原始数据文件,再运行:
```powershell
pip install pandas numpy openpyxl
python generate_fundamental_dashboard.py
```
脚本会重新读取 Excel、计算得分、生成新的 `基本面评分面板.html`。为了保证同事之间结果一致,请不要随意改 Excel 文件名、表头结构或 `基本面指标确认.xlsx` 中的品种/维度列名。
## 评分设计逻辑
每个品种默认包含四个核心维度:
- `利润`:越高越好。
- `产量`:越高越好。
- `库存`:越低越好,因此反向计分。
- `需求`:越高越好,页面顶部可选择是否参与总分。
维度分数按当前页面阴影带范围动态计算:
- 正向指标:高于阴影带上沿记 100 分,低于下沿记 0 分,区间内线性映射。
- 库存指标:方向相反,低于下沿记 100 分,高于上沿记 0 分。
- 总分:取参与计算维度的简单平均值。
这里刻意使用简单平均,而不是复杂权重,是为了让研究员能直接解释每个维度对总分的贡献。后续如要加权,应先在 README 中说明权重来源和业务理由。
## 阴影带设计逻辑
图表阴影带用于表示“同期正常波动区间”,同时也是当前页面分数的计算依据。顶部参数会同时影响图表和得分:
- `窗口`:按一年中的相近日期取样,体现季节性。
- `带宽`:调节阴影带宽度。
- `去尾`:控制分位数取样的尾部剔除。
- `图始`:图表和阴影带计算的起始年份。
当前阴影带不是简单的 `均值 ± 标准差`,原因是部分商品在某些年份存在极端行情。例如焦炭利润 2021 年、棕榈油利润 2022 年上半年,如果直接纳入正常区间,会把极端行情误认为正常波动。
因此脚本采用了两层处理:
1. 对趋势型指标,保留更多历史年份参与季节性区间计算,避免类似“豆二需求”这类趋势指标覆盖不足。
2. 对非趋势型、均值回归特征更强的指标,剔除偏离年度中位数较远的极端年份,再计算阴影带,避免类似利润极端年份把正常区间拉得过宽。
这套逻辑的目标是:趋势指标不过度剔除,利润等极端年份不污染正常区间。后续 AI 或同事修改阴影带时,请同时检查“豆二需求”和“20号胶利润”等案例,避免只优化其中一类指标。
## 页面功能说明
- 顶部黄色区域展示搜索、维度筛选、板块筛选、阴影带参数、图表起始年份、需求是否计分、刷新按钮和完成度。
- `完成度` 表示四个季节性图均有可绘制数据的品种数量占全部品种数量的比例。
- 主面板按当前总分排序,展示各品种维度分、总分和季节性折线图。
- 评分走势页把各品种基本面得分变化画在同一张图上,支持图例高亮、提示框降序显示和十字辅助线。
## 给后续维护者和 AI 的注意事项
- 页面形式和配色经过多轮确认,除非明确要求,不要改布局、颜色和交互样式。
- 评分逻辑必须与阴影带范围保持一致,不能只改图表不改得分。
- 库存必须反向计分,这是核心业务假设。
- 需求维度是否参与总分由页面开关控制,不要写死。
- 阴影带计算默认不使用当年数据作为正常区间样本,避免当前年份行情污染正常区间。
- 新增指标时优先修改 `基本面指标确认.xlsx`,不要在 Python 里硬编码单个品种。
- 生成后的 HTML 是可交付结果,但真正的来源是 Excel 原始数据和 `generate_fundamental_dashboard.py`
## 常见问题
如果浏览器中点击“刷新数据”提示不能直接运行本地 Python,通常是因为没有启动 `refresh_dashboard_server.py`。先运行本地刷新服务,再通过 `http://127.0.0.1:8765/基本面评分面板.html` 打开页面即可。
如果某些图为空,优先检查 `基本面指标确认.xlsx` 中对应公式能否匹配到 Wind/钢联原始数据里的指标名称,以及原始数据中是否有足够历史样本。
+77 -29
View File
@@ -515,7 +515,7 @@ svg {{ width:100%; height:230px; display:block; }}
<div class="chips" id="weak"></div>
</div>
</section>
<div class="note">维度得分按同期正常波动区间计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。阴影带参数只影响图表展示,总分使用默认 ±15 天、3 倍标准差、去尾 10%。</div>
<div class="note">维度得分按当前阴影带范围计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。顶部阴影带参数同步影响维度分数和总分。</div>
<main class="rows" id="rows"></main>
<section class="history-view" id="historyView">
<div class="history-tools">
@@ -541,6 +541,7 @@ function yearColor(year, years) {{
}}
const dimColors = {json.dumps(DIM_COLORS, ensure_ascii=False)};
const dims = DATA.dimensions;
const inverseDims = new Set({json.dumps(list(INVERSE_DIMS), ensure_ascii=False)});
const search = document.getElementById("search");
const dimSel = document.getElementById("dim");
const boardSel = document.getElementById("board");
@@ -613,13 +614,26 @@ function matches(c) {{
return hay.includes(q);
}}
function activeDims() {{ return includeDemand.checked ? ["利润","产量","库存","需求"] : ["利润","产量","库存"]; }}
function currentDimScore(dim, v) {{
const dynamic = scoreFromCurrentBand(v, inverseDims.has(dim));
if (dynamic != null && Number.isFinite(dynamic)) return dynamic;
return v?.score;
}}
function scoreCommodity(c) {{
const next = Object.assign({{}}, c);
next.dims = Object.fromEntries(Object.entries(c.dims).map(([d, v]) => {{
const score = currentDimScore(d, v);
return [d, Object.assign({{}}, v, {{score: score == null ? null : score}})];
}}));
return Object.assign(next, computeTotal(next));
}}
function computeTotal(c) {{
const keys = activeDims();
const values = keys.map(d => c.dims[d]?.score).filter(v => v != null && Number.isFinite(Number(v))).map(Number);
return {{total: values.length ? values.reduce((s,v)=>s+v,0)/values.length : null, valid: values.length, denom: keys.length}};
}}
function rankedList(list) {{
const mapped = list.map(c => Object.assign({{}}, c, computeTotal(c)));
const mapped = list.map(scoreCommodity);
const scored = mapped.filter(c => c.total != null).sort((a,b)=>b.total-a.total);
scored.forEach((c,i)=>c.displayRank=i+1);
return scored.concat(mapped.filter(c => c.total == null));
@@ -757,6 +771,64 @@ function robustAnnualSeasonValues(points, useRobust=true) {{
.map(x=>x.value)
.sort((a,b)=>a-b);
}}
function rawPointsFromYears(years) {{
const params = chartParams();
const rawPoints = [];
Object.entries(years || {{}}).forEach(([year, pts]) => {{
if (Number(year) < params.startYear) return;
(pts || []).forEach(p => {{
const value = Number(p[1]);
if (Number.isFinite(value)) rawPoints.push({{year:Number(year), md:p[0], day:parseDay(p[0]), value}});
}});
}});
return rawPoints;
}}
function buildRawBand(years, rawPoints=null) {{
const params = chartParams();
const points = rawPoints || rawPointsFromYears(years);
const bandUniverse = points.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
const trendLike = trendLikeAnnualLevel(bandUniverse);
const band = [];
for (let day=1; day<=366; day++) {{
const samples = robustAnnualSeasonValues(bandUniverse.filter(p => circularDiff(p.day, day) <= params.window), !trendLike);
if (samples.length < 3) continue;
const tail = Math.max(0.02, Math.min(0.45, params.trim || 0.10));
const mid = quantile(samples, 0.50);
let low = quantile(samples, tail);
let high = quantile(samples, 1 - tail);
const widthScale = Math.max(0.25, Math.min(2.5, params.k / 3));
low = mid - (mid - low) * widthScale;
high = mid + (high - mid) * widthScale;
const date = new Date(2024, 0, day);
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
band.push([md, low, high, mid]);
}}
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
return smoothBand(band, smoothRadius);
}}
function latestRawPoint(years) {{
const points = rawPointsFromYears(years);
if (!points.length) return null;
return points.sort((a,b) => a.year-b.year || a.day-b.day).at(-1);
}}
function scoreFromCurrentBand(v, inverse=false) {{
if (!v || !v.years) return null;
const latest = latestRawPoint(v.years);
if (!latest) return null;
const rawBand = buildRawBand(v.years);
const row = rawBand.find(p => p[0] === latest.md);
if (!row) return null;
let low = Number(row[1]), high = Number(row[2]);
if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
if (low > high) {{ const tmp = low; low = high; high = tmp; }}
let rawScore;
if (high === low) rawScore = latest.value >= high ? 100 : 0;
else if (latest.value >= high) rawScore = 100;
else if (latest.value <= low) rawScore = 0;
else rawScore = 100 * (latest.value - low) / (high - low);
const score = inverse ? 100 - rawScore : rawScore;
return Math.max(0, Math.min(100, score));
}}
function smoothBand(band, radius) {{
if (!band.length || radius <= 0) return band;
const n = band.length;
@@ -783,18 +855,9 @@ function smoothBand(band, radius) {{
}}
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 rawPoints = rawPointsFromYears(years);
const rawValues = rawPoints.map(p=>p.value);
if (!rawValues.length) return {{years:{{}}, band:[]}};
const bandUniverse = rawPoints.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
const trendLike = trendLikeAnnualLevel(bandUniverse);
const m = mean(rawValues);
let s = std(rawValues);
if (!Number.isFinite(s) || s === 0) s = 1;
@@ -804,23 +867,8 @@ function buildNormalizedChart(years) {{
if (Number(year) < params.startYear) return;
normYears[year] = pts.map(p => [p[0], z(Number(p[1]))]).filter(p => Number.isFinite(p[1]));
}});
const band = [];
for (let day=1; day<=366; day++) {{
const samples = robustAnnualSeasonValues(bandUniverse.filter(p => circularDiff(p.day, day) <= params.window), !trendLike);
if (samples.length < 3) continue;
const tail = Math.max(0.02, Math.min(0.45, params.trim || 0.10));
const mid = quantile(samples, 0.50);
let low = quantile(samples, tail);
let high = quantile(samples, 1 - tail);
const widthScale = Math.max(0.25, Math.min(2.5, params.k / 3));
low = mid - (mid - low) * widthScale;
high = mid + (high - mid) * widthScale;
const date = new Date(2024, 0, day);
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
band.push([md, z(low), z(high), z(mid)]);
}}
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
return {{years:normYears, band:smoothBand(band, smoothRadius)}};
const band = buildRawBand(years, rawPoints).map(p => [p[0], z(p[1]), z(p[2]), z(p[3])]);
return {{years:normYears, band}};
}}
function drawChart(svg, years) {{
const normalized = buildNormalizedChart(years);
+3
View File
@@ -0,0 +1,3 @@
pandas
numpy
openpyxl
File diff suppressed because one or more lines are too long