Skip to main content

mts1b_foundation.positions — full reference

What you currently hold + the lots that built it.

Position

class Position(BaseModel):
fund_id: str
symbol: Symbol
quantity: Decimal # signed: + long, - short
avg_price: Decimal
last_price: Decimal
market_value_usd: Decimal
unrealized_pl_usd: Decimal = Decimal("0")
realized_pl_ytd_usd: Decimal = Decimal("0")
last_updated: datetime
tax_lots: list[TaxLot] = []

Quick example

from datetime import datetime, timezone
from decimal import Decimal
from mts1b_foundation.positions import Position, TaxLot
from mts1b_foundation.symbology import Symbol

pos = Position(
fund_id="paper-momentum",
symbol=Symbol("AAPL"),
quantity=Decimal("100"),
avg_price=Decimal("180.50"),
last_price=Decimal("185.00"),
market_value_usd=Decimal("18500"),
unrealized_pl_usd=Decimal("450"), # (185 - 180.50) × 100
last_updated=datetime.now(timezone.utc),
)

print(pos.model_dump_json(indent=2))

Sign conventions

quantityMeaning
> 0Long
< 0Short
0Flat

market_value_usd is signed too. A short position has negative market value (you owe shares).

short_pos = Position(
fund_id="paper-ls",
symbol=Symbol("TSLA"),
quantity=Decimal("-50"),
avg_price=Decimal("200"),
last_price=Decimal("210"),
market_value_usd=Decimal("-10500"), # short = negative MV
unrealized_pl_usd=Decimal("-500"), # short going against you
last_updated=datetime.now(timezone.utc),
)

Computing P/L

def unrealized_pl(position: Position) -> Decimal:
return position.quantity * (position.last_price - position.avg_price)


def realized_pl_from_close(opening_avg: Decimal, close_price: Decimal,
qty: Decimal, side: Side) -> Decimal:
if side in (Side.SELL, Side.COVER):
return qty * (close_price - opening_avg)
raise ValueError("realized P/L is computed on close (SELL or COVER)")

TaxLot

class TaxLot(BaseModel):
lot_id: str
fund_id: str
symbol: Symbol
open_date: date
open_qty: Decimal
open_price: Decimal
close_date: date | None = None
close_qty: Decimal = Decimal("0")
close_price: Decimal | None = None
realized_pl_usd: Decimal | None = None
holding_period_days: int = 0
short_term: bool = True

Lifecycle

Phaseopen_qtyclose_qtyclose_date
Open lot1000None
Partial close10040None
Closed1001002026-05-23
lot = TaxLot(
lot_id="lot-001",
fund_id="live-equities",
symbol=Symbol("AAPL"),
open_date=date(2025, 1, 15),
open_qty=Decimal("100"),
open_price=Decimal("150"),
)


# Sell 40 at $200
partial = lot.model_copy(update={
"close_qty": Decimal("40"),
"close_price": Decimal("200"),
"realized_pl_usd": Decimal("40") * (Decimal("200") - Decimal("150")), # +$2000
"holding_period_days": (date(2025, 3, 1) - lot.open_date).days, # 45 days
"short_term": True,
})

Long-term vs short-term

def update_short_term(lot: TaxLot, asof: date) -> TaxLot:
"""Held > 365 days = long-term for U.S. tax."""
days = (asof - lot.open_date).days
return lot.model_copy(update={
"holding_period_days": days,
"short_term": days < 365,
})

FIFO / HIFO matchers

def fifo_match(open_lots: list[TaxLot], close_qty: Decimal) -> list[tuple[TaxLot, Decimal]]:
"""Match a close against oldest lots first."""
results = []
remaining = close_qty
for lot in sorted(open_lots, key=lambda l: l.open_date):
if remaining <= 0:
break
available = lot.open_qty - lot.close_qty
take = min(available, remaining)
results.append((lot, take))
remaining -= take
if remaining > 0:
raise ValueError(f"insufficient lots to close {close_qty}; short by {remaining}")
return results


def hifo_match(open_lots: list[TaxLot], close_qty: Decimal) -> list[tuple[TaxLot, Decimal]]:
"""Match against HIGHEST cost lots first (minimizes current-year taxable gain)."""
results = []
remaining = close_qty
for lot in sorted(open_lots, key=lambda l: -l.open_price):
if remaining <= 0:
break
available = lot.open_qty - lot.close_qty
take = min(available, remaining)
results.append((lot, take))
remaining -= take
return results

See cookbook — tax-lot accounting for the full FIFO/LIFO/HIFO/SpecID comparison.

Wash-sale tracking

If you close a lot at a loss and reopen within 30 days, the IRS disallows the loss.

def is_wash_sale(closed_lot: TaxLot, new_lot: TaxLot) -> bool:
if closed_lot.realized_pl_usd is None or closed_lot.realized_pl_usd >= 0:
return False
if closed_lot.symbol != new_lot.symbol:
return False
return abs((new_lot.open_date - closed_lot.close_date).days) <= 30

mts1b-treasury performs this check across all registered funds.

Multi-currency

Positions are USD-denominated by default. For international funds:

# fund_config.base_currency = "EUR"
# market_value_usd is still the USD equivalent for firm-level reporting
# Add base-currency fields for fund-level reports

class PositionWithBase(Position):
market_value_base: Decimal # in fund's base_currency
fx_rate_to_usd: Decimal # rate used for conversion

See also