feat(terminal): add Google Finance and SEC EDGAR backends

This commit is contained in:
2026-04-05 21:11:05 -04:00
parent d62b02482e
commit cfc5a615e3
44 changed files with 4691 additions and 476 deletions

View File

@@ -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"

View File

@@ -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]

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);

View File

@@ -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.

View File

@@ -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(

View File

@@ -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,

View File

@@ -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),
})
}

View File

@@ -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"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};

View 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));
}
}

View 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)
}

View 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};

View 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));
}
}

View 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,
}

View 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(':')
}

View 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));
}
}

View File

@@ -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,
}

View File

@@ -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()]));
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"directory":{"item":[{"name":"aapl-20240928_htm.xml"}]}}

View File

@@ -0,0 +1 @@
{"directory":{"item":[{"name":"aapl-20241228_htm.xml"}]}}

View 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>

View 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>

View 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"]}}}

View 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"}}

View 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"}]}}}}}

View File

@@ -0,0 +1 @@
{"directory":{"item":[{"name":"ko-20250328_htm.xml"}]}}

View 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>

View 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"]}}}

View 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"}]}}}}}

View File

@@ -0,0 +1 @@
{"directory":{"item":[{"name":"msft-20240630_htm.xml"}]}}

View 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>

View 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"]}}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"directory":{"item":[{"name":"nvda-20250427_htm.xml"}]}}

View 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>

View 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"]}}}

View 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"}]}}}}}

View File

@@ -0,0 +1 @@
{"directory":{"item":[{"name":"sap-20231231_htm.xml"}]}}

View 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>

View 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"]}}}