From 777ca2d62a8097fab3f7671c0950f3813dc6f2e4 Mon Sep 17 00:00:00 2001 From: 0xcathiefish <72328723+0xcathiefish@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:56:41 +0000 Subject: [PATCH] feat: integrate TradeSnap to fetch and append multi-timeframe screenshots to Notion page --- .env.example | 18 +++++ CHANGELOG.md | 6 ++ Cargo.lock | 1 + Cargo.toml | 1 + README.md | 8 ++ docker-compose.yml | 2 - src/main.rs | 49 +++++++++++- src/notion.rs | 193 ++++++++++++++++++++++++++++++++++++++++++++- src/structs.rs | 1 - 9 files changed, 271 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index c9044dd..873ebd4 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,21 @@ IS_TESTNET=true NOTION_API_KEY= # Notion Database ID to write trade records into NOTION_DATABASE_ID= + +# TradeSnap configuration +# Whether to enable TradingView chart screenshots (true/false) +ENABLE_SCREENSHOT=false +# The API URL of the TradeSnap service +TRADESNAP_URL=http://tradesnap:8003 +# Ticker type switch: true to use BINANCE:{coin}USDT.P (USDT perp), false to use BINANCE:{coin}USDC.P (USDC perp) +BTCUSDT_SNAPSHOT=false +# Switch to enable/disable 15m screenshots +SYMBOL_15M_SNAPSHOT=false +# Switch to enable/disable 1h screenshots +SYMBOL_1H_SNAPSHOT=false +# Switch to enable/disable 4h screenshots +SYMBOL_4H_SNAPSHOT=false +# Switch to enable/disable 1D screenshots +SYMBOL_1D_SNAPSHOT=false + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6dcd8..3ee030a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ## [Unreleased] ### Added +- 支持对接 TradeSnap 截图服务,自动抓取新交易对应的 TradingView 图表快照。 +- 新增 `ENABLE_SCREENSHOT` 与 `TRADESNAP_URL` 配置,控制截图的开关和请求地址。 +- 新增 `BTCUSDT_SNAPSHOT` 配置,控制使用 `BINANCE:{coin}USDT.P` 还是 `BINANCE:{coin}USDC.P` 格式生成图表截图。 +- 新增 `SYMBOL_15M_SNAPSHOT`、`SYMBOL_1H_SNAPSHOT`、`SYMBOL_4H_SNAPSHOT`、`SYMBOL_1D_SNAPSHOT` 四个独立的开关配置,精细控制所要插入截图的时间周期。 +- 新建页面时,按配置周期顺序在 Notion 页面正文内自动追加文字标题(`SYMBOL_{timeframe} Snapshot`)、截图图片以及空白行间距。 +- 添加 `serde` 依赖库(并开启 `derive` feature)以支持接口响应的反序列化。 - 支持根据 `crossed` 属性自动映射 `Order Type` 类型(`MARKET` / `LIMIT`)写入 Notion 数据库。 - 实现 `NotionWriter` 模块,支持将格式化交易记录批量写入 Notion 数据库。 - 实现 `HyperliquidMonitor` 针对同一订单(`oid`)在 500 毫秒内的多成交 tick 聚合功能,防止拆单造成多笔重复写入。 diff --git a/Cargo.lock b/Cargo.lock index ac66d1a..0bcc83b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2974,6 +2974,7 @@ dependencies = [ "hyperliquid_rust_sdk", "notion-client", "reqwest 0.13.4", + "serde", "serde_json", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index a7b3d47..19fada3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ dotenvy = "0.15.7" hyperliquid_rust_sdk = { git = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk.git" } notion-client = { git = "https://github.com/takassh/notion-client.git" } reqwest = "0.13.4" +serde = { version = "1.0.218", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } diff --git a/README.md b/README.md index 86fe0b2..02a3bd2 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ async fn main() -> Result<(), Box> { | `NOTION_API_KEY` | Notion integration token (internal secret) | **Yes** | `secret_xxxxxx...` | | `NOTION_DATABASE_ID` | Notion Database ID | **Yes** | `2b08f81ac37083389c5c01242f3c1557` | | `RUST_LOG` | Logging verbosity level (error, warn, info, debug) | No | `info` | +| `ENABLE_SCREENSHOT` | Enable TradingView chart screenshots in Notion pages (`true`/`false`) | No | `false` | +| `TRADESNAP_URL` | The API endpoint URL of the TradeSnap service | No | `http://tradesnap:8003` | +| `BTCUSDT_SNAPSHOT` | Use Binance USDT perps (`true`) instead of USDC perps (`false`) for screenshots | No | `false` | +| `SYMBOL_15M_SNAPSHOT` | Capture and insert 15m interval screenshot (`true`/`false`) | No | `false` | +| `SYMBOL_1H_SNAPSHOT` | Capture and insert 1h interval screenshot (`true`/`false`) | No | `false` | +| `SYMBOL_4H_SNAPSHOT` | Capture and insert 4h interval screenshot (`true`/`false`) | No | `false` | +| `SYMBOL_1D_SNAPSHOT` | Capture and insert 1D interval screenshot (`true`/`false`) | No | `false` | + ## 8. Development & Testing ### Git Branching Strategy diff --git a/docker-compose.yml b/docker-compose.yml index 37c21b2..8c06506 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,6 @@ services: container_name: nosync env_file: - .env - volumes: - - ./data:/app/data networks: - cycle restart: unless-stopped diff --git a/src/main.rs b/src/main.rs index f0ede97..1b1b076 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,10 +37,55 @@ async fn main() -> Result<()> { let notion_database_id = env::var("NOTION_DATABASE_ID") .context("Missing NOTION_DATABASE_ID in environment variables")?; + // Retrieve TradeSnap configurations from environment + let enable_screenshot_str = + env::var("ENABLE_SCREENSHOT").unwrap_or_else(|_| "false".to_string()); + let enable_screenshot = enable_screenshot_str.parse::().unwrap_or(false); + let tradesnap_url = env::var("TRADESNAP_URL").ok(); + + let btcusdt_snapshot = env::var("BTCUSDT_SNAPSHOT") + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + + let snapshot_15m = env::var("SYMBOL_15M_SNAPSHOT") + .or_else(|_| env::var("SYMBOL_15m_SNAPSHOT")) + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + + let snapshot_1h = env::var("SYMBOL_1H_SNAPSHOT") + .or_else(|_| env::var("SYMBOL_1h_SNAPSHOT")) + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + + let snapshot_4h = env::var("SYMBOL_4H_SNAPSHOT") + .or_else(|_| env::var("SYMBOL_4h_SNAPSHOT")) + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + + let snapshot_1d = env::var("SYMBOL_1D_SNAPSHOT") + .or_else(|_| env::var("SYMBOL_1d_SNAPSHOT")) + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + // Initialize monitor and writer let monitor = HyperliquidMonitor::new(wallet, is_testnet); - let writer = NotionWriter::new(notion_token, notion_database_id) - .context("Failed to initialize NotionWriter")?; + let writer = NotionWriter::new( + notion_token, + notion_database_id, + enable_screenshot, + tradesnap_url, + btcusdt_snapshot, + snapshot_15m, + snapshot_1h, + snapshot_4h, + snapshot_1d, + ) + .context("Failed to initialize NotionWriter")?; let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/src/notion.rs b/src/notion.rs index 8413abf..9eadbc3 100644 --- a/src/notion.rs +++ b/src/notion.rs @@ -1,7 +1,10 @@ use crate::structs::NotionRowData; use chrono::{TimeZone, Utc}; use notion_client::endpoints::Client as NotionClient; +use notion_client::endpoints::blocks::append::request::AppendBlockChildrenRequest; use notion_client::endpoints::pages::create::request::CreateAPageRequestBuilder; +use notion_client::objects::block::{Block, BlockType, ImageValue, ParagraphValue}; +use notion_client::objects::file::{ExternalFile, File}; use notion_client::objects::page::{DatePropertyValue, PageProperty, SelectPropertyValue}; use notion_client::objects::parent::Parent; use notion_client::objects::property::DateOrDateTime; @@ -10,7 +13,7 @@ use reqwest::ClientBuilder; use serde_json::Number; use std::collections::BTreeMap; use thiserror::Error; -use tracing::info; +use tracing::{error, info}; /// Errors specific to the NotionWriter. #[derive(Error, Debug)] @@ -25,16 +28,41 @@ pub enum NotionWriterError { pub struct NotionWriter { client: NotionClient, database_id: String, + enable_screenshot: bool, + tradesnap_url: Option, + btcusdt_snapshot: bool, + snapshot_15m: bool, + snapshot_1h: bool, + snapshot_4h: bool, + snapshot_1d: bool, } impl NotionWriter { /// Creates a new NotionWriter instance. - pub fn new(token: String, database_id: String) -> Result { + #[allow(clippy::too_many_arguments)] + pub fn new( + token: String, + database_id: String, + enable_screenshot: bool, + tradesnap_url: Option, + btcusdt_snapshot: bool, + snapshot_15m: bool, + snapshot_1h: bool, + snapshot_4h: bool, + snapshot_1d: bool, + ) -> Result { let client = NotionClient::new(token, Some(ClientBuilder::new())) .map_err(|e| NotionWriterError::ClientError(format!("{:?}", e)))?; Ok(Self { client, database_id, + enable_screenshot, + tradesnap_url, + btcusdt_snapshot, + snapshot_15m, + snapshot_1h, + snapshot_4h, + snapshot_1d, }) } @@ -55,7 +83,7 @@ impl NotionWriter { id: None, title: vec![RichText::Text { text: Text { - content: data.symbol, + content: data.symbol.clone(), link: None, }, annotations: None, @@ -187,6 +215,165 @@ impl NotionWriter { let page = self.client.pages.create_a_page(request).await?; info!(page_id = %page.id, "Successfully wrote row to Notion Database"); + + if let (true, Some(url)) = (self.enable_screenshot, &self.tradesnap_url) { + let coin = if data.symbol.ends_with("USDC") { + data.symbol.trim_end_matches("USDC").to_string() + } else if data.symbol.ends_with("USDT") { + data.symbol.trim_end_matches("USDT").to_string() + } else { + data.symbol.clone() + }; + + let ticker = if self.btcusdt_snapshot { + format!("BINANCE:{}USDT.P", coin.to_uppercase()) + } else { + format!("BINANCE:{}USDC.P", coin.to_uppercase()) + }; + let tradesnap_url = url.trim_end_matches('/'); + let http_client = reqwest::Client::new(); + let mut children = Vec::new(); + + let mut timeframes = Vec::new(); + if self.snapshot_15m { + timeframes.push("15m"); + } + if self.snapshot_1h { + timeframes.push("1h"); + } + if self.snapshot_4h { + timeframes.push("4h"); + } + if self.snapshot_1d { + timeframes.push("1D"); + } + + for timeframe in &timeframes { + let request_url = format!( + "{}/chart?ticker={}&interval={}", + tradesnap_url, ticker, timeframe + ); + + info!( + symbol = %data.symbol, + ticker = %ticker, + timeframe = %timeframe, + request_url = %request_url, + "Requesting screenshot from TradeSnap..." + ); + + match http_client.get(&request_url).send().await { + Ok(response) => { + if response.status().is_success() { + #[derive(serde::Deserialize)] + struct TradeSnapResponse { + png_url: String, + } + match response.json::().await { + Ok(json_res) => { + let png_url = json_res.png_url; + info!( + symbol = %data.symbol, + timeframe = %timeframe, + png_url = %png_url, + "Successfully got screenshot URL from TradeSnap." + ); + + let text_block = Block { + block_type: BlockType::Paragraph { + paragraph: ParagraphValue { + rich_text: vec![RichText::Text { + text: Text { + content: format!( + "{}_{} Snapshot", + data.symbol, timeframe + ), + link: None, + }, + annotations: None, + plain_text: None, + href: None, + }], + color: None, + children: None, + }, + }, + ..Default::default() + }; + + let image_block = Block { + block_type: BlockType::Image { + image: ImageValue { + file_type: File::External { + external: ExternalFile { url: png_url }, + }, + }, + }, + ..Default::default() + }; + + let empty_block = Block { + block_type: BlockType::Paragraph { + paragraph: ParagraphValue { + rich_text: vec![], + color: None, + children: None, + }, + }, + ..Default::default() + }; + + children.push(text_block); + children.push(image_block); + children.push(empty_block); + } + Err(err) => { + error!( + "Failed to deserialize TradeSnap response for {}: {:?}", + timeframe, err + ); + } + } + } else { + error!( + "TradeSnap returned error status for {}: {:?}", + timeframe, + response.status() + ); + } + } + Err(err) => { + error!("Failed to request TradeSnap for {}: {:?}", timeframe, err); + } + } + } + + if !children.is_empty() { + info!( + page_id = %page.id, + block_count = children.len(), + "Appending all screenshot blocks to Notion page..." + ); + let append_request = AppendBlockChildrenRequest { + children, + position: None, + }; + if let Err(err) = self + .client + .blocks + .append_block_children(&page.id, append_request) + .await + { + error!("Failed to append blocks to Notion page: {:?}", err); + } else { + info!( + "Successfully appended all screenshot blocks to Notion page {}", + page.id + ); + } + } + } + Ok(()) } } diff --git a/src/structs.rs b/src/structs.rs index 2a6d0c4..9cc1fc2 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -64,4 +64,3 @@ mod tests { assert_eq!(NotionRowData::determine_order_type(false), "LIMIT"); } } -