From cfc5a615e3dd5ff302fa89b71212a3930af66b67 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 5 Apr 2026 21:11:05 -0400 Subject: [PATCH] feat(terminal): add Google Finance and SEC EDGAR backends --- MosaicIQ/src-tauri/Cargo.lock | 106 +- MosaicIQ/src-tauri/Cargo.toml | 11 +- MosaicIQ/src-tauri/src/agent/mod.rs | 1 + MosaicIQ/src-tauri/src/agent/service.rs | 10 + MosaicIQ/src-tauri/src/agent/settings.rs | 9 + MosaicIQ/src-tauri/src/agent/types.rs | 5 + MosaicIQ/src-tauri/src/commands/terminal.rs | 11 +- MosaicIQ/src-tauri/src/lib.rs | 1 + MosaicIQ/src-tauri/src/state.rs | 41 +- .../src-tauri/src/terminal/command_service.rs | 490 +++++- .../src-tauri/src/terminal/google_finance.rs | 1449 +++++++++++++++++ MosaicIQ/src-tauri/src/terminal/mod.rs | 11 +- .../src/terminal/sec_edgar/client.rs | 390 +++++ .../src-tauri/src/terminal/sec_edgar/facts.rs | 747 +++++++++ .../src-tauri/src/terminal/sec_edgar/mod.rs | 8 + .../src/terminal/sec_edgar/service.rs | 530 ++++++ .../src-tauri/src/terminal/sec_edgar/types.rs | 199 +++ .../src-tauri/src/terminal/sec_edgar/xbrl.rs | 188 +++ .../src-tauri/src/terminal/security_lookup.rs | 247 +++ MosaicIQ/src-tauri/src/terminal/types.rs | 222 ++- .../src-tauri/src/terminal/yahoo_finance.rs | 438 ----- .../tests/fixtures/sec/aapl/companyfacts.json | 1 + .../tests/fixtures/sec/aapl/index_annual.json | 1 + .../fixtures/sec/aapl/index_quarterly.json | 1 + .../fixtures/sec/aapl/instance_annual.xml | 6 + .../fixtures/sec/aapl/instance_quarterly.xml | 6 + .../tests/fixtures/sec/aapl/submissions.json | 1 + .../tests/fixtures/sec/company_tickers.json | 1 + .../tests/fixtures/sec/ko/companyfacts.json | 1 + .../tests/fixtures/sec/ko/index.json | 1 + .../tests/fixtures/sec/ko/instance.xml | 6 + .../tests/fixtures/sec/ko/submissions.json | 1 + .../tests/fixtures/sec/msft/companyfacts.json | 1 + .../tests/fixtures/sec/msft/index.json | 1 + .../tests/fixtures/sec/msft/instance.xml | 6 + .../tests/fixtures/sec/msft/submissions.json | 1 + .../tests/fixtures/sec/nvda/companyfacts.json | 1 + .../tests/fixtures/sec/nvda/index.json | 1 + .../tests/fixtures/sec/nvda/instance.xml | 6 + .../tests/fixtures/sec/nvda/submissions.json | 1 + .../tests/fixtures/sec/sap/companyfacts.json | 1 + .../tests/fixtures/sec/sap/index.json | 1 + .../tests/fixtures/sec/sap/instance.xml | 6 + .../tests/fixtures/sec/sap/submissions.json | 1 + 44 files changed, 4691 insertions(+), 476 deletions(-) create mode 100644 MosaicIQ/src-tauri/src/terminal/google_finance.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs create mode 100644 MosaicIQ/src-tauri/src/terminal/security_lookup.rs delete mode 100644 MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/aapl/companyfacts.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_annual.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_quarterly.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/aapl/submissions.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/company_tickers.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/ko/companyfacts.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/ko/index.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/ko/submissions.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/msft/companyfacts.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/msft/index.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/msft/submissions.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/nvda/companyfacts.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/nvda/index.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/nvda/submissions.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/sap/companyfacts.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/sap/index.json create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml create mode 100644 MosaicIQ/src-tauri/tests/fixtures/sec/sap/submissions.json diff --git a/MosaicIQ/src-tauri/Cargo.lock b/MosaicIQ/src-tauri/Cargo.lock index ffbc7ce..37d4d73 100644 --- a/MosaicIQ/src-tauri/Cargo.lock +++ b/MosaicIQ/src-tauri/Cargo.lock @@ -19,6 +19,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -538,6 +551,15 @@ 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" @@ -633,6 +655,20 @@ 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" @@ -754,6 +790,25 @@ 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" @@ -1767,7 +1822,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -2392,6 +2447,16 @@ 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" @@ -2503,6 +2568,15 @@ 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" @@ -2550,7 +2624,12 @@ dependencies = [ name = "mosaiciq" version = "0.1.0" dependencies = [ + "chrono", + "chrono-tz", + "crabrl", "futures", + "quick-xml 0.36.2", + "regex", "reqwest 0.12.28", "rig-core", "serde", @@ -2559,7 +2638,9 @@ dependencies = [ "tauri-build", "tauri-plugin-opener", "tauri-plugin-store", + "thiserror 2.0.18", "tokio", + "urlencoding", "yfinance-rs", ] @@ -3361,7 +3442,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3588,6 +3669,15 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4654,6 +4744,12 @@ 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" @@ -5725,6 +5821,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/MosaicIQ/src-tauri/Cargo.toml b/MosaicIQ/src-tauri/Cargo.toml index 9bef431..f501cca 100644 --- a/MosaicIQ/src-tauri/Cargo.toml +++ b/MosaicIQ/src-tauri/Cargo.toml @@ -24,9 +24,16 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" rig-core = "0.34.0" tauri-plugin-store = "2" -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["time", "sync"] } futures = "0.3" -reqwest = { version = "0.12", features = ["json"] } +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" +urlencoding = "2" yfinance-rs = "0.7.2" [dev-dependencies] diff --git a/MosaicIQ/src-tauri/src/agent/mod.rs b/MosaicIQ/src-tauri/src/agent/mod.rs index cac265b..bcfc656 100644 --- a/MosaicIQ/src-tauri/src/agent/mod.rs +++ b/MosaicIQ/src-tauri/src/agent/mod.rs @@ -8,6 +8,7 @@ mod types; pub use gateway::{ChatGateway, RigChatGateway}; pub use service::AgentService; +pub(crate) use settings::AgentSettingsService; pub use types::{ default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPromptRequest, ChatStreamStart, diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index 9b38a93..bcab993 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -127,6 +127,7 @@ impl AgentService { }; settings.default_remote_model = request.default_remote_model.trim().to_string(); settings.task_defaults = request.task_defaults; + settings.sec_edgar_user_agent = request.sec_edgar_user_agent.trim().to_string(); normalize_routes(&mut settings)?; validate_settings(&settings)?; @@ -158,9 +159,11 @@ impl AgentService { remote_configured: compute_remote_configured(&settings), remote_enabled: settings.remote.enabled, has_remote_api_key: !settings.remote.api_key.trim().is_empty(), + has_sec_edgar_user_agent: !settings.sec_edgar_user_agent.trim().is_empty(), remote_base_url: settings.remote.base_url, default_remote_model: settings.default_remote_model, task_defaults: settings.task_defaults, + sec_edgar_user_agent: settings.sec_edgar_user_agent, } } @@ -297,11 +300,14 @@ mod tests { remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), + sec_edgar_user_agent: "MosaicIQ admin@example.com".to_string(), }) .unwrap(); assert_eq!(saved.remote_base_url, "https://example.test/v4"); assert_eq!(saved.default_remote_model, "glm-test"); assert!(!saved.has_remote_api_key); + assert!(saved.has_sec_edgar_user_agent); + assert_eq!(saved.sec_edgar_user_agent, "MosaicIQ admin@example.com"); let updated = service .update_remote_api_key(UpdateRemoteApiKeyRequest { @@ -338,6 +344,7 @@ mod tests { remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), + sec_edgar_user_agent: String::new(), }) .unwrap(); @@ -373,6 +380,7 @@ mod tests { remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), + sec_edgar_user_agent: String::new(), }) .unwrap(); service @@ -414,6 +422,7 @@ mod tests { remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults, + sec_edgar_user_agent: String::new(), }) .unwrap(); @@ -435,6 +444,7 @@ mod tests { remote_base_url: "https://example.test/v4".to_string(), default_remote_model: "glm-test".to_string(), task_defaults: default_task_defaults("glm-test"), + sec_edgar_user_agent: String::new(), }) .unwrap(); diff --git a/MosaicIQ/src-tauri/src/agent/settings.rs b/MosaicIQ/src-tauri/src/agent/settings.rs index 7194515..4b2e93d 100644 --- a/MosaicIQ/src-tauri/src/agent/settings.rs +++ b/MosaicIQ/src-tauri/src/agent/settings.rs @@ -13,6 +13,7 @@ const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl"; const REMOTE_API_KEY_KEY: &str = "remoteApiKey"; const DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel"; const TASK_DEFAULTS_KEY: &str = "taskDefaults"; +const SEC_EDGAR_USER_AGENT_KEY: &str = "secEdgarUserAgent"; const LEGACY_BASE_URL_KEY: &str = "baseUrl"; const LEGACY_MODEL_KEY: &str = "model"; const LEGACY_API_KEY_KEY: &str = "apiKey"; @@ -81,6 +82,10 @@ impl AgentSettingsService { }, default_remote_model, task_defaults, + sec_edgar_user_agent: store + .get(SEC_EDGAR_USER_AGENT_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_default(), }) } @@ -119,6 +124,10 @@ impl AgentSettingsService { json!(settings.default_remote_model), ); store.set(TASK_DEFAULTS_KEY.to_string(), json!(settings.task_defaults)); + store.set( + SEC_EDGAR_USER_AGENT_KEY.to_string(), + json!(settings.sec_edgar_user_agent), + ); store.delete(LOCAL_ENABLED_KEY); store.delete(LOCAL_BASE_URL_KEY); diff --git a/MosaicIQ/src-tauri/src/agent/types.rs b/MosaicIQ/src-tauri/src/agent/types.rs index a6d4680..dac000a 100644 --- a/MosaicIQ/src-tauri/src/agent/types.rs +++ b/MosaicIQ/src-tauri/src/agent/types.rs @@ -94,6 +94,7 @@ pub struct AgentStoredSettings { pub remote: RemoteProviderSettings, pub default_remote_model: String, pub task_defaults: HashMap, + pub sec_edgar_user_agent: String, } impl Default for AgentStoredSettings { @@ -102,6 +103,7 @@ impl Default for AgentStoredSettings { remote: RemoteProviderSettings::default(), default_remote_model: DEFAULT_REMOTE_MODEL.to_string(), task_defaults: default_task_defaults(DEFAULT_REMOTE_MODEL), + sec_edgar_user_agent: String::new(), } } } @@ -114,9 +116,11 @@ pub struct AgentConfigStatus { pub remote_configured: bool, pub remote_enabled: bool, pub has_remote_api_key: bool, + pub has_sec_edgar_user_agent: bool, pub remote_base_url: String, pub default_remote_model: String, pub task_defaults: HashMap, + pub sec_edgar_user_agent: String, } /// Request payload for updating persisted non-secret settings. @@ -127,6 +131,7 @@ pub struct SaveAgentRuntimeConfigRequest { pub remote_base_url: String, pub default_remote_model: String, pub task_defaults: HashMap, + pub sec_edgar_user_agent: String, } /// Request payload for rotating the stored remote API key. diff --git a/MosaicIQ/src-tauri/src/commands/terminal.rs b/MosaicIQ/src-tauri/src/commands/terminal.rs index 540c6bf..1b63c44 100644 --- a/MosaicIQ/src-tauri/src/commands/terminal.rs +++ b/MosaicIQ/src-tauri/src/commands/terminal.rs @@ -8,7 +8,7 @@ use crate::agent::{ ChatStreamStart, }; use crate::state::AppState; -use crate::terminal::{ExecuteTerminalCommandRequest, TerminalCommandResponse}; +use crate::terminal::{Company, ExecuteTerminalCommandRequest, LookupCompanyRequest, TerminalCommandResponse}; /// Executes a slash command and returns either terminal text or a structured panel payload. #[tauri::command] @@ -19,6 +19,15 @@ pub async fn execute_terminal_command( Ok(state.command_service.execute(request).await) } +/// Looks up a live company snapshot directly from the quote provider. +#[tauri::command] +pub async fn lookup_company( + state: tauri::State<'_, AppState>, + request: LookupCompanyRequest, +) -> Result { + state.command_service.lookup_company(&request.symbol).await +} + /// Starts a streaming plain-text chat turn and emits progress over Tauri events. #[tauri::command] pub async fn start_chat_stream( diff --git a/MosaicIQ/src-tauri/src/lib.rs b/MosaicIQ/src-tauri/src/lib.rs index 3924e7b..f727e00 100644 --- a/MosaicIQ/src-tauri/src/lib.rs +++ b/MosaicIQ/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ commands::terminal::execute_terminal_command, + commands::terminal::lookup_company, commands::terminal::start_chat_stream, commands::settings::get_agent_config_status, commands::settings::save_agent_runtime_config, diff --git a/MosaicIQ/src-tauri/src/state.rs b/MosaicIQ/src-tauri/src/state.rs index 917932a..26b34ac 100644 --- a/MosaicIQ/src-tauri/src/state.rs +++ b/MosaicIQ/src-tauri/src/state.rs @@ -5,10 +5,35 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Wry}; -use crate::agent::AgentService; +use crate::agent::{AgentService, AgentSettingsService}; use crate::error::AppError; +use crate::terminal::google_finance::GoogleFinanceLookup; +use crate::terminal::sec_edgar::{ + LiveSecFetcher, SecEdgarClient, SecEdgarLookup, SecUserAgentProvider, +}; use crate::terminal::TerminalCommandService; +struct SettingsBackedSecUserAgentProvider { + settings: AgentSettingsService, +} + +impl SecUserAgentProvider for SettingsBackedSecUserAgentProvider { + fn user_agent(&self) -> Option { + self.settings + .load() + .ok() + .and_then(|settings| { + let value = settings.sec_edgar_user_agent.trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } + }) + .or_else(|| std::env::var("SEC_EDGAR_USER_AGENT").ok()) + } +} + /// Runtime services shared across Tauri commands. pub struct AppState { /// Stateful chat service used for per-session conversation history and agent config. @@ -21,11 +46,19 @@ pub struct AppState { impl AppState { /// Create a new application state for the current Tauri app. pub fn new(app_handle: &AppHandle) -> Result { + let sec_user_agent_provider = Arc::new(SettingsBackedSecUserAgentProvider { + settings: AgentSettingsService::new(app_handle), + }); + let sec_edgar_lookup = Arc::new(SecEdgarLookup::new(Arc::new(SecEdgarClient::new( + Box::new(LiveSecFetcher::new(sec_user_agent_provider)), + )))); + Ok(Self { agent: Mutex::new(AgentService::new(app_handle)?), - command_service: TerminalCommandService::new(Arc::new( - crate::terminal::yahoo_finance::YahooFinanceLookup::default(), - )), + command_service: TerminalCommandService::new( + Arc::new(GoogleFinanceLookup::default()), + sec_edgar_lookup, + ), next_request_id: AtomicU64::new(1), }) } diff --git a/MosaicIQ/src-tauri/src/terminal/command_service.rs b/MosaicIQ/src-tauri/src/terminal/command_service.rs index a18efc2..c8fdff6 100644 --- a/MosaicIQ/src-tauri/src/terminal/command_service.rs +++ b/MosaicIQ/src-tauri/src/terminal/command_service.rs @@ -1,42 +1,61 @@ use std::sync::Arc; +use std::time::Duration; +use crate::terminal::google_finance::GoogleFinanceLookup; use crate::terminal::mock_data::load_mock_financial_data; -use crate::terminal::yahoo_finance::{ - SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup, +use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError, SecEdgarLookup}; +use crate::terminal::security_lookup::{ + SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, }; use crate::terminal::{ - ChatCommandRequest, ErrorPanel, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload, - TerminalCommandResponse, + ChatCommandRequest, ErrorPanel, ExecuteTerminalCommandRequest, Frequency, MockFinancialData, + PanelPayload, TerminalCommandResponse, }; /// Executes supported slash commands against live search plus shared local fixture data. pub struct TerminalCommandService { mock_data: MockFinancialData, security_lookup: Arc, + edgar_lookup: Arc, + lookup_followup_delay: Duration, } impl Default for TerminalCommandService { fn default() -> Self { Self::with_dependencies( load_mock_financial_data(), - Arc::new(YahooFinanceLookup::default()), + Arc::new(GoogleFinanceLookup::default()), + Arc::new(SecEdgarLookup::default()), + DEFAULT_LOOKUP_FOLLOWUP_DELAY, ) } } impl TerminalCommandService { /// Creates a terminal command service with a custom security lookup backend. - pub fn new(security_lookup: Arc) -> Self { - Self::with_dependencies(load_mock_financial_data(), security_lookup) + pub fn new( + security_lookup: Arc, + edgar_lookup: Arc, + ) -> Self { + Self::with_dependencies( + load_mock_financial_data(), + security_lookup, + edgar_lookup, + DEFAULT_LOOKUP_FOLLOWUP_DELAY, + ) } fn with_dependencies( mock_data: MockFinancialData, security_lookup: Arc, + edgar_lookup: Arc, + lookup_followup_delay: Duration, ) -> Self { Self { mock_data, security_lookup, + edgar_lookup, + lookup_followup_delay, } } @@ -51,6 +70,50 @@ impl TerminalCommandService { data: self.mock_data.portfolio.clone(), }, }, + "/fa" => { + if command.args.len() > 2 { + TerminalCommandResponse::Text { + content: "Usage: /fa [ticker] [annual|quarterly]".to_string(), + } + } else { + self.financials(command.args.first(), command.args.get(1), Frequency::Annual) + .await + } + } + "/cf" => { + if command.args.len() > 2 { + TerminalCommandResponse::Text { + content: "Usage: /cf [ticker] [annual|quarterly]".to_string(), + } + } else { + self.cash_flow(command.args.first(), command.args.get(1), Frequency::Annual) + .await + } + } + "/dvd" => { + if command.args.len() > 1 { + TerminalCommandResponse::Text { + content: "Usage: /dvd [ticker]".to_string(), + } + } else { + self.dividends(command.args.first(), command.args.get(1)) + .await + } + } + "/em" => { + if command.args.len() > 2 { + TerminalCommandResponse::Text { + content: "Usage: /em [ticker] [annual|quarterly]".to_string(), + } + } else { + self.earnings( + command.args.first(), + command.args.get(1), + Frequency::Quarterly, + ) + .await + } + } "/news" => self.news(command.args.first().map(String::as_str)), "/analyze" => self.analyze(command.args.first().map(String::as_str)), "/help" => help_response(), @@ -60,6 +123,36 @@ impl TerminalCommandService { } } + /// Looks up a live company snapshot directly from the configured quote provider. + pub async fn lookup_company(&self, symbol: &str) -> Result { + let normalized_symbol = symbol.trim().to_ascii_uppercase(); + + if normalized_symbol.is_empty() { + return Err("Ticker symbol required.".to_string()); + } + + let security_match = SecurityMatch { + symbol: normalized_symbol.clone(), + name: None, + exchange: None, + kind: SecurityKind::Equity, + }; + + self.security_lookup + .load_company(&security_match) + .await + .map_err(|error| match error { + SecurityLookupError::DetailUnavailable { symbol, detail } => format!( + "{} quote unavailable for {symbol}: {detail}", + self.security_lookup.provider_name() + ), + SecurityLookupError::SearchUnavailable { query, detail } => format!( + "{} quote unavailable for {query}: {detail}", + self.security_lookup.provider_name() + ), + }) + } + async fn search(&self, query: &str) -> TerminalCommandResponse { let query = query.trim(); @@ -68,6 +161,7 @@ impl TerminalCommandService { "Search query required", "Enter a ticker or company name.", Some("Usage: /search [ticker or company name]".to_string()), + self.security_lookup.provider_name(), None, None, ); @@ -81,8 +175,9 @@ impl TerminalCommandService { symbol: query.to_ascii_uppercase(), name: None, exchange: None, - kind: crate::terminal::yahoo_finance::SecurityKind::Equity, + kind: SecurityKind::Equity, }, + false, ) .await; } @@ -94,18 +189,20 @@ impl TerminalCommandService { .collect::>(), Err(SecurityLookupError::SearchUnavailable { detail, .. }) => { return search_error_response( - "Yahoo Finance search failed", + &format!("{} search failed", self.security_lookup.provider_name()), "The live search request did not complete.", Some(detail), + self.security_lookup.provider_name(), Some(query.to_string()), None, ) } Err(SecurityLookupError::DetailUnavailable { detail, .. }) => { return search_error_response( - "Yahoo Finance search failed", + &format!("{} search failed", self.security_lookup.provider_name()), "The live search request did not complete.", Some(detail), + self.security_lookup.provider_name(), Some(query.to_string()), None, ) @@ -115,8 +212,12 @@ impl TerminalCommandService { if matches.is_empty() { return search_error_response( "No supported search results", - "Yahoo Finance did not return any supported equities or funds.", + &format!( + "{} did not return any supported equities or funds.", + self.security_lookup.provider_name() + ), None, + self.security_lookup.provider_name(), Some(query.to_string()), None, ); @@ -125,14 +226,18 @@ impl TerminalCommandService { let Some(selected_match) = select_best_match(query, &matches) else { return search_error_response( "No supported search results", - "Yahoo Finance did not return any supported equities or funds.", + &format!( + "{} did not return any supported equities or funds.", + self.security_lookup.provider_name() + ), None, + self.security_lookup.provider_name(), Some(query.to_string()), None, ); }; - self.load_search_match(query, selected_match).await + self.load_search_match(query, selected_match, true).await } fn news(&self, ticker: Option<&str>) -> TerminalCommandResponse { @@ -179,28 +284,125 @@ impl TerminalCommandService { } } + async fn financials( + &self, + ticker: Option<&String>, + period: Option<&String>, + default_frequency: Frequency, + ) -> TerminalCommandResponse { + let (ticker, frequency) = + match parse_symbol_and_frequency("/fa", ticker, period, default_frequency) { + Ok(value) => value, + Err(response) => return response, + }; + + match self.edgar_lookup.financials(&ticker, frequency).await { + Ok(data) => TerminalCommandResponse::Panel { + panel: PanelPayload::Financials { data }, + }, + Err(error) => sec_error_response("SEC financials unavailable", &ticker, error), + } + } + + async fn cash_flow( + &self, + ticker: Option<&String>, + period: Option<&String>, + default_frequency: Frequency, + ) -> TerminalCommandResponse { + let (ticker, frequency) = + match parse_symbol_and_frequency("/cf", ticker, period, default_frequency) { + Ok(value) => value, + Err(response) => return response, + }; + + match self.edgar_lookup.cash_flow(&ticker, frequency).await { + Ok(data) => TerminalCommandResponse::Panel { + panel: PanelPayload::CashFlow { data }, + }, + Err(error) => sec_error_response("SEC cash flow unavailable", &ticker, error), + } + } + + async fn dividends( + &self, + ticker: Option<&String>, + extra_arg: Option<&String>, + ) -> TerminalCommandResponse { + let ticker = match ticker + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + Some(value) if extra_arg.is_none() => value.to_ascii_uppercase(), + Some(_) => { + return TerminalCommandResponse::Text { + content: "Usage: /dvd [ticker]".to_string(), + } + } + None => { + return TerminalCommandResponse::Text { + content: "Usage: /dvd [ticker]".to_string(), + } + } + }; + + match self.edgar_lookup.dividends(&ticker).await { + Ok(data) => TerminalCommandResponse::Panel { + panel: PanelPayload::Dividends { data }, + }, + Err(error) => sec_error_response("SEC dividends unavailable", &ticker, error), + } + } + + async fn earnings( + &self, + ticker: Option<&String>, + period: Option<&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, + }; + + match self.edgar_lookup.earnings(&ticker, frequency).await { + Ok(data) => TerminalCommandResponse::Panel { + panel: PanelPayload::Earnings { data }, + }, + Err(error) => sec_error_response("SEC earnings unavailable", &ticker, error), + } + } + async fn load_search_match( &self, query: &str, security_match: SecurityMatch, + apply_followup_delay: bool, ) -> TerminalCommandResponse { + if apply_followup_delay && !self.lookup_followup_delay.is_zero() { + tokio::time::sleep(self.lookup_followup_delay).await; + } + match self.security_lookup.load_company(&security_match).await { Ok(company) => TerminalCommandResponse::Panel { panel: PanelPayload::Company { data: company }, }, Err(SecurityLookupError::DetailUnavailable { symbol, detail }) => { search_error_response( - "Yahoo Finance quote unavailable", + &format!("{} quote unavailable", self.security_lookup.provider_name()), "The selected result could not be expanded into a stock overview card.", Some(detail), + self.security_lookup.provider_name(), Some(query.to_string()), Some(symbol), ) } Err(SecurityLookupError::SearchUnavailable { detail, .. }) => search_error_response( - "Yahoo Finance quote unavailable", + &format!("{} quote unavailable", self.security_lookup.provider_name()), "The selected result could not be expanded into a stock overview card.", Some(detail), + self.security_lookup.provider_name(), Some(query.to_string()), Some(security_match.symbol), ), @@ -208,6 +410,8 @@ impl TerminalCommandService { } } +const DEFAULT_LOOKUP_FOLLOWUP_DELAY: Duration = Duration::ZERO; + fn looks_like_symbol_query(query: &str) -> bool { !query.is_empty() && !query.contains(char::is_whitespace) @@ -254,8 +458,9 @@ fn exchange_priority(exchange: Option<&str>) -> usize { Some("NASDAQ") => 0, Some("NYSE") => 1, Some("AMEX") => 2, - Some("BATS") => 3, - _ => 4, + Some("NYSEARCA") => 3, + Some("BATS") => 4, + _ => 5, } } @@ -263,6 +468,7 @@ fn search_error_response( title: &str, message: &str, detail: Option, + provider: &str, query: Option, symbol: Option, ) -> TerminalCommandResponse { @@ -272,7 +478,7 @@ fn search_error_response( title: title.to_string(), message: message.to_string(), detail, - provider: Some("Yahoo Finance".to_string()), + provider: Some(provider.to_string()), query, symbol, }, @@ -280,6 +486,54 @@ fn search_error_response( } } +fn sec_error_response( + title: &str, + ticker: &str, + error: EdgarLookupError, +) -> TerminalCommandResponse { + TerminalCommandResponse::Panel { + panel: PanelPayload::Error { + data: ErrorPanel { + title: title.to_string(), + message: "The SEC EDGAR request did not complete.".to_string(), + detail: Some(error.to_string()), + provider: Some("SEC EDGAR".to_string()), + query: Some(ticker.to_string()), + symbol: Some(ticker.to_string()), + }, + }, + } +} + +fn parse_symbol_and_frequency( + command: &str, + ticker: Option<&String>, + period: Option<&String>, + default_frequency: Frequency, +) -> Result<(String, Frequency), TerminalCommandResponse> { + let Some(ticker) = ticker + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return Err(TerminalCommandResponse::Text { + content: format!("Usage: {command} [ticker] [annual|quarterly]"), + }); + }; + + let frequency = match period.map(|value| value.trim().to_ascii_lowercase()) { + None => default_frequency, + Some(value) if value == "annual" => Frequency::Annual, + Some(value) if value == "quarterly" => Frequency::Quarterly, + Some(_) => { + return Err(TerminalCommandResponse::Text { + content: format!("Usage: {command} [ticker] [annual|quarterly]"), + }) + } + }; + + Ok((ticker.to_ascii_uppercase(), frequency)) +} + /// Parses raw slash-command input into a normalized command plus positional arguments. fn parse_command(input: &str) -> ChatCommandRequest { let trimmed = input.trim(); @@ -296,7 +550,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 /portfolio - Show your portfolio\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 /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 /portfolio - Show your portfolio\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. @@ -310,16 +564,20 @@ fn help_response() -> TerminalCommandResponse { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + use std::time::Duration; use futures::future::BoxFuture; use super::TerminalCommandService; use crate::terminal::mock_data::load_mock_financial_data; - use crate::terminal::yahoo_finance::{ + use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError}; + use crate::terminal::security_lookup::{ SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, }; use crate::terminal::{ - Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse, + CashFlowPanelData, Company, DividendsPanelData, EarningsPanelData, + ExecuteTerminalCommandRequest, FinancialsPanelData, Frequency, PanelPayload, SourceStatus, + TerminalCommandResponse, }; struct FakeSecurityLookup { @@ -341,6 +599,10 @@ mod tests { } impl SecurityLookup for FakeSecurityLookup { + fn provider_name(&self) -> &'static str { + "Google Finance" + } + fn search<'a>( &'a self, _query: &'a str, @@ -372,11 +634,108 @@ mod tests { change: 1.0, change_percent: 1.0, market_cap: 1_000_000.0, - volume: 10_000, + volume: Some(10_000), + volume_label: None, pe: Some(20.0), eps: Some(2.0), high52_week: Some(110.0), low52_week: Some(80.0), + profile: None, + price_chart: None, + price_chart_ranges: None, + }) + }) + } + } + + struct FakeEdgarLookup; + + impl EdgarDataLookup for FakeEdgarLookup { + fn financials<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(FinancialsPanelData { + symbol: ticker.to_string(), + company_name: "Example Co".to_string(), + cik: "0000000001".to_string(), + frequency, + periods: Vec::new(), + latest_filing: None, + source_status: SourceStatus { + companyfacts_used: true, + latest_xbrl_parsed: false, + degraded_reason: None, + }, + }) + }) + } + + fn cash_flow<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(CashFlowPanelData { + symbol: ticker.to_string(), + company_name: "Example Co".to_string(), + cik: "0000000001".to_string(), + frequency, + periods: Vec::new(), + latest_filing: None, + source_status: SourceStatus { + companyfacts_used: true, + latest_xbrl_parsed: false, + degraded_reason: None, + }, + }) + }) + } + + fn dividends<'a>( + &'a self, + ticker: &'a str, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(DividendsPanelData { + symbol: ticker.to_string(), + company_name: "Example Co".to_string(), + cik: "0000000001".to_string(), + ttm_dividends_per_share: Some(1.0), + ttm_common_dividends_paid: Some(100.0), + latest_event: None, + events: Vec::new(), + latest_filing: None, + source_status: SourceStatus { + companyfacts_used: true, + latest_xbrl_parsed: false, + degraded_reason: None, + }, + }) + }) + } + + fn earnings<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(EarningsPanelData { + symbol: ticker.to_string(), + company_name: "Example Co".to_string(), + cik: "0000000001".to_string(), + frequency, + periods: Vec::new(), + latest_filing: None, + source_status: SourceStatus { + companyfacts_used: true, + latest_xbrl_parsed: false, + degraded_reason: None, + }, }) }) } @@ -393,7 +752,12 @@ mod tests { }); ( - TerminalCommandService::with_dependencies(load_mock_financial_data(), lookup.clone()), + TerminalCommandService::with_dependencies( + load_mock_financial_data(), + lookup.clone(), + Arc::new(FakeEdgarLookup), + Duration::ZERO, + ), lookup, ) } @@ -409,6 +773,8 @@ mod tests { search_calls: AtomicUsize::new(0), detail_calls: AtomicUsize::new(0), }), + Arc::new(FakeEdgarLookup), + Duration::ZERO, ) } @@ -419,6 +785,13 @@ mod tests { })) } + fn lookup_company( + service: &TerminalCommandService, + symbol: &str, + ) -> Result { + futures::executor::block_on(service.lookup_company(symbol)) + } + #[test] fn returns_company_panel_for_exact_search_match() { let (service, lookup) = build_service(Ok(vec![SecurityMatch { @@ -533,7 +906,7 @@ mod tests { TerminalCommandResponse::Panel { panel: PanelPayload::Error { data }, } => { - assert_eq!(data.title, "Yahoo Finance search failed"); + assert_eq!(data.title, "Google Finance search failed"); assert_eq!(data.detail.as_deref(), Some("429 Too Many Requests")); assert_eq!(data.query.as_deref(), Some("apple")); } @@ -556,7 +929,7 @@ mod tests { TerminalCommandResponse::Panel { panel: PanelPayload::Error { data }, } => { - assert_eq!(data.title, "Yahoo Finance quote unavailable"); + assert_eq!(data.title, "Google Finance quote unavailable"); assert_eq!(data.symbol.as_deref(), Some("AAPL")); assert_eq!(data.detail.as_deref(), Some("quote endpoint timed out")); } @@ -569,6 +942,8 @@ mod tests { let service = TerminalCommandService::with_dependencies( load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Duration::ZERO, ); let response = execute(&service, "/search"); @@ -609,6 +984,8 @@ mod tests { let service = TerminalCommandService::with_dependencies( load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Duration::ZERO, ); let response = execute(&service, "/wat"); @@ -626,6 +1003,8 @@ mod tests { let service = TerminalCommandService::with_dependencies( load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Duration::ZERO, ); let response = execute(&service, "/analyze"); @@ -637,4 +1016,65 @@ mod tests { other => panic!("expected text response, got {other:?}"), } } + + #[test] + fn routes_financials_command_to_sec_panel() { + let service = TerminalCommandService::with_dependencies( + load_mock_financial_data(), + Arc::new(FakeSecurityLookup::successful(vec![])), + Arc::new(FakeEdgarLookup), + Duration::ZERO, + ); + + let response = execute(&service, "/fa AAPL quarterly"); + + match response { + TerminalCommandResponse::Panel { + panel: PanelPayload::Financials { data }, + } => { + assert_eq!(data.symbol, "AAPL"); + assert_eq!(data.frequency, Frequency::Quarterly); + } + other => panic!("expected financials panel, got {other:?}"), + } + } + + #[test] + fn direct_lookup_company_returns_live_company_snapshot() { + let (service, lookup) = build_service(Ok(vec![])); + + let response = lookup_company(&service, "AAPL").expect("lookup should succeed"); + + assert_eq!(response.symbol, "AAPL"); + assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0); + assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1); + } + + #[test] + fn direct_lookup_company_normalizes_symbol() { + let (service, _) = build_service(Ok(vec![])); + + let response = lookup_company(&service, " aapl ").expect("lookup should succeed"); + + assert_eq!(response.symbol, "AAPL"); + } + + #[test] + fn direct_lookup_company_rejects_empty_symbol() { + let (service, _) = build_service(Ok(vec![])); + + let error = lookup_company(&service, " ").expect_err("lookup should fail"); + + assert_eq!(error, "Ticker symbol required."); + } + + #[test] + fn direct_lookup_company_surfaces_provider_error() { + let service = build_failing_service(Ok(vec![])); + + let error = lookup_company(&service, "BADSYMBOL").expect_err("lookup should fail"); + + assert!(error.contains("Google Finance quote unavailable for BADSYMBOL")); + assert!(error.contains("quote endpoint timed out")); + } } diff --git a/MosaicIQ/src-tauri/src/terminal/google_finance.rs b/MosaicIQ/src-tauri/src/terminal/google_finance.rs new file mode 100644 index 0000000..0b7d331 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/google_finance.rs @@ -0,0 +1,1449 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::Duration; + +use chrono::{FixedOffset, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; +use chrono_tz::America::New_York; +use futures::future::BoxFuture; +use regex::Regex; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT_LANGUAGE, REFERER}; +use reqwest::{Client, StatusCode}; +use serde_json::{json, Value}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::terminal::security_lookup::{ + get_cached_value, get_cached_value_within, normalize_search_query, normalize_symbol, + store_cached_value, CacheEntry, RequestGate, SecurityKind, SecurityLookup, SecurityLookupError, + SecurityMatch, +}; +use crate::terminal::{Company, CompanyPricePoint, CompanyProfile}; + +const GOOGLE_FINANCE_HOME: &str = "https://www.google.com/finance/"; +const GOOGLE_FINANCE_BATCH_ENDPOINT: &str = + "https://www.google.com/finance/_/GoogleFinanceUi/data/batchexecute"; +const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const DEFAULT_MIN_REQUEST_SPACING_MS: u64 = 1_500; +const DEFAULT_COOLDOWN_SECS: u64 = 15 * 60; +const SEARCH_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); +const SYMBOL_EXCHANGE_CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const SESSION_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +const COMPANY_ACTIVE_CACHE_TTL: Duration = Duration::from_secs(2 * 60); +const COMPANY_INACTIVE_CACHE_TTL: Duration = Duration::from_secs(12 * 60 * 60); +const COMPANY_STALE_IF_ERROR_TTL: Duration = Duration::from_secs(24 * 60 * 60); +const MIN_REQUEST_JITTER: Duration = Duration::from_millis(150); +const MAX_REQUEST_JITTER: Duration = Duration::from_millis(350); +const RETRY_DELAYS: [Duration; 2] = [Duration::from_secs(1), Duration::from_secs(3)]; +const PRICE_CHART_RANGES: [(&str, i64); 8] = [ + ("1D", 1), + ("5D", 2), + ("1M", 3), + ("6M", 4), + ("YTD", 5), + ("1Y", 6), + ("5Y", 7), + ("MAX", 8), +]; + +pub(crate) struct GoogleFinanceLookup { + client: Client, + search_cache: Mutex>>>, + symbol_exchange_cache: Mutex>>, + company_cache: Mutex>>, + session_cache: Mutex>>, + request_gate: AsyncMutex, + request_lane: AsyncMutex<()>, + cooldown: Duration, +} + +#[derive(Debug, Clone)] +struct BatchSession { + sid: String, + bl: String, +} + +#[derive(Debug)] +enum HttpFetchError { + Blocked(String), + Failed(String), +} + +impl Default for GoogleFinanceLookup { + fn default() -> Self { + let user_agent = std::env::var("GOOGLE_FINANCE_USER_AGENT") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); + let min_request_spacing_ms = std::env::var("GOOGLE_FINANCE_MIN_REQUEST_SPACING_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_MIN_REQUEST_SPACING_MS); + let cooldown_secs = std::env::var("GOOGLE_FINANCE_COOLDOWN_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_COOLDOWN_SECS); + + let mut default_headers = HeaderMap::new(); + default_headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); + default_headers.insert(REFERER, HeaderValue::from_static(GOOGLE_FINANCE_HOME)); + + let client = Client::builder() + .cookie_store(true) + .default_headers(default_headers) + .user_agent(user_agent) + .build() + .expect("google finance reqwest client"); + + Self { + client, + search_cache: Mutex::new(HashMap::new()), + symbol_exchange_cache: Mutex::new(HashMap::new()), + company_cache: Mutex::new(HashMap::new()), + session_cache: Mutex::new(None), + request_gate: AsyncMutex::new(RequestGate::new( + Duration::from_millis(min_request_spacing_ms), + MIN_REQUEST_JITTER, + MAX_REQUEST_JITTER, + )), + request_lane: AsyncMutex::new(()), + cooldown: Duration::from_secs(cooldown_secs), + } + } +} + +impl SecurityLookup for GoogleFinanceLookup { + fn provider_name(&self) -> &'static str { + "Google Finance" + } + + fn search<'a>( + &'a self, + query: &'a str, + ) -> BoxFuture<'a, Result, SecurityLookupError>> { + Box::pin(async move { + let normalized_query = normalize_search_query(query); + if let Some(cached_matches) = + get_cached_value(&self.search_cache, &normalized_query, SEARCH_CACHE_TTL) + { + return Ok(cached_matches); + } + + let session = self.batch_session().await.map_err(|detail| { + SecurityLookupError::SearchUnavailable { + query: query.to_string(), + detail, + } + })?; + + let body = self + .post_batch_rpc( + "mKsvE", + "/finance/", + json!([query, Value::Null, 1, 1]), + &session, + GOOGLE_FINANCE_HOME, + ) + .await + .map_err(|error| self.map_search_http_error(query, error))?; + + let matches = decode_search_matches(&body).map_err(|detail| { + SecurityLookupError::SearchUnavailable { + query: query.to_string(), + detail, + } + })?; + + for security_match in &matches { + if let Some(exchange) = security_match.exchange.as_deref() { + self.store_symbol_exchange(&security_match.symbol, exchange); + } + } + + store_cached_value(&self.search_cache, normalized_query, matches.clone()); + Ok(matches) + }) + } + + fn load_company<'a>( + &'a self, + security_match: &'a SecurityMatch, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let symbol = normalize_symbol(&security_match.symbol); + let exchange = self.resolve_exchange(security_match).await?; + let cache_key = format!("{symbol}:{exchange}"); + + if let Some(cached_company) = + get_cached_value(&self.company_cache, &cache_key, company_cache_ttl()) + { + return Ok(cached_company); + } + + let quote_url = format!( + "https://www.google.com/finance/quote/{}:{}?hl=en-US", + symbol, exchange + ); + let quote_html = match self.fetch_text("e_url, "e_url).await { + Ok(body) => body, + Err(error) => { + if let Some(stale_company) = get_cached_value_within( + &self.company_cache, + &cache_key, + COMPANY_STALE_IF_ERROR_TTL, + ) { + return Ok(stale_company); + } + + return Err(self.map_detail_http_error(&symbol, error)); + } + }; + + let mut batch_session = parse_batch_session("e_html).ok().inspect(|session| { + if let Ok(mut guard) = self.session_cache.lock() { + *guard = Some(CacheEntry::new(session.clone())); + } + }); + if batch_session.is_none() { + batch_session = self.batch_session().await.ok(); + } + let price_chart_ranges = if let Some(session) = batch_session.as_ref() { + self.fetch_price_chart_ranges(&symbol, &exchange, session, "e_url) + .await + .ok() + } else { + None + }; + + let company = parse_company_from_quote_html( + "e_html, + security_match, + &symbol, + &exchange, + price_chart_ranges, + ) + .map_err(|detail| SecurityLookupError::DetailUnavailable { + symbol: symbol.clone(), + detail, + })?; + + store_cached_value(&self.company_cache, cache_key, company.clone()); + self.store_symbol_exchange(&symbol, &exchange); + Ok(company) + }) + } +} + +impl GoogleFinanceLookup { + async fn batch_session(&self) -> Result { + if let Ok(guard) = self.session_cache.lock() { + if let Some(session) = guard + .as_ref() + .filter(|entry| entry.is_fresh(SESSION_CACHE_TTL)) + .map(|entry| entry.cloned_value()) + { + return Ok(session); + } + } + + let body = self + .fetch_text(GOOGLE_FINANCE_HOME, GOOGLE_FINANCE_HOME) + .await + .map_err(|error| self.describe_http_error(error))?; + let session = parse_batch_session(&body)?; + + if let Ok(mut guard) = self.session_cache.lock() { + *guard = Some(CacheEntry::new(session.clone())); + } + + Ok(session) + } + + async fn post_batch_rpc( + &self, + rpc_id: &str, + source_path: &str, + request: Value, + session: &BatchSession, + referer: &str, + ) -> Result { + let request_json = serde_json::to_string(&request) + .map_err(|error| HttpFetchError::Failed(error.to_string()))?; + let payload = + serde_json::to_string(&json!([[[rpc_id, request_json, Value::Null, "generic"]]])) + .map_err(|error| HttpFetchError::Failed(error.to_string()))?; + let req_id = (Utc::now().timestamp_subsec_millis() % 900_000) + 100_000; + let url = format!( + "{GOOGLE_FINANCE_BATCH_ENDPOINT}?rpcids={rpc_id}&source-path={}&f.sid={}&bl={}&hl=en-US&soc-app=162&soc-platform=1&soc-device=1&_reqid={req_id}&rt=c", + urlencoding::encode(source_path), + urlencoding::encode(&session.sid), + urlencoding::encode(&session.bl), + ); + let body = format!("f.req={}&", urlencoding::encode(&payload)); + + self.execute_request( + || { + self.client + .post(&url) + .header( + "Content-Type", + "application/x-www-form-urlencoded;charset=UTF-8", + ) + .header(REFERER, referer) + .header("Origin", "https://www.google.com") + .header("X-Same-Domain", "1") + .body(body.clone()) + }, + true, + ) + .await + } + + async fn post_batch_rpc_calls( + &self, + rpc_id: &str, + source_path: &str, + requests: &[Value], + session: &BatchSession, + referer: &str, + ) -> Result { + let mut calls = Vec::with_capacity(requests.len()); + for request in requests { + let request_json = serde_json::to_string(request) + .map_err(|error| HttpFetchError::Failed(error.to_string()))?; + calls.push(json!([rpc_id, request_json, Value::Null, "generic"])); + } + + let payload = serde_json::to_string(&vec![calls]) + .map_err(|error| HttpFetchError::Failed(error.to_string()))?; + let req_id = (Utc::now().timestamp_subsec_millis() % 900_000) + 100_000; + let url = format!( + "{GOOGLE_FINANCE_BATCH_ENDPOINT}?rpcids={rpc_id}&source-path={}&f.sid={}&bl={}&hl=en-US&soc-app=162&soc-platform=1&soc-device=1&_reqid={req_id}&rt=c", + urlencoding::encode(source_path), + urlencoding::encode(&session.sid), + urlencoding::encode(&session.bl), + ); + let body = format!("f.req={}&", urlencoding::encode(&payload)); + + self.execute_request( + || { + self.client + .post(&url) + .header( + "Content-Type", + "application/x-www-form-urlencoded;charset=UTF-8", + ) + .header(REFERER, referer) + .header("Origin", "https://www.google.com") + .header("X-Same-Domain", "1") + .body(body.clone()) + }, + true, + ) + .await + } + + async fn fetch_text(&self, url: &str, referer: &str) -> Result { + self.execute_request(|| self.client.get(url).header(REFERER, referer), true) + .await + } + + async fn execute_request( + &self, + mut build_request: F, + allow_transient_retries: bool, + ) -> Result + where + F: FnMut() -> reqwest::RequestBuilder, + { + let _request_lane = self.request_lane.lock().await; + let max_attempts = RETRY_DELAYS.len() + 1; + let mut last_error = None; + + for attempt in 0..max_attempts { + self.wait_for_request_slot().await; + + match build_request().send().await { + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_else(|error| { + format!("Failed to read Google Finance response body: {error}") + }); + + if is_blocked_response(status, &body) { + self.record_block().await; + return Err(HttpFetchError::Blocked(blocked_detail(status, &body))); + } + + if status.is_success() { + return Ok(body); + } + + if allow_transient_retries + && status.is_server_error() + && attempt < RETRY_DELAYS.len() + { + last_error = Some(format!("Google Finance returned HTTP {status}.")); + tokio::time::sleep(RETRY_DELAYS[attempt]).await; + continue; + } + + return Err(HttpFetchError::Failed(format!( + "Google Finance returned HTTP {status}." + ))); + } + Err(error) => { + if allow_transient_retries + && (error.is_connect() || error.is_timeout()) + && attempt < RETRY_DELAYS.len() + { + last_error = Some(error.to_string()); + tokio::time::sleep(RETRY_DELAYS[attempt]).await; + continue; + } + + return Err(HttpFetchError::Failed(error.to_string())); + } + } + } + + Err(HttpFetchError::Failed(last_error.unwrap_or_else(|| { + "Google Finance request failed.".to_string() + }))) + } + + async fn wait_for_request_slot(&self) { + let wait_duration = { + let mut gate = self.request_gate.lock().await; + gate.reserve_slot() + }; + + if !wait_duration.is_zero() { + tokio::time::sleep(wait_duration).await; + } + } + + async fn record_block(&self) { + let mut gate = self.request_gate.lock().await; + gate.extend_cooldown(self.cooldown); + } + + fn map_search_http_error(&self, query: &str, error: HttpFetchError) -> SecurityLookupError { + SecurityLookupError::SearchUnavailable { + query: query.to_string(), + detail: self.describe_http_error(error), + } + } + + fn map_detail_http_error(&self, symbol: &str, error: HttpFetchError) -> SecurityLookupError { + SecurityLookupError::DetailUnavailable { + symbol: symbol.to_string(), + detail: self.describe_http_error(error), + } + } + + fn describe_http_error(&self, error: HttpFetchError) -> String { + match error { + HttpFetchError::Blocked(detail) | HttpFetchError::Failed(detail) => detail, + } + } + + fn store_symbol_exchange(&self, symbol: &str, exchange: &str) { + store_cached_value( + &self.symbol_exchange_cache, + normalize_symbol(symbol), + exchange.to_string(), + ); + } + + async fn resolve_exchange( + &self, + security_match: &SecurityMatch, + ) -> Result { + if let Some(exchange) = security_match + .exchange + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + self.store_symbol_exchange(&security_match.symbol, exchange); + return Ok(exchange.to_string()); + } + + let symbol = normalize_symbol(&security_match.symbol); + if let Some(exchange) = get_cached_value( + &self.symbol_exchange_cache, + &symbol, + SYMBOL_EXCHANGE_CACHE_TTL, + ) { + return Ok(exchange); + } + + let matches = self.search(&symbol).await?; + let selected = matches + .iter() + .filter(|candidate| candidate.symbol.eq_ignore_ascii_case(&symbol)) + .min_by_key(|candidate| exchange_priority(candidate.exchange.as_deref())) + .and_then(|candidate| candidate.exchange.clone()) + .ok_or_else(|| SecurityLookupError::DetailUnavailable { + symbol: symbol.clone(), + detail: format!("Google Finance did not resolve an exchange for \"{symbol}\"."), + })?; + + self.store_symbol_exchange(&symbol, &selected); + Ok(selected) + } + + async fn fetch_price_chart_ranges( + &self, + symbol: &str, + exchange: &str, + session: &BatchSession, + referer: &str, + ) -> Result>, String> { + let source_path = format!("/finance/quote/{symbol}:{exchange}"); + let requests = PRICE_CHART_RANGES + .iter() + .map(|(_, window_index)| { + json!([ + [[Value::Null, [symbol, exchange]]], + window_index, + Value::Null, + Value::Null, + Value::Null, + Value::Null, + Value::Null, + 0 + ]) + }) + .collect::>(); + + let body = self + .post_batch_rpc_calls("AiCwsd", &source_path, &requests, session, referer) + .await + .map_err(|error| self.describe_http_error(error))?; + decode_chart_range_map(&body) + } +} + +fn parse_batch_session(body: &str) -> Result { + let sid = capture_string(body, "FdrFJe").ok_or_else(|| { + "Google Finance did not expose a batch session id for this request.".to_string() + })?; + let bl = capture_string(body, "cfb2h").ok_or_else(|| { + "Google Finance did not expose a build label for this request.".to_string() + })?; + + Ok(BatchSession { sid, bl }) +} + +fn capture_string(body: &str, key: &str) -> Option { + let pattern = format!(r#""{}":"([^"]+)""#, regex::escape(key)); + Regex::new(&pattern) + .ok()? + .captures(body) + .and_then(|captures| captures.get(1)) + .map(|value| value.as_str().to_string()) +} + +fn decode_search_matches(response: &str) -> Result, String> { + let payload = decode_batchexecute_payload(response, "mKsvE")?; + let value = serde_json::from_str::(&payload) + .map_err(|error| format!("Failed to decode Google Finance search payload: {error}"))?; + + let mut matches = Vec::new(); + collect_search_matches(&value, &mut matches); + + let mut seen = HashSet::new(); + let deduped = matches + .into_iter() + .filter(|security_match| { + let key = format!( + "{}:{}", + security_match.symbol, + security_match.exchange.as_deref().unwrap_or_default() + ); + seen.insert(key) + }) + .collect::>(); + + Ok(deduped) +} + +fn decode_batchexecute_payload(response: &str, rpc_id: &str) -> Result { + decode_batchexecute_payloads(response, rpc_id)? + .into_iter() + .next() + .ok_or_else(|| { + format!("Google Finance batchexecute response did not include an {rpc_id} payload.") + }) +} + +fn decode_batchexecute_payloads(response: &str, rpc_id: &str) -> Result, String> { + let mut payloads = Vec::new(); + + for line in response + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if !line.starts_with("[[") { + continue; + } + + let rows = serde_json::from_str::(line).map_err(|error| { + format!("Failed to decode Google Finance batchexecute envelope: {error}") + })?; + let Some(rows) = rows.as_array() else { + continue; + }; + + for row in rows { + let Some(row_items) = row.as_array() else { + continue; + }; + if row_items.get(1).and_then(Value::as_str) != Some(rpc_id) { + continue; + } + + if let Some(payload) = row_items.get(2).and_then(Value::as_str) { + payloads.push(payload.to_string()); + } + } + } + + if payloads.is_empty() { + Err(format!( + "Google Finance batchexecute response did not include an {rpc_id} payload." + )) + } else { + Ok(payloads) + } +} + +fn collect_search_matches(value: &Value, matches: &mut Vec) { + let Some(items) = value.as_array() else { + return; + }; + + if let Some(security_match) = match_from_value(items) { + matches.push(security_match); + } + + for item in items { + collect_search_matches(item, matches); + } +} + +fn match_from_value(items: &[Value]) -> Option { + if items.first()?.as_str()?.trim().is_empty() { + return None; + } + + let listing = items.get(1)?.as_array()?; + let symbol = listing.first()?.as_str()?.trim(); + let exchange = listing.get(1)?.as_str()?.trim(); + let name = items.get(2)?.as_str()?.trim(); + let type_code = items.get(3)?.as_i64()?; + + if symbol.is_empty() || exchange.is_empty() || name.is_empty() { + return None; + } + + Some(SecurityMatch { + symbol: symbol.to_string(), + name: Some(name.to_string()), + exchange: Some(exchange.to_string()), + kind: match type_code { + 0 => SecurityKind::Equity, + 5 => SecurityKind::Fund, + other => SecurityKind::Other(format!("type-code-{other}")), + }, + }) +} + +fn parse_company_from_quote_html( + html: &str, + security_match: &SecurityMatch, + symbol: &str, + exchange: &str, + price_chart_ranges: Option>>, +) -> Result { + if !html.contains("/finance/quote/") || !html.contains("data-last-price=") { + return Err( + "Google Finance quote page was missing the expected price markers.".to_string(), + ); + } + + let embedded = parse_embedded_quote_data(html); + let parsed_symbol = parse_canonical_quote_part(html, 1).unwrap_or_else(|| symbol.to_string()); + let _parsed_exchange = + parse_canonical_quote_part(html, 2).unwrap_or_else(|| exchange.to_string()); + let name = parse_quote_name(html) + .or_else(|| embedded.as_ref().and_then(|data| data.name.clone())) + .or_else(|| security_match.name.clone()) + .unwrap_or_else(|| parsed_symbol.clone()); + let price = capture_attribute(html, "data-last-price") + .and_then(|value| parse_numeric_value(&value)) + .or_else(|| embedded.as_ref().and_then(|data| data.price)) + .ok_or_else(|| "Google Finance quote page did not expose a last price.".to_string())?; + let previous_close = extract_metric_text(html, "Previous close") + .and_then(|value| parse_numeric_value(&value)) + .or_else(|| embedded.as_ref().and_then(|data| data.previous_close)); + let market_cap = extract_metric_text(html, "Market cap") + .and_then(|value| parse_compact_number(&value)) + .or_else(|| embedded.as_ref().and_then(|data| data.market_cap)) + .unwrap_or(0.0); + let year_range = extract_metric_text(html, "Year range") + .and_then(|value| { + let mut parts = value.split('-').map(str::trim); + let low = parts.next().and_then(parse_numeric_value)?; + let high = parts.next().and_then(parse_numeric_value)?; + Some((low, high)) + }) + .or_else(|| { + embedded + .as_ref() + .and_then(|data| Some((data.low52_week?, data.high52_week?))) + }); + let pe = extract_metric_text(html, "P/E ratio") + .and_then(|value| parse_numeric_value(&value)) + .or_else(|| embedded.as_ref().and_then(|data| data.pe)); + let eps = extract_metric_text(html, "EPS") + .and_then(|value| parse_numeric_value(&value)) + .or_else(|| embedded.as_ref().and_then(|data| data.eps)) + .or_else(|| match pe { + Some(pe) if pe > 0.0 => Some(((price / pe) * 100_000.0).round() / 100_000.0), + _ => None, + }); + let volume = extract_metric_text(html, "Volume") + .and_then(|value| parse_compact_number(&value)) + .map(|value| value.round() as u64) + .map(|value| (value, "Volume".to_string())) + .or_else(|| { + extract_metric_text(html, "Avg Volume") + .and_then(|value| parse_compact_number(&value)) + .map(|value| (value.round() as u64, "Avg Volume".to_string())) + }) + .or_else(|| { + embedded + .as_ref() + .and_then(|data| data.volume) + .map(|value| (value, "Volume".to_string())) + }) + .or_else(|| { + embedded + .as_ref() + .and_then(|data| data.avg_volume) + .map(|value| (value, "Avg Volume".to_string())) + }); + let profile = parse_company_profile(html); + let price_chart = price_chart_ranges + .as_ref() + .and_then(|ranges| ranges.get("1D").cloned()) + .or_else(|| parse_price_chart(html)); + + let (change, change_percent) = match previous_close { + Some(previous_close) if previous_close > 0.0 => { + let change = price - previous_close; + (change, (change / previous_close) * 100.0) + } + _ => ( + embedded + .as_ref() + .and_then(|data| data.change) + .unwrap_or(0.0), + embedded + .as_ref() + .and_then(|data| data.change_percent) + .unwrap_or(0.0), + ), + }; + + Ok(Company { + symbol: parsed_symbol, + name, + price, + change, + change_percent, + market_cap, + volume: volume.as_ref().map(|(value, _)| *value), + volume_label: volume.map(|(_, label)| label), + pe, + eps, + high52_week: year_range.map(|(_, high)| high), + low52_week: year_range.map(|(low, _)| low), + profile, + price_chart, + price_chart_ranges, + }) +} + +fn parse_quote_name(html: &str) -> Option { + let title = Regex::new(r"(?s)(.*?)") + .ok()? + .captures(html)? + .get(1)? + .as_str() + .to_string(); + let title = html_entity_decode(&title); + title + .split(" (") + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn parse_canonical_quote_part(html: &str, capture_index: usize) -> Option { + let captures = Regex::new( + r#" Option { + let pattern = format!(r#"{attribute}="([^"]+)""#); + Regex::new(&pattern) + .ok()? + .captures(html) + .and_then(|captures| captures.get(1)) + .map(|value| html_entity_decode(value.as_str())) +} + +fn extract_metric_text(html: &str, label: &str) -> Option { + let pattern = format!( + r#"(?s)>{label}.*?]*class="P6K39c"[^>]*>([^<]+)"#, + label = regex::escape(label) + ); + Regex::new(&pattern) + .ok()? + .captures(html) + .and_then(|captures| captures.get(1)) + .map(|value| normalize_space(&html_entity_decode(value.as_str()))) +} + +fn parse_numeric_value(value: &str) -> Option { + let cleaned = value + .replace('\u{a0}', " ") + .chars() + .filter(|character| { + character.is_ascii_digit() + || matches!(character, '+' | '-' | '.' | ',' | ' ' | '\u{202f}') + }) + .collect::() + .trim() + .to_string(); + + if cleaned.is_empty() { + return None; + } + + let compact = cleaned.replace([' ', '\u{202f}'], ""); + normalize_number_string(&compact).and_then(|normalized| normalized.parse::().ok()) +} + +fn parse_compact_number(value: &str) -> Option { + let suffix = value + .chars() + .find(|character| matches!(character, 'K' | 'M' | 'B' | 'T' | 'k' | 'm' | 'b' | 't')) + .map(|character| character.to_ascii_uppercase()); + let numeric = Regex::new(r"[-+]?[\d.,\u{202f}\s]+") + .ok()? + .find(value)? + .as_str() + .trim() + .to_string(); + let base = parse_numeric_value(&numeric)?; + let multiplier = match suffix { + Some('K') => 1_000.0, + Some('M') => 1_000_000.0, + Some('B') => 1_000_000_000.0, + Some('T') => 1_000_000_000_000.0, + _ => 1.0, + }; + + Some(base * multiplier) +} + +fn normalize_number_string(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + let last_dot = value.rfind('.'); + let last_comma = value.rfind(','); + + let normalized = match (last_dot, last_comma) { + (Some(dot), Some(comma)) if comma > dot => value.replace('.', "").replace(',', "."), + (Some(_), Some(_)) => value.replace(',', ""), + (None, Some(comma)) => { + let decimals = value.len().saturating_sub(comma + 1); + if decimals <= 2 { + value.replace(',', ".") + } else { + value.replace(',', "") + } + } + _ => value.replace(',', ""), + }; + + Some(normalized) +} + +fn normalize_space(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn html_entity_decode(value: &str) -> String { + value + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("·", "·") + .replace("-", "-") +} + +#[derive(Debug, Clone, Default)] +struct EmbeddedQuoteData { + name: Option, + price: Option, + previous_close: Option, + market_cap: Option, + volume: Option, + avg_volume: Option, + pe: Option, + eps: Option, + high52_week: Option, + low52_week: Option, + change: Option, + change_percent: Option, +} + +fn parse_company_profile(html: &str) -> Option { + let payload = extract_init_data_payload(html, "ds:1")?; + let value = serde_json::from_str::(&payload).ok()?; + let fields = value.as_array()?.first()?.as_array()?.first()?.as_array()?; + let headquarters = fields + .get(3) + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .take(3) + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(", ") + }) + .filter(|value| !value.is_empty()); + let profile = CompanyProfile { + description: fields + .get(2) + .and_then(Value::as_str) + .map(normalize_space) + .filter(|value| !value.is_empty()), + wiki_url: fields + .get(31) + .and_then(Value::as_str) + .map(ToString::to_string) + .filter(|value| !value.is_empty()), + ceo: fields + .get(5) + .and_then(Value::as_str) + .map(ToString::to_string) + .filter(|value| !value.is_empty()), + headquarters, + employees: as_u64_value(fields.get(6)?), + founded: fields + .get(4) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_i64) + .map(|value| value as i32), + sector: fields + .iter() + .rev() + .find_map(Value::as_str) + .map(str::trim) + .filter(|value| { + !value.is_empty() + && !value.starts_with("http") + && *value != "USD" + && *value != "NASDAQ" + && !value.contains("wiki") + }) + .map(ToString::to_string), + website: fields + .get(23) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.starts_with("http") + && *value != "USD" + && !value.contains("wikipedia.org") + && !value.contains("google.com") + }) + .map(ToString::to_string), + }; + + if profile.description.is_none() + && profile.wiki_url.is_none() + && profile.ceo.is_none() + && profile.headquarters.is_none() + && profile.employees.is_none() + && profile.founded.is_none() + && profile.sector.is_none() + && profile.website.is_none() + { + None + } else { + Some(profile) + } +} + +fn parse_price_chart(html: &str) -> Option> { + for key in ["ds:10", "ds:11"] { + let Some(payload) = extract_init_data_payload(html, key) else { + continue; + }; + if let Some(points) = parse_price_chart_payload(&payload) { + return Some(points); + } + } + + None +} + +fn decode_chart_range_map( + response: &str, +) -> Result>, String> { + let payloads = decode_batchexecute_payloads(response, "AiCwsd")?; + let mut ranges = HashMap::new(); + + for ((range_key, _), payload) in PRICE_CHART_RANGES.iter().zip(payloads.iter()) { + if let Some(points) = parse_price_chart_payload(payload) { + ranges.insert((*range_key).to_string(), points); + } + } + + if ranges.is_empty() { + Err("Google Finance chart response returned no usable price history.".to_string()) + } else { + Ok(ranges) + } +} + +fn parse_price_chart_payload(payload: &str) -> Option> { + let value = serde_json::from_str::(payload).ok()?; + let mut points = Vec::new(); + collect_chart_points(&value, &mut points); + (points.len() > 1).then_some(points) +} + +fn collect_chart_points(value: &Value, points: &mut Vec) { + let Some(items) = value.as_array() else { + return; + }; + + if let Some(point) = parse_chart_point(items) { + points.push(point); + return; + } + + for item in items { + collect_chart_points(item, points); + } +} + +fn parse_chart_point(items: &[Value]) -> Option { + let timestamp = items.first()?.as_array()?; + let price_info = items.get(1)?.as_array()?; + let price = price_info.first()?.as_f64()?; + + Some(CompanyPricePoint { + label: chart_label_from_timestamp(timestamp)?, + price, + volume: items.get(2).and_then(as_u64_value), + timestamp: chart_timestamp_from_parts(timestamp), + }) +} + +fn chart_label_from_timestamp(parts: &[Value]) -> Option { + let year = parts.first()?.as_i64()?; + let month = parts.get(1)?.as_i64()?; + let day = parts.get(2)?.as_i64()?; + let hour = parts.get(3).and_then(Value::as_i64); + let minute = parts.get(4).and_then(Value::as_i64).unwrap_or(0); + + Some(match hour { + Some(hour) => format!("{hour:02}:{minute:02}"), + None => format!("{year:04}-{month:02}-{day:02}"), + }) +} + +fn chart_timestamp_from_parts(parts: &[Value]) -> Option { + let year = parts.first()?.as_i64()?; + let month = parts.get(1)?.as_i64()?; + let day = parts.get(2)?.as_i64()?; + let hour = parts.get(3).and_then(Value::as_i64).unwrap_or(0); + let minute = parts.get(4).and_then(Value::as_i64).unwrap_or(0); + let offset_seconds = parts + .get(6) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_i64) + .unwrap_or(0); + + let date = NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32)?; + let time = NaiveTime::from_hms_opt(hour as u32, minute as u32, 0)?; + let naive = date.and_time(time); + let offset = FixedOffset::east_opt(offset_seconds as i32)?; + + offset + .from_local_datetime(&naive) + .single() + .map(|value| value.to_rfc3339()) +} + +fn parse_embedded_quote_data(html: &str) -> Option { + let payload = extract_init_data_payload(html, "ds:1")?; + let value = serde_json::from_str::(&payload).ok()?; + let fields = value.as_array()?.first()?.as_array()?.first()?.as_array()?; + + let price = fields.get(9).and_then(Value::as_f64); + let previous_close = fields.get(8).and_then(Value::as_f64); + + Some(EmbeddedQuoteData { + name: fields + .get(1) + .and_then(Value::as_str) + .map(ToString::to_string), + price, + previous_close, + market_cap: fields.get(7).and_then(Value::as_f64), + volume: fields.get(14).and_then(Value::as_u64).or_else(|| { + fields + .get(14) + .and_then(Value::as_f64) + .map(|value| value.round() as u64) + }), + avg_volume: fields.get(18).and_then(Value::as_u64).or_else(|| { + fields + .get(18) + .and_then(Value::as_f64) + .map(|value| value.round() as u64) + }), + pe: fields.get(16).and_then(Value::as_f64), + eps: fields.get(20).and_then(Value::as_f64), + high52_week: fields.get(12).and_then(Value::as_f64), + low52_week: fields.get(13).and_then(Value::as_f64), + change: match (price, previous_close) { + (Some(price), Some(previous_close)) => Some(price - previous_close), + _ => None, + }, + change_percent: match (price, previous_close) { + (Some(price), Some(previous_close)) if previous_close > 0.0 => { + Some(((price - previous_close) / previous_close) * 100.0) + } + _ => None, + }, + }) +} + +fn as_u64_value(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| value.as_f64().map(|value| value.round() as u64)) +} + +fn extract_init_data_payload(html: &str, key: &str) -> Option { + let marker = format!("AF_initDataCallback({{key: '{key}'"); + let marker_index = html.find(&marker)?; + let data_index = html[marker_index..].find("data:")? + marker_index + "data:".len(); + let start_index = html[data_index..] + .char_indices() + .find(|(_, character)| !character.is_whitespace()) + .map(|(offset, _)| data_index + offset)?; + + if html[start_index..].chars().next()? != '[' { + return None; + } + + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + + for (offset, character) in html[start_index..].char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + + match character { + '"' => in_string = true, + '[' => depth += 1, + ']' => { + depth = depth.saturating_sub(1); + if depth == 0 { + let end_index = start_index + offset + character.len_utf8(); + return Some(html[start_index..end_index].to_string()); + } + } + _ => {} + } + } + + None +} + +fn company_cache_ttl() -> Duration { + let now = Utc::now().with_timezone(&New_York); + if (4..20).contains(&now.hour()) { + COMPANY_ACTIVE_CACHE_TTL + } else { + COMPANY_INACTIVE_CACHE_TTL + } +} + +fn exchange_priority(exchange: Option<&str>) -> usize { + match exchange { + Some("NASDAQ") => 0, + Some("NYSE") => 1, + Some("AMEX") => 2, + Some("NYSEARCA") => 3, + Some("BATS") => 4, + _ => 5, + } +} + +fn is_blocked_response(status: StatusCode, body: &str) -> bool { + if matches!( + status, + StatusCode::TOO_MANY_REQUESTS | StatusCode::FORBIDDEN + ) { + return true; + } + + let body = body.to_ascii_lowercase(); + body.contains("unusual traffic") + || body.contains("our systems have detected") + || body.contains("consent.google.com") + || body.contains("/sorry/") + || body.contains("captcha") +} + +fn blocked_detail(status: StatusCode, body: &str) -> String { + if matches!( + status, + StatusCode::TOO_MANY_REQUESTS | StatusCode::FORBIDDEN + ) { + return format!("Google Finance returned HTTP {status}."); + } + + if body.to_ascii_lowercase().contains("unusual traffic") { + return "Google Finance blocked the request as unusual traffic.".to_string(); + } + + "Google Finance blocked the request.".to_string() +} + +#[cfg(test)] +mod tests { + use super::{ + company_cache_ttl, decode_batchexecute_payload, decode_chart_range_map, + decode_search_matches, extract_init_data_payload, parse_company_from_quote_html, + parse_company_profile, parse_embedded_quote_data, parse_numeric_value, parse_price_chart, + }; + use crate::terminal::security_lookup::{SecurityKind, SecurityMatch}; + + const MKSVE_RESPONSE: &str = r##" +)]}' + +4484 +[["wrb.fr","mKsvE","[[[\"/m/07zln_9\",null,null,[\"/m/07zln_9\",[\"MSFT\",\"NASDAQ\"],\"Microsoft Corp\",0,\"USD\",[373.46,4.0899963,1.1072898,2,2,2],null,369.37,\"#737373\",\"US\",\"/m/04sv4\",[1775176200],\"America/New_York\",-14400,\"/m/07zln_9\",null,[373.3999,-0.06008911,-0.016089838,2,3,3],[1775160001],[1775174397],[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]]],null,\"MSFT:NASDAQ\",0,null,null,null,false],[null,[\"MSFT\",\"NASDAQ\"]],null,\"\",\"msft:nasdaq\"],[\"/g/11fzpf6_1y\",null,null,[\"NVD\",[\"NVD\",\"NASDAQ\"],\"GraniteShares 2x Long NVDA Daily ETF\",5,\"USD\",[60.68,1.21,2.03,2,2,2],null,59.47,\"#737373\",\"US\",\"/m/0hjjjhh\",[1775176200],\"America/New_York\",-14400,\"/g/11fzpf6_1y\",null,[60.58,-0.1,-0.16,2,3,3],[1775160001],[1775174397],[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]]],null,\"NVD:NASDAQ\",0,null,null,null,false],[null,[\"NVD\",\"NASDAQ\"]],null,\"\",\"nvd:nasdaq\"]]]",null,null,null,"generic"]] +"##; + + const QUOTE_HTML: &str = r#" + + + + + Apple Inc (AAPL) Stock Price & News - Google Finance + + + + + +
+
Previous close
$255.63
+
Year range
$169.21 - $288.61
+
Market cap
3.76T USD
+
Avg Volume
41.19M
+
P/E ratio
32.38
+ + +"#; + + #[test] + fn decodes_batchexecute_payload() { + let payload = decode_batchexecute_payload(MKSVE_RESPONSE, "mKsvE").expect("mKsvE payload"); + assert!(payload.contains("MSFT")); + } + + #[test] + fn decodes_search_matches_from_mksve() { + let matches = decode_search_matches(MKSVE_RESPONSE).expect("search matches"); + assert_eq!(matches[0].symbol, "MSFT"); + assert_eq!(matches[0].exchange.as_deref(), Some("NASDAQ")); + assert_eq!(matches[0].name.as_deref(), Some("Microsoft Corp")); + assert!(matches[0].kind.is_supported()); + assert_eq!(matches[1].symbol, "NVD"); + assert!(matches[1].kind.is_supported()); + } + + #[test] + fn parses_company_from_quote_html_with_avg_volume_fallback() { + let company = parse_company_from_quote_html( + QUOTE_HTML, + &SecurityMatch { + symbol: "AAPL".to_string(), + name: Some("Apple Inc".to_string()), + exchange: Some("NASDAQ".to_string()), + kind: SecurityKind::Equity, + }, + "AAPL", + "NASDAQ", + None, + ) + .expect("company parse"); + + assert_eq!(company.symbol, "AAPL"); + assert_eq!(company.name, "Apple Inc"); + assert_eq!(company.price, 255.92); + assert_eq!(company.volume, Some(41_190_000)); + assert_eq!(company.volume_label.as_deref(), Some("Avg Volume")); + assert_eq!(company.pe, Some(32.38)); + assert_eq!(company.low52_week, Some(169.21)); + assert_eq!(company.high52_week, Some(288.61)); + assert_eq!( + company + .profile + .as_ref() + .and_then(|profile| profile.ceo.as_deref()), + Some("Tim Cook") + ); + assert_eq!(company.price_chart.as_ref().map(Vec::len), Some(3)); + } + + #[test] + fn parses_localized_numbers() { + assert_eq!(parse_numeric_value("$1,234.56"), Some(1234.56)); + assert_eq!(parse_numeric_value("1.234,56"), Some(1234.56)); + assert_eq!(parse_numeric_value("41,19"), Some(41.19)); + } + + #[test] + fn extracts_embedded_quote_payload_and_supports_partial_quote_returns() { + let html = r#" + +
+ +Caseys General Stores Inc (CASY) Stock Price & News - Google Finance +"#; + + let payload = extract_init_data_payload(html, "ds:1").expect("ds:1 payload"); + assert!(payload.contains("Casey's")); + + let embedded = parse_embedded_quote_data(html).expect("embedded quote"); + assert_eq!(embedded.previous_close, Some(737.16)); + assert_eq!(embedded.volume, Some(368_239)); + assert_eq!(embedded.avg_volume, Some(519_009)); + + let company = parse_company_from_quote_html( + html, + &SecurityMatch { + symbol: "CASY".to_string(), + name: Some("Caseys General Stores Inc".to_string()), + exchange: Some("NASDAQ".to_string()), + kind: SecurityKind::Equity, + }, + "CASY", + "NASDAQ", + None, + ) + .expect("partial company parse"); + + assert_eq!(company.symbol, "CASY"); + assert_eq!(company.price, 743.42); + assert!((company.change - 6.26).abs() < 0.0001); + assert_eq!(company.volume, Some(368_239)); + assert_eq!(company.eps, Some(17.42121)); + } + + #[test] + fn parses_profile_and_intraday_chart() { + let html = r#" + + +"#; + + let profile = parse_company_profile(html).expect("profile"); + assert_eq!(profile.ceo.as_deref(), Some("Darren Rebelez")); + assert_eq!( + profile.headquarters.as_deref(), + Some("Ankeny, Iowa, United States") + ); + assert_eq!(profile.sector.as_deref(), Some("Consumer")); + + let chart = parse_price_chart(html).expect("chart"); + assert_eq!(chart.len(), 3); + assert_eq!(chart[0].label, "09:30"); + assert_eq!(chart[2].price, 743.42); + } + + #[test] + fn decodes_chart_range_map_from_batched_aicwsd_payloads() { + let response = r#" +)]}' + +512 +[["wrb.fr","AiCwsd","[[[[\"CASY\",\"NASDAQ\"],\"/m/07zm6yd\",\"USD\",[[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]],[[[2026,4,2,9,30,null,null,[-14400]],[735.99,-1.17,-0.15,2,2,4],120],[[2026,4,2,9,31,null,null,[-14400]],[743.42,6.26,0.84,2,2,4],131]]]]]]]]",null,null,null,"generic"],["wrb.fr","AiCwsd","[[[[\"CASY\",\"NASDAQ\"],\"/m/07zm6yd\",\"USD\",[[[1,[2026,3,27,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]],[[[2026,3,27,9,30,null,null,[-14400]],[708.15,0,0,2,2,2],3111],[[2026,4,2,16,null,null,null,[-14400]],[743.42,35.27,0.0498,2,2,2],68444]]]]]]]]",null,null,null,"generic"]] +"#; + + let ranges = decode_chart_range_map(response).expect("range map"); + assert_eq!(ranges.get("1D").map(Vec::len), Some(2)); + assert_eq!(ranges.get("5D").map(Vec::len), Some(2)); + assert_eq!( + ranges + .get("1D") + .and_then(|points| points.first()) + .map(|point| point.label.as_str()), + Some("09:30") + ); + assert_eq!( + ranges + .get("5D") + .and_then(|points| points.first()) + .map(|point| point.label.as_str()), + Some("09:30") + ); + } + + #[test] + fn company_cache_ttl_is_non_zero() { + assert!(company_cache_ttl() > std::time::Duration::ZERO); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/mod.rs b/MosaicIQ/src-tauri/src/terminal/mod.rs index 1a3c4de..2edd27c 100644 --- a/MosaicIQ/src-tauri/src/terminal/mod.rs +++ b/MosaicIQ/src-tauri/src/terminal/mod.rs @@ -1,10 +1,15 @@ mod command_service; +pub(crate) mod google_finance; mod mock_data; +pub(crate) mod sec_edgar; +pub(crate) mod security_lookup; mod types; -pub(crate) mod yahoo_finance; pub use command_service::TerminalCommandService; pub use types::{ - ChatCommandRequest, Company, ErrorPanel, ExecuteTerminalCommandRequest, MockFinancialData, - PanelPayload, TerminalCommandResponse, + CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint, + CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod, + ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, + LookupCompanyRequest, MockFinancialData, PanelPayload, SourceStatus, StatementPeriod, + TerminalCommandResponse, }; diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs new file mode 100644 index 0000000..2fe4956 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs @@ -0,0 +1,390 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use futures::future::BoxFuture; +use reqwest::Client; +use tokio::sync::Mutex as AsyncMutex; + +use super::service::EdgarLookupError; +use super::types::{ + CompanyFactsResponse, CompanySubmissions, FilingIndex, ParsedXbrlDocument, 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 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); +const SHORT_CACHE_TTL: Duration = Duration::from_secs(15 * 60); +const REQUEST_SPACING: Duration = Duration::from_millis(125); + +pub(crate) trait SecFetch: Send + Sync { + fn get_text<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result>; + fn get_bytes<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result, EdgarLookupError>>; +} + +pub(crate) trait SecUserAgentProvider: Send + Sync { + fn user_agent(&self) -> Option; +} + +struct EnvSecUserAgentProvider; + +impl SecUserAgentProvider for EnvSecUserAgentProvider { + fn user_agent(&self) -> Option { + std::env::var("SEC_EDGAR_USER_AGENT").ok() + } +} + +pub(crate) struct LiveSecFetcher { + client: Client, + user_agent_provider: std::sync::Arc, + last_request_at: AsyncMutex>, +} + +impl LiveSecFetcher { + pub(crate) fn new(user_agent_provider: std::sync::Arc) -> Self { + Self { + client: Client::new(), + user_agent_provider, + last_request_at: AsyncMutex::new(None), + } + } + + async fn throttle(&self) { + let mut guard = self.last_request_at.lock().await; + if let Some(last_request_at) = *guard { + let elapsed = last_request_at.elapsed(); + if elapsed < REQUEST_SPACING { + tokio::time::sleep(REQUEST_SPACING - elapsed).await; + } + } + *guard = Some(Instant::now()); + } + + fn user_agent(&self) -> Result { + self.user_agent_provider + .user_agent() + .filter(|value| !value.trim().is_empty()) + .ok_or(EdgarLookupError::MissingUserAgent) + } +} + +impl Default for LiveSecFetcher { + fn default() -> Self { + Self::new(std::sync::Arc::new(EnvSecUserAgentProvider)) + } +} + +impl SecFetch for LiveSecFetcher { + fn get_text<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.throttle().await; + let response = self + .client + .get(url) + .header("User-Agent", self.user_agent()?) + .header("Accept-Encoding", "gzip, deflate") + .send() + .await + .map_err(|source| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: source.to_string(), + })? + .error_for_status() + .map_err(|source| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: source.to_string(), + })?; + + response + .text() + .await + .map_err(|source| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: source.to_string(), + }) + }) + } + + fn get_bytes<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result, EdgarLookupError>> { + Box::pin(async move { + self.throttle().await; + let response = self + .client + .get(url) + .header("User-Agent", self.user_agent()?) + .header("Accept-Encoding", "gzip, deflate") + .send() + .await + .map_err(|source| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: source.to_string(), + })? + .error_for_status() + .map_err(|source| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: source.to_string(), + })?; + + response + .bytes() + .await + .map(|value| value.to_vec()) + .map_err(|source| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: source.to_string(), + }) + }) + } +} + +#[derive(Debug, Clone)] +struct CacheEntry { + cached_at: Instant, + value: T, +} + +impl CacheEntry { + fn new(value: T) -> Self { + Self { + cached_at: Instant::now(), + value, + } + } + + fn is_fresh(&self, ttl: Duration) -> bool { + self.cached_at.elapsed() <= ttl + } +} + +pub(crate) struct SecEdgarClient { + fetcher: Box, + tickers_cache: Mutex>>>, + submissions_cache: Mutex>>, + companyfacts_cache: Mutex>>, + filing_index_cache: Mutex>>, + instance_xml_cache: Mutex>>>, + parsed_xbrl_cache: Mutex>>, +} + +impl SecEdgarClient { + pub(crate) fn new(fetcher: Box) -> Self { + Self { + fetcher, + tickers_cache: Mutex::new(None), + submissions_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()), + parsed_xbrl_cache: Mutex::new(HashMap::new()), + } + } + + pub(crate) async fn resolve_company( + &self, + ticker: &str, + ) -> Result { + let normalized = normalize_ticker(ticker); + let directory = self.load_tickers().await?; + + if let Some(company) = directory.get(&normalized) { + return Ok(company.clone()); + } + + let fallback = normalized.replace('.', "-"); + if let Some(company) = directory.get(&fallback) { + return Ok(company.clone()); + } + + let alternate = normalized.replace('-', "."); + if let Some(company) = directory.get(&alternate) { + return Ok(company.clone()); + } + + Err(EdgarLookupError::UnknownTicker { + ticker: ticker.to_string(), + }) + } + + pub(crate) async fn load_submissions( + &self, + cik: &str, + ) -> Result { + if let Some(cached) = get_cached_value(&self.submissions_cache, cik, SHORT_CACHE_TTL) { + return Ok(cached); + } + + let url = format!("{SUBMISSIONS_URL_PREFIX}{cik}.json"); + 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_cache, cik.to_string(), decoded.clone()); + Ok(decoded) + } + + pub(crate) async fn load_companyfacts( + &self, + cik: &str, + ) -> Result { + if let Some(cached) = get_cached_value(&self.companyfacts_cache, cik, SHORT_CACHE_TTL) { + return Ok(cached); + } + + let url = format!("{COMPANYFACTS_URL_PREFIX}{cik}.json"); + 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.companyfacts_cache, cik.to_string(), decoded.clone()); + Ok(decoded) + } + + pub(crate) async fn load_filing_index( + &self, + cik: &str, + accession_number: &str, + ) -> Result { + let cache_key = format!("{cik}:{accession_number}"); + if let Some(cached) = + get_cached_value(&self.filing_index_cache, &cache_key, SHORT_CACHE_TTL) + { + return Ok(cached); + } + + let accession_number_no_dashes = accession_number.replace('-', ""); + let cik_no_zeroes = cik.trim_start_matches('0'); + let url = + format!("{SEC_ARCHIVE_PREFIX}/{cik_no_zeroes}/{accession_number_no_dashes}/index.json"); + 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.filing_index_cache, cache_key, decoded.clone()); + Ok(decoded) + } + + pub(crate) async fn load_instance_xml( + &self, + cik: &str, + accession_number: &str, + filename: &str, + ) -> Result, EdgarLookupError> { + let cache_key = format!("{cik}:{accession_number}:{filename}"); + if let Some(cached) = + get_cached_value(&self.instance_xml_cache, &cache_key, SHORT_CACHE_TTL) + { + return Ok(cached); + } + + let accession_number_no_dashes = accession_number.replace('-', ""); + let cik_no_zeroes = cik.trim_start_matches('0'); + let url = + format!("{SEC_ARCHIVE_PREFIX}/{cik_no_zeroes}/{accession_number_no_dashes}/{filename}"); + let payload = self.fetcher.get_bytes(&url).await?; + store_cached_value(&self.instance_xml_cache, cache_key, payload.clone()); + Ok(payload) + } + + pub(crate) async fn load_parsed_xbrl( + &self, + cik: &str, + accession_number: &str, + filename: &str, + ) -> Result { + let cache_key = format!("{cik}:{accession_number}:{filename}"); + if let Some(cached) = get_cached_value(&self.parsed_xbrl_cache, &cache_key, SHORT_CACHE_TTL) + { + return Ok(cached); + } + + let bytes = self + .load_instance_xml(cik, accession_number, filename) + .await?; + let parsed = parse_xbrl_instance(&bytes)?; + store_cached_value(&self.parsed_xbrl_cache, cache_key, parsed.clone()); + Ok(parsed) + } + + async fn load_tickers(&self) -> Result, EdgarLookupError> { + if let Ok(guard) = self.tickers_cache.lock() { + if let Some(entry) = guard + .as_ref() + .filter(|entry| entry.is_fresh(TICKER_CACHE_TTL)) + { + return Ok(entry.value.clone()); + } + } + + let payload = self.fetcher.get_text(TICKERS_URL).await?; + let decoded = serde_json::from_str::>(&payload) + .map_err(|source| EdgarLookupError::InvalidResponse { + provider: "SEC EDGAR", + detail: source.to_string(), + })?; + + let directory = decoded + .into_values() + .map(|entry| { + ( + normalize_ticker(&entry.ticker), + ResolvedCompany { + ticker: normalize_ticker(&entry.ticker), + company_name: entry.title, + cik: format!("{:010}", entry.cik_str), + }, + ) + }) + .collect::>(); + + if let Ok(mut guard) = self.tickers_cache.lock() { + *guard = Some(CacheEntry::new(directory.clone())); + } + + Ok(directory) + } +} + +impl Default for SecEdgarClient { + fn default() -> Self { + Self::new(Box::new(LiveSecFetcher::default())) + } +} + +fn normalize_ticker(value: &str) -> String { + value.trim().to_ascii_uppercase() +} + +fn get_cached_value( + cache: &Mutex>>, + key: &str, + ttl: Duration, +) -> Option { + let mut guard = cache.lock().ok()?; + match guard.get(key) { + Some(entry) if entry.is_fresh(ttl) => Some(entry.value.clone()), + Some(_) => { + guard.remove(key); + None + } + None => None, + } +} + +fn store_cached_value(cache: &Mutex>>, key: String, value: T) { + if let Ok(mut guard) = cache.lock() { + guard.insert(key, CacheEntry::new(value)); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs new file mode 100644 index 0000000..046d2ac --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs @@ -0,0 +1,747 @@ +use std::collections::{BTreeMap, HashMap}; + +use chrono::NaiveDate; + +use crate::terminal::{ + CashFlowPeriod, DividendEvent, EarningsPeriod, FilingRef, Frequency, SourceStatus, + StatementPeriod, +}; + +use super::service::EdgarLookupError; +use super::types::{ + CompanyConceptFacts, CompanyFactRecord, CompanyFactsResponse, ConceptCandidate, NormalizedFact, + ParsedXbrlDocument, PeriodRow, ResolvedCompany, UnitFamily, +}; + +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"]; + +pub(crate) const REVENUE_CONCEPTS: &[ConceptCandidate] = &[ + 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 GROSS_PROFIT_CONCEPTS: &[ConceptCandidate] = + &[candidate("us-gaap", "GrossProfit", UnitFamily::Currency)]; +pub(crate) const OPERATING_INCOME_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "OperatingIncomeLoss", UnitFamily::Currency), + candidate( + "ifrs-full", + "ProfitLossFromOperatingActivities", + UnitFamily::Currency, + ), +]; +pub(crate) const NET_INCOME_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "NetIncomeLoss", UnitFamily::Currency), + candidate("ifrs-full", "ProfitLoss", UnitFamily::Currency), +]; +pub(crate) const DILUTED_EPS_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "EarningsPerShareDiluted", + UnitFamily::CurrencyPerShare, + ), + candidate( + "ifrs-full", + "BasicAndDilutedEarningsLossPerShare", + UnitFamily::CurrencyPerShare, + ), + candidate( + "ifrs-full", + "DilutedEarningsLossPerShare", + UnitFamily::CurrencyPerShare, + ), +]; +pub(crate) const BASIC_EPS_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "EarningsPerShareBasic", + UnitFamily::CurrencyPerShare, + ), + candidate( + "ifrs-full", + "BasicEarningsLossPerShare", + UnitFamily::CurrencyPerShare, + ), +]; +pub(crate) const CASH_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "CashAndCashEquivalentsAtCarryingValue", + UnitFamily::Currency, + ), + candidate("ifrs-full", "CashAndCashEquivalents", UnitFamily::Currency), +]; +pub(crate) const ASSET_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "Assets", UnitFamily::Currency), + candidate("ifrs-full", "Assets", UnitFamily::Currency), +]; +pub(crate) const LIABILITY_CONCEPTS: &[ConceptCandidate] = &[ + candidate("us-gaap", "Liabilities", UnitFamily::Currency), + candidate("ifrs-full", "Liabilities", UnitFamily::Currency), +]; +pub(crate) const EQUITY_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest", + UnitFamily::Currency, + ), + candidate("us-gaap", "StockholdersEquity", UnitFamily::Currency), + candidate("ifrs-full", "Equity", UnitFamily::Currency), +]; +pub(crate) const SHARES_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "dei", + "EntityCommonStockSharesOutstanding", + UnitFamily::Shares, + ), + candidate( + "us-gaap", + "CommonStockSharesOutstanding", + UnitFamily::Shares, + ), + candidate("ifrs-full", "NumberOfSharesOutstanding", UnitFamily::Shares), +]; +pub(crate) const CFO_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "NetCashProvidedByUsedInOperatingActivities", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "CashFlowsFromUsedInOperatingActivities", + UnitFamily::Currency, + ), +]; +pub(crate) const CFI_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "NetCashProvidedByUsedInInvestingActivities", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "CashFlowsFromUsedInInvestingActivities", + UnitFamily::Currency, + ), +]; +pub(crate) const CFF_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "NetCashProvidedByUsedInFinancingActivities", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "CashFlowsFromUsedInFinancingActivities", + UnitFamily::Currency, + ), +]; +pub(crate) const CAPEX_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "PaymentsToAcquirePropertyPlantAndEquipment", + UnitFamily::Currency, + ), + candidate( + "us-gaap", + "PropertyPlantAndEquipmentAdditions", + UnitFamily::Currency, + ), + candidate( + "ifrs-full", + "PurchaseOfPropertyPlantAndEquipmentClassifiedAsInvestingActivities", + UnitFamily::Currency, + ), +]; +pub(crate) const ENDING_CASH_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents", + UnitFamily::Currency, + ), + candidate( + "us-gaap", + "CashAndCashEquivalentsAtCarryingValue", + UnitFamily::Currency, + ), + candidate("ifrs-full", "CashAndCashEquivalents", UnitFamily::Currency), +]; +pub(crate) const DIVIDEND_PER_SHARE_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "CommonStockDividendsPerShareDeclared", + UnitFamily::CurrencyPerShare, + ), + candidate( + "us-gaap", + "CommonStockDividendsPerShareCashPaid", + UnitFamily::CurrencyPerShare, + ), +]; +pub(crate) const DIVIDEND_CASH_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "PaymentsOfDividendsCommonStock", + UnitFamily::Currency, + ), + candidate("us-gaap", "DividendsCash", UnitFamily::Currency), + candidate( + "ifrs-full", + "DividendsPaidClassifiedAsFinancingActivities", + UnitFamily::Currency, + ), +]; +pub(crate) const DILUTED_SHARES_CONCEPTS: &[ConceptCandidate] = &[ + candidate( + "us-gaap", + "WeightedAverageNumberOfDilutedSharesOutstanding", + UnitFamily::Shares, + ), + candidate( + "ifrs-full", + "WeightedAverageNumberOfSharesOutstandingDiluted", + UnitFamily::Shares, + ), +]; + +const fn candidate( + taxonomy: &'static str, + concept: &'static str, + unit_family: UnitFamily, +) -> ConceptCandidate { + ConceptCandidate { + taxonomy, + concept, + unit_family, + } +} + +pub(crate) fn select_latest_filing( + filings: &[FilingRef], + frequency: Frequency, +) -> Option { + filings + .iter() + .filter(|filing| matches_frequency_form(&filing.form, frequency)) + .cloned() + .max_by(|left, right| compare_filing_priority(left, right)) +} + +pub(crate) fn normalize_all_facts( + company_facts: &CompanyFactsResponse, +) -> Result, EdgarLookupError> { + let mut normalized = Vec::new(); + + for concept in flatten_concepts(company_facts) { + for (unit, records) in concept.units { + let unit_family = classify_unit(unit); + for record in records { + let Some(value) = json_number(&record.val) else { + continue; + }; + + normalized.push(NormalizedFact { + taxonomy: concept.taxonomy, + concept: concept.concept_name.clone(), + unit: unit.to_string(), + unit_family, + value, + filed: record.filed.clone(), + form: record.form.clone(), + fiscal_year: record.fy.as_ref().map(fiscal_year_to_string), + fiscal_period: record.fp.clone(), + period_start: record.start.clone(), + period_end: record.end.clone(), + accession_number: record.accn.clone(), + }); + } + } + } + + if normalized.is_empty() { + return Err(EdgarLookupError::NoFactsAvailable); + } + + Ok(normalized) +} + +pub(crate) fn build_statement_periods( + facts: &[NormalizedFact], + frequency: Frequency, + period_limit: usize, + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Vec { + let rows = build_period_rows(facts, frequency, REVENUE_CONCEPTS, period_limit); + + rows.into_iter() + .enumerate() + .map(|(index, row)| { + let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten(); + StatementPeriod { + label: row.label.clone(), + 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), + gross_profit: value_for_period(facts, &row, GROSS_PROFIT_CONCEPTS, latest_xbrl), + operating_income: value_for_period( + facts, + &row, + OPERATING_INCOME_CONCEPTS, + latest_xbrl, + ), + net_income: value_for_period(facts, &row, NET_INCOME_CONCEPTS, latest_xbrl), + diluted_eps: value_for_period(facts, &row, DILUTED_EPS_CONCEPTS, latest_xbrl), + cash_and_equivalents: value_for_period(facts, &row, CASH_CONCEPTS, latest_xbrl), + total_assets: value_for_period(facts, &row, ASSET_CONCEPTS, latest_xbrl), + total_liabilities: value_for_period(facts, &row, LIABILITY_CONCEPTS, latest_xbrl), + total_equity: value_for_period(facts, &row, EQUITY_CONCEPTS, latest_xbrl), + shares_outstanding: value_for_period(facts, &row, SHARES_CONCEPTS, latest_xbrl), + } + }) + .collect() +} + +pub(crate) fn build_cash_flow_periods( + facts: &[NormalizedFact], + frequency: Frequency, + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Vec { + build_period_rows(facts, frequency, CFO_CONCEPTS, 4) + .into_iter() + .enumerate() + .map(|(index, row)| { + let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten(); + let capex = value_for_period(facts, &row, CAPEX_CONCEPTS, latest_xbrl); + let operating_cash_flow = value_for_period(facts, &row, CFO_CONCEPTS, latest_xbrl); + CashFlowPeriod { + label: row.label.clone(), + 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(), + operating_cash_flow, + investing_cash_flow: value_for_period(facts, &row, CFI_CONCEPTS, latest_xbrl), + financing_cash_flow: value_for_period(facts, &row, CFF_CONCEPTS, latest_xbrl), + capex, + free_cash_flow: match (operating_cash_flow, capex) { + (Some(cfo), Some(capex)) => Some(cfo - capex.abs()), + _ => None, + }, + ending_cash: value_for_period(facts, &row, ENDING_CASH_CONCEPTS, latest_xbrl), + } + }) + .collect() +} + +pub(crate) fn build_dividend_events( + facts: &[NormalizedFact], + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Vec { + let rows = build_period_rows(facts, Frequency::Quarterly, DIVIDEND_PER_SHARE_CONCEPTS, 8); + + let mut events = rows + .into_iter() + .enumerate() + .map(|(index, row)| { + let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten(); + DividendEvent { + end_date: row.period_end.clone(), + filed_date: row.filed_date.clone(), + form: row.form.clone(), + frequency_guess: "unknown".to_string(), + dividend_per_share: value_for_period( + facts, + &row, + DIVIDEND_PER_SHARE_CONCEPTS, + latest_xbrl, + ), + total_cash_dividends: value_for_period( + facts, + &row, + DIVIDEND_CASH_CONCEPTS, + latest_xbrl, + ), + } + }) + .collect::>(); + + for index in 0..events.len() { + let frequency_guess = classify_dividend_frequency( + events.get(index).map(|value| value.end_date.as_str()), + events.get(index + 1).map(|value| value.end_date.as_str()), + ); + if let Some(event) = events.get_mut(index) { + event.frequency_guess = frequency_guess; + } + } + + events +} + +pub(crate) fn build_earnings_periods( + facts: &[NormalizedFact], + frequency: Frequency, + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Vec { + let limit = match frequency { + Frequency::Annual => 4, + Frequency::Quarterly => 8, + }; + + let rows = build_period_rows(facts, frequency, REVENUE_CONCEPTS, limit); + + let mut periods = rows + .into_iter() + .enumerate() + .map(|(index, row)| { + let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten(); + EarningsPeriod { + label: row.label.clone(), + 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, + ), + revenue_yoy_change_percent: None, + diluted_eps_yoy_change_percent: None, + } + }) + .collect::>(); + + let offset = match frequency { + Frequency::Annual => 1, + Frequency::Quarterly => 4, + }; + + for index in 0..periods.len() { + 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.diluted_eps_yoy_change_percent = + calculate_change_percent(current.diluted_eps, previous.diluted_eps); + } + } + } + + periods +} + +pub(crate) fn build_source_status( + latest_xbrl: Result, &EdgarLookupError>, +) -> SourceStatus { + 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()), + }, + } +} + +pub(crate) fn company_name<'a>( + resolved_company: &'a ResolvedCompany, + filings_name: Option<&'a str>, + companyfacts_name: Option<&'a str>, +) -> String { + filings_name + .filter(|value| !value.trim().is_empty()) + .or(companyfacts_name.filter(|value| !value.trim().is_empty())) + .unwrap_or(&resolved_company.company_name) + .to_string() +} + +pub(crate) fn limit_dividend_ttm(events: &[DividendEvent]) -> (Option, Option) { + let total_per_share = events + .iter() + .take(4) + .filter_map(|event| event.dividend_per_share) + .reduce(|acc, value| acc + value); + let total_cash = events + .iter() + .take(4) + .filter_map(|event| event.total_cash_dividends) + .reduce(|acc, value| acc + value); + + (total_per_share, total_cash) +} + +fn flatten_concepts(company_facts: &CompanyFactsResponse) -> Vec> { + let mut concepts = Vec::new(); + concepts.extend(flatten_taxonomy("us-gaap", &company_facts.facts.us_gaap)); + concepts.extend(flatten_taxonomy( + "ifrs-full", + &company_facts.facts.ifrs_full, + )); + concepts.extend(flatten_taxonomy("dei", &company_facts.facts.dei)); + concepts +} + +fn flatten_taxonomy<'a>( + taxonomy: &'static str, + facts: &'a HashMap, +) -> Vec> { + facts + .iter() + .map(|(concept_name, concept)| FlattenedConcept { + taxonomy, + concept_name: concept_name.clone(), + units: &concept.units, + }) + .collect() +} + +struct FlattenedConcept<'a> { + taxonomy: &'static str, + concept_name: String, + units: &'a HashMap>, +} + +fn classify_unit(unit: &str) -> UnitFamily { + let normalized = unit.to_ascii_uppercase(); + if normalized.contains("USD/SHARE") || normalized.contains("USD-PER-SHARES") { + UnitFamily::CurrencyPerShare + } else if normalized == "SHARES" || normalized.ends_with(":SHARES") { + UnitFamily::Shares + } else if normalized == "PURE" { + UnitFamily::Pure + } else { + UnitFamily::Currency + } +} + +fn json_number(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Number(number) => number.as_f64(), + serde_json::Value::String(string) => string.parse::().ok(), + _ => None, + } +} + +fn fiscal_year_to_string(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Number(number) => number.to_string(), + serde_json::Value::String(value) => value.clone(), + _ => String::new(), + } +} + +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) -> std::cmp::Ordering { + left.filing_date + .cmp(&right.filing_date) + .then_with(|| amended_rank(&right.form).cmp(&amended_rank(&left.form))) +} + +fn amended_rank(form: &str) -> usize { + usize::from(form.contains("/A")) +} + +fn build_period_rows( + facts: &[NormalizedFact], + frequency: Frequency, + anchor_concepts: &[ConceptCandidate], + period_limit: usize, +) -> Vec { + let anchored = facts + .iter() + .filter(|fact| is_fact_for_frequency(fact, frequency)) + .filter(|fact| concept_match(anchor_concepts, fact)) + .map(PeriodRow::from_fact) + .collect::>(); + + if !anchored.is_empty() { + return dedupe_rows(anchored, period_limit); + } + + dedupe_rows( + facts + .iter() + .filter(|fact| is_fact_for_frequency(fact, frequency)) + .map(PeriodRow::from_fact) + .collect(), + period_limit, + ) +} + +fn dedupe_rows(rows: Vec, period_limit: usize) -> Vec { + let mut grouped = BTreeMap::::new(); + + for row in rows { + grouped + .entry(format!( + "{}:{}:{}", + row.fiscal_year.clone().unwrap_or_default(), + row.fiscal_period.clone().unwrap_or_default(), + row.period_end + )) + .and_modify(|existing| { + if row.filed_date > existing.filed_date { + *existing = row.clone(); + } + }) + .or_insert(row); + } + + let mut values = grouped.into_values().collect::>(); + values.sort_by(|left, right| right.period_end.cmp(&left.period_end)); + values.truncate(period_limit); + values +} + +fn is_fact_for_frequency(fact: &NormalizedFact, frequency: Frequency) -> bool { + if !matches_frequency_form(&fact.form, frequency) { + return false; + } + + match frequency { + Frequency::Annual => { + fact.fiscal_period.as_deref() == Some("FY") + || duration_days(fact).is_none_or(|days| days >= 250) + || fact.period_start.is_none() + } + Frequency::Quarterly => { + fact.fiscal_period + .as_deref() + .is_some_and(|period| period.starts_with('Q')) + || duration_days(fact).is_none_or(|days| days <= 120) + || fact.period_start.is_none() + } + } +} + +fn duration_days(fact: &NormalizedFact) -> Option { + let start = fact.period_start.as_ref()?; + let start = NaiveDate::parse_from_str(start, "%Y-%m-%d").ok()?; + let end = NaiveDate::parse_from_str(&fact.period_end, "%Y-%m-%d").ok()?; + Some((end - start).num_days().abs()) +} + +fn concept_match(candidates: &[ConceptCandidate], fact: &NormalizedFact) -> bool { + candidates.iter().any(|candidate| { + candidate.taxonomy == fact.taxonomy + && candidate.concept == fact.concept + && candidate.unit_family == fact.unit_family + }) +} + +fn value_for_period( + facts: &[NormalizedFact], + row: &PeriodRow, + concepts: &[ConceptCandidate], + latest_xbrl: Option<&ParsedXbrlDocument>, +) -> Option { + let mut best = facts + .iter() + .filter(|fact| concept_match(concepts, fact)) + .filter(|fact| fact.period_end == row.period_end) + .filter(|fact| fact.fiscal_year == row.fiscal_year || row.fiscal_year.is_none()) + .max_by(|left, right| { + left.filed + .cmp(&right.filed) + .then_with(|| left.accession_number.cmp(&right.accession_number)) + }) + .map(|fact| fact.value); + + if let Some(latest_xbrl) = latest_xbrl { + if let Some(value) = overlay_xbrl_value(latest_xbrl, concepts, &row.period_end) { + best = Some(value); + } + } + + best +} + +fn overlay_xbrl_value( + latest_xbrl: &ParsedXbrlDocument, + concepts: &[ConceptCandidate], + period_end: &str, +) -> Option { + latest_xbrl + .facts + .iter() + .filter(|fact| fact.period_end.as_deref() == Some(period_end)) + .find(|fact| { + concepts.iter().any(|candidate| { + fact.concept + .eq_ignore_ascii_case(&format!("{}:{}", candidate.taxonomy, candidate.concept)) + || fact.concept.eq_ignore_ascii_case(candidate.concept) + }) + }) + .map(|fact| fact.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(); + }; + let Some(previous_end) = previous_end else { + return "unknown".to_string(); + }; + let Ok(current) = NaiveDate::parse_from_str(current_end, "%Y-%m-%d") else { + return "unknown".to_string(); + }; + let Ok(previous) = NaiveDate::parse_from_str(previous_end, "%Y-%m-%d") else { + return "unknown".to_string(); + }; + let spacing = (current - previous).num_days().abs(); + if spacing <= 120 { + "quarterly".to_string() + } else if spacing <= 220 { + "semiannual".to_string() + } else if spacing <= 420 { + "annual".to_string() + } else { + "special".to_string() + } +} + +fn calculate_change_percent(current: Option, previous: Option) -> Option { + let current = current?; + let previous = previous?; + if previous.abs() < f64::EPSILON { + return None; + } + Some(((current - previous) / previous.abs()) * 100.0) +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs new file mode 100644 index 0000000..d30b745 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs @@ -0,0 +1,8 @@ +mod client; +mod facts; +mod service; +mod types; +mod xbrl; + +pub(crate) use client::{LiveSecFetcher, SecEdgarClient, SecUserAgentProvider}; +pub(crate) use service::{EdgarDataLookup, EdgarLookupError, SecEdgarLookup}; diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs new file mode 100644 index 0000000..665c276 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs @@ -0,0 +1,530 @@ +use std::fmt::{Display, Formatter}; +use std::sync::Arc; + +use futures::future::BoxFuture; + +use crate::terminal::{ + CashFlowPanelData, DividendsPanelData, EarningsPanelData, FilingRef, FinancialsPanelData, + Frequency, +}; + +use super::client::SecEdgarClient; +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, +}; +use super::types::CompanySubmissions; +use super::xbrl::pick_instance_document; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EdgarLookupError { + MissingUserAgent, + UnknownTicker { + ticker: String, + }, + NoFactsAvailable, + NoEligibleFilings { + ticker: String, + frequency: Frequency, + }, + RequestFailed { + provider: &'static str, + detail: String, + }, + InvalidResponse { + provider: &'static str, + detail: String, + }, + XbrlParseFailed { + detail: String, + }, +} + +impl Display for EdgarLookupError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingUserAgent => formatter.write_str( + "Set SEC_EDGAR_USER_AGENT to a value like `MosaicIQ admin@example.com`.", + ), + Self::UnknownTicker { ticker } => { + write!(formatter, "No SEC CIK mapping found for {ticker}.") + } + Self::NoFactsAvailable => { + formatter.write_str("SEC companyfacts did not contain matching disclosures.") + } + Self::NoEligibleFilings { ticker, frequency } => write!( + formatter, + "No eligible {} filings were found for {ticker}.", + frequency.as_str() + ), + Self::RequestFailed { detail, .. } + | Self::InvalidResponse { detail, .. } + | Self::XbrlParseFailed { detail } => formatter.write_str(detail), + } + } +} + +pub(crate) trait EdgarDataLookup: Send + Sync { + fn financials<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> BoxFuture<'a, Result>; + + fn cash_flow<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> BoxFuture<'a, Result>; + + fn dividends<'a>( + &'a self, + ticker: &'a str, + ) -> BoxFuture<'a, Result>; + + fn earnings<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> BoxFuture<'a, Result>; +} + +pub(crate) struct SecEdgarLookup { + client: Arc, +} + +impl SecEdgarLookup { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + async fn context_for( + &self, + ticker: &str, + frequency: Frequency, + ) -> Result { + let company = self.client.resolve_company(ticker).await?; + let submissions = self.client.load_submissions(&company.cik).await?; + let companyfacts = self.client.load_companyfacts(&company.cik).await?; + let latest_filing = select_latest_filing(&submissions.filings.recent.rows(), frequency) + .ok_or_else(|| EdgarLookupError::NoEligibleFilings { + ticker: company.ticker.clone(), + frequency, + })?; + + Ok(LookupContext { + company, + submissions, + companyfacts, + latest_filing, + }) + } + + async fn latest_xbrl( + &self, + cik: &str, + filing: &FilingRef, + ) -> Result, EdgarLookupError> { + let index = self + .client + .load_filing_index(cik, &filing.accession_number) + .await?; + let filenames = index + .directory + .item + .into_iter() + .map(|item| item.name) + .collect::>(); + let Some(filename) = pick_instance_document(&filenames) else { + return Ok(None); + }; + let parsed = self + .client + .load_parsed_xbrl(cik, &filing.accession_number, &filename) + .await?; + Ok(Some(parsed)) + } +} + +impl Default for SecEdgarLookup { + fn default() -> Self { + Self::new(Arc::new(SecEdgarClient::default())) + } +} + +impl EdgarDataLookup for SecEdgarLookup { + fn financials<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> 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_statement_periods( + &facts, + frequency, + 4, + latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()), + ); + + Ok(FinancialsPanelData { + 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, + }) + }) + } + + fn cash_flow<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> 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_cash_flow_periods( + &facts, + frequency, + latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()), + ); + + Ok(CashFlowPanelData { + 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, + }) + }) + } + + fn dividends<'a>( + &'a self, + ticker: &'a str, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let context = self.context_for(ticker, Frequency::Quarterly).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 events = build_dividend_events( + &facts, + latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()), + ); + let (ttm_dividends_per_share, ttm_common_dividends_paid) = limit_dividend_ttm(&events); + + Ok(DividendsPanelData { + 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(), + ttm_dividends_per_share, + ttm_common_dividends_paid, + latest_event: events.first().cloned(), + events, + latest_filing: Some(context.latest_filing), + source_status: status, + }) + }) + } + + fn earnings<'a>( + &'a self, + ticker: &'a str, + frequency: Frequency, + ) -> 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, + }) + }) + } +} + +struct LookupContext { + company: super::types::ResolvedCompany, + submissions: CompanySubmissions, + companyfacts: super::types::CompanyFactsResponse, + latest_filing: FilingRef, +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use futures::future::BoxFuture; + + use super::{EdgarDataLookup, EdgarLookupError, SecEdgarLookup}; + use crate::terminal::sec_edgar::client::{SecEdgarClient, SecFetch}; + use crate::terminal::Frequency; + + const TICKERS_URL: &str = "https://www.sec.gov/files/company_tickers.json"; + + struct FixtureFetcher { + text: HashMap, + bytes: HashMap>, + } + + impl SecFetch for FixtureFetcher { + fn get_text<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.text + .get(url) + .cloned() + .ok_or_else(|| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: format!("missing fixture for {url}"), + }) + }) + } + + fn get_bytes<'a>( + &'a self, + url: &'a str, + ) -> BoxFuture<'a, Result, EdgarLookupError>> { + Box::pin(async move { + self.bytes + .get(url) + .cloned() + .ok_or_else(|| EdgarLookupError::RequestFailed { + provider: "SEC EDGAR", + detail: format!("missing fixture for {url}"), + }) + }) + } + } + + fn lookup() -> SecEdgarLookup { + let mut text = HashMap::new(); + let mut bytes = HashMap::new(); + + text.insert( + TICKERS_URL.to_string(), + include_str!("../../../tests/fixtures/sec/company_tickers.json").to_string(), + ); + + add_company( + &mut text, + &mut bytes, + "0000320193", + "0000320193-24-000123", + include_str!("../../../tests/fixtures/sec/aapl/submissions.json"), + include_str!("../../../tests/fixtures/sec/aapl/companyfacts.json"), + include_str!("../../../tests/fixtures/sec/aapl/index_annual.json"), + "aapl-20240928_htm.xml", + include_str!("../../../tests/fixtures/sec/aapl/instance_annual.xml"), + ); + text.insert( + "https://www.sec.gov/Archives/edgar/data/320193/000032019325000010/index.json" + .to_string(), + include_str!("../../../tests/fixtures/sec/aapl/index_quarterly.json").to_string(), + ); + bytes.insert( + "https://www.sec.gov/Archives/edgar/data/320193/000032019325000010/aapl-20241228_htm.xml".to_string(), + include_bytes!("../../../tests/fixtures/sec/aapl/instance_quarterly.xml").to_vec(), + ); + + add_company( + &mut text, + &mut bytes, + "0000789019", + "0000950170-24-087843", + include_str!("../../../tests/fixtures/sec/msft/submissions.json"), + include_str!("../../../tests/fixtures/sec/msft/companyfacts.json"), + include_str!("../../../tests/fixtures/sec/msft/index.json"), + "msft-20240630_htm.xml", + include_str!("../../../tests/fixtures/sec/msft/instance.xml"), + ); + add_company( + &mut text, + &mut bytes, + "0000021344", + "0000021344-25-000010", + include_str!("../../../tests/fixtures/sec/ko/submissions.json"), + include_str!("../../../tests/fixtures/sec/ko/companyfacts.json"), + include_str!("../../../tests/fixtures/sec/ko/index.json"), + "ko-20250328_htm.xml", + include_str!("../../../tests/fixtures/sec/ko/instance.xml"), + ); + add_company( + &mut text, + &mut bytes, + "0001045810", + "0001045810-25-000020", + include_str!("../../../tests/fixtures/sec/nvda/submissions.json"), + include_str!("../../../tests/fixtures/sec/nvda/companyfacts.json"), + include_str!("../../../tests/fixtures/sec/nvda/index.json"), + "nvda-20250427_htm.xml", + include_str!("../../../tests/fixtures/sec/nvda/instance.xml"), + ); + add_company( + &mut text, + &mut bytes, + "0001000184", + "0001000184-24-000001", + include_str!("../../../tests/fixtures/sec/sap/submissions.json"), + include_str!("../../../tests/fixtures/sec/sap/companyfacts.json"), + include_str!("../../../tests/fixtures/sec/sap/index.json"), + "sap-20231231_htm.xml", + include_str!("../../../tests/fixtures/sec/sap/instance.xml"), + ); + + SecEdgarLookup::new(Arc::new(SecEdgarClient::new(Box::new(FixtureFetcher { + text, + bytes, + })))) + } + + fn add_company( + text: &mut HashMap, + bytes: &mut HashMap>, + cik: &str, + accession_number: &str, + submissions: &str, + companyfacts: &str, + index: &str, + xml_name: &str, + xml: &str, + ) { + text.insert( + format!("https://data.sec.gov/submissions/CIK{cik}.json"), + submissions.to_string(), + ); + text.insert( + format!("https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"), + companyfacts.to_string(), + ); + text.insert( + format!( + "https://www.sec.gov/Archives/edgar/data/{}/{}/index.json", + cik.trim_start_matches('0'), + accession_number.replace('-', "") + ), + index.to_string(), + ); + bytes.insert( + format!( + "https://www.sec.gov/Archives/edgar/data/{}/{}/{}", + cik.trim_start_matches('0'), + accession_number.replace('-', ""), + xml_name + ), + xml.as_bytes().to_vec(), + ); + } + + #[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] + fn financials_supports_quarterly_periods() { + let data = futures::executor::block_on(lookup().financials("AAPL", Frequency::Quarterly)) + .expect("quarterly financials should load"); + assert_eq!(data.periods.len(), 4); + assert_eq!(data.periods[0].revenue, Some(125000000000.0)); + } + + #[test] + fn cash_flow_computes_free_cash_flow() { + let data = futures::executor::block_on(lookup().cash_flow("MSFT", Frequency::Annual)) + .expect("cash flow should load"); + assert_eq!(data.periods[0].operating_cash_flow, Some(120000000000.0)); + assert_eq!(data.periods[0].free_cash_flow, Some(75523000000.0)); + } + + #[test] + fn dividends_returns_history_and_ttm_summary() { + let data = + futures::executor::block_on(lookup().dividends("KO")).expect("dividends should load"); + assert_eq!(data.events.len(), 8); + let ttm_dividends_per_share = data.ttm_dividends_per_share.unwrap_or_default(); + assert!((ttm_dividends_per_share - 1.985).abs() < 0.000_1); + assert_eq!( + data.latest_event + .as_ref() + .map(|value| value.frequency_guess.as_str()), + Some("quarterly") + ); + } + + #[test] + fn earnings_returns_yoy_deltas() { + let data = futures::executor::block_on(lookup().earnings("NVDA", Frequency::Quarterly)) + .expect("earnings should load"); + assert_eq!(data.periods.len(), 8); + assert_eq!(data.periods[0].revenue, Some(26100000000.0)); + assert!( + data.periods[0] + .revenue_yoy_change_percent + .unwrap_or_default() + > 250.0 + ); + } + + #[test] + fn ifrs_financials_fallback_works() { + let data = futures::executor::block_on(lookup().financials("SAP", Frequency::Annual)) + .expect("ifrs annual financials should load"); + assert_eq!(data.periods[0].revenue, Some(35200000000.0)); + assert_eq!(data.periods[0].net_income, Some(6200000000.0)); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs new file mode 100644 index 0000000..6205845 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs @@ -0,0 +1,199 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +use crate::terminal::FilingRef; + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct TickerDirectoryEntry { + pub cik_str: u64, + pub ticker: String, + pub title: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedCompany { + pub ticker: String, + pub company_name: String, + pub cik: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CompanySubmissions { + pub name: String, + pub tickers: Vec, + pub filings: CompanyFilings, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct CompanyFilings { + pub recent: RecentFilings, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecentFilings { + pub accession_number: Vec, + pub filing_date: Vec, + pub report_date: Vec>, + pub form: Vec, + pub primary_document: Vec, +} + +impl RecentFilings { + pub(crate) fn rows(&self) -> Vec { + self.accession_number + .iter() + .enumerate() + .map(|(index, accession_number)| FilingRef { + accession_number: accession_number.clone(), + filing_date: self + .filing_date + .get(index) + .cloned() + .unwrap_or_else(|| "0000-00-00".to_string()), + report_date: self.report_date.get(index).cloned().flatten(), + form: self.form.get(index).cloned().unwrap_or_default(), + primary_document: self.primary_document.get(index).cloned(), + }) + .collect() + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CompanyFactsResponse { + pub cik: u64, + pub entity_name: String, + pub facts: TaxonomyFacts, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub(crate) struct TaxonomyFacts { + #[serde(rename = "us-gaap", default)] + pub us_gaap: HashMap, + #[serde(rename = "ifrs-full", default)] + pub ifrs_full: HashMap, + #[serde(rename = "dei", default)] + pub dei: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct CompanyConceptFacts { + #[serde(default)] + pub label: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub units: HashMap>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CompanyFactRecord { + pub end: String, + #[serde(default)] + pub start: Option, + pub val: serde_json::Value, + pub filed: String, + pub form: String, + #[serde(default)] + pub fy: Option, + #[serde(default)] + pub fp: Option, + #[serde(default)] + pub frame: Option, + #[serde(default)] + pub accn: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct FilingIndex { + pub directory: FilingIndexDirectory, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct FilingIndexDirectory { + pub item: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct FilingIndexItem { + pub name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnitFamily { + Currency, + CurrencyPerShare, + Shares, + Pure, +} + +#[derive(Debug, Clone)] +pub(crate) struct NormalizedFact { + pub taxonomy: &'static str, + pub concept: String, + pub unit: String, + pub unit_family: UnitFamily, + pub value: f64, + pub filed: String, + pub form: String, + pub fiscal_year: Option, + pub fiscal_period: Option, + pub period_start: Option, + pub period_end: String, + pub accession_number: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct PeriodRow { + pub 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, +} + +impl PeriodRow { + pub(crate) fn from_fact(fact: &NormalizedFact) -> Self { + let label = match (fact.fiscal_year.as_deref(), fact.fiscal_period.as_deref()) { + (Some(year), Some(period)) if period != "FY" => format!("{year} {period}"), + (Some(year), _) => year.to_string(), + _ => fact.period_end.clone(), + }; + + Self { + label, + fiscal_year: fact.fiscal_year.clone(), + fiscal_period: fact.fiscal_period.clone(), + period_start: fact.period_start.clone(), + period_end: fact.period_end.clone(), + filed_date: fact.filed.clone(), + form: fact.form.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LatestXbrlFact { + pub concept: String, + pub unit: Option, + pub value: f64, + pub period_end: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ParsedXbrlDocument { + pub facts: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ConceptCandidate { + pub taxonomy: &'static str, + pub concept: &'static str, + pub unit_family: UnitFamily, +} diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs new file mode 100644 index 0000000..54917a9 --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs @@ -0,0 +1,188 @@ +use std::collections::HashMap; + +use crabrl::Parser; +use quick_xml::events::Event; +use quick_xml::Reader; + +use super::service::EdgarLookupError; +use super::types::{LatestXbrlFact, ParsedXbrlDocument}; + +#[derive(Debug, Clone, Default)] +struct ParsedContext { + start: Option, + end: Option, + instant: Option, +} + +pub(crate) fn pick_instance_document(candidates: &[String]) -> Option { + let preferred = candidates.iter().find(|name| name.ends_with("_htm.xml")); + if let Some(value) = preferred { + return Some(value.clone()); + } + + let preferred = candidates.iter().find(|name| { + name.ends_with(".xml") + && !name.contains("_cal") + && !name.contains("_def") + && !name.contains("_lab") + && !name.contains("_pre") + && !name.contains("schema") + }); + if let Some(value) = preferred { + return Some(value.clone()); + } + + candidates + .iter() + .find(|name| name.ends_with(".xml")) + .cloned() +} + +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); + + let mut contexts = HashMap::::new(); + let mut units = HashMap::::new(); + let mut current_context: Option = None; + let mut current_context_kind: Option<&'static str> = None; + let mut current_unit: Option = None; + let mut unit_parts: Vec = Vec::new(); + + let mut facts = Vec::new(); + let mut buffer = Vec::new(); + + loop { + match reader.read_event_into(&mut buffer) { + Ok(Event::Start(element)) => { + let name = element.name().as_ref().to_vec(); + let name = String::from_utf8_lossy(&name).to_string(); + + if name.ends_with("context") { + current_context = attribute_value(&element, b"id"); + current_context_kind = None; + } else if name.ends_with("startDate") { + current_context_kind = Some("start"); + } else if name.ends_with("endDate") { + current_context_kind = Some("end"); + } else if name.ends_with("instant") { + current_context_kind = Some("instant"); + } else if name.ends_with("unit") { + current_unit = attribute_value(&element, b"id"); + unit_parts.clear(); + } else if name.ends_with("measure") { + current_context_kind = Some("measure"); + } else if is_fact_candidate(&name) { + let Some(context_ref) = attribute_value(&element, b"contextRef") else { + buffer.clear(); + continue; + }; + let unit_ref = attribute_value(&element, b"unitRef"); + let text = reader + .read_text(element.name()) + .map_err(|source| EdgarLookupError::XbrlParseFailed { + detail: source.to_string(), + })? + .trim() + .to_string(); + + if let Ok(value) = text.parse::() { + let context = contexts.get(&context_ref).cloned().unwrap_or_default(); + facts.push(LatestXbrlFact { + concept: strip_namespace(&name), + unit: unit_ref.and_then(|reference| units.get(&reference).cloned()), + value, + period_end: context.end.or(context.instant), + }); + } + } + } + Ok(Event::Text(text)) => { + let text = String::from_utf8_lossy(text.as_ref()).trim().to_string(); + if text.is_empty() { + buffer.clear(); + continue; + } + + match current_context_kind { + Some("start") => { + if let Some(context_id) = current_context.as_ref() { + contexts.entry(context_id.clone()).or_default().start = Some(text); + } + } + Some("end") => { + if let Some(context_id) = current_context.as_ref() { + contexts.entry(context_id.clone()).or_default().end = Some(text); + } + } + Some("instant") => { + if let Some(context_id) = current_context.as_ref() { + contexts.entry(context_id.clone()).or_default().instant = Some(text); + } + } + Some("measure") => unit_parts.push(strip_namespace(&text)), + _ => {} + } + } + Ok(Event::End(element)) => { + let name = element.name().as_ref().to_vec(); + let name = String::from_utf8_lossy(&name).to_string(); + if name.ends_with("context") { + current_context = None; + current_context_kind = None; + } else if name.ends_with("unit") { + if let Some(unit_id) = current_unit.take() { + units.insert(unit_id, unit_parts.join("/")); + } + unit_parts.clear(); + } else if name.ends_with("startDate") + || name.ends_with("endDate") + || name.ends_with("instant") + || name.ends_with("measure") + { + current_context_kind = None; + } + } + Ok(Event::Eof) => break, + Err(source) => { + return Err(EdgarLookupError::XbrlParseFailed { + detail: source.to_string(), + }) + } + _ => {} + } + + buffer.clear(); + } + + Ok(ParsedXbrlDocument { facts }) +} + +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 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") + && !name.ends_with("measure") + && !name.ends_with("identifier") + && !name.ends_with("segment") + && !name.ends_with("entity") + && !name.ends_with("period") + && name.contains(':') +} diff --git a/MosaicIQ/src-tauri/src/terminal/security_lookup.rs b/MosaicIQ/src-tauri/src/terminal/security_lookup.rs new file mode 100644 index 0000000..d1c19ed --- /dev/null +++ b/MosaicIQ/src-tauri/src/terminal/security_lookup.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use futures::future::BoxFuture; + +use crate::terminal::Company; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SecurityKind { + Equity, + Fund, + Other(String), +} + +impl SecurityKind { + #[must_use] + pub(crate) const fn is_supported(&self) -> bool { + matches!(self, Self::Equity | Self::Fund) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SecurityMatch { + pub symbol: String, + pub name: Option, + pub exchange: Option, + pub kind: SecurityKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SecurityLookupError { + SearchUnavailable { query: String, detail: String }, + DetailUnavailable { symbol: String, detail: String }, +} + +pub(crate) trait SecurityLookup: Send + Sync { + fn provider_name(&self) -> &'static str; + + fn search<'a>( + &'a self, + query: &'a str, + ) -> BoxFuture<'a, Result, SecurityLookupError>>; + + fn load_company<'a>( + &'a self, + security_match: &'a SecurityMatch, + ) -> BoxFuture<'a, Result>; +} + +#[derive(Debug, Clone)] +pub(crate) struct CacheEntry { + cached_at: Instant, + value: T, +} + +impl CacheEntry { + #[must_use] + pub(crate) fn new(value: T) -> Self { + Self { + cached_at: Instant::now(), + value, + } + } + + #[must_use] + pub(crate) fn is_fresh(&self, ttl: Duration) -> bool { + self.cached_at.elapsed() <= ttl + } + + #[must_use] + pub(crate) fn is_within(&self, max_age: Duration) -> bool { + self.cached_at.elapsed() <= max_age + } + + #[must_use] + pub(crate) fn cloned_value(&self) -> T + where + T: Clone, + { + self.value.clone() + } +} + +pub(crate) fn get_cached_value( + cache: &Mutex>>, + key: &str, + ttl: Duration, +) -> Option { + let mut guard = cache.lock().ok()?; + + match guard.get(key) { + Some(entry) if entry.is_fresh(ttl) => Some(entry.value.clone()), + Some(_) => { + guard.remove(key); + None + } + None => None, + } +} + +pub(crate) fn get_cached_value_within( + cache: &Mutex>>, + key: &str, + max_age: Duration, +) -> Option { + let guard = cache.lock().ok()?; + guard + .get(key) + .filter(|entry| entry.is_within(max_age)) + .map(|entry| entry.value.clone()) +} + +pub(crate) fn store_cached_value( + cache: &Mutex>>, + key: String, + value: T, +) { + if let Ok(mut guard) = cache.lock() { + guard.insert(key, CacheEntry::new(value)); + } +} + +#[must_use] +pub(crate) fn normalize_search_query(query: &str) -> String { + query.trim().to_ascii_lowercase() +} + +#[must_use] +pub(crate) fn normalize_symbol(symbol: &str) -> String { + symbol.trim().to_ascii_uppercase() +} + +#[derive(Debug)] +pub(crate) struct RequestGate { + next_allowed_at: Instant, + min_spacing: Duration, + min_jitter: Duration, + max_jitter: Duration, +} + +impl RequestGate { + #[must_use] + pub(crate) fn new(min_spacing: Duration, min_jitter: Duration, max_jitter: Duration) -> Self { + Self { + next_allowed_at: Instant::now(), + min_spacing, + min_jitter, + max_jitter, + } + } + + #[must_use] + pub(crate) fn reserve_slot(&mut self) -> Duration { + let now = Instant::now(); + let scheduled_at = self.next_allowed_at.max(now); + self.next_allowed_at = scheduled_at + self.min_spacing + self.jitter(); + scheduled_at.saturating_duration_since(now) + } + + pub(crate) fn extend_cooldown(&mut self, cooldown: Duration) { + self.next_allowed_at = self + .next_allowed_at + .max(Instant::now() + cooldown + self.jitter()); + } + + #[must_use] + fn jitter(&self) -> Duration { + if self.max_jitter <= self.min_jitter { + return self.min_jitter; + } + + let span_ms = self.max_jitter.saturating_sub(self.min_jitter).as_millis() as u64; + if span_ms == 0 { + return self.min_jitter; + } + + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos() as u64) + .unwrap_or(0); + + self.min_jitter + Duration::from_millis(seed % (span_ms + 1)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + use super::{ + get_cached_value, get_cached_value_within, normalize_search_query, normalize_symbol, + store_cached_value, CacheEntry, RequestGate, + }; + + #[test] + fn normalizes_cache_keys() { + assert_eq!(normalize_search_query(" MicRoSoft "), "microsoft"); + assert_eq!(normalize_symbol(" msft "), "MSFT"); + } + + #[test] + fn returns_stale_entries_within_extended_window() { + let cache = Mutex::new(HashMap::from([( + "msft".to_string(), + CacheEntry { + cached_at: Instant::now() - Duration::from_secs(120), + value: "cached".to_string(), + }, + )])); + + assert_eq!( + get_cached_value_within(&cache, "msft", Duration::from_secs(180)), + Some("cached".to_string()) + ); + assert_eq!( + get_cached_value(&cache, "msft", Duration::from_secs(60)), + None + ); + } + + #[test] + fn stores_and_returns_fresh_entries() { + let cache = Mutex::new(HashMap::new()); + store_cached_value(&cache, "msft".to_string(), "fresh".to_string()); + + assert_eq!( + get_cached_value(&cache, "msft", Duration::from_secs(60)), + Some("fresh".to_string()) + ); + } + + #[test] + fn cooldown_pushes_request_gate_forward() { + let mut gate = RequestGate::new( + Duration::from_millis(1_500), + Duration::from_millis(0), + Duration::from_millis(0), + ); + + assert!(gate.reserve_slot() <= Duration::from_millis(50)); + gate.extend_cooldown(Duration::from_secs(1)); + assert!(gate.reserve_slot() >= Duration::from_millis(900)); + } +} diff --git a/MosaicIQ/src-tauri/src/terminal/types.rs b/MosaicIQ/src-tauri/src/terminal/types.rs index 607a12d..402561c 100644 --- a/MosaicIQ/src-tauri/src/terminal/types.rs +++ b/MosaicIQ/src-tauri/src/terminal/types.rs @@ -12,6 +12,14 @@ pub struct ExecuteTerminalCommandRequest { pub input: String, } +/// Frontend request payload for direct live company lookup by ticker symbol. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LookupCompanyRequest { + /// Symbol to resolve through the live quote provider. + pub symbol: String, +} + /// Parsed slash command used internally by the backend command service. #[derive(Debug, Clone, PartialEq)] pub struct ChatCommandRequest { @@ -53,6 +61,18 @@ pub enum PanelPayload { Analysis { data: StockAnalysis, }, + Financials { + data: FinancialsPanelData, + }, + CashFlow { + data: CashFlowPanelData, + }, + Dividends { + data: DividendsPanelData, + }, + Earnings { + data: EarningsPanelData, + }, } /// Structured error payload rendered as a terminal card. @@ -77,11 +97,56 @@ pub struct Company { pub change: f64, pub change_percent: f64, pub market_cap: f64, - pub volume: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub pe: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub eps: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub high52_week: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub low52_week: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price_chart: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub price_chart_ranges: Option>>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyProfile { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wiki_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ceo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headquarters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub employees: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub founded: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPricePoint { + pub label: String, + pub price: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, } /// Portfolio holding row. @@ -147,3 +212,158 @@ pub struct MockFinancialData { pub news_items: Vec, pub analyses: HashMap, } + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Frequency { + Annual, + Quarterly, +} + +impl Frequency { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Annual => "annual", + Self::Quarterly => "quarterly", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FilingRef { + pub accession_number: String, + pub filing_date: String, + pub report_date: Option, + pub form: String, + pub primary_document: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SourceStatus { + pub companyfacts_used: bool, + pub latest_xbrl_parsed: bool, + pub degraded_reason: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StatementPeriod { + pub 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 revenue: Option, + pub gross_profit: Option, + pub operating_income: Option, + pub net_income: Option, + pub diluted_eps: Option, + pub cash_and_equivalents: Option, + pub total_assets: Option, + pub total_liabilities: Option, + pub total_equity: Option, + pub shares_outstanding: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CashFlowPeriod { + pub 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 operating_cash_flow: Option, + pub investing_cash_flow: Option, + pub financing_cash_flow: Option, + pub capex: Option, + pub free_cash_flow: Option, + pub ending_cash: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DividendEvent { + pub end_date: String, + pub filed_date: String, + pub form: String, + pub frequency_guess: String, + pub dividend_per_share: Option, + pub total_cash_dividends: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EarningsPeriod { + pub 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 revenue: Option, + pub net_income: Option, + pub basic_eps: Option, + pub diluted_eps: Option, + pub diluted_weighted_average_shares: Option, + pub revenue_yoy_change_percent: Option, + pub diluted_eps_yoy_change_percent: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FinancialsPanelData { + pub symbol: String, + pub company_name: String, + pub cik: String, + pub frequency: Frequency, + pub periods: Vec, + pub latest_filing: Option, + pub source_status: SourceStatus, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CashFlowPanelData { + pub symbol: String, + pub company_name: String, + pub cik: String, + pub frequency: Frequency, + pub periods: Vec, + pub latest_filing: Option, + pub source_status: SourceStatus, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DividendsPanelData { + pub symbol: String, + pub company_name: String, + pub cik: String, + pub ttm_dividends_per_share: Option, + pub ttm_common_dividends_paid: Option, + pub latest_event: Option, + pub events: Vec, + pub latest_filing: Option, + pub source_status: SourceStatus, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EarningsPanelData { + pub symbol: String, + pub company_name: String, + pub cik: String, + pub frequency: Frequency, + pub periods: Vec, + pub latest_filing: Option, + pub source_status: SourceStatus, +} diff --git a/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs b/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs deleted file mode 100644 index 4f09156..0000000 --- a/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs +++ /dev/null @@ -1,438 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -use futures::future::BoxFuture; -use reqwest::Client; -use serde::Deserialize; -use yfinance_rs::{CacheMode, SearchBuilder, YfClient}; - -use crate::terminal::Company; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum SecurityKind { - Equity, - Fund, - Other(String), -} - -impl SecurityKind { - #[must_use] - pub(crate) const fn is_supported(&self) -> bool { - matches!(self, Self::Equity | Self::Fund) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SecurityMatch { - pub symbol: String, - pub name: Option, - pub exchange: Option, - pub kind: SecurityKind, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum SecurityLookupError { - SearchUnavailable { query: String, detail: String }, - DetailUnavailable { symbol: String, detail: String }, -} - -pub(crate) trait SecurityLookup: Send + Sync { - fn search<'a>( - &'a self, - query: &'a str, - ) -> BoxFuture<'a, Result, SecurityLookupError>>; - - fn load_company<'a>( - &'a self, - security_match: &'a SecurityMatch, - ) -> BoxFuture<'a, Result>; -} - -pub(crate) struct YahooFinanceLookup { - client: YfClient, - http_client: Client, - search_cache: Mutex>>>, - company_cache: Mutex>>, -} - -impl Default for YahooFinanceLookup { - fn default() -> Self { - Self { - client: YfClient::default(), - http_client: Client::new(), - search_cache: Mutex::new(HashMap::new()), - company_cache: Mutex::new(HashMap::new()), - } - } -} - -impl SecurityLookup for YahooFinanceLookup { - fn search<'a>( - &'a self, - query: &'a str, - ) -> BoxFuture<'a, Result, SecurityLookupError>> { - Box::pin(async move { - let normalized_query = normalize_search_query(query); - if let Some(cached_matches) = self.get_cached_search(&normalized_query) { - return Ok(cached_matches); - } - - let response = SearchBuilder::new(&self.client, query) - .quotes_count(10) - .news_count(0) - .lists_count(0) - .lang("en-US") - .region("US") - .cache_mode(CacheMode::Bypass) - .fetch() - .await - .map_err(|error| SecurityLookupError::SearchUnavailable { - query: query.to_string(), - detail: error.to_string(), - })?; - - let matches = response - .results - .into_iter() - .map(|result| { - let kind = result.kind.to_string(); - - SecurityMatch { - symbol: result.symbol.to_string(), - name: result.name, - exchange: result.exchange.map(|exchange| exchange.to_string()), - kind: match kind.as_str() { - "EQUITY" => SecurityKind::Equity, - "FUND" => SecurityKind::Fund, - _ => SecurityKind::Other(kind), - }, - } - }) - .collect::>(); - - self.store_search_cache(normalized_query, matches.clone()); - - Ok(matches) - }) - } - - fn load_company<'a>( - &'a self, - security_match: &'a SecurityMatch, - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let cache_key = normalize_symbol(&security_match.symbol); - if let Some(cached_company) = self.get_cached_company(&cache_key) { - return Ok(cached_company); - } - - let detail_error = |detail: String| SecurityLookupError::DetailUnavailable { - symbol: security_match.symbol.clone(), - detail, - }; - - let quote = self.fetch_live_quote(&security_match.symbol).await?; - let company = map_live_quote_to_company(security_match, quote).ok_or_else(|| { - detail_error( - "Yahoo Finance returned quote data without a regular market price.".to_string(), - ) - })?; - - self.store_company_cache(cache_key, company.clone()); - - Ok(company) - }) - } -} - -impl YahooFinanceLookup { - async fn fetch_live_quote( - &self, - symbol: &str, - ) -> Result { - let response = self - .http_client - .get("https://query1.finance.yahoo.com/v7/finance/quote") - .query(&[("symbols", symbol)]) - .send() - .await - .map_err(|error| SecurityLookupError::DetailUnavailable { - symbol: symbol.to_string(), - detail: error.to_string(), - })? - .error_for_status() - .map_err(|error| SecurityLookupError::DetailUnavailable { - symbol: symbol.to_string(), - detail: error.to_string(), - })?; - - let envelope = response - .json::() - .await - .map_err(|error| SecurityLookupError::DetailUnavailable { - symbol: symbol.to_string(), - detail: error.to_string(), - })?; - - envelope - .quote_response - .result - .into_iter() - .find(|quote| quote.symbol.eq_ignore_ascii_case(symbol)) - .ok_or_else(|| SecurityLookupError::DetailUnavailable { - symbol: symbol.to_string(), - detail: format!("Yahoo Finance returned no quote rows for \"{symbol}\"."), - }) - } - - fn get_cached_search(&self, key: &str) -> Option> { - get_cached_value(&self.search_cache, key, SEARCH_CACHE_TTL) - } - - fn store_search_cache(&self, key: String, value: Vec) { - store_cached_value(&self.search_cache, key, value); - } - - fn get_cached_company(&self, key: &str) -> Option { - get_cached_value(&self.company_cache, key, COMPANY_CACHE_TTL) - } - - fn store_company_cache(&self, key: String, value: Company) { - store_cached_value(&self.company_cache, key, value); - } -} - -const SEARCH_CACHE_TTL: Duration = Duration::from_secs(4 * 60 * 60); -const COMPANY_CACHE_TTL: Duration = Duration::from_secs(4 * 60 * 60); - -#[derive(Debug, Clone)] -struct CacheEntry { - cached_at: Instant, - value: T, -} - -impl CacheEntry { - fn new(value: T) -> Self { - Self { - cached_at: Instant::now(), - value, - } - } - - fn is_fresh(&self, ttl: Duration) -> bool { - self.cached_at.elapsed() <= ttl - } -} - -fn get_cached_value( - cache: &Mutex>>, - key: &str, - ttl: Duration, -) -> Option { - let mut guard = cache.lock().ok()?; - - match guard.get(key) { - Some(entry) if entry.is_fresh(ttl) => Some(entry.value.clone()), - Some(_) => { - guard.remove(key); - None - } - None => None, - } -} - -fn store_cached_value(cache: &Mutex>>, key: String, value: T) { - if let Ok(mut guard) = cache.lock() { - guard.insert(key, CacheEntry::new(value)); - } -} - -fn normalize_search_query(query: &str) -> String { - query.trim().to_ascii_lowercase() -} - -fn normalize_symbol(symbol: &str) -> String { - symbol.trim().to_ascii_uppercase() -} - -// Map the full quote payload directly so the selected Yahoo Finance match can render in one card. -fn map_live_quote_to_company( - security_match: &SecurityMatch, - quote: YahooQuoteResult, -) -> Option { - let price = quote.regular_market_price?; - let previous_close = quote.regular_market_previous_close.unwrap_or(price); - let change = price - previous_close; - let change_percent = if previous_close > 0.0 { - (change / previous_close) * 100.0 - } else { - 0.0 - }; - - let name = quote - .long_name - .or(quote.short_name) - .or_else(|| security_match.name.clone()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| security_match.symbol.clone()); - - Some(Company { - symbol: security_match.symbol.clone(), - name, - price, - change, - change_percent, - market_cap: quote.market_cap.unwrap_or(0.0), - volume: quote.regular_market_volume.unwrap_or(0), - pe: quote.trailing_pe, - eps: quote.eps_trailing_twelve_months, - high52_week: quote.fifty_two_week_high, - low52_week: quote.fifty_two_week_low, - }) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct YahooQuoteEnvelope { - quote_response: YahooQuoteResponse, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct YahooQuoteResponse { - result: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct YahooQuoteResult { - symbol: String, - short_name: Option, - long_name: Option, - regular_market_price: Option, - regular_market_previous_close: Option, - regular_market_volume: Option, - market_cap: Option, - trailing_pe: Option, - eps_trailing_twelve_months: Option, - fifty_two_week_high: Option, - fifty_two_week_low: Option, -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::Mutex; - use std::time::{Duration, Instant}; - - use super::{ - get_cached_value, map_live_quote_to_company, normalize_search_query, normalize_symbol, - store_cached_value, CacheEntry, SecurityKind, SecurityMatch, YahooQuoteResult, - }; - - #[test] - fn maps_company_panel_shape_from_single_live_quote_response() { - let security_match = SecurityMatch { - symbol: "CASEY".to_string(), - name: Some("Fallback Name".to_string()), - exchange: Some("NASDAQ".to_string()), - kind: SecurityKind::Equity, - }; - - let company = map_live_quote_to_company( - &security_match, - YahooQuoteResult { - symbol: "CASEY".to_string(), - short_name: Some("Casey's".to_string()), - long_name: Some("Casey's General Stores, Inc.".to_string()), - regular_market_price: Some(743.42), - regular_market_previous_close: Some(737.16), - regular_market_volume: Some(363_594), - market_cap: Some(27_650_000_000.0), - trailing_pe: Some(33.4), - eps_trailing_twelve_months: Some(22.28), - fifty_two_week_high: Some(751.24), - fifty_two_week_low: Some(313.89), - }, - ) - .expect("quote should map into a company"); - - assert_eq!(company.symbol, "CASEY"); - assert_eq!(company.name, "Casey's General Stores, Inc."); - assert_eq!(company.price, 743.42); - assert!((company.change - 6.26).abs() < 0.0001); - assert!((company.change_percent - 0.849205).abs() < 0.0001); - assert_eq!(company.market_cap, 27_650_000_000.0); - assert_eq!(company.volume, 363_594); - assert_eq!(company.pe, Some(33.4)); - assert_eq!(company.eps, Some(22.28)); - assert_eq!(company.high52_week, Some(751.24)); - assert_eq!(company.low52_week, Some(313.89)); - } - - #[test] - fn falls_back_to_security_match_name_when_quote_name_is_missing() { - let security_match = SecurityMatch { - symbol: "ABC".to_string(), - name: Some("Fallback Name".to_string()), - exchange: Some("NYSE".to_string()), - kind: SecurityKind::Equity, - }; - - let company = map_live_quote_to_company( - &security_match, - YahooQuoteResult { - symbol: "ABC".to_string(), - short_name: None, - long_name: None, - regular_market_price: Some(100.0), - regular_market_previous_close: None, - regular_market_volume: None, - market_cap: None, - trailing_pe: None, - eps_trailing_twelve_months: None, - fifty_two_week_high: None, - fifty_two_week_low: None, - }, - ) - .expect("quote should map into a company"); - - assert_eq!(company.name, "Fallback Name"); - assert_eq!(company.change, 0.0); - assert_eq!(company.change_percent, 0.0); - assert_eq!(company.market_cap, 0.0); - } - - #[test] - fn normalizes_cache_keys_for_queries_and_symbols() { - assert_eq!(normalize_search_query(" CaSy "), "casy"); - assert_eq!(normalize_symbol(" casy "), "CASY"); - } - - #[test] - fn removes_expired_cache_entries() { - let cache = Mutex::new(HashMap::from([( - "casy".to_string(), - CacheEntry { - cached_at: Instant::now() - Duration::from_secs(120), - value: vec!["expired".to_string()], - }, - )])); - - let cached = get_cached_value(&cache, "casy", Duration::from_secs(60)); - - assert_eq!(cached, None); - assert!(cache.lock().expect("cache lock").is_empty()); - } - - #[test] - fn returns_fresh_cached_entries() { - let cache = Mutex::new(HashMap::new()); - store_cached_value(&cache, "casy".to_string(), vec!["fresh".to_string()]); - - let cached = get_cached_value(&cache, "casy", Duration::from_secs(60)); - - assert_eq!(cached, Some(vec!["fresh".to_string()])); - } -} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/companyfacts.json b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/companyfacts.json new file mode 100644 index 0000000..9a4179c --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/companyfacts.json @@ -0,0 +1 @@ +{"cik":320193,"entityName":"Apple Inc.","facts":{"us-gaap":{"RevenueFromContractWithCustomerExcludingAssessedTax":{"units":{"USD":[{"end":"2024-09-28","start":"2023-09-30","val":391035000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","start":"2022-09-25","val":383285000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","start":"2021-09-26","val":394328000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","start":"2020-09-27","val":365817000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","start":"2024-09-29","val":124300000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"},{"end":"2024-09-28","start":"2024-06-30","val":94930000000,"filed":"2024-08-02","form":"10-Q","fy":2024,"fp":"Q4","accn":"0000320193-24-000090"},{"end":"2024-06-29","start":"2024-03-31","val":85780000000,"filed":"2024-05-03","form":"10-Q","fy":2024,"fp":"Q3","accn":"0000320193-24-000077"},{"end":"2024-03-30","start":"2023-12-31","val":90750000000,"filed":"2024-02-02","form":"10-Q","fy":2024,"fp":"Q2","accn":"0000320193-24-000061"}]}},"GrossProfit":{"units":{"USD":[{"end":"2024-09-28","start":"2023-09-30","val":180683000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","start":"2022-09-25","val":169148000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","start":"2021-09-26","val":170782000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","start":"2020-09-27","val":152836000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","start":"2024-09-29","val":57500000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"OperatingIncomeLoss":{"units":{"USD":[{"end":"2024-09-28","start":"2023-09-30","val":123216000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","start":"2022-09-25","val":114301000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","start":"2021-09-26","val":119437000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","start":"2020-09-27","val":108949000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","start":"2024-09-29","val":42800000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"NetIncomeLoss":{"units":{"USD":[{"end":"2024-09-28","start":"2023-09-30","val":93736000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","start":"2022-09-25","val":96995000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","start":"2021-09-26","val":99803000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","start":"2020-09-27","val":94680000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","start":"2024-09-29","val":36300000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"EarningsPerShareDiluted":{"units":{"USD/shares":[{"end":"2024-09-28","start":"2023-09-30","val":6.08,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","start":"2022-09-25","val":6.13,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","start":"2021-09-26","val":6.11,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","start":"2020-09-27","val":5.61,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","start":"2024-09-29","val":2.42,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"CashAndCashEquivalentsAtCarryingValue":{"units":{"USD":[{"end":"2024-09-28","val":29943000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","val":29965000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","val":23646000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","val":34940000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","val":31500000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"Assets":{"units":{"USD":[{"end":"2024-09-28","val":364980000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","val":352583000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","val":352755000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","val":351002000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","val":370000000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"Liabilities":{"units":{"USD":[{"end":"2024-09-28","val":308030000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","val":290437000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","val":302083000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","val":287912000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","val":311000000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}},"StockholdersEquity":{"units":{"USD":[{"end":"2024-09-28","val":56950000000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","val":62146000000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","val":50672000000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","val":63090000000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","val":59000000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}}},"dei":{"EntityCommonStockSharesOutstanding":{"units":{"shares":[{"end":"2024-09-28","val":15204137000,"filed":"2024-11-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000320193-24-000123"},{"end":"2023-09-30","val":15550061000,"filed":"2023-11-03","form":"10-K","fy":2023,"fp":"FY","accn":"0000320193-23-000106"},{"end":"2022-09-24","val":15943425000,"filed":"2022-10-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000320193-22-000108"},{"end":"2021-09-25","val":16426786000,"filed":"2021-10-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000320193-21-000105"},{"end":"2024-12-28","val":15000000000,"filed":"2025-02-01","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000320193-25-000010"}]}}}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_annual.json b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_annual.json new file mode 100644 index 0000000..5158d16 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_annual.json @@ -0,0 +1 @@ +{"directory":{"item":[{"name":"aapl-20240928_htm.xml"}]}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_quarterly.json b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_quarterly.json new file mode 100644 index 0000000..b2c9478 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_quarterly.json @@ -0,0 +1 @@ +{"directory":{"item":[{"name":"aapl-20241228_htm.xml"}]}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml new file mode 100644 index 0000000..fb5c058 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml @@ -0,0 +1,6 @@ + + + 00003201932023-09-302024-09-28 + iso4217:USD + 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 new file mode 100644 index 0000000..2089447 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml @@ -0,0 +1,6 @@ + + + 00003201932024-09-292024-12-28 + iso4217:USD + 125000000000 + diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/submissions.json b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/submissions.json new file mode 100644 index 0000000..ca75107 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/aapl/submissions.json @@ -0,0 +1 @@ +{"name":"Apple Inc.","tickers":["AAPL"],"filings":{"recent":{"accessionNumber":["0000320193-25-000010","0000320193-24-000123"],"filingDate":["2025-02-01","2024-11-01"],"reportDate":["2024-12-28","2024-09-28"],"form":["10-Q","10-K"],"primaryDocument":["aapl-20241228x10q.htm","aapl-20240928x10k.htm"]}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/company_tickers.json b/MosaicIQ/src-tauri/tests/fixtures/sec/company_tickers.json new file mode 100644 index 0000000..dce9888 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/company_tickers.json @@ -0,0 +1 @@ +{"0":{"cik_str":320193,"ticker":"AAPL","title":"Apple Inc."},"1":{"cik_str":789019,"ticker":"MSFT","title":"Microsoft Corporation"},"2":{"cik_str":21344,"ticker":"KO","title":"Coca-Cola Co"},"3":{"cik_str":1045810,"ticker":"NVDA","title":"NVIDIA CORP"},"4":{"cik_str":1000184,"ticker":"SAP","title":"SAP SE"}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/ko/companyfacts.json b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/companyfacts.json new file mode 100644 index 0000000..1d665ab --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/companyfacts.json @@ -0,0 +1 @@ +{"cik":21344,"entityName":"Coca-Cola Co","facts":{"us-gaap":{"CommonStockDividendsPerShareDeclared":{"units":{"USD/shares":[{"end":"2025-03-28","start":"2024-12-28","val":0.51,"filed":"2025-04-25","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000021344-25-000010"},{"end":"2024-12-27","start":"2024-09-28","val":0.49,"filed":"2025-02-20","form":"10-Q","fy":2024,"fp":"Q4","accn":"0000021344-25-000001"},{"end":"2024-09-27","start":"2024-06-29","val":0.49,"filed":"2024-10-25","form":"10-Q","fy":2024,"fp":"Q3","accn":"0000021344-24-000090"},{"end":"2024-06-28","start":"2024-03-30","val":0.485,"filed":"2024-07-26","form":"10-Q","fy":2024,"fp":"Q2","accn":"0000021344-24-000070"},{"end":"2024-03-29","start":"2023-12-30","val":0.485,"filed":"2024-04-26","form":"10-Q","fy":2024,"fp":"Q1","accn":"0000021344-24-000050"},{"end":"2023-12-29","start":"2023-09-30","val":0.46,"filed":"2024-02-16","form":"10-Q","fy":2023,"fp":"Q4","accn":"0000021344-24-000020"},{"end":"2023-09-29","start":"2023-07-01","val":0.46,"filed":"2023-10-27","form":"10-Q","fy":2023,"fp":"Q3","accn":"0000021344-23-000088"},{"end":"2023-06-30","start":"2023-04-01","val":0.46,"filed":"2023-07-28","form":"10-Q","fy":2023,"fp":"Q2","accn":"0000021344-23-000070"}]}},"PaymentsOfDividendsCommonStock":{"units":{"USD":[{"end":"2025-03-28","start":"2024-12-28","val":-1960000000,"filed":"2025-04-25","form":"10-Q","fy":2025,"fp":"Q1","accn":"0000021344-25-000010"},{"end":"2024-12-27","start":"2024-09-28","val":-1880000000,"filed":"2025-02-20","form":"10-Q","fy":2024,"fp":"Q4","accn":"0000021344-25-000001"},{"end":"2024-09-27","start":"2024-06-29","val":-1880000000,"filed":"2024-10-25","form":"10-Q","fy":2024,"fp":"Q3","accn":"0000021344-24-000090"},{"end":"2024-06-28","start":"2024-03-30","val":-1860000000,"filed":"2024-07-26","form":"10-Q","fy":2024,"fp":"Q2","accn":"0000021344-24-000070"},{"end":"2024-03-29","start":"2023-12-30","val":-1860000000,"filed":"2024-04-26","form":"10-Q","fy":2024,"fp":"Q1","accn":"0000021344-24-000050"},{"end":"2023-12-29","start":"2023-09-30","val":-1770000000,"filed":"2024-02-16","form":"10-Q","fy":2023,"fp":"Q4","accn":"0000021344-24-000020"},{"end":"2023-09-29","start":"2023-07-01","val":-1770000000,"filed":"2023-10-27","form":"10-Q","fy":2023,"fp":"Q3","accn":"0000021344-23-000088"},{"end":"2023-06-30","start":"2023-04-01","val":-1770000000,"filed":"2023-07-28","form":"10-Q","fy":2023,"fp":"Q2","accn":"0000021344-23-000070"}]}}}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/ko/index.json b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/index.json new file mode 100644 index 0000000..82c6008 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/index.json @@ -0,0 +1 @@ +{"directory":{"item":[{"name":"ko-20250328_htm.xml"}]}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml new file mode 100644 index 0000000..82ead1f --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml @@ -0,0 +1,6 @@ + + + 00000213442024-12-282025-03-28 + iso4217:USD/shares + 0.52 + diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/ko/submissions.json b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/submissions.json new file mode 100644 index 0000000..7d01b73 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/ko/submissions.json @@ -0,0 +1 @@ +{"name":"Coca-Cola Co","tickers":["KO"],"filings":{"recent":{"accessionNumber":["0000021344-25-000010"],"filingDate":["2025-04-25"],"reportDate":["2025-03-28"],"form":["10-Q"],"primaryDocument":["ko-20250328x10q.htm"]}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/msft/companyfacts.json b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/companyfacts.json new file mode 100644 index 0000000..01e7bd3 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/companyfacts.json @@ -0,0 +1 @@ +{"cik":789019,"entityName":"Microsoft Corporation","facts":{"us-gaap":{"NetCashProvidedByUsedInOperatingActivities":{"units":{"USD":[{"end":"2024-06-30","start":"2023-07-01","val":118548000000,"filed":"2024-08-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000950170-24-087843"},{"end":"2023-06-30","start":"2022-07-01","val":87582000000,"filed":"2023-07-27","form":"10-K","fy":2023,"fp":"FY","accn":"0000950170-23-035122"},{"end":"2022-06-30","start":"2021-07-01","val":89035000000,"filed":"2022-07-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000950170-22-012345"},{"end":"2021-06-30","start":"2020-07-01","val":76740000000,"filed":"2021-07-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000950170-21-000999"}]}},"NetCashProvidedByUsedInInvestingActivities":{"units":{"USD":[{"end":"2024-06-30","start":"2023-07-01","val":-96970000000,"filed":"2024-08-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000950170-24-087843"},{"end":"2023-06-30","start":"2022-07-01","val":-22680000000,"filed":"2023-07-27","form":"10-K","fy":2023,"fp":"FY","accn":"0000950170-23-035122"},{"end":"2022-06-30","start":"2021-07-01","val":-30311000000,"filed":"2022-07-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000950170-22-012345"},{"end":"2021-06-30","start":"2020-07-01","val":-27577000000,"filed":"2021-07-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000950170-21-000999"}]}},"NetCashProvidedByUsedInFinancingActivities":{"units":{"USD":[{"end":"2024-06-30","start":"2023-07-01","val":-37757000000,"filed":"2024-08-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000950170-24-087843"},{"end":"2023-06-30","start":"2022-07-01","val":-43935000000,"filed":"2023-07-27","form":"10-K","fy":2023,"fp":"FY","accn":"0000950170-23-035122"},{"end":"2022-06-30","start":"2021-07-01","val":-58876000000,"filed":"2022-07-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000950170-22-012345"},{"end":"2021-06-30","start":"2020-07-01","val":-48486000000,"filed":"2021-07-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000950170-21-000999"}]}},"PaymentsToAcquirePropertyPlantAndEquipment":{"units":{"USD":[{"end":"2024-06-30","start":"2023-07-01","val":-44477000000,"filed":"2024-08-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000950170-24-087843"},{"end":"2023-06-30","start":"2022-07-01","val":-28107000000,"filed":"2023-07-27","form":"10-K","fy":2023,"fp":"FY","accn":"0000950170-23-035122"},{"end":"2022-06-30","start":"2021-07-01","val":-23886000000,"filed":"2022-07-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000950170-22-012345"},{"end":"2021-06-30","start":"2020-07-01","val":-20622000000,"filed":"2021-07-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000950170-21-000999"}]}},"CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents":{"units":{"USD":[{"end":"2024-06-30","val":75531000000,"filed":"2024-08-01","form":"10-K","fy":2024,"fp":"FY","accn":"0000950170-24-087843"},{"end":"2023-06-30","val":111256000000,"filed":"2023-07-27","form":"10-K","fy":2023,"fp":"FY","accn":"0000950170-23-035122"},{"end":"2022-06-30","val":104749000000,"filed":"2022-07-28","form":"10-K","fy":2022,"fp":"FY","accn":"0000950170-22-012345"},{"end":"2021-06-30","val":130334000000,"filed":"2021-07-29","form":"10-K","fy":2021,"fp":"FY","accn":"0000950170-21-000999"}]}}}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/msft/index.json b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/index.json new file mode 100644 index 0000000..476bee9 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/index.json @@ -0,0 +1 @@ +{"directory":{"item":[{"name":"msft-20240630_htm.xml"}]}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml new file mode 100644 index 0000000..9803ff5 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml @@ -0,0 +1,6 @@ + + + 00007890192023-07-012024-06-30 + iso4217:USD + 120000000000 + diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/msft/submissions.json b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/submissions.json new file mode 100644 index 0000000..aec56d0 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/msft/submissions.json @@ -0,0 +1 @@ +{"name":"Microsoft Corporation","tickers":["MSFT"],"filings":{"recent":{"accessionNumber":["0000950170-24-087843"],"filingDate":["2024-08-01"],"reportDate":["2024-06-30"],"form":["10-K"],"primaryDocument":["msft-20240630x10k.htm"]}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/companyfacts.json b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/companyfacts.json new file mode 100644 index 0000000..40fd4b5 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/companyfacts.json @@ -0,0 +1 @@ +{"cik":1045810,"entityName":"NVIDIA CORP","facts":{"us-gaap":{"RevenueFromContractWithCustomerExcludingAssessedTax":{"units":{"USD":[{"end":"2025-04-27","start":"2025-01-27","val":26044000000,"filed":"2025-05-30","form":"10-Q","fy":2026,"fp":"Q1","accn":"0001045810-25-000020"},{"end":"2025-01-26","start":"2024-10-27","val":22103000000,"filed":"2025-02-22","form":"10-Q","fy":2025,"fp":"Q4","accn":"0001045810-25-000005"},{"end":"2024-10-27","start":"2024-07-28","val":18120000000,"filed":"2024-11-21","form":"10-Q","fy":2025,"fp":"Q3","accn":"0001045810-24-000090"},{"end":"2024-07-28","start":"2024-04-28","val":13507000000,"filed":"2024-08-21","form":"10-Q","fy":2025,"fp":"Q2","accn":"0001045810-24-000070"},{"end":"2024-04-28","start":"2024-01-29","val":7192000000,"filed":"2024-05-22","form":"10-Q","fy":2025,"fp":"Q1","accn":"0001045810-24-000050"},{"end":"2024-01-28","start":"2023-10-30","val":6091000000,"filed":"2024-02-21","form":"10-Q","fy":2024,"fp":"Q4","accn":"0001045810-24-000020"},{"end":"2023-10-29","start":"2023-07-31","val":5020000000,"filed":"2023-11-21","form":"10-Q","fy":2024,"fp":"Q3","accn":"0001045810-23-000090"},{"end":"2023-07-30","start":"2023-05-01","val":3675000000,"filed":"2023-08-23","form":"10-Q","fy":2024,"fp":"Q2","accn":"0001045810-23-000070"}]}},"NetIncomeLoss":{"units":{"USD":[{"end":"2025-04-27","start":"2025-01-27","val":14881000000,"filed":"2025-05-30","form":"10-Q","fy":2026,"fp":"Q1","accn":"0001045810-25-000020"},{"end":"2025-01-26","start":"2024-10-27","val":12285000000,"filed":"2025-02-22","form":"10-Q","fy":2025,"fp":"Q4","accn":"0001045810-25-000005"},{"end":"2024-10-27","start":"2024-07-28","val":10250000000,"filed":"2024-11-21","form":"10-Q","fy":2025,"fp":"Q3","accn":"0001045810-24-000090"},{"end":"2024-07-28","start":"2024-04-28","val":6188000000,"filed":"2024-08-21","form":"10-Q","fy":2025,"fp":"Q2","accn":"0001045810-24-000070"},{"end":"2024-04-28","start":"2024-01-29","val":4380000000,"filed":"2024-05-22","form":"10-Q","fy":2025,"fp":"Q1","accn":"0001045810-24-000050"},{"end":"2024-01-28","start":"2023-10-30","val":2970000000,"filed":"2024-02-21","form":"10-Q","fy":2024,"fp":"Q4","accn":"0001045810-24-000020"},{"end":"2023-10-29","start":"2023-07-31","val":2010000000,"filed":"2023-11-21","form":"10-Q","fy":2024,"fp":"Q3","accn":"0001045810-23-000090"},{"end":"2023-07-30","start":"2023-05-01","val":1014000000,"filed":"2023-08-23","form":"10-Q","fy":2024,"fp":"Q2","accn":"0001045810-23-000070"}]}},"EarningsPerShareBasic":{"units":{"USD/shares":[{"end":"2025-04-27","start":"2025-01-27","val":6.12,"filed":"2025-05-30","form":"10-Q","fy":2026,"fp":"Q1","accn":"0001045810-25-000020"},{"end":"2025-01-26","start":"2024-10-27","val":5.05,"filed":"2025-02-22","form":"10-Q","fy":2025,"fp":"Q4","accn":"0001045810-25-000005"},{"end":"2024-10-27","start":"2024-07-28","val":4.22,"filed":"2024-11-21","form":"10-Q","fy":2025,"fp":"Q3","accn":"0001045810-24-000090"},{"end":"2024-07-28","start":"2024-04-28","val":2.45,"filed":"2024-08-21","form":"10-Q","fy":2025,"fp":"Q2","accn":"0001045810-24-000070"},{"end":"2024-04-28","start":"2024-01-29","val":1.78,"filed":"2024-05-22","form":"10-Q","fy":2025,"fp":"Q1","accn":"0001045810-24-000050"},{"end":"2024-01-28","start":"2023-10-30","val":1.20,"filed":"2024-02-21","form":"10-Q","fy":2024,"fp":"Q4","accn":"0001045810-24-000020"},{"end":"2023-10-29","start":"2023-07-31","val":0.81,"filed":"2023-11-21","form":"10-Q","fy":2024,"fp":"Q3","accn":"0001045810-23-000090"},{"end":"2023-07-30","start":"2023-05-01","val":0.41,"filed":"2023-08-23","form":"10-Q","fy":2024,"fp":"Q2","accn":"0001045810-23-000070"}]}},"EarningsPerShareDiluted":{"units":{"USD/shares":[{"end":"2025-04-27","start":"2025-01-27","val":6.08,"filed":"2025-05-30","form":"10-Q","fy":2026,"fp":"Q1","accn":"0001045810-25-000020"},{"end":"2025-01-26","start":"2024-10-27","val":5.00,"filed":"2025-02-22","form":"10-Q","fy":2025,"fp":"Q4","accn":"0001045810-25-000005"},{"end":"2024-10-27","start":"2024-07-28","val":4.17,"filed":"2024-11-21","form":"10-Q","fy":2025,"fp":"Q3","accn":"0001045810-24-000090"},{"end":"2024-07-28","start":"2024-04-28","val":2.42,"filed":"2024-08-21","form":"10-Q","fy":2025,"fp":"Q2","accn":"0001045810-24-000070"},{"end":"2024-04-28","start":"2024-01-29","val":1.74,"filed":"2024-05-22","form":"10-Q","fy":2025,"fp":"Q1","accn":"0001045810-24-000050"},{"end":"2024-01-28","start":"2023-10-30","val":1.18,"filed":"2024-02-21","form":"10-Q","fy":2024,"fp":"Q4","accn":"0001045810-24-000020"},{"end":"2023-10-29","start":"2023-07-31","val":0.79,"filed":"2023-11-21","form":"10-Q","fy":2024,"fp":"Q3","accn":"0001045810-23-000090"},{"end":"2023-07-30","start":"2023-05-01","val":0.40,"filed":"2023-08-23","form":"10-Q","fy":2024,"fp":"Q2","accn":"0001045810-23-000070"}]}},"WeightedAverageNumberOfDilutedSharesOutstanding":{"units":{"shares":[{"end":"2025-04-27","start":"2025-01-27","val":2446000000,"filed":"2025-05-30","form":"10-Q","fy":2026,"fp":"Q1","accn":"0001045810-25-000020"},{"end":"2025-01-26","start":"2024-10-27","val":2457000000,"filed":"2025-02-22","form":"10-Q","fy":2025,"fp":"Q4","accn":"0001045810-25-000005"},{"end":"2024-10-27","start":"2024-07-28","val":2460000000,"filed":"2024-11-21","form":"10-Q","fy":2025,"fp":"Q3","accn":"0001045810-24-000090"},{"end":"2024-07-28","start":"2024-04-28","val":2465000000,"filed":"2024-08-21","form":"10-Q","fy":2025,"fp":"Q2","accn":"0001045810-24-000070"},{"end":"2024-04-28","start":"2024-01-29","val":2470000000,"filed":"2024-05-22","form":"10-Q","fy":2025,"fp":"Q1","accn":"0001045810-24-000050"},{"end":"2024-01-28","start":"2023-10-30","val":2475000000,"filed":"2024-02-21","form":"10-Q","fy":2024,"fp":"Q4","accn":"0001045810-24-000020"},{"end":"2023-10-29","start":"2023-07-31","val":2480000000,"filed":"2023-11-21","form":"10-Q","fy":2024,"fp":"Q3","accn":"0001045810-23-000090"},{"end":"2023-07-30","start":"2023-05-01","val":2485000000,"filed":"2023-08-23","form":"10-Q","fy":2024,"fp":"Q2","accn":"0001045810-23-000070"}]}}}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/index.json b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/index.json new file mode 100644 index 0000000..b27cd29 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/index.json @@ -0,0 +1 @@ +{"directory":{"item":[{"name":"nvda-20250427_htm.xml"}]}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml new file mode 100644 index 0000000..64b6262 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml @@ -0,0 +1,6 @@ + + + 00010458102025-01-272025-04-27 + iso4217:USD + 26100000000 + diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/submissions.json b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/submissions.json new file mode 100644 index 0000000..9c2a7ce --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/nvda/submissions.json @@ -0,0 +1 @@ +{"name":"NVIDIA CORP","tickers":["NVDA"],"filings":{"recent":{"accessionNumber":["0001045810-25-000020"],"filingDate":["2025-05-30"],"reportDate":["2025-04-27"],"form":["10-Q"],"primaryDocument":["nvda-20250427x10q.htm"]}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/sap/companyfacts.json b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/companyfacts.json new file mode 100644 index 0000000..0ac19d9 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/companyfacts.json @@ -0,0 +1 @@ +{"cik":1000184,"entityName":"SAP SE","facts":{"ifrs-full":{"Revenue":{"units":{"USD":[{"end":"2023-12-31","start":"2023-01-01","val":35000000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"},{"end":"2022-12-31","start":"2022-01-01","val":32000000000,"filed":"2023-03-21","form":"20-F","fy":2022,"fp":"FY","accn":"0001000184-23-000001"},{"end":"2021-12-31","start":"2021-01-01","val":30000000000,"filed":"2022-03-22","form":"20-F","fy":2021,"fp":"FY","accn":"0001000184-22-000001"},{"end":"2020-12-31","start":"2020-01-01","val":28000000000,"filed":"2021-03-20","form":"20-F","fy":2020,"fp":"FY","accn":"0001000184-21-000001"}]}},"ProfitLoss":{"units":{"USD":[{"end":"2023-12-31","start":"2023-01-01","val":6200000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}},"BasicAndDilutedEarningsLossPerShare":{"units":{"USD/shares":[{"end":"2023-12-31","start":"2023-01-01","val":5.2,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}},"CashAndCashEquivalents":{"units":{"USD":[{"end":"2023-12-31","val":15000000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}},"Assets":{"units":{"USD":[{"end":"2023-12-31","val":78000000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}},"Liabilities":{"units":{"USD":[{"end":"2023-12-31","val":41000000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}},"Equity":{"units":{"USD":[{"end":"2023-12-31","val":37000000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}}},"dei":{"EntityCommonStockSharesOutstanding":{"units":{"shares":[{"end":"2023-12-31","val":1200000000,"filed":"2024-03-20","form":"20-F","fy":2023,"fp":"FY","accn":"0001000184-24-000001"}]}}}}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/sap/index.json b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/index.json new file mode 100644 index 0000000..6d1ae38 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/index.json @@ -0,0 +1 @@ +{"directory":{"item":[{"name":"sap-20231231_htm.xml"}]}} diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml new file mode 100644 index 0000000..bb6e468 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml @@ -0,0 +1,6 @@ + + + 00010001842023-01-012023-12-31 + iso4217:USD + 35200000000 + diff --git a/MosaicIQ/src-tauri/tests/fixtures/sec/sap/submissions.json b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/submissions.json new file mode 100644 index 0000000..05f8a94 --- /dev/null +++ b/MosaicIQ/src-tauri/tests/fixtures/sec/sap/submissions.json @@ -0,0 +1 @@ +{"name":"SAP SE","tickers":["SAP"],"filings":{"recent":{"accessionNumber":["0001000184-24-000001"],"filingDate":["2024-03-20"],"reportDate":["2023-12-31"],"form":["20-F"],"primaryDocument":["sap-20231231x20f.htm"]}}}