212 lines
8.8 KiB
HTML
212 lines
8.8 KiB
HTML
<!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>
|