feat: implement NotionWriter database insertion and wire it with HyperliquidMonitor

This commit is contained in:
0xcathiefish
2026-05-30 09:35:36 +00:00
parent e721988984
commit d86b03f3bb
8 changed files with 324 additions and 13 deletions
Generated
+3
View File
@@ -2969,9 +2969,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"alloy 2.0.5", "alloy 2.0.5",
"anyhow", "anyhow",
"chrono",
"dotenvy", "dotenvy",
"hyperliquid_rust_sdk", "hyperliquid_rust_sdk",
"notion-client", "notion-client",
"reqwest 0.13.4",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
+3
View File
@@ -6,9 +6,12 @@ edition = "2024"
[dependencies] [dependencies]
alloy = "2.0.5" alloy = "2.0.5"
anyhow = "1.0.102" anyhow = "1.0.102"
chrono = { version = "0.4.44", features = ["serde"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
hyperliquid_rust_sdk = { git = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk.git" } hyperliquid_rust_sdk = { git = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk.git" }
notion-client = { git = "https://github.com/takassh/notion-client.git" } notion-client = { git = "https://github.com/takassh/notion-client.git" }
reqwest = "0.13.4"
serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
+68
View File
@@ -0,0 +1,68 @@
use anyhow::{Context, Result};
use dotenvy::dotenv;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use std::env;
fn format_db_id(id: &str) -> String {
let cleaned = id.replace("-", "");
if cleaned.len() == 32 {
format!(
"{}-{}-{}-{}-{}",
&cleaned[0..8],
&cleaned[8..12],
&cleaned[12..16],
&cleaned[16..20],
&cleaned[20..32]
)
} else {
id.to_string()
}
}
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenv();
let token = env::var("NOTION_API_KEY").context("Missing NOTION_API_KEY")?;
let db_id_raw = env::var("NOTION_DATABASE_ID").context("Missing NOTION_DATABASE_ID")?;
let db_id = format_db_id(&db_id_raw);
// Setup headers
let mut headers = HeaderMap::new();
headers.insert("Notion-Version", HeaderValue::from_static("2022-06-28"));
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token))?,
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
// 1. Try GET /v1/databases/{db_id}
let get_url = format!("https://api.notion.com/v1/databases/{}", db_id);
println!("Sending GET request to: {} ...", get_url);
let get_res = client.get(&get_url).send().await?;
let get_status = get_res.status();
let get_body = get_res.text().await?;
println!("GET Status: {}", get_status);
println!("GET Body:\n{}", get_body);
println!("\n--------------------------------------------------\n");
// 2. Try POST /v1/databases/{db_id}/query
let post_url = format!("https://api.notion.com/v1/databases/{}/query", db_id);
println!("Sending POST request to: {} ...", post_url);
let post_res = client
.post(&post_url)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await?;
let post_status = post_res.status();
let post_body = post_res.text().await?;
println!("POST Status: {}", post_status);
println!("POST Body:\n{}", post_body);
Ok(())
}
+1
View File
@@ -202,6 +202,7 @@ impl HyperliquidMonitor {
sz: format!("{:.5}", state.accumulated_sz), sz: format!("{:.5}", state.accumulated_sz),
time: state.time, time: state.time,
tid: state.tid, tid: state.tid,
oid,
action: TradeAction::Open, action: TradeAction::Open,
start_pos: "0.0".to_string(), start_pos: "0.0".to_string(),
end_pos: format!("{:.5}", state.accumulated_sz), end_pos: format!("{:.5}", state.accumulated_sz),
+3 -1
View File
@@ -1,5 +1,7 @@
pub mod hyperliquid; pub mod hyperliquid;
pub mod notion;
pub mod structs; pub mod structs;
pub use hyperliquid::{HyperliquidMonitor, HyperliquidMonitorError}; pub use hyperliquid::{HyperliquidMonitor, HyperliquidMonitorError};
pub use structs::{PositionTradeEvent, TradeAction}; pub use notion::{NotionWriter, NotionWriterError};
pub use structs::{NotionRowData, PositionTradeEvent, TradeAction};
+52 -12
View File
@@ -1,6 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{TimeZone, Utc};
use dotenvy::dotenv; use dotenvy::dotenv;
use nosync::HyperliquidMonitor; use nosync::{HyperliquidMonitor, NotionRowData, NotionWriter};
use std::env; use std::env;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{error, info}; use tracing::{error, info};
@@ -18,20 +19,29 @@ async fn main() -> Result<()> {
tracing::subscriber::set_global_default(subscriber) tracing::subscriber::set_global_default(subscriber)
.map_err(|e| anyhow::anyhow!("failed to set global tracing subscriber: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to set global tracing subscriber: {e}"))?;
info!("Starting Hyperliquid Monitor Service..."); info!("Starting Hyperliquid to Notion Sync Service...");
// Retrieve WALLET_ADDRESS from environment // Retrieve WALLET_ADDRESS and IS_TESTNET from environment
let wallet_str = let wallet_str =
env::var("WALLET_ADDRESS").context("Missing WALLET_ADDRESS in environment variables")?; env::var("WALLET_ADDRESS").context("Missing WALLET_ADDRESS in environment variables")?;
let wallet = wallet_str let wallet = wallet_str
.parse::<alloy::primitives::Address>() .parse::<alloy::primitives::Address>()
.map_err(|e| anyhow::anyhow!("Invalid WALLET_ADDRESS: {e}"))?; .map_err(|e| anyhow::anyhow!("Invalid WALLET_ADDRESS: {e}"))?;
// Retrieve IS_TESTNET from environment, defaulting to true if not set
let is_testnet_str = env::var("IS_TESTNET").unwrap_or_else(|_| "true".to_string()); let is_testnet_str = env::var("IS_TESTNET").unwrap_or_else(|_| "true".to_string());
let is_testnet = is_testnet_str.parse::<bool>().unwrap_or(true); let is_testnet = is_testnet_str.parse::<bool>().unwrap_or(true);
// Retrieve Notion configurations from environment
let notion_token =
env::var("NOTION_API_KEY").context("Missing NOTION_API_KEY in environment variables")?;
let notion_database_id = env::var("NOTION_DATABASE_ID")
.context("Missing NOTION_DATABASE_ID in environment variables")?;
// Initialize monitor and writer
let monitor = HyperliquidMonitor::new(wallet, is_testnet); let monitor = HyperliquidMonitor::new(wallet, is_testnet);
let writer = NotionWriter::new(notion_token, notion_database_id)
.context("Failed to initialize NotionWriter")?;
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
// Spawn the monitor loop in the background // Spawn the monitor loop in the background
@@ -41,21 +51,51 @@ async fn main() -> Result<()> {
} }
}); });
info!("Service running, waiting for wallet events..."); info!("Service running. Monitoring position openings and writing to Notion...");
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
info!( info!(
coin = %event.coin, coin = %event.coin,
side = %event.side, side = %event.side,
px = %event.px, px = %event.px,
sz = %event.sz, sz = %event.sz,
action = ?event.action, oid = event.oid,
start_pos = %event.start_pos, "Captured position open event, writing to Notion..."
end_pos = %event.end_pos,
time = event.time,
tid = event.tid,
"Captured position trade event!"
); );
// Notion database integration will be wired here in the future
// Format Date/Time to "YYYY/MM/DD HH:MM"
let dt = Utc
.timestamp_millis_opt(event.time as i64)
.single()
.unwrap_or_else(Utc::now);
let date_time_str = dt.format("%Y/%m/%d %H:%M").to_string();
// Map Symbol to appends USDC
let symbol_formatted = format!("{}USDC", event.coin);
// Map Direction: B -> Long, S -> Short
let direction_str = if event.side == "B" {
"Long".to_string()
} else {
"Short".to_string()
};
// Construct Notion Row Data
let row = NotionRowData {
symbol: symbol_formatted,
quantity: event.sz,
filled_price: event.px,
direction: direction_str,
exchange: "Hyperliquid".to_string(), // Can be dynamically set or config-driven in the future
date_time: date_time_str,
time: event.time,
order_id: event.oid,
check: false,
};
// Write row to Notion
if let Err(e) = writer.write_row(row).await {
error!("Failed to write row to Notion: {:?}", e);
}
} }
Ok(()) Ok(())
+179
View File
@@ -0,0 +1,179 @@
use crate::structs::NotionRowData;
use chrono::{TimeZone, Utc};
use notion_client::endpoints::Client as NotionClient;
use notion_client::endpoints::pages::create::request::CreateAPageRequestBuilder;
use notion_client::objects::page::{DatePropertyValue, PageProperty, SelectPropertyValue};
use notion_client::objects::parent::Parent;
use notion_client::objects::property::DateOrDateTime;
use notion_client::objects::rich_text::{RichText, Text};
use reqwest::ClientBuilder;
use serde_json::Number;
use std::collections::BTreeMap;
use thiserror::Error;
use tracing::info;
/// Errors specific to the NotionWriter.
#[derive(Error, Debug)]
pub enum NotionWriterError {
#[error("Notion client error: {0}")]
ClientError(String),
#[error("Notion API request failed: {0}")]
RequestError(#[from] notion_client::NotionClientError),
}
/// Writer for logging trade records to the Notion Database.
pub struct NotionWriter {
client: NotionClient,
database_id: String,
}
impl NotionWriter {
/// Creates a new NotionWriter instance.
pub fn new(token: String, database_id: String) -> Result<Self, NotionWriterError> {
let client = NotionClient::new(token, Some(ClientBuilder::new()))
.map_err(|e| NotionWriterError::ClientError(format!("{:?}", e)))?;
Ok(Self {
client,
database_id,
})
}
/// Inserts a new row into the target Notion database.
pub async fn write_row(&self, data: NotionRowData) -> Result<(), NotionWriterError> {
info!(
symbol = %data.symbol,
order_id = data.order_id,
"Writing row to Notion database"
);
let mut properties = BTreeMap::new();
// 1. Symbol (Title column)
properties.insert(
"Symbol".to_string(),
PageProperty::Title {
id: None,
title: vec![RichText::Text {
text: Text {
content: data.symbol,
link: None,
},
annotations: None,
plain_text: None,
href: None,
}],
},
);
// 2. Quantity (RichText column)
properties.insert(
"Quantity".to_string(),
PageProperty::RichText {
id: None,
rich_text: vec![RichText::Text {
text: Text {
content: data.quantity,
link: None,
},
annotations: None,
plain_text: None,
href: None,
}],
},
);
// 3. Filled Price (RichText column)
properties.insert(
"Filled Price".to_string(),
PageProperty::RichText {
id: None,
rich_text: vec![RichText::Text {
text: Text {
content: data.filled_price,
link: None,
},
annotations: None,
plain_text: None,
href: None,
}],
},
);
// 4. Direction (Select column)
properties.insert(
"Direction".to_string(),
PageProperty::Select {
id: None,
select: Some(SelectPropertyValue {
id: None,
name: Some(data.direction),
color: None,
}),
},
);
// 5. Exchange (MultiSelect column)
properties.insert(
"Exchange".to_string(),
PageProperty::MultiSelect {
id: None,
multi_select: vec![SelectPropertyValue {
id: None,
name: Some(data.exchange),
color: None,
}],
},
);
// 6. DataTime (Date column)
let dt = Utc
.timestamp_millis_opt(data.time as i64)
.single()
.unwrap_or_else(Utc::now);
properties.insert(
"DataTime".to_string(),
PageProperty::Date {
id: None,
date: Some(DatePropertyValue {
start: Some(DateOrDateTime::DateTime(dt)),
end: None,
time_zone: None,
}),
},
);
// 7. Order ID (Number column)
properties.insert(
"Order ID".to_string(),
PageProperty::Number {
id: None,
number: Some(Number::from(data.order_id)),
},
);
// 8. Check (Checkbox column)
properties.insert(
"Check".to_string(),
PageProperty::Checkbox {
id: None,
checkbox: data.check,
},
);
// Build CreateAPageRequest and create page
let request = CreateAPageRequestBuilder::default()
.parent(Parent::DatabaseId {
database_id: self.database_id.clone(),
})
.properties(properties)
.build()
.map_err(|e| {
NotionWriterError::ClientError(format!("Failed to build page request: {:?}", e))
})?;
let page = self.client.pages.create_a_page(request).await?;
info!(page_id = %page.id, "Successfully wrote row to Notion Database");
Ok(())
}
}
+15
View File
@@ -20,7 +20,22 @@ pub struct PositionTradeEvent {
pub sz: String, // Trade size as string pub sz: String, // Trade size as string
pub time: u64, // Epoch timestamp in milliseconds pub time: u64, // Epoch timestamp in milliseconds
pub tid: u64, // Unique trade ID pub tid: u64, // Unique trade ID
pub oid: u64, // Hyperliquid Order ID
pub action: TradeAction, pub action: TradeAction,
pub start_pos: String, // Position size before the trade pub start_pos: String, // Position size before the trade
pub end_pos: String, // Position size after the trade pub end_pos: String, // Position size after the trade
} }
/// Represents the structured row data formatted for Notion database insertion.
#[derive(Debug, Clone)]
pub struct NotionRowData {
pub symbol: String, // e.g., "BTCUSDC"
pub quantity: String, // e.g., "0.02484"
pub filled_price: String, // e.g., "72543"
pub direction: String, // "Long" or "Short"
pub exchange: String, // e.g., "Hyperliquid"
pub date_time: String, // Format: "YYYY/MM/DD HH:MM"
pub time: u64, // Unix timestamp in milliseconds
pub order_id: u64, // Hyperliquid order ID (oid)
pub check: bool, // false
}