Skip to content

Commit 351ea8d

Browse files
author
Your Name
committed
Merge upstream main: fix phantom order sync corrupting position and PnL.
2 parents 35d4278 + b61972e commit 351ea8d

5 files changed

Lines changed: 141 additions & 36 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ rust_decimal = { version = "1", features = ["serde-with-str"] }
2727
rust_decimal_macros = "1"
2828
async-trait = "0.1"
2929
anyhow = "1"
30+
fs2 = "0.4"

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ grid-engine = { path = "../../../crates/grid-engine" }
2222
exchange = { path = "../../../crates/exchange" }
2323
storage = { path = "../../../crates/storage" }
2424
chrono = { workspace = true }
25+
fs2 = { workspace = true }
2526

2627
[features]
2728
default = ["custom-protocol"]

apps/desktop/src-tauri/src/main.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
mod runner;
22
mod i18n_err;
33

4+
use std::fs::OpenOptions;
45
use std::path::PathBuf;
56
use std::sync::Arc;
67

8+
use fs2::FileExt;
79
use exchange::{
810
fetch_candles, fetch_live_mid, list_live_markets, list_live_mids, Candle, CandleInterval,
911
Exchange, HyperliquidExchange, MarketInfo, SimExchange,
@@ -1221,10 +1223,33 @@ async fn save_settings(
12211223
})
12221224
}
12231225

