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 }}
暂无数据
+
{{ monthlyStats.DLinear.accuracy }}%
{{ monthlyStats.DLinear.correct }}/{{ monthlyStats.DLinear.total }}
暂无数据
+
{{ monthlyStats['N-BEATS'].accuracy }}%
{{ monthlyStats['N-BEATS'].correct }}/{{ monthlyStats['N-BEATS'].total }}
暂无数据
+
{{ monthlyStats['TTM-R2'].accuracy }}%
{{ monthlyStats['TTM-R2'].correct }}/{{ monthlyStats['TTM-R2'].total }}
暂无数据
+
+
+
+
+
+
+
+
+
+ | 日期 |
+ 集成策略 |
+ DLinear |
+ N-BEATS |
+ TTM-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)