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
package/src/app.rs ADDED
@@ -0,0 +1,642 @@
1
+ use std::collections::BTreeMap;
2
+ use std::time::Duration;
3
+
4
+ use anyhow::{Result, anyhow};
5
+ use clap::Parser;
6
+ use futures_util::StreamExt;
7
+ use serde::Serialize;
8
+
9
+ use crate::cli::{
10
+ Cli, Command, FuturesArgs, HistoryArgs, HistorySession, IndicatorsArgs, NewsArgs, OptionsArgs,
11
+ OptionsProvider, Provider, ProviderResearchArgs, ProvidersArgs, ReadUrlArgs, ResearchArgs,
12
+ ScreenArgs, SearchArgs, SessionsArgs, StooqArgs, StooqCommand, WatchArgs,
13
+ };
14
+ use crate::http::http_client;
15
+ use crate::indicators::compute_indicator;
16
+ use crate::model::DerivedIndicator;
17
+ use crate::output;
18
+ use crate::page_read;
19
+ use crate::price;
20
+ use crate::providers::{self, binance_futures, stooq};
21
+ use crate::research;
22
+ use crate::skills;
23
+ use crate::stream;
24
+
25
+ pub async fn run() -> Result<()> {
26
+ let cli = Cli::parse();
27
+ let proxy = cli.proxy.as_deref();
28
+ let no_proxy = cli.no_proxy;
29
+ let timezone = cli.timezone.as_str();
30
+ let timeout_seconds = cli.timeout_seconds;
31
+ match cli.command {
32
+ Command::Price(args) => run_price(args, proxy, no_proxy, timeout_seconds, timezone).await,
33
+ Command::Sessions(args) => {
34
+ run_sessions(args, proxy, no_proxy, timeout_seconds, timezone).await
35
+ }
36
+ Command::History(args) => run_history(args, proxy, no_proxy, timeout_seconds).await,
37
+ Command::Indicators(args) => run_indicators(args, proxy, no_proxy, timeout_seconds).await,
38
+ Command::Futures(args) => run_futures(args, proxy, no_proxy, timeout_seconds).await,
39
+ Command::Fundamentals(args) => {
40
+ run_provider_quote_summary(
41
+ args,
42
+ research::QuoteSummaryKind::Fundamentals,
43
+ proxy,
44
+ no_proxy,
45
+ timeout_seconds,
46
+ timezone,
47
+ )
48
+ .await
49
+ }
50
+ Command::Analysis(args) => {
51
+ run_quote_summary(
52
+ args,
53
+ research::QuoteSummaryKind::Analysis,
54
+ crate::cli::ResearchProvider::Yahoo,
55
+ proxy,
56
+ no_proxy,
57
+ timeout_seconds,
58
+ timezone,
59
+ )
60
+ .await
61
+ }
62
+ Command::Options(args) => {
63
+ run_options(args, proxy, no_proxy, timeout_seconds, timezone).await
64
+ }
65
+ Command::Ownership(args) => {
66
+ run_quote_summary(
67
+ args,
68
+ research::QuoteSummaryKind::Ownership,
69
+ crate::cli::ResearchProvider::Yahoo,
70
+ proxy,
71
+ no_proxy,
72
+ timeout_seconds,
73
+ timezone,
74
+ )
75
+ .await
76
+ }
77
+ Command::Events(args) => {
78
+ run_provider_quote_summary(
79
+ args,
80
+ research::QuoteSummaryKind::Events,
81
+ proxy,
82
+ no_proxy,
83
+ timeout_seconds,
84
+ timezone,
85
+ )
86
+ .await
87
+ }
88
+ Command::News(args) => run_news(args, proxy, no_proxy, timeout_seconds, timezone).await,
89
+ Command::ReadUrl(args) => run_read_url(args, proxy, no_proxy, timeout_seconds).await,
90
+ Command::Search(args) => run_search(args, proxy, no_proxy, timeout_seconds, timezone).await,
91
+ Command::Screen(args) => run_screen(args, proxy, no_proxy, timeout_seconds, timezone).await,
92
+ Command::Stooq(args) => run_stooq(args, proxy, no_proxy, timeout_seconds).await,
93
+ Command::Providers(args) => run_providers(args),
94
+ Command::Watch(args) => run_watch(args, proxy, no_proxy, timeout_seconds, timezone).await,
95
+ Command::Stream(args) => run_stream(args, proxy, no_proxy, timeout_seconds, timezone).await,
96
+ Command::Skills(args) => run_skills(args),
97
+ }
98
+ }
99
+
100
+ fn run_skills(args: crate::cli::SkillsArgs) -> Result<()> {
101
+ match args.command {
102
+ crate::cli::SkillsCommand::List => {
103
+ skills::print_list();
104
+ Ok(())
105
+ }
106
+ crate::cli::SkillsCommand::Get(args) => {
107
+ let Some(content) = skills::get(&args.name, args.full) else {
108
+ return Err(anyhow!(
109
+ "unknown skill '{}'; run `agent-finance skills list`",
110
+ args.name
111
+ ));
112
+ };
113
+ println!("{content}");
114
+ Ok(())
115
+ }
116
+ }
117
+ }
118
+
119
+ async fn run_price(
120
+ args: crate::cli::PriceArgs,
121
+ proxy: Option<&str>,
122
+ no_proxy: bool,
123
+ timeout_seconds: u64,
124
+ timezone: &str,
125
+ ) -> Result<()> {
126
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
127
+ let summaries = futures_util::stream::iter(args.symbols)
128
+ .map(|symbol| {
129
+ let client = &client;
130
+ let proxy_symbol = args.proxy_symbol.as_deref();
131
+ async move {
132
+ price::fetch_price_summary(client, &symbol, timezone, args.session, proxy_symbol)
133
+ .await
134
+ }
135
+ })
136
+ .buffered(4)
137
+ .collect::<Vec<_>>()
138
+ .await;
139
+
140
+ if args.json {
141
+ println!("{}", serde_json::to_string_pretty(&summaries)?);
142
+ } else {
143
+ for (index, summary) in summaries.iter().enumerate() {
144
+ if index > 0 {
145
+ println!();
146
+ }
147
+ output::print_price_summary(
148
+ summary,
149
+ matches!(args.session, crate::cli::SessionMode::All),
150
+ );
151
+ }
152
+ }
153
+
154
+ if summaries.iter().all(|summary| summary.current.is_some()) {
155
+ Ok(())
156
+ } else {
157
+ Err(anyhow!("one or more price summaries had no current quote"))
158
+ }
159
+ }
160
+
161
+ async fn run_sessions(
162
+ args: SessionsArgs,
163
+ proxy: Option<&str>,
164
+ no_proxy: bool,
165
+ timeout_seconds: u64,
166
+ timezone: &str,
167
+ ) -> Result<()> {
168
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
169
+ let summary = price::fetch_price_summary(
170
+ &client,
171
+ &args.symbol,
172
+ timezone,
173
+ crate::cli::SessionMode::All,
174
+ args.proxy_symbol.as_deref(),
175
+ )
176
+ .await;
177
+
178
+ if args.json {
179
+ println!("{}", serde_json::to_string_pretty(&summary)?);
180
+ } else {
181
+ output::print_price_summary(&summary, true);
182
+ }
183
+
184
+ if summary.current.is_some() {
185
+ Ok(())
186
+ } else {
187
+ Err(anyhow!("no current quote found for {}", summary.symbol))
188
+ }
189
+ }
190
+
191
+ async fn run_history(
192
+ args: HistoryArgs,
193
+ proxy: Option<&str>,
194
+ no_proxy: bool,
195
+ timeout_seconds: u64,
196
+ ) -> Result<()> {
197
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
198
+ let provider = effective_history_provider(args.provider, args.session);
199
+ let request = providers::HistoryRequest {
200
+ symbol: args.symbol,
201
+ interval: args.interval,
202
+ range: args.range,
203
+ limit: args.limit,
204
+ extended_session: matches!(args.session, HistorySession::Extended),
205
+ adjustment: args.adjustment,
206
+ actions: !args.no_actions,
207
+ repair: args.repair,
208
+ stooq_market: args.stooq_market,
209
+ stooq_asset: args.stooq_asset,
210
+ };
211
+ let history = providers::fetch_history(&client, provider, &request).await?;
212
+
213
+ if args.json {
214
+ println!("{}", serde_json::to_string_pretty(&history)?);
215
+ } else {
216
+ output::print_history_table(&history);
217
+ }
218
+ Ok(())
219
+ }
220
+
221
+ async fn run_indicators(
222
+ args: IndicatorsArgs,
223
+ proxy: Option<&str>,
224
+ no_proxy: bool,
225
+ timeout_seconds: u64,
226
+ ) -> Result<()> {
227
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
228
+ let mut indicators = Vec::new();
229
+ let mut errors = BTreeMap::new();
230
+ let provider = effective_history_provider(args.provider, args.session);
231
+
232
+ let symbols = args
233
+ .symbols
234
+ .into_iter()
235
+ .map(|symbol| symbol.trim().to_uppercase())
236
+ .filter(|symbol| !symbol.is_empty())
237
+ .collect::<Vec<_>>();
238
+ let results = futures_util::stream::iter(symbols)
239
+ .map(|normalized| {
240
+ let client = &client;
241
+ let interval = args.interval.clone();
242
+ let range = args.range.clone();
243
+ let stooq_market = args.stooq_market;
244
+ let stooq_asset = args.stooq_asset;
245
+ async move {
246
+ let request = providers::HistoryRequest {
247
+ symbol: normalized.clone(),
248
+ interval,
249
+ range,
250
+ limit: args.limit,
251
+ extended_session: matches!(args.session, HistorySession::Extended),
252
+ adjustment: args.adjustment,
253
+ actions: false,
254
+ repair: args.repair,
255
+ stooq_market,
256
+ stooq_asset,
257
+ };
258
+ let result = providers::fetch_history(client, provider, &request).await;
259
+ (normalized, result)
260
+ }
261
+ })
262
+ .buffered(4)
263
+ .collect::<Vec<_>>()
264
+ .await;
265
+
266
+ for (normalized, result) in results {
267
+ match result {
268
+ Ok(history) => indicators.push(compute_indicator(&history)),
269
+ Err(error) => {
270
+ errors.insert(normalized, format!("{error:#}"));
271
+ }
272
+ }
273
+ }
274
+
275
+ let batch = IndicatorBatch { indicators, errors };
276
+ if args.json {
277
+ println!("{}", serde_json::to_string_pretty(&batch)?);
278
+ } else {
279
+ output::print_indicator_table(&batch.indicators, &batch.errors);
280
+ }
281
+
282
+ if batch.errors.is_empty() {
283
+ Ok(())
284
+ } else {
285
+ Err(anyhow!("one or more indicators failed"))
286
+ }
287
+ }
288
+
289
+ async fn run_futures(
290
+ args: FuturesArgs,
291
+ proxy: Option<&str>,
292
+ no_proxy: bool,
293
+ timeout_seconds: u64,
294
+ ) -> Result<()> {
295
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
296
+ let stats =
297
+ binance_futures::fetch_futures_stats(&client, &args.symbol, args.funding_limit).await;
298
+
299
+ if args.json {
300
+ println!("{}", serde_json::to_string_pretty(&stats)?);
301
+ } else {
302
+ output::print_futures_stats(&stats);
303
+ }
304
+
305
+ if stats.errors.is_empty() {
306
+ Ok(())
307
+ } else {
308
+ Err(anyhow!("one or more Binance futures endpoints failed"))
309
+ }
310
+ }
311
+
312
+ async fn run_quote_summary(
313
+ args: ResearchArgs,
314
+ kind: research::QuoteSummaryKind,
315
+ provider: crate::cli::ResearchProvider,
316
+ proxy: Option<&str>,
317
+ no_proxy: bool,
318
+ timeout_seconds: u64,
319
+ timezone: &str,
320
+ ) -> Result<()> {
321
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
322
+ let report = research::quote_summary_report(
323
+ &client,
324
+ &args.symbol,
325
+ kind,
326
+ provider,
327
+ timezone,
328
+ args.refresh,
329
+ args.cache_ttl_seconds,
330
+ )
331
+ .await?;
332
+ if args.json {
333
+ println!("{}", serde_json::to_string_pretty(&report)?);
334
+ } else {
335
+ output::print_research_report(&report, args.raw)?;
336
+ }
337
+ Ok(())
338
+ }
339
+
340
+ async fn run_provider_quote_summary(
341
+ args: ProviderResearchArgs,
342
+ kind: research::QuoteSummaryKind,
343
+ proxy: Option<&str>,
344
+ no_proxy: bool,
345
+ timeout_seconds: u64,
346
+ timezone: &str,
347
+ ) -> Result<()> {
348
+ let provider = args.provider;
349
+ run_quote_summary(
350
+ args.without_provider(),
351
+ kind,
352
+ provider,
353
+ proxy,
354
+ no_proxy,
355
+ timeout_seconds,
356
+ timezone,
357
+ )
358
+ .await
359
+ }
360
+
361
+ fn run_providers(args: ProvidersArgs) -> Result<()> {
362
+ let profiles = providers::capabilities::profiles();
363
+ if args.json {
364
+ println!("{}", serde_json::to_string_pretty(&profiles)?);
365
+ } else {
366
+ output::print_provider_profiles(&profiles);
367
+ }
368
+ Ok(())
369
+ }
370
+
371
+ async fn run_options(
372
+ args: OptionsArgs,
373
+ proxy: Option<&str>,
374
+ no_proxy: bool,
375
+ timeout_seconds: u64,
376
+ timezone: &str,
377
+ ) -> Result<()> {
378
+ if args.expiration_date.is_some() && !matches!(args.provider, OptionsProvider::Robinhood) {
379
+ return Err(anyhow!(
380
+ "--expiration-date is Robinhood-only; use --expiry for yahoo/auto or pass --provider robinhood"
381
+ ));
382
+ }
383
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
384
+ let report = research::options_report(research::OptionsReportRequest {
385
+ client: &client,
386
+ symbol: &args.symbol,
387
+ provider: args.provider,
388
+ expiry: args.expiry,
389
+ expiration_date: args.expiration_date.as_deref(),
390
+ count: args.count,
391
+ timezone,
392
+ refresh: args.refresh,
393
+ ttl_seconds: args.cache_ttl_seconds,
394
+ })
395
+ .await?;
396
+ if args.json {
397
+ println!("{}", serde_json::to_string_pretty(&report)?);
398
+ } else {
399
+ output::print_research_report(&report, args.raw)?;
400
+ }
401
+ Ok(())
402
+ }
403
+
404
+ async fn run_stooq(
405
+ args: StooqArgs,
406
+ proxy: Option<&str>,
407
+ no_proxy: bool,
408
+ timeout_seconds: u64,
409
+ ) -> Result<()> {
410
+ match args.command {
411
+ StooqCommand::Catalog(args) => {
412
+ let catalog = stooq::catalog();
413
+ if args.json {
414
+ println!("{}", serde_json::to_string_pretty(&catalog)?);
415
+ } else {
416
+ output::print_stooq_catalog(&catalog);
417
+ }
418
+ Ok(())
419
+ }
420
+ StooqCommand::Sync(args) => {
421
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
422
+ let report = stooq::sync_bulk(
423
+ &client,
424
+ stooq::StooqSyncRequest {
425
+ frequency: args.frequency,
426
+ market: args.market,
427
+ asset: args.asset,
428
+ url: args.url,
429
+ zip_path: args.zip_path,
430
+ force: args.force,
431
+ },
432
+ )
433
+ .await?;
434
+ if args.json {
435
+ println!("{}", serde_json::to_string_pretty(&report)?);
436
+ } else {
437
+ output::print_stooq_sync_report(&report);
438
+ }
439
+ Ok(())
440
+ }
441
+ }
442
+ }
443
+
444
+ async fn run_news(
445
+ args: NewsArgs,
446
+ proxy: Option<&str>,
447
+ no_proxy: bool,
448
+ timeout_seconds: u64,
449
+ timezone: &str,
450
+ ) -> Result<()> {
451
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
452
+ let report = research::news_report(
453
+ &client,
454
+ &args.symbol,
455
+ args.count,
456
+ timezone,
457
+ args.refresh,
458
+ args.cache_ttl_seconds,
459
+ )
460
+ .await?;
461
+ if args.json {
462
+ println!("{}", serde_json::to_string_pretty(&report)?);
463
+ } else {
464
+ output::print_search_report(&report, args.raw)?;
465
+ }
466
+ Ok(())
467
+ }
468
+
469
+ async fn run_read_url(
470
+ args: ReadUrlArgs,
471
+ proxy: Option<&str>,
472
+ no_proxy: bool,
473
+ timeout_seconds: u64,
474
+ ) -> Result<()> {
475
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
476
+ let max_chars = if args.json { 0 } else { args.max_chars };
477
+ let report = page_read::read_url(&client, &args.url, args.provider, max_chars).await?;
478
+ if args.json {
479
+ println!("{}", serde_json::to_string_pretty(&report)?);
480
+ } else {
481
+ output::print_page_read_report(&report);
482
+ }
483
+ Ok(())
484
+ }
485
+
486
+ async fn run_search(
487
+ args: SearchArgs,
488
+ proxy: Option<&str>,
489
+ no_proxy: bool,
490
+ timeout_seconds: u64,
491
+ timezone: &str,
492
+ ) -> Result<()> {
493
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
494
+ let report = research::search_report(
495
+ &client,
496
+ &args.query,
497
+ args.quotes_count,
498
+ args.news_count,
499
+ timezone,
500
+ args.refresh,
501
+ args.cache_ttl_seconds,
502
+ )
503
+ .await?;
504
+ if args.json {
505
+ println!("{}", serde_json::to_string_pretty(&report)?);
506
+ } else {
507
+ output::print_search_report(&report, args.raw)?;
508
+ }
509
+ Ok(())
510
+ }
511
+
512
+ async fn run_screen(
513
+ args: ScreenArgs,
514
+ proxy: Option<&str>,
515
+ no_proxy: bool,
516
+ timeout_seconds: u64,
517
+ timezone: &str,
518
+ ) -> Result<()> {
519
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
520
+ let report = research::screen_report(
521
+ &client,
522
+ &args.screener,
523
+ args.count,
524
+ timezone,
525
+ args.refresh,
526
+ args.cache_ttl_seconds,
527
+ )
528
+ .await?;
529
+ if args.json {
530
+ println!("{}", serde_json::to_string_pretty(&report)?);
531
+ } else {
532
+ output::print_search_report(&report, args.raw)?;
533
+ }
534
+ Ok(())
535
+ }
536
+
537
+ async fn run_watch(
538
+ args: WatchArgs,
539
+ proxy: Option<&str>,
540
+ no_proxy: bool,
541
+ timeout_seconds: u64,
542
+ timezone: &str,
543
+ ) -> Result<()> {
544
+ let client = http_client(timeout_seconds, proxy, no_proxy)?;
545
+ let mut iteration = 0usize;
546
+ loop {
547
+ iteration += 1;
548
+ let last_summaries = futures_util::stream::iter(args.symbols.iter())
549
+ .map(|symbol| {
550
+ let client = &client;
551
+ async move {
552
+ price::fetch_price_summary(
553
+ client,
554
+ symbol,
555
+ timezone,
556
+ crate::cli::SessionMode::Smart,
557
+ None,
558
+ )
559
+ .await
560
+ }
561
+ })
562
+ .buffered(4)
563
+ .collect::<Vec<_>>()
564
+ .await;
565
+ if args.json {
566
+ println!("{}", serde_json::to_string_pretty(&last_summaries)?);
567
+ } else {
568
+ for summary in &last_summaries {
569
+ output::print_price_summary(summary, false);
570
+ println!();
571
+ }
572
+ }
573
+ if args.iterations != 0 && iteration >= args.iterations {
574
+ break;
575
+ }
576
+ tokio::time::sleep(Duration::from_secs(args.interval_seconds.max(1))).await;
577
+ }
578
+ Ok(())
579
+ }
580
+
581
+ async fn run_stream(
582
+ args: crate::cli::StreamArgs,
583
+ proxy: Option<&str>,
584
+ no_proxy: bool,
585
+ timeout_seconds: u64,
586
+ timezone: &str,
587
+ ) -> Result<()> {
588
+ if args.messages == 0 {
589
+ stream::stream_quotes_each(
590
+ stream::StreamOptions {
591
+ url: &args.url,
592
+ symbols: args.symbols,
593
+ message_limit: args.messages,
594
+ read_timeout: Duration::from_secs(timeout_seconds.max(1)),
595
+ timezone,
596
+ proxy,
597
+ no_proxy,
598
+ },
599
+ |quote| {
600
+ if args.json {
601
+ println!("{}", serde_json::to_string(&quote)?);
602
+ } else {
603
+ output::print_stream_quotes(std::slice::from_ref(&quote));
604
+ }
605
+ Ok(())
606
+ },
607
+ )
608
+ .await?;
609
+ return Ok(());
610
+ }
611
+
612
+ let updates = stream::stream_quotes(stream::StreamOptions {
613
+ url: &args.url,
614
+ symbols: args.symbols,
615
+ message_limit: args.messages,
616
+ read_timeout: Duration::from_secs(timeout_seconds.max(1)),
617
+ timezone,
618
+ proxy,
619
+ no_proxy,
620
+ })
621
+ .await?;
622
+ if args.json {
623
+ println!("{}", serde_json::to_string_pretty(&updates)?);
624
+ } else {
625
+ output::print_stream_quotes(&updates);
626
+ }
627
+ Ok(())
628
+ }
629
+
630
+ fn effective_history_provider(provider: Provider, session: HistorySession) -> Provider {
631
+ match (provider, session) {
632
+ (Provider::Auto, HistorySession::Extended) => Provider::YahooExtended,
633
+ (Provider::Yahoo, HistorySession::Extended) => Provider::YahooExtended,
634
+ (provider, _) => provider,
635
+ }
636
+ }
637
+
638
+ #[derive(Debug, Serialize)]
639
+ struct IndicatorBatch {
640
+ indicators: Vec<DerivedIndicator>,
641
+ errors: BTreeMap<String, String>,
642
+ }
package/src/cache.rs ADDED
@@ -0,0 +1,67 @@
1
+ use std::fs;
2
+ use std::path::PathBuf;
3
+ use std::time::{Duration, SystemTime};
4
+
5
+ use anyhow::{Context, Result};
6
+ use base64::{Engine as _, engine::general_purpose};
7
+ use serde::{Deserialize, Serialize};
8
+ use serde_json::Value;
9
+
10
+ #[derive(Debug, Serialize, Deserialize)]
11
+ struct CacheEnvelope {
12
+ fetched_at_utc: String,
13
+ payload: Value,
14
+ }
15
+
16
+ pub fn read_json(namespace: &str, key: &str, ttl_seconds: u64) -> Option<(String, Value)> {
17
+ let path = cache_path(namespace, key).ok()?;
18
+ let metadata = fs::metadata(&path).ok()?;
19
+ let modified = metadata.modified().ok()?;
20
+ if SystemTime::now()
21
+ .duration_since(modified)
22
+ .unwrap_or(Duration::MAX)
23
+ > Duration::from_secs(ttl_seconds)
24
+ {
25
+ return None;
26
+ }
27
+ let payload = fs::read_to_string(path).ok()?;
28
+ let envelope = serde_json::from_str::<CacheEnvelope>(&payload).ok()?;
29
+ Some((envelope.fetched_at_utc, envelope.payload))
30
+ }
31
+
32
+ pub fn write_json(namespace: &str, key: &str, fetched_at_utc: &str, payload: &Value) -> Result<()> {
33
+ let path = cache_path(namespace, key)?;
34
+ if let Some(parent) = path.parent() {
35
+ fs::create_dir_all(parent)
36
+ .with_context(|| format!("failed to create cache directory {}", parent.display()))?;
37
+ }
38
+ let envelope = CacheEnvelope {
39
+ fetched_at_utc: fetched_at_utc.to_string(),
40
+ payload: payload.clone(),
41
+ };
42
+ fs::write(&path, serde_json::to_string_pretty(&envelope)?)
43
+ .with_context(|| format!("failed to write cache file {}", path.display()))
44
+ }
45
+
46
+ fn cache_path(namespace: &str, key: &str) -> Result<PathBuf> {
47
+ Ok(agent_finance_cache_root()?
48
+ .join(safe_segment(namespace))
49
+ .join(format!("{}.json", safe_segment(key))))
50
+ }
51
+
52
+ pub fn agent_finance_cache_root() -> Result<PathBuf> {
53
+ let root = std::env::var("XDG_CACHE_HOME")
54
+ .map(PathBuf::from)
55
+ .or_else(|_| std::env::var("HOME").map(|home| PathBuf::from(home).join(".cache")))
56
+ .context("HOME or XDG_CACHE_HOME is required for cache")?;
57
+ Ok(root.join("agent-finance-cli"))
58
+ }
59
+
60
+ fn safe_segment(value: &str) -> String {
61
+ let encoded = general_purpose::URL_SAFE_NO_PAD.encode(value.as_bytes());
62
+ if encoded.is_empty() {
63
+ "_".to_string()
64
+ } else {
65
+ encoded
66
+ }
67
+ }