Merge pull request #13 from selimaj-dev/accounts-and-positions

Accounts and positions
This commit is contained in:
2026-07-25 04:44:44 +02:00
committed by GitHub
14 changed files with 594 additions and 279 deletions
+99
View File
@@ -0,0 +1,99 @@
use pulse_macros::pwp;
use crate::{
PulseWire,
units::{Symbol, USD},
};
#[pwp]
pub enum MarketTrend {
Bullish,
Bearish,
Neutral,
}
#[pwp]
pub enum Volatility {
Low,
Medium,
High,
}
#[pwp]
pub enum Direction {
Buy,
Sell,
}
#[pwp]
pub struct Signal {
pub symbol: String,
pub kind: Direction,
pub confidence: f32,
pub size: f64,
pub price: USD,
pub take_profit: USD,
pub stop_loss: USD,
}
#[pwp]
pub enum LogKind {
Info,
Warn,
Err,
Debug,
}
#[pwp]
pub struct EventLog {
pub kind: LogKind,
pub name: String,
pub message: String,
}
#[pwp]
pub struct Position {
pub symbol: Symbol,
pub size: f64,
pub entry_price: USD,
pub profit: USD,
}
impl std::fmt::Display for Direction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Buy => write!(f, "BUY"),
Self::Sell => write!(f, "SELL"),
}
}
}
impl std::fmt::Display for MarketTrend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bullish => write!(f, "Bullish"),
Self::Bearish => write!(f, "Bearish"),
Self::Neutral => write!(f, "Neutral"),
}
}
}
impl std::fmt::Display for Volatility {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "Low"),
Self::Medium => write!(f, "Medium"),
Self::High => write!(f, "High"),
}
}
}
impl std::fmt::Display for LogKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "INFO"),
Self::Warn => write!(f, "WARN"),
Self::Err => write!(f, "ERR"),
Self::Debug => write!(f, "DEBUG"),
}
}
}
+8
View File
@@ -1,7 +1,15 @@
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use std::path::PathBuf; use std::path::PathBuf;
pub mod general;
pub mod terminal; pub mod terminal;
pub mod units;
pub mod prelude {
pub use crate::general::*;
pub use crate::terminal::*;
pub use crate::units::*;
}
pub fn server_path() -> PathBuf { pub fn server_path() -> PathBuf {
PathBuf::from("/tmp/pulse-engine.sock") PathBuf::from("/tmp/pulse-engine.sock")
+38 -140
View File
@@ -1,13 +1,17 @@
use crate::PulseWire; use crate::{
PulseWire,
general::{EventLog, MarketTrend, Position, Signal, Volatility},
units::{Symbol, USD},
};
use pulse_macros::pwp; use pulse_macros::pwp;
#[pwp] #[pwp]
pub enum TerminalServerMessage { pub enum TerminalServerMessage {
// WatchList // WatchList
WatchListUpdated(Vec<WatchListItem>), WatchListUpdated(Vec<MarketItem>),
// Positions // Positions
PositionsUpdated(Vec<ActivePosition>), PositionsUpdated(Vec<Position>),
// Overview // Overview
OverviewUpdated(MarketOverview), OverviewUpdated(MarketOverview),
@@ -19,7 +23,7 @@ pub enum TerminalServerMessage {
Inspect(InspectTarget), Inspect(InspectTarget),
// Status // Status
SetStatus(Status), StatusUpdated(Status),
// Logs // Logs
SetLogs(Vec<EventLog>), SetLogs(Vec<EventLog>),
@@ -32,96 +36,19 @@ pub enum TerminalClientMessage {
} }
#[pwp] #[pwp]
pub struct WatchListItem { pub struct MarketItem {
symbol: String, pub symbol: Symbol,
price: f64, pub price: USD,
trend: f64, pub trend: f64,
volume_24h: f64, pub volume_24h: USD,
}
#[pwp]
pub struct ActivePosition {
pub symbol: String,
pub size: f64,
pub entry_price: f64,
pub mark_price: f64,
pub profit: f64,
}
#[pwp]
pub enum MarketTrend {
Bullish,
Bearish,
Neutral,
}
#[pwp]
pub enum Volatility {
Low,
Medium,
High,
} }
#[pwp] #[pwp]
pub struct MarketOverview { pub struct MarketOverview {
trend: MarketTrend, pub trend: MarketTrend,
volatility: Volatility, pub volatility: Volatility,
pressure: f64, pub pressure: f64,
pub alerts: Vec<Alert>,
alerts: Vec<Alert>,
}
#[pwp]
pub enum Feed {
Connected,
Disconnected,
Connecting,
Failed,
}
#[pwp]
pub struct Status {
feed: Feed,
exchange: String,
dex: String,
latency: u16,
}
#[pwp]
pub enum SignalKind {
Buy,
Sell,
}
#[pwp]
pub enum SignalParameter {
Lim,
Stl,
Tap,
Chk,
}
#[pwp]
pub struct Signal {
kind: SignalKind,
symbol: String,
param: SignalParameter,
price: f64,
}
#[pwp]
pub enum LogKind {
Info,
Warn,
Err,
Debug,
}
#[pwp]
pub struct EventLog {
kind: LogKind,
name: String,
message: String,
} }
#[pwp] #[pwp]
@@ -130,13 +57,19 @@ pub enum AlertLevel {
Medium, Medium,
Low, Low,
} }
#[pwp] #[pwp]
pub struct Alert { pub struct Alert {
level: AlertLevel, level: AlertLevel,
message: String, message: String,
} }
#[pwp]
pub struct Balance {
pub asset: String,
pub amount: f64,
pub value: f64,
}
#[pwp] #[pwp]
pub enum InspectTarget { pub enum InspectTarget {
None, None,
@@ -151,14 +84,20 @@ pub enum InspectItem {
F64(f64), F64(f64),
} }
impl std::fmt::Display for MarketTrend { #[pwp]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { pub struct Status {
match self { feed: Feed,
Self::Bullish => write!(f, "Bullish"), exchange: String,
Self::Bearish => write!(f, "Bearish"), dex: String,
Self::Neutral => write!(f, "Neutral"), latency: u16,
} }
}
#[pwp]
pub enum Feed {
Connected,
Disconnected,
Connecting,
Failed,
} }
impl std::fmt::Display for AlertLevel { impl std::fmt::Display for AlertLevel {
@@ -170,44 +109,3 @@ impl std::fmt::Display for AlertLevel {
} }
} }
} }
impl std::fmt::Display for Volatility {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "Low"),
Self::Medium => write!(f, "Medium"),
Self::High => write!(f, "High"),
}
}
}
impl std::fmt::Display for LogKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "INFO"),
Self::Warn => write!(f, "WARN"),
Self::Err => write!(f, "ERR"),
Self::Debug => write!(f, "DEBUG"),
}
}
}
impl std::fmt::Display for SignalKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Buy => write!(f, "BUY"),
Self::Sell => write!(f, "SELL"),
}
}
}
impl std::fmt::Display for SignalParameter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Lim => write!(f, "LIM"),
Self::Stl => write!(f, "STL"),
Self::Tap => write!(f, "TAP"),
Self::Chk => write!(f, "CHK"),
}
}
}
+98
View File
@@ -0,0 +1,98 @@
use crate::PulseWire;
#[derive(Debug, Clone)]
pub struct Symbol(pub String);
#[derive(Debug, Clone, Copy)]
pub struct USD(pub f64);
impl std::fmt::Display for Symbol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "\x1b[35m{}\x1b[0m", self.0)
}
}
impl std::fmt::Display for USD {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0 > 0.0 {
write!(f, "\x1b[32m${}\x1b[0m", format_f64(self.0))
} else {
write!(f, "\x1b[31m${}\x1b[0m", format_f64(self.0))
}
}
}
impl PulseWire for Symbol {
fn from_com(com: &mut Vec<u8>) -> Self {
Self(String::from_com(com))
}
fn to_com(&self) -> Vec<u8> {
self.0.to_com()
}
}
impl PulseWire for USD {
fn from_com(com: &mut Vec<u8>) -> Self {
Self(f64::from_com(com))
}
fn to_com(&self) -> Vec<u8> {
self.0.to_com()
}
}
pub fn format_f64(value: f64) -> 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 int = parts[0].to_string();
let negative = int.starts_with('-');
let start = if negative { 1 } else { 0 };
let mut result = String::new();
for (i, c) in int[start..].chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
let mut formatted: String = result.chars().rev().collect();
if negative {
formatted.insert(0, '-');
}
if parts.len() > 1 {
formatted.push('.');
formatted.push_str(parts[1]);
}
formatted
}
+144 -11
View File
@@ -1,22 +1,32 @@
use std::sync::Arc; use std::sync::Arc;
use pulse_wire::{
general::Position,
units::{Symbol, USD},
};
use tokio::{sync::Mutex, task::JoinHandle}; use tokio::{sync::Mutex, task::JoinHandle};
use crate::{config::Config, terminal::TerminalServer}; use crate::{
store::{accounts::AccountList, config::Config},
terminal::TerminalServer,
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Engine { pub struct Engine {
pub terminal_server: Arc<TerminalServer>, pub terminal_server: Arc<TerminalServer>,
pub config: Arc<Mutex<Config>>, pub config: Arc<Mutex<Config>>,
pub accounts: Arc<Mutex<AccountList>>,
} }
impl Engine { impl Engine {
pub async fn new() -> tokio::io::Result<Arc<Self>> { pub async fn new() -> tokio::io::Result<Arc<Self>> {
let config = Arc::new(Mutex::new(Config::new().await?)); let config = Arc::new(Mutex::new(Config::new().await?));
let accounts = Arc::new(Mutex::new(AccountList::new().await?));
Ok(Arc::new_cyclic(|engine| Self { Ok(Arc::new_cyclic(|engine| Self {
terminal_server: TerminalServer::new(engine.clone()), terminal_server: TerminalServer::new(engine.clone()),
config, config,
accounts,
})) }))
} }
@@ -35,12 +45,14 @@ impl Engine {
pub async fn run_broadcaster(&self) -> tokio::io::Result<()> { pub async fn run_broadcaster(&self) -> tokio::io::Result<()> {
let mut refresh = tokio::time::interval(tokio::time::Duration::from_secs(5)); let mut refresh = tokio::time::interval(tokio::time::Duration::from_secs(5));
let client = hypersdk::hypercore::mainnet();
loop { loop {
refresh.tick().await; refresh.tick().await;
let watch_list = &self.config.lock().await.watchlist.symbols; let watch_list = &self.config.lock().await.watchlist.symbols;
match crate::fetch::fetch_watch_list(watch_list).await { match crate::fetch::fetch_watch_list(&client, watch_list).await {
Ok(watch_list) => { Ok(watch_list) => {
if let Err(error) = self if let Err(error) = self
.terminal_server .terminal_server
@@ -51,10 +63,62 @@ impl Engine {
) )
.await .await
{ {
eprintln!("Failed to broadcast Hyperliquid watch list: {error}"); self.terminal_server
.error(
"Broadcaster",
&format!("Failed to broadcast HyperLiquid watch list: {error}"),
)
.await?
} }
} }
Err(error) => eprintln!("Failed to refresh Hyperliquid watch list: {error}"),
Err(error) => {
self.terminal_server
.error(
"Broadcaster",
&format!("Failed to refresh HyperLiquid watch list: {error}"),
)
.await?
}
}
let accounts = self.accounts.lock().await;
if let Some(acc) = accounts.get_active() {
match client.clearinghouse_state(acc.address, None).await {
Ok(state) => {
self.terminal_server
.broadcast(
pulse_wire::terminal::TerminalServerMessage::PositionsUpdated(
state
.asset_positions
.into_iter()
.map(|position| Position {
symbol: Symbol(position.position.coin),
size: position.position.szi.as_f64(),
entry_price: USD(position
.position
.entry_px
.map(|px| px.as_f64())
.unwrap_or(0.0)),
profit: USD(position.position.unrealized_pnl.as_f64()),
})
.collect(),
),
)
.await?;
}
Err(e) => {
self.terminal_server
.error("orders", &format!("Unable to get open orders: {e}"))
.await?;
}
}
} else {
self.terminal_server.error(
"orders",
"Unable to get active account, make sure you have configured accounts properly",
).await?;
} }
} }
} }
@@ -65,15 +129,15 @@ impl Engine {
} }
} }
pub async fn invalid_command_usage(&self, name: &str) -> tokio::io::Result<()> {
self.terminal_server.error(name, "Invalid usage").await
}
pub async fn execute_command(&self, command: &str, args: Vec<&str>) -> tokio::io::Result<()> { pub async fn execute_command(&self, command: &str, args: Vec<&str>) -> tokio::io::Result<()> {
match command { match command {
"config" => { "config" | "cfg" => {
const MESSAGE: &str = "Invalid command arguments, usage: config <reload>";
if args.len() != 1 { if args.len() != 1 {
self.terminal_server.error("config", MESSAGE).await?; return self.invalid_command_usage("config").await;
return Ok(());
} }
match args[0] { match args[0] {
@@ -82,7 +146,76 @@ impl Engine {
} }
_ => { _ => {
self.terminal_server.error("config", MESSAGE).await?; self.terminal_server
.error("config", "Invalid usage")
.await?;
}
}
}
"account" | "acc" => {
if args.len() == 0 {
return self.invalid_command_usage("account man").await;
}
match args[0] {
"reload" => {
*self.accounts.lock().await = AccountList::new().await?;
}
"list" | "ls" => {
self.terminal_server
.info("account man", "ACCOUNT LIST")
.await?;
let accounts = self.accounts.lock().await;
for (name, acc) in &accounts.accounts {
self.terminal_server
.info(
"account man",
&if name == &accounts.active {
format!(
"{} (active) -> {}",
name,
acc.get_truncated_address()
)
} else {
format!("{} -> {}", name, acc.get_truncated_address())
},
)
.await?;
}
}
"use" | "set" => {
if args.len() < 2 {
return self.invalid_command_usage("account man").await;
}
let new_active = args[1];
let mut accounts = self.accounts.lock().await;
if !accounts.accounts.contains_key(new_active) {
return self
.terminal_server
.error("account man", &format!("Account not found ({new_active})"))
.await;
}
accounts.active = new_active.to_string();
self.terminal_server
.info(
"account man",
&format!("Account set to {new_active} successfully!"),
)
.await?;
}
_ => {
return self.invalid_command_usage("account man").await;
} }
} }
} }
+13 -7
View File
@@ -1,6 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use pulse_wire::terminal::WatchListItem; use pulse_wire::{
terminal::MarketItem,
units::{Symbol, USD},
};
use serde_json::Value; use serde_json::Value;
fn number(value: &Value, field: &str) -> Result<f64, String> { fn number(value: &Value, field: &str) -> Result<f64, String> {
@@ -12,8 +15,11 @@ fn number(value: &Value, field: &str) -> Result<f64, String> {
.map_err(|error| format!("could not parse asset context field {field} ({raw}): {error}")) .map_err(|error| format!("could not parse asset context field {field} ({raw}): {error}"))
} }
pub async fn fetch_watch_list(symbols: &[String]) -> Result<Vec<WatchListItem>, String> { pub async fn fetch_watch_list(
let response = hypersdk::hypercore::mainnet() client: &hypersdk::hypercore::HttpClient,
symbols: &[String],
) -> Result<Vec<MarketItem>, String> {
let response = client
.meta_and_asset_ctxs(None) .meta_and_asset_ctxs(None)
.await .await
.map_err(|error| format!("Hyperliquid metaAndAssetCtxs request failed: {error}"))?; .map_err(|error| format!("Hyperliquid metaAndAssetCtxs request failed: {error}"))?;
@@ -60,11 +66,11 @@ pub async fn fetch_watch_list(symbols: &[String]) -> Result<Vec<WatchListItem>,
by_symbol.insert( by_symbol.insert(
symbol, symbol,
WatchListItem { MarketItem {
symbol: symbol.to_owned(), symbol: Symbol(symbol.to_owned()),
price, price: USD(price),
volume_24h: USD(volume_24h),
trend: ((price / previous_day_price) - 1.0) * 100.0, trend: ((price / previous_day_price) - 1.0) * 100.0,
volume_24h,
}, },
); );
} }
+1 -1
View File
@@ -1,6 +1,6 @@
pub mod config;
pub mod engine; pub mod engine;
pub mod fetch; pub mod fetch;
pub mod store;
pub mod terminal; pub mod terminal;
#[tokio::main] #[tokio::main]
+109
View File
@@ -0,0 +1,109 @@
use std::{collections::HashMap, str::FromStr};
use hypersdk::{Address, hypercore::PrivateKeySigner};
#[derive(Debug)]
pub struct PrivateKey(pub PrivateKeySigner, pub String);
impl FromStr for PrivateKey {
type Err = <PrivateKeySigner as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.parse()?, s.to_string()))
}
}
impl std::fmt::Display for PrivateKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.1.fmt(f)
}
}
impl<'de> serde::Deserialize<'de> for PrivateKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl serde::Serialize for PrivateKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Account {
pub address: Address,
pub private_key: PrivateKey,
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct AccountList {
pub active: String,
#[serde(flatten)]
pub accounts: HashMap<String, Account>,
}
impl AccountList {
pub async fn new() -> tokio::io::Result<Self> {
let path = crate::store::pulse_accounts_file()?;
if !path.exists() {
let default = Self::default();
tokio::fs::create_dir_all(path.parent().unwrap()).await?;
tokio::fs::write(&path, default.to_string()?).await?;
return Ok(default);
}
let output = tokio::fs::read_to_string(path).await?;
Self::from_str(&output)
}
pub fn from_str(s: &str) -> tokio::io::Result<Self> {
toml::from_str(s)
.map_err(|v| tokio::io::Error::new(std::io::ErrorKind::InvalidInput, v.to_string()))
}
pub fn to_string(&self) -> tokio::io::Result<String> {
toml::to_string(self)
.map_err(|v| tokio::io::Error::new(std::io::ErrorKind::Other, v.to_string()))
}
pub fn get_active<'a>(&'a self) -> Option<&'a Account> {
self.accounts.get(&self.active)
}
}
impl Account {
pub fn get_truncated_address(&self) -> String {
let s = format!("{:x}", self.address);
if s.len() <= 6 {
return format!("INVALID");
}
let first: String = s.chars().take(4).collect();
let last: String = s
.chars()
.rev()
.take(4)
.collect::<Vec<char>>()
.into_iter()
.rev()
.collect();
format!("{}..{}", first, last)
}
}
@@ -1,19 +1,3 @@
use std::path::PathBuf;
pub fn home_dir() -> tokio::io::Result<PathBuf> {
std::env::home_dir().ok_or_else(|| {
tokio::io::Error::new(std::io::ErrorKind::NotFound, "Unable to get home directory")
})
}
pub fn pulse_directory() -> tokio::io::Result<PathBuf> {
Ok(home_dir()?.join(".config/pulse-trader"))
}
pub fn pulse_config_directory() -> tokio::io::Result<PathBuf> {
Ok(home_dir()?.join(".config/pulse-trader/config.toml"))
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct WatchList { pub struct WatchList {
pub symbols: Vec<String>, pub symbols: Vec<String>,
@@ -26,7 +10,7 @@ pub struct Config {
impl Config { impl Config {
pub async fn new() -> tokio::io::Result<Self> { pub async fn new() -> tokio::io::Result<Self> {
let path = pulse_config_directory()?; let path = crate::store::pulse_config_file()?;
if !path.exists() { if !path.exists() {
let default = Self::default(); let default = Self::default();
+22
View File
@@ -0,0 +1,22 @@
use std::path::PathBuf;
pub mod accounts;
pub mod config;
pub fn home_dir() -> tokio::io::Result<PathBuf> {
std::env::home_dir().ok_or_else(|| {
tokio::io::Error::new(std::io::ErrorKind::NotFound, "Unable to get home directory")
})
}
pub fn pulse_directory() -> tokio::io::Result<PathBuf> {
Ok(home_dir()?.join(".config").join("pulse-trader"))
}
pub fn pulse_config_file() -> tokio::io::Result<PathBuf> {
Ok(pulse_directory()?.join("config.toml"))
}
pub fn pulse_accounts_file() -> tokio::io::Result<PathBuf> {
Ok(pulse_directory()?.join("accounts.toml"))
}
+1 -4
View File
@@ -3,10 +3,7 @@ use std::{
sync::{Arc, Weak}, sync::{Arc, Weak},
}; };
use pulse_wire::{ use pulse_wire::{PulseWire, general::{EventLog, LogKind}, terminal::TerminalClientMessage};
PulseWire,
terminal::{EventLog, LogKind, TerminalClientMessage},
};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
net::{ net::{
+18 -73
View File
@@ -1,8 +1,10 @@
use pulse_wire::terminal::{ use pulse_wire::terminal::{
ActivePosition, Alert, AlertLevel, EventLog, InspectItem, InspectTarget, LogKind, Alert, AlertLevel, InspectItem, InspectTarget, MarketItem, MarketOverview, Status,
MarketOverview, Signal, SignalKind, Status, WatchListItem,
}; };
use pulse_wire::general::{Direction, EventLog, LogKind, Position, Signal};
use pulse_wire::units::format_f64;
pub trait Formatted { pub trait Formatted {
fn get_formatted(&self) -> Vec<String>; fn get_formatted(&self) -> Vec<String>;
} }
@@ -64,24 +66,23 @@ impl Formatted for EventLog {
impl Formatted for Signal { impl Formatted for Signal {
fn get_formatted(&self) -> Vec<String> { fn get_formatted(&self) -> Vec<String> {
vec![ vec![
if matches!(self.kind, SignalKind::Buy) { if matches!(self.kind, Direction::Buy) {
format!("\x1b[32m{}\x1b[0m", self.kind) format!("\x1b[32m{}\x1b[0m", self.kind)
} else { } else {
format!("\x1b[31m{}\x1b[0m", self.kind) format!("\x1b[31m{}\x1b[0m", self.kind)
}, },
format!("\x1b[35m{}\x1b[0m", self.symbol), format!("\x1b[35m{}\x1b[0m", self.symbol),
format!("\x1b[34m{}\x1b[0m", self.param), self.price.to_string(),
format_f64(self.price),
] ]
} }
} }
impl Formatted for WatchListItem { impl Formatted for MarketItem {
fn get_formatted(&self) -> Vec<String> { fn get_formatted(&self) -> Vec<String> {
vec![ vec![
format!("\x1b[35m{}\x1b[0m", self.symbol), self.symbol.to_string(),
format!("${}", format_f64(self.price)), self.price.to_string(),
format!("${}", format_f64(self.volume_24h)), self.volume_24h.to_string(),
format!( format!(
"{} {}", "{} {}",
if self.trend.is_sign_positive() { if self.trend.is_sign_positive() {
@@ -95,21 +96,20 @@ impl Formatted for WatchListItem {
} }
} }
impl Formatted for ActivePosition { impl Formatted for Position {
fn get_formatted(&self) -> Vec<String> { fn get_formatted(&self) -> Vec<String> {
vec![ vec![
format!("\x1b[35m{}\x1b[0m", self.symbol), self.symbol.to_string(),
format_f64(self.size), format_f64(self.size),
format_f64(self.entry_price), self.entry_price.to_string(),
format_f64(self.mark_price),
format!( format!(
"{} {}", "{}{}\x1b[0m",
if self.profit.is_sign_positive() { if self.profit.0.is_sign_positive() {
"\x1b[32m▲\x1b[0m" "\x1b[32m"
} else { } else {
"\x1b[31m▼\x1b[0m" "\x1b[31m"
}, },
format_f64(self.profit.abs()) self.profit,
), ),
] ]
} }
@@ -207,58 +207,3 @@ pub fn apply_padding(mut items: Vec<String>) -> Vec<String> {
items items
} }
pub fn format_f64(value: f64) -> 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 int = parts[0].to_string();
let negative = int.starts_with('-');
let start = if negative { 1 } else { 0 };
let mut result = String::new();
for (i, c) in int[start..].chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
let mut formatted: String = result.chars().rev().collect();
if negative {
formatted.insert(0, '-');
}
if parts.len() > 1 {
formatted.push('.');
formatted.push_str(parts[1]);
}
formatted
}
+38 -22
View File
@@ -21,8 +21,9 @@ use pulse_ui::{
use crate::formatting::{Formatted, apply_padding}; use crate::formatting::{Formatted, apply_padding};
use pulse_wire::terminal::{ use pulse_wire::{
ActivePosition, EventLog, InspectTarget, MarketOverview, Signal, Status, WatchListItem, general::{EventLog, Position, Signal},
terminal::{InspectTarget, MarketItem, MarketOverview, Status},
}; };
pub struct PulseTradeApp { pub struct PulseTradeApp {
@@ -30,12 +31,14 @@ pub struct PulseTradeApp {
command: State<InputState>, command: State<InputState>,
scroll: State<ScrollState<7>>, scroll: State<ScrollState<7>>,
watch_list: State<Vec<WatchListItem>>,
active_positions: State<Vec<ActivePosition>>, market_overview: State<Option<MarketOverview>>,
status: State<Option<Status>>,
watch_list: State<Vec<MarketItem>>,
active_positions: State<Vec<Position>>,
logs: State<Vec<EventLog>>, logs: State<Vec<EventLog>>,
signals: State<Vec<Signal>>, signals: State<Vec<Signal>>,
market_overview: State<MarketOverview>,
status: State<Status>,
inspect: State<InspectTarget>, inspect: State<InspectTarget>,
} }
@@ -128,14 +131,18 @@ impl App for PulseTradeApp {
( (
LayoutItem::Widget(Size::Flex(1)), LayoutItem::Widget(Size::Flex(1)),
Box::new( Box::new(
advanced_draw(&self.scroll, 1, "ACTIVE POSITIONS", &self.active_positions) advanced_draw(&self.scroll, 1, "POSITIONS", &self.active_positions).await,
.await,
), ),
), ),
( (
LayoutItem::Widget(Size::Flex(1)), LayoutItem::Widget(Size::Flex(1)),
Box::new( Box::new(
advanced_draw(&self.scroll, 2, "MARKET OVERVIEW", &self.market_overview) advanced_option_draw(
&self.scroll,
2,
"MARKET OVERVIEW",
&self.market_overview,
)
.await, .await,
), ),
), ),
@@ -155,7 +162,7 @@ impl App for PulseTradeApp {
), ),
( (
LayoutItem::Widget(Size::Flex(1)), LayoutItem::Widget(Size::Flex(1)),
Box::new(advanced_draw(&self.scroll, 5, "STATUS", &self.status).await), Box::new(advanced_option_draw(&self.scroll, 5, "STATUS", &self.status).await),
), ),
]), ]),
); );
@@ -183,18 +190,8 @@ async fn main() -> tokio::io::Result<()> {
signals: ctx.use_state(Vec::new()), signals: ctx.use_state(Vec::new()),
logs: ctx.use_state(Vec::new()), logs: ctx.use_state(Vec::new()),
inspect: ctx.use_state(InspectTarget::None), inspect: ctx.use_state(InspectTarget::None),
market_overview: ctx.use_state(MarketOverview { market_overview: ctx.use_state(None),
trend: pulse_wire::terminal::MarketTrend::Bullish, status: ctx.use_state(None),
volatility: pulse_wire::terminal::Volatility::High,
pressure: 0.324,
alerts: Vec::new(),
}),
status: ctx.use_state(Status {
feed: pulse_wire::terminal::Feed::Connected,
exchange: "Binance".to_string(),
dex: "DEX SCREENER".to_string(),
latency: 18,
}),
}) })
}) })
.await; .await;
@@ -216,3 +213,22 @@ pub async fn advanced_draw<const N: usize, T: Formatted>(
apply_padding(state.lock().await.get_formatted()).join("\n"), apply_padding(state.lock().await.get_formatted()).join("\n"),
) )
} }
pub async fn advanced_option_draw<const N: usize, T: Formatted>(
scroll: &State<ScrollState<N>>,
index: usize,
title: &'static str,
state: &State<Option<T>>,
) -> ScrollText {
let scroll = scroll.lock().await;
scroll.scroll(
index,
format!(" {}{title}\x1b[0m", scroll.get_selected(index)),
if let Some(state) = &*state.lock().await {
apply_padding(state.get_formatted()).join("\n")
} else {
" Loading..".to_string()
},
)
}
+3 -3
View File
@@ -78,7 +78,7 @@ impl TerminalClient {
} }
TerminalServerMessage::OverviewUpdated(v) => { TerminalServerMessage::OverviewUpdated(v) => {
*market_overview.lock().await = v; *market_overview.lock().await = Some(v);
} }
TerminalServerMessage::SignalsUpdated(v) => { TerminalServerMessage::SignalsUpdated(v) => {
@@ -89,8 +89,8 @@ impl TerminalClient {
*inspect.lock().await = v; *inspect.lock().await = v;
} }
TerminalServerMessage::SetStatus(v) => { TerminalServerMessage::StatusUpdated(v) => {
*status.lock().await = v; *status.lock().await = Some(v);
} }
TerminalServerMessage::SetLogs(v) => { TerminalServerMessage::SetLogs(v) => {