549 lines
15 KiB
Go
549 lines
15 KiB
Go
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
|
|
}
|