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.
- package/Cargo.lock +2632 -0
- package/Cargo.toml +31 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +119 -0
- package/bin/agent-finance.js +27 -0
- package/npm/check-binary-links.js +50 -0
- package/npm/check-package.js +39 -0
- package/npm/create-platform-package.js +90 -0
- package/npm/platform.js +33 -0
- package/npm/postinstall.js +62 -0
- package/npm/resolve-binary.js +38 -0
- package/package.json +54 -0
- package/skills/core-full.md +74 -0
- package/skills/core.md +59 -0
- package/skills/futures.md +18 -0
- package/skills/history-indicators.md +42 -0
- package/skills/price.md +40 -0
- package/skills/providers.md +25 -0
- package/skills/research-data.md +34 -0
- package/src/app.rs +642 -0
- package/src/cache.rs +67 -0
- package/src/cli.rs +651 -0
- package/src/history.rs +150 -0
- package/src/http.rs +76 -0
- package/src/indicators.rs +82 -0
- package/src/lib.rs +15 -0
- package/src/main.rs +4 -0
- package/src/model.rs +347 -0
- package/src/output.rs +544 -0
- package/src/page_read.rs +443 -0
- package/src/price.rs +255 -0
- package/src/providers/binance_futures.rs +342 -0
- package/src/providers/capabilities.rs +322 -0
- package/src/providers/cnbc.rs +302 -0
- package/src/providers/mod.rs +117 -0
- package/src/providers/robinhood.rs +580 -0
- package/src/providers/sec_edgar.rs +399 -0
- package/src/providers/stooq/catalog.rs +159 -0
- package/src/providers/stooq.rs +904 -0
- package/src/providers/yahoo.rs +836 -0
- package/src/research/fetchers.rs +111 -0
- package/src/research/highlights.rs +345 -0
- package/src/research/mod.rs +943 -0
- package/src/research/tests.rs +42 -0
- package/src/skills.rs +58 -0
- package/src/stream.rs +356 -0
- package/src/time.rs +21 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
use anyhow::{Context, Result, anyhow};
|
|
2
|
+
use serde::Deserialize;
|
|
3
|
+
use serde_json::{Value, json};
|
|
4
|
+
use url::Url;
|
|
5
|
+
use wreq::Client;
|
|
6
|
+
|
|
7
|
+
use crate::http::{change_pct, parse_optional_f64, utc_now};
|
|
8
|
+
use crate::model::{
|
|
9
|
+
HistoryBatch, OhlcBar, Quote, ResearchHighlight, SESSION_EXTENDED, SESSION_REGULAR,
|
|
10
|
+
research_value_string,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Deserialize)]
|
|
14
|
+
struct RobinhoodQuote {
|
|
15
|
+
symbol: String,
|
|
16
|
+
last_trade_price: Option<String>,
|
|
17
|
+
last_extended_hours_trade_price: Option<String>,
|
|
18
|
+
last_non_reg_trade_price: Option<String>,
|
|
19
|
+
venue_last_trade_time: Option<String>,
|
|
20
|
+
venue_last_non_reg_trade_time: Option<String>,
|
|
21
|
+
previous_close: Option<String>,
|
|
22
|
+
updated_at: Option<String>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug, Deserialize)]
|
|
26
|
+
struct RobinhoodInstrumentPage {
|
|
27
|
+
results: Vec<Value>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Debug, Deserialize)]
|
|
31
|
+
struct RobinhoodHistorical {
|
|
32
|
+
symbol: String,
|
|
33
|
+
interval: String,
|
|
34
|
+
historicals: Vec<RobinhoodHistoricalBar>,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Debug, Deserialize)]
|
|
38
|
+
struct RobinhoodHistoricalBar {
|
|
39
|
+
begins_at: Option<String>,
|
|
40
|
+
open_price: Option<String>,
|
|
41
|
+
close_price: Option<String>,
|
|
42
|
+
high_price: Option<String>,
|
|
43
|
+
low_price: Option<String>,
|
|
44
|
+
volume: Option<u64>,
|
|
45
|
+
interpolated: Option<bool>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub async fn fetch_quote(client: &Client, symbol: &str) -> Result<Quote> {
|
|
49
|
+
let provider_symbol = symbol.to_uppercase();
|
|
50
|
+
let url = format!("https://api.robinhood.com/quotes/{provider_symbol}/");
|
|
51
|
+
let quote: RobinhoodQuote = client
|
|
52
|
+
.get(url)
|
|
53
|
+
.send()
|
|
54
|
+
.await
|
|
55
|
+
.context("Robinhood quote request failed")?
|
|
56
|
+
.error_for_status()
|
|
57
|
+
.context("Robinhood quote returned HTTP error")?
|
|
58
|
+
.json()
|
|
59
|
+
.await
|
|
60
|
+
.context("Robinhood quote JSON parse failed")?;
|
|
61
|
+
|
|
62
|
+
let extended_price = parse_optional_f64(quote.last_extended_hours_trade_price.as_deref())
|
|
63
|
+
.or_else(|| parse_optional_f64(quote.last_non_reg_trade_price.as_deref()));
|
|
64
|
+
let regular_price = parse_optional_f64(quote.last_trade_price.as_deref());
|
|
65
|
+
let price = extended_price
|
|
66
|
+
.or(regular_price)
|
|
67
|
+
.ok_or_else(|| anyhow!("Robinhood quote missing usable price"))?;
|
|
68
|
+
let previous_close = parse_optional_f64(quote.previous_close.as_deref());
|
|
69
|
+
let session = if extended_price.is_some() {
|
|
70
|
+
SESSION_EXTENDED
|
|
71
|
+
} else {
|
|
72
|
+
SESSION_REGULAR
|
|
73
|
+
};
|
|
74
|
+
let market_time = if extended_price.is_some() {
|
|
75
|
+
quote
|
|
76
|
+
.venue_last_non_reg_trade_time
|
|
77
|
+
.or(quote.updated_at)
|
|
78
|
+
.or(quote.venue_last_trade_time)
|
|
79
|
+
} else {
|
|
80
|
+
quote
|
|
81
|
+
.venue_last_trade_time
|
|
82
|
+
.or(quote.updated_at)
|
|
83
|
+
.or(quote.venue_last_non_reg_trade_time)
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
Ok(Quote {
|
|
87
|
+
symbol: quote.symbol,
|
|
88
|
+
price,
|
|
89
|
+
currency: Some("USD".to_string()),
|
|
90
|
+
provider: "robinhood".to_string(),
|
|
91
|
+
session: Some(session.to_string()),
|
|
92
|
+
fetched_at_utc: utc_now(),
|
|
93
|
+
market_time,
|
|
94
|
+
previous_close,
|
|
95
|
+
open: None,
|
|
96
|
+
high: None,
|
|
97
|
+
low: None,
|
|
98
|
+
volume: None,
|
|
99
|
+
exchange: None,
|
|
100
|
+
provider_symbol: Some(provider_symbol),
|
|
101
|
+
change_pct: change_pct(price, previous_close),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pub async fn fetch_fundamentals_bundle(client: &Client, symbol: &str) -> Result<Value> {
|
|
106
|
+
let instrument = fetch_instrument(client, symbol).await?;
|
|
107
|
+
let fundamentals = fetch_json(
|
|
108
|
+
client,
|
|
109
|
+
&format!(
|
|
110
|
+
"https://api.robinhood.com/fundamentals/{}/",
|
|
111
|
+
symbol.trim().to_uppercase()
|
|
112
|
+
),
|
|
113
|
+
"Robinhood fundamentals",
|
|
114
|
+
)
|
|
115
|
+
.await?;
|
|
116
|
+
let market = instrument
|
|
117
|
+
.get("market")
|
|
118
|
+
.and_then(Value::as_str)
|
|
119
|
+
.map(|url| fetch_json(client, url, "Robinhood market"));
|
|
120
|
+
let market = match market {
|
|
121
|
+
Some(future) => future.await.ok(),
|
|
122
|
+
None => None,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
Ok(json!({
|
|
126
|
+
"symbol": symbol.trim().to_uppercase(),
|
|
127
|
+
"instrument": instrument,
|
|
128
|
+
"fundamentals": fundamentals,
|
|
129
|
+
"market": market,
|
|
130
|
+
}))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
pub async fn fetch_events_bundle(client: &Client, symbol: &str) -> Result<Value> {
|
|
134
|
+
let instrument = fetch_instrument(client, symbol).await?;
|
|
135
|
+
let splits_url = instrument
|
|
136
|
+
.get("splits")
|
|
137
|
+
.and_then(Value::as_str)
|
|
138
|
+
.ok_or_else(|| anyhow!("Robinhood instrument missing splits URL"))?;
|
|
139
|
+
let splits = fetch_json(client, splits_url, "Robinhood splits").await?;
|
|
140
|
+
let market = instrument
|
|
141
|
+
.get("market")
|
|
142
|
+
.and_then(Value::as_str)
|
|
143
|
+
.map(|url| fetch_json(client, url, "Robinhood market"));
|
|
144
|
+
let market = match market {
|
|
145
|
+
Some(future) => future.await.ok(),
|
|
146
|
+
None => None,
|
|
147
|
+
};
|
|
148
|
+
let hours = market
|
|
149
|
+
.as_ref()
|
|
150
|
+
.and_then(|market| market.get("todays_hours"))
|
|
151
|
+
.and_then(Value::as_str)
|
|
152
|
+
.map(|url| fetch_json(client, url, "Robinhood market hours"));
|
|
153
|
+
let hours = match hours {
|
|
154
|
+
Some(future) => future.await.ok(),
|
|
155
|
+
None => None,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
Ok(json!({
|
|
159
|
+
"symbol": symbol.trim().to_uppercase(),
|
|
160
|
+
"instrument": instrument,
|
|
161
|
+
"splits": splits,
|
|
162
|
+
"market": market,
|
|
163
|
+
"market_hours": hours,
|
|
164
|
+
}))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pub async fn fetch_options_bundle(
|
|
168
|
+
client: &Client,
|
|
169
|
+
symbol: &str,
|
|
170
|
+
expiration_date: Option<&str>,
|
|
171
|
+
count: usize,
|
|
172
|
+
) -> Result<Value> {
|
|
173
|
+
let instrument = fetch_instrument(client, symbol).await?;
|
|
174
|
+
let chain_id = instrument
|
|
175
|
+
.get("tradable_chain_id")
|
|
176
|
+
.and_then(Value::as_str)
|
|
177
|
+
.ok_or_else(|| anyhow!("Robinhood instrument missing tradable_chain_id"))?;
|
|
178
|
+
let chain = fetch_json(
|
|
179
|
+
client,
|
|
180
|
+
&format!("https://api.robinhood.com/options/chains/{chain_id}/"),
|
|
181
|
+
"Robinhood options chain",
|
|
182
|
+
)
|
|
183
|
+
.await?;
|
|
184
|
+
let selected_expiration = expiration_date.map(ToString::to_string).or_else(|| {
|
|
185
|
+
chain
|
|
186
|
+
.get("expiration_dates")
|
|
187
|
+
.and_then(Value::as_array)
|
|
188
|
+
.and_then(|dates| dates.first())
|
|
189
|
+
.and_then(Value::as_str)
|
|
190
|
+
.map(ToString::to_string)
|
|
191
|
+
});
|
|
192
|
+
let instruments = match selected_expiration.as_deref() {
|
|
193
|
+
Some(expiration) => fetch_option_instruments(client, chain_id, expiration, count).await?,
|
|
194
|
+
None => Vec::new(),
|
|
195
|
+
};
|
|
196
|
+
let coverage_gaps = vec![
|
|
197
|
+
"Robinhood option quote marketdata endpoints are not reliably anonymous; this payload includes chain and contract metadata, not bid/ask/IV quotes.",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
Ok(json!({
|
|
201
|
+
"symbol": symbol.trim().to_uppercase(),
|
|
202
|
+
"instrument": instrument,
|
|
203
|
+
"chain": chain,
|
|
204
|
+
"selected_expiration": selected_expiration,
|
|
205
|
+
"option_instruments": instruments,
|
|
206
|
+
"coverage_gaps": coverage_gaps,
|
|
207
|
+
}))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
pub async fn fetch_history(
|
|
211
|
+
client: &Client,
|
|
212
|
+
symbol: &str,
|
|
213
|
+
interval: &str,
|
|
214
|
+
range: &str,
|
|
215
|
+
extended: bool,
|
|
216
|
+
limit: usize,
|
|
217
|
+
) -> Result<HistoryBatch> {
|
|
218
|
+
let provider_symbol = symbol.trim().to_uppercase();
|
|
219
|
+
let interval = robinhood_interval(interval)?;
|
|
220
|
+
let span = robinhood_span(range)?;
|
|
221
|
+
let bounds = if extended { "trading" } else { "regular" };
|
|
222
|
+
let mut url = Url::parse(&format!(
|
|
223
|
+
"https://api.robinhood.com/quotes/historicals/{provider_symbol}/"
|
|
224
|
+
))
|
|
225
|
+
.context("invalid Robinhood historicals URL")?;
|
|
226
|
+
url.query_pairs_mut()
|
|
227
|
+
.append_pair("interval", interval)
|
|
228
|
+
.append_pair("span", span)
|
|
229
|
+
.append_pair("bounds", bounds);
|
|
230
|
+
let history: RobinhoodHistorical = client
|
|
231
|
+
.get(url.as_str())
|
|
232
|
+
.send()
|
|
233
|
+
.await
|
|
234
|
+
.context("Robinhood historicals request failed")?
|
|
235
|
+
.error_for_status()
|
|
236
|
+
.context("Robinhood historicals returned HTTP error")?
|
|
237
|
+
.json()
|
|
238
|
+
.await
|
|
239
|
+
.context("Robinhood historicals JSON parse failed")?;
|
|
240
|
+
let mut bars = history
|
|
241
|
+
.historicals
|
|
242
|
+
.into_iter()
|
|
243
|
+
.filter_map(|row| historical_bar_to_ohlc(&history.symbol, row))
|
|
244
|
+
.collect::<Vec<_>>();
|
|
245
|
+
if bars.len() > limit {
|
|
246
|
+
let start = bars.len().saturating_sub(limit);
|
|
247
|
+
bars = bars.split_off(start);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
Ok(HistoryBatch {
|
|
251
|
+
symbol: history.symbol,
|
|
252
|
+
provider: "robinhood".to_string(),
|
|
253
|
+
interval: history.interval,
|
|
254
|
+
adjustment: "raw".to_string(),
|
|
255
|
+
actions_included: false,
|
|
256
|
+
repair_requested: false,
|
|
257
|
+
repair_applied: false,
|
|
258
|
+
bars,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pub fn fundamentals_highlights(payload: &Value) -> Vec<ResearchHighlight> {
|
|
263
|
+
let mut rows = Vec::new();
|
|
264
|
+
for (label, path, module) in [
|
|
265
|
+
("Company", "/instrument/name", "instrument"),
|
|
266
|
+
("List date", "/instrument/list_date", "instrument"),
|
|
267
|
+
("Tradability", "/instrument/tradability", "instrument"),
|
|
268
|
+
(
|
|
269
|
+
"Fractional trading",
|
|
270
|
+
"/instrument/fractional_tradability",
|
|
271
|
+
"instrument",
|
|
272
|
+
),
|
|
273
|
+
(
|
|
274
|
+
"Shorting status",
|
|
275
|
+
"/instrument/short_selling_tradability",
|
|
276
|
+
"instrument",
|
|
277
|
+
),
|
|
278
|
+
(
|
|
279
|
+
"High-risk flag",
|
|
280
|
+
"/instrument/is_high_investment_risk",
|
|
281
|
+
"instrument",
|
|
282
|
+
),
|
|
283
|
+
("SPAC flag", "/instrument/is_spac", "instrument"),
|
|
284
|
+
("Market cap", "/fundamentals/market_cap", "fundamentals"),
|
|
285
|
+
("PE", "/fundamentals/pe_ratio", "fundamentals"),
|
|
286
|
+
("PB", "/fundamentals/pb_ratio", "fundamentals"),
|
|
287
|
+
(
|
|
288
|
+
"Dividend yield",
|
|
289
|
+
"/fundamentals/dividend_yield",
|
|
290
|
+
"fundamentals",
|
|
291
|
+
),
|
|
292
|
+
("Float", "/fundamentals/float", "fundamentals"),
|
|
293
|
+
(
|
|
294
|
+
"Shares outstanding",
|
|
295
|
+
"/fundamentals/shares_outstanding",
|
|
296
|
+
"fundamentals",
|
|
297
|
+
),
|
|
298
|
+
(
|
|
299
|
+
"52-week high",
|
|
300
|
+
"/fundamentals/high_52_weeks",
|
|
301
|
+
"fundamentals",
|
|
302
|
+
),
|
|
303
|
+
("52-week low", "/fundamentals/low_52_weeks", "fundamentals"),
|
|
304
|
+
(
|
|
305
|
+
"30-day avg volume",
|
|
306
|
+
"/fundamentals/average_volume_30_days",
|
|
307
|
+
"fundamentals",
|
|
308
|
+
),
|
|
309
|
+
("Market", "/market/name", "market"),
|
|
310
|
+
("Timezone", "/market/timezone", "market"),
|
|
311
|
+
] {
|
|
312
|
+
push_path(&mut rows, payload, label, path, module);
|
|
313
|
+
}
|
|
314
|
+
if let Some(description) = payload
|
|
315
|
+
.pointer("/fundamentals/description")
|
|
316
|
+
.and_then(Value::as_str)
|
|
317
|
+
.filter(|value| !value.trim().is_empty())
|
|
318
|
+
{
|
|
319
|
+
rows.push(ResearchHighlight::new(
|
|
320
|
+
"Company description",
|
|
321
|
+
truncate(description.trim(), 220),
|
|
322
|
+
"robinhood",
|
|
323
|
+
"fundamentals",
|
|
324
|
+
));
|
|
325
|
+
}
|
|
326
|
+
rows
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
pub fn events_highlights(payload: &Value) -> Vec<ResearchHighlight> {
|
|
330
|
+
let mut rows = Vec::new();
|
|
331
|
+
push_path(
|
|
332
|
+
&mut rows,
|
|
333
|
+
payload,
|
|
334
|
+
"Company",
|
|
335
|
+
"/instrument/name",
|
|
336
|
+
"instrument",
|
|
337
|
+
);
|
|
338
|
+
push_path(
|
|
339
|
+
&mut rows,
|
|
340
|
+
payload,
|
|
341
|
+
"Status",
|
|
342
|
+
"/instrument/state",
|
|
343
|
+
"instrument",
|
|
344
|
+
);
|
|
345
|
+
push_path(
|
|
346
|
+
&mut rows,
|
|
347
|
+
payload,
|
|
348
|
+
"Market open today",
|
|
349
|
+
"/market_hours/is_open",
|
|
350
|
+
"market_hours",
|
|
351
|
+
);
|
|
352
|
+
push_path(
|
|
353
|
+
&mut rows,
|
|
354
|
+
payload,
|
|
355
|
+
"Regular open",
|
|
356
|
+
"/market_hours/opens_at",
|
|
357
|
+
"market_hours",
|
|
358
|
+
);
|
|
359
|
+
push_path(
|
|
360
|
+
&mut rows,
|
|
361
|
+
payload,
|
|
362
|
+
"Regular close",
|
|
363
|
+
"/market_hours/closes_at",
|
|
364
|
+
"market_hours",
|
|
365
|
+
);
|
|
366
|
+
push_path(
|
|
367
|
+
&mut rows,
|
|
368
|
+
payload,
|
|
369
|
+
"Extended open",
|
|
370
|
+
"/market_hours/extended_opens_at",
|
|
371
|
+
"market_hours",
|
|
372
|
+
);
|
|
373
|
+
push_path(
|
|
374
|
+
&mut rows,
|
|
375
|
+
payload,
|
|
376
|
+
"Extended close",
|
|
377
|
+
"/market_hours/extended_closes_at",
|
|
378
|
+
"market_hours",
|
|
379
|
+
);
|
|
380
|
+
if let Some(splits) = payload.pointer("/splits/results").and_then(Value::as_array) {
|
|
381
|
+
for (index, split) in splits.iter().take(5).enumerate() {
|
|
382
|
+
let date = string_at(split, "/execution_date").unwrap_or_else(|| "-".to_string());
|
|
383
|
+
let multiplier = string_at(split, "/multiplier").unwrap_or_else(|| "-".to_string());
|
|
384
|
+
let divisor = string_at(split, "/divisor").unwrap_or_else(|| "-".to_string());
|
|
385
|
+
rows.push(ResearchHighlight::new(
|
|
386
|
+
&format!("Split {}", index + 1),
|
|
387
|
+
format!("{date} multiplier={multiplier} divisor={divisor}"),
|
|
388
|
+
"robinhood",
|
|
389
|
+
"splits",
|
|
390
|
+
));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
rows
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
pub fn options_highlights(payload: &Value) -> Vec<ResearchHighlight> {
|
|
397
|
+
let mut rows = Vec::new();
|
|
398
|
+
push_path(&mut rows, payload, "Chain", "/chain/id", "options");
|
|
399
|
+
push_path(
|
|
400
|
+
&mut rows,
|
|
401
|
+
payload,
|
|
402
|
+
"Selected Expiration",
|
|
403
|
+
"/selected_expiration",
|
|
404
|
+
"options",
|
|
405
|
+
);
|
|
406
|
+
if let Some(expirations) = payload
|
|
407
|
+
.pointer("/chain/expiration_dates")
|
|
408
|
+
.and_then(Value::as_array)
|
|
409
|
+
{
|
|
410
|
+
rows.push(ResearchHighlight::new(
|
|
411
|
+
"Expiry count",
|
|
412
|
+
expirations.len().to_string(),
|
|
413
|
+
"robinhood",
|
|
414
|
+
"options",
|
|
415
|
+
));
|
|
416
|
+
rows.push(ResearchHighlight::new(
|
|
417
|
+
"Nearest expiry",
|
|
418
|
+
expirations
|
|
419
|
+
.iter()
|
|
420
|
+
.take(8)
|
|
421
|
+
.filter_map(Value::as_str)
|
|
422
|
+
.collect::<Vec<_>>()
|
|
423
|
+
.join(", "),
|
|
424
|
+
"robinhood",
|
|
425
|
+
"options",
|
|
426
|
+
));
|
|
427
|
+
}
|
|
428
|
+
if let Some(instruments) = payload
|
|
429
|
+
.pointer("/option_instruments")
|
|
430
|
+
.and_then(Value::as_array)
|
|
431
|
+
{
|
|
432
|
+
rows.push(ResearchHighlight::new(
|
|
433
|
+
"Contract count",
|
|
434
|
+
instruments.len().to_string(),
|
|
435
|
+
"robinhood",
|
|
436
|
+
"option_instruments",
|
|
437
|
+
));
|
|
438
|
+
for contract in instruments.iter().take(6) {
|
|
439
|
+
let strike = string_at(contract, "/strike_price").unwrap_or_else(|| "-".to_string());
|
|
440
|
+
let contract_type = string_at(contract, "/type").unwrap_or_else(|| "-".to_string());
|
|
441
|
+
let tradability =
|
|
442
|
+
string_at(contract, "/tradability").unwrap_or_else(|| "-".to_string());
|
|
443
|
+
rows.push(ResearchHighlight::new(
|
|
444
|
+
"Contract sample",
|
|
445
|
+
format!("{contract_type} strike={strike} tradability={tradability}"),
|
|
446
|
+
"robinhood",
|
|
447
|
+
"option_instruments",
|
|
448
|
+
));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
rows
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async fn fetch_instrument(client: &Client, symbol: &str) -> Result<Value> {
|
|
455
|
+
let provider_symbol = symbol.trim().to_uppercase();
|
|
456
|
+
let url = format!("https://api.robinhood.com/instruments/?symbol={provider_symbol}");
|
|
457
|
+
let page: RobinhoodInstrumentPage = client
|
|
458
|
+
.get(url)
|
|
459
|
+
.send()
|
|
460
|
+
.await
|
|
461
|
+
.context("Robinhood instruments request failed")?
|
|
462
|
+
.error_for_status()
|
|
463
|
+
.context("Robinhood instruments returned HTTP error")?
|
|
464
|
+
.json()
|
|
465
|
+
.await
|
|
466
|
+
.context("Robinhood instruments JSON parse failed")?;
|
|
467
|
+
page.results
|
|
468
|
+
.into_iter()
|
|
469
|
+
.next()
|
|
470
|
+
.ok_or_else(|| anyhow!("Robinhood instruments did not contain {provider_symbol}"))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async fn fetch_json(client: &Client, url: &str, label: &str) -> Result<Value> {
|
|
474
|
+
client
|
|
475
|
+
.get(url)
|
|
476
|
+
.send()
|
|
477
|
+
.await
|
|
478
|
+
.with_context(|| format!("{label} request failed"))?
|
|
479
|
+
.error_for_status()
|
|
480
|
+
.with_context(|| format!("{label} returned HTTP error"))?
|
|
481
|
+
.json()
|
|
482
|
+
.await
|
|
483
|
+
.with_context(|| format!("{label} JSON parse failed"))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async fn fetch_option_instruments(
|
|
487
|
+
client: &Client,
|
|
488
|
+
chain_id: &str,
|
|
489
|
+
expiration: &str,
|
|
490
|
+
count: usize,
|
|
491
|
+
) -> Result<Vec<Value>> {
|
|
492
|
+
let mut url = Url::parse("https://api.robinhood.com/options/instruments/")
|
|
493
|
+
.context("invalid Robinhood option instruments URL")?;
|
|
494
|
+
url.query_pairs_mut()
|
|
495
|
+
.append_pair("chain_id", chain_id)
|
|
496
|
+
.append_pair("expiration_dates", expiration)
|
|
497
|
+
.append_pair("state", "active");
|
|
498
|
+
let page = fetch_json(client, url.as_str(), "Robinhood option instruments").await?;
|
|
499
|
+
Ok(page
|
|
500
|
+
.get("results")
|
|
501
|
+
.and_then(Value::as_array)
|
|
502
|
+
.cloned()
|
|
503
|
+
.unwrap_or_default()
|
|
504
|
+
.into_iter()
|
|
505
|
+
.take(count.max(1))
|
|
506
|
+
.collect())
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fn robinhood_interval(interval: &str) -> Result<&'static str> {
|
|
510
|
+
match interval {
|
|
511
|
+
"5m" | "5minute" => Ok("5minute"),
|
|
512
|
+
"10m" | "10minute" => Ok("10minute"),
|
|
513
|
+
"1h" | "hour" => Ok("hour"),
|
|
514
|
+
"1d" | "day" => Ok("day"),
|
|
515
|
+
"1w" | "week" => Ok("week"),
|
|
516
|
+
_ => Err(anyhow!(
|
|
517
|
+
"Robinhood history supports 5m, 10m, 1h, 1d, and 1w intervals"
|
|
518
|
+
)),
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn robinhood_span(range: &str) -> Result<&'static str> {
|
|
523
|
+
match range {
|
|
524
|
+
"1d" | "day" => Ok("day"),
|
|
525
|
+
"1w" | "5d" | "week" => Ok("week"),
|
|
526
|
+
"1mo" | "month" => Ok("month"),
|
|
527
|
+
"3mo" | "3month" => Ok("3month"),
|
|
528
|
+
"1y" | "year" => Ok("year"),
|
|
529
|
+
_ => Err(anyhow!(
|
|
530
|
+
"Robinhood history supports 1d, 5d/1w, 1mo, 3mo, and 1y ranges"
|
|
531
|
+
)),
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn historical_bar_to_ohlc(symbol: &str, row: RobinhoodHistoricalBar) -> Option<OhlcBar> {
|
|
536
|
+
let close = parse_optional_f64(row.close_price.as_deref())?;
|
|
537
|
+
Some(OhlcBar {
|
|
538
|
+
symbol: symbol.to_uppercase(),
|
|
539
|
+
provider: "robinhood".to_string(),
|
|
540
|
+
open_time: row.begins_at?,
|
|
541
|
+
close_time: None,
|
|
542
|
+
open: parse_optional_f64(row.open_price.as_deref()),
|
|
543
|
+
high: parse_optional_f64(row.high_price.as_deref()),
|
|
544
|
+
low: parse_optional_f64(row.low_price.as_deref()),
|
|
545
|
+
close,
|
|
546
|
+
adj_close: None,
|
|
547
|
+
volume: row.volume.map(|value| value as f64),
|
|
548
|
+
quote_volume: None,
|
|
549
|
+
trades: None,
|
|
550
|
+
dividend: None,
|
|
551
|
+
stock_split: None,
|
|
552
|
+
capital_gain: None,
|
|
553
|
+
repaired: row.interpolated.unwrap_or(false),
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fn push_path(
|
|
558
|
+
rows: &mut Vec<ResearchHighlight>,
|
|
559
|
+
payload: &Value,
|
|
560
|
+
label: &str,
|
|
561
|
+
path: &str,
|
|
562
|
+
module: &str,
|
|
563
|
+
) {
|
|
564
|
+
if let Some(row) = ResearchHighlight::from_path(Some(payload), label, path, "robinhood", module)
|
|
565
|
+
.filter(|row| !row.value.trim().is_empty())
|
|
566
|
+
{
|
|
567
|
+
rows.push(row);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
fn string_at(payload: &Value, path: &str) -> Option<String> {
|
|
572
|
+
research_value_string(payload.pointer(path)).filter(|value| !value.trim().is_empty())
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
fn truncate(value: &str, max_chars: usize) -> String {
|
|
576
|
+
if value.chars().count() <= max_chars {
|
|
577
|
+
return value.to_string();
|
|
578
|
+
}
|
|
579
|
+
value.chars().take(max_chars).collect::<String>() + "..."
|
|
580
|
+
}
|