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,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,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,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
|
+
|