Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7cbd0da4 | ||
|
|
ad84ee62c4 | ||
|
|
77d084341c |
No files matched your search
@@ -17,8 +17,7 @@
|
||||
"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 checkout:*)"
|
||||
"Bash(git -C /Users/xxz/PycharmProjects/crypto_predictor log --oneline -5)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,6 @@ BINANCE_API_URL=https://api.binance.com
|
||||
SOCKS5_PROXY=socks5://127.0.0.1:7890
|
||||
USE_PROXY=true
|
||||
|
||||
# send_msg 独立代理(可选,优先级高于 SOCKS5_PROXY)
|
||||
# SEND_MSG_PROXY=socks5://127.0.0.1:7891
|
||||
# SEND_MSG_PROXY_PORT=7891
|
||||
|
||||
# Data Configuration
|
||||
DATA_CACHE_DIR=./data/cache
|
||||
LOOKBACK_1H=168 # 1 week for 1h data
|
||||
@@ -42,19 +38,6 @@ 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
|
||||
|
||||
+30
-23
@@ -12,13 +12,9 @@ import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
from utils.network_proxy import apply_global_proxy_from_env, get_proxy_url
|
||||
from utils.project_logger import get_project_logger
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
apply_global_proxy_from_env()
|
||||
logger = get_project_logger("api.polymarket_client")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,6 +36,16 @@ class PolymarketClient:
|
||||
GAMMA_API = "https://gamma-api.polymarket.com"
|
||||
CLOB_API = "https://clob.polymarket.com"
|
||||
|
||||
@staticmethod
|
||||
def _apply_global_proxy(proxy_url: str) -> None:
|
||||
"""统一设置代理环境变量,确保第三方网络库也走代理。"""
|
||||
os.environ["HTTP_PROXY"] = proxy_url
|
||||
os.environ["HTTPS_PROXY"] = proxy_url
|
||||
os.environ["ALL_PROXY"] = proxy_url
|
||||
os.environ["http_proxy"] = proxy_url
|
||||
os.environ["https_proxy"] = proxy_url
|
||||
os.environ["all_proxy"] = proxy_url
|
||||
|
||||
def __init__(self, private_key: Optional[str] = None):
|
||||
"""
|
||||
初始化客户端
|
||||
@@ -50,7 +56,8 @@ class PolymarketClient:
|
||||
self.private_key = private_key or os.getenv("POLYMARKET_PRIVATE_KEY")
|
||||
|
||||
# 配置代理
|
||||
proxy = get_proxy_url()
|
||||
proxy = os.getenv("SOCKS5_PROXY", "socks5://127.0.0.1:7890")
|
||||
self._apply_global_proxy(proxy)
|
||||
|
||||
# HTTP客户端 - 禁用压缩避免brotli问题
|
||||
self.http = httpx.Client(
|
||||
@@ -83,9 +90,9 @@ class PolymarketClient:
|
||||
# 获取或创建API凭证
|
||||
self.clob_client.set_api_creds(self.clob_client.create_or_derive_api_creds())
|
||||
|
||||
logger.info("CLOB客户端初始化成功")
|
||||
print("✓ CLOB客户端初始化成功")
|
||||
except Exception as e:
|
||||
logger.warning("CLOB客户端初始化失败: %s", e)
|
||||
print(f"⚠️ CLOB客户端初始化失败: {e}")
|
||||
self.clob_client = None
|
||||
|
||||
def search_markets(self, query: str = "BTC", limit: int = 20) -> List[Dict]:
|
||||
@@ -115,7 +122,7 @@ class PolymarketClient:
|
||||
filtered = [m for m in markets if query.lower() in m.get("question", "").lower()]
|
||||
return filtered
|
||||
except Exception as e:
|
||||
logger.error("搜索市场失败: %s", e)
|
||||
print(f"搜索市场失败: {e}")
|
||||
return []
|
||||
|
||||
def search_btc_1h_markets(self) -> List[Dict]:
|
||||
@@ -154,7 +161,7 @@ class PolymarketClient:
|
||||
|
||||
return btc_1h_markets
|
||||
except Exception as e:
|
||||
logger.error("搜索BTC 1H市场失败: %s", e)
|
||||
print(f"搜索BTC 1H市场失败: {e}")
|
||||
return []
|
||||
|
||||
def get_market_info(self, condition_id: str) -> Optional[MarketInfo]:
|
||||
@@ -183,7 +190,7 @@ class PolymarketClient:
|
||||
end_date=data.get("endDate", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("获取市场信息失败: %s", e)
|
||||
print(f"获取市场信息失败: {e}")
|
||||
return None
|
||||
|
||||
def get_orderbook(self, token_id: str) -> Dict:
|
||||
@@ -204,7 +211,7 @@ class PolymarketClient:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error("获取订单簿失败: %s", e)
|
||||
print(f"获取订单簿失败: {e}")
|
||||
return {}
|
||||
|
||||
def get_price(self, token_id: str) -> Optional[float]:
|
||||
@@ -226,7 +233,7 @@ class PolymarketClient:
|
||||
data = response.json()
|
||||
return float(data.get("price", 0))
|
||||
except Exception as e:
|
||||
logger.error("获取价格失败: %s", e)
|
||||
print(f"获取价格失败: {e}")
|
||||
return None
|
||||
|
||||
def place_market_order(
|
||||
@@ -247,7 +254,7 @@ class PolymarketClient:
|
||||
订单响应
|
||||
"""
|
||||
if not self.clob_client:
|
||||
logger.error("CLOB客户端未初始化,无法下单")
|
||||
print("❌ CLOB客户端未初始化,无法下单")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -264,10 +271,10 @@ class PolymarketClient:
|
||||
|
||||
# 提交订单
|
||||
response = self.clob_client.post_order(order)
|
||||
logger.info("订单已提交: %s", response)
|
||||
print(f"✓ 订单已提交: {response}")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("下单失败: %s", e)
|
||||
print(f"❌ 下单失败: {e}")
|
||||
return None
|
||||
|
||||
def place_limit_order(
|
||||
@@ -290,7 +297,7 @@ class PolymarketClient:
|
||||
订单响应
|
||||
"""
|
||||
if not self.clob_client:
|
||||
logger.error("CLOB客户端未初始化,无法下单")
|
||||
print("❌ CLOB客户端未初始化,无法下单")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -308,10 +315,10 @@ class PolymarketClient:
|
||||
|
||||
# 提交订单
|
||||
response = self.clob_client.post_order(order)
|
||||
logger.info("限价单已提交: %s", response)
|
||||
print(f"✓ 限价单已提交: {response}")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("下单失败: %s", e)
|
||||
print(f"❌ 下单失败: {e}")
|
||||
return None
|
||||
|
||||
def get_positions(self) -> List[Dict]:
|
||||
@@ -322,14 +329,14 @@ class PolymarketClient:
|
||||
仓位列表
|
||||
"""
|
||||
if not self.clob_client:
|
||||
logger.error("CLOB客户端未初始化")
|
||||
print("❌ CLOB客户端未初始化")
|
||||
return []
|
||||
|
||||
try:
|
||||
positions = self.clob_client.get_positions()
|
||||
return positions
|
||||
except Exception as e:
|
||||
logger.error("获取仓位失败: %s", e)
|
||||
print(f"获取仓位失败: {e}")
|
||||
return []
|
||||
|
||||
def cancel_order(self, order_id: str) -> bool:
|
||||
@@ -343,15 +350,15 @@ class PolymarketClient:
|
||||
是否成功
|
||||
"""
|
||||
if not self.clob_client:
|
||||
logger.error("CLOB客户端未初始化")
|
||||
print("❌ CLOB客户端未初始化")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.clob_client.cancel(order_id)
|
||||
logger.info("订单已取消: %s", order_id)
|
||||
print(f"✓ 订单已取消: {order_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("取消订单失败: %s", e)
|
||||
print(f"❌ 取消订单失败: {e}")
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"account_id": "7ea8a511cad4d467490fe36aa7d6638c",
|
||||
"project_name": "trades"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"account": {
|
||||
"id": "7ea8a511cad4d467490fe36aa7d6638c",
|
||||
"name": "Subuhuinew@gmail.com's Account"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -1,41 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,101 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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');
|
||||
@@ -1,251 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BTC 预测交易看板 (Pages + D1)</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#">
|
||||
<i class="bi bi-currency-bitcoin"></i> BTC 预测交易看板 (Pages)
|
||||
</a>
|
||||
<span class="navbar-text text-light">
|
||||
<i class="bi bi-clock"></i> {{ serverTime }}
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'trades' }" @click="activeTab = 'trades'">
|
||||
<i class="bi bi-graph-up"></i> 交易详情
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'accuracy' }" @click="activeTab = 'accuracy'">
|
||||
<i class="bi bi-bullseye"></i> 模型准确率
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-show="activeTab === 'trades'">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<i class="bi bi-lightning-charge"></i> 当前小时交易
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="currentTrade" class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">市场</div>
|
||||
<div class="stat-value small">{{ currentTrade.market_title || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">预测方向</div>
|
||||
<div class="stat-value" :class="getPredictionClass(currentTrade.prediction)">
|
||||
{{ currentTrade.prediction || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">概率</div>
|
||||
<div class="stat-value">{{ formatPercent(currentTrade.probability) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">策略</div>
|
||||
<div class="stat-value small">{{ currentTrade.strategy || 'Ensemble' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">状态</div>
|
||||
<div class="stat-value" :class="getStatusClass(currentTrade.status)">
|
||||
{{ getStatusText(currentTrade.status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox"></i> 暂无交易记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-clock-history"></i> 历史交易记录</span>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="month" class="form-control form-control-sm" v-model="tradeFilter.month" @change="loadTradeHistory(1)" style="width: 150px;">
|
||||
<input type="date" class="form-control form-control-sm" v-model="tradeFilter.day" @change="loadTradeHistory(1)" style="width: 150px;">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="clearTradeFilter">
|
||||
<i class="bi bi-x-circle"></i> 清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>市场</th>
|
||||
<th>策略</th>
|
||||
<th>预测</th>
|
||||
<th>概率</th>
|
||||
<th>价格</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="trade in tradeHistory.data" :key="trade.id">
|
||||
<td>{{ formatTime(trade.timestamp) }}</td>
|
||||
<td class="text-truncate" style="max-width: 200px;">{{ trade.market_title }}</td>
|
||||
<td><span class="badge bg-secondary">{{ trade.strategy || 'Ensemble' }}</span></td>
|
||||
<td :class="getPredictionClass(trade.prediction)">{{ trade.prediction }}</td>
|
||||
<td>{{ formatPercent(trade.probability) }}</td>
|
||||
<td>{{ trade.price ?? '-' }}</td>
|
||||
<td :class="getStatusClass(trade.status)">{{ getStatusText(trade.status) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!tradeHistory.data?.length"><td colspan="7" class="text-center text-muted py-4">暂无数据</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted">共 {{ tradeHistory.total }} 条</span>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item" :class="{ disabled: tradeHistory.page <= 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="loadTradeHistory(tradeHistory.page - 1)">上一页</a>
|
||||
</li>
|
||||
<li class="page-item disabled"><span class="page-link">{{ tradeHistory.page }} / {{ tradeHistory.total_pages }}</span></li>
|
||||
<li class="page-item" :class="{ disabled: tradeHistory.page >= tradeHistory.total_pages }">
|
||||
<a class="page-link" href="#" @click.prevent="loadTradeHistory(tradeHistory.page + 1)">下一页</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'accuracy'">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="form-label mb-0">选择月份:</label>
|
||||
<input type="month" class="form-control" v-model="accuracyFilter.month" @change="loadAccuracyData" style="width: 180px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3"><div class="card h-100 border-primary shadow-sm"><div class="card-header bg-primary text-white text-center">集成策略</div><div class="card-body text-center" v-if="monthlyStats.Ensemble"><h2 class="display-6 text-primary fw-bold">{{ monthlyStats.Ensemble.accuracy }}%</h2><div class="text-muted mb-2">准确率</div><div class="small text-muted">{{ monthlyStats.Ensemble.correct }}/{{ monthlyStats.Ensemble.total }}</div></div><div v-else class="text-center text-muted py-3">暂无数据</div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100 border-secondary"><div class="card-header bg-secondary text-white text-center">DLinear</div><div class="card-body text-center" v-if="monthlyStats.DLinear"><h3 class="text-dark">{{ monthlyStats.DLinear.accuracy }}%</h3><div class="small text-muted">{{ monthlyStats.DLinear.correct }}/{{ monthlyStats.DLinear.total }}</div></div><div v-else class="text-center text-muted py-3">暂无数据</div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100 border-secondary"><div class="card-header bg-secondary text-white text-center">N-BEATS</div><div class="card-body text-center" v-if="monthlyStats['N-BEATS']"><h3 class="text-dark">{{ monthlyStats['N-BEATS'].accuracy }}%</h3><div class="small text-muted">{{ monthlyStats['N-BEATS'].correct }}/{{ monthlyStats['N-BEATS'].total }}</div></div><div v-else class="text-center text-muted py-3">暂无数据</div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100 border-secondary"><div class="card-header bg-secondary text-white text-center">TTM-R2</div><div class="card-body text-center" v-if="monthlyStats['TTM-R2']"><h3 class="text-dark">{{ monthlyStats['TTM-R2'].accuracy }}%</h3><div class="small text-muted">{{ monthlyStats['TTM-R2'].correct }}/{{ monthlyStats['TTM-R2'].total }}</div></div><div v-else class="text-center text-muted py-3">暂无数据</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-calendar3"></i> 每日准确率概览</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 text-center">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="text-start ps-4">日期</th>
|
||||
<th>集成策略</th>
|
||||
<th>DLinear</th>
|
||||
<th>N-BEATS</th>
|
||||
<th>TTM-R2</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in dailyStats.data" :key="day.date">
|
||||
<td class="text-start ps-4">{{ day.date }}</td>
|
||||
<td><span v-if="day.Ensemble" :class="getAccuracyClass(day.Ensemble.accuracy)">{{ day.Ensemble.accuracy }}%</span><span v-else class="text-muted">-</span></td>
|
||||
<td><span v-if="day.DLinear" :class="getAccuracyClass(day.DLinear.accuracy)">{{ day.DLinear.accuracy }}%</span><span v-else class="text-muted">-</span></td>
|
||||
<td><span v-if="day['N-BEATS']" :class="getAccuracyClass(day['N-BEATS'].accuracy)">{{ day['N-BEATS'].accuracy }}%</span><span v-else class="text-muted">-</span></td>
|
||||
<td><span v-if="day['TTM-R2']" :class="getAccuracyClass(day['TTM-R2'].accuracy)">{{ day['TTM-R2'].accuracy }}%</span><span v-else class="text-muted">-</span></td>
|
||||
<td><button class="btn btn-sm btn-outline-primary" @click="showHourlyDetails(day.date)"><i class="bi bi-eye"></i> 详情</button></td>
|
||||
</tr>
|
||||
<tr v-if="!dailyStats.data?.length"><td colspan="6" class="text-center text-muted py-4">暂无数据</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted">共 {{ dailyStats.total }} 天</span>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item" :class="{ disabled: dailyStats.page <= 1 }"><a class="page-link" href="#" @click.prevent="loadDailyStats(dailyStats.page - 1)">上一页</a></li>
|
||||
<li class="page-item disabled"><span class="page-link">{{ dailyStats.page }} / {{ dailyStats.total_pages }}</span></li>
|
||||
<li class="page-item" :class="{ disabled: dailyStats.page >= dailyStats.total_pages }"><a class="page-link" href="#" @click.prevent="loadDailyStats(dailyStats.page + 1)">下一页</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="hourlyModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-clock"></i> {{ hourlyModal.date }} 每小时预测详情</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<table class="table table-sm table-hover mb-0 text-center align-middle">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>开盘</th>
|
||||
<th>收盘</th>
|
||||
<th>实际</th>
|
||||
<th class="table-primary">集成策略</th>
|
||||
<th>DLinear</th>
|
||||
<th>N-BEATS</th>
|
||||
<th>TTM-R2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="hour in hourlyModal.data" :key="hour.hour">
|
||||
<td>{{ hour.timestamp_cst ? hour.timestamp_cst.split(' ')[1] : (hour.hour + ':00') }}</td>
|
||||
<td>{{ hour.open_price ?? '-' }}</td>
|
||||
<td>{{ hour.close_price ?? '-' }}</td>
|
||||
<td :class="hour.actual_result === 1 ? 'text-success fw-bold' : (hour.actual_result === 0 ? 'text-danger fw-bold' : 'text-muted')">{{ hour.actual_result === 1 ? 'UP' : (hour.actual_result === 0 ? 'DOWN' : '-') }}</td>
|
||||
<td class="table-primary bg-opacity-10"><span v-if="hour.models?.Ensemble" :class="hour.models.Ensemble.is_correct ? 'text-success' : 'text-danger'">{{ hour.models.Ensemble.prediction === 1 ? 'UP' : 'DOWN' }}</span></td>
|
||||
<td><span v-if="hour.models?.DLinear" :class="hour.models.DLinear.is_correct ? 'text-success' : 'text-danger'">{{ hour.models.DLinear.prediction === 1 ? 'UP' : 'DOWN' }}</span></td>
|
||||
<td><span v-if="hour.models?.['N-BEATS']" :class="hour.models['N-BEATS'].is_correct ? 'text-success' : 'text-danger'">{{ hour.models['N-BEATS'].prediction === 1 ? 'UP' : 'DOWN' }}</span></td>
|
||||
<td><span v-if="hour.models?.['TTM-R2']" :class="hour.models['TTM-R2'].is_correct ? 'text-success' : 'text-danger'">{{ hour.models['TTM-R2'].prediction === 1 ? 'UP' : 'DOWN' }}</span></td>
|
||||
</tr>
|
||||
<tr v-if="!hourlyModal.data?.length"><td colspan="8" class="text-center text-muted py-4">暂无数据</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">加载中...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.15/dist/vue.global.prod.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,118 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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"
|
||||
@@ -1,11 +0,0 @@
|
||||
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"
|
||||
|
||||
+134
-32
@@ -1,25 +1,38 @@
|
||||
"""从Binance获取加密货币OHLCV数据"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional, Literal, Any
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pandas as pd
|
||||
from binance.client import Client
|
||||
import pytz
|
||||
from dotenv import load_dotenv
|
||||
from websocket import create_connection
|
||||
|
||||
from data.database import DatabaseManager
|
||||
from utils.network_proxy import apply_global_proxy_from_env, get_proxy_url
|
||||
from utils.project_logger import get_project_logger
|
||||
from utils.send_msg import lark_notifier
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
apply_global_proxy_from_env()
|
||||
logger = get_project_logger("data.fetcher")
|
||||
|
||||
|
||||
logger = logging.getLogger("polymarket_trader")
|
||||
|
||||
|
||||
def _apply_global_proxy(proxy_url: str) -> None:
|
||||
"""统一设置代理环境变量,确保第三方网络库也走代理。"""
|
||||
os.environ['HTTP_PROXY'] = proxy_url
|
||||
os.environ['HTTPS_PROXY'] = proxy_url
|
||||
os.environ['ALL_PROXY'] = proxy_url
|
||||
os.environ['http_proxy'] = proxy_url
|
||||
os.environ['https_proxy'] = proxy_url
|
||||
os.environ['all_proxy'] = proxy_url
|
||||
|
||||
|
||||
class BinanceDataFetcher:
|
||||
@@ -30,36 +43,38 @@ class BinanceDataFetcher:
|
||||
'15m': Client.KLINE_INTERVAL_15MINUTE,
|
||||
}
|
||||
|
||||
INTERVAL_MS = {
|
||||
'1h': 60 * 60 * 1000,
|
||||
'15m': 15 * 60 * 1000,
|
||||
}
|
||||
|
||||
WS_API_ENDPOINT = "wss://ws-api.binance.com/ws-api/v3"
|
||||
|
||||
def __init__(self, cache_dir: str = "./data/cache"):
|
||||
"""初始化Binance客户端和数据库管理器
|
||||
|
||||
Args:
|
||||
cache_dir: 缓存/数据库目录
|
||||
"""
|
||||
# 统一读取代理配置,兼容全局代理环境变量。
|
||||
proxies = None
|
||||
proxy_url = get_proxy_url()
|
||||
|
||||
if proxy_url:
|
||||
proxies = {
|
||||
'http': proxy_url,
|
||||
'https': proxy_url
|
||||
}
|
||||
logger.info("使用代理: %s", proxy_url)
|
||||
# 配置代理
|
||||
socks5_proxy = os.getenv('SOCKS5_PROXY', 'socks5://127.0.0.1:7890')
|
||||
_apply_global_proxy(socks5_proxy)
|
||||
proxies = {
|
||||
'http': socks5_proxy,
|
||||
'https': socks5_proxy
|
||||
}
|
||||
logger.info(f"强制使用SOCKS5代理: {socks5_proxy}")
|
||||
|
||||
try:
|
||||
# 初始化Binance客户端(带代理)
|
||||
if proxies:
|
||||
self.client = Client(requests_params={'proxies': proxies})
|
||||
else:
|
||||
self.client = Client()
|
||||
# 初始化Binance客户端(强制走代理)
|
||||
self.client = Client(requests_params={'proxies': proxies})
|
||||
|
||||
# 测试连接
|
||||
self.client.ping()
|
||||
logger.info("已成功连接到Binance API")
|
||||
except Exception as e:
|
||||
logger.warning("Binance API连接失败: %s", e)
|
||||
logger.warning("提示: 检查代理设置、代理服务状态,或使用缓存数据")
|
||||
logger.warning(f"Binance API连接失败: {e}")
|
||||
logger.warning("提示: 1) 检查代理设置 2) 确保代理服务运行 3) 或者使用缓存数据")
|
||||
self.client = None
|
||||
|
||||
# 初始化数据库管理器
|
||||
@@ -112,13 +127,13 @@ class BinanceDataFetcher:
|
||||
|
||||
# 简单判断覆盖范围 (容忍少量缺失)
|
||||
if min_ts <= start_dt + timedelta(days=2) and max_ts >= end_dt - timedelta(days=2):
|
||||
logger.info("从数据库加载数据: %s %s (%s rows)", symbol, timeframe, len(df))
|
||||
logger.info(f"从数据库加载数据: {symbol} {timeframe} ({len(df)} rows)")
|
||||
return df
|
||||
|
||||
# 2. 从API获取数据
|
||||
if self.client is None:
|
||||
if not df.empty:
|
||||
logger.warning("API不可用,返回数据库现有数据 (%s rows)", len(df))
|
||||
logger.warning(f"API不可用,返回数据库现有数据 ({len(df)} rows)")
|
||||
return df
|
||||
|
||||
raise ConnectionError(
|
||||
@@ -128,32 +143,35 @@ class BinanceDataFetcher:
|
||||
# Binance使用USDT交易对
|
||||
pair = f"{symbol}USDT"
|
||||
interval = self.INTERVALS[timeframe]
|
||||
interval_ms = self.INTERVAL_MS[timeframe]
|
||||
|
||||
logger.info("获取 %s %s 数据,时间范围: %s 至 %s", symbol, timeframe, start_dt.date(), end_dt.date())
|
||||
logger.info(f"获取 {symbol} {timeframe} 数据,时间范围: {start_dt.date()} 至 {end_dt.date()}")
|
||||
|
||||
klines = []
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
klines = self.client.get_historical_klines(
|
||||
# 使用 Binance WebSocket API 的 klines 方法获取历史K线
|
||||
klines = self._get_historical_klines_via_ws(
|
||||
symbol=pair,
|
||||
interval=interval,
|
||||
start_str=str(int(start_dt.timestamp() * 1000)),
|
||||
end_str=str(int(end_dt.timestamp() * 1000))
|
||||
start_ts_ms=int(start_dt.timestamp() * 1000),
|
||||
end_ts_ms=int(end_dt.timestamp() * 1000),
|
||||
interval_ms=interval_ms
|
||||
)
|
||||
# 如果成功获取,跳出循环
|
||||
break
|
||||
except Exception as e:
|
||||
# 如果是最后一次尝试,且依然失败
|
||||
if attempt == max_retries - 1:
|
||||
logger.error("第 %s 次尝试失败,已达到最大重试次数。错误: %s", attempt + 1, e)
|
||||
logger.error(f"第 {attempt + 1} 次尝试失败,已达到最大重试次数。错误: {e}")
|
||||
if not df.empty:
|
||||
logger.warning("API请求最终失败,返回数据库现有数据")
|
||||
logger.warning("WebSocket请求最终失败,返回数据库现有数据")
|
||||
return df
|
||||
raise e # 如果没有缓存数据,抛出异常
|
||||
|
||||
# 计算等待时间 (指数退避: 2s, 4s, 8s...)
|
||||
wait_time = 2 * (2 ** attempt)
|
||||
logger.warning("请求失败(尝试 %s/%s),%s 秒后重试,错误: %s", attempt + 1, max_retries, wait_time, e)
|
||||
logger.warning(f"请求失败 (尝试 {attempt + 1}/{max_retries}),{wait_time}秒后重试。错误: {e}")
|
||||
time.sleep(wait_time)
|
||||
|
||||
if not klines:
|
||||
@@ -179,13 +197,94 @@ class BinanceDataFetcher:
|
||||
# 按时间排序
|
||||
new_df = new_df.sort_values('timestamp').reset_index(drop=True)
|
||||
|
||||
logger.info("已获取 %s 行数据", len(new_df))
|
||||
logger.info(f"已获取 {len(new_df)} 行数据")
|
||||
|
||||
# 保存到数据库
|
||||
self.db_manager.save_ohlcv(new_df, symbol, timeframe)
|
||||
|
||||
return new_df
|
||||
|
||||
def _get_historical_klines_via_ws(
|
||||
self,
|
||||
symbol: str,
|
||||
interval: str,
|
||||
start_ts_ms: int,
|
||||
end_ts_ms: int,
|
||||
interval_ms: int,
|
||||
limit: int = 1000
|
||||
):
|
||||
"""通过 Binance WebSocket API 分页拉取历史K线。"""
|
||||
if end_ts_ms <= start_ts_ms:
|
||||
return []
|
||||
|
||||
all_klines = []
|
||||
request_id = 1
|
||||
current_start = start_ts_ms
|
||||
|
||||
# WebSocket API 单次最多返回 1000 条,这里按窗口分页拉取。
|
||||
while current_start < end_ts_ms:
|
||||
max_window_end = current_start + (limit * interval_ms)
|
||||
current_end = min(end_ts_ms, max_window_end)
|
||||
|
||||
payload = {
|
||||
"id": request_id,
|
||||
"method": "klines",
|
||||
"params": {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"startTime": current_start,
|
||||
"endTime": current_end,
|
||||
"limit": limit
|
||||
}
|
||||
}
|
||||
|
||||
response_data = self._ws_request(payload)
|
||||
batch = response_data.get("result", [])
|
||||
if not batch:
|
||||
break
|
||||
|
||||
all_klines.extend(batch)
|
||||
last_open_time = int(batch[-1][0])
|
||||
next_start = last_open_time + interval_ms
|
||||
if next_start <= current_start:
|
||||
break
|
||||
current_start = next_start
|
||||
request_id += 1
|
||||
|
||||
return all_klines
|
||||
|
||||
def _ws_request(self, payload: dict) -> dict:
|
||||
"""发送单次 WebSocket API 请求并返回 JSON 响应。"""
|
||||
ws_kwargs: dict[str, Any] = {
|
||||
"timeout": 30
|
||||
}
|
||||
|
||||
socks5_proxy = os.getenv('SOCKS5_PROXY', 'socks5://127.0.0.1:7890')
|
||||
parsed = urlparse(socks5_proxy)
|
||||
if parsed.hostname and parsed.port:
|
||||
scheme = (parsed.scheme or 'socks5').lower()
|
||||
if scheme not in {'socks4', 'socks5', 'http'}:
|
||||
scheme = 'socks5'
|
||||
if scheme == 'socks5h':
|
||||
scheme = 'socks5'
|
||||
ws_kwargs.update({
|
||||
"http_proxy_host": parsed.hostname,
|
||||
"http_proxy_port": parsed.port,
|
||||
"proxy_type": scheme
|
||||
})
|
||||
|
||||
ws = create_connection(self.WS_API_ENDPOINT, **ws_kwargs)
|
||||
try:
|
||||
ws.send(json.dumps(payload))
|
||||
raw = ws.recv()
|
||||
data = json.loads(raw)
|
||||
status = data.get("status", 200)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Binance WS API返回异常状态: {data}")
|
||||
return data
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
def get_latest_price(self, symbol: Literal['BTC', 'ETH', 'SOL']) -> float:
|
||||
"""获取符号的当前价格
|
||||
|
||||
@@ -195,6 +294,9 @@ class BinanceDataFetcher:
|
||||
Returns:
|
||||
当前USDT价格
|
||||
"""
|
||||
if self.client is None:
|
||||
raise ConnectionError("Binance客户端不可用,无法获取最新价格")
|
||||
|
||||
pair = f"{symbol}USDT"
|
||||
ticker = self.client.get_symbol_ticker(symbol=pair)
|
||||
return float(ticker['price'])
|
||||
|
||||
@@ -99,7 +99,7 @@ def fmt_result(pred, actual):
|
||||
|
||||
def main():
|
||||
print("=" * 110)
|
||||
print("2026年3月 BTC 1h 涨跌预测准确率测试 (DLinear, N-BEATS, TTM-R2) - ONNX Version")
|
||||
print("2026年2月 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年3月数据...")
|
||||
print("\n[2/4] 获取2026年2月数据...")
|
||||
fetcher = BinanceDataFetcher()
|
||||
df = fetcher.fetch_ohlcv('BTC', '1h', start_date='2026-01-01', end_date='2026-04-01', use_cache=False)
|
||||
df = fetcher.fetch_ohlcv('BTC', '1h', start_date='2025-10-01', end_date='2026-03-01', use_cache=False)
|
||||
|
||||
print(f"数据范围(UTC): {df['timestamp'].min()} -> {df['timestamp'].max()}")
|
||||
print(f"总行数: {len(df)}")
|
||||
@@ -221,38 +221,28 @@ def main():
|
||||
X_ttm = np.array(X_ttm, dtype=np.float32)
|
||||
idx_ttm_map = {v: k for k, v in enumerate(idx_ttm)}
|
||||
|
||||
# 找出3月(北京时间)的所有预测目标K线索引
|
||||
# 找出2月(北京时间)的所有预测目标K线索引
|
||||
# df 中每一行都是一根已收盘的K线,timestamp 是 UTC
|
||||
jan_targets = []
|
||||
for target_idx in range(1, len(df)):
|
||||
# 同样在筛选样本时也将时间向后移动一个小时
|
||||
target_time_utc = df.iloc[target_idx]['timestamp'] + timedelta(hours=1)
|
||||
target_time_utc = df.iloc[target_idx]['timestamp']
|
||||
target_time_bj = utc_to_beijing(target_time_utc)
|
||||
if target_time_bj.year == 2026 and target_time_bj.month == 3:
|
||||
if target_time_bj.year == 2026 and target_time_bj.month == 2:
|
||||
jan_targets.append(target_idx)
|
||||
|
||||
print(f"3月(北京时间)目标K线数: {len(jan_targets)}")
|
||||
print(f"2月(北京时间)目标K线数: {len(jan_targets)}")
|
||||
|
||||
results = []
|
||||
|
||||
for target_idx in jan_targets:
|
||||
# 原始timestamp表示k线的开始时间,因此预测的结果实际对应下一小时的k线。
|
||||
# 所以展示时需要将时间向后移动1小时。
|
||||
target_time_utc = df.iloc[target_idx]['timestamp'] + timedelta(hours=1)
|
||||
target_time_utc = df.iloc[target_idx]['timestamp']
|
||||
target_time_bj = utc_to_beijing(target_time_utc)
|
||||
|
||||
# 现在我们将预测目标设为下一根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
|
||||
# 输入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
|
||||
|
||||
# --- DLinear 预测 (ONNX) ---
|
||||
dl_pred = None
|
||||
@@ -314,8 +304,8 @@ def main():
|
||||
results.append({
|
||||
'time_utc': target_time_utc,
|
||||
'time_bj': target_time_bj,
|
||||
'open': open_price,
|
||||
'close': close_price,
|
||||
'current_close': current_close,
|
||||
'target_close': target_close,
|
||||
'actual': actual_label,
|
||||
'dlinear': dl_pred,
|
||||
'nbeats': nb_pred,
|
||||
@@ -334,7 +324,7 @@ def main():
|
||||
|
||||
total_samples = len(df_res)
|
||||
|
||||
print(f"\n[4/4] 准确率统计 (2026年3月,北京时间)")
|
||||
print(f"\n[4/4] 准确率统计 (2026年2月,北京时间)")
|
||||
print(f"总样本数: {total_samples}")
|
||||
|
||||
# ========== 每小时详细数据 ==========
|
||||
@@ -342,9 +332,8 @@ 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}"
|
||||
)
|
||||
|
||||
@@ -382,7 +371,7 @@ def main():
|
||||
ens_r = fmt_result(row['ensemble'], row['actual'])
|
||||
|
||||
print(
|
||||
f"{time_str:<18} {row['open']:>10.2f} {row['close']:>10.2f} {actual_str:>4} "
|
||||
f"{time_str:<18} {row['current_close']:>10.2f} {row['target_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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ from datetime import timedelta
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from data.fetcher import BinanceDataFetcher
|
||||
from tsfm_public.models.tinytimemixer import TinyTimeMixerForPrediction
|
||||
from utils.network_proxy import apply_global_proxy_from_env
|
||||
|
||||
apply_global_proxy_from_env()
|
||||
|
||||
# ==================== 时区与美股判断 ====================
|
||||
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
#!/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()
|
||||
@@ -46,6 +46,9 @@ httpx>=0.25.0
|
||||
# 数据源
|
||||
# ----------------------------------------------
|
||||
python-binance>=1.0.19
|
||||
websocket-client>=1.8.0
|
||||
python-socks>=2.5.0
|
||||
PySocks>=1.7.1
|
||||
|
||||
# ----------------------------------------------
|
||||
# 任务调度
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""整点后5秒抓取 BTC 1h K线(至少512根,且包含最新时间)"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
import pytz
|
||||
|
||||
# 直接运行脚本时,确保可以从项目根目录导入 data 包
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from data.fetcher import BinanceDataFetcher
|
||||
|
||||
|
||||
def get_next_run_time(delay_seconds: int = 5) -> datetime:
|
||||
"""计算下一次执行时间:下一个整点 + delay_seconds。"""
|
||||
now_utc = datetime.now(pytz.UTC)
|
||||
next_hour = (now_utc + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
|
||||
return next_hour + timedelta(seconds=delay_seconds)
|
||||
|
||||
|
||||
def fetch_min_klines_with_latest(
|
||||
fetcher: BinanceDataFetcher,
|
||||
min_klines: int = 512,
|
||||
latest_check_retries: int = 8,
|
||||
latest_check_interval: int = 2,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
拉取 BTC 1h K线,确保:
|
||||
1) 数量不少于 min_klines
|
||||
2) 包含当前小时的最新时间(UTC 整点)
|
||||
"""
|
||||
now_utc = datetime.now(pytz.UTC)
|
||||
expected_latest_ts = now_utc.replace(minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||||
|
||||
lookback_days = 35
|
||||
for attempt in range(latest_check_retries):
|
||||
start_date = (datetime.now(pytz.UTC) - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
|
||||
|
||||
# 使用 WebSocket 拉取,避免仅命中缓存导致最新K线不更新。
|
||||
df = fetcher.fetch_ohlcv(
|
||||
symbol="BTC",
|
||||
timeframe="1h",
|
||||
start_date=start_date,
|
||||
end_date=None,
|
||||
use_cache=False,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
if len(df) < min_klines:
|
||||
lookback_days += 7
|
||||
print(
|
||||
f"[WARN] 当前仅获取 {len(df)} 根K线,小于 {min_klines},扩大回看窗口到 {lookback_days} 天后重试。"
|
||||
)
|
||||
continue
|
||||
|
||||
latest_ts = df["timestamp"].max().to_pydatetime().replace(tzinfo=None)
|
||||
if latest_ts >= expected_latest_ts:
|
||||
result = df.tail(min_klines).copy().reset_index(drop=True)
|
||||
print(
|
||||
f"[OK] 获取成功: {len(result)} 根 | 最新时间: {latest_ts} UTC | 期望最新时间: {expected_latest_ts} UTC"
|
||||
)
|
||||
return result
|
||||
|
||||
if attempt < latest_check_retries - 1:
|
||||
print(
|
||||
f"[WAIT] 最新K线未到位,当前最新: {latest_ts} UTC,期望: {expected_latest_ts} UTC,"
|
||||
f"{latest_check_interval}秒后重试({attempt + 1}/{latest_check_retries})。"
|
||||
)
|
||||
time.sleep(latest_check_interval)
|
||||
|
||||
raise RuntimeError("多次重试后仍未拿到包含最新时间的BTC 1h K线数据")
|
||||
|
||||
|
||||
def run_once(min_klines: int = 512) -> None:
|
||||
"""执行一次抓取任务。"""
|
||||
fetcher = BinanceDataFetcher()
|
||||
data = fetch_min_klines_with_latest(fetcher=fetcher, min_klines=min_klines)
|
||||
|
||||
print("-" * 80)
|
||||
print(f"抓取完成,返回K线数量: {len(data)}")
|
||||
print(f"时间范围: {data['timestamp'].min()} -> {data['timestamp'].max()} (UTC)")
|
||||
print(data.tail(3)[["timestamp", "open", "high", "low", "close", "volume"]])
|
||||
print("-" * 80)
|
||||
|
||||
|
||||
def run_forever(min_klines: int = 512, delay_seconds: int = 5) -> None:
|
||||
"""常驻运行:每个整点延迟 delay_seconds 后执行抓取。"""
|
||||
while True:
|
||||
next_run = get_next_run_time(delay_seconds=delay_seconds)
|
||||
sleep_seconds = max(0.0, (next_run - datetime.now(pytz.UTC)).total_seconds())
|
||||
|
||||
print(f"[SCHEDULE] 当前UTC: {datetime.now(pytz.UTC)},下次执行: {next_run},等待 {sleep_seconds:.1f} 秒")
|
||||
time.sleep(sleep_seconds)
|
||||
|
||||
try:
|
||||
run_once(min_klines=min_klines)
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] 本轮抓取失败: {exc}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""脚本入口。"""
|
||||
# 默认常驻运行,如需只执行一次,可把这里改成 run_once(min_klines=512)
|
||||
run_forever(min_klines=512, delay_seconds=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,10 +15,6 @@ import signal
|
||||
import os
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from utils.network_proxy import apply_global_proxy_from_env
|
||||
from utils.project_logger import get_project_logger
|
||||
|
||||
try:
|
||||
from daily_report_sender import run_scheduler_background
|
||||
@@ -27,17 +23,6 @@ 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")
|
||||
|
||||
# 检测操作系统
|
||||
IS_WINDOWS = platform.system() == 'Windows'
|
||||
|
||||
@@ -65,7 +50,6 @@ else:
|
||||
def log(message, color=RESET):
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
print(f"{color}[{timestamp}] {message}{RESET}")
|
||||
logger.info(message)
|
||||
|
||||
def start_process(command, name):
|
||||
"""启动子进程(跨平台兼容)"""
|
||||
@@ -162,17 +146,6 @@ def main():
|
||||
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)
|
||||
log("按 Ctrl+C 停止所有服务...", YELLOW)
|
||||
|
||||
@@ -15,7 +15,7 @@ import argparse
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
import schedule
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple, Dict, List
|
||||
@@ -34,11 +34,9 @@ from data.fetcher import BinanceDataFetcher
|
||||
from data.trading_db import TradingDatabase
|
||||
from api.models import PredictionModel
|
||||
from models.inference import get_inference_manager
|
||||
from utils.network_proxy import apply_global_proxy_from_env, get_proxy_url
|
||||
from utils.send_msg import lark_notifier
|
||||
|
||||
load_dotenv()
|
||||
apply_global_proxy_from_env()
|
||||
|
||||
|
||||
def setup_logging(log_dir: str = "logs", log_level: int = logging.INFO) -> logging.Logger:
|
||||
@@ -70,24 +68,20 @@ def setup_logging(log_dir: str = "logs", log_level: int = logging.INFO) -> loggi
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# 文件handler (按大小轮转, 最大10MB, 保留5个备份)
|
||||
# 文件handler (按天轮转, 保留最近30天)
|
||||
log_file = os.path.join(log_dir, "polymarket_trading.log")
|
||||
file_handler = RotatingFileHandler(
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
when='midnight',
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.suffix = "%Y-%m-%d"
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# 控制台handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
@@ -95,6 +89,16 @@ def setup_logging(log_dir: str = "logs", log_level: int = logging.INFO) -> loggi
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
def _apply_global_proxy(proxy_url: str) -> None:
|
||||
"""统一设置代理环境变量,确保第三方网络库也走代理。"""
|
||||
os.environ["HTTP_PROXY"] = proxy_url
|
||||
os.environ["HTTPS_PROXY"] = proxy_url
|
||||
os.environ["ALL_PROXY"] = proxy_url
|
||||
os.environ["http_proxy"] = proxy_url
|
||||
os.environ["https_proxy"] = proxy_url
|
||||
os.environ["all_proxy"] = proxy_url
|
||||
|
||||
|
||||
class PolymarketBTCTrader:
|
||||
"""Polymarket BTC Hourly交易器 (多模型集成版)"""
|
||||
|
||||
@@ -121,7 +125,8 @@ class PolymarketBTCTrader:
|
||||
self.dry_run = dry_run
|
||||
|
||||
# HTTP客户端
|
||||
proxy = get_proxy_url()
|
||||
proxy = os.getenv("SOCKS5_PROXY", "socks5://127.0.0.1:7890")
|
||||
_apply_global_proxy(proxy)
|
||||
self.http = httpx.Client(
|
||||
timeout=30.0,
|
||||
proxy=proxy,
|
||||
@@ -280,7 +285,6 @@ class PolymarketBTCTrader:
|
||||
|
||||
month_name = self.MONTH_NAMES[target_time.month]
|
||||
day = target_time.day
|
||||
year = target_time.year
|
||||
|
||||
# 转换为12小时制
|
||||
hour_24 = target_time.hour
|
||||
@@ -297,7 +301,7 @@ class PolymarketBTCTrader:
|
||||
hour_12 = hour_24 - 12
|
||||
am_pm = 'pm'
|
||||
|
||||
slug = f"bitcoin-up-or-down-{month_name}-{day}-{year}-{hour_12}{am_pm}-et"
|
||||
slug = f"bitcoin-up-or-down-{month_name}-{day}-{hour_12}{am_pm}-et"
|
||||
return slug
|
||||
|
||||
def get_market_by_slug(self, slug: str) -> Optional[Dict]:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""统一网络代理配置工具。"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_proxy_url() -> Optional[str]:
|
||||
"""从环境变量读取代理地址,统一兼容不同变量名。"""
|
||||
use_proxy = os.getenv("USE_PROXY", "false").lower() == "true"
|
||||
if not use_proxy:
|
||||
return None
|
||||
|
||||
return (
|
||||
os.getenv("SOCKS5_PROXY")
|
||||
or os.getenv("ALL_PROXY")
|
||||
or os.getenv("HTTPS_PROXY")
|
||||
or os.getenv("HTTP_PROXY")
|
||||
)
|
||||
|
||||
|
||||
def get_send_msg_proxy_url() -> Optional[str]:
|
||||
"""读取 send_msg 独立代理,未配置时回退到全局代理。"""
|
||||
send_msg_proxy = os.getenv("SEND_MSG_PROXY")
|
||||
if send_msg_proxy:
|
||||
return send_msg_proxy
|
||||
|
||||
send_msg_proxy_port = os.getenv("SEND_MSG_PROXY_PORT")
|
||||
if send_msg_proxy_port:
|
||||
return f"socks5://127.0.0.1:{send_msg_proxy_port}"
|
||||
|
||||
return get_proxy_url()
|
||||
|
||||
|
||||
def apply_global_proxy_from_env() -> Optional[str]:
|
||||
"""将代理写入全局环境变量,确保 requests/httpx/curl 等默认走同一代理。"""
|
||||
proxy_url = get_proxy_url()
|
||||
if not proxy_url:
|
||||
return None
|
||||
|
||||
os.environ["HTTP_PROXY"] = proxy_url
|
||||
os.environ["HTTPS_PROXY"] = proxy_url
|
||||
os.environ["ALL_PROXY"] = proxy_url
|
||||
os.environ["http_proxy"] = proxy_url
|
||||
os.environ["https_proxy"] = proxy_url
|
||||
os.environ["all_proxy"] = proxy_url
|
||||
|
||||
# 保留本地直连,避免影响本机服务调用。
|
||||
if not os.getenv("NO_PROXY"):
|
||||
os.environ["NO_PROXY"] = "127.0.0.1,localhost"
|
||||
if not os.getenv("no_proxy"):
|
||||
os.environ["no_proxy"] = os.environ["NO_PROXY"]
|
||||
|
||||
return proxy_url
|
||||
@@ -1,40 +0,0 @@
|
||||
"""项目统一日志工具。"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
||||
def get_project_logger(name: str) -> logging.Logger:
|
||||
"""返回统一配置的日志实例,避免重复添加处理器。"""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
"logs/project.log",
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
+9
-22
@@ -1,10 +1,4 @@
|
||||
import requests
|
||||
from utils.network_proxy import apply_global_proxy_from_env, get_send_msg_proxy_url
|
||||
from utils.project_logger import get_project_logger
|
||||
|
||||
|
||||
apply_global_proxy_from_env()
|
||||
logger = get_project_logger("send_msg")
|
||||
|
||||
class LarkNotifier:
|
||||
"""飞书消息发送工具类"""
|
||||
@@ -15,18 +9,11 @@ class LarkNotifier:
|
||||
self.bot_name = bot_name
|
||||
self.receive_id = receive_id
|
||||
|
||||
# 使用 Session 可以复用底层 TCP 连接,提升发送效率
|
||||
self.session = requests.Session()
|
||||
|
||||
proxy_url = get_send_msg_proxy_url()
|
||||
if proxy_url:
|
||||
self.session.proxies.update({
|
||||
"http": proxy_url,
|
||||
"https": proxy_url,
|
||||
})
|
||||
logger.info("send_msg 使用独立代理: %s", proxy_url)
|
||||
else:
|
||||
logger.info("send_msg 未启用代理")
|
||||
# 代理固定为本地 7898 端口
|
||||
self.proxies = {
|
||||
"http": "socks5h://127.0.0.1:7898",
|
||||
"https": "socks5h://127.0.0.1:7898"
|
||||
}
|
||||
|
||||
def send_alert(self, title: str, content: str):
|
||||
"""
|
||||
@@ -42,13 +29,13 @@ class LarkNotifier:
|
||||
}
|
||||
|
||||
try:
|
||||
# 使用 data=payload 发送 Form Data
|
||||
response = self.session.post(self.url, data=payload, timeout=5)
|
||||
# 每次请求直接调用 requests.post,不复用 Session
|
||||
response = requests.post(self.url, data=payload, timeout=5, proxies=self.proxies)
|
||||
response.raise_for_status()
|
||||
logger.info("告警发送成功: %s", title)
|
||||
print(f"告警发送成功: {title}")
|
||||
except Exception as e:
|
||||
# 告警系统自身的错误只记录日志,绝不抛出异常影响主程序
|
||||
logger.error("发送飞书告警失败: %s", e)
|
||||
print(f"发送飞书告警失败: {e}")
|
||||
|
||||
# 实例化一个全局单例供其他模块直接导入使用
|
||||
lark_notifier = LarkNotifier()
|
||||
Reference in new issue
Block a user