新增价差走势图脚本,含01/05/09合约配对和均值回归交易信号

This commit is contained in:
fish
2026-07-20 09:53:54 +08:00
parent a5c17b6ba0
commit d74829ad32
2 changed files with 565 additions and 0 deletions
+163
View File
File diff suppressed because one or more lines are too long
+402
View File
@@ -0,0 +1,402 @@
"""
主力合约 1月/5月/9月 两两价差走势图 + 均值回归交易信号。
输出 HTML 图表,浏览器打开即可查看。
用法: python3 spread_chart.py
"""
import sqlite3
import json
import os
from collections import defaultdict
from statistics import mean, stdev
DB_PATH = os.path.join(os.path.dirname(__file__), "db", "futures.db")
OUT_PATH = os.path.join(os.path.dirname(__file__), "spread_chart.html")
WINDOW = 60
ENTRY_Z = 2.0
def build_spreads():
"""构建三组价差序列:01-05, 05-09, 01-09(同年配对)。"""
conn = sqlite3.connect(DB_PATH)
daily = conn.execute(
"SELECT ts_code, trade_date, close FROM daily WHERE close IS NOT NULL AND close != '' ORDER BY trade_date"
).fetchall()
conn.close()
by_date = defaultdict(dict)
for ts_code, d, close in daily:
by_date[d][ts_code] = float(close)
dates = sorted(by_date.keys())
def build(month_a, month_b, cross_year=False):
per_date = {}
for d in dates:
items = by_date[d]
best_yr = -1
best = None
for ts_code in items:
if not ts_code.endswith(".ZCE") or not ts_code.startswith("FG"):
continue
yr_str, mon = ts_code[2:4], ts_code[4:6]
if mon == month_a:
yr = int(yr_str)
yr_b_str = str(yr + 1) if cross_year else yr_str
ts_b = f"FG{yr_b_str}{month_b}.ZCE"
if ts_b in items and yr > best_yr:
best_yr = yr
spread = items[ts_b] - items[ts_code]
best = (d, spread)
if best:
per_date[best[0]] = best[1]
sorted_dates = sorted(per_date.keys())
return sorted_dates, [per_date[d] for d in sorted_dates]
return {
"01-05": build("01", "05"),
"05-09": build("05", "09"),
"01-09": build("01", "09"),
}
def compute_signals(spread_data):
"""计算每个价差的滚动 z-score 和交易信号。"""
month_map = {
"01-05": ("01", "05"),
"05-09": ("05", "09"),
"01-09": ("01", "09"),
}
signals = {}
for label, (dates, values) in spread_data.items():
if len(values) < WINDOW + 1:
signals[label] = None
continue
ma, mb = month_map[label]
recent = values[-WINDOW:]
roll_mean = mean(recent)
roll_std = stdev(recent)
latest = values[-1]
z = (latest - roll_mean) / roll_std if roll_std > 0 else 0
if z > ENTRY_Z:
action = f"做空{mb},做多{ma}"
elif z < -ENTRY_Z:
action = f"做多{mb},做空{ma}"
else:
action = "观望"
z_dates = dates[WINDOW:]
z_values = []
for i in range(WINDOW, len(values)):
h = values[i - WINDOW : i]
m = mean(h)
s = stdev(h)
z_values.append((values[i] - m) / s if s > 0 else 0)
signals[label] = {
"latest": latest,
"latest_date": dates[-1],
"mean": round(roll_mean, 1),
"std": round(roll_std, 1),
"z_score": round(z, 2),
"action": action,
"count": len(values),
"z_dates": z_dates,
"z_values": z_values,
}
return signals
def build_chart_data(spread_data, signals):
"""构建前端所需的数据结构。"""
all_dates_set = set()
for dates, _ in spread_data.values():
all_dates_set.update(dates)
all_dates = sorted(all_dates_set)
colors = {
"01-05": {"line": "#06b6d4", "area": "rgba(6,182,212,0.08)"},
"05-09": {"line": "#22c55e", "area": "rgba(34,197,94,0.08)"},
"01-09": {"line": "#f59e0b", "area": "rgba(245,158,11,0.08)"},
}
# 主图数据集
datasets = []
for label, (dates, values) in spread_data.items():
date_map = dict(zip(dates, values))
series = [date_map.get(d) for d in all_dates]
c = colors[label]
datasets.append({
"label": label,
"data": series,
"borderColor": c["line"],
"backgroundColor": c["area"],
"borderWidth": 1.5,
"pointRadius": 0,
"fill": True,
"tension": 0.1,
})
# z-score 数据集
z_datasets = []
for label, sig in signals.items():
if sig is None:
continue
date_map = dict(zip(sig["z_dates"], sig["z_values"]))
series = [date_map.get(d) for d in all_dates]
c = colors[label]
z_datasets.append({
"label": label,
"data": series,
"borderColor": c["line"],
"backgroundColor": "transparent",
"borderWidth": 1,
"pointRadius": 0,
"tension": 0.1,
})
# 统计卡片
stat_cards = []
for label, (dates, values) in spread_data.items():
if not values:
continue
pos = sum(1 for v in values if v > 0)
neg = sum(1 for v in values if v < 0)
stat_cards.append({
"label": label,
"color": colors[label]["line"],
"count": len(values),
"avg": round(sum(values) / len(values), 1),
"max": max(values),
"min": min(values),
"pos": pos,
"neg": neg,
})
# 信号卡片
sig_cards = []
for label, sig in signals.items():
if sig is None:
continue
if "做空" in sig["action"]:
sig_type = "bear"
sig_color = "#ef4444"
elif "做多" in sig["action"]:
sig_type = "bull"
sig_color = "#22c55e"
else:
sig_type = "neutral"
sig_color = "#94a3b8"
sig_cards.append({
"label": label,
"color": colors[label]["line"],
"sig_type": sig_type,
"sig_color": sig_color,
"action": sig["action"],
"latest": sig["latest"],
"mean": sig["mean"],
"std": sig["std"],
"z_score": sig["z_score"],
"latest_date": sig["latest_date"],
})
return {
"all_dates": all_dates,
"datasets": datasets,
"z_datasets": z_datasets,
"stat_cards": stat_cards,
"sig_cards": sig_cards,
}
TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FG 主力合约价差走势</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f172a; color: #e2e8f0; font-family: system-ui, sans-serif; padding: 24px; }
h1 { font-size: 20px; margin-bottom: 8px; color: #f1f5f9; }
.sub { font-size: 13px; color: #64748b; margin-bottom: 24px; }
.chart-box { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.chart-box canvas { width: 100% !important; }
.stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.stat-card { background: #1e293b; border-radius: 12px; padding: 16px 20px; min-width: 170px; flex: 1; }
.stat-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.stat-row { font-size: 12px; color: #94a3b8; margin: 2px 0; display: flex; justify-content: space-between; }
.stat-row .val { color: #e2e8f0; font-weight: 500; }
.val.pos { color: #22c55e; }
.val.neg { color: #ef4444; }
.sig-section { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.sig-card { background: #1e293b; border-radius: 12px; padding: 16px 20px; min-width: 210px; flex: 1; border-left: 3px solid #334155; }
.sig-card.bull { border-left-color: #22c55e; }
.sig-card.bear { border-left-color: #ef4444; }
.sig-card.neutral { border-left-color: #64748b; }
.sig-hdr { font-size: 12px; color: #64748b; margin-bottom: 2px; }
.sig-action { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
.sig-row { font-size: 12px; color: #94a3b8; margin: 2px 0; }
.sig-row b { color: #e2e8f0; }
</style>
</head>
<body>
<h1>玻璃期货 — 主力合约价差分析</h1>
<p class="sub">1月 / 5月 / 9月 两两价差(同年配对,远月减近月) | 均值回归信号(60日滚动,2σ 入场)</p>
<div class="chart-box">
<canvas id="chart"></canvas>
</div>
<div class="stats" id="stats"></div>
<div class="sig-section" id="signals"></div>
<div class="chart-box">
<canvas id="zchart"></canvas>
</div>
<script>
const D = __DATA__;
// ── 统计卡片 ──
let statsHtml = '';
for (const s of D.statCards) {
const cls = v => v >= 0 ? 'pos' : 'neg';
statsHtml += `
<div class="stat-card">
<div class="stat-title" style="color:${s.color}">${s.label}</div>
<div class="stat-row"><span>数据天数</span><span class="val">${s.count}</span></div>
<div class="stat-row"><span>平均价差</span><span class="val ${cls(s.avg)}">${s.avg}</span></div>
<div class="stat-row"><span>最大升水</span><span class="val pos">+${s.max}</span></div>
<div class="stat-row"><span>最大贴水</span><span class="val neg">${s.min}</span></div>
<div class="stat-row"><span>升水/贴水</span><span class="val"><span class="pos">${s.pos}</span>/<span class="neg">${s.neg}</span></span></div>
</div>`;
}
document.getElementById('stats').innerHTML = statsHtml;
// ── 信号卡片 ──
let sigHtml = '';
for (const s of D.sigCards) {
const cls = v => v >= 0 ? 'pos' : 'neg';
sigHtml += `
<div class="sig-card ${s.sigType}">
<div class="sig-hdr">${s.label}</div>
<div class="sig-action" style="color:${s.sigColor}">${s.action}</div>
<div class="sig-row">当前价差: <b class="${cls(s.latest)}">${s.latest}</b> (${s.latestDate})</div>
<div class="sig-row">近60日均值: <b>${s.mean}</b> &sigma;: <b>${s.std}</b></div>
<div class="sig-row">z值: <b class="${cls(s.zScore)}">${s.zScore >= 0 ? '+' : ''}${s.zScore}</b> (超 2 触发)</div>
</div>`;
}
document.getElementById('signals').innerHTML = sigHtml;
// ── 主图:价差走势 ──
new Chart(document.getElementById('chart'), {
type: 'line',
data: { labels: D.allDates, datasets: D.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#94a3b8', boxWidth: 14, padding: 16, usePointStyle: true } },
tooltip: {
backgroundColor: '#0f172a', titleColor: '#f1f5f9', bodyColor: '#e2e8f0',
borderColor: '#334155', borderWidth: 1,
callbacks: {
label: ctx => ctx.parsed.y !== null ? ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) : ''
}
}
},
scales: {
x: { ticks: { color: '#64748b', maxTicksLimit: 20, font: { size: 10 } }, grid: { color: '#1e293b' } },
y: {
ticks: { color: '#94a3b8' }, grid: { color: '#334155' },
title: { display: true, text: '价差(元/吨)', color: '#94a3b8' }
}
}
}
});
// ── 副图:z-score ──
new Chart(document.getElementById('zchart'), {
type: 'line',
data: { labels: D.allDates, datasets: D.zDatasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#94a3b8', boxWidth: 14, padding: 16, usePointStyle: true } },
tooltip: {
backgroundColor: '#0f172a', titleColor: '#f1f5f9', bodyColor: '#e2e8f0',
borderColor: '#334155', borderWidth: 1,
callbacks: {
label: ctx => ctx.parsed.y !== null ? ctx.dataset.label + ': z=' + ctx.parsed.y.toFixed(2) : ''
}
}
},
scales: {
x: { ticks: { color: '#64748b', maxTicksLimit: 20, font: { size: 10 } }, grid: { color: '#1e293b' } },
y: {
ticks: { color: '#94a3b8' }, grid: { color: '#334155' },
title: { display: true, text: 'z-score', color: '#94a3b8' }
}
}
},
plugins: [{
id: 'thresholdLines',
afterDraw(chart) {
const ys = chart.scales.y;
const ctx = chart.ctx;
[{ v: 2, c: '#ef4444', l: '+2σ' }, { v: -2, c: '#22c55e', l: '-2σ' }].forEach(t => {
const y = ys.getPixelForValue(t.v);
ctx.save();
ctx.beginPath();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = t.c;
ctx.lineWidth = 1;
ctx.moveTo(chart.chartArea.left, y);
ctx.lineTo(chart.chartArea.right, y);
ctx.stroke();
ctx.restore();
ctx.fillStyle = t.c;
ctx.font = '11px system-ui';
ctx.fillText(t.l, chart.chartArea.right - 36, y - 4);
});
}
}]
});
</script>
</body>
</html>"""
def generate_html(chart_data):
"""渲染 HTML。"""
payload = json.dumps({
"allDates": chart_data["all_dates"],
"datasets": chart_data["datasets"],
"zDatasets": chart_data["z_datasets"],
"statCards": chart_data["stat_cards"],
"sigCards": chart_data["sig_cards"],
})
html = TEMPLATE.replace("__DATA__", payload)
with open(OUT_PATH, "w") as f:
f.write(html)
print(f"已生成: {OUT_PATH}")
if __name__ == "__main__":
data = build_spreads()
sigs = compute_signals(data)
chart_data = build_chart_data(data, sigs)
generate_html(chart_data)