Initial commit
This commit is contained in:
693
venv/lib/python3.10/site-packages/edgar/xbrl/periods.py
Normal file
693
venv/lib/python3.10/site-packages/edgar/xbrl/periods.py
Normal file
@@ -0,0 +1,693 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user