Files
finance-talk/spread_chart.py
T

403 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
主力合约 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)