新增价差走势图脚本,含01/05/09合约配对和均值回归交易信号
This commit is contained in:
File diff suppressed because one or more lines are too long
+402
@@ -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> σ: <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)
|
||||
Reference in New Issue
Block a user