diff --git a/spread_chart.html b/spread_chart.html
new file mode 100644
index 0000000..9020b19
--- /dev/null
+++ b/spread_chart.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+FG 主力合约价差走势
+
+
+
+
+
+玻璃期货 — 主力合约价差分析
+1月 / 5月 / 9月 两两价差(同年配对,远月减近月) | 均值回归信号(60日滚动,2σ 入场)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spread_chart.py b/spread_chart.py
new file mode 100644
index 0000000..54c1baa
--- /dev/null
+++ b/spread_chart.py
@@ -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"""
+
+
+
+
+FG 主力合约价差走势
+
+
+
+
+
+玻璃期货 — 主力合约价差分析
+1月 / 5月 / 9月 两两价差(同年配对,远月减近月) | 均值回归信号(60日滚动,2σ 入场)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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)