Merge pull request #11 from selimaj-dev/engine-market-data

Engine market data
This commit is contained in:
2026-07-24 03:19:15 +02:00
committed by GitHub
8 changed files with 5665 additions and 171 deletions
Generated
+5448 -16
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,6 +10,8 @@ pulse-wire = { workspace = true }
chrono = "0.4.45" chrono = "0.4.45"
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "fs", "io-util", "time"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "fs", "io-util", "time"] }
crossterm = { workspace = true } crossterm = { workspace = true }
hypersdk = "0.2.14"
serde_json = "1"
[workspace] [workspace]
members = ["pulse-macros", "pulse-ui", "pulse-wire"] members = ["pulse-macros", "pulse-ui", "pulse-wire"]
+15 -7
View File
@@ -36,13 +36,16 @@ pub struct WatchListItem {
symbol: String, symbol: String,
price: f64, price: f64,
trend: f64, trend: f64,
volume_24h: f64,
} }
#[pwp] #[pwp]
pub struct ActivePosition { pub struct ActivePosition {
symbol: String, pub symbol: String,
profit: f64, pub size: f64,
amount: f64, pub entry_price: f64,
pub mark_price: f64,
pub profit: f64,
} }
#[pwp] #[pwp]
@@ -137,10 +140,15 @@ pub struct Alert {
#[pwp] #[pwp]
pub enum InspectTarget { pub enum InspectTarget {
None, None,
Symbol(WatchListItem), Some(Vec<InspectItem>),
Position(ActivePosition), }
Signal(Signal),
Alert(Alert), #[pwp]
pub enum InspectItem {
String(String),
Symbol(String),
USD(f64),
F64(f64),
} }
impl std::fmt::Display for MarketTrend { impl std::fmt::Display for MarketTrend {
+60
View File
@@ -0,0 +1,60 @@
use crate::terminal::TerminalServer;
const WATCH_LIST_SYMBOLS: &[&str] = &["BTC", "ETH", "SOL", "XRP"];
pub struct Engine {
pub terminal_server: TerminalServer,
}
impl Engine {
pub fn new() -> Self {
Self {
terminal_server: TerminalServer::new(),
}
}
pub fn spawn_terminal_server(&self) {
let terminal_server = self.terminal_server.clone();
tokio::spawn(async move {
terminal_server
.run()
.await
.expect("Failed to run terminal server");
});
}
pub fn spawn_broadcaster(&mut self) {
let terminal_server = self.terminal_server.clone();
tokio::spawn(async move {
let mut refresh = tokio::time::interval(tokio::time::Duration::from_secs(5));
loop {
refresh.tick().await;
match crate::fetch::fetch_watch_list(WATCH_LIST_SYMBOLS).await {
Ok(watch_list) => {
if let Err(error) = terminal_server
.broadcast(
pulse_wire::terminal::TerminalServerMessage::WatchListUpdated(
watch_list,
),
)
.await
{
eprintln!("Failed to broadcast Hyperliquid watch list: {error}");
}
}
Err(error) => eprintln!("Failed to refresh Hyperliquid watch list: {error}"),
}
}
});
}
pub async fn run_engine(&mut self) -> tokio::io::Result<()> {
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(5000)).await;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::collections::HashMap;
use pulse_wire::terminal::WatchListItem;
use serde_json::Value;
fn number(value: &Value, field: &str) -> Result<f64, String> {
let raw = value[field]
.as_str()
.ok_or_else(|| format!("asset context field {field} must be a string"))?;
raw.parse::<f64>()
.map_err(|error| format!("could not parse asset context field {field} ({raw}): {error}"))
}
pub async fn fetch_watch_list(symbols: &[&str]) -> Result<Vec<WatchListItem>, String> {
let response = hypersdk::hypercore::mainnet()
.meta_and_asset_ctxs(None)
.await
.map_err(|error| format!("Hyperliquid metaAndAssetCtxs request failed: {error}"))?;
let response = response
.as_array()
.ok_or("metaAndAssetCtxs response must be an array")?;
if response.len() != 2 {
return Err(format!(
"metaAndAssetCtxs response must contain meta and contexts, got {} entries",
response.len()
));
}
let universe = response[0]["universe"]
.as_array()
.ok_or("metaAndAssetCtxs response is missing meta.universe")?;
let contexts = response[1]
.as_array()
.ok_or("metaAndAssetCtxs response contexts must be an array")?;
if universe.len() != contexts.len() {
return Err(format!(
"Hyperliquid returned {} instruments but {} contexts",
universe.len(),
contexts.len()
));
}
let mut by_symbol = HashMap::with_capacity(universe.len());
for (meta, context) in universe.iter().zip(contexts) {
let symbol = meta["name"]
.as_str()
.ok_or("instrument metadata is missing a name")?;
let price = number(context, "markPx")?;
let previous_day_price = number(context, "prevDayPx")?;
let volume_24h = number(context, "dayNtlVlm")?;
if previous_day_price <= 0.0 {
return Err(format!("{symbol} has an invalid previous-day price"));
}
by_symbol.insert(
symbol,
WatchListItem {
symbol: symbol.to_owned(),
price,
trend: ((price / previous_day_price) - 1.0) * 100.0,
volume_24h,
},
);
}
symbols
.iter()
.map(|symbol| {
by_symbol
.remove(*symbol)
.ok_or_else(|| format!("{symbol} is not in the Hyperliquid perpetual universe"))
})
.collect()
}
+7 -105
View File
@@ -1,114 +1,16 @@
use pulse_wire::terminal::{ActivePosition, Signal, WatchListItem}; pub mod engine;
pub mod fetch;
pub mod terminal; pub mod terminal;
#[tokio::main] #[tokio::main]
async fn main() -> tokio::io::Result<()> { async fn main() -> tokio::io::Result<()> {
let terminal_server = terminal::TerminalServer::new(); let mut engine = engine::Engine::new();
{ engine.spawn_terminal_server();
let terminal_server = terminal_server.clone();
tokio::spawn(async move { engine.spawn_broadcaster();
terminal_server
.run()
.await
.expect("Failed to run terminal server");
});
}
loop { engine.run_engine().await?;
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
terminal_server Ok(())
.broadcast(
pulse_wire::terminal::TerminalServerMessage::WatchListUpdated(vec![
WatchListItem {
symbol: "BTC".to_string(),
price: 118_402.12,
trend: 0.82,
},
WatchListItem {
symbol: "ETH".to_string(),
price: 3_912.48,
trend: -0.41,
},
WatchListItem {
symbol: "SOL".to_string(),
price: 182.91,
trend: 2.18,
},
WatchListItem {
symbol: "XRP".to_string(),
price: 2.84,
trend: 1.22,
},
]),
)
.await?;
terminal_server
.broadcast(
pulse_wire::terminal::TerminalServerMessage::PositionsUpdated(vec![
ActivePosition {
symbol: "BTC".to_string(),
profit: 125.50,
amount: 0.25,
},
ActivePosition {
symbol: "SOL".to_string(),
profit: 84.20,
amount: 5.0,
},
ActivePosition {
symbol: "ETH".to_string(),
profit: -32.75,
amount: 1.0,
},
ActivePosition {
symbol: "XRP".to_string(),
profit: 12.30,
amount: 0.75,
},
]),
)
.await?;
terminal_server
.broadcast(pulse_wire::terminal::TerminalServerMessage::SignalsUpdated(
vec![
Signal {
kind: pulse_wire::terminal::SignalKind::Buy,
symbol: "BTC".to_string(),
param: pulse_wire::terminal::SignalParameter::Lim,
price: 118_800.0,
},
Signal {
kind: pulse_wire::terminal::SignalKind::Buy,
symbol: "BTC".to_string(),
param: pulse_wire::terminal::SignalParameter::Tap,
price: 120_000.0,
},
Signal {
kind: pulse_wire::terminal::SignalKind::Buy,
symbol: "BTC".to_string(),
param: pulse_wire::terminal::SignalParameter::Stl,
price: 118_000.0,
},
],
))
.await?;
terminal_server
.broadcast(pulse_wire::terminal::TerminalServerMessage::AddLog(
pulse_wire::terminal::EventLog {
kind: pulse_wire::terminal::LogKind::Debug,
name: "Engine".to_string(),
message: "Hello, world!".to_string(),
},
))
.await?;
println!("Ok");
}
} }
+4
View File
@@ -75,6 +75,8 @@ impl TerminalServer {
(command, Vec::new()) (command, Vec::new())
}; };
match command {
_ => {
self.broadcast(pulse_wire::terminal::TerminalServerMessage::AddLog( self.broadcast(pulse_wire::terminal::TerminalServerMessage::AddLog(
pulse_wire::terminal::EventLog { pulse_wire::terminal::EventLog {
kind: pulse_wire::terminal::LogKind::Err, kind: pulse_wire::terminal::LogKind::Err,
@@ -86,6 +88,8 @@ impl TerminalServer {
} }
} }
} }
}
}
Ok(()) Ok(())
} }
+41 -35
View File
@@ -1,6 +1,6 @@
use pulse_wire::terminal::{ use pulse_wire::terminal::{
ActivePosition, Alert, AlertLevel, EventLog, InspectTarget, LogKind, MarketOverview, Signal, ActivePosition, Alert, AlertLevel, EventLog, InspectItem, InspectTarget, LogKind,
SignalKind, Status, WatchListItem, MarketOverview, Signal, SignalKind, Status, WatchListItem,
}; };
pub trait Formatted { pub trait Formatted {
@@ -12,36 +12,15 @@ impl Formatted for InspectTarget {
match self { match self {
Self::None => vec!["\x1b[2mnothing to inspect\x1b[0m".to_string()], Self::None => vec!["\x1b[2mnothing to inspect\x1b[0m".to_string()],
Self::Symbol(watch) => vec![ Self::Some(items) => items
Property("Symbol", format!("\x1b[35m{}\x1b[0m", watch.symbol)), .iter()
Property( .map(|item| match item {
"Price", InspectItem::F64(v) => format_f64(*v),
format!("\x1b[96m{}\x1b[0m", format_f64(watch.price)), InspectItem::USD(v) => format!("${}", format_f64(*v)),
), InspectItem::Symbol(v) => format!("\x1b[35m{v}\x1b[0m"),
Property("Trend", format!("{}", format_f64(watch.trend))), InspectItem::String(v) => v.clone(),
] })
.get_formatted(), .collect(),
Self::Position(pos) => vec![
Property("Symbol", format!("\x1b[35m{}\x1b[0m", pos.symbol)),
Property("Profit", format!("{:+.2}", pos.profit)),
Property("Amount", format!("{}", pos.amount)),
]
.get_formatted(),
Self::Signal(sig) => vec![
Property("Type", format!("{}", sig.kind)),
Property("Symbol", format!("\x1b[35m{}\x1b[0m", sig.symbol)),
Property("Parameter", format!("{}", sig.param)),
Property("Price", format!("\x1b[96m{}\x1b[0m", format_f64(sig.price))),
]
.get_formatted(),
Self::Alert(alert) => vec![
Property("Level", format!("{}", alert.level)),
Property("Message", alert.message.clone()),
]
.get_formatted(),
} }
} }
} }
@@ -101,7 +80,8 @@ impl Formatted for WatchListItem {
fn get_formatted(&self) -> Vec<String> { fn get_formatted(&self) -> Vec<String> {
vec![ vec![
format!("\x1b[35m{}\x1b[0m", self.symbol), format!("\x1b[35m{}\x1b[0m", self.symbol),
format_f64(self.price), format!("${}", format_f64(self.price)),
format!("${}", format_f64(self.volume_24h)),
format!( format!(
"{} {}", "{} {}",
if self.trend.is_sign_positive() { if self.trend.is_sign_positive() {
@@ -119,7 +99,9 @@ impl Formatted for ActivePosition {
fn get_formatted(&self) -> Vec<String> { fn get_formatted(&self) -> Vec<String> {
vec![ vec![
format!("\x1b[35m{}\x1b[0m", self.symbol), format!("\x1b[35m{}\x1b[0m", self.symbol),
format_f64(self.amount), format_f64(self.size),
format_f64(self.entry_price),
format_f64(self.mark_price),
format!( format!(
"{} {}", "{} {}",
if self.profit.is_sign_positive() { if self.profit.is_sign_positive() {
@@ -227,7 +209,31 @@ pub fn apply_padding(mut items: Vec<String>) -> Vec<String> {
} }
pub fn format_f64(value: f64) -> String { pub fn format_f64(value: f64) -> String {
let val = value.to_string(); let abs = value.abs();
let (divisor, suffix) = if abs >= 1_000_000_000.0 {
(1_000_000_000.0, "B")
} else if abs >= 1_000_000.0 {
(1_000_000.0, "M")
} else if abs >= 1_000.0 {
(1_000.0, "K")
} else {
(1.0, "")
};
if divisor != 1.0 {
let formatted = value / divisor;
// Remove unnecessary trailing zeros
let s = format!("{:.2}", formatted)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string();
return format!("{}{}", s, suffix);
}
let val = format!("{:.3}", value);
let parts: Vec<&str> = val.split('.').collect(); let parts: Vec<&str> = val.split('.').collect();
let int = parts[0].to_string(); let int = parts[0].to_string();