Files
trahn-trade/trahn-trade-backend/internal/bot/paper_wallet.go
2026-02-22 15:21:18 -05:00

188 lines
5.0 KiB
Go

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