Compare commits
15 Commits
9a383143e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1deb97843 | ||
|
|
b2515bb7ce | ||
|
|
c473c738a3 | ||
|
|
79f4e03d6a | ||
|
|
6b1a707e3f | ||
|
|
3a6678089c | ||
|
|
5530a008b3 | ||
|
|
01b6a0fdcb | ||
|
|
b74449989b | ||
|
|
89bc5a7a00 | ||
|
|
4b5a66d588 | ||
|
|
de22d1f3ae | ||
|
|
2a98d431a9 | ||
|
|
79bf079548 | ||
|
|
aec9fc7830 |
310
backtest_outputs/charts/bb_backtest_2026_full_visualization.html
Normal file
310
backtest_outputs/charts/bb_backtest_2026_full_visualization.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
310
backtest_outputs/charts/bb_backtest_visualization.html
Normal file
310
backtest_outputs/charts/bb_backtest_visualization.html
Normal file
File diff suppressed because one or more lines are too long
145
backtest_outputs/charts/bb_chart_2026_03_02.html
Normal file
145
backtest_outputs/charts/bb_chart_2026_03_02.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>ETHUSDT 2026-03-02 K线 + 布林带</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
#chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart"></div>
|
||||
<script>
|
||||
async function main() {
|
||||
// 从同目录加载由 Python 导出的 JSON 数据
|
||||
const resp = await fetch("bb_chart_2026_03_02_data.json");
|
||||
const raw = await resp.json();
|
||||
|
||||
const categoryData = [];
|
||||
const klineData = [];
|
||||
const upper = [];
|
||||
const mid = [];
|
||||
const lower = [];
|
||||
|
||||
for (const k of raw) {
|
||||
const d = new Date(k.timestamp);
|
||||
const label = `${d.getHours().toString().padStart(2, "0")}:${d
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
categoryData.push(label);
|
||||
klineData.push([k.open, k.close, k.low, k.high]);
|
||||
upper.push(k.bb_upper);
|
||||
mid.push(k.bb_mid);
|
||||
lower.push(k.bb_lower);
|
||||
}
|
||||
|
||||
const chartDom = document.getElementById("chart");
|
||||
const chart = echarts.init(chartDom, null, { renderer: "canvas" });
|
||||
|
||||
const option = {
|
||||
backgroundColor: "#111",
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "cross" },
|
||||
},
|
||||
axisPointer: {
|
||||
link: [{ xAxisIndex: "all" }],
|
||||
},
|
||||
grid: {
|
||||
left: "3%",
|
||||
right: "3%",
|
||||
top: "6%",
|
||||
bottom: "5%",
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: categoryData,
|
||||
scale: true,
|
||||
boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: "#888" } },
|
||||
axisLabel: { color: "#ccc" },
|
||||
},
|
||||
yAxis: {
|
||||
scale: true,
|
||||
axisLine: { lineStyle: { color: "#888" } },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#ccc" },
|
||||
},
|
||||
dataZoom: [
|
||||
{
|
||||
type: "inside",
|
||||
start: 0,
|
||||
end: 100,
|
||||
},
|
||||
{
|
||||
type: "slider",
|
||||
start: 0,
|
||||
end: 100,
|
||||
height: 20,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: "K线",
|
||||
type: "candlestick",
|
||||
data: klineData,
|
||||
itemStyle: {
|
||||
color: "#26a69a",
|
||||
color0: "#ef5350",
|
||||
borderColor: "#26a69a",
|
||||
borderColor0: "#ef5350",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BB上轨",
|
||||
type: "line",
|
||||
data: upper,
|
||||
symbol: "none",
|
||||
lineStyle: { color: "#ff9800", width: 1 },
|
||||
},
|
||||
{
|
||||
name: "BB中轨",
|
||||
type: "line",
|
||||
data: mid,
|
||||
symbol: "none",
|
||||
lineStyle: { color: "#fff", width: 1, type: "dashed" },
|
||||
},
|
||||
{
|
||||
name: "BB下轨",
|
||||
type: "line",
|
||||
data: lower,
|
||||
symbol: "none",
|
||||
lineStyle: { color: "#ff9800", width: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
window.addEventListener("resize", () => chart.resize());
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
alert("加载数据失败,请确认 bb_chart_2026_03_02_data.json 已生成并与本 HTML 同目录。");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
17941
backtest_outputs/logs/backtest_result.txt
Normal file
17941
backtest_outputs/logs/backtest_result.txt
Normal file
File diff suppressed because it is too large
Load Diff
142073
backtest_outputs/reports/bb_sweep_results.csv
Normal file
142073
backtest_outputs/reports/bb_sweep_results.csv
Normal file
File diff suppressed because it is too large
Load Diff
43
backtest_outputs/reports/bb_sweep_results_snapshot.csv
Normal file
43
backtest_outputs/reports/bb_sweep_results_snapshot.csv
Normal file
@@ -0,0 +1,43 @@
|
||||
178.5,305.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,305.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,306.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,306.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,307.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,307.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,308.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,308.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,309.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,309.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,310.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,310.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,311.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,311.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,312.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,312.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,313.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,313.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,314.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,314.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,315.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,315.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,316.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,316.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,317.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,317.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,318.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,318.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,319.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,319.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,320.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,320.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,321.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,321.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,322.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,322.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,323.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,323.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,324.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,324.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,325.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,325.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,326.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
|
42500
backtest_outputs/trades/bb_backtest_20250101_20251231_trades.csv
Normal file
42500
backtest_outputs/trades/bb_backtest_20250101_20251231_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
19004
backtest_outputs/trades/bb_backtest_2025_15m_trades.csv
Normal file
19004
backtest_outputs/trades/bb_backtest_2025_15m_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
247966
backtest_outputs/trades/bb_backtest_2025_1m_trades.csv
Normal file
247966
backtest_outputs/trades/bb_backtest_2025_1m_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
88456
backtest_outputs/trades/bb_backtest_2025_3m_trades.csv
Normal file
88456
backtest_outputs/trades/bb_backtest_2025_3m_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
54689
backtest_outputs/trades/bb_backtest_2025_5m_trades.csv
Normal file
54689
backtest_outputs/trades/bb_backtest_2025_5m_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
7346
backtest_outputs/trades/bb_backtest_20260101_20260228_trades.csv
Normal file
7346
backtest_outputs/trades/bb_backtest_20260101_20260228_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
17941
backtest_outputs/trades/bb_backtest_20260101_20261231_trades.csv
Normal file
17941
backtest_outputs/trades/bb_backtest_20260101_20261231_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
157
backtest_outputs/trades/bb_backtest_march_2026_trades.csv
Normal file
157
backtest_outputs/trades/bb_backtest_march_2026_trades.csv
Normal file
@@ -0,0 +1,157 @@
|
||||
timestamp,action,price,size,margin,fee,capital,reason,pnl
|
||||
2026-03-01 01:35,开short,1970.895742,0.02536917551471376,1.0,0.0024999999999999996,98.9975,触上轨开空,
|
||||
2026-03-01 01:50,平仓100%,2022.33,0.02536917551471376,,0.0025652422359335528,98.690090039093,止损,-1.3048447186710703
|
||||
2026-03-01 03:50,开long,2019.4038,0.02443545219611179,0.9869009003909299,0.0024672522509773245,97.70072188645109,触下轨开多,
|
||||
2026-03-01 03:55,加long,2018.383596,0.04840542802670058,1.9540144377290218,0.004885036094322554,95.74182241262774,触下轨加多,
|
||||
2026-03-01 04:00,平仓50%,2028.348,0.03642044011140619,,0.0036936663429545246,97.55902982143648,触中轨平50%-1m(04:02)回踩中轨,0.35044340609171903
|
||||
2026-03-01 04:05,平仓100%,2018.7258371425564,0.03642044011140619,,0.003676144172649939,99.0258113463238,回开仓价全平,0.0
|
||||
2026-03-01 04:15,开long,1999.6626196004338,0.02476062971215385,0.9902581134632381,0.0024756452836580947,98.03307758757691,触下轨开多,
|
||||
2026-03-01 04:35,平仓50%,2014.6299999999999,0.012380314856076925,,0.0012470876859249124,98.71226043853991,触中轨平50%-1m(04:39)回踩中轨,0.18530088191730254
|
||||
2026-03-01 05:10,平仓100%,2017.8250206638015,0.012380314856076925,,0.0012490654540143892,99.43099667352436,延迟反转-同K回调确认-平多,0.22485624370683877
|
||||
2026-03-01 05:10,开short,2017.4214556596687,0.02464308991920873,0.9943099667352436,0.0024857749168381085,98.43420093187228,延迟反转-同K回调确认-开空,
|
||||
2026-03-01 05:15,平仓50%,2014.157,0.012321544959604365,,0.0012408763015600921,98.9703381761176,触中轨平50%-1m(05:16)反抽中轨,0.04022313717924384
|
||||
2026-03-01 05:20,平仓100%,2017.4214556596687,0.012321544959604365,,0.0012428874584190543,99.4662502720268,回开仓价全平,0.0
|
||||
2026-03-01 05:20,开short,2020.9165042134857,0.0246091934190863,0.994662502720268,0.0024866562568006696,98.46910111304973,触上轨开空,
|
||||
2026-03-01 05:35,平仓50%,2016.4740000000002,0.01230459670954315,,0.0012405949672639653,99.01985499216998,触中轨平50%-1m(05:38)反抽中轨,0.05466322272738604
|
||||
2026-03-01 06:10,平仓100%,1993.0582785593572,0.01230459670954315,,0.0012261889168144599,99.8587442863308,延迟反转-同K反弹确认-平空,0.34278423171750066
|
||||
2026-03-01 06:10,开long,1993.456890215069,0.02504662748828175,0.998587442863308,0.00249646860715827,98.85766037486034,延迟反转-同K反弹确认-开多,
|
||||
2026-03-01 07:00,平仓50%,1996.611,0.012523313744140875,,0.0012502092989001429,99.39520379341326,触中轨平50%-1m(07:01)回踩中轨,0.03949990642015626
|
||||
2026-03-01 07:05,平仓100%,1993.456890215069,0.012523313744140875,,0.001248234303579135,99.89324928054134,回开仓价全平,0.0
|
||||
2026-03-01 07:10,开short,2002.49942,0.024942141876011462,0.9989324928054134,0.002497331232013533,98.89181945650391,触上轨开空,
|
||||
2026-03-01 07:25,平仓50%,1997.67,0.012471070938005731,,0.0012456542140362953,99.450268088102,触中轨平50%-1m(07:26)反抽中轨,0.06022803940942398
|
||||
2026-03-01 07:35,平仓100%,2002.49942,0.012471070938005731,,0.0012486656160067666,99.94848566888871,回开仓价全平,0.0
|
||||
2026-03-01 08:50,开long,1983.9752370436452,0.02518894485241248,0.9994848566888871,0.0024987121417222175,98.9465021000581,触下轨开多,
|
||||
2026-03-01 09:00,加long,1984.6668539999998,0.049855471662982744,1.978930042001162,0.004947325105002905,96.96262473295194,触下轨加多,
|
||||
2026-03-01 09:15,平仓50%,1992.7069999999999,0.037522208257697615,,0.003738538352528591,98.75848822726503,触中轨平50%-1m(09:18)回踩中轨,0.31039458332059994
|
||||
2026-03-01 09:30,平仓100%,1984.4347101286562,0.037522208257697615,,0.003723018623362561,100.24397265798669,回开仓价全平,0.0
|
||||
2026-03-01 09:35,开long,1977.5454880734026,0.025345554188907188,1.0024397265798668,0.0025060993164496663,99.23902683209037,触下轨开多,
|
||||
2026-03-01 10:15,平仓50%,1979.625,0.012672777094453594,,0.0012543673177803846,99.76534551917355,触中轨平50%-1m(10:17)回踩中轨,0.02635319111102702
|
||||
2026-03-01 10:15,平仓100%,1977.5454880734026,0.012672777094453594,,0.0012530496582248331,100.26531233280527,回开仓价全平,0.0
|
||||
2026-03-01 10:25,开short,1983.593202,0.02527365798383223,1.0026531233280527,0.0025066328083201313,99.26015257666889,触上轨开空,
|
||||
2026-03-01 10:35,加short,1987.0210368714647,0.049954253495449914,1.9852030515333778,0.004963007628833443,97.26998651750668,触上轨加空,
|
||||
2026-03-01 10:40,平仓50%,1980.9359999999997,0.037613955739641075,,0.0037255419513530803,98.94575440746019,触中轨平50%-1m(10:41)反抽中轨,0.18556534447413794
|
||||
2026-03-01 11:00,平仓100%,1985.8694174198158,0.037613955739641075,,0.0037348202185767876,100.43594767467232,回开仓价全平,0.0
|
||||
2026-03-01 13:20,开long,1971.0811861410878,0.02547737464617128,1.0043594767467232,0.0025108986918668075,99.42907729923373,触下轨开多,
|
||||
2026-03-01 13:25,平仓50%,1977.221,0.01273868732308564,,0.0012593600043819353,100.00821084657333,触中轨平50%-1m(13:27)回踩中轨,0.07821316897063098
|
||||
2026-03-01 13:50,平仓100%,2012.7953226137352,0.01273868732308564,,0.0012820185130072825,101.04049190791126,延迟反转-同K回调确认-平多,0.5313833414775777
|
||||
2026-03-01 13:50,开short,2012.3927635492125,0.025104565504824317,1.0104049190791127,0.002526012297697781,100.02756097653445,延迟反转-同K回调确认-开空,
|
||||
2026-03-01 14:25,平仓50%,2004.753,0.012552282752412158,,0.0012582113252373262,100.62740169698006,触中轨平50%-1m(14:28)反抽中轨,0.09589647223128844
|
||||
2026-03-01 14:40,平仓100%,1994.9544227315625,0.012552282752412158,,0.0012520615996150872,101.35024307959607,延迟反转-同K反弹确认-平空,0.21889098467607288
|
||||
2026-03-01 14:40,开long,1995.3534136161088,0.025396564435150013,1.0135024307959608,0.0025337560769899017,100.33420689272312,延迟反转-同K反弹确认-开多,
|
||||
2026-03-01 15:00,平仓50%,2004.636,0.012698282217575007,,0.0012727716835755343,100.95755823804919,触中轨平50%-1m(15:03)回踩中轨,0.11787290161166897
|
||||
2026-03-01 15:20,平仓100%,2012.645228772288,0.012698282217575007,,0.0012778568559403158,101.68260794549853,延迟反转-同K回调确认-平多,0.21957634890730374
|
||||
2026-03-01 15:20,开short,2012.2426997265336,0.02526599002180933,1.0168260794549853,0.002542065198637463,100.6632398008449,延迟反转-同K回调确认-开空,
|
||||
2026-03-01 15:50,平仓50%,2008.9260000000002,0.012632995010904665,,0.0012689376067638331,101.2122837540636,触中轨平50%-1m(15:52)反抽中轨,0.04189985109796585
|
||||
2026-03-01 15:50,平仓100%,2012.2426997265336,0.012632995010904665,,0.0012710325993187314,101.71942576119179,回开仓价全平,0.0
|
||||
2026-03-01 15:55,开long,1992.6786162417027,0.025523289338307855,1.017194257611918,0.0025429856440297945,100.69968851793584,触下轨开多,
|
||||
2026-03-01 16:00,加long,1991.1565538891698,0.050573466120103445,2.013993770358717,0.005034984425896792,98.68065976315123,触下轨加多,
|
||||
2026-03-01 16:25,平仓50%,2002.4759999999999,0.03804837772920565,,0.00380954816208344,100.60370677137739,触中轨平50%-1m(16:26)回踩中轨,0.41126254240293536
|
||||
2026-03-01 16:45,平仓100%,1967.62,0.03804837772920565,,0.00374323744937698,101.20060583618708,止损,-0.9149517117262566
|
||||
2026-03-01 16:50,开long,1965.2229659999998,0.02574786871185666,1.0120060583618709,0.0025300151459046764,100.18606976267931,触下轨开多,
|
||||
2026-03-01 17:35,平仓50%,1967.5260000000003,0.01287393435592833,,0.001266490028379112,100.72045541036735,触中轨平50%-1m(17:36)回踩中轨,0.029649108535477225
|
||||
2026-03-01 17:45,平仓100%,1965.2229659999998,0.01287393435592833,,0.0012650075729523382,101.22519343197533,回开仓价全平,0.0
|
||||
2026-03-01 18:00,开short,1982.393442,0.025531055361505614,1.0122519343197534,0.002530629835799383,100.21041086781977,触上轨开空,
|
||||
2026-03-01 18:05,加short,1991.233970955805,0.05032578407635248,2.0042082173563953,0.005010520543390987,98.20119212991999,触上轨加空,
|
||||
2026-03-01 18:30,平仓50%,1977.5280000000002,0.037928419718929046,,0.0037502255994967156,100.11266378212795,触中轨平50%-1m(18:33)反抽中轨,0.4069918019693819
|
||||
2026-03-01 18:55,平仓100%,1972.9829470185493,0.037928419718929046,,0.0037416062656404534,102.196530730795,延迟反转-同K反弹确认-平空,0.5793784790946218
|
||||
2026-03-01 18:55,开long,1973.377543607953,0.025893811111265538,1.02196530730795,0.0025549132682698744,101.17201051021878,延迟反转-同K反弹确认-开多,
|
||||
2026-03-01 19:05,加long,1968.973716,0.051383118874614164,2.0234402102043756,0.0050586005255109385,99.1435116994889,触下轨加多,
|
||||
2026-03-01 19:40,平仓100%,1946.27,0.0772769299858797,,0.007520088526180904,100.31289177647686,止损,-1.8685053519981858
|
||||
2026-03-01 20:10,开long,1933.526628,0.025940395731771856,1.0031289177647686,0.002507822294411921,99.30725503641769,触下轨开多,
|
||||
2026-03-01 20:35,平仓100%,1911.05,0.025940395731771856,,0.00247866966316013,99.72485265948347,止损,-0.5830526250358269
|
||||
2026-03-01 22:10,开short,1943.1708021370632,0.025660341476366342,0.9972485265948348,0.002493121316487086,98.72511101157215,触上轨开空,
|
||||
2026-03-01 22:25,平仓50%,1933.2939999999999,0.012830170738183171,,0.0012402246053552545,99.34921610802999,触中轨平50%-1m(22:28)反抽中轨,0.12672105776577522
|
||||
2026-03-01 22:25,平仓100%,1943.1708021370632,0.012830170738183171,,0.001246560658243543,99.84659381066916,回开仓价全平,0.0
|
||||
2026-03-02 00:10,开short,1947.900342,0.025629286996313172,0.9984659381066916,0.0024961648452667285,98.8456317077172,触上轨开空,
|
||||
2026-03-02 00:15,平仓50%,1940.6370000000002,0.012814643498156586,,0.001243428565716605,99.43669838654002,触中轨平50%-1m(00:17)反抽中轨,0.0930771383351843
|
||||
2026-03-02 00:20,平仓100%,1947.900342,0.012814643498156586,,0.0012480824226333643,99.93468327317073,回开仓价全平,0.0
|
||||
2026-03-02 00:40,开short,1961.187684,0.02547810290888272,0.9993468327317073,0.002498367081829268,98.9328380733572,触上轨开空,
|
||||
2026-03-02 00:45,加short,1959.7479720000001,0.050482429111735394,1.9786567614671442,0.004946641903667859,96.9492346699864,触上轨加空,
|
||||
2026-03-02 01:10,平仓100%,1988.21,0.07596053202061812,,0.007551274468435656,97.79437733094622,止损,-2.1253096587705884
|
||||
2026-03-02 02:05,开long,1949.6071946200707,0.02508053355588993,0.9779437733094622,0.002444859433273655,96.81398869820349,触下轨开多,
|
||||
2026-03-02 02:10,加long,1940.8287934851146,0.049882807295102104,1.9362797739640698,0.004840699434910173,94.8728682248045,触下轨加多,
|
||||
2026-03-02 02:30,平仓50%,1953.676,0.037481670425496015,,0.0036613519975100665,96.69776991480681,触中轨平50%-1m(02:34)回踩中轨,0.371451268363048
|
||||
2026-03-02 03:10,平仓100%,1966.7001436494575,0.037481670425496015,,0.0036857603305022308,99.0108138563194,延迟反转-同K回调确认-平多,0.859617928206332
|
||||
2026-03-02 03:10,开short,1966.3068036207276,0.025176847700979926,0.990108138563194,0.0024752703464079847,98.0182304474098,延迟反转-同K回调确认-开空,
|
||||
2026-03-02 03:55,平仓50%,1971.86,0.012588423850489963,,0.0012411304726913566,98.44213739647142,触中轨平50%-1m(03:57)反抽中轨,-0.0699059897472862
|
||||
2026-03-02 03:55,平仓100%,1966.3068036207276,0.012588423850489963,,0.0012376351732039923,98.93595383057982,回开仓价全平,0.0
|
||||
2026-03-02 04:15,开long,1969.413804,0.025118122364541886,0.9893595383057983,0.002473398845764495,97.94412089342826,触下轨开多,
|
||||
2026-03-02 04:20,平仓50%,1971.578,0.012559061182270943,,0.001238058436380969,98.46474287411921,触中轨平50%-1m(04:22)回踩中轨,0.02718026997442538
|
||||
2026-03-02 04:20,平仓100%,1969.413804,0.012559061182270943,,0.0012366994228822474,98.95818594384923,回开仓价全平,0.0
|
||||
2026-03-02 05:05,开long,1964.19276,0.025190548493786637,0.9895818594384923,0.0024739546485962305,97.96613012976213,触下轨开多,
|
||||
2026-03-02 05:15,平仓50%,1970.2719999999997,0.012595274246893318,,0.0012408058090487492,98.53624994868501,触中轨平50%-1m(05:17)回踩中轨,0.07656969501268121
|
||||
2026-03-02 05:20,平仓100%,1964.19276,0.012595274246893318,,0.0012369773242981153,99.02980390107996,回开仓价全平,0.0
|
||||
2026-03-02 05:40,开short,1979.524016,0.025013539391451352,0.9902980390107996,0.0024757450975269983,98.03703011697164,触上轨开空,
|
||||
2026-03-02 07:20,平仓50%,1937.3039999999996,0.012506769695725676,,0.0012114707479304063,99.05900368239097,触中轨平50%-1m(07:24)反抽中轨,0.5280360166618587
|
||||
2026-03-02 08:10,平仓100%,1928.936186384675,0.012506769695725676,,0.0012062380320432252,100.1856367982698,延迟反转-同K反弹确认-平空,0.6326903344054814
|
||||
2026-03-02 08:10,开long,1929.3219736219519,0.025963949555342865,1.001856367982698,0.0025046409199567447,99.18127578936715,延迟反转-同K反弹确认-开多,
|
||||
2026-03-02 08:15,加long,1930.6160459999999,0.051372864114984755,1.983625515787343,0.004959063789468357,97.19269120979034,触下轨加多,
|
||||
2026-03-02 08:50,平仓100%,1948.7595882760102,0.07733681367032762,,0.007535542858338308,101.60740052896617,延迟反转-同K回调确认-平多,1.4367629782641276
|
||||
2026-03-02 08:50,开short,1948.369836358355,0.026074977818091708,1.0160740052896617,0.0025401850132241535,100.58878633866328,延迟反转-同K回调确认-开空,
|
||||
2026-03-02 09:40,平仓50%,1955.359,0.013037488909045854,,0.0012746485637851492,101.00442754928288,触中轨平50%-1m(09:43)反抽中轨,-0.09112114346145109
|
||||
2026-03-02 09:40,平仓100%,1948.369836358355,0.013037488909045854,,0.0012700925066120767,101.5111944594211,回开仓价全平,0.0
|
||||
2026-03-02 11:05,开long,1939.03773,0.02617566251777399,1.0151119445942112,0.0025377798614855274,100.49354473496541,触下轨开多,
|
||||
2026-03-02 12:00,平仓50%,1943.6330000000003,0.013087831258886995,,0.0012718970366602154,101.05997092857488,触中轨平50%-1m(12:02)回踩中轨,0.06014211834902898
|
||||
2026-03-02 12:20,平仓100%,1939.03773,0.013087831258886995,,0.0012688899307427637,101.56625801094124,回开仓价全平,0.0
|
||||
2026-03-02 12:20,开long,1936.8878482318667,0.026218931081543613,1.0156625801094123,0.0025391564502735306,100.54805627438155,触下轨开多,
|
||||
2026-03-02 12:35,加long,1930.6260479999999,0.05208054474274946,2.010961125487631,0.005027402813719077,98.5320677460802,触下轨加多,
|
||||
2026-03-02 12:50,平仓50%,1939.3809999999999,0.039149737912146536,,0.003796312893089832,100.30224850785619,触中轨平50%-1m(12:51)回踩中轨,0.26066522187056274
|
||||
2026-03-02 13:05,平仓100%,1932.7228399260932,0.039149737912146536,,0.0037832796319963034,101.81177708102271,回开仓价全平,0.0
|
||||
2026-03-02 13:05,开long,1930.4860199999998,0.026369467591643766,1.0181177708102271,0.0025452944270255673,100.79111401578545,触下轨开多,
|
||||
2026-03-02 13:15,平仓50%,1935.7879999999998,0.013184733795821883,,0.001276142473257322,101.36880195360808,触中轨平50%-1m(13:16)回踩中轨,0.06990519489077116
|
||||
2026-03-02 13:25,平仓100%,1930.4860199999998,0.013184733795821883,,0.0012726472135127836,101.87658819179968,回开仓价全平,0.0
|
||||
2026-03-02 14:25,开long,1922.6183440503523,0.02649423077311791,1.0187658819179968,0.002546914704794992,100.85527539517689,触下轨开多,
|
||||
2026-03-02 14:30,平仓100%,1950.3106548105818,0.02649423077311791,,0.0025835990283911126,102.60514414998892,延迟反转-同K回调确认-平多,0.733686471922416
|
||||
2026-03-02 14:30,开short,1949.9205926796196,0.02631008271187774,1.0260514414998891,0.0025651286037497224,101.57652757988528,延迟反转-同K回调确认-开空,
|
||||
2026-03-02 14:45,平仓100%,1977.81,0.02631008271187774,,0.002601817234418945,101.8662045907663,止损,-0.7337726133844545
|
||||
2026-03-02 17:40,开long,2025.7860592669192,0.025142389573859815,1.018662045907663,0.002546655114769157,100.84499588974387,触下轨开多,
|
||||
2026-03-02 18:15,平仓50%,2037.0170000000003,0.012571194786929907,,0.00128038687456438,101.49423286941916,触中轨平50%-1m(18:15)回踩中轨,0.1411863435960275
|
||||
2026-03-02 18:40,加long,2027.0190691607734,0.05007068478711442,2.0298846573883833,0.005074711643470957,99.45927350038731,触下轨加多,
|
||||
2026-03-02 18:45,平仓50%,2033.3019999999997,0.03132093978702216,,0.0031842464755415857,100.93023459635924,触中轨平50%-1m(18:47)回踩中轨,0.20453750227635803
|
||||
2026-03-02 18:50,平仓100%,2026.7716243577877,0.03132093978702216,,0.003174019600427768,102.19666841692992,回开仓价全平,0.0
|
||||
2026-03-02 18:50,开long,2028.895698,0.02518529378264026,1.0219666841692994,0.002554916710423248,101.1721468160502,触下轨开多,
|
||||
2026-03-02 18:55,加long,2027.7254639999999,0.049894400702781824,2.023442936321004,0.005058607340802509,99.1436452723884,触下轨加多,
|
||||
2026-03-02 19:05,平仓50%,2031.3199999999997,0.03753984724271104,,0.003812772125053188,100.78273929931468,触中轨平50%-1m(19:05)回踩中轨,0.1202019888061939
|
||||
2026-03-02 19:05,平仓100%,2028.1180160380236,0.03753984724271104,,0.003806762025612878,102.30163734753422,回开仓价全平,0.0
|
||||
2026-03-02 19:05,开long,2026.35519,0.025242770332760428,1.0230163734753424,0.0025575409336883554,101.27606343312519,触下轨开多,
|
||||
2026-03-02 19:10,加long,2023.6443782356102,0.05004637401825834,2.0255212686625037,0.005063803171656258,99.24547836129103,触下轨加多,
|
||||
2026-03-02 19:15,平仓50%,2030.7600000000002,0.037644572175509386,,0.0038223545695568717,100.99957516548169,触中轨平50%-1m(19:18)回踩中轨,0.23365033769128954
|
||||
2026-03-02 19:35,平仓100%,2038.953388071054,0.037644572175509386,,0.003837776398987009,103.06209313644565,延迟反转-同K回调确认-平多,0.5420869262940294
|
||||
2026-03-02 19:35,开short,2038.5455973934397,0.025278338946213584,1.0306209313644565,0.002576552328411141,102.02889565275278,延迟反转-同K回调确认-开空,
|
||||
2026-03-02 19:45,平仓50%,2031.934,0.012639169473106792,,0.0012840979092083886,102.62648712046943,触中轨平50%-1m(19:45)反抽中轨,0.08356509994363615
|
||||
2026-03-02 19:50,平仓100%,2038.5455973934397,0.012639169473106792,,0.0012882761642055705,103.14050930998745,回开仓价全平,0.0
|
||||
2026-03-02 19:50,开short,2043.2612660000002,0.025239187720692457,1.0314050930998746,0.0025785127327496863,102.10652570415482,触上轨开空,
|
||||
2026-03-02 19:55,加short,2042.021514,0.05000266892592338,2.0421305140830963,0.00510532628520774,100.05928986378652,触上轨加空,
|
||||
2026-03-02 20:05,平仓50%,2037.4699999999998,0.03762092832330792,,0.003832575641544508,101.77910244042056,触中轨平50%-1m(20:09)反抽中轨,0.18687734868409472
|
||||
2026-03-02 20:05,平仓100%,2042.4373773884072,0.03762092832330792,,0.003841919508978713,103.31202832450306,回开仓价全平,0.0
|
||||
2026-03-02 20:50,开short,2045.370844,0.02525508482424204,1.0331202832450306,0.002582800708112576,102.27632524054992,触上轨开空,
|
||||
2026-03-02 20:55,平仓50%,2038.2310000000002,0.01262754241212102,,0.0012868924199099917,102.88175717267845,触中轨平50%-1m(20:59)反抽中轨,0.09015868292592541
|
||||
2026-03-02 21:45,平仓100%,2045.370844,0.01262754241212102,,0.001291400354056288,103.3970259139469,回开仓价全平,0.0
|
||||
2026-03-02 22:05,开short,2047.470424,0.025249943711506063,1.033970259139469,0.002584925647848672,102.36047072915959,触上轨开空,
|
||||
2026-03-02 22:15,平仓50%,2043.7830000000001,0.012624971855753031,,0.0012901351427133249,102.92271934780683,触中轨平50%-1m(22:15)反抽中轨,0.04655362422022782
|
||||
2026-03-02 22:25,平仓100%,2047.470424,0.012624971855753031,,0.001292462823924336,103.43841201455264,回开仓价全平,0.0
|
||||
2026-03-02 22:30,开short,2051.5995980000002,0.025209210441303816,1.0343841201455264,0.0025859603003638154,102.40144193410674,触上轨开空,
|
||||
2026-03-02 22:50,平仓50%,2049.2699999999995,0.012604605220651908,,0.001291511967026266,102.94670614532531,触中轨平50%-1m(22:54)反抽中轨,0.029363663112829255
|
||||
2026-03-02 22:50,平仓100%,2051.5995980000002,0.012604605220651908,,0.0012929801501819077,103.46260522524788,回开仓价全平,0.0
|
||||
2026-03-03 00:30,开long,2020.584036,0.02560215348183813,1.0346260522524788,0.0025865651306311967,102.42539260786478,触下轨开多,
|
||||
2026-03-03 00:45,平仓50%,2029.438,0.012801076740919065,,0.001298949578946865,103.05474695703741,触中轨平50%-1m(00:47)回踩中轨,0.11334027262533643
|
||||
2026-03-03 01:30,加long,2025.1515544797203,0.05088742456290541,2.061094939140748,0.005152737347851869,100.98849928054881,触下轨加多,
|
||||
2026-03-03 02:15,平仓100%,2002.4,0.06368850130382447,,0.0063764927505389046,102.16998750049402,止损,-1.390543252571244
|
||||
2026-03-03 02:45,开short,2019.206078,0.025299544363914604,1.0216998750049402,0.00255424968751235,101.14573337580157,触上轨开空,
|
||||
2026-03-03 03:10,平仓50%,2012.1740000000002,0.012649772181957302,,0.0012726771345228875,101.74426482083527,触中轨平50%-1m(03:13)反抽中轨,0.0889541846657505
|
||||
2026-03-03 04:35,平仓100%,2003.7637516585983,0.012649772181957302,,0.0012673577482472664,102.44918931076766,延迟反转-同K反弹确认-平空,0.1953419101781693
|
||||
2026-03-03 04:35,开long,2004.16450440893,0.025559076883507146,1.0244918931076767,0.0025612297327691916,101.42213618792722,延迟反转-同K反弹确认-开多,
|
||||
2026-03-03 04:40,加long,2002.570434,0.05064597702331163,2.0284427237585443,0.0050711068093963595,99.38862235735928,触下轨加多,
|
||||
2026-03-03 05:05,平仓50%,2003.5459999999998,0.03810252695340939,,0.0038170082733697775,100.92807270325906,触中轨平50%-1m(05:09)回踩中轨,0.016800045740044088
|
||||
2026-03-03 05:05,平仓100%,2003.1050831610567,0.03810252695340939,,0.003816168271082775,102.45072384342109,回开仓价全平,0.0
|
||||
2026-03-03 05:15,开short,2013.5872020000002,0.02543985275176105,1.0245072384342109,0.0025612680960855265,101.42365533689079,触上轨开空,
|
||||
2026-03-03 05:20,平仓50%,2003.975,0.012719926375880526,,0.0012745207229552585,102.05690093713504,触中轨平50%-1m(05:24)反抽中轨,0.12226650175009475
|
||||
2026-03-03 06:35,加short,1993.821156,0.0511865874379031,2.041138018742701,0.005102845046856751,100.01066007334548,触上轨加空,
|
||||
2026-03-03 06:40,平仓50%,1992.6649999999997,0.03195325690689181,,0.0031836068337185775,101.44682656011526,触中轨平50%-1m(06:41)反抽中轨,0.1626542746235937
|
||||
2026-03-03 06:45,平仓100%,1988.3703092642115,0.03195325690689181,,0.003176745365897763,103.02022926476914,延迟反转-同K反弹确认-平空,0.2998836310398825
|
||||
2026-03-03 06:45,开long,1988.7679833260643,0.025900514823372103,1.0302022926476915,0.002575505731619228,101.98745146638983,延迟反转-同K反弹确认-开多,
|
||||
2026-03-03 06:50,平仓50%,1992.611,0.012950257411686051,,0.0012902412685678573,102.55103042660998,触中轨平50%-1m(06:51)回踩中轨,0.04976805516487063
|
||||
2026-03-03 06:55,平仓100%,1999.2593560774471,0.012950257411686051,,0.0012945461646962318,103.20070300450149,延迟反转-同K回调确认-平多,0.1358659777323571
|
||||
2026-03-03 06:55,开short,1998.8595042062318,0.025814896641643555,1.032007030045015,0.002580017575112537,102.16611595688137,延迟反转-同K回调确认-开空,
|
||||
2026-03-03 07:05,平仓50%,1993.9159999999997,0.012907448320821777,,0.0012868183863029834,102.74464067858328,触中轨平50%-1m(07:08)反抽中轨,0.0638080250657058
|
||||
2026-03-03 07:05,平仓100%,1998.8595042062318,0.012907448320821777,,0.0012900087875562685,103.25935418481824,回开仓价全平,0.0
|
||||
|
580
bb_backtest_2025_multi_timeframe.py
Normal file
580
bb_backtest_2025_multi_timeframe.py
Normal file
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
布林带延迟反转策略回测 - 2025年多周期测试
|
||||
测试1分钟和15分钟周期的收益对比
|
||||
|
||||
策略规则:
|
||||
1. BB(10, 2.5)
|
||||
2. 空仓触上轨开空,触下轨开多
|
||||
3. 同向加仓最多1次,保证金1%
|
||||
4. 延迟反转:触轨不立刻平仓,记录价格,回调到该价再平仓+反向开仓
|
||||
5. 中轨平半+回开仓价全平
|
||||
|
||||
回测参数:
|
||||
- 本金: 100U
|
||||
- 杠杆: 100x
|
||||
- 逐仓模式
|
||||
- 开仓保证金: 1%
|
||||
- 手续费: 0.05% (万五)
|
||||
- 返佣: 90%,次日早上8点到账
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from peewee import *
|
||||
from loguru import logger
|
||||
import numpy as np
|
||||
|
||||
# 数据库配置
|
||||
DB_PATH = Path(__file__).parent / 'models' / 'database.db'
|
||||
db = SqliteDatabase(str(DB_PATH))
|
||||
|
||||
|
||||
class BitMartETH1M(Model):
|
||||
"""1分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_1m'
|
||||
|
||||
|
||||
class BitMartETH3M(Model):
|
||||
"""3分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_3m'
|
||||
|
||||
|
||||
class BitMartETH5M(Model):
|
||||
"""5分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_5m'
|
||||
|
||||
|
||||
class BitMartETH15M(Model):
|
||||
"""15分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_15m'
|
||||
|
||||
|
||||
class BBDelayReversalBacktest:
|
||||
"""布林带延迟反转策略回测"""
|
||||
|
||||
def __init__(self, timeframe='1m'):
|
||||
# 策略参数
|
||||
self.timeframe = timeframe
|
||||
self.bb_period = 10
|
||||
self.bb_std = 2.5
|
||||
self.initial_capital = 100 # 初始本金100U
|
||||
self.leverage = 100 # 100倍杠杆
|
||||
self.fee_rate = 0.0005 # 万五手续费
|
||||
self.rebate_rate = 0.9 # 90%返佣
|
||||
self.margin_ratio = 0.01 # 开仓保证金1%
|
||||
self.rebate_credit_hour = 8 # 次日早上8点返佣到账
|
||||
|
||||
# 账户状态
|
||||
self.capital = self.initial_capital
|
||||
self.position = 0 # 持仓量(正=多,负=空)
|
||||
self.position_count = 0 # 持仓次数(0=空仓,1=首次,2=加仓)
|
||||
self.entry_price = 0 # 开仓均价
|
||||
self.total_margin = 0 # 总保证金
|
||||
|
||||
# 延迟反转状态
|
||||
self.delay_reverse_price = None
|
||||
self.delay_reverse_type = None
|
||||
self.delay_reverse_kline_index = None
|
||||
|
||||
# 中轨平仓记录
|
||||
self.mid_closed_half = False
|
||||
|
||||
# 交易记录
|
||||
self.trades = []
|
||||
self.pending_rebates = []
|
||||
self.total_rebate_credited = 0.0
|
||||
|
||||
def calculate_bollinger_bands(self, df):
|
||||
"""计算布林带(整体右移1根,避免使用当前K收盘价)"""
|
||||
df['sma'] = df['close'].rolling(window=self.bb_period).mean()
|
||||
df['std'] = df['close'].rolling(window=self.bb_period).std()
|
||||
df['upper'] = (df['sma'] + self.bb_std * df['std']).shift(1)
|
||||
df['lower'] = (df['sma'] - self.bb_std * df['std']).shift(1)
|
||||
df['middle'] = df['sma'].shift(1)
|
||||
return df
|
||||
|
||||
def schedule_rebate(self, fee, timestamp):
|
||||
"""登记返佣到账时间(次日08:00,上海时间)"""
|
||||
rebate = fee * self.rebate_rate
|
||||
if rebate <= 0:
|
||||
return
|
||||
|
||||
trade_utc = pd.Timestamp(timestamp, tz='UTC')
|
||||
trade_local = trade_utc.tz_convert('Asia/Shanghai')
|
||||
credit_local = (trade_local + pd.Timedelta(days=1)).normalize() + pd.Timedelta(hours=self.rebate_credit_hour)
|
||||
credit_utc = credit_local.tz_convert('UTC').tz_localize(None)
|
||||
|
||||
self.pending_rebates.append({
|
||||
'credit_time': credit_utc,
|
||||
'amount': rebate,
|
||||
'trade_time': trade_utc.tz_localize(None),
|
||||
})
|
||||
|
||||
def apply_pending_rebates(self, current_time):
|
||||
"""处理当前时刻前应到账的返佣"""
|
||||
if not self.pending_rebates:
|
||||
return
|
||||
|
||||
remaining = []
|
||||
for item in self.pending_rebates:
|
||||
if item['credit_time'] <= current_time:
|
||||
amount = item['amount']
|
||||
self.capital += amount
|
||||
self.total_rebate_credited += amount
|
||||
else:
|
||||
remaining.append(item)
|
||||
|
||||
self.pending_rebates = remaining
|
||||
|
||||
def clear_delay_reversal(self):
|
||||
"""清理延迟反转状态"""
|
||||
self.delay_reverse_price = None
|
||||
self.delay_reverse_type = None
|
||||
self.delay_reverse_kline_index = None
|
||||
|
||||
def mark_delay_reversal(self, reverse_type, trigger_price, kline_index):
|
||||
"""记录延迟反转触发信息"""
|
||||
self.delay_reverse_type = reverse_type
|
||||
self.delay_reverse_price = trigger_price
|
||||
self.delay_reverse_kline_index = kline_index
|
||||
|
||||
def check_delay_reversal_signal(self, i, row, prev_row):
|
||||
"""检查延迟反转是否成立"""
|
||||
if self.position == 0 or self.delay_reverse_price is None or self.delay_reverse_kline_index is None:
|
||||
return None
|
||||
|
||||
offset = i - self.delay_reverse_kline_index
|
||||
if offset <= 0:
|
||||
return None
|
||||
|
||||
high = row['high']
|
||||
low = row['low']
|
||||
|
||||
if self.delay_reverse_type == 'long_to_short':
|
||||
# 多转空:回调确认
|
||||
if offset == 1 and low <= self.delay_reverse_price:
|
||||
return 'short', self.delay_reverse_price, "次K回调确认"
|
||||
|
||||
if offset >= 2 and prev_row is not None:
|
||||
prev_upper = prev_row['upper']
|
||||
prev_touch_upper = pd.notna(prev_upper) and prev_row['high'] >= prev_upper
|
||||
if prev_touch_upper:
|
||||
if low <= prev_upper:
|
||||
return 'short', prev_upper, "上一根触上轨后回调确认"
|
||||
else:
|
||||
prev_body_low = min(prev_row['open'], prev_row['close'])
|
||||
if low <= prev_body_low:
|
||||
return 'short', prev_body_low, "跌破上一根实体确认"
|
||||
|
||||
elif self.delay_reverse_type == 'short_to_long':
|
||||
# 空转多:反弹确认
|
||||
if offset == 1 and high >= self.delay_reverse_price:
|
||||
return 'long', self.delay_reverse_price, "次K反弹确认"
|
||||
|
||||
if offset >= 2 and prev_row is not None:
|
||||
prev_lower = prev_row['lower']
|
||||
prev_touch_lower = pd.notna(prev_lower) and prev_row['low'] <= prev_lower
|
||||
if prev_touch_lower:
|
||||
if high >= prev_lower:
|
||||
return 'long', prev_lower, "上一根触下轨后反弹确认"
|
||||
else:
|
||||
prev_body_high = max(prev_row['open'], prev_row['close'])
|
||||
if high >= prev_body_high:
|
||||
return 'long', prev_body_high, "突破上一根实体确认"
|
||||
|
||||
return None
|
||||
|
||||
def open_position(self, price, direction, timestamp, reason):
|
||||
"""开仓或加仓"""
|
||||
if self.position_count not in (0, 1):
|
||||
return False
|
||||
|
||||
if self.position_count == 1:
|
||||
current_direction = 'long' if self.position > 0 else 'short'
|
||||
if direction != current_direction:
|
||||
return False
|
||||
|
||||
margin = self.capital * self.margin_ratio
|
||||
if margin <= 0:
|
||||
return False
|
||||
|
||||
position_size = margin * self.leverage / price
|
||||
fee = position_size * price * self.fee_rate
|
||||
required = margin + fee
|
||||
if self.capital < required:
|
||||
return False
|
||||
|
||||
self.capital -= required
|
||||
self.schedule_rebate(fee, timestamp)
|
||||
|
||||
if self.position_count == 0:
|
||||
self.position = position_size if direction == 'long' else -position_size
|
||||
self.entry_price = price
|
||||
self.total_margin = margin
|
||||
self.position_count = 1
|
||||
else:
|
||||
old_size = abs(self.position)
|
||||
new_size = old_size + position_size
|
||||
old_value = old_size * self.entry_price
|
||||
new_value = position_size * price
|
||||
self.entry_price = (old_value + new_value) / new_size
|
||||
self.position = new_size if direction == 'long' else -new_size
|
||||
self.total_margin += margin
|
||||
self.position_count = 2
|
||||
|
||||
self.mid_closed_half = False
|
||||
|
||||
self.trades.append({
|
||||
'timestamp': timestamp,
|
||||
'action': f'开{direction}' if self.position_count == 1 else f'加{direction}',
|
||||
'price': price,
|
||||
'size': position_size,
|
||||
'margin': margin,
|
||||
'fee': fee,
|
||||
'capital': self.capital,
|
||||
'reason': reason
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
def close_position(self, price, ratio, timestamp, reason):
|
||||
"""平仓"""
|
||||
if self.position == 0:
|
||||
return False
|
||||
|
||||
ratio = min(max(ratio, 0.0), 1.0)
|
||||
if ratio == 0:
|
||||
return False
|
||||
|
||||
close_size = abs(self.position) * ratio
|
||||
if self.position > 0:
|
||||
pnl = close_size * (price - self.entry_price)
|
||||
else:
|
||||
pnl = close_size * (self.entry_price - price)
|
||||
|
||||
fee = close_size * price * self.fee_rate
|
||||
|
||||
released_margin = self.total_margin * ratio
|
||||
self.capital += released_margin + pnl - fee
|
||||
self.schedule_rebate(fee, timestamp)
|
||||
|
||||
if ratio >= 0.999:
|
||||
self.position = 0
|
||||
self.position_count = 0
|
||||
self.total_margin = 0
|
||||
self.entry_price = 0
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
else:
|
||||
self.position *= (1 - ratio)
|
||||
self.total_margin *= (1 - ratio)
|
||||
|
||||
self.trades.append({
|
||||
'timestamp': timestamp,
|
||||
'action': f'平仓{int(ratio*100)}%',
|
||||
'price': price,
|
||||
'size': close_size,
|
||||
'pnl': pnl,
|
||||
'fee': fee,
|
||||
'capital': self.capital,
|
||||
'reason': reason
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
def run_backtest(self, start_date, end_date):
|
||||
"""运行回测"""
|
||||
# 重置状态
|
||||
self.capital = self.initial_capital
|
||||
self.position = 0
|
||||
self.position_count = 0
|
||||
self.entry_price = 0
|
||||
self.total_margin = 0
|
||||
self.mid_closed_half = False
|
||||
self.trades = []
|
||||
self.pending_rebates = []
|
||||
self.total_rebate_credited = 0.0
|
||||
self.clear_delay_reversal()
|
||||
|
||||
logger.info(f"{'='*80}")
|
||||
logger.info(f"开始回测: {start_date} ~ {end_date} | 周期: {self.timeframe}")
|
||||
logger.info(f"初始资金: {self.initial_capital}U | 杠杆: {self.leverage}x | BB({self.bb_period}, {self.bb_std})")
|
||||
logger.info(f"{'='*80}")
|
||||
|
||||
# 从数据库加载数据
|
||||
start_dt = pd.Timestamp(start_date)
|
||||
end_dt = pd.Timestamp(end_date)
|
||||
if isinstance(end_date, str) and len(end_date) <= 10:
|
||||
end_dt = end_dt + pd.Timedelta(days=1) - pd.Timedelta(milliseconds=1)
|
||||
|
||||
start_ts = int(start_dt.timestamp() * 1000)
|
||||
end_ts = int(end_dt.timestamp() * 1000)
|
||||
|
||||
# 根据周期选择数据表
|
||||
model_mapping = {
|
||||
'1m': BitMartETH1M,
|
||||
'3m': BitMartETH3M,
|
||||
'5m': BitMartETH5M,
|
||||
'15m': BitMartETH15M,
|
||||
}
|
||||
Model = model_mapping.get(self.timeframe)
|
||||
if Model is None:
|
||||
logger.error(f"不支持的周期: {self.timeframe}")
|
||||
return None
|
||||
|
||||
query = Model.select().where(
|
||||
(Model.id >= start_ts) & (Model.id <= end_ts)
|
||||
).order_by(Model.id)
|
||||
|
||||
data = []
|
||||
for row in query:
|
||||
data.append({
|
||||
'timestamp': row.id,
|
||||
'open': row.open,
|
||||
'high': row.high,
|
||||
'low': row.low,
|
||||
'close': row.close
|
||||
})
|
||||
|
||||
if not data:
|
||||
logger.error("没有找到数据!")
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||||
|
||||
logger.info(f"加载数据: {len(df)} 根K线")
|
||||
logger.info(f"时间范围: {df['datetime'].min()} ~ {df['datetime'].max()}")
|
||||
|
||||
# 计算布林带
|
||||
df = self.calculate_bollinger_bands(df)
|
||||
|
||||
if len(df) <= self.bb_period + 1:
|
||||
logger.error("数据不足,无法执行回测")
|
||||
return None
|
||||
|
||||
# 逐根K线回测
|
||||
for i in range(self.bb_period, len(df)):
|
||||
row = df.iloc[i]
|
||||
prev_row = df.iloc[i-1] if i > 0 else None
|
||||
|
||||
signal_dt = row['datetime']
|
||||
signal_ts = signal_dt.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
high = row['high']
|
||||
low = row['low']
|
||||
close = row['close']
|
||||
upper = row['upper']
|
||||
lower = row['lower']
|
||||
middle = row['middle']
|
||||
|
||||
# 处理返佣到账
|
||||
self.apply_pending_rebates(signal_dt)
|
||||
|
||||
if pd.isna(upper) or pd.isna(lower) or pd.isna(middle):
|
||||
continue
|
||||
|
||||
# 检查延迟反转确认
|
||||
if self.delay_reverse_price is not None:
|
||||
reversal_signal = self.check_delay_reversal_signal(i, row, prev_row)
|
||||
if reversal_signal is not None and self.position != 0:
|
||||
new_direction, reversal_price, reason = reversal_signal
|
||||
self.close_position(reversal_price, 1.0, signal_ts, f"{reason}-平仓")
|
||||
self.open_position(reversal_price, new_direction, signal_ts, f"{reason}-开仓")
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
continue
|
||||
|
||||
# 中轨平仓逻辑
|
||||
if self.position != 0:
|
||||
if self.position > 0:
|
||||
# 回到开仓价全平+反手
|
||||
if self.mid_closed_half and low <= self.entry_price:
|
||||
self.close_position(close, 1.0, signal_ts, "回开仓价全平")
|
||||
self.open_position(close, 'short', signal_ts, "回开仓价反手开空")
|
||||
self.mid_closed_half = False
|
||||
continue
|
||||
# 触中轨平半
|
||||
if not self.mid_closed_half and low <= middle <= high:
|
||||
self.close_position(close, 0.5, signal_ts, "触中轨平50%")
|
||||
self.mid_closed_half = True
|
||||
continue
|
||||
|
||||
else: # 空仓
|
||||
# 回到开仓价全平+反手
|
||||
if self.mid_closed_half and high >= self.entry_price:
|
||||
self.close_position(close, 1.0, signal_ts, "回开仓价全平")
|
||||
self.open_position(close, 'long', signal_ts, "回开仓价反手开多")
|
||||
self.mid_closed_half = False
|
||||
continue
|
||||
# 触中轨平半
|
||||
if not self.mid_closed_half and low <= middle <= high:
|
||||
self.close_position(close, 0.5, signal_ts, "触中轨平50%")
|
||||
self.mid_closed_half = True
|
||||
continue
|
||||
|
||||
# 开仓与加仓逻辑
|
||||
if self.position == 0:
|
||||
self.clear_delay_reversal()
|
||||
|
||||
# 触上轨开空
|
||||
if high >= upper:
|
||||
self.open_position(upper, 'short', signal_ts, "触上轨开空")
|
||||
|
||||
# 触下轨开多
|
||||
elif low <= lower:
|
||||
self.open_position(lower, 'long', signal_ts, "触下轨开多")
|
||||
continue
|
||||
|
||||
# 延迟反转触发
|
||||
if self.position > 0 and high >= upper:
|
||||
self.mark_delay_reversal('long_to_short', upper, i)
|
||||
continue
|
||||
|
||||
elif self.position < 0 and low <= lower:
|
||||
self.mark_delay_reversal('short_to_long', lower, i)
|
||||
continue
|
||||
|
||||
# 加仓
|
||||
if self.delay_reverse_price is None and self.position_count == 1:
|
||||
if self.position > 0 and low <= lower:
|
||||
self.open_position(lower, 'long', signal_ts, "触下轨加多")
|
||||
elif self.position < 0 and high >= upper:
|
||||
self.open_position(upper, 'short', signal_ts, "触上轨加空")
|
||||
|
||||
# 回测末尾处理返佣
|
||||
self.apply_pending_rebates(df.iloc[-1]['datetime'])
|
||||
|
||||
# 最后平仓
|
||||
if self.position != 0:
|
||||
final_price = df.iloc[-1]['close']
|
||||
final_time = df.iloc[-1]['datetime'].strftime('%Y-%m-%d %H:%M')
|
||||
self.close_position(final_price, 1.0, final_time, "回测结束平仓")
|
||||
|
||||
# 生成报告
|
||||
return self.generate_report(df)
|
||||
|
||||
def generate_report(self, df):
|
||||
"""生成回测报告"""
|
||||
logger.info(f"\n{'='*80}")
|
||||
logger.info(f"回测报告 - {self.timeframe}")
|
||||
logger.info(f"{'='*80}")
|
||||
|
||||
# 基本统计
|
||||
total_trades = len([t for t in self.trades if '开' in t['action']])
|
||||
win_trades = len([t for t in self.trades if '平' in t['action'] and t.get('pnl', 0) > 0])
|
||||
loss_trades = len([t for t in self.trades if '平' in t['action'] and t.get('pnl', 0) < 0])
|
||||
|
||||
total_pnl = sum([t.get('pnl', 0) for t in self.trades])
|
||||
total_fee = sum([t.get('fee', 0) for t in self.trades])
|
||||
pending_rebate = sum([x['amount'] for x in self.pending_rebates])
|
||||
realized_net_fee = total_fee - self.total_rebate_credited
|
||||
|
||||
final_capital = self.capital
|
||||
roi = (final_capital - self.initial_capital) / self.initial_capital * 100
|
||||
|
||||
logger.info(f"初始资金: {self.initial_capital:.2f}U")
|
||||
logger.info(f"最终资金: {final_capital:.2f}U")
|
||||
logger.info(f"总盈亏: {total_pnl:.2f}U")
|
||||
logger.info(f"总手续费: {total_fee:.2f}U")
|
||||
logger.info(f"返佣已到账: {self.total_rebate_credited:.2f}U")
|
||||
logger.info(f"返佣待到账: {pending_rebate:.2f}U")
|
||||
logger.info(f"已实现净手续费: {realized_net_fee:.2f}U")
|
||||
logger.info(f"净收益: {final_capital - self.initial_capital:.2f}U")
|
||||
logger.info(f"收益率: {roi:.2f}%")
|
||||
logger.info(f"总交易次数: {total_trades}")
|
||||
logger.info(f"盈利次数: {win_trades}")
|
||||
logger.info(f"亏损次数: {loss_trades}")
|
||||
if win_trades + loss_trades > 0:
|
||||
logger.info(f"胜率: {win_trades/(win_trades+loss_trades)*100:.2f}%")
|
||||
|
||||
# 保存交易记录
|
||||
trades_df = pd.DataFrame(self.trades)
|
||||
output_dir = Path(__file__).parent / 'backtest_outputs' / 'trades'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_file = output_dir / f'bb_backtest_2025_{self.timeframe}_trades.csv'
|
||||
trades_df.to_csv(output_file, index=False, encoding='utf-8-sig')
|
||||
logger.info(f"\n交易记录已保存到: {output_file}")
|
||||
|
||||
return {
|
||||
'timeframe': self.timeframe,
|
||||
'initial_capital': self.initial_capital,
|
||||
'final_capital': final_capital,
|
||||
'total_pnl': total_pnl,
|
||||
'total_fee': total_fee,
|
||||
'total_rebate_credited': self.total_rebate_credited,
|
||||
'pending_rebate': pending_rebate,
|
||||
'realized_net_fee': realized_net_fee,
|
||||
'roi': roi,
|
||||
'total_trades': total_trades,
|
||||
'win_trades': win_trades,
|
||||
'loss_trades': loss_trades,
|
||||
'win_rate': win_trades/(win_trades+loss_trades)*100 if (win_trades+loss_trades) > 0 else 0,
|
||||
'trades_file': str(output_file)
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 连接数据库
|
||||
db.connect(reuse_if_open=True)
|
||||
|
||||
try:
|
||||
results = []
|
||||
|
||||
for tf in ['1m', '3m', '5m', '15m']:
|
||||
logger.info("\n" + "="*80)
|
||||
logger.info(f"测试 {tf} 周期")
|
||||
logger.info("="*80)
|
||||
backtest = BBDelayReversalBacktest(timeframe=tf)
|
||||
result = backtest.run_backtest('2025-01-01', '2025-12-31')
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# 对比总结
|
||||
if len(results) > 0:
|
||||
logger.info("\n" + "="*80)
|
||||
logger.info("回测对比总结")
|
||||
logger.info("="*80)
|
||||
|
||||
for result in results:
|
||||
logger.info(f"\n【{result['timeframe']}周期】")
|
||||
logger.info(f" 收益率: {result['roi']:.2f}%")
|
||||
logger.info(f" 最终资金: {result['final_capital']:.2f}U")
|
||||
logger.info(f" 总交易次数: {result['total_trades']}")
|
||||
logger.info(f" 胜率: {result['win_rate']:.2f}%")
|
||||
logger.info(f" 净手续费: {result['realized_net_fee']:.2f}U")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
754
bb_backtest_march_2026.py
Normal file
754
bb_backtest_march_2026.py
Normal file
@@ -0,0 +1,754 @@
|
||||
"""
|
||||
布林带延迟反转策略回测 - 2026年3月
|
||||
策略规则:
|
||||
1. 5分钟K线,BB(10, 2.5)
|
||||
2. 空仓触上轨开空,触下轨开多
|
||||
3. 同向加仓最多1次,保证金递增(1%->2%)
|
||||
4. 延迟反转:触轨不立刻平仓,记录价格,回调到该价再平仓+反向开仓
|
||||
5. 中轨平半+回开仓价全平
|
||||
6. 止损:亏损达保证金50%
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from peewee import *
|
||||
from loguru import logger
|
||||
|
||||
# 数据库配置
|
||||
DB_PATH = Path(__file__).parent / 'models' / 'database.db'
|
||||
db = SqliteDatabase(str(DB_PATH))
|
||||
|
||||
class BitMartETH5M(Model):
|
||||
"""5分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_5m'
|
||||
|
||||
|
||||
class BitMartETH1M(Model):
|
||||
"""1分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_1m'
|
||||
|
||||
|
||||
class BollingerBandBacktest:
|
||||
"""布林带延迟反转策略回测"""
|
||||
|
||||
def __init__(self):
|
||||
# 策略参数
|
||||
self.bb_period = 10
|
||||
self.bb_std = 2.5
|
||||
self.initial_capital = 100 # 初始本金100U
|
||||
self.leverage = 50 # 50倍杠杆
|
||||
self.fee_rate = 0.0005 # 万五手续费
|
||||
self.rebate_rate = 0.9 # 90%返佣
|
||||
self.margin_ratio_1 = 0.01 # 首次开仓保证金比例1%
|
||||
self.margin_ratio_2 = 0.01 # 加仓保证金比例1%
|
||||
self.stop_loss_ratio = 0.5 # 止损比例50%
|
||||
self.entry_slippage = 0.0002 # 开仓滑点(2bps)
|
||||
self.rebate_credit_hour = 8 # 次日早上8点返佣到账(上海时间)
|
||||
|
||||
# 账户状态
|
||||
self.capital = self.initial_capital
|
||||
self.position = 0 # 持仓量(正=多,负=空)
|
||||
self.position_count = 0 # 持仓次数(0=空仓,1=首次,2=加仓)
|
||||
self.entry_price = 0 # 开仓均价
|
||||
self.total_margin = 0 # 总保证金
|
||||
|
||||
# 延迟反转状态
|
||||
self.delay_reverse_price = None # 记录触碰轨道时的轨道价格
|
||||
self.delay_reverse_type = None # 'long_to_short' 或 'short_to_long'
|
||||
self.delay_reverse_kline_index = None # 触发延迟反转的K线索引
|
||||
|
||||
# 中轨平仓记录
|
||||
self.mid_closed_half = False # 是否已平50%
|
||||
|
||||
# 1分钟K线缓存(key=5分钟起始时间戳, value=该5分钟内的1m列表)
|
||||
self.one_minute_by_5m = {}
|
||||
|
||||
# 交易记录
|
||||
self.trades = []
|
||||
self.daily_pnl = []
|
||||
self.pending_rebates = [] # 待到账返佣队列
|
||||
self.total_rebate_credited = 0.0
|
||||
self.current_run_label = "default"
|
||||
|
||||
def calculate_bollinger_bands(self, df):
|
||||
"""计算布林带(整体右移1根,避免使用当前K收盘价)"""
|
||||
df['sma'] = df['close'].rolling(window=self.bb_period).mean()
|
||||
df['std'] = df['close'].rolling(window=self.bb_period).std()
|
||||
df['upper'] = (df['sma'] + self.bb_std * df['std']).shift(1)
|
||||
df['lower'] = (df['sma'] - self.bb_std * df['std']).shift(1)
|
||||
df['middle'] = df['sma'].shift(1)
|
||||
return df
|
||||
|
||||
def get_rebate_amount(self, fee):
|
||||
"""计算返佣金额"""
|
||||
return fee * self.rebate_rate
|
||||
|
||||
def schedule_rebate(self, fee, timestamp):
|
||||
"""登记返佣到账时间(次日08:00,上海时间)"""
|
||||
rebate = self.get_rebate_amount(fee)
|
||||
if rebate <= 0:
|
||||
return
|
||||
|
||||
trade_utc = pd.Timestamp(timestamp, tz='UTC')
|
||||
trade_local = trade_utc.tz_convert('Asia/Shanghai')
|
||||
credit_local = (trade_local + pd.Timedelta(days=1)).normalize() + pd.Timedelta(hours=self.rebate_credit_hour)
|
||||
credit_utc = credit_local.tz_convert('UTC').tz_localize(None)
|
||||
|
||||
self.pending_rebates.append({
|
||||
'credit_time': credit_utc,
|
||||
'amount': rebate,
|
||||
'trade_time': trade_utc.tz_localize(None),
|
||||
})
|
||||
|
||||
def apply_pending_rebates(self, current_time):
|
||||
"""处理当前时刻前应到账的返佣"""
|
||||
if not self.pending_rebates:
|
||||
return
|
||||
|
||||
remaining = []
|
||||
for item in self.pending_rebates:
|
||||
if item['credit_time'] <= current_time:
|
||||
amount = item['amount']
|
||||
self.capital += amount
|
||||
self.total_rebate_credited += amount
|
||||
self.trades.append({
|
||||
'timestamp': current_time.strftime('%Y-%m-%d %H:%M'),
|
||||
'action': '返佣到账',
|
||||
'price': None,
|
||||
'size': None,
|
||||
'rebate': amount,
|
||||
'capital': self.capital,
|
||||
'reason': f"次日08:00返佣到账({item['trade_time'].strftime('%Y-%m-%d %H:%M')}手续费)"
|
||||
})
|
||||
logger.info(
|
||||
f"[{current_time.strftime('%Y-%m-%d %H:%M')}] 返佣到账: {amount:.4f}U | 可用资金: {self.capital:.4f}U"
|
||||
)
|
||||
else:
|
||||
remaining.append(item)
|
||||
|
||||
self.pending_rebates = remaining
|
||||
|
||||
def apply_entry_slippage(self, price, direction):
|
||||
"""按方向施加不利滑点"""
|
||||
if direction == 'long':
|
||||
return price * (1 + self.entry_slippage)
|
||||
return price * (1 - self.entry_slippage)
|
||||
|
||||
def load_one_minute_cache(self, start_ts, end_ts):
|
||||
"""加载并缓存1分钟K线,用于5分钟内走势确认"""
|
||||
query = BitMartETH1M.select().where(
|
||||
(BitMartETH1M.id >= start_ts) & (BitMartETH1M.id <= end_ts)
|
||||
).order_by(BitMartETH1M.id)
|
||||
|
||||
bucket = {}
|
||||
for row in query:
|
||||
five_min_start = int(row.id - (row.id % 300000))
|
||||
bucket.setdefault(five_min_start, []).append({
|
||||
'timestamp': int(row.id),
|
||||
'open': float(row.open) if row.open is not None else None,
|
||||
'high': float(row.high) if row.high is not None else None,
|
||||
'low': float(row.low) if row.low is not None else None,
|
||||
'close': float(row.close) if row.close is not None else None,
|
||||
})
|
||||
|
||||
self.one_minute_by_5m = bucket
|
||||
total_rows = sum(len(v) for v in bucket.values())
|
||||
logger.info(f"加载1分钟数据: {total_rows} 根 | 5分钟桶: {len(bucket)}")
|
||||
|
||||
def should_close_half_by_1m_trend(self, five_min_ts, middle_price, side):
|
||||
"""
|
||||
1分钟顺序确认中轨平半:
|
||||
- 多仓:先到中轨上方,再回踩中轨
|
||||
- 空仓:先到中轨下方,再反抽中轨
|
||||
"""
|
||||
minute_rows = self.one_minute_by_5m.get(five_min_ts, [])
|
||||
if not minute_rows:
|
||||
return False, None
|
||||
|
||||
seen_above_middle = False
|
||||
seen_below_middle = False
|
||||
|
||||
for minute in minute_rows:
|
||||
m_open = minute['open']
|
||||
m_high = minute['high']
|
||||
m_low = minute['low']
|
||||
m_close = minute['close']
|
||||
|
||||
if None in (m_open, m_high, m_low, m_close):
|
||||
continue
|
||||
|
||||
minute_time = pd.to_datetime(minute['timestamp'], unit='ms').strftime('%H:%M')
|
||||
|
||||
if side == 'long':
|
||||
# 当前1m开在中轨上方且回踩到中轨,视为有效回踩
|
||||
if m_open >= middle_price and m_low <= middle_price:
|
||||
return True, f"1m({minute_time})回踩中轨"
|
||||
|
||||
# 已经到过中轨上方后,再次触及中轨
|
||||
if seen_above_middle and m_low <= middle_price:
|
||||
return True, f"1m({minute_time})回踩中轨"
|
||||
|
||||
if m_high >= middle_price:
|
||||
seen_above_middle = True
|
||||
|
||||
else: # short
|
||||
# 当前1m开在中轨下方且反抽到中轨,视为有效反抽
|
||||
if m_open <= middle_price and m_high >= middle_price:
|
||||
return True, f"1m({minute_time})反抽中轨"
|
||||
|
||||
# 已经到过中轨下方后,再次触及中轨
|
||||
if seen_below_middle and m_high >= middle_price:
|
||||
return True, f"1m({minute_time})反抽中轨"
|
||||
|
||||
if m_low <= middle_price:
|
||||
seen_below_middle = True
|
||||
|
||||
return False, None
|
||||
|
||||
def get_touch_entry_price(self, five_min_ts, direction, touch_price):
|
||||
"""
|
||||
触轨开仓的保守成交价:
|
||||
- 在该5分钟内找到首个触轨1m
|
||||
- 用该1m收盘价作为基准
|
||||
- 为避免“过于理想”,不允许优于触轨价,再叠加不利滑点
|
||||
"""
|
||||
minute_rows = self.one_minute_by_5m.get(five_min_ts, [])
|
||||
if not minute_rows:
|
||||
return self.apply_entry_slippage(touch_price, direction)
|
||||
|
||||
trigger_close = None
|
||||
for minute in minute_rows:
|
||||
m_high = minute['high']
|
||||
m_low = minute['low']
|
||||
m_close = minute['close']
|
||||
if None in (m_high, m_low, m_close):
|
||||
continue
|
||||
|
||||
if direction == 'short' and m_high >= touch_price:
|
||||
trigger_close = m_close
|
||||
break
|
||||
if direction == 'long' and m_low <= touch_price:
|
||||
trigger_close = m_close
|
||||
break
|
||||
|
||||
if trigger_close is None:
|
||||
base_price = touch_price
|
||||
elif direction == 'long':
|
||||
base_price = max(touch_price, trigger_close)
|
||||
else:
|
||||
base_price = min(touch_price, trigger_close)
|
||||
|
||||
return self.apply_entry_slippage(base_price, direction)
|
||||
|
||||
def clear_delay_reversal(self):
|
||||
"""清理延迟反转状态"""
|
||||
self.delay_reverse_price = None
|
||||
self.delay_reverse_type = None
|
||||
self.delay_reverse_kline_index = None
|
||||
|
||||
def mark_delay_reversal(self, reverse_type, trigger_price, kline_index, timestamp):
|
||||
"""记录延迟反转触发信息"""
|
||||
self.delay_reverse_type = reverse_type
|
||||
self.delay_reverse_price = trigger_price
|
||||
self.delay_reverse_kline_index = kline_index
|
||||
|
||||
if reverse_type == 'long_to_short':
|
||||
logger.info(f"[{timestamp}] 多仓触上轨 @ {trigger_price:.2f},进入延迟反转")
|
||||
else:
|
||||
logger.info(f"[{timestamp}] 空仓触下轨 @ {trigger_price:.2f},进入延迟反转")
|
||||
|
||||
def reverse_position(self, price, new_direction, timestamp, reason):
|
||||
"""全平后反向开仓"""
|
||||
if self.position == 0:
|
||||
self.clear_delay_reversal()
|
||||
return False
|
||||
|
||||
close_side = "多" if self.position > 0 else "空"
|
||||
open_side = "多" if new_direction == "long" else "空"
|
||||
self.close_position(price, 1.0, timestamp, f"{reason}-平{close_side}")
|
||||
open_price = self.apply_entry_slippage(price, new_direction)
|
||||
self.open_position(open_price, new_direction, timestamp, f"{reason}-开{open_side}")
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
return True
|
||||
|
||||
def check_delay_reversal_signal(self, i, row, prev_row):
|
||||
"""检查延迟反转是否在当前收盘K成立(仅返回信号,不直接执行)"""
|
||||
if self.position == 0 or self.delay_reverse_price is None or self.delay_reverse_kline_index is None:
|
||||
return None
|
||||
|
||||
offset = i - self.delay_reverse_kline_index
|
||||
# 禁止同K确认,最早次K确认
|
||||
if offset <= 0:
|
||||
return None
|
||||
|
||||
high = row['high']
|
||||
low = row['low']
|
||||
|
||||
if self.delay_reverse_type == 'long_to_short':
|
||||
# 情况1:触上轨后,次K回调到记录上轨价
|
||||
if offset == 1 and low <= self.delay_reverse_price:
|
||||
return 'short', "延迟反转-次K回调确认", self.delay_reverse_price
|
||||
|
||||
# 情况2:持续等待,动态追踪上一根K线条件
|
||||
if offset >= 2 and prev_row is not None:
|
||||
prev_upper = prev_row['upper']
|
||||
prev_touch_upper = pd.notna(prev_upper) and prev_row['high'] >= prev_upper
|
||||
if prev_touch_upper:
|
||||
if low <= prev_upper:
|
||||
return 'short', "延迟反转-上一根触上轨后回调确认", prev_upper
|
||||
else:
|
||||
prev_body_low = min(prev_row['open'], prev_row['close'])
|
||||
if low <= prev_body_low:
|
||||
return 'short', "延迟反转-跌破上一根实体确认", prev_body_low
|
||||
|
||||
elif self.delay_reverse_type == 'short_to_long':
|
||||
# 情况1:触下轨后,次K反弹到记录下轨价
|
||||
if offset == 1 and high >= self.delay_reverse_price:
|
||||
return 'long', "延迟反转-次K反弹确认", self.delay_reverse_price
|
||||
|
||||
# 情况2:持续等待,动态追踪上一根K线条件
|
||||
if offset >= 2 and prev_row is not None:
|
||||
prev_lower = prev_row['lower']
|
||||
prev_touch_lower = pd.notna(prev_lower) and prev_row['low'] <= prev_lower
|
||||
if prev_touch_lower:
|
||||
if high >= prev_lower:
|
||||
return 'long', "延迟反转-上一根触下轨后反弹确认", prev_lower
|
||||
else:
|
||||
prev_body_high = max(prev_row['open'], prev_row['close'])
|
||||
if high >= prev_body_high:
|
||||
return 'long', "延迟反转-突破上一根实体确认", prev_body_high
|
||||
|
||||
return None
|
||||
|
||||
def open_position(self, price, direction, timestamp, reason):
|
||||
"""开仓或加仓"""
|
||||
if self.position_count not in (0, 1):
|
||||
return False
|
||||
|
||||
if self.position_count == 1:
|
||||
current_direction = 'long' if self.position > 0 else 'short'
|
||||
if direction != current_direction:
|
||||
logger.warning("加仓方向不一致,跳过")
|
||||
return False
|
||||
|
||||
margin_ratio = self.margin_ratio_1 if self.position_count == 0 else self.margin_ratio_2
|
||||
margin = self.capital * margin_ratio
|
||||
if margin <= 0:
|
||||
logger.warning(f"[{timestamp}] 资金不足,无法开仓 | 可用资金: {self.capital:.4f}U")
|
||||
return False
|
||||
|
||||
position_size = margin * self.leverage / price
|
||||
fee = position_size * price * self.fee_rate
|
||||
required = margin + fee
|
||||
if self.capital < required:
|
||||
logger.warning(
|
||||
f"[{timestamp}] 可用资金不足,无法开仓 | 需要: {required:.4f}U | 可用: {self.capital:.4f}U"
|
||||
)
|
||||
return False
|
||||
|
||||
# 冻结保证金并扣除手续费
|
||||
self.capital -= required
|
||||
self.schedule_rebate(fee, timestamp)
|
||||
|
||||
if self.position_count == 0:
|
||||
self.position = position_size if direction == 'long' else -position_size
|
||||
self.entry_price = price
|
||||
self.total_margin = margin
|
||||
self.position_count = 1
|
||||
action = f'开{direction}'
|
||||
else:
|
||||
old_size = abs(self.position)
|
||||
new_size = old_size + position_size
|
||||
old_value = old_size * self.entry_price
|
||||
new_value = position_size * price
|
||||
self.entry_price = (old_value + new_value) / new_size
|
||||
self.position = new_size if direction == 'long' else -new_size
|
||||
self.total_margin += margin
|
||||
self.position_count = 2
|
||||
action = f'加{direction}'
|
||||
|
||||
self.mid_closed_half = False
|
||||
|
||||
self.trades.append({
|
||||
'timestamp': timestamp,
|
||||
'action': action,
|
||||
'price': price,
|
||||
'size': position_size,
|
||||
'margin': margin,
|
||||
'fee': fee,
|
||||
'rebate': self.get_rebate_amount(fee),
|
||||
'capital': self.capital,
|
||||
'reason': reason
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"[{timestamp}] {action} @ {price:.2f} | 仓位: {position_size:.4f} | "
|
||||
f"保证金: {margin:.4f}U | 手续费: {fee:.4f}U | 返佣待到账: {self.get_rebate_amount(fee):.4f}U | "
|
||||
f"可用资金: {self.capital:.4f}U | {reason}"
|
||||
)
|
||||
return True
|
||||
|
||||
def close_position(self, price, ratio, timestamp, reason):
|
||||
"""平仓"""
|
||||
if self.position == 0:
|
||||
return False
|
||||
|
||||
ratio = min(max(ratio, 0.0), 1.0)
|
||||
if ratio == 0:
|
||||
return False
|
||||
|
||||
close_size = abs(self.position) * ratio
|
||||
if self.position > 0:
|
||||
pnl = close_size * (price - self.entry_price)
|
||||
else:
|
||||
pnl = close_size * (self.entry_price - price)
|
||||
|
||||
fee = close_size * price * self.fee_rate
|
||||
|
||||
released_margin = self.total_margin * ratio
|
||||
self.capital += released_margin + pnl - fee
|
||||
self.schedule_rebate(fee, timestamp)
|
||||
|
||||
if ratio >= 0.999:
|
||||
self.position = 0
|
||||
self.position_count = 0
|
||||
self.total_margin = 0
|
||||
self.entry_price = 0
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
else:
|
||||
self.position *= (1 - ratio)
|
||||
self.total_margin *= (1 - ratio)
|
||||
if abs(self.position) < 1e-12:
|
||||
self.position = 0
|
||||
self.position_count = 0
|
||||
self.total_margin = 0
|
||||
self.entry_price = 0
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
|
||||
self.trades.append({
|
||||
'timestamp': timestamp,
|
||||
'action': f'平仓{int(ratio*100)}%',
|
||||
'price': price,
|
||||
'size': close_size,
|
||||
'pnl': pnl,
|
||||
'fee': fee,
|
||||
'rebate': self.get_rebate_amount(fee),
|
||||
'capital': self.capital,
|
||||
'reason': reason
|
||||
})
|
||||
|
||||
logger.info(f"[{timestamp}] 平仓{int(ratio*100)}% @ {price:.2f} | "
|
||||
f"盈亏: {pnl:.4f}U | 手续费: {fee:.4f}U | 返佣待到账: {self.get_rebate_amount(fee):.4f}U | "
|
||||
f"可用资金: {self.capital:.4f}U | {reason}")
|
||||
return True
|
||||
|
||||
def check_stop_loss(self, high, low):
|
||||
"""检查当前收盘K是否触发止损信号"""
|
||||
if self.position == 0:
|
||||
return False
|
||||
|
||||
stop_price = low if self.position > 0 else high
|
||||
if self.position > 0:
|
||||
unrealized_pnl = abs(self.position) * (stop_price - self.entry_price)
|
||||
else:
|
||||
unrealized_pnl = abs(self.position) * (self.entry_price - stop_price)
|
||||
|
||||
return unrealized_pnl <= -self.total_margin * self.stop_loss_ratio
|
||||
|
||||
def run_backtest(self, start_date, end_date):
|
||||
"""运行回测(开仓当K触发,平仓/止损仍按下一根K开盘执行)"""
|
||||
# 重置状态,支持同一实例重复回测
|
||||
self.capital = self.initial_capital
|
||||
self.position = 0
|
||||
self.position_count = 0
|
||||
self.entry_price = 0
|
||||
self.total_margin = 0
|
||||
self.mid_closed_half = False
|
||||
self.trades = []
|
||||
self.daily_pnl = []
|
||||
self.pending_rebates = []
|
||||
self.total_rebate_credited = 0.0
|
||||
self.clear_delay_reversal()
|
||||
|
||||
logger.info(f"{'='*80}")
|
||||
logger.info(f"开始回测: {start_date} ~ {end_date}")
|
||||
logger.info(f"初始资金: {self.initial_capital}U | 杠杆: {self.leverage}x | BB({self.bb_period}, {self.bb_std})")
|
||||
logger.info(f"{'='*80}")
|
||||
|
||||
# 从数据库加载数据
|
||||
start_dt = pd.Timestamp(start_date)
|
||||
end_dt = pd.Timestamp(end_date)
|
||||
if isinstance(end_date, str) and len(end_date) <= 10:
|
||||
end_dt = end_dt + pd.Timedelta(days=1) - pd.Timedelta(milliseconds=1)
|
||||
|
||||
self.current_run_label = f"{start_dt.strftime('%Y%m%d')}_{end_dt.strftime('%Y%m%d')}"
|
||||
|
||||
start_ts = int(start_dt.timestamp() * 1000)
|
||||
end_ts = int(end_dt.timestamp() * 1000)
|
||||
|
||||
query = BitMartETH5M.select().where(
|
||||
(BitMartETH5M.id >= start_ts) & (BitMartETH5M.id <= end_ts)
|
||||
).order_by(BitMartETH5M.id)
|
||||
|
||||
data = []
|
||||
for row in query:
|
||||
data.append({
|
||||
'timestamp': row.id,
|
||||
'open': row.open,
|
||||
'high': row.high,
|
||||
'low': row.low,
|
||||
'close': row.close
|
||||
})
|
||||
|
||||
if not data:
|
||||
logger.error("没有找到数据!")
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||||
|
||||
logger.info(f"加载数据: {len(df)} 根K线")
|
||||
logger.info(f"时间范围: {df['datetime'].min()} ~ {df['datetime'].max()}")
|
||||
|
||||
# 计算布林带
|
||||
df = self.calculate_bollinger_bands(df)
|
||||
|
||||
if len(df) <= self.bb_period + 1:
|
||||
logger.error("数据不足,无法执行回测")
|
||||
return None
|
||||
|
||||
# 逐根K线回测(开仓当K触发,平仓/止损下一K开盘执行)
|
||||
for i in range(self.bb_period, len(df) - 1):
|
||||
row = df.iloc[i]
|
||||
prev_row = df.iloc[i-1] if i > 0 else None
|
||||
next_row = df.iloc[i + 1]
|
||||
|
||||
signal_dt = row['datetime']
|
||||
signal_ts = signal_dt.strftime('%Y-%m-%d %H:%M')
|
||||
five_min_ts = int(row['timestamp'])
|
||||
execute_ts = next_row['datetime'].strftime('%Y-%m-%d %H:%M')
|
||||
next_open = float(next_row['open']) if pd.notna(next_row['open']) else None
|
||||
|
||||
high = row['high']
|
||||
low = row['low']
|
||||
upper = row['upper']
|
||||
lower = row['lower']
|
||||
middle = row['middle']
|
||||
|
||||
# 先处理当前收盘时刻返佣到账
|
||||
self.apply_pending_rebates(signal_dt)
|
||||
|
||||
if pd.isna(upper) or pd.isna(lower) or pd.isna(middle):
|
||||
continue
|
||||
if next_open is None:
|
||||
continue
|
||||
|
||||
# 检查止损(收盘确认,下一K开盘平仓)
|
||||
if self.check_stop_loss(high, low):
|
||||
logger.warning(f"[{signal_ts}] 触发止损信号,下一K开盘执行")
|
||||
self.close_position(next_open, 1.0, execute_ts, f"止损-收盘确认({signal_ts})")
|
||||
continue
|
||||
|
||||
# 已处于延迟反转状态时,先检查确认逻辑
|
||||
if self.delay_reverse_price is not None:
|
||||
reversal_signal = self.check_delay_reversal_signal(i, row, prev_row)
|
||||
if reversal_signal is not None and self.position != 0:
|
||||
new_direction, reason, reversal_price = reversal_signal
|
||||
if reversal_price is None or pd.isna(reversal_price):
|
||||
reversal_price = float(row['close'])
|
||||
close_side = "多" if self.position > 0 else "空"
|
||||
open_side = "多" if new_direction == 'long' else "空"
|
||||
self.close_position(
|
||||
reversal_price,
|
||||
1.0,
|
||||
signal_ts,
|
||||
f"{reason}-当K确认({signal_ts})-平{close_side}"
|
||||
)
|
||||
open_price = self.apply_entry_slippage(reversal_price, new_direction)
|
||||
self.open_position(
|
||||
open_price,
|
||||
new_direction,
|
||||
signal_ts,
|
||||
f"{reason}-当K确认({signal_ts})-开{open_side}"
|
||||
)
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
continue
|
||||
|
||||
# === 中轨平仓逻辑 ===
|
||||
if self.position != 0:
|
||||
had_mid_closed_half = self.mid_closed_half
|
||||
if self.position > 0: # 多仓
|
||||
# 回到开仓价全平+反手(仅在此前已平半的前提下触发)
|
||||
if had_mid_closed_half and low <= self.entry_price:
|
||||
self.close_position(next_open, 1.0, execute_ts, f"回开仓价全平-收盘确认({signal_ts})")
|
||||
entry_price = self.apply_entry_slippage(next_open, 'short')
|
||||
self.open_position(entry_price, 'short', execute_ts, f"回开仓价反手开空-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = False
|
||||
continue
|
||||
# 触中轨平半(收盘确认)
|
||||
if not had_mid_closed_half and low <= middle <= high:
|
||||
self.close_position(next_open, 0.5, execute_ts, f"触中轨平50%-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = True
|
||||
continue
|
||||
|
||||
else: # 空仓
|
||||
# 回到开仓价全平+反手(仅在此前已平半的前提下触发)
|
||||
if had_mid_closed_half and high >= self.entry_price:
|
||||
self.close_position(next_open, 1.0, execute_ts, f"回开仓价全平-收盘确认({signal_ts})")
|
||||
entry_price = self.apply_entry_slippage(next_open, 'long')
|
||||
self.open_position(entry_price, 'long', execute_ts, f"回开仓价反手开多-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = False
|
||||
continue
|
||||
# 触中轨平半(收盘确认)
|
||||
if not had_mid_closed_half and low <= middle <= high:
|
||||
self.close_position(next_open, 0.5, execute_ts, f"触中轨平50%-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = True
|
||||
continue
|
||||
|
||||
# === 开仓与加仓逻辑 ===
|
||||
if self.position == 0: # 空仓
|
||||
self.clear_delay_reversal()
|
||||
|
||||
# 触上轨开空
|
||||
if high >= upper:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'short', upper)
|
||||
self.open_position(entry_price, 'short', signal_ts, f"触上轨开空-当K触发({signal_ts})")
|
||||
|
||||
# 触下轨开多
|
||||
elif low <= lower:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'long', lower)
|
||||
self.open_position(entry_price, 'long', signal_ts, f"触下轨开多-当K触发({signal_ts})")
|
||||
continue
|
||||
|
||||
# 有持仓:先检查是否触发延迟反转(核心)
|
||||
if self.position > 0 and high >= upper:
|
||||
self.mark_delay_reversal('long_to_short', upper, i, signal_ts)
|
||||
continue
|
||||
|
||||
elif self.position < 0 and low <= lower:
|
||||
self.mark_delay_reversal('short_to_long', lower, i, signal_ts)
|
||||
continue
|
||||
|
||||
# 进入延迟反转等待后,不再执行加仓
|
||||
if self.delay_reverse_price is not None:
|
||||
continue
|
||||
|
||||
# 同向加仓最多1次
|
||||
if self.position_count == 1:
|
||||
if self.position > 0 and low <= lower:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'long', lower)
|
||||
self.open_position(entry_price, 'long', signal_ts, f"触下轨加多-当K触发({signal_ts})")
|
||||
elif self.position < 0 and high >= upper:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'short', upper)
|
||||
self.open_position(entry_price, 'short', signal_ts, f"触上轨加空-当K触发({signal_ts})")
|
||||
|
||||
# 回测末尾再处理一次返佣到账
|
||||
self.apply_pending_rebates(df.iloc[-1]['datetime'])
|
||||
|
||||
# 最后平仓
|
||||
if self.position != 0:
|
||||
final_price = df.iloc[-1]['close']
|
||||
final_time = df.iloc[-1]['datetime'].strftime('%Y-%m-%d %H:%M')
|
||||
self.close_position(final_price, 1.0, final_time, "回测结束平仓")
|
||||
|
||||
# 生成报告
|
||||
return self.generate_report(df)
|
||||
|
||||
def generate_report(self, df):
|
||||
"""生成回测报告"""
|
||||
logger.info(f"\n{'='*80}")
|
||||
logger.info("回测报告")
|
||||
logger.info(f"{'='*80}")
|
||||
|
||||
# 基本统计
|
||||
total_trades = len([t for t in self.trades if '开' in t['action']])
|
||||
win_trades = len([t for t in self.trades if '平' in t['action'] and t.get('pnl', 0) > 0])
|
||||
loss_trades = len([t for t in self.trades if '平' in t['action'] and t.get('pnl', 0) < 0])
|
||||
|
||||
total_pnl = sum([t.get('pnl', 0) for t in self.trades])
|
||||
total_fee = sum([t.get('fee', 0) for t in self.trades])
|
||||
total_rebate_expected = sum([t.get('rebate', 0) for t in self.trades if t.get('fee', 0) > 0])
|
||||
pending_rebate = sum([x['amount'] for x in self.pending_rebates])
|
||||
realized_net_fee = total_fee - self.total_rebate_credited
|
||||
|
||||
final_capital = self.capital
|
||||
roi = (final_capital - self.initial_capital) / self.initial_capital * 100
|
||||
|
||||
logger.info(f"初始资金: {self.initial_capital:.2f}U")
|
||||
logger.info(f"最终资金: {final_capital:.2f}U")
|
||||
logger.info(f"总盈亏: {total_pnl:.2f}U")
|
||||
logger.info(f"总手续费(开平全额): {total_fee:.2f}U")
|
||||
logger.info(f"返佣应返总额: {total_rebate_expected:.2f}U")
|
||||
logger.info(f"返佣已到账: {self.total_rebate_credited:.2f}U")
|
||||
logger.info(f"返佣待到账: {pending_rebate:.2f}U")
|
||||
logger.info(f"已实现净手续费: {realized_net_fee:.2f}U")
|
||||
logger.info(f"净收益: {final_capital - self.initial_capital:.2f}U")
|
||||
logger.info(f"收益率: {roi:.2f}%")
|
||||
logger.info(f"总交易次数: {total_trades}")
|
||||
logger.info(f"盈利次数: {win_trades}")
|
||||
logger.info(f"亏损次数: {loss_trades}")
|
||||
if win_trades + loss_trades > 0:
|
||||
logger.info(f"胜率: {win_trades/(win_trades+loss_trades)*100:.2f}%")
|
||||
|
||||
# 保存交易记录
|
||||
trades_df = pd.DataFrame(self.trades)
|
||||
output_dir = Path(__file__).parent / 'backtest_outputs' / 'trades'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_file = output_dir / f'bb_backtest_{self.current_run_label}_trades.csv'
|
||||
trades_df.to_csv(output_file, index=False, encoding='utf-8-sig')
|
||||
logger.info(f"\n交易记录已保存到: {output_file}")
|
||||
|
||||
return {
|
||||
'initial_capital': self.initial_capital,
|
||||
'final_capital': final_capital,
|
||||
'total_pnl': total_pnl,
|
||||
'total_fee': total_fee,
|
||||
'total_rebate_expected': total_rebate_expected,
|
||||
'total_rebate_credited': self.total_rebate_credited,
|
||||
'pending_rebate': pending_rebate,
|
||||
'realized_net_fee': realized_net_fee,
|
||||
'roi': roi,
|
||||
'total_trades': total_trades,
|
||||
'win_trades': win_trades,
|
||||
'loss_trades': loss_trades,
|
||||
'trades_file': str(output_file),
|
||||
'trades': self.trades
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 连接数据库
|
||||
db.connect(reuse_if_open=True)
|
||||
|
||||
try:
|
||||
# 创建回测实例
|
||||
backtest = BollingerBandBacktest()
|
||||
|
||||
# 运行回测(2026年全年)
|
||||
result = backtest.run_backtest('2026-01-01', '2026-12-31')
|
||||
|
||||
if result:
|
||||
logger.success(f"\n回测完成!最终收益率: {result['roi']:.2f}%")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
626
bb_delay_reversal_trade.py
Normal file
626
bb_delay_reversal_trade.py
Normal file
@@ -0,0 +1,626 @@
|
||||
"""
|
||||
布林带均值回归策略 — 实盘交易 (D方案: 递增加仓)
|
||||
BB(10, 2.5) | 5分钟K线 | ETH | 50x杠杆 逐仓 | 递增加仓+1%/次 max=3
|
||||
|
||||
逻辑:
|
||||
- 价格触及上布林带 → 平多(如有) + 开空; 已持空则加仓
|
||||
- 价格触及下布林带 → 平空(如有) + 开多; 已持多则加仓
|
||||
- 始终持仓(多空翻转 + 同向加仓)
|
||||
- 加仓比例: 开仓1%, 第1次加仓2%, 第2次3%, 第3次4%, 最多加仓3次
|
||||
|
||||
使用浏览器自动化进行开平仓(有手续费返佣),API仅用于查询数据
|
||||
"""
|
||||
import time
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
from bitmart.api_contract import APIContract
|
||||
from bit_tools import openBrowser
|
||||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置
|
||||
# ---------------------------------------------------------------------------
|
||||
class BBDelayReversalConfig:
|
||||
# API 凭证
|
||||
API_KEY = "a0fb7b98464fd9bcce67e7c519d58ec10d0c38a8"
|
||||
SECRET_KEY = "4eaeba78e77aeaab1c2027f846a276d164f264a44c2c1bb1c5f3be50c8de1ca5"
|
||||
MEMO = "合约交易"
|
||||
|
||||
# 合约
|
||||
CONTRACT_SYMBOL = "ETHUSDT"
|
||||
TRADE_URL = "https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT"
|
||||
|
||||
# 浏览器
|
||||
BIT_ID = "f2320f57e24c45529a009e1541e25961"
|
||||
|
||||
# 布林带参数
|
||||
BB_PERIOD = 10 # 10根5分钟K线 = 50分钟回看
|
||||
BB_STD = 2.5 # 标准差倍数
|
||||
|
||||
# 仓位管理
|
||||
LEVERAGE = 50 # 杠杆倍数
|
||||
OPEN_TYPE = "isolated" # 逐仓模式
|
||||
MARGIN_PCT = 0.01 # 首次开仓用权益的1%作为保证金
|
||||
|
||||
# 递增加仓 (D方案)
|
||||
PYRAMID_STEP = 0.01 # 每次加仓增加1%权益比例 (1%→2%→3%→4%)
|
||||
PYRAMID_MAX = 3 # 最多加仓3次 (首次开仓不算)
|
||||
|
||||
# 风控
|
||||
MAX_DAILY_LOSS = 50.0 # 日最大亏损(U),达到后停止交易
|
||||
COOLDOWN_SECONDS = 30 # 两次交易之间最小间隔(秒)
|
||||
|
||||
# 运行
|
||||
POLL_INTERVAL = 5 # 主循环轮询间隔(秒)
|
||||
KLINE_STEP = 5 # K线周期(分钟)
|
||||
KLINE_HOURS = 2 # 获取最近多少小时K线(需覆盖BB_PERIOD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 布林带计算
|
||||
# ---------------------------------------------------------------------------
|
||||
def calc_bollinger(closes: list, period: int, n_std: float):
|
||||
"""计算布林带,返回 (mid, upper, lower) 或 None(数据不足时)"""
|
||||
if len(closes) < period:
|
||||
return None
|
||||
arr = np.array(closes[-period:], dtype=float)
|
||||
mid = arr.mean()
|
||||
std = arr.std(ddof=0)
|
||||
upper = mid + n_std * std
|
||||
lower = mid - n_std * std
|
||||
return mid, upper, lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 交易主类
|
||||
# ---------------------------------------------------------------------------
|
||||
class BBDelayReversalTrader:
|
||||
def __init__(self, cfg: BBDelayReversalConfig = None, bit_id: str = None):
|
||||
self.cfg = cfg or BBDelayReversalConfig()
|
||||
if bit_id:
|
||||
self.cfg.BIT_ID = bit_id
|
||||
self.api = APIContract(
|
||||
self.cfg.API_KEY, self.cfg.SECRET_KEY, self.cfg.MEMO,
|
||||
timeout=(5, 15)
|
||||
)
|
||||
|
||||
# 浏览器
|
||||
self.page: ChromiumPage | None = None
|
||||
self.page_start = True # 需要(重新)打开浏览器
|
||||
self.last_page_open_time = 0.0 # 上次打开浏览器的时间
|
||||
self.PAGE_REFRESH_INTERVAL = 1800 # 每30分钟关闭重开浏览器
|
||||
|
||||
# 持仓状态: -1=空, 0=无, 1=多
|
||||
self.position = 0
|
||||
self.open_avg_price = None
|
||||
self.current_amount = None
|
||||
|
||||
# 加仓状态
|
||||
self.pyramid_count = 0 # 当前已加仓次数 (0=仅首次开仓)
|
||||
|
||||
# 风控
|
||||
self.daily_pnl = 0.0
|
||||
self.daily_stopped = False
|
||||
self.current_date = None
|
||||
self.last_trade_time = 0.0
|
||||
|
||||
# 日志
|
||||
self.log_dir = Path(__file__).resolve().parent
|
||||
logger.add(
|
||||
self.log_dir / "bb_delay_reversal_trade_{time:YYYY-MM-DD}.log",
|
||||
rotation="1 day", retention="30 days",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API 封装
|
||||
# ------------------------------------------------------------------
|
||||
def get_klines(self) -> list | None:
|
||||
"""获取最近N小时的5分钟K线,返回 [{id, open, high, low, close}, ...]"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
start_time = end_time - 3600 * self.cfg.KLINE_HOURS
|
||||
resp = self.api.get_kline(
|
||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
||||
step=self.cfg.KLINE_STEP,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)[0]
|
||||
if resp.get("code") != 1000:
|
||||
logger.error(f"获取K线失败: {resp}")
|
||||
return None
|
||||
data = resp["data"]
|
||||
klines = []
|
||||
for k in data:
|
||||
klines.append({
|
||||
"id": int(k["timestamp"]),
|
||||
"open": float(k["open_price"]),
|
||||
"high": float(k["high_price"]),
|
||||
"low": float(k["low_price"]),
|
||||
"close": float(k["close_price"]),
|
||||
})
|
||||
klines.sort(key=lambda x: x["id"])
|
||||
return klines
|
||||
except Exception as e:
|
||||
logger.error(f"获取K线异常: {e}")
|
||||
return None
|
||||
|
||||
def get_current_price(self) -> float | None:
|
||||
"""获取当前最新价格(最近1分钟K线收盘价)"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
resp = self.api.get_kline(
|
||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
||||
step=1,
|
||||
start_time=end_time - 300,
|
||||
end_time=end_time
|
||||
)[0]
|
||||
if resp.get("code") == 1000 and resp["data"]:
|
||||
return float(resp["data"][-1]["close_price"])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取价格异常: {e}")
|
||||
return None
|
||||
|
||||
def get_balance(self) -> float | None:
|
||||
"""获取合约账户可用余额"""
|
||||
try:
|
||||
resp = self.api.get_assets_detail()[0]
|
||||
if resp.get("code") == 1000:
|
||||
data = resp["data"]
|
||||
if isinstance(data, dict):
|
||||
return float(data.get("available_balance", 0))
|
||||
elif isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get("currency") == "USDT":
|
||||
return float(asset.get("available_balance", 0))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"查询余额异常: {e}")
|
||||
return None
|
||||
|
||||
def get_position_status(self) -> bool:
|
||||
"""查询当前持仓,更新 self.position / open_avg_price / current_amount"""
|
||||
try:
|
||||
resp = self.api.get_position(contract_symbol=self.cfg.CONTRACT_SYMBOL)[0]
|
||||
if resp.get("code") != 1000:
|
||||
logger.error(f"查询持仓失败: {resp}")
|
||||
return False
|
||||
positions = resp["data"]
|
||||
if not positions:
|
||||
self.position = 0
|
||||
self.open_avg_price = None
|
||||
self.current_amount = None
|
||||
return True
|
||||
pos = positions[0]
|
||||
self.position = 1 if pos["position_type"] == 1 else -1
|
||||
self.open_avg_price = float(pos["open_avg_price"])
|
||||
self.current_amount = float(pos["current_amount"])
|
||||
unrealized = float(pos.get("unrealized_value", 0))
|
||||
logger.debug(f"持仓: dir={self.position} price={self.open_avg_price} "
|
||||
f"amt={self.current_amount} upnl={unrealized:.2f}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"查询持仓异常: {e}")
|
||||
return False
|
||||
|
||||
def set_leverage(self) -> bool:
|
||||
"""设置杠杆和逐仓模式"""
|
||||
try:
|
||||
resp = self.api.post_submit_leverage(
|
||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
||||
leverage=str(self.cfg.LEVERAGE),
|
||||
open_type=self.cfg.OPEN_TYPE
|
||||
)[0]
|
||||
if resp.get("code") == 1000:
|
||||
logger.success(f"杠杆设置成功: {self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"杠杆设置失败: {resp}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"设置杠杆异常: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 浏览器自动化
|
||||
# ------------------------------------------------------------------
|
||||
def open_browser(self) -> bool:
|
||||
"""打开浏览器并进入交易页面"""
|
||||
try:
|
||||
bit_port = openBrowser(id=self.cfg.BIT_ID)
|
||||
co = ChromiumOptions()
|
||||
co.set_local_port(port=bit_port)
|
||||
self.page = ChromiumPage(addr_or_opts=co)
|
||||
self.last_page_open_time = time.time()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"打开浏览器失败: {e}")
|
||||
return False
|
||||
|
||||
def click_safe(self, xpath, sleep=0.5) -> bool:
|
||||
"""安全点击元素"""
|
||||
try:
|
||||
ele = self.page.ele(xpath)
|
||||
if not ele:
|
||||
return False
|
||||
ele.click(by_js=True)
|
||||
time.sleep(sleep)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"点击失败 [{xpath}]: {e}")
|
||||
return False
|
||||
|
||||
def browser_close_position(self) -> bool:
|
||||
"""浏览器点击市价全平"""
|
||||
logger.info("浏览器操作: 市价平仓")
|
||||
return self.click_safe('x://span[normalize-space(text()) ="市价"]')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 仓位操作
|
||||
# ------------------------------------------------------------------
|
||||
def calc_order_usdt(self, is_add: bool = False) -> float:
|
||||
"""
|
||||
计算开仓/加仓金额(U)
|
||||
首次开仓: 余额 × MARGIN_PCT (1%)
|
||||
加仓: 余额 × (MARGIN_PCT + PYRAMID_STEP × (pyramid_count+1))
|
||||
例: 开仓1%, 第1次加仓2%, 第2次加仓3%, 第3次加仓4%
|
||||
"""
|
||||
balance = self.get_balance()
|
||||
if balance is None or balance <= 0:
|
||||
logger.warning(f"余额不足或查询失败: {balance}")
|
||||
return 0
|
||||
if is_add:
|
||||
pct = self.cfg.MARGIN_PCT + self.cfg.PYRAMID_STEP * (self.pyramid_count + 1)
|
||||
else:
|
||||
pct = self.cfg.MARGIN_PCT
|
||||
order_usdt = round(balance * pct, 2)
|
||||
logger.info(f"仓位计算: 余额={balance:.2f} × {pct:.0%} = {order_usdt} U"
|
||||
f" ({'加仓#' + str(self.pyramid_count+1) if is_add else '首次开仓'})")
|
||||
return order_usdt
|
||||
|
||||
def verify_position(self, expected: int) -> bool:
|
||||
"""验证持仓方向"""
|
||||
if self.get_position_status():
|
||||
if self.position == expected:
|
||||
return True
|
||||
logger.warning(f"持仓方向不符: 期望{expected}, 实际{self.position}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 风控
|
||||
# ------------------------------------------------------------------
|
||||
def check_daily_reset(self):
|
||||
"""每日重置(UTC+8 00:00 = UTC 16:00)"""
|
||||
now = datetime.now(timezone.utc)
|
||||
# 用UTC日期做简单日切
|
||||
today = now.date()
|
||||
if self.current_date != today:
|
||||
if self.current_date is not None:
|
||||
logger.info(f"日切: {self.current_date} → {today}, 日PnL={self.daily_pnl:.2f}")
|
||||
self.current_date = today
|
||||
self.daily_pnl = 0.0
|
||||
self.daily_stopped = False
|
||||
|
||||
def can_trade(self) -> bool:
|
||||
"""检查是否可交易"""
|
||||
if self.daily_stopped:
|
||||
return False
|
||||
now = time.time()
|
||||
if now - self.last_trade_time < self.cfg.COOLDOWN_SECONDS:
|
||||
remain = self.cfg.COOLDOWN_SECONDS - (now - self.last_trade_time)
|
||||
logger.debug(f"交易冷却中,剩余 {remain:.0f}s")
|
||||
return False
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 日志
|
||||
# ------------------------------------------------------------------
|
||||
def write_trade_log(self, action: str, price: float, bb_upper: float,
|
||||
bb_mid: float, bb_lower: float, reason: str):
|
||||
"""写入交易日志文件"""
|
||||
try:
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
log_file = self.log_dir / f"bb_delay_reversal_trade_log_{date_str}.txt"
|
||||
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
block = (
|
||||
f"\n{'='*60}\n"
|
||||
f"时间: {time_str}\n"
|
||||
f"操作: {action}\n"
|
||||
f"价格: {price:.2f}\n"
|
||||
f"BB上轨: {bb_upper:.2f} | 中轨: {bb_mid:.2f} | 下轨: {bb_lower:.2f}\n"
|
||||
f"原因: {reason}\n"
|
||||
f"{'='*60}\n"
|
||||
)
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(block)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入日志失败: {e}")
|
||||
|
||||
def login(self):
|
||||
self.page.ele('x://input[@placeholder="邮箱"]').input("ddrwode@gmail.com")
|
||||
self.page.ele('x://input[@placeholder="密码"]').input("040828cjj")
|
||||
self.page.ele('x://*[@id="__layout"]/div/div[2]/div/div[2]/div/div/div[2]/div[1]/div[2]/div/div[1]/div[2]/form/div[3]/div/button').click()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主循环(浏览器流程与四分之一代码一致)
|
||||
# ------------------------------------------------------------------
|
||||
def run(self):
|
||||
"""策略主循环"""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f" BB策略启动(D方案): BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
|
||||
logger.info(f" 合约: {self.cfg.CONTRACT_SYMBOL} | {self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
|
||||
logger.info(f" 首次开仓: 权益×{self.cfg.MARGIN_PCT:.0%} | 递增加仓: +{self.cfg.PYRAMID_STEP:.0%}/次 | 最多{self.cfg.PYRAMID_MAX}次")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 设置杠杆
|
||||
if not self.set_leverage():
|
||||
logger.error("杠杆设置失败,退出")
|
||||
return
|
||||
|
||||
# 初始持仓同步
|
||||
if not self.get_position_status():
|
||||
logger.error("初始持仓查询失败,退出")
|
||||
return
|
||||
logger.info(f"初始持仓状态: {self.position}")
|
||||
|
||||
last_kline_id = None # 避免同一根K线重复触发
|
||||
page_start = True # 需要打开浏览器
|
||||
|
||||
while True:
|
||||
|
||||
# ===== 浏览器管理 =====
|
||||
# page_start时: 打开浏览器 → 导航 → 点市价 → 输入张数
|
||||
if page_start:
|
||||
for i in range(5):
|
||||
if self.open_browser():
|
||||
logger.info("浏览器打开成功")
|
||||
break
|
||||
else:
|
||||
logger.error("打开浏览器失败!")
|
||||
return
|
||||
|
||||
# self.login()
|
||||
|
||||
self.page.get(self.cfg.TRADE_URL)
|
||||
time.sleep(2)
|
||||
# 点击市价模式
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
time.sleep(0.5)
|
||||
|
||||
# 计算并预输入开仓金额(U)
|
||||
current_price = self.get_current_price()
|
||||
if current_price:
|
||||
order_usdt = self.calc_order_usdt()
|
||||
if order_usdt > 0:
|
||||
self.page.ele('x://*[@id="size_0"]').input(vals=order_usdt, clear=True)
|
||||
logger.info(f"预输入开仓金额: {order_usdt} U")
|
||||
|
||||
page_start = False
|
||||
|
||||
try:
|
||||
# 每30分钟关闭浏览器重新打开
|
||||
if time.time() - self.last_page_open_time >= self.PAGE_REFRESH_INTERVAL:
|
||||
logger.info("浏览器已打开超过30分钟,关闭刷新")
|
||||
try:
|
||||
self.page.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.page = None
|
||||
page_start = True
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
self.check_daily_reset()
|
||||
|
||||
if self.daily_stopped:
|
||||
logger.info(f"日亏损已达限制({self.daily_pnl:.2f}),等待日切")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# 1. 获取K线
|
||||
klines = self.get_klines()
|
||||
if not klines or len(klines) < self.cfg.BB_PERIOD:
|
||||
logger.warning(f"K线数据不足({len(klines) if klines else 0}根),等待...")
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
closed_klines = klines[:-1]
|
||||
current_kline = klines[-1]
|
||||
|
||||
if len(closed_klines) < self.cfg.BB_PERIOD:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# 2. 计算布林带
|
||||
closes = [k["close"] for k in closed_klines]
|
||||
bb = calc_bollinger(closes, self.cfg.BB_PERIOD, self.cfg.BB_STD)
|
||||
if bb is None:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
bb_mid, bb_upper, bb_lower = bb
|
||||
|
||||
# 3. 获取当前价格
|
||||
current_price = self.get_current_price()
|
||||
if current_price is None:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
cur_high = current_kline["high"]
|
||||
cur_low = current_kline["low"]
|
||||
# 容错: K线high/low + 当前实时价格,任一触及即算触碰
|
||||
touched_upper = cur_high >= bb_upper or current_price >= bb_upper
|
||||
touched_lower = cur_low <= bb_lower or current_price <= bb_lower
|
||||
|
||||
logger.info(
|
||||
f"价格={current_price:.2f} | "
|
||||
f"BB: {bb_lower:.2f} / {bb_mid:.2f} / {bb_upper:.2f} | "
|
||||
f"H={cur_high:.2f} L={cur_low:.2f} | "
|
||||
f"触上={touched_upper} 触下={touched_lower} | "
|
||||
f"仓位={self.position}"
|
||||
)
|
||||
|
||||
# 4. 同步持仓状态
|
||||
if not self.get_position_status():
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# 5. 信号判断
|
||||
kline_id = current_kline["id"]
|
||||
if kline_id == last_kline_id:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if touched_upper and touched_lower:
|
||||
logger.warning("同时触及上下轨,跳过")
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
action = None
|
||||
reason = ""
|
||||
success = False
|
||||
|
||||
# ===== 触及上轨 → 开空 / 翻转为空 / 加仓空 =====
|
||||
if touched_upper:
|
||||
if not self.can_trade():
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
reason = (f"价格最高{cur_high:.2f}触及上轨{bb_upper:.2f},"
|
||||
f"BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
|
||||
|
||||
if self.position == 1:
|
||||
action = "翻转: 平多→开空"
|
||||
# 在当前页面点市价平仓
|
||||
self.browser_close_position()
|
||||
time.sleep(1)
|
||||
# 等待确认平仓
|
||||
for _ in range(10):
|
||||
if self.get_position_status() and self.position == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
if self.position != 0:
|
||||
logger.warning(f"平仓后仍有持仓({self.position}),放弃开空")
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
# 翻转时重置加仓计数
|
||||
self.pyramid_count = 0
|
||||
# 平仓后在同一页面直接点卖出/做空
|
||||
logger.info("平仓完成,直接开空")
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
time.sleep(3)
|
||||
if self.verify_position(-1):
|
||||
success = True
|
||||
elif self.position == 0:
|
||||
action = "开空"
|
||||
self.pyramid_count = 0
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
time.sleep(3)
|
||||
if self.verify_position(-1):
|
||||
success = True
|
||||
elif self.position == -1 and self.pyramid_count < self.cfg.PYRAMID_MAX:
|
||||
# 已持空仓 + 再次触上轨 → 加仓做空
|
||||
action = f"加仓空#{self.pyramid_count+1}"
|
||||
reason += f" (加仓#{self.pyramid_count+1}/{self.cfg.PYRAMID_MAX})"
|
||||
# 重新计算加仓金额并输入
|
||||
add_usdt = self.calc_order_usdt(is_add=True)
|
||||
if add_usdt > 0:
|
||||
self.page.ele('x://*[@id="size_0"]').input(vals=add_usdt, clear=True)
|
||||
time.sleep(0.5)
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
time.sleep(3)
|
||||
if self.verify_position(-1):
|
||||
self.pyramid_count += 1
|
||||
success = True
|
||||
else:
|
||||
logger.info(f"已持空仓,加仓已达上限({self.pyramid_count}/{self.cfg.PYRAMID_MAX})")
|
||||
|
||||
# ===== 触及下轨 → 开多 / 翻转为多 / 加仓多 =====
|
||||
elif touched_lower:
|
||||
if not self.can_trade():
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
reason = (f"价格最低{cur_low:.2f}触及下轨{bb_lower:.2f},"
|
||||
f"BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
|
||||
|
||||
if self.position == -1:
|
||||
action = "翻转: 平空→开多"
|
||||
self.browser_close_position()
|
||||
time.sleep(1)
|
||||
for _ in range(10):
|
||||
if self.get_position_status() and self.position == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
if self.position != 0:
|
||||
logger.warning(f"平仓后仍有持仓({self.position}),放弃开多")
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
# 翻转时重置加仓计数
|
||||
self.pyramid_count = 0
|
||||
logger.info("平仓完成,直接开多")
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
time.sleep(3)
|
||||
if self.verify_position(1):
|
||||
success = True
|
||||
elif self.position == 0:
|
||||
action = "开多"
|
||||
self.pyramid_count = 0
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
time.sleep(3)
|
||||
if self.verify_position(1):
|
||||
success = True
|
||||
elif self.position == 1 and self.pyramid_count < self.cfg.PYRAMID_MAX:
|
||||
# 已持多仓 + 再次触下轨 → 加仓做多
|
||||
action = f"加仓多#{self.pyramid_count+1}"
|
||||
reason += f" (加仓#{self.pyramid_count+1}/{self.cfg.PYRAMID_MAX})"
|
||||
# 重新计算加仓金额并输入
|
||||
add_usdt = self.calc_order_usdt(is_add=True)
|
||||
if add_usdt > 0:
|
||||
self.page.ele('x://*[@id="size_0"]').input(vals=add_usdt, clear=True)
|
||||
time.sleep(0.5)
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
time.sleep(3)
|
||||
if self.verify_position(1):
|
||||
self.pyramid_count += 1
|
||||
success = True
|
||||
else:
|
||||
logger.info(f"已持多仓,加仓已达上限({self.pyramid_count}/{self.cfg.PYRAMID_MAX})")
|
||||
|
||||
# ===== 交易成功后处理 =====
|
||||
if success and action:
|
||||
last_kline_id = kline_id
|
||||
self.last_trade_time = time.time()
|
||||
self.write_trade_log(action, current_price,
|
||||
bb_upper, bb_mid, bb_lower, reason)
|
||||
logger.success(f"{action} 执行成功")
|
||||
# 交易完成后关闭浏览器,下轮重新打开
|
||||
page_start = True
|
||||
try:
|
||||
self.page.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.page = None
|
||||
time.sleep(5)
|
||||
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("用户中断,程序退出")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"主循环异常: {e}")
|
||||
page_start = True
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
trader = BBDelayReversalTrader()
|
||||
trader.run()
|
||||
189789
bb_sweep_results.csv
Normal file
189789
bb_sweep_results.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -339,6 +339,11 @@ class BBTrader:
|
||||
except Exception as e:
|
||||
logger.warning(f"写入日志失败: {e}")
|
||||
|
||||
def login(self):
|
||||
self.page.ele('x://input[@placeholder="邮箱"]').input("ddrwode@gmail.com")
|
||||
self.page.ele('x://input[@placeholder="密码"]').input("040828cjj")
|
||||
self.page.ele('x://*[@id="__layout"]/div/div[2]/div/div[2]/div/div/div[2]/div[1]/div[2]/div/div[1]/div[2]/form/div[3]/div/button').click()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主循环(浏览器流程与四分之一代码一致)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -377,6 +382,8 @@ class BBTrader:
|
||||
logger.error("打开浏览器失败!")
|
||||
return
|
||||
|
||||
# self.login()
|
||||
|
||||
self.page.get(self.cfg.TRADE_URL)
|
||||
time.sleep(2)
|
||||
# 点击市价模式
|
||||
|
||||
268
bitmart/框架.py
Normal file
268
bitmart/框架.py
Normal file
@@ -0,0 +1,268 @@
|
||||
import time
|
||||
|
||||
from tqdm import tqdm
|
||||
from loguru import logger
|
||||
from bit_tools import openBrowser
|
||||
from DrissionPage import ChromiumPage
|
||||
from DrissionPage import ChromiumOptions
|
||||
|
||||
from bitmart.api_contract import APIContract
|
||||
|
||||
|
||||
class BitmartFuturesTransaction:
|
||||
def __init__(self, bit_id):
|
||||
|
||||
self.page: ChromiumPage | None = None
|
||||
|
||||
self.api_key = "a0fb7b98464fd9bcce67e7c519d58ec10d0c38a8"
|
||||
self.secret_key = "4eaeba78e77aeaab1c2027f846a276d164f264a44c2c1bb1c5f3be50c8de1ca5"
|
||||
self.memo = "合约交易"
|
||||
|
||||
self.contract_symbol = "ETHUSDT"
|
||||
|
||||
self.contractAPI = APIContract(self.api_key, self.secret_key, self.memo, timeout=(5, 15))
|
||||
|
||||
self.start = 0 # 持仓状态: -1 空, 0 无, 1 多
|
||||
self.direction = None
|
||||
|
||||
self.pbar = tqdm(total=30, desc="等待K线", ncols=80)
|
||||
|
||||
self.last_kline_time = None
|
||||
|
||||
self.leverage = "100" # 高杠杆(全仓模式下可开更大仓位)
|
||||
self.open_type = "cross" # 全仓模式(你的“成本开仓”需求)
|
||||
self.risk_percent = 0.01 # 每次开仓使用可用余额的 1%
|
||||
|
||||
self.open_avg_price = None # 开仓价格
|
||||
self.current_amount = None # 持仓量
|
||||
|
||||
self.bit_id = bit_id
|
||||
|
||||
def get_klines(self):
|
||||
"""获取最近3根30分钟K线(step=30)"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
# 获取足够多的条目确保有最新3根
|
||||
response = self.contractAPI.get_kline(
|
||||
contract_symbol=self.contract_symbol,
|
||||
step=30, # 30分钟
|
||||
start_time=end_time - 3600 * 10, # 取最近10小时
|
||||
end_time=end_time
|
||||
)[0]["data"]
|
||||
|
||||
# 每根: [timestamp, open, high, low, close, volume]
|
||||
formatted = []
|
||||
for k in response:
|
||||
formatted.append({
|
||||
'id': int(k["timestamp"]),
|
||||
'open': float(k["open_price"]),
|
||||
'high': float(k["high_price"]),
|
||||
'low': float(k["low_price"]),
|
||||
'close': float(k["close_price"])
|
||||
})
|
||||
formatted.sort(key=lambda x: x['id'])
|
||||
return formatted # 最近3根: kline_1 (最老), kline_2, kline_3 (最新)
|
||||
except Exception as e:
|
||||
logger.error(f"获取K线异常: {e}")
|
||||
self.ding(error=True, msg="获取K线异常")
|
||||
return None
|
||||
|
||||
def get_current_price(self):
|
||||
"""获取当前最新价格,用于计算张数"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
response = self.contractAPI.get_kline(
|
||||
contract_symbol=self.contract_symbol,
|
||||
step=1, # 1分钟
|
||||
start_time=end_time - 3600 * 3, # 取最近10小时
|
||||
end_time=end_time
|
||||
)[0]
|
||||
if response['code'] == 1000:
|
||||
return float(response['data'][-1]["close_price"])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取价格异常: {e}")
|
||||
return None
|
||||
|
||||
def get_available_balance(self):
|
||||
"""获取合约账户可用USDT余额"""
|
||||
try:
|
||||
response = self.contractAPI.get_assets_detail()[0]
|
||||
if response['code'] == 1000:
|
||||
data = response['data']
|
||||
if isinstance(data, dict):
|
||||
return float(data.get('available_balance', 0))
|
||||
elif isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get('currency') == 'USDT':
|
||||
return float(asset.get('available_balance', 0))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"余额查询异常: {e}")
|
||||
return None
|
||||
|
||||
# 获取当前持仓方向
|
||||
def get_position_status(self):
|
||||
"""获取当前持仓方向"""
|
||||
try:
|
||||
response = self.contractAPI.get_position(contract_symbol=self.contract_symbol)[0]
|
||||
if response['code'] == 1000:
|
||||
positions = response['data']
|
||||
if not positions:
|
||||
self.start = 0
|
||||
return True
|
||||
self.start = 1 if positions[0]['position_type'] == 1 else -1
|
||||
self.open_avg_price = positions[0]['open_avg_price']
|
||||
self.current_amount = positions[0]['current_amount']
|
||||
self.position_cross = positions[0]["position_cross"]
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"持仓查询异常: {e}")
|
||||
return False
|
||||
|
||||
# 设置杠杆和全仓
|
||||
def set_leverage(self):
|
||||
"""程序启动时设置全仓 + 高杠杆"""
|
||||
try:
|
||||
response = self.contractAPI.post_submit_leverage(
|
||||
contract_symbol=self.contract_symbol,
|
||||
leverage=self.leverage,
|
||||
open_type=self.open_type
|
||||
)[0]
|
||||
if response['code'] == 1000:
|
||||
logger.success(f"全仓模式 + {self.leverage}x 杠杆设置成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"杠杆设置失败: {response}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"设置杠杆异常: {e}")
|
||||
return False
|
||||
|
||||
def openBrowser(self):
|
||||
"""打开 TGE 对应浏览器实例"""
|
||||
try:
|
||||
bit_port = openBrowser(id=self.bit_id)
|
||||
co = ChromiumOptions()
|
||||
co.set_local_port(port=bit_port)
|
||||
self.page = ChromiumPage(addr_or_opts=co)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def take_over_browser(self):
|
||||
"""接管浏览器"""
|
||||
try:
|
||||
co = ChromiumOptions()
|
||||
co.set_local_port(self.tge_port)
|
||||
self.page = ChromiumPage(addr_or_opts=co)
|
||||
self.page.set.window.max()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def close_extra_tabs(self):
|
||||
"""关闭多余 tab"""
|
||||
try:
|
||||
for idx, tab in enumerate(self.page.get_tabs()):
|
||||
if idx > 0:
|
||||
tab.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def click_safe(self, xpath, sleep=0.5):
|
||||
"""安全点击"""
|
||||
try:
|
||||
ele = self.page.ele(xpath)
|
||||
if not ele:
|
||||
return False
|
||||
ele.scroll.to_see(center=True)
|
||||
time.sleep(sleep)
|
||||
ele.click()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def 全平仓(self):
|
||||
self.click_safe('x://span[normalize-space(text()) ="市价"]')
|
||||
|
||||
def 平一半多仓(self):
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/平多"]')
|
||||
|
||||
def 平一半空仓(self):
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/平空"]')
|
||||
|
||||
def 开单(self, marketPriceLongOrder=0, limitPriceShortOrder=0, size=None, price=None):
|
||||
"""
|
||||
marketPriceLongOrder 市价最多或者做空,1是做多,-1是做空
|
||||
limitPriceShortOrder 限价最多或者做空
|
||||
"""
|
||||
|
||||
if marketPriceLongOrder == -1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
self.page.ele('x://*[@id="size_0"]').input(size)
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
elif marketPriceLongOrder == 1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
self.page.ele('x://*[@id="size_0"]').input(size)
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
|
||||
if limitPriceShortOrder == -1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="限价"]')
|
||||
self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True)
|
||||
time.sleep(1)
|
||||
self.page.ele('x://*[@id="size_0"]').input(1)
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
elif limitPriceShortOrder == 1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="限价"]')
|
||||
self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True)
|
||||
time.sleep(1)
|
||||
self.page.ele('x://*[@id="size_0"]').input(1)
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
|
||||
def ding(self, text, error=False):
|
||||
logger.info(text)
|
||||
|
||||
def action(self):
|
||||
# 启动时设置全仓高杠杆
|
||||
if not self.set_leverage():
|
||||
logger.error("杠杆设置失败,程序继续运行但可能下单失败")
|
||||
return
|
||||
|
||||
# 1. 打开浏览器
|
||||
if not self.openBrowser():
|
||||
self.ding("打开 TGE 失败!", error=True)
|
||||
return
|
||||
logger.info("TGE 端口获取成功")
|
||||
|
||||
self.get_klines()
|
||||
|
||||
# self.close_extra_tabs()
|
||||
# self.page.get("https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT")
|
||||
#
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
# self.click_safe('x://button[normalize-space(text()) ="限价"]')
|
||||
#
|
||||
# self.page.ele('x://*[@id="price_0"]').input(vals=3000, clear=True)
|
||||
# self.page.ele('x://*[@id="size_0"]').input(1)
|
||||
# self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
# self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
|
||||
self.click_safe('x://button[normalize-space(text()) ="开仓"]')
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/平空"]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/平多"]')
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
BitmartFuturesTransaction(bit_id="f2320f57e24c45529a009e1541e25961").action()
|
||||
527
generate_backtest_chart.py
Normal file
527
generate_backtest_chart.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
生成回测可视化图表
|
||||
显示K线、布林带、开仓/平仓位置
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import json
|
||||
from pathlib import Path
|
||||
from peewee import *
|
||||
import time
|
||||
from loguru import logger
|
||||
|
||||
# 数据库配置
|
||||
DB_PATH = Path(__file__).parent / 'models' / 'database.db'
|
||||
db = SqliteDatabase(str(DB_PATH))
|
||||
|
||||
class BitMartETH5M(Model):
|
||||
"""5分钟K线模型"""
|
||||
id = BigIntegerField(primary_key=True)
|
||||
open = FloatField(null=True)
|
||||
high = FloatField(null=True)
|
||||
low = FloatField(null=True)
|
||||
close = FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = 'bitmart_eth_5m'
|
||||
|
||||
|
||||
def calculate_bollinger_bands(df, period=10, std_dev=2.5):
|
||||
"""计算布林带(右移1根,与回测口径一致)"""
|
||||
df['sma'] = df['close'].rolling(window=period).mean()
|
||||
df['std'] = df['close'].rolling(window=period).std()
|
||||
df['bb_upper'] = (df['sma'] + std_dev * df['std']).shift(1)
|
||||
df['bb_mid'] = df['sma'].shift(1)
|
||||
df['bb_lower'] = (df['sma'] - std_dev * df['std']).shift(1)
|
||||
return df
|
||||
|
||||
|
||||
def generate_chart_data(start_date, end_date, trades_file):
|
||||
"""生成图表数据"""
|
||||
|
||||
# 1. 加载K线数据
|
||||
start_ts = int(pd.Timestamp(start_date).timestamp() * 1000)
|
||||
end_ts = int(pd.Timestamp(end_date).timestamp() * 1000) + 86400000 # 加一天确保包含end_date当天的数据
|
||||
|
||||
query = BitMartETH5M.select().where(
|
||||
(BitMartETH5M.id >= start_ts) & (BitMartETH5M.id <= end_ts)
|
||||
).order_by(BitMartETH5M.id)
|
||||
|
||||
data = []
|
||||
for row in query:
|
||||
data.append({
|
||||
'timestamp': row.id,
|
||||
'open': row.open,
|
||||
'high': row.high,
|
||||
'low': row.low,
|
||||
'close': row.close
|
||||
})
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||||
|
||||
# 2. 计算布林带
|
||||
df = calculate_bollinger_bands(df)
|
||||
|
||||
# 3. 加载交易记录
|
||||
trades_df = pd.read_csv(trades_file)
|
||||
trades_df['datetime'] = pd.to_datetime(trades_df['timestamp'])
|
||||
|
||||
# 4. 准备图表数据
|
||||
chart_data = []
|
||||
for idx, row in df.iterrows():
|
||||
chart_data.append({
|
||||
'timestamp': int(row['timestamp']),
|
||||
'datetime': row['datetime'].strftime('%Y-%m-%d %H:%M'),
|
||||
'open': float(row['open']) if pd.notna(row['open']) else None,
|
||||
'high': float(row['high']) if pd.notna(row['high']) else None,
|
||||
'low': float(row['low']) if pd.notna(row['low']) else None,
|
||||
'close': float(row['close']) if pd.notna(row['close']) else None,
|
||||
'bb_upper': float(row['bb_upper']) if pd.notna(row['bb_upper']) else None,
|
||||
'bb_mid': float(row['bb_mid']) if pd.notna(row['bb_mid']) else None,
|
||||
'bb_lower': float(row['bb_lower']) if pd.notna(row['bb_lower']) else None,
|
||||
})
|
||||
|
||||
# 5. 准备交易标记数据
|
||||
trades_markers = []
|
||||
for idx, trade in trades_df.iterrows():
|
||||
action = trade['action']
|
||||
price = trade['price']
|
||||
timestamp = trade['datetime']
|
||||
reason = trade['reason']
|
||||
|
||||
# 找到对应的K线索引(使用最近的K线)
|
||||
kline_idx = df[df['datetime'] == timestamp].index
|
||||
if len(kline_idx) == 0:
|
||||
# 如果找不到完全匹配的,找最近的K线
|
||||
time_diff = abs(df['datetime'] - timestamp)
|
||||
min_diff = time_diff.min()
|
||||
# 如果时间差超过10分钟,跳过这个标记
|
||||
if min_diff > pd.Timedelta(minutes=10):
|
||||
print(f"跳过标记 {timestamp},找不到匹配的K线(最小时间差: {min_diff})")
|
||||
continue
|
||||
kline_idx = time_diff.idxmin()
|
||||
else:
|
||||
kline_idx = kline_idx[0]
|
||||
|
||||
# 图上显示真实成交价,避免“总在最高/最低点成交”的错觉
|
||||
kline = df.loc[kline_idx]
|
||||
if pd.notna(price):
|
||||
display_price = float(price)
|
||||
else:
|
||||
display_price = float(kline['close'])
|
||||
|
||||
marker = {
|
||||
'timestamp': timestamp.strftime('%Y-%m-%d %H:%M'),
|
||||
'price': display_price, # 使用K线实际价格
|
||||
'action': action,
|
||||
'reason': reason,
|
||||
'index': int(kline_idx)
|
||||
}
|
||||
|
||||
# 关键操作直接显示在图上,便于快速理解“为什么做这笔交易”
|
||||
# 新增:开仓/加仓也展示标签
|
||||
marker['show_reason_label'] = (
|
||||
('延迟反转' in reason)
|
||||
or ('止损' in reason)
|
||||
or ('开long' in action)
|
||||
or ('开short' in action)
|
||||
or ('加long' in action)
|
||||
or ('加short' in action)
|
||||
)
|
||||
if '止损' in reason:
|
||||
marker['short_reason'] = '止损'
|
||||
elif '延迟反转' in reason:
|
||||
marker['short_reason'] = '延迟反转'
|
||||
elif '触中轨平50%' in reason:
|
||||
marker['short_reason'] = '中轨平半'
|
||||
elif '回开仓价全平' in reason:
|
||||
marker['short_reason'] = '回本全平'
|
||||
elif '触上轨开空' in reason:
|
||||
marker['short_reason'] = '上轨开空'
|
||||
elif '触下轨开多' in reason:
|
||||
marker['short_reason'] = '下轨开多'
|
||||
elif '触上轨加空' in reason:
|
||||
marker['short_reason'] = '上轨加空'
|
||||
elif '触下轨加多' in reason:
|
||||
marker['short_reason'] = '下轨加多'
|
||||
else:
|
||||
marker['short_reason'] = reason
|
||||
|
||||
# 分类标记
|
||||
if '开long' in action or '加long' in action:
|
||||
marker['type'] = 'open_long'
|
||||
marker['color'] = '#00ff00'
|
||||
marker['symbol'] = 'triangle'
|
||||
elif '开short' in action or '加short' in action:
|
||||
marker['type'] = 'open_short'
|
||||
marker['color'] = '#ff0000'
|
||||
marker['symbol'] = 'triangle'
|
||||
elif '平仓' in action:
|
||||
if '50%' in action:
|
||||
marker['type'] = 'close_half'
|
||||
marker['color'] = '#ffff00'
|
||||
marker['symbol'] = 'diamond'
|
||||
else:
|
||||
marker['type'] = 'close_all'
|
||||
marker['color'] = '#ff00ff'
|
||||
marker['symbol'] = 'circle'
|
||||
else:
|
||||
marker['type'] = 'other'
|
||||
marker['color'] = '#ffffff'
|
||||
marker['symbol'] = 'circle'
|
||||
|
||||
trades_markers.append(marker)
|
||||
|
||||
return chart_data, trades_markers
|
||||
|
||||
|
||||
def generate_html(chart_data, trades_markers, output_file):
|
||||
"""生成HTML文件"""
|
||||
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>布林带策略回测可视化 - 2026年3月</title>
|
||||
<style>
|
||||
html,
|
||||
body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0e27;
|
||||
color: #e0e6ed;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}}
|
||||
#chart {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}}
|
||||
.legend {{
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1000;
|
||||
}}
|
||||
.legend-title {{
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #f1f5f9;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.5px;
|
||||
}}
|
||||
.legend-item {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}}
|
||||
.legend-marker {{
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 10px;
|
||||
border-radius: 2px;
|
||||
}}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart"></div>
|
||||
<div class="legend">
|
||||
<div class="legend-title">📊 交易标记说明</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-marker" style="background: #00ff00;">▲</div>
|
||||
<span>开多/加多</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-marker" style="background: #ff0000;">▼</div>
|
||||
<span>开空/加空</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-marker" style="background: #ffff00;">◆</div>
|
||||
<span>平仓50%</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-marker" style="background: #ff00ff;">●</div>
|
||||
<span>平仓100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const chartData = {json.dumps(chart_data, ensure_ascii=False)};
|
||||
const tradesMarkers = {json.dumps(trades_markers, ensure_ascii=False)};
|
||||
|
||||
function main() {{
|
||||
const categoryData = [];
|
||||
const klineData = [];
|
||||
const upper = [];
|
||||
const mid = [];
|
||||
const lower = [];
|
||||
|
||||
for (const k of chartData) {{
|
||||
const d = new Date(k.timestamp);
|
||||
const label = `${{d.getMonth()+1}}/${{d.getDate()}} ${{d.getHours().toString().padStart(2, "0")}}:${{d
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, "0")}}`;
|
||||
categoryData.push(label);
|
||||
klineData.push([k.open, k.close, k.low, k.high]);
|
||||
upper.push(k.bb_upper);
|
||||
mid.push(k.bb_mid);
|
||||
lower.push(k.bb_lower);
|
||||
}}
|
||||
|
||||
// 准备交易标记数据
|
||||
const openLongData = [];
|
||||
const openShortData = [];
|
||||
const closeHalfData = [];
|
||||
const closeAllData = [];
|
||||
|
||||
for (const marker of tradesMarkers) {{
|
||||
const point = {{
|
||||
value: [marker.index, marker.price],
|
||||
reason: marker.reason,
|
||||
action: marker.action,
|
||||
shortReason: marker.short_reason,
|
||||
label: marker.show_reason_label
|
||||
? {{
|
||||
show: true,
|
||||
formatter: marker.short_reason,
|
||||
color: "#f8fafc",
|
||||
backgroundColor: "rgba(15,23,42,0.85)",
|
||||
borderColor: "#475569",
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
padding: [2, 4],
|
||||
fontSize: 10,
|
||||
}}
|
||||
: {{ show: false }},
|
||||
itemStyle: {{ color: marker.color }},
|
||||
}};
|
||||
|
||||
if (marker.type === 'open_long') {{
|
||||
openLongData.push(point);
|
||||
}} else if (marker.type === 'open_short') {{
|
||||
openShortData.push(point);
|
||||
}} else if (marker.type === 'close_half') {{
|
||||
closeHalfData.push(point);
|
||||
}} else if (marker.type === 'close_all') {{
|
||||
closeAllData.push(point);
|
||||
}}
|
||||
}}
|
||||
|
||||
const chartDom = document.getElementById("chart");
|
||||
const chart = echarts.init(chartDom, null, {{ renderer: "canvas" }});
|
||||
|
||||
const option = {{
|
||||
backgroundColor: "#0a0e27",
|
||||
tooltip: {{
|
||||
trigger: "axis",
|
||||
axisPointer: {{ type: "cross" }},
|
||||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||
borderColor: "#334155",
|
||||
textStyle: {{ color: "#e0e6ed" }},
|
||||
}},
|
||||
axisPointer: {{
|
||||
link: [{{ xAxisIndex: "all" }}],
|
||||
}},
|
||||
grid: {{
|
||||
left: "3%",
|
||||
right: "200px",
|
||||
top: "6%",
|
||||
bottom: "8%",
|
||||
containLabel: true,
|
||||
}},
|
||||
xAxis: {{
|
||||
type: "category",
|
||||
data: categoryData,
|
||||
scale: true,
|
||||
boundaryGap: true,
|
||||
axisLine: {{ lineStyle: {{ color: "#475569" }} }},
|
||||
axisLabel: {{
|
||||
color: "#94a3b8",
|
||||
rotate: 45,
|
||||
fontSize: 11
|
||||
}},
|
||||
}},
|
||||
yAxis: {{
|
||||
scale: true,
|
||||
axisLine: {{ lineStyle: {{ color: "#475569" }} }},
|
||||
splitLine: {{ lineStyle: {{ color: "#1e293b" }} }},
|
||||
axisLabel: {{ color: "#94a3b8" }},
|
||||
}},
|
||||
dataZoom: [
|
||||
{{
|
||||
type: "inside",
|
||||
start: 0,
|
||||
end: 100,
|
||||
}},
|
||||
{{
|
||||
type: "slider",
|
||||
start: 0,
|
||||
end: 100,
|
||||
height: 30,
|
||||
backgroundColor: "#1e293b",
|
||||
fillerColor: "rgba(100, 116, 139, 0.3)",
|
||||
borderColor: "#334155",
|
||||
handleStyle: {{
|
||||
color: "#64748b",
|
||||
borderColor: "#94a3b8"
|
||||
}},
|
||||
textStyle: {{ color: "#94a3b8" }},
|
||||
}},
|
||||
],
|
||||
series: [
|
||||
{{
|
||||
name: "K线",
|
||||
type: "candlestick",
|
||||
data: klineData,
|
||||
itemStyle: {{
|
||||
color: "#10b981",
|
||||
color0: "#ef4444",
|
||||
borderColor: "#10b981",
|
||||
borderColor0: "#ef4444",
|
||||
}},
|
||||
}},
|
||||
{{
|
||||
name: "BB上轨",
|
||||
type: "line",
|
||||
data: upper,
|
||||
symbol: "none",
|
||||
lineStyle: {{ color: "#f59e0b", width: 2 }},
|
||||
z: 1,
|
||||
}},
|
||||
{{
|
||||
name: "BB中轨",
|
||||
type: "line",
|
||||
data: mid,
|
||||
symbol: "none",
|
||||
lineStyle: {{ color: "#8b5cf6", width: 2, type: "dashed" }},
|
||||
z: 1,
|
||||
}},
|
||||
{{
|
||||
name: "BB下轨",
|
||||
type: "line",
|
||||
data: lower,
|
||||
symbol: "none",
|
||||
lineStyle: {{ color: "#f59e0b", width: 2 }},
|
||||
z: 1,
|
||||
}},
|
||||
{{
|
||||
name: "开多/加多",
|
||||
type: "scatter",
|
||||
symbol: "triangle",
|
||||
symbolSize: 12,
|
||||
symbolOffset: [0, 8], // 向下偏移,使三角形底部对齐价格
|
||||
data: openLongData,
|
||||
itemStyle: {{ color: "#00ff00" }},
|
||||
z: 10,
|
||||
tooltip: {{
|
||||
formatter: (params) => {{
|
||||
const marker = params.data;
|
||||
return `开多/加多<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
name: "开空/加空",
|
||||
type: "scatter",
|
||||
symbol: "triangle",
|
||||
symbolSize: 12,
|
||||
symbolRotate: 180,
|
||||
symbolOffset: [0, -8], // 向上偏移,使三角形底部对齐价格
|
||||
data: openShortData,
|
||||
itemStyle: {{ color: "#ff0000" }},
|
||||
z: 10,
|
||||
tooltip: {{
|
||||
formatter: (params) => {{
|
||||
const marker = params.data;
|
||||
return `开空/加空<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
name: "平仓50%",
|
||||
type: "scatter",
|
||||
symbol: "diamond",
|
||||
symbolSize: 10,
|
||||
data: closeHalfData,
|
||||
itemStyle: {{ color: "#ffff00" }},
|
||||
z: 10,
|
||||
tooltip: {{
|
||||
formatter: (params) => {{
|
||||
const marker = params.data;
|
||||
return `平仓50%<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
name: "平仓100%",
|
||||
type: "scatter",
|
||||
symbol: "circle",
|
||||
symbolSize: 10,
|
||||
data: closeAllData,
|
||||
itemStyle: {{ color: "#ff00ff" }},
|
||||
z: 10,
|
||||
tooltip: {{
|
||||
formatter: (params) => {{
|
||||
const marker = params.data;
|
||||
return `平仓100%<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
],
|
||||
}};
|
||||
|
||||
chart.setOption(option);
|
||||
window.addEventListener("resize", () => chart.resize());
|
||||
}}
|
||||
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
print(f"✅ 图表已生成: {output_file}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect(reuse_if_open=True)
|
||||
|
||||
try:
|
||||
print("正在生成回测可视化图表...")
|
||||
|
||||
output_dir = Path(__file__).parent / 'backtest_outputs' / 'charts'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成图表数据
|
||||
chart_data, trades_markers = generate_chart_data(
|
||||
start_date='2026-03-01',
|
||||
end_date='2026-03-03',
|
||||
trades_file=str(Path(__file__).parent / 'backtest_outputs' / 'trades' / 'bb_backtest_march_2026_trades.csv')
|
||||
)
|
||||
|
||||
print(f"📊 K线数据: {len(chart_data)} 根")
|
||||
print(f"📍 交易标记: {len(trades_markers)} 个")
|
||||
|
||||
# 生成HTML
|
||||
output_file = output_dir / 'bb_backtest_visualization.html'
|
||||
generate_html(chart_data, trades_markers, str(output_file))
|
||||
|
||||
print(f"\n🎉 完成!请在浏览器中打开 {output_file} 查看图表")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
Binary file not shown.
1
token.json
Normal file
1
token.json
Normal file
@@ -0,0 +1 @@
|
||||
{"token": "ya29.a0ATkoCc7EYaXMumpShCwmfiq5pqSbgMB_XkUuWWUFnYRs8fvfPt6xYaU3r8rrdiFWHpQ_07R2FN_ML4X-4Q9eoHp5Dj6R0xgL1A9xFONvaJhlEP_ndce3Uht6LpeIUhfoGw2fHU9ZlvKcVNyCfcGsTqsR8YXWoe49MmU3k-DiHQgIDrxZ0c7Kyfb0FltZ9bUwXJg8ILIaCgYKAYUSARMSFQHGX2MiD7gOuz1kuvvNQABqx39YNw0206", "refresh_token": "1//0gVVN8c-x-T1MCgYIARAAGBASNwF-L9IrNPuDnHu-3vZoJvPChjsyjrUwtlSX7qmY_xnwFUr0FPf-t7Tm53f3vSo4OZOaJWSqA0s", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "823839778551-mgovppr13aoil1r69upj7uiv84ej0sih.apps.googleusercontent.com", "client_secret": "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", "scopes": ["https://www.googleapis.com/auth/gmail.readonly"], "universe_domain": "googleapis.com", "account": "", "expiry": "2026-02-28T19:35:00Z"}
|
||||
Reference in New Issue
Block a user