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
quantity | Meaning |
|---|---|
> 0 | Long |
< 0 | Short |
0 | Flat |
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
| Phase | open_qty | close_qty | close_date |
|---|---|---|---|
| Open lot | 100 | 0 | None |
| Partial close | 100 | 40 | None |
| Closed | 100 | 100 | 2026-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
orders—Fill→ Position updatefunds—FundConfig.tax_lot_methodpicks FIFO/HIFO/SpecIDrisk—RiskEnvelope.max_position_pctcaps individual positions- Cookbook — tax-lot accounting