Files
myTradeTracker/backend/app/parsers/fidelity_parser.py
Chris eea4469095 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>
2026-01-22 14:27:43 -05:00

258 lines
8.3 KiB
Python

"""Fidelity brokerage CSV parser."""
from pathlib import Path
from typing import List, Dict, Any
import pandas as pd
from datetime import datetime
import re
from app.parsers.base_parser import BaseParser, ParseResult
class FidelityParser(BaseParser):
"""
Parser for Fidelity brokerage account history CSV files.
Expected CSV columns:
- Run Date
- Action
- Symbol
- Description
- Type
- Exchange Quantity
- Exchange Currency
- Currency
- Price
- Quantity
- Exchange Rate
- Commission
- Fees
- Accrued Interest
- Amount
- Cash Balance
- Settlement Date
"""
# Expected column names in Fidelity CSV
EXPECTED_COLUMNS = [
"Run Date",
"Action",
"Symbol",
"Description",
"Type",
"Exchange Quantity",
"Exchange Currency",
"Currency",
"Price",
"Quantity",
"Exchange Rate",
"Commission",
"Fees",
"Accrued Interest",
"Amount",
"Cash Balance",
"Settlement Date",
]
def parse(self, file_path: Path) -> ParseResult:
"""
Parse a Fidelity CSV file into standardized transaction dictionaries.
Args:
file_path: Path to the Fidelity CSV file
Returns:
ParseResult containing parsed transactions, errors, and row count
Raises:
FileNotFoundError: If the file does not exist
ValueError: If the CSV format is invalid
"""
errors = []
transactions = []
try:
# Read CSV, skipping empty rows at the beginning
df = self._read_csv(file_path, skiprows=self._find_header_row(file_path))
# Validate columns
missing_cols = set(self.EXPECTED_COLUMNS) - set(df.columns)
if missing_cols:
raise ValueError(f"Missing required columns: {missing_cols}")
# Parse each row
for idx, row in df.iterrows():
try:
transaction = self._parse_row(row)
if transaction:
transactions.append(transaction)
except Exception as e:
errors.append(f"Row {idx + 1}: {str(e)}")
return ParseResult(
transactions=transactions, errors=errors, row_count=len(df)
)
except FileNotFoundError as e:
raise e
except Exception as e:
raise ValueError(f"Failed to parse Fidelity CSV: {str(e)}")
def _find_header_row(self, file_path: Path) -> int:
"""
Find the row number where the header starts in Fidelity CSV.
Fidelity CSVs may have empty rows or metadata at the beginning.
Args:
file_path: Path to CSV file
Returns:
Row number (0-indexed) where the header is located
"""
with open(file_path, "r", encoding="utf-8-sig") as f:
for i, line in enumerate(f):
if "Run Date" in line:
return i
return 0 # Default to first row if not found
def _extract_real_ticker(self, symbol: str, description: str, action: str) -> str:
"""
Extract the real underlying ticker from option descriptions.
Fidelity uses internal reference numbers (like 6736999MM) in the Symbol column
for options, but the real ticker is in the Description/Action in parentheses.
Examples:
- Description: "CALL (OPEN) OPENDOOR JAN 16 26 (100 SHS)"
- Action: "YOU SOLD CLOSING TRANSACTION CALL (OPEN) OPENDOOR..."
Args:
symbol: Symbol from CSV (might be Fidelity internal reference)
description: Description field
action: Action field
Returns:
Real ticker symbol, or original symbol if not found
"""
# If symbol looks normal (letters only, not Fidelity's numeric codes), return it
if symbol and re.match(r'^[A-Z]{1,5}$', symbol):
return symbol
# Try to extract from description first (more reliable)
# Pattern: (TICKER) or CALL (TICKER) or PUT (TICKER)
if description:
# Look for pattern like "CALL (OPEN)" or "PUT (AAPL)"
match = re.search(r'(?:CALL|PUT)\s*\(([A-Z]+)\)', description, re.IGNORECASE)
if match:
return match.group(1)
# Look for standalone (TICKER) pattern
match = re.search(r'\(([A-Z]{1,5})\)', description)
if match:
ticker = match.group(1)
# Make sure it's not something like (100 or (Margin)
if not ticker.isdigit() and ticker not in ['MARGIN', 'CASH', 'SHS']:
return ticker
# Fall back to action field
if action:
match = re.search(r'(?:CALL|PUT)\s*\(([A-Z]+)\)', action, re.IGNORECASE)
if match:
return match.group(1)
# Return original symbol if we couldn't extract anything better
return symbol if symbol else None
def _parse_row(self, row: pd.Series) -> Dict[str, Any]:
"""
Parse a single row from Fidelity CSV into a transaction dictionary.
Args:
row: Pandas Series representing one CSV row
Returns:
Dictionary with transaction data, or None if row should be skipped
Raises:
ValueError: If required fields are missing or invalid
"""
# Parse dates
run_date = self._parse_date(row["Run Date"])
settlement_date = self._parse_date(row["Settlement Date"])
# Extract raw values
raw_symbol = self._safe_string(row["Symbol"])
description = self._safe_string(row["Description"])
action = str(row["Action"]).strip() if pd.notna(row["Action"]) else ""
# Extract the real ticker (especially important for options)
actual_symbol = self._extract_real_ticker(raw_symbol, description, action)
# Extract and clean values
transaction = {
"run_date": run_date,
"action": action,
"symbol": actual_symbol,
"description": description,
"transaction_type": self._safe_string(row["Type"]),
"exchange_quantity": self._safe_decimal(row["Exchange Quantity"]),
"exchange_currency": self._safe_string(row["Exchange Currency"]),
"currency": self._safe_string(row["Currency"]),
"price": self._safe_decimal(row["Price"]),
"quantity": self._safe_decimal(row["Quantity"]),
"exchange_rate": self._safe_decimal(row["Exchange Rate"]),
"commission": self._safe_decimal(row["Commission"]),
"fees": self._safe_decimal(row["Fees"]),
"accrued_interest": self._safe_decimal(row["Accrued Interest"]),
"amount": self._safe_decimal(row["Amount"]),
"cash_balance": self._safe_decimal(row["Cash Balance"]),
"settlement_date": settlement_date,
}
return transaction
def _parse_date(self, date_value: Any) -> Any:
"""
Parse date value from CSV, handling various formats.
Args:
date_value: Date value from CSV (string or datetime)
Returns:
datetime.date object or None if empty/invalid
"""
if pd.isna(date_value) or date_value == "":
return None
# If already a datetime object
if isinstance(date_value, datetime):
return date_value.date()
# Try parsing common date formats
date_str = str(date_value).strip()
if not date_str:
return None
# Try common formats
for fmt in ["%m/%d/%Y", "%Y-%m-%d", "%m-%d-%Y"]:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
continue
return None
def _safe_string(self, value: Any) -> str:
"""
Safely convert value to string, handling NaN and empty values.
Args:
value: Value to convert
Returns:
String value or None if empty
"""
if pd.isna(value) or value == "":
return None
return str(value).strip()