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,42 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
use serde_json::json;
|
|
3
|
+
|
|
4
|
+
#[test]
|
|
5
|
+
fn fundamentals_highlights_extract_raw_and_fmt_values() {
|
|
6
|
+
let payload = json!({
|
|
7
|
+
"quoteSummary": {
|
|
8
|
+
"result": [{
|
|
9
|
+
"price": {
|
|
10
|
+
"longName": "Credo Technology Group Holding Ltd",
|
|
11
|
+
"marketCap": {"raw": 41704132608_i64, "fmt": "41.7B"}
|
|
12
|
+
},
|
|
13
|
+
"summaryProfile": {"industry": "Semiconductors"},
|
|
14
|
+
"financialData": {
|
|
15
|
+
"revenueGrowth": {"raw": 2.015, "fmt": "201.5%"},
|
|
16
|
+
"freeCashflow": {"raw": 172241120_i64}
|
|
17
|
+
}
|
|
18
|
+
}]
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
let highlights = fundamentals_highlights(quote_summary_root(&payload));
|
|
22
|
+
assert!(
|
|
23
|
+
highlights
|
|
24
|
+
.iter()
|
|
25
|
+
.any(|row| row.label == "Company" && row.value == "Credo Technology Group Holding Ltd")
|
|
26
|
+
);
|
|
27
|
+
assert!(
|
|
28
|
+
highlights
|
|
29
|
+
.iter()
|
|
30
|
+
.any(|row| row.label == "Market cap" && row.value == "41.7B")
|
|
31
|
+
);
|
|
32
|
+
assert!(
|
|
33
|
+
highlights
|
|
34
|
+
.iter()
|
|
35
|
+
.any(|row| row.label == "Revenue growth" && row.value == "201.5%")
|
|
36
|
+
);
|
|
37
|
+
assert!(
|
|
38
|
+
highlights
|
|
39
|
+
.iter()
|
|
40
|
+
.any(|row| row.label == "Free cash flow" && row.value == "172241120")
|
|
41
|
+
);
|
|
42
|
+
}
|
package/src/skills.rs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
pub struct BuiltinSkill {
|
|
2
|
+
pub name: &'static str,
|
|
3
|
+
pub description: &'static str,
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const SKILLS: &[BuiltinSkill] = &[
|
|
7
|
+
BuiltinSkill {
|
|
8
|
+
name: "core",
|
|
9
|
+
description: "Entry guide for price, sessions, history, research, providers, proxy data, and safe source handling.",
|
|
10
|
+
},
|
|
11
|
+
BuiltinSkill {
|
|
12
|
+
name: "price",
|
|
13
|
+
description: "Fetch current price summaries, regular-market basis, pre/post/overnight sessions, streams, and proxy prices.",
|
|
14
|
+
},
|
|
15
|
+
BuiltinSkill {
|
|
16
|
+
name: "research-data",
|
|
17
|
+
description: "Fetch no-key Yahoo/SEC EDGAR/Robinhood/CNBC research data and read URL text with fallback readers.",
|
|
18
|
+
},
|
|
19
|
+
BuiltinSkill {
|
|
20
|
+
name: "providers",
|
|
21
|
+
description: "Understand Yahoo, SEC EDGAR, CNBC, Robinhood, Stooq, Binance futures, and proxy quote capabilities.",
|
|
22
|
+
},
|
|
23
|
+
BuiltinSkill {
|
|
24
|
+
name: "history-indicators",
|
|
25
|
+
description: "Fetch OHLCV, understand intervals, adjustments, repair, actions, and local technical indicators.",
|
|
26
|
+
},
|
|
27
|
+
BuiltinSkill {
|
|
28
|
+
name: "futures",
|
|
29
|
+
description: "Use Binance USD-M futures / TradFi perps for proxy price, funding, open interest, and mark price.",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
pub fn print_list() {
|
|
34
|
+
for skill in SKILLS {
|
|
35
|
+
println!("{:<20} {}", skill.name, skill.description);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pub fn get(name: &str, full: bool) -> Option<&'static str> {
|
|
40
|
+
match (name, full) {
|
|
41
|
+
("core", false) => Some(CORE),
|
|
42
|
+
("core", true) => Some(CORE_FULL),
|
|
43
|
+
("price", _) => Some(PRICE),
|
|
44
|
+
("research-data", _) => Some(RESEARCH_DATA),
|
|
45
|
+
("providers", _) => Some(PROVIDERS),
|
|
46
|
+
("history-indicators", _) => Some(HISTORY_INDICATORS),
|
|
47
|
+
("futures", _) => Some(FUTURES),
|
|
48
|
+
_ => None,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CORE: &str = include_str!("../skills/core.md");
|
|
53
|
+
const CORE_FULL: &str = include_str!("../skills/core-full.md");
|
|
54
|
+
const PRICE: &str = include_str!("../skills/price.md");
|
|
55
|
+
const RESEARCH_DATA: &str = include_str!("../skills/research-data.md");
|
|
56
|
+
const PROVIDERS: &str = include_str!("../skills/providers.md");
|
|
57
|
+
const HISTORY_INDICATORS: &str = include_str!("../skills/history-indicators.md");
|
|
58
|
+
const FUTURES: &str = include_str!("../skills/futures.md");
|
package/src/stream.rs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
use std::time::Duration;
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result, anyhow};
|
|
4
|
+
use base64::{Engine as _, engine::general_purpose};
|
|
5
|
+
use futures_util::{SinkExt, StreamExt};
|
|
6
|
+
use prost::Message as _;
|
|
7
|
+
use serde::Serialize;
|
|
8
|
+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
9
|
+
use tokio::net::TcpStream;
|
|
10
|
+
use tokio_tungstenite::client_async_tls_with_config;
|
|
11
|
+
use tokio_tungstenite::connect_async;
|
|
12
|
+
use tokio_tungstenite::tungstenite::Message;
|
|
13
|
+
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
14
|
+
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
|
15
|
+
use url::Url;
|
|
16
|
+
|
|
17
|
+
use crate::http::selected_proxy;
|
|
18
|
+
use crate::http::timestamp_ms_to_utc;
|
|
19
|
+
use crate::model::StreamQuote;
|
|
20
|
+
use crate::time::utc_to_local;
|
|
21
|
+
|
|
22
|
+
#[derive(Serialize)]
|
|
23
|
+
struct SubscribeMessage {
|
|
24
|
+
subscribe: Vec<String>,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub async fn stream_quotes(options: StreamOptions<'_>) -> Result<Vec<StreamQuote>> {
|
|
28
|
+
let mut updates = Vec::new();
|
|
29
|
+
stream_quotes_each(options, |quote| {
|
|
30
|
+
updates.push(quote);
|
|
31
|
+
Ok(())
|
|
32
|
+
})
|
|
33
|
+
.await?;
|
|
34
|
+
Ok(updates)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Debug)]
|
|
38
|
+
pub struct StreamOptions<'a> {
|
|
39
|
+
pub url: &'a str,
|
|
40
|
+
pub symbols: Vec<String>,
|
|
41
|
+
pub message_limit: usize,
|
|
42
|
+
pub read_timeout: Duration,
|
|
43
|
+
pub timezone: &'a str,
|
|
44
|
+
pub proxy: Option<&'a str>,
|
|
45
|
+
pub no_proxy: bool,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub async fn stream_quotes_each<F>(options: StreamOptions<'_>, mut on_quote: F) -> Result<()>
|
|
49
|
+
where
|
|
50
|
+
F: FnMut(StreamQuote) -> Result<()>,
|
|
51
|
+
{
|
|
52
|
+
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
53
|
+
let symbols = options
|
|
54
|
+
.symbols
|
|
55
|
+
.into_iter()
|
|
56
|
+
.map(|symbol| symbol.trim().to_uppercase())
|
|
57
|
+
.filter(|symbol| !symbol.is_empty())
|
|
58
|
+
.collect::<Vec<_>>();
|
|
59
|
+
if symbols.is_empty() {
|
|
60
|
+
return Err(anyhow!("at least one symbol is required"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let mut request = options
|
|
64
|
+
.url
|
|
65
|
+
.into_client_request()
|
|
66
|
+
.with_context(|| format!("invalid websocket URL: {}", options.url))?;
|
|
67
|
+
request.headers_mut().insert(
|
|
68
|
+
"Origin",
|
|
69
|
+
"https://finance.yahoo.com"
|
|
70
|
+
.parse()
|
|
71
|
+
.context("invalid websocket Origin header")?,
|
|
72
|
+
);
|
|
73
|
+
request.headers_mut().insert(
|
|
74
|
+
"User-Agent",
|
|
75
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
|
76
|
+
.parse()
|
|
77
|
+
.context("invalid websocket User-Agent header")?,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
let proxy = selected_proxy(options.proxy, options.no_proxy);
|
|
81
|
+
let (socket, _) = connect_websocket(request, proxy.as_deref())
|
|
82
|
+
.await
|
|
83
|
+
.context("Yahoo websocket connect failed")?;
|
|
84
|
+
let (mut write, mut read) = socket.split();
|
|
85
|
+
write
|
|
86
|
+
.send(Message::Text(
|
|
87
|
+
serde_json::to_string(&SubscribeMessage { subscribe: symbols })?.into(),
|
|
88
|
+
))
|
|
89
|
+
.await
|
|
90
|
+
.context("Yahoo websocket subscribe failed")?;
|
|
91
|
+
|
|
92
|
+
let mut seen = 0usize;
|
|
93
|
+
while options.message_limit == 0 || seen < options.message_limit {
|
|
94
|
+
let Some(message) = tokio::time::timeout(options.read_timeout, read.next())
|
|
95
|
+
.await
|
|
96
|
+
.context("Yahoo websocket read timed out")?
|
|
97
|
+
else {
|
|
98
|
+
break;
|
|
99
|
+
};
|
|
100
|
+
let message = message.context("Yahoo websocket frame failed")?;
|
|
101
|
+
match message {
|
|
102
|
+
Message::Text(text) => {
|
|
103
|
+
if let Ok(update) = decode_text_message(&text, options.timezone) {
|
|
104
|
+
on_quote(update)?;
|
|
105
|
+
seen += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
Message::Binary(bytes) => {
|
|
109
|
+
if let Ok(text) = std::str::from_utf8(&bytes)
|
|
110
|
+
&& let Ok(update) = decode_text_message(text, options.timezone)
|
|
111
|
+
{
|
|
112
|
+
on_quote(update)?;
|
|
113
|
+
seen += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if let Ok(update) = decode_pricing_data(&bytes, options.timezone) {
|
|
117
|
+
on_quote(update)?;
|
|
118
|
+
seen += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
Message::Ping(_) | Message::Pong(_) => {}
|
|
122
|
+
Message::Close(_) => break,
|
|
123
|
+
_ => {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if seen == 0 {
|
|
128
|
+
return Err(anyhow!(
|
|
129
|
+
"Yahoo websocket returned no pricing updates before timeout"
|
|
130
|
+
));
|
|
131
|
+
}
|
|
132
|
+
Ok(())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async fn connect_websocket(
|
|
136
|
+
request: tokio_tungstenite::tungstenite::handshake::client::Request,
|
|
137
|
+
proxy: Option<&str>,
|
|
138
|
+
) -> Result<(
|
|
139
|
+
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
|
140
|
+
tokio_tungstenite::tungstenite::handshake::client::Response,
|
|
141
|
+
)> {
|
|
142
|
+
let Some(proxy) = proxy else {
|
|
143
|
+
return connect_async(request)
|
|
144
|
+
.await
|
|
145
|
+
.context("direct websocket connect failed");
|
|
146
|
+
};
|
|
147
|
+
let proxy =
|
|
148
|
+
Url::parse(proxy).with_context(|| format!("invalid websocket proxy URL: {proxy}"))?;
|
|
149
|
+
if proxy.scheme() != "http" {
|
|
150
|
+
return Err(anyhow!(
|
|
151
|
+
"websocket stream currently supports http proxy only; got {}",
|
|
152
|
+
proxy.scheme()
|
|
153
|
+
));
|
|
154
|
+
}
|
|
155
|
+
let proxy_host = proxy
|
|
156
|
+
.host_str()
|
|
157
|
+
.ok_or_else(|| anyhow!("websocket proxy URL missing host"))?;
|
|
158
|
+
let proxy_port = proxy
|
|
159
|
+
.port_or_known_default()
|
|
160
|
+
.ok_or_else(|| anyhow!("websocket proxy URL missing port"))?;
|
|
161
|
+
let target_host = request
|
|
162
|
+
.uri()
|
|
163
|
+
.host()
|
|
164
|
+
.ok_or_else(|| anyhow!("websocket target URL missing host"))?
|
|
165
|
+
.to_string();
|
|
166
|
+
let target_port = request
|
|
167
|
+
.uri()
|
|
168
|
+
.port_u16()
|
|
169
|
+
.or_else(|| match request.uri().scheme_str() {
|
|
170
|
+
Some("wss") => Some(443),
|
|
171
|
+
Some("ws") => Some(80),
|
|
172
|
+
_ => None,
|
|
173
|
+
})
|
|
174
|
+
.ok_or_else(|| anyhow!("websocket target URL missing port"))?;
|
|
175
|
+
let target = format!("{target_host}:{target_port}");
|
|
176
|
+
let mut stream = TcpStream::connect((proxy_host, proxy_port))
|
|
177
|
+
.await
|
|
178
|
+
.with_context(|| format!("failed to connect websocket proxy {proxy_host}:{proxy_port}"))?;
|
|
179
|
+
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
|
180
|
+
stream
|
|
181
|
+
.write_all(connect.as_bytes())
|
|
182
|
+
.await
|
|
183
|
+
.context("failed to write websocket proxy CONNECT")?;
|
|
184
|
+
let mut response = Vec::new();
|
|
185
|
+
let mut buffer = [0_u8; 1024];
|
|
186
|
+
loop {
|
|
187
|
+
let read = stream
|
|
188
|
+
.read(&mut buffer)
|
|
189
|
+
.await
|
|
190
|
+
.context("failed to read websocket proxy CONNECT response")?;
|
|
191
|
+
if read == 0 {
|
|
192
|
+
return Err(anyhow!("websocket proxy closed before CONNECT completed"));
|
|
193
|
+
}
|
|
194
|
+
response.extend_from_slice(&buffer[..read]);
|
|
195
|
+
if response.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if response.len() > 8192 {
|
|
199
|
+
return Err(anyhow!("websocket proxy CONNECT response was too large"));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
let response_text = String::from_utf8_lossy(&response);
|
|
203
|
+
if !(response_text.starts_with("HTTP/1.1 200") || response_text.starts_with("HTTP/1.0 200")) {
|
|
204
|
+
return Err(anyhow!(
|
|
205
|
+
"websocket proxy CONNECT failed: {}",
|
|
206
|
+
response_text.lines().next().unwrap_or("<empty>")
|
|
207
|
+
));
|
|
208
|
+
}
|
|
209
|
+
client_async_tls_with_config(request, stream, None, None)
|
|
210
|
+
.await
|
|
211
|
+
.context("websocket TLS handshake through proxy failed")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pub fn decode_text_message(text: &str, timezone: &str) -> Result<StreamQuote> {
|
|
215
|
+
let text = text.trim();
|
|
216
|
+
let message = if text.starts_with('{') {
|
|
217
|
+
serde_json::from_str::<serde_json::Value>(text)?
|
|
218
|
+
.get("message")
|
|
219
|
+
.and_then(|value| value.as_str())
|
|
220
|
+
.ok_or_else(|| anyhow!("websocket JSON frame missing message field"))?
|
|
221
|
+
.to_string()
|
|
222
|
+
} else {
|
|
223
|
+
text.to_string()
|
|
224
|
+
};
|
|
225
|
+
let bytes = general_purpose::STANDARD
|
|
226
|
+
.decode(message.as_bytes())
|
|
227
|
+
.context("websocket base64 decode failed")?;
|
|
228
|
+
decode_pricing_data(&bytes, timezone)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fn decode_pricing_data(bytes: &[u8], timezone: &str) -> Result<StreamQuote> {
|
|
232
|
+
let data = PricingData::decode(bytes).context("websocket protobuf decode failed")?;
|
|
233
|
+
let time_utc = (data.time > 0)
|
|
234
|
+
.then_some(data.time)
|
|
235
|
+
.and_then(timestamp_ms_to_utc);
|
|
236
|
+
Ok(StreamQuote {
|
|
237
|
+
symbol: data.id,
|
|
238
|
+
price: data.price as f64,
|
|
239
|
+
time_local: utc_to_local(time_utc.as_deref(), timezone),
|
|
240
|
+
time_utc,
|
|
241
|
+
currency: non_empty(data.currency),
|
|
242
|
+
exchange: non_empty(data.exchange),
|
|
243
|
+
quote_type: Some(data.quote_type).filter(|value| *value != 0),
|
|
244
|
+
market_hours: Some(data.market_hours).filter(|value| *value != 0),
|
|
245
|
+
change_pct: non_zero_f32(data.change_percent),
|
|
246
|
+
day_volume: Some(data.day_volume).filter(|value| *value != 0),
|
|
247
|
+
day_high: non_zero_f32(data.day_high),
|
|
248
|
+
day_low: non_zero_f32(data.day_low),
|
|
249
|
+
change: non_zero_f32(data.change),
|
|
250
|
+
short_name: non_empty(data.short_name),
|
|
251
|
+
open: non_zero_f32(data.open_price),
|
|
252
|
+
previous_close: non_zero_f32(data.previous_close),
|
|
253
|
+
provider: "yahoo-websocket".to_string(),
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn non_empty(value: String) -> Option<String> {
|
|
258
|
+
(!value.is_empty()).then_some(value)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fn non_zero_f32(value: f32) -> Option<f64> {
|
|
262
|
+
(value != 0.0).then_some(value as f64)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[derive(Clone, PartialEq, ::prost::Message)]
|
|
266
|
+
struct PricingData {
|
|
267
|
+
#[prost(string, tag = "1")]
|
|
268
|
+
id: String,
|
|
269
|
+
#[prost(float, tag = "2")]
|
|
270
|
+
price: f32,
|
|
271
|
+
#[prost(sint64, tag = "3")]
|
|
272
|
+
time: i64,
|
|
273
|
+
#[prost(string, tag = "4")]
|
|
274
|
+
currency: String,
|
|
275
|
+
#[prost(string, tag = "5")]
|
|
276
|
+
exchange: String,
|
|
277
|
+
#[prost(int32, tag = "6")]
|
|
278
|
+
quote_type: i32,
|
|
279
|
+
#[prost(int32, tag = "7")]
|
|
280
|
+
market_hours: i32,
|
|
281
|
+
#[prost(float, tag = "8")]
|
|
282
|
+
change_percent: f32,
|
|
283
|
+
#[prost(sint64, tag = "9")]
|
|
284
|
+
day_volume: i64,
|
|
285
|
+
#[prost(float, tag = "10")]
|
|
286
|
+
day_high: f32,
|
|
287
|
+
#[prost(float, tag = "11")]
|
|
288
|
+
day_low: f32,
|
|
289
|
+
#[prost(float, tag = "12")]
|
|
290
|
+
change: f32,
|
|
291
|
+
#[prost(string, tag = "13")]
|
|
292
|
+
short_name: String,
|
|
293
|
+
#[prost(sint64, tag = "14")]
|
|
294
|
+
expire_date: i64,
|
|
295
|
+
#[prost(float, tag = "15")]
|
|
296
|
+
open_price: f32,
|
|
297
|
+
#[prost(float, tag = "16")]
|
|
298
|
+
previous_close: f32,
|
|
299
|
+
#[prost(float, tag = "17")]
|
|
300
|
+
strike_price: f32,
|
|
301
|
+
#[prost(string, tag = "18")]
|
|
302
|
+
underlying_symbol: String,
|
|
303
|
+
#[prost(sint64, tag = "19")]
|
|
304
|
+
open_interest: i64,
|
|
305
|
+
#[prost(sint64, tag = "20")]
|
|
306
|
+
options_type: i64,
|
|
307
|
+
#[prost(sint64, tag = "21")]
|
|
308
|
+
mini_option: i64,
|
|
309
|
+
#[prost(sint64, tag = "22")]
|
|
310
|
+
last_size: i64,
|
|
311
|
+
#[prost(float, tag = "23")]
|
|
312
|
+
bid: f32,
|
|
313
|
+
#[prost(sint64, tag = "24")]
|
|
314
|
+
bid_size: i64,
|
|
315
|
+
#[prost(float, tag = "25")]
|
|
316
|
+
ask: f32,
|
|
317
|
+
#[prost(sint64, tag = "26")]
|
|
318
|
+
ask_size: i64,
|
|
319
|
+
#[prost(sint64, tag = "27")]
|
|
320
|
+
price_hint: i64,
|
|
321
|
+
#[prost(sint64, tag = "28")]
|
|
322
|
+
vol_24hr: i64,
|
|
323
|
+
#[prost(sint64, tag = "29")]
|
|
324
|
+
vol_all_currencies: i64,
|
|
325
|
+
#[prost(string, tag = "30")]
|
|
326
|
+
from_currency: String,
|
|
327
|
+
#[prost(string, tag = "31")]
|
|
328
|
+
last_market: String,
|
|
329
|
+
#[prost(double, tag = "32")]
|
|
330
|
+
circulating_supply: f64,
|
|
331
|
+
#[prost(double, tag = "33")]
|
|
332
|
+
market_cap: f64,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#[cfg(test)]
|
|
336
|
+
mod tests {
|
|
337
|
+
use super::*;
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn decodes_yahoo_json_wrapped_base64_pricing_frame() {
|
|
341
|
+
let frame = r#"{"message":"CgRDUkRPFQCATkMYgLaO8tBnIgNVU0QqCE5hc2RhcUdTMAg4AkVcjwrBSPa4ggFVwzVzQ109il9DZc3MnMFqEENyZWRvIFRlY2hub2xvZ3l9mhluQ4UBrgdsQw=="}"#;
|
|
342
|
+
let quote = decode_text_message(frame, "Asia/Singapore").unwrap();
|
|
343
|
+
assert_eq!(quote.symbol, "CRDO");
|
|
344
|
+
assert_eq!(quote.price, 206.5);
|
|
345
|
+
assert_eq!(quote.currency.as_deref(), Some("USD"));
|
|
346
|
+
assert_eq!(quote.exchange.as_deref(), Some("NasdaqGS"));
|
|
347
|
+
assert_eq!(quote.market_hours, Some(2));
|
|
348
|
+
assert_eq!(quote.day_volume, Some(1_068_603));
|
|
349
|
+
assert_eq!(quote.short_name.as_deref(), Some("Credo Technology"));
|
|
350
|
+
assert_eq!(quote.time_utc.as_deref(), Some("2026-06-02T07:00:00Z"));
|
|
351
|
+
assert_eq!(
|
|
352
|
+
quote.time_local.as_deref(),
|
|
353
|
+
Some("2026-06-02T15:00:00+08:00")
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
package/src/time.rs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
use chrono::{DateTime, SecondsFormat, Utc};
|
|
2
|
+
use chrono_tz::Tz;
|
|
3
|
+
|
|
4
|
+
pub const DEFAULT_TIMEZONE: &str = "Asia/Singapore";
|
|
5
|
+
|
|
6
|
+
pub fn utc_to_local(value: Option<&str>, timezone: &str) -> Option<String> {
|
|
7
|
+
let value = value?;
|
|
8
|
+
let datetime = DateTime::parse_from_rfc3339(value).ok()?;
|
|
9
|
+
Some(format_local(datetime.with_timezone(&Utc), timezone))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
pub fn now_local(timezone: &str) -> String {
|
|
13
|
+
format_local(Utc::now(), timezone)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub fn format_local(datetime: DateTime<Utc>, timezone: &str) -> String {
|
|
17
|
+
let timezone = timezone.parse::<Tz>().unwrap_or(chrono_tz::Asia::Singapore);
|
|
18
|
+
datetime
|
|
19
|
+
.with_timezone(&timezone)
|
|
20
|
+
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
|
21
|
+
}
|