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.
Files changed (90) hide show
  1. signalflow/__init__.py +21 -0
  2. signalflow/analytics/__init__.py +0 -0
  3. signalflow/core/__init__.py +46 -0
  4. signalflow/core/base_mixin.py +232 -0
  5. signalflow/core/containers/__init__.py +21 -0
  6. signalflow/core/containers/order.py +216 -0
  7. signalflow/core/containers/portfolio.py +211 -0
  8. signalflow/core/containers/position.py +296 -0
  9. signalflow/core/containers/raw_data.py +167 -0
  10. signalflow/core/containers/raw_data_view.py +169 -0
  11. signalflow/core/containers/signals.py +198 -0
  12. signalflow/core/containers/strategy_state.py +147 -0
  13. signalflow/core/containers/trade.py +112 -0
  14. signalflow/core/decorators.py +103 -0
  15. signalflow/core/enums.py +270 -0
  16. signalflow/core/registry.py +322 -0
  17. signalflow/core/rolling_aggregator.py +362 -0
  18. signalflow/core/signal_transforms/__init__.py +5 -0
  19. signalflow/core/signal_transforms/base_signal_transform.py +186 -0
  20. signalflow/data/__init__.py +11 -0
  21. signalflow/data/raw_data_factory.py +225 -0
  22. signalflow/data/raw_store/__init__.py +7 -0
  23. signalflow/data/raw_store/base.py +271 -0
  24. signalflow/data/raw_store/duckdb_stores.py +696 -0
  25. signalflow/data/source/__init__.py +10 -0
  26. signalflow/data/source/base.py +300 -0
  27. signalflow/data/source/binance.py +442 -0
  28. signalflow/data/strategy_store/__init__.py +8 -0
  29. signalflow/data/strategy_store/base.py +278 -0
  30. signalflow/data/strategy_store/duckdb.py +409 -0
  31. signalflow/data/strategy_store/schema.py +36 -0
  32. signalflow/detector/__init__.py +7 -0
  33. signalflow/detector/adapter/__init__.py +5 -0
  34. signalflow/detector/adapter/pandas_detector.py +46 -0
  35. signalflow/detector/base.py +390 -0
  36. signalflow/detector/sma_cross.py +105 -0
  37. signalflow/feature/__init__.py +16 -0
  38. signalflow/feature/adapter/__init__.py +5 -0
  39. signalflow/feature/adapter/pandas_feature_extractor.py +54 -0
  40. signalflow/feature/base.py +330 -0
  41. signalflow/feature/feature_set.py +286 -0
  42. signalflow/feature/oscillator/__init__.py +5 -0
  43. signalflow/feature/oscillator/rsi_extractor.py +42 -0
  44. signalflow/feature/pandasta/__init__.py +10 -0
  45. signalflow/feature/pandasta/pandas_ta_extractor.py +141 -0
  46. signalflow/feature/pandasta/top_pandasta_extractors.py +64 -0
  47. signalflow/feature/smoother/__init__.py +5 -0
  48. signalflow/feature/smoother/sma_extractor.py +46 -0
  49. signalflow/strategy/__init__.py +9 -0
  50. signalflow/strategy/broker/__init__.py +15 -0
  51. signalflow/strategy/broker/backtest.py +172 -0
  52. signalflow/strategy/broker/base.py +186 -0
  53. signalflow/strategy/broker/executor/__init__.py +9 -0
  54. signalflow/strategy/broker/executor/base.py +35 -0
  55. signalflow/strategy/broker/executor/binance_spot.py +12 -0
  56. signalflow/strategy/broker/executor/virtual_spot.py +81 -0
  57. signalflow/strategy/broker/realtime_spot.py +12 -0
  58. signalflow/strategy/component/__init__.py +9 -0
  59. signalflow/strategy/component/base.py +65 -0
  60. signalflow/strategy/component/entry/__init__.py +7 -0
  61. signalflow/strategy/component/entry/fixed_size.py +57 -0
  62. signalflow/strategy/component/entry/signal.py +127 -0
  63. signalflow/strategy/component/exit/__init__.py +5 -0
  64. signalflow/strategy/component/exit/time_based.py +47 -0
  65. signalflow/strategy/component/exit/tp_sl.py +80 -0
  66. signalflow/strategy/component/metric/__init__.py +8 -0
  67. signalflow/strategy/component/metric/main_metrics.py +181 -0
  68. signalflow/strategy/runner/__init__.py +8 -0
  69. signalflow/strategy/runner/backtest_runner.py +208 -0
  70. signalflow/strategy/runner/base.py +19 -0
  71. signalflow/strategy/runner/optimized_backtest_runner.py +178 -0
  72. signalflow/strategy/runner/realtime_runner.py +0 -0
  73. signalflow/target/__init__.py +14 -0
  74. signalflow/target/adapter/__init__.py +5 -0
  75. signalflow/target/adapter/pandas_labeler.py +45 -0
  76. signalflow/target/base.py +409 -0
  77. signalflow/target/fixed_horizon_labeler.py +93 -0
  78. signalflow/target/static_triple_barrier.py +162 -0
  79. signalflow/target/triple_barrier.py +188 -0
  80. signalflow/utils/__init__.py +7 -0
  81. signalflow/utils/import_utils.py +11 -0
  82. signalflow/utils/tune_utils.py +19 -0
  83. signalflow/validator/__init__.py +6 -0
  84. signalflow/validator/base.py +139 -0
  85. signalflow/validator/sklearn_validator.py +527 -0
  86. signalflow_trading-0.2.1.dist-info/METADATA +149 -0
  87. signalflow_trading-0.2.1.dist-info/RECORD +90 -0
  88. signalflow_trading-0.2.1.dist-info/WHEEL +5 -0
  89. signalflow_trading-0.2.1.dist-info/licenses/LICENSE +21 -0
  90. 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,5 @@
1
+ from signalflow.target.adapter.pandas_labeler import PandasLabeler
2
+
3
+ __all__ = [
4
+ "PandasLabeler",
5
+ ]
@@ -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