Files
2025-12-09 12:13:01 +01:00

1480 lines
63 KiB
Python

"""
Enhanced Fund reporting module with better derivative transaction handling.
"""
import logging
from datetime import datetime
from decimal import Decimal
from functools import lru_cache
from typing import Any, Dict, List, Optional, Union
import pandas as pd
from bs4 import Tag
from pydantic import BaseModel
from rich import box
from rich.console import Group, Text
from rich.panel import Panel
from rich.table import Table
from edgar.core import get_bool
from edgar.formatting import moneyfmt
from edgar.funds import FundCompany, FundSeries
from edgar.richtools import df_to_rich_table, repr_rich
from edgar.xmltools import child_text, find_element, optional_decimal
log = logging.getLogger(__name__)
# Functions for export
__all__ = [
'FundReport',
'CurrentMetric',
'NPORT_FORMS',
'get_fund_portfolio_from_filing',
]
def optional_decimal_attr(element, attr_name):
"""Helper function to parse optional decimal attributes from XML elements"""
if element is None:
return None
attr_value = element.attrs.get(attr_name)
if not attr_value or attr_value == "N/A":
return None
try:
return Decimal(attr_value)
except (ValueError, TypeError):
return None
# Define constants
NPORT_FORMS = ["NPORT-P", "NPORT-EX", "N-PORT", "N-PORT/A"]
class IssuerCredentials(BaseModel):
cik: str
ccc: str # cik confirmation code
class SeriesClassInfo(BaseModel):
series_id: str
class_id: str
@classmethod
def from_xml(cls, tag):
if tag and tag.name == "seriesClassInfo":
return cls(series_id=child_text(tag, "seriesId"),
class_id=child_text(tag, "classId"))
class FilerInfo(BaseModel):
issuer_credentials: IssuerCredentials
series_class_info: Optional[SeriesClassInfo]
@property
def series_id(self):
return self.series_class_info.series_id if self.series_class_info else ""
@property
def class_id(self):
return self.series_class_info.class_id if self.series_class_info else ""
class Header(BaseModel):
submission_type: str
is_confidential: bool
filer_info: FilerInfo
class GeneralInfo(BaseModel):
name: str
cik: str
file_number: str
reg_lei: Optional[str]
street1: str
street2: Optional[str]
city: Optional[str]
state: Optional[str]
country: Optional[str]
zip_or_postal_code: Optional[str]
phone: Optional[str]
series_name: Optional[str]
series_lei: Optional[str]
series_id: Optional[str]
fiscal_year_end: Optional[str]
rep_period_date: Optional[str]
is_final_filing: Optional[bool]
class PeriodType(BaseModel):
period3Mon: Decimal
period1Yr: Decimal
period5Yr: Decimal
period10Yr: Decimal
period30Yr: Decimal
@classmethod
def from_xml(cls, tag: Tag = None):
if tag:
return cls(period1Yr=Decimal(tag.attrs.get("period1Yr")),
period3Mon=Decimal(tag.attrs.get("period3Mon")),
period5Yr=Decimal(tag.attrs.get("period5Yr")),
period10Yr=Decimal(tag.attrs.get("period10Yr")),
period30Yr=Decimal(tag.attrs.get("period30Yr"))
)
class CurrentMetric(BaseModel):
currency: str
intrstRtRiskdv01: PeriodType
intrstRtRiskdv100: PeriodType
def decimal_or_na(value: str):
return value if value == "N/A" else Decimal(value)
def datetime_or_na(value: str):
return value if value == "N/A" else datetime.strptime(value, "%Y-%m-%d")
def format_date(date: Union[str, datetime]) -> str:
if isinstance(date, str):
return date
return date.strftime("%Y-%m-%d")
class MonthlyTotalReturn(BaseModel):
class_id: Optional[str]
return1: Optional[Union[Decimal, str]]
return2: Optional[Union[Decimal, str]]
return3: Optional[Union[Decimal, str]]
@classmethod
def from_xml(cls, tag: Tag):
return cls(
class_id=tag.attrs.get("classId"),
return1=decimal_or_na(tag.attrs.get("rtn1")),
return2=decimal_or_na(tag.attrs.get("rtn2")),
return3=decimal_or_na(tag.attrs.get("rtn3"))
)
class RealizedChange(BaseModel):
net_realized_gain: Optional[Union[Decimal, str]]
net_unrealized_appreciation: Optional[Union[Decimal, str]]
@classmethod
def from_xml(cls, tag):
if tag:
return cls(
net_realized_gain=decimal_or_na(tag.attrs.get("netRealizedGain")),
net_unrealized_appreciation=decimal_or_na(tag.attrs.get("netUnrealizedAppr"))
)
class MonthlyFlow(BaseModel):
redemption: Optional[Union[Decimal, str]]
reinvestment: Optional[Union[Decimal, str]]
sales: Optional[Union[Decimal, str]]
@classmethod
def from_xml(cls, tag):
if tag:
return cls(
redemption=decimal_or_na(tag.attrs.get("redemption")),
reinvestment=decimal_or_na(tag.attrs.get("reinvestment")),
sales=decimal_or_na(tag.attrs.get("sales"))
)
class ReturnInfo(BaseModel):
monthly_total_returns: List[MonthlyTotalReturn]
other_mon1: RealizedChange
other_mon2: RealizedChange
other_mon3: RealizedChange
class FundInfo(BaseModel):
total_assets: Decimal
total_liabilities: Decimal
net_assets: Optional[Decimal]
assets_attr_misc_sec: Optional[Decimal]
assets_invested: Optional[Decimal]
amt_pay_one_yr_banks_borr: Optional[Decimal]
amt_pay_one_yr_ctrld_comp: Optional[Decimal]
amt_pay_one_yr_oth_affil: Optional[Decimal]
amt_pay_one_yr_other: Optional[Decimal]
amt_pay_aft_one_yr_banks_borr: Optional[Decimal]
amt_pay_aft_one_yr_ctrld_comp: Optional[Decimal]
amt_pay_aft_one_yr_oth_affil: Optional[Decimal]
amt_pay_aft_one_yr_other: Optional[Decimal]
delay_deliv: Optional[Decimal]
stand_by_commit: Optional[Decimal]
liquidity_pref: Optional[Decimal]
cash_not_report_in_cor_d: Optional[Decimal]
current_metrics: Dict[str, CurrentMetric]
credit_spread_risk_investment_grade: Optional[PeriodType]
credit_spread_risk_non_investment_grade: Optional[PeriodType]
is_non_cash_collateral: Optional[bool]
return_info: Optional[ReturnInfo]
monthly_flow1: Optional[MonthlyFlow]
monthly_flow2: Optional[MonthlyFlow]
monthly_flow3: Optional[MonthlyFlow]
class DebtSecurity(BaseModel):
maturity_date: Union[datetime, str]
coupon_kind: str
annualized_rate: Optional[Decimal]
is_default: bool
are_instrument_payents_in_arrears: bool
is_paid_kind: bool
is_mandatory_convertible: bool
is_continuing_convertible: bool
@classmethod
def from_xml(cls, tag: Tag):
if tag and tag.name == "debtSec":
return cls(
maturity_date=datetime_or_na(child_text(tag, "maturityDt")),
coupon_kind=child_text(tag, "couponKind"),
annualized_rate=optional_decimal(tag, "annualizedRt"),
is_default=child_text(tag, "isDefault") == "Y",
are_instrument_payents_in_arrears=child_text(tag, "areIntrstPmntsInArrs") == "Y",
is_paid_kind=child_text(tag, "isPaidKind") == "Y",
is_mandatory_convertible=child_text(tag, "isMandatoryConvrtbl") == "Y",
is_continuing_convertible=child_text(tag, "isContngtConvrtbl") == "Y"
)
class SecurityLending(BaseModel):
is_cash_collateral: Optional[str]
is_non_cash_collateral: Optional[str]
is_loan_by_fund: Optional[str]
@classmethod
def from_xml(cls, tag):
if tag and tag.name == "securityLending":
return cls(
is_cash_collateral=child_text(tag, "isCashCollateral"),
is_non_cash_collateral=child_text(tag, "isNonCashCollateral"),
is_loan_by_fund=child_text(tag, "isLoanByFund")
)
class Identifiers(BaseModel):
ticker: Optional[str]
isin: Optional[str]
other: Dict
@classmethod
def from_xml(cls, tag):
if tag and tag.name == "identifiers":
ticker_tag = tag.find("ticker")
ticker = ticker_tag.attrs.get("value") if ticker_tag else None
isin_tag = tag.find("isin")
isin = isin_tag.attrs.get("value") if isin_tag else None
other_tag = tag.find("other")
other = {other_tag.attrs.get("otherDesc"): other_tag.attrs.get("value")} if other_tag else {}
return cls(ticker=ticker, isin=isin, other=other)
# Import derivative models from separate module
from edgar.funds.models.derivatives import (
DerivativeInfo,
ForwardDerivative,
SwapDerivative,
FutureDerivative,
SwaptionDerivative,
OptionDerivative,
)
class InvestmentOrSecurity(BaseModel):
name: str
lei: str
title: str
cusip: str
identifiers: Identifiers
balance: Optional[Decimal]
units: Optional[str]
desc_other_units: Optional[str]
currency_code: Optional[str]
# Currency conditional fields
currency_conditional_code: Optional[str]
exchange_rate: Optional[Decimal]
value_usd: Decimal
pct_value: Optional[Decimal]
payoff_profile: Optional[str]
asset_category: Optional[str]
issuer_category: Optional[str]
investment_country: Optional[str]
is_restricted_security: bool
fair_value_level: Optional[str]
debt_security: Optional[DebtSecurity]
security_lending: Optional[SecurityLending]
derivative_info: Optional[DerivativeInfo] # New field
@property
def ticker(self) -> Optional[str]:
"""Return resolved ticker with fallback logic"""
result = self.ticker_resolution_info
return result.ticker
@property
def ticker_resolution_info(self) -> 'TickerResolutionResult':
"""Provide full resolution metadata"""
from edgar.funds.ticker_resolution import TickerResolutionService
return TickerResolutionService.resolve_ticker(
ticker=self.identifiers.ticker,
cusip=self.cusip,
isin=self.identifiers.isin,
company_name=self.name
)
@property
def isin(self):
return self.identifiers.isin
@property
def is_derivative(self):
"""Check if this investment is a derivative"""
return self.derivative_info is not None
@property
def absolute_value(self):
"""Return absolute value for sorting purposes"""
return abs(self.value_usd) if self.value_usd else Decimal(0)
@property
def derivative_type(self):
"""Get the derivative type (FWD, SWP, FUT, OPT)"""
if self.derivative_info:
return self.derivative_info.derivative_category
return None
@property
def is_credit_derivative(self):
"""Check if this is a credit derivative (CDS)"""
return self.asset_category == "DCR"
@property
def is_interest_rate_derivative(self):
"""Check if this is an interest rate derivative"""
return self.asset_category == "DIR"
@property
def is_commodity_derivative(self):
"""Check if this is a commodity derivative"""
return self.asset_category == "DCO"
@property
def is_fx_derivative(self):
"""Check if this is a foreign exchange derivative"""
return self.asset_category == "DFE"
@property
def is_equity_derivative(self):
"""Check if this is an equity derivative (including TRS)"""
return self.asset_category == "DE"
@property
def derivative_subtype(self):
"""Get a descriptive derivative subtype"""
if not self.is_derivative:
return None
deriv_type = self.derivative_type
asset_cat = self.asset_category
if deriv_type == "SWP":
if asset_cat == "DCR":
return "Credit Default Swap"
elif asset_cat == "DIR":
return "Interest Rate Swap"
elif asset_cat == "DE":
return "Total Return Swap (Equity)"
else:
return "Swap"
elif deriv_type == "FUT":
if asset_cat == "DCO":
return "Commodity Future"
elif asset_cat == "DIR":
return "Interest Rate Future"
else:
return "Future"
elif deriv_type == "FWD":
if asset_cat == "DFE":
return "FX Forward"
else:
return "Forward"
elif deriv_type == "OPT":
return "Option"
else:
return deriv_type
class FundReport:
"""
Form N-PORT-P is a form filed with the SEC by mutual funds to report their monthly portfolio holdings to the SEC.
"""
def __init__(self,
header: Header,
general_info: GeneralInfo,
fund_info: FundInfo,
investments: List[InvestmentOrSecurity]):
self.header = header
self.general_info: GeneralInfo = general_info
self.fund_info: FundInfo = fund_info
self.investments: List[InvestmentOrSecurity] = investments
self.fund_company = FundCompany(cik_or_identifier=self.general_info.cik, fund_name=self.general_info.name)
def __str__(self):
return (f"{self.name} {self.general_info.rep_period_date} - {self.general_info.fiscal_year_end}"
)
def get_fund_series(self) -> FundSeries:
return FundSeries(series_id=self.general_info.series_id,
name=self.general_info.series_name,
fund_company=self.fund_company)
def get_ticker_for_series(self) -> Optional[str]:
"""Get the ticker that corresponds to this report's series."""
if not self.general_info.series_id:
return None
from edgar.reference.tickers import get_mutual_fund_tickers
mf_data = get_mutual_fund_tickers()
matches = mf_data[mf_data['seriesId'] == self.general_info.series_id]
if len(matches) == 1:
return matches.iloc[0]['ticker']
return None
def matches_ticker(self, ticker: str) -> bool:
"""Check if this report's series matches the given ticker."""
series_ticker = self.get_ticker_for_series()
return series_ticker and series_ticker.upper() == ticker.upper()
@property
def reporting_period(self):
return self.general_info.rep_period_date
@property
def name(self):
return f"{self.general_info.name} - {self.general_info.series_name}"
@property
def has_investments(self):
return len(self.investments) > 0
@property
def derivatives(self) -> List[InvestmentOrSecurity]:
"""Return only derivative investments"""
return [inv for inv in self.investments if inv.is_derivative]
@property
def non_derivatives(self) -> List[InvestmentOrSecurity]:
"""Return only non-derivative investments"""
return [inv for inv in self.investments if not inv.is_derivative]
@lru_cache(maxsize=2)
def investment_data(self, include_derivatives=True, include_ticker_metadata=False) -> pd.DataFrame:
"""
Enhanced to optionally include ticker resolution information
Args:
include_derivatives: Whether to include derivative positions
include_ticker_metadata: Add columns for ticker resolution method and confidence
Returns:
DataFrame with investment data, optionally including ticker resolution metadata
"""
if len(self.investments) == 0:
return pd.DataFrame(columns=['name', 'title', 'cusip', 'ticker', 'balance', 'units'])
# Filter investments based on derivative inclusion
investments_to_process = self.investments if include_derivatives else self.non_derivatives
# Handle case where no investments match the filter
if len(investments_to_process) == 0:
return pd.DataFrame(columns=['name', 'title', 'cusip', 'ticker', 'balance', 'units', 'value_usd'])
# Build data rows
data = []
for investment in investments_to_process:
row_data = {
"name": investment.name,
"title": investment.title,
"lei": investment.lei,
"cusip": investment.cusip,
"ticker": investment.ticker, # Now uses resolved ticker
"isin": investment.identifiers.isin,
"balance": investment.balance,
"units": investment.units,
"desc_other_units": investment.desc_other_units,
"value_usd": investment.value_usd,
"pct_value": investment.pct_value,
"payoff_profile": investment.payoff_profile,
"asset_category": investment.asset_category,
"issuer_category": investment.issuer_category,
"currency_code": investment.currency_code,
"investment_country": investment.investment_country,
"restricted": investment.is_restricted_security,
"is_derivative": investment.is_derivative,
"maturity_date": investment.debt_security.maturity_date if investment.debt_security else pd.NA,
"annualized_rate": investment.debt_security.annualized_rate if investment.debt_security else pd.NA,
"is_default": investment.debt_security.is_default if investment.debt_security else pd.NA,
"cash_collateral": investment.security_lending.is_cash_collateral
if investment.security_lending else pd.NA,
"non_cash_collateral": investment.security_lending.is_non_cash_collateral
if investment.security_lending else pd.NA,
# Derivative-specific fields
"derivative_type": investment.derivative_info.derivative_category if investment.derivative_info else pd.NA,
"notional_amount": self._get_notional_amount(investment),
"counterparty": self._get_counterparty(investment),
}
# Add metadata columns if requested
if include_ticker_metadata:
ticker_info = investment.ticker_resolution_info
row_data.update({
"ticker_resolution_method": ticker_info.method,
"ticker_resolution_confidence": ticker_info.confidence
})
data.append(row_data)
investment_df = pd.DataFrame(data)
# Sort by absolute value using a temporary column
investment_df = pd.DataFrame(investment_df)
investment_df['_sort_value'] = investment_df['value_usd'].abs()
investment_df = investment_df.sort_values(['_sort_value', 'name', 'title'], ascending=[False, True, True]).reset_index(drop=True)
investment_df = investment_df.drop(columns=['_sort_value'])
return investment_df
def securities_data(self) -> pd.DataFrame:
"""
Return only non-derivative securities (stocks, bonds, etc.)
This is equivalent to calling investment_data(include_derivatives=False).
Use this method when you want to analyze only traditional securities
without derivatives positions.
:return: Securities data as pandas dataframe (excluding derivatives)
"""
return self.investment_data(include_derivatives=False)
def _get_notional_amount(self, investment: InvestmentOrSecurity) -> Optional[Decimal]:
"""Extract notional amount - check investment level first, then derivative-specific"""
# First check if investment balance represents notional (when desc_other_units indicates it)
if (investment.desc_other_units and
'notional' in investment.desc_other_units.lower() and
investment.balance):
return investment.balance
if not investment.derivative_info:
return pd.NA
deriv = investment.derivative_info
if deriv.swap_derivative:
return deriv.swap_derivative.notional_amount
elif deriv.swaption_derivative:
# For swaptions, notional is from the underlying swap
if deriv.swaption_derivative.nested_swap:
return deriv.swaption_derivative.nested_swap.notional_amount
return pd.NA
elif deriv.future_derivative:
return deriv.future_derivative.notional_amount
elif deriv.forward_derivative:
# For forwards, use the larger absolute amount as notional
sold = abs(deriv.forward_derivative.amount_sold) if deriv.forward_derivative.amount_sold else 0
purchased = abs(deriv.forward_derivative.amount_purchased) if deriv.forward_derivative.amount_purchased else 0
return max(sold, purchased) if max(sold, purchased) > 0 else pd.NA
elif deriv.option_derivative:
# Options themselves don't have notional amounts at the derivative level
return pd.NA
return pd.NA
def _get_payoff_profile(self, investment: InvestmentOrSecurity) -> Optional[str]:
"""Extract payoff profile from any derivative type"""
if not investment.derivative_info:
return investment.payoff_profile # Fallback to investment level
deriv = investment.derivative_info
if deriv.future_derivative:
return deriv.future_derivative.payoff_profile
elif deriv.swap_derivative:
return None # Swaps don't have payoff_profile in N-PORT
elif deriv.option_derivative:
return None # Options don't have payoff_profile in N-PORT
elif deriv.forward_derivative:
return None # Forwards don't have payoff_profile in N-PORT
return investment.payoff_profile # Fallback
def _get_counterparty(self, investment: InvestmentOrSecurity) -> Optional[str]:
"""Extract counterparty name from any derivative type"""
if not investment.derivative_info:
return pd.NA
deriv = investment.derivative_info
if deriv.forward_derivative:
return deriv.forward_derivative.counterparty_name
elif deriv.swap_derivative:
return deriv.swap_derivative.counterparty_name
elif deriv.future_derivative:
return deriv.future_derivative.counterparty_name
elif deriv.option_derivative:
return deriv.option_derivative.counterparty_name
return pd.NA
def _get_unrealized_pnl(self, investment: InvestmentOrSecurity) -> Optional[Decimal]:
"""Extract unrealized P&L from derivative, preferring derivative-specific fields"""
if not investment.derivative_info:
return investment.value_usd
deriv = investment.derivative_info
# Try to get unrealized appreciation from the derivative itself
if deriv.option_derivative and deriv.option_derivative.unrealized_appreciation is not None:
return deriv.option_derivative.unrealized_appreciation
elif deriv.swaption_derivative and deriv.swaption_derivative.unrealized_appreciation is not None:
return deriv.swaption_derivative.unrealized_appreciation
elif deriv.swap_derivative and deriv.swap_derivative.unrealized_appreciation is not None:
return deriv.swap_derivative.unrealized_appreciation
elif deriv.forward_derivative and deriv.forward_derivative.unrealized_appreciation is not None:
return deriv.forward_derivative.unrealized_appreciation
elif deriv.future_derivative and deriv.future_derivative.unrealized_appreciation is not None:
return deriv.future_derivative.unrealized_appreciation
# Fallback to investment level
return investment.value_usd
def get_base_derivative_data(self, investment):
"""Get base fields that ALL derivatives should have for consistency"""
derivative = investment.derivative_info
return {
# Basic investment info
"name": investment.name, # Added name field as requested
"title": investment.title,
"asset_category": investment.asset_category,
"issuer_category": investment.issuer_category,
"investment_country": investment.investment_country,
"restricted": investment.is_restricted_security, # Changed to match investment_data naming
"fair_value_level": investment.fair_value_level,
# Position info
"balance": investment.balance,
"units": investment.units,
"pct_value": investment.pct_value, # Changed from pct_nav to pct_value to match investment_data
"value_usd": getattr(investment, 'value_usd', None),
# Identifiers
"lei": investment.lei,
"cusip": investment.cusip,
"ticker": investment.ticker,
"isin": investment.isin,
# Financial
"currency_code": investment.currency_code,
"exchange_rate": investment.exchange_rate,
# Common derivative fields
"derivative_type": derivative.derivative_category if derivative else "Unknown", # Changed from type to derivative_type
"subtype": investment.derivative_subtype,
"payoff_profile": self._get_payoff_profile(investment),
"counterparty": self._get_counterparty(investment) if derivative else None,
"counterparty_lei": self._get_counterparty_lei(derivative) if derivative else None,
"notional_amount": self._get_notional_amount(investment) if derivative else None,
"currency": investment.currency_conditional_code or investment.currency_code,
"unrealized_pnl": self._get_unrealized_pnl(investment), # Now uses derivative-specific P&L
"termination_date": self._get_termination_date(investment) if derivative else None,
}
def _get_counterparty_lei(self, derivative):
"""Get counterparty LEI from any derivative type"""
if derivative.swap_derivative:
return derivative.swap_derivative.counterparty_lei
elif derivative.future_derivative:
return derivative.future_derivative.counterparty_lei
elif derivative.option_derivative:
return derivative.option_derivative.counterparty_lei
elif derivative.forward_derivative:
return derivative.forward_derivative.counterparty_lei
return None
@lru_cache(maxsize=2)
def derivatives_data(self) -> pd.DataFrame:
"""
:return: Only derivative positions as a pandas dataframe
"""
derivatives = [inv for inv in self.investments if inv.is_derivative]
if len(derivatives) == 0:
return pd.DataFrame()
deriv_data = []
for d in derivatives:
# Get base derivative fields (unified across all types)
row_data = self.get_base_derivative_data(d)
# Add derivatives_data-specific fields
row_data.update({
"reference": self._get_reference(d)
})
deriv_data.append(row_data)
deriv_df = pd.DataFrame(deriv_data)
# Sort by absolute unrealized P&L
deriv_df['abs_pnl'] = deriv_df['unrealized_pnl'].abs()
deriv_df = deriv_df.sort_values('abs_pnl', ascending=False).drop('abs_pnl', axis=1)
return deriv_df.reset_index(drop=True)
def swaps_data(self) -> pd.DataFrame:
"""Return detailed swap derivatives data with directional receive/pay fields"""
swaps = [inv for inv in self.investments
if inv.is_derivative and inv.derivative_info and inv.derivative_info.swap_derivative]
if len(swaps) == 0:
return pd.DataFrame()
swap_data = []
for d in swaps:
swap = d.derivative_info.swap_derivative
swap_data.append({
# Basic investment info
"name": d.name,
"title": d.title,
"derivative_type": "SWP",
"subtype": d.derivative_subtype,
"asset_category": d.asset_category,
"issuer_category": d.issuer_category,
"investment_country": d.investment_country,
"restricted": d.is_restricted_security,
"fair_value_level": d.fair_value_level,
"payoff_profile": d.payoff_profile,
"balance": d.balance,
"units": d.units,
"pct_value": d.pct_value,
"exchange_rate": d.exchange_rate,
# Basic swap info
"counterparty": swap.counterparty_name,
"counterparty_lei": swap.counterparty_lei,
"reference_entity": swap.reference_entity_name,
"reference_entity_isin": swap.reference_entity_isin,
"reference_entity_ticker": swap.reference_entity_ticker,
"swap_flag": swap.swap_flag,
"notional_amount": swap.notional_amount,
"currency": swap.currency,
"termination_date": swap.termination_date,
"upfront_payment": swap.upfront_payment,
"payment_currency": swap.payment_currency,
"upfront_receipt": swap.upfront_receipt,
"receipt_currency": swap.receipt_currency,
"unrealized_pnl": swap.unrealized_appreciation,
# DIRECTIONAL RECEIVE LEG (what we receive)
"fixed_rate_receive": swap.fixed_rate_receive,
"fixed_amount_receive": swap.fixed_amount_receive,
"fixed_currency_receive": swap.fixed_currency_receive,
"floating_index_receive": swap.floating_index_receive,
"floating_spread_receive": swap.floating_spread_receive,
"floating_amount_receive": swap.floating_amount_receive,
"floating_currency_receive": swap.floating_currency_receive,
"floating_tenor_receive": swap.floating_tenor_receive,
"floating_tenor_unit_receive": swap.floating_tenor_unit_receive,
"floating_reset_date_tenor_receive": swap.floating_reset_date_tenor_receive,
"floating_reset_date_unit_receive": swap.floating_reset_date_unit_receive,
"other_description_receive": swap.other_description_receive,
"other_type_receive": swap.other_type_receive,
# DIRECTIONAL PAYMENT LEG (what we pay)
"fixed_rate_pay": swap.fixed_rate_pay,
"fixed_amount_pay": swap.fixed_amount_pay,
"fixed_currency_pay": swap.fixed_currency_pay,
"floating_index_pay": swap.floating_index_pay,
"floating_spread_pay": swap.floating_spread_pay,
"floating_amount_pay": swap.floating_amount_pay,
"floating_currency_pay": swap.floating_currency_pay,
"floating_tenor_pay": swap.floating_tenor_pay,
"floating_tenor_unit_pay": swap.floating_tenor_unit_pay,
"floating_reset_date_tenor_pay": swap.floating_reset_date_tenor_pay,
"floating_reset_date_unit_pay": swap.floating_reset_date_unit_pay,
"other_description_pay": swap.other_description_pay,
"other_type_pay": swap.other_type_pay
})
return pd.DataFrame(swap_data)
def swaptions_data(self) -> pd.DataFrame:
"""Return detailed swaptions (SWO) derivatives data with unified base fields and nested swap info"""
swaptions = [inv for inv in self.investments
if inv.is_derivative and inv.derivative_info and inv.derivative_info.swaption_derivative]
if len(swaptions) == 0:
return pd.DataFrame()
swaption_data = []
for d in swaptions:
swo = d.derivative_info.swaption_derivative
# Get base derivative fields (consistent across all types)
row_data = self.get_base_derivative_data(d)
# Add swaption-specific fields
row_data.update({
"put_or_call": swo.put_or_call,
"written_or_purchased": swo.written_or_purchased,
"share_number": swo.share_number,
"exercise_price": swo.exercise_price,
"exercise_price_currency": swo.exercise_price_currency,
"expiration_date": swo.expiration_date,
"delta": swo.delta,
# Additional identifiers if available
"main_internal_id": list(d.identifiers.other.values())[0] if d.identifiers and d.identifiers.other else None,
"main_internal_id_desc": list(d.identifiers.other.keys())[0] if d.identifiers and d.identifiers.other else None,
})
# Add nested swap info if available
if swo.nested_swap:
nested = swo.nested_swap
row_data.update({
"underlying_swap_counterparty": nested.counterparty_name,
"underlying_swap_notional": nested.notional_amount,
"underlying_swap_currency": nested.currency,
"underlying_swap_termination": nested.termination_date,
# Fixed rate legs
"underlying_swap_fixed_rate_receive": nested.fixed_rate_receive,
"underlying_swap_fixed_currency_receive": nested.fixed_currency_receive,
"underlying_swap_fixed_rate_pay": nested.fixed_rate_pay,
"underlying_swap_fixed_currency_pay": nested.fixed_currency_pay,
# Floating rate legs with detailed info
"underlying_swap_floating_index_receive": nested.floating_index_receive,
"underlying_swap_floating_currency_receive": nested.floating_currency_receive,
"underlying_swap_floating_spread_receive": nested.floating_spread_receive,
"underlying_swap_floating_tenor_receive": nested.floating_tenor_receive,
"underlying_swap_floating_tenor_unit_receive": nested.floating_tenor_unit_receive,
"underlying_swap_floating_index_pay": nested.floating_index_pay,
"underlying_swap_floating_currency_pay": nested.floating_currency_pay,
"underlying_swap_floating_spread_pay": nested.floating_spread_pay,
"underlying_swap_floating_tenor_pay": nested.floating_tenor_pay,
"underlying_swap_floating_tenor_unit_pay": nested.floating_tenor_unit_pay,
# Upfront payment/receipt info
"underlying_swap_upfront_payment": nested.upfront_payment,
"underlying_swap_payment_currency": nested.payment_currency,
"underlying_swap_upfront_receipt": nested.upfront_receipt,
"underlying_swap_receipt_currency": nested.receipt_currency,
# Additional info from derivAddlInfo if present
"underlying_swap_internal_id": nested.deriv_addl_identifier,
"underlying_swap_value_usd": nested.deriv_addl_value_usd,
"underlying_swap_balance": nested.deriv_addl_balance,
"underlying_swap_units": nested.deriv_addl_units,
})
swaption_data.append(row_data)
return pd.DataFrame(swaption_data)
def options_data(self) -> pd.DataFrame:
"""Return detailed options derivatives data with clear separation of option vs underlying data"""
options = [inv for inv in self.investments
if inv.is_derivative and inv.derivative_info and inv.derivative_info.option_derivative]
if len(options) == 0:
return pd.DataFrame()
option_data = []
for d in options:
opt = d.derivative_info.option_derivative
# Get base derivative fields (consistent across all types)
row_data = self.get_base_derivative_data(d)
# OPTION-SPECIFIC FIELDS (what the option contract itself specifies)
row_data.update({
# Core option contract terms
"option_type": opt.put_or_call,
"option_position": opt.written_or_purchased,
"option_quantity": opt.share_number,
"exercise_price": opt.exercise_price,
"exercise_currency": opt.exercise_price_currency,
"expiration_date": opt.expiration_date,
"delta": opt.delta,
# Reference entity (for options on stocks/bonds)
"reference_entity": opt.reference_entity_name,
"reference_entity_title": opt.reference_entity_title,
"reference_entity_isin": opt.reference_entity_isin,
"reference_entity_ticker": opt.reference_entity_ticker,
"reference_entity_cusip": opt.reference_entity_cusip,
"reference_entity_other_id": opt.reference_entity_other_id,
# Index reference (for options on indices like S&P 500)
"index_name": opt.index_name,
"index_identifier": opt.index_identifier,
})
# UNDERLYING DERIVATIVE INFO (dynamic columns based on actual nested type)
has_nested = False
nested_type = None
if opt.nested_forward:
has_nested = True
nested_type = "Forward"
fwd = opt.nested_forward
# Calculate primary exposure in USD equivalent
sold_usd = abs(fwd.amount_sold) if fwd.currency_sold == 'USD' else None
purchased_usd = abs(fwd.amount_purchased) if fwd.currency_purchased == 'USD' else None
primary_exposure_usd = sold_usd or purchased_usd
# Calculate exchange rate from forward amounts
fwd_exchange_rate = None
if (fwd.amount_sold and fwd.amount_purchased and
abs(fwd.amount_sold) > 0 and abs(fwd.amount_purchased) > 0):
fwd_exchange_rate = abs(fwd.amount_sold) / abs(fwd.amount_purchased)
# Add forward-specific fields (only when relevant)
row_data.update({
"nested_fwd_currency_sold": fwd.currency_sold,
"nested_fwd_amount_sold": fwd.amount_sold,
"nested_fwd_currency_purchased": fwd.currency_purchased,
"nested_fwd_amount_purchased": fwd.amount_purchased,
"nested_fwd_settlement_date": fwd.settlement_date,
"nested_fwd_internal_id": fwd.deriv_addl_identifier,
"nested_fwd_unrealized_pnl": fwd.unrealized_appreciation,
"nested_fwd_exchange_rate": fwd_exchange_rate,
"nested_fx_pair": f"{fwd.currency_sold}/{fwd.currency_purchased}" if fwd.currency_sold and fwd.currency_purchased else None,
"primary_exposure_usd": primary_exposure_usd,
})
elif opt.nested_future:
has_nested = True
nested_type = "Future"
fut = opt.nested_future
# Add future-specific fields (only when relevant)
row_data.update({
"nested_fut_payoff_profile": fut.payoff_profile,
"nested_fut_expiration_date": fut.expiration_date,
"nested_fut_notional_amount": fut.notional_amount,
"nested_fut_currency": fut.currency,
"nested_fut_unrealized_pnl": fut.unrealized_appreciation,
"nested_fut_internal_id": fut.reference_entity_other_id,
"nested_fut_reference_entity": fut.reference_entity_name,
"primary_exposure_usd": fut.notional_amount if fut.currency == 'USD' else None,
})
elif opt.nested_swap:
has_nested = True
nested_type = "Swap"
swp = opt.nested_swap
# Add swap-specific fields (only when relevant)
# NOTE: This is rare - most options on swaps should be derivCat="SWO" (swaptions)
row_data.update({
"nested_swp_notional_amount": swp.notional_amount,
"nested_swp_currency": swp.currency,
"nested_swp_termination_date": swp.termination_date,
"nested_swp_unrealized_pnl": swp.unrealized_appreciation,
"nested_swp_internal_id": swp.deriv_addl_identifier,
"nested_swp_fixed_rate_receive": swp.fixed_rate_receive,
"nested_swp_fixed_rate_pay": swp.fixed_rate_pay,
"nested_swp_floating_index_receive": swp.floating_index_receive,
"nested_swp_floating_index_pay": swp.floating_index_pay,
"primary_exposure_usd": swp.notional_amount if swp.currency == 'USD' else None,
})
# Add common nested derivative fields
row_data.update({
"has_nested_derivative": has_nested,
"nested_derivative_type": nested_type,
})
# Set primary_exposure_usd to None if not set above (for pure options)
if "primary_exposure_usd" not in row_data:
row_data["primary_exposure_usd"] = None
# No else block needed - dynamic columns only created when relevant
option_data.append(row_data)
return pd.DataFrame(option_data)
def forwards_data(self) -> pd.DataFrame:
"""Return detailed forward derivatives data with unified base fields"""
forwards = [inv for inv in self.investments
if inv.is_derivative and inv.derivative_info and inv.derivative_info.forward_derivative]
if len(forwards) == 0:
return pd.DataFrame()
forward_data = []
for d in forwards:
fwd = d.derivative_info.forward_derivative
# Get base derivative fields (consistent across all types)
row_data = self.get_base_derivative_data(d)
# Add forward-specific fields
row_data.update({
"currency_sold": fwd.currency_sold,
"amount_sold": fwd.amount_sold,
"currency_purchased": fwd.currency_purchased,
"amount_purchased": fwd.amount_purchased,
"settlement_date": fwd.settlement_date,
})
forward_data.append(row_data)
return pd.DataFrame(forward_data)
def futures_data(self) -> pd.DataFrame:
"""Return detailed futures derivatives data with unified base fields"""
futures = [inv for inv in self.investments
if inv.is_derivative and inv.derivative_info and inv.derivative_info.future_derivative]
if len(futures) == 0:
return pd.DataFrame()
future_data = []
for d in futures:
fut = d.derivative_info.future_derivative
# Get base derivative fields (consistent across all types)
row_data = self.get_base_derivative_data(d)
# Add futures-specific fields
row_data.update({
"reference_entity": fut.reference_entity_name,
"reference_entity_title": fut.reference_entity_title,
"reference_entity_cusip": fut.reference_entity_cusip,
"reference_entity_isin": fut.reference_entity_isin,
"reference_entity_ticker": fut.reference_entity_ticker,
"reference_entity_other_id": fut.reference_entity_other_id,
"reference_entity_other_id_type": fut.reference_entity_other_id_type,
"expiration_date": fut.expiration_date,
})
future_data.append(row_data)
return pd.DataFrame(future_data)
def _get_reference(self, investment: InvestmentOrSecurity) -> str:
"""Extract reference entity/index from derivative"""
if not investment.derivative_info:
return investment.title
deriv = investment.derivative_info
if deriv.swap_derivative and deriv.swap_derivative.reference_entity_name:
return deriv.swap_derivative.reference_entity_name
elif deriv.future_derivative and deriv.future_derivative.reference_entity_name:
return deriv.future_derivative.reference_entity_name
elif deriv.option_derivative:
opt = deriv.option_derivative
# Prioritize index name over reference entity for options
if opt.index_name:
return opt.index_name
elif opt.reference_entity_name:
return opt.reference_entity_name
# Fallback to ticker if available
elif opt.reference_entity_ticker:
return opt.reference_entity_ticker
elif deriv.forward_derivative:
# For FX forwards, show currency pair
fwd = deriv.forward_derivative
if fwd.currency_sold and fwd.currency_purchased:
return f"{fwd.currency_sold}/{fwd.currency_purchased}"
return investment.title
def _get_delta(self, investment: InvestmentOrSecurity):
"""Extract delta from option derivatives"""
if not investment.derivative_info:
return pd.NA
deriv = investment.derivative_info
if deriv.option_derivative and deriv.option_derivative.delta:
# Try to convert to decimal, return as string if it's 'XXXX' or similar
try:
return float(deriv.option_derivative.delta)
except (ValueError, TypeError, AttributeError):
return deriv.option_derivative.delta
return pd.NA
def _get_termination_date(self, investment: InvestmentOrSecurity) -> str:
"""Extract termination/expiration date from derivative"""
if not investment.derivative_info:
return "N/A"
deriv = investment.derivative_info
if deriv.swap_derivative:
return deriv.swap_derivative.termination_date or "N/A"
elif deriv.future_derivative:
return deriv.future_derivative.expiration_date or "N/A"
elif deriv.forward_derivative:
return deriv.forward_derivative.settlement_date or "N/A"
elif deriv.option_derivative:
return deriv.option_derivative.expiration_date or "N/A"
return "N/A"
@classmethod
def from_filing(cls, filing):
xml = filing.xml()
if not xml:
return None
fund_report_dict = FundReport.parse_fund_xml(xml)
return cls(**fund_report_dict)
@classmethod
def parse_fund_xml(cls, xml: Union[str, Tag]) -> Dict[str, Any]:
root = find_element(xml, "edgarSubmission")
# Get the header
header_el = root.find("headerData")
filer_info_tag = header_el.find("filerInfo")
# Filer Info
issuer_credentials_tag = header_el.find("issuerCredentials")
header = Header(
submission_type=child_text(header_el, "submissionType"),
is_confidential=child_text(header_el, "isConfidential") == "true",
filer_info=FilerInfo(
issuer_credentials=IssuerCredentials(
cik=child_text(issuer_credentials_tag, "cik"),
ccc=child_text(issuer_credentials_tag, "ccc")
),
series_class_info=SeriesClassInfo.from_xml(filer_info_tag.find("seriesClassInfo"))
)
)
# Form data
form_data_tag = root.find("formData")
# General info
general_info_tag = form_data_tag.find("genInfo")
reg_state_conditional_tag = general_info_tag.find("regStateConditional")
if reg_state_conditional_tag:
state = reg_state_conditional_tag.attrs.get("regState")
country = reg_state_conditional_tag.attrs.get("regCountry")
else:
state = None
country = child_text(general_info_tag, "regCountry")
general_info = GeneralInfo(
name=child_text(general_info_tag, "regName"),
cik=child_text(general_info_tag, "regCik"),
file_number=child_text(general_info_tag, "regFileNumber"),
reg_lei=child_text(general_info_tag, "regLei"),
street1=child_text(general_info_tag, "regStreet1"),
street2=child_text(general_info_tag, "regStreet2"),
city=child_text(general_info_tag, "regCity"),
zip_or_postal_code=child_text(general_info_tag, "regZipOrPostalCode"),
phone=child_text(general_info_tag, "regPhone"),
state=state,
country=country,
series_name=child_text(general_info_tag, "seriesName"),
series_id=child_text(general_info_tag, "seriesId"),
series_lei=child_text(general_info_tag, "seriesLei"),
fiscal_year_end=child_text(general_info_tag, "repPdEnd"),
rep_period_date=child_text(general_info_tag, "repPdDate"),
is_final_filing=get_bool(child_text(general_info_tag, "isFinalFiling"))
)
# Fund info
fund_info_tag = root.find("fundInfo")
# Current metrics
current_metrics_tag = fund_info_tag.find("curMetrics")
current_metrics = {}
if current_metrics_tag:
for curr_metric_tag in current_metrics_tag.find_all("curMetric"):
currency = child_text(curr_metric_tag, "curCd")
current_metrics[currency] = CurrentMetric(
currency=currency,
intrstRtRiskdv01=PeriodType.from_xml(curr_metric_tag.find("intrstRtRiskdv01")),
intrstRtRiskdv100=PeriodType.from_xml(curr_metric_tag.find("intrstRtRiskdv100"))
)
# Return Info
return_info_tag = fund_info_tag.find("returnInfo")
monthly_returns_tag = return_info_tag.find("monthlyTotReturns")
return_info: ReturnInfo = ReturnInfo(
monthly_total_returns=[
MonthlyTotalReturn.from_xml(monthly_return_tag)
for monthly_return_tag
in monthly_returns_tag.find_all("monthlyTotReturn")
],
other_mon1=RealizedChange.from_xml(return_info_tag.find("othMon1")),
other_mon2=RealizedChange.from_xml(return_info_tag.find("othMon2")),
other_mon3=RealizedChange.from_xml(return_info_tag.find("othMon3"))
)
fund_info = FundInfo(
total_assets=Decimal(child_text(fund_info_tag, "totAssets")),
total_liabilities=Decimal(child_text(fund_info_tag, "totLiabs")),
net_assets=Decimal(child_text(fund_info_tag, "netAssets")),
assets_attr_misc_sec=Decimal(child_text(fund_info_tag, "assetsAttrMiscSec")),
assets_invested=Decimal(child_text(fund_info_tag, "assetsInvested")),
amt_pay_one_yr_banks_borr=Decimal(child_text(fund_info_tag, "amtPayOneYrBanksBorr")),
amt_pay_one_yr_ctrld_comp=Decimal(child_text(fund_info_tag, "amtPayOneYrCtrldComp")),
amt_pay_one_yr_oth_affil=Decimal(child_text(fund_info_tag, "amtPayOneYrOthAffil")),
amt_pay_one_yr_other=Decimal(child_text(fund_info_tag, "amtPayOneYrOther")),
amt_pay_aft_one_yr_banks_borr=optional_decimal(fund_info_tag, "amtPayAftOneYrBanksBorr"),
amt_pay_aft_one_yr_ctrld_comp=optional_decimal(fund_info_tag, "amtPayAftOneYrCtrldComp"),
amt_pay_aft_one_yr_oth_affil=optional_decimal(fund_info_tag, "amtPayAftOneYrOthAffil"),
amt_pay_aft_one_yr_other=optional_decimal(fund_info_tag, "amtPayAftOneYrOther"),
delay_deliv=optional_decimal(fund_info_tag, "delayDeliv"),
stand_by_commit=optional_decimal(fund_info_tag, "standByCommit"),
liquidity_pref=optional_decimal(fund_info_tag, "liquidPref"),
cash_not_report_in_cor_d=optional_decimal(fund_info_tag, "cshNotRptdInCorD"),
current_metrics=current_metrics,
credit_spread_risk_investment_grade=PeriodType.from_xml(fund_info_tag.find("creditSprdRiskInvstGrade")),
credit_spread_risk_non_investment_grade=PeriodType.from_xml(
fund_info_tag.find("creditSprdRiskNonInvstGrade")),
is_non_cash_collateral=child_text(fund_info_tag, "isNonCashCollateral") == "Y",
return_info=return_info,
monthly_flow1=MonthlyFlow.from_xml(fund_info_tag.find("mon1Flow")),
monthly_flow2=MonthlyFlow.from_xml(fund_info_tag.find("mon2Flow")),
monthly_flow3=MonthlyFlow.from_xml(fund_info_tag.find("mon3Flow"))
)
# Investments or securities
investments_or_securities = []
investment_or_secs_tag = form_data_tag.find("invstOrSecs")
if investment_or_secs_tag:
investments_or_securities = []
for investment_tag in investment_or_secs_tag.find_all("invstOrSec"):
# issuer conditional
asset_conditional_tag = investment_tag.find("assetConditional")
if asset_conditional_tag:
asset_category = asset_conditional_tag.attrs.get("assetCat")
else:
asset_category = child_text(investment_tag, "assetCat")
# issuer conditional
issuer_conditional_tag = investment_tag.find("issuerConditional")
if issuer_conditional_tag:
issuer_category = issuer_conditional_tag.attrs.get("issuerCat")
else:
issuer_category = child_text(investment_tag, "issuerCat")
# currency conditional
currency_conditional_code = None
exchange_rate = None
currency_conditional_tag = investment_tag.find("currencyConditional")
if currency_conditional_tag:
currency_conditional_code = currency_conditional_tag.attrs.get("curCd")
exchange_rate = optional_decimal_attr(currency_conditional_tag, "exchangeRt")
investments_or_security = InvestmentOrSecurity(
name=child_text(investment_tag, "name"),
lei=child_text(investment_tag, "lei"),
title=child_text(investment_tag, "title"),
cusip=child_text(investment_tag, "cusip"),
identifiers=Identifiers.from_xml(investment_tag.find("identifiers")),
balance=optional_decimal(investment_tag, "balance"),
units=child_text(investment_tag, "units"),
desc_other_units=child_text(investment_tag, "descOthUnits"),
currency_code=child_text(investment_tag, "curCd"),
currency_conditional_code=currency_conditional_code,
exchange_rate=exchange_rate,
value_usd=optional_decimal(investment_tag, "valUSD"),
pct_value=optional_decimal(investment_tag, "pctVal"),
payoff_profile=child_text(investment_tag, "payoffProfile"),
asset_category=asset_category,
issuer_category=issuer_category,
investment_country=child_text(investment_tag, "invCountry"),
is_restricted_security=child_text(investment_tag, "isRestrictedSec") == "Y",
fair_value_level=child_text(investment_tag, "fairValLevel"),
debt_security=DebtSecurity.from_xml(investment_tag.find("debtSec")),
security_lending=SecurityLending.from_xml(investment_tag.find("securityLending")),
derivative_info=DerivativeInfo.from_xml(investment_tag.find("derivativeInfo")) # Parse derivatives
)
investments_or_securities.append(investments_or_security)
# Get the fund Information from the filing header
return {'header': header,
'general_info': general_info,
'fund_info': fund_info,
'investments': investments_or_securities}
@property
def fund_info_table(self) -> Table:
fund_info_table = Table("Fund", "Series", "As Of Date", "Fiscal Year", box=box.SIMPLE)
fund_info_table.add_row(self.general_info.name,
f"{self.general_info.series_name} {self.general_info.series_id or ''}",
self.general_info.rep_period_date,
self.general_info.fiscal_year_end)
return fund_info_table
@property
def fund_summary_table(self) -> Table:
# Financials
financials_table = Table("Assets",
"Liabilities",
"Net Assets",
"Total Positions",
"Derivatives",
title="Financials", title_style="bold deep_sky_blue1", box=box.SIMPLE)
financials_table.add_row(moneyfmt(self.fund_info.total_assets, curr="$", places=0),
moneyfmt(self.fund_info.total_liabilities, curr="$", places=0),
moneyfmt(self.fund_info.net_assets, curr="$", places=0),
f"{len(self.investments)}",
f"{len(self.derivatives)}"
)
return financials_table
@property
def metrics_table(self):
table = Table("Metric", "Currency", "3 month", "1 year", "5 year", "10 year", "30 year",
title="Interest Rate Sensitivity", title_style="bold deep_sky_blue1", box=box.SIMPLE)
for currency, current_metric in self.fund_info.current_metrics.items():
table.add_row("Dollar Value 01",
currency,
moneyfmt(current_metric.intrstRtRiskdv01.period3Mon),
moneyfmt(current_metric.intrstRtRiskdv01.period1Yr),
moneyfmt(current_metric.intrstRtRiskdv01.period5Yr),
moneyfmt(current_metric.intrstRtRiskdv01.period10Yr),
moneyfmt(current_metric.intrstRtRiskdv01.period30Yr)
)
table.add_row("Dollar Value 100",
currency,
moneyfmt(current_metric.intrstRtRiskdv100.period3Mon, ),
moneyfmt(current_metric.intrstRtRiskdv100.period1Yr),
moneyfmt(current_metric.intrstRtRiskdv100.period5Yr),
moneyfmt(current_metric.intrstRtRiskdv100.period10Yr),
moneyfmt(current_metric.intrstRtRiskdv100.period30Yr))
return table
@property
def credit_spread_table(self):
if not (
self.fund_info.credit_spread_risk_investment_grade or
self.fund_info.credit_spread_risk_non_investment_grade):
return Text(" ")
table = Table("Metric", "3 month", "1 year", "5 year", "10 year", "30 year",
title="Credit Spread Risk", title_style="bold deep_sky_blue1", box=box.SIMPLE)
if self.fund_info.credit_spread_risk_investment_grade:
table.add_row("Investment Grade",
moneyfmt(self.fund_info.credit_spread_risk_investment_grade.period3Mon),
moneyfmt(self.fund_info.credit_spread_risk_investment_grade.period1Yr),
moneyfmt(self.fund_info.credit_spread_risk_investment_grade.period5Yr),
moneyfmt(self.fund_info.credit_spread_risk_investment_grade.period10Yr),
moneyfmt(self.fund_info.credit_spread_risk_investment_grade.period30Yr))
if self.fund_info.credit_spread_risk_non_investment_grade:
table.add_row("Non Investment Grade",
moneyfmt(self.fund_info.credit_spread_risk_non_investment_grade.period3Mon),
moneyfmt(self.fund_info.credit_spread_risk_non_investment_grade.period1Yr),
moneyfmt(self.fund_info.credit_spread_risk_non_investment_grade.period5Yr),
moneyfmt(self.fund_info.credit_spread_risk_non_investment_grade.period10Yr),
moneyfmt(self.fund_info.credit_spread_risk_non_investment_grade.period30Yr))
return table
@property
@lru_cache(maxsize=2)
def investments_table(self):
investments = self.investment_data(include_derivatives=False)
if not investments.empty:
investments = (investments
.assign(Name=lambda df: df.name,
Title=lambda df: df.title,
Cusip=lambda df: df.cusip,
Ticker=lambda df: df.ticker,
Value=lambda df: df.value_usd.apply(moneyfmt, curr='$', places=0),
Pct=lambda df: df.pct_value.apply(moneyfmt, curr='', places=1),
Category=lambda df: df.issuer_category + " " + df.asset_category)
).filter(['Name', 'Title', 'Cusip', 'Ticker', 'Category', 'Value', 'Pct'])
return df_to_rich_table(investments, title="Non-Derivative Investments", title_style="bold deep_sky_blue1", max_rows=2000)
@property
@lru_cache(maxsize=2)
def derivatives_table(self):
def safe_moneyfmt(value, **kwargs):
"""Apply moneyfmt safely, handling NaN/NA values"""
if pd.isna(value):
return "N/A"
return moneyfmt(value, **kwargs)
derivatives = self.derivatives_data()
if not derivatives.empty:
derivatives = derivatives.assign(
Title=lambda df: df.title,
Subtype=lambda df: df.subtype,
Reference=lambda df: df.reference,
Counterparty=lambda df: df.counterparty,
Notional=lambda df: df.notional_amount.apply(safe_moneyfmt, curr='$', places=0),
**{
'Unrealized P&L': lambda df: df.unrealized_pnl.apply(safe_moneyfmt, curr='$', places=0),
'% NAV': lambda df: df.pct_value.apply(safe_moneyfmt, curr='', places=2),
'Term/Exp Date': lambda df: df.termination_date
}
).filter(['Title', 'Subtype', 'Reference', 'Counterparty', 'Notional', 'Unrealized P&L', '% NAV', 'Term/Exp Date'])
return df_to_rich_table(derivatives, title="Derivative Positions", title_style="bold deep_sky_blue1", max_rows=2000)
def __rich__(self):
title = f"{self.general_info.name} - {self.general_info.series_name} {self.general_info.rep_period_date}"
tables_to_show = [
self.fund_summary_table,
self.metrics_table,
self.credit_spread_table,
self.investments_table
]
# Only add derivatives table if there are derivatives
if len(self.derivatives) > 0:
tables_to_show.append(self.derivatives_table)
return Panel(Group(*tables_to_show), title=title, subtitle=title)
def __repr__(self):
return repr_rich(self.__rich__())
def get_fund_portfolio_from_filing(filing) -> pd.DataFrame:
"""
Extract portfolio holdings from an NPORT filing.
Args:
filing: The NPORT filing to extract data from
Returns:
DataFrame containing portfolio holdings
"""
try:
# Create a FundReport from the filing
fund_report = FundReport.from_filing(filing)
if fund_report and hasattr(fund_report, 'investment_data'):
return fund_report.investment_data()
except Exception as e:
log.warning("Error extracting portfolio from NPORT filing: %s", e)
# Return empty DataFrame if extraction failed
return pd.DataFrame()