feat(terminal): add Google Finance and SEC EDGAR backends
This commit is contained in:
106
MosaicIQ/src-tauri/Cargo.lock
generated
106
MosaicIQ/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -127,6 +127,7 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
|
||||
};
|
||||
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<R: Runtime, G: ChatGateway> AgentService<R, G> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<R: Runtime> AgentSettingsService<R> {
|
||||
},
|
||||
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<R: Runtime> AgentSettingsService<R> {
|
||||
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);
|
||||
|
||||
@@ -94,6 +94,7 @@ pub struct AgentStoredSettings {
|
||||
pub remote: RemoteProviderSettings,
|
||||
pub default_remote_model: String,
|
||||
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
|
||||
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<TaskProfile, AgentTaskRoute>,
|
||||
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<TaskProfile, AgentTaskRoute>,
|
||||
pub sec_edgar_user_agent: String,
|
||||
}
|
||||
|
||||
/// Request payload for rotating the stored remote API key.
|
||||
|
||||
@@ -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<Company, String> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Wry>,
|
||||
}
|
||||
|
||||
impl SecUserAgentProvider for SettingsBackedSecUserAgentProvider {
|
||||
fn user_agent(&self) -> Option<String> {
|
||||
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<Wry>) -> Result<Self, AppError> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<dyn SecurityLookup>,
|
||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||
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<dyn SecurityLookup>) -> Self {
|
||||
Self::with_dependencies(load_mock_financial_data(), security_lookup)
|
||||
pub fn new(
|
||||
security_lookup: Arc<dyn SecurityLookup>,
|
||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||
) -> 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<dyn SecurityLookup>,
|
||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||
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<crate::terminal::Company, String> {
|
||||
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::<Vec<_>>(),
|
||||
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<String>,
|
||||
provider: &str,
|
||||
query: Option<String>,
|
||||
symbol: Option<String>,
|
||||
) -> 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<FinancialsPanelData, EdgarLookupError>> {
|
||||
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<CashFlowPanelData, EdgarLookupError>> {
|
||||
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<DividendsPanelData, EdgarLookupError>> {
|
||||
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<EarningsPanelData, EdgarLookupError>> {
|
||||
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<crate::terminal::Company, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
1449
MosaicIQ/src-tauri/src/terminal/google_finance.rs
Normal file
1449
MosaicIQ/src-tauri/src/terminal/google_finance.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
|
||||
390
MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs
Normal file
390
MosaicIQ/src-tauri/src/terminal/sec_edgar/client.rs
Normal file
@@ -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<String, EdgarLookupError>>;
|
||||
fn get_bytes<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result<Vec<u8>, EdgarLookupError>>;
|
||||
}
|
||||
|
||||
pub(crate) trait SecUserAgentProvider: Send + Sync {
|
||||
fn user_agent(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
struct EnvSecUserAgentProvider;
|
||||
|
||||
impl SecUserAgentProvider for EnvSecUserAgentProvider {
|
||||
fn user_agent(&self) -> Option<String> {
|
||||
std::env::var("SEC_EDGAR_USER_AGENT").ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct LiveSecFetcher {
|
||||
client: Client,
|
||||
user_agent_provider: std::sync::Arc<dyn SecUserAgentProvider>,
|
||||
last_request_at: AsyncMutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl LiveSecFetcher {
|
||||
pub(crate) fn new(user_agent_provider: std::sync::Arc<dyn SecUserAgentProvider>) -> 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<String, EdgarLookupError> {
|
||||
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<String, 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
|
||||
.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<Vec<u8>, 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<T> {
|
||||
cached_at: Instant,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
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<dyn SecFetch>,
|
||||
tickers_cache: Mutex<Option<CacheEntry<HashMap<String, ResolvedCompany>>>>,
|
||||
submissions_cache: Mutex<HashMap<String, CacheEntry<CompanySubmissions>>>,
|
||||
companyfacts_cache: Mutex<HashMap<String, CacheEntry<CompanyFactsResponse>>>,
|
||||
filing_index_cache: Mutex<HashMap<String, CacheEntry<FilingIndex>>>,
|
||||
instance_xml_cache: Mutex<HashMap<String, CacheEntry<Vec<u8>>>>,
|
||||
parsed_xbrl_cache: Mutex<HashMap<String, CacheEntry<ParsedXbrlDocument>>>,
|
||||
}
|
||||
|
||||
impl SecEdgarClient {
|
||||
pub(crate) fn new(fetcher: Box<dyn SecFetch>) -> 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<ResolvedCompany, EdgarLookupError> {
|
||||
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<CompanySubmissions, EdgarLookupError> {
|
||||
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::<CompanySubmissions>(&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<CompanyFactsResponse, EdgarLookupError> {
|
||||
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::<CompanyFactsResponse>(&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<FilingIndex, EdgarLookupError> {
|
||||
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::<FilingIndex>(&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<Vec<u8>, 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<ParsedXbrlDocument, EdgarLookupError> {
|
||||
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<HashMap<String, ResolvedCompany>, 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::<HashMap<String, TickerDirectoryEntry>>(&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::<HashMap<_, _>>();
|
||||
|
||||
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<T: Clone>(
|
||||
cache: &Mutex<HashMap<String, CacheEntry<T>>>,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
) -> Option<T> {
|
||||
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<T>(cache: &Mutex<HashMap<String, CacheEntry<T>>>, key: String, value: T) {
|
||||
if let Ok(mut guard) = cache.lock() {
|
||||
guard.insert(key, CacheEntry::new(value));
|
||||
}
|
||||
}
|
||||
747
MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs
Normal file
747
MosaicIQ/src-tauri/src/terminal/sec_edgar/facts.rs
Normal file
@@ -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<FilingRef> {
|
||||
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<Vec<NormalizedFact>, 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<StatementPeriod> {
|
||||
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<CashFlowPeriod> {
|
||||
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<DividendEvent> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<EarningsPeriod> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<Option<&ParsedXbrlDocument>, &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<f64>, Option<f64>) {
|
||||
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<FlattenedConcept<'_>> {
|
||||
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<String, CompanyConceptFacts>,
|
||||
) -> Vec<FlattenedConcept<'a>> {
|
||||
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<String, Vec<CompanyFactRecord>>,
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
match value {
|
||||
serde_json::Value::Number(number) => number.as_f64(),
|
||||
serde_json::Value::String(string) => string.parse::<f64>().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<PeriodRow> {
|
||||
let anchored = facts
|
||||
.iter()
|
||||
.filter(|fact| is_fact_for_frequency(fact, frequency))
|
||||
.filter(|fact| concept_match(anchor_concepts, fact))
|
||||
.map(PeriodRow::from_fact)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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<PeriodRow>, period_limit: usize) -> Vec<PeriodRow> {
|
||||
let mut grouped = BTreeMap::<String, PeriodRow>::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::<Vec<_>>();
|
||||
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<i64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<f64>, previous: Option<f64>) -> Option<f64> {
|
||||
let current = current?;
|
||||
let previous = previous?;
|
||||
if previous.abs() < f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
Some(((current - previous) / previous.abs()) * 100.0)
|
||||
}
|
||||
8
MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs
Normal file
8
MosaicIQ/src-tauri/src/terminal/sec_edgar/mod.rs
Normal file
@@ -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};
|
||||
530
MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs
Normal file
530
MosaicIQ/src-tauri/src/terminal/sec_edgar/service.rs
Normal file
@@ -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<FinancialsPanelData, EdgarLookupError>>;
|
||||
|
||||
fn cash_flow<'a>(
|
||||
&'a self,
|
||||
ticker: &'a str,
|
||||
frequency: Frequency,
|
||||
) -> BoxFuture<'a, Result<CashFlowPanelData, EdgarLookupError>>;
|
||||
|
||||
fn dividends<'a>(
|
||||
&'a self,
|
||||
ticker: &'a str,
|
||||
) -> BoxFuture<'a, Result<DividendsPanelData, EdgarLookupError>>;
|
||||
|
||||
fn earnings<'a>(
|
||||
&'a self,
|
||||
ticker: &'a str,
|
||||
frequency: Frequency,
|
||||
) -> BoxFuture<'a, Result<EarningsPanelData, EdgarLookupError>>;
|
||||
}
|
||||
|
||||
pub(crate) struct SecEdgarLookup {
|
||||
client: Arc<SecEdgarClient>,
|
||||
}
|
||||
|
||||
impl SecEdgarLookup {
|
||||
pub(crate) fn new(client: Arc<SecEdgarClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
async fn context_for(
|
||||
&self,
|
||||
ticker: &str,
|
||||
frequency: Frequency,
|
||||
) -> Result<LookupContext, EdgarLookupError> {
|
||||
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<Option<super::types::ParsedXbrlDocument>, 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::<Vec<_>>();
|
||||
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<FinancialsPanelData, EdgarLookupError>> {
|
||||
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<CashFlowPanelData, EdgarLookupError>> {
|
||||
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<DividendsPanelData, EdgarLookupError>> {
|
||||
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<EarningsPanelData, EdgarLookupError>> {
|
||||
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<String, String>,
|
||||
bytes: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SecFetch for FixtureFetcher {
|
||||
fn get_text<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result<String, EdgarLookupError>> {
|
||||
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<Vec<u8>, 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<String, String>,
|
||||
bytes: &mut HashMap<String, Vec<u8>>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
199
MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs
Normal file
199
MosaicIQ/src-tauri/src/terminal/sec_edgar/types.rs
Normal file
@@ -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<String>,
|
||||
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<String>,
|
||||
pub filing_date: Vec<String>,
|
||||
pub report_date: Vec<Option<String>>,
|
||||
pub form: Vec<String>,
|
||||
pub primary_document: Vec<String>,
|
||||
}
|
||||
|
||||
impl RecentFilings {
|
||||
pub(crate) fn rows(&self) -> Vec<FilingRef> {
|
||||
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<String, CompanyConceptFacts>,
|
||||
#[serde(rename = "ifrs-full", default)]
|
||||
pub ifrs_full: HashMap<String, CompanyConceptFacts>,
|
||||
#[serde(rename = "dei", default)]
|
||||
pub dei: HashMap<String, CompanyConceptFacts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct CompanyConceptFacts {
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub units: HashMap<String, Vec<CompanyFactRecord>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CompanyFactRecord {
|
||||
pub end: String,
|
||||
#[serde(default)]
|
||||
pub start: Option<String>,
|
||||
pub val: serde_json::Value,
|
||||
pub filed: String,
|
||||
pub form: String,
|
||||
#[serde(default)]
|
||||
pub fy: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub fp: Option<String>,
|
||||
#[serde(default)]
|
||||
pub frame: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accn: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct FilingIndex {
|
||||
pub directory: FilingIndexDirectory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct FilingIndexDirectory {
|
||||
pub item: Vec<FilingIndexItem>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub fiscal_period: Option<String>,
|
||||
pub period_start: Option<String>,
|
||||
pub period_end: String,
|
||||
pub accession_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PeriodRow {
|
||||
pub label: String,
|
||||
pub fiscal_year: Option<String>,
|
||||
pub fiscal_period: Option<String>,
|
||||
pub period_start: Option<String>,
|
||||
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<String>,
|
||||
pub value: f64,
|
||||
pub period_end: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct ParsedXbrlDocument {
|
||||
pub facts: Vec<LatestXbrlFact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct ConceptCandidate {
|
||||
pub taxonomy: &'static str,
|
||||
pub concept: &'static str,
|
||||
pub unit_family: UnitFamily,
|
||||
}
|
||||
188
MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs
Normal file
188
MosaicIQ/src-tauri/src/terminal/sec_edgar/xbrl.rs
Normal file
@@ -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<String>,
|
||||
end: Option<String>,
|
||||
instant: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn pick_instance_document(candidates: &[String]) -> Option<String> {
|
||||
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<ParsedXbrlDocument, EdgarLookupError> {
|
||||
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::<String, ParsedContext>::new();
|
||||
let mut units = HashMap::<String, String>::new();
|
||||
let mut current_context: Option<String> = None;
|
||||
let mut current_context_kind: Option<&'static str> = None;
|
||||
let mut current_unit: Option<String> = None;
|
||||
let mut unit_parts: Vec<String> = 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::<f64>() {
|
||||
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<String> {
|
||||
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(':')
|
||||
}
|
||||
247
MosaicIQ/src-tauri/src/terminal/security_lookup.rs
Normal file
247
MosaicIQ/src-tauri/src/terminal/security_lookup.rs
Normal file
@@ -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<String>,
|
||||
pub exchange: Option<String>,
|
||||
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<Vec<SecurityMatch>, SecurityLookupError>>;
|
||||
|
||||
fn load_company<'a>(
|
||||
&'a self,
|
||||
security_match: &'a SecurityMatch,
|
||||
) -> BoxFuture<'a, Result<Company, SecurityLookupError>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CacheEntry<T> {
|
||||
cached_at: Instant,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
#[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<T: Clone>(
|
||||
cache: &Mutex<HashMap<String, CacheEntry<T>>>,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
) -> Option<T> {
|
||||
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<T: Clone>(
|
||||
cache: &Mutex<HashMap<String, CacheEntry<T>>>,
|
||||
key: &str,
|
||||
max_age: Duration,
|
||||
) -> Option<T> {
|
||||
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<T>(
|
||||
cache: &Mutex<HashMap<String, CacheEntry<T>>>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub volume_label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pe: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub eps: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub high52_week: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub low52_week: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<CompanyProfile>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub price_chart: Option<Vec<CompanyPricePoint>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub price_chart_ranges: Option<HashMap<String, Vec<CompanyPricePoint>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompanyProfile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wiki_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ceo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headquarters: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub employees: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub founded: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
}
|
||||
|
||||
#[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<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Portfolio holding row.
|
||||
@@ -147,3 +212,158 @@ pub struct MockFinancialData {
|
||||
pub news_items: Vec<NewsItem>,
|
||||
pub analyses: HashMap<String, StockAnalysis>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub form: String,
|
||||
pub primary_document: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatementPeriod {
|
||||
pub label: String,
|
||||
pub fiscal_year: Option<String>,
|
||||
pub fiscal_period: Option<String>,
|
||||
pub period_start: Option<String>,
|
||||
pub period_end: String,
|
||||
pub filed_date: String,
|
||||
pub form: String,
|
||||
pub revenue: Option<f64>,
|
||||
pub gross_profit: Option<f64>,
|
||||
pub operating_income: Option<f64>,
|
||||
pub net_income: Option<f64>,
|
||||
pub diluted_eps: Option<f64>,
|
||||
pub cash_and_equivalents: Option<f64>,
|
||||
pub total_assets: Option<f64>,
|
||||
pub total_liabilities: Option<f64>,
|
||||
pub total_equity: Option<f64>,
|
||||
pub shares_outstanding: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CashFlowPeriod {
|
||||
pub label: String,
|
||||
pub fiscal_year: Option<String>,
|
||||
pub fiscal_period: Option<String>,
|
||||
pub period_start: Option<String>,
|
||||
pub period_end: String,
|
||||
pub filed_date: String,
|
||||
pub form: String,
|
||||
pub operating_cash_flow: Option<f64>,
|
||||
pub investing_cash_flow: Option<f64>,
|
||||
pub financing_cash_flow: Option<f64>,
|
||||
pub capex: Option<f64>,
|
||||
pub free_cash_flow: Option<f64>,
|
||||
pub ending_cash: Option<f64>,
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
pub total_cash_dividends: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EarningsPeriod {
|
||||
pub label: String,
|
||||
pub fiscal_year: Option<String>,
|
||||
pub fiscal_period: Option<String>,
|
||||
pub period_start: Option<String>,
|
||||
pub period_end: String,
|
||||
pub filed_date: String,
|
||||
pub form: String,
|
||||
pub revenue: Option<f64>,
|
||||
pub net_income: Option<f64>,
|
||||
pub basic_eps: Option<f64>,
|
||||
pub diluted_eps: Option<f64>,
|
||||
pub diluted_weighted_average_shares: Option<f64>,
|
||||
pub revenue_yoy_change_percent: Option<f64>,
|
||||
pub diluted_eps_yoy_change_percent: Option<f64>,
|
||||
}
|
||||
|
||||
#[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<StatementPeriod>,
|
||||
pub latest_filing: Option<FilingRef>,
|
||||
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<CashFlowPeriod>,
|
||||
pub latest_filing: Option<FilingRef>,
|
||||
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<f64>,
|
||||
pub ttm_common_dividends_paid: Option<f64>,
|
||||
pub latest_event: Option<DividendEvent>,
|
||||
pub events: Vec<DividendEvent>,
|
||||
pub latest_filing: Option<FilingRef>,
|
||||
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<EarningsPeriod>,
|
||||
pub latest_filing: Option<FilingRef>,
|
||||
pub source_status: SourceStatus,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub exchange: Option<String>,
|
||||
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<Vec<SecurityMatch>, SecurityLookupError>>;
|
||||
|
||||
fn load_company<'a>(
|
||||
&'a self,
|
||||
security_match: &'a SecurityMatch,
|
||||
) -> BoxFuture<'a, Result<Company, SecurityLookupError>>;
|
||||
}
|
||||
|
||||
pub(crate) struct YahooFinanceLookup {
|
||||
client: YfClient,
|
||||
http_client: Client,
|
||||
search_cache: Mutex<HashMap<String, CacheEntry<Vec<SecurityMatch>>>>,
|
||||
company_cache: Mutex<HashMap<String, CacheEntry<Company>>>,
|
||||
}
|
||||
|
||||
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<Vec<SecurityMatch>, 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::<Vec<_>>();
|
||||
|
||||
self.store_search_cache(normalized_query, matches.clone());
|
||||
|
||||
Ok(matches)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_company<'a>(
|
||||
&'a self,
|
||||
security_match: &'a SecurityMatch,
|
||||
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
|
||||
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<YahooQuoteResult, SecurityLookupError> {
|
||||
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::<YahooQuoteEnvelope>()
|
||||
.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<Vec<SecurityMatch>> {
|
||||
get_cached_value(&self.search_cache, key, SEARCH_CACHE_TTL)
|
||||
}
|
||||
|
||||
fn store_search_cache(&self, key: String, value: Vec<SecurityMatch>) {
|
||||
store_cached_value(&self.search_cache, key, value);
|
||||
}
|
||||
|
||||
fn get_cached_company(&self, key: &str) -> Option<Company> {
|
||||
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<T> {
|
||||
cached_at: Instant,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
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<T: Clone>(
|
||||
cache: &Mutex<HashMap<String, CacheEntry<T>>>,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
) -> Option<T> {
|
||||
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<T>(cache: &Mutex<HashMap<String, CacheEntry<T>>>, 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<Company> {
|
||||
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<YahooQuoteResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct YahooQuoteResult {
|
||||
symbol: String,
|
||||
short_name: Option<String>,
|
||||
long_name: Option<String>,
|
||||
regular_market_price: Option<f64>,
|
||||
regular_market_previous_close: Option<f64>,
|
||||
regular_market_volume: Option<u64>,
|
||||
market_cap: Option<f64>,
|
||||
trailing_pe: Option<f64>,
|
||||
eps_trailing_twelve_months: Option<f64>,
|
||||
fifty_two_week_high: Option<f64>,
|
||||
fifty_two_week_low: Option<f64>,
|
||||
}
|
||||
|
||||
#[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()]));
|
||||
}
|
||||
}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/companyfacts.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/companyfacts.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_annual.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_annual.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"directory":{"item":[{"name":"aapl-20240928_htm.xml"}]}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_quarterly.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/index_quarterly.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"directory":{"item":[{"name":"aapl-20241228_htm.xml"}]}}
|
||||
6
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml
vendored
Normal file
6
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_annual.xml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:dei="http://xbrl.sec.gov/dei/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
|
||||
<xbrli:context id="fy2024"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">0000320193</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2023-09-30</xbrli:startDate><xbrli:endDate>2024-09-28</xbrli:endDate></xbrli:period></xbrli:context>
|
||||
<xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
|
||||
<us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="fy2024" unitRef="usd">395000000000</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
|
||||
</xbrli:xbrl>
|
||||
6
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml
vendored
Normal file
6
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/instance_quarterly.xml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
|
||||
<xbrli:context id="q12025"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">0000320193</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2024-09-29</xbrli:startDate><xbrli:endDate>2024-12-28</xbrli:endDate></xbrli:period></xbrli:context>
|
||||
<xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
|
||||
<us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="q12025" unitRef="usd">125000000000</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
|
||||
</xbrli:xbrl>
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/submissions.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/aapl/submissions.json
vendored
Normal file
@@ -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"]}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/company_tickers.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/company_tickers.json
vendored
Normal file
@@ -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"}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/ko/companyfacts.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/ko/companyfacts.json
vendored
Normal file
@@ -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"}]}}}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/ko/index.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/ko/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"directory":{"item":[{"name":"ko-20250328_htm.xml"}]}}
|
||||
6
MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml
vendored
Normal file
6
MosaicIQ/src-tauri/tests/fixtures/sec/ko/instance.xml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
|
||||
<xbrli:context id="q12025"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">0000021344</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2024-12-28</xbrli:startDate><xbrli:endDate>2025-03-28</xbrli:endDate></xbrli:period></xbrli:context>
|
||||
<xbrli:unit id="usdPerShare"><xbrli:measure>iso4217:USD/shares</xbrli:measure></xbrli:unit>
|
||||
<us-gaap:CommonStockDividendsPerShareDeclared contextRef="q12025" unitRef="usdPerShare">0.52</us-gaap:CommonStockDividendsPerShareDeclared>
|
||||
</xbrli:xbrl>
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/ko/submissions.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/ko/submissions.json
vendored
Normal file
@@ -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"]}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/msft/companyfacts.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/msft/companyfacts.json
vendored
Normal file
@@ -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"}]}}}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/msft/index.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/msft/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"directory":{"item":[{"name":"msft-20240630_htm.xml"}]}}
|
||||
6
MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml
vendored
Normal file
6
MosaicIQ/src-tauri/tests/fixtures/sec/msft/instance.xml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
|
||||
<xbrli:context id="fy2024"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">0000789019</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2023-07-01</xbrli:startDate><xbrli:endDate>2024-06-30</xbrli:endDate></xbrli:period></xbrli:context>
|
||||
<xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
|
||||
<us-gaap:NetCashProvidedByUsedInOperatingActivities contextRef="fy2024" unitRef="usd">120000000000</us-gaap:NetCashProvidedByUsedInOperatingActivities>
|
||||
</xbrli:xbrl>
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/msft/submissions.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/msft/submissions.json
vendored
Normal file
@@ -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"]}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/companyfacts.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/companyfacts.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/index.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"directory":{"item":[{"name":"nvda-20250427_htm.xml"}]}}
|
||||
6
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml
vendored
Normal file
6
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/instance.xml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
|
||||
<xbrli:context id="q12026"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">0001045810</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2025-01-27</xbrli:startDate><xbrli:endDate>2025-04-27</xbrli:endDate></xbrli:period></xbrli:context>
|
||||
<xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
|
||||
<us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="q12026" unitRef="usd">26100000000</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
|
||||
</xbrli:xbrl>
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/submissions.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/nvda/submissions.json
vendored
Normal file
@@ -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"]}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/sap/companyfacts.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/sap/companyfacts.json
vendored
Normal file
@@ -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"}]}}}}}
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/sap/index.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/sap/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"directory":{"item":[{"name":"sap-20231231_htm.xml"}]}}
|
||||
6
MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml
vendored
Normal file
6
MosaicIQ/src-tauri/tests/fixtures/sec/sap/instance.xml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:ifrs-full="http://xbrl.ifrs.org/taxonomy/2024-03-27/ifrs-full" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
|
||||
<xbrli:context id="fy2023"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">0001000184</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2023-01-01</xbrli:startDate><xbrli:endDate>2023-12-31</xbrli:endDate></xbrli:period></xbrli:context>
|
||||
<xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
|
||||
<ifrs-full:Revenue contextRef="fy2023" unitRef="usd">35200000000</ifrs-full:Revenue>
|
||||
</xbrli:xbrl>
|
||||
1
MosaicIQ/src-tauri/tests/fixtures/sec/sap/submissions.json
vendored
Normal file
1
MosaicIQ/src-tauri/tests/fixtures/sec/sap/submissions.json
vendored
Normal file
@@ -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"]}}}
|
||||
Reference in New Issue
Block a user