Python执行环境限制为Docker;建材指数看板新增历史全景图表和周期对比
This commit is contained in:
@@ -8,16 +8,11 @@ AI 协助分析交易数据和回测的仓库。主要用来做策略回测、
|
|||||||
|
|
||||||
## Python 执行环境
|
## Python 执行环境
|
||||||
|
|
||||||
按优先级选择:
|
**仅允许使用 Docker 运行 Python**,严禁使用设备本地 Python:
|
||||||
|
|
||||||
1. **Docker(推荐)** — 隔离环境,避免依赖冲突
|
|
||||||
- **⚠️ 严禁直接 `docker run python:3.11-slim`**,必须先执行 `docker images` 确认本地已有镜像,优先复用
|
- **⚠️ 严禁直接 `docker run python:3.11-slim`**,必须先执行 `docker images` 确认本地已有镜像,优先复用
|
||||||
- 无可用镜像时才允许拉取,默认拉取 `python:3.11-slim`
|
- 无可用镜像时才允许拉取,默认拉取 `python:3.11-slim`
|
||||||
- 示例: `docker images | grep python` 查看已有镜像,再用已有镜像执行
|
- 示例: `docker images | grep python` 查看已有镜像,再用已有镜像执行
|
||||||
2. **设备 Python + venv** — Docker 不可用时的降级方案
|
|
||||||
- 检测 Python: `which python3 || which python`,取其路径
|
|
||||||
- 在项目目录创建 `.venv/`(已 gitignore),`<python> -m venv .venv && source .venv/bin/activate`
|
|
||||||
- 安装包: `pip install <pkg>`
|
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ AI 协助分析交易数据和回测的仓库。主要用来做策略回测、
|
|||||||
|
|
||||||
## Python 执行环境
|
## Python 执行环境
|
||||||
|
|
||||||
按优先级选择:
|
**仅允许使用 Docker 运行 Python**,严禁使用设备本地 Python:
|
||||||
|
|
||||||
1. **Docker(推荐)** — 隔离环境,避免依赖冲突
|
|
||||||
- **⚠️ 严禁直接 `docker run python:3.11-slim`**,必须先执行 `docker images` 确认本地已有镜像,优先复用
|
- **⚠️ 严禁直接 `docker run python:3.11-slim`**,必须先执行 `docker images` 确认本地已有镜像,优先复用
|
||||||
- 无可用镜像时才允许拉取,默认拉取 `python:3.11-slim`
|
- 无可用镜像时才允许拉取,默认拉取 `python:3.11-slim`
|
||||||
- 示例: `docker images | grep python` 查看已有镜像,再用已有镜像执行
|
- 示例: `docker images | grep python` 查看已有镜像,再用已有镜像执行
|
||||||
2. **设备 Python + venv** — Docker 不可用时的降级方案
|
|
||||||
- 检测 Python: `which python3 || which python`,取其路径
|
|
||||||
- 在项目目录创建 `.venv/`(已 gitignore),`<python> -m venv .venv && source .venv/bin/activate`
|
|
||||||
- 安装包: `pip install <pkg>`
|
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
index.html
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>建材价格指数 · 趋势监控</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #fff; --fg: #1e293b; --sub: #64748b;
|
|
||||||
--card-bg: #f8fafc; --card-border: #e2e8f0;
|
|
||||||
--th-bg: #f1f5f9; --th-fg: #334155; --th-border: #cbd5e1;
|
|
||||||
--td-border: #e2e8f0; --td-even: #f8fafc;
|
|
||||||
--green: #16a34a; --red: #dc2626; --amber: #d97706;
|
|
||||||
--up-bg: #dcfce7; --down-bg: #fee2e2;
|
|
||||||
--tag-up-bg: #dcfce7; --tag-up-fg: #166534;
|
|
||||||
--tag-down-bg: #fee2e2; --tag-down-fg: #991b1b;
|
|
||||||
--tag-neutral-bg: #fef3c7; --tag-neutral-fg: #92400e;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bg: #0f172a; --fg: #e2e8f0; --sub: #94a3b8;
|
|
||||||
--card-bg: #1a2332; --card-border: #334155;
|
|
||||||
--th-bg: #1e293b; --th-fg: #cbd5e1; --th-border: #334155;
|
|
||||||
--td-border: #1e293b; --td-even: #1a2332;
|
|
||||||
--up-bg: #1c3b1c; --down-bg: #3b1c1c;
|
|
||||||
--tag-up-bg: #1c3b1c; --tag-up-fg: #86efac;
|
|
||||||
--tag-down-bg: #3b1c1c; --tag-down-fg: #fca5a5;
|
|
||||||
--tag-neutral-bg: #3b2e1c; --tag-neutral-fg: #fcd34d;
|
|
||||||
}
|
|
||||||
body { font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; padding: 24px; max-width: 960px; margin: 0 auto; color: var(--fg); background: var(--bg); }
|
|
||||||
h1 { font-size: 1.4rem; margin-bottom: 4px; }
|
|
||||||
.sub { color: var(--sub); font-size: 0.85rem; margin-bottom: 24px; }
|
|
||||||
|
|
||||||
.toggle-btn { position: fixed; top: 16px; right: 24px; background: var(--th-bg); border: 1px solid var(--th-border); color: var(--th-fg); padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; z-index: 10; }
|
|
||||||
.toggle-btn:hover { opacity: 0.8; }
|
|
||||||
|
|
||||||
/* ---- summary cards ---- */
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
||||||
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 8px; padding: 16px; }
|
|
||||||
.card .label { font-size: 0.78rem; color: var(--sub); margin-bottom: 4px; }
|
|
||||||
.card .value { font-size: 1.6rem; font-weight: 700; }
|
|
||||||
.card .delta { font-size: 0.82rem; margin-top: 4px; }
|
|
||||||
.delta.up { color: var(--green); }
|
|
||||||
.delta.down { color: var(--red); }
|
|
||||||
|
|
||||||
/* ---- signal strip ---- */
|
|
||||||
.signal-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; align-items: center; }
|
|
||||||
.signal-tag { padding: 4px 12px; border-radius: 14px; font-size: 0.82rem; font-weight: 600; }
|
|
||||||
.signal-tag.up { background: var(--tag-up-bg); color: var(--tag-up-fg); }
|
|
||||||
.signal-tag.down { background: var(--tag-down-bg); color: var(--tag-down-fg); }
|
|
||||||
.signal-tag.neutral { background: var(--tag-neutral-bg); color: var(--tag-neutral-fg); }
|
|
||||||
.signal-verdict { font-size: 1.05rem; font-weight: 700; margin-left: 8px; }
|
|
||||||
|
|
||||||
/* ---- MA table ---- */
|
|
||||||
table { border-collapse: collapse; width: 100%; font-size: 0.88rem; margin: 14px 0 20px; }
|
|
||||||
th { background: var(--th-bg); color: var(--th-fg); font-weight: 600; padding: 8px 12px; border: 1px solid var(--th-border); text-align: center; white-space: nowrap; }
|
|
||||||
td { padding: 7px 12px; border: 1px solid var(--td-border); text-align: center; }
|
|
||||||
tr:nth-child(even) td { background: var(--td-even); }
|
|
||||||
td:first-child { font-weight: 500; }
|
|
||||||
|
|
||||||
.arrow-up { color: var(--green); }
|
|
||||||
.arrow-down { color: var(--red); }
|
|
||||||
.arrow-flat { color: var(--sub); }
|
|
||||||
|
|
||||||
h2 { font-size: 1.1rem; margin: 28px 0 10px; padding-bottom: 4px; border-bottom: 1px solid var(--card-border); }
|
|
||||||
|
|
||||||
.row-up td { background: var(--up-bg) !important; }
|
|
||||||
.row-down td { background: var(--down-bg) !important; }
|
|
||||||
[data-theme="dark"] .row-up td { background: var(--up-bg) !important; }
|
|
||||||
[data-theme="dark"] .row-down td { background: var(--down-bg) !important; }
|
|
||||||
|
|
||||||
.footnote { color: var(--sub); font-size: 0.78rem; margin-top: 28px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<button class="toggle-btn" id="themeToggle">🌙 暗夜</button>
|
|
||||||
|
|
||||||
<h1>建材价格指数 · 趋势监控</h1>
|
|
||||||
<p class="sub" id="updateTime"></p>
|
|
||||||
|
|
||||||
<!-- ── 概览卡片 ── -->
|
|
||||||
<div class="cards">
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">最新值</div>
|
|
||||||
<div class="value" id="curValue"></div>
|
|
||||||
<div class="delta" id="curDelta"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">近3月涨跌</div>
|
|
||||||
<div class="value" id="chg3m"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">近6月涨跌</div>
|
|
||||||
<div class="value" id="chg6m"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">近1年涨跌</div>
|
|
||||||
<div class="value" id="chg1y"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── 趋势信号 ── -->
|
|
||||||
<div class="signal-strip" id="signalStrip"></div>
|
|
||||||
|
|
||||||
<!-- ── 均线对比 ── -->
|
|
||||||
<h2>均线系统</h2>
|
|
||||||
<table>
|
|
||||||
<tr><th>均线</th><th>数值</th><th>方向</th><th>价格位置</th></tr>
|
|
||||||
<tbody id="maTable"></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- ── 近30日明细 ── -->
|
|
||||||
<h2>近30日明细</h2>
|
|
||||||
<table>
|
|
||||||
<tr><th>日期</th><th>指数</th><th>日涨跌%</th><th>MA5</th><th>MA20</th><th>MA60</th></tr>
|
|
||||||
<tbody id="recentTable"></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p class="footnote">数据来源:akshare macro_china_construction_index · 建材综合指数</p>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ── 暗夜模式 ──
|
|
||||||
var themeBtn = document.getElementById('themeToggle');
|
|
||||||
function setTheme(dark) {
|
|
||||||
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); themeBtn.textContent = '☀️ 日间'; }
|
|
||||||
else { document.documentElement.removeAttribute('data-theme'); themeBtn.textContent = '🌙 暗夜'; }
|
|
||||||
try { localStorage.setItem('ft-bci-theme', dark ? 'dark' : 'light'); } catch(e) {}
|
|
||||||
}
|
|
||||||
themeBtn.addEventListener('click', function() {
|
|
||||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
|
||||||
});
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
var s = localStorage.getItem('ft-bci-theme');
|
|
||||||
if (s === 'dark' || (!s && matchMedia('(prefers-color-scheme: dark)').matches)) setTheme(true);
|
|
||||||
} catch(e) {}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── 数据渲染 ──
|
|
||||||
var D = {"summary": {"date": "2026-07-23", "value": 915, "change": 0.2191, "chg_3m": 0.6601, "chg_6m": 5.5363, "chg_1y": -1.507}, "mas": {"MA5": {"value": 913.4, "direction": "up", "price_vs_ma": "above"}, "MA10": {"value": 911.6, "direction": "up", "price_vs_ma": "above"}, "MA20": {"value": 906.2, "direction": "up", "price_vs_ma": "above"}, "MA60": {"value": 911.1, "direction": "down", "price_vs_ma": "above"}}, "signals": [{"period": "短期", "signal": "偏多", "detail": "价格 > MA5"}, {"period": "中期", "signal": "偏多", "detail": "MA5 > MA20"}, {"period": "长期", "signal": "偏空", "detail": "MA20 < MA60"}, {"period": "动量(5日)", "signal": "偏多", "detail": "+3"}], "overall": {"signal": "偏多", "color": "#16a34a"}, "recent": [{"date": "06-24", "value": 901, "chg": -0.2215, "MA5": 905.8, "MA20": 914.2, "MA60": 920.5}, {"date": "06-25", "value": 900, "chg": -0.111, "MA5": 904.0, "MA20": 913.1, "MA60": 920.4}, {"date": "06-26", "value": 897, "chg": -0.3333, "MA5": 901.6, "MA20": 911.9, "MA60": 920.1}, {"date": "06-27", "value": 896, "chg": -0.1115, "MA5": 899.4, "MA20": 910.6, "MA60": 919.8}, {"date": "06-28", "value": 896, "chg": 0.0, "MA5": 898.0, "MA20": 909.4, "MA60": 919.5}, {"date": "06-29", "value": 899, "chg": 0.3348, "MA5": 897.6, "MA20": 908.6, "MA60": 919.2}, {"date": "06-30", "value": 898, "chg": -0.1112, "MA5": 897.2, "MA20": 907.6, "MA60": 918.8}, {"date": "07-01", "value": 898, "chg": 0.0, "MA5": 897.4, "MA20": 906.6, "MA60": 918.4}, {"date": "07-02", "value": 898, "chg": 0.0, "MA5": 897.8, "MA20": 905.6, "MA60": 918.1}, {"date": "07-03", "value": 899, "chg": 0.1114, "MA5": 898.4, "MA20": 904.6, "MA60": 917.7}, {"date": "07-04", "value": 899, "chg": 0.0, "MA5": 898.4, "MA20": 903.6, "MA60": 917.4}, {"date": "07-05", "value": 899, "chg": 0.0, "MA5": 898.6, "MA20": 902.8, "MA60": 917.0}, {"date": "07-06", "value": 898, "chg": -0.1112, "MA5": 898.6, "MA20": 901.9, "MA60": 916.6}, {"date": "07-07", "value": 901, "chg": 0.3341, "MA5": 899.2, "MA20": 901.2, "MA60": 916.2}, {"date": "07-08", "value": 899, "chg": -0.222, "MA5": 899.2, "MA20": 900.8, "MA60": 915.7}, {"date": "07-09", "value": 900, "chg": 0.1112, "MA5": 899.4, "MA20": 900.3, "MA60": 915.3}, {"date": "07-10", "value": 903, "chg": 0.3333, "MA5": 900.2, "MA20": 900.0, "MA60": 915.0}, {"date": "07-11", "value": 903, "chg": 0.0, "MA5": 901.2, "MA20": 899.7, "MA60": 914.5}, {"date": "07-12", "value": 903, "chg": 0.0, "MA5": 901.6, "MA20": 899.5, "MA60": 914.1}, {"date": "07-13", "value": 904, "chg": 0.1107, "MA5": 902.6, "MA20": 899.6, "MA60": 913.7}, {"date": "07-14", "value": 905, "chg": 0.1106, "MA5": 903.6, "MA20": 899.8, "MA60": 913.3}, {"date": "07-15", "value": 908, "chg": 0.3315, "MA5": 904.6, "MA20": 900.2, "MA60": 913.0}, {"date": "07-16", "value": 912, "chg": 0.4405, "MA5": 906.4, "MA20": 900.9, "MA60": 912.8}, {"date": "07-17", "value": 912, "chg": 0.0, "MA5": 908.2, "MA20": 901.7, "MA60": 912.5}, {"date": "07-18", "value": 912, "chg": 0.0, "MA5": 909.8, "MA20": 902.5, "MA60": 912.2}, {"date": "07-19", "value": 912, "chg": 0.0, "MA5": 911.2, "MA20": 903.2, "MA60": 912.0}, {"date": "07-20", "value": 914, "chg": 0.2193, "MA5": 912.4, "MA20": 904.0, "MA60": 911.7}, {"date": "07-21", "value": 913, "chg": -0.1094, "MA5": 912.6, "MA20": 904.7, "MA60": 911.5}, {"date": "07-22", "value": 913, "chg": 0.0, "MA5": 912.8, "MA20": 905.4, "MA60": 911.3}, {"date": "07-23", "value": 915, "chg": 0.2191, "MA5": 913.4, "MA20": 906.2, "MA60": 911.1}]};
|
|
||||||
|
|
||||||
document.getElementById('updateTime').textContent = '更新至 ' + D.summary.date;
|
|
||||||
|
|
||||||
// 卡片
|
|
||||||
var sv = D.summary;
|
|
||||||
document.getElementById('curValue').textContent = sv.value;
|
|
||||||
var chgEl = document.getElementById('curDelta');
|
|
||||||
var chg = sv.change;
|
|
||||||
chgEl.textContent = (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%';
|
|
||||||
chgEl.className = 'delta ' + (chg >= 0 ? 'up' : 'down');
|
|
||||||
|
|
||||||
function fmtPct(v) {
|
|
||||||
var s = (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
|
|
||||||
return '<span class="' + (v >= 0 ? 'arrow-up' : 'arrow-down') + '">' + s + '</span>';
|
|
||||||
}
|
|
||||||
document.getElementById('chg3m').innerHTML = fmtPct(sv.chg_3m);
|
|
||||||
document.getElementById('chg6m').innerHTML = fmtPct(sv.chg_6m);
|
|
||||||
document.getElementById('chg1y').innerHTML = fmtPct(sv.chg_1y);
|
|
||||||
|
|
||||||
// 信号
|
|
||||||
var strip = document.getElementById('signalStrip');
|
|
||||||
D.signals.forEach(function(s) {
|
|
||||||
var cls = s.signal === '偏多' ? 'up' : 'down';
|
|
||||||
strip.innerHTML += '<span class="signal-tag ' + cls + '">' + s.period + ': ' + s.signal + '</span>';
|
|
||||||
});
|
|
||||||
strip.innerHTML += '<span class="signal-verdict" style="color:' + D.overall.color + '">综合: ' + D.overall.signal + '</span>';
|
|
||||||
|
|
||||||
// 均线表
|
|
||||||
var maHtml = '';
|
|
||||||
['MA5','MA10','MA20','MA60'].forEach(function(k) {
|
|
||||||
var m = D.mas[k];
|
|
||||||
var dirIcon = m.direction === 'up' ? '↑' : (m.direction === 'down' ? '↓' : '→');
|
|
||||||
var dirCls = 'arrow-' + m.direction;
|
|
||||||
var posText = m.price_vs_ma === 'above' ? '价格在上 ↑' : '价格在下 ↓';
|
|
||||||
var posCls = m.price_vs_ma === 'above' ? 'arrow-up' : 'arrow-down';
|
|
||||||
maHtml += '<tr><td>' + k + '</td><td><strong>' + m.value + '</strong></td><td><span class="' + dirCls + '">' + dirIcon + '</span></td><td><span class="' + posCls + '">' + posText + '</span></td></tr>';
|
|
||||||
});
|
|
||||||
document.getElementById('maTable').innerHTML = maHtml;
|
|
||||||
|
|
||||||
// 近30日明细
|
|
||||||
var recentHtml = '';
|
|
||||||
D.recent.forEach(function(r) {
|
|
||||||
var rowCls = (r.chg >= 0) ? 'row-up' : 'row-down';
|
|
||||||
recentHtml += '<tr class="' + rowCls + '">';
|
|
||||||
recentHtml += '<td>' + r.date + '</td>';
|
|
||||||
recentHtml += '<td>' + r.value + '</td>';
|
|
||||||
recentHtml += '<td>' + (r.chg >= 0 ? '+' : '') + r.chg.toFixed(2) + '%</td>';
|
|
||||||
recentHtml += '<td>' + (r.MA5 != null ? r.MA5 : '-') + '</td>';
|
|
||||||
recentHtml += '<td>' + (r.MA20 != null ? r.MA20 : '-') + '</td>';
|
|
||||||
recentHtml += '<td>' + (r.MA60 != null ? r.MA60 : '-') + '</td>';
|
|
||||||
recentHtml += '</tr>';
|
|
||||||
});
|
|
||||||
document.getElementById('recentTable').innerHTML = recentHtml;
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>建材价格指数 · 趋势监控</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #fff; --fg: #1e293b; --sub: #64748b;
|
||||||
|
--card-bg: #f8fafc; --card-border: #e2e8f0;
|
||||||
|
--th-bg: #f1f5f9; --th-fg: #334155; --th-border: #cbd5e1;
|
||||||
|
--td-border: #e2e8f0; --td-even: #f8fafc;
|
||||||
|
--green: #16a34a; --red: #dc2626; --amber: #d97706;
|
||||||
|
--up-bg: #dcfce7; --down-bg: #fee2e2;
|
||||||
|
--tag-up-bg: #dcfce7; --tag-up-fg: #166534;
|
||||||
|
--tag-down-bg: #fee2e2; --tag-down-fg: #991b1b;
|
||||||
|
--tag-neutral-bg: #fef3c7; --tag-neutral-fg: #92400e;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0f172a; --fg: #e2e8f0; --sub: #94a3b8;
|
||||||
|
--card-bg: #1a2332; --card-border: #334155;
|
||||||
|
--th-bg: #1e293b; --th-fg: #cbd5e1; --th-border: #334155;
|
||||||
|
--td-border: #1e293b; --td-even: #1a2332;
|
||||||
|
--up-bg: #1c3b1c; --down-bg: #3b1c1c;
|
||||||
|
--tag-up-bg: #1c3b1c; --tag-up-fg: #86efac;
|
||||||
|
--tag-down-bg: #3b1c1c; --tag-down-fg: #fca5a5;
|
||||||
|
--tag-neutral-bg: #3b2e1c; --tag-neutral-fg: #fcd34d;
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; padding: 24px; max-width: 960px; margin: 0 auto; color: var(--fg); background: var(--bg); }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 4px; }
|
||||||
|
.sub { color: var(--sub); font-size: 0.85rem; margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.toggle-btn { position: fixed; top: 16px; right: 24px; background: var(--th-bg); border: 1px solid var(--th-border); color: var(--th-fg); padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; z-index: 10; }
|
||||||
|
.toggle-btn:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
/* ---- summary cards ---- */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||||
|
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 8px; padding: 16px; }
|
||||||
|
.card .label { font-size: 0.78rem; color: var(--sub); margin-bottom: 4px; }
|
||||||
|
.card .value { font-size: 1.6rem; font-weight: 700; }
|
||||||
|
.card .delta { font-size: 0.82rem; margin-top: 4px; }
|
||||||
|
.delta.up { color: var(--green); }
|
||||||
|
.delta.down { color: var(--red); }
|
||||||
|
|
||||||
|
/* ---- signal strip ---- */
|
||||||
|
.signal-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; align-items: center; }
|
||||||
|
.signal-tag { padding: 4px 12px; border-radius: 14px; font-size: 0.82rem; font-weight: 600; }
|
||||||
|
.signal-tag.up { background: var(--tag-up-bg); color: var(--tag-up-fg); }
|
||||||
|
.signal-tag.down { background: var(--tag-down-bg); color: var(--tag-down-fg); }
|
||||||
|
.signal-tag.neutral { background: var(--tag-neutral-bg); color: var(--tag-neutral-fg); }
|
||||||
|
.signal-verdict { font-size: 1.05rem; font-weight: 700; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* ---- MA table ---- */
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 0.88rem; margin: 14px 0 20px; }
|
||||||
|
th { background: var(--th-bg); color: var(--th-fg); font-weight: 600; padding: 8px 12px; border: 1px solid var(--th-border); text-align: center; white-space: nowrap; }
|
||||||
|
td { padding: 7px 12px; border: 1px solid var(--td-border); text-align: center; }
|
||||||
|
tr:nth-child(even) td { background: var(--td-even); }
|
||||||
|
td:first-child { font-weight: 500; }
|
||||||
|
|
||||||
|
.arrow-up { color: var(--green); }
|
||||||
|
.arrow-down { color: var(--red); }
|
||||||
|
.arrow-flat { color: var(--sub); }
|
||||||
|
|
||||||
|
h2 { font-size: 1.1rem; margin: 28px 0 10px; padding-bottom: 4px; border-bottom: 1px solid var(--card-border); }
|
||||||
|
|
||||||
|
.row-up td { background: var(--up-bg) !important; }
|
||||||
|
.row-down td { background: var(--down-bg) !important; }
|
||||||
|
[data-theme="dark"] .row-up td { background: var(--up-bg) !important; }
|
||||||
|
[data-theme="dark"] .row-down td { background: var(--down-bg) !important; }
|
||||||
|
|
||||||
|
.footnote { color: var(--sub); font-size: 0.78rem; margin-top: 28px; }
|
||||||
|
|
||||||
|
/* ---- history section ---- */
|
||||||
|
.history-stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; font-size: 0.82rem; }
|
||||||
|
.history-stat { display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.history-stat .label { color: var(--sub); }
|
||||||
|
.history-stat .value { font-weight: 700; }
|
||||||
|
#historyChart { width: 100%; max-width: 900px; height: auto; border: 1px solid var(--card-border); border-radius: 6px; margin: 8px 0 20px; }
|
||||||
|
.cycle-table td:first-child { text-align: left; }
|
||||||
|
.cycle-table .bar-cell { width: 120px; }
|
||||||
|
.cycle-bar { display: inline-block; height: 8px; border-radius: 4px; vertical-align: middle; margin-right: 6px; }
|
||||||
|
.cycle-bar.up { background: var(--green); }
|
||||||
|
.cycle-bar.down { background: var(--red); }
|
||||||
|
.cycle-label { font-size: 0.78rem; color: var(--sub); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<button class="toggle-btn" id="themeToggle">🌙 暗夜</button>
|
||||||
|
|
||||||
|
<h1>建材价格指数 · 趋势监控</h1>
|
||||||
|
<p class="sub" id="updateTime"></p>
|
||||||
|
|
||||||
|
<!-- ── 概览卡片 ── -->
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">最新值</div>
|
||||||
|
<div class="value" id="curValue"></div>
|
||||||
|
<div class="delta" id="curDelta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">近3月涨跌</div>
|
||||||
|
<div class="value" id="chg3m"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">近6月涨跌</div>
|
||||||
|
<div class="value" id="chg6m"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">近1年涨跌</div>
|
||||||
|
<div class="value" id="chg1y"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 趋势信号 ── -->
|
||||||
|
<div class="signal-strip" id="signalStrip"></div>
|
||||||
|
|
||||||
|
<!-- ── 历史全景 ── -->
|
||||||
|
<h2>历史全景 <span style="font-weight:400;font-size:0.82rem;color:var(--sub)">2011.12 – 2026.07</span></h2>
|
||||||
|
<div class="history-stats" id="historyStats"></div>
|
||||||
|
<canvas id="historyChart"></canvas>
|
||||||
|
|
||||||
|
<!-- ── 周期对比 ── -->
|
||||||
|
<h2>周期对比</h2>
|
||||||
|
<table class="cycle-table">
|
||||||
|
<tr><th>周期</th><th>阶段</th><th>区间</th><th>谷 → 峰</th><th>涨幅</th></tr>
|
||||||
|
<tbody id="cycleTable"></tbody>
|
||||||
|
</table>
|
||||||
|
<p id="cycleNote" class="cycle-label"></p>
|
||||||
|
|
||||||
|
<!-- ── 均线系统 ── -->
|
||||||
|
<h2>均线系统</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>均线</th><th>数值</th><th>方向</th><th>价格位置</th></tr>
|
||||||
|
<tbody id="maTable"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- ── 近30日明细 ── -->
|
||||||
|
<h2>近30日明细</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>日期</th><th>指数</th><th>日涨跌%</th><th>MA5</th><th>MA20</th><th>MA60</th></tr>
|
||||||
|
<tbody id="recentTable"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="footnote">数据来源:akshare macro_china_construction_index · 建材综合指数</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── 暗夜模式 ──
|
||||||
|
var themeBtn = document.getElementById('themeToggle');
|
||||||
|
function setTheme(dark) {
|
||||||
|
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); themeBtn.textContent = '☀️ 日间'; }
|
||||||
|
else { document.documentElement.removeAttribute('data-theme'); themeBtn.textContent = '🌙 暗夜'; }
|
||||||
|
try { localStorage.setItem('ft-bci-theme', dark ? 'dark' : 'light'); } catch(e) {}
|
||||||
|
}
|
||||||
|
themeBtn.addEventListener('click', function() {
|
||||||
|
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||||
|
});
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var s = localStorage.getItem('ft-bci-theme');
|
||||||
|
if (s === 'dark' || (!s && matchMedia('(prefers-color-scheme: dark)').matches)) setTheme(true);
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── 数据渲染 ──
|
||||||
|
var D = /*__DATA_SENTINEL__*/{};
|
||||||
|
|
||||||
|
document.getElementById('updateTime').textContent = '更新至 ' + D.summary.date;
|
||||||
|
|
||||||
|
// 卡片
|
||||||
|
var sv = D.summary;
|
||||||
|
document.getElementById('curValue').textContent = sv.value;
|
||||||
|
var chgEl = document.getElementById('curDelta');
|
||||||
|
var chg = sv.change;
|
||||||
|
chgEl.textContent = (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%';
|
||||||
|
chgEl.className = 'delta ' + (chg >= 0 ? 'up' : 'down');
|
||||||
|
|
||||||
|
function fmtPct(v) {
|
||||||
|
var s = (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
|
||||||
|
return '<span class="' + (v >= 0 ? 'arrow-up' : 'arrow-down') + '">' + s + '</span>';
|
||||||
|
}
|
||||||
|
document.getElementById('chg3m').innerHTML = fmtPct(sv.chg_3m);
|
||||||
|
document.getElementById('chg6m').innerHTML = fmtPct(sv.chg_6m);
|
||||||
|
document.getElementById('chg1y').innerHTML = fmtPct(sv.chg_1y);
|
||||||
|
|
||||||
|
// 信号
|
||||||
|
var strip = document.getElementById('signalStrip');
|
||||||
|
D.signals.forEach(function(s) {
|
||||||
|
var cls = s.signal === '偏多' ? 'up' : 'down';
|
||||||
|
strip.innerHTML += '<span class="signal-tag ' + cls + '">' + s.period + ': ' + s.signal + '</span>';
|
||||||
|
});
|
||||||
|
strip.innerHTML += '<span class="signal-verdict" style="color:' + D.overall.color + '">综合: ' + D.overall.signal + '</span>';
|
||||||
|
|
||||||
|
// 均线表
|
||||||
|
var maHtml = '';
|
||||||
|
['MA5','MA10','MA20','MA60'].forEach(function(k) {
|
||||||
|
var m = D.mas[k];
|
||||||
|
var dirIcon = m.direction === 'up' ? '↑' : (m.direction === 'down' ? '↓' : '→');
|
||||||
|
var dirCls = 'arrow-' + m.direction;
|
||||||
|
var posText = m.price_vs_ma === 'above' ? '价格在上 ↑' : '价格在下 ↓';
|
||||||
|
var posCls = m.price_vs_ma === 'above' ? 'arrow-up' : 'arrow-down';
|
||||||
|
maHtml += '<tr><td>' + k + '</td><td><strong>' + m.value + '</strong></td><td><span class="' + dirCls + '">' + dirIcon + '</span></td><td><span class="' + posCls + '">' + posText + '</span></td></tr>';
|
||||||
|
});
|
||||||
|
document.getElementById('maTable').innerHTML = maHtml;
|
||||||
|
|
||||||
|
// 近30日明细
|
||||||
|
var recentHtml = '';
|
||||||
|
D.recent.forEach(function(r) {
|
||||||
|
var rowCls = (r.chg >= 0) ? 'row-up' : 'row-down';
|
||||||
|
recentHtml += '<tr class="' + rowCls + '">';
|
||||||
|
recentHtml += '<td>' + r.date + '</td>';
|
||||||
|
recentHtml += '<td>' + r.value + '</td>';
|
||||||
|
recentHtml += '<td>' + (r.chg >= 0 ? '+' : '') + r.chg.toFixed(2) + '%</td>';
|
||||||
|
recentHtml += '<td>' + (r.MA5 != null ? r.MA5 : '-') + '</td>';
|
||||||
|
recentHtml += '<td>' + (r.MA20 != null ? r.MA20 : '-') + '</td>';
|
||||||
|
recentHtml += '<td>' + (r.MA60 != null ? r.MA60 : '-') + '</td>';
|
||||||
|
recentHtml += '</tr>';
|
||||||
|
});
|
||||||
|
document.getElementById('recentTable').innerHTML = recentHtml;
|
||||||
|
|
||||||
|
// ── 历史全景渲染 ──
|
||||||
|
(function renderHistory() {
|
||||||
|
var H = D.history;
|
||||||
|
if (!H || !H.monthly) return;
|
||||||
|
|
||||||
|
// 统计条
|
||||||
|
var s = H.stats;
|
||||||
|
var stHtml = '';
|
||||||
|
stHtml += '<div class="history-stat"><span class="label">均值</span><span class="value">' + s.mean + '</span></div>';
|
||||||
|
stHtml += '<div class="history-stat"><span class="label">历史低位</span><span class="value" style="color:var(--green)">' + s.min + '</span><span class="label">(' + s.min_date + ')</span></div>';
|
||||||
|
stHtml += '<div class="history-stat"><span class="label">历史高位</span><span class="value" style="color:var(--red)">' + s.max + '</span><span class="label">(' + s.max_date + ')</span></div>';
|
||||||
|
stHtml += '<div class="history-stat"><span class="label">当前分位</span><span class="value">' + s.percentile + '%</span></div>';
|
||||||
|
stHtml += '<div class="history-stat"><span class="label">下跌年数</span><span class="value">' + s.down_years + ' 年</span><span class="label">/ ' + (new Date().getFullYear() - 2012) + ' 年</span></div>';
|
||||||
|
stHtml += '<div class="history-stat"><span class="label">年迄今</span><span class="value" style="color:' + (s.ytd_chg >= 0 ? 'var(--green)' : 'var(--red)') + '">' + (s.ytd_chg >= 0 ? '+' : '') + s.ytd_chg.toFixed(1) + '%</span></div>';
|
||||||
|
document.getElementById('historyStats').innerHTML = stHtml;
|
||||||
|
|
||||||
|
// Canvas 图表
|
||||||
|
var canvas = document.getElementById('historyChart');
|
||||||
|
var dpr = window.devicePixelRatio || 1;
|
||||||
|
var W = canvas.parentElement.clientWidth - 2;
|
||||||
|
var H_c = Math.round(W * 0.42);
|
||||||
|
canvas.width = W * dpr;
|
||||||
|
canvas.height = H_c * dpr;
|
||||||
|
canvas.style.width = W + 'px';
|
||||||
|
canvas.style.height = H_c + 'px';
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
var C = {
|
||||||
|
bg: isDark ? '#0f172a' : '#fff',
|
||||||
|
grid: isDark ? '#1e293b' : '#f1f5f9',
|
||||||
|
text: isDark ? '#94a3b8' : '#94a3b8',
|
||||||
|
price: isDark ? '#e2e8f0' : '#334155',
|
||||||
|
ma60: '#3b82f6',
|
||||||
|
upFill: isDark ? 'rgba(22,163,74,0.08)' : 'rgba(22,163,74,0.06)',
|
||||||
|
downFill: isDark ? 'rgba(220,38,38,0.06)' : 'rgba(220,38,38,0.04)',
|
||||||
|
marker: '#f59e0b',
|
||||||
|
cycleUp: 'rgba(22,163,74,0.12)',
|
||||||
|
cycleDown: 'rgba(220,38,38,0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
var data = H.monthly;
|
||||||
|
var pad = { top: 20, right: 30, bottom: 32, left: 48 };
|
||||||
|
var pw = W - pad.left - pad.right;
|
||||||
|
var ph = H_c - pad.top - pad.bottom;
|
||||||
|
|
||||||
|
var vals = data.map(function(d) { return d.value; });
|
||||||
|
var ma60vals = data.map(function(d) { return d.ma60; });
|
||||||
|
var yMin = Math.floor(Math.min.apply(null, vals) / 10) * 10 - 20;
|
||||||
|
var yMax = Math.ceil(Math.max.apply(null, vals) / 10) * 10 + 20;
|
||||||
|
|
||||||
|
function x(i) { return pad.left + (i / (data.length - 1)) * pw; }
|
||||||
|
function y(v) { return pad.top + ph - ((v - yMin) / (yMax - yMin)) * ph; }
|
||||||
|
|
||||||
|
// 背景
|
||||||
|
ctx.fillStyle = C.bg;
|
||||||
|
ctx.fillRect(0, 0, W, H_c);
|
||||||
|
|
||||||
|
// 价格区域填充
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x(0), y(yMax));
|
||||||
|
for (var i = 0; i < data.length; i++) ctx.lineTo(x(i), y(vals[i]));
|
||||||
|
ctx.lineTo(x(data.length - 1), y(yMax));
|
||||||
|
ctx.closePath();
|
||||||
|
var grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ph);
|
||||||
|
grad.addColorStop(0, isDark ? 'rgba(59,130,246,0.12)' : 'rgba(59,130,246,0.08)');
|
||||||
|
grad.addColorStop(1, 'rgba(59,130,246,0)');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 网格
|
||||||
|
ctx.strokeStyle = C.grid;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
var ySteps = 5;
|
||||||
|
for (var j = 0; j <= ySteps; j++) {
|
||||||
|
var vy = yMin + (yMax - yMin) * j / ySteps;
|
||||||
|
var py = y(vy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.left, py);
|
||||||
|
ctx.lineTo(pad.left + pw, py);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = C.text;
|
||||||
|
ctx.font = '10px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(Math.round(vy), pad.left - 6, py + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年份标签
|
||||||
|
ctx.fillStyle = C.text;
|
||||||
|
ctx.font = '10px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
var lastYear = '';
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
var yr = data[i].date.substring(0, 4);
|
||||||
|
if (yr !== lastYear) {
|
||||||
|
ctx.fillText(yr, x(i), pad.top + ph + 16);
|
||||||
|
lastYear = yr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前值虚线
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.strokeStyle = C.marker;
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x(data.length - 1), pad.top);
|
||||||
|
ctx.lineTo(x(data.length - 1), pad.top + ph);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// 均值虚线
|
||||||
|
var meanY = y(s.mean);
|
||||||
|
ctx.setLineDash([3, 5]);
|
||||||
|
ctx.strokeStyle = C.text;
|
||||||
|
ctx.lineWidth = 0.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.left, meanY);
|
||||||
|
ctx.lineTo(pad.left + pw, meanY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = C.text;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('均值 ' + s.mean, pad.left + 4, meanY - 3);
|
||||||
|
|
||||||
|
// MA60 线
|
||||||
|
ctx.beginPath();
|
||||||
|
var started = false;
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
if (ma60vals[i] == null) continue;
|
||||||
|
if (!started) { ctx.moveTo(x(i), y(ma60vals[i])); started = true; }
|
||||||
|
else ctx.lineTo(x(i), y(ma60vals[i]));
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = C.ma60;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 价格线
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x(0), y(vals[0]));
|
||||||
|
for (var i = 1; i < data.length; i++) ctx.lineTo(x(i), y(vals[i]));
|
||||||
|
ctx.strokeStyle = C.price;
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 周期高亮区域
|
||||||
|
if (H.cycles) {
|
||||||
|
H.cycles.forEach(function(c) {
|
||||||
|
var si = data.findIndex(function(d) { return d.date >= c.start; });
|
||||||
|
var ei = data.findIndex(function(d) { return d.date > c.end; });
|
||||||
|
if (si < 0) si = 0;
|
||||||
|
if (ei < 0) ei = data.length - 1;
|
||||||
|
ctx.fillStyle = c.pct > 15 ? C.cycleUp : C.cycleDown;
|
||||||
|
ctx.fillRect(x(si), pad.top, x(ei) - x(si), ph);
|
||||||
|
// 标签
|
||||||
|
if (c.label) {
|
||||||
|
var lx = (x(si) + x(ei)) / 2;
|
||||||
|
ctx.fillStyle = C.text;
|
||||||
|
ctx.font = 'bold 9px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(c.label + ' +' + c.pct + '%', lx, pad.top + 12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前反弹区域
|
||||||
|
if (H.current_rebound && H.current_rebound.pct > 0) {
|
||||||
|
var cr = H.current_rebound;
|
||||||
|
var si2 = data.findIndex(function(d) { return d.date >= cr.start; });
|
||||||
|
if (si2 >= 0) {
|
||||||
|
ctx.fillStyle = isDark ? 'rgba(245,158,11,0.10)' : 'rgba(245,158,11,0.12)';
|
||||||
|
ctx.fillRect(x(si2), pad.top, x(data.length - 1) - x(si2), ph);
|
||||||
|
ctx.fillStyle = '#d97706';
|
||||||
|
ctx.font = 'bold 9px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('反弹 +' + cr.pct + '%', (x(si2) + x(data.length - 1)) / 2, pad.top + ph - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前值圆点
|
||||||
|
var cx = x(data.length - 1), cy = y(vals[data.length - 1]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = C.marker;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = isDark ? '#0f172a' : '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 标注
|
||||||
|
ctx.fillStyle = C.marker;
|
||||||
|
ctx.font = 'bold 12px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(vals[data.length - 1], cx + 8, cy + 4);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── 周期对比表 ──
|
||||||
|
(function renderCycles() {
|
||||||
|
var H = D.history;
|
||||||
|
if (!H) return;
|
||||||
|
|
||||||
|
var allCycles = (H.cycles || []).slice();
|
||||||
|
if (H.current_rebound) allCycles.push(H.current_rebound);
|
||||||
|
|
||||||
|
var maxPct = 0;
|
||||||
|
allCycles.forEach(function(c) { if (c.pct > maxPct) maxPct = c.pct; });
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
allCycles.forEach(function(c, idx) {
|
||||||
|
var isCurrent = c.end == null;
|
||||||
|
var rowStyle = isCurrent ? ' style="font-weight:700"' : '';
|
||||||
|
var barW = Math.max(4, Math.round((c.pct / maxPct) * 100));
|
||||||
|
var barCls = c.pct >= 0 ? 'up' : 'down';
|
||||||
|
|
||||||
|
html += '<tr' + rowStyle + '>';
|
||||||
|
html += '<td>' + (c.label || ('周期 ' + (idx + 1))) + '</td>';
|
||||||
|
html += '<td>' + (c.pct >= 15 ? '大牛市' : (c.pct >= 0 ? '反弹' : '下跌')) + '</td>';
|
||||||
|
html += '<td>' + c.start + ' → ' + (c.end || '至今') + '</td>';
|
||||||
|
html += '<td>' + c.from + ' → ' + c.to + '</td>';
|
||||||
|
html += '<td class="bar-cell"><span class="cycle-bar ' + barCls + '" style="width:' + barW + 'px"></span>' + (c.pct > 0 ? '+' : '') + c.pct.toFixed(1) + '%</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
document.getElementById('cycleTable').innerHTML = html;
|
||||||
|
|
||||||
|
// 注释
|
||||||
|
var note = '';
|
||||||
|
if (H.ma20_vs_ma60 === 'below') {
|
||||||
|
note += '⚠ MA20 仍低于 MA60,长期空头排列尚未修复。';
|
||||||
|
}
|
||||||
|
if (H.current_rebound && H.current_rebound.pct < 15) {
|
||||||
|
if (allCycles.length > 0) {
|
||||||
|
var bigCycles = allCycles.filter(function(c) { return c.pct >= 15; });
|
||||||
|
if (bigCycles.length > 0) {
|
||||||
|
note += ' 历史大级别反弹均 >15%,当前涨幅尚属弱势反弹范畴。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('cycleNote').textContent = note;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+113
-1
@@ -66,19 +66,131 @@ for _, r in df.tail(30).iterrows():
|
|||||||
'MA60': None if pd.isna(r['MA60']) else round(float(r['MA60']), 1),
|
'MA60': None if pd.isna(r['MA60']) else round(float(r['MA60']), 1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def build_history(df):
|
||||||
|
"""构建历史全景数据:月度采样 + 周期标注 + 统计。"""
|
||||||
|
|
||||||
|
# 月度采样(取每月最后一条有效记录)
|
||||||
|
monthly = df.set_index('日期').resample('ME')['最新值'].last().dropna().reset_index()
|
||||||
|
ma60_monthly = df.set_index('日期').resample('ME')['MA60'].last().dropna().reset_index()
|
||||||
|
monthly_vals = []
|
||||||
|
for _, r in monthly.iterrows():
|
||||||
|
ma_row = ma60_monthly[ma60_monthly['日期'] == r['日期']]
|
||||||
|
ma60_val = None if ma_row.empty or pd.isna(ma_row['MA60'].iloc[0]) else round(float(ma_row['MA60'].iloc[0]), 1)
|
||||||
|
monthly_vals.append({
|
||||||
|
'date': r['日期'].strftime('%Y-%m'),
|
||||||
|
'value': int(r['最新值']),
|
||||||
|
'ma60': ma60_val,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 历史统计
|
||||||
|
vals = df['最新值']
|
||||||
|
latest_val = float(vals.iloc[-1])
|
||||||
|
pct_rank = round((vals < latest_val).sum() / len(vals) * 100, 1)
|
||||||
|
|
||||||
|
# 年度涨跌(排除不足 200 个交易日的首尾年)
|
||||||
|
df['year'] = df['日期'].dt.year
|
||||||
|
yearly = df.groupby('year')['最新值'].agg(['first', 'last', 'count'])
|
||||||
|
yearly = yearly[yearly['count'] >= 200]
|
||||||
|
yearly['chg'] = ((yearly['last'] - yearly['first']) / yearly['first'] * 100).round(1)
|
||||||
|
down_years = int((yearly['chg'] < 0).sum())
|
||||||
|
ytd = float(yearly['chg'].iloc[-1]) if len(yearly) > 0 else 0
|
||||||
|
|
||||||
|
# 识别主要周期:用未取整的 MA60 找极值,间距至少 4 个月
|
||||||
|
ma60_raw = df['最新值'].rolling(60).mean().values # 未取整,保留精度
|
||||||
|
all_turns = [] # (date, value, type)
|
||||||
|
min_gap = 80 # 交易日,约 4 个月
|
||||||
|
last_turn_idx = -999
|
||||||
|
for i in range(2, len(ma60_raw) - 2):
|
||||||
|
if pd.isna(ma60_raw[i]):
|
||||||
|
continue
|
||||||
|
if ma60_raw[i] > ma60_raw[i - 1] and ma60_raw[i] > ma60_raw[i - 2] and ma60_raw[i] > ma60_raw[i + 1] and ma60_raw[i] > ma60_raw[i + 2]:
|
||||||
|
if i - last_turn_idx >= min_gap:
|
||||||
|
all_turns.append((df['日期'].iloc[i], float(ma60_raw[i]), 'peak'))
|
||||||
|
last_turn_idx = i
|
||||||
|
elif ma60_raw[i] < ma60_raw[i - 1] and ma60_raw[i] < ma60_raw[i - 2] and ma60_raw[i] < ma60_raw[i + 1] and ma60_raw[i] < ma60_raw[i + 2]:
|
||||||
|
if i - last_turn_idx >= min_gap:
|
||||||
|
all_turns.append((df['日期'].iloc[i], float(ma60_raw[i]), 'trough'))
|
||||||
|
last_turn_idx = i
|
||||||
|
|
||||||
|
# 构建周期(谷 → 下一个峰)
|
||||||
|
cycles = []
|
||||||
|
for i, (t_date, t_val, t_type) in enumerate(all_turns):
|
||||||
|
if t_type != 'trough':
|
||||||
|
continue
|
||||||
|
# 找下一个峰
|
||||||
|
for j in range(i + 1, len(all_turns)):
|
||||||
|
if all_turns[j][2] == 'peak':
|
||||||
|
p_date, p_val, _ = all_turns[j]
|
||||||
|
gain = round((p_val - t_val) / t_val * 100, 1)
|
||||||
|
if gain > 10:
|
||||||
|
label = ''
|
||||||
|
if t_date.year == 2015 or t_date.year == 2016:
|
||||||
|
label = '供给侧改革'
|
||||||
|
elif t_date.year == 2020:
|
||||||
|
label = '疫情后刺激'
|
||||||
|
cycles.append({
|
||||||
|
'label': label,
|
||||||
|
'start': t_date.strftime('%Y-%m'),
|
||||||
|
'end': p_date.strftime('%Y-%m'),
|
||||||
|
'from': int(t_val),
|
||||||
|
'to': int(p_val),
|
||||||
|
'pct': gain,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
# 当前反弹(用价格实际谷底,不是 MA60 谷底)
|
||||||
|
# 找最近 2 年的价格最低点
|
||||||
|
recent_2y = df[df['日期'] >= df['日期'].iloc[-1] - pd.DateOffset(years=2)]
|
||||||
|
trough_idx = recent_2y['最新值'].idxmin()
|
||||||
|
trough_date = df.loc[trough_idx, '日期']
|
||||||
|
trough_val = float(df.loc[trough_idx, '最新值'])
|
||||||
|
cur_gain = round((latest_val - trough_val) / trough_val * 100, 1)
|
||||||
|
current_rebound = {
|
||||||
|
'label': '当前反弹',
|
||||||
|
'start': trough_date.strftime('%Y-%m'),
|
||||||
|
'end': None,
|
||||||
|
'from': int(trough_val),
|
||||||
|
'to': int(latest_val),
|
||||||
|
'pct': cur_gain,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 均线结构
|
||||||
|
last = df.iloc[-1]
|
||||||
|
ma20_vs_ma60 = 'above' if last['MA20'] > last['MA60'] else 'below'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'monthly': monthly_vals,
|
||||||
|
'stats': {
|
||||||
|
'mean': round(float(vals.mean()), 1),
|
||||||
|
'min': int(vals.min()),
|
||||||
|
'max': int(vals.max()),
|
||||||
|
'min_date': df.loc[vals.idxmin(), '日期'].strftime('%Y-%m'),
|
||||||
|
'max_date': df.loc[vals.idxmax(), '日期'].strftime('%Y-%m'),
|
||||||
|
'percentile': pct_rank,
|
||||||
|
'down_years': down_years,
|
||||||
|
'ytd_chg': round(ytd, 1),
|
||||||
|
},
|
||||||
|
'cycles': cycles,
|
||||||
|
'current_rebound': current_rebound,
|
||||||
|
'ma20_vs_ma60': ma20_vs_ma60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
output = {
|
output = {
|
||||||
'summary': summary,
|
'summary': summary,
|
||||||
'mas': mas,
|
'mas': mas,
|
||||||
'signals': [{'period': p, 'signal': s, 'detail': d} for p, s, d in signals],
|
'signals': [{'period': p, 'signal': s, 'detail': d} for p, s, d in signals],
|
||||||
'overall': {'signal': overall[0], 'color': overall[1]},
|
'overall': {'signal': overall[0], 'color': overall[1]},
|
||||||
'recent': rows,
|
'recent': rows,
|
||||||
|
'history': build_history(df),
|
||||||
}
|
}
|
||||||
|
|
||||||
with open('data.json', 'w') as f:
|
with open('data.json', 'w') as f:
|
||||||
json.dump(output, f, ensure_ascii=False, indent=2)
|
json.dump(output, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
SENTINEL = '/*__DATA_SENTINEL__*/{}'
|
SENTINEL = '/*__DATA_SENTINEL__*/{}'
|
||||||
html = open('index.html').read()
|
html = open('index_template.html').read()
|
||||||
html = html.replace(SENTINEL, json.dumps(output, ensure_ascii=False))
|
html = html.replace(SENTINEL, json.dumps(output, ensure_ascii=False))
|
||||||
open('index.html', 'w').write(html)
|
open('index.html', 'w').write(html)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user