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,141 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+
8
+ from signalflow.feature.adapter.pandas_feature_extractor import PandasFeatureExtractor
9
+ from signalflow.core import sf_component
10
+
11
+
12
+ @dataclass
13
+ @sf_component(name="pta")
14
+ class PandasTaExtractor(PandasFeatureExtractor):
15
+ """
16
+ Polars-first Pandas-TA adapter.
17
+
18
+ This extractor runs pandas-ta inside `pandas_group_fn` per (pair, resample_offset) group,
19
+ then merges produced feature columns back into the Polars pipeline.
20
+
21
+ Key guarantees:
22
+ - pandas-ta output is normalized to pd.DataFrame
23
+ - output length matches input group length
24
+ - output columns are namespaced to avoid collisions across extractors
25
+ """
26
+
27
+ indicator: str = "rsi"
28
+ params: dict[str, Any] = field(default_factory=dict)
29
+
30
+ input_column: str = "close"
31
+ additional_inputs: dict[str, str] = field(default_factory=dict)
32
+
33
+ feature_prefix: str | None = None
34
+
35
+ def __post_init__(self) -> None:
36
+ try:
37
+ import pandas_ta as _
38
+ except ImportError as e:
39
+ raise ImportError("pandas-ta is required. Install with: pip install pandas-ta") from e
40
+
41
+ if not isinstance(self.indicator, str) or not self.indicator.strip():
42
+ raise ValueError("indicator name must be a non-empty string")
43
+
44
+ if not isinstance(self.input_column, str) or not self.input_column.strip():
45
+ raise ValueError("input_column must be a non-empty string")
46
+
47
+ if not isinstance(self.params, dict):
48
+ raise TypeError(f"params must be dict[str, Any], got {type(self.params)}")
49
+
50
+ if not isinstance(self.additional_inputs, dict):
51
+ raise TypeError(f"additional_inputs must be dict[str, str], got {type(self.additional_inputs)}")
52
+
53
+ for k, v in self.additional_inputs.items():
54
+ if not isinstance(k, str) or not k.strip():
55
+ raise TypeError(f"additional_inputs keys must be non-empty str, got {k!r}")
56
+ if not isinstance(v, str) or not v.strip():
57
+ raise TypeError(f"additional_inputs values must be non-empty str column names, got {v!r}")
58
+
59
+ self.pandas_group_fn = self._pandas_ta_group_fn
60
+
61
+ super().__post_init__()
62
+
63
+ def _pandas_ta_group_fn(self, group: pd.DataFrame, ctx: dict[str, Any] | None) -> pd.DataFrame:
64
+ import pandas_ta as ta
65
+
66
+ self._validate_required_columns(group)
67
+
68
+ try:
69
+ indicator_func = getattr(ta, self.indicator)
70
+ except AttributeError as e:
71
+ raise AttributeError(f"Indicator '{self.indicator}' not found in pandas-ta.") from e
72
+
73
+ kwargs = dict(self.params)
74
+
75
+ primary_input = group[self.input_column]
76
+ for param_name, column_name in self.additional_inputs.items():
77
+ kwargs[param_name] = group[column_name]
78
+
79
+ out = indicator_func(primary_input, **kwargs)
80
+
81
+ out_df = self._normalize_output(out, group_len=len(group))
82
+ out_df = self._namespace_columns(out_df)
83
+
84
+ return out_df
85
+
86
+ def _validate_required_columns(self, df: pd.DataFrame) -> None:
87
+ required = [self.input_column, *self.additional_inputs.values()]
88
+ missing = sorted(set(required) - set(df.columns))
89
+ if missing:
90
+ raise ValueError(f"Missing required columns for pandas-ta: {missing}")
91
+
92
+ def _normalize_output(self, out: Any, group_len: int) -> pd.DataFrame:
93
+ """
94
+ Normalize pandas-ta output to pd.DataFrame and ensure length matches group.
95
+ """
96
+ if isinstance(out, pd.Series):
97
+ out_df = out.to_frame()
98
+ col = out_df.columns[0]
99
+ if col is None or (isinstance(col, str) and not col.strip()):
100
+ out_df.columns = [self.indicator]
101
+ elif isinstance(out, pd.DataFrame):
102
+ out_df = out
103
+ if out_df.columns.isnull().any():
104
+ out_df = out_df.copy()
105
+ out_df.columns = [
106
+ c if (c is not None and (not isinstance(c, str) or c.strip())) else f"{self.indicator}_{i}"
107
+ for i, c in enumerate(out_df.columns)
108
+ ]
109
+ else:
110
+ raise TypeError(
111
+ f"pandas-ta '{self.indicator}' returned unsupported type: {type(out)}. "
112
+ f"Expected pd.Series or pd.DataFrame."
113
+ )
114
+
115
+ if len(out_df) != group_len:
116
+ raise ValueError(
117
+ f"{self.__class__.__name__}: len(output_group)={len(out_df)} != len(input_group)={group_len}"
118
+ )
119
+
120
+ return out_df
121
+
122
+ def _namespace_columns(self, df: pd.DataFrame) -> pd.DataFrame:
123
+ """
124
+ Prefix output columns to avoid collisions across different indicators/extractors.
125
+ """
126
+ prefix = self.feature_prefix or self.indicator
127
+ prefix = str(prefix).strip()
128
+
129
+ df = df.copy()
130
+ new_cols: list[str] = []
131
+ for i, c in enumerate(df.columns):
132
+ name = str(c) if c is not None else f"{self.indicator}_{i}"
133
+ name = name.strip() or f"{self.indicator}_{i}"
134
+
135
+ if name == prefix or name.startswith(prefix + "_"):
136
+ new_cols.append(name)
137
+ else:
138
+ new_cols.append(f"{prefix}_{name}")
139
+
140
+ df.columns = new_cols
141
+ return df
@@ -0,0 +1,64 @@
1
+
2
+
3
+ from dataclasses import dataclass
4
+ from signalflow.feature.pandasta.pandas_ta_extractor import PandasTaExtractor
5
+ from signalflow.core import sf_component
6
+
7
+
8
+ @dataclass
9
+ @sf_component(name="pta/rsi")
10
+ class PandasTaRsiExtractor(PandasTaExtractor):
11
+ length: int = 14
12
+
13
+ def __post_init__(self) -> None:
14
+ self.indicator = "rsi"
15
+ self.params = {"length": int(self.length)}
16
+ self.input_column = "close"
17
+ self.additional_inputs = {}
18
+ self.feature_prefix = "rsi"
19
+ super().__post_init__()
20
+
21
+
22
+ @dataclass
23
+ @sf_component(name="pta/bbands")
24
+ class PandasTaBbandsExtractor(PandasTaExtractor):
25
+ length: int = 20
26
+ std: float = 2.0
27
+
28
+ def __post_init__(self) -> None:
29
+ self.indicator = "bbands"
30
+ self.params = {"length": int(self.length), "std": float(self.std)}
31
+ self.input_column = "close"
32
+ self.additional_inputs = {}
33
+ self.feature_prefix = f"bbands_{int(self.length)}_{float(self.std)}"
34
+ super().__post_init__()
35
+
36
+
37
+ @dataclass
38
+ @sf_component(name="pta/macd")
39
+ class PandasTaMacdExtractor(PandasTaExtractor):
40
+ fast: int = 12
41
+ slow: int = 26
42
+ signal: int = 9
43
+
44
+ def __post_init__(self) -> None:
45
+ self.indicator = "macd"
46
+ self.params = {"fast": int(self.fast), "slow": int(self.slow), "signal": int(self.signal)}
47
+ self.input_column = "close"
48
+ self.additional_inputs = {}
49
+ self.feature_prefix = f"macd_{int(self.fast)}_{int(self.slow)}_{int(self.signal)}"
50
+ super().__post_init__()
51
+
52
+
53
+ @dataclass
54
+ @sf_component(name="pta/atr")
55
+ class PandasTaAtrExtractor(PandasTaExtractor):
56
+ length: int = 14
57
+
58
+ def __post_init__(self) -> None:
59
+ self.indicator = "atr"
60
+ self.params = {"length": int(self.length)}
61
+ self.input_column = "high"
62
+ self.additional_inputs = {"low": "low", "close": "close"}
63
+ self.feature_prefix = f"atr_{int(self.length)}"
64
+ super().__post_init__()
@@ -0,0 +1,5 @@
1
+ from signalflow.feature.smoother.sma_extractor import SmaExtractor
2
+
3
+ __all__ = [
4
+ "SmaExtractor",
5
+ ]
@@ -0,0 +1,46 @@
1
+ # src/signalflow/feature/extractor/sma_extractor.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import polars as pl
6
+
7
+
8
+ from signalflow.feature.base import FeatureExtractor
9
+ from signalflow.core import sf_component
10
+
11
+
12
+ @dataclass
13
+ @sf_component(name="smooth/sma")
14
+ class SmaExtractor(FeatureExtractor):
15
+ """
16
+ SMA per (pair, resample_offset) group.
17
+
18
+ Notes:
19
+ - offset_window here is for RollingAggregator (your framework requirement).
20
+ SMA window is `sma_period`.
21
+ - In v1 you said only spot -> keep data_type="spot" by default.
22
+ """
23
+ offset_window: int = 1
24
+ use_resample: bool = True
25
+
26
+ sma_period: int = 20
27
+ price_col: str = "close"
28
+ out_col: str = "sma"
29
+
30
+ def __post_init__(self) -> None:
31
+ super().__post_init__()
32
+ if self.sma_period <= 0:
33
+ raise ValueError(f"sma_period must be > 0, got {self.sma_period}")
34
+ if not self.out_col:
35
+ self.out_col = "sma"
36
+
37
+ def compute_group(self, group_df: pl.DataFrame, data_context: dict | None) -> pl.DataFrame:
38
+ if self.price_col not in group_df.columns:
39
+ raise ValueError(f"Missing required column: {self.price_col}")
40
+
41
+ sma = (
42
+ pl.col(self.price_col)
43
+ .rolling_mean(window_size=self.sma_period, min_samples=self.sma_period)
44
+ .alias(self.out_col)
45
+ )
46
+ return group_df.with_columns(sma)
@@ -0,0 +1,9 @@
1
+ import signalflow.strategy.broker as broker
2
+ import signalflow.strategy.component as component
3
+ import signalflow.strategy.runner as runner
4
+
5
+ __all__ = [
6
+ "broker",
7
+ "component",
8
+ "runner",
9
+ ]
@@ -0,0 +1,15 @@
1
+ """
2
+ SignalFlow Broker Module.
3
+
4
+ Broker handles order execution and state persistence.
5
+ """
6
+ import signalflow.strategy.broker.executor as executor
7
+ from signalflow.strategy.broker.base import Broker
8
+ from signalflow.strategy.broker.backtest import BacktestBroker
9
+
10
+
11
+ __all__ = [
12
+ "executor",
13
+ "Broker",
14
+ "BacktestBroker",
15
+ ]
@@ -0,0 +1,172 @@
1
+ """Backtest broker implementation."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import ClassVar
6
+ import uuid
7
+
8
+ from signalflow.core.enums import SfComponentType, PositionType
9
+ from signalflow.core.decorators import sf_component
10
+ from signalflow.core.containers.position import Position
11
+ from signalflow.core.containers.trade import Trade
12
+ from signalflow.core.containers.order import Order, OrderFill
13
+ from signalflow.core.containers.strategy_state import StrategyState
14
+ from signalflow.strategy.broker.base import Broker
15
+ from signalflow.strategy.broker.executor.base import OrderExecutor
16
+ from signalflow.data.strategy_store.base import StrategyStore
17
+
18
+
19
+ @dataclass
20
+ @sf_component(name='backtest', override=True)
21
+ class BacktestBroker(Broker):
22
+ """
23
+ Broker for backtesting - handles order execution, position management, and state persistence.
24
+
25
+ Execution flow:
26
+ 1. Mark prices on positions
27
+ 2. Submit orders -> get fills
28
+ 3. Apply fills to positions
29
+ 4. Persist state
30
+ """
31
+ component_type: ClassVar[SfComponentType] = SfComponentType.STRATEGY_BROKER
32
+
33
+ def create_position(self, order: Order, fill: OrderFill) -> Position:
34
+ """Create a new position from an order fill."""
35
+ # Determine position type from order side
36
+ position_type = PositionType.LONG if order.side == 'BUY' else PositionType.SHORT
37
+
38
+ position = Position(
39
+ id=str(uuid.uuid4()),
40
+ is_closed=False,
41
+ pair=fill.pair,
42
+ position_type=position_type,
43
+ signal_strength=order.signal_strength,
44
+ entry_time=fill.ts,
45
+ last_time=fill.ts,
46
+ entry_price=fill.price,
47
+ last_price=fill.price,
48
+ qty=fill.qty,
49
+ fees_paid=fill.fee,
50
+ realized_pnl=0.0,
51
+ meta={
52
+ 'order_id': order.id,
53
+ 'fill_id': fill.id,
54
+ **order.meta
55
+ }
56
+ )
57
+ return position
58
+
59
+ def apply_fill_to_position(self, position: Position, fill: OrderFill) -> None:
60
+ """Apply a fill to an existing position."""
61
+ trade = Trade(
62
+ id=fill.id,
63
+ position_id=position.id,
64
+ pair=fill.pair,
65
+ side=fill.side,
66
+ ts=fill.ts,
67
+ price=fill.price,
68
+ qty=fill.qty,
69
+ fee=fill.fee,
70
+ meta=fill.meta
71
+ )
72
+ position.apply_trade(trade)
73
+
74
+ def process_fills(
75
+ self,
76
+ fills: list[OrderFill],
77
+ orders: list[Order],
78
+ state: StrategyState
79
+ ) -> list[Trade]:
80
+ """
81
+ Process fills and UPDATE CASH BALANCE.
82
+
83
+ FIX: Properly update portfolio.cash when opening/closing positions
84
+ """
85
+ trades: list[Trade] = []
86
+ order_map = {o.id: o for o in orders}
87
+
88
+ for fill in fills:
89
+ order = order_map.get(fill.order_id)
90
+ if order is None:
91
+ continue
92
+
93
+ notional = fill.price * fill.qty
94
+
95
+ if fill.position_id and fill.position_id in state.portfolio.positions:
96
+ position = state.portfolio.positions[fill.position_id]
97
+ self.apply_fill_to_position(position, fill)
98
+
99
+ if fill.side == 'SELL':
100
+ state.portfolio.cash += (notional - fill.fee)
101
+ elif fill.side == 'BUY':
102
+ state.portfolio.cash -= (notional + fill.fee)
103
+
104
+ trade = Trade(
105
+ id=fill.id,
106
+ position_id=position.id,
107
+ pair=fill.pair,
108
+ side=fill.side,
109
+ ts=fill.ts,
110
+ price=fill.price,
111
+ qty=fill.qty,
112
+ fee=fill.fee,
113
+ meta={'type': 'exit', **fill.meta}
114
+ )
115
+ trades.append(trade)
116
+
117
+ else:
118
+ position = self.create_position(order, fill)
119
+ state.portfolio.positions[position.id] = position
120
+
121
+ if fill.side == 'BUY':
122
+ state.portfolio.cash -= (notional + fill.fee)
123
+ elif fill.side == 'SELL':
124
+ state.portfolio.cash += (notional - fill.fee)
125
+
126
+ trade = Trade(
127
+ id=fill.id,
128
+ position_id=position.id,
129
+ pair=fill.pair,
130
+ side=fill.side,
131
+ ts=fill.ts,
132
+ price=fill.price,
133
+ qty=fill.qty,
134
+ fee=fill.fee,
135
+ meta={'type': 'entry', **fill.meta}
136
+ )
137
+ trades.append(trade)
138
+
139
+ return trades
140
+
141
+ def mark_positions(
142
+ self,
143
+ state: StrategyState,
144
+ prices: dict[str, float],
145
+ ts: datetime
146
+ ) -> None:
147
+ """Mark all open positions to current prices."""
148
+ for position in state.portfolio.open_positions():
149
+ price = prices.get(position.pair)
150
+ if price is not None and price > 0:
151
+ position.mark(ts=ts, price=price)
152
+
153
+ def get_open_position_for_pair(
154
+ self,
155
+ state: StrategyState,
156
+ pair: str
157
+ ) -> Position | None:
158
+ """Get open position for a specific pair, if any."""
159
+ for pos in state.portfolio.open_positions():
160
+ if pos.pair == pair:
161
+ return pos
162
+ return None
163
+
164
+ def get_open_positions_by_pair(
165
+ self,
166
+ state: StrategyState
167
+ ) -> dict[str, Position]:
168
+ """Get dict of pair -> open position."""
169
+ return {
170
+ pos.pair: pos
171
+ for pos in state.portfolio.open_positions()
172
+ }
@@ -0,0 +1,186 @@
1
+ """
2
+ SignalFlow Broker Base.
3
+
4
+ Broker is the bridge between Strategy (business logic) and execution.
5
+ It handles:
6
+ - Order execution (backtest or live)
7
+ - State persistence
8
+ - Fill synchronization
9
+
10
+ Key principle: Portfolio changes ONLY through fills.
11
+ Strategy generates intents (orders), Broker executes them.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from signalflow.strategy.broker.executor.base import OrderExecutor
19
+ from signalflow.data.strategy_store.base import StrategyStore
20
+ from signalflow.core import Position, Order, OrderFill, StrategyState
21
+ from signalflow.core.enums import SfComponentType
22
+ from typing import ClassVar
23
+
24
+
25
+ @dataclass
26
+ class Broker(ABC):
27
+ """
28
+ Base Broker class.
29
+
30
+ Combines execution and storage. Single source of truth through fills:
31
+ - Strategy generates orders (intents)
32
+ - Broker executes them and returns fills
33
+ - Fills are the ONLY way portfolio changes
34
+
35
+ This design ensures:
36
+ - Clean separation between intent and execution
37
+ - Easy switch from backtest to live (just swap executor)
38
+ - Proper state recovery on restart
39
+
40
+ Attributes:
41
+ executor: Order execution implementation
42
+ store: State persistence implementation
43
+ fee_rate: Trading fee rate (e.g., 0.001 = 0.1%)
44
+ """
45
+
46
+ component_type: ClassVar[SfComponentType] = SfComponentType.STRATEGY_BROKER
47
+ executor: OrderExecutor
48
+ store: StrategyStore
49
+ fee_rate: float = 0.001
50
+
51
+ _pending_fills: list[OrderFill] = field(default_factory=list)
52
+
53
+
54
+ def submit_orders(
55
+ self,
56
+ orders: list[Order],
57
+ prices: dict[str, float],
58
+ ts: datetime,
59
+ ) -> list[OrderFill]:
60
+ """
61
+ Submit orders for execution.
62
+
63
+ Args:
64
+ orders: Orders to execute
65
+ prices: Current prices per pair
66
+ ts: Current timestamp
67
+
68
+ Returns:
69
+ List of fills from execution
70
+ """
71
+ if not orders:
72
+ return []
73
+
74
+ fills = self.executor.execute(orders, prices, ts)
75
+
76
+ # Persist fills
77
+ if fills:
78
+ strategy_id = self._get_strategy_id(orders)
79
+ if strategy_id:
80
+ self.store.save_fills(strategy_id, fills)
81
+
82
+ return fills
83
+
84
+ def _get_strategy_id(self, orders: list[Order]) -> str | None:
85
+ """Extract strategy_id from orders metadata."""
86
+ for order in orders:
87
+ if "strategy_id" in order.meta:
88
+ return order.meta["strategy_id"]
89
+ return None
90
+
91
+
92
+ def sync_fills(self) -> list[OrderFill]:
93
+ """
94
+ Synchronize fills from external source.
95
+
96
+ For backtest: returns empty (fills are immediate)
97
+ For live: returns fills that arrived since last sync
98
+
99
+ Returns:
100
+ List of new fills
101
+ """
102
+ fills = list(self._pending_fills)
103
+ self._pending_fills.clear()
104
+ return fills
105
+
106
+ def add_pending_fill(self, fill: OrderFill) -> None:
107
+ """Add fill to pending queue (for live executor callbacks)."""
108
+ self._pending_fills.append(fill)
109
+
110
+ def persist_state(self, state: StrategyState) -> None:
111
+ """
112
+ Persist current strategy state.
113
+
114
+ Called at end of each tick to save:
115
+ - Portfolio state
116
+ - Positions
117
+ - Metrics
118
+ """
119
+ self.store.save_state(state)
120
+ self.store.save_positions(
121
+ state.strategy_id,
122
+ list(state.positions.values())
123
+ )
124
+ if state.last_ts and state.all_metrics:
125
+ self.store.save_metrics(
126
+ state.strategy_id,
127
+ state.last_ts,
128
+ state.all_metrics,
129
+ )
130
+
131
+ def restore_state(self, strategy_id: str) -> StrategyState:
132
+ """
133
+ Restore strategy state from storage.
134
+
135
+ Called on startup to recover from last known state.
136
+
137
+ Args:
138
+ strategy_id: Strategy to restore
139
+
140
+ Returns:
141
+ Restored state (or fresh state if not found)
142
+ """
143
+ state = self.store.load_state(strategy_id)
144
+
145
+ if state is None:
146
+ return StrategyState(strategy_id=strategy_id)
147
+
148
+ positions = self.store.load_positions(strategy_id, open_only=False)
149
+ state.portfolio.positions = {p.id: p for p in positions}
150
+
151
+ return state
152
+
153
+ @abstractmethod
154
+ def create_position(
155
+ self,
156
+ order: Order,
157
+ fill: OrderFill,
158
+ ) -> Position:
159
+ """
160
+ Create new position from order and fill.
161
+
162
+ Args:
163
+ order: Original open order
164
+ fill: Execution fill
165
+
166
+ Returns:
167
+ New Position instance
168
+ """
169
+ ...
170
+
171
+ @abstractmethod
172
+ def apply_fill_to_position(
173
+ self,
174
+ position: Position,
175
+ fill: OrderFill,
176
+ ) -> None:
177
+ """
178
+ Apply fill to existing position.
179
+
180
+ Mutates position in-place.
181
+
182
+ Args:
183
+ position: Position to update
184
+ fill: Fill to apply
185
+ """
186
+ ...
@@ -0,0 +1,9 @@
1
+ from signalflow.strategy.broker.executor.base import OrderExecutor
2
+ from signalflow.strategy.broker.executor.binance_spot import BinanceSpotExecutor
3
+ from signalflow.strategy.broker.executor.virtual_spot import VirtualSpotExecutor
4
+
5
+ __all__ = [
6
+ "OrderExecutor",
7
+ "BinanceSpotExecutor",
8
+ "VirtualSpotExecutor",
9
+ ]
@@ -0,0 +1,35 @@
1
+ from signalflow.core import Order, OrderFill, SfComponentType
2
+ from datetime import datetime
3
+ from typing import Protocol, ClassVar
4
+
5
+ class OrderExecutor(Protocol):
6
+ component_type: ClassVar[SfComponentType] = SfComponentType.STRATEGY_EXECUTOR
7
+ """
8
+ Protocol for order execution.
9
+
10
+ Implementations:
11
+ - VirtualExecutor: Simulates fills at current prices
12
+ - LiveExecutor: Submits orders to exchange
13
+ - BinanceExecutor(LiveExecutor): Submits orders to Binance
14
+ """
15
+
16
+ def execute(
17
+ self,
18
+ orders: list[Order],
19
+ prices: dict[str, float],
20
+ ts: datetime,
21
+ ) -> list[OrderFill]:
22
+ """
23
+ Execute orders and return fills.
24
+
25
+ Args:
26
+ orders: List of orders to execute
27
+ prices: Current prices per pair
28
+ ts: Current timestamp
29
+
30
+ Returns:
31
+ List of fills (may be empty if orders rejected)
32
+ """
33
+ ...
34
+
35
+