Phase 1: SQLite persistence layer

- Add modernc.org/sqlite (pure Go, no CGO)
- db/ package: trades, orders, config_log tables + CRUD
- Trade persistence: every closed trade saved to SQLite
- Restart recovery: open positions restored from DB
- Automatic migration on startup
This commit is contained in:
jackyu66git
2026-05-03 17:28:08 +08:00
parent 189fc0d9b6
commit b09314f317
7 changed files with 457 additions and 5 deletions
+95 -2
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"exchange-monitor/db"
"exchange-monitor/exchange"
)
@@ -57,6 +58,7 @@ type Trader struct {
bitget *exchange.BitgetTrade
hyperliquid *exchange.HyperLiquidTrade
db *db.DB
mu sync.Mutex
positions map[string]*ArbPosition // coin -> position
lastTradeTime map[string]time.Time
@@ -79,20 +81,28 @@ type TradeRecord struct {
AmountUSD float64
}
func NewTrader(cfg *Config) *Trader {
func NewTrader(cfg *Config, database *db.DB) *Trader {
var bt *exchange.BitgetTrade
if cfg.BitgetAPIKey != "" {
bt = exchange.NewBitgetTrade(cfg.BitgetAPIKey, cfg.BitgetAPISecret, cfg.BitgetPassphrase)
}
hl, _ := exchange.NewHyperLiquidTrade(cfg.HLPrivateKey, cfg.HLAddress)
return &Trader{
t := &Trader{
cfg: cfg,
db: database,
bitget: bt,
hyperliquid: hl,
positions: make(map[string]*ArbPosition),
lastTradeTime: make(map[string]time.Time),
}
// Restore open positions from DB on restart
if database != nil {
t.restoreOpenPositions()
}
return t
}
func (t *Trader) IsConfigured() bool {
@@ -395,6 +405,11 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
t.closedTrades = append(t.closedTrades, record)
t.mu.Unlock()
// Persist to SQLite
if t.db != nil {
go t.persistTrade(pos, diffPct, convergenceLabel, exitReason, netPnl, longPnl, shortPnl, totalFees)
}
msg := fmt.Sprintf(
"<b>[平仓]</b> %s/USDT %s\n"+
" 持仓: %s 加仓: %d次\n"+
@@ -555,3 +570,81 @@ func (t *Trader) GetClosedTrades() []TradeRecord {
copy(r, t.closedTrades)
return r
}
// persistTrade saves a completed trade to SQLite.
func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, exitReason string, netPnl, longPnl, shortPnl, totalFees float64) {
var entrySpread, fe float64
if pos.LongLeg != nil {
entrySpread = pos.EntrySpread
}
fe = totalFees / 2 // split into entry/exit halves
now := time.Now()
dbTrade := &db.TradeRecord{
Coin: pos.Coin,
Direction: pos.Direction,
Status: "closed",
EntrySpread: &entrySpread,
ExitSpread: &exitSpread,
LongExchange: pos.LongLeg.Exchange,
ShortExchange: pos.ShortLeg.Exchange,
LongEntry: &pos.LongLeg.EntryPrice,
LongExit: &pos.LongLeg.ExitPrice,
ShortEntry: &pos.ShortLeg.EntryPrice,
ShortExit: &pos.ShortLeg.ExitPrice,
LongPnl: &longPnl,
ShortPnl: &shortPnl,
FeeEntry: &fe,
FeeExit: &fe,
NetPnl: &netPnl,
AmountUSD: pos.AmountUSD,
ScaleCount: pos.ScaleLevels,
ExitReason: &exitReason,
Convergence: &convergence,
OpenedAt: pos.StartedAt,
ClosedAt: &now,
}
if _, err := t.db.SaveTrade(dbTrade); err != nil {
log.Printf("[Trader] Failed to save trade to DB: %v", err)
}
}
// restoreOpenPositions loads open trades from DB and recreates their positions.
func (t *Trader) restoreOpenPositions() {
openTrades, err := t.db.GetOpenTrades()
if err != nil {
log.Printf("[Trader] Failed to load open trades: %v", err)
return
}
for i := range openTrades {
tr := &openTrades[i]
// Recreate position structure from DB record
pos := &ArbPosition{
Coin: tr.Coin,
Direction: tr.Direction,
AmountUSD: tr.AmountUSD,
EntrySpread: *tr.EntrySpread,
ScaleLevels: tr.ScaleCount,
StartedAt: tr.OpenedAt,
Status: "open",
}
if tr.LongEntry != nil {
pos.LongLeg = &PositionLeg{
Coin: tr.Coin, Exchange: tr.LongExchange, Side: Long,
EntryPrice: *tr.LongEntry, EntryTime: tr.OpenedAt,
}
}
if tr.ShortEntry != nil {
pos.ShortLeg = &PositionLeg{
Coin: tr.Coin, Exchange: tr.ShortExchange, Side: Short,
EntryPrice: *tr.ShortEntry, EntryTime: tr.OpenedAt,
}
}
t.positions[tr.Coin] = pos
// Prevent immediate re-trading of the same coin
t.lastTradeTime[tr.Coin] = tr.OpenedAt
}
if len(openTrades) > 0 {
log.Printf("[Trader] Restored %d open positions from DB", len(openTrades))
}
}