fix: persist positions to DB before goroutine so restarts don't lose open trades
This commit is contained in:
+8
-2
@@ -105,14 +105,20 @@ func (d *DB) UpdateTradeStatus(id int64, t *TradeRecord) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenTrades returns all trades with status='open'.
|
// SetTradeStatus updates only the status field of a trade.
|
||||||
|
func (d *DB) SetTradeStatus(id int64, status string) error {
|
||||||
|
_, err := d.Exec("UPDATE trades SET status=? WHERE id=?", status, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOpenTrades returns all non-closed trades (status='open' or status='entering').
|
||||||
func (d *DB) GetOpenTrades() ([]TradeRecord, error) {
|
func (d *DB) GetOpenTrades() ([]TradeRecord, error) {
|
||||||
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||||
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||||
FROM trades WHERE status='open'`)
|
FROM trades WHERE status IN ('open','entering')`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,9 +471,32 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
|
|||||||
t.entering[opp.Coin] = true
|
t.entering[opp.Coin] = true
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Persist an "entering" record to DB BEFORE the goroutine,
|
||||||
|
// so even if the process is killed mid-entry, the position survives restart.
|
||||||
|
var pendingTradeID int64
|
||||||
|
if t.db != nil {
|
||||||
|
now := time.Now()
|
||||||
|
entrySpread := opp.NetProfit
|
||||||
|
dbTrade := &db.TradeRecord{
|
||||||
|
Coin: opp.Coin,
|
||||||
|
Direction: opp.Direction,
|
||||||
|
Status: "entering",
|
||||||
|
EntrySpread: &entrySpread,
|
||||||
|
LongExchange: opp.BuyEx,
|
||||||
|
ShortExchange: opp.SellEx,
|
||||||
|
AmountUSD: t.cfg.TradeAmountUSD,
|
||||||
|
OpenedAt: now,
|
||||||
|
}
|
||||||
|
if id, err := t.db.SaveTrade(dbTrade); err == nil {
|
||||||
|
pendingTradeID = id
|
||||||
|
} else {
|
||||||
|
log.Printf("[Trader] Failed to save entering trade for %s: %v", opp.Coin, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Async goroutine — placeOrder calls (REST or mock) don't block the main loop
|
// Async goroutine — placeOrder calls (REST or mock) don't block the main loop
|
||||||
go func() {
|
go func() {
|
||||||
t.executeEntry(opp, store, notifier)
|
t.executeEntry(opp, store, notifier, pendingTradeID)
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
delete(t.entering, opp.Coin)
|
delete(t.entering, opp.Coin)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -484,7 +507,7 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
|
|||||||
// executeEntry places both legs using the scan-time prices from ArbOpportunity.
|
// executeEntry places both legs using the scan-time prices from ArbOpportunity.
|
||||||
// Synchronous — runs in the scanner tick to avoid WS price movement between
|
// Synchronous — runs in the scanner tick to avoid WS price movement between
|
||||||
// detection and execution.
|
// detection and execution.
|
||||||
func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *Notifier) bool {
|
func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *Notifier, pendingTradeID int64) bool {
|
||||||
// Use scan-time prices directly to avoid WS jitter killing the entry
|
// Use scan-time prices directly to avoid WS jitter killing the entry
|
||||||
bgP, hlP := opp.BuyPrice, opp.SellPrice
|
bgP, hlP := opp.BuyPrice, opp.SellPrice
|
||||||
if opp.BuyEx == ExHyperLiquid {
|
if opp.BuyEx == ExHyperLiquid {
|
||||||
@@ -586,6 +609,14 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
|||||||
|
|
||||||
// Persist entry to DB immediately (incremental — not batch at close)
|
// Persist entry to DB immediately (incremental — not batch at close)
|
||||||
if t.db != nil {
|
if t.db != nil {
|
||||||
|
if pendingTradeID > 0 {
|
||||||
|
// Trade was already saved with "entering" status — update to "open"
|
||||||
|
if err := t.db.SetTradeStatus(pendingTradeID, "open"); err == nil {
|
||||||
|
pos.DBTradeID = pendingTradeID
|
||||||
|
log.Printf("[Trader] %s: DB status entering->open (id=%d)", pos.Coin, pendingTradeID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: no pending record, insert fresh
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
status := "filled"
|
status := "filled"
|
||||||
tradeUnit := t.cfg.TradeAmountUSD
|
tradeUnit := t.cfg.TradeAmountUSD
|
||||||
@@ -637,6 +668,7 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[Trader] %s: Opened %s | Long %s @ %.2f Short %s @ %.2f | $%.0f",
|
log.Printf("[Trader] %s: Opened %s | Long %s @ %.2f Short %s @ %.2f | $%.0f",
|
||||||
pos.Coin, pos.Direction, pos.LongLeg.Exchange, pos.LongLeg.EntryPrice,
|
pos.Coin, pos.Direction, pos.LongLeg.Exchange, pos.LongLeg.EntryPrice,
|
||||||
@@ -1440,6 +1472,12 @@ func (t *Trader) restoreOpenPositions() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
tr := &openTrades[i]
|
tr := &openTrades[i]
|
||||||
|
// Skip "entering" trades — process was killed mid-entry, orders not confirmed
|
||||||
|
if tr.Status == "entering" {
|
||||||
|
log.Printf("[Trader] Skipping incomplete trade %d (%s status='entering'), marking as failed", tr.ID, tr.Coin)
|
||||||
|
t.db.SetTradeStatus(tr.ID, "failed")
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Recreate position structure from DB record
|
// Recreate position structure from DB record
|
||||||
pos := &ArbPosition{
|
pos := &ArbPosition{
|
||||||
Coin: tr.Coin,
|
Coin: tr.Coin,
|
||||||
|
|||||||
Reference in New Issue
Block a user