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