signalflow-trading 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- signalflow/__init__.py +21 -0
- signalflow/analytics/__init__.py +0 -0
- signalflow/core/__init__.py +46 -0
- signalflow/core/base_mixin.py +232 -0
- signalflow/core/containers/__init__.py +21 -0
- signalflow/core/containers/order.py +216 -0
- signalflow/core/containers/portfolio.py +211 -0
- signalflow/core/containers/position.py +296 -0
- signalflow/core/containers/raw_data.py +167 -0
- signalflow/core/containers/raw_data_view.py +169 -0
- signalflow/core/containers/signals.py +198 -0
- signalflow/core/containers/strategy_state.py +147 -0
- signalflow/core/containers/trade.py +112 -0
- signalflow/core/decorators.py +103 -0
- signalflow/core/enums.py +270 -0
- signalflow/core/registry.py +322 -0
- signalflow/core/rolling_aggregator.py +362 -0
- signalflow/core/signal_transforms/__init__.py +5 -0
- signalflow/core/signal_transforms/base_signal_transform.py +186 -0
- signalflow/data/__init__.py +11 -0
- signalflow/data/raw_data_factory.py +225 -0
- signalflow/data/raw_store/__init__.py +7 -0
- signalflow/data/raw_store/base.py +271 -0
- signalflow/data/raw_store/duckdb_stores.py +696 -0
- signalflow/data/source/__init__.py +10 -0
- signalflow/data/source/base.py +300 -0
- signalflow/data/source/binance.py +442 -0
- signalflow/data/strategy_store/__init__.py +8 -0
- signalflow/data/strategy_store/base.py +278 -0
- signalflow/data/strategy_store/duckdb.py +409 -0
- signalflow/data/strategy_store/schema.py +36 -0
- signalflow/detector/__init__.py +7 -0
- signalflow/detector/adapter/__init__.py +5 -0
- signalflow/detector/adapter/pandas_detector.py +46 -0
- signalflow/detector/base.py +390 -0
- signalflow/detector/sma_cross.py +105 -0
- signalflow/feature/__init__.py +16 -0
- signalflow/feature/adapter/__init__.py +5 -0
- signalflow/feature/adapter/pandas_feature_extractor.py +54 -0
- signalflow/feature/base.py +330 -0
- signalflow/feature/feature_set.py +286 -0
- signalflow/feature/oscillator/__init__.py +5 -0
- signalflow/feature/oscillator/rsi_extractor.py +42 -0
- signalflow/feature/pandasta/__init__.py +10 -0
- signalflow/feature/pandasta/pandas_ta_extractor.py +141 -0
- signalflow/feature/pandasta/top_pandasta_extractors.py +64 -0
- signalflow/feature/smoother/__init__.py +5 -0
- signalflow/feature/smoother/sma_extractor.py +46 -0
- signalflow/strategy/__init__.py +9 -0
- signalflow/strategy/broker/__init__.py +15 -0
- signalflow/strategy/broker/backtest.py +172 -0
- signalflow/strategy/broker/base.py +186 -0
- signalflow/strategy/broker/executor/__init__.py +9 -0
- signalflow/strategy/broker/executor/base.py +35 -0
- signalflow/strategy/broker/executor/binance_spot.py +12 -0
- signalflow/strategy/broker/executor/virtual_spot.py +81 -0
- signalflow/strategy/broker/realtime_spot.py +12 -0
- signalflow/strategy/component/__init__.py +9 -0
- signalflow/strategy/component/base.py +65 -0
- signalflow/strategy/component/entry/__init__.py +7 -0
- signalflow/strategy/component/entry/fixed_size.py +57 -0
- signalflow/strategy/component/entry/signal.py +127 -0
- signalflow/strategy/component/exit/__init__.py +5 -0
- signalflow/strategy/component/exit/time_based.py +47 -0
- signalflow/strategy/component/exit/tp_sl.py +80 -0
- signalflow/strategy/component/metric/__init__.py +8 -0
- signalflow/strategy/component/metric/main_metrics.py +181 -0
- signalflow/strategy/runner/__init__.py +8 -0
- signalflow/strategy/runner/backtest_runner.py +208 -0
- signalflow/strategy/runner/base.py +19 -0
- signalflow/strategy/runner/optimized_backtest_runner.py +178 -0
- signalflow/strategy/runner/realtime_runner.py +0 -0
- signalflow/target/__init__.py +14 -0
- signalflow/target/adapter/__init__.py +5 -0
- signalflow/target/adapter/pandas_labeler.py +45 -0
- signalflow/target/base.py +409 -0
- signalflow/target/fixed_horizon_labeler.py +93 -0
- signalflow/target/static_triple_barrier.py +162 -0
- signalflow/target/triple_barrier.py +188 -0
- signalflow/utils/__init__.py +7 -0
- signalflow/utils/import_utils.py +11 -0
- signalflow/utils/tune_utils.py +19 -0
- signalflow/validator/__init__.py +6 -0
- signalflow/validator/base.py +139 -0
- signalflow/validator/sklearn_validator.py +527 -0
- signalflow_trading-0.2.1.dist-info/METADATA +149 -0
- signalflow_trading-0.2.1.dist-info/RECORD +90 -0
- signalflow_trading-0.2.1.dist-info/WHEEL +5 -0
- signalflow_trading-0.2.1.dist-info/licenses/LICENSE +21 -0
- signalflow_trading-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from signalflow.core import StrategyState
|
|
4
|
+
from signalflow.core.decorators import sf_component
|
|
5
|
+
from signalflow.strategy.component.base import StrategyMetric
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
@sf_component(name='total_return', override=True)
|
|
11
|
+
class TotalReturnMetric(StrategyMetric):
|
|
12
|
+
"""Computes total return metrics for the portfolio."""
|
|
13
|
+
|
|
14
|
+
initial_capital: float = 10000.0
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str:
|
|
18
|
+
return 'total_return'
|
|
19
|
+
|
|
20
|
+
def compute(
|
|
21
|
+
self,
|
|
22
|
+
state: StrategyState,
|
|
23
|
+
prices: dict[str, float]
|
|
24
|
+
) -> dict[str, float]:
|
|
25
|
+
equity = state.portfolio.equity(prices=prices)
|
|
26
|
+
cash = state.portfolio.cash
|
|
27
|
+
|
|
28
|
+
total_realized = sum(p.realized_pnl for p in state.portfolio.positions.values())
|
|
29
|
+
total_unrealized = sum(p.unrealized_pnl for p in state.portfolio.open_positions())
|
|
30
|
+
total_fees = sum(p.fees_paid for p in state.portfolio.positions.values())
|
|
31
|
+
|
|
32
|
+
total_return = (equity - self.initial_capital) / self.initial_capital if self.initial_capital > 0 else 0.0
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
'equity': equity,
|
|
36
|
+
'cash': cash,
|
|
37
|
+
'total_return': total_return,
|
|
38
|
+
'realized_pnl': total_realized,
|
|
39
|
+
'unrealized_pnl': total_unrealized,
|
|
40
|
+
'total_fees': total_fees,
|
|
41
|
+
'open_positions': len(state.portfolio.open_positions()),
|
|
42
|
+
'closed_positions': len([p for p in state.portfolio.positions.values() if p.is_closed]),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
@sf_component(name='balance_allocation', override=True)
|
|
47
|
+
class BalanceAllocationMetric(StrategyMetric):
|
|
48
|
+
|
|
49
|
+
initial_capital: float = 10000.0
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
return 'balance_allocation'
|
|
54
|
+
|
|
55
|
+
def compute(self, state: StrategyState, prices: dict[str, float]) -> dict[str, float]:
|
|
56
|
+
equity = state.portfolio.equity(prices=prices)
|
|
57
|
+
cash = state.portfolio.cash
|
|
58
|
+
|
|
59
|
+
positions_value = equity - cash
|
|
60
|
+
capital_utilization = positions_value / equity if equity > 0 else 0.0
|
|
61
|
+
free_balance_pct = cash / equity if equity > 0 else 0.0
|
|
62
|
+
allocation_vs_initial = positions_value / self.initial_capital if self.initial_capital > 0 else 0.0
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
'capital_utilization': capital_utilization,
|
|
66
|
+
'free_balance_pct': free_balance_pct,
|
|
67
|
+
'allocated_value': positions_value,
|
|
68
|
+
'allocation_vs_initial': allocation_vs_initial
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
@sf_component(name='drawdown', override=True)
|
|
74
|
+
class DrawdownMetric(StrategyMetric):
|
|
75
|
+
|
|
76
|
+
_peak_equity: float = 0.0
|
|
77
|
+
_max_drawdown: float = 0.0
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def name(self) -> str:
|
|
81
|
+
return 'drawdown'
|
|
82
|
+
|
|
83
|
+
def compute(self, state: StrategyState, prices: dict[str, float]) -> dict[str, float]:
|
|
84
|
+
equity = state.portfolio.equity(prices=prices)
|
|
85
|
+
|
|
86
|
+
if equity > self._peak_equity:
|
|
87
|
+
self._peak_equity = equity
|
|
88
|
+
|
|
89
|
+
current_drawdown = 0.0
|
|
90
|
+
if self._peak_equity > 0:
|
|
91
|
+
current_drawdown = (self._peak_equity - equity) / self._peak_equity
|
|
92
|
+
|
|
93
|
+
if current_drawdown > self._max_drawdown:
|
|
94
|
+
self._max_drawdown = current_drawdown
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
'current_drawdown': current_drawdown,
|
|
98
|
+
'max_drawdown': self._max_drawdown,
|
|
99
|
+
'peak_equity': self._peak_equity
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
@sf_component(name='win_rate', override=True)
|
|
105
|
+
class WinRateMetric(StrategyMetric):
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def name(self) -> str:
|
|
109
|
+
return 'win_rate'
|
|
110
|
+
|
|
111
|
+
def compute(self, state: StrategyState, prices: dict[str, float]) -> dict[str, float]:
|
|
112
|
+
closed_positions = [
|
|
113
|
+
p for p in state.portfolio.positions.values()
|
|
114
|
+
if p.is_closed
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if not closed_positions:
|
|
118
|
+
return {
|
|
119
|
+
'win_rate': 0.0,
|
|
120
|
+
'winning_trades': 0,
|
|
121
|
+
'losing_trades': 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
winning = sum(1 for p in closed_positions if p.realized_pnl > 0)
|
|
125
|
+
losing = sum(1 for p in closed_positions if p.realized_pnl <= 0)
|
|
126
|
+
|
|
127
|
+
win_rate = winning / len(closed_positions) if closed_positions else 0.0
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
'win_rate': win_rate,
|
|
131
|
+
'winning_trades': winning,
|
|
132
|
+
'losing_trades': losing
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
@sf_component(name='sharpe_ratio', override=True)
|
|
138
|
+
class SharpeRatioMetric(StrategyMetric):
|
|
139
|
+
|
|
140
|
+
initial_capital: float = 10000.0
|
|
141
|
+
window_size: int = 100
|
|
142
|
+
risk_free_rate: float = 0.0
|
|
143
|
+
_returns_history: list[float] = None
|
|
144
|
+
|
|
145
|
+
def __post_init__(self):
|
|
146
|
+
self._returns_history = []
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def name(self) -> str:
|
|
150
|
+
return 'sharpe_ratio'
|
|
151
|
+
|
|
152
|
+
def compute(self, state: StrategyState, prices: dict[str, float]) -> dict[str, float]:
|
|
153
|
+
import numpy as np
|
|
154
|
+
|
|
155
|
+
equity = state.portfolio.equity(prices=prices)
|
|
156
|
+
current_return = (equity - self.initial_capital) / self.initial_capital
|
|
157
|
+
|
|
158
|
+
self._returns_history.append(current_return)
|
|
159
|
+
|
|
160
|
+
if len(self._returns_history) > self.window_size:
|
|
161
|
+
self._returns_history.pop(0)
|
|
162
|
+
|
|
163
|
+
if len(self._returns_history) < 2:
|
|
164
|
+
return {'sharpe_ratio': 0.0}
|
|
165
|
+
|
|
166
|
+
returns_array = np.array(self._returns_history)
|
|
167
|
+
returns_diff = np.diff(returns_array)
|
|
168
|
+
|
|
169
|
+
if len(returns_diff) < 2:
|
|
170
|
+
return {'sharpe_ratio': 0.0}
|
|
171
|
+
|
|
172
|
+
mean_return = np.mean(returns_diff)
|
|
173
|
+
std_return = np.std(returns_diff)
|
|
174
|
+
|
|
175
|
+
if std_return == 0:
|
|
176
|
+
return {'sharpe_ratio': 0.0}
|
|
177
|
+
|
|
178
|
+
sharpe = (mean_return - self.risk_free_rate) / std_return
|
|
179
|
+
|
|
180
|
+
return {'sharpe_ratio': sharpe}
|
|
181
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from signalflow.strategy.runner.base import StrategyRunner
|
|
2
|
+
from signalflow.strategy.runner.backtest_runner import BacktestRunner
|
|
3
|
+
from signalflow.strategy.runner.optimized_backtest_runner import OptimizedBacktestRunner
|
|
4
|
+
__all__ = [
|
|
5
|
+
"StrategyRunner",
|
|
6
|
+
"BacktestRunner",
|
|
7
|
+
"OptimizedBacktestRunner",
|
|
8
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
|
|
2
|
+
"""Backtest runner - orchestrates the backtesting loop."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
import polars as pl
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from signalflow.core.containers.raw_data import RawData
|
|
11
|
+
from signalflow.core.containers.signals import Signals
|
|
12
|
+
from signalflow.core.containers.strategy_state import StrategyState
|
|
13
|
+
from signalflow.core.containers.trade import Trade
|
|
14
|
+
from signalflow.core.decorators import sf_component
|
|
15
|
+
from signalflow.strategy.component.base import EntryRule, ExitRule, StrategyMetric
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
from signalflow.strategy.runner.base import StrategyRunner
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
@sf_component(name='backtest_runner')
|
|
21
|
+
class BacktestRunner(StrategyRunner):
|
|
22
|
+
"""
|
|
23
|
+
Runs backtests over historical data.
|
|
24
|
+
|
|
25
|
+
Execution flow per bar:
|
|
26
|
+
1. Mark prices on all positions
|
|
27
|
+
2. Compute metrics
|
|
28
|
+
3. Check and execute exits
|
|
29
|
+
4. Check and execute entries
|
|
30
|
+
|
|
31
|
+
This order ensures:
|
|
32
|
+
- Metrics reflect current market state
|
|
33
|
+
- Exits are processed before entries (can close and re-enter same bar)
|
|
34
|
+
- No look-ahead bias
|
|
35
|
+
"""
|
|
36
|
+
strategy_id: str = 'backtest'
|
|
37
|
+
broker: Any = None
|
|
38
|
+
entry_rules: list[EntryRule] = field(default_factory=list)
|
|
39
|
+
exit_rules: list[ExitRule] = field(default_factory=list)
|
|
40
|
+
metrics: list[StrategyMetric] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
initial_capital: float = 10000.0
|
|
43
|
+
pair_col: str = 'pair'
|
|
44
|
+
ts_col: str = 'timestamp'
|
|
45
|
+
price_col: str = 'close'
|
|
46
|
+
data_key: str = 'spot'
|
|
47
|
+
|
|
48
|
+
_trades: list[Trade] = field(default_factory=list, init=False)
|
|
49
|
+
_metrics_history: list[dict] = field(default_factory=list, init=False)
|
|
50
|
+
|
|
51
|
+
def run(
|
|
52
|
+
self,
|
|
53
|
+
raw_data: RawData,
|
|
54
|
+
signals: Signals,
|
|
55
|
+
state: StrategyState | None = None
|
|
56
|
+
) -> StrategyState:
|
|
57
|
+
"""
|
|
58
|
+
Run backtest over the entire dataset.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
raw_data: Historical OHLCV data
|
|
62
|
+
signals: Pre-computed signals for the period
|
|
63
|
+
state: Optional initial state (for continuing backtests)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Final strategy state
|
|
67
|
+
"""
|
|
68
|
+
if state is None:
|
|
69
|
+
state = StrategyState(
|
|
70
|
+
strategy_id=self.strategy_id,
|
|
71
|
+
)
|
|
72
|
+
state.portfolio.cash = self.initial_capital
|
|
73
|
+
|
|
74
|
+
self._trades = []
|
|
75
|
+
self._metrics_history = []
|
|
76
|
+
|
|
77
|
+
# Get data
|
|
78
|
+
df = raw_data.get(self.data_key)
|
|
79
|
+
if df.height == 0:
|
|
80
|
+
logger.warning("No data to backtest")
|
|
81
|
+
return state
|
|
82
|
+
|
|
83
|
+
timestamps = df.select(self.ts_col).unique().sort(self.ts_col).get_column(self.ts_col)
|
|
84
|
+
|
|
85
|
+
signals_df = signals.value if signals else pl.DataFrame()
|
|
86
|
+
|
|
87
|
+
logger.info(f"Starting backtest: {len(timestamps)} bars, {signals_df.height} signals")
|
|
88
|
+
|
|
89
|
+
for ts in tqdm(timestamps, desc="Processing bars"):
|
|
90
|
+
state = self._process_bar(
|
|
91
|
+
ts=ts,
|
|
92
|
+
raw_df=df,
|
|
93
|
+
signals_df=signals_df,
|
|
94
|
+
state=state
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
logger.info(
|
|
98
|
+
f"Backtest complete: {len(self._trades)} trades, "
|
|
99
|
+
f"{len(state.portfolio.open_positions())} open positions"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return state
|
|
103
|
+
|
|
104
|
+
def _process_bar(
|
|
105
|
+
self,
|
|
106
|
+
ts: datetime,
|
|
107
|
+
raw_df: pl.DataFrame,
|
|
108
|
+
signals_df: pl.DataFrame,
|
|
109
|
+
state: StrategyState
|
|
110
|
+
) -> StrategyState:
|
|
111
|
+
"""Process a single bar."""
|
|
112
|
+
state.touch(ts)
|
|
113
|
+
state.reset_tick_cache()
|
|
114
|
+
|
|
115
|
+
bar_data = raw_df.filter(pl.col(self.ts_col) == ts)
|
|
116
|
+
prices = self._build_prices(bar_data)
|
|
117
|
+
self.broker.mark_positions(state, prices, ts)
|
|
118
|
+
|
|
119
|
+
all_metrics: dict[str, float] = {'timestamp': ts.timestamp()}
|
|
120
|
+
for metric in self.metrics:
|
|
121
|
+
metric_values = metric.compute(state, prices)
|
|
122
|
+
all_metrics.update(metric_values)
|
|
123
|
+
state.metrics = all_metrics
|
|
124
|
+
self._metrics_history.append(all_metrics.copy())
|
|
125
|
+
|
|
126
|
+
exit_orders = []
|
|
127
|
+
open_positions = state.portfolio.open_positions()
|
|
128
|
+
for exit_rule in self.exit_rules:
|
|
129
|
+
orders = exit_rule.check_exits(open_positions, prices, state)
|
|
130
|
+
exit_orders.extend(orders)
|
|
131
|
+
|
|
132
|
+
if exit_orders:
|
|
133
|
+
exit_fills = self.broker.submit_orders(exit_orders, prices, ts)
|
|
134
|
+
exit_trades = self.broker.process_fills(exit_fills, exit_orders, state)
|
|
135
|
+
self._trades.extend(exit_trades)
|
|
136
|
+
|
|
137
|
+
bar_signals = self._get_bar_signals(signals_df, ts)
|
|
138
|
+
|
|
139
|
+
entry_orders = []
|
|
140
|
+
for entry_rule in self.entry_rules:
|
|
141
|
+
orders = entry_rule.check_entries(bar_signals, prices, state)
|
|
142
|
+
entry_orders.extend(orders)
|
|
143
|
+
|
|
144
|
+
if entry_orders:
|
|
145
|
+
entry_fills = self.broker.submit_orders(entry_orders, prices, ts)
|
|
146
|
+
entry_trades = self.broker.process_fills(entry_fills, entry_orders, state)
|
|
147
|
+
self._trades.extend(entry_trades)
|
|
148
|
+
|
|
149
|
+
return state
|
|
150
|
+
|
|
151
|
+
def _build_prices(self, bar_data: pl.DataFrame) -> dict[str, float]:
|
|
152
|
+
"""Build pair -> price mapping from bar data."""
|
|
153
|
+
prices = {}
|
|
154
|
+
for row in bar_data.iter_rows(named=True):
|
|
155
|
+
pair = row.get(self.pair_col)
|
|
156
|
+
price = row.get(self.price_col)
|
|
157
|
+
if pair and price is not None:
|
|
158
|
+
prices[pair] = float(price)
|
|
159
|
+
return prices
|
|
160
|
+
|
|
161
|
+
def _get_bar_signals(self, signals_df: pl.DataFrame, ts: datetime) -> Signals:
|
|
162
|
+
"""Get signals for the current bar."""
|
|
163
|
+
if signals_df.height == 0:
|
|
164
|
+
return Signals(pl.DataFrame())
|
|
165
|
+
|
|
166
|
+
bar_signals = signals_df.filter(pl.col(self.ts_col) == ts)
|
|
167
|
+
return Signals(bar_signals)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def trades(self) -> list[Trade]:
|
|
171
|
+
"""Get all trades from the backtest."""
|
|
172
|
+
return self._trades
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def trades_df(self) -> pl.DataFrame:
|
|
176
|
+
"""Get trades as a DataFrame."""
|
|
177
|
+
from signalflow.core.containers.portfolio import Portfolio
|
|
178
|
+
return Portfolio.trades_to_pl(self._trades)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def metrics_df(self) -> pl.DataFrame:
|
|
182
|
+
"""Get metrics history as a DataFrame."""
|
|
183
|
+
if not self._metrics_history:
|
|
184
|
+
return pl.DataFrame()
|
|
185
|
+
return pl.DataFrame(self._metrics_history)
|
|
186
|
+
|
|
187
|
+
def get_results(self) -> dict[str, Any]:
|
|
188
|
+
"""Get backtest results summary."""
|
|
189
|
+
trades_df = self.trades_df
|
|
190
|
+
metrics_df = self.metrics_df
|
|
191
|
+
|
|
192
|
+
results = {
|
|
193
|
+
'total_trades': len(self._trades),
|
|
194
|
+
'metrics_df': metrics_df,
|
|
195
|
+
'trades_df': trades_df,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if metrics_df.height > 0 and 'total_return' in metrics_df.columns:
|
|
199
|
+
results['final_return'] = metrics_df.select('total_return').tail(1).item()
|
|
200
|
+
results['final_equity'] = metrics_df.select('equity').tail(1).item()
|
|
201
|
+
|
|
202
|
+
if trades_df.height > 0:
|
|
203
|
+
entry_trades = trades_df.filter(pl.col('meta').struct.field('type') == 'entry')
|
|
204
|
+
exit_trades = trades_df.filter(pl.col('meta').struct.field('type') == 'exit')
|
|
205
|
+
results['entry_count'] = entry_trades.height
|
|
206
|
+
results['exit_count'] = exit_trades.height
|
|
207
|
+
|
|
208
|
+
return results
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from signalflow.core import SfComponentType, StrategyState, Position, Order, RawData, Signals
|
|
6
|
+
from signalflow.strategy.broker.base import Broker
|
|
7
|
+
from signalflow.strategy.component.base import EntryRule, ExitRule, StrategyMetric
|
|
8
|
+
|
|
9
|
+
class StrategyRunner(ABC):
|
|
10
|
+
"""Base class for strategy runners."""
|
|
11
|
+
component_type: ClassVar[SfComponentType] = SfComponentType.STRATEGY_RUNNER
|
|
12
|
+
broker: Broker
|
|
13
|
+
entry_rules: list[EntryRule]
|
|
14
|
+
exit_rules: list[ExitRule]
|
|
15
|
+
metrics: list[StrategyMetric]
|
|
16
|
+
|
|
17
|
+
def run(self, raw_data: RawData, signals: Signals, state: StrategyState) -> StrategyState:
|
|
18
|
+
"""Run the strategy."""
|
|
19
|
+
...
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
from signalflow.core import SfComponentType, StrategyState, Signals, sf_component
|
|
5
|
+
from signalflow.strategy.component.base import EntryRule, ExitRule, StrategyMetric
|
|
6
|
+
from signalflow.strategy.runner.base import StrategyRunner
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
@sf_component(name='backtest/optimized', override=True)
|
|
12
|
+
class OptimizedBacktestRunner(StrategyRunner):
|
|
13
|
+
|
|
14
|
+
component_type = SfComponentType.STRATEGY_RUNNER
|
|
15
|
+
strategy_id: str = 'backtest'
|
|
16
|
+
broker: Any = None
|
|
17
|
+
entry_rules: list[EntryRule] = field(default_factory=list)
|
|
18
|
+
exit_rules: list = field(default_factory=list)
|
|
19
|
+
metrics: list[StrategyMetric] = field(default_factory=list)
|
|
20
|
+
initial_capital: float = 10000.0
|
|
21
|
+
pair_col: str = 'pair'
|
|
22
|
+
ts_col: str = 'timestamp'
|
|
23
|
+
price_col: str = 'close'
|
|
24
|
+
data_key: str = 'spot'
|
|
25
|
+
show_progress: bool = True
|
|
26
|
+
_trades: list = field(default_factory=list, init=False)
|
|
27
|
+
_metrics_history: list[dict] = field(default_factory=list, init=False)
|
|
28
|
+
|
|
29
|
+
def run(self, raw_data, signals: Signals, state: StrategyState | None = None):
|
|
30
|
+
from signalflow.core.containers.strategy_state import StrategyState
|
|
31
|
+
from tqdm import tqdm
|
|
32
|
+
|
|
33
|
+
if state is None:
|
|
34
|
+
state = StrategyState(strategy_id=self.strategy_id)
|
|
35
|
+
state.portfolio.cash = self.initial_capital
|
|
36
|
+
|
|
37
|
+
self._trades = []
|
|
38
|
+
self._metrics_history = []
|
|
39
|
+
|
|
40
|
+
df = raw_data.get(self.data_key)
|
|
41
|
+
if df.height == 0:
|
|
42
|
+
return state
|
|
43
|
+
|
|
44
|
+
timestamps = df.select(self.ts_col).unique().sort(self.ts_col).get_column(self.ts_col)
|
|
45
|
+
signals_df = signals.value if signals else pl.DataFrame()
|
|
46
|
+
|
|
47
|
+
price_lookup = self._build_price_lookup(df)
|
|
48
|
+
|
|
49
|
+
signal_lookup = self._build_signal_lookup(signals_df) if signals_df.height > 0 else {}
|
|
50
|
+
|
|
51
|
+
iterator = tqdm(timestamps, desc='Backtesting') if self.show_progress else timestamps
|
|
52
|
+
|
|
53
|
+
for ts in iterator:
|
|
54
|
+
state = self._process_bar_optimized(
|
|
55
|
+
ts=ts,
|
|
56
|
+
price_lookup=price_lookup,
|
|
57
|
+
signal_lookup=signal_lookup,
|
|
58
|
+
state=state
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return state
|
|
62
|
+
|
|
63
|
+
def _build_price_lookup(self, df: pl.DataFrame) -> dict[datetime, dict[str, float]]:
|
|
64
|
+
lookup = {}
|
|
65
|
+
for row in df.select([self.ts_col, self.pair_col, self.price_col]).iter_rows():
|
|
66
|
+
ts, pair, price = row
|
|
67
|
+
if ts not in lookup:
|
|
68
|
+
lookup[ts] = {}
|
|
69
|
+
lookup[ts][pair] = float(price)
|
|
70
|
+
return lookup
|
|
71
|
+
|
|
72
|
+
def _build_signal_lookup(self, signals_df: pl.DataFrame) -> dict[datetime, pl.DataFrame]:
|
|
73
|
+
lookup = {}
|
|
74
|
+
if signals_df.height == 0:
|
|
75
|
+
return lookup
|
|
76
|
+
|
|
77
|
+
for ts in signals_df.select(self.ts_col).unique().get_column(self.ts_col):
|
|
78
|
+
lookup[ts] = signals_df.filter(pl.col(self.ts_col) == ts)
|
|
79
|
+
|
|
80
|
+
return lookup
|
|
81
|
+
|
|
82
|
+
def _process_bar_optimized(
|
|
83
|
+
self,
|
|
84
|
+
ts: datetime,
|
|
85
|
+
price_lookup: dict[datetime, dict[str, float]],
|
|
86
|
+
signal_lookup: dict[datetime, pl.DataFrame],
|
|
87
|
+
state: StrategyState
|
|
88
|
+
) -> StrategyState:
|
|
89
|
+
state.touch(ts)
|
|
90
|
+
state.reset_tick_cache()
|
|
91
|
+
|
|
92
|
+
prices = price_lookup.get(ts, {})
|
|
93
|
+
|
|
94
|
+
self.broker.mark_positions(state, prices, ts)
|
|
95
|
+
|
|
96
|
+
all_metrics: dict[str, float] = {'timestamp': ts.timestamp()}
|
|
97
|
+
for metric in self.metrics:
|
|
98
|
+
metric_values = metric.compute(state, prices)
|
|
99
|
+
all_metrics.update(metric_values)
|
|
100
|
+
state.metrics = all_metrics
|
|
101
|
+
self._metrics_history.append(all_metrics.copy())
|
|
102
|
+
|
|
103
|
+
exit_orders = []
|
|
104
|
+
open_positions = state.portfolio.open_positions()
|
|
105
|
+
for exit_rule in self.exit_rules:
|
|
106
|
+
orders = exit_rule.check_exits(open_positions, prices, state)
|
|
107
|
+
exit_orders.extend(orders)
|
|
108
|
+
|
|
109
|
+
if exit_orders:
|
|
110
|
+
exit_fills = self.broker.submit_orders(exit_orders, prices, ts)
|
|
111
|
+
exit_trades = self.broker.process_fills(exit_fills, exit_orders, state)
|
|
112
|
+
self._trades.extend(exit_trades)
|
|
113
|
+
|
|
114
|
+
bar_signals_df = signal_lookup.get(ts, pl.DataFrame())
|
|
115
|
+
bar_signals = Signals(bar_signals_df)
|
|
116
|
+
|
|
117
|
+
entry_orders = []
|
|
118
|
+
for entry_rule in self.entry_rules:
|
|
119
|
+
orders = entry_rule.check_entries(bar_signals, prices, state)
|
|
120
|
+
entry_orders.extend(orders)
|
|
121
|
+
|
|
122
|
+
if entry_orders:
|
|
123
|
+
entry_fills = self.broker.submit_orders(entry_orders, prices, ts)
|
|
124
|
+
entry_trades = self.broker.process_fills(entry_fills, entry_orders, state)
|
|
125
|
+
self._trades.extend(entry_trades)
|
|
126
|
+
|
|
127
|
+
return state
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def trades(self):
|
|
131
|
+
return self._trades
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def trades_df(self) -> pl.DataFrame:
|
|
135
|
+
from signalflow.core.containers.portfolio import Portfolio
|
|
136
|
+
return Portfolio.trades_to_pl(self._trades)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def metrics_df(self) -> pl.DataFrame:
|
|
140
|
+
if not self._metrics_history:
|
|
141
|
+
return pl.DataFrame()
|
|
142
|
+
return pl.DataFrame(self._metrics_history)
|
|
143
|
+
|
|
144
|
+
def get_results(self) -> dict[str, Any]:
|
|
145
|
+
trades_df = self.trades_df
|
|
146
|
+
metrics_df = self.metrics_df
|
|
147
|
+
|
|
148
|
+
results = {
|
|
149
|
+
'total_trades': len(self._trades),
|
|
150
|
+
'metrics_df': metrics_df,
|
|
151
|
+
'trades_df': trades_df,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if metrics_df.height > 0:
|
|
155
|
+
last_row = metrics_df.tail(1)
|
|
156
|
+
|
|
157
|
+
if 'total_return' in metrics_df.columns:
|
|
158
|
+
results['final_return'] = last_row.select('total_return').item()
|
|
159
|
+
if 'equity' in metrics_df.columns:
|
|
160
|
+
results['final_equity'] = last_row.select('equity').item()
|
|
161
|
+
if 'sharpe_ratio' in metrics_df.columns:
|
|
162
|
+
results['sharpe_ratio'] = last_row.select('sharpe_ratio').item()
|
|
163
|
+
if 'max_drawdown' in metrics_df.columns:
|
|
164
|
+
results['max_drawdown'] = last_row.select('max_drawdown').item()
|
|
165
|
+
if 'win_rate' in metrics_df.columns:
|
|
166
|
+
results['win_rate'] = last_row.select('win_rate').item()
|
|
167
|
+
|
|
168
|
+
if trades_df.height > 0:
|
|
169
|
+
entry_trades = trades_df.filter(
|
|
170
|
+
pl.col('meta').struct.field('type') == 'entry'
|
|
171
|
+
)
|
|
172
|
+
exit_trades = trades_df.filter(
|
|
173
|
+
pl.col('meta').struct.field('type') == 'exit'
|
|
174
|
+
)
|
|
175
|
+
results['entry_count'] = entry_trades.height
|
|
176
|
+
results['exit_count'] = exit_trades.height
|
|
177
|
+
|
|
178
|
+
return results
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from signalflow.target.base import Labeler
|
|
2
|
+
from signalflow.target.fixed_horizon_labeler import FixedHorizonLabeler
|
|
3
|
+
from signalflow.target.static_triple_barrier import StaticTripleBarrierLabeler
|
|
4
|
+
from signalflow.target.triple_barrier import TripleBarrierLabeler
|
|
5
|
+
|
|
6
|
+
import signalflow.target.adapter as adapter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Labeler",
|
|
10
|
+
"FixedHorizonLabeler",
|
|
11
|
+
"StaticTripleBarrierLabeler",
|
|
12
|
+
"TripleBarrierLabeler",
|
|
13
|
+
"adapter",
|
|
14
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from signalflow.target.base import Labeler
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import polars as pl
|
|
5
|
+
from typing import Any
|
|
6
|
+
from abc import abstractmethod
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PandasLabeler(Labeler):
|
|
11
|
+
"""
|
|
12
|
+
Pandas-based labeling implementation, but with the SAME public interface as Labeler:
|
|
13
|
+
extract(pl.DataFrame) -> pl.DataFrame
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
- all business logic is implemented on pandas in compute_pd_group()
|
|
17
|
+
- framework stays polars-first externally
|
|
18
|
+
- conversion happens:
|
|
19
|
+
pl group -> pandas group -> pandas out -> polars out
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def compute_group(self, group_df: pl.DataFrame, data_context: dict[str, Any] | None) -> pl.DataFrame:
|
|
23
|
+
pd_in = group_df.to_pandas()
|
|
24
|
+
pd_out = self.compute_pd_group(pd_in, data_context=data_context)
|
|
25
|
+
|
|
26
|
+
if not isinstance(pd_out, pd.DataFrame):
|
|
27
|
+
raise TypeError(f"{self.__class__.__name__}.compute_pd_group must return pd.DataFrame")
|
|
28
|
+
if len(pd_out) != group_df.height:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"{self.__class__.__name__}: len(output_group)={len(pd_out)} != len(input_group)={group_df.height}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# IMPORTANT: ensure column order + row order preserved by user implementation
|
|
34
|
+
return pl.from_pandas(pd_out, include_index=False)
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def compute_pd_group(self, group_df: pd.DataFrame, data_context: dict[str, Any] | None) -> pd.DataFrame:
|
|
38
|
+
"""
|
|
39
|
+
Pandas labeling per pair.
|
|
40
|
+
|
|
41
|
+
MUST:
|
|
42
|
+
- preserve row order
|
|
43
|
+
- preserve row count (no filtering)
|
|
44
|
+
"""
|
|
45
|
+
raise NotImplementedError
|