Initial release v1.1.0
- Complete MVP for tracking Fidelity brokerage account performance - Transaction import from CSV with deduplication - Automatic FIFO position tracking with options support - Real-time P&L calculations with market data caching - Dashboard with timeframe filtering (30/90/180 days, 1 year, YTD, all time) - Docker-based deployment with PostgreSQL backend - React/TypeScript frontend with TailwindCSS - FastAPI backend with SQLAlchemy ORM Features: - Multi-account support - Import via CSV upload or filesystem - Open and closed position tracking - Balance history charting - Performance analytics and metrics - Top trades analysis - Responsive UI design Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
465
backend/app/services/position_tracker.py
Normal file
465
backend/app/services/position_tracker.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""Service for tracking and calculating trading positions."""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
from typing import List, Optional, Dict
|
||||
from decimal import Decimal
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from app.models import Transaction, Position, PositionTransaction
|
||||
from app.models.position import PositionType, PositionStatus
|
||||
from app.utils import parse_option_symbol
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
"""
|
||||
Service for tracking trading positions from transactions.
|
||||
|
||||
Matches opening and closing transactions using FIFO (First-In-First-Out) method.
|
||||
Handles stocks, calls, and puts including complex scenarios like assignments and expirations.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""
|
||||
Initialize position tracker.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
def rebuild_positions(self, account_id: int) -> int:
|
||||
"""
|
||||
Rebuild all positions for an account from transactions.
|
||||
|
||||
Deletes existing positions and recalculates from scratch.
|
||||
|
||||
Args:
|
||||
account_id: Account ID to rebuild positions for
|
||||
|
||||
Returns:
|
||||
Number of positions created
|
||||
"""
|
||||
# Delete existing positions
|
||||
self.db.query(Position).filter(Position.account_id == account_id).delete()
|
||||
self.db.commit()
|
||||
|
||||
# Get all transactions ordered by date
|
||||
transactions = (
|
||||
self.db.query(Transaction)
|
||||
.filter(Transaction.account_id == account_id)
|
||||
.order_by(Transaction.run_date, Transaction.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Group transactions by symbol and option details
|
||||
# For options, we need to group by the full option contract (symbol + strike + expiration)
|
||||
# For stocks, we group by symbol only
|
||||
symbol_txns = defaultdict(list)
|
||||
for txn in transactions:
|
||||
if txn.symbol:
|
||||
# Create a unique grouping key
|
||||
grouping_key = self._get_grouping_key(txn)
|
||||
symbol_txns[grouping_key].append(txn)
|
||||
|
||||
# Process each symbol/contract group
|
||||
position_count = 0
|
||||
for grouping_key, txns in symbol_txns.items():
|
||||
positions = self._process_symbol_transactions(account_id, grouping_key, txns)
|
||||
position_count += len(positions)
|
||||
|
||||
self.db.commit()
|
||||
return position_count
|
||||
|
||||
def _process_symbol_transactions(
|
||||
self, account_id: int, symbol: str, transactions: List[Transaction]
|
||||
) -> List[Position]:
|
||||
"""
|
||||
Process all transactions for a single symbol to create positions.
|
||||
|
||||
Args:
|
||||
account_id: Account ID
|
||||
symbol: Trading symbol
|
||||
transactions: List of transactions for this symbol
|
||||
|
||||
Returns:
|
||||
List of created Position objects
|
||||
"""
|
||||
positions = []
|
||||
|
||||
# Determine position type from first transaction
|
||||
position_type = self._determine_position_type_from_txn(transactions[0]) if transactions else PositionType.STOCK
|
||||
|
||||
# Track open positions using FIFO
|
||||
open_positions: List[Dict] = []
|
||||
|
||||
for txn in transactions:
|
||||
action = txn.action.upper()
|
||||
|
||||
# Determine if this is an opening or closing transaction
|
||||
if self._is_opening_transaction(action):
|
||||
# Create new open position
|
||||
open_pos = {
|
||||
"transactions": [txn],
|
||||
"quantity": abs(txn.quantity) if txn.quantity else Decimal("0"),
|
||||
"entry_price": txn.price,
|
||||
"open_date": txn.run_date,
|
||||
"is_short": "SELL" in action or "SOLD" in action,
|
||||
}
|
||||
open_positions.append(open_pos)
|
||||
|
||||
elif self._is_closing_transaction(action):
|
||||
# Close positions using FIFO
|
||||
close_quantity = abs(txn.quantity) if txn.quantity else Decimal("0")
|
||||
remaining_to_close = close_quantity
|
||||
|
||||
while remaining_to_close > 0 and open_positions:
|
||||
open_pos = open_positions[0]
|
||||
open_qty = open_pos["quantity"]
|
||||
|
||||
if open_qty <= remaining_to_close:
|
||||
# Close entire position
|
||||
open_pos["transactions"].append(txn)
|
||||
position = self._create_position(
|
||||
account_id,
|
||||
symbol,
|
||||
position_type,
|
||||
open_pos,
|
||||
close_date=txn.run_date,
|
||||
exit_price=txn.price,
|
||||
close_quantity=open_qty,
|
||||
)
|
||||
positions.append(position)
|
||||
open_positions.pop(0)
|
||||
remaining_to_close -= open_qty
|
||||
else:
|
||||
# Partially close position
|
||||
# Split into closed portion
|
||||
closed_portion = {
|
||||
"transactions": open_pos["transactions"] + [txn],
|
||||
"quantity": remaining_to_close,
|
||||
"entry_price": open_pos["entry_price"],
|
||||
"open_date": open_pos["open_date"],
|
||||
"is_short": open_pos["is_short"],
|
||||
}
|
||||
position = self._create_position(
|
||||
account_id,
|
||||
symbol,
|
||||
position_type,
|
||||
closed_portion,
|
||||
close_date=txn.run_date,
|
||||
exit_price=txn.price,
|
||||
close_quantity=remaining_to_close,
|
||||
)
|
||||
positions.append(position)
|
||||
|
||||
# Update open position with remaining quantity
|
||||
open_pos["quantity"] -= remaining_to_close
|
||||
remaining_to_close = Decimal("0")
|
||||
|
||||
elif self._is_expiration(action):
|
||||
# Handle option expirations
|
||||
expire_quantity = abs(txn.quantity) if txn.quantity else Decimal("0")
|
||||
remaining_to_expire = expire_quantity
|
||||
|
||||
while remaining_to_expire > 0 and open_positions:
|
||||
open_pos = open_positions[0]
|
||||
open_qty = open_pos["quantity"]
|
||||
|
||||
if open_qty <= remaining_to_expire:
|
||||
# Expire entire position
|
||||
open_pos["transactions"].append(txn)
|
||||
position = self._create_position(
|
||||
account_id,
|
||||
symbol,
|
||||
position_type,
|
||||
open_pos,
|
||||
close_date=txn.run_date,
|
||||
exit_price=Decimal("0"), # Expired worthless
|
||||
close_quantity=open_qty,
|
||||
)
|
||||
positions.append(position)
|
||||
open_positions.pop(0)
|
||||
remaining_to_expire -= open_qty
|
||||
else:
|
||||
# Partially expire
|
||||
closed_portion = {
|
||||
"transactions": open_pos["transactions"] + [txn],
|
||||
"quantity": remaining_to_expire,
|
||||
"entry_price": open_pos["entry_price"],
|
||||
"open_date": open_pos["open_date"],
|
||||
"is_short": open_pos["is_short"],
|
||||
}
|
||||
position = self._create_position(
|
||||
account_id,
|
||||
symbol,
|
||||
position_type,
|
||||
closed_portion,
|
||||
close_date=txn.run_date,
|
||||
exit_price=Decimal("0"),
|
||||
close_quantity=remaining_to_expire,
|
||||
)
|
||||
positions.append(position)
|
||||
open_pos["quantity"] -= remaining_to_expire
|
||||
remaining_to_expire = Decimal("0")
|
||||
|
||||
# Create positions for any remaining open positions
|
||||
for open_pos in open_positions:
|
||||
position = self._create_position(
|
||||
account_id, symbol, position_type, open_pos
|
||||
)
|
||||
positions.append(position)
|
||||
|
||||
return positions
|
||||
|
||||
def _create_position(
|
||||
self,
|
||||
account_id: int,
|
||||
symbol: str,
|
||||
position_type: PositionType,
|
||||
position_data: Dict,
|
||||
close_date: Optional[datetime] = None,
|
||||
exit_price: Optional[Decimal] = None,
|
||||
close_quantity: Optional[Decimal] = None,
|
||||
) -> Position:
|
||||
"""
|
||||
Create a Position database object.
|
||||
|
||||
Args:
|
||||
account_id: Account ID
|
||||
symbol: Trading symbol
|
||||
position_type: Type of position
|
||||
position_data: Dictionary with position information
|
||||
close_date: Close date (if closed)
|
||||
exit_price: Exit price (if closed)
|
||||
close_quantity: Quantity closed (if closed)
|
||||
|
||||
Returns:
|
||||
Created Position object
|
||||
"""
|
||||
is_closed = close_date is not None
|
||||
quantity = close_quantity if close_quantity else position_data["quantity"]
|
||||
|
||||
# Calculate P&L if closed
|
||||
realized_pnl = None
|
||||
if is_closed and position_data["entry_price"] and exit_price is not None:
|
||||
if position_data["is_short"]:
|
||||
# Short position: profit when price decreases
|
||||
realized_pnl = (
|
||||
position_data["entry_price"] - exit_price
|
||||
) * quantity * 100
|
||||
else:
|
||||
# Long position: profit when price increases
|
||||
realized_pnl = (
|
||||
exit_price - position_data["entry_price"]
|
||||
) * quantity * 100
|
||||
|
||||
# Subtract fees and commissions
|
||||
for txn in position_data["transactions"]:
|
||||
if txn.commission:
|
||||
realized_pnl -= txn.commission
|
||||
if txn.fees:
|
||||
realized_pnl -= txn.fees
|
||||
|
||||
# Extract option symbol from first transaction if this is an option
|
||||
option_symbol = None
|
||||
if position_type != PositionType.STOCK and position_data["transactions"]:
|
||||
first_txn = position_data["transactions"][0]
|
||||
# Try to extract option details from description
|
||||
option_symbol = self._extract_option_symbol_from_description(
|
||||
first_txn.description, first_txn.action, symbol
|
||||
)
|
||||
|
||||
# Create position
|
||||
position = Position(
|
||||
account_id=account_id,
|
||||
symbol=symbol,
|
||||
option_symbol=option_symbol,
|
||||
position_type=position_type,
|
||||
status=PositionStatus.CLOSED if is_closed else PositionStatus.OPEN,
|
||||
open_date=position_data["open_date"],
|
||||
close_date=close_date,
|
||||
total_quantity=quantity if not position_data["is_short"] else -quantity,
|
||||
avg_entry_price=position_data["entry_price"],
|
||||
avg_exit_price=exit_price,
|
||||
realized_pnl=realized_pnl,
|
||||
)
|
||||
|
||||
self.db.add(position)
|
||||
self.db.flush() # Get position ID
|
||||
|
||||
# Link transactions to position
|
||||
for txn in position_data["transactions"]:
|
||||
link = PositionTransaction(
|
||||
position_id=position.id, transaction_id=txn.id
|
||||
)
|
||||
self.db.add(link)
|
||||
|
||||
return position
|
||||
|
||||
def _extract_option_symbol_from_description(
|
||||
self, description: str, action: str, base_symbol: str
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Extract option symbol from transaction description.
|
||||
|
||||
Example: "CALL (TGT) TARGET CORP JAN 16 26 $95 (100 SHS)"
|
||||
Returns: "-TGT260116C95"
|
||||
|
||||
Args:
|
||||
description: Transaction description
|
||||
action: Transaction action
|
||||
base_symbol: Underlying symbol
|
||||
|
||||
Returns:
|
||||
Option symbol in standard format, or None if can't parse
|
||||
"""
|
||||
if not description:
|
||||
return None
|
||||
|
||||
# Determine if CALL or PUT
|
||||
call_or_put = None
|
||||
if "CALL" in description.upper():
|
||||
call_or_put = "C"
|
||||
elif "PUT" in description.upper():
|
||||
call_or_put = "P"
|
||||
else:
|
||||
return None
|
||||
|
||||
# Extract date and strike: "JAN 16 26 $95"
|
||||
# Pattern: MONTH DAY YY $STRIKE
|
||||
date_strike_pattern = r'([A-Z]{3})\s+(\d{1,2})\s+(\d{2})\s+\$([\d.]+)'
|
||||
match = re.search(date_strike_pattern, description)
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
month_abbr, day, year, strike = match.groups()
|
||||
|
||||
# Convert month abbreviation to number
|
||||
month_map = {
|
||||
'JAN': '01', 'FEB': '02', 'MAR': '03', 'APR': '04',
|
||||
'MAY': '05', 'JUN': '06', 'JUL': '07', 'AUG': '08',
|
||||
'SEP': '09', 'OCT': '10', 'NOV': '11', 'DEC': '12'
|
||||
}
|
||||
|
||||
month = month_map.get(month_abbr.upper())
|
||||
if not month:
|
||||
return None
|
||||
|
||||
# Format: -SYMBOL + YYMMDD + C/P + STRIKE
|
||||
# Remove decimal point from strike if it's a whole number
|
||||
strike_num = float(strike)
|
||||
strike_str = str(int(strike_num)) if strike_num.is_integer() else strike.replace('.', '')
|
||||
|
||||
option_symbol = f"-{base_symbol}{year}{month}{day.zfill(2)}{call_or_put}{strike_str}"
|
||||
return option_symbol
|
||||
|
||||
def _determine_position_type_from_txn(self, txn: Transaction) -> PositionType:
|
||||
"""
|
||||
Determine position type from transaction action/description.
|
||||
|
||||
Args:
|
||||
txn: Transaction to analyze
|
||||
|
||||
Returns:
|
||||
PositionType (STOCK, CALL, or PUT)
|
||||
"""
|
||||
# Check action and description for option indicators
|
||||
action_upper = txn.action.upper() if txn.action else ""
|
||||
desc_upper = txn.description.upper() if txn.description else ""
|
||||
|
||||
# Look for CALL or PUT keywords
|
||||
if "CALL" in action_upper or "CALL" in desc_upper:
|
||||
return PositionType.CALL
|
||||
elif "PUT" in action_upper or "PUT" in desc_upper:
|
||||
return PositionType.PUT
|
||||
|
||||
# Fall back to checking symbol format (for backwards compatibility)
|
||||
if txn.symbol and txn.symbol.startswith("-"):
|
||||
option_info = parse_option_symbol(txn.symbol)
|
||||
if option_info:
|
||||
return (
|
||||
PositionType.CALL
|
||||
if option_info.option_type == "CALL"
|
||||
else PositionType.PUT
|
||||
)
|
||||
|
||||
return PositionType.STOCK
|
||||
|
||||
def _get_base_symbol(self, symbol: str) -> str:
|
||||
"""Extract base symbol from option symbol."""
|
||||
if symbol.startswith("-"):
|
||||
option_info = parse_option_symbol(symbol)
|
||||
if option_info:
|
||||
return option_info.underlying_symbol
|
||||
return symbol
|
||||
|
||||
def _is_opening_transaction(self, action: str) -> bool:
|
||||
"""Check if action represents opening a position."""
|
||||
opening_keywords = [
|
||||
"OPENING TRANSACTION",
|
||||
"YOU BOUGHT OPENING",
|
||||
"YOU SOLD OPENING",
|
||||
]
|
||||
return any(keyword in action for keyword in opening_keywords)
|
||||
|
||||
def _is_closing_transaction(self, action: str) -> bool:
|
||||
"""Check if action represents closing a position."""
|
||||
closing_keywords = [
|
||||
"CLOSING TRANSACTION",
|
||||
"YOU BOUGHT CLOSING",
|
||||
"YOU SOLD CLOSING",
|
||||
"ASSIGNED",
|
||||
]
|
||||
return any(keyword in action for keyword in closing_keywords)
|
||||
|
||||
def _is_expiration(self, action: str) -> bool:
|
||||
"""Check if action represents an expiration."""
|
||||
return "EXPIRED" in action
|
||||
|
||||
def _get_grouping_key(self, txn: Transaction) -> str:
|
||||
"""
|
||||
Create a unique grouping key for transactions.
|
||||
|
||||
For options, returns: symbol + option details (e.g., "TGT-JAN16-100C")
|
||||
For stocks, returns: just the symbol (e.g., "TGT")
|
||||
|
||||
Args:
|
||||
txn: Transaction to create key for
|
||||
|
||||
Returns:
|
||||
Grouping key string
|
||||
"""
|
||||
# Determine if this is an option transaction
|
||||
action_upper = txn.action.upper() if txn.action else ""
|
||||
desc_upper = txn.description.upper() if txn.description else ""
|
||||
|
||||
is_option = "CALL" in action_upper or "CALL" in desc_upper or "PUT" in action_upper or "PUT" in desc_upper
|
||||
|
||||
if not is_option or not txn.description:
|
||||
# Stock transaction - group by symbol only
|
||||
return txn.symbol
|
||||
|
||||
# Option transaction - extract strike and expiration to create unique key
|
||||
# Pattern: "CALL (TGT) TARGET CORP JAN 16 26 $100 (100 SHS)"
|
||||
date_strike_pattern = r'([A-Z]{3})\s+(\d{1,2})\s+(\d{2})\s+\$([\d.]+)'
|
||||
match = re.search(date_strike_pattern, txn.description)
|
||||
|
||||
if not match:
|
||||
# Can't parse option details, fall back to symbol only
|
||||
return txn.symbol
|
||||
|
||||
month_abbr, day, year, strike = match.groups()
|
||||
|
||||
# Determine call or put
|
||||
call_or_put = "C" if "CALL" in desc_upper else "P"
|
||||
|
||||
# Create key: SYMBOL-MONTHDAY-STRIKEC/P
|
||||
# e.g., "TGT-JAN16-100C"
|
||||
strike_num = float(strike)
|
||||
strike_str = str(int(strike_num)) if strike_num.is_integer() else strike
|
||||
|
||||
grouping_key = f"{txn.symbol}-{month_abbr}{day}-{strike_str}{call_or_put}"
|
||||
return grouping_key
|
||||
Reference in New Issue
Block a user