package bot import ( "context" "encoding/json" "fmt" "time" "github.com/kjannette/trahn-backend/internal/config" "github.com/kjannette/trahn-backend/internal/ethereum" "github.com/kjannette/trahn-backend/internal/external" "github.com/kjannette/trahn-backend/internal/models" "github.com/kjannette/trahn-backend/internal/notifications" "github.com/kjannette/trahn-backend/internal/repository" "github.com/kjannette/trahn-backend/internal/risk" "github.com/kjannette/trahn-backend/internal/strategy" ) type GridBot struct { cfg *config.Config coingecko *external.CoinGeckoClient dune *external.DuneClient priceRepo *repository.PriceRepo tradeRepo *repository.TradeRepo gridRepo *repository.GridStateRepo notify *notifications.Sender Grid []strategy.GridLevel LastETHPrice float64 BasePrice float64 TradesExecuted int TotalProfit float64 PriceChecks int LastStatusReport time.Time LastSRRefresh *time.Time guardian *risk.Guardian paperWallet *PaperWallet uniswap *ethereum.UniswapV2 ethClient *ethereum.Client running bool stopCh chan struct{} } func NewGridBot( cfg *config.Config, priceRepo *repository.PriceRepo, tradeRepo *repository.TradeRepo, gridRepo *repository.GridStateRepo, notify *notifications.Sender, dune *external.DuneClient, ) *GridBot { b := &GridBot{ cfg: cfg, coingecko: external.NewCoinGeckoClient(), dune: dune, priceRepo: priceRepo, tradeRepo: tradeRepo, gridRepo: gridRepo, notify: notify, stopCh: make(chan struct{}), guardian: risk.NewGuardian(risk.Limits{ MaxDailyTrades: cfg.MaxDailyTrades, MaxPositionSizeUSD: cfg.MaxPositionSizeUSD, StopLossPercent: cfg.StopLossPercent, TakeProfitPercent: cfg.TakeProfitPercent, }, tradeRepo), } if dune != nil { fmt.Printf("[S/R] Dune Analytics configured: %s method, %d-day lookback\n", cfg.SRMethod, cfg.SRLookbackDays) } else { fmt.Println("[S/R] Dune API key not set - using fallback (current price as midpoint)") } return b } func (b *GridBot) Init(ctx context.Context) error { if err := b.loadState(ctx); err != nil { fmt.Printf("Warning: failed to load state: %v\n", err) } if b.cfg.PaperTradingEnabled { b.paperWallet = NewPaperWallet(b.gridRepo, b.cfg.PaperInitialETH, b.cfg.PaperInitialUSDC) if err := b.paperWallet.Init(ctx); err != nil { return fmt.Errorf("paper wallet init: %w", err) } } else { ethC, err := ethereum.NewClient( b.cfg.EthereumAPIEndpoint, b.cfg.PrivateKey, int64(b.cfg.ChainID), b.cfg.GasLimit, b.cfg.GasMultiplier, ) if err != nil { return fmt.Errorf("ethereum client: %w", err) } b.ethClient = ethC uni, err := ethereum.NewUniswapV2( ethC, b.cfg.UniswapRouterAddress, b.cfg.WETHAddress, b.cfg.QuoteTokenAddress, b.cfg.QuoteTokenSymbol, b.cfg.QuoteTokenDecimals, b.cfg.SlippageTolerance, ) if err != nil { return fmt.Errorf("uniswap client: %w", err) } b.uniswap = uni fmt.Printf("[LIVE] Ethereum client connected, wallet %s\n", ethC.WalletAddress().Hex()) } return nil } // --- state management --- func (b *GridBot) loadState(ctx context.Context) error { state, err := b.gridRepo.GetActive(ctx) if err != nil { return err } if state == nil || state.GridLevelsJSON == nil { fmt.Println("No existing state found in DB - will initialize fresh") return nil } if err := json.Unmarshal(state.GridLevelsJSON, &b.Grid); err != nil { return fmt.Errorf("unmarshal grid: %w", err) } b.TradesExecuted = state.TradesExecuted b.TotalProfit = state.TotalProfit if state.BasePrice != nil { b.BasePrice = *state.BasePrice } b.LastSRRefresh = state.LastSRRefresh fmt.Printf("Loaded state from DB: %d grid levels, %d trades\n", len(b.Grid), b.TradesExecuted) return nil } func (b *GridBot) saveState(ctx context.Context) { levelsJSON, err := json.Marshal(b.Grid) if err != nil { fmt.Printf("[STATE] Failed to marshal grid levels: %v\n", err) return } _, err = b.gridRepo.Save(ctx, &models.GridState{ BasePrice: &b.BasePrice, GridLevelsJSON: levelsJSON, TradesExecuted: b.TradesExecuted, TotalProfit: b.TotalProfit, LastSRRefresh: b.LastSRRefresh, }) if err != nil { fmt.Printf("[STATE] Failed to save state to DB: %v\n", err) } } // --- price --- func (b *GridBot) fetchETHPrice(ctx context.Context) float64 { price, err := b.coingecko.GetETHPrice(ctx) if err != nil { fmt.Printf("Failed to fetch ETH price: %v\n", err) return b.LastETHPrice } if price < 100 || price > 100000 { fmt.Printf("ETH price %.2f failed sanity check\n", price) return b.LastETHPrice } b.LastETHPrice = price _, _ = b.priceRepo.Record(ctx, price, time.Now()) return price } // --- grid initialization --- func (b *GridBot) InitializeGrid(ctx context.Context) error { sr := b.fetchSR(ctx) center := b.BasePrice if center == 0 { center = sr.Midpoint } b.BasePrice = center price := b.fetchETHPrice(ctx) if price <= 0 { return fmt.Errorf("cannot initialize grid: invalid ETH price") } b.notify.Send(fmt.Sprintf("S/R Analysis (%s, %dd): Support $%.2f | Resistance $%.2f | Midpoint $%.2f", sr.Method, sr.LookbackDays, sr.Support, sr.Resistance, sr.Midpoint)) grid, err := strategy.CalculateGridLevels(strategy.GridParams{ CenterPrice: center, LevelCount: b.cfg.GridLevels, SpacingPercent: b.cfg.GridSpacingPercent, AmountPerGrid: b.cfg.AmountPerGrid, }) if err != nil { return fmt.Errorf("calculate grid: %w", err) } b.Grid = grid b.saveState(ctx) b.notify.Send(fmt.Sprintf("Grid initialized: %d levels from $%.2f to $%.2f, center at $%.2f", len(grid), grid[0].Price, grid[len(grid)-1].Price, center)) return nil } func (b *GridBot) fetchSR(ctx context.Context) *external.SRResult { if b.dune == nil { price := b.fetchETHPrice(ctx) fb := strategy.CreateFallbackSR(price) return &external.SRResult{ Support: fb.Support, Resistance: fb.Resistance, Midpoint: fb.Midpoint, Method: fb.Method, } } sr, err := b.dune.FetchSupportResistance(ctx, false) if err != nil { fmt.Printf("[S/R] Dune fetch failed: %v — falling back to current price\n", err) price := b.fetchETHPrice(ctx) fb := strategy.CreateFallbackSR(price) return &external.SRResult{ Support: fb.Support, Resistance: fb.Resistance, Midpoint: fb.Midpoint, Method: fb.Method, } } now := time.Now() b.LastSRRefresh = &now return sr } // --- trading --- func (b *GridBot) executeTrade(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error { tradeUSD := level.Quantity * currentPrice if err := b.guardian.PreTradeCheck(ctx, tradeUSD); err != nil { b.notify.Send(fmt.Sprintf("[RISK] %v", err)) return err } if level.Side == "buy" { return b.executeBuy(ctx, level, currentPrice) } return b.executeSell(ctx, level, currentPrice) } func (b *GridBot) executeBuy(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error { return b.executeSwap(ctx, level, currentPrice, "buy") } func (b *GridBot) executeSell(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error { return b.executeSwap(ctx, level, currentPrice, "sell") } func (b *GridBot) executeSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string) error { ethAmount := level.Quantity usdcAmount := ethAmount * currentPrice prefix := "" if b.cfg.PaperTradingEnabled { prefix = "[PAPER] " } b.notify.Send(fmt.Sprintf("%sExecuting %s at grid level %d: ~%.6f ETH for ~%.2f USDC (@ $%.2f/ETH)", prefix, side, level.Index, ethAmount, usdcAmount, currentPrice)) var txHash string var slippagePct, gasCost *float64 if b.cfg.PaperTradingEnabled { hash, slip, gas, err := b.executePaperSwap(ctx, level, currentPrice, side, ethAmount, usdcAmount) if err != nil { return err } txHash = hash slippagePct = slip gasCost = gas } else { hash, gas, err := b.executeLiveSwap(ctx, side, ethAmount, usdcAmount) if err != nil { return err } txHash = hash gasCost = gas } level.Filled = true now := time.Now() level.FilledAt = &now level.TxHash = &txHash b.TradesExecuted++ b.saveState(ctx) gridLevel := level.Index _, _ = b.tradeRepo.Record(ctx, &models.Trade{ Timestamp: now, Side: side, Price: currentPrice, Quantity: ethAmount, USDValue: usdcAmount, GridLevel: &gridLevel, TxHash: &txHash, IsPaperTrade: b.cfg.PaperTradingEnabled, SlippagePercent: slippagePct, GasCostETH: gasCost, }) b.resetOppositeLevel(ctx, level) return nil } func (b *GridBot) executePaperSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string, ethAmount, usdcAmount float64) (txHash string, slippagePct, gasCost *float64, err error) { slip := randomSlippage(b.cfg.PaperSlippagePercent) gas := defaultPaperGasCost if side == "buy" { actualETH := ethAmount * (1 - slip) if err := b.paperWallet.ExecuteBuy(ctx, usdcAmount, actualETH); err != nil { return "", nil, nil, err } ethAmount = actualETH } else { if b.paperWallet.ETHBalance < ethAmount+gas { return "", nil, nil, fmt.Errorf("insufficient ETH: have %.6f, need %.6f", b.paperWallet.ETHBalance, ethAmount+gas) } usdcAmount = usdcAmount * (1 - slip) if err := b.paperWallet.ExecuteSell(ctx, ethAmount, usdcAmount); err != nil { return "", nil, nil, err } } b.paperWallet.DeductGas(ctx, gas) b.paperWallet.RecordTrade(ctx, PaperTrade{ Side: side, GridLevel: level.Index, TriggerPrice: level.Price, ExecutionPrice: currentPrice, ETHAmount: ethAmount, USDCAmount: usdcAmount, SlippagePct: slip * 100, GasCost: gas, }) txHash = fmt.Sprintf("0xPAPER_%s_%x", side, time.Now().UnixNano()) s := slip * 100 slippagePct = &s gasCost = &gas fmt.Printf("[PAPER] %s executed: %.6f ETH for %.2f USDC (slippage: %.3f%%, gas: %.6f ETH)\n", side, ethAmount, usdcAmount, s, gas) return txHash, slippagePct, gasCost, nil } func (b *GridBot) executeLiveSwap(ctx context.Context, side string, ethAmount, usdcAmount float64) (txHash string, gasCost *float64, err error) { var hash string var swapErr error if side == "buy" { b.notify.Send(fmt.Sprintf("Broadcasting BUY TX: %.6f ETH for %.2f USDC...", ethAmount, usdcAmount)) hash, swapErr = b.uniswap.SwapUSDCForETH(ctx, usdcAmount, ethAmount) } else { b.notify.Send(fmt.Sprintf("Broadcasting SELL TX: %.6f ETH for ~%.2f USDC...", ethAmount, usdcAmount)) hash, swapErr = b.uniswap.SwapETHForUSDC(ctx, ethAmount) } if swapErr != nil { b.notify.Send(fmt.Sprintf("%s TX failed: %v", side, swapErr)) return "", nil, fmt.Errorf("swap failed (%s): %w", side, swapErr) } b.notify.Send(fmt.Sprintf("%s TX confirmed: %s", side, b.uniswap.ExplorerURL(hash))) gas, _ := b.uniswap.GasCostETH(ctx) gasCost = &gas return hash, gasCost, nil } func (b *GridBot) resetOppositeLevel(ctx context.Context, filled *strategy.GridLevel) { idx := strategy.GetOppositeLevelIndex(filled, len(b.Grid)) if idx == nil { return } adj := &b.Grid[*idx] if adj.Filled { adj.Filled = false adj.FilledAt = nil adj.TxHash = nil b.saveState(ctx) fmt.Printf("Reset grid level %d for opposite trade\n", *idx) } } // --- risk --- // portfolioPnLPercent returns the current unrealized P&L as a percentage. // The second return value is false when P&L cannot be determined (e.g. live // mode without balance tracking), in which case the caller should skip the // portfolio-level check. func (b *GridBot) portfolioPnLPercent(currentPrice float64) (float64, bool) { if b.cfg.PaperTradingEnabled && b.paperWallet != nil { return b.paperWallet.Stats(currentPrice).UnrealizedPnLPct, true } return 0, false } // --- main loop --- func (b *GridBot) Run(ctx context.Context) { b.running = true b.notify.Send(fmt.Sprintf("Starting ETH grid trader with %d levels, %.1f%% spacing", b.cfg.GridLevels, b.cfg.GridSpacingPercent)) if len(b.Grid) == 0 { if err := b.InitializeGrid(ctx); err != nil { fmt.Printf("Failed to initialize grid: %v\n", err) return } } display := strategy.FormatGridDisplay(b.Grid, b.BasePrice, b.cfg.AmountPerGrid) fmt.Println("\n" + display + "\n") ticker := time.NewTicker(time.Duration(b.cfg.PriceCheckIntervalSeconds) * time.Second) defer ticker.Stop() // Do one immediate tick b.tick(ctx) for { select { case <-b.stopCh: b.running = false b.notify.Send("Grid trader shutting down") return case <-ctx.Done(): b.running = false return case <-ticker.C: b.tick(ctx) } } } func (b *GridBot) tick(ctx context.Context) { b.PriceChecks++ price := b.fetchETHPrice(ctx) if price <= 0 { fmt.Println("Could not fetch ETH price, skipping tick") return } if pnl, ok := b.portfolioPnLPercent(price); ok { if err := b.guardian.PortfolioCheck(pnl); err != nil { b.notify.Send(fmt.Sprintf("CIRCUIT BREAKER: %v — halting trading", err)) fmt.Printf("[RISK] %v\n", err) close(b.stopCh) b.running = false return } } triggered := strategy.FindTriggeredLevel(price, b.Grid) if triggered != nil { fmt.Printf("Grid level %d triggered: %s ETH at $%.2f\n", triggered.Index, triggered.Side, triggered.Price) if err := b.executeTrade(ctx, triggered, price); err != nil { fmt.Printf("Trade execution failed: %v\n", err) } else { // Post-trade cooldown select { case <-time.After(time.Duration(b.cfg.PostTradeCooldownSeconds) * time.Second): case <-b.stopCh: return } } } b.maybeReportStatus(ctx, price) } func (b *GridBot) maybeReportStatus(ctx context.Context, currentPrice float64) { interval := time.Duration(b.cfg.StatusReportIntervalMinutes) * time.Minute if time.Since(b.LastStatusReport) < interval { return } stats := strategy.GetGridStats(b.Grid) prefix := "" if b.cfg.PaperTradingEnabled { prefix = "[PAPER] " } var ethBal, usdcBal float64 if b.cfg.PaperTradingEnabled && b.paperWallet != nil { ethBal = b.paperWallet.ETHBalance usdcBal = b.paperWallet.USDCBalance } else if b.uniswap != nil { ethBal, _ = b.uniswap.ETHBalance(ctx) usdcBal, _ = b.uniswap.TokenBalance(ctx) } b.notify.Send(fmt.Sprintf( "%sStatus: ETH @ $%.2f | ETH: %.4f ($%.2f) | USDC: %.2f | Grid: %d/%d buys, %d/%d sells | Checks: %d | Trades: %d", prefix, currentPrice, ethBal, ethBal*currentPrice, usdcBal, stats.FilledBuys, stats.FilledBuys+stats.PendingBuys, stats.FilledSells, stats.FilledSells+stats.PendingSells, b.PriceChecks, b.TradesExecuted, )) if b.cfg.PaperTradingEnabled && b.paperWallet != nil { ps := b.paperWallet.Stats(currentPrice) sign := "+" if ps.UnrealizedPnL < 0 { sign = "" } b.notify.Send(fmt.Sprintf( "[PAPER P&L] Initial: $%.2f -> Current: $%.2f | P&L: %s$%.2f (%s%.2f%%) | Gas: %.6f ETH ($%.2f) | Running: %.1fh", ps.InitialValueUSD, ps.CurrentValueUSD, sign, ps.UnrealizedPnL, sign, ps.UnrealizedPnLPct, ps.TotalGasSpent, ps.GasSpentUSD, ps.RunningTimeHours, )) } b.LastStatusReport = time.Now() } func (b *GridBot) Shutdown() { if b.running { close(b.stopCh) } if b.ethClient != nil { b.ethClient.Close() } fmt.Println("[BOT] Shutting down gracefully") } func (b *GridBot) IsRunning() bool { return b.running }