Coverage for src/finbot/portfolio/portfolio.py: 100%
67 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
1import math
2from dataclasses import dataclass, field
4from finbot.execution.models import Fill, OrderSide
7@dataclass
8class Position:
9 symbol: str
10 quantity: float = 0.0
11 average_entry_price: float = 0.0
12 realized_pnl: float = 0.0
13 entry_fees: float = 0.0
15 def market_value(self, market_price: float) -> float:
16 return self.quantity * market_price
18 def unrealized_pnl(self, market_price: float) -> float:
19 return (market_price - self.average_entry_price) * self.quantity
22@dataclass
23class Portfolio:
24 cash: float
25 positions: dict[str, Position] = field(default_factory=dict)
26 fees_paid: float = 0.0
28 def apply_fill(self, fill: Fill) -> Fill:
29 if fill.side == OrderSide.BUY:
30 self._apply_buy_fill(fill)
31 elif fill.side == OrderSide.SELL:
32 self._apply_sell_fill(fill)
33 else:
34 raise ValueError(f"Unsupported fill side: {fill.side}")
36 return fill
38 def _apply_buy_fill(self, fill: Fill) -> None:
39 cost = fill.price * fill.quantity + fill.fee
41 if cost > self.cash and not math.isclose(
42 cost,
43 self.cash,
44 rel_tol=1e-12,
45 abs_tol=1e-9,
46 ):
47 raise ValueError(
48 f"Insufficient funds: required {cost}, available {self.cash}",
49 )
51 position = self.positions.get(fill.symbol)
52 if position is None:
53 position = Position(symbol=fill.symbol)
54 new_quantity = position.quantity + fill.quantity
55 new_average_entry_price = \
56 (
57 position.quantity * position.average_entry_price + fill.quantity * fill.price
58 ) / new_quantity
60 self.fees_paid += fill.fee
61 self.cash -= cost
63 position.quantity = new_quantity
64 position.average_entry_price = new_average_entry_price
65 position.entry_fees += fill.fee
67 self.positions[fill.symbol] = position
69 def _apply_sell_fill(self, fill: Fill) -> None:
70 position = self.positions.get(fill.symbol)
72 if position is None:
73 raise ValueError(f"No position for {fill.symbol}")
75 if fill.quantity > position.quantity and not math.isclose(
76 fill.quantity,
77 position.quantity,
78 rel_tol=1e-12,
79 abs_tol=1e-9,
80 ):
81 raise ValueError(
82 f"Insufficient position: trying to sell {fill.quantity}, "
83 f"but only {position.quantity} available",
84 )
86 proceeds = fill.price * fill.quantity - fill.fee
88 entry_fee = position.entry_fees * (fill.quantity / position.quantity)
89 realized_pnl = (
90 (fill.price - position.average_entry_price) * fill.quantity - entry_fee - fill.fee
91 )
93 self.fees_paid += fill.fee
94 self.cash += proceeds
95 position.quantity -= fill.quantity
96 position.entry_fees -= entry_fee
97 position.realized_pnl += realized_pnl
99 if math.isclose(position.quantity, 0.0, abs_tol=1e-12):
100 position.quantity = 0.0
102 self.positions[fill.symbol] = position
104 def positions_value(self, market_prices: dict[str, float]) -> float:
105 total = 0.0
107 for symbol, position in self.positions.items():
108 if symbol not in market_prices:
109 raise ValueError(f"Missing market price for {symbol}")
110 total += position.market_value(market_prices[symbol])
112 return total
114 def total_value(self, market_prices: dict[str, float]) -> float:
115 return self.cash + self.positions_value(market_prices)