mirror of
https://github.com/exchanges-lab/tradesync.git
synced 2026-08-08 06:14:43 +08:00
fix: capture position-increase fills and debounce slow multi-fill aggregation
- Gate opening fills on Hyperliquid's dir field (Open Long/Short) instead of start_position == 0, so adds to an existing position are no longer dropped - Replace the fixed 500ms aggregation window with a 3s per-fill debounce (generation counter) so late fills of the same oid are not truncated - Carry real start_pos/end_pos on PositionTradeEvent and distinguish Open vs Increase actions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,11 @@
|
|||||||
格式遵循 Keep a Changelog,版本号遵循 SemVer。
|
格式遵循 Keep a Changelog,版本号遵循 SemVer。
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Fixed
|
||||||
|
- 修复加仓成交被静默忽略的问题:开仓判断从 `start_position == 0` 改为使用 Hyperliquid 的 `dir` 字段(`Open Long` / `Open Short`),已有持仓时的加仓现在也会写入 Notion。
|
||||||
|
- 修复同一订单(`oid`)慢速多笔成交被截断的问题:聚合由「首笔成交后固定 500 毫秒窗口」改为「3 秒防抖」,每笔新成交重置计时器,静默 3 秒后才汇总发送,避免写入的数量小于实际成交量。
|
||||||
|
- `PositionTradeEvent` 现在携带真实的 `start_pos` / `end_pos`,并区分 `Open`(从零开仓)与 `Increase`(加仓)动作。
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 支持对接 TradeSnap 截图服务,自动抓取新交易对应的 TradingView 图表快照。
|
- 支持对接 TradeSnap 截图服务,自动抓取新交易对应的 TradingView 图表快照。
|
||||||
- 新增 `ENABLE_SCREENSHOT` 与 `TRADESNAP_URL` 配置,控制截图的开关和请求地址。
|
- 新增 `ENABLE_SCREENSHOT` 与 `TRADESNAP_URL` 配置,控制截图的开关和请求地址。
|
||||||
|
|||||||
+63
-20
@@ -84,7 +84,13 @@ impl HyperliquidMonitor {
|
|||||||
|
|
||||||
info!("Successfully subscribed to UserEvents.");
|
info!("Successfully subscribed to UserEvents.");
|
||||||
|
|
||||||
let (timeout_tx, mut timeout_rx) = tokio::sync::mpsc::unbounded_channel::<u64>();
|
// Quiet window after the last fill of an order before flushing the
|
||||||
|
// aggregate. Fills of one order can arrive several seconds apart
|
||||||
|
// (observed gaps up to ~1.5s), so the timer is re-armed per fill
|
||||||
|
// (debounce) instead of running once from the first fill.
|
||||||
|
const AGGREGATION_QUIET_WINDOW: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
let (timeout_tx, mut timeout_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, u64)>();
|
||||||
|
|
||||||
struct AggregationState {
|
struct AggregationState {
|
||||||
coin: String,
|
coin: String,
|
||||||
@@ -94,6 +100,10 @@ impl HyperliquidMonitor {
|
|||||||
time: u64,
|
time: u64,
|
||||||
tid: u64,
|
tid: u64,
|
||||||
crossed: bool,
|
crossed: bool,
|
||||||
|
start_position: f64,
|
||||||
|
// Bumped on every fill; a flush timer only fires the flush if
|
||||||
|
// its generation still matches (i.e. no newer fill arrived).
|
||||||
|
generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut active_opening_orders =
|
let mut active_opening_orders =
|
||||||
@@ -111,11 +121,14 @@ impl HyperliquidMonitor {
|
|||||||
let sz: f64 = fill.sz.parse().unwrap_or(0.0);
|
let sz: f64 = fill.sz.parse().unwrap_or(0.0);
|
||||||
let px: f64 = fill.px.parse().unwrap_or(0.0);
|
let px: f64 = fill.px.parse().unwrap_or(0.0);
|
||||||
|
|
||||||
// If it's a new open order or is part of an active opening order
|
// Capture fills that open or increase a position. Hyperliquid
|
||||||
if start_pos == 0.0 || active_opening_orders.contains_key(&fill.oid) {
|
// labels these dir = "Open Long" / "Open Short"; gating on
|
||||||
match active_opening_orders.entry(fill.oid) {
|
// start_pos == 0.0 (the old check) silently dropped adds to an
|
||||||
|
// existing position.
|
||||||
|
if fill.dir.starts_with("Open") || active_opening_orders.contains_key(&fill.oid) {
|
||||||
|
let generation = match active_opening_orders.entry(fill.oid) {
|
||||||
std::collections::hash_map::Entry::Vacant(e) => {
|
std::collections::hash_map::Entry::Vacant(e) => {
|
||||||
// First fill of the opening order: create entry and spawn timeout
|
// First fill of the opening order: create entry
|
||||||
e.insert(AggregationState {
|
e.insert(AggregationState {
|
||||||
coin: fill.coin.clone(),
|
coin: fill.coin.clone(),
|
||||||
side: fill.side.clone(),
|
side: fill.side.clone(),
|
||||||
@@ -124,6 +137,8 @@ impl HyperliquidMonitor {
|
|||||||
time: fill.time,
|
time: fill.time,
|
||||||
tid: fill.tid,
|
tid: fill.tid,
|
||||||
crossed: fill.crossed,
|
crossed: fill.crossed,
|
||||||
|
start_position: start_pos,
|
||||||
|
generation: 0,
|
||||||
});
|
});
|
||||||
info!(
|
info!(
|
||||||
coin = %fill.coin,
|
coin = %fill.coin,
|
||||||
@@ -131,21 +146,18 @@ impl HyperliquidMonitor {
|
|||||||
px = %fill.px,
|
px = %fill.px,
|
||||||
sz = %fill.sz,
|
sz = %fill.sz,
|
||||||
oid = fill.oid,
|
oid = fill.oid,
|
||||||
|
dir = %fill.dir,
|
||||||
|
start_pos = start_pos,
|
||||||
"New opening order detected, starting aggregation..."
|
"New opening order detected, starting aggregation..."
|
||||||
);
|
);
|
||||||
|
0
|
||||||
let tx = timeout_tx.clone();
|
|
||||||
let oid = fill.oid;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
||||||
let _ = tx.send(oid);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||||
// Subsequent fill of the active opening order: aggregate it
|
// Subsequent fill of the active opening order: aggregate it
|
||||||
let state = e.get_mut();
|
let state = e.get_mut();
|
||||||
state.accumulated_sz += sz;
|
state.accumulated_sz += sz;
|
||||||
state.accumulated_px_sz += px * sz;
|
state.accumulated_px_sz += px * sz;
|
||||||
|
state.generation += 1;
|
||||||
info!(
|
info!(
|
||||||
coin = %fill.coin,
|
coin = %fill.coin,
|
||||||
side = %fill.side,
|
side = %fill.side,
|
||||||
@@ -155,11 +167,22 @@ impl HyperliquidMonitor {
|
|||||||
accumulated_sz = %state.accumulated_sz,
|
accumulated_sz = %state.accumulated_sz,
|
||||||
"Aggregated additional fill for active opening order"
|
"Aggregated additional fill for active opening order"
|
||||||
);
|
);
|
||||||
|
state.generation
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// (Re-)arm the flush timer for this order; older timers
|
||||||
|
// become no-ops because their generation no longer matches.
|
||||||
|
let tx = timeout_tx.clone();
|
||||||
|
let oid = fill.oid;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
sleep(AGGREGATION_QUIET_WINDOW).await;
|
||||||
|
let _ = tx.send((oid, generation));
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
coin = %fill.coin,
|
coin = %fill.coin,
|
||||||
|
dir = %fill.dir,
|
||||||
start_pos = start_pos,
|
start_pos = start_pos,
|
||||||
oid = fill.oid,
|
oid = fill.oid,
|
||||||
"Ignoring non-opening trade fill"
|
"Ignoring non-opening trade fill"
|
||||||
@@ -195,9 +218,17 @@ impl HyperliquidMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
oid = timeout_rx.recv() => {
|
flush = timeout_rx.recv() => {
|
||||||
match oid {
|
match flush {
|
||||||
Some(oid) => {
|
Some((oid, generation)) => {
|
||||||
|
// Only flush if no newer fill re-armed the timer since this
|
||||||
|
// one was spawned; otherwise a later timer will handle it.
|
||||||
|
let matches_generation = active_opening_orders
|
||||||
|
.get(&oid)
|
||||||
|
.is_some_and(|s| s.generation == generation);
|
||||||
|
if !matches_generation {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(state) = active_opening_orders.remove(&oid).filter(|s| s.accumulated_sz > 0.0) {
|
if let Some(state) = active_opening_orders.remove(&oid).filter(|s| s.accumulated_sz > 0.0) {
|
||||||
let avg_px = state.accumulated_px_sz / state.accumulated_sz;
|
let avg_px = state.accumulated_px_sz / state.accumulated_sz;
|
||||||
info!(
|
info!(
|
||||||
@@ -209,17 +240,29 @@ impl HyperliquidMonitor {
|
|||||||
"Aggregated opening order completed! Sending event..."
|
"Aggregated opening order completed! Sending event..."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Position delta is negative for sells (short opens/adds)
|
||||||
|
let signed_sz = if state.side == "B" {
|
||||||
|
state.accumulated_sz
|
||||||
|
} else {
|
||||||
|
-state.accumulated_sz
|
||||||
|
};
|
||||||
|
let action = if state.start_position == 0.0 {
|
||||||
|
TradeAction::Open
|
||||||
|
} else {
|
||||||
|
TradeAction::Increase
|
||||||
|
};
|
||||||
|
|
||||||
let event = PositionTradeEvent {
|
let event = PositionTradeEvent {
|
||||||
coin: state.coin,
|
coin: state.coin,
|
||||||
side: state.side,
|
side: state.side.clone(),
|
||||||
px: format!("{:.5}", avg_px),
|
px: format!("{:.5}", avg_px),
|
||||||
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,
|
oid,
|
||||||
action: TradeAction::Open,
|
action,
|
||||||
start_pos: "0.0".to_string(),
|
start_pos: format!("{:.5}", state.start_position),
|
||||||
end_pos: format!("{:.5}", state.accumulated_sz),
|
end_pos: format!("{:.5}", state.start_position + signed_sz),
|
||||||
crossed: state.crossed,
|
crossed: state.crossed,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user