บทนำ: ทำไมต้อง Backtest ด้วย Historical Orderbook
การ backtest คือการทดสอบกลยุทธ์การเทรดด้วยข้อมูลในอดีตก่อนนำไปใช้จริง และ orderbook data เป็นข้อมูลที่มีคุณค่ามากเพราะแสดง liquidity, market depth และพฤติกรรมของ market makers อย่างละเอียด ผมใช้ Tardis.dev API มากว่า 2 ปีสำหรับโปรเจกต์ quantitative trading และต้องบอกว่า coverage ของ orderbook data ครอบคลุมมากที่สุดในตลาด crypto
Tardis.dev มี data ตั้งแต่ปี 2019 สำหรับ Binance Futures และรองรับ granular levels หลายระดับ ตั้งแต่ raw trades ไปจนถึง aggregated orderbook snapshots
สถาปัตยกรรมของระบบ Backtest
ก่อนเข้าสู่โค้ด มาดู architecture ของระบบที่เราจะสร้างกัน:
┌─────────────────────────────────────────────────────────────────┐
│ BACKTEST ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Tardis.dev │───▶│ Python │───▶│ Strategy │ │
│ │ API │ │ Data Loader │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ • Orderbook │ │ • Streaming │ │ • Signals │ │
│ │ • Trades │ │ • Caching │ │ • Execution │ │
│ │ • Kline │ │ • Normalize │ │ • Metrics │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SQLite/ │ │ Results │ │
│ │ Parquet │ │ Analysis │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
การติดตั้งและ Setup Environment
# สร้าง virtual environment
python -m venv venv
source venv/bin/activate # Linux/Mac หรือ venv\Scripts\activate บน Windows
ติดตั้ง dependencies ที่จำเป็น
pip install tardis-client pandas numpy pyarrow sqlalchemy aiohttp
pip install asyncpg # สำหรับ PostgreSQL (optional)
pip install python-dotenv redis # สำหรับ caching
สร้างไฟล์ .env
cat > .env << 'EOF'
TARDIS_API_KEY=your_tardis_api_key_here
DATA_DIR=./backtest_data
CACHE_ENABLED=true
REDIS_URL=redis://localhost:6379
EOF
การใช้งาน Tardis.dev API Client
import os
from dotenv import load_dotenv
from tardis_client import TardisClient, Channel, MessageType
import asyncio
import pandas as pd
from typing import Dict, List, Optional
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import json
import hashlib
load_dotenv()
@dataclass
class OrderbookLevel:
"""โครงสร้างข้อมูล orderbook level"""
price: float
quantity: float
side: str # 'bid' หรือ 'ask'
@dataclass
class OrderbookSnapshot:
"""Orderbook snapshot ณ จุดเวลาใดเวลาหนึ่ง"""
timestamp: datetime
symbol: str
bids: List[OrderbookLevel] # sorted descending by price
asks: List[OrderbookLevel] # sorted ascending by price
local_timestamp: datetime = field(default_factory=datetime.now)
@property
def best_bid(self) -> float:
return self.bids[0].price if self.bids else 0.0
@property
def best_ask(self) -> float:
return self.asks[0].price if self.asks else 0.0
@property
def mid_price(self) -> float:
return (self.best_bid + self.best_ask) / 2
@property
def spread(self) -> float:
return self.best_ask - self.best_bid
@property
def spread_bps(self) -> float:
"""Spread ในหน่วย basis points"""
return (self.spread / self.mid_price) * 10000 if self.mid_price > 0 else 0
class TardisDataLoader:
"""
Data loader สำหรับดึง historical orderbook data จาก Tardis.dev
รองรับ both replay mode และ historical query mode
"""
def __init__(self, api_key: str, exchange: str = "binance-futures"):
self.api_key = api_key
self.exchange = exchange
self.client = None
self._orderbook_cache: Dict[str, List[OrderbookSnapshot]] = {}
async def __aenter__(self):
self.client = TardisClient(api_key=self.api_key)
return self
async def __aexit__(self, *args):
if self.client:
await self.client.close()
async def fetch_orderbook_range(
self,
symbol: str,
start_time: datetime,
end_time: datetime,
symbols_filter: Optional[List[str]] = None
) -> List[OrderbookSnapshot]:
"""
ดึง orderbook snapshots ในช่วงเวลาที่กำหนด
Args:
symbol: เช่น 'BTCUSDT'
start_time: วันที่เริ่มต้น
end_time: วันที่สิ้นสุด
symbols_filter: list of symbols to filter (for efficiency)
Returns:
List of OrderbookSnapshot objects
"""
cache_key = f"{symbol}_{start_time.isoformat()}_{end_time.isoformat()}"
# Check cache first
if cache_key in self._orderbook_cache:
print(f"📦 Using cached data for {symbol}")
return self._orderbook_cache[cache_key]
print(f"📥 Fetching orderbook data for {symbol} from {start_time} to {end_time}")
# Convert to milliseconds
from_ms = int(start_time.timestamp() * 1000)
to_ms = int(end_time.timestamp() * 1000)
orderbook_data = []
# Replay mode - iterates through historical data
async for message in self.client.replay(
exchange=self.exchange,
from_timestamp=from_ms,
to_timestamp=to_ms,
filters=[Channel(name=f"{symbol}@orderbook", type="orderbook")]
):
if message.type == MessageType.Snapshot:
snapshot = self._parse_orderbook_message(message, symbol)
if snapshot:
orderbook_data.append(snapshot)
# Cache the results
self._orderbook_cache[cache_key] = orderbook_data
print(f"✅ Loaded {len(orderbook_data)} orderbook snapshots")
return orderbook_data
def _parse_orderbook_message(self, message, symbol: str) -> Optional[OrderbookSnapshot]:
"""Parse Tardis message เป็น OrderbookSnapshot"""
try:
data = message.data
# Binance Futures orderbook structure
bids = [
OrderbookLevel(price=float(b[0]), quantity=float(b[1]), side='bid')
for b in data.get('b', data.get('bids', []))
]
asks = [
OrderbookLevel(price=float(a[0]), quantity=float(a[1]), side='ask')
for a in data.get('a', data.get('asks', []))
]
# Sort bids descending, asks ascending
bids.sort(key=lambda x: x.price, reverse=True)
asks.sort(key=lambda x: x.price)
return OrderbookSnapshot(
timestamp=datetime.fromtimestamp(message.timestamp / 1000),
symbol=symbol,
bids=bids,
asks=asks
)
except Exception as e:
print(f"⚠️ Error parsing orderbook message: {e}")
return None
async def fetch_trades(
self,
symbol: str,
start_time: datetime,
end_time: datetime
) -> pd.DataFrame:
"""ดึง trade data สำหรับ analysis"""
from_ms = int(start_time.timestamp() * 1000)
to_ms = int(end_time.timestamp() * 1000)
trades = []
async for message in self.client.replay(
exchange=self.exchange,
from_timestamp=from_ms,
to_timestamp=to_ms,
filters=[Channel(name=f"{symbol}@trade", type="trade")]
):
trades.append({
'timestamp': datetime.fromtimestamp(message.timestamp / 1000),
'price': float(message.data['p']),
'quantity': float(message.data['q']),
'side': message.data.get('m', None), # maker sell = True
'trade_id': message.data.get('t')
})
return pd.DataFrame(trades)
ตัวอย่างการใช้งาน
async def main():
api_key = os.getenv("TARDIS_API_KEY")
async with TardisDataLoader(api_key) as loader:
# ดึงข้อมูล 1 ชั่วโมงล่าสุด
end_time = datetime.now()
start_time = end_time - timedelta(hours=1)
snapshots = await loader.fetch_orderbook_range(
symbol="BTCUSDT",
start_time=start_time,
end_time=end_time
)
if snapshots:
print(f"\n📊 Orderbook Analysis:")
print(f" Best Bid Range: {min(s.mid_price for s in snapshots):.2f} - {max(s.mid_price for s in snapshots):.2f}")
print(f" Avg Spread: {sum(s.spread for s in snapshots) / len(snapshots):.4f} USDT")
if __name__ == "__main__":
asyncio.run(main())
การสร้าง Backtest Engine
import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
import statistics
class OrderSide(Enum):
BUY = "BUY"
SELL = "SELL"
@dataclass
class Position:
"""ข้อมูล position ปัจจุบัน"""
symbol: str
side: OrderSide
entry_price: float
quantity: float
entry_time: datetime
unrealized_pnl: float = 0.0
@dataclass
class Trade:
"""Record ของ trade ที่เกิดขึ้น"""
timestamp: datetime
side: OrderSide
price: float
quantity: float
pnl: float = 0.0
commission: float = 0.0
slippage: float = 0.0
@dataclass
class BacktestResult:
"""ผลลัพธ์ของ backtest"""
total_trades: int = 0
winning_trades: int = 0
losing_trades: int = 0
total_pnl: float = 0.0
max_drawdown: float = 0.0
sharpe_ratio: float = 0.0
win_rate: float = 0.0
avg_win: float = 0.0
avg_loss: float = 0.0
profit_factor: float = 0.0
trades: List[Trade] = field(default_factory=list)
def summary(self) -> str:
return f"""
╔══════════════════════════════════════════════════════════╗
║ BACKTEST RESULTS SUMMARY ║
╠══════════════════════════════════════════════════════════╣
║ Total Trades: {self.total_trades:>10} ║
║ Win Rate: {self.win_rate*100:>10.2f}% ║
║ Total P&L: {self.total_pnl:>10.2f} USDT ║
║ Max Drawdown: {self.max_drawdown:>10.2f}% ║
║ Sharpe Ratio: {self.sharpe_ratio:>10.2f} ║
║ Profit Factor: {self.profit_factor:>10.2f} ║
║ Avg Win: {self.avg_win:>10.2f} USDT ║
║ Avg Loss: {self.avg_loss:>10.2f} USDT ║
╚══════════════════════════════════════════════════════════╝
"""
class MeanReversionStrategy:
"""
Mean Reversion Strategy โดยใช้ orderbook data
Logic:
1. คำนวณ mid price และ moving average
2. เมื่อ price ห่างจาก MA เกิน threshold → signal
3. ซื้อเมื่อ underpriced (ราคาต่ำกว่า MA มาก)
4. ขายเมื่อ overpriced (ราคาสูงกว่า MA มาก)
"""
def __init__(
self,
window: int = 20,
entry_threshold: float = 2.0, # ห่างกี่ std
exit_threshold: float = 0.5,
position_size: float = 0.1 # 10% ของ capital
):
self.window = window
self.entry_threshold = entry_threshold
self.exit_threshold = exit_threshold
self.position_size = position_size
self.price_history: List[float] = []
self.ma: float = 0
self.std: float = 0
def update(self, mid_price: float) -> Optional[str]:
"""อัพเดท strategy และ return signal"""
self.price_history.append(mid_price)
if len(self.price_history) < self.window:
return None
# Keep window size
if len(self.price_history) > self.window:
self.price_history.pop(0)
# Calculate stats
self.ma = statistics.mean(self.price_history)
self.std = statistics.stdev(self.price_history)
if self.std == 0:
return None
z_score = (mid_price - self.ma) / self.std
# Signals
if z_score < -self.entry_threshold:
return "LONG" # Underpriced - คาดว่าราคาจะกลับขึ้น
elif z_score > self.entry_threshold:
return "SHORT" # Overpriced - คาดว่าราคาจะลง
elif abs(z_score) < self.exit_threshold:
if z_score < 0:
return "CLOSE_SHORT"
else:
return "CLOSE_LONG"
return None
class BacktestEngine:
"""
Backtest engine สำหรับทดสอบกลยุทธ์ด้วย historical data
"""
def __init__(
self,
initial_capital: float = 10000.0,
commission_rate: float = 0.0004, # Binance Futures: 0.04%
slippage_bps: float = 2.0 # 2 basis points
):
self.initial_capital = initial_capital
self.capital = initial_capital
self.commission_rate = commission_rate
self.slippage_bps = slippage_bps
self.position: Optional[Position] = None
self.trades: List[Trade] = []
self.equity_curve: List[float] = [initial_capital]
self.timestamps: List[datetime] = []
def execute_signal(
self,
signal: str,
price: float,
timestamp: datetime
) -> Optional[Trade]:
"""Execute trade based on signal"""
trade = None
if signal == "LONG" and self.position is None:
# Open long position
quantity = (self.capital * 0.1) / price # 10% position size
slippage = price * (self.slippage_bps / 10000)
execution_price = price + slippage
commission = execution_price * quantity * self.commission_rate
self.position = Position(
symbol="BTCUSDT",
side=OrderSide.BUY,
entry_price=execution_price,
quantity=quantity,
entry_time=timestamp
)
trade = Trade(
timestamp=timestamp,
side=OrderSide.BUY,
price=execution_price,
quantity=quantity,
commission=commission,
slippage=slippage
)
elif signal == "SHORT" and self.position is None:
# Open short position
quantity = (self.capital * 0.1) / price
slippage = price * (self.slippage_bps / 10000)
execution_price = price - slippage # Short: execute below market
commission = execution_price * quantity * self.commission_rate
self.position = Position(
symbol="BTCUSDT",
side=OrderSide.SELL,
entry_price=execution_price,
quantity=quantity,
entry_time=timestamp
)
trade = Trade(
timestamp=timestamp,
side=OrderSide.SELL,
price=execution_price,
quantity=quantity,
commission=commission,
slippage=slippage
)
elif signal in ["CLOSE_LONG", "CLOSE_SHORT"] and self.position:
# Close position
is_long = self.position.side == OrderSide.BUY
if (signal == "CLOSE_LONG" and is_long) or \
(signal == "CLOSE_SHORT" and not is_long):
slippage = price * (self.slippage_bps / 10000)
exit_price = price - slippage if is_long else price + slippage
commission = exit_price * self.position.quantity * self.commission_rate
# Calculate P&L
if is_long:
pnl = (exit_price - self.position.entry_price) * self.position.quantity
else:
pnl = (self.position.entry_price - exit_price) * self.position.quantity
pnl -= commission
trade = Trade(
timestamp=timestamp,
side=OrderSide.SELL if is_long else OrderSide.BUY,
price=exit_price,
quantity=self.position.quantity,
pnl=pnl,
commission=commission,
slippage=slippage
)
self.capital += pnl
self.position = None
if trade:
self.trades.append(trade)
self.equity_curve.append(self.capital)
self.timestamps.append(timestamp)
return trade
def run(
self,
strategy: MeanReversionStrategy,
orderbook_snapshots: List
) -> BacktestResult:
"""Run backtest with orderbook data"""
print(f"🚀 Starting backtest with {len(orderbook_snapshots)} data points")
for snapshot in orderbook_snapshots:
mid_price = snapshot.mid_price
timestamp = snapshot.timestamp
signal = strategy.update(mid_price)
if signal:
trade = self.execute_signal(signal, mid_price, timestamp)
if trade:
print(f"📋 {timestamp} | {signal} @ {trade.price:.2f} | P&L: {trade.pnl:.2f}")
# Close any remaining position at last price
if self.position:
last_snapshot = orderbook_snapshots[-1]
self.execute_signal("CLOSE_LONG", last_snapshot.mid_price, last_snapshot.timestamp)
return self.calculate_results()
def calculate_results(self) -> BacktestResult:
"""Calculate backtest metrics"""
result = BacktestResult()
if not self.trades:
return result
closed_trades = [t for t in self.trades if t.pnl != 0]
result.total_trades = len(closed_trades)
winning = [t for t in closed_trades if t.pnl > 0]
losing = [t for t in closed_trades if t.pnl <= 0]
result.winning_trades = len(winning)
result.losing_trades = len(losing)
result.total_pnl = sum(t.pnl for t in closed_trades)
if result.total_trades > 0:
result.win_rate = result.winning_trades / result.total_trades
if winning:
result.avg_win = sum(t.pnl for t in winning) / len(winning)
if losing:
result.avg_loss = abs(sum(t.pnl for t in losing) / len(losing))
if result.avg_loss > 0:
result.profit_factor = result.avg_win * result.winning_trades / (result.avg_loss * result.losing_trades)
# Calculate max drawdown
peak = self.equity_curve[0]
max_dd = 0
for equity in self.equity_curve:
if equity > peak:
peak = equity
dd = (peak - equity) / peak * 100
if dd > max_dd:
max_dd = dd
result.max_drawdown = max_dd
# Calculate Sharpe ratio (annualized)
if len(self.equity_curve) > 1:
returns = np.diff(self.equity_curve) / self.equity_curve[:-1]
if np.std(returns) > 0:
result.sharpe_ratio = np.mean(returns) / np.std(returns) * np.sqrt(252 * 24) # Assuming hourly data
result.trades = closed_trades
return result
ตัวอย่างการรัน backtest
async def run_backtest_example():
from tardis_data_loader import TardisDataLoader
api_key = os.getenv("TARDIS_API_KEY")
async with TardisDataLoader(api_key) as loader:
# ดึงข้อมูล 7 วัน
end_time = datetime.now()
start_time = end_time - timedelta(days=7)
print("📥 Fetching orderbook data...")
snapshots = await loader.fetch_orderbook_range(
symbol="BTCUSDT",
start_time=start_time,
end_time=end_time
)
if len(snapshots) < 100:
print("⚠️ Not enough data for backtest, using sample data")
# Generate sample data for demonstration
snapshots = generate_sample_orderbook_data(days=7)
# Initialize strategy and engine
strategy = MeanReversionStrategy(
window=50,
entry_threshold=1.5,
exit_threshold=0.3,
position_size=0.1
)
engine = BacktestEngine(
initial_capital=10000,
commission_rate=0.0004,
slippage_bps=2.0
)
# Run backtest
results = engine.run(strategy, snapshots)
# Print results
print(results.summary())
# Save detailed results
df = pd.DataFrame([
{
'timestamp': t.timestamp,
'side': t.side.value,
'price': t.price,
'quantity': t.quantity,
'pnl': t.pnl,
'commission': t.commission
}
for t in results.trades
])
df.to_csv('backtest_results.csv', index=False)
print("💾 Results saved to backtest_results.csv")
return results
def generate_sample_orderbook_data(days: int = 1) -> List:
"""Generate synthetic orderbook data for testing"""
snapshots = []
base_price = 45000.0
now = datetime.now()
# Generate hourly snapshots
for i in range(days * 24):
timestamp = now - timedelta(hours=days * 24 - i)
# Random walk with mean reversion tendency
change = np.random.normal(0, 10)
base_price = base_price * 0.999 + (base_price + change) * 0.001
spread = 0.5 + np.random.exponential(0.5)
mid = base_price
bids = [
OrderbookLevel(price=mid - spread/2 - j * 0.1, quantity=1 + np.random.random(), side='bid')
for j in range(10)
]
asks = [
OrderbookLevel(price=mid + spread/2 + j * 0.1, quantity=1 + np.random.random(), side='ask')
for j in range(10)
]
snapshots.append(OrderbookSnapshot(
timestamp=timestamp,
symbol="BTCUSDT",
bids=bids,
asks=asks
))
return snapshots
if __name__ == "__main__":
asyncio.run(run_backtest_example())
การ Optimize Performance สำหรับ Large Dataset
เมื่อต้องทำ backtest กับข้อมูลหลายเดือน ประสิทธิภาพของโค้ดมีความสำคัญมาก ผมได้ทำ benchmark และพบว่ามีหลายจุดที่ต้อง optimize:
"""
Performance Optimization Module สำหรับ Large-scale Backtesting
รวบรวมเทคนิคที่ใช้ลดเวลา processing ลง 10-50 เท่า
"""
import numpy as np
import pandas as pd
from numba import jit
import pyarrow as pa
import pyarrow.parquet as pq
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from functools import lru_cache
import mmap
import struct
from typing import Generator
import time
============================================================
1. NUMBA JIT COMPILATION สำหรับ Calculation-Intensive Tasks
============================================================
@jit(nopython=True, cache=True)
def calculate_z_score_numba(prices: np.ndarray, window: int) -> np.ndarray:
"""
Calculate rolling z-score using Numba for 10-50x speedup
ใช้ Numba JIT compile เป็น machine code โดยตรง
"""
n = len(prices)
z_scores = np.full(n, np.nan)
for i in range(window, n):
window_data = prices[i-window:i]
mean = np.mean(window_data)
std = np.std(window_data)
if std > 0:
z_scores[i] = (prices[i] - mean) / std
return z_scores
@jit(nopython=True, cache=True, parallel=True)
def calculate_orderbook_metrics_numba(
bids: np.ndarray,
asks: np.ndarray,
quantities_bid: np.ndarray,
quantities_ask: np.ndarray
) -> np.ndarray:
"""
Calculate multiple orderbook metrics in parallel
- VWAP
- Market depth
- Order flow imbalance
"""
n = len(bids)
results = np.zeros((n, 5)) # mid, spread, depth, imbalance, vwap
for i in prange(n):
if bids[i] > 0 and asks[i] > 0:
# Mid price
results[i, 0] = (bids[i] + asks[i]) / 2
# Spread
results[i, 1] = asks[i] - bids[i]
# Depth (sum of top 10 levels)
depth_bid = 0
depth_ask = 0
for j in range(min(10, len(quantities_bid[i]))):
depth_bid += quantities_bid[i, j]
depth_ask += quantities_ask[i, j]
results[i, 2] = depth_bid + depth_ask
# Order flow imbalance
total_bid_qty = np.sum(quantities_bid[i])
total_ask_qty = np.sum(quantities_ask[i])
if total_bid_qty + total_ask_qty > 0:
results[i, 3] = (total_bid_qty - total_ask_qty) / (total_bid_qty + total_ask_qty)
# VWAP
bid_volume = bids[i] * quantities_bid[i]
ask_volume = asks[i] * quantities_ask[i]
total_volume = np.sum(quantities_bid[i]) + np.sum(quantities_ask[i])
if total_volume > 0:
results[i, 4] = np.sum(bid_volume + ask_volume) / total_volume
return results
============================================================
2. VECTORIZED OPERATIONS ด้วย NumPy
============================================================
class VectorizedBacktest:
"""
Backtest engine ที่ใช้ vectorized operations แทน loops
เร็วกว่า loop-based ถึง 100 เท่าสำหรับ large datasets
"""
def __init__(self, initial_capital: float = 10000.0):
self.initial_capital = initial_capital
def preprocess_orderbook_to_arrays(
self,
orderbook_snapshots: list
) -> dict:
"""
แปลง orderbook snapshots เป็น numpy arrays สำหรับ vectorized operations
"""
n = len(orderbook_snapshots)
# Initialize arrays
timestamps = np.zeros(n, dtype=np.int64)
mid_prices = np.zeros(n, dtype=np.float64)
spreads = np.zeros(n, dtype=np.float64)
bid_depths = np.zeros(n, dtype=np.float64)
ask_depths = np.zeros(n, dtype=np.float64)
for i, snapshot in enumerate(orderbook_snapshots):
timestamps[i] = int(snapshot.timestamp.timestamp() * 1000)
mid_prices[i] = snapshot.mid_price
spreads[i] = snapshot.spread
# Calculate depth
bid_depths[i] = sum(b.quantity for b in snapshot.bids[:10])
ask_depths[i] = sum(a.quantity for a in snapshot.asks[:10])
return {
'timestamps': timestamps,
'mid_prices': mid_prices,
'spreads': spreads,
'bid_depths': bid_depths,
'ask_depths': ask_depths
}
def generate_signals_vectorized(
self,
mid_prices: np.ndarray,
window: int = 20,
entry_threshold: float = 2.0,
exit_threshold: float = 0.5
) -> np.ndarray:
"""
Generate trading signals using vectorized z-score calculation
แทนที่จะ loop ผ่านแต่ละ timestamp ใช้ NumPy vectorized operations
"""
n = len(mid_prices)
# Use Numba-optimized function
z_scores = calculate_z_score_numba(mid_prices, window)
# Generate signals (0 = no signal, 1 = long, -1 = short, 2 = close)
signals = np.zeros(n, dtype=np.int8)
signals[z_scores < -entry_threshold] = 1 # LONG
signals[z_scores > entry_threshold] = -1 # SHORT
signals[np.abs(z_scores) < exit_threshold] = 2 # CLOSE
return signals
def run_vectorized_backtest(
แหล่งข้อมูลที่เกี่ยวข้อง
บทความที่เกี่ยวข้อง