diff --git a/MosaicIQ/src-tauri/Cargo.lock b/MosaicIQ/src-tauri/Cargo.lock index 334a4b3..bbcff34 100644 --- a/MosaicIQ/src-tauri/Cargo.lock +++ b/MosaicIQ/src-tauri/Cargo.lock @@ -26,7 +26,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -574,15 +573,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" version = "1.2.59" @@ -678,20 +668,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "compression-codecs" version = "0.4.37" @@ -813,25 +789,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crabrl" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e747436809bc651c62d6b4e9e9f6c3d9009ffc0432f5cf562c395354a678e6cf" -dependencies = [ - "ahash 0.8.12", - "anyhow", - "bitflags 2.11.0", - "chrono", - "compact_str", - "mimalloc", - "parking_lot", - "quick-xml 0.36.2", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -2600,16 +2557,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libmimalloc-sys" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "libredox" version = "0.1.15" @@ -2732,15 +2679,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mimalloc" -version = "0.1.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" -dependencies = [ - "libmimalloc-sys", -] - [[package]] name = "mime" version = "0.3.17" @@ -2791,7 +2729,6 @@ dependencies = [ "atom_syndication", "chrono", "chrono-tz", - "crabrl", "futures", "hex", "quick-xml 0.36.2", @@ -4967,12 +4904,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.8.9" diff --git a/MosaicIQ/src-tauri/Cargo.toml b/MosaicIQ/src-tauri/Cargo.toml index a4cc428..74b99fc 100644 --- a/MosaicIQ/src-tauri/Cargo.toml +++ b/MosaicIQ/src-tauri/Cargo.toml @@ -29,7 +29,6 @@ futures = "0.3" reqwest = { version = "0.12", features = ["json", "cookies", "gzip", "brotli"] } chrono = { version = "0.4", features = ["clock"] } chrono-tz = "0.10" -crabrl = { version = "0.1.0", default-features = false } quick-xml = "0.36" regex = "1" thiserror = "2" diff --git a/MosaicIQ/src-tauri/src/agent/panel_context.rs b/MosaicIQ/src-tauri/src/agent/panel_context.rs index 0c214d5..6a6c06f 100644 --- a/MosaicIQ/src-tauri/src/agent/panel_context.rs +++ b/MosaicIQ/src-tauri/src/agent/panel_context.rs @@ -251,6 +251,9 @@ fn compact_earnings_panel(data: &EarningsPanelData) -> Value { "companyName": data.company_name, "cik": data.cik, "frequency": data.frequency, + "availablePeriods": data.available_periods, + "selectedPeriodStart": data.selected_period_start, + "selectedPeriodEnd": data.selected_period_end, "latestFiling": data.latest_filing, "sourceStatus": compact_source_status(&data.source_status), "periods": data @@ -332,12 +335,34 @@ fn compact_dividend_event(event: &DividendEvent) -> Value { fn compact_earnings_period(period: &EarningsPeriod) -> Value { json!({ "label": truncate_text(&period.label), + "displayLabel": truncate_text(&period.display_label), "fiscalYear": period.fiscal_year, "fiscalPeriod": period.fiscal_period, "periodStart": period.period_start, "periodEnd": period.period_end, "filedDate": period.filed_date, "form": period.form, + "totalRevenues": period.total_revenues, + "totalRevenuesYoyChangePercent": period.total_revenues_yoy_change_percent, + "costOfSales": period.cost_of_sales, + "grossProfit": period.gross_profit, + "grossProfitMargin": period.gross_profit_margin, + "sellingGeneralAndAdministrativeExpenses": period.selling_general_and_administrative_expenses, + "researchAndDevelopmentExpenses": period.research_and_development_expenses, + "otherOperatingExpenses": period.other_operating_expenses, + "operatingProfit": period.operating_profit, + "operatingMargin": period.operating_margin, + "nonOperatingIncome": period.non_operating_income, + "totalNonOperatingIncome": period.total_non_operating_income, + "incomeBeforeProvisionForIncomeTaxes": period.income_before_provision_for_income_taxes, + "provisionForIncomeTaxes": period.provision_for_income_taxes, + "consolidatedNetIncome": period.consolidated_net_income, + "netIncomeAttributableToCommonShareholders": period.net_income_attributable_to_common_shareholders, + "totalSharesOutstanding": period.total_shares_outstanding, + "basicWeightedAverageSharesOutstanding": period.basic_weighted_average_shares_outstanding, + "dilutedWeightedAverageSharesOutstanding": period.diluted_weighted_average_shares_outstanding, + "ebitda": period.ebitda, + "effectiveTaxRate": period.effective_tax_rate, "revenue": period.revenue, "netIncome": period.net_income, "basicEps": period.basic_eps, @@ -351,7 +376,6 @@ fn compact_earnings_period(period: &EarningsPeriod) -> Value { fn compact_source_status(source_status: &SourceStatus) -> Value { json!({ "companyfactsUsed": source_status.companyfacts_used, - "latestXbrlParsed": source_status.latest_xbrl_parsed, "degradedReason": source_status.degraded_reason.as_deref().map(truncate_text), }) } @@ -472,6 +496,21 @@ mod tests { assert_eq!(value["events"].as_array().unwrap().len(), 4); } + #[test] + fn earnings_context_keeps_expanded_income_statement_fields() { + let value = compact_panel_payload(&PanelPayload::Earnings { + data: sample_earnings(), + }); + + let latest = &value["periods"].as_array().unwrap()[0]; + assert_eq!(value["selectedPeriodStart"], "2020"); + assert_eq!(value["selectedPeriodEnd"], "2024"); + assert_eq!(value["availablePeriods"].as_array().unwrap().len(), 5); + assert_eq!(latest["totalRevenues"], 100.0); + assert_eq!(latest["operatingProfit"], 40.0); + assert_eq!(latest["effectiveTaxRate"], 20.0); + } + #[test] fn portfolio_context_caps_holdings() { let value = compact_panel_payload(&PanelPayload::Portfolio { @@ -644,8 +683,21 @@ mod tests { symbol: "AAPL".to_string(), company_name: "Apple Inc.".to_string(), cik: "0000320193".to_string(), - frequency: Frequency::Quarterly, + frequency: Frequency::Annual, periods: (0..5).map(sample_earnings_period).collect(), + available_periods: (0..5) + .map(|index| crate::terminal::EarningsPeriodOption { + id: format!("202{}", index), + label: format!("FY202{} (Sep)", index), + frequency: Frequency::Annual, + fiscal_year: Some(format!("202{}", index)), + fiscal_period: Some("FY".to_string()), + period_end: "2026-03-31".to_string(), + sort_key: format!("202{}", index), + }) + .collect(), + selected_period_start: Some("2020".to_string()), + selected_period_end: Some("2024".to_string()), latest_filing: Some(sample_filing_ref()), source_status: sample_source_status(), } @@ -696,7 +748,6 @@ mod tests { fn sample_source_status() -> SourceStatus { SourceStatus { companyfacts_used: true, - latest_xbrl_parsed: true, degraded_reason: None, } } @@ -764,13 +815,35 @@ mod tests { fn sample_earnings_period(index: usize) -> EarningsPeriod { EarningsPeriod { - label: format!("Q{index}"), - fiscal_year: Some("2026".to_string()), - fiscal_period: Some("Q1".to_string()), - period_start: Some("2026-01-01".to_string()), - period_end: "2026-03-31".to_string(), + label: format!("FY202{} (Sep)", index), + display_label: format!("FY202{} (Sep)", index), + fiscal_year: Some(format!("202{}", index)), + fiscal_period: Some("FY".to_string()), + period_start: Some(format!("202{}-01-01", index)), + period_end: format!("202{}-09-30", index), filed_date: "2026-04-30".to_string(), - form: "10-Q".to_string(), + form: "10-K".to_string(), + total_revenues: Some(100.0), + total_revenues_yoy_change_percent: Some(5.0), + cost_of_sales: Some(45.0), + gross_profit: Some(55.0), + gross_profit_margin: Some(55.0), + selling_general_and_administrative_expenses: Some(10.0), + research_and_development_expenses: Some(5.0), + other_operating_expenses: Some(0.0), + operating_profit: Some(40.0), + operating_margin: Some(40.0), + non_operating_income: Some(2.0), + total_non_operating_income: Some(2.0), + income_before_provision_for_income_taxes: Some(42.0), + provision_for_income_taxes: Some(8.4), + consolidated_net_income: Some(30.0), + net_income_attributable_to_common_shareholders: Some(29.5), + total_shares_outstanding: Some(101.0), + basic_weighted_average_shares_outstanding: Some(99.0), + diluted_weighted_average_shares_outstanding: Some(100.0), + ebitda: Some(44.0), + effective_tax_rate: Some(20.0), revenue: Some(100.0), net_income: Some(30.0), basic_eps: Some(1.0), diff --git a/MosaicIQ/src-tauri/src/terminal/command_service.rs b/MosaicIQ/src-tauri/src/terminal/command_service.rs index 2211e51..63b678c 100644 --- a/MosaicIQ/src-tauri/src/terminal/command_service.rs +++ b/MosaicIQ/src-tauri/src/terminal/command_service.rs @@ -7,7 +7,9 @@ use crate::portfolio::{ PortfolioTransaction, TradeConfirmation, TransactionKind, }; use crate::terminal::mock_data::load_mock_financial_data; -use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError}; +use crate::terminal::sec_edgar::{ + EarningsPeriodKey, EarningsQuery, EarningsRange, EdgarDataLookup, EdgarLookupError, +}; use crate::terminal::security_lookup::{ SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, }; @@ -106,18 +108,13 @@ impl TerminalCommandService { } } "/em" => { - if command.args.len() > 2 { + if command.args.len() > 4 { TerminalCommandResponse::Text { - content: "Usage: /em [ticker] [annual|quarterly]".to_string(), + content: earnings_usage().to_string(), portfolio: None, } } else { - self.earnings( - command.args.first(), - command.args.get(1), - Frequency::Quarterly, - ) - .await + self.earnings(&command.args, Frequency::Annual).await } } "/news" => self.news(command.args.first().map(String::as_str)).await, @@ -486,18 +483,20 @@ impl TerminalCommandService { async fn earnings( &self, - ticker: Option<&String>, - period: Option<&String>, + args: &[String], default_frequency: Frequency, ) -> TerminalCommandResponse { - let (ticker, frequency) = - match parse_symbol_and_frequency("/em", ticker, period, default_frequency) { - Ok(value) => value, - Err(response) => return *response, - }; + let (ticker, query) = match parse_earnings_query("/em", args, default_frequency) { + Ok(value) => value, + Err(response) => return *response, + }; - match self.edgar_lookup.earnings(&ticker, frequency).await { + match self.edgar_lookup.earnings(&ticker, query).await { Ok(data) => TerminalCommandResponse::panel(PanelPayload::Earnings { data }), + Err(EdgarLookupError::InvalidPeriodRange { detail }) => TerminalCommandResponse::Text { + content: detail, + portfolio: None, + }, Err(error) => sec_error_response("SEC earnings unavailable", &ticker, error), } } @@ -658,6 +657,114 @@ fn parse_symbol_and_frequency( Ok((ticker.to_ascii_uppercase(), frequency)) } +fn earnings_usage() -> &'static str { + "Usage: /em [ticker] [annual|quarterly] [start] [end]\nExamples: /em AAPL | /em AAPL annual 2021 2024 | /em AAPL quarterly 2024Q1 2025Q2" +} + +fn parse_earnings_query( + command: &str, + args: &[String], + default_frequency: Frequency, +) -> Result<(String, EarningsQuery), Box> { + let Some(ticker) = args + .first() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return Err(Box::new(TerminalCommandResponse::Text { + content: earnings_usage().to_string(), + portfolio: None, + })); + }; + + let (frequency, remaining_args) = + match args.get(1).map(|value| value.trim().to_ascii_lowercase()) { + Some(value) if value == "annual" => (Frequency::Annual, &args[2..]), + Some(value) if value == "quarterly" => (Frequency::Quarterly, &args[2..]), + _ => (default_frequency, &args[1..]), + }; + + let range = match remaining_args { + [] => None, + [start, end] => Some( + parse_earnings_range(start, end, frequency).map_err(|detail| { + Box::new(TerminalCommandResponse::Text { + content: format!("{detail}\n{}", earnings_usage()), + portfolio: None, + }) + })?, + ), + _ => { + return Err(Box::new(TerminalCommandResponse::Text { + content: earnings_usage().to_string(), + portfolio: None, + })) + } + }; + + let _ = command; + + Ok(( + ticker.to_ascii_uppercase(), + EarningsQuery { frequency, range }, + )) +} + +fn parse_earnings_range( + start: &str, + end: &str, + frequency: Frequency, +) -> Result { + let start = parse_earnings_period_key(start, frequency)?; + let end = parse_earnings_period_key(end, frequency)?; + + if start.token() > end.token() { + return Err("Earnings range start must be earlier than or equal to the end.".to_string()); + } + + Ok(EarningsRange { start, end }) +} + +fn parse_earnings_period_key( + value: &str, + frequency: Frequency, +) -> Result { + let value = value.trim().to_ascii_uppercase(); + match frequency { + Frequency::Annual => { + if value.len() != 4 || !value.chars().all(|character| character.is_ascii_digit()) { + return Err("Annual /em ranges must use YYYY tokens.".to_string()); + } + + let fiscal_year = value + .parse::() + .map_err(|_| "Annual /em ranges must use YYYY tokens.".to_string())?; + Ok(EarningsPeriodKey::Annual { fiscal_year }) + } + Frequency::Quarterly => { + let (year, quarter) = value + .split_once('Q') + .ok_or_else(|| "Quarterly /em ranges must use YYYYQn tokens.".to_string())?; + if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) { + return Err("Quarterly /em ranges must use YYYYQn tokens.".to_string()); + } + let fiscal_year = year + .parse::() + .map_err(|_| "Quarterly /em ranges must use YYYYQn tokens.".to_string())?; + let quarter = quarter + .parse::() + .map_err(|_| "Quarterly /em ranges must use YYYYQn tokens.".to_string())?; + if !(1..=4).contains(&quarter) { + return Err("Quarterly /em ranges must use YYYYQn tokens.".to_string()); + } + Ok(EarningsPeriodKey::Quarterly { + fiscal_year, + quarter, + }) + } + } +} + fn parse_trade_args(command: &str, args: &[String]) -> Option<(String, f64, Option)> { if args.len() < 2 || args.len() > 3 { return None; @@ -717,7 +824,7 @@ fn parse_command(input: &str) -> ChatCommandRequest { /// Human-readable help text returned for `/help` and unknown commands. fn help_text() -> &'static str { - "Available Commands:\n\n /search [ticker] - Search live security data\n /buy [ticker] [quantity] [price?] - Buy a company into the portfolio\n /sell [ticker] [quantity] [price?] - Sell a company from the portfolio\n /cash [deposit|withdraw] [amount] - Adjust portfolio cash\n /portfolio - Show your portfolio\n /portfolio stats - Show portfolio statistics\n /portfolio history [limit] - Show recent transactions\n /fa [ticker] [annual|quarterly] - SEC financial statements\n /cf [ticker] [annual|quarterly] - SEC cash flow summary\n /dvd [ticker] - SEC dividends history\n /em [ticker] [annual|quarterly] - SEC earnings history\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally" + "Available Commands:\n\n /search [ticker] - Search live security data\n /buy [ticker] [quantity] [price?] - Buy a company into the portfolio\n /sell [ticker] [quantity] [price?] - Sell a company from the portfolio\n /cash [deposit|withdraw] [amount] - Adjust portfolio cash\n /portfolio - Show your portfolio\n /portfolio stats - Show portfolio statistics\n /portfolio history [limit] - Show recent transactions\n /fa [ticker] [annual|quarterly] - SEC financial statements\n /cf [ticker] [annual|quarterly] - SEC cash flow summary\n /dvd [ticker] - SEC dividends history\n /em [ticker] [annual|quarterly] [start] [end] - SEC earnings history\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally" } /// Wraps the shared help text into the terminal command response envelope. @@ -857,7 +964,7 @@ mod tests { PortfolioTransaction, TradeConfirmation, TransactionKind, }; use crate::terminal::mock_data::load_mock_financial_data; - use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError}; + use crate::terminal::sec_edgar::{EarningsQuery, EdgarDataLookup, EdgarLookupError}; use crate::terminal::security_lookup::{ SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, }; @@ -1078,7 +1185,6 @@ mod tests { latest_filing: None, source_status: SourceStatus { companyfacts_used: true, - latest_xbrl_parsed: false, degraded_reason: None, }, }) @@ -1100,7 +1206,6 @@ mod tests { latest_filing: None, source_status: SourceStatus { companyfacts_used: true, - latest_xbrl_parsed: false, degraded_reason: None, }, }) @@ -1133,15 +1238,18 @@ mod tests { fn earnings<'a>( &'a self, ticker: &'a str, - frequency: Frequency, + query: EarningsQuery, ) -> BoxFuture<'a, Result> { Box::pin(async move { Ok(EarningsPanelData { symbol: ticker.to_string(), company_name: "Example Co".to_string(), cik: "0000000001".to_string(), - frequency, + frequency: query.frequency, periods: Vec::new(), + available_periods: Vec::new(), + selected_period_start: query.range.as_ref().map(|range| range.start.token()), + selected_period_end: query.range.as_ref().map(|range| range.end.token()), latest_filing: None, source_status: SourceStatus { companyfacts_used: true, @@ -1561,6 +1669,78 @@ mod tests { } } + #[test] + fn em_defaults_to_annual_frequency() { + let service = TerminalCommandService::with_dependencies( + load_mock_financial_data(), + Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), + test_news_service(), + Duration::ZERO, + ); + + let response = execute(&service, "/em AAPL"); + + match response { + TerminalCommandResponse::Panel { panel } => match panel.as_ref() { + PanelPayload::Earnings { data } => { + assert_eq!(data.symbol, "AAPL"); + assert_eq!(data.frequency, Frequency::Annual); + } + other => panic!("expected earnings panel, got {other:?}"), + }, + other => panic!("expected earnings panel, got {other:?}"), + } + } + + #[test] + fn em_parses_explicit_quarterly_ranges() { + let service = TerminalCommandService::with_dependencies( + load_mock_financial_data(), + Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), + test_news_service(), + Duration::ZERO, + ); + + let response = execute(&service, "/em AAPL quarterly 2024Q1 2025Q2"); + + match response { + TerminalCommandResponse::Panel { panel } => match panel.as_ref() { + PanelPayload::Earnings { data } => { + assert_eq!(data.frequency, Frequency::Quarterly); + assert_eq!(data.selected_period_start.as_deref(), Some("2024Q1")); + assert_eq!(data.selected_period_end.as_deref(), Some("2025Q2")); + } + other => panic!("expected earnings panel, got {other:?}"), + }, + other => panic!("expected earnings panel, got {other:?}"), + } + } + + #[test] + fn em_rejects_invalid_range_order() { + let service = TerminalCommandService::with_dependencies( + load_mock_financial_data(), + Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), + test_news_service(), + Duration::ZERO, + ); + + let response = execute(&service, "/em AAPL annual 2025 2024"); + + match response { + TerminalCommandResponse::Text { content, .. } => { + assert!(content.contains("Earnings range start must be earlier")); + } + other => panic!("expected text response, got {other:?}"), + } + } + #[test] fn buy_command_uses_provided_execution_price() { let portfolio_service = Arc::new(FakePortfolioService { diff --git a/MosaicIQ/src-tauri/src/terminal/mod.rs b/MosaicIQ/src-tauri/src/terminal/mod.rs index ebbd6c2..b25094f 100644 --- a/MosaicIQ/src-tauri/src/terminal/mod.rs +++ b/MosaicIQ/src-tauri/src/terminal/mod.rs @@ -9,7 +9,7 @@ pub use command_service::TerminalCommandService; pub use types::{ CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint, CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod, - ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding, - LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus, - StatementPeriod, StockAnalysis, TerminalCommandResponse, + EarningsPeriodOption, ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, + FinancialsPanelData, Frequency, Holding, LookupCompanyRequest, MockFinancialData, PanelPayload, + Portfolio, SourceStatus, StatementPeriod, StockAnalysis, TerminalCommandResponse, }; diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs index 2fe4956..ff206b7 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs @@ -8,13 +8,14 @@ use tokio::sync::Mutex as AsyncMutex; use super::service::EdgarLookupError; use super::types::{ - CompanyFactsResponse, CompanySubmissions, FilingIndex, ParsedXbrlDocument, ResolvedCompany, - TickerDirectoryEntry, + CompanyFactsResponse, CompanySubmissions, FilingIndex, ParsedXbrlDocument, RecentFilings, + ResolvedCompany, TickerDirectoryEntry, }; use super::xbrl::parse_xbrl_instance; const TICKERS_URL: &str = "https://www.sec.gov/files/company_tickers.json"; const SUBMISSIONS_URL_PREFIX: &str = "https://data.sec.gov/submissions/CIK"; +const SUBMISSIONS_FILE_URL_PREFIX: &str = "https://data.sec.gov/submissions"; const COMPANYFACTS_URL_PREFIX: &str = "https://data.sec.gov/api/xbrl/companyfacts/CIK"; const SEC_ARCHIVE_PREFIX: &str = "https://www.sec.gov/Archives/edgar/data"; const TICKER_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); @@ -164,6 +165,7 @@ pub(crate) struct SecEdgarClient { fetcher: Box, tickers_cache: Mutex>>>, submissions_cache: Mutex>>, + submissions_file_cache: Mutex>>, companyfacts_cache: Mutex>>, filing_index_cache: Mutex>>, instance_xml_cache: Mutex>>>, @@ -176,6 +178,7 @@ impl SecEdgarClient { fetcher, tickers_cache: Mutex::new(None), submissions_cache: Mutex::new(HashMap::new()), + submissions_file_cache: Mutex::new(HashMap::new()), companyfacts_cache: Mutex::new(HashMap::new()), filing_index_cache: Mutex::new(HashMap::new()), instance_xml_cache: Mutex::new(HashMap::new()), @@ -249,6 +252,32 @@ impl SecEdgarClient { Ok(decoded) } + pub(crate) async fn load_submissions_file( + &self, + filename: &str, + ) -> Result { + if let Some(cached) = + get_cached_value(&self.submissions_file_cache, filename, SHORT_CACHE_TTL) + { + return Ok(cached); + } + + let url = format!("{SUBMISSIONS_FILE_URL_PREFIX}/{filename}"); + let payload = self.fetcher.get_text(&url).await?; + let decoded = serde_json::from_str::(&payload).map_err(|source| { + EdgarLookupError::InvalidResponse { + provider: "SEC EDGAR", + detail: source.to_string(), + } + })?; + store_cached_value( + &self.submissions_file_cache, + filename.to_string(), + decoded.clone(), + ); + Ok(decoded) + } + pub(crate) async fn load_filing_index( &self, cik: &str, diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/catalog.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/catalog.rs new file mode 100644 index 0000000..67c0988 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/catalog.rs @@ -0,0 +1,100 @@ +use std::cmp::Ordering; +use std::collections::HashSet; + +use chrono::NaiveDate; + +use crate::terminal::{FilingRef, Frequency}; + +use super::super::client::SecEdgarClient; +use super::super::service::EdgarLookupError; +use super::super::types::CompanySubmissions; + +const ANNUAL_FORMS: &[&str] = &["10-K", "10-K/A", "20-F", "20-F/A", "40-F", "40-F/A"]; +const QUARTERLY_FORMS: &[&str] = &["10-Q", "10-Q/A", "6-K", "6-K/A"]; + +pub(crate) async fn load_candidate_filings( + client: &SecEdgarClient, + _cik: &str, + submissions: &CompanySubmissions, + frequency: Frequency, +) -> Result, EdgarLookupError> { + let mut filings = submissions.filings.recent.rows(); + + for file in &submissions.filings.files { + let supplemental = client.load_submissions_file(&file.name).await?; + filings.extend(supplemental.rows()); + } + + filings.retain(|filing| matches_frequency_form(&filing.form, frequency)); + filings.sort_by(compare_filing_priority); + + let mut seen_accessions = HashSet::new(); + filings.retain(|filing| seen_accessions.insert(filing.accession_number.clone())); + + Ok(filings) +} + +pub(crate) fn matches_frequency_form(form: &str, frequency: Frequency) -> bool { + match frequency { + Frequency::Annual => ANNUAL_FORMS.contains(&form), + Frequency::Quarterly => QUARTERLY_FORMS.contains(&form), + } +} + +fn compare_filing_priority(left: &FilingRef, right: &FilingRef) -> Ordering { + compare_date_strings( + left.report_date.as_deref(), + right.report_date.as_deref(), + false, + ) + .then_with(|| compare_date_strings(Some(&left.filing_date), Some(&right.filing_date), false)) + .then_with(|| amendment_priority(&right.form).cmp(&amendment_priority(&left.form))) + .then_with(|| right.accession_number.cmp(&left.accession_number)) +} + +fn compare_date_strings(left: Option<&str>, right: Option<&str>, ascending: bool) -> Ordering { + let left_date = left.and_then(parse_date); + let right_date = right.and_then(parse_date); + + let ordering = left_date.cmp(&right_date); + if ascending { + ordering + } else { + ordering.reverse() + } +} + +fn parse_date(value: &str) -> Option { + NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() +} + +fn amendment_priority(form: &str) -> u8 { + u8::from(form.ends_with("/A")) +} + +#[cfg(test)] +mod tests { + use crate::terminal::FilingRef; + + use super::*; + + #[test] + fn compare_filing_priority_should_prefer_newer_report_dates() { + let newer = FilingRef { + accession_number: "0001".to_string(), + filing_date: "2025-02-01".to_string(), + report_date: Some("2024-12-31".to_string()), + form: "10-K".to_string(), + primary_document: None, + }; + let older = FilingRef { + accession_number: "0002".to_string(), + filing_date: "2024-02-01".to_string(), + report_date: Some("2023-12-31".to_string()), + form: "10-K".to_string(), + primary_document: None, + }; + + assert_eq!(compare_filing_priority(&newer, &older), Ordering::Less); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/dei.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/dei.rs new file mode 100644 index 0000000..149e10f --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/dei.rs @@ -0,0 +1,194 @@ +use crate::terminal::Frequency; + +use super::super::types::EarningsPeriodKey; +use super::facts::{find_text_fact, FilingXbrlDocument}; + +const DOCUMENT_FISCAL_YEAR_FOCUS: (&str, &str) = ("dei", "DocumentFiscalYearFocus"); +const DOCUMENT_FISCAL_PERIOD_FOCUS: (&str, &str) = ("dei", "DocumentFiscalPeriodFocus"); +const DOCUMENT_PERIOD_END_DATE: (&str, &str) = ("dei", "DocumentPeriodEndDate"); + +/// Helper to extract fiscal year from period end date +fn extract_fiscal_year_from_period_end(period_end: &str) -> Option { + // Period end dates are typically YYYY-MM-DD + // For fiscal year, we need to determine which fiscal year this belongs to + // This is a simplified approach - assumes calendar year alignment + period_end.split('-').next()?.parse::().ok() +} + +/// Helper to infer fiscal period from filing form and period dates +fn infer_fiscal_period_from_duration( + period_start: &str, + period_end: &str, + frequency: Frequency, +) -> Option { + match frequency { + Frequency::Annual => Some("FY".to_string()), + Frequency::Quarterly => { + // Try to determine quarter from the period end date month + let month = period_end.split('-').nth(1)?; + match month { + "03" | "04" => Some("Q1".to_string()), + "06" | "07" => Some("Q2".to_string()), + "09" | "10" => Some("Q3".to_string()), + "12" | "01" => Some("Q4".to_string()), + _ => None, + } + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PeriodIdentity { + pub key: EarningsPeriodKey, + pub fiscal_year: String, + pub fiscal_period: Option, + pub period_end: String, + pub display_label: String, +} + +pub(crate) fn extract_period_identity( + document: &FilingXbrlDocument, + frequency: Frequency, +) -> Option { + // Try to extract DEI facts first + let fiscal_year_from_dei = find_text_fact( + document, + DOCUMENT_FISCAL_YEAR_FOCUS.0, + DOCUMENT_FISCAL_YEAR_FOCUS.1, + ) + .and_then(|value| value.trim().parse::().ok()); + + let fiscal_period_from_dei = find_text_fact( + document, + DOCUMENT_FISCAL_PERIOD_FOCUS.0, + DOCUMENT_FISCAL_PERIOD_FOCUS.1, + ) + .map(|value| value.trim().to_ascii_uppercase()); + + let period_end_from_dei = find_text_fact( + document, + DOCUMENT_PERIOD_END_DATE.0, + DOCUMENT_PERIOD_END_DATE.1, + ) + .map(|value| value.trim().to_string()); + + // Find a duration context to extract fallback period information + let duration_context = document.contexts.values().find(|ctx| { + ctx.period_start.is_some() && ctx.period_end.is_some() + }); + + // Determine fiscal year (prefer DEI, fallback to period end extraction) + let fiscal_year = fiscal_year_from_dei.or_else(|| { + period_end_from_dei.as_ref() + .or_else(|| duration_context.and_then(|ctx| ctx.period_end.as_ref())) + .and_then(|period_end| extract_fiscal_year_from_period_end(period_end)) + })?; + + // Determine period end (prefer DEI, fallback to context) + let period_end = period_end_from_dei + .or_else(|| duration_context.and_then(|ctx| ctx.period_end.clone())) + .unwrap_or_default(); + + // Determine fiscal period (prefer DEI, fallback to inference) + let fiscal_period = fiscal_period_from_dei.or_else(|| { + duration_context.and_then(|ctx| { + let period_start = ctx.period_start.as_ref()?; + infer_fiscal_period_from_duration(period_start, &period_end, frequency) + }) + }); + + // Build the period key + let key = match frequency { + Frequency::Annual => { + let period = fiscal_period.as_deref().unwrap_or("FY"); + (period == "FY").then_some(EarningsPeriodKey::Annual { fiscal_year })? + } + Frequency::Quarterly => { + let quarter = fiscal_period.as_deref().and_then(parse_quarter)?; + EarningsPeriodKey::Quarterly { + fiscal_year, + quarter, + } + } + }; + + Some(PeriodIdentity { + key, + fiscal_year: fiscal_year.to_string(), + fiscal_period: fiscal_period.clone(), + display_label: display_label( + fiscal_year, + fiscal_period.as_deref(), + &period_end, + frequency, + ), + period_end, + }) +} + +pub(crate) fn display_label( + fiscal_year: i32, + fiscal_period: Option<&str>, + period_end: &str, + frequency: Frequency, +) -> String { + let month_suffix = month_abbreviation(period_end) + .map(|month| format!(" ({month})")) + .unwrap_or_default(); + + match frequency { + Frequency::Annual => format!("FY{fiscal_year}{month_suffix}"), + Frequency::Quarterly => match fiscal_period { + Some(period) if period.starts_with('Q') => { + format!("FY{fiscal_year} {period}{month_suffix}") + } + _ => period_end.to_string(), + }, + } +} + +pub(crate) fn parse_quarter(value: &str) -> Option { + value + .strip_prefix('Q') + .and_then(|quarter| quarter.parse::().ok()) + .filter(|quarter| (1..=4).contains(quarter)) +} + +fn month_abbreviation(value: &str) -> Option<&'static str> { + let month = value.split('-').nth(1)?; + match month { + "01" => Some("Jan"), + "02" => Some("Feb"), + "03" => Some("Mar"), + "04" => Some("Apr"), + "05" => Some("May"), + "06" => Some("Jun"), + "07" => Some("Jul"), + "08" => Some("Aug"), + "09" => Some("Sep"), + "10" => Some("Oct"), + "11" => Some("Nov"), + "12" => Some("Dec"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::terminal::Frequency; + + use super::*; + + #[test] + fn display_label_should_include_month_for_annual_periods() { + assert_eq!( + display_label(2024, Some("FY"), "2024-09-28", Frequency::Annual), + "FY2024 (Sep)" + ); + } + + #[test] + fn parse_quarter_should_extract_quarter_number() { + assert_eq!(parse_quarter("Q3"), Some(3)); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/facts.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/facts.rs new file mode 100644 index 0000000..f96dc7c --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/facts.rs @@ -0,0 +1,401 @@ +use std::collections::HashMap; + +use quick_xml::events::Event; +use quick_xml::Reader; + +use super::super::service::EdgarLookupError; +use super::super::types::UnitFamily; + +#[derive(Debug, Clone, Default)] +pub(crate) struct FilingXbrlDocument { + pub contexts: HashMap, + pub units: HashMap, + pub facts: Vec, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct FilingContext { + pub entity_identifier: Option, + pub period_start: Option, + pub period_end: Option, + pub instant: Option, + pub has_dimensions: bool, +} + +impl FilingContext { + pub(crate) fn is_duration(&self) -> bool { + self.period_start.is_some() && self.period_end.is_some() + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct FilingUnit { + pub measures: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct FilingFact { + pub prefix: Option, + pub local_name: String, + pub context_ref: Option, + pub unit_ref: Option, + pub value: FilingFactValue, + pub is_nil: bool, +} + +#[derive(Debug, Clone)] +pub(crate) enum FilingFactValue { + Numeric(f64), + Text(String), +} + +impl FilingFact { + pub(crate) fn numeric_value(&self) -> Option { + match self.value { + FilingFactValue::Numeric(value) => Some(value), + FilingFactValue::Text(_) => None, + } + } + + pub(crate) fn text_value(&self) -> Option<&str> { + match self.value { + FilingFactValue::Numeric(_) => None, + FilingFactValue::Text(ref value) => Some(value), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ContextField { + Identifier, + StartDate, + EndDate, + Instant, + Measure, +} + +pub(crate) fn parse_filing_xbrl_instance( + bytes: &[u8], +) -> Result { + let mut reader = Reader::from_reader(bytes); + reader.config_mut().trim_text(true); + + let mut document = FilingXbrlDocument::default(); + let mut current_context_id: Option = None; + let mut current_unit_id: Option = None; + let mut current_field: Option = None; + let mut buffer = Vec::new(); + + loop { + match reader.read_event_into(&mut buffer) { + Ok(Event::Start(element)) => { + let element_name = decode_name(element.name().as_ref()); + match element_name.as_str() { + name if name.ends_with("context") => { + current_context_id = attribute_value(&element, b"id"); + current_field = None; + } + name if name.ends_with("unit") => { + current_unit_id = attribute_value(&element, b"id"); + current_field = None; + } + name if name.ends_with("identifier") => { + current_field = Some(ContextField::Identifier) + } + name if name.ends_with("startDate") => { + current_field = Some(ContextField::StartDate) + } + name if name.ends_with("endDate") => { + current_field = Some(ContextField::EndDate) + } + name if name.ends_with("instant") => { + current_field = Some(ContextField::Instant) + } + name if name.ends_with("measure") => { + current_field = Some(ContextField::Measure) + } + name if name.ends_with("segment") || name.ends_with("scenario") => { + if let Some(context_id) = current_context_id.as_ref() { + document + .contexts + .entry(context_id.clone()) + .or_default() + .has_dimensions = true; + } + } + _ if is_fact_candidate(&element_name) => { + let fact = read_fact(&mut reader, &element, &element_name)?; + document.facts.push(fact); + } + _ => {} + } + } + Ok(Event::Empty(element)) => { + let element_name = decode_name(element.name().as_ref()); + if is_fact_candidate(&element_name) { + document.facts.push(empty_fact(&element, &element_name)); + } + } + Ok(Event::Text(text)) => { + let value = String::from_utf8_lossy(text.as_ref()).trim().to_string(); + if value.is_empty() { + buffer.clear(); + continue; + } + + match current_field { + Some(ContextField::Identifier) => { + if let Some(context_id) = current_context_id.as_ref() { + document + .contexts + .entry(context_id.clone()) + .or_default() + .entity_identifier = Some(value); + } + } + Some(ContextField::StartDate) => { + if let Some(context_id) = current_context_id.as_ref() { + document + .contexts + .entry(context_id.clone()) + .or_default() + .period_start = Some(value); + } + } + Some(ContextField::EndDate) => { + if let Some(context_id) = current_context_id.as_ref() { + document + .contexts + .entry(context_id.clone()) + .or_default() + .period_end = Some(value); + } + } + Some(ContextField::Instant) => { + if let Some(context_id) = current_context_id.as_ref() { + document + .contexts + .entry(context_id.clone()) + .or_default() + .instant = Some(value); + } + } + Some(ContextField::Measure) => { + if let Some(unit_id) = current_unit_id.as_ref() { + document + .units + .entry(unit_id.clone()) + .or_default() + .measures + .push(value); + } + } + None => {} + } + } + Ok(Event::End(element)) => { + let element_name = decode_name(element.name().as_ref()); + if element_name.ends_with("context") { + current_context_id = None; + current_field = None; + } else if element_name.ends_with("unit") { + current_unit_id = None; + current_field = None; + } else if matches!( + current_field, + Some(ContextField::Identifier) + | Some(ContextField::StartDate) + | Some(ContextField::EndDate) + | Some(ContextField::Instant) + | Some(ContextField::Measure) + ) { + current_field = None; + } + } + Ok(Event::Eof) => break, + Err(source) => { + return Err(EdgarLookupError::XbrlParseFailed { + detail: source.to_string(), + }); + } + _ => {} + } + + buffer.clear(); + } + + Ok(document) +} + +pub(crate) fn find_text_fact<'a>( + document: &'a FilingXbrlDocument, + prefix: &str, + local_name: &str, +) -> Option<&'a str> { + document + .facts + .iter() + .find(|fact| { + !fact.is_nil && fact.prefix.as_deref() == Some(prefix) && fact.local_name == local_name + }) + .and_then(FilingFact::text_value) +} + +pub(crate) fn classify_fact_unit_family( + document: &FilingXbrlDocument, + fact: &FilingFact, +) -> Option { + let unit_ref = fact.unit_ref.as_deref()?; + let unit = document.units.get(unit_ref)?; + classify_unit_measures(&unit.measures) +} + +fn read_fact( + reader: &mut Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, + element_name: &str, +) -> Result { + let (prefix, local_name) = split_qualified_name(element_name); + let is_nil = attribute_value_by_suffix(element, b"nil") + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let text = reader + .read_text(element.name()) + .map_err(|source| EdgarLookupError::XbrlParseFailed { + detail: source.to_string(), + })? + .trim() + .to_string(); + + Ok(FilingFact { + prefix, + local_name, + context_ref: attribute_value(element, b"contextRef"), + unit_ref: attribute_value(element, b"unitRef"), + value: parse_fact_value(text), + is_nil, + }) +} + +fn empty_fact(element: &quick_xml::events::BytesStart<'_>, element_name: &str) -> FilingFact { + let (prefix, local_name) = split_qualified_name(element_name); + FilingFact { + prefix, + local_name, + context_ref: attribute_value(element, b"contextRef"), + unit_ref: attribute_value(element, b"unitRef"), + value: FilingFactValue::Text(String::new()), + is_nil: attribute_value_by_suffix(element, b"nil") + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(false), + } +} + +fn parse_fact_value(raw: String) -> FilingFactValue { + raw.parse::() + .map(FilingFactValue::Numeric) + .unwrap_or_else(|_| FilingFactValue::Text(raw)) +} + +fn classify_unit_measures(measures: &[String]) -> Option { + if measures.is_empty() { + return None; + } + + let has_currency = measures.iter().any(|measure| { + measure.ends_with(":USD") + || measure.ends_with(":EUR") + || measure.ends_with(":GBP") + || measure.ends_with(":JPY") + || measure.ends_with(":CAD") + }); + let has_shares = measures.iter().any(|measure| measure.ends_with(":shares")); + let has_pure = measures.iter().any(|measure| measure.ends_with(":pure")); + + if has_currency && has_shares { + return Some(UnitFamily::CurrencyPerShare); + } + if has_currency { + return Some(UnitFamily::Currency); + } + if has_shares { + return Some(UnitFamily::Shares); + } + if has_pure { + return Some(UnitFamily::Pure); + } + + None +} + +fn attribute_value(element: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Option { + element + .attributes() + .flatten() + .find(|attribute| attribute.key.as_ref() == key) + .map(|attribute| String::from_utf8_lossy(attribute.value.as_ref()).to_string()) +} + +fn attribute_value_by_suffix( + element: &quick_xml::events::BytesStart<'_>, + key_suffix: &[u8], +) -> Option { + element + .attributes() + .flatten() + .find(|attribute| attribute.key.as_ref().ends_with(key_suffix)) + .map(|attribute| String::from_utf8_lossy(attribute.value.as_ref()).to_string()) +} + +fn decode_name(value: &[u8]) -> String { + String::from_utf8_lossy(value).to_string() +} + +fn split_qualified_name(value: &str) -> (Option, String) { + match value.split_once(':') { + Some((prefix, local_name)) => (Some(prefix.to_string()), local_name.to_string()), + None => (None, value.to_string()), + } +} + +fn is_fact_candidate(name: &str) -> bool { + !name.ends_with("context") + && !name.ends_with("unit") + && !name.ends_with("measure") + && !name.ends_with("identifier") + && !name.ends_with("segment") + && !name.ends_with("entity") + && !name.ends_with("period") + && !name.contains("schemaRef") + && name.contains(':') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_filing_xbrl_instance_should_capture_text_and_numeric_facts() { + let xml = r#" + + 12024-01-012024-12-31 + iso4217:USD + 2024 + 100 +"#; + + let document = parse_filing_xbrl_instance(xml.as_bytes()).expect("xml should parse"); + assert_eq!( + find_text_fact(&document, "dei", "DocumentFiscalYearFocus"), + Some("2024") + ); + assert_eq!( + document + .facts + .iter() + .filter_map(FilingFact::numeric_value) + .next(), + Some(100.0) + ); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs new file mode 100644 index 0000000..58f80a8 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs @@ -0,0 +1,407 @@ +use chrono::NaiveDate; + +use crate::terminal::{EarningsPeriod, EarningsPeriodOption, FilingRef, Frequency}; + +use super::dei::PeriodIdentity; +use super::facts::{classify_fact_unit_family, FilingContext, FilingFact, FilingXbrlDocument}; +use super::presentation::{ + FilingConceptCandidate, BASIC_EPS_CONCEPTS, BASIC_SHARES_CONCEPTS, + COMMON_SHAREHOLDER_NET_INCOME_CONCEPTS, CONSOLIDATED_NET_INCOME_CONCEPTS, + COST_OF_SALES_CONCEPTS, DILUTED_EPS_CONCEPTS, DILUTED_SHARES_CONCEPTS, EBITDA_CONCEPTS, + EFFECTIVE_TAX_RATE_CONCEPTS, GROSS_PROFIT_CONCEPTS, INCOME_BEFORE_TAX_CONCEPTS, + NON_OPERATING_INCOME_CONCEPTS, OPERATING_PROFIT_CONCEPTS, PROVISION_FOR_INCOME_TAXES_CONCEPTS, + RESEARCH_AND_DEVELOPMENT_CONCEPTS, REVENUE_CONCEPTS, R_AND_D_CONCEPTS, + SELLING_GENERAL_AND_ADMINISTRATIVE_CONCEPTS, TOTAL_NON_OPERATING_INCOME_CONCEPTS, + TOTAL_SHARES_OUTSTANDING_CONCEPTS, +}; +#[derive(Debug, Clone)] +pub(crate) struct StatementWindow { + pub start: String, + pub end: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct FilingEarningsRecord { + pub period: EarningsPeriod, + pub option: EarningsPeriodOption, + pub filing: FilingRef, +} + +pub(crate) fn map_filing_to_record( + document: &FilingXbrlDocument, + filing: &FilingRef, + identity: &PeriodIdentity, + frequency: Frequency, +) -> Option { + let statement_window = select_statement_window(document, identity, frequency)?; + let total_revenues = select_duration_value(document, &statement_window, REVENUE_CONCEPTS); + let consolidated_net_income = select_duration_value( + document, + &statement_window, + CONSOLIDATED_NET_INCOME_CONCEPTS, + ); + + if total_revenues.is_none() && consolidated_net_income.is_none() { + return None; + } + + let effective_tax_rate = + select_duration_value(document, &statement_window, EFFECTIVE_TAX_RATE_CONCEPTS) + .map(normalize_percent); + let basic_eps = select_duration_value(document, &statement_window, BASIC_EPS_CONCEPTS); + let diluted_eps = select_duration_value(document, &statement_window, DILUTED_EPS_CONCEPTS); + let diluted_weighted_average_shares_outstanding = + select_duration_value(document, &statement_window, DILUTED_SHARES_CONCEPTS); + + let period = EarningsPeriod { + label: identity.display_label.clone(), + display_label: identity.display_label.clone(), + fiscal_year: Some(identity.fiscal_year.clone()), + fiscal_period: identity.fiscal_period.clone(), + period_start: Some(statement_window.start.clone()), + period_end: identity.period_end.clone(), + filed_date: filing.filing_date.clone(), + form: filing.form.clone(), + total_revenues, + total_revenues_yoy_change_percent: None, + cost_of_sales: select_duration_value(document, &statement_window, COST_OF_SALES_CONCEPTS), + gross_profit: select_duration_value(document, &statement_window, GROSS_PROFIT_CONCEPTS), + gross_profit_margin: None, + selling_general_and_administrative_expenses: select_duration_value( + document, + &statement_window, + SELLING_GENERAL_AND_ADMINISTRATIVE_CONCEPTS, + ), + research_and_development_expenses: select_duration_value( + document, + &statement_window, + RESEARCH_AND_DEVELOPMENT_CONCEPTS, + ) + .or_else(|| select_duration_value(document, &statement_window, R_AND_D_CONCEPTS)), + other_operating_expenses: None, + operating_profit: select_duration_value( + document, + &statement_window, + OPERATING_PROFIT_CONCEPTS, + ), + operating_margin: None, + non_operating_income: select_duration_value( + document, + &statement_window, + NON_OPERATING_INCOME_CONCEPTS, + ), + total_non_operating_income: select_duration_value( + document, + &statement_window, + TOTAL_NON_OPERATING_INCOME_CONCEPTS, + ), + income_before_provision_for_income_taxes: select_duration_value( + document, + &statement_window, + INCOME_BEFORE_TAX_CONCEPTS, + ), + provision_for_income_taxes: select_duration_value( + document, + &statement_window, + PROVISION_FOR_INCOME_TAXES_CONCEPTS, + ), + consolidated_net_income, + net_income_attributable_to_common_shareholders: select_duration_value( + document, + &statement_window, + COMMON_SHAREHOLDER_NET_INCOME_CONCEPTS, + ), + total_shares_outstanding: select_instant_value( + document, + &identity.period_end, + TOTAL_SHARES_OUTSTANDING_CONCEPTS, + ), + basic_weighted_average_shares_outstanding: select_duration_value( + document, + &statement_window, + BASIC_SHARES_CONCEPTS, + ), + diluted_weighted_average_shares_outstanding, + ebitda: select_duration_value(document, &statement_window, EBITDA_CONCEPTS), + effective_tax_rate, + revenue: total_revenues, + net_income: consolidated_net_income, + basic_eps, + diluted_eps, + diluted_weighted_average_shares: diluted_weighted_average_shares_outstanding, + revenue_yoy_change_percent: None, + diluted_eps_yoy_change_percent: None, + }; + + let option = EarningsPeriodOption { + id: identity.key.token(), + label: identity.display_label.clone(), + frequency, + fiscal_year: Some(identity.fiscal_year.clone()), + fiscal_period: identity.fiscal_period.clone(), + period_end: identity.period_end.clone(), + sort_key: identity.key.sort_key(), + }; + + Some(FilingEarningsRecord { + period, + option, + filing: filing.clone(), + }) +} + +pub(crate) fn is_clearly_quarter_length(period: &EarningsPeriod) -> bool { + let Some(start) = period.period_start.as_deref().and_then(parse_date) else { + return false; + }; + let Some(end) = parse_date(&period.period_end) else { + return false; + }; + let days = (end - start).num_days().abs() + 1; + (70..=110).contains(&days) +} + +fn select_statement_window( + document: &FilingXbrlDocument, + identity: &PeriodIdentity, + frequency: Frequency, +) -> Option { + let mut candidates = document + .contexts + .values() + .filter_map(|context| score_context(document, context, identity, frequency)) + .collect::>(); + + candidates.sort_by_key(|candidate| candidate.0); + candidates.into_iter().next().map(|(_, window)| window) +} + +fn score_context( + document: &FilingXbrlDocument, + context: &FilingContext, + identity: &PeriodIdentity, + frequency: Frequency, +) -> Option<(i64, StatementWindow)> { + if context.has_dimensions || !context.is_duration() { + return None; + } + + let start = context.period_start.as_ref()?; + let end = context.period_end.as_ref()?; + if end != &identity.period_end { + return None; + } + + let days = duration_days(start, end)?; + let target_days = match frequency { + Frequency::Annual => 365, + Frequency::Quarterly => 91, + }; + let accepted = match frequency { + Frequency::Annual => (300..=390).contains(&days), + Frequency::Quarterly => (70..=110).contains(&days), + }; + if !accepted { + return None; + } + + let has_revenue = context_has_fact(document, context, REVENUE_CONCEPTS); + let has_net_income = context_has_fact(document, context, CONSOLIDATED_NET_INCOME_CONCEPTS); + let completeness_penalty = match (has_revenue, has_net_income) { + (true, true) => 0, + (true, false) | (false, true) => 500, + (false, false) => 1_000, + }; + + Some(( + (days - target_days).abs() + completeness_penalty, + StatementWindow { + start: start.clone(), + end: end.clone(), + }, + )) +} + +fn context_has_fact( + document: &FilingXbrlDocument, + context: &FilingContext, + candidates: &[FilingConceptCandidate], +) -> bool { + document.facts.iter().any(|fact| { + let Some(context_ref) = fact.context_ref.as_deref() else { + return false; + }; + let Some(candidate_context) = document.contexts.get(context_ref) else { + return false; + }; + candidate_context.period_start == context.period_start + && candidate_context.period_end == context.period_end + && !candidate_context.has_dimensions + && matches_candidate(fact, candidates) + && fact.numeric_value().is_some() + }) +} + +fn select_duration_value( + document: &FilingXbrlDocument, + window: &StatementWindow, + candidates: &[FilingConceptCandidate], +) -> Option { + select_numeric_value(document, candidates, |context| { + !context.has_dimensions + && context.period_start.as_deref() == Some(window.start.as_str()) + && context.period_end.as_deref() == Some(window.end.as_str()) + }) +} + +fn select_instant_value( + document: &FilingXbrlDocument, + period_end: &str, + candidates: &[FilingConceptCandidate], +) -> Option { + select_numeric_value(document, candidates, |context| { + !context.has_dimensions && context.instant.as_deref() == Some(period_end) + }) +} + +fn select_numeric_value( + document: &FilingXbrlDocument, + candidates: &[FilingConceptCandidate], + matches_context: impl Fn(&FilingContext) -> bool, +) -> Option { + candidates.iter().find_map(|candidate| { + document.facts.iter().find_map(|fact| { + let context_ref = fact.context_ref.as_deref()?; + let context = document.contexts.get(context_ref)?; + (matches_context(context) + && fact.prefix.as_deref() == Some(candidate.prefix) + && fact.local_name == candidate.local_name + && classify_fact_unit_family(document, fact) == Some(candidate.unit_family)) + .then(|| fact.numeric_value()) + .flatten() + }) + }) +} + +fn matches_candidate(fact: &FilingFact, candidates: &[FilingConceptCandidate]) -> bool { + candidates.iter().any(|candidate| { + fact.prefix.as_deref() == Some(candidate.prefix) && fact.local_name == candidate.local_name + }) +} + +fn normalize_percent(value: f64) -> f64 { + if value.abs() <= 1.0 { + value * 100.0 + } else { + value + } +} + +fn duration_days(start: &str, end: &str) -> Option { + let start = parse_date(start)?; + let end = parse_date(end)?; + Some((end - start).num_days().abs() + 1) +} + +fn parse_date(value: &str) -> Option { + NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() +} + +#[cfg(test)] +mod tests { + use crate::terminal::{FilingRef, Frequency}; + + use super::super::dei::PeriodIdentity; + use super::super::facts::{parse_filing_xbrl_instance, FilingXbrlDocument}; + use super::*; + use crate::terminal::sec_edgar::types::EarningsPeriodKey; + + #[test] + fn map_filing_to_record_should_select_the_quarter_length_context() { + let document = sample_document(); + let filing = FilingRef { + accession_number: "0001".to_string(), + filing_date: "2025-05-30".to_string(), + report_date: Some("2025-04-27".to_string()), + form: "10-Q".to_string(), + primary_document: None, + }; + let identity = PeriodIdentity { + key: EarningsPeriodKey::Quarterly { + fiscal_year: 2026, + quarter: 1, + }, + fiscal_year: "2026".to_string(), + fiscal_period: Some("Q1".to_string()), + period_end: "2025-04-27".to_string(), + display_label: "FY2026 Q1 (Apr)".to_string(), + }; + + let record = map_filing_to_record(&document, &filing, &identity, Frequency::Quarterly) + .expect("record should map"); + + assert_eq!(record.period.total_revenues, Some(100.0)); + assert_eq!(record.period.period_start.as_deref(), Some("2025-01-27")); + } + + #[test] + fn is_clearly_quarter_length_should_accept_standard_quarters() { + let period = EarningsPeriod { + label: String::new(), + display_label: String::new(), + fiscal_year: Some("2026".to_string()), + fiscal_period: Some("Q1".to_string()), + period_start: Some("2025-01-27".to_string()), + period_end: "2025-04-27".to_string(), + filed_date: "2025-05-30".to_string(), + form: "10-Q".to_string(), + total_revenues: None, + total_revenues_yoy_change_percent: None, + cost_of_sales: None, + gross_profit: None, + gross_profit_margin: None, + selling_general_and_administrative_expenses: None, + research_and_development_expenses: None, + other_operating_expenses: None, + operating_profit: None, + operating_margin: None, + non_operating_income: None, + total_non_operating_income: None, + income_before_provision_for_income_taxes: None, + provision_for_income_taxes: None, + consolidated_net_income: None, + net_income_attributable_to_common_shareholders: None, + total_shares_outstanding: None, + basic_weighted_average_shares_outstanding: None, + diluted_weighted_average_shares_outstanding: None, + ebitda: None, + effective_tax_rate: None, + revenue: None, + net_income: None, + basic_eps: None, + diluted_eps: None, + diluted_weighted_average_shares: None, + revenue_yoy_change_percent: None, + diluted_eps_yoy_change_percent: None, + }; + + assert!(is_clearly_quarter_length(&period)); + } + + fn sample_document() -> FilingXbrlDocument { + let xml = r#" + + 12025-01-272025-04-27 + 12024-10-282025-04-27 + iso4217:USD + 2026 + Q1 + 2025-04-27 + 100 + 200 + 25 +"#; + + parse_filing_xbrl_instance(xml.as_bytes()).expect("document should parse") + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mod.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mod.rs new file mode 100644 index 0000000..d664e96 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mod.rs @@ -0,0 +1,269 @@ +mod catalog; +mod dei; +mod facts; +mod mapper; +mod package; +mod presentation; + +use crate::terminal::{ + EarningsPanelData, EarningsPeriod, EarningsPeriodOption, FilingRef, Frequency, SourceStatus, +}; + +use super::client::SecEdgarClient; +use super::facts::{company_name, default_earnings_period_limit}; +use super::service::EdgarLookupError; +use super::types::{CompanySubmissions, EarningsQuery, ResolvedCompany}; +use mapper::{is_clearly_quarter_length, FilingEarningsRecord}; + +#[derive(Debug, Clone)] +pub(crate) struct FilingNativeEarningsData { + pub periods: Vec, + pub available_periods: Vec, + pub selected_period_start: Option, + pub selected_period_end: Option, + pub latest_filing: Option, + pub source_status: SourceStatus, +} + +pub(crate) async fn load_earnings_data( + client: &SecEdgarClient, + company: &ResolvedCompany, + submissions: &CompanySubmissions, + query: &EarningsQuery, +) -> Result { + let filings = + catalog::load_candidate_filings(client, &company.cik, submissions, query.frequency).await?; + if filings.is_empty() { + return Err(EdgarLookupError::NoEligibleFilings { + ticker: company.ticker.clone(), + frequency: query.frequency, + }); + } + + let mut records = Vec::::new(); + let mut skipped_reasons = Vec::::new(); + let mut seen_periods = std::collections::HashSet::::new(); + + for filing in filings { + let package = match package::load_filing_package(client, &company.cik, &filing).await { + Ok(package) => package, + Err(error) => { + skipped_reasons.push(error.to_string()); + continue; + } + }; + let Some(identity) = dei::extract_period_identity(&package.document, query.frequency) + else { + skipped_reasons.push(format!( + "Skipped {} because DEI fiscal period metadata was unavailable.", + filing.accession_number + )); + continue; + }; + let Some(record) = + mapper::map_filing_to_record(&package.document, &filing, &identity, query.frequency) + else { + skipped_reasons.push(format!( + "Skipped {} because no direct filing-native income statement facts matched the filing period.", + filing.accession_number + )); + continue; + }; + + if filing.form.starts_with("6-K") && !is_clearly_quarter_length(&record.period) { + skipped_reasons.push(format!( + "Skipped {} because the filing duration was not a clear fiscal quarter.", + filing.accession_number + )); + continue; + } + + if seen_periods.insert(record.option.id.clone()) { + records.push(record); + } + } + + if records.is_empty() { + let detail = if skipped_reasons.is_empty() { + format!( + "No eligible {} filings were found for {}.", + query.frequency.as_str(), + company.ticker + ) + } else { + format!( + "No eligible {} filings were found for {}. {}", + query.frequency.as_str(), + company.ticker, + summarize_skips(&skipped_reasons).unwrap_or_default() + ) + }; + return Err(EdgarLookupError::InvalidPeriodRange { detail }); + } + + records.sort_by(|left, right| right.option.sort_key.cmp(&left.option.sort_key)); + + let available_periods = records + .iter() + .rev() + .map(|record| record.option.clone()) + .collect::>(); + + let requested_range = query.range.as_ref().map(|range| { + let start = range.start.token(); + let end = range.end.token(); + (start, end) + }); + + if let Some((start, end)) = requested_range.as_ref() { + let has_start = available_periods.iter().any(|period| period.id == *start); + let has_end = available_periods.iter().any(|period| period.id == *end); + if !has_start || !has_end { + let oldest = available_periods.first().map(|period| period.id.as_str()); + let newest = available_periods.last().map(|period| period.id.as_str()); + return Err(EdgarLookupError::InvalidPeriodRange { + detail: match (oldest, newest) { + (Some(oldest), Some(newest)) => format!( + "Requested /em range is unavailable. Try a reported range between {oldest} and {newest}." + ), + _ => "Requested /em range is unavailable for this company.".to_string(), + }, + }); + } + } + + let mut selected = records + .into_iter() + .filter(|record| match requested_range.as_ref() { + Some((start, end)) => record.option.id >= *start && record.option.id <= *end, + None => true, + }) + .collect::>(); + + if requested_range.is_none() { + selected.truncate(default_earnings_period_limit(query.frequency)); + } + + apply_yoy_metrics(&mut selected, query.frequency); + + let periods = selected + .iter() + .map(|record| record.period.clone()) + .collect::>(); + let selected_period_start = selected.last().map(|record| record.option.id.clone()); + let selected_period_end = selected.first().map(|record| record.option.id.clone()); + let latest_filing = selected + .first() + .map(|record| record.filing.clone()) + .or_else(|| available_latest_filing(&available_periods, &selected)); + + Ok(FilingNativeEarningsData { + periods, + available_periods, + selected_period_start, + selected_period_end, + latest_filing, + source_status: SourceStatus { + companyfacts_used: false, + degraded_reason: summarize_skips(&skipped_reasons), + }, + }) +} + +pub(crate) fn build_panel_data( + company: &ResolvedCompany, + submissions: &CompanySubmissions, + query: &EarningsQuery, + data: FilingNativeEarningsData, +) -> EarningsPanelData { + EarningsPanelData { + symbol: company.ticker.clone(), + company_name: company_name(company, Some(&submissions.name), None), + cik: company.cik.clone(), + frequency: query.frequency, + periods: data.periods, + available_periods: data.available_periods, + selected_period_start: data.selected_period_start, + selected_period_end: data.selected_period_end, + latest_filing: data.latest_filing, + source_status: data.source_status, + } +} + +fn apply_yoy_metrics(records: &mut [FilingEarningsRecord], frequency: Frequency) { + let offset = match frequency { + Frequency::Annual => 1, + Frequency::Quarterly => 4, + }; + + for index in 0..records.len() { + let Some(previous) = records + .get(index + offset) + .map(|record| (record.period.total_revenues, record.period.diluted_eps)) + else { + continue; + }; + let Some(current) = records.get_mut(index).map(|record| &mut record.period) else { + continue; + }; + + current.total_revenues_yoy_change_percent = + calculate_change_percent(current.total_revenues, previous.0); + current.revenue_yoy_change_percent = current.total_revenues_yoy_change_percent; + current.diluted_eps_yoy_change_percent = + calculate_change_percent(current.diluted_eps, previous.1); + } +} + +fn calculate_change_percent(current: Option, previous: Option) -> Option { + let current = current?; + let previous = previous?; + (previous.abs() > f64::EPSILON).then_some(((current - previous) / previous.abs()) * 100.0) +} + +fn summarize_skips(skipped_reasons: &[String]) -> Option { + if skipped_reasons.is_empty() { + return None; + } + + let preview = skipped_reasons + .iter() + .take(2) + .cloned() + .collect::>() + .join(" "); + let suffix = if skipped_reasons.len() > 2 { + format!( + " {} additional filing(s) were skipped.", + skipped_reasons.len() - 2 + ) + } else { + String::new() + }; + + Some(format!( + "Filing-native earnings skipped {} filing(s). {preview}{suffix}", + skipped_reasons.len() + )) +} + +fn available_latest_filing( + _available_periods: &[EarningsPeriodOption], + selected: &[FilingEarningsRecord], +) -> Option { + selected.first().map(|record| record.filing.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calculate_change_percent_should_only_use_direct_values() { + assert_eq!( + calculate_change_percent(Some(120.0), Some(100.0)), + Some(20.0) + ); + assert_eq!(calculate_change_percent(Some(120.0), None), None); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/package.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/package.rs new file mode 100644 index 0000000..b659c7b --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/package.rs @@ -0,0 +1,41 @@ +use crate::terminal::FilingRef; + +use super::super::client::SecEdgarClient; +use super::super::service::EdgarLookupError; +use super::super::xbrl::pick_instance_document; +use super::facts::{parse_filing_xbrl_instance, FilingXbrlDocument}; + +#[derive(Debug, Clone)] +pub(crate) struct FilingPackage { + pub document: FilingXbrlDocument, +} + +pub(crate) async fn load_filing_package( + client: &SecEdgarClient, + cik: &str, + filing: &FilingRef, +) -> Result { + let index = client + .load_filing_index(cik, &filing.accession_number) + .await?; + let filenames = index + .directory + .item + .into_iter() + .map(|item| item.name) + .collect::>(); + let filename = + pick_instance_document(&filenames).ok_or_else(|| EdgarLookupError::InvalidResponse { + provider: "SEC EDGAR", + detail: format!( + "No XBRL instance document was published for accession {}.", + filing.accession_number + ), + })?; + let bytes = client + .load_instance_xml(cik, &filing.accession_number, &filename) + .await?; + let document = parse_filing_xbrl_instance(&bytes)?; + + Ok(FilingPackage { document }) +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/presentation.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/presentation.rs new file mode 100644 index 0000000..715907c --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/presentation.rs @@ -0,0 +1,200 @@ +use super::super::types::UnitFamily; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct FilingConceptCandidate { + pub prefix: &'static str, + pub local_name: &'static str, + pub unit_family: UnitFamily, +} + +const fn candidate( + prefix: &'static str, + local_name: &'static str, + unit_family: UnitFamily, +) -> FilingConceptCandidate { + FilingConceptCandidate { + prefix, + local_name, + unit_family, + } +} + +pub(crate) const REVENUE_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + UnitFamily::Currency, + ), + candidate("us-gaap", "SalesRevenueNet", UnitFamily::Currency), + candidate("us-gaap", "Revenues", UnitFamily::Currency), + candidate("ifrs-full", "Revenue", UnitFamily::Currency), +]; + +pub(crate) const COST_OF_SALES_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate("us-gaap", "CostOfRevenue", UnitFamily::Currency), + candidate("us-gaap", "CostOfGoodsSold", UnitFamily::Currency), + candidate("us-gaap", "CostOfSales", UnitFamily::Currency), +]; + +pub(crate) const GROSS_PROFIT_CONCEPTS: &[FilingConceptCandidate] = + &[candidate("us-gaap", "GrossProfit", UnitFamily::Currency)]; + +pub(crate) const SELLING_GENERAL_AND_ADMINISTRATIVE_CONCEPTS: &[FilingConceptCandidate] = + &[candidate( + "us-gaap", + "SellingGeneralAndAdministrativeExpense", + UnitFamily::Currency, + )]; + +pub(crate) const RESEARCH_AND_DEVELOPMENT_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "ResearchAndDevelopmentExpense", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "ResearchAndDevelopmentExpense", + UnitFamily::Currency, + ), +]; + +pub(crate) const R_AND_D_CONCEPTS: &[FilingConceptCandidate] = RESEARCH_AND_DEVELOPMENT_CONCEPTS; + +pub(crate) const OPERATING_PROFIT_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate("us-gaap", "OperatingIncomeLoss", UnitFamily::Currency), + candidate( + "ifrs-full", + "ProfitLossFromOperatingActivities", + UnitFamily::Currency, + ), +]; + +pub(crate) const NON_OPERATING_INCOME_CONCEPTS: &[FilingConceptCandidate] = &[candidate( + "us-gaap", + "OtherNonoperatingIncomeExpense", + UnitFamily::Currency, +)]; + +pub(crate) const TOTAL_NON_OPERATING_INCOME_CONCEPTS: &[FilingConceptCandidate] = &[candidate( + "us-gaap", + "NonoperatingIncomeExpense", + UnitFamily::Currency, +)]; + +pub(crate) const INCOME_BEFORE_TAX_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest", + UnitFamily::Currency, + ), + candidate("ifrs-full", "ProfitLossBeforeTax", UnitFamily::Currency), +]; + +pub(crate) const PROVISION_FOR_INCOME_TAXES_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate("us-gaap", "IncomeTaxExpenseBenefit", UnitFamily::Currency), + candidate( + "ifrs-full", + "IncomeTaxExpenseContinuingOperations", + UnitFamily::Currency, + ), +]; + +pub(crate) const CONSOLIDATED_NET_INCOME_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate("us-gaap", "NetIncomeLoss", UnitFamily::Currency), + candidate("ifrs-full", "ProfitLoss", UnitFamily::Currency), +]; + +pub(crate) const COMMON_SHAREHOLDER_NET_INCOME_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "NetIncomeLossAvailableToCommonStockholdersBasic", + UnitFamily::Currency, + ), + candidate( + "us-gaap", + "NetIncomeLossAvailableToCommonStockholdersDiluted", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "ProfitLossAttributableToOwnersOfParent", + UnitFamily::Currency, + ), +]; + +pub(crate) const BASIC_EPS_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "EarningsPerShareBasic", + UnitFamily::CurrencyPerShare, + ), + candidate( + "ifrs-full", + "BasicEarningsLossPerShare", + UnitFamily::CurrencyPerShare, + ), +]; + +pub(crate) const DILUTED_EPS_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "EarningsPerShareDiluted", + UnitFamily::CurrencyPerShare, + ), + candidate( + "ifrs-full", + "DilutedEarningsLossPerShare", + UnitFamily::CurrencyPerShare, + ), + candidate( + "ifrs-full", + "BasicAndDilutedEarningsLossPerShare", + UnitFamily::CurrencyPerShare, + ), +]; + +pub(crate) const BASIC_SHARES_CONCEPTS: &[FilingConceptCandidate] = &[candidate( + "us-gaap", + "WeightedAverageNumberOfSharesOutstandingBasic", + UnitFamily::Shares, +)]; + +pub(crate) const DILUTED_SHARES_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "us-gaap", + "WeightedAverageNumberOfDilutedSharesOutstanding", + UnitFamily::Shares, + ), + candidate( + "ifrs-full", + "WeightedAverageNumberOfSharesOutstandingDiluted", + UnitFamily::Shares, + ), +]; + +pub(crate) const TOTAL_SHARES_OUTSTANDING_CONCEPTS: &[FilingConceptCandidate] = &[ + candidate( + "dei", + "EntityCommonStockSharesOutstanding", + UnitFamily::Shares, + ), + candidate( + "us-gaap", + "CommonStockSharesOutstanding", + UnitFamily::Shares, + ), + candidate("ifrs-full", "NumberOfSharesOutstanding", UnitFamily::Shares), +]; + +pub(crate) const EBITDA_CONCEPTS: &[FilingConceptCandidate] = &[candidate( + "us-gaap", + "EarningsBeforeInterestTaxesDepreciationAndAmortization", + UnitFamily::Currency, +)]; + +pub(crate) const EFFECTIVE_TAX_RATE_CONCEPTS: &[FilingConceptCandidate] = &[candidate( + "us-gaap", + "EffectiveIncomeTaxRateContinuingOperations", + UnitFamily::Pure, +)]; diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs index 24a94a0..5a9caae 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs @@ -3,16 +3,19 @@ use std::collections::{BTreeMap, HashMap}; use chrono::NaiveDate; use crate::terminal::{ - CashFlowPeriod, DividendEvent, EarningsPeriod, FilingRef, Frequency, SourceStatus, - StatementPeriod, + CashFlowPeriod, DividendEvent, EarningsPeriod, EarningsPeriodOption, FilingRef, Frequency, + SourceStatus, StatementPeriod, }; use super::service::EdgarLookupError; use super::types::{ - CompanyConceptFacts, CompanyFactRecord, CompanyFactsResponse, ConceptCandidate, NormalizedFact, - ParsedXbrlDocument, PeriodRow, ResolvedCompany, UnitFamily, + CompanyConceptFacts, CompanyFactRecord, CompanyFactsResponse, ConceptCandidate, + EarningsPeriodKey, NormalizedFact, ParsedXbrlDocument, PeriodRow, ResolvedCompany, UnitFamily, }; +pub(crate) const DEFAULT_ANNUAL_EARNINGS_PERIODS: usize = 4; +pub(crate) const DEFAULT_QUARTERLY_EARNINGS_PERIODS: usize = 8; + const ANNUAL_FORMS: &[&str] = &["10-K", "20-F", "40-F", "10-K/A", "20-F/A", "40-F/A"]; const QUARTERLY_FORMS: &[&str] = &["10-Q", "6-K", "10-Q/A", "6-K/A"]; @@ -69,6 +72,113 @@ pub(crate) const BASIC_EPS_CONCEPTS: &[ConceptCandidate] = &[ UnitFamily::CurrencyPerShare, ), ]; +pub(crate) const COST_OF_SALES_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "CostOfRevenue", UnitFamily::Currency), + candidate("us-gaap", "CostOfGoodsSold", UnitFamily::Currency), + candidate("us-gaap", "CostOfSales", UnitFamily::Currency), +]; +pub(crate) const SGA_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "SellingGeneralAndAdministrativeExpense", + UnitFamily::Currency, +)]; +pub(crate) const SELLING_AND_MARKETING_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "SellingAndMarketingExpense", + UnitFamily::Currency, +)]; +pub(crate) const GENERAL_AND_ADMINISTRATIVE_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "GeneralAndAdministrativeExpense", + UnitFamily::Currency, +)]; +pub(crate) const R_AND_D_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "ResearchAndDevelopmentExpense", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "ResearchAndDevelopmentExpense", + UnitFamily::Currency, + ), +]; +pub(crate) const OPERATING_EXPENSES_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "OperatingExpenses", UnitFamily::Currency), + candidate("us-gaap", "OperatingExpense", UnitFamily::Currency), +]; +pub(crate) const OTHER_NON_OPERATING_INCOME_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "OtherNonoperatingIncomeExpense", + UnitFamily::Currency, +)]; +pub(crate) const TOTAL_NON_OPERATING_INCOME_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "NonoperatingIncomeExpense", + UnitFamily::Currency, +)]; +pub(crate) const PRETAX_INCOME_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest", + UnitFamily::Currency, + ), + candidate("ifrs-full", "ProfitLossBeforeTax", UnitFamily::Currency), +]; +pub(crate) const PROVISION_FOR_INCOME_TAXES_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "IncomeTaxExpenseBenefit", UnitFamily::Currency), + candidate( + "ifrs-full", + "IncomeTaxExpenseContinuingOperations", + UnitFamily::Currency, + ), +]; +pub(crate) const COMMON_SHAREHOLDER_NET_INCOME_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "NetIncomeLossAvailableToCommonStockholdersBasic", + UnitFamily::Currency, + ), + candidate( + "us-gaap", + "NetIncomeLossAvailableToCommonStockholdersDiluted", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "ProfitLossAttributableToOwnersOfParent", + UnitFamily::Currency, + ), +]; +pub(crate) const BASIC_SHARES_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "WeightedAverageNumberOfSharesOutstandingBasic", + UnitFamily::Shares, +)]; +pub(crate) const TOTAL_SHARES_OUTSTANDING_CONCEPTS: &[ConceptCandidate] = SHARES_CONCEPTS; +pub(crate) const DEPRECIATION_AND_AMORTIZATION_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "DepreciationDepletionAndAmortization", + UnitFamily::Currency, + ), + candidate( + "us-gaap", + "DepreciationAmortizationAndAccretionNet", + UnitFamily::Currency, + ), + candidate( + "us-gaap", + "DepreciationAndAmortization", + UnitFamily::Currency, + ), +]; +pub(crate) const EFFECTIVE_TAX_RATE_CONCEPTS: &[ConceptCandidate] = &[candidate( + "us-gaap", + "EffectiveIncomeTaxRateContinuingOperations", + UnitFamily::Pure, +)]; pub(crate) const CASH_CONCEPTS: &[ConceptCandidate] = &[ candidate( "us-gaap", @@ -395,36 +505,282 @@ pub(crate) fn build_earnings_periods( frequency: Frequency, latest_xbrl: Option<&ParsedXbrlDocument>, ) -> Vec { - let limit = match frequency { - Frequency::Annual => 4, - Frequency::Quarterly => 8, - }; + let rows = build_earnings_period_rows(facts, frequency); + build_earnings_periods_from_rows(facts, &rows, frequency, latest_xbrl) +} - let rows = build_period_rows(facts, frequency, REVENUE_CONCEPTS, limit); +pub(crate) fn build_earnings_period_rows( + facts: &[NormalizedFact], + frequency: Frequency, +) -> Vec { + build_period_rows(facts, frequency, REVENUE_CONCEPTS, usize::MAX) +} +pub(crate) fn build_earnings_period_options( + rows: &[PeriodRow], + frequency: Frequency, +) -> Vec { + rows.iter() + .rev() + .filter_map(|row| { + let key = earnings_period_key_for_values( + row.fiscal_year.as_deref(), + row.fiscal_period.as_deref(), + frequency, + )?; + Some(EarningsPeriodOption { + id: key.token(), + label: earnings_display_label_for_values( + row.fiscal_year.as_deref(), + row.fiscal_period.as_deref(), + &row.period_end, + frequency, + ), + frequency, + fiscal_year: row.fiscal_year.clone(), + fiscal_period: row.fiscal_period.clone(), + period_end: row.period_end.clone(), + sort_key: key.sort_key(), + }) + }) + .collect() +} + +pub(crate) fn default_earnings_period_limit(frequency: Frequency) -> usize { + match frequency { + Frequency::Annual => DEFAULT_ANNUAL_EARNINGS_PERIODS, + Frequency::Quarterly => DEFAULT_QUARTERLY_EARNINGS_PERIODS, + } +} + +pub(crate) fn earnings_period_key_for_values( + fiscal_year: Option<&str>, + fiscal_period: Option<&str>, + frequency: Frequency, +) -> Option { + let fiscal_year = fiscal_year?.parse::().ok()?; + match frequency { + Frequency::Annual => (fiscal_period.is_none() || fiscal_period == Some("FY")) + .then_some(EarningsPeriodKey::Annual { fiscal_year }), + Frequency::Quarterly => { + let quarter = fiscal_period + .and_then(|period| period.strip_prefix('Q')) + .and_then(|quarter| quarter.parse::().ok()) + .filter(|quarter| (1..=4).contains(quarter))?; + Some(EarningsPeriodKey::Quarterly { + fiscal_year, + quarter, + }) + } + } +} + +pub(crate) fn earnings_period_token_for_values( + fiscal_year: Option<&str>, + fiscal_period: Option<&str>, + frequency: Frequency, +) -> Option { + earnings_period_key_for_values(fiscal_year, fiscal_period, frequency).map(|key| key.token()) +} + +pub(crate) fn earnings_display_label_for_values( + fiscal_year: Option<&str>, + fiscal_period: Option<&str>, + period_end: &str, + frequency: Frequency, +) -> String { + let month_suffix = month_abbreviation(period_end) + .map(|month| format!(" ({month})")) + .unwrap_or_default(); + + match frequency { + Frequency::Annual => fiscal_year + .map(|year| format!("FY{year}{month_suffix}")) + .unwrap_or_else(|| period_end.to_string()), + Frequency::Quarterly => match (fiscal_year, fiscal_period) { + (Some(year), Some(period)) if period.starts_with('Q') => { + format!("FY{year} {period}{month_suffix}") + } + _ => period_end.to_string(), + }, + } +} + +pub(crate) fn build_earnings_periods_from_rows( + facts: &[NormalizedFact], + rows: &[PeriodRow], + frequency: Frequency, + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Vec { let mut periods = rows - .into_iter() + .iter() .enumerate() .map(|(index, row)| { let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten(); + let total_revenues = + duration_value_for_period(facts, &row, REVENUE_CONCEPTS, latest_xbrl); + let mut cost_of_sales = + duration_value_for_period(facts, &row, COST_OF_SALES_CONCEPTS, latest_xbrl); + let mut gross_profit = + duration_value_for_period(facts, &row, GROSS_PROFIT_CONCEPTS, latest_xbrl); + let selling_general_and_administrative_expenses = + duration_value_for_period(facts, &row, SGA_CONCEPTS, latest_xbrl).or_else(|| { + sum_values( + duration_value_for_period( + facts, + &row, + SELLING_AND_MARKETING_CONCEPTS, + latest_xbrl, + ), + duration_value_for_period( + facts, + &row, + GENERAL_AND_ADMINISTRATIVE_CONCEPTS, + latest_xbrl, + ), + ) + }); + let research_and_development_expenses = + duration_value_for_period(facts, &row, R_AND_D_CONCEPTS, latest_xbrl); + let operating_expenses = + duration_value_for_period(facts, &row, OPERATING_EXPENSES_CONCEPTS, latest_xbrl); + let operating_profit = + duration_value_for_period(facts, &row, OPERATING_INCOME_CONCEPTS, latest_xbrl); + let non_operating_income = duration_value_for_period( + facts, + &row, + OTHER_NON_OPERATING_INCOME_CONCEPTS, + latest_xbrl, + ); + let mut total_non_operating_income = duration_value_for_period( + facts, + &row, + TOTAL_NON_OPERATING_INCOME_CONCEPTS, + latest_xbrl, + ); + let income_before_provision_for_income_taxes = + duration_value_for_period(facts, &row, PRETAX_INCOME_CONCEPTS, latest_xbrl); + let provision_for_income_taxes = duration_value_for_period( + facts, + &row, + PROVISION_FOR_INCOME_TAXES_CONCEPTS, + latest_xbrl, + ); + let consolidated_net_income = + duration_value_for_period(facts, &row, NET_INCOME_CONCEPTS, latest_xbrl); + let net_income_attributable_to_common_shareholders = duration_value_for_period( + facts, + &row, + COMMON_SHAREHOLDER_NET_INCOME_CONCEPTS, + latest_xbrl, + ); + let basic_eps = duration_value_for_period(facts, &row, BASIC_EPS_CONCEPTS, latest_xbrl); + let diluted_eps = + duration_value_for_period(facts, &row, DILUTED_EPS_CONCEPTS, latest_xbrl); + let basic_weighted_average_shares_outstanding = + duration_value_for_period(facts, &row, BASIC_SHARES_CONCEPTS, latest_xbrl); + let total_shares_outstanding = instant_value_for_period( + facts, + &row, + TOTAL_SHARES_OUTSTANDING_CONCEPTS, + latest_xbrl, + ); + let diluted_weighted_average_shares_outstanding = + duration_value_for_period(facts, &row, DILUTED_SHARES_CONCEPTS, latest_xbrl); + let depreciation_and_amortization = duration_value_for_period( + facts, + &row, + DEPRECIATION_AND_AMORTIZATION_CONCEPTS, + latest_xbrl, + ); + let direct_effective_tax_rate = normalize_percent_value(value_for_period_with_overlay( + facts, + &row, + EFFECTIVE_TAX_RATE_CONCEPTS, + latest_xbrl, + OverlayPeriodKind::Duration, + )); + + if cost_of_sales.is_none() { + cost_of_sales = difference(total_revenues, gross_profit); + } + if gross_profit.is_none() { + gross_profit = difference(total_revenues, cost_of_sales); + } + + if total_non_operating_income.is_none() { + total_non_operating_income = + difference(income_before_provision_for_income_taxes, operating_profit); + } + + let other_operating_expenses = match operating_expenses { + Some(operating_expenses) => subtract_many( + Some(operating_expenses), + [ + selling_general_and_administrative_expenses, + research_and_development_expenses, + ], + ), + None => subtract_many( + difference(gross_profit, operating_profit), + [ + selling_general_and_administrative_expenses, + research_and_development_expenses, + ], + ), + }; + + let gross_profit_margin = calculate_ratio_percent(gross_profit, total_revenues); + let operating_margin = calculate_ratio_percent(operating_profit, total_revenues); + let ebitda = sum_values(operating_profit, depreciation_and_amortization); + let effective_tax_rate = direct_effective_tax_rate.or_else(|| { + calculate_ratio_percent( + provision_for_income_taxes, + income_before_provision_for_income_taxes, + ) + }); + let display_label = earnings_display_label_for_values( + row.fiscal_year.as_deref(), + row.fiscal_period.as_deref(), + &row.period_end, + frequency, + ); + EarningsPeriod { - label: row.label.clone(), + label: display_label.clone(), + display_label, fiscal_year: row.fiscal_year.clone(), fiscal_period: row.fiscal_period.clone(), period_start: row.period_start.clone(), period_end: row.period_end.clone(), filed_date: row.filed_date.clone(), form: row.form.clone(), - revenue: value_for_period(facts, &row, REVENUE_CONCEPTS, latest_xbrl), - net_income: value_for_period(facts, &row, NET_INCOME_CONCEPTS, latest_xbrl), - basic_eps: value_for_period(facts, &row, BASIC_EPS_CONCEPTS, latest_xbrl), - diluted_eps: value_for_period(facts, &row, DILUTED_EPS_CONCEPTS, latest_xbrl), - diluted_weighted_average_shares: value_for_period( - facts, - &row, - DILUTED_SHARES_CONCEPTS, - latest_xbrl, - ), + total_revenues, + total_revenues_yoy_change_percent: None, + cost_of_sales, + gross_profit, + gross_profit_margin, + selling_general_and_administrative_expenses, + research_and_development_expenses, + other_operating_expenses, + operating_profit, + operating_margin, + non_operating_income, + total_non_operating_income, + income_before_provision_for_income_taxes, + provision_for_income_taxes, + consolidated_net_income, + net_income_attributable_to_common_shareholders, + total_shares_outstanding, + basic_weighted_average_shares_outstanding, + diluted_weighted_average_shares_outstanding, + ebitda, + effective_tax_rate, + revenue: total_revenues, + net_income: consolidated_net_income, + basic_eps, + diluted_eps, + diluted_weighted_average_shares: diluted_weighted_average_shares_outstanding, revenue_yoy_change_percent: None, diluted_eps_yoy_change_percent: None, } @@ -440,8 +796,9 @@ pub(crate) fn build_earnings_periods( let reference = periods.get(index + offset).cloned(); if let (Some(current), Some(previous)) = (periods.get(index).cloned(), reference) { if let Some(period) = periods.get_mut(index) { - period.revenue_yoy_change_percent = - calculate_change_percent(current.revenue, previous.revenue); + period.total_revenues_yoy_change_percent = + calculate_change_percent(current.total_revenues, previous.total_revenues); + period.revenue_yoy_change_percent = period.total_revenues_yoy_change_percent; period.diluted_eps_yoy_change_percent = calculate_change_percent(current.diluted_eps, previous.diluted_eps); } @@ -457,17 +814,14 @@ pub(crate) fn build_source_status( match latest_xbrl { Ok(Some(_)) => SourceStatus { companyfacts_used: true, - latest_xbrl_parsed: true, degraded_reason: None, }, Ok(None) => SourceStatus { companyfacts_used: true, - latest_xbrl_parsed: false, degraded_reason: Some("Latest filing XBRL instance was unavailable.".to_string()), }, Err(error) => SourceStatus { companyfacts_used: true, - latest_xbrl_parsed: false, degraded_reason: Some(error.to_string()), }, } @@ -657,6 +1011,12 @@ fn duration_days(fact: &NormalizedFact) -> Option { Some((end - start).num_days().abs()) } +fn month_abbreviation(period_end: &str) -> Option { + NaiveDate::parse_from_str(period_end, "%Y-%m-%d") + .ok() + .map(|date| date.format("%b").to_string()) +} + fn concept_match(candidates: &[ConceptCandidate], fact: &NormalizedFact) -> bool { candidates.iter().any(|candidate| { candidate.taxonomy == fact.taxonomy @@ -670,6 +1030,46 @@ fn value_for_period( row: &PeriodRow, concepts: &[ConceptCandidate], latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Option { + value_for_period_with_overlay(facts, row, concepts, latest_xbrl, OverlayPeriodKind::Any) +} + +fn duration_value_for_period( + facts: &[NormalizedFact], + row: &PeriodRow, + concepts: &[ConceptCandidate], + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Option { + value_for_period_with_overlay( + facts, + row, + concepts, + latest_xbrl, + OverlayPeriodKind::Duration, + ) +} + +fn instant_value_for_period( + facts: &[NormalizedFact], + row: &PeriodRow, + concepts: &[ConceptCandidate], + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Option { + value_for_period_with_overlay( + facts, + row, + concepts, + latest_xbrl, + OverlayPeriodKind::Instant, + ) +} + +fn value_for_period_with_overlay( + facts: &[NormalizedFact], + row: &PeriodRow, + concepts: &[ConceptCandidate], + latest_xbrl: Option<&ParsedXbrlDocument>, + overlay_period_kind: OverlayPeriodKind, ) -> Option { let mut best = facts .iter() @@ -684,7 +1084,7 @@ fn value_for_period( .map(|fact| fact.value); if let Some(latest_xbrl) = latest_xbrl { - if let Some(value) = overlay_xbrl_value(latest_xbrl, concepts, &row.period_end) { + if let Some(value) = overlay_xbrl_value(latest_xbrl, row, concepts, overlay_period_kind) { best = Some(value); } } @@ -692,15 +1092,34 @@ fn value_for_period( best } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OverlayPeriodKind { + Any, + Duration, + Instant, +} + fn overlay_xbrl_value( latest_xbrl: &ParsedXbrlDocument, + row: &PeriodRow, concepts: &[ConceptCandidate], - period_end: &str, + overlay_period_kind: OverlayPeriodKind, ) -> Option { latest_xbrl .facts .iter() - .filter(|fact| fact.period_end.as_deref() == Some(period_end)) + .filter(|fact| !fact.has_dimensions) + .filter(|fact| fact.period_end.as_deref() == Some(row.period_end.as_str())) + .filter(|fact| match overlay_period_kind { + OverlayPeriodKind::Any => true, + OverlayPeriodKind::Duration => { + !fact.is_instant + && row.period_start.as_deref().is_none_or(|period_start| { + fact.period_start.as_deref() == Some(period_start) + }) + } + OverlayPeriodKind::Instant => fact.is_instant, + }) .find(|fact| { concepts.iter().any(|candidate| { fact.concept @@ -711,6 +1130,40 @@ fn overlay_xbrl_value( .map(|fact| fact.value) } +fn difference(left: Option, right: Option) -> Option { + Some(left? - right?) +} + +fn sum_values(left: Option, right: Option) -> Option { + Some(left? + right?) +} + +fn subtract_many(base: Option, values: [Option; N]) -> Option { + let mut remainder = base?; + for value in values { + remainder -= value?; + } + Some(remainder) +} + +fn calculate_ratio_percent(numerator: Option, denominator: Option) -> Option { + let numerator = numerator?; + let denominator = denominator?; + if denominator.abs() < f64::EPSILON { + return None; + } + Some((numerator / denominator) * 100.0) +} + +fn normalize_percent_value(value: Option) -> Option { + let value = value?; + if value.abs() <= 1.0 { + Some(value * 100.0) + } else { + Some(value) + } +} + fn classify_dividend_frequency(current_end: Option<&str>, previous_end: Option<&str>) -> String { let Some(current_end) = current_end else { return "unknown".to_string(); @@ -744,3 +1197,391 @@ fn calculate_change_percent(current: Option, previous: Option) -> Opti } Some(((current - previous) / previous.abs()) * 100.0) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::sec_edgar::types::LatestXbrlFact; + use crate::terminal::Frequency; + + #[test] + fn build_earnings_periods_should_backfill_cost_of_sales_and_gross_profit_when_missing() { + let facts = vec![ + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 100.0, + "2025", + "2025-01-01", + "2025-12-31", + ), + duration_fact( + "us-gaap", + "GrossProfit", + 40.0, + "2025", + "2025-01-01", + "2025-12-31", + ), + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 80.0, + "2024", + "2024-01-01", + "2024-12-31", + ), + duration_fact( + "us-gaap", + "CostOfRevenue", + 48.0, + "2024", + "2024-01-01", + "2024-12-31", + ), + ]; + + let periods = build_earnings_periods(&facts, Frequency::Annual, None); + let latest = &periods[0]; + + assert_eq!(latest.total_revenues, Some(100.0)); + assert_eq!(latest.cost_of_sales, Some(60.0)); + assert_eq!(latest.gross_profit, Some(40.0)); + assert_eq!(latest.gross_profit_margin, Some(40.0)); + assert_eq!(latest.revenue, latest.total_revenues); + assert_eq!(latest.total_revenues_yoy_change_percent, Some(25.0)); + assert_eq!( + latest.revenue_yoy_change_percent, + latest.total_revenues_yoy_change_percent + ); + } + + #[test] + fn build_earnings_periods_should_compute_other_operating_expenses_and_tax_metrics() { + let facts = vec![ + duration_fact("us-gaap", "RevenueFromContractWithCustomerExcludingAssessedTax", 100.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "GrossProfit", 70.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "OperatingIncomeLoss", 30.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "SellingGeneralAndAdministrativeExpense", 20.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "ResearchAndDevelopmentExpense", 5.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest", 32.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "IncomeTaxExpenseBenefit", 8.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "NetIncomeLoss", 24.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "DepreciationAndAmortization", 4.0, "2025", "2025-01-01", "2025-12-31"), + duration_fact("us-gaap", "RevenueFromContractWithCustomerExcludingAssessedTax", 90.0, "2024", "2024-01-01", "2024-12-31"), + ]; + + let periods = build_earnings_periods(&facts, Frequency::Annual, None); + let latest = &periods[0]; + + assert_eq!(latest.other_operating_expenses, Some(15.0)); + assert_eq!(latest.total_non_operating_income, Some(2.0)); + assert_eq!(latest.operating_margin, Some(30.0)); + assert_eq!(latest.effective_tax_rate, Some(25.0)); + assert_eq!(latest.ebitda, Some(34.0)); + assert_eq!(latest.consolidated_net_income, Some(24.0)); + assert_eq!(latest.net_income, latest.consolidated_net_income); + } + + #[test] + fn build_earnings_periods_should_normalize_direct_tax_rate_ratio_values() { + let facts = vec![ + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 100.0, + "2025", + "2025-01-01", + "2025-12-31", + ), + pure_duration_fact( + "us-gaap", + "EffectiveIncomeTaxRateContinuingOperations", + 0.215, + "2025", + "2025-01-01", + "2025-12-31", + ), + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 90.0, + "2024", + "2024-01-01", + "2024-12-31", + ), + ]; + + let periods = build_earnings_periods(&facts, Frequency::Annual, None); + + assert_eq!(periods[0].effective_tax_rate, Some(21.5)); + } + + #[test] + fn overlay_xbrl_value_should_ignore_dimensional_facts_for_duration_metrics() { + let row = sample_period_row(); + let latest_xbrl = ParsedXbrlDocument { + facts: vec![ + xbrl_duration_fact( + "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax", + 999.0, + "2025-01-01", + "2025-12-31", + true, + ), + xbrl_duration_fact( + "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax", + 110.0, + "2025-01-01", + "2025-12-31", + false, + ), + ], + }; + + let value = overlay_xbrl_value( + &latest_xbrl, + &row, + REVENUE_CONCEPTS, + OverlayPeriodKind::Duration, + ); + + assert_eq!(value, Some(110.0)); + } + + #[test] + fn overlay_xbrl_value_should_match_duration_facts_on_start_and_end_dates() { + let row = sample_period_row(); + let latest_xbrl = ParsedXbrlDocument { + facts: vec![ + xbrl_duration_fact( + "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax", + 999.0, + "2024-01-01", + "2025-12-31", + false, + ), + xbrl_duration_fact( + "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax", + 110.0, + "2025-01-01", + "2025-12-31", + false, + ), + ], + }; + + let value = overlay_xbrl_value( + &latest_xbrl, + &row, + REVENUE_CONCEPTS, + OverlayPeriodKind::Duration, + ); + + assert_eq!(value, Some(110.0)); + } + + #[test] + fn overlay_xbrl_value_should_require_instant_contexts_for_total_shares_outstanding() { + let row = sample_period_row(); + let latest_xbrl = ParsedXbrlDocument { + facts: vec![ + xbrl_duration_fact( + "dei:EntityCommonStockSharesOutstanding", + 90.0, + "2025-01-01", + "2025-12-31", + false, + ), + xbrl_instant_fact( + "dei:EntityCommonStockSharesOutstanding", + 150.0, + "2025-12-31", + false, + ), + ], + }; + + let value = overlay_xbrl_value( + &latest_xbrl, + &row, + TOTAL_SHARES_OUTSTANDING_CONCEPTS, + OverlayPeriodKind::Instant, + ); + + assert_eq!(value, Some(150.0)); + } + + #[test] + fn build_earnings_period_rows_should_keep_full_annual_history() { + let facts = vec![ + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 140.0, + "2025", + "2025-01-01", + "2025-12-31", + ), + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 130.0, + "2024", + "2024-01-01", + "2024-12-31", + ), + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 120.0, + "2023", + "2023-01-01", + "2023-12-31", + ), + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 110.0, + "2022", + "2022-01-01", + "2022-12-31", + ), + duration_fact( + "us-gaap", + "RevenueFromContractWithCustomerExcludingAssessedTax", + 100.0, + "2021", + "2021-01-01", + "2021-12-31", + ), + ]; + + let rows = build_earnings_period_rows(&facts, Frequency::Annual); + + assert_eq!(rows.len(), 5); + assert_eq!(rows[0].fiscal_year.as_deref(), Some("2025")); + assert_eq!(rows[4].fiscal_year.as_deref(), Some("2021")); + } + + #[test] + fn build_earnings_period_options_should_create_stable_annual_labels() { + let rows = vec![ + PeriodRow { + label: "2024".to_string(), + fiscal_year: Some("2024".to_string()), + fiscal_period: Some("FY".to_string()), + period_start: Some("2024-01-01".to_string()), + period_end: "2024-09-28".to_string(), + filed_date: "2024-11-01".to_string(), + form: "10-K".to_string(), + }, + PeriodRow { + label: "2023".to_string(), + fiscal_year: Some("2023".to_string()), + fiscal_period: Some("FY".to_string()), + period_start: Some("2023-01-01".to_string()), + period_end: "2023-09-30".to_string(), + filed_date: "2023-11-01".to_string(), + form: "10-K".to_string(), + }, + ]; + + let options = build_earnings_period_options(&rows, Frequency::Annual); + + assert_eq!(options[0].id, "2023"); + assert_eq!(options[1].id, "2024"); + assert_eq!(options[1].label, "FY2024 (Sep)"); + } + + fn duration_fact( + taxonomy: &'static str, + concept: &str, + value: f64, + fiscal_year: &str, + period_start: &str, + period_end: &str, + ) -> NormalizedFact { + NormalizedFact { + taxonomy, + concept: concept.to_string(), + unit_family: UnitFamily::Currency, + value, + filed: "2026-01-31".to_string(), + form: "10-K".to_string(), + fiscal_year: Some(fiscal_year.to_string()), + fiscal_period: Some("FY".to_string()), + period_start: Some(period_start.to_string()), + period_end: period_end.to_string(), + accession_number: Some(format!("{fiscal_year}-000001")), + } + } + + fn pure_duration_fact( + taxonomy: &'static str, + concept: &str, + value: f64, + fiscal_year: &str, + period_start: &str, + period_end: &str, + ) -> NormalizedFact { + NormalizedFact { + unit_family: UnitFamily::Pure, + ..duration_fact( + taxonomy, + concept, + value, + fiscal_year, + period_start, + period_end, + ) + } + } + + fn sample_period_row() -> PeriodRow { + PeriodRow { + label: "2025 FY".to_string(), + fiscal_year: Some("2025".to_string()), + fiscal_period: Some("FY".to_string()), + period_start: Some("2025-01-01".to_string()), + period_end: "2025-12-31".to_string(), + filed_date: "2026-01-31".to_string(), + form: "10-K".to_string(), + } + } + + fn xbrl_duration_fact( + concept: &str, + value: f64, + period_start: &str, + period_end: &str, + has_dimensions: bool, + ) -> LatestXbrlFact { + LatestXbrlFact { + concept: concept.to_string(), + value, + context_ref: "ctx-duration".to_string(), + period_start: Some(period_start.to_string()), + period_end: Some(period_end.to_string()), + is_instant: false, + has_dimensions, + } + } + + fn xbrl_instant_fact( + concept: &str, + value: f64, + period_end: &str, + has_dimensions: bool, + ) -> LatestXbrlFact { + LatestXbrlFact { + concept: concept.to_string(), + value, + context_ref: "ctx-instant".to_string(), + period_start: None, + period_end: Some(period_end.to_string()), + is_instant: true, + has_dimensions, + } + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs index d30b745..135d608 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs @@ -1,4 +1,5 @@ mod client; +mod earnings; mod facts; mod service; mod types; @@ -6,3 +7,4 @@ mod xbrl; pub(crate) use client::{LiveSecFetcher, SecEdgarClient, SecUserAgentProvider}; pub(crate) use service::{EdgarDataLookup, EdgarLookupError, SecEdgarLookup}; +pub(crate) use types::{EarningsPeriodKey, EarningsQuery, EarningsRange}; diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs index 1cdd749..ff92f0d 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs @@ -9,12 +9,12 @@ use crate::terminal::{ }; use super::client::SecEdgarClient; +use super::earnings::{build_panel_data as build_earnings_panel_data, load_earnings_data}; use super::facts::{ - build_cash_flow_periods, build_dividend_events, build_earnings_periods, build_source_status, - build_statement_periods, company_name, limit_dividend_ttm, normalize_all_facts, - select_latest_filing, + build_cash_flow_periods, build_dividend_events, build_source_status, build_statement_periods, + company_name, limit_dividend_ttm, normalize_all_facts, select_latest_filing, }; -use super::types::CompanySubmissions; +use super::types::{CompanySubmissions, EarningsQuery}; use super::xbrl::pick_instance_document; #[derive(Debug, Clone, PartialEq, Eq)] @@ -28,6 +28,9 @@ pub(crate) enum EdgarLookupError { ticker: String, frequency: Frequency, }, + InvalidPeriodRange { + detail: String, + }, RequestFailed { provider: &'static str, detail: String, @@ -53,6 +56,7 @@ impl Display for EdgarLookupError { Self::NoFactsAvailable => { formatter.write_str("SEC companyfacts did not contain matching disclosures.") } + Self::InvalidPeriodRange { detail } => formatter.write_str(detail), Self::NoEligibleFilings { ticker, frequency } => write!( formatter, "No eligible {} filings were found for {ticker}.", @@ -86,7 +90,7 @@ pub(crate) trait EdgarDataLookup: Send + Sync { fn earnings<'a>( &'a self, ticker: &'a str, - frequency: Frequency, + query: EarningsQuery, ) -> BoxFuture<'a, Result>; } @@ -261,34 +265,18 @@ impl EdgarDataLookup for SecEdgarLookup { fn earnings<'a>( &'a self, ticker: &'a str, - frequency: Frequency, + query: EarningsQuery, ) -> BoxFuture<'a, Result> { Box::pin(async move { - let context = self.context_for(ticker, frequency).await?; - let facts = normalize_all_facts(&context.companyfacts)?; - let latest_xbrl = self - .latest_xbrl(&context.company.cik, &context.latest_filing) - .await; - let status = build_source_status(latest_xbrl.as_ref().map(|value| value.as_ref())); - let periods = build_earnings_periods( - &facts, - frequency, - latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()), - ); - - Ok(EarningsPanelData { - symbol: context.company.ticker.clone(), - company_name: company_name( - &context.company, - Some(&context.submissions.name), - Some(&context.companyfacts.entity_name), - ), - cik: context.company.cik.clone(), - frequency, - periods, - latest_filing: Some(context.latest_filing), - source_status: status, - }) + let company = self.client.resolve_company(ticker).await?; + let submissions = self.client.load_submissions(&company.cik).await?; + let data = load_earnings_data(&self.client, &company, &submissions, &query).await?; + Ok(build_earnings_panel_data( + &company, + &submissions, + &query, + data, + )) }) } } @@ -309,6 +297,7 @@ mod tests { use super::{EdgarDataLookup, EdgarLookupError, SecEdgarLookup}; use crate::terminal::sec_edgar::client::{SecEdgarClient, SecFetch}; + use crate::terminal::sec_edgar::{EarningsPeriodKey, EarningsQuery, EarningsRange}; use crate::terminal::Frequency; const TICKERS_URL: &str = "https://www.sec.gov/files/company_tickers.json"; @@ -438,6 +427,48 @@ mod tests { })))) } + fn earnings_lookup() -> SecEdgarLookup { + let mut text = HashMap::new(); + let mut bytes = HashMap::new(); + + text.insert( + TICKERS_URL.to_string(), + serde_json::json!({ + "0": { + "cik_str": 1234567, + "ticker": "EARN", + "title": "Earnings Annual Inc." + }, + "1": { + "cik_str": 7654321, + "ticker": "QEM", + "title": "Quarterly Earnings Inc." + } + }) + .to_string(), + ); + + add_earnings_company( + &mut text, + &mut bytes, + "0001234567", + "Earnings Annual Inc.", + &annual_earnings_filings(), + ); + add_earnings_company( + &mut text, + &mut bytes, + "0007654321", + "Quarterly Earnings Inc.", + &quarterly_earnings_filings(), + ); + + SecEdgarLookup::new(Arc::new(SecEdgarClient::new(Box::new(FixtureFetcher { + text, + bytes, + })))) + } + struct CompanyFixture<'a> { cik: &'a str, accession_number: &'a str, @@ -483,13 +514,363 @@ mod tests { ); } + #[derive(Clone)] + struct SyntheticEarningsFiling { + accession_number: &'static str, + filing_date: &'static str, + report_date: &'static str, + form: &'static str, + primary_document: &'static str, + xml_name: &'static str, + xml: String, + } + + fn add_earnings_company( + text: &mut HashMap, + bytes: &mut HashMap>, + cik: &str, + name: &str, + filings: &[SyntheticEarningsFiling], + ) { + text.insert( + format!("https://data.sec.gov/submissions/CIK{cik}.json"), + serde_json::json!({ + "name": name, + "filings": { + "recent": { + "accessionNumber": filings.iter().map(|filing| filing.accession_number).collect::>(), + "filingDate": filings.iter().map(|filing| filing.filing_date).collect::>(), + "reportDate": filings.iter().map(|filing| Some(filing.report_date)).collect::>(), + "form": filings.iter().map(|filing| filing.form).collect::>(), + "primaryDocument": filings.iter().map(|filing| filing.primary_document).collect::>() + }, + "files": [] + } + }) + .to_string(), + ); + + for filing in filings { + text.insert( + format!( + "https://www.sec.gov/Archives/edgar/data/{}/{}/index.json", + cik.trim_start_matches('0'), + filing.accession_number.replace('-', "") + ), + serde_json::json!({ + "directory": { + "item": [ + { + "name": filing.xml_name + } + ] + } + }) + .to_string(), + ); + bytes.insert( + format!( + "https://www.sec.gov/Archives/edgar/data/{}/{}/{}", + cik.trim_start_matches('0'), + filing.accession_number.replace('-', ""), + filing.xml_name + ), + filing.xml.as_bytes().to_vec(), + ); + } + } + + fn annual_earnings_filings() -> Vec { + vec![ + synthetic_annual_filing( + "0001234567-24-000004", + "2024-11-01", + "2024-09-28", + 2024, + "2023-09-30", + "2024-09-28", + 300.0, + 90.0, + 120.0, + 3.0, + ), + synthetic_annual_filing( + "0001234567-23-000003", + "2023-11-03", + "2023-09-30", + 2023, + "2022-10-01", + "2023-09-30", + 280.0, + 84.0, + 110.0, + 2.8, + ), + synthetic_annual_filing( + "0001234567-22-000002", + "2022-11-04", + "2022-09-24", + 2022, + "2021-09-26", + "2022-09-24", + 260.0, + 78.0, + 100.0, + 2.6, + ), + synthetic_annual_filing( + "0001234567-21-000001", + "2021-10-29", + "2021-09-25", + 2021, + "2020-09-27", + "2021-09-25", + 240.0, + 72.0, + 96.0, + 2.4, + ), + ] + } + + fn quarterly_earnings_filings() -> Vec { + vec![ + synthetic_quarterly_filing( + "0007654321-25-000008", + "2025-05-30", + "2025-04-27", + 2026, + "Q1", + "2025-01-27", + "2025-04-27", + Some("2024-10-28"), + 100.0, + 30.0, + 40.0, + 1.0, + ), + synthetic_quarterly_filing( + "0007654321-25-000007", + "2025-02-28", + "2025-01-26", + 2025, + "Q4", + "2024-10-28", + "2025-01-26", + Some("2024-01-29"), + 92.0, + 28.0, + 36.0, + 0.92, + ), + synthetic_quarterly_filing( + "0007654321-24-000006", + "2024-11-27", + "2024-10-27", + 2025, + "Q3", + "2024-07-29", + "2024-10-27", + Some("2024-01-29"), + 84.0, + 24.0, + 32.0, + 0.84, + ), + synthetic_quarterly_filing( + "0007654321-24-000005", + "2024-08-28", + "2024-07-28", + 2025, + "Q2", + "2024-04-29", + "2024-07-28", + Some("2024-01-29"), + 76.0, + 22.0, + 29.0, + 0.76, + ), + synthetic_quarterly_filing( + "0007654321-24-000004", + "2024-05-29", + "2024-04-28", + 2025, + "Q1", + "2024-01-29", + "2024-04-28", + Some("2023-10-30"), + 25.0, + 7.0, + 10.0, + 0.25, + ), + synthetic_quarterly_filing( + "0007654321-24-000003", + "2024-02-28", + "2024-01-28", + 2024, + "Q4", + "2023-10-30", + "2024-01-28", + Some("2023-01-30"), + 23.0, + 6.5, + 9.0, + 0.23, + ), + synthetic_quarterly_filing( + "0007654321-23-000002", + "2023-11-29", + "2023-10-29", + 2024, + "Q3", + "2023-07-31", + "2023-10-29", + Some("2023-01-30"), + 21.0, + 6.0, + 8.0, + 0.21, + ), + synthetic_quarterly_filing( + "0007654321-23-000001", + "2023-08-30", + "2023-07-30", + 2024, + "Q2", + "2023-05-01", + "2023-07-30", + Some("2023-01-30"), + 19.0, + 5.0, + 7.0, + 0.19, + ), + ] + } + + fn synthetic_annual_filing( + accession_number: &'static str, + filing_date: &'static str, + report_date: &'static str, + fiscal_year: i32, + period_start: &'static str, + period_end: &'static str, + revenue: f64, + net_income: f64, + operating_profit: f64, + diluted_eps: f64, + ) -> SyntheticEarningsFiling { + SyntheticEarningsFiling { + accession_number, + filing_date, + report_date, + form: "10-K", + primary_document: "earn-annual.htm", + xml_name: "earn-annual_htm.xml", + xml: synthetic_earnings_instance( + fiscal_year, + "FY", + period_start, + period_end, + None, + revenue, + net_income, + operating_profit, + diluted_eps, + ), + } + } + + fn synthetic_quarterly_filing( + accession_number: &'static str, + filing_date: &'static str, + report_date: &'static str, + fiscal_year: i32, + fiscal_period: &'static str, + period_start: &'static str, + period_end: &'static str, + ytd_start: Option<&'static str>, + revenue: f64, + net_income: f64, + operating_profit: f64, + diluted_eps: f64, + ) -> SyntheticEarningsFiling { + SyntheticEarningsFiling { + accession_number, + filing_date, + report_date, + form: "10-Q", + primary_document: "earn-quarter.htm", + xml_name: "earn-quarter_htm.xml", + xml: synthetic_earnings_instance( + fiscal_year, + fiscal_period, + period_start, + period_end, + ytd_start, + revenue, + net_income, + operating_profit, + diluted_eps, + ), + } + } + + fn synthetic_earnings_instance( + fiscal_year: i32, + fiscal_period: &str, + period_start: &str, + period_end: &str, + ytd_start: Option<&str>, + revenue: f64, + net_income: f64, + operating_profit: f64, + diluted_eps: f64, + ) -> String { + let ytd_context = ytd_start.map(|ytd_start| { + format!( + r#"1{ytd_start}{period_end} + {} + {}"#, + revenue * 2.0, + net_income * 2.0 + ) + }).unwrap_or_default(); + + format!( + r#" + + 1{period_start}{period_end} + 1{period_end} + {ytd_context} + iso4217:USD + xbrli:shares + xbrli:pure + iso4217:USDxbrli:shares + {fiscal_year} + {fiscal_period} + {period_end} + {revenue} + {operating_profit} + {net_income} + {diluted_eps} + {diluted_eps} + 100 + 100 + 101 + 0.21 +"# + ) + } + #[test] fn financials_returns_four_annual_periods() { let data = futures::executor::block_on(lookup().financials("AAPL", Frequency::Annual)) .expect("financials should load"); assert_eq!(data.periods.len(), 4); assert_eq!(data.periods[0].revenue, Some(395000000000.0)); - assert!(data.source_status.latest_xbrl_parsed); } #[test] @@ -524,17 +905,62 @@ mod tests { } #[test] - fn earnings_returns_yoy_deltas() { - let data = futures::executor::block_on(lookup().earnings("NVDA", Frequency::Quarterly)) - .expect("earnings should load"); + fn earnings_returns_filing_native_quarterly_values() { + let data = futures::executor::block_on(earnings_lookup().earnings( + "QEM", + EarningsQuery { + frequency: Frequency::Quarterly, + range: None, + }, + )) + .expect("earnings should load"); assert_eq!(data.periods.len(), 8); - assert_eq!(data.periods[0].revenue, Some(26100000000.0)); + assert_eq!(data.periods[0].revenue, Some(100.0)); + assert_eq!(data.periods[0].total_revenues, Some(100.0)); + assert_eq!(data.periods[0].operating_profit, Some(40.0)); + assert!(!data.available_periods.is_empty()); + assert!(!data.source_status.companyfacts_used); + assert_eq!( + data.available_periods + .last() + .map(|period| period.id.as_str()), + Some("2026Q1") + ); + assert_eq!(data.selected_period_start.as_deref(), Some("2024Q2")); + assert_eq!(data.selected_period_end.as_deref(), Some("2026Q1")); assert!( data.periods[0] .revenue_yoy_change_percent .unwrap_or_default() - > 250.0 + > 299.0 ); + assert_eq!( + data.periods[0].revenue_yoy_change_percent, + data.periods[0].total_revenues_yoy_change_percent + ); + assert_eq!(data.periods[0].display_label, "FY2026 Q1 (Apr)"); + } + + #[test] + fn earnings_supports_explicit_annual_ranges() { + let data = futures::executor::block_on(earnings_lookup().earnings( + "EARN", + EarningsQuery { + frequency: Frequency::Annual, + range: Some(EarningsRange { + start: EarningsPeriodKey::Annual { fiscal_year: 2022 }, + end: EarningsPeriodKey::Annual { fiscal_year: 2024 }, + }), + }, + )) + .expect("earnings should load"); + + assert_eq!(data.frequency, Frequency::Annual); + assert_eq!(data.periods.len(), 3); + assert_eq!(data.selected_period_start.as_deref(), Some("2022")); + assert_eq!(data.selected_period_end.as_deref(), Some("2024")); + assert_eq!(data.periods[0].display_label, "FY2024 (Sep)"); + assert_eq!(data.periods[0].period_end, "2024-09-28"); } #[test] diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs index da4b649..2eac7ae 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::Deserialize; -use crate::terminal::FilingRef; +use crate::terminal::{FilingRef, Frequency}; #[derive(Debug, Clone, Deserialize)] pub(crate) struct TickerDirectoryEntry { @@ -28,6 +28,13 @@ pub(crate) struct CompanySubmissions { #[derive(Debug, Clone, Deserialize)] pub(crate) struct CompanyFilings { pub recent: RecentFilings, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct CompanyFilingsFile { + pub name: String, } #[derive(Debug, Clone, Deserialize)] @@ -123,6 +130,40 @@ pub(crate) enum UnitFamily { Pure, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EarningsPeriodKey { + Annual { fiscal_year: i32 }, + Quarterly { fiscal_year: i32, quarter: u8 }, +} + +impl EarningsPeriodKey { + pub(crate) fn token(&self) -> String { + match self { + Self::Annual { fiscal_year } => fiscal_year.to_string(), + Self::Quarterly { + fiscal_year, + quarter, + } => format!("{fiscal_year}Q{quarter}"), + } + } + + pub(crate) fn sort_key(&self) -> String { + self.token() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EarningsRange { + pub start: EarningsPeriodKey, + pub end: EarningsPeriodKey, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EarningsQuery { + pub frequency: Frequency, + pub range: Option, +} + #[derive(Debug, Clone)] pub(crate) struct NormalizedFact { pub taxonomy: &'static str, @@ -173,7 +214,12 @@ impl PeriodRow { pub(crate) struct LatestXbrlFact { pub concept: String, pub value: f64, + #[allow(dead_code)] + pub context_ref: String, + pub period_start: Option, pub period_end: Option, + pub is_instant: bool, + pub has_dimensions: bool, } #[derive(Debug, Clone, Default)] diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs index 6b5d811..d358fd7 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; -use crabrl::Parser; use quick_xml::events::Event; use quick_xml::Reader; @@ -12,6 +11,7 @@ struct ParsedContext { start: Option, end: Option, instant: Option, + has_dimensions: bool, } pub(crate) fn pick_instance_document(candidates: &[String]) -> Option { @@ -39,12 +39,6 @@ pub(crate) fn pick_instance_document(candidates: &[String]) -> Option { } pub(crate) fn parse_xbrl_instance(bytes: &[u8]) -> Result { - Parser::new() - .parse_bytes(bytes) - .map_err(|source| EdgarLookupError::XbrlParseFailed { - detail: source.to_string(), - })?; - let mut reader = Reader::from_reader(bytes); reader.config_mut().trim_text(true); @@ -70,6 +64,13 @@ pub(crate) fn parse_xbrl_instance(bytes: &[u8]) -> Result Result() { let context = contexts.get(&context_ref).cloned().unwrap_or_default(); facts.push(LatestXbrlFact { - concept: strip_namespace(&name), + concept: name, value, - period_end: context.end.or(context.instant), + context_ref, + period_start: context.start.clone(), + period_end: context.end.clone().or(context.instant.clone()), + is_instant: context.instant.is_some(), + has_dimensions: context.has_dimensions, }); } } @@ -154,11 +159,6 @@ fn attribute_value(element: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> O .find(|attribute| attribute.key.as_ref() == key) .map(|attribute| String::from_utf8_lossy(attribute.value.as_ref()).to_string()) } - -fn strip_namespace(value: &str) -> String { - value.rsplit(':').next().unwrap_or(value).to_string() -} - fn is_fact_candidate(name: &str) -> bool { !name.ends_with("context") && !name.ends_with("unit") diff --git a/MosaicIQ/src-tauri/src/terminal/types.rs b/MosaicIQ/src-tauri/src/terminal/types.rs index 6d9734a..0faf336 100644 --- a/MosaicIQ/src-tauri/src/terminal/types.rs +++ b/MosaicIQ/src-tauri/src/terminal/types.rs @@ -262,7 +262,6 @@ pub struct FilingRef { #[serde(rename_all = "camelCase")] pub struct SourceStatus { pub companyfacts_used: bool, - pub latest_xbrl_parsed: bool, pub degraded_reason: Option, } @@ -321,12 +320,34 @@ pub struct DividendEvent { #[serde(rename_all = "camelCase")] pub struct EarningsPeriod { pub label: String, + pub display_label: String, pub fiscal_year: Option, pub fiscal_period: Option, pub period_start: Option, pub period_end: String, pub filed_date: String, pub form: String, + pub total_revenues: Option, + pub total_revenues_yoy_change_percent: Option, + pub cost_of_sales: Option, + pub gross_profit: Option, + pub gross_profit_margin: Option, + pub selling_general_and_administrative_expenses: Option, + pub research_and_development_expenses: Option, + pub other_operating_expenses: Option, + pub operating_profit: Option, + pub operating_margin: Option, + pub non_operating_income: Option, + pub total_non_operating_income: Option, + pub income_before_provision_for_income_taxes: Option, + pub provision_for_income_taxes: Option, + pub consolidated_net_income: Option, + pub net_income_attributable_to_common_shareholders: Option, + pub total_shares_outstanding: Option, + pub basic_weighted_average_shares_outstanding: Option, + pub diluted_weighted_average_shares_outstanding: Option, + pub ebitda: Option, + pub effective_tax_rate: Option, pub revenue: Option, pub net_income: Option, pub basic_eps: Option, @@ -336,6 +357,18 @@ pub struct EarningsPeriod { pub diluted_eps_yoy_change_percent: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EarningsPeriodOption { + pub id: String, + pub label: String, + pub frequency: Frequency, + pub fiscal_year: Option, + pub fiscal_period: Option, + pub period_end: String, + pub sort_key: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct FinancialsPanelData { @@ -382,6 +415,9 @@ pub struct EarningsPanelData { pub cik: String, pub frequency: Frequency, pub periods: Vec, + pub available_periods: Vec, + pub selected_period_start: Option, + pub selected_period_end: Option, pub latest_filing: Option, pub source_status: SourceStatus, } diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml index fb5c058..c13bf28 100644 --- a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml @@ -1,6 +1,9 @@ - 00003201932023-09-302024-09-28 + 00003201932023-09-302024-09-28 iso4217:USD - 395000000000 + 2024 + FY + 2024-09-28 + 395000000000 diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml index 2089447..c95e466 100644 --- a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml @@ -1,6 +1,9 @@ - - 00003201932024-09-292024-12-28 + + 00003201932024-09-292024-12-28 iso4217:USD - 125000000000 + 2025 + Q1 + 2024-12-28 + 125000000000 diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml index 82ead1f..c30a784 100644 --- a/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml @@ -1,6 +1,9 @@ - - 00000213442024-12-282025-03-28 + + 00000213442024-12-282025-03-28 iso4217:USD/shares - 0.52 + 2025 + Q1 + 2025-03-28 + 0.52 diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml index 9803ff5..b6811e0 100644 --- a/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml @@ -1,6 +1,9 @@ - - 00007890192023-07-012024-06-30 + + 00007890192023-07-012024-06-30 iso4217:USD - 120000000000 + 2024 + FY + 2024-06-30 + 120000000000 diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml index 64b6262..08f2632 100644 --- a/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml @@ -1,6 +1,9 @@ - - 00010458102025-01-272025-04-27 + + 00010458102025-01-272025-04-27 iso4217:USD - 26100000000 + 2026 + Q1 + 2025-04-27 + 26100000000 diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml index bb6e468..1165ec8 100644 --- a/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml @@ -1,6 +1,9 @@ - - 00010001842023-01-012023-12-31 + + 00010001842023-01-012023-12-31 iso4217:USD - 35200000000 + 2023 + FY + 2023-12-31 + 35200000000 diff --git a/MosaicIQ/src/components/Panels/CashFlowPanel.tsx b/MosaicIQ/src/components/Panels/CashFlowPanel.tsx index da7dd4f..1a423ff 100644 --- a/MosaicIQ/src/components/Panels/CashFlowPanel.tsx +++ b/MosaicIQ/src/components/Panels/CashFlowPanel.tsx @@ -10,7 +10,6 @@ const SourceAttribution: React.FC<{ status: CashFlowPanelData['sourceStatus'] }> return (
SEC EDGAR • companyfacts - {status.latestXbrlParsed ? ' • latest filing parsed with crabrl' : ''} {status.degradedReason && (
{status.degradedReason}
)} diff --git a/MosaicIQ/src/components/Panels/DividendsPanel.tsx b/MosaicIQ/src/components/Panels/DividendsPanel.tsx index 5e8a862..a85838d 100644 --- a/MosaicIQ/src/components/Panels/DividendsPanel.tsx +++ b/MosaicIQ/src/components/Panels/DividendsPanel.tsx @@ -10,7 +10,6 @@ const SourceAttribution: React.FC<{ status: DividendsPanelData['sourceStatus'] } return (
SEC EDGAR • companyfacts - {status.latestXbrlParsed ? ' • latest filing parsed with crabrl' : ''} {status.degradedReason && (
{status.degradedReason}
)} diff --git a/MosaicIQ/src/components/Panels/EarningsPanel.test.tsx b/MosaicIQ/src/components/Panels/EarningsPanel.test.tsx new file mode 100644 index 0000000..e6b53c6 --- /dev/null +++ b/MosaicIQ/src/components/Panels/EarningsPanel.test.tsx @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + EarningsPanel, + buildEarningsRangeCommand, + reconcileEarningsRange, +} from './EarningsPanel'; +import type { EarningsPanelData } from '../../types/financial'; + +const makePeriod = ( + periodEnd: string, + overrides: Partial = {}, +): EarningsPanelData['periods'][number] => ({ + label: periodEnd, + displayLabel: "FY2024 Q1 (Mar)", + periodEnd, + filedDate: '2025-08-01', + form: '10-Q', + totalRevenues: 100, + totalRevenuesYoyChangePercent: 5, + costOfSales: 45, + grossProfit: 55, + grossProfitMargin: 55, + sellingGeneralAndAdministrativeExpenses: 10, + researchAndDevelopmentExpenses: 5, + otherOperatingExpenses: undefined, + operatingProfit: 40, + operatingMargin: 40, + nonOperatingIncome: 2, + totalNonOperatingIncome: 2, + incomeBeforeProvisionForIncomeTaxes: 42, + provisionForIncomeTaxes: 8, + consolidatedNetIncome: 34, + netIncomeAttributableToCommonShareholders: 33, + basicEps: 1.1, + dilutedEps: 1, + basicWeightedAverageSharesOutstanding: 98, + totalSharesOutstanding: 101, + dilutedWeightedAverageSharesOutstanding: 100, + ebitda: 44, + effectiveTaxRate: 19, + revenue: 100, + netIncome: 34, + dilutedWeightedAverageShares: 100, + revenueYoyChangePercent: 5, + dilutedEpsYoyChangePercent: 4, + ...overrides, +}); + +const samplePanel: EarningsPanelData = { + symbol: 'AAPL', + companyName: 'Apple Inc.', + cik: '0000320193', + frequency: 'annual', + latestFiling: { + accessionNumber: '0000320193-25-000010', + filingDate: '2025-02-01', + form: '10-K', + }, + sourceStatus: { + companyfactsUsed: true, + }, + availablePeriods: [ + { + id: '2021', + label: 'FY2021 (Sep)', + frequency: 'annual', + fiscalYear: '2021', + fiscalPeriod: 'FY', + periodEnd: '2021-09-25', + sortKey: '2021', + }, + { + id: '2022', + label: 'FY2022 (Sep)', + frequency: 'annual', + fiscalYear: '2022', + fiscalPeriod: 'FY', + periodEnd: '2022-09-24', + sortKey: '2022', + }, + { + id: '2023', + label: 'FY2023 (Sep)', + frequency: 'annual', + fiscalYear: '2023', + fiscalPeriod: 'FY', + periodEnd: '2023-09-30', + sortKey: '2023', + }, + { + id: '2024', + label: 'FY2024 (Sep)', + frequency: 'annual', + fiscalYear: '2024', + fiscalPeriod: 'FY', + periodEnd: '2024-09-28', + sortKey: '2024', + }, + ], + selectedPeriodStart: '2021', + selectedPeriodEnd: '2024', + periods: [ + makePeriod('2024-09-28', { + fiscalYear: '2024', + fiscalPeriod: 'FY', + displayLabel: 'FY2024 (Sep)', + }), + makePeriod('2023-09-30', { + fiscalYear: '2023', + fiscalPeriod: 'FY', + displayLabel: 'FY2023 (Sep)', + }), + makePeriod('2022-09-24', { + fiscalYear: '2022', + fiscalPeriod: 'FY', + displayLabel: 'FY2022 (Sep)', + }), + makePeriod('2021-09-25', { + fiscalYear: '2021', + fiscalPeriod: 'FY', + displayLabel: 'FY2021 (Sep)', + }), + ], +}; + +describe('EarningsPanel', () => { + it('renders the expanded income statement metric order', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('Total Revenues'); + expect(html).toContain('Total Revenues %Chg'); + expect(html).toContain('Cost of Sales'); + expect(html).toContain('Gross Profit Margin'); + expect(html).toContain('Operating Profit'); + expect(html).toContain('Effective Tax Rate'); + expect(html.indexOf('Total Revenues')).toBeLessThan( + html.indexOf('Effective Tax Rate'), + ); + }); + + it('renders range selectors and backend-provided annual labels', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('Reported Range'); + expect(html).toContain('Start Period'); + expect(html).toContain('End Period'); + expect(html).toContain('FY2024 (Sep)'); + expect(html).not.toContain('2025 { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Other Operating Expenses'); + expect(html).toContain('—'); + }); + + it('builds canonical /em commands for selected ranges', () => { + expect(buildEarningsRangeCommand('NVDA', 'quarterly', '2024Q1', '2025Q2')).toBe( + '/em NVDA quarterly 2024Q1 2025Q2', + ); + }); + + it('clamps invalid local ranges to an inclusive reported window', () => { + expect( + reconcileEarningsRange('2024', '2022', samplePanel.availablePeriods), + ).toEqual({ + start: '2022', + end: '2022', + }); + }); +}); diff --git a/MosaicIQ/src/components/Panels/EarningsPanel.tsx b/MosaicIQ/src/components/Panels/EarningsPanel.tsx index afc74a1..351afdb 100644 --- a/MosaicIQ/src/components/Panels/EarningsPanel.tsx +++ b/MosaicIQ/src/components/Panels/EarningsPanel.tsx @@ -1,16 +1,56 @@ import React from 'react'; -import { EarningsPanelData } from '../../types/financial'; -import { StatementTableMinimal, formatMoney, formatNumber, formatPercent } from '../ui/StatementTableMinimal'; +import { + EarningsPanelData, + EarningsPeriodOption, + Frequency, +} from '../../types/financial'; +import { + StatementTableMinimal, + formatMoney, + formatNumber, + formatPercent, +} from '../ui/StatementTableMinimal'; interface EarningsPanelProps { data: EarningsPanelData; + onRunCommand?: (command: string) => void; } -const SourceAttribution: React.FC<{ status: EarningsPanelData['sourceStatus'] }> = ({ status }) => { +export const buildEarningsRangeCommand = ( + symbol: string, + frequency: Frequency, + start: string, + end: string, +) => `/em ${symbol} ${frequency} ${start} ${end}`; + +export const reconcileEarningsRange = ( + start: string, + end: string, + periods: EarningsPeriodOption[], +) => { + const periodIndexes = new Map( + periods.map((period, index) => [period.id, index]), + ); + const startIndex = periodIndexes.get(start); + const endIndex = periodIndexes.get(end); + + if (startIndex === undefined || endIndex === undefined) { + return { start, end }; + } + + if (startIndex <= endIndex) { + return { start, end }; + } + + return { start: end, end }; +}; + +const SourceAttribution: React.FC<{ + status: EarningsPanelData['sourceStatus']; +}> = ({ status }) => { return (
- SEC EDGAR • companyfacts - {status.latestXbrlParsed ? ' • latest filing parsed with crabrl' : ''} + SEC EDGAR • filing XBRL {status.degradedReason && (
{status.degradedReason}
)} @@ -18,14 +58,80 @@ const SourceAttribution: React.FC<{ status: EarningsPanelData['sourceStatus'] }> ); }; -export const EarningsPanel: React.FC = ({ data }) => { +export const EarningsPanel: React.FC = ({ + data, + onRunCommand, +}) => { + const [selectedStart, setSelectedStart] = React.useState( + data.selectedPeriodStart ?? '', + ); + const [selectedEnd, setSelectedEnd] = React.useState(data.selectedPeriodEnd ?? ''); + const hasRangeControls = + data.availablePeriods.length > 0 && + Boolean(data.selectedPeriodStart) && + Boolean(data.selectedPeriodEnd); + const displayPeriods = data.periods.map((period) => ({ + ...period, + label: period.displayLabel || period.label, + })); + + React.useEffect(() => { + setSelectedStart(data.selectedPeriodStart ?? ''); + setSelectedEnd(data.selectedPeriodEnd ?? ''); + }, [ + data.frequency, + data.selectedPeriodEnd, + data.selectedPeriodStart, + data.symbol, + ]); + + const commitRange = React.useCallback( + (start: string, end: string) => { + if (!onRunCommand || !start || !end) { + return; + } + + const nextCommand = buildEarningsRangeCommand( + data.symbol, + data.frequency, + start, + end, + ); + onRunCommand(nextCommand); + }, + [data.frequency, data.symbol, onRunCommand], + ); + + const handleStartChange = (nextStart: string) => { + const nextRange = reconcileEarningsRange( + nextStart, + selectedEnd || nextStart, + data.availablePeriods, + ); + setSelectedStart(nextRange.start); + setSelectedEnd(nextRange.end); + commitRange(nextRange.start, nextRange.end); + }; + + const handleEndChange = (nextEnd: string) => { + const nextRange = reconcileEarningsRange( + selectedStart || nextEnd, + nextEnd, + data.availablePeriods, + ); + setSelectedStart(nextRange.start); + setSelectedEnd(nextRange.end); + commitRange(nextRange.start, nextRange.end); + }; + return ( -
- {/* Header - Minimal */} +
-

{data.symbol} Earnings

+

+ {data.symbol} Earnings +

{data.companyName} • {data.frequency}

@@ -41,23 +147,179 @@ export const EarningsPanel: React.FC = ({ data }) => {
- {/* Table - Minimal styling */}
+ {hasRangeControls && ( +
+
+ Reported Range +
+
+ + +
+
+ )} formatMoney(period.revenue) }, - { key: 'netIncome', label: 'Net Income', render: (period) => formatMoney(period.netIncome) }, - { key: 'basicEps', label: 'Basic EPS', render: (period) => formatMoney(period.basicEps) }, - { key: 'dilutedEps', label: 'Diluted EPS', render: (period) => formatMoney(period.dilutedEps) }, - { key: 'shares', label: 'Diluted Shares', render: (period) => formatNumber(period.dilutedWeightedAverageShares) }, - { key: 'revYoy', label: 'Revenue YoY', render: (period) => formatPercent(period.revenueYoyChangePercent) }, - { key: 'epsYoy', label: 'Diluted EPS YoY', render: (period) => formatPercent(period.dilutedEpsYoyChangePercent) }, + { + key: 'totalRevenues', + label: 'Total Revenues', + render: (period) => formatMoney(period.totalRevenues), + }, + { + key: 'totalRevenuesYoyChangePercent', + label: 'Total Revenues %Chg', + render: (period) => + formatPercent(period.totalRevenuesYoyChangePercent), + }, + { + key: 'costOfSales', + label: 'Cost of Sales', + render: (period) => formatMoney(period.costOfSales), + }, + { + key: 'grossProfit', + label: 'Gross Profit', + render: (period) => formatMoney(period.grossProfit), + }, + { + key: 'grossProfitMargin', + label: 'Gross Profit Margin', + render: (period) => formatPercent(period.grossProfitMargin), + }, + { + key: 'sellingGeneralAndAdministrativeExpenses', + label: 'Selling, General & Administrative Expenses', + render: (period) => + formatMoney(period.sellingGeneralAndAdministrativeExpenses), + }, + { + key: 'researchAndDevelopmentExpenses', + label: 'Research & Development Expenses', + render: (period) => + formatMoney(period.researchAndDevelopmentExpenses), + }, + { + key: 'otherOperatingExpenses', + label: 'Other Operating Expenses', + render: (period) => formatMoney(period.otherOperatingExpenses), + }, + { + key: 'operatingProfit', + label: 'Operating Profit', + render: (period) => formatMoney(period.operatingProfit), + }, + { + key: 'operatingMargin', + label: 'Operating Margin', + render: (period) => formatPercent(period.operatingMargin), + }, + { + key: 'nonOperatingIncome', + label: 'Non-Operating Income', + render: (period) => formatMoney(period.nonOperatingIncome), + }, + { + key: 'totalNonOperatingIncome', + label: 'Total Non-Operating Income', + render: (period) => formatMoney(period.totalNonOperatingIncome), + }, + { + key: 'incomeBeforeProvisionForIncomeTaxes', + label: 'Income Before Provision for Income Taxes', + render: (period) => + formatMoney(period.incomeBeforeProvisionForIncomeTaxes), + }, + { + key: 'provisionForIncomeTaxes', + label: 'Provision for Income Taxes', + render: (period) => formatMoney(period.provisionForIncomeTaxes), + }, + { + key: 'consolidatedNetIncome', + label: 'Consolidated Net Income', + render: (period) => formatMoney(period.consolidatedNetIncome), + }, + { + key: 'netIncomeAttributableToCommonShareholders', + label: 'Net Income Attributable to Common Shareholders', + render: (period) => + formatMoney(period.netIncomeAttributableToCommonShareholders), + }, + { + key: 'basicEps', + label: 'Basic EPS', + render: (period) => formatMoney(period.basicEps), + }, + { + key: 'dilutedEps', + label: 'Diluted EPS', + render: (period) => formatMoney(period.dilutedEps), + }, + { + key: 'basicWeightedAverageSharesOutstanding', + label: 'Basic Weighted Average Shares Outstanding', + render: (period) => + formatNumber(period.basicWeightedAverageSharesOutstanding), + }, + { + key: 'totalSharesOutstanding', + label: 'Total Shares Outstanding', + render: (period) => formatNumber(period.totalSharesOutstanding), + }, + { + key: 'dilutedWeightedAverageSharesOutstanding', + label: 'Diluted Weighted Average Shares Outstanding', + render: (period) => + formatNumber(period.dilutedWeightedAverageSharesOutstanding), + }, + { + key: 'ebitda', + label: 'EBITDA', + render: (period) => formatMoney(period.ebitda), + }, + { + key: 'effectiveTaxRate', + label: 'Effective Tax Rate', + render: (period) => formatPercent(period.effectiveTaxRate), + }, ]} />
- {/* Footer - Minimal attribution */}
diff --git a/MosaicIQ/src/components/Panels/FinancialsPanel.tsx b/MosaicIQ/src/components/Panels/FinancialsPanel.tsx index 560e21d..35441a1 100644 --- a/MosaicIQ/src/components/Panels/FinancialsPanel.tsx +++ b/MosaicIQ/src/components/Panels/FinancialsPanel.tsx @@ -10,7 +10,6 @@ const SourceAttribution: React.FC<{ status: FinancialsPanelData['sourceStatus'] return (
SEC EDGAR • companyfacts - {status.latestXbrlParsed ? ' • latest filing parsed with crabrl' : ''} {status.degradedReason && (
{status.degradedReason}
)} diff --git a/MosaicIQ/src/components/Panels/SecPanelChrome.tsx b/MosaicIQ/src/components/Panels/SecPanelChrome.tsx index 5169b31..3536b56 100644 --- a/MosaicIQ/src/components/Panels/SecPanelChrome.tsx +++ b/MosaicIQ/src/components/Panels/SecPanelChrome.tsx @@ -42,7 +42,6 @@ export const SecPanelChrome: React.FC = ({
SEC EDGAR • companyfacts - {sourceStatus.latestXbrlParsed ? ' • latest filing parsed with crabrl' : ''}
{sourceStatus.degradedReason && (
{sourceStatus.degradedReason}
diff --git a/MosaicIQ/src/components/Terminal/TerminalOutput.tsx b/MosaicIQ/src/components/Terminal/TerminalOutput.tsx index 7f42edd..ec51afe 100644 --- a/MosaicIQ/src/components/Terminal/TerminalOutput.tsx +++ b/MosaicIQ/src/components/Terminal/TerminalOutput.tsx @@ -281,7 +281,7 @@ export const TerminalOutput: React.FC = ({ case 'dividends': return ; case 'earnings': - return ; + return ; default: return null; } diff --git a/MosaicIQ/src/lib/terminalCommandSpecs.ts b/MosaicIQ/src/lib/terminalCommandSpecs.ts index 7b6598c..23f9171 100644 --- a/MosaicIQ/src/lib/terminalCommandSpecs.ts +++ b/MosaicIQ/src/lib/terminalCommandSpecs.ts @@ -238,8 +238,22 @@ export const TERMINAL_COMMAND_SPECS: TerminalCommandSpec[] = [ required: false, options: ['annual', 'quarterly'], }, + { + key: 'start', + label: 'Start', + description: 'Optional reported period start token such as 2021 or 2024Q1.', + placeholder: 'start-period', + required: false, + }, + { + key: 'end', + label: 'End', + description: 'Optional reported period end token such as 2024 or 2025Q2.', + placeholder: 'end-period', + required: false, + }, ], - examples: ['/em SAP', '/em SAP quarterly'], + examples: ['/em SAP', '/em SAP quarterly', '/em SAP annual 2021 2024'], }, { command: '/news', diff --git a/MosaicIQ/src/lib/terminalResearchNote.ts b/MosaicIQ/src/lib/terminalResearchNote.ts index 97fb0ab..d08be33 100644 --- a/MosaicIQ/src/lib/terminalResearchNote.ts +++ b/MosaicIQ/src/lib/terminalResearchNote.ts @@ -150,8 +150,17 @@ const summarizeEarnings = (panel: EarningsPanelData, sourceCommand?: string) => const rows = latest ? [ `Latest period: ${latest.label}.`, - latest.revenue != null ? `Revenue: ${latest.revenue.toLocaleString()}.` : null, + latest.totalRevenues != null ? `Revenue: ${latest.totalRevenues.toLocaleString()}.` : null, + latest.operatingProfit != null + ? `Operating profit: ${latest.operatingProfit.toLocaleString()}.` + : null, + latest.consolidatedNetIncome != null + ? `Net income: ${latest.consolidatedNetIncome.toLocaleString()}.` + : null, latest.dilutedEps != null ? `Diluted EPS: ${latest.dilutedEps.toFixed(2)}.` : null, + latest.effectiveTaxRate != null + ? `Effective tax rate: ${latest.effectiveTaxRate.toFixed(1)}%.` + : null, ].filter(Boolean) as string[] : ['No earnings rows loaded.']; return summarizeStatementPanel('earnings history', panel.symbol, panel.latestFiling, rows, sourceCommand); diff --git a/MosaicIQ/src/types/financial.ts b/MosaicIQ/src/types/financial.ts index d5c2c6b..0a1c300 100644 --- a/MosaicIQ/src/types/financial.ts +++ b/MosaicIQ/src/types/financial.ts @@ -144,7 +144,6 @@ export interface FilingRef { export interface SourceStatus { companyfactsUsed: boolean; - latestXbrlParsed: boolean; degradedReason?: string; } @@ -195,12 +194,34 @@ export interface DividendEvent { export interface EarningsPeriod { label: string; + displayLabel: string; fiscalYear?: string; fiscalPeriod?: string; periodStart?: string; periodEnd: string; filedDate: string; form: string; + totalRevenues?: number; + totalRevenuesYoyChangePercent?: number; + costOfSales?: number; + grossProfit?: number; + grossProfitMargin?: number; + sellingGeneralAndAdministrativeExpenses?: number; + researchAndDevelopmentExpenses?: number; + otherOperatingExpenses?: number; + operatingProfit?: number; + operatingMargin?: number; + nonOperatingIncome?: number; + totalNonOperatingIncome?: number; + incomeBeforeProvisionForIncomeTaxes?: number; + provisionForIncomeTaxes?: number; + consolidatedNetIncome?: number; + netIncomeAttributableToCommonShareholders?: number; + totalSharesOutstanding?: number; + basicWeightedAverageSharesOutstanding?: number; + dilutedWeightedAverageSharesOutstanding?: number; + ebitda?: number; + effectiveTaxRate?: number; revenue?: number; netIncome?: number; basicEps?: number; @@ -210,6 +231,16 @@ export interface EarningsPeriod { dilutedEpsYoyChangePercent?: number; } +export interface EarningsPeriodOption { + id: string; + label: string; + frequency: Frequency; + fiscalYear?: string; + fiscalPeriod?: string; + periodEnd: string; + sortKey: string; +} + export interface FinancialsPanelData { symbol: string; companyName: string; @@ -248,6 +279,9 @@ export interface EarningsPanelData { cik: string; frequency: Frequency; periods: EarningsPeriod[]; + availablePeriods: EarningsPeriodOption[]; + selectedPeriodStart?: string; + selectedPeriodEnd?: string; latestFiling?: FilingRef; sourceStatus: SourceStatus; } diff --git a/MosaicIQ/agent.md b/agent.md similarity index 100% rename from MosaicIQ/agent.md rename to agent.md diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/claude.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file