diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cf95fc1..05e2114 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,8 @@ "Bash(git -C /Users/xxz/PycharmProjects/crypto_predictor check-ignore -v test_dlinear_jan_2026_create_labels.py)", "Bash(git -C /Users/xxz/PycharmProjects/crypto_predictor status)", "Bash(git -C /Users/xxz/PycharmProjects/crypto_predictor diff)", - "Bash(git -C /Users/xxz/PycharmProjects/crypto_predictor log --oneline -5)" + "Bash(git -C /Users/xxz/PycharmProjects/crypto_predictor log --oneline -5)", + "Bash(git checkout:*)" ] } } diff --git a/.env.example b/.env.example index 12393bf..46c4441 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,19 @@ MYSQL_USER=user MYSQL_PASSWORD=password MYSQL_DB=crypto_predictor +# Cloudflare D1 Backup Configuration +# 开关:true 开启 MySQL -> D1 定时备份(每小时 :30) +D1_BACKUP_ENABLED=false +CF_D1_API_TOKEN=your_cloudflare_api_token +CF_D1_ACCOUNT_ID=your_cloudflare_account_id +CF_D1_DATABASE_ID=your_d1_database_id +# 备份批大小(单次 SQL 插入的最大行数) +D1_BACKUP_BATCH_SIZE=500 +# 单条 SQL 最大参数数(D1 限制差异较大,默认保守) +D1_MAX_SQL_PARAMS=90 +# 指定备份表(逗号分隔);留空时自动备份 MySQL 全部表 +D1_BACKUP_TABLES=ohlcv,hourly_model_predictions,trades + # Polymarket Configuration POLYMARKET_PRIVATE_KEY=your_private_key_here POLYMARKET_API_KEY=your_api_key_here diff --git a/cloudflare_pages/.wrangler/cache/pages.json b/cloudflare_pages/.wrangler/cache/pages.json new file mode 100644 index 0000000..4e9709f --- /dev/null +++ b/cloudflare_pages/.wrangler/cache/pages.json @@ -0,0 +1,4 @@ +{ + "account_id": "7ea8a511cad4d467490fe36aa7d6638c", + "project_name": "trades" +} \ No newline at end of file diff --git a/cloudflare_pages/.wrangler/cache/wrangler-account.json b/cloudflare_pages/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..60ff173 --- /dev/null +++ b/cloudflare_pages/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "7ea8a511cad4d467490fe36aa7d6638c", + "name": "Subuhuinew@gmail.com's Account" + } +} \ No newline at end of file diff --git a/cloudflare_pages/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/b8932d66c674bcaf529d90b1b9cf47f951a5309de6b1305a0deb27a5a9d49c86.sqlite b/cloudflare_pages/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/b8932d66c674bcaf529d90b1b9cf47f951a5309de6b1305a0deb27a5a9d49c86.sqlite new file mode 100644 index 0000000..0b58f0d Binary files /dev/null and b/cloudflare_pages/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/b8932d66c674bcaf529d90b1b9cf47f951a5309de6b1305a0deb27a5a9d49c86.sqlite differ diff --git a/cloudflare_pages/README.md b/cloudflare_pages/README.md new file mode 100644 index 0000000..0e38a26 --- /dev/null +++ b/cloudflare_pages/README.md @@ -0,0 +1,41 @@ +# Cloudflare Pages 看板 + +本目录提供一个可直接部署到 Cloudflare Pages 的页面,数据源为 D1(由 MySQL 备份任务同步而来)。 + +## 目录结构 + +- `public/`:静态页面资源 +- `functions/api/`:Pages Functions,直接查询 D1 + +## 必要前置 + +1. 已在 D1 中有备份表: +- `backup_trades` +- `backup_hourly_model_predictions` + +2. Pages 项目绑定 D1(绑定名必须是 `DB`) + +## 本地开发 + +```bash +cd cloudflare_pages +npx wrangler pages dev public --d1 DB=<你的D1数据库ID> +``` + +## 生产部署 + +1. 在 Cloudflare Pages 新建项目,根目录设置为 `cloudflare_pages` +2. Build command 留空 +3. Build output directory 设置为 `public` +4. 在 Pages 项目 Settings -> Functions -> D1 bindings 中添加: +- Variable name: `DB` +- D1 database: 选择你的数据库 + +## 页面接口 + +- `/api/status` +- `/api/trades/current` +- `/api/trades/history?page=1&limit=15&month=YYYY-MM&day=YYYY-MM-DD` +- `/api/accuracy/monthly?month=YYYY-MM` +- `/api/accuracy/daily?month=YYYY-MM&page=1&limit=10` +- `/api/accuracy/hourly/YYYY-MM-DD` diff --git a/cloudflare_pages/functions/api/accuracy/daily.js b/cloudflare_pages/functions/api/accuracy/daily.js new file mode 100644 index 0000000..ce8ba1c --- /dev/null +++ b/cloudflare_pages/functions/api/accuracy/daily.js @@ -0,0 +1,101 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=UTF-8" }, + }); +} + +function toInt(v, d) { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : d; +} + +function ratio(correct, total) { + if (!total) return 0; + return Math.round((correct / total) * 10000) / 100; +} + +function item(total, correct) { + const t = Number(total || 0); + const c = Number(correct || 0); + return { + total: t, + correct: c, + incorrect: Math.max(0, t - c), + accuracy: ratio(c, t), + }; +} + +export async function onRequestGet(context) { + const db = context.env.DB; + if (!db) { + return json({ success: false, error: "缺少 D1 绑定 DB" }, 500); + } + + try { + const url = new URL(context.request.url); + const month = (url.searchParams.get("month") || new Date().toISOString().slice(0, 7)).trim(); + const page = toInt(url.searchParams.get("page"), 1); + const limit = Math.min(100, toInt(url.searchParams.get("limit"), 10)); + const offset = (page - 1) * limit; + + const baseWhere = "date LIKE ?"; + const monthArg = `${month}%`; + + const dataSql = ` + SELECT + date, + SUM(CASE WHEN actual_result IS NOT NULL AND ensemble_prediction IS NOT NULL THEN 1 ELSE 0 END) AS ens_total, + SUM(CASE WHEN ensemble_correct = 1 THEN 1 ELSE 0 END) AS ens_correct, + + SUM(CASE WHEN actual_result IS NOT NULL AND dlinear_prediction IS NOT NULL THEN 1 ELSE 0 END) AS dl_total, + SUM(CASE WHEN dlinear_correct = 1 THEN 1 ELSE 0 END) AS dl_correct, + + SUM(CASE WHEN actual_result IS NOT NULL AND nbeats_prediction IS NOT NULL THEN 1 ELSE 0 END) AS nb_total, + SUM(CASE WHEN nbeats_correct = 1 THEN 1 ELSE 0 END) AS nb_correct, + + SUM(CASE WHEN actual_result IS NOT NULL AND ttm_prediction IS NOT NULL THEN 1 ELSE 0 END) AS ttm_total, + SUM(CASE WHEN ttm_correct = 1 THEN 1 ELSE 0 END) AS ttm_correct + FROM backup_hourly_model_predictions + WHERE ${baseWhere} + GROUP BY date + ORDER BY date DESC + LIMIT ? OFFSET ? + `; + + const countSql = ` + SELECT COUNT(*) AS cnt + FROM ( + SELECT date + FROM backup_hourly_model_predictions + WHERE ${baseWhere} + GROUP BY date + ) x + `; + + const dataResult = await db.prepare(dataSql).bind(monthArg, limit, offset).all(); + const countResult = await db.prepare(countSql).bind(monthArg).first(); + + const rows = (dataResult.results || []).map((r) => ({ + date: r.date, + Ensemble: item(r.ens_total, r.ens_correct), + DLinear: item(r.dl_total, r.dl_correct), + "N-BEATS": item(r.nb_total, r.nb_correct), + "TTM-R2": item(r.ttm_total, r.ttm_correct), + })); + + const total = Number(countResult?.cnt || 0); + const totalPages = total > 0 ? Math.ceil(total / limit) : 1; + + return json({ + success: true, + data: rows, + page, + limit, + total, + total_pages: totalPages, + }); + } catch (error) { + return json({ success: false, error: String(error) }, 500); + } +} diff --git a/cloudflare_pages/functions/api/accuracy/hourly/[date].js b/cloudflare_pages/functions/api/accuracy/hourly/[date].js new file mode 100644 index 0000000..5abe5f7 --- /dev/null +++ b/cloudflare_pages/functions/api/accuracy/hourly/[date].js @@ -0,0 +1,67 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=UTF-8" }, + }); +} + +function modelItem(prediction, probability, isCorrect) { + if (prediction === null || prediction === undefined) return null; + return { + prediction: Number(prediction), + probability: probability === null || probability === undefined ? null : Number(probability), + is_correct: isCorrect === null || isCorrect === undefined ? null : Number(isCorrect) === 1, + }; +} + +export async function onRequestGet(context) { + const db = context.env.DB; + if (!db) { + return json({ success: false, error: "缺少 D1 绑定 DB" }, 500); + } + + try { + const date = context.params.date; + const sql = ` + SELECT + timestamp, + hour, + open_price, + close_price, + actual_result, + dlinear_prediction, + dlinear_prob, + dlinear_correct, + nbeats_prediction, + nbeats_prob, + nbeats_correct, + ttm_prediction, + ttm_prob, + ttm_correct, + ensemble_prediction, + ensemble_correct + FROM backup_hourly_model_predictions + WHERE date = ? + ORDER BY hour ASC + `; + + const result = await db.prepare(sql).bind(date).all(); + const rows = (result.results || []).map((r) => ({ + timestamp_cst: r.timestamp, + hour: r.hour, + open_price: r.open_price === null || r.open_price === undefined ? null : Number(r.open_price), + close_price: r.close_price === null || r.close_price === undefined ? null : Number(r.close_price), + actual_result: r.actual_result === null || r.actual_result === undefined ? null : Number(r.actual_result), + models: { + Ensemble: modelItem(r.ensemble_prediction, null, r.ensemble_correct), + DLinear: modelItem(r.dlinear_prediction, r.dlinear_prob, r.dlinear_correct), + "N-BEATS": modelItem(r.nbeats_prediction, r.nbeats_prob, r.nbeats_correct), + "TTM-R2": modelItem(r.ttm_prediction, r.ttm_prob, r.ttm_correct), + }, + })); + + return json({ success: true, date, data: rows }); + } catch (error) { + return json({ success: false, error: String(error) }, 500); + } +} diff --git a/cloudflare_pages/functions/api/accuracy/monthly.js b/cloudflare_pages/functions/api/accuracy/monthly.js new file mode 100644 index 0000000..e9066cd --- /dev/null +++ b/cloudflare_pages/functions/api/accuracy/monthly.js @@ -0,0 +1,87 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=UTF-8" }, + }); +} + +function ratio(correct, total) { + if (!total) return 0; + return Math.round((correct / total) * 10000) / 100; +} + +export async function onRequestGet(context) { + const db = context.env.DB; + if (!db) { + return json({ success: false, error: "缺少 D1 绑定 DB" }, 500); + } + + try { + const url = new URL(context.request.url); + const month = (url.searchParams.get("month") || new Date().toISOString().slice(0, 7)).trim(); + + const sql = ` + SELECT + SUM(CASE WHEN actual_result IS NOT NULL AND ensemble_prediction IS NOT NULL THEN 1 ELSE 0 END) AS ens_total, + SUM(CASE WHEN ensemble_correct = 1 THEN 1 ELSE 0 END) AS ens_correct, + + SUM(CASE WHEN actual_result IS NOT NULL AND dlinear_prediction IS NOT NULL THEN 1 ELSE 0 END) AS dl_total, + SUM(CASE WHEN dlinear_correct = 1 THEN 1 ELSE 0 END) AS dl_correct, + + SUM(CASE WHEN actual_result IS NOT NULL AND nbeats_prediction IS NOT NULL THEN 1 ELSE 0 END) AS nb_total, + SUM(CASE WHEN nbeats_correct = 1 THEN 1 ELSE 0 END) AS nb_correct, + + SUM(CASE WHEN actual_result IS NOT NULL AND ttm_prediction IS NOT NULL THEN 1 ELSE 0 END) AS ttm_total, + SUM(CASE WHEN ttm_correct = 1 THEN 1 ELSE 0 END) AS ttm_correct + FROM backup_hourly_model_predictions + WHERE date LIKE ? + `; + + const row = await db.prepare(sql).bind(`${month}%`).first(); + + const ensTotal = Number(row?.ens_total || 0); + const ensCorrect = Number(row?.ens_correct || 0); + + const dlTotal = Number(row?.dl_total || 0); + const dlCorrect = Number(row?.dl_correct || 0); + + const nbTotal = Number(row?.nb_total || 0); + const nbCorrect = Number(row?.nb_correct || 0); + + const ttmTotal = Number(row?.ttm_total || 0); + const ttmCorrect = Number(row?.ttm_correct || 0); + + return json({ + success: true, + month, + data: { + Ensemble: { + total: ensTotal, + correct: ensCorrect, + incorrect: Math.max(0, ensTotal - ensCorrect), + accuracy: ratio(ensCorrect, ensTotal), + }, + DLinear: { + total: dlTotal, + correct: dlCorrect, + incorrect: Math.max(0, dlTotal - dlCorrect), + accuracy: ratio(dlCorrect, dlTotal), + }, + "N-BEATS": { + total: nbTotal, + correct: nbCorrect, + incorrect: Math.max(0, nbTotal - nbCorrect), + accuracy: ratio(nbCorrect, nbTotal), + }, + "TTM-R2": { + total: ttmTotal, + correct: ttmCorrect, + incorrect: Math.max(0, ttmTotal - ttmCorrect), + accuracy: ratio(ttmCorrect, ttmTotal), + }, + }, + }); + } catch (error) { + return json({ success: false, error: String(error) }, 500); + } +} diff --git a/cloudflare_pages/functions/api/status.js b/cloudflare_pages/functions/api/status.js new file mode 100644 index 0000000..277de9a --- /dev/null +++ b/cloudflare_pages/functions/api/status.js @@ -0,0 +1,15 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=UTF-8" }, + }); +} + +export async function onRequestGet(context) { + return json({ + success: true, + status: "running", + server_time: new Date().toISOString(), + version: "pages-1.0.0", + }); +} diff --git a/cloudflare_pages/functions/api/trades/current.js b/cloudflare_pages/functions/api/trades/current.js new file mode 100644 index 0000000..c38582f --- /dev/null +++ b/cloudflare_pages/functions/api/trades/current.js @@ -0,0 +1,23 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=UTF-8" }, + }); +} + +export async function onRequestGet(context) { + const db = context.env.DB; + if (!db) { + return json({ success: false, error: "缺少 D1 绑定 DB" }, 500); + } + + try { + const row = await db + .prepare("SELECT * FROM backup_trades ORDER BY timestamp DESC LIMIT 1") + .first(); + + return json({ success: true, data: row || null }); + } catch (error) { + return json({ success: false, error: String(error) }, 500); + } +} diff --git a/cloudflare_pages/functions/api/trades/history.js b/cloudflare_pages/functions/api/trades/history.js new file mode 100644 index 0000000..2b4065f --- /dev/null +++ b/cloudflare_pages/functions/api/trades/history.js @@ -0,0 +1,71 @@ +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=UTF-8" }, + }); +} + +function toInt(v, d) { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : d; +} + +export async function onRequestGet(context) { + const db = context.env.DB; + if (!db) { + return json({ success: false, error: "缺少 D1 绑定 DB" }, 500); + } + + try { + const url = new URL(context.request.url); + const page = toInt(url.searchParams.get("page"), 1); + const limit = Math.min(100, toInt(url.searchParams.get("limit"), 10)); + const month = (url.searchParams.get("month") || "").trim(); + const day = (url.searchParams.get("day") || "").trim(); + const offset = (page - 1) * limit; + + const where = ["1=1"]; + const params = []; + + // 时间字段是 YYYY-MM-DD HH:MM:SS 文本,直接前缀过滤可走索引/减少转换成本 + if (month) { + where.push("timestamp LIKE ?"); + params.push(`${month}%`); + } + if (day) { + where.push("timestamp LIKE ?"); + params.push(`${day}%`); + } + + const whereSql = where.join(" AND "); + const dataSql = ` + SELECT * + FROM backup_trades + WHERE ${whereSql} + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + `; + const countSql = ` + SELECT COUNT(*) AS cnt + FROM backup_trades + WHERE ${whereSql} + `; + + const dataResult = await db.prepare(dataSql).bind(...params, limit, offset).all(); + const countResult = await db.prepare(countSql).bind(...params).first(); + + const total = Number(countResult?.cnt || 0); + const totalPages = total > 0 ? Math.ceil(total / limit) : 1; + + return json({ + success: true, + data: dataResult.results || [], + page, + limit, + total, + total_pages: totalPages, + }); + } catch (error) { + return json({ success: false, error: String(error) }, 500); + } +} diff --git a/cloudflare_pages/public/app.js b/cloudflare_pages/public/app.js new file mode 100644 index 0000000..160598a --- /dev/null +++ b/cloudflare_pages/public/app.js @@ -0,0 +1,200 @@ +const { createApp } = Vue; + +const app = createApp({ + data() { + return { + loading: false, + activeTab: 'trades', + serverTime: '', + currentTrade: null, + tradeHistory: { + data: [], + page: 1, + limit: 10, + total: 0, + total_pages: 0, + }, + tradeFilter: { + month: '', + day: '', + }, + accuracyFilter: { + month: '', + }, + monthlyStats: {}, + dailyStats: { + data: [], + page: 1, + limit: 10, + total: 0, + total_pages: 0, + }, + hourlyModal: { + date: '', + data: [], + }, + }; + }, + + mounted() { + this.accuracyFilter.month = this.getCurrentMonth(); + this.loadCurrentTrade(); + this.loadTradeHistory(1); + this.updateServerTime(); + setInterval(() => this.updateServerTime(), 60000); + }, + + methods: { + getCurrentMonth() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + }, + + updateServerTime() { + const now = new Date(); + this.serverTime = now.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }, + + formatTime(timestamp) { + if (!timestamp) return '-'; + return String(timestamp).slice(0, 19); + }, + + formatPercent(value) { + if (value === null || value === undefined) return '-'; + return `${(Number(value) * 100).toFixed(1)}%`; + }, + + getPredictionClass(prediction) { + if (prediction === 'UP') return 'text-success fw-bold'; + if (prediction === 'DOWN') return 'text-danger fw-bold'; + return ''; + }, + + getStatusClass(status) { + switch (status) { + case 'success': return 'text-success'; + case 'test': return 'text-info'; + case 'failure': return 'text-danger'; + case 'in_progress': return 'text-warning'; + default: return 'text-muted'; + } + }, + + getStatusText(status) { + switch (status) { + case 'success': return '成功'; + case 'test': return '测试'; + case 'failure': return '失败'; + case 'in_progress': return '进行中'; + default: return status || '-'; + } + }, + + getAccuracyClass(accuracy) { + if (accuracy >= 60) return 'text-success fw-bold'; + if (accuracy >= 50) return 'text-warning'; + return 'text-danger'; + }, + + async fetchAPI(url) { + try { + const response = await fetch(url); + return await response.json(); + } catch (error) { + return { success: false, error: String(error) }; + } + }, + + async loadCurrentTrade() { + const data = await this.fetchAPI('/api/trades/current'); + if (data.success) { + this.currentTrade = data.data; + } + }, + + async loadTradeHistory(page) { + if (page < 1) return; + this.loading = true; + + let url = `/api/trades/history?page=${page}&limit=${this.tradeHistory.limit}`; + if (this.tradeFilter.month) { + url += `&month=${this.tradeFilter.month}`; + } + if (this.tradeFilter.day) { + url += `&day=${this.tradeFilter.day}`; + } + + const data = await this.fetchAPI(url); + if (data.success) { + this.tradeHistory = { + data: data.data, + page: data.page, + limit: data.limit, + total: data.total, + total_pages: data.total_pages, + }; + } + + this.loading = false; + }, + + clearTradeFilter() { + this.tradeFilter.month = ''; + this.tradeFilter.day = ''; + this.loadTradeHistory(1); + }, + + async loadAccuracyData() { + this.loading = true; + const monthlyData = await this.fetchAPI(`/api/accuracy/monthly?month=${this.accuracyFilter.month}`); + if (monthlyData.success) { + this.monthlyStats = monthlyData.data || {}; + } + await this.loadDailyStats(1); + this.loading = false; + }, + + async loadDailyStats(page) { + if (page < 1) return; + const data = await this.fetchAPI(`/api/accuracy/daily?month=${this.accuracyFilter.month}&page=${page}&limit=10`); + if (data.success) { + this.dailyStats = { + data: data.data, + page: data.page, + limit: data.limit, + total: data.total, + total_pages: data.total_pages, + }; + } + }, + + async showHourlyDetails(date) { + this.loading = true; + this.hourlyModal.date = date; + const data = await this.fetchAPI(`/api/accuracy/hourly/${date}`); + if (data.success) { + this.hourlyModal.data = data.data || []; + } + this.loading = false; + const modal = new bootstrap.Modal(document.getElementById('hourlyModal')); + modal.show(); + }, + }, + + watch: { + activeTab(newTab) { + if (newTab === 'accuracy' && !this.dailyStats.data.length) { + this.loadAccuracyData(); + } + }, + }, +}); + +app.mount('#app'); diff --git a/cloudflare_pages/public/index.html b/cloudflare_pages/public/index.html new file mode 100644 index 0000000..4504949 --- /dev/null +++ b/cloudflare_pages/public/index.html @@ -0,0 +1,251 @@ + + + + + + BTC 预测交易看板 (Pages + D1) + + + + + +
+ + +
+ + +
+
+
+ 当前小时交易 +
+
+
+
+
+
市场
+
{{ currentTrade.market_title || '-' }}
+
+
+
+
+
预测方向
+
+ {{ currentTrade.prediction || '-' }} +
+
+
+
+
+
概率
+
{{ formatPercent(currentTrade.probability) }}
+
+
+
+
+
策略
+
{{ currentTrade.strategy || 'Ensemble' }}
+
+
+
+
+
状态
+
+ {{ getStatusText(currentTrade.status) }} +
+
+
+
+
+ 暂无交易记录 +
+
+
+ +
+
+ 历史交易记录 +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
时间市场策略预测概率价格状态
{{ formatTime(trade.timestamp) }}{{ trade.market_title }}{{ trade.strategy || 'Ensemble' }}{{ trade.prediction }}{{ formatPercent(trade.probability) }}{{ trade.price ?? '-' }}{{ getStatusText(trade.status) }}
暂无数据
+
+
+ +
+
+ +
+
+
+ + +
+
+ +
+
集成策略

