feat: integrate TradeSnap to fetch and append multi-timeframe screenshots to Notion page

This commit is contained in:
0xcathiefish
2026-06-01 05:56:41 +00:00
parent f54ba537a9
commit 777ca2d62a
9 changed files with 271 additions and 8 deletions
+18
View File
@@ -14,3 +14,21 @@ IS_TESTNET=true
NOTION_API_KEY= NOTION_API_KEY=
# Notion Database ID to write trade records into # Notion Database ID to write trade records into
NOTION_DATABASE_ID= 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
+6
View File
@@ -5,6 +5,12 @@
## [Unreleased] ## [Unreleased]
### Added ### 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 数据库。 - 支持根据 `crossed` 属性自动映射 `Order Type` 类型(`MARKET` / `LIMIT`)写入 Notion 数据库。
- 实现 `NotionWriter` 模块,支持将格式化交易记录批量写入 Notion 数据库。 - 实现 `NotionWriter` 模块,支持将格式化交易记录批量写入 Notion 数据库。
- 实现 `HyperliquidMonitor` 针对同一订单(`oid`)在 500 毫秒内的多成交 tick 聚合功能,防止拆单造成多笔重复写入。 - 实现 `HyperliquidMonitor` 针对同一订单(`oid`)在 500 毫秒内的多成交 tick 聚合功能,防止拆单造成多笔重复写入。
Generated
+1
View File
@@ -2974,6 +2974,7 @@ dependencies = [
"hyperliquid_rust_sdk", "hyperliquid_rust_sdk",
"notion-client", "notion-client",
"reqwest 0.13.4", "reqwest 0.13.4",
"serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
+1
View File
@@ -11,6 +11,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" reqwest = "0.13.4"
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.150" 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"] }
+8
View File
@@ -95,6 +95,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
| `NOTION_API_KEY` | Notion integration token (internal secret) | **Yes** | `secret_xxxxxx...` | | `NOTION_API_KEY` | Notion integration token (internal secret) | **Yes** | `secret_xxxxxx...` |
| `NOTION_DATABASE_ID` | Notion Database ID | **Yes** | `2b08f81ac37083389c5c01242f3c1557` | | `NOTION_DATABASE_ID` | Notion Database ID | **Yes** | `2b08f81ac37083389c5c01242f3c1557` |
| `RUST_LOG` | Logging verbosity level (error, warn, info, debug) | No | `info` | | `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 ## 8. Development & Testing
### Git Branching Strategy ### Git Branching Strategy
-2
View File
@@ -4,8 +4,6 @@ services:
container_name: nosync container_name: nosync
env_file: env_file:
- .env - .env
volumes:
- ./data:/app/data
networks: networks:
- cycle - cycle
restart: unless-stopped restart: unless-stopped
+46 -1
View File
@@ -37,9 +37,54 @@ async fn main() -> Result<()> {
let notion_database_id = env::var("NOTION_DATABASE_ID") let notion_database_id = env::var("NOTION_DATABASE_ID")
.context("Missing NOTION_DATABASE_ID in environment variables")?; .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::<bool>().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::<bool>()
.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::<bool>()
.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::<bool>()
.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::<bool>()
.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::<bool>()
.unwrap_or(false);
// Initialize monitor and writer // 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) 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")?; .context("Failed to initialize NotionWriter")?;
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
+190 -3
View File
@@ -1,7 +1,10 @@
use crate::structs::NotionRowData; use crate::structs::NotionRowData;
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use notion_client::endpoints::Client as NotionClient; 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::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::page::{DatePropertyValue, PageProperty, SelectPropertyValue};
use notion_client::objects::parent::Parent; use notion_client::objects::parent::Parent;
use notion_client::objects::property::DateOrDateTime; use notion_client::objects::property::DateOrDateTime;
@@ -10,7 +13,7 @@ use reqwest::ClientBuilder;
use serde_json::Number; use serde_json::Number;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::{error, info};
/// Errors specific to the NotionWriter. /// Errors specific to the NotionWriter.
#[derive(Error, Debug)] #[derive(Error, Debug)]
@@ -25,16 +28,41 @@ pub enum NotionWriterError {
pub struct NotionWriter { pub struct NotionWriter {
client: NotionClient, client: NotionClient,
database_id: String, database_id: String,
enable_screenshot: bool,
tradesnap_url: Option<String>,
btcusdt_snapshot: bool,
snapshot_15m: bool,
snapshot_1h: bool,
snapshot_4h: bool,
snapshot_1d: bool,
} }
impl NotionWriter { impl NotionWriter {
/// Creates a new NotionWriter instance. /// Creates a new NotionWriter instance.
pub fn new(token: String, database_id: String) -> Result<Self, NotionWriterError> { #[allow(clippy::too_many_arguments)]
pub fn new(
token: String,
database_id: String,
enable_screenshot: bool,
tradesnap_url: Option<String>,
btcusdt_snapshot: bool,
snapshot_15m: bool,
snapshot_1h: bool,
snapshot_4h: bool,
snapshot_1d: bool,
) -> Result<Self, NotionWriterError> {
let client = NotionClient::new(token, Some(ClientBuilder::new())) let client = NotionClient::new(token, Some(ClientBuilder::new()))
.map_err(|e| NotionWriterError::ClientError(format!("{:?}", e)))?; .map_err(|e| NotionWriterError::ClientError(format!("{:?}", e)))?;
Ok(Self { Ok(Self {
client, client,
database_id, database_id,
enable_screenshot,
tradesnap_url,
btcusdt_snapshot,
snapshot_15m,
snapshot_1h,
snapshot_4h,
snapshot_1d,
}) })
} }
@@ -55,7 +83,7 @@ impl NotionWriter {
id: None, id: None,
title: vec![RichText::Text { title: vec![RichText::Text {
text: Text { text: Text {
content: data.symbol, content: data.symbol.clone(),
link: None, link: None,
}, },
annotations: None, annotations: None,
@@ -187,6 +215,165 @@ impl NotionWriter {
let page = self.client.pages.create_a_page(request).await?; let page = self.client.pages.create_a_page(request).await?;
info!(page_id = %page.id, "Successfully wrote row to Notion Database"); 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::<TradeSnapResponse>().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(()) Ok(())
} }
} }
-1
View File
@@ -64,4 +64,3 @@ mod tests {
assert_eq!(NotionRowData::determine_order_type(false), "LIMIT"); assert_eq!(NotionRowData::determine_order_type(false), "LIMIT");
} }
} }