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,278 @@
|
|
|
1
|
+
# src/signalflow/data/strategy_store/base.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Iterable, Optional
|
|
7
|
+
|
|
8
|
+
from signalflow.core import StrategyState, Position, Trade
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StrategyStore(ABC):
|
|
12
|
+
"""Abstract base class for strategy state persistence.
|
|
13
|
+
|
|
14
|
+
Defines the interface for persisting strategy execution state, including
|
|
15
|
+
portfolio positions, trades, and performance metrics. Stores are responsible
|
|
16
|
+
only for persistence - no business logic.
|
|
17
|
+
|
|
18
|
+
Key responsibilities:
|
|
19
|
+
- Initialize storage backend (tables, indexes, etc.)
|
|
20
|
+
- Load/save complete strategy state for recovery
|
|
21
|
+
- Append event streams (trades, metrics)
|
|
22
|
+
- Upsert position snapshots
|
|
23
|
+
|
|
24
|
+
Common implementations:
|
|
25
|
+
- DuckDB: Local file-based storage
|
|
26
|
+
- PostgreSQL: Shared database for multiple strategies
|
|
27
|
+
- Parquet: Time-series optimized storage
|
|
28
|
+
|
|
29
|
+
Persistence patterns:
|
|
30
|
+
- State: Full snapshot for recovery (load_state, save_state)
|
|
31
|
+
- Events: Append-only logs (append_trade, append_metrics)
|
|
32
|
+
- Snapshots: Point-in-time positions (upsert_positions)
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
```python
|
|
36
|
+
from signalflow.data.strategy_store import DuckDbStrategyStore
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
# Create store
|
|
40
|
+
store = DuckDbStrategyStore(db_path=Path("backtest.duckdb"))
|
|
41
|
+
store.init()
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Load existing state
|
|
45
|
+
state = store.load_state("my_strategy")
|
|
46
|
+
|
|
47
|
+
if state is None:
|
|
48
|
+
# Initialize new state
|
|
49
|
+
state = StrategyState(strategy_id="my_strategy")
|
|
50
|
+
state.portfolio.cash = 10000.0
|
|
51
|
+
|
|
52
|
+
# Run strategy tick
|
|
53
|
+
# ... execute trades ...
|
|
54
|
+
|
|
55
|
+
# Persist trade
|
|
56
|
+
trade = Trade(
|
|
57
|
+
pair="BTCUSDT",
|
|
58
|
+
side="BUY",
|
|
59
|
+
price=45000.0,
|
|
60
|
+
qty=0.5,
|
|
61
|
+
fee=22.5
|
|
62
|
+
)
|
|
63
|
+
store.append_trade("my_strategy", trade)
|
|
64
|
+
|
|
65
|
+
# Persist position snapshot
|
|
66
|
+
store.upsert_positions(
|
|
67
|
+
"my_strategy",
|
|
68
|
+
datetime.now(),
|
|
69
|
+
state.portfolio.positions.values()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Persist metrics
|
|
73
|
+
metrics = {"total_return": 0.05, "sharpe_ratio": 1.2}
|
|
74
|
+
store.append_metrics("my_strategy", datetime.now(), metrics)
|
|
75
|
+
|
|
76
|
+
# Save state checkpoint
|
|
77
|
+
store.save_state(state)
|
|
78
|
+
|
|
79
|
+
finally:
|
|
80
|
+
store.close()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Note:
|
|
84
|
+
Implementations should be thread-safe for concurrent access.
|
|
85
|
+
State saves should be atomic to prevent corruption on crashes.
|
|
86
|
+
Append operations should be optimized for high-frequency writes.
|
|
87
|
+
|
|
88
|
+
See Also:
|
|
89
|
+
StrategyState: The state object being persisted.
|
|
90
|
+
BacktestBroker: Uses store to persist execution events.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def init(self) -> None:
|
|
95
|
+
"""Initialize storage backend.
|
|
96
|
+
|
|
97
|
+
Creates necessary tables, indexes, and schema. Idempotent - safe
|
|
98
|
+
to call multiple times. Should handle schema migrations if needed.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
```python
|
|
102
|
+
store = DuckDbStrategyStore(Path("backtest.duckdb"))
|
|
103
|
+
store.init() # Creates tables
|
|
104
|
+
store.init() # Safe to call again
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Note:
|
|
108
|
+
Should create:
|
|
109
|
+
- state table (strategy snapshots)
|
|
110
|
+
- positions table (position history)
|
|
111
|
+
- trades table (trade log)
|
|
112
|
+
- metrics table (performance metrics)
|
|
113
|
+
"""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def load_state(self, strategy_id: str) -> Optional[StrategyState]:
|
|
118
|
+
"""Load strategy state from storage.
|
|
119
|
+
|
|
120
|
+
Retrieves most recent saved state for recovery or resumption.
|
|
121
|
+
Returns None if strategy has never been saved.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
strategy_id (str): Unique strategy identifier.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
StrategyState | None: Loaded state or None if not found.
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
```python
|
|
131
|
+
# Load existing state
|
|
132
|
+
state = store.load_state("my_strategy")
|
|
133
|
+
|
|
134
|
+
if state:
|
|
135
|
+
print(f"Resuming from: {state.last_ts}")
|
|
136
|
+
print(f"Cash: ${state.portfolio.cash}")
|
|
137
|
+
else:
|
|
138
|
+
print("Starting fresh")
|
|
139
|
+
state = StrategyState(strategy_id="my_strategy")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Note:
|
|
143
|
+
Should load most recent checkpoint only.
|
|
144
|
+
Portfolio positions should be reconstructed from state.
|
|
145
|
+
"""
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
def save_state(self, state: StrategyState) -> None:
|
|
150
|
+
"""Save strategy state to storage.
|
|
151
|
+
|
|
152
|
+
Persists complete strategy state as checkpoint. Should be atomic
|
|
153
|
+
to prevent corruption. Overwrites previous state for same strategy_id.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
state (StrategyState): Strategy state to persist.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
```python
|
|
160
|
+
# Update state
|
|
161
|
+
state.last_ts = datetime.now()
|
|
162
|
+
state.portfolio.cash = 9500.0
|
|
163
|
+
|
|
164
|
+
# Save checkpoint
|
|
165
|
+
store.save_state(state)
|
|
166
|
+
|
|
167
|
+
# Can resume from this point later
|
|
168
|
+
resumed_state = store.load_state(state.strategy_id)
|
|
169
|
+
assert resumed_state.last_ts == state.last_ts
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Note:
|
|
173
|
+
Should be atomic - write to temp then rename/swap.
|
|
174
|
+
Consider compression for large portfolios.
|
|
175
|
+
"""
|
|
176
|
+
...
|
|
177
|
+
|
|
178
|
+
@abstractmethod
|
|
179
|
+
def upsert_positions(self, strategy_id: str, ts: datetime, positions: Iterable[Position]) -> None:
|
|
180
|
+
"""Upsert position snapshots.
|
|
181
|
+
|
|
182
|
+
Records point-in-time position state for analysis and visualization.
|
|
183
|
+
Updates existing positions or inserts new ones based on (strategy_id, ts, position_id).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
strategy_id (str): Strategy identifier.
|
|
187
|
+
ts (datetime): Snapshot timestamp.
|
|
188
|
+
positions (Iterable[Position]): Positions to persist.
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
```python
|
|
192
|
+
# After each bar
|
|
193
|
+
store.upsert_positions(
|
|
194
|
+
strategy_id="my_strategy",
|
|
195
|
+
ts=current_bar_time,
|
|
196
|
+
positions=state.portfolio.positions.values()
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Query positions later
|
|
200
|
+
# SELECT * FROM positions WHERE strategy_id = 'my_strategy'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Note:
|
|
204
|
+
Upsert based on (strategy_id, ts, position_id).
|
|
205
|
+
Used for equity curve computation and position analysis.
|
|
206
|
+
"""
|
|
207
|
+
...
|
|
208
|
+
|
|
209
|
+
@abstractmethod
|
|
210
|
+
def append_trade(self, strategy_id: str, trade: Trade) -> None:
|
|
211
|
+
"""Append trade to log.
|
|
212
|
+
|
|
213
|
+
Immutable event log - trades are never updated or deleted.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
strategy_id (str): Strategy identifier.
|
|
217
|
+
trade (Trade): Trade to persist.
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
```python
|
|
221
|
+
# After trade execution
|
|
222
|
+
trade = Trade(
|
|
223
|
+
position_id="pos_123",
|
|
224
|
+
pair="BTCUSDT",
|
|
225
|
+
side="BUY",
|
|
226
|
+
ts=datetime.now(),
|
|
227
|
+
price=45000.0,
|
|
228
|
+
qty=0.5,
|
|
229
|
+
fee=22.5
|
|
230
|
+
)
|
|
231
|
+
store.append_trade("my_strategy", trade)
|
|
232
|
+
|
|
233
|
+
# Query all trades
|
|
234
|
+
# SELECT * FROM trades WHERE strategy_id = 'my_strategy'
|
|
235
|
+
# ORDER BY ts
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Note:
|
|
239
|
+
Append-only - optimized for sequential writes.
|
|
240
|
+
Used for trade analysis and PnL verification.
|
|
241
|
+
"""
|
|
242
|
+
...
|
|
243
|
+
|
|
244
|
+
@abstractmethod
|
|
245
|
+
def append_metrics(self, strategy_id: str, ts: datetime, metrics: dict[str, float]) -> None:
|
|
246
|
+
"""Append metrics snapshot.
|
|
247
|
+
|
|
248
|
+
Records performance metrics at specific timestamp. Metrics are
|
|
249
|
+
strategy-defined (returns, sharpe, drawdown, etc.).
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
strategy_id (str): Strategy identifier.
|
|
253
|
+
ts (datetime): Metrics timestamp.
|
|
254
|
+
metrics (dict[str, float]): Metric name-value pairs.
|
|
255
|
+
|
|
256
|
+
Example:
|
|
257
|
+
```python
|
|
258
|
+
# After each bar
|
|
259
|
+
metrics = {
|
|
260
|
+
"total_return": 0.05,
|
|
261
|
+
"sharpe_ratio": 1.2,
|
|
262
|
+
"max_drawdown": -0.03,
|
|
263
|
+
"win_rate": 0.65
|
|
264
|
+
}
|
|
265
|
+
store.append_metrics("my_strategy", datetime.now(), metrics)
|
|
266
|
+
|
|
267
|
+
# Query metrics time series
|
|
268
|
+
# SELECT ts, total_return FROM metrics
|
|
269
|
+
# WHERE strategy_id = 'my_strategy'
|
|
270
|
+
# ORDER BY ts
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Note:
|
|
274
|
+
Append-only time series.
|
|
275
|
+
Metric names should be consistent across snapshots.
|
|
276
|
+
Used for performance visualization and optimization.
|
|
277
|
+
"""
|
|
278
|
+
...
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# src/signalflow/data/strategy_store/duckdb.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Iterable, Optional
|
|
8
|
+
|
|
9
|
+
import duckdb
|
|
10
|
+
|
|
11
|
+
from signalflow.core import StrategyState, Position, Trade
|
|
12
|
+
|
|
13
|
+
from signalflow.data.strategy_store.base import StrategyStore
|
|
14
|
+
from signalflow.data.strategy_store.schema import SCHEMA_SQL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_json(obj) -> str:
|
|
18
|
+
"""Convert object to JSON string.
|
|
19
|
+
|
|
20
|
+
Handles dataclasses by converting to dict first. Uses default=str
|
|
21
|
+
for non-serializable types (e.g., datetime).
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
obj: Object to serialize (dataclass, dict, or JSON-serializable).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
str: JSON string representation.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Example:
|
|
36
|
+
name: str
|
|
37
|
+
created: datetime
|
|
38
|
+
|
|
39
|
+
obj = Example(name="test", created=datetime.now())
|
|
40
|
+
json_str = _to_json(obj)
|
|
41
|
+
# '{"name": "test", "created": "2024-01-01 12:00:00"}'
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
if is_dataclass(obj):
|
|
45
|
+
obj = asdict(obj)
|
|
46
|
+
return json.dumps(obj, default=str, ensure_ascii=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _state_from_json(payload: str) -> StrategyState:
|
|
50
|
+
"""Deserialize StrategyState from JSON string.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
payload (str): JSON string containing serialized StrategyState.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
StrategyState: Reconstructed strategy state.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
```python
|
|
60
|
+
json_str = '{"strategy_id": "test", "last_ts": null, ...}'
|
|
61
|
+
state = _state_from_json(json_str)
|
|
62
|
+
assert state.strategy_id == "test"
|
|
63
|
+
```
|
|
64
|
+
"""
|
|
65
|
+
data = json.loads(payload)
|
|
66
|
+
return StrategyState(**data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DuckDbStrategyStore(StrategyStore):
|
|
70
|
+
"""DuckDB implementation of strategy persistence.
|
|
71
|
+
|
|
72
|
+
Stores strategy state, positions, trades, and metrics in local DuckDB
|
|
73
|
+
file. Provides efficient storage with SQL query capabilities.
|
|
74
|
+
|
|
75
|
+
Schema:
|
|
76
|
+
- strategy_state: Current state snapshots (keyed by strategy_id)
|
|
77
|
+
- positions: Position history (strategy_id, ts, position_id)
|
|
78
|
+
- trades: Trade log (strategy_id, trade_id)
|
|
79
|
+
- metrics: Performance metrics (strategy_id, ts, name)
|
|
80
|
+
|
|
81
|
+
Storage format:
|
|
82
|
+
- State: JSON payload with full StrategyState
|
|
83
|
+
- Positions: JSON payload per position
|
|
84
|
+
- Trades: JSON payload per trade
|
|
85
|
+
- Metrics: Normalized table (name-value pairs)
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
path (str): Path to DuckDB file.
|
|
89
|
+
con (duckdb.DuckDBPyConnection): Database connection.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
```python
|
|
93
|
+
from signalflow.data.strategy_store import DuckDbStrategyStore
|
|
94
|
+
from signalflow.core import StrategyState
|
|
95
|
+
from pathlib import Path
|
|
96
|
+
|
|
97
|
+
# Create store
|
|
98
|
+
store = DuckDbStrategyStore(str(Path("backtest.duckdb")))
|
|
99
|
+
store.init()
|
|
100
|
+
|
|
101
|
+
# Initialize state
|
|
102
|
+
state = StrategyState(strategy_id="my_strategy")
|
|
103
|
+
state.portfolio.cash = 10000.0
|
|
104
|
+
|
|
105
|
+
# Save state
|
|
106
|
+
store.save_state(state)
|
|
107
|
+
|
|
108
|
+
# Load state
|
|
109
|
+
loaded = store.load_state("my_strategy")
|
|
110
|
+
assert loaded.portfolio.cash == 10000.0
|
|
111
|
+
|
|
112
|
+
# Query with SQL
|
|
113
|
+
trades_df = store.con.execute(
|
|
114
|
+
"SELECT * FROM trades WHERE strategy_id = ?",
|
|
115
|
+
["my_strategy"]
|
|
116
|
+
).pl()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Note:
|
|
120
|
+
JSON serialization handles datetime via default=str.
|
|
121
|
+
All operations use upsert semantics (INSERT ON CONFLICT).
|
|
122
|
+
Connection remains open for query access.
|
|
123
|
+
|
|
124
|
+
See Also:
|
|
125
|
+
StrategyStore: Base class with interface definition.
|
|
126
|
+
SCHEMA_SQL: Schema definition for tables.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, path: str) -> None:
|
|
130
|
+
"""Initialize DuckDB store.
|
|
131
|
+
|
|
132
|
+
Opens connection to DuckDB file (creates if doesn't exist).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
path (str): Path to DuckDB file.
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
```python
|
|
139
|
+
store = DuckDbStrategyStore("backtest.duckdb")
|
|
140
|
+
store.init() # Create tables
|
|
141
|
+
```
|
|
142
|
+
"""
|
|
143
|
+
self.path = path
|
|
144
|
+
self.con = duckdb.connect(path)
|
|
145
|
+
|
|
146
|
+
def init(self) -> None:
|
|
147
|
+
"""Initialize database schema.
|
|
148
|
+
|
|
149
|
+
Creates tables if they don't exist:
|
|
150
|
+
- strategy_state (current snapshots)
|
|
151
|
+
- positions (position history)
|
|
152
|
+
- trades (trade log)
|
|
153
|
+
- metrics (performance metrics)
|
|
154
|
+
|
|
155
|
+
Idempotent - safe to call multiple times.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
```python
|
|
159
|
+
store = DuckDbStrategyStore("backtest.duckdb")
|
|
160
|
+
store.init() # Creates tables
|
|
161
|
+
store.init() # Safe - no-op if tables exist
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Note:
|
|
165
|
+
Uses SCHEMA_SQL from schema module.
|
|
166
|
+
Creates indexes for efficient queries.
|
|
167
|
+
"""
|
|
168
|
+
self.con.execute(SCHEMA_SQL)
|
|
169
|
+
|
|
170
|
+
def load_state(self, strategy_id: str) -> Optional[StrategyState]:
|
|
171
|
+
"""Load strategy state from database.
|
|
172
|
+
|
|
173
|
+
Retrieves most recent saved state for given strategy.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
strategy_id (str): Strategy identifier.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
StrategyState | None: Loaded state or None if not found.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
```python
|
|
183
|
+
# Load existing state
|
|
184
|
+
state = store.load_state("my_strategy")
|
|
185
|
+
|
|
186
|
+
if state:
|
|
187
|
+
print(f"Resuming from: {state.last_ts}")
|
|
188
|
+
print(f"Cash: ${state.portfolio.cash}")
|
|
189
|
+
else:
|
|
190
|
+
print("No saved state - starting fresh")
|
|
191
|
+
state = StrategyState(strategy_id="my_strategy")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Note:
|
|
195
|
+
Returns None if strategy never saved.
|
|
196
|
+
Deserializes JSON payload to StrategyState.
|
|
197
|
+
"""
|
|
198
|
+
row = self.con.execute(
|
|
199
|
+
"SELECT payload_json FROM strategy_state WHERE strategy_id = ?",
|
|
200
|
+
[strategy_id],
|
|
201
|
+
).fetchone()
|
|
202
|
+
if not row:
|
|
203
|
+
return None
|
|
204
|
+
return _state_from_json(row[0])
|
|
205
|
+
|
|
206
|
+
def save_state(self, state: StrategyState) -> None:
|
|
207
|
+
"""Save strategy state to database.
|
|
208
|
+
|
|
209
|
+
Upserts complete state snapshot. Updates if exists, inserts if new.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
state (StrategyState): Strategy state to persist.
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
```python
|
|
216
|
+
# Update state
|
|
217
|
+
state.last_ts = datetime.now()
|
|
218
|
+
state.portfolio.cash = 9500.0
|
|
219
|
+
|
|
220
|
+
# Save (upsert)
|
|
221
|
+
store.save_state(state)
|
|
222
|
+
|
|
223
|
+
# Verify
|
|
224
|
+
loaded = store.load_state(state.strategy_id)
|
|
225
|
+
assert loaded.last_ts == state.last_ts
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Note:
|
|
229
|
+
Uses INSERT ON CONFLICT to handle updates.
|
|
230
|
+
Serializes entire state to JSON.
|
|
231
|
+
"""
|
|
232
|
+
payload = _to_json(state)
|
|
233
|
+
self.con.execute(
|
|
234
|
+
"""
|
|
235
|
+
INSERT INTO strategy_state(strategy_id, last_ts, last_event_id, payload_json)
|
|
236
|
+
VALUES (?, ?, ?, ?)
|
|
237
|
+
ON CONFLICT(strategy_id) DO UPDATE SET
|
|
238
|
+
last_ts = excluded.last_ts,
|
|
239
|
+
last_event_id = excluded.last_event_id,
|
|
240
|
+
payload_json = excluded.payload_json
|
|
241
|
+
""",
|
|
242
|
+
[state.strategy_id, state.last_ts, state.last_event_id, payload],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def upsert_positions(self, strategy_id: str, ts: datetime, positions: Iterable[Position]) -> None:
|
|
246
|
+
"""Upsert position snapshots to database.
|
|
247
|
+
|
|
248
|
+
Records point-in-time position state. Updates if (strategy_id, ts, position_id)
|
|
249
|
+
exists, inserts otherwise.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
strategy_id (str): Strategy identifier.
|
|
253
|
+
ts (datetime): Snapshot timestamp.
|
|
254
|
+
positions (Iterable[Position]): Positions to persist.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ValueError: If position missing id attribute.
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
```python
|
|
261
|
+
from datetime import datetime
|
|
262
|
+
|
|
263
|
+
# After bar close
|
|
264
|
+
positions = state.portfolio.positions.values()
|
|
265
|
+
store.upsert_positions(
|
|
266
|
+
strategy_id="my_strategy",
|
|
267
|
+
ts=datetime.now(),
|
|
268
|
+
positions=positions
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Query positions
|
|
272
|
+
positions_df = store.con.execute(
|
|
273
|
+
"SELECT * FROM positions WHERE strategy_id = ?",
|
|
274
|
+
["my_strategy"]
|
|
275
|
+
).pl()
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Note:
|
|
279
|
+
Uses batch executemany for efficiency.
|
|
280
|
+
Silently returns if positions is empty.
|
|
281
|
+
Position id must be present.
|
|
282
|
+
"""
|
|
283
|
+
rows = []
|
|
284
|
+
for p in positions:
|
|
285
|
+
pid = getattr(p, "id", None)
|
|
286
|
+
if pid is None:
|
|
287
|
+
raise ValueError("Position must have id")
|
|
288
|
+
rows.append((strategy_id, ts, str(pid), _to_json(p)))
|
|
289
|
+
|
|
290
|
+
if not rows:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
self.con.executemany(
|
|
294
|
+
"""
|
|
295
|
+
INSERT INTO positions(strategy_id, ts, position_id, payload_json)
|
|
296
|
+
VALUES (?, ?, ?, ?)
|
|
297
|
+
ON CONFLICT(strategy_id, ts, position_id) DO UPDATE SET
|
|
298
|
+
payload_json = excluded.payload_json
|
|
299
|
+
""",
|
|
300
|
+
rows,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def append_trade(self, strategy_id: str, trade: Trade) -> None:
|
|
304
|
+
"""Append trade to log.
|
|
305
|
+
|
|
306
|
+
Adds trade to immutable log. Ignores if (strategy_id, trade_id) already exists.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
strategy_id (str): Strategy identifier.
|
|
310
|
+
trade (Trade): Trade to persist.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
ValueError: If trade missing id or timestamp.
|
|
314
|
+
|
|
315
|
+
Example:
|
|
316
|
+
```python
|
|
317
|
+
# After trade execution
|
|
318
|
+
trade = Trade(
|
|
319
|
+
position_id="pos_123",
|
|
320
|
+
pair="BTCUSDT",
|
|
321
|
+
side="BUY",
|
|
322
|
+
ts=datetime.now(),
|
|
323
|
+
price=45000.0,
|
|
324
|
+
qty=0.5,
|
|
325
|
+
fee=22.5
|
|
326
|
+
)
|
|
327
|
+
store.append_trade("my_strategy", trade)
|
|
328
|
+
|
|
329
|
+
# Query trades
|
|
330
|
+
trades_df = store.con.execute(
|
|
331
|
+
"SELECT * FROM trades WHERE strategy_id = ? ORDER BY ts",
|
|
332
|
+
["my_strategy"]
|
|
333
|
+
).pl()
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Note:
|
|
337
|
+
Uses INSERT ON CONFLICT DO NOTHING for idempotence.
|
|
338
|
+
Accepts both 'id' and 'trade_id' attributes.
|
|
339
|
+
Accepts both 'ts' and 'timestamp' attributes.
|
|
340
|
+
"""
|
|
341
|
+
tid = getattr(trade, "id", None) or getattr(trade, "trade_id", None)
|
|
342
|
+
ts = getattr(trade, "ts", None) or getattr(trade, "timestamp", None)
|
|
343
|
+
if tid is None or ts is None:
|
|
344
|
+
raise ValueError("Trade must have id and ts/timestamp")
|
|
345
|
+
|
|
346
|
+
self.con.execute(
|
|
347
|
+
"""
|
|
348
|
+
INSERT INTO trades(strategy_id, ts, trade_id, payload_json)
|
|
349
|
+
VALUES (?, ?, ?, ?)
|
|
350
|
+
ON CONFLICT(strategy_id, trade_id) DO NOTHING
|
|
351
|
+
""",
|
|
352
|
+
[strategy_id, ts, str(tid), _to_json(trade)],
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def append_metrics(self, strategy_id: str, ts: datetime, metrics: dict[str, float]) -> None:
|
|
356
|
+
"""Append metrics snapshot to database.
|
|
357
|
+
|
|
358
|
+
Records performance metrics at timestamp. Updates if (strategy_id, ts, name)
|
|
359
|
+
exists, inserts otherwise.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
strategy_id (str): Strategy identifier.
|
|
363
|
+
ts (datetime): Metrics timestamp.
|
|
364
|
+
metrics (dict[str, float]): Metric name-value pairs.
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
```python
|
|
368
|
+
from datetime import datetime
|
|
369
|
+
|
|
370
|
+
# After bar close
|
|
371
|
+
metrics = {
|
|
372
|
+
"total_return": 0.05,
|
|
373
|
+
"sharpe_ratio": 1.2,
|
|
374
|
+
"max_drawdown": -0.03,
|
|
375
|
+
"win_rate": 0.65
|
|
376
|
+
}
|
|
377
|
+
store.append_metrics("my_strategy", datetime.now(), metrics)
|
|
378
|
+
|
|
379
|
+
# Query metrics time series
|
|
380
|
+
metrics_df = store.con.execute('''
|
|
381
|
+
SELECT ts, name, value FROM metrics
|
|
382
|
+
WHERE strategy_id = ?
|
|
383
|
+
ORDER BY ts, name
|
|
384
|
+
''', ["my_strategy"]).pl()
|
|
385
|
+
|
|
386
|
+
# Pivot for analysis
|
|
387
|
+
pivoted = metrics_df.pivot(
|
|
388
|
+
index="ts",
|
|
389
|
+
columns="name",
|
|
390
|
+
values="value"
|
|
391
|
+
)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Note:
|
|
395
|
+
Uses batch executemany for efficiency.
|
|
396
|
+
Silently returns if metrics is empty.
|
|
397
|
+
Values coerced to float.
|
|
398
|
+
"""
|
|
399
|
+
if not metrics:
|
|
400
|
+
return
|
|
401
|
+
rows = [(strategy_id, ts, k, float(v)) for k, v in metrics.items()]
|
|
402
|
+
self.con.executemany(
|
|
403
|
+
"""
|
|
404
|
+
INSERT INTO metrics(strategy_id, ts, name, value)
|
|
405
|
+
VALUES (?, ?, ?, ?)
|
|
406
|
+
ON CONFLICT(strategy_id, ts, name) DO UPDATE SET value = excluded.value
|
|
407
|
+
""",
|
|
408
|
+
rows,
|
|
409
|
+
)
|