{{ monthlyStats.Ensemble.accuracy }}%

准确率
{{ monthlyStats.Ensemble.correct }}/{{ monthlyStats.Ensemble.total }}
暂无数据
+
DLinear

{{ monthlyStats.DLinear.accuracy }}%

{{ monthlyStats.DLinear.correct }}/{{ monthlyStats.DLinear.total }}
暂无数据
+
N-BEATS

{{ monthlyStats['N-BEATS'].accuracy }}%

{{ monthlyStats['N-BEATS'].correct }}/{{ monthlyStats['N-BEATS'].total }}
暂无数据
+
TTM-R2

{{ monthlyStats['TTM-R2'].accuracy }}%

{{ monthlyStats['TTM-R2'].correct }}/{{ monthlyStats['TTM-R2'].total }}
暂无数据
+
+ +
+
每日准确率概览
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
日期集成策略DLinearN-BEATSTTM-R2操作
{{ day.date }}{{ day.Ensemble.accuracy }}%-{{ day.DLinear.accuracy }}%-{{ day['N-BEATS'].accuracy }}%-{{ day['TTM-R2'].accuracy }}%-
暂无数据
+
+
+ +
+
+
+ + + +
+
加载中...
+
+
+ + + + + + diff --git a/cloudflare_pages/public/style.css b/cloudflare_pages/public/style.css new file mode 100644 index 0000000..7ea3309 --- /dev/null +++ b/cloudflare_pages/public/style.css @@ -0,0 +1,118 @@ +body { + background-color: #f8f9fa; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.navbar-brand { + font-weight: 600; +} + +.navbar-brand i { + color: #f7931a; +} + +.nav-tabs .nav-link { + color: #6c757d; + border: none; + padding: 0.75rem 1.5rem; +} + +.nav-tabs .nav-link.active { + color: #0d6efd; + border-bottom: 3px solid #0d6efd; + background: transparent; +} + +.nav-tabs .nav-link:hover:not(.active) { + border-bottom: 3px solid #dee2e6; +} + +.card { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + border-radius: 0.5rem; +} + +.card-header { + font-weight: 600; + border-radius: 0.5rem 0.5rem 0 0 !important; +} + +.stat-card { + padding: 1rem; + text-align: center; + background: #f8f9fa; + border-radius: 0.5rem; + height: 100%; +} + +.stat-label { + font-size: 0.75rem; + color: #6c757d; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.25rem; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 600; +} + +.table { + margin-bottom: 0; +} + +.table th { + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6c757d; + border-bottom: 2px solid #dee2e6; +} + +.table td { + vertical-align: middle; +} + +.table-hover tbody tr:hover { + background-color: rgba(13, 110, 253, 0.05); +} + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.modal-content { + border-radius: 0.5rem; +} + +.modal-header { + background: #f8f9fa; + border-radius: 0.5rem 0.5rem 0 0; +} + +@media (max-width: 768px) { + .stat-card { + margin-bottom: 1rem; + } + + .card-header .d-flex { + flex-direction: column; + gap: 0.5rem !important; + } + + .nav-tabs .nav-link { + padding: 0.5rem 1rem; + font-size: 0.875rem; + } +} diff --git a/cloudflare_pages/wrangler.toml b/cloudflare_pages/wrangler.toml new file mode 100644 index 0000000..15da311 --- /dev/null +++ b/cloudflare_pages/wrangler.toml @@ -0,0 +1,10 @@ +name = "trades" +compatibility_date = "2026-03-10" + +[vars] +APP_TITLE = "BTC 预测看板" + +[[d1_databases]] +binding = "DB" +database_name = "orders" +database_id = "ae0cd6aa-10da-4459-9768-8e6ffad55a00" diff --git a/cloudflare_pages/wrangler.toml.example b/cloudflare_pages/wrangler.toml.example new file mode 100644 index 0000000..df2fb5e --- /dev/null +++ b/cloudflare_pages/wrangler.toml.example @@ -0,0 +1,11 @@ +name = "trades" +compatibility_date = "2026-03-10" + +[vars] +APP_TITLE = "BTC 预测看板" + +[[d1_databases]] +binding = "DB" +database_name = "orders" +database_id = "ae0cd6aa-10da-4459-9768-8e6ffad55a00" + diff --git a/ensemble_models_feb_2026_back_onnx.py b/ensemble_models_feb_2026_back_onnx.py index 19045e1..07a88f7 100644 --- a/ensemble_models_feb_2026_back_onnx.py +++ b/ensemble_models_feb_2026_back_onnx.py @@ -99,7 +99,7 @@ def fmt_result(pred, actual): def main(): print("=" * 110) - print("2026年2月 BTC 1h 涨跌预测准确率测试 (DLinear, N-BEATS, TTM-R2) - ONNX Version") + print("2026年3月 BTC 1h 涨跌预测准确率测试 (DLinear, N-BEATS, TTM-R2) - ONNX Version") print("输入:预测K线的前一根 | 对比:预测结果 vs 预测K线收盘价 | 时间:北京时间(UTC+8)") print("DLinear预测逻辑:整个df一次性预处理,按索引遍历窗口") print("=" * 110) @@ -168,9 +168,9 @@ def main(): ttm_session = None # ========== 步骤2: 获取测试数据 ========== - print("\n[2/4] 获取2026年2月数据...") + print("\n[2/4] 获取2026年3月数据...") fetcher = BinanceDataFetcher() - df = fetcher.fetch_ohlcv('BTC', '1h', start_date='2025-10-01', end_date='2026-03-01', use_cache=False) + df = fetcher.fetch_ohlcv('BTC', '1h', start_date='2026-01-01', end_date='2026-04-01', use_cache=False) print(f"数据范围(UTC): {df['timestamp'].min()} -> {df['timestamp'].max()}") print(f"总行数: {len(df)}") @@ -221,28 +221,38 @@ def main(): X_ttm = np.array(X_ttm, dtype=np.float32) idx_ttm_map = {v: k for k, v in enumerate(idx_ttm)} - # 找出2月(北京时间)的所有预测目标K线索引 + # 找出3月(北京时间)的所有预测目标K线索引 # df 中每一行都是一根已收盘的K线,timestamp 是 UTC jan_targets = [] for target_idx in range(1, len(df)): - target_time_utc = df.iloc[target_idx]['timestamp'] + # 同样在筛选样本时也将时间向后移动一个小时 + target_time_utc = df.iloc[target_idx]['timestamp'] + timedelta(hours=1) target_time_bj = utc_to_beijing(target_time_utc) - if target_time_bj.year == 2026 and target_time_bj.month == 2: + if target_time_bj.year == 2026 and target_time_bj.month == 3: jan_targets.append(target_idx) - print(f"2月(北京时间)目标K线数: {len(jan_targets)}") + print(f"3月(北京时间)目标K线数: {len(jan_targets)}") results = [] for target_idx in jan_targets: - target_time_utc = df.iloc[target_idx]['timestamp'] + # 原始timestamp表示k线的开始时间,因此预测的结果实际对应下一小时的k线。 + # 所以展示时需要将时间向后移动1小时。 + target_time_utc = df.iloc[target_idx]['timestamp'] + timedelta(hours=1) target_time_bj = utc_to_beijing(target_time_utc) - # 输入K线索引(前一根) - input_idx = target_idx - 1 - current_close = df.iloc[input_idx]['close'] - target_close = df.iloc[target_idx]['close'] - actual_label = 1 if target_close > current_close else 0 + # 现在我们将预测目标设为下一根k线(即target_idx+1), + # 输入使用它的前一根(等于原先的target_idx)。 + predicted_idx = target_idx + 1 + if predicted_idx >= len(df): + # 已经到达数据末尾,无法构建完整样本,跳过 + continue + input_idx = target_idx + # 开盘/收盘对应预测k线 + open_price = df.iloc[predicted_idx]['open'] + close_price = df.iloc[predicted_idx]['close'] + # 实际涨跌以预测k线的开盘为基准 + actual_label = 1 if close_price > open_price else 0 # --- DLinear 预测 (ONNX) --- dl_pred = None @@ -304,8 +314,8 @@ def main(): results.append({ 'time_utc': target_time_utc, 'time_bj': target_time_bj, - 'current_close': current_close, - 'target_close': target_close, + 'open': open_price, + 'close': close_price, 'actual': actual_label, 'dlinear': dl_pred, 'nbeats': nb_pred, @@ -324,7 +334,7 @@ def main(): total_samples = len(df_res) - print(f"\n[4/4] 准确率统计 (2026年2月,北京时间)") + print(f"\n[4/4] 准确率统计 (2026年3月,北京时间)") print(f"总样本数: {total_samples}") # ========== 每小时详细数据 ========== @@ -332,8 +342,9 @@ def main(): print("每小时详细数据") print("=" * 110) + # 改为显示开盘/收盘 hourly_header = ( - f"{'北京时间':<18} {'前收盘':>10} {'现收盘':>10} {'实际':>4} " + f"{'北京时间':<18} {'开盘':>10} {'收盘':>10} {'实际':>4} " f"{'DL':>3} {'':>1} {'NB':>3} {'':>1} {'TTM':>3} {'':>1} {'集成':>4} {'':<1}" ) @@ -371,7 +382,7 @@ def main(): ens_r = fmt_result(row['ensemble'], row['actual']) print( - f"{time_str:<18} {row['current_close']:>10.2f} {row['target_close']:>10.2f} {actual_str:>4} " + f"{time_str:<18} {row['open']:>10.2f} {row['close']:>10.2f} {actual_str:>4} " f"{dl_str:>3} {dl_r:>1} {nb_str:>3} {nb_r:>1} {ttm_str:>3} {ttm_r:>1} {ens_str:>4} {ens_r:<1}" ) diff --git a/mysql_d1_backup_scheduler.py b/mysql_d1_backup_scheduler.py new file mode 100644 index 0000000..f3982cf --- /dev/null +++ b/mysql_d1_backup_scheduler.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +""" +MySQL 数据定时备份到 Cloudflare D1 +- 定时:每小时 XX:30 +- 模式:按游标列增量同步(timestamp/created_at/updated_at/id) +""" + +import json +import os +import threading +import time +from datetime import datetime +from decimal import Decimal +from typing import Any + +import httpx +import schedule +from dotenv import load_dotenv + +from api.models import get_db_connection +from utils.project_logger import get_project_logger + +load_dotenv() +logger = get_project_logger("mysql_d1_backup") + + +class MySQLD1BackupService: + """MySQL -> D1 备份服务。""" + + def __init__(self): + self.enabled = os.getenv("D1_BACKUP_ENABLED", "false").lower() == "true" + self.api_token = os.getenv("CF_D1_API_TOKEN", "") + self.account_id = os.getenv("CF_D1_ACCOUNT_ID", "") + self.database_id = os.getenv("CF_D1_DATABASE_ID", "") + self.batch_size = int(os.getenv("D1_BACKUP_BATCH_SIZE", "500")) + self.max_sql_params = int(os.getenv("D1_MAX_SQL_PARAMS", "90")) + self.tables_config = os.getenv("D1_BACKUP_TABLES", "").strip() + self._job_lock = threading.Lock() + + def is_config_ready(self) -> bool: + """检查 D1 配置是否完整。""" + if not self.enabled: + return False + return bool(self.api_token and self.account_id and self.database_id) + + def _d1_endpoint(self) -> str: + return ( + f"https://api.cloudflare.com/client/v4/accounts/" + f"{self.account_id}/d1/database/{self.database_id}/query" + ) + + def _query_d1(self, sql: str, params: list[Any] | None = None) -> dict: + """执行 D1 SQL。""" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + statement: dict[str, Any] = {"sql": sql} + if params is not None: + statement["params"] = params + + with httpx.Client(timeout=30) as client: + # Cloudflare D1 REST API 对 /query 更稳定的格式是语句数组 + resp = client.post(self._d1_endpoint(), headers=headers, json=[statement]) + if resp.status_code >= 400: + # 兼容部分环境下使用对象格式的情况,失败时回退重试一次 + fallback_resp = client.post(self._d1_endpoint(), headers=headers, json=statement) + if fallback_resp.status_code >= 400: + sql_preview = " ".join(sql.strip().split())[:180] + raise RuntimeError( + f"D1 HTTP {fallback_resp.status_code},sql={sql_preview},响应={fallback_resp.text[:500]}" + ) + data = fallback_resp.json() + else: + data = resp.json() + + if not data.get("success", False): + raise RuntimeError(f"D1 SQL 执行失败: {data}") + return data + + def _d1_results(self, response: dict) -> list[dict]: + """解析 D1 响应结果。""" + result = response.get("result", []) + if not result: + return [] + first = result[0] if isinstance(result, list) else result + return first.get("results", []) if isinstance(first, dict) else [] + + def _normalize_value(self, value: Any) -> Any: + """把 MySQL 字段值转换为可写入 D1 的类型。""" + if value is None: + return None + if isinstance(value, (datetime,)): + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, Decimal): + return float(value) + if isinstance(value, bytes): + return value.hex() + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return value + + def _sqlite_type_from_mysql(self, mysql_type: str) -> str: + t = mysql_type.lower() + if any(x in t for x in ["int", "bit", "bool"]): + return "INTEGER" + if any(x in t for x in ["double", "float", "decimal", "numeric", "real"]): + return "REAL" + if any(x in t for x in ["blob", "binary", "varbinary"]): + return "BLOB" + return "TEXT" + + def _get_mysql_tables(self) -> list[str]: + """获取待备份表。""" + if self.tables_config: + return [x.strip() for x in self.tables_config.split(",") if x.strip()] + + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SHOW TABLES") + rows = cursor.fetchall() + + tables: list[str] = [] + for row in rows: + # DictCursor 下 SHOW TABLES 返回单键字典 + table_name = list(row.values())[0] + tables.append(str(table_name)) + return tables + + def _get_mysql_columns(self, table_name: str) -> list[dict]: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f"SHOW COLUMNS FROM `{table_name}`") + return list(cursor.fetchall()) + + def _guess_cursor_column(self, columns: list[dict]) -> str | None: + """选择增量同步游标列。""" + column_names = {c["Field"] for c in columns} + for candidate in ["timestamp", "created_at", "updated_at", "id"]: + if candidate in column_names: + return candidate + return None + + def _ensure_d1_meta_tables(self): + """创建 D1 元数据表。""" + self._query_d1( + """ + CREATE TABLE IF NOT EXISTS backup_sync_state ( + table_name TEXT PRIMARY KEY, + cursor_col TEXT, + last_cursor TEXT, + updated_at TEXT + ) + """ + ) + self._query_d1( + """ + CREATE TABLE IF NOT EXISTS backup_run_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_at TEXT, + table_name TEXT, + synced_rows INTEGER, + status TEXT, + message TEXT + ) + """ + ) + + def _ensure_d1_table(self, table_name: str, columns: list[dict]): + """创建 D1 备份表。""" + table_sql_name = f"backup_{table_name}" + col_defs: list[str] = [] + primary_keys: list[str] = [] + + for col in columns: + field = col["Field"] + col_type = self._sqlite_type_from_mysql(col["Type"]) + col_defs.append(f'"{field}" {col_type}') + if col.get("Key") == "PRI": + primary_keys.append(field) + + # SQLite/D1 对复合主键需要表级 PRIMARY KEY 约束,不能多列各自 PRIMARY KEY + if primary_keys: + pk_cols = ", ".join([f'"{k}"' for k in primary_keys]) + col_defs.append(f"PRIMARY KEY ({pk_cols})") + + create_sql = f"CREATE TABLE IF NOT EXISTS \"{table_sql_name}\" ({', '.join(col_defs)})" + self._query_d1(create_sql) + + def _get_last_cursor(self, table_name: str) -> str | None: + """读取 D1 已同步游标。""" + resp = self._query_d1( + "SELECT last_cursor FROM backup_sync_state WHERE table_name = ?", + [table_name], + ) + rows = self._d1_results(resp) + if not rows: + return None + return rows[0].get("last_cursor") + + def _update_last_cursor(self, table_name: str, cursor_col: str, cursor_value: Any): + """更新 D1 已同步游标。""" + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self._query_d1( + """ + INSERT INTO backup_sync_state(table_name, cursor_col, last_cursor, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(table_name) DO UPDATE SET + cursor_col = excluded.cursor_col, + last_cursor = excluded.last_cursor, + updated_at = excluded.updated_at + """, + [table_name, cursor_col, str(cursor_value), now_str], + ) + + def _log_run(self, table_name: str, synced_rows: int, status: str, message: str): + """记录备份执行日志。""" + run_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self._query_d1( + "INSERT INTO backup_run_log(run_at, table_name, synced_rows, status, message) VALUES (?, ?, ?, ?, ?)", + [run_at, table_name, synced_rows, status, message[:1000]], + ) + + def _fetch_mysql_rows( + self, + table_name: str, + cursor_col: str | None, + last_cursor: str | None, + ) -> list[dict]: + """按游标从 MySQL 抽取增量数据。""" + with get_db_connection() as conn: + cursor = conn.cursor() + if cursor_col and last_cursor: + sql = ( + f"SELECT * FROM `{table_name}` WHERE `{cursor_col}` > %s " + f"ORDER BY `{cursor_col}` ASC LIMIT %s" + ) + cursor.execute(sql, (last_cursor, self.batch_size)) + elif cursor_col: + sql = f"SELECT * FROM `{table_name}` ORDER BY `{cursor_col}` ASC LIMIT %s" + cursor.execute(sql, (self.batch_size,)) + else: + sql = f"SELECT * FROM `{table_name}` LIMIT %s" + cursor.execute(sql, (self.batch_size,)) + return list(cursor.fetchall()) + + def _upsert_rows_to_d1(self, table_name: str, columns: list[dict], rows: list[dict]): + """把数据批量 UPSERT 到 D1。""" + if not rows: + return + + table_sql_name = f"backup_{table_name}" + col_names = [c["Field"] for c in columns] + col_sql = ", ".join([f'"{c}"' for c in col_names]) + + # 避免 D1 的 SQL 变量数量限制触发 400 + # 通过环境变量 D1_MAX_SQL_PARAMS 控制,默认保守值 90 + chunk_size = max(1, self.max_sql_params // max(1, len(col_names))) + + for i in range(0, len(rows), chunk_size): + chunk_rows = rows[i:i + chunk_size] + values_clause: list[str] = [] + params: list[Any] = [] + for row in chunk_rows: + placeholders = ", ".join(["?" for _ in col_names]) + values_clause.append(f"({placeholders})") + for col in col_names: + params.append(self._normalize_value(row.get(col))) + + sql = ( + f"INSERT OR REPLACE INTO \"{table_sql_name}\" ({col_sql}) " + f"VALUES {', '.join(values_clause)}" + ) + self._query_d1(sql, params) + + def backup_table_once(self, table_name: str) -> int: + """单表执行一次增量备份,返回同步行数。""" + columns = self._get_mysql_columns(table_name) + if not columns: + return 0 + + cursor_col = self._guess_cursor_column(columns) + self._ensure_d1_table(table_name, columns) + + total_synced = 0 + last_cursor = self._get_last_cursor(table_name) + + while True: + rows = self._fetch_mysql_rows(table_name, cursor_col, last_cursor) + if not rows: + break + + self._upsert_rows_to_d1(table_name, columns, rows) + total_synced += len(rows) + + if cursor_col: + last_cursor = self._normalize_value(rows[-1].get(cursor_col)) + self._update_last_cursor(table_name, cursor_col, last_cursor) + else: + # 无游标列时仅做一批,避免重复全量 + break + + if len(rows) < self.batch_size: + break + + return total_synced + + def run_backup_once(self): + """执行一次全表备份任务。""" + if not self.is_config_ready(): + logger.warning("D1 备份未启用或配置不完整,跳过本次任务") + return + + if not self._job_lock.acquire(blocking=False): + logger.warning("上一次 D1 备份尚未完成,跳过本次任务") + return + + try: + self._ensure_d1_meta_tables() + tables = self._get_mysql_tables() + logger.info("D1 备份任务开始,待同步表: %s", ", ".join(tables)) + + for table_name in tables: + try: + synced = self.backup_table_once(table_name) + logger.info("表 %s 同步完成,本次同步 %d 行", table_name, synced) + self._log_run(table_name, synced, "success", "ok") + except Exception as table_error: + logger.exception("表 %s 同步失败", table_name) + self._log_run(table_name, 0, "failed", str(table_error)) + except Exception: + logger.exception("D1 备份任务执行失败") + finally: + self._job_lock.release() + + +def schedule_mysql_d1_backup(): + """启动 D1 备份定时任务(每小时 :30)。""" + service = MySQLD1BackupService() + if not service.is_config_ready(): + logger.warning("D1 备份调度未启动:请检查 D1_BACKUP_ENABLED 与 Cloudflare 参数") + return + + # 启动后先执行一次,避免首次备份必须等待到下一个 :30 + service.run_backup_once() + + # 使用独立调度器,避免与项目内其他定时任务共享全局调度状态 + local_scheduler = schedule.Scheduler() + local_scheduler.every().hour.at(":30").do(service.run_backup_once) + logger.info("MySQL -> D1 定时备份已启动(每小时 :30 执行)") + + while True: + local_scheduler.run_pending() + time.sleep(1) + + +def run_mysql_d1_backup_scheduler_background(): + """后台线程启动 D1 备份调度器。""" + service = MySQLD1BackupService() + if not service.is_config_ready(): + logger.warning("D1 备份配置不完整,后台线程不启动") + return None + + scheduler_thread = threading.Thread(target=schedule_mysql_d1_backup, daemon=True) + scheduler_thread.start() + logger.info("D1 备份调度器已在后台启动") + return scheduler_thread + + +if __name__ == "__main__": + schedule_mysql_d1_backup() \ No newline at end of file diff --git a/start_system.py b/start_system.py index f84dc75..c0eec86 100755 --- a/start_system.py +++ b/start_system.py @@ -27,6 +27,13 @@ except ImportError: def run_scheduler_background(): return None +try: + from mysql_d1_backup_scheduler import run_mysql_d1_backup_scheduler_background +except ImportError: + # 如果模块导入失败,定义一个空函数避免启动失败 + def run_mysql_d1_backup_scheduler_background(): + return None + load_dotenv() apply_global_proxy_from_env() logger = get_project_logger("start_system") @@ -154,6 +161,17 @@ def main(): except Exception as e: log(f"⚠️ 启动每日报告程序异常: {e}", YELLOW) report_thread = None + + # 4. 启动 MySQL -> Cloudflare D1 定时备份(每小时 :30) + try: + d1_backup_thread = run_mysql_d1_backup_scheduler_background() + if d1_backup_thread: + log("MySQL -> D1 定时备份程序已启动(每小时 :30)", GREEN) + else: + log("D1 定时备份未启动(可能未启用或配置不完整)", YELLOW) + except Exception as e: + log(f"⚠️ 启动 D1 定时备份程序异常: {e}", YELLOW) + d1_backup_thread = None log("系统启动完成!", GREEN) log("前端访问地址: http://localhost:8000", GREEN)