mirror of
https://github.com/exchanges-lab/tradesync.git
synced 2026-08-05 05:06:07 +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_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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+47
-2
@@ -37,10 +37,55 @@ 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(
|
||||||
.context("Failed to initialize NotionWriter")?;
|
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();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
|||||||
+190
-3
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,4 +64,3 @@ mod tests {
|
|||||||
assert_eq!(NotionRowData::determine_order_type(false), "LIMIT");
|
assert_eq!(NotionRowData::determine_order_type(false), "LIMIT");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user