1226+
/// One process per data directory — prevents two windows fighting the same HL account.
1227+
fn acquire_instance_lock() -> anyhow::Result<std::fs::File> {
1228+
let dir = resolve_data_dir();
1229+
std::fs::create_dir_all(&dir)?;
1230+
let path = dir.join(".hyper-grid.lock");
1231+
let file = OpenOptions::new()
1232+
.create(true)
1233+
.write(true)
1234+
.open(&path)?;
1235+
file.try_lock_exclusive()
1236+
.map_err(|_| anyhow::anyhow!("another hyper-grid instance is already using {}", dir.display()))?;
1237+
Ok(file)
1238+
}
1239+
12241240
#[cfg_attr(mobile, tauri::mobile_entry_point)]
12251241
pub fn run() {
12261242
tracing_subscriber::fmt().with_env_filter("info").init();
12271243

1244+
let _instance_lock = match acquire_instance_lock() {
1245+
Ok(f) => f,
1246+
Err(e) => {
1247+
eprintln!("{e}");
1248+
eprintln!("Close the other hyper-grid window or use a separate HYPER_GRID_HOME.");
1249+
std::process::exit(1);
1250+
}
1251+
};
1252+
12281253
let state = AppState::new().expect("storage");
12291254
let state = Arc::new(Mutex::new(state));
12301255
let state_for_exit = state.clone();

apps/desktop/src-tauri/src/runner.rs

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ pub fn persist_checkpoint(storage: &Storage, engine: &GridEngine, phase: &str) {
273273
}
274274

275275
pub fn record_fill_ledger(storage: &Storage, session_id: &str, fill: &grid_engine::FillEvent, pnl: Decimal) {
276+
// `pnl` is net of fees from the engine; ledger stores gross separately.
277+
let gross = pnl + fill.fee;
276278
let notional = fill.price * fill.size;
277279
let row = FillLedgerRow {
278280
session_id: session_id.to_string(),
@@ -291,7 +293,7 @@ pub fn record_fill_ledger(storage: &Storage, session_id: &str, fill: &grid_engin
291293
crossed: fill.crossed,
292294
fee: fill.fee,
293295
fee_token: fill.fee_token.clone(),
294-
gross_closed_pnl: fill.closed_pnl.unwrap_or(pnl),
296+
gross_closed_pnl: gross,
295297
position_before: None,
296298
position_after: None,
297299
source: "exchange".into(),
@@ -301,6 +303,36 @@ pub fn record_fill_ledger(storage: &Storage, session_id: &str, fill: &grid_engin
301303
}
302304
}
303305

306+
fn sort_fills_chronologically(fills: &mut [grid_engine::FillEvent]) {
307+
fills.sort_by_key(|f| f.exchange_time_ms.unwrap_or(0));
308+
}
309+
310+
/// Apply exchange fills in time order. Returns replenish intents from successful fills.
311+
fn apply_fills_to_engine(
312+
storage: &Storage,
313+
engine: &mut GridEngine,
314+
session_id: &str,
315+
mut fills: Vec<grid_engine::FillEvent>,
316+
record_ledger: bool,
317+
) -> Vec<grid_engine::OrderIntent> {
318+
sort_fills_chronologically(&mut fills);
319+
let mut replenish = Vec::new();
320+
for fill in fills {
321+
match engine.on_fill(fill.clone()) {
322+
Ok((pnl, intent)) => {
323+
if record_ledger {
324+
record_fill_ledger(storage, session_id, &fill, pnl);
325+
}
326+
if let Some(i) = intent {
327+
replenish.push(i);
328+
}
329+
}
330+
Err(e) => warn!("fill apply failed: {e}"),
331+
}
332+
}
333+
replenish
334+
}
335+
304336
pub fn record_equity(storage: &Storage, engine: &GridEngine) {
305337
let snap = engine.snapshot();
306338
let sid = engine.session_id().to_string();
@@ -615,8 +647,8 @@ pub async fn execute_recenter(
615647
}
616648

617649
/// Sync engine (+ HL cache) open orders from exchange.
618-
/// Local-only phantoms are treated as fills (so reverse replenish runs), then
619-
/// missing grid levels are repaired up toward `target_resting`.
650+
/// Stale local orders missing on the exchange are dropped (not simulated as fills).
651+
/// Missing grid levels are repaired only when the book is fully in sync.
620652
async fn sync_open_orders_from_exchange(
621653
st: &mut AppState,
622654
symbol: &str,
@@ -640,34 +672,58 @@ async fn sync_open_orders_from_exchange(
640672
let Some(engine) = st.engine.as_mut() else {
641673
return Ok(vec![]);
642674
};
643-
let local = engine.live_orders().to_vec();
644675
let session_id = engine.session_id().to_string();
645676

646-
let mut replenish = Vec::new();
677+
// Drain exchange fills we may have missed before inferring phantoms.
678+
let mut replenish = if st.mode == RunMode::Simulation {
679+
vec![]
680+
} else {
681+
let live = engine.live_orders().to_vec();
682+
if let Some(hl) = st.hl.as_mut() {
683+
hl.restore_tracked_orders(&live);
684+
}
685+
let fills = st
686+
.hl
687+
.as_mut()
688+
.unwrap()
689+
.drain_fills()
690+
.await
691+
.unwrap_or_default();
692+
apply_fills_to_engine(&st.storage, engine, &session_id, fills, true)
693+
};
694+
695+
let local = engine.live_orders().to_vec();
696+
697+
// Exchange says no open orders but we still track locals → desync (2nd instance,
698+
// API glitch). Do not phantom-fill, replace with empty, or repair — that triggers
699+
// a burst of replenish orders (see repair_hole after replace_open_orders([])).
700+
if exchange.is_empty() && !local.is_empty() {
701+
warn!(
702+
"open-order sync {symbol}: exchange=0 local={} — skipping sync (desync)",
703+
local.len()
704+
);
705+
let _ = st.storage.record_event(
706+
"order_sync_desync",
707+
&format!("{symbol}: exchange=0 local={} sync_skipped", local.len()),
708+
);
709+
return Ok(replenish);
710+
}
711+
647712
let local_only: Vec<_> = local
648713
.iter()
649714
.filter(|l| !exchange.iter().any(|e| orders_same(l, e)))
650715
.cloned()
651716
.collect();
652717
for phantom in &local_only {
653-
let fill = GridEngine::synthetic_fill_from_order(phantom);
654-
match engine.on_fill(fill.clone()) {
655-
Ok((pnl, intent)) => {
656-
record_fill_ledger(&st.storage, &session_id, &fill, pnl);
657-
warn!(
658-
"phantom order treated as fill {:?} {} @ {}",
659-
fill.side, fill.size, fill.price
660-
);
661-
let _ = st.storage.record_event(
662-
"phantom_fill",
663-
&format!("{:?} {} @ {}", fill.side, fill.size, fill.price),
664-
);
665-
if let Some(i) = intent {
666-
replenish.push(i);
667-
}
668-
}
669-
Err(e) => warn!("phantom fill apply failed: {e}"),
670-
}
718+
// Drop stale local entries only — never simulate fills or replenish from phantoms.
719+
warn!(
720+
"local order missing on exchange (not a fill): {:?} {} @ {}",
721+
phantom.side, phantom.size, phantom.price
722+
);
723+
let _ = st.storage.record_event(
724+
"phantom_drop",
725+
&format!("{:?} {} @ {}", phantom.side, phantom.size, phantom.price),
726+
);
671727
}
672728

673729
let local_after = engine.live_orders().to_vec();
@@ -711,18 +767,27 @@ async fn sync_open_orders_from_exchange(
711767
let mid = engine
712768
.mid_price
713769
.unwrap_or_else(|| (engine.active_bounds().0 + engine.active_bounds().1) / Decimal::from(2));
714-
match engine.repair_hole_intents(mid) {
715-
Ok(extra) if !extra.is_empty() => {
716-
info!(
717-
"repairing {} missing grid level(s); open={} target={}",
718-
extra.len(),
719-
engine.live_orders().len(),
720-
engine.target_resting
721-
);
722-
replenish.extend(extra);
770+
// Only repair when exchange confirmed open orders — never fill the whole grid in one
771+
// burst after a partial desync (local_only non-empty but exchange had some orders).
772+
if local_only.is_empty() {
773+
match engine.repair_hole_intents(mid) {
774+
Ok(extra) if !extra.is_empty() => {
775+
info!(
776+
"repairing {} missing grid level(s); open={} target={}",
777+
extra.len(),
778+
engine.live_orders().len(),
779+
engine.target_resting
780+
);
781+
replenish.extend(extra);
782+
}
783+
Ok(_) => {}
784+
Err(e) => warn!("repair_hole_intents: {e}"),
723785
}
724-
Ok(_) => {}
725-
Err(e) => warn!("repair_hole_intents: {e}"),
786+
} else if !local_only.is_empty() {
787+
warn!(
788+
"open-order sync {symbol}: {} local-only order(s) dropped; skipping repair this tick",
789+
local_only.len()
790+
);
726791
}
727792

728793
if let Some(hl) = st.hl.as_mut() {
@@ -852,7 +917,7 @@ async fn sync_fills_and_position(
852917
symbol: &str,
853918
session_id: &str,
854919
) -> Result<(), String> {
855-
let fills = if st.mode == RunMode::Simulation {
920+
let mut fills = if st.mode == RunMode::Simulation {
856921
st.sim
857922
.as_mut()
858923
.unwrap()
@@ -875,6 +940,7 @@ async fn sync_fills_and_position(
875940
.await
876941
.unwrap_or_default()
877942
};
943+
sort_fills_chronologically(&mut fills);
878944
for fill in fills {
879945
if let Some(engine) = st.engine.as_mut() {
880946
match engine.on_fill(fill.clone()) {
@@ -1132,7 +1198,7 @@ async fn process_fills_and_replenish(
11321198
symbol: &str,
11331199
session_id: &str,
11341200
) -> Result<(), String> {
1135-
let fills = if st.mode == RunMode::Simulation {
1201+
let mut fills = if st.mode == RunMode::Simulation {
11361202
st.sim
11371203
.as_mut()
11381204
.unwrap()
@@ -1158,6 +1224,7 @@ async fn process_fills_and_replenish(
11581224

11591225
let mut replenish_intents = Vec::new();
11601226
let mut risk_exit: Option<String> = None;
1227+
sort_fills_chronologically(&mut fills);
11611228
for fill in fills {
11621229
match st.engine.as_mut().unwrap().on_fill(fill.clone()) {
11631230
Ok((pnl, replenish)) => {

0 commit comments

Comments
 (0)