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,211 @@
|
|
|
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
|
+
from signalflow.core.containers.position import Position
|
|
11
|
+
from signalflow.core.containers.trade import Trade
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Portfolio:
|
|
15
|
+
"""Portfolio snapshot (pure domain).
|
|
16
|
+
|
|
17
|
+
Tracks cash and open/closed positions. Provides equity calculation
|
|
18
|
+
and DataFrame conversion utilities.
|
|
19
|
+
|
|
20
|
+
Portfolio state should only be modified through broker operations
|
|
21
|
+
to maintain accounting consistency.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
cash (float): Available cash balance.
|
|
25
|
+
positions (dict[str, Position]): Dictionary of positions keyed by position ID.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
from signalflow.core import Portfolio, Position, PositionType
|
|
30
|
+
|
|
31
|
+
# Initialize portfolio
|
|
32
|
+
portfolio = Portfolio(cash=10000.0)
|
|
33
|
+
|
|
34
|
+
# Add position
|
|
35
|
+
position = Position(
|
|
36
|
+
pair="BTCUSDT",
|
|
37
|
+
position_type=PositionType.LONG,
|
|
38
|
+
entry_price=45000.0,
|
|
39
|
+
qty=0.5
|
|
40
|
+
)
|
|
41
|
+
portfolio.positions[position.id] = position
|
|
42
|
+
|
|
43
|
+
# Calculate equity
|
|
44
|
+
prices = {"BTCUSDT": 46000.0, "ETHUSDT": 3200.0}
|
|
45
|
+
equity = portfolio.equity(prices=prices)
|
|
46
|
+
|
|
47
|
+
# Get open positions
|
|
48
|
+
open_positions = portfolio.open_positions()
|
|
49
|
+
|
|
50
|
+
# Convert to DataFrame
|
|
51
|
+
positions_df = Portfolio.positions_to_pl(open_positions)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Note:
|
|
55
|
+
Portfolio state should only be modified through broker operations.
|
|
56
|
+
Direct manipulation may lead to accounting inconsistencies.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
cash: float = 0.0
|
|
60
|
+
positions: dict[str, Position] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
def open_positions(self) -> list[Position]:
|
|
63
|
+
"""Get list of open (non-closed) positions.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
list[Position]: Open positions.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
```python
|
|
70
|
+
open = portfolio.open_positions()
|
|
71
|
+
print(f"Open positions: {len(open)}")
|
|
72
|
+
|
|
73
|
+
for pos in open:
|
|
74
|
+
print(f"{pos.pair}: {pos.qty} @ ${pos.entry_price}")
|
|
75
|
+
```
|
|
76
|
+
"""
|
|
77
|
+
return [p for p in self.positions.values() if not p.is_closed]
|
|
78
|
+
|
|
79
|
+
def equity(self, *, prices: dict[str, float]) -> float:
|
|
80
|
+
"""Calculate total portfolio equity.
|
|
81
|
+
|
|
82
|
+
Equity = cash + sum(side_sign * price * qty for all positions)
|
|
83
|
+
|
|
84
|
+
Executor must keep accounting consistent for accurate equity.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
prices (dict[str, float]): Current prices per pair.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
float: Total equity in currency units.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
```python
|
|
94
|
+
prices = {
|
|
95
|
+
"BTCUSDT": 46000.0,
|
|
96
|
+
"ETHUSDT": 3200.0
|
|
97
|
+
}
|
|
98
|
+
equity = portfolio.equity(prices=prices)
|
|
99
|
+
print(f"Total equity: ${equity:,.2f}")
|
|
100
|
+
|
|
101
|
+
# Track equity over time
|
|
102
|
+
equity_history = []
|
|
103
|
+
for ts, prices in price_history:
|
|
104
|
+
eq = portfolio.equity(prices=prices)
|
|
105
|
+
equity_history.append((ts, eq))
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Note:
|
|
109
|
+
If price not in dict, uses position's last_price.
|
|
110
|
+
Executor must keep accounting consistent.
|
|
111
|
+
"""
|
|
112
|
+
eq = self.cash
|
|
113
|
+
for p in self.positions.values():
|
|
114
|
+
px = prices.get(p.pair, p.last_price)
|
|
115
|
+
eq += p.side_sign * px * p.qty
|
|
116
|
+
return eq
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def positions_to_pl(positions: Iterable[Position]) -> pl.DataFrame:
|
|
120
|
+
"""Convert positions to Polars DataFrame.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
positions (Iterable[Position]): Positions to convert.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
pl.DataFrame: DataFrame with position data. Empty if no positions.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
```python
|
|
130
|
+
# Convert all positions
|
|
131
|
+
all_df = Portfolio.positions_to_pl(portfolio.positions.values())
|
|
132
|
+
|
|
133
|
+
# Convert only open positions
|
|
134
|
+
open_df = Portfolio.positions_to_pl(portfolio.open_positions())
|
|
135
|
+
|
|
136
|
+
# Analyze positions
|
|
137
|
+
print(open_df.select(["pair", "qty", "realized_pnl"]))
|
|
138
|
+
|
|
139
|
+
# Group by pair
|
|
140
|
+
by_pair = open_df.group_by("pair").agg([
|
|
141
|
+
pl.col("qty").sum().alias("total_qty"),
|
|
142
|
+
pl.col("realized_pnl").sum().alias("total_pnl")
|
|
143
|
+
])
|
|
144
|
+
```
|
|
145
|
+
"""
|
|
146
|
+
if not positions:
|
|
147
|
+
return pl.DataFrame()
|
|
148
|
+
return pl.DataFrame([
|
|
149
|
+
{
|
|
150
|
+
"id": p.id,
|
|
151
|
+
"is_closed": p.is_closed,
|
|
152
|
+
"pair": p.pair,
|
|
153
|
+
"position_type": p.position_type.value,
|
|
154
|
+
"signal_strength": p.signal_strength,
|
|
155
|
+
"entry_time": p.entry_time,
|
|
156
|
+
"last_time": p.last_time,
|
|
157
|
+
"entry_price": p.entry_price,
|
|
158
|
+
"last_price": p.last_price,
|
|
159
|
+
"qty": p.qty,
|
|
160
|
+
"fees_paid": p.fees_paid,
|
|
161
|
+
"realized_pnl": p.realized_pnl,
|
|
162
|
+
"meta": p.meta,
|
|
163
|
+
}
|
|
164
|
+
for p in positions
|
|
165
|
+
])
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def trades_to_pl(trades: Iterable[Trade]) -> pl.DataFrame:
|
|
169
|
+
"""Convert trades to Polars DataFrame.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
trades (Iterable[Trade]): Trades to convert.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
pl.DataFrame: DataFrame with trade data. Empty if no trades.
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
```python
|
|
179
|
+
# Convert all trades
|
|
180
|
+
trades_df = Portfolio.trades_to_pl(all_trades)
|
|
181
|
+
|
|
182
|
+
# Analyze trades
|
|
183
|
+
print(trades_df.select(["pair", "side", "price", "qty"]))
|
|
184
|
+
|
|
185
|
+
# Filter by type
|
|
186
|
+
entry_trades = trades_df.filter(
|
|
187
|
+
pl.col("meta").struct.field("type") == "entry"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Calculate total volume
|
|
191
|
+
total_volume = trades_df.select(
|
|
192
|
+
(pl.col("price") * pl.col("qty")).sum()
|
|
193
|
+
).item()
|
|
194
|
+
```
|
|
195
|
+
"""
|
|
196
|
+
if not trades:
|
|
197
|
+
return pl.DataFrame()
|
|
198
|
+
return pl.DataFrame([
|
|
199
|
+
{
|
|
200
|
+
"id": t.id,
|
|
201
|
+
"position_id": t.position_id,
|
|
202
|
+
"pair": t.pair,
|
|
203
|
+
"side": t.side,
|
|
204
|
+
"ts": t.ts,
|
|
205
|
+
"price": t.price,
|
|
206
|
+
"qty": t.qty,
|
|
207
|
+
"fee": t.fee,
|
|
208
|
+
"meta": t.meta,
|
|
209
|
+
}
|
|
210
|
+
for t in trades
|
|
211
|
+
])
|
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
from signalflow.core.containers.trade import Trade
|
|
11
|
+
from signalflow.core.containers.trade import TradeSide
|
|
12
|
+
from signalflow.core.enums import PositionType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class Position:
|
|
17
|
+
"""Trading position aggregate.
|
|
18
|
+
|
|
19
|
+
Mutable by design - tracks the lifecycle of a trading position through
|
|
20
|
+
multiple trades (entry, partial exits, full exit).
|
|
21
|
+
|
|
22
|
+
Position state changes through two operations:
|
|
23
|
+
- mark(): Update to current market price (mark-to-market)
|
|
24
|
+
- apply_trade(): Apply executed trade to position
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
id (str): Unique position identifier.
|
|
28
|
+
is_closed (bool): Whether position is closed.
|
|
29
|
+
pair (str): Trading pair (e.g. "BTCUSDT").
|
|
30
|
+
position_type (PositionType): LONG or SHORT.
|
|
31
|
+
signal_strength (float): Strength of initial signal (0-1).
|
|
32
|
+
entry_time (datetime | None): Position entry timestamp.
|
|
33
|
+
last_time (datetime | None): Last update timestamp.
|
|
34
|
+
entry_price (float): Average entry price.
|
|
35
|
+
last_price (float): Current/last marked price.
|
|
36
|
+
qty (float): Current quantity held.
|
|
37
|
+
fees_paid (float): Total fees paid.
|
|
38
|
+
realized_pnl (float): Realized profit/loss.
|
|
39
|
+
meta (dict[str, Any]): Additional metadata.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
```python
|
|
43
|
+
from signalflow.core import Position, PositionType, Trade
|
|
44
|
+
from datetime import datetime
|
|
45
|
+
|
|
46
|
+
# Create position
|
|
47
|
+
position = Position(
|
|
48
|
+
pair="BTCUSDT",
|
|
49
|
+
position_type=PositionType.LONG,
|
|
50
|
+
entry_price=45000.0,
|
|
51
|
+
qty=0.5,
|
|
52
|
+
signal_strength=0.85,
|
|
53
|
+
entry_time=datetime.now()
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Mark to market
|
|
57
|
+
position.mark(ts=datetime.now(), price=46000.0)
|
|
58
|
+
|
|
59
|
+
# Apply exit trade
|
|
60
|
+
exit_trade = Trade(
|
|
61
|
+
pair="BTCUSDT",
|
|
62
|
+
side="SELL",
|
|
63
|
+
ts=datetime.now(),
|
|
64
|
+
price=46500.0,
|
|
65
|
+
qty=0.5,
|
|
66
|
+
fee=23.25
|
|
67
|
+
)
|
|
68
|
+
position.apply_trade(exit_trade)
|
|
69
|
+
|
|
70
|
+
# Check results
|
|
71
|
+
print(f"Total PnL: ${position.total_pnl:.2f}")
|
|
72
|
+
print(f"Closed: {position.is_closed}")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Note:
|
|
76
|
+
Position changes ONLY through apply_trade() and mark().
|
|
77
|
+
Direct attribute modification should be avoided.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
81
|
+
is_closed: bool = False
|
|
82
|
+
|
|
83
|
+
pair: str = ""
|
|
84
|
+
position_type: PositionType = PositionType.LONG
|
|
85
|
+
signal_strength: float = 1.0
|
|
86
|
+
|
|
87
|
+
entry_time: datetime | None = None
|
|
88
|
+
last_time: datetime | None = None
|
|
89
|
+
|
|
90
|
+
entry_price: float = 0.0
|
|
91
|
+
last_price: float = 0.0
|
|
92
|
+
|
|
93
|
+
qty: float = 0.0
|
|
94
|
+
fees_paid: float = 0.0
|
|
95
|
+
realized_pnl: float = 0.0
|
|
96
|
+
|
|
97
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def side_sign(self) -> float:
|
|
101
|
+
"""Position direction multiplier.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
float: 1.0 for LONG, -1.0 for SHORT.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
```python
|
|
108
|
+
long_pos = Position(position_type=PositionType.LONG)
|
|
109
|
+
assert long_pos.side_sign == 1.0
|
|
110
|
+
|
|
111
|
+
short_pos = Position(position_type=PositionType.SHORT)
|
|
112
|
+
assert short_pos.side_sign == -1.0
|
|
113
|
+
```
|
|
114
|
+
"""
|
|
115
|
+
return 1.0 if self.position_type == PositionType.LONG else -1.0
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def unrealized_pnl(self) -> float:
|
|
119
|
+
"""Unrealized profit/loss at current price.
|
|
120
|
+
|
|
121
|
+
Calculated as: side_sign * (last_price - entry_price) * qty
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
float: Unrealized PnL in currency units.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
```python
|
|
128
|
+
position = Position(
|
|
129
|
+
position_type=PositionType.LONG,
|
|
130
|
+
entry_price=45000.0,
|
|
131
|
+
last_price=46000.0,
|
|
132
|
+
qty=0.5
|
|
133
|
+
)
|
|
134
|
+
# (46000 - 45000) * 0.5 = 500
|
|
135
|
+
assert position.unrealized_pnl == 500.0
|
|
136
|
+
```
|
|
137
|
+
"""
|
|
138
|
+
return self.side_sign * (self.last_price - self.entry_price) * self.qty
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def total_pnl(self) -> float:
|
|
142
|
+
"""Total profit/loss including fees.
|
|
143
|
+
|
|
144
|
+
Calculated as: realized_pnl + unrealized_pnl - fees_paid
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
float: Total PnL in currency units.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
```python
|
|
151
|
+
position = Position(
|
|
152
|
+
realized_pnl=100.0,
|
|
153
|
+
fees_paid=50.0
|
|
154
|
+
)
|
|
155
|
+
position.mark(ts=datetime.now(), price=46000.0)
|
|
156
|
+
total = position.total_pnl # realized + unrealized - fees
|
|
157
|
+
```
|
|
158
|
+
"""
|
|
159
|
+
return self.realized_pnl + self.unrealized_pnl - self.fees_paid
|
|
160
|
+
|
|
161
|
+
def mark(self, *, ts: datetime, price: float) -> None:
|
|
162
|
+
"""Update position to current market price (mark-to-market).
|
|
163
|
+
|
|
164
|
+
Updates last_time and last_price without affecting position size.
|
|
165
|
+
Used for tracking unrealized PnL over time.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
ts (datetime): Update timestamp.
|
|
169
|
+
price (float): Current market price.
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
```python
|
|
173
|
+
# Mark position at each bar
|
|
174
|
+
for bar in bars:
|
|
175
|
+
position.mark(ts=bar.timestamp, price=bar.close)
|
|
176
|
+
print(f"Unrealized PnL: ${position.unrealized_pnl:.2f}")
|
|
177
|
+
```
|
|
178
|
+
"""
|
|
179
|
+
self.last_time = ts
|
|
180
|
+
self.last_price = float(price)
|
|
181
|
+
|
|
182
|
+
def apply_trade(self, trade: Trade) -> None:
|
|
183
|
+
"""Apply trade fill to position.
|
|
184
|
+
|
|
185
|
+
Updates position state based on trade:
|
|
186
|
+
- Increases position if trade direction matches position type
|
|
187
|
+
- Decreases position (partial/full close) if direction opposes
|
|
188
|
+
- Updates fees, realized PnL, and closing status
|
|
189
|
+
|
|
190
|
+
Trade processing:
|
|
191
|
+
- BUY increases LONG, SELL decreases LONG
|
|
192
|
+
- SELL increases SHORT, BUY decreases SHORT
|
|
193
|
+
- Entry price updated as weighted average on increases
|
|
194
|
+
- Realized PnL computed on decreases
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
trade (Trade): Trade to apply. Must have qty > 0.
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
```python
|
|
201
|
+
# Open position
|
|
202
|
+
entry_trade = Trade(
|
|
203
|
+
pair="BTCUSDT",
|
|
204
|
+
side="BUY",
|
|
205
|
+
price=45000.0,
|
|
206
|
+
qty=1.0,
|
|
207
|
+
fee=45.0
|
|
208
|
+
)
|
|
209
|
+
position.apply_trade(entry_trade)
|
|
210
|
+
|
|
211
|
+
# Partial close
|
|
212
|
+
exit_trade = Trade(
|
|
213
|
+
pair="BTCUSDT",
|
|
214
|
+
side="SELL",
|
|
215
|
+
price=46000.0,
|
|
216
|
+
qty=0.5,
|
|
217
|
+
fee=23.0
|
|
218
|
+
)
|
|
219
|
+
position.apply_trade(exit_trade)
|
|
220
|
+
|
|
221
|
+
# Check state
|
|
222
|
+
assert position.qty == 0.5
|
|
223
|
+
assert position.realized_pnl == 500.0 # (46000-45000)*0.5
|
|
224
|
+
assert not position.is_closed
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Note:
|
|
228
|
+
Assumes trade.qty > 0. Position automatically marked as closed
|
|
229
|
+
when qty reaches 0.
|
|
230
|
+
"""
|
|
231
|
+
self.last_time = trade.ts
|
|
232
|
+
self.last_price = float(trade.price)
|
|
233
|
+
self.fees_paid += float(trade.fee)
|
|
234
|
+
|
|
235
|
+
is_increase = self._is_increase(trade.side)
|
|
236
|
+
|
|
237
|
+
if is_increase:
|
|
238
|
+
self._increase(trade)
|
|
239
|
+
else:
|
|
240
|
+
self._decrease(trade)
|
|
241
|
+
|
|
242
|
+
def _is_increase(self, side: TradeSide) -> bool:
|
|
243
|
+
"""Check if trade increases position size.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
side (TradeSide): Trade side ("BUY" or "SELL").
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
bool: True if trade increases position.
|
|
250
|
+
"""
|
|
251
|
+
return (
|
|
252
|
+
(self.position_type == PositionType.LONG and side == "BUY")
|
|
253
|
+
or (self.position_type == PositionType.SHORT and side == "SELL")
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _increase(self, trade: Trade) -> None:
|
|
257
|
+
"""Increase position size with new trade.
|
|
258
|
+
|
|
259
|
+
Updates entry_price as weighted average of existing and new position.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
trade (Trade): Trade to add to position.
|
|
263
|
+
"""
|
|
264
|
+
new_qty = self.qty + trade.qty
|
|
265
|
+
if new_qty <= 0:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if self.qty == 0:
|
|
269
|
+
self.entry_price = trade.price
|
|
270
|
+
self.entry_time = trade.ts
|
|
271
|
+
else:
|
|
272
|
+
self.entry_price = (
|
|
273
|
+
self.entry_price * self.qty + trade.price * trade.qty
|
|
274
|
+
) / new_qty
|
|
275
|
+
|
|
276
|
+
self.qty = new_qty
|
|
277
|
+
|
|
278
|
+
def _decrease(self, trade: Trade) -> None:
|
|
279
|
+
"""Decrease position size (partial or full close).
|
|
280
|
+
|
|
281
|
+
Computes realized PnL for closed portion.
|
|
282
|
+
Marks position as closed if qty reaches 0.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
trade (Trade): Trade to close position.
|
|
286
|
+
"""
|
|
287
|
+
close_qty = min(self.qty, trade.qty)
|
|
288
|
+
if close_qty <= 0:
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
pnl = self.side_sign * (trade.price - self.entry_price) * close_qty
|
|
292
|
+
self.realized_pnl += pnl
|
|
293
|
+
self.qty -= close_qty
|
|
294
|
+
|
|
295
|
+
if self.qty == 0:
|
|
296
|
+
self.is_closed = True
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import polars as pl
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RawData:
|
|
11
|
+
"""Immutable container for raw market data.
|
|
12
|
+
|
|
13
|
+
Acts as a unified in-memory bundle for multiple raw datasets
|
|
14
|
+
(e.g. spot prices, funding, trades, orderbook, signals).
|
|
15
|
+
|
|
16
|
+
Design principles:
|
|
17
|
+
- Canonical storage is dataset-based (dictionary by name)
|
|
18
|
+
- Datasets accessed via string keys (e.g. raw_data["spot"])
|
|
19
|
+
- No business logic or transformations
|
|
20
|
+
- Immutability ensures reproducibility in pipelines
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
datetime_start (datetime): Start datetime of the data snapshot.
|
|
24
|
+
datetime_end (datetime): End datetime of the data snapshot.
|
|
25
|
+
pairs (list[str]): List of trading pairs in the snapshot.
|
|
26
|
+
data (dict[str, pl.DataFrame]): Dictionary of datasets keyed by name.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
```python
|
|
30
|
+
from signalflow.core import RawData
|
|
31
|
+
import polars as pl
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
|
|
34
|
+
# Create RawData with spot data
|
|
35
|
+
raw_data = RawData(
|
|
36
|
+
datetime_start=datetime(2024, 1, 1),
|
|
37
|
+
datetime_end=datetime(2024, 12, 31),
|
|
38
|
+
pairs=["BTCUSDT", "ETHUSDT"],
|
|
39
|
+
data={
|
|
40
|
+
"spot": spot_dataframe,
|
|
41
|
+
"signals": signals_dataframe,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Access datasets
|
|
46
|
+
spot_df = raw_data["spot"]
|
|
47
|
+
signals_df = raw_data.get("signals")
|
|
48
|
+
|
|
49
|
+
# Check if dataset exists
|
|
50
|
+
if "spot" in raw_data:
|
|
51
|
+
print("Spot data available")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Note:
|
|
55
|
+
Dataset schemas are defined by convention, not enforced.
|
|
56
|
+
Views (pandas/polars) should be handled by RawDataView wrapper.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
datetime_start: datetime
|
|
60
|
+
datetime_end: datetime
|
|
61
|
+
pairs: list[str] = field(default_factory=list)
|
|
62
|
+
data: dict[str, pl.DataFrame] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def get(self, key: str) -> pl.DataFrame:
|
|
65
|
+
"""Get dataset by key.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
key (str): Dataset name (e.g. "spot", "signals").
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
pl.DataFrame: Polars DataFrame if exists, empty DataFrame otherwise.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
TypeError: If dataset exists but is not a Polars DataFrame.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
```python
|
|
78
|
+
spot_df = raw_data.get("spot")
|
|
79
|
+
|
|
80
|
+
# Returns empty DataFrame if key doesn't exist
|
|
81
|
+
missing_df = raw_data.get("nonexistent")
|
|
82
|
+
assert missing_df.is_empty()
|
|
83
|
+
```
|
|
84
|
+
"""
|
|
85
|
+
obj = self.data.get(key)
|
|
86
|
+
if obj is None:
|
|
87
|
+
return pl.DataFrame()
|
|
88
|
+
if not isinstance(obj, pl.DataFrame):
|
|
89
|
+
raise TypeError(
|
|
90
|
+
f"Dataset '{key}' is not a polars.DataFrame: {type(obj)}"
|
|
91
|
+
)
|
|
92
|
+
return obj
|
|
93
|
+
|
|
94
|
+
def __getitem__(self, key: str) -> pl.DataFrame:
|
|
95
|
+
"""Dictionary-style access to datasets.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
key (str): Dataset name.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
pl.DataFrame: Dataset as Polars DataFrame.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
```python
|
|
105
|
+
spot_df = raw_data["spot"]
|
|
106
|
+
```
|
|
107
|
+
"""
|
|
108
|
+
return self.get(key)
|
|
109
|
+
|
|
110
|
+
def __contains__(self, key: str) -> bool:
|
|
111
|
+
"""Check if dataset exists.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
key (str): Dataset name to check.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
bool: True if dataset exists, False otherwise.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
```python
|
|
121
|
+
if "spot" in raw_data:
|
|
122
|
+
process_spot_data(raw_data["spot"])
|
|
123
|
+
```
|
|
124
|
+
"""
|
|
125
|
+
return key in self.data
|
|
126
|
+
|
|
127
|
+
def keys(self) -> Iterator[str]:
|
|
128
|
+
"""Return available dataset keys.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Iterator[str]: Iterator over dataset names.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
```python
|
|
135
|
+
for key in raw_data.keys():
|
|
136
|
+
print(f"Dataset: {key}")
|
|
137
|
+
```
|
|
138
|
+
"""
|
|
139
|
+
return self.data.keys()
|
|
140
|
+
|
|
141
|
+
def items(self):
|
|
142
|
+
"""Return (key, dataset) pairs.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Iterator: Iterator over (key, DataFrame) tuples.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
```python
|
|
149
|
+
for name, df in raw_data.items():
|
|
150
|
+
print(f"{name}: {df.shape}")
|
|
151
|
+
```
|
|
152
|
+
"""
|
|
153
|
+
return self.data.items()
|
|
154
|
+
|
|
155
|
+
def values(self):
|
|
156
|
+
"""Return dataset values.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Iterator: Iterator over DataFrames.
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
```python
|
|
163
|
+
for df in raw_data.values():
|
|
164
|
+
print(df.columns)
|
|
165
|
+
```
|
|
166
|
+
"""
|
|
167
|
+
return self.data.values()
|