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

694 lines
32 KiB
Python

"""
Period handling functionality for XBRL statements.
This module provides functions for handling periods in XBRL statements, including:
- Determining available period views for different statement types
- Selecting appropriate periods for display
- Handling fiscal year and quarter information
"""
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
# Configuration for different statement types
STATEMENT_TYPE_CONFIG = {
'BalanceSheet': {
'period_type': 'instant',
'max_periods': 3,
'allow_annual_comparison': True,
'views': [
{
'name': 'Three Recent Periods',
'description': 'Shows three most recent reporting periods',
'max_periods': 3,
'requires_min_periods': 3
},
{
'name': 'Current vs. Previous Period',
'description': 'Shows the current period and the previous period',
'max_periods': 2,
'requires_min_periods': 1
},
{
'name': 'Three-Year Annual Comparison',
'description': 'Shows three fiscal years for comparison',
'max_periods': 3,
'requires_min_periods': 3,
'annual_only': True
}
]
},
'IncomeStatement': {
'period_type': 'duration',
'max_periods': 3,
'allow_annual_comparison': True,
'views': [
{
'name': 'Three Recent Periods',
'description': 'Shows three most recent reporting periods',
'max_periods': 3,
'requires_min_periods': 3
},
{
'name': 'YTD and Quarterly Breakdown',
'description': 'Shows YTD figures and quarterly breakdown',
'max_periods': 5,
'requires_min_periods': 2,
'mixed_view': True
}
]
},
'StatementOfEquity': {
'period_type': 'duration',
'max_periods': 3,
'views': [
{
'name': 'Three Recent Periods',
'description': 'Shows three most recent reporting periods',
'max_periods': 3,
'requires_min_periods': 1
}
]
},
'ComprehensiveIncome': {
'period_type': 'duration',
'max_periods': 3,
'views': [
{
'name': 'Three Recent Periods',
'description': 'Shows three most recent reporting periods',
'max_periods': 3,
'requires_min_periods': 1
}
]
},
'CoverPage': {
'period_type': 'instant',
'max_periods': 1,
'views': [
{
'name': 'Current Period',
'description': 'Shows the current reporting period',
'max_periods': 1,
'requires_min_periods': 1
}
]
},
'Notes': {
'period_type': 'instant',
'max_periods': 1,
'views': [
{
'name': 'Current Period',
'description': 'Shows the current reporting period',
'max_periods': 1,
'requires_min_periods': 1
}
]
}
}
def sort_periods(periods: List[Dict], period_type: str) -> List[Dict]:
"""Sort periods by date, with most recent first."""
if period_type == 'instant':
return sorted(periods, key=lambda x: x['date'], reverse=True)
return sorted(periods, key=lambda x: (x['end_date'], x['start_date']), reverse=True)
def filter_periods_by_document_end_date(periods: List[Dict], document_period_end_date: str, period_type: str) -> List[Dict]:
"""Filter periods to only include those that end on or before the document period end date."""
if not document_period_end_date:
return periods
try:
doc_end_date = datetime.strptime(document_period_end_date, '%Y-%m-%d').date()
except (ValueError, TypeError):
# If we can't parse the document end date, return all periods
return periods
filtered_periods = []
for period in periods:
try:
if period_type == 'instant':
period_date = datetime.strptime(period['date'], '%Y-%m-%d').date()
if period_date <= doc_end_date:
filtered_periods.append(period)
else: # duration
period_end_date = datetime.strptime(period['end_date'], '%Y-%m-%d').date()
if period_end_date <= doc_end_date:
filtered_periods.append(period)
except (ValueError, TypeError):
# If we can't parse the period date, include it to be safe
filtered_periods.append(period)
return filtered_periods
def filter_periods_by_type(periods: List[Dict], period_type: str) -> List[Dict]:
"""Filter periods by their type (instant or duration)."""
return [p for p in periods if p['type'] == period_type]
def calculate_fiscal_alignment_score(end_date: datetime.date, fiscal_month: int, fiscal_day: int) -> int:
"""Calculate how well a date aligns with fiscal year end."""
if end_date.month == fiscal_month and end_date.day == fiscal_day:
return 100
if end_date.month == fiscal_month and abs(end_date.day - fiscal_day) <= 15:
return 75
if abs(end_date.month - fiscal_month) <= 1 and abs(end_date.day - fiscal_day) <= 15:
return 50
return 0
def generate_period_view(view_config: Dict[str, Any], periods: List[Dict], is_annual: bool = False) -> Optional[Dict[str, Any]]:
"""Generate a period view based on configuration and available periods.
Args:
view_config: Configuration for the view (from STATEMENT_TYPE_CONFIG)
periods: List of periods to choose from
is_annual: Whether this is an annual report
Returns:
Dictionary with view name, description, and period keys if view is valid,
None if view cannot be generated with available periods
"""
if len(periods) < view_config['requires_min_periods']:
return None
if view_config.get('annual_only', False) and not is_annual:
return None
max_periods = min(view_config['max_periods'], len(periods))
return {
'name': view_config['name'],
'description': view_config['description'],
'period_keys': [p['key'] for p in periods[:max_periods]]
}
def generate_mixed_view(view_config: Dict[str, Any], ytd_periods: List[Dict],
quarterly_periods: List[Dict]) -> Optional[Dict[str, Any]]:
"""Generate a mixed view combining YTD and quarterly periods.
Args:
view_config: Configuration for the view
ytd_periods: List of year-to-date periods
quarterly_periods: List of quarterly periods
Returns:
Dictionary with view configuration if valid, None otherwise
"""
if not ytd_periods or not quarterly_periods:
return None
mixed_keys = []
# Add current YTD
mixed_keys.append(ytd_periods[0]['key'])
# Add recent quarters
for q in quarterly_periods[:min(4, len(quarterly_periods))]:
if q['key'] not in mixed_keys:
mixed_keys.append(q['key'])
if len(mixed_keys) >= view_config['requires_min_periods']:
return {
'name': view_config['name'],
'description': view_config['description'],
'period_keys': mixed_keys[:view_config['max_periods']]
}
return None
def get_period_views(xbrl_instance, statement_type: str) -> List[Dict[str, Any]]:
"""
Get available period views for a statement type.
Args:
xbrl_instance: XBRL instance with context and entity information
statement_type: Type of statement to get period views for
Returns:
List of period view options with name, description, and period keys
"""
period_views = []
# Get statement configuration
config = STATEMENT_TYPE_CONFIG.get(statement_type)
if not config:
return period_views
# Get useful entity info for period selection
entity_info = xbrl_instance.entity_info
fiscal_period_focus = entity_info.get('fiscal_period')
annual_report = fiscal_period_focus == 'FY'
# Get all periods
all_periods = xbrl_instance.reporting_periods
document_period_end_date = xbrl_instance.period_of_report
# Filter and sort periods by type
period_type = config['period_type']
periods = filter_periods_by_type(all_periods, period_type)
# Filter by document period end date to exclude periods after the reporting period
periods = filter_periods_by_document_end_date(periods, document_period_end_date, period_type)
periods = sort_periods(periods, period_type)
# If this statement type allows annual comparison and this is an annual report,
# filter for annual periods
annual_periods = []
if config.get('allow_annual_comparison') and annual_report:
fiscal_month = entity_info.get('fiscal_year_end_month')
fiscal_day = entity_info.get('fiscal_year_end_day')
if fiscal_month is not None and fiscal_day is not None:
for period in periods:
try:
date_field = 'date' if period_type == 'instant' else 'end_date'
end_date = datetime.strptime(period[date_field], '%Y-%m-%d').date()
score = calculate_fiscal_alignment_score(end_date, fiscal_month, fiscal_day)
if score > 0: # Any alignment is good enough for a view
annual_periods.append(period)
except (ValueError, TypeError):
continue
# Generate views based on configuration
for view_config in config.get('views', []):
if view_config.get('mixed_view'):
# Special handling for mixed YTD/quarterly views
ytd_periods = [p for p in periods if p.get('ytd')]
quarterly_periods = [p for p in periods if p.get('quarterly')]
view = generate_mixed_view(view_config, ytd_periods, quarterly_periods)
elif view_config.get('annual_only'):
# Views that should only show annual periods
view = generate_period_view(view_config, annual_periods, annual_report)
else:
# Standard views using all periods
view = generate_period_view(view_config, periods, annual_report)
if view:
period_views.append(view)
return period_views
def determine_periods_to_display(
xbrl_instance,
statement_type: str,
period_filter: Optional[str] = None,
period_view: Optional[str] = None
) -> List[Tuple[str, str]]:
"""
Determine which periods should be displayed for a statement.
Uses smart period selection, which balances investor needs
with data availability for optimal financial analysis.
Args:
xbrl_instance: XBRL instance with context and entity information
statement_type: Type of statement ('BalanceSheet', 'IncomeStatement', etc.)
period_filter: Optional period key to filter by specific reporting period
period_view: Optional name of a predefined period view
Returns:
List of tuples with period keys and labels to display
"""
periods_to_display = []
# If a specific period is requested, use only that
if period_filter:
for period in xbrl_instance.reporting_periods:
if period['key'] == period_filter:
periods_to_display.append((period_filter, period['label']))
break
return periods_to_display
# If a period view is specified, use that
if period_view:
available_views = get_period_views(xbrl_instance, statement_type)
matching_view = next((view for view in available_views if view['name'] == period_view), None)
if matching_view:
for period_key in matching_view['period_keys']:
for period in xbrl_instance.reporting_periods:
if period['key'] == period_key:
periods_to_display.append((period_key, period['label']))
break
return periods_to_display
# Use unified period selection system with fallback to legacy logic
try:
from edgar.xbrl.period_selector import select_periods
return select_periods(xbrl_instance, statement_type)
except Exception as e:
# Log the error and fall back to legacy logic
import logging
logging.warning("Unified period selection failed, using legacy logic: %s", e)
# Continue to legacy logic below
# If no specific periods requested, use default logic based on statement type
all_periods = xbrl_instance.reporting_periods
entity_info = xbrl_instance.entity_info
fiscal_period_focus = entity_info.get('fiscal_period')
document_period_end_date = xbrl_instance.period_of_report
# Filter periods by statement type
if statement_type == 'BalanceSheet':
instant_periods = filter_periods_by_type(all_periods, 'instant')
# Filter by document period end date to exclude periods after the reporting period
instant_periods = filter_periods_by_document_end_date(instant_periods, document_period_end_date, 'instant')
instant_periods = sort_periods(instant_periods, 'instant')
# Get fiscal information for better period matching
fiscal_period_focus = entity_info.get('fiscal_period')
fiscal_year_focus = entity_info.get('fiscal_year')
fiscal_year_end_month = entity_info.get('fiscal_year_end_month')
fiscal_year_end_day = entity_info.get('fiscal_year_end_day')
if instant_periods:
# Take latest instant period that is not later than document_period_end_date
current_period = instant_periods[0] # Most recent
period_key = current_period['key']
periods_to_display.append((period_key, current_period['label']))
# Try to find appropriate comparison period
try:
current_date = datetime.strptime(current_period['date'], '%Y-%m-%d').date()
# Use fiscal information if available for better matching
if fiscal_year_end_month is not None and fiscal_year_end_day is not None:
# Check if this is a fiscal year end report
is_fiscal_year_end = False
if fiscal_period_focus == 'FY' or (
current_date.month == fiscal_year_end_month and
abs(current_date.day - fiscal_year_end_day) <= 7):
is_fiscal_year_end = True
if is_fiscal_year_end and fiscal_year_focus:
# For fiscal year end, find the previous fiscal year end period
prev_fiscal_year = int(fiscal_year_focus) - 1 if isinstance(fiscal_year_focus,
(int, str)) and str(
fiscal_year_focus).isdigit() else current_date.year - 1
# Look for a comparable period from previous fiscal year
for period in instant_periods[1:]: # Skip the current one
try:
period_date = datetime.strptime(period['date'], '%Y-%m-%d').date()
# Check if this period is from the previous fiscal year and around fiscal year end
if (period_date.year == prev_fiscal_year and
period_date.month == fiscal_year_end_month and
abs(period_date.day - fiscal_year_end_day) <= 15):
periods_to_display.append((period['key'], period['label']))
break
except (ValueError, TypeError):
continue
# If no appropriate period found yet, try generic date-based comparison
if len(periods_to_display) == 1:
# Look for a period from previous year with similar date pattern
prev_year = current_date.year - 1
for period in instant_periods[1:]: # Skip the current one
try:
period_date = datetime.strptime(period['date'], '%Y-%m-%d').date()
# If from previous year with similar month/day
if period_date.year == prev_year:
periods_to_display.append((period['key'], period['label']))
break
except (ValueError, TypeError):
continue
# Only add additional comparable periods (up to a total of 3)
# For annual reports, only add periods that are also fiscal year ends
is_annual_report = (fiscal_period_focus == 'FY')
added_period_keys = [key for key, _ in periods_to_display]
for period in instant_periods[1:]: # Skip current period
if len(periods_to_display) >= 3:
break # Stop when we have 3 periods
# For annual reports, only add periods that are fiscal year ends
# ENHANCED: Ensure we're selecting true annual period ends, not quarterly
if is_annual_report and fiscal_year_end_month is not None and fiscal_year_end_day is not None:
try:
# Check if this period is close to the fiscal year end
period_date = datetime.strptime(period['date'], '%Y-%m-%d').date()
# STRICT CHECK: For annual reports, be more selective
# The period should be within a reasonable range of fiscal year end
is_fiscal_year_end = (
period_date.month == fiscal_year_end_month and
abs(period_date.day - fiscal_year_end_day) <= 15 # Allow some flexibility
)
# Additional check: Ensure this is approximately 1 year before previous periods
if is_fiscal_year_end and len(periods_to_display) > 0:
prev_date_str = periods_to_display[-1][0].split('_')[-1] if '_' in periods_to_display[-1][0] else None
if prev_date_str:
try:
prev_date = datetime.strptime(prev_date_str, '%Y-%m-%d').date()
year_diff = abs((prev_date - period_date).days)
# Should be approximately 365 days apart (allow 350-380 range)
if not (350 <= year_diff <= 380):
is_fiscal_year_end = False
except (ValueError, TypeError):
pass
# Only include this period if it's a fiscal year end
if not is_fiscal_year_end:
continue # Skip non-fiscal-year-end periods
except (ValueError, TypeError):
continue # Skip periods with invalid dates
# Don't add periods we've already added
period_key = period['key']
if period_key not in added_period_keys:
periods_to_display.append((period_key, period['label']))
except (ValueError, TypeError):
# If date parsing failed, still try to select appropriate periods
# For annual reports, we should only show fiscal year end periods
is_annual_report = (fiscal_period_focus == 'FY')
added_count = 0
for i, period in enumerate(instant_periods):
if i == 0:
continue # Skip first period which should already be added
if added_count >= 2: # Already added 2 more (for a total of 3)
break
# For annual reports, only add periods that are close to fiscal year end
if (is_annual_report and fiscal_year_end_month is not None and
fiscal_year_end_day is not None):
try:
period_date = datetime.strptime(period['date'], '%Y-%m-%d').date()
# Only add periods close to fiscal year end
if (period_date.month != fiscal_year_end_month or
abs(period_date.day - fiscal_year_end_day) > 15):
continue # Skip periods that aren't fiscal year ends
except (ValueError, TypeError):
continue # Skip periods with invalid dates
periods_to_display.append((period['key'], period['label']))
added_count += 1
elif statement_type in ['IncomeStatement', 'CashFlowStatement']:
duration_periods = filter_periods_by_type(all_periods, 'duration')
# Filter by document period end date to exclude periods after the reporting period
duration_periods = filter_periods_by_document_end_date(duration_periods, document_period_end_date, 'duration')
duration_periods = sort_periods(duration_periods, 'duration')
if duration_periods:
# For annual reports, prioritize annual periods
if fiscal_period_focus == 'FY':
# Get fiscal year end information if available
fiscal_year_end_month = entity_info.get('fiscal_year_end_month')
fiscal_year_end_day = entity_info.get('fiscal_year_end_day')
# First pass: Find all periods that are approximately a year long
# CRITICAL FIX: Apply strict duration filtering to ensure we only get annual periods
# Some facts are marked as FY but are actually quarterly (90 days vs 363+ days)
candidate_annual_periods = []
for period in duration_periods:
try:
start_date = datetime.strptime(period['start_date'], '%Y-%m-%d').date()
end_date = datetime.strptime(period['end_date'], '%Y-%m-%d').date()
days = (end_date - start_date).days
# STRICT CHECK: Annual periods must be > 300 days
# This filters out quarterly periods incorrectly marked as FY
if days > 300: # Truly annual period (not quarterly)
# Add a score to each period for later sorting
# Default score is 0 (will be increased for fiscal year matches)
period_with_score = period.copy()
period_with_score['fiscal_alignment_score'] = 0
period_with_score['duration_days'] = days # Store for debugging
candidate_annual_periods.append(period_with_score)
except (ValueError, TypeError):
continue
# Second pass: Score periods based on alignment with fiscal year pattern
if fiscal_year_end_month is not None and fiscal_year_end_day is not None:
for period in candidate_annual_periods:
try:
# Check how closely the end date aligns with fiscal year end
end_date = datetime.strptime(period['end_date'], '%Y-%m-%d').date()
# Perfect match: Same month and day as fiscal year end
if end_date.month == fiscal_year_end_month and end_date.day == fiscal_year_end_day:
period['fiscal_alignment_score'] = 100
# Strong match: Same month and within 15 days
elif end_date.month == fiscal_year_end_month and abs(end_date.day - fiscal_year_end_day) <= 15:
period['fiscal_alignment_score'] = 75
# Moderate match: Month before/after and close to the day
elif abs(end_date.month - fiscal_year_end_month) <= 1 and abs(end_date.day - fiscal_year_end_day) <= 15:
period['fiscal_alignment_score'] = 50
except (ValueError, TypeError):
continue
# Sort periods by fiscal alignment (higher score first) and then by recency (end date)
annual_periods = sorted(
candidate_annual_periods,
key=lambda x: (x['fiscal_alignment_score'], x['end_date']),
reverse=True # Highest score and most recent first
)
if annual_periods:
# Take up to 3 best matching annual periods (prioritizing fiscal year alignment)
for period in annual_periods[:3]:
periods_to_display.append((period['key'], period['label']))
return periods_to_display
# For quarterly reports, apply intelligent period selection
else:
# First, categorize periods by duration to identify meaningful financial periods
quarterly_periods = [] # 85-95 days (one quarter)
ytd_periods = [] # 175-185 days (two quarters), 265-275 days (three quarters)
annual_periods = [] # 350-380 days (full year for comparisons)
current_year = None
if document_period_end_date:
try:
current_year = datetime.strptime(document_period_end_date, '%Y-%m-%d').year
except (ValueError, TypeError):
pass
# Categorize all duration periods by their length
# ENHANCED: More strict duration checking to avoid misclassification
for period in duration_periods:
try:
start_date = datetime.strptime(period['start_date'], '%Y-%m-%d').date()
end_date = datetime.strptime(period['end_date'], '%Y-%m-%d').date()
days = (end_date - start_date).days
# Skip single-day or very short periods (less than 30 days)
if days < 30:
continue
# Categorize by duration with stricter checks
if 80 <= days <= 100: # Quarterly period (~90 days), slightly wider range
period['period_type'] = 'quarterly'
period['days'] = days
quarterly_periods.append(period)
elif 170 <= days <= 190: # Semi-annual/YTD for Q2 (~180 days)
period['period_type'] = 'semi-annual'
period['days'] = days
ytd_periods.append(period)
elif 260 <= days <= 280: # YTD for Q3 (~270 days)
period['period_type'] = 'three-quarters'
period['days'] = days
ytd_periods.append(period)
elif days > 300: # Annual period for comparisons (strict check)
period['period_type'] = 'annual'
period['days'] = days
annual_periods.append(period)
except (ValueError, TypeError):
continue
# Build the optimal set of periods for quarterly reporting
selected_periods = []
# 1. Add the most recent quarterly period (current quarter)
if quarterly_periods:
# Find the most recent quarterly period
recent_quarterly = quarterly_periods[0] # Already sorted by end date
selected_periods.append(recent_quarterly)
# Try to find the same quarter from previous year for comparison
if current_year:
for qp in quarterly_periods[1:]:
try:
qp_end = datetime.strptime(qp['end_date'], '%Y-%m-%d').date()
recent_end = datetime.strptime(recent_quarterly['end_date'], '%Y-%m-%d').date()
# Same quarter, previous year (within 15 days tolerance)
if (qp_end.year == current_year - 1 and
qp_end.month == recent_end.month and
abs(qp_end.day - recent_end.day) <= 15):
selected_periods.append(qp)
break
except (ValueError, TypeError):
continue
# 2. Add the most recent YTD period if available
if ytd_periods:
# Find the YTD period that ends closest to the document period end
selected_periods.append(ytd_periods[0])
# 3. If we don't have enough periods yet, add more quarterly periods
if len(selected_periods) < 3:
for period in quarterly_periods:
if period not in selected_periods and len(selected_periods) < 3:
selected_periods.append(period)
# 4. If still not enough, consider annual periods for year-over-year comparison
if len(selected_periods) < 3 and annual_periods:
for period in annual_periods:
if len(selected_periods) < 3:
selected_periods.append(period)
# Convert selected periods to display format
for period in selected_periods[:3]: # Limit to 3 periods
periods_to_display.append((period['key'], period['label']))
# For other statement types (not covered by specific logic above)
else:
# Get configuration for this statement type, or use defaults
statement_info = STATEMENT_TYPE_CONFIG.get(statement_type, {})
if not statement_info:
# For unknown statement types, use heuristics based on available periods
# For unknown statement types, determine preferences based on fiscal period
if fiscal_period_focus == 'FY':
# For annual reports, prefer duration periods and show comparisons
statement_info = {
'period_type': 'duration',
'max_periods': 3,
'allow_annual_comparison': True
}
else:
# For interim reports, accept either type but limit to current period
statement_info = {
'period_type': 'either',
'max_periods': 1,
'allow_annual_comparison': False
}
# Select periods based on determined preferences
period_type = statement_info.get('period_type', 'either')
max_periods = statement_info.get('max_periods', 1)
if period_type == 'instant' or period_type == 'either':
instant_periods = filter_periods_by_type(all_periods, 'instant')
instant_periods = filter_periods_by_document_end_date(instant_periods, document_period_end_date, 'instant')
instant_periods = sort_periods(instant_periods, 'instant')
if instant_periods:
for period in instant_periods[:max_periods]:
periods_to_display.append((period['key'], period['label']))
if (period_type == 'duration' or (period_type == 'either' and not periods_to_display)):
duration_periods = filter_periods_by_type(all_periods, 'duration')
duration_periods = filter_periods_by_document_end_date(duration_periods, document_period_end_date, 'duration')
duration_periods = sort_periods(duration_periods, 'duration')
if duration_periods:
for period in duration_periods[:max_periods]:
periods_to_display.append((period['key'], period['label']))
return periods_to_display