first commit of restructured project
This commit is contained in:
548
trahn-trade-backend/internal/bot/gridbot.go
Normal file
548
trahn-trade-backend/internal/bot/gridbot.go
Normal file
@@ -0,0 +1,548 @@
|
||||
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
|
||||
}
|
||||
187
trahn-trade-backend/internal/bot/paper_wallet.go
Normal file
187
trahn-trade-backend/internal/bot/paper_wallet.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
)
|
||||
|
||||
type PaperWallet struct {
|
||||
gridRepo *repository.GridStateRepo
|
||||
initialETH float64
|
||||
initialUSDC float64
|
||||
ETHBalance float64
|
||||
USDCBalance float64
|
||||
Trades []PaperTrade
|
||||
TotalGas float64
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
type PaperTrade struct {
|
||||
ID int `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Side string `json:"side"`
|
||||
GridLevel int `json:"gridLevel"`
|
||||
TriggerPrice float64 `json:"triggerPrice"`
|
||||
ExecutionPrice float64 `json:"executionPrice"`
|
||||
ETHAmount float64 `json:"ethAmount"`
|
||||
USDCAmount float64 `json:"usdcAmount"`
|
||||
SlippagePct float64 `json:"slippagePercent"`
|
||||
GasCost float64 `json:"gasCost"`
|
||||
BalanceAfter BalSnap `json:"balanceAfter"`
|
||||
}
|
||||
|
||||
type BalSnap struct {
|
||||
ETH float64 `json:"eth"`
|
||||
USDC float64 `json:"usdc"`
|
||||
}
|
||||
|
||||
func NewPaperWallet(gridRepo *repository.GridStateRepo, initialETH, initialUSDC float64) *PaperWallet {
|
||||
return &PaperWallet{
|
||||
gridRepo: gridRepo,
|
||||
initialETH: initialETH,
|
||||
initialUSDC: initialUSDC,
|
||||
ETHBalance: initialETH,
|
||||
USDCBalance: initialUSDC,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) Init(ctx context.Context) error {
|
||||
state, err := pw.gridRepo.GetPaperWallet(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load paper state: %w", err)
|
||||
}
|
||||
|
||||
if state != nil && state.ETHBalance > 0 {
|
||||
pw.ETHBalance = state.ETHBalance
|
||||
pw.USDCBalance = state.USDCBalance
|
||||
pw.TotalGas = state.TotalGasSpent
|
||||
pw.initialETH = state.InitialETH
|
||||
pw.initialUSDC = state.InitialUSDC
|
||||
if state.StartTime != nil {
|
||||
pw.StartTime = *state.StartTime
|
||||
}
|
||||
if state.Trades != nil {
|
||||
_ = json.Unmarshal(state.Trades, &pw.Trades)
|
||||
}
|
||||
fmt.Printf("[PAPER] Loaded from DB: %.6f ETH, %.2f USDC, %d trades\n",
|
||||
pw.ETHBalance, pw.USDCBalance, len(pw.Trades))
|
||||
} else {
|
||||
fmt.Printf("[PAPER] Starting fresh paper wallet: %.4f ETH, %.2f USDC\n",
|
||||
pw.initialETH, pw.initialUSDC)
|
||||
if err := pw.gridRepo.InitializePaperWallet(ctx, pw.initialETH, pw.initialUSDC); err != nil {
|
||||
return fmt.Errorf("initialize paper wallet: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) save(ctx context.Context) {
|
||||
tradesJSON, _ := json.Marshal(pw.Trades)
|
||||
err := pw.gridRepo.UpdatePaperWallet(ctx, &models.PaperWallet{
|
||||
ETHBalance: pw.ETHBalance,
|
||||
USDCBalance: pw.USDCBalance,
|
||||
TotalGasSpent: pw.TotalGas,
|
||||
Trades: tradesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[PAPER] Failed to save paper state: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) ExecuteBuy(ctx context.Context, usdcAmount, ethAmount float64) error {
|
||||
if pw.USDCBalance < usdcAmount {
|
||||
return fmt.Errorf("insufficient USDC: have %.2f, need %.2f", pw.USDCBalance, usdcAmount)
|
||||
}
|
||||
pw.USDCBalance -= usdcAmount
|
||||
pw.ETHBalance += ethAmount
|
||||
pw.save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) ExecuteSell(ctx context.Context, ethAmount, usdcAmount float64) error {
|
||||
if pw.ETHBalance < ethAmount {
|
||||
return fmt.Errorf("insufficient ETH: have %.6f, need %.6f", pw.ETHBalance, ethAmount)
|
||||
}
|
||||
pw.ETHBalance -= ethAmount
|
||||
pw.USDCBalance += usdcAmount
|
||||
pw.save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) DeductGas(ctx context.Context, gasETH float64) {
|
||||
pw.ETHBalance -= gasETH
|
||||
pw.TotalGas += gasETH
|
||||
pw.save(ctx)
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) RecordTrade(ctx context.Context, t PaperTrade) {
|
||||
t.ID = len(pw.Trades) + 1
|
||||
t.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
t.BalanceAfter = BalSnap{ETH: pw.ETHBalance, USDC: pw.USDCBalance}
|
||||
pw.Trades = append(pw.Trades, t)
|
||||
pw.save(ctx)
|
||||
}
|
||||
|
||||
type PaperStats struct {
|
||||
InitialETH float64
|
||||
InitialUSDC float64
|
||||
CurrentETH float64
|
||||
CurrentUSDC float64
|
||||
InitialValueUSD float64
|
||||
CurrentValueUSD float64
|
||||
UnrealizedPnL float64
|
||||
UnrealizedPnLPct float64
|
||||
TotalTrades int
|
||||
BuyTrades int
|
||||
SellTrades int
|
||||
TotalGasSpent float64
|
||||
GasSpentUSD float64
|
||||
RunningTimeHours float64
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) Stats(currentETHPrice float64) PaperStats {
|
||||
initialVal := pw.initialETH*currentETHPrice + pw.initialUSDC
|
||||
currentVal := pw.ETHBalance*currentETHPrice + pw.USDCBalance
|
||||
pnl := currentVal - initialVal
|
||||
pnlPct := 0.0
|
||||
if initialVal > 0 {
|
||||
pnlPct = pnl / initialVal * 100
|
||||
}
|
||||
buys, sells := 0, 0
|
||||
for _, t := range pw.Trades {
|
||||
if t.Side == "buy" {
|
||||
buys++
|
||||
} else {
|
||||
sells++
|
||||
}
|
||||
}
|
||||
return PaperStats{
|
||||
InitialETH: pw.initialETH,
|
||||
InitialUSDC: pw.initialUSDC,
|
||||
CurrentETH: pw.ETHBalance,
|
||||
CurrentUSDC: pw.USDCBalance,
|
||||
InitialValueUSD: initialVal,
|
||||
CurrentValueUSD: currentVal,
|
||||
UnrealizedPnL: pnl,
|
||||
UnrealizedPnLPct: pnlPct,
|
||||
TotalTrades: len(pw.Trades),
|
||||
BuyTrades: buys,
|
||||
SellTrades: sells,
|
||||
TotalGasSpent: pw.TotalGas,
|
||||
GasSpentUSD: pw.TotalGas * currentETHPrice,
|
||||
RunningTimeHours: time.Since(pw.StartTime).Hours(),
|
||||
}
|
||||
}
|
||||
|
||||
func randomSlippage(maxPct float64) float64 {
|
||||
return rand.Float64() * maxPct / 100
|
||||
}
|
||||
|
||||
const defaultPaperGasCost = 0.005
|
||||
108
trahn-trade-backend/internal/bot/service.go
Normal file
108
trahn-trade-backend/internal/bot/service.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/config"
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/notifications"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/scheduler"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
bot *GridBot
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, cfg *config.Config,
|
||||
priceRepo *repository.PriceRepo,
|
||||
tradeRepo *repository.TradeRepo,
|
||||
gridRepo *repository.GridStateRepo,
|
||||
notify *notifications.Sender,
|
||||
dune *external.DuneClient,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot != nil && s.bot.IsRunning() {
|
||||
fmt.Println("[BOT] Already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
mode := "LIVE MODE"
|
||||
if cfg.PaperTradingEnabled {
|
||||
mode = "PAPER MODE"
|
||||
}
|
||||
notify.Send(fmt.Sprintf("Starting ETH Grid Trader (ETH/%s) - %s", cfg.QuoteTokenSymbol, mode))
|
||||
|
||||
b := NewGridBot(cfg, priceRepo, tradeRepo, gridRepo, notify, dune)
|
||||
if err := b.Init(ctx); err != nil {
|
||||
return fmt.Errorf("bot init: %w", err)
|
||||
}
|
||||
s.bot = b
|
||||
|
||||
fmt.Println("[BOT] Grid trading bot initialized")
|
||||
fmt.Println("[BOT] State loaded from database")
|
||||
|
||||
go func() {
|
||||
b.Run(ctx)
|
||||
fmt.Println("[BOT] Run loop exited")
|
||||
}()
|
||||
|
||||
fmt.Println("[BOT] Started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot != nil {
|
||||
s.bot.Shutdown()
|
||||
s.bot = nil
|
||||
}
|
||||
fmt.Println("[BOT] Stopped")
|
||||
}
|
||||
|
||||
// BotState returns the current bot state for the scheduler's decision-making.
|
||||
func (s *Service) BotState() *scheduler.BotState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot == nil || len(s.bot.Grid) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy grid to avoid data races
|
||||
grid := make([]strategy.GridLevel, len(s.bot.Grid))
|
||||
copy(grid, s.bot.Grid)
|
||||
|
||||
return &scheduler.BotState{
|
||||
Grid: grid,
|
||||
LastETHPrice: s.bot.LastETHPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeGrid triggers a grid recalculation (called by scheduler).
|
||||
func (s *Service) InitializeGrid(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
b := s.bot
|
||||
s.mu.Unlock()
|
||||
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
fmt.Println("[BOT] Recalculating grid with new S/R midpoint...")
|
||||
b.BasePrice = 0 // force recalculation from S/R
|
||||
if err := b.InitializeGrid(ctx); err != nil {
|
||||
fmt.Printf("[BOT] Grid recalculation failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user