feat(api): add MSN Finance as alternative data provider

Add MsnProvider implementing MarketDataProvider trait with quote,
fundamentals, and history support. Includes provider-aware config
(file/env/CLI), cache namespace isolation per provider, symbol ID
mapping via embedded TSV, OHLCV resampling, and comprehensive unit
+ integration tests with MSN fixture data.

Key changes:
- MsnProvider with quote, key-ratios, and chart endpoints
- ProviderKind enum (yahoo/msn) with config hierarchy support
- Provider-namespaced cache buckets to prevent cross-provider poisoning
- Provider-aware MockProvider loading correct fixtures per provider
- Integration tests for config round-trip and cache isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0xrsydn 2026-03-06 10:19:10 +00:00
commit 2cdefeb7a6
11 changed files with 2099 additions and 36 deletions

View file

@ -195,6 +195,8 @@ fn config_init_creates_file() {
.success();
assert!(config_home.join("idx/config.toml").exists());
let raw = fs::read_to_string(config_home.join("idx/config.toml")).expect("read config");
assert!(raw.contains("provider = \"yahoo\""));
}
#[test]
@ -239,6 +241,22 @@ fn serves_stale_cache_on_provider_failure_with_warning() {
.stderr(predicate::str::contains("warning: network failed"));
}
#[test]
fn config_set_and_get_provider_round_trip() {
let root = test_env_dir("config-provider");
bin_with_root(&root)
.args(["config", "set", "general.provider", "msn"])
.assert()
.success();
bin_with_root(&root)
.args(["config", "get", "general.provider"])
.assert()
.success()
.stdout(predicate::str::contains("msn"));
}
#[test]
fn technical_serves_stale_cache_on_provider_failure_with_warning() {
let root = test_env_dir("technical-stale");
@ -263,6 +281,43 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() {
.stderr(predicate::str::contains("warning: network failed"));
}
#[test]
fn cache_namespace_isolated_by_provider() {
let root = test_env_dir("provider-cache");
let cache_home = root.join("cache");
// Populate Yahoo cache
bin_with_root(&root)
.env("XDG_CACHE_HOME", &cache_home)
.env("IDX_USE_MOCK_PROVIDER", "1")
.env("IDX_CACHE_QUOTE_TTL", "0")
.args(["stocks", "quote", "BBCA"])
.assert()
.success();
// MSN mock succeeds independently (uses MSN fixtures, not Yahoo's cache)
bin_with_root(&root)
.env("XDG_CACHE_HOME", &cache_home)
.env("IDX_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1")
.env("IDX_CACHE_QUOTE_TTL", "0")
.args(["stocks", "quote", "BBCA"])
.assert()
.success();
// MSN with mock error fails — Yahoo's cached data is NOT reused for MSN
bin_with_root(&root)
.env("XDG_CACHE_HOME", &cache_home)
.env("IDX_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1")
.env("IDX_CACHE_QUOTE_TTL", "0")
.env("IDX_MOCK_ERROR", "1")
.args(["stocks", "quote", "BBCA"])
.assert()
.success()
.stderr(predicate::str::contains("warning: network failed"));
}
#[test]
fn invalid_symbol_returns_non_zero() {
test_bin("invalid-symbol")
@ -273,3 +328,13 @@ fn invalid_symbol_returns_non_zero() {
.failure()
.stderr(predicate::str::contains("Error:"));
}
#[test]
fn invalid_provider_env_returns_non_zero() {
test_bin("invalid-provider")
.env("IDX_PROVIDER", "bogus")
.args(["version"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid provider"));
}

22
tests/fixtures/msn_chart_bbca_3mo.json vendored Normal file
View file

@ -0,0 +1,22 @@
[
{
"_p": "bn91jc",
"chartType": "3M",
"symbol": "BBCA",
"series": {
"timeStamps": [
"2025-01-06T00:00:00Z",
"2025-01-07T00:00:00Z",
"2025-01-08T00:00:00Z",
"2025-01-13T00:00:00Z",
"2025-01-14T00:00:00Z",
"2025-01-15T00:00:00Z"
],
"prices": [9875.0, 9880.0, 9895.0, 9910.0, 9925.0, 9940.0],
"openPrices": [9800.0, 9850.0, 9870.0, 9890.0, 9905.0, 9920.0],
"pricesHigh": [9900.0, 9905.0, 9910.0, 9930.0, 9940.0, 9950.0],
"pricesLow": [9750.0, 9825.0, 9855.0, 9880.0, 9890.0, 9910.0],
"volumes": [12300000.0, 11000000.0, 11300000.0, 12500000.0, 11800000.0, 12000000.0]
}
}
]

36
tests/fixtures/msn_keyratios_bbca.json vendored Normal file
View file

@ -0,0 +1,36 @@
[
{
"stockId": "bn91jc",
"symbol": "BBCA",
"displayName": "Bank Central Asia Tbk",
"shortName": "Bank Central Asia",
"industryMetrics": [
{
"year": "2025",
"fiscalPeriodType": "FY",
"revenueGrowthRate": 0.081,
"earningsGrowthRate": 0.121,
"netMargin": 0.324,
"roe": 0.198,
"returnOnAssetCurrent": 0.031,
"debtToEquityRatio": 0.42,
"currentRatio": 1.18,
"priceToEarningsRatio": 25.4,
"priceToBookRatio": 4.6
},
{
"year": "2024",
"fiscalPeriodType": "FY",
"revenueGrowthRate": 0.073,
"earningsGrowthRate": 0.102,
"netMargin": 0.311,
"roe": 0.187,
"returnOnAssetCurrent": 0.029,
"debtToEquityRatio": 0.45,
"currentRatio": 1.14,
"priceToEarningsRatio": 24.1,
"priceToBookRatio": 4.2
}
]
}
]

18
tests/fixtures/msn_quote_bbca.json vendored Normal file
View file

@ -0,0 +1,18 @@
[
{
"id": "bn91jc",
"instrumentId": "bn91jc",
"symbol": "BBCA",
"shortName": "Bank Central Asia",
"displayName": "Bank Central Asia Tbk",
"price": 9875.0,
"priceChange": 117.0,
"priceChangePercent": 1.2,
"pricePreviousClose": 9758.0,
"price52wHigh": 10250.0,
"price52wLow": 7800.0,
"accumulatedVolume": 12300000.0,
"averageVolume": 10000000.0,
"marketCap": 1215200000000000.0
}
]