feat(engine): 添加策略示例集(18 个 Demo)
- backtest_demo.py: 回测基础演示 - strategy_simple.py / three_ema.py / long_short.py: 基础策略(双均线/三均线/多空) - strategy_optimize*.py (3 版本): 参数优化示例(网格搜索/贝叶斯/遗传算法) - multi_tf_*.py (4 版本): 多时间框架策略(EMA200/多周期共振/混合信号) - regime_*.py (4 版本): 市场状态检测(趋势/震荡/波动率区间/全状态) - cross_section.py: 截面多品种策略 - factor_demo.py: 多因子模型演示 - strategy_battle.py / strategy_more.py: 策略对比与组合 - full_cycle.py: 全流程演示(数据→回测→分析) - data.py: 数据读取示例
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
多空双向回测 — EMA 趋势跟踪(支持做空)
|
||||
|
||||
基于表现最好的纯趋势参数,增加做空能力:
|
||||
- 金叉 → 平空仓 + 做多
|
||||
- 死叉 → 平多仓 + 做空
|
||||
- ATR 动态止损(多空双向)
|
||||
- 始终持仓(非多即空)
|
||||
- 输出增加年化收益
|
||||
|
||||
参数(各币种历史最优):
|
||||
BTC(10,50) ETH(10,75) BNB(20,50) SOL(30,50)
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/long_short.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import statistics
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
|
||||
_project_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_project_root) not in sys.path:
|
||||
sys.path.insert(0, str(_project_root))
|
||||
|
||||
from engine.common.base import BaseStrategy, Signal, StrategyConfig
|
||||
from engine.common.models import Kline
|
||||
from engine.common.config import config, DBConfig
|
||||
from engine.data import DataService
|
||||
from engine.indicators import ema, atr
|
||||
from engine.backtest.models import BacktestConfig, BacktestMetrics, BacktestResult, BacktestTrade
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 多空回测引擎
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class LongShortEngine:
|
||||
"""支持多空双向的事件驱动回测引擎"""
|
||||
|
||||
def __init__(self, bt_config: BacktestConfig, db_config=None):
|
||||
self.config = bt_config
|
||||
self._db_config = db_config
|
||||
self._cash: float = bt_config.initial_capital
|
||||
self._position: float = 0.0 # >0 多头, <0 空头, =0 空仓
|
||||
self._avg_entry_price: float = 0.0
|
||||
self._trades: list[BacktestTrade] = []
|
||||
self._equity: list[dict] = []
|
||||
self._peak_equity: float = 0.0
|
||||
self._pending_buy: Optional[Signal] = None
|
||||
self._pending_sell: Optional[Signal] = None
|
||||
|
||||
async def run(self, strategy_cls, strategy_config: StrategyConfig) -> BacktestResult:
|
||||
from engine.common.config import config as app_config
|
||||
strategy_config.symbol = self.config.symbol
|
||||
strategy_config.exchange = self.config.exchange
|
||||
db_cfg = self._db_config or app_config.db
|
||||
ds = DataService(db_cfg)
|
||||
await ds.connect()
|
||||
try:
|
||||
klines = await ds.fetch_klines(
|
||||
symbol=self.config.symbol, interval=self.config.interval,
|
||||
start_time=self.config.start_time, end_time=self.config.end_time,
|
||||
limit=1_000_000,
|
||||
)
|
||||
if len(klines) < self.config.warmup_bars + 2:
|
||||
raise ValueError(f"数据不足:需 {self.config.warmup_bars+2},实际 {len(klines)}")
|
||||
|
||||
strategy = strategy_cls(strategy_config)
|
||||
await strategy.on_start()
|
||||
self._cash = self.config.initial_capital
|
||||
self._position = 0.0
|
||||
self._avg_entry_price = 0.0
|
||||
self._trades = []
|
||||
self._equity = []
|
||||
self._pending_buy = None
|
||||
self._pending_sell = None
|
||||
|
||||
warmup_end = self.config.warmup_bars
|
||||
for i in range(warmup_end):
|
||||
await strategy.on_kline(klines[i])
|
||||
|
||||
for i in range(warmup_end, len(klines)):
|
||||
kline = klines[i]
|
||||
|
||||
# 先执行待执行订单(下一根 bar 开盘价)
|
||||
if self._pending_buy is not None:
|
||||
self._execute_buy(self._pending_buy, kline)
|
||||
self._pending_buy = None
|
||||
if self._pending_sell is not None:
|
||||
self._execute_sell(self._pending_sell, kline)
|
||||
self._pending_sell = None
|
||||
|
||||
signal = await strategy.on_kline(kline)
|
||||
|
||||
if signal is not None and signal.side == "BUY":
|
||||
self._pending_buy = signal
|
||||
elif signal is not None and signal.side == "SELL":
|
||||
self._pending_sell = signal
|
||||
|
||||
self._record_equity(kline)
|
||||
|
||||
# 强平
|
||||
if self._position != 0 and len(klines) > 0:
|
||||
last_k = klines[-1]
|
||||
if self._position > 0:
|
||||
self._execute_sell(Signal(symbol=self.config.symbol, side="SELL",
|
||||
quantity=abs(self._position),
|
||||
reason="回测结束—强平多仓", timestamp=last_k.open_time), last_k)
|
||||
else:
|
||||
self._execute_buy(Signal(symbol=self.config.symbol, side="BUY",
|
||||
quantity=abs(self._position),
|
||||
reason="回测结束—强平空仓", timestamp=last_k.open_time), last_k)
|
||||
|
||||
await strategy.on_stop()
|
||||
metrics = self._compute_metrics()
|
||||
return BacktestResult(config=self.config, strategy_config=strategy_config.model_dump(),
|
||||
metrics=metrics, trades=self._trades, equity_curve=self._equity)
|
||||
finally:
|
||||
await ds.close()
|
||||
|
||||
# ── 交易执行 ──
|
||||
|
||||
def _execute_buy(self, signal: Signal, kline: Kline) -> None:
|
||||
exec_price = kline.open * (1 + self.config.slippage_pct)
|
||||
qty = signal.quantity
|
||||
if qty is None:
|
||||
if self._position < 0:
|
||||
qty = abs(self._position) # 平空仓
|
||||
else:
|
||||
max_notional = self._cash * signal.confidence
|
||||
qty = max_notional / exec_price
|
||||
qty = self._round_qty(qty)
|
||||
if qty < self.config.min_order_qty:
|
||||
return
|
||||
|
||||
notional = exec_price * qty
|
||||
commission = notional * self.config.commission_pct
|
||||
|
||||
if self._position < 0:
|
||||
# 平空仓
|
||||
cover_qty = min(qty, abs(self._position))
|
||||
cover_notional = exec_price * cover_qty
|
||||
cover_comm = cover_notional * self.config.commission_pct
|
||||
pnl = (self._avg_entry_price - exec_price) * cover_qty - cover_comm
|
||||
self._cash -= cover_notional + cover_comm
|
||||
self._position += cover_qty
|
||||
if abs(self._position) < self.config.min_order_qty:
|
||||
self._position = 0.0
|
||||
self._avg_entry_price = 0.0
|
||||
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||
side="BUY", price=exec_price, quantity=cover_qty,
|
||||
notional=cover_notional, commission=cover_comm,
|
||||
slippage=exec_price - kline.open, pnl=pnl,
|
||||
reason=signal.reason))
|
||||
|
||||
# 剩余开多
|
||||
remaining = qty - cover_qty
|
||||
if remaining >= self.config.min_order_qty:
|
||||
self._open_long(remaining, exec_price, kline, signal)
|
||||
else:
|
||||
# 开多 / 加仓
|
||||
self._open_long(qty, exec_price, kline, signal)
|
||||
|
||||
def _open_long(self, qty: float, exec_price: float, kline: Kline, signal: Signal):
|
||||
notional = exec_price * qty
|
||||
commission = notional * self.config.commission_pct
|
||||
total_cost = notional + commission
|
||||
if total_cost > self._cash:
|
||||
qty = self._round_qty(self._cash / (exec_price * (1 + self.config.commission_pct)))
|
||||
if qty < self.config.min_order_qty:
|
||||
return
|
||||
notional = exec_price * qty
|
||||
commission = notional * self.config.commission_pct
|
||||
total_cost = notional + commission
|
||||
if self._position > 0:
|
||||
total_value = self._avg_entry_price * self._position + notional
|
||||
self._position += qty
|
||||
self._avg_entry_price = total_value / self._position
|
||||
else:
|
||||
self._position = qty
|
||||
self._avg_entry_price = exec_price
|
||||
self._cash -= total_cost
|
||||
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||
side="BUY", price=exec_price, quantity=qty,
|
||||
notional=notional, commission=commission,
|
||||
slippage=exec_price - kline.open, reason=signal.reason))
|
||||
|
||||
def _execute_sell(self, signal: Signal, kline: Kline) -> None:
|
||||
exec_price = kline.close * (1 - self.config.slippage_pct)
|
||||
qty = signal.quantity
|
||||
if qty is None:
|
||||
if self._position > 0:
|
||||
qty = self._position
|
||||
else:
|
||||
max_notional = self._cash * signal.confidence
|
||||
qty = max_notional / exec_price
|
||||
qty = self._round_qty(qty)
|
||||
if qty < self.config.min_order_qty:
|
||||
return
|
||||
|
||||
notional = exec_price * qty
|
||||
commission = notional * self.config.commission_pct
|
||||
|
||||
if self._position > 0:
|
||||
# 平多仓
|
||||
close_qty = min(qty, self._position)
|
||||
close_notional = exec_price * close_qty
|
||||
close_comm = close_notional * self.config.commission_pct
|
||||
pnl = (exec_price - self._avg_entry_price) * close_qty - close_comm
|
||||
self._position -= close_qty
|
||||
self._cash += close_notional - close_comm
|
||||
if self._position < self.config.min_order_qty:
|
||||
self._position = 0.0
|
||||
self._avg_entry_price = 0.0
|
||||
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||
side="SELL", price=exec_price, quantity=close_qty,
|
||||
notional=close_notional, commission=close_comm,
|
||||
slippage=kline.close - exec_price, pnl=pnl,
|
||||
reason=signal.reason))
|
||||
# 剩余开空
|
||||
remaining = qty - close_qty
|
||||
if remaining >= self.config.min_order_qty:
|
||||
self._open_short(remaining, exec_price, kline, signal)
|
||||
else:
|
||||
self._open_short(qty, exec_price, kline, signal)
|
||||
|
||||
def _open_short(self, qty: float, exec_price: float, kline: Kline, signal: Signal):
|
||||
notional = exec_price * qty
|
||||
commission = notional * self.config.commission_pct
|
||||
if self._position < 0:
|
||||
total_value = self._avg_entry_price * abs(self._position) + notional
|
||||
self._position -= qty
|
||||
self._avg_entry_price = total_value / abs(self._position)
|
||||
else:
|
||||
self._position = -qty
|
||||
self._avg_entry_price = exec_price
|
||||
self._cash += notional - commission
|
||||
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||
side="SELL", price=exec_price, quantity=qty,
|
||||
notional=notional, commission=commission,
|
||||
slippage=kline.close - exec_price, reason=signal.reason))
|
||||
|
||||
# ── 资金曲线 ──
|
||||
|
||||
def _record_equity(self, kline: Kline) -> None:
|
||||
equity = self._cash + self._position * kline.close
|
||||
if not self._equity:
|
||||
self._peak_equity = equity
|
||||
elif equity > self._peak_equity:
|
||||
self._peak_equity = equity
|
||||
dd = (equity - self._peak_equity) / self._peak_equity * 100 if self._peak_equity > 0 else 0.0
|
||||
self._equity.append({"timestamp": kline.open_time, "equity": equity,
|
||||
"drawdown": dd, "position": self._position})
|
||||
|
||||
# ── 绩效 ──
|
||||
|
||||
def _compute_metrics(self) -> BacktestMetrics:
|
||||
if not self._equity:
|
||||
return BacktestMetrics()
|
||||
initial = self.config.initial_capital
|
||||
final = self._equity[-1]["equity"]
|
||||
total_return_pct = (final - initial) / initial * 100
|
||||
|
||||
first_ts = self._equity[0]["timestamp"]
|
||||
last_ts = self._equity[-1]["timestamp"]
|
||||
days = (last_ts - first_ts) / (1000 * 86400)
|
||||
if days > 0 and final > 0 and initial > 0:
|
||||
annual_return_pct = ((final / initial) ** (365 / days) - 1) * 100
|
||||
else:
|
||||
annual_return_pct = 0.0
|
||||
|
||||
daily_returns = self._compute_daily_returns()
|
||||
if len(daily_returns) > 1:
|
||||
mean_ret = statistics.mean(daily_returns)
|
||||
std_ret = statistics.stdev(daily_returns)
|
||||
sharpe_ratio = (mean_ret / std_ret * (365 ** 0.5)) if std_ret > 0 else 0.0
|
||||
else:
|
||||
sharpe_ratio = 0.0
|
||||
|
||||
max_dd_pct, max_dd_days = self._compute_max_drawdown()
|
||||
closed = [t for t in self._trades if t.pnl is not None]
|
||||
total_trades = len(closed)
|
||||
if total_trades > 0:
|
||||
winners = [t for t in closed if t.pnl > 0]
|
||||
losers = [t for t in closed if t.pnl <= 0]
|
||||
win_rate = len(winners) / total_trades
|
||||
gp = sum(t.pnl for t in winners)
|
||||
gl = abs(sum(t.pnl for t in losers))
|
||||
profit_factor = gp / gl if gl > 0 else (gp if gp > 0 else 0.0)
|
||||
avg_pnl = sum(t.pnl for t in closed) / total_trades
|
||||
best_pnl = max(t.pnl for t in closed)
|
||||
worst_pnl = min(t.pnl for t in closed)
|
||||
else:
|
||||
win_rate = profit_factor = avg_pnl = best_pnl = worst_pnl = 0.0
|
||||
|
||||
calmar = annual_return_pct / abs(max_dd_pct) if max_dd_pct < 0 else 0.0
|
||||
return BacktestMetrics(
|
||||
total_return_pct=total_return_pct, annual_return_pct=annual_return_pct,
|
||||
sharpe_ratio=sharpe_ratio, max_drawdown_pct=max_dd_pct,
|
||||
max_drawdown_duration_days=max_dd_days, win_rate=win_rate,
|
||||
profit_factor=profit_factor, total_trades=total_trades,
|
||||
avg_trade_pnl=avg_pnl, best_trade_pnl=best_pnl, worst_trade_pnl=worst_pnl,
|
||||
calmar_ratio=calmar, final_equity=final,
|
||||
)
|
||||
|
||||
def _compute_daily_returns(self) -> list[float]:
|
||||
if not self._equity:
|
||||
return []
|
||||
daily: dict[str, float] = {}
|
||||
for point in self._equity:
|
||||
dt = datetime.fromtimestamp(point["timestamp"] / 1000, tz=timezone.utc)
|
||||
daily[dt.strftime("%Y-%m-%d")] = point["equity"]
|
||||
sorted_dates = sorted(daily.keys())
|
||||
returns = []
|
||||
for i in range(1, len(sorted_dates)):
|
||||
prev = daily[sorted_dates[i - 1]]
|
||||
curr = daily[sorted_dates[i]]
|
||||
if prev > 0:
|
||||
returns.append((curr - prev) / prev)
|
||||
return returns
|
||||
|
||||
def _compute_max_drawdown(self) -> tuple[float, int]:
|
||||
if not self._equity:
|
||||
return 0.0, 0
|
||||
peak = self._equity[0]["equity"]
|
||||
max_dd = 0.0
|
||||
dd_start_idx = 0
|
||||
max_dd_days = 0
|
||||
for i, point in enumerate(self._equity):
|
||||
equity = point["equity"]
|
||||
if equity > peak:
|
||||
peak = equity
|
||||
dd_start_idx = i
|
||||
dd = (equity - peak) / peak * 100
|
||||
if dd < max_dd:
|
||||
max_dd = dd
|
||||
peak_ts = self._equity[dd_start_idx]["timestamp"]
|
||||
dd_days = int((point["timestamp"] - peak_ts) / (1000 * 86400))
|
||||
if dd_days > max_dd_days:
|
||||
max_dd_days = dd_days
|
||||
return max_dd, max_dd_days
|
||||
|
||||
@staticmethod
|
||||
def _round_qty(qty: float, decimals: int = 8) -> float:
|
||||
factor = 10 ** decimals
|
||||
return int(qty * factor) / factor
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 多空趋势策略
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class LongShortEmaConfig(StrategyConfig):
|
||||
fast: int = 10
|
||||
slow: int = 50
|
||||
atr_stop: float = 2.5
|
||||
|
||||
|
||||
class LongShortEmaStrategy(BaseStrategy):
|
||||
"""EMA金叉做多、死叉做空,始终在场"""
|
||||
|
||||
strategy_type = "long_short_ema"
|
||||
|
||||
def __init__(self, c: LongShortEmaConfig):
|
||||
super().__init__(c)
|
||||
self.cfg = c
|
||||
self._closes: list[float] = []
|
||||
self._highs: list[float] = []
|
||||
self._lows: list[float] = []
|
||||
self._highest: float = 0.0
|
||||
self._lowest: float = float('inf')
|
||||
self._position_side: str = "" # "long" / "short"
|
||||
|
||||
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||
self._closes.append(k.close)
|
||||
self._highs.append(k.high)
|
||||
self._lows.append(k.low)
|
||||
n = len(self._closes)
|
||||
if n < self.cfg.slow + 5:
|
||||
return None
|
||||
|
||||
fast = ema(self._closes, self.cfg.fast)
|
||||
slow = ema(self._closes, self.cfg.slow)
|
||||
atr_vals = atr(self._highs, self._lows, self._closes, 14)
|
||||
cur_f, cur_s, cur_atr = fast[-1], slow[-1], atr_vals[-1]
|
||||
prev_f, prev_s = fast[-2], slow[-2]
|
||||
if cur_f == 0 or cur_s == 0 or cur_atr == 0:
|
||||
return None
|
||||
|
||||
golden = prev_f <= prev_s and cur_f > cur_s
|
||||
death = prev_f >= prev_s and cur_f < cur_s
|
||||
|
||||
# ── 多头持仓 ──
|
||||
if self._position_side == "long":
|
||||
self._highest = max(self._highest, k.high)
|
||||
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||
if death:
|
||||
self._position_side = "short"
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA死叉→做空", timestamp=k.open_time)
|
||||
if k.close < stop:
|
||||
self._position_side = ""
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损→空仓", timestamp=k.open_time)
|
||||
|
||||
# ── 空头持仓 ──
|
||||
elif self._position_side == "short":
|
||||
self._lowest = min(self._lowest, k.low)
|
||||
stop = self._lowest + self.cfg.atr_stop * cur_atr
|
||||
if golden:
|
||||
self._position_side = "long"
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason="EMA金叉→做多", timestamp=k.open_time)
|
||||
if k.close > stop:
|
||||
self._position_side = ""
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason="ATR止损→空仓", timestamp=k.open_time)
|
||||
|
||||
# ── 空仓等待信号 ──
|
||||
else:
|
||||
if golden:
|
||||
self._position_side = "long"
|
||||
self._highest = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason="金叉→做多", timestamp=k.open_time)
|
||||
elif death:
|
||||
self._position_side = "short"
|
||||
self._lowest = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason="死叉→做空", timestamp=k.open_time)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||
|
||||
# 各币种历史最优参数
|
||||
PARAMS = {
|
||||
"BTCUSDT": (10, 50),
|
||||
"ETHUSDT": (10, 75),
|
||||
"BNBUSDT": (20, 50),
|
||||
"SOLUSDT": (30, 50),
|
||||
}
|
||||
|
||||
# 只做多结果(用于对比)
|
||||
LONG_ONLY = {
|
||||
"BTCUSDT": (39.9, 1.03, -11.5, 18.3),
|
||||
"ETHUSDT": (53.6, 1.04, -15.3, 23.9),
|
||||
"BNBUSDT": (52.0, 0.71, -39.8, 23.3),
|
||||
"SOLUSDT": (73.6, 1.18, -25.7, 31.7),
|
||||
}
|
||||
# (总收益%, 夏普, 回撤%, 年化%)
|
||||
|
||||
DATE_START = datetime(2024, 1, 1)
|
||||
DATE_END = datetime(2026, 1, 1)
|
||||
|
||||
|
||||
async def main():
|
||||
print()
|
||||
print("═" * 112)
|
||||
print(" 多空双向 EMA 趋势跟踪 | 4h | 2024-2026")
|
||||
print("═" * 112)
|
||||
header = f" {'币种':<10} {'方向':<6} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6}"
|
||||
print(header)
|
||||
print("─" * 112)
|
||||
|
||||
for symbol in SYMBOLS:
|
||||
fast, slow = PARAMS[symbol]
|
||||
sc = LongShortEmaConfig(symbol=symbol, fast=fast, slow=slow)
|
||||
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||
engine = LongShortEngine(bt, db_config=config.db)
|
||||
r = await engine.run(LongShortEmaStrategy, sc)
|
||||
m = r.metrics
|
||||
|
||||
long_trades = [t for t in r.trades if t.pnl is not None and t.side == "SELL"]
|
||||
short_trades = [t for t in r.trades if t.pnl is not None and t.side == "BUY"]
|
||||
lo = LONG_ONLY[symbol]
|
||||
|
||||
long_pnl = sum(t.pnl for t in long_trades) if long_trades else 0
|
||||
short_pnl = sum(t.pnl for t in short_trades) if short_trades else 0
|
||||
|
||||
print(f" {symbol:<10} 多空 {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f}")
|
||||
print(f" {'':<10} 只做多 {lo[0]:>6.1f}% {lo[3]:>6.1f}% {lo[1]:>6.2f} {lo[2]:>6.1f}%")
|
||||
if long_trades or short_trades:
|
||||
print(f" {'':<10} └ 多头P&L {long_pnl:>+7.0f} ({len(long_trades)}笔) 空头P&L {short_pnl:>+7.0f} ({len(short_trades)}笔)")
|
||||
for t in (r.trades[-2:] if r.trades else []):
|
||||
if t.pnl is not None:
|
||||
side_label = "平多" if t.side == "SELL" else "平空"
|
||||
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
|
||||
print(f" {'':<10} └ {dt} {side_label} {t.pnl:>+8.2f} {t.reason}")
|
||||
|
||||
print("─" * 112)
|
||||
print("\n═" * 112)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user