UNPKG

fed-policy-cli

Version:

Macro trading intelligence from Fed policy analysis. Transform economic data into actionable trading insights by analyzing historical Fed policy analogues.

534 lines (452 loc) 19.4 kB
// /src/services/analysis.ts import { EconomicDataPoint, ScenarioParams, HistoricalAnalogue, FedPolicyAction, WeightedIndicator, DateRange, } from '../types'; import { calculateDtwDistance } from '../utils/similarity'; import { DATA_QUALITY, PERIOD_EXCLUSION } from '../constants'; /** * Samples economic data at monthly intervals to align with economic time scales. * Takes the first data point of each calendar month to create a proper monthly series. * @param data - Daily economic data points * @returns Monthly sampled data points */ export function sampleMonthlyData(data: EconomicDataPoint[]): EconomicDataPoint[] { if (data.length === 0) return []; const monthlyData: EconomicDataPoint[] = []; let currentMonth = ''; for (const point of data) { const monthKey = point.date.substring(0, 7); // YYYY-MM format // Add first data point of each month if (monthKey !== currentMonth) { monthlyData.push(point); currentMonth = monthKey; } } return monthlyData; } /** * Gets the last N calendar months from economic data. * Properly samples monthly data instead of using daily data points. * @param data - All economic data points * @param months - Number of months to retrieve * @returns Monthly sampled data for the specified number of months */ export function getLastNMonths(data: EconomicDataPoint[], months: number): EconomicDataPoint[] { const monthlyData = sampleMonthlyData(data); return monthlyData.slice(-months); } /** * Extracts a specific historical period from economic data. * Samples monthly data for the specified date range. * @param data - All economic data points * @param startYearMonth - Start date in YYYY-MM format * @param endYearMonth - End date in YYYY-MM format * @returns Monthly sampled data for the specified period */ export function getTargetPeriod(data: EconomicDataPoint[], startYearMonth: string, endYearMonth: string): EconomicDataPoint[] { const monthlyData = sampleMonthlyData(data); // Filter to the specified date range const targetData = monthlyData.filter(point => { const pointMonth = point.date.substring(0, 7); // YYYY-MM format return pointMonth >= startYearMonth && pointMonth <= endYearMonth; }); return targetData; } /** * Analyzes the fed_funds_rate within a given data period to extract meaningful policy actions. * Filters out daily noise and groups consecutive changes to show realistic Fed policy decisions. * @param periodData - A slice of economic data for a specific period. * @returns An array of FedPolicyAction objects representing significant policy moves. */ export function extractFedPolicyActions(periodData: EconomicDataPoint[]): FedPolicyAction[] { if (periodData.length < 2) { return []; } // First, identify all rate changes above the minimum threshold const MIN_SIGNIFICANT_CHANGE_BPS = 10; // Minimum 10 bps to be considered significant const rawChanges: { date: string; changeBps: number; rate: number }[] = []; for (let i = 1; i < periodData.length; i++) { const prevRate = periodData[i - 1].DFF as number; const currRate = periodData[i].DFF as number; if (typeof prevRate === 'number' && typeof currRate === 'number') { const changeBps = Math.round((currRate - prevRate) * 100); if (Math.abs(changeBps) >= MIN_SIGNIFICANT_CHANGE_BPS) { rawChanges.push({ date: periodData[i].date, changeBps, rate: currRate }); } } } // Group consecutive changes in the same direction within a reasonable timeframe const actions: FedPolicyAction[] = []; const MAX_GROUPING_DAYS = 30; // Group changes within 30 days for (let i = 0; i < rawChanges.length; i++) { const currentChange = rawChanges[i]; let totalChange = currentChange.changeBps; let endDate = currentChange.date; // Look ahead for consecutive changes in the same direction let j = i + 1; while (j < rawChanges.length) { const nextChange = rawChanges[j]; const daysDiff = Math.abs(new Date(nextChange.date).getTime() - new Date(currentChange.date).getTime()) / (1000 * 60 * 60 * 24); // If next change is in same direction and within grouping window if (daysDiff <= MAX_GROUPING_DAYS && Math.sign(nextChange.changeBps) === Math.sign(currentChange.changeBps)) { totalChange += nextChange.changeBps; endDate = nextChange.date; j++; } else { break; } } // Only add significant grouped changes if (Math.abs(totalChange) >= MIN_SIGNIFICANT_CHANGE_BPS) { actions.push({ date: endDate, // Use the final date of the grouped changes action: totalChange > 0 ? 'HIKE' : 'CUT', changeBps: totalChange, }); } // Skip the changes we've already grouped i = j - 1; } // If no significant changes found, report a HOLD action if (actions.length === 0) { actions.push({ date: periodData[Math.floor(periodData.length / 2)].date, action: 'HOLD', changeBps: 0, }); } return actions; } // Helper to normalize a single series (0 to 1 scale) const normalizeSeries = (series: number[]): number[] => { const min = Math.min(...series); const max = Math.max(...series); const range = max - min; if (range === 0) return series.map(() => 0.5); // All values are the same return series.map(val => (val - min) / range); }; /** * Defines major economic eras for enhanced temporal diversity scoring. * Each era has distinct economic characteristics that provide valuable analogues. */ const ECONOMIC_ERAS = { MODERN_ERA: { start: 2020, end: 2030, name: 'Modern Era (Post-COVID)', bonus: 1.15 }, GREAT_RECESSION_RECOVERY: { start: 2010, end: 2019, name: 'Great Recession Recovery', bonus: 1.05 }, FINANCIAL_CRISIS: { start: 2007, end: 2009, name: 'Financial Crisis', bonus: 0.85 }, DOT_COM_ERA: { start: 1995, end: 2006, name: 'Dot-Com Era', bonus: 0.90 }, GREENSPAN_ERA: { start: 1987, end: 1994, name: 'Greenspan Era', bonus: 0.88 }, VOLCKER_INFLATION: { start: 1979, end: 1986, name: 'Volcker Anti-Inflation', bonus: 0.80 }, STAGFLATION: { start: 1970, end: 1978, name: 'Stagflation Era', bonus: 0.75 }, GOLDEN_AGE: { start: 1950, end: 1969, name: 'Post-War Golden Age', bonus: 0.85 }, HISTORICAL: { start: 1900, end: 1949, name: 'Early Historical', bonus: 0.95 } }; /** * Enhanced temporal diversity bonus that considers economic eras and promotes historical spread. * Provides stronger bonuses for historical periods and penalizes recent clustering. * @param startDate - The start date of the historical period * @returns Diversity multiplier (lower values = better diversity bonus) */ function calculateTemporalDiversityBonus(startDate: string): number { const date = new Date(startDate); const year = date.getFullYear(); // Find which economic era this period belongs to const era = Object.values(ECONOMIC_ERAS).find(era => year >= era.start && year <= era.end); const baseBonus = era ? era.bonus : 1.0; // Apply additional recency penalty for very recent periods to encourage historical diversity const currentYear = new Date().getFullYear(); const yearsAgo = currentYear - year; let recencyPenalty = 1.0; if (yearsAgo < 2) { recencyPenalty = 1.3; // Strong penalty for very recent periods (last 2 years) } else if (yearsAgo < 5) { recencyPenalty = 1.2; // Moderate penalty for recent periods (last 5 years) } else if (yearsAgo < 10) { recencyPenalty = 1.1; // Light penalty for somewhat recent periods } // Calculate final temporal diversity score const finalScore = baseBonus * recencyPenalty; return Math.max(0.5, Math.min(2.0, finalScore)); // Clamp between 0.5 and 2.0 } /** * Gets the economic era name for a given date. * Useful for displaying historical context in results. * @param startDate - The start date of the historical period * @returns Era name and timeframe */ export function getEconomicEra(startDate: string): { name: string; timeframe: string } { const year = new Date(startDate).getFullYear(); const era = Object.values(ECONOMIC_ERAS).find(era => year >= era.start && year <= era.end); if (era) { return { name: era.name, timeframe: `${era.start}-${era.end}` }; } return { name: 'Unknown Era', timeframe: `${year}` }; } /** * Assesses data quality for a given date period. * @param startDate - The start date of the historical period * @param endDate - The end date of the historical period * @returns Data quality assessment */ export function assessDataQuality(startDate: string, endDate?: string): { reliability: 'high' | 'medium' | 'low'; warnings: string[]; shouldExclude: boolean; } { const date = new Date(startDate); const warnings: string[] = []; let reliability: 'high' | 'medium' | 'low' = 'high'; let shouldExclude = false; // Check against data quality eras const qualityEra = Object.values(DATA_QUALITY.QUALITY_ERAS).find(era => date >= new Date(era.start) && date <= new Date(era.end) ); if (qualityEra) { reliability = qualityEra.reliability as 'high' | 'medium' | 'low'; if (reliability === 'low') { warnings.push('Pre-1960 data may contain unrealistic Fed policy volatility'); shouldExclude = true; // Exclude by default, can be overridden with flags } else if (reliability === 'medium') { warnings.push('Early Fed data (1960s-1980s) may have some quality issues'); } } // Check if period is before reliable Fed data if (date < new Date(DATA_QUALITY.RELIABLE_FED_DATA_START)) { warnings.push('Period predates reliable Fed Funds Rate data (1960+)'); shouldExclude = true; } return { reliability, warnings, shouldExclude }; } /** * Filters historical data to exclude periods with known data quality issues. * @param data - Array of economic data points * @param excludeUnreliable - Whether to exclude unreliable periods (default: true) * @returns Filtered data array */ export function filterDataQuality( data: EconomicDataPoint[], excludeUnreliable: boolean = true ): EconomicDataPoint[] { if (!excludeUnreliable) { return data; // Return all data if filtering is disabled } return data.filter(point => { const quality = assessDataQuality(point.date); return !quality.shouldExclude; }); } /** * Resolves era aliases to internal era keys. * @param eraName - User-provided era name or alias * @returns Internal era key or null if not found */ function resolveEraAlias(eraName: string): string | null { const normalizedName = eraName.toLowerCase().trim(); return PERIOD_EXCLUSION.ERA_ALIASES[normalizedName] || null; } /** * Gets the economic era key for a given date. * @param date - Date string in YYYY-MM-DD format * @returns Era key or null if no era matches */ function getEraKeyForDate(date: string): string | null { const year = new Date(date).getFullYear(); for (const [eraKey, era] of Object.entries(ECONOMIC_ERAS)) { if (year >= era.start && year <= era.end) { return eraKey; } } return null; } /** * Checks if a date falls within any of the specified date ranges. * @param date - Date string in YYYY-MM-DD format * @param dateRanges - Array of date ranges to check against * @returns True if date falls within any range */ function isDateInRanges(date: string, dateRanges: DateRange[]): boolean { const checkDate = new Date(date); return dateRanges.some(range => { const startDate = new Date(range.start); const endDate = new Date(range.end); return checkDate >= startDate && checkDate <= endDate; }); } /** * Applies period exclusion filters to historical data based on scenario parameters. * @param data - Array of economic data points * @param params - Scenario parameters containing exclusion criteria * @returns Filtered data array with excluded periods removed */ export function applyPeriodExclusions( data: EconomicDataPoint[], params: ScenarioParams ): EconomicDataPoint[] { let filteredData = [...data]; // 1. Exclude recent years if specified if (params.excludeRecentYears && params.excludeRecentYears > 0) { const cutoffDate = new Date(); cutoffDate.setFullYear(cutoffDate.getFullYear() - params.excludeRecentYears); const cutoffString = cutoffDate.toISOString().split('T')[0]; filteredData = filteredData.filter(point => point.date < cutoffString); } // 2. Apply era-based filtering if (params.focusEras && params.focusEras.length > 0) { // Focus mode: only include specified eras const resolvedFocusEras = params.focusEras .map(resolveEraAlias) .filter(era => era !== null) as string[]; filteredData = filteredData.filter(point => { const eraKey = getEraKeyForDate(point.date); return eraKey && resolvedFocusEras.includes(eraKey); }); } else if (params.excludeEras && params.excludeEras.length > 0) { // Exclude mode: remove specified eras const resolvedExcludeEras = params.excludeEras .map(resolveEraAlias) .filter(era => era !== null) as string[]; filteredData = filteredData.filter(point => { const eraKey = getEraKeyForDate(point.date); return !eraKey || !resolvedExcludeEras.includes(eraKey); }); } // 3. Apply custom date range exclusions if (params.excludeDateRanges && params.excludeDateRanges.length > 0) { filteredData = filteredData.filter(point => !isDateInRanges(point.date, params.excludeDateRanges!) ); } return filteredData; } /** * Applies temporal diversity filtering to prevent overlapping periods in results. * Ensures minimum time gap between returned analogues for meaningful historical diversity. * @param sortedAnalogues - Analogues sorted by similarity score (best first) * @param maxResults - Maximum number of results to return * @returns Filtered analogues with enforced temporal diversity */ function applyTemporalDiversityFilter( sortedAnalogues: Omit<HistoricalAnalogue, 'fedPolicyActions'>[], maxResults: number, minTimeGapMonths: number = 6 ): Omit<HistoricalAnalogue, 'fedPolicyActions'>[] { const selectedAnalogues: Omit<HistoricalAnalogue, 'fedPolicyActions'>[] = []; for (const analogue of sortedAnalogues) { // Check if this analogue conflicts with any already selected const hasOverlap = selectedAnalogues.some(selected => { const analogueStart = new Date(analogue.startDate); const analogueEnd = new Date(analogue.endDate); const selectedStart = new Date(selected.startDate); const selectedEnd = new Date(selected.endDate); // Calculate time gap between periods const gapFromEnd = Math.abs(analogueStart.getTime() - selectedEnd.getTime()); const gapFromStart = Math.abs(selectedStart.getTime() - analogueEnd.getTime()); const minGap = Math.min(gapFromEnd, gapFromStart); // Convert milliseconds to months (approximate) const gapMonths = minGap / (1000 * 60 * 60 * 24 * 30.44); return gapMonths < minTimeGapMonths; }); if (!hasOverlap) { selectedAnalogues.push(analogue); // Stop when we have enough diverse results if (selectedAnalogues.length >= maxResults) { break; } } } return selectedAnalogues; } /** * Finds the most similar historical periods to a target scenario using weighted DTW with windowed normalization. * @param allData - The entire historical dataset. * @param targetScenario - The recent data slice to compare against. * @param params - Parameters for the analysis, including weighted indicators. * @param topN - The number of top analogues to return. * @returns A ranked array of HistoricalAnalogue objects. */ export function findAnalogues( allData: EconomicDataPoint[], targetScenario: EconomicDataPoint[], params: ScenarioParams, topN: number = 5 ): HistoricalAnalogue[] { const { indicators, excludeUnreliableData = true } = params; const windowSize = targetScenario.length; if (windowSize === 0 || indicators.length === 0) { return []; } // Apply data quality filtering to historical data let filteredData = filterDataQuality(allData, excludeUnreliableData); // Apply period exclusion filtering filteredData = applyPeriodExclusions(filteredData, params); // **CRITICAL ECONOMIC TIME SCALE FIX**: Sample historical data at monthly intervals // This ensures we compare economic patterns at proper time scales, not daily noise const monthlyFilteredData = sampleMonthlyData(filteredData); // 1. Pre-extract the series for the target window for each indicator const targetSeriesByIndicator: { [id: string]: number[] } = {}; for (const indicator of indicators) { targetSeriesByIndicator[indicator.id] = targetScenario.map(d => d[indicator.id] as number); } // 2. Iterate through historical windows and calculate weighted DTW distance with enhanced diversity const analogues: Omit<HistoricalAnalogue, 'fedPolicyActions' | 'dataQuality'>[] = []; // Use monthly data for sliding window analysis for (let i = 0; i <= monthlyFilteredData.length - windowSize; i++) { const historicalWindow = monthlyFilteredData.slice(i, i + windowSize); // Avoid comparing the scenario with itself by checking the start date if (historicalWindow[0].date === targetScenario[0].date) { continue; } let totalWeightedDistance = 0; for (const indicator of indicators) { const targetSeries = targetSeriesByIndicator[indicator.id]; const historicalSeries = historicalWindow.map(d => d[indicator.id] as number); // ** CRITICAL CHANGE: Normalize each window independently ** const normalizedTarget = normalizeSeries(targetSeries); const normalizedHistorical = normalizeSeries(historicalSeries); const distance = calculateDtwDistance(normalizedTarget, normalizedHistorical); totalWeightedDistance += distance * indicator.weight; } // Apply enhanced temporal diversity bonus (simplified approach) const temporalDiversityScore = calculateTemporalDiversityBonus(historicalWindow[0].date); const finalScore = totalWeightedDistance * temporalDiversityScore; analogues.push({ startDate: historicalWindow[0].date, endDate: historicalWindow[historicalWindow.length - 1].date, similarityScore: finalScore, data: historicalWindow, }); } // 3. Sort by similarity and apply temporal diversity filtering const sortedAnalogues = analogues.sort((a, b) => a.similarityScore - b.similarityScore); // 4. Apply temporal diversity filter to prevent overlapping periods const minGapMonths = params.minTimeGapMonths || 6; // Default to 6 months const diverseAnalogues = applyTemporalDiversityFilter(sortedAnalogues, topN, minGapMonths); const detailedAnalogues: HistoricalAnalogue[] = diverseAnalogues.map(analogue => { const fedPolicyActions = extractFedPolicyActions(analogue.data); const dataQualityAssessment = assessDataQuality(analogue.startDate); return { ...analogue, fedPolicyActions, dataQuality: { reliability: dataQualityAssessment.reliability, warnings: dataQualityAssessment.warnings } }; }); return detailedAnalogues; }