use std::collections::HashMap;
use crate::model::Bar;
use crate::timeframe::Timeframe;
pub trait DataFeedAdapter {
type Error;
fn fetch_historical(
&mut self,
symbol: &str,
timeframe: Timeframe,
from: i64,
to: i64,
) -> Result<Vec<Bar>, Self::Error>;
fn subscribe_live(&mut self, symbol: &str, timeframe: Timeframe) -> Result<(), Self::Error>;
fn poll_live(&mut self) -> Result<Vec<Bar>, Self::Error>;
}
#[derive(Debug, Clone, Default)]
pub struct InMemoryDataFeed {
bars_by_symbol: HashMap<String, Vec<Bar>>,
live_cursor: HashMap<String, usize>,
subscribed: Vec<String>,
}
impl InMemoryDataFeed {
pub fn new() -> Self {
Self::default()
}
pub fn load(&mut self, symbol: impl Into<String>, bars: Vec<Bar>) {
self.bars_by_symbol.insert(symbol.into(), bars);
}
}
impl DataFeedAdapter for InMemoryDataFeed {
type Error = String;
fn fetch_historical(
&mut self,
symbol: &str,
_timeframe: Timeframe,
from: i64,
to: i64,
) -> Result<Vec<Bar>, Self::Error> {
let bars = self
.bars_by_symbol
.get(symbol)
.ok_or_else(|| format!("no bars loaded for symbol '{symbol}'"))?;
Ok(bars
.iter()
.filter(|b| b.timestamp >= from && b.timestamp <= to)
.cloned()
.collect())
}
fn subscribe_live(&mut self, symbol: &str, _timeframe: Timeframe) -> Result<(), Self::Error> {
if !self.subscribed.contains(&symbol.to_string()) {
self.subscribed.push(symbol.to_string());
self.live_cursor.insert(
symbol.to_string(),
self.bars_by_symbol
.get(symbol)
.map(|b| b.len())
.unwrap_or(0),
);
}
Ok(())
}
fn poll_live(&mut self) -> Result<Vec<Bar>, Self::Error> {
let mut new_bars = Vec::new();
for symbol in &self.subscribed {
let cursor = self.live_cursor.get(symbol).copied().unwrap_or(0);
if let Some(bars) = self.bars_by_symbol.get(symbol) {
if cursor < bars.len() {
new_bars.extend(bars[cursor..].iter().cloned());
self.live_cursor.insert(symbol.clone(), bars.len());
}
}
}
Ok(new_bars)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationSeverity {
Info,
Warning,
Critical,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NotificationEvent {
pub timestamp: i64,
pub severity: NotificationSeverity,
pub title: String,
pub body: String,
}
pub trait NotificationSink {
type Error;
fn notify(&mut self, event: &NotificationEvent) -> Result<(), Self::Error>;
}
#[derive(Debug, Clone, Default)]
pub struct LoggingNotificationSink {
pub events: Vec<NotificationEvent>,
}
impl LoggingNotificationSink {
pub fn new() -> Self {
Self::default()
}
}
impl NotificationSink for LoggingNotificationSink {
type Error = std::convert::Infallible;
fn notify(&mut self, event: &NotificationEvent) -> Result<(), Self::Error> {
self.events.push(event.clone());
Ok(())
}
}
pub struct WebhookNotificationSink<F>
where
F: FnMut(&str, &NotificationEvent) -> Result<(), String>,
{
pub url: String,
send: F,
}
impl<F> WebhookNotificationSink<F>
where
F: FnMut(&str, &NotificationEvent) -> Result<(), String>,
{
pub fn new(url: impl Into<String>, send: F) -> Self {
Self {
url: url.into(),
send,
}
}
}
impl<F> NotificationSink for WebhookNotificationSink<F>
where
F: FnMut(&str, &NotificationEvent) -> Result<(), String>,
{
type Error = String;
fn notify(&mut self, event: &NotificationEvent) -> Result<(), Self::Error> {
(self.send)(&self.url, event)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bar(ts: i64) -> Bar {
Bar::new(ts, 100.0, 101.0, 99.0, 100.0, 10.0)
}
#[test]
fn test_in_memory_feed_fetch_historical_filters_range() {
let mut feed = InMemoryDataFeed::new();
feed.load("TEST", vec![bar(0), bar(60), bar(120), bar(180)]);
let result = feed
.fetch_historical("TEST", Timeframe::Minute(1), 60, 120)
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].timestamp, 60);
}
#[test]
fn test_in_memory_feed_fetch_unknown_symbol_errors() {
let mut feed = InMemoryDataFeed::new();
assert!(feed
.fetch_historical("NOPE", Timeframe::Minute(1), 0, 100)
.is_err());
}
#[test]
fn test_in_memory_feed_poll_live_only_returns_new_bars() {
let mut feed = InMemoryDataFeed::new();
feed.load("TEST", vec![bar(0), bar(60)]);
feed.subscribe_live("TEST", Timeframe::Minute(1)).unwrap();
let first_poll = feed.poll_live().unwrap();
assert!(
first_poll.is_empty(),
"no bars arrived after the subscription cursor yet"
);
feed.load("TEST", vec![bar(0), bar(60), bar(120)]);
let second_poll = feed.poll_live().unwrap();
assert_eq!(second_poll.len(), 1);
assert_eq!(second_poll[0].timestamp, 120);
}
#[test]
fn test_logging_sink_records_events() {
let mut sink = LoggingNotificationSink::new();
let event = NotificationEvent {
timestamp: 0,
severity: NotificationSeverity::Warning,
title: "test".to_string(),
body: "body".to_string(),
};
sink.notify(&event).unwrap();
assert_eq!(sink.events.len(), 1);
assert_eq!(sink.events[0].severity, NotificationSeverity::Warning);
}
#[test]
fn test_webhook_sink_delegates_to_injected_transport() {
let mut received: Vec<(String, String)> = Vec::new();
let mut sink = WebhookNotificationSink::new("https://example.test/hook", |url, event| {
received.push((url.to_string(), event.title.clone()));
Ok(())
});
let event = NotificationEvent {
timestamp: 0,
severity: NotificationSeverity::Critical,
title: "alert".to_string(),
body: "body".to_string(),
};
sink.notify(&event).unwrap();
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, "https://example.test/hook");
assert_eq!(received[0].1, "alert");
}
#[test]
fn test_webhook_sink_propagates_transport_errors() {
let mut sink = WebhookNotificationSink::new("https://example.test/hook", |_url, _event| {
Err("network unreachable".to_string())
});
let event = NotificationEvent {
timestamp: 0,
severity: NotificationSeverity::Info,
title: "x".to_string(),
body: String::new(),
};
assert!(sink.notify(&event).is_err());
}
}