agent-finance-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/Cargo.lock +2632 -0
  2. package/Cargo.toml +31 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/LICENSE-MIT +21 -0
  5. package/README.md +119 -0
  6. package/bin/agent-finance.js +27 -0
  7. package/npm/check-binary-links.js +50 -0
  8. package/npm/check-package.js +39 -0
  9. package/npm/create-platform-package.js +90 -0
  10. package/npm/platform.js +33 -0
  11. package/npm/postinstall.js +62 -0
  12. package/npm/resolve-binary.js +38 -0
  13. package/package.json +54 -0
  14. package/skills/core-full.md +74 -0
  15. package/skills/core.md +59 -0
  16. package/skills/futures.md +18 -0
  17. package/skills/history-indicators.md +42 -0
  18. package/skills/price.md +40 -0
  19. package/skills/providers.md +25 -0
  20. package/skills/research-data.md +34 -0
  21. package/src/app.rs +642 -0
  22. package/src/cache.rs +67 -0
  23. package/src/cli.rs +651 -0
  24. package/src/history.rs +150 -0
  25. package/src/http.rs +76 -0
  26. package/src/indicators.rs +82 -0
  27. package/src/lib.rs +15 -0
  28. package/src/main.rs +4 -0
  29. package/src/model.rs +347 -0
  30. package/src/output.rs +544 -0
  31. package/src/page_read.rs +443 -0
  32. package/src/price.rs +255 -0
  33. package/src/providers/binance_futures.rs +342 -0
  34. package/src/providers/capabilities.rs +322 -0
  35. package/src/providers/cnbc.rs +302 -0
  36. package/src/providers/mod.rs +117 -0
  37. package/src/providers/robinhood.rs +580 -0
  38. package/src/providers/sec_edgar.rs +399 -0
  39. package/src/providers/stooq/catalog.rs +159 -0
  40. package/src/providers/stooq.rs +904 -0
  41. package/src/providers/yahoo.rs +836 -0
  42. package/src/research/fetchers.rs +111 -0
  43. package/src/research/highlights.rs +345 -0
  44. package/src/research/mod.rs +943 -0
  45. package/src/research/tests.rs +42 -0
  46. package/src/skills.rs +58 -0
  47. package/src/stream.rs +356 -0
  48. package/src/time.rs +21 -0
@@ -0,0 +1,342 @@
1
+ use std::collections::BTreeMap;
2
+
3
+ use anyhow::{Context, Result};
4
+ use serde::Deserialize;
5
+ use serde_json::Value;
6
+ use wreq::Client;
7
+
8
+ use crate::http::{parse_optional_f64, timestamp_ms_to_utc, utc_now};
9
+ use crate::model::{
10
+ FuturesFundingRate, FuturesMarkPrice, FuturesOpenInterest, FuturesStats, FuturesTicker24h,
11
+ HistoryBatch, OhlcBar, Quote, SESSION_24H_PROXY,
12
+ };
13
+
14
+ const BASE_URL: &str = "https://fapi.binance.com";
15
+
16
+ #[derive(Debug, Deserialize)]
17
+ #[serde(rename_all = "camelCase")]
18
+ struct PriceTicker {
19
+ symbol: String,
20
+ price: String,
21
+ time: Option<i64>,
22
+ }
23
+
24
+ #[derive(Debug, Deserialize)]
25
+ #[serde(rename_all = "camelCase")]
26
+ struct Ticker24h {
27
+ last_price: Option<String>,
28
+ price_change: Option<String>,
29
+ price_change_percent: Option<String>,
30
+ weighted_avg_price: Option<String>,
31
+ open_price: Option<String>,
32
+ high_price: Option<String>,
33
+ low_price: Option<String>,
34
+ volume: Option<String>,
35
+ quote_volume: Option<String>,
36
+ open_time: Option<i64>,
37
+ close_time: Option<i64>,
38
+ count: Option<u64>,
39
+ }
40
+
41
+ #[derive(Debug, Deserialize)]
42
+ #[serde(rename_all = "camelCase")]
43
+ struct PremiumIndex {
44
+ mark_price: Option<String>,
45
+ index_price: Option<String>,
46
+ estimated_settle_price: Option<String>,
47
+ last_funding_rate: Option<String>,
48
+ interest_rate: Option<String>,
49
+ next_funding_time: Option<i64>,
50
+ time: Option<i64>,
51
+ }
52
+
53
+ #[derive(Debug, Deserialize)]
54
+ #[serde(rename_all = "camelCase")]
55
+ struct OpenInterest {
56
+ open_interest: Option<String>,
57
+ time: Option<i64>,
58
+ }
59
+
60
+ #[derive(Debug, Deserialize)]
61
+ #[serde(rename_all = "camelCase")]
62
+ struct FundingRate {
63
+ funding_rate: Option<String>,
64
+ funding_time: Option<i64>,
65
+ mark_price: Option<String>,
66
+ }
67
+
68
+ pub async fn fetch_quote(client: &Client, symbol: &str) -> Result<Quote> {
69
+ let provider_symbol = symbol.to_uppercase();
70
+ let url = format!(
71
+ "{}/fapi/v2/ticker/price?symbol={provider_symbol}",
72
+ base_url()
73
+ );
74
+ let ticker: PriceTicker = client
75
+ .get(url)
76
+ .send()
77
+ .await
78
+ .context("Binance futures price request failed")?
79
+ .error_for_status()
80
+ .context("Binance futures price returned HTTP error")?
81
+ .json()
82
+ .await
83
+ .context("Binance futures price JSON parse failed")?;
84
+ let price = ticker
85
+ .price
86
+ .parse::<f64>()
87
+ .context("Binance futures price is not numeric")?;
88
+
89
+ Ok(Quote {
90
+ symbol: ticker.symbol,
91
+ price,
92
+ currency: Some("USDT".to_string()),
93
+ provider: "binance-futures".to_string(),
94
+ session: Some(SESSION_24H_PROXY.to_string()),
95
+ fetched_at_utc: utc_now(),
96
+ market_time: ticker.time.and_then(timestamp_ms_to_utc),
97
+ previous_close: None,
98
+ open: None,
99
+ high: None,
100
+ low: None,
101
+ volume: None,
102
+ exchange: Some("Binance USDⓈ-M Futures".to_string()),
103
+ provider_symbol: Some(provider_symbol),
104
+ change_pct: None,
105
+ })
106
+ }
107
+
108
+ pub async fn fetch_history(
109
+ client: &Client,
110
+ symbol: &str,
111
+ interval: &str,
112
+ limit: usize,
113
+ ) -> Result<HistoryBatch> {
114
+ let provider_symbol = symbol.to_uppercase();
115
+ let limit = limit.clamp(1, 1500);
116
+ let url = format!(
117
+ "{}/fapi/v1/klines?symbol={provider_symbol}&interval={interval}&limit={limit}",
118
+ base_url()
119
+ );
120
+ let rows: Vec<Vec<Value>> = client
121
+ .get(url)
122
+ .send()
123
+ .await
124
+ .context("Binance futures klines request failed")?
125
+ .error_for_status()
126
+ .context("Binance futures klines returned HTTP error")?
127
+ .json()
128
+ .await
129
+ .context("Binance futures klines JSON parse failed")?;
130
+ let bars = rows
131
+ .into_iter()
132
+ .filter_map(|row| kline_row_to_bar(symbol, row))
133
+ .collect::<Vec<_>>();
134
+
135
+ Ok(HistoryBatch {
136
+ symbol: symbol.to_uppercase(),
137
+ provider: "binance-futures".to_string(),
138
+ interval: interval.to_string(),
139
+ adjustment: "raw".to_string(),
140
+ actions_included: false,
141
+ repair_requested: false,
142
+ repair_applied: false,
143
+ bars,
144
+ })
145
+ }
146
+
147
+ pub async fn fetch_futures_stats(
148
+ client: &Client,
149
+ symbol: &str,
150
+ funding_limit: usize,
151
+ ) -> FuturesStats {
152
+ let normalized = symbol.to_uppercase();
153
+ let mut errors = BTreeMap::new();
154
+
155
+ let (ticker_24h_result, mark_price_result, open_interest_result, funding_rates_result) = tokio::join!(
156
+ fetch_24h(client, &normalized),
157
+ fetch_mark_price(client, &normalized),
158
+ fetch_open_interest(client, &normalized),
159
+ fetch_funding_rates(client, &normalized, funding_limit)
160
+ );
161
+
162
+ let ticker_24h = match ticker_24h_result {
163
+ Ok(value) => Some(value),
164
+ Err(error) => {
165
+ errors.insert("ticker_24h".to_string(), format!("{error:#}"));
166
+ None
167
+ }
168
+ };
169
+ let mark_price = match mark_price_result {
170
+ Ok(value) => Some(value),
171
+ Err(error) => {
172
+ errors.insert("mark_price".to_string(), format!("{error:#}"));
173
+ None
174
+ }
175
+ };
176
+ let open_interest = match open_interest_result {
177
+ Ok(value) => Some(value),
178
+ Err(error) => {
179
+ errors.insert("open_interest".to_string(), format!("{error:#}"));
180
+ None
181
+ }
182
+ };
183
+ let funding_rates = match funding_rates_result {
184
+ Ok(value) => value,
185
+ Err(error) => {
186
+ errors.insert("funding_rates".to_string(), format!("{error:#}"));
187
+ Vec::new()
188
+ }
189
+ };
190
+
191
+ FuturesStats {
192
+ symbol: normalized,
193
+ provider: "binance-futures".to_string(),
194
+ fetched_at_utc: utc_now(),
195
+ ticker_24h,
196
+ mark_price,
197
+ open_interest,
198
+ funding_rates,
199
+ errors,
200
+ }
201
+ }
202
+
203
+ async fn fetch_24h(client: &Client, symbol: &str) -> Result<FuturesTicker24h> {
204
+ let url = format!("{}/fapi/v1/ticker/24hr?symbol={symbol}", base_url());
205
+ let ticker: Ticker24h = client
206
+ .get(url)
207
+ .send()
208
+ .await
209
+ .context("Binance futures 24h ticker request failed")?
210
+ .error_for_status()
211
+ .context("Binance futures 24h ticker returned HTTP error")?
212
+ .json()
213
+ .await
214
+ .context("Binance futures 24h ticker JSON parse failed")?;
215
+ Ok(FuturesTicker24h {
216
+ last_price: parse_optional_f64(ticker.last_price.as_deref()),
217
+ price_change: parse_optional_f64(ticker.price_change.as_deref()),
218
+ price_change_pct: parse_optional_f64(ticker.price_change_percent.as_deref()),
219
+ weighted_avg_price: parse_optional_f64(ticker.weighted_avg_price.as_deref()),
220
+ open_price: parse_optional_f64(ticker.open_price.as_deref()),
221
+ high_price: parse_optional_f64(ticker.high_price.as_deref()),
222
+ low_price: parse_optional_f64(ticker.low_price.as_deref()),
223
+ volume: parse_optional_f64(ticker.volume.as_deref()),
224
+ quote_volume: parse_optional_f64(ticker.quote_volume.as_deref()),
225
+ count: ticker.count,
226
+ open_time: ticker.open_time.and_then(timestamp_ms_to_utc),
227
+ close_time: ticker.close_time.and_then(timestamp_ms_to_utc),
228
+ })
229
+ }
230
+
231
+ async fn fetch_mark_price(client: &Client, symbol: &str) -> Result<FuturesMarkPrice> {
232
+ let url = format!("{}/fapi/v1/premiumIndex?symbol={symbol}", base_url());
233
+ let mark: PremiumIndex = client
234
+ .get(url)
235
+ .send()
236
+ .await
237
+ .context("Binance futures mark price request failed")?
238
+ .error_for_status()
239
+ .context("Binance futures mark price returned HTTP error")?
240
+ .json()
241
+ .await
242
+ .context("Binance futures mark price JSON parse failed")?;
243
+ Ok(FuturesMarkPrice {
244
+ mark_price: parse_optional_f64(mark.mark_price.as_deref()),
245
+ index_price: parse_optional_f64(mark.index_price.as_deref()),
246
+ estimated_settle_price: parse_optional_f64(mark.estimated_settle_price.as_deref()),
247
+ last_funding_rate: parse_optional_f64(mark.last_funding_rate.as_deref()),
248
+ interest_rate: parse_optional_f64(mark.interest_rate.as_deref()),
249
+ next_funding_time: mark.next_funding_time.and_then(timestamp_ms_to_utc),
250
+ time: mark.time.and_then(timestamp_ms_to_utc),
251
+ })
252
+ }
253
+
254
+ async fn fetch_open_interest(client: &Client, symbol: &str) -> Result<FuturesOpenInterest> {
255
+ let url = format!("{}/fapi/v1/openInterest?symbol={symbol}", base_url());
256
+ let open_interest: OpenInterest = client
257
+ .get(url)
258
+ .send()
259
+ .await
260
+ .context("Binance futures open interest request failed")?
261
+ .error_for_status()
262
+ .context("Binance futures open interest returned HTTP error")?
263
+ .json()
264
+ .await
265
+ .context("Binance futures open interest JSON parse failed")?;
266
+ Ok(FuturesOpenInterest {
267
+ open_interest: parse_optional_f64(open_interest.open_interest.as_deref()),
268
+ time: open_interest.time.and_then(timestamp_ms_to_utc),
269
+ })
270
+ }
271
+
272
+ async fn fetch_funding_rates(
273
+ client: &Client,
274
+ symbol: &str,
275
+ funding_limit: usize,
276
+ ) -> Result<Vec<FuturesFundingRate>> {
277
+ let limit = funding_limit.clamp(1, 1000);
278
+ let url = format!(
279
+ "{}/fapi/v1/fundingRate?symbol={symbol}&limit={limit}",
280
+ base_url()
281
+ );
282
+ let rows: Vec<FundingRate> = client
283
+ .get(url)
284
+ .send()
285
+ .await
286
+ .context("Binance futures funding rate request failed")?
287
+ .error_for_status()
288
+ .context("Binance futures funding rate returned HTTP error")?
289
+ .json()
290
+ .await
291
+ .context("Binance futures funding rate JSON parse failed")?;
292
+ Ok(rows
293
+ .into_iter()
294
+ .map(|row| FuturesFundingRate {
295
+ funding_rate: parse_optional_f64(row.funding_rate.as_deref()),
296
+ funding_time: row.funding_time.and_then(timestamp_ms_to_utc),
297
+ mark_price: parse_optional_f64(row.mark_price.as_deref()),
298
+ })
299
+ .collect())
300
+ }
301
+
302
+ fn kline_row_to_bar(symbol: &str, row: Vec<Value>) -> Option<OhlcBar> {
303
+ let open_time = row.first()?.as_i64()?;
304
+ let open = parse_value_f64(row.get(1));
305
+ let high = parse_value_f64(row.get(2));
306
+ let low = parse_value_f64(row.get(3));
307
+ let close = parse_value_f64(row.get(4))?;
308
+ let volume = parse_value_f64(row.get(5));
309
+ let close_time = row.get(6).and_then(Value::as_i64);
310
+ let quote_volume = parse_value_f64(row.get(7));
311
+ let trades = row.get(8).and_then(Value::as_u64);
312
+ Some(OhlcBar {
313
+ symbol: symbol.to_uppercase(),
314
+ provider: "binance-futures".to_string(),
315
+ open_time: timestamp_ms_to_utc(open_time)?,
316
+ close_time: close_time.and_then(timestamp_ms_to_utc),
317
+ open,
318
+ high,
319
+ low,
320
+ close,
321
+ volume,
322
+ quote_volume,
323
+ trades,
324
+ adj_close: None,
325
+ dividend: None,
326
+ stock_split: None,
327
+ capital_gain: None,
328
+ repaired: false,
329
+ })
330
+ }
331
+
332
+ fn parse_value_f64(value: Option<&Value>) -> Option<f64> {
333
+ match value? {
334
+ Value::String(value) => parse_optional_f64(Some(value)),
335
+ Value::Number(value) => value.as_f64(),
336
+ _ => None,
337
+ }
338
+ }
339
+
340
+ fn base_url() -> String {
341
+ std::env::var("BINANCE_FUTURES_BASE_URL").unwrap_or_else(|_| BASE_URL.to_string())
342
+ }
@@ -0,0 +1,322 @@
1
+ use crate::cli::{Provider, ResearchProvider};
2
+ use crate::model::{ProviderCapability, ProviderProfile};
3
+
4
+ pub fn profiles() -> Vec<ProviderProfile> {
5
+ vec![
6
+ profile(
7
+ ResearchProvider::Auto.label(),
8
+ false,
9
+ "composite",
10
+ "composite",
11
+ "Default router; selects the most useful no-key source by module.",
12
+ &[
13
+ cap(
14
+ "quote",
15
+ "yes",
16
+ "Yahoo BOATS/extended/regular, then Stooq fallback",
17
+ ),
18
+ cap("history", "yes", "Yahoo, then Stooq fallback"),
19
+ cap(
20
+ "fundamentals",
21
+ "partial",
22
+ "Yahoo quoteSummary + SEC companyfacts/submissions + Robinhood/CNBC cross-check",
23
+ ),
24
+ cap(
25
+ "events",
26
+ "partial",
27
+ "Yahoo calendar/secFilings + SEC submissions + Robinhood splits/market hours",
28
+ ),
29
+ cap(
30
+ "analysis",
31
+ "yahoo-only",
32
+ "No stable no-key replacement is currently implemented",
33
+ ),
34
+ cap(
35
+ "options",
36
+ "partial",
37
+ "Yahoo optionChain + Robinhood chain/contract metadata",
38
+ ),
39
+ cap(
40
+ "ownership",
41
+ "yahoo-only",
42
+ "No stable no-key replacement is currently implemented",
43
+ ),
44
+ cap(
45
+ "news",
46
+ "yahoo-only",
47
+ "CNBC/Nasdaq public pages are useful browser research targets, but not stable CLI providers yet",
48
+ ),
49
+ cap("search", "yahoo-only", "Yahoo Finance search"),
50
+ cap("screen", "yahoo-only", "Yahoo predefined screeners"),
51
+ ],
52
+ &["Composite source; always inspect provider/source fields per module."],
53
+ ),
54
+ profile(
55
+ ResearchProvider::Yahoo.label(),
56
+ false,
57
+ "unofficial-public-endpoint",
58
+ "unofficial",
59
+ "Broadest no-key research data source.",
60
+ &[
61
+ cap("quote", "yes", "chart/v7 quote"),
62
+ cap("history", "yes", "chart OHLCV"),
63
+ cap("extended sessions", "yes", "includePrePost and BOATS quote"),
64
+ cap("fundamentals", "yes", "quoteSummary modules"),
65
+ cap(
66
+ "analysis",
67
+ "yes",
68
+ "analyst targets/recommendations/estimates",
69
+ ),
70
+ cap("options", "yes", "option expiries and chains"),
71
+ cap("ownership", "yes", "holders and insider modules"),
72
+ cap("events", "yes", "calendarEvents/secFilings/earnings"),
73
+ cap("news", "yes", "finance search news"),
74
+ cap("search", "yes", "finance search"),
75
+ cap("screen", "yes", "predefined screeners"),
76
+ ],
77
+ &[
78
+ "Not an official stable API; verify key facts against company releases, SEC filings, and primary text.",
79
+ ],
80
+ ),
81
+ profile(
82
+ ResearchProvider::SecEdgar.label(),
83
+ false,
84
+ "official-api",
85
+ "official",
86
+ "Official filings, submissions, and XBRL companyfacts.",
87
+ &[
88
+ cap("filings", "yes", "submissions API"),
89
+ cap("companyfacts", "yes", "XBRL companyfacts API"),
90
+ cap(
91
+ "fundamentals",
92
+ "partial",
93
+ "Official disclosure facts; no valuation or analyst data",
94
+ ),
95
+ cap(
96
+ "events",
97
+ "partial",
98
+ "Recent filings; no Yahoo earnings-calendar estimates",
99
+ ),
100
+ cap("quote", "no", "SEC does not provide market quotes"),
101
+ cap("history", "no", "SEC does not provide OHLCV history"),
102
+ cap("analysis", "no", "SEC does not provide analyst targets"),
103
+ cap("options", "no", "SEC does not provide option chains"),
104
+ cap("news", "no", "SEC does not provide news aggregation"),
105
+ cap(
106
+ "search",
107
+ "no",
108
+ "Only ticker-to-CIK and company filings are implemented here",
109
+ ),
110
+ cap("screen", "no", "SEC does not provide stock screeners"),
111
+ ],
112
+ &[
113
+ "Fields come from filed XBRL and differ from Yahoo financialData definitions; preserve provenance.",
114
+ ],
115
+ ),
116
+ profile(
117
+ Provider::Stooq.label(),
118
+ false,
119
+ "official-public-download",
120
+ "public-html/csv",
121
+ "Delayed quotes, no-key HTML history tables, and explicitly imported bulk OHLCV.",
122
+ &[
123
+ cap("quote", "yes", "delayed CSV"),
124
+ cap(
125
+ "history",
126
+ "yes",
127
+ "daily HTML table; weekly/monthly are aggregated from daily rows; CSV can use STOOQ_API_KEY",
128
+ ),
129
+ cap("catalog", "yes", "Official daily/hourly/5min bulk catalog"),
130
+ cap(
131
+ "bulk history",
132
+ "yes",
133
+ "Read hourly/5min after explicit sync from captcha-authorized URL or local ZIP",
134
+ ),
135
+ cap("research", "no", "No fundamentals/analysis/options/news"),
136
+ ],
137
+ &[
138
+ "CSV downloads require a captcha-issued API key; no-key live history uses web tables.",
139
+ "Web tables can hit Stooq daily site limits; use STOOQ_API_KEY or bulk sync for stable batch history.",
140
+ "Useful as price backup and historical-data base, not as a research-data provider.",
141
+ ],
142
+ ),
143
+ profile(
144
+ Provider::CnbcExtended.label(),
145
+ false,
146
+ "unofficial-public-endpoint",
147
+ "public-endpoint",
148
+ "pre/post extended quote cross-check.",
149
+ &[
150
+ cap("quote", "yes", "ExtendedMktQuote"),
151
+ cap("history", "no", "History is not currently implemented"),
152
+ cap(
153
+ "research",
154
+ "no",
155
+ "Use cnbc for research data; page evidence still belongs in a browser",
156
+ ),
157
+ ],
158
+ &["Use for extended-price cross-checking, not as a complete research source."],
159
+ ),
160
+ profile(
161
+ ResearchProvider::Cnbc.label(),
162
+ false,
163
+ "unofficial-public-endpoint",
164
+ "public-endpoint",
165
+ "CNBC quote payload fundamentals-lite valuation cross-check.",
166
+ &[
167
+ cap(
168
+ "fundamentals-lite",
169
+ "yes",
170
+ "PE, forward PE, market cap, beta, TTM revenue, margins, and other quote payload fields",
171
+ ),
172
+ cap(
173
+ "quote",
174
+ "partial",
175
+ "payload includes quote fields; extended-hours quote command uses cnbc-extended",
176
+ ),
177
+ cap("history", "no", "History is not currently implemented"),
178
+ cap(
179
+ "research",
180
+ "partial",
181
+ "CLI provider covers fundamentals-lite; full news and page evidence still require a browser",
182
+ ),
183
+ ],
184
+ &["Use for fundamentals-lite cross-checking, not as a complete research source."],
185
+ ),
186
+ profile(
187
+ Provider::Robinhood.label(),
188
+ false,
189
+ "unofficial-public-endpoint",
190
+ "public-endpoint",
191
+ "extended-hours quote, instrument/fundamentals, minute history, and option metadata cross-check.",
192
+ &[
193
+ cap("quote", "yes", "public quote"),
194
+ cap("history", "yes", "public historicals endpoint"),
195
+ cap(
196
+ "fundamentals",
197
+ "partial",
198
+ "instrument profile + fundamentals endpoint",
199
+ ),
200
+ cap("events", "partial", "splits and market hours"),
201
+ cap(
202
+ "options",
203
+ "partial",
204
+ "chain expirations and contract metadata",
205
+ ),
206
+ available_cap(
207
+ "option quotes",
208
+ "auth-limited",
209
+ "marketdata options endpoints may require auth; expose as coverage gap when blocked",
210
+ ),
211
+ ],
212
+ &["Use for extended-hours price checks."],
213
+ ),
214
+ profile(
215
+ Provider::BinanceFutures.label(),
216
+ false,
217
+ "official-api",
218
+ "exchange-api",
219
+ "TradFi / futures / pre-IPO proxy contract price discovery.",
220
+ &[
221
+ cap("quote", "yes", "USD-M futures ticker/mark price"),
222
+ cap("history", "yes", "klines"),
223
+ cap(
224
+ "proxy metrics",
225
+ "yes",
226
+ "24h ticker、mark、open interest、funding",
227
+ ),
228
+ cap("research", "no", "No company fundamentals/analysis"),
229
+ ],
230
+ &["Proxy price is not the stock or pre-IPO legal-equity price."],
231
+ ),
232
+ ]
233
+ }
234
+
235
+ fn profile(
236
+ provider: &str,
237
+ requires_api_key: bool,
238
+ official_status: &str,
239
+ stability: &str,
240
+ best_for: &str,
241
+ capabilities: &[ProviderCapability],
242
+ limitations: &[&str],
243
+ ) -> ProviderProfile {
244
+ ProviderProfile {
245
+ provider: provider.to_string(),
246
+ requires_api_key,
247
+ official_status: official_status.to_string(),
248
+ stability: stability.to_string(),
249
+ best_for: best_for.to_string(),
250
+ large_download: provider == Provider::Stooq.label(),
251
+ capabilities: capabilities.to_vec(),
252
+ limitations: limitations.iter().map(|value| value.to_string()).collect(),
253
+ }
254
+ }
255
+
256
+ fn cap(module: &str, status: &str, note: &str) -> ProviderCapability {
257
+ implemented_cap(module, status, note, true)
258
+ }
259
+
260
+ fn available_cap(module: &str, status: &str, note: &str) -> ProviderCapability {
261
+ implemented_cap(module, status, note, false)
262
+ }
263
+
264
+ fn implemented_cap(
265
+ module: &str,
266
+ status: &str,
267
+ note: &str,
268
+ implemented: bool,
269
+ ) -> ProviderCapability {
270
+ ProviderCapability {
271
+ module: module.to_string(),
272
+ status: status.to_string(),
273
+ note: note.to_string(),
274
+ implemented,
275
+ }
276
+ }
277
+
278
+ #[cfg(test)]
279
+ mod tests {
280
+ use super::*;
281
+
282
+ #[test]
283
+ fn sec_edgar_is_official_but_not_a_market_or_options_provider() {
284
+ let profiles = profiles();
285
+ let sec = profiles
286
+ .iter()
287
+ .find(|profile| profile.provider == "sec-edgar")
288
+ .expect("sec-edgar profile");
289
+
290
+ assert!(!sec.requires_api_key);
291
+ assert_eq!(sec.stability, "official");
292
+ assert!(sec.capabilities.iter().any(|capability| {
293
+ capability.module == "companyfacts" && capability.status == "yes"
294
+ }));
295
+ assert!(
296
+ sec.capabilities
297
+ .iter()
298
+ .any(|capability| { capability.module == "quote" && capability.status == "no" })
299
+ );
300
+ assert!(
301
+ sec.capabilities
302
+ .iter()
303
+ .any(|capability| { capability.module == "options" && capability.status == "no" })
304
+ );
305
+ }
306
+
307
+ #[test]
308
+ fn auto_marks_research_breadth_as_partial_not_full_replacement() {
309
+ let profiles = profiles();
310
+ let auto = profiles
311
+ .iter()
312
+ .find(|profile| profile.provider == "auto")
313
+ .expect("auto profile");
314
+
315
+ assert!(auto.capabilities.iter().any(|capability| {
316
+ capability.module == "fundamentals" && capability.status == "partial"
317
+ }));
318
+ assert!(auto.capabilities.iter().any(|capability| {
319
+ capability.module == "analysis" && capability.status == "yahoo-only"
320
+ }));
321
+ }
322
+ }