chanmacro: add web dashboard (Flask + Chart.js, port 8124)
- /api/state: current market state with all factor scores - /api/history: regime + breadth history for charts - /api/expectancy: signal expectancy query - Bootstrap 5 + Chart.js dark theme, Chinese UI - Factor cards, regime timeline, breadth chart, expectancy table
This commit is contained in:
@@ -5,3 +5,4 @@ pydantic>=2.0.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
scipy>=1.10.0
|
||||
flask>=3.0.0
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
web/app.py — ChanMacro dashboard (Flask, port 8124).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from datetime import date as Date, timedelta
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
|
||||
from database import get_connection
|
||||
from config import config
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketStateVector
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _build_state(target: Date):
|
||||
"""Shared: build MarketStateVector for a date."""
|
||||
ps = PriceStructureScorer().compute(target)
|
||||
br = BreadthScorer().compute(target)
|
||||
oi = OIMatrixScorer().compute(target)
|
||||
vol = VolatilityRegimeScorer().compute(target)
|
||||
|
||||
detector = RegimeDetector()
|
||||
detector.load_state(config.db_path)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps, breadth_score=br,
|
||||
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
return state
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/state")
|
||||
def api_state():
|
||||
"""Current market state with all factor scores."""
|
||||
try:
|
||||
target = Date.today()
|
||||
state = _build_state(target)
|
||||
return jsonify({
|
||||
"date": str(state.date),
|
||||
"regime": state.regime.value,
|
||||
"regime_confidence": state.regime_confidence,
|
||||
"regime_maturity": state.regime_maturity_score,
|
||||
"breadth": {
|
||||
"score": state.breadth_score.score,
|
||||
"bucket": state.breadth_bucket.value,
|
||||
"top20": state.breadth_top20,
|
||||
"top30": state.breadth_top30,
|
||||
"top50": state.breadth_top50,
|
||||
"divergence": state.breadth_divergence,
|
||||
"narrative": state.breadth_score.narrative,
|
||||
},
|
||||
"oi_state": state.oi_state.value,
|
||||
"oi_score": state.oi_matrix_score.score,
|
||||
"oi_narrative": state.oi_matrix_score.narrative,
|
||||
"volatility": state.volatility_regime.value,
|
||||
"price_structure": {
|
||||
"score": state.price_structure_score.score,
|
||||
"trend": state.price_structure_score.trend_strength,
|
||||
"vol_comp": state.price_structure_score.volatility_compression,
|
||||
"momentum": state.price_structure_score.momentum,
|
||||
"label": state.price_structure_score.label,
|
||||
"narrative": state.price_structure_score.narrative,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
"""Regime and factor score history."""
|
||||
days = request.args.get("days", 60, type=int)
|
||||
conn = get_connection()
|
||||
|
||||
# Regime history
|
||||
regimes = conn.execute(
|
||||
"SELECT date, regime, confidence, maturity_score FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
# Breadth history
|
||||
breadth = conn.execute(
|
||||
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
"regimes": [{"date": r["date"], "regime": r["regime"],
|
||||
"confidence": r["confidence"], "maturity": r["maturity_score"]}
|
||||
for r in reversed(regimes)],
|
||||
"breadth": [{"date": b["date"], "advance": b["advance_top50"],
|
||||
"decline": b["decline_top50"], "above_ema20": b["above_ema20_top50"]}
|
||||
for b in reversed(breadth)],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/expectancy")
|
||||
def api_expectancy():
|
||||
"""Query signal expectancy."""
|
||||
signal = request.args.get("signal", "B3")
|
||||
try:
|
||||
target = Date.today()
|
||||
state = _build_state(target)
|
||||
engine = BayesianExpectancyEngine(level_min_samples=5)
|
||||
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||
|
||||
layers = []
|
||||
for l in report.layers:
|
||||
layers.append({
|
||||
"name": l.name,
|
||||
"samples": l.samples,
|
||||
"effective_samples": l.effective_samples,
|
||||
"raw_winrate": l.raw_winrate,
|
||||
"posterior_winrate": l.posterior_winrate,
|
||||
"avg_return": l.avg_return,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"signal": signal,
|
||||
"final_estimate": report.final_estimate,
|
||||
"sufficiency": report.sufficiency.value,
|
||||
"source": report.source,
|
||||
"avg_return_7d": report.avg_return_7d,
|
||||
"profit_factor": report.profit_factor,
|
||||
"max_adverse": report.max_adverse_excursion,
|
||||
"layers": layers,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8124, debug=True)
|
||||
@@ -0,0 +1,160 @@
|
||||
// dashboard.js — ChanMacro dashboard
|
||||
|
||||
let regimeChart = null, breadthChart = null;
|
||||
|
||||
const REGIME_COLORS = { TREND: "#3fb950", RANGE: "#d29922", PANIC: "#f85149" };
|
||||
const BUCKET_CLASS = { EXTREME: "bucket-EXTREME", STRONG: "bucket-STRONG",
|
||||
NORMAL: "bucket-NORMAL", WEAK: "bucket-WEAK", PANIC: "bucket-PANIC" };
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const r = await fetch("/api/state");
|
||||
const d = await r.json();
|
||||
if (d.error) { document.getElementById("db-status").textContent = d.error; return; }
|
||||
|
||||
document.getElementById("db-status").textContent = "✓ " + d.date;
|
||||
document.getElementById("update-time").textContent = "更新于 " + new Date().toLocaleTimeString();
|
||||
|
||||
// Hero
|
||||
const regime = d.regime;
|
||||
document.getElementById("hero-regime").textContent = regime === "TREND" ? "趋势" : regime === "RANGE" ? "震荡" : "恐慌";
|
||||
document.getElementById("hero-regime").className = "hero-regime regime-" + regime;
|
||||
document.getElementById("hero-badge").textContent = regime;
|
||||
document.getElementById("hero-badge").className = "badge-regime badge-" + regime;
|
||||
document.getElementById("hero-conf").textContent = (d.regime_confidence * 100).toFixed(0) + "%";
|
||||
document.getElementById("hero-maturity").textContent = d.regime_maturity.toFixed(0) + "/100";
|
||||
|
||||
// Factors
|
||||
document.getElementById("f-price").textContent = d.price_structure.score.toFixed(0);
|
||||
document.getElementById("f-price-sub").textContent = d.price_structure.label;
|
||||
document.getElementById("f-price-narr").textContent = d.price_structure.narrative;
|
||||
|
||||
const b = d.breadth;
|
||||
document.getElementById("f-breadth").textContent = b.score.toFixed(0);
|
||||
document.getElementById("f-breadth").className = "factor-value " + (BUCKET_CLASS[b.bucket] || "");
|
||||
document.getElementById("f-breadth-sub").textContent =
|
||||
`${b.bucket} · T20=${b.top20.toFixed(0)} T50=${b.top50.toFixed(0)} div=${b.divergence > 0 ? "+" : ""}${b.divergence.toFixed(0)}`;
|
||||
document.getElementById("f-breadth-narr").textContent = b.narrative;
|
||||
|
||||
document.getElementById("f-oi").textContent = d.oi_state;
|
||||
document.getElementById("f-oi-sub").textContent = `分数: ${d.oi_score.toFixed(0)}`;
|
||||
document.getElementById("f-oi-narr").textContent = d.oi_narrative;
|
||||
|
||||
document.getElementById("f-vol").textContent = d.volatility;
|
||||
document.getElementById("f-vol-sub").textContent = `分数: ${d.price_structure.score.toFixed(0)}`;
|
||||
} catch (e) {
|
||||
document.getElementById("db-status").textContent = "连接失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const r = await fetch("/api/history?days=60");
|
||||
const d = await r.json();
|
||||
|
||||
// Regime chart
|
||||
const dates = d.regimes.map(x => x.date);
|
||||
const regimes = d.regimes.map(x => x.regime);
|
||||
const colors = regimes.map(r => REGIME_COLORS[r] || "#8b949e");
|
||||
|
||||
if (regimeChart) regimeChart.destroy();
|
||||
const ctx1 = document.getElementById("chart-regime").getContext("2d");
|
||||
regimeChart = new Chart(ctx1, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [{
|
||||
label: "置信度",
|
||||
data: d.regimes.map(x => x.confidence * 100),
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
borderRadius: 2,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => `${d.regimes[ctx.dataIndex].regime} · ${ctx.raw.toFixed(0)}%`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: "#8b949e", maxTicksLimit: 15, maxRotation: 45 } },
|
||||
y: { max: 100, ticks: { color: "#8b949e" } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Breadth chart
|
||||
if (breadthChart) breadthChart.destroy();
|
||||
const ctx2 = document.getElementById("chart-breadth").getContext("2d");
|
||||
breadthChart = new Chart(ctx2, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: d.breadth.map(x => x.date),
|
||||
datasets: [
|
||||
{ label: "上涨", data: d.breadth.map(x => x.advance), borderColor: "#3fb950",
|
||||
backgroundColor: "rgba(63,185,80,0.1)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||
{ label: "下跌", data: d.breadth.map(x => x.decline), borderColor: "#f85149",
|
||||
backgroundColor: "rgba(248,81,73,0.1)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||
{ label: ">EMA20", data: d.breadth.map(x => x.above_ema20), borderColor: "#58a6ff",
|
||||
borderDash: [4, 2], tension: 0.3, pointRadius: 0 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: "#8b949e", usePointStyle: true, boxWidth: 8 } } },
|
||||
scales: {
|
||||
x: { ticks: { color: "#8b949e", maxTicksLimit: 15, maxRotation: 45 } },
|
||||
y: { max: 50, ticks: { color: "#8b949e" } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("History load failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExpectancy() {
|
||||
const signal = document.getElementById("exp-signal").value;
|
||||
try {
|
||||
const r = await fetch(`/api/expectancy?signal=${signal}`);
|
||||
const d = await r.json();
|
||||
if (d.error) { document.getElementById("exp-layers").innerHTML = `<tr><td colspan="6">${d.error}</td></tr>`; return; }
|
||||
|
||||
document.getElementById("exp-sufficiency").textContent = d.sufficiency;
|
||||
document.getElementById("exp-sufficiency").className =
|
||||
"badge " + (d.sufficiency === "HIGH" ? "bg-success" : d.sufficiency === "MEDIUM" ? "bg-warning" :
|
||||
d.sufficiency === "LOW" ? "bg-danger" : "bg-secondary");
|
||||
|
||||
let html = "";
|
||||
for (const l of d.layers) {
|
||||
html += `<tr>
|
||||
<td>${l.name}</td>
|
||||
<td>${l.samples}</td>
|
||||
<td>${l.effective_samples.toFixed(0)}</td>
|
||||
<td>${l.raw_winrate ? (l.raw_winrate * 100).toFixed(1) + "%" : "—"}</td>
|
||||
<td><strong>${(l.posterior_winrate * 100).toFixed(1)}%</strong></td>
|
||||
<td>${l.avg_return ? (l.avg_return > 0 ? "+" : "") + l.avg_return.toFixed(1) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
}
|
||||
document.getElementById("exp-layers").innerHTML = html;
|
||||
|
||||
let summary = `最终估计: <strong>${(d.final_estimate * 100).toFixed(1)}%</strong>`;
|
||||
if (d.avg_return_7d) summary += ` · 平均收益: <strong>${d.avg_return_7d > 0 ? "+" : ""}${d.avg_return_7d.toFixed(1)}%</strong>`;
|
||||
if (d.profit_factor) summary += ` · 盈亏比: <strong>${d.profit_factor}</strong>`;
|
||||
document.getElementById("exp-summary").innerHTML = summary;
|
||||
} catch (e) {
|
||||
console.error("Expectancy load failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
loadState();
|
||||
loadHistory();
|
||||
loadExpectancy();
|
||||
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChanMacro — 市场状态</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #e6edf3; --muted: #8b949e;
|
||||
--green: #3fb950; --red: #f85149; --orange: #d29922; --blue: #58a6ff; }
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.hero-regime { font-size: 3rem; font-weight: 700; }
|
||||
.hero-conf { font-size: 1.2rem; color: var(--muted); }
|
||||
.factor-value { font-size: 2.2rem; font-weight: 700; }
|
||||
.factor-label { color: var(--muted); font-size: 0.85rem; }
|
||||
.regime-TREND { color: var(--green); }
|
||||
.regime-RANGE { color: var(--orange); }
|
||||
.regime-PANIC { color: var(--red); }
|
||||
.bucket-EXTREME, .bucket-STRONG { color: var(--green); }
|
||||
.bucket-NORMAL { color: var(--orange); }
|
||||
.bucket-WEAK, .bucket-PANIC { color: var(--red); }
|
||||
.badge-regime { font-size: 0.85rem; padding: 4px 12px; border-radius: 20px; }
|
||||
.badge-TREND { background: #1a3a1a; color: var(--green); }
|
||||
.badge-RANGE { background: #3a2a0a; color: var(--orange); }
|
||||
.badge-PANIC { background: #3a0a0a; color: var(--red); }
|
||||
.narrative { color: var(--muted); font-size: 0.9rem; }
|
||||
.loading { opacity: 0.5; }
|
||||
canvas { max-height: 300px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid py-3 px-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4 class="mb-0">ChanMacro <span class="text-muted fs-6">市场状态</span></h4>
|
||||
<small class="text-muted" id="update-time"></small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge bg-secondary" id="db-status">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero: Regime -->
|
||||
<div class="card p-4 mb-3 text-center">
|
||||
<div class="hero-conf mb-1">当前制度</div>
|
||||
<div class="hero-regime" id="hero-regime">—</div>
|
||||
<div>
|
||||
<span class="badge-regime" id="hero-badge">—</span>
|
||||
<span class="ms-2 text-muted">置信度 <strong id="hero-conf">—</strong></span>
|
||||
<span class="ms-2 text-muted">成熟度 <strong id="hero-maturity">—</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 Factor Cards -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">价格结构</div>
|
||||
<div class="factor-value" id="f-price">—</div>
|
||||
<div class="text-muted small" id="f-price-sub"></div>
|
||||
<div class="narrative mt-1" id="f-price-narr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">市场广度</div>
|
||||
<div class="factor-value" id="f-breadth">—</div>
|
||||
<div class="text-muted small" id="f-breadth-sub"></div>
|
||||
<div class="narrative mt-1" id="f-breadth-narr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">OI 状态</div>
|
||||
<div class="factor-value fs-4" id="f-oi">—</div>
|
||||
<div class="text-muted small" id="f-oi-sub"></div>
|
||||
<div class="narrative mt-1" id="f-oi-narr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">波动率</div>
|
||||
<div class="factor-value" id="f-vol">—</div>
|
||||
<div class="text-muted small" id="f-vol-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3">
|
||||
<h6 class="mb-3">制度历史</h6>
|
||||
<canvas id="chart-regime"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3">
|
||||
<h6 class="mb-3">市场广度</h6>
|
||||
<canvas id="chart-breadth"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expectancy -->
|
||||
<div class="card p-3">
|
||||
<h6 class="mb-3">信号期望查询</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" id="exp-signal" style="background:#0d1117;color:#e6edf3;border-color:#30363d">
|
||||
<option value="B3">B3 (三买)</option><option value="B2">B2 (二买)</option><option value="B1">B1 (一买)</option>
|
||||
<option value="S3">S3 (三卖)</option><option value="S2">S2 (二卖)</option><option value="S1">S1 (一卖)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary" onclick="loadExpectancy()">查询</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge bg-secondary" id="exp-sufficiency">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-dark mb-0" style="--bs-table-bg:#161b22">
|
||||
<thead><tr><th>层级</th><th>样本</th><th>有效样本</th><th>原始胜率</th><th>后验胜率</th><th>平均收益</th></tr></thead>
|
||||
<tbody id="exp-layers"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small" id="exp-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user