"""Definitions related to tracking an investment portfolio of cash and stocks.
Underlying arithmetic uses exact decimal representations for max precision.
"""
from decimal import Decimal
from functools import total_ordering
[docs]@total_ordering
class Position:
"""A position in holding a security.
Args:
cost: Initial purchase cost.
quantity: Number of shares held at ``cost``.
"""
# Average dollar cost for each share in the position.
_average_cost_basis: Decimal
# Current number of shares owned in the position.
_quantity: Decimal
# Total dollar cost for all shares in the position.
# The amount of dollars or cash invested in this security.
_total_cost_basis: Decimal
def __init__(self, cost: float, quantity: float, /) -> None:
self._average_cost_basis = Decimal(cost)
self._quantity = Decimal(quantity)
self._total_cost_basis = self._average_cost_basis * self._quantity
def __eq__(self, __o: object) -> bool:
"""Compare the position's cost basis."""
if not isinstance(__o, float | Position):
raise TypeError(
"Can only compare "
f"{self.__class__.__name__} to [{float.__name__}, {Position.__name__}]"
)
if isinstance(__o, Position):
return self._average_cost_basis == __o._average_cost_basis
return self._average_cost_basis == __o
def __lt__(self, __o: object) -> bool:
"""Compare the position's cost basis."""
if not isinstance(__o, float | Position):
raise TypeError(
"Can only compare "
f"{self.__class__.__name__} to [{float.__name__}, {Position.__name__}]"
)
if isinstance(__o, Position):
return self._average_cost_basis < __o._average_cost_basis
return self._average_cost_basis < __o
@property
def average_cost_basis(self) -> float:
"""Average dollar cost for each share in the position."""
return float(self._average_cost_basis)
[docs] def buy(self, cost: float, quantity: float, /) -> float:
"""Buy ``quantity`` of the position for ``cost``.
Args:
cost: Cost to buy at.
quantity: Number of shares to buy.
Returns:
Value of the bought position.
Examples:
>>> from finagg.portfolio import Position
>>> pos = Position(100.0, 1)
>>> pos.buy(50.0, 1)
50.0
>>> pos.total_cost_basis
150.0
>>> pos.average_cost_basis
75.0
>>> pos.quantity
2.0
"""
exact_cost = Decimal(cost)
exact_quantity = Decimal(quantity)
self._quantity += exact_quantity
self._total_cost_basis = self._total_cost_basis + exact_cost * exact_quantity
self._average_cost_basis = self._total_cost_basis / self._quantity
return float(exact_cost * exact_quantity)
@property
def quantity(self) -> float:
"""Current number of shares owned in the position."""
return float(self._quantity)
[docs] def sell(self, cost: float, quantity: float, /) -> float:
"""Sell ``quantity`` of the position for ``cost``.
Args:
cost: Cost to sell at.
quantity: Number of shares to sell.
Returns:
Value of the sold position.
Raises:
`ValueError`: If there aren't enough shares
to sell in the position.
Examples:
>>> from finagg.portfolio import Position
>>> pos = Position(100.0, 2)
>>> pos.sell(50.0, 1)
50.0
>>> pos.total_cost_basis
100.0
>>> pos.average_cost_basis
100.0
>>> pos.quantity
1.0
"""
exact_cost = Decimal(cost)
exact_quantity = Decimal(quantity)
if self._quantity < exact_quantity:
raise ValueError("Invalid order - not enough shares.")
self._quantity -= exact_quantity
self._total_cost_basis = self._average_cost_basis * self._quantity
return float(exact_cost * exact_quantity)
@property
def total_cost_basis(self) -> float:
"""Total dollar cost for all shares in the position. The amount of
dollars or cash invested in this security.
"""
return float(self._total_cost_basis)
[docs] def total_dollar_change(self, cost: float, /) -> float:
"""Compute the total dollar change relative to the average
cost basis and the current value of the security.
Args:
cost: Current value of one share.
Returns:
Total dollar change in value.
Examples:
>>> from finagg.portfolio import Position
>>> pos = Position(100.0, 1)
>>> pos.total_dollar_change(50.0)
-50.0
"""
return float((Decimal(cost) - self._average_cost_basis) * self._quantity)
[docs] def total_log_change(self, cost: float, /) -> float:
"""Compute the total log change relative to the average
cost basis and the current value of the security.
Args:
cost: Current value of one share.
Returns:
Total log change in value. Negative indicates loss
in value, positive indicates gain in value.
Examples:
>>> from finagg.portfolio import Position
>>> pos = Position(100.0, 1)
>>> pos.total_log_change(50.0)
-0.6931471805599453
"""
return float(Decimal(cost).ln() - self._average_cost_basis.ln())
[docs] def total_percent_change(self, cost: float, /) -> float:
"""Compute the total percent change relative to the average
cost basis and the current value of the security.
Args:
cost: Current value of one share.
Returns:
Total percent change in value. Negative indicates loss
in value, positive indicates gain in value.
Examples:
>>> from finagg.portfolio import Position
>>> pos = Position(100.0, 1)
>>> pos.total_percent_change(50.0)
-0.5
"""
return float((Decimal(cost) / self._average_cost_basis) - 1)
[docs]class Portfolio:
"""A collection of cash and security positions.
Args:
cash: Starting cash position.
"""
# Total liquid cash on-hand.
_cash: Decimal
# Total cash deposited since starting the portfolio.
_total_deposits: Decimal
# Total cash withdrawn since starting the portfolio.
_total_withdrawals: Decimal
#: Existing positions for each security.
positions: dict[str, Position]
def __init__(self, cash: float, /) -> None:
self._cash = Decimal(cash)
self._total_deposits = self._cash
self._total_withdrawals = Decimal(0)
self.positions = {}
def __contains__(self, symbol: str) -> bool:
"""Return whether the portfolio contains a position
in ``symbol``.
"""
return symbol in self.positions
def __getitem__(self, symbol: str) -> Position:
"""Return the portfolio's position in the security
identified by `symbol`.
"""
return self.positions[symbol]
@property
def cash(self) -> float:
"""Total liquid cash on-hand."""
return float(self._cash)
[docs] def buy(self, symbol: str, cost: float, quantity: float, /) -> float:
"""Buy ``quantity`` of security with ``symbol`` for ``cost``.
Args:
symbol: Security ticker.
cost: Cost to buy the symbol at.
quantity: Number of shares to purchase.
Returns:
Value of the symbol's bought position in the
portfolio.
Raises:
`ValueError`: If the portfolio doesn't have enough cash
to execute the buy order.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.buy("AAPL", 100.0, 1)
100.0
>>> pos = port["AAPL"]
>>> pos.total_cost_basis
100.0
>>> pos.average_cost_basis
100.0
>>> pos.quantity
1.0
"""
current_value = Decimal(cost) * Decimal(quantity)
if self._cash < current_value:
raise ValueError("Invalid order - not enough cash.")
self._cash -= current_value
if symbol not in self.positions:
self.positions[symbol] = Position(cost, quantity)
return float(current_value)
else:
return self.positions[symbol].buy(cost, quantity)
[docs] def deposit(self, cash: float, /) -> float:
"""Deposit more cash into the portfolio.
Args:
cash: Cash to deposit.
Returns:
Total cash in the portfolio.
"""
exact_cash = Decimal(cash)
self._cash += exact_cash
self._total_deposits += exact_cash
return float(self._cash)
[docs] def sell(self, symbol: str, cost: float, quantity: float, /) -> float:
"""Sell ``quantity`` of security with `symbol` for ``cost``.
Args:
symbol: Security ticker.
cost: Cost to sell the symbol at.
quantity: Number of shares to sell.
Returns:
Value of the symbol's sold position in the
portfolio.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.buy("AAPL", 100.0, 2)
200.0
>>> port.sell("AAPL", 50.0, 1)
50.0
>>> pos = port["AAPL"]
>>> pos.total_cost_basis
100.0
>>> pos.average_cost_basis
100.0
>>> pos.quantity
1.0
"""
current_value = self.positions[symbol].sell(cost, quantity)
if not self.positions[symbol]._quantity:
self.positions.pop(symbol)
self._cash += Decimal(cost) * Decimal(quantity)
return float(current_value)
@property
def total_deposits(self) -> float:
"""Total cash deposited since starting the portfolio."""
return float(self._total_deposits)
[docs] def total_dollar_change(self, costs: dict[str, float], /) -> float:
"""Compute the total dollar change relative to the total
deposits made into the portfolio.
Args:
costs: Mapping of symbol to its current value of one share.
Returns:
Total dollar change in value.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.buy("AAPL", 100.0, 1)
100.0
>>> port.total_dollar_change({"AAPL": 50.0})
-50.0
"""
return float(Decimal(self.total_dollar_value(costs)) - self._total_deposits)
[docs] def total_dollar_value(self, costs: dict[str, float], /) -> float:
"""Compute the total dollar value of the portfolio.
Args:
costs: Mapping of symbol to its current value of one share.
Returns:
Total dollar value.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.buy("AAPL", 100.0, 1)
100.0
>>> port.total_dollar_value({"AAPL": 50.0})
950.0
"""
total_dollar_value = self._cash
for symbol, cost in costs.items():
if symbol in self.positions:
total_dollar_value += Decimal(cost) * self.positions[symbol]._quantity
return float(total_dollar_value)
[docs] def total_log_change(self, costs: dict[str, float], /) -> float:
"""Compute the total log change relative to the total
deposits made into the portfolio.
Args:
costs: Mapping of symbol to its current value of one share.
Returns:
Total log change in value. Negative indicates loss
in value, positive indicates gain in value.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.buy("AAPL", 100.0, 1)
100.0
>>> port.total_log_change({"AAPL": 50.0})
-0.051293294387550536
"""
return float(
Decimal(self.total_dollar_value(costs)).ln() - self._total_deposits.ln()
)
[docs] def total_percent_change(self, costs: dict[str, float], /) -> float:
"""Compute the total percent change relative to the total
deposits made into the portfolio.
Args:
costs: Mapping of symbol to its current value of one share.
Returns:
Total percent change in value. Negative indicates loss
in value, positive indicates gain in value.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.buy("AAPL", 100.0, 1)
100.0
>>> port.total_percent_change({"AAPL": 50.0})
-0.05
"""
return float(
(Decimal(self.total_dollar_value(costs)) / self._total_deposits) - 1
)
@property
def total_withdrawals(self) -> float:
"""Total cash withdrawn since starting the portfolio."""
return float(self._total_withdrawals)
[docs] def withdraw(self, cash: float, /) -> float:
"""Withdraw cash from the portfolio.
Args:
cash: Cash to withdraw.
Returns:
Total cash in the portfolio.
Raises:
`ValueError`: If the portfolio doesn't have at least ``cash``
liquid cash to withdraw.
Examples:
>>> from finagg.portfolio import Portfolio
>>> port = Portfolio(1000.0)
>>> port.withdraw(100.0)
900.0
"""
exact_cash = Decimal(cash)
if self._cash < exact_cash:
raise ValueError("Not enough cash to withdraw.")
self._cash -= exact_cash
self._total_withdrawals += exact_cash
return float(self._cash)