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