Files
AITrading/python-app/app/web/index.html
2026-03-26 14:13:44 +08:00

212 lines
8.8 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AITrading 股票看板</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; margin: 20px; background:#f7f8fa; color:#1f2937; }
.card { background:#fff; border-radius:10px; box-shadow:0 2px 8px rgba(0,0,0,.06); padding:16px; margin-bottom:16px; }
.row { display:flex; gap:12px; flex-wrap:wrap; }
input, button { padding:8px 10px; border-radius:8px; border:1px solid #d1d5db; }
button { background:#2563eb; color:#fff; border:none; cursor:pointer; }
.grid { display:grid; grid-template-columns: repeat(3, minmax(180px,1fr)); gap:8px; }
.item { background:#f9fafb; border-radius:8px; padding:8px; }
.label { color:#6b7280; font-size:12px; }
.value { font-size:14px; font-weight:600; }
#status { font-size:13px; color:#6b7280; }
</style>
</head>
<body>
<div class="card">
<h2>股票信息与买卖点</h2>
<div class="row">
<input id="query" placeholder="输入股票代码或名称,如 600519 / 贵州茅台" style="min-width:300px;" />
<input id="fast" type="number" min="2" max="60" value="5" />
<input id="slow" type="number" min="3" max="120" value="20" />
<button onclick="searchStock()">查询</button>
</div>
<p id="status">请输入代码或名称并点击查询。</p>
</div>
<div class="card">
<div id="info" class="grid"></div>
</div>
<div class="card">
<canvas id="chart" height="120"></canvas>
</div>
<script>
let chart;
let realtimeWs = null;
let currentPoints = [];
let currentSignals = [];
let currentFast = 5;
let currentSlow = 20;
function renderInfo(info) {
const fields = [
["代码", info.code], ["名称", info.name], ["最新价", info.price],
["涨跌幅", info.change_pct], ["涨跌额", info.change], ["成交额", info.turnover],
["PE(动态)", info.pe], ["PB", info.pb], ["总市值", info.market_cap]
];
const container = document.getElementById("info");
container.innerHTML = fields.map(([k, v]) =>
`<div class="item"><div class="label">${k}</div><div class="value">${v ?? "-"}</div></div>`
).join("");
}
function buildSignals(points, fast, slow) {
const signals = [];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
if (prev.sma_fast == null || prev.sma_slow == null || curr.sma_fast == null || curr.sma_slow == null) continue;
const crossUp = prev.sma_fast <= prev.sma_slow && curr.sma_fast > curr.sma_slow;
const crossDown = prev.sma_fast >= prev.sma_slow && curr.sma_fast < curr.sma_slow;
if (crossUp) signals.push({ date: curr.date, type: "buy", price: curr.close });
else if (crossDown) signals.push({ date: curr.date, type: "sell", price: curr.close });
}
return signals;
}
function recomputeSma(points, fast, slow) {
const sumFast = [];
const sumSlow = [];
for (let i = 0; i < points.length; i++) {
sumFast[i] = (sumFast[i - 1] || 0) + Number(points[i].close || 0);
sumSlow[i] = (sumSlow[i - 1] || 0) + Number(points[i].close || 0);
if (i >= fast - 1) {
const prev = i - fast >= 0 ? sumFast[i - fast] : 0;
points[i].sma_fast = (sumFast[i] - prev) / fast;
} else {
points[i].sma_fast = null;
}
if (i >= slow - 1) {
const prev = i - slow >= 0 ? sumSlow[i - slow] : 0;
points[i].sma_slow = (sumSlow[i] - prev) / slow;
} else {
points[i].sma_slow = null;
}
}
}
function upsertRealtimePoint(point) {
if (!point || point.close == null || currentPoints.length === 0) return;
const last = currentPoints[currentPoints.length - 1];
if (last.date === point.date) {
last.close = Number(point.close);
if (point.volume != null) last.volume = Number(point.volume);
} else if (point.date > last.date) {
currentPoints.push({
date: point.date,
close: Number(point.close),
volume: Number(point.volume || 0),
sma_fast: null,
sma_slow: null,
});
}
recomputeSma(currentPoints, currentFast, currentSlow);
currentSignals = buildSignals(currentPoints, currentFast, currentSlow);
drawChart(currentPoints, currentSignals);
}
function drawChart(points, signals) {
const labels = points.map(p => p.date);
const close = points.map(p => p.close);
const fast = points.map(p => p.sma_fast);
const slow = points.map(p => p.sma_slow);
const buyPoints = signals.filter(s => s.type === "buy").map(s => ({x: s.date, y: s.price}));
const sellPoints = signals.filter(s => s.type === "sell").map(s => ({x: s.date, y: s.price}));
if (!chart) {
chart = new Chart(document.getElementById("chart"), {
type: "line",
data: {
labels,
datasets: [
{ label: "收盘价", data: close, borderColor: "#1f2937", tension: 0.2, pointRadius: 0 },
{ label: "SMA Fast", data: fast, borderColor: "#2563eb", tension: 0.2, pointRadius: 0 },
{ label: "SMA Slow", data: slow, borderColor: "#16a34a", tension: 0.2, pointRadius: 0 },
{ label: "买点", data: buyPoints, parsing: {xAxisKey: "x", yAxisKey: "y"}, showLine:false, pointRadius:5, pointStyle:"triangle", pointBackgroundColor:"#dc2626", pointBorderColor:"#dc2626" },
{ label: "卖点", data: sellPoints, parsing: {xAxisKey: "x", yAxisKey: "y"}, showLine:false, pointRadius:5, pointStyle:"rectRot", pointBackgroundColor:"#7c3aed", pointBorderColor:"#7c3aed" }
]
},
options: {
responsive: true,
animation: false,
scales: { x: { ticks: { maxTicksLimit: 10 } } }
}
});
return;
}
chart.data.labels = labels;
chart.data.datasets[0].data = close;
chart.data.datasets[1].data = fast;
chart.data.datasets[2].data = slow;
chart.data.datasets[3].data = buyPoints;
chart.data.datasets[4].data = sellPoints;
chart.update("none");
}
async function searchStock() {
const query = document.getElementById("query").value.trim();
const fast = Number(document.getElementById("fast").value);
const slow = Number(document.getElementById("slow").value);
if (!query) return;
const status = document.getElementById("status");
status.innerText = "正在建立实时 WebSocket 连接...";
try {
if (realtimeWs) {
realtimeWs.close();
realtimeWs = null;
}
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
realtimeWs = new WebSocket(`${protocol}://${window.location.host}/ws/stock/realtime?query=${encodeURIComponent(query)}`);
realtimeWs.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.error) {
status.innerText = `实时通道错误: ${data.error}`;
return;
}
renderInfo(data.info);
upsertRealtimePoint(data.realtime_point);
status.innerText = `实时更新中:${data.info.code} ${data.info.name}${data.updated_at}`;
};
realtimeWs.onclose = () => {
if (status.innerText.startsWith("实时更新中")) {
status.innerText = "实时连接已断开";
}
};
realtimeWs.onerror = () => {
status.innerText = "实时连接失败";
};
const res = await fetch(`/api/stock?query=${encodeURIComponent(query)}&sma_fast=${fast}&sma_slow=${slow}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "查询失败");
currentFast = fast;
currentSlow = slow;
currentPoints = data.points.map(p => ({
date: p.date,
close: Number(p.close),
sma_fast: p.sma_fast == null ? null : Number(p.sma_fast),
sma_slow: p.sma_slow == null ? null : Number(p.sma_slow),
volume: p.volume == null ? 0 : Number(p.volume),
}));
currentSignals = data.signals;
renderInfo(data.info);
drawChart(currentPoints, currentSignals);
status.innerText = `历史K线加载完成${data.info.code} ${data.info.name},买点 ${data.signals.filter(s=>s.type==="buy").length},卖点 ${data.signals.filter(s=>s.type==="sell").length}(实时通道已连接)`;
} catch (err) {
status.innerText = `错误: ${err.message}`;
}
}
</script>
</body>
</html>