Coverage for src/finbot/portfolio/portfolio.py: 100%
57 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
1import math
2from dataclasses import dataclass, field
4from finbot.execution.fill import Fill
5from finbot.execution.order import OrderSide
6from finbot.portfolio.position import Position
9@dataclass
10class Portfolio:
11 cash: float
12 positions: dict[str, Position] = field(default_factory=dict)
13 fees_paid: float = 0.0
15 def apply_fill(self, fill: Fill) -> Fill:
16 if fill.side == OrderSide.BUY:
17 self._apply_buy_fill(fill)
18 elif fill.side == OrderSide.SELL:
19 self._apply_sell_fill(fill)
20 else:
21 raise ValueError(f"Unsupported fill side: {fill.side}")
23 return fill
25 def _apply_buy_fill(self, fill: Fill) -> None:
26 cost = fill.price * fill.quantity + fill.fee
28 if cost > self.cash and not math.isclose(
29 cost,
30 self.cash,
31 rel_tol=1e-12,
32 abs_tol=1e-9,
33 ):
34 raise ValueError(
35 f"Insufficient funds: required {cost}, available {self.cash}",
36 )
38 position = self.positions.get(fill.symbol)
39 if position is None: position = Position(symbol=fill.symbol)
40 new_quantity = position.quantity + fill.quantity
41 new_average_entry_price = \
42 (
43 position.quantity * position.average_entry_price + fill.quantity * fill.price
44 ) / new_quantity
46 self.fees_paid += fill.fee
47 self.cash -= cost
49 position.quantity = new_quantity
50 position.average_entry_price = new_average_entry_price
51 position.entry_fees += fill.fee
53 self.positions[fill.symbol] = position
55 def _apply_sell_fill(self, fill: Fill) -> None:
56 position = self.positions.get(fill.symbol)
58 if position is None:
59 raise ValueError(f"No position for {fill.symbol}")
61 if fill.quantity > position.quantity and not math.isclose(
62 fill.quantity,
63 position.quantity,
64 rel_tol=1e-12,
65 abs_tol=1e-9,
66 ):
67 raise ValueError(
68 f"Insufficient position: trying to sell {fill.quantity}, "
69 f"but only {position.quantity} available",
70 )
72 proceeds = fill.price * fill.quantity - fill.fee
74 entry_fee = position.entry_fees * (fill.quantity / position.quantity)
75 realized_pnl = (
76 (fill.price - position.average_entry_price) * fill.quantity - entry_fee - fill.fee
77 )
79 self.fees_paid += fill.fee
80 self.cash += proceeds
81 position.quantity -= fill.quantity
82 position.entry_fees -= entry_fee
83 position.realized_pnl += realized_pnl
85 if math.isclose(position.quantity, 0.0, abs_tol=1e-12):
86 position.quantity = 0.0
88 self.positions[fill.symbol] = position
90 def positions_value(self, market_prices: dict[str, float]) -> float:
91 total = 0.0
93 for symbol, position in self.positions.items():
94 if symbol not in market_prices:
95 raise ValueError(f"Missing market price for {symbol}")
96 total += position.market_value(market_prices[symbol])
98 return total
100 def total_value(self, market_prices: dict[str, float]) -> float:
101 return self.cash + self.positions_value(market_prices)