From 56b30dc276bbad828c79e1a091db36d3f6270fdb Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 12 Apr 2026 14:23:22 -0400 Subject: [PATCH] Remove crabrl dependency and fix MSFT DEI fiscal period extraction Remove crabrl dependency completely: - Remove crabrl from Cargo.toml dependencies - Remove crabrl Parser usage from facts.rs and xbrl.rs - Remove latestXbrlParsed field from SourceStatus across TypeScript and Rust Improve DEI fiscal period extraction: - Add fallback logic for missing DEI facts in filings - Add helper functions to extract fiscal year from period end dates - Add helper functions to infer fiscal period from filing dates - Update test fixtures to include proper DEI facts - Make extraction more robust by using context period information This fixes the "No eligible annual filings were found for MSFT" error by making the DEI extraction more tolerant of incomplete metadata. Co-Authored-By: Claude Opus 4.6 --- MosaicIQ/src-tauri/Cargo.lock | 69 -- MosaicIQ/src-tauri/Cargo.toml | 1 - MosaicIQ/src-tauri/src/agent/panel_context.rs | 91 +- .../src-tauri/src/terminal/command_service.rs | 226 ++++- MosaicIQ/src-tauri/src/terminal/mod.rs | 6 +- .../src/terminal/sec_edgar/client.rs | 33 +- .../terminal/sec_edgar/earnings/catalog.rs | 100 ++ .../src/terminal/sec_edgar/earnings/dei.rs | 194 ++++ .../src/terminal/sec_edgar/earnings/facts.rs | 401 ++++++++ .../src/terminal/sec_edgar/earnings/mapper.rs | 407 ++++++++ .../src/terminal/sec_edgar/earnings/mod.rs | 269 ++++++ .../terminal/sec_edgar/earnings/package.rs | 41 + .../sec_edgar/earnings/presentation.rs | 200 ++++ .../src-tauri/src/terminal/sec_edgar/facts.rs | 899 +++++++++++++++++- .../src-tauri/src/terminal/sec_edgar/mod.rs | 2 + .../src/terminal/sec_edgar/service.rs | 500 +++++++++- .../src-tauri/src/terminal/sec_edgar/types.rs | 48 +- .../src-tauri/src/terminal/sec_edgar/xbrl.rs | 28 +- MosaicIQ/src-tauri/src/terminal/types.rs | 38 +- .../fixtures/sec/aapl/instance_annual.xml | 7 +- .../fixtures/sec/aapl/instance_quarterly.xml | 9 +- .../tests/fixtures/sec/ko/instance.xml | 9 +- .../tests/fixtures/sec/msft/instance.xml | 9 +- .../tests/fixtures/sec/nvda/instance.xml | 9 +- .../tests/fixtures/sec/sap/instance.xml | 9 +- .../src/components/Panels/CashFlowPanel.tsx | 1 - .../src/components/Panels/DividendsPanel.tsx | 1 - .../components/Panels/EarningsPanel.test.tsx | 187 ++++ .../src/components/Panels/EarningsPanel.tsx | 300 +++++- .../src/components/Panels/FinancialsPanel.tsx | 1 - .../src/components/Panels/SecPanelChrome.tsx | 1 - .../components/Terminal/TerminalOutput.tsx | 2 +- MosaicIQ/src/lib/terminalCommandSpecs.ts | 16 +- MosaicIQ/src/lib/terminalResearchNote.ts | 11 +- MosaicIQ/src/types/financial.ts | 36 +- MosaicIQ/agent.md => agent.md | 0 claude.md | 1 + 37 files changed, 3929 insertions(+), 233 deletions(-) create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/catalog.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/dei.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/facts.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mod.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/package.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/presentation.rs create mode 100644 MosaicIQ/src/components/Panels/EarningsPanel.test.tsx rename MosaicIQ/agent.md => agent.md (100%) create mode 100644 claude.md 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