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,169 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import polars as pl
|
|
4
|
+
from .raw_data import RawData
|
|
5
|
+
from signalflow.core.enums import DataFrameType
|
|
6
|
+
|
|
7
|
+
# TODO raw_data_type -> RawDataType
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RawDataView:
|
|
11
|
+
"""Adapter for accessing RawData in different DataFrame formats.
|
|
12
|
+
|
|
13
|
+
Provides unified interface for converting between Polars and Pandas formats,
|
|
14
|
+
with optional caching for Pandas conversions.
|
|
15
|
+
|
|
16
|
+
Key features:
|
|
17
|
+
- Lazy conversion: Polars → Pandas only when needed
|
|
18
|
+
- Optional caching for repeated Pandas access
|
|
19
|
+
- Automatic timestamp normalization
|
|
20
|
+
- Automatic sorting by (pair, timestamp)
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
raw (RawData): Underlying raw data container.
|
|
24
|
+
cache_pandas (bool): Enable caching for Pandas conversions. Default: False.
|
|
25
|
+
_pandas_cache (dict[str, pd.DataFrame]): Internal cache for Pandas DataFrames.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
from signalflow.core import RawData, RawDataView
|
|
30
|
+
|
|
31
|
+
# Create view
|
|
32
|
+
view = RawDataView(raw=raw_data, cache_pandas=True)
|
|
33
|
+
|
|
34
|
+
# Access as Polars (zero-copy)
|
|
35
|
+
spot_pl = view.to_polars("spot")
|
|
36
|
+
|
|
37
|
+
# Access as Pandas (cached)
|
|
38
|
+
spot_pd = view.to_pandas("spot")
|
|
39
|
+
|
|
40
|
+
# Unified interface
|
|
41
|
+
from signalflow.core.enums import DataFrameType
|
|
42
|
+
data = view.get_data("spot", DataFrameType.POLARS)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Note:
|
|
46
|
+
Polars access is zero-copy. Pandas conversion creates a copy.
|
|
47
|
+
Enable cache_pandas for repeated Pandas access to same dataset.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
raw: RawData
|
|
51
|
+
cache_pandas: bool = False
|
|
52
|
+
_pandas_cache: dict[str, pd.DataFrame] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
"""Initialize internal cache if needed."""
|
|
56
|
+
if self._pandas_cache is None:
|
|
57
|
+
self._pandas_cache = {}
|
|
58
|
+
|
|
59
|
+
def to_polars(self, key: str) -> pl.DataFrame:
|
|
60
|
+
"""Get dataset as Polars DataFrame.
|
|
61
|
+
|
|
62
|
+
Zero-copy access to underlying Polars DataFrame in RawData.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key (str): Dataset name (e.g. "spot", "signals").
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
pl.DataFrame: Dataset as Polars DataFrame.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
```python
|
|
72
|
+
spot_df = view.to_polars("spot")
|
|
73
|
+
print(f"Shape: {spot_df.shape}")
|
|
74
|
+
```
|
|
75
|
+
"""
|
|
76
|
+
return self.raw[key]
|
|
77
|
+
|
|
78
|
+
def to_pandas(self, key: str) -> pd.DataFrame:
|
|
79
|
+
"""Get dataset as Pandas DataFrame.
|
|
80
|
+
|
|
81
|
+
Converts Polars DataFrame to Pandas with:
|
|
82
|
+
- Timestamp normalization (UTC-aware → naive)
|
|
83
|
+
- Automatic sorting by (pair, timestamp)
|
|
84
|
+
- Optional caching for repeated access
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key (str): Dataset name (e.g. "spot", "signals").
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
pd.DataFrame: Dataset as Pandas DataFrame, sorted and normalized.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
```python
|
|
94
|
+
# First access: converts and caches (if enabled)
|
|
95
|
+
spot_df = view.to_pandas("spot")
|
|
96
|
+
|
|
97
|
+
# Second access: returns cached version (if cache enabled)
|
|
98
|
+
spot_df_again = view.to_pandas("spot")
|
|
99
|
+
|
|
100
|
+
# Check timestamp type
|
|
101
|
+
print(spot_df["timestamp"].dtype) # datetime64[ns]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Note:
|
|
105
|
+
Returns empty DataFrame if dataset doesn't exist.
|
|
106
|
+
Timestamp column is converted to timezone-naive datetime64[ns].
|
|
107
|
+
"""
|
|
108
|
+
df_pl = self.to_polars(key)
|
|
109
|
+
if df_pl.is_empty():
|
|
110
|
+
return pd.DataFrame()
|
|
111
|
+
|
|
112
|
+
if self.cache_pandas and key in self._pandas_cache:
|
|
113
|
+
df = self._pandas_cache[key]
|
|
114
|
+
else:
|
|
115
|
+
df = df_pl.to_pandas()
|
|
116
|
+
if self.cache_pandas:
|
|
117
|
+
self._pandas_cache[key] = df
|
|
118
|
+
|
|
119
|
+
if "timestamp" in df.columns:
|
|
120
|
+
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=False, errors="raise")
|
|
121
|
+
|
|
122
|
+
if {"pair", "timestamp"}.issubset(df.columns):
|
|
123
|
+
df = df.sort_values(["pair", "timestamp"], kind="stable").reset_index(drop=True)
|
|
124
|
+
|
|
125
|
+
return df
|
|
126
|
+
|
|
127
|
+
def get_data(
|
|
128
|
+
self,
|
|
129
|
+
raw_data_type: str,
|
|
130
|
+
df_type: DataFrameType
|
|
131
|
+
) -> pl.DataFrame | pd.DataFrame:
|
|
132
|
+
"""Get raw data in specified format.
|
|
133
|
+
|
|
134
|
+
Unified interface for accessing data in required DataFrame format.
|
|
135
|
+
Used by FeatureSet to get data in format expected by extractors.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
raw_data_type (str): Type of data ('spot', 'futures', 'perpetual').
|
|
139
|
+
df_type (DataFrameType): Target DataFrame type (POLARS or PANDAS).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
pl.DataFrame | pd.DataFrame: Dataset in requested format.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If df_type is not POLARS or PANDAS.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
```python
|
|
149
|
+
from signalflow.core.enums import DataFrameType
|
|
150
|
+
|
|
151
|
+
# Get as Polars
|
|
152
|
+
spot_pl = view.get_data('spot', DataFrameType.POLARS)
|
|
153
|
+
|
|
154
|
+
# Get as Pandas
|
|
155
|
+
spot_pd = view.get_data('spot', DataFrameType.PANDAS)
|
|
156
|
+
|
|
157
|
+
# Used by FeatureExtractor
|
|
158
|
+
class MyExtractor(FeatureExtractor):
|
|
159
|
+
def extract(self, df):
|
|
160
|
+
# df will be in format specified by df_type
|
|
161
|
+
pass
|
|
162
|
+
```
|
|
163
|
+
"""
|
|
164
|
+
if df_type == DataFrameType.POLARS:
|
|
165
|
+
return self.to_polars(raw_data_type)
|
|
166
|
+
elif df_type == DataFrameType.PANDAS:
|
|
167
|
+
return self.to_pandas(raw_data_type)
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(f"Unsupported df_type: {df_type}")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import polars as pl
|
|
5
|
+
from signalflow.core.signal_transforms import SignalsTransform
|
|
6
|
+
from signalflow.core.enums import SignalType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Signals:
|
|
11
|
+
"""Immutable container for trading signals.
|
|
12
|
+
|
|
13
|
+
Canonical in-memory format is a Polars DataFrame with long schema.
|
|
14
|
+
|
|
15
|
+
Required columns:
|
|
16
|
+
- pair (str): Trading pair identifier
|
|
17
|
+
- timestamp (datetime): Signal timestamp
|
|
18
|
+
- signal_type (SignalType | int): Signal type (RISE, FALL, NONE)
|
|
19
|
+
- signal (int | float): Signal value
|
|
20
|
+
|
|
21
|
+
Optional columns:
|
|
22
|
+
- probability (float): Signal probability (required for merge logic)
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
value (pl.DataFrame): Polars DataFrame containing signal data.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
from signalflow.core import Signals, SignalType
|
|
30
|
+
import polars as pl
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
|
|
33
|
+
# Create signals
|
|
34
|
+
signals_df = pl.DataFrame({
|
|
35
|
+
"pair": ["BTCUSDT", "ETHUSDT"],
|
|
36
|
+
"timestamp": [datetime.now(), datetime.now()],
|
|
37
|
+
"signal_type": [SignalType.RISE.value, SignalType.FALL.value],
|
|
38
|
+
"signal": [1, -1],
|
|
39
|
+
"probability": [0.8, 0.7]
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
signals = Signals(signals_df)
|
|
43
|
+
|
|
44
|
+
# Apply transformation
|
|
45
|
+
filtered = signals.apply(filter_transform)
|
|
46
|
+
|
|
47
|
+
# Chain transformations
|
|
48
|
+
processed = signals.pipe(
|
|
49
|
+
transform1,
|
|
50
|
+
transform2,
|
|
51
|
+
transform3
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Merge signals
|
|
55
|
+
combined = signals1 + signals2
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Note:
|
|
59
|
+
All transformations return new Signals instance.
|
|
60
|
+
No in-place mutation is allowed.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
value: pl.DataFrame
|
|
64
|
+
|
|
65
|
+
def apply(self, transform: SignalsTransform) -> "Signals":
|
|
66
|
+
"""Apply a single transformation to signals.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
transform (SignalsTransform): Callable transformation implementing
|
|
70
|
+
SignalsTransform protocol.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Signals: New Signals instance with transformed data.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
```python
|
|
77
|
+
from signalflow.core import Signals
|
|
78
|
+
import polars as pl
|
|
79
|
+
|
|
80
|
+
def filter_high_probability(df: pl.DataFrame) -> pl.DataFrame:
|
|
81
|
+
return df.filter(pl.col("probability") > 0.7)
|
|
82
|
+
|
|
83
|
+
filtered = signals.apply(filter_high_probability)
|
|
84
|
+
```
|
|
85
|
+
"""
|
|
86
|
+
out = transform(self.value)
|
|
87
|
+
return Signals(out)
|
|
88
|
+
|
|
89
|
+
def pipe(self, *transforms: SignalsTransform) -> "Signals":
|
|
90
|
+
"""Apply multiple transformations sequentially.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
*transforms (SignalsTransform): Sequence of transformations to apply in order.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Signals: New Signals instance after applying all transformations.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
```python
|
|
100
|
+
result = signals.pipe(
|
|
101
|
+
filter_none_signals,
|
|
102
|
+
normalize_probabilities,
|
|
103
|
+
add_metadata
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
"""
|
|
107
|
+
s = self
|
|
108
|
+
for t in transforms:
|
|
109
|
+
s = s.apply(t)
|
|
110
|
+
return s
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def __add__(self, other: "Signals") -> "Signals":
|
|
114
|
+
"""Merge two Signals objects.
|
|
115
|
+
|
|
116
|
+
Merge rules:
|
|
117
|
+
1. Key: (pair, timestamp)
|
|
118
|
+
2. Signal type priority:
|
|
119
|
+
- SignalType.NONE has lowest priority
|
|
120
|
+
- Non-NONE always overrides NONE
|
|
121
|
+
- If both non-NONE, `other` wins
|
|
122
|
+
3. SignalType.NONE normalized to probability = 0
|
|
123
|
+
4. Merge is deterministic
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
other (Signals): Another Signals object to merge.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Signals: New merged Signals instance.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
TypeError: If other is not a Signals instance.
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
```python
|
|
136
|
+
# Detector 1 signals
|
|
137
|
+
signals1 = detector1.run(data)
|
|
138
|
+
|
|
139
|
+
# Detector 2 signals
|
|
140
|
+
signals2 = detector2.run(data)
|
|
141
|
+
|
|
142
|
+
# Merge with priority to signals2
|
|
143
|
+
merged = signals1 + signals2
|
|
144
|
+
|
|
145
|
+
# NONE signals overridden by non-NONE
|
|
146
|
+
# Non-NONE conflicts resolved by taking signals2
|
|
147
|
+
```
|
|
148
|
+
"""
|
|
149
|
+
if not isinstance(other, Signals):
|
|
150
|
+
return NotImplemented
|
|
151
|
+
|
|
152
|
+
a = self.value
|
|
153
|
+
b = other.value
|
|
154
|
+
|
|
155
|
+
all_cols = list(dict.fromkeys([*a.columns, *b.columns]))
|
|
156
|
+
|
|
157
|
+
def align(df: pl.DataFrame) -> pl.DataFrame:
|
|
158
|
+
return (
|
|
159
|
+
df.with_columns(
|
|
160
|
+
[pl.lit(None).alias(c) for c in all_cols if c not in df.columns]
|
|
161
|
+
)
|
|
162
|
+
.select(all_cols)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
a = align(a).with_columns(pl.lit(0).alias("_src"))
|
|
166
|
+
b = align(b).with_columns(pl.lit(1).alias("_src"))
|
|
167
|
+
|
|
168
|
+
merged = pl.concat([a, b], how="vertical")
|
|
169
|
+
|
|
170
|
+
merged = merged.with_columns(
|
|
171
|
+
pl.when(pl.col("signal_type") == SignalType.NONE.value)
|
|
172
|
+
.then(pl.lit(0))
|
|
173
|
+
.otherwise(pl.col("probability"))
|
|
174
|
+
.alias("probability")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
merged = merged.with_columns(
|
|
178
|
+
pl.when(pl.col("signal_type") == SignalType.NONE.value)
|
|
179
|
+
.then(pl.lit(0))
|
|
180
|
+
.otherwise(pl.lit(1))
|
|
181
|
+
.alias("_priority")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
merged = (
|
|
185
|
+
merged
|
|
186
|
+
.sort(
|
|
187
|
+
["pair", "timestamp", "_priority", "_src"],
|
|
188
|
+
descending=[False, False, True, True],
|
|
189
|
+
)
|
|
190
|
+
.unique(
|
|
191
|
+
subset=["pair", "timestamp"],
|
|
192
|
+
keep="first",
|
|
193
|
+
)
|
|
194
|
+
.drop(["_priority", "_src"])
|
|
195
|
+
.sort(["pair", "timestamp"])
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return Signals(merged)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from signalflow.core.containers import Portfolio
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class StrategyState:
|
|
10
|
+
"""Single source of truth for strategy runtime state.
|
|
11
|
+
|
|
12
|
+
Mutable aggregate that tracks all strategy state including portfolio,
|
|
13
|
+
runtime context, metrics, and execution watermarks.
|
|
14
|
+
|
|
15
|
+
Lives in `signalflow.core` so both `signalflow.strategy` (logic/execution)
|
|
16
|
+
and `signalflow.data` (persistence) can depend on it without cycles.
|
|
17
|
+
|
|
18
|
+
State components:
|
|
19
|
+
- portfolio: Canonical portfolio state (cash + positions)
|
|
20
|
+
- runtime: Flexible bag for cooldowns, watermarks, guards
|
|
21
|
+
- metrics: Latest computed metrics snapshot
|
|
22
|
+
- watermarks: Last processed timestamp and event for idempotency
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
strategy_id (str): Unique strategy identifier.
|
|
26
|
+
last_ts (datetime | None): Last processed timestamp.
|
|
27
|
+
last_event_id (str | None): Last processed event ID (for live idempotency/resume).
|
|
28
|
+
portfolio (Portfolio): Current portfolio state.
|
|
29
|
+
runtime (dict[str, Any]): Runtime context (cooldowns, guards, etc.).
|
|
30
|
+
metrics (dict[str, float]): Latest metrics snapshot.
|
|
31
|
+
metrics_phase_done (set[str]): Phase completion tracking for metrics.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
```python
|
|
35
|
+
from signalflow.core import StrategyState, Portfolio
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
|
|
38
|
+
# Initialize state
|
|
39
|
+
state = StrategyState(strategy_id="my_strategy")
|
|
40
|
+
state.portfolio.cash = 10000.0
|
|
41
|
+
|
|
42
|
+
# Process bar
|
|
43
|
+
state.touch(ts=datetime(2024, 1, 1, 10, 0))
|
|
44
|
+
state.metrics["total_return"] = 0.05
|
|
45
|
+
state.runtime["last_signal"] = "sma_cross"
|
|
46
|
+
|
|
47
|
+
# Save and resume
|
|
48
|
+
saved_state = save_to_db(state)
|
|
49
|
+
resumed_state = load_from_db(strategy_id="my_strategy")
|
|
50
|
+
|
|
51
|
+
# Continue from last watermark
|
|
52
|
+
print(f"Resuming from: {resumed_state.last_ts}")
|
|
53
|
+
|
|
54
|
+
# Phase-gated metrics
|
|
55
|
+
state.reset_tick_cache()
|
|
56
|
+
# ... compute metrics for new tick ...
|
|
57
|
+
state.metrics_phase_done.add("returns")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Note:
|
|
61
|
+
State should be persisted regularly for recovery.
|
|
62
|
+
All portfolio changes flow through fills, not direct modification.
|
|
63
|
+
Use touch() to update watermarks after successful tick commit.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
strategy_id: str
|
|
67
|
+
|
|
68
|
+
last_ts: Optional[datetime] = None
|
|
69
|
+
last_event_id: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
portfolio: Portfolio = field(default_factory=Portfolio)
|
|
72
|
+
|
|
73
|
+
runtime: Dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
metrics: Dict[str, float] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
metrics_phase_done: set[str] = field(default_factory=set)
|
|
77
|
+
|
|
78
|
+
def touch(self, ts: datetime, event_id: Optional[str] = None) -> None:
|
|
79
|
+
"""Update watermarks after successful tick commit.
|
|
80
|
+
|
|
81
|
+
Updates last_ts and optionally last_event_id to track processing progress.
|
|
82
|
+
Used for resume/recovery and live idempotency.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
ts (datetime): Timestamp of successfully processed tick.
|
|
86
|
+
event_id (str | None): Optional event ID for idempotency tracking.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
```python
|
|
90
|
+
# Process bar successfully
|
|
91
|
+
for bar in bars:
|
|
92
|
+
# ... process trading logic ...
|
|
93
|
+
|
|
94
|
+
# Commit and update watermarks
|
|
95
|
+
state.touch(ts=bar.timestamp)
|
|
96
|
+
save_to_db(state)
|
|
97
|
+
|
|
98
|
+
# Live trading with event IDs
|
|
99
|
+
for event in event_stream:
|
|
100
|
+
# ... process event ...
|
|
101
|
+
|
|
102
|
+
# Track event for idempotency
|
|
103
|
+
state.touch(ts=event.timestamp, event_id=event.id)
|
|
104
|
+
save_to_db(state)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Note:
|
|
108
|
+
Call after successful tick processing and before persistence.
|
|
109
|
+
Enables safe resume from last committed state.
|
|
110
|
+
"""
|
|
111
|
+
self.last_ts = ts
|
|
112
|
+
if event_id is not None:
|
|
113
|
+
self.last_event_id = event_id
|
|
114
|
+
|
|
115
|
+
def reset_tick_cache(self) -> None:
|
|
116
|
+
"""Clear phase completion tracking for new tick.
|
|
117
|
+
|
|
118
|
+
Resets metrics_phase_done set at the start of each tick
|
|
119
|
+
for phase-gated metrics computation.
|
|
120
|
+
|
|
121
|
+
Use when computing metrics incrementally across phases
|
|
122
|
+
(e.g., pre-trade, post-trade, end-of-bar).
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
```python
|
|
126
|
+
# At start of each tick
|
|
127
|
+
state.reset_tick_cache()
|
|
128
|
+
|
|
129
|
+
# Phase 1: Pre-trade metrics
|
|
130
|
+
if "returns" not in state.metrics_phase_done:
|
|
131
|
+
compute_return_metrics(state)
|
|
132
|
+
state.metrics_phase_done.add("returns")
|
|
133
|
+
|
|
134
|
+
# Phase 2: Post-trade metrics
|
|
135
|
+
if "drawdown" not in state.metrics_phase_done:
|
|
136
|
+
compute_drawdown_metrics(state)
|
|
137
|
+
state.metrics_phase_done.add("drawdown")
|
|
138
|
+
|
|
139
|
+
# Next tick - reset and recompute
|
|
140
|
+
state.reset_tick_cache()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Note:
|
|
144
|
+
Only needed if using phase-gated metrics pattern.
|
|
145
|
+
Otherwise, metrics_phase_done can be ignored.
|
|
146
|
+
"""
|
|
147
|
+
self.metrics_phase_done.clear()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Iterable, Literal
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
TradeSide = Literal["BUY", "SELL"]
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Trade:
|
|
14
|
+
"""Immutable domain event representing an executed trade.
|
|
15
|
+
|
|
16
|
+
A Trade is a fact: it happened, it cannot be changed.
|
|
17
|
+
All position accounting flows from trades.
|
|
18
|
+
|
|
19
|
+
Trade → Position relationship:
|
|
20
|
+
- Trades are immutable events
|
|
21
|
+
- Position state is derived from applying trades
|
|
22
|
+
- One position can have multiple trades (entry, partial exits, full exit)
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
id (str): Unique trade identifier.
|
|
26
|
+
position_id (str | None): ID of the position this trade belongs to.
|
|
27
|
+
pair (str): Trading pair (e.g. "BTCUSDT").
|
|
28
|
+
side (TradeSide): Trade side - "BUY" or "SELL".
|
|
29
|
+
ts (datetime | None): Execution timestamp.
|
|
30
|
+
price (float): Execution price.
|
|
31
|
+
qty (float): Executed quantity (always positive).
|
|
32
|
+
fee (float): Transaction fee paid (always positive).
|
|
33
|
+
meta (dict[str, Any]): Additional metadata (e.g., order_id, fill_type).
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
```python
|
|
37
|
+
from signalflow.core import Trade
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
|
|
40
|
+
# Entry trade
|
|
41
|
+
entry = Trade(
|
|
42
|
+
position_id="pos_123",
|
|
43
|
+
pair="BTCUSDT",
|
|
44
|
+
side="BUY",
|
|
45
|
+
ts=datetime.now(),
|
|
46
|
+
price=45000.0,
|
|
47
|
+
qty=0.5,
|
|
48
|
+
fee=22.5,
|
|
49
|
+
meta={"type": "entry", "signal": "sma_cross"}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Exit trade
|
|
53
|
+
exit = Trade(
|
|
54
|
+
position_id="pos_123",
|
|
55
|
+
pair="BTCUSDT",
|
|
56
|
+
side="SELL",
|
|
57
|
+
ts=datetime.now(),
|
|
58
|
+
price=46000.0,
|
|
59
|
+
qty=0.5,
|
|
60
|
+
fee=23.0,
|
|
61
|
+
meta={"type": "exit", "reason": "take_profit"}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Calculate PnL
|
|
65
|
+
pnl = (exit.price - entry.price) * entry.qty
|
|
66
|
+
total_fees = entry.fee + exit.fee
|
|
67
|
+
net_pnl = pnl - total_fees
|
|
68
|
+
print(f"Net PnL: ${net_pnl:.2f}")
|
|
69
|
+
|
|
70
|
+
# Check notional value
|
|
71
|
+
print(f"Entry notional: ${entry.notional:.2f}")
|
|
72
|
+
print(f"Exit notional: ${exit.notional:.2f}")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Note:
|
|
76
|
+
Trades are immutable events. Position state is derived from trades.
|
|
77
|
+
qty and fee are always positive regardless of side.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
81
|
+
position_id: str | None = None
|
|
82
|
+
|
|
83
|
+
pair: str = ""
|
|
84
|
+
side: TradeSide = "BUY"
|
|
85
|
+
ts: datetime | None = None
|
|
86
|
+
|
|
87
|
+
price: float = 0.0
|
|
88
|
+
qty: float = 0.0
|
|
89
|
+
fee: float = 0.0
|
|
90
|
+
|
|
91
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def notional(self) -> float:
|
|
95
|
+
"""Calculate notional value of the trade.
|
|
96
|
+
|
|
97
|
+
Notional = price * qty
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
float: Notional value in currency units.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
```python
|
|
104
|
+
trade = Trade(price=45000.0, qty=0.5)
|
|
105
|
+
assert trade.notional == 22500.0 # 45000 * 0.5
|
|
106
|
+
|
|
107
|
+
# Track total volume
|
|
108
|
+
trades = [trade1, trade2, trade3]
|
|
109
|
+
total_volume = sum(t.notional for t in trades)
|
|
110
|
+
```
|
|
111
|
+
"""
|
|
112
|
+
return float(self.price) * float(self.qty)
|