mirror of
https://github.com/exchanges-lab/tradesync.git
synced 2026-08-04 21:01:23 +08:00
feat: integrate TradeSnap to fetch and append multi-timeframe screenshots to Notion page
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 聚合功能,防止拆单造成多笔重复写入。
|
||||
|
||||
Generated
+1
@@ -2974,6 +2974,7 @@ dependencies = [
|
||||
"hyperliquid_rust_sdk",
|
||||
"notion-client",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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_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
|
||||
|
||||
@@ -4,8 +4,6 @@ services:
|
||||
container_name: nosync
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- cycle
|
||||
restart: unless-stopped
|
||||
|
||||
+47
-2
@@ -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::<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
|
||||
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();
|
||||
|
||||
|
||||
+190
-3
@@ -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<String>,
|
||||
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<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()))
|
||||
.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::<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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +64,3 @@ mod tests {
|
||||
assert_eq!(NotionRowData::determine_order_type(false), "LIMIT");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user