first commit of restructured project

This commit is contained in:
KS Jannette
2026-02-22 15:21:18 -05:00
commit 9fca234606
75 changed files with 8299 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
)
type gridCurrentResponse struct {
BasePrice *float64 `json:"basePrice"`
Grid json.RawMessage `json:"grid"`
TradesExecuted int `json:"tradesExecuted"`
TotalProfit float64 `json:"totalProfit"`
LastUpdate *string `json:"lastUpdate,omitempty"`
}
func (s *Server) handleGridCurrent(w http.ResponseWriter, r *http.Request) {
state, err := s.gridRepo.GetActive(r.Context())
if err != nil {
fmt.Printf("Error fetching grid state: %v\n", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if state == nil {
writeJSON(w, http.StatusOK, gridCurrentResponse{
Grid: json.RawMessage("[]"),
})
return
}
grid := state.GridLevelsJSON
if grid == nil {
grid = json.RawMessage("[]")
}
var lastUpdate *string
ts := state.UpdatedAt.Format("2006-01-02T15:04:05.000Z")
lastUpdate = &ts
writeJSON(w, http.StatusOK, gridCurrentResponse{
BasePrice: state.BasePrice,
Grid: grid,
TradesExecuted: state.TradesExecuted,
TotalProfit: state.TotalProfit,
LastUpdate: lastUpdate,
})
}

View File

@@ -0,0 +1,29 @@
package api
import (
"net/http"
"time"
)
type healthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Services healthServices `json:"services"`
}
type healthServices struct {
Database string `json:"database"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
dbStatus := "connected"
if err := s.pool.Ping(r.Context()); err != nil {
dbStatus = "disconnected"
}
writeJSON(w, http.StatusOK, healthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Services: healthServices{Database: dbStatus},
})
}

View File

@@ -0,0 +1,187 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthMiddleware_NoKeyConfigured(t *testing.T) {
s := &Server{apiKey: ""}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/trades/stats", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 when no API key configured, got %d", rr.Code)
}
}
func TestAuthMiddleware_HealthBypass(t *testing.T) {
s := &Server{apiKey: "secret123"}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for /health without auth, got %d", rr.Code)
}
}
func TestAuthMiddleware_MissingHeader(t *testing.T) {
s := &Server{apiKey: "secret123"}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAuthMiddleware_WrongKey(t *testing.T) {
s := &Server{apiKey: "secret123"}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
req.Header.Set("Authorization", "Bearer wrong_key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAuthMiddleware_CorrectKey(t *testing.T) {
s := &Server{apiKey: "secret123"}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
req.Header.Set("Authorization", "Bearer secret123")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestAuthMiddleware_MalformedBearer(t *testing.T) {
s := &Server{apiKey: "secret123"}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := s.authMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
req.Header.Set("Authorization", "Basic secret123")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for non-Bearer auth, got %d", rr.Code)
}
}
func TestValidateDate(t *testing.T) {
valid := []string{"2024-01-15", "2025-12-31", "2020-02-29"}
for _, d := range valid {
if !validateDate(d) {
t.Fatalf("expected %q to be valid", d)
}
}
invalid := []string{
"", "2024", "01-15-2024", "2024/01/15",
"abcd-ef-gh", "2024-13-01", "2024-01-32",
"2024-1-5", "20240115",
}
for _, d := range invalid {
if validateDate(d) {
t.Fatalf("expected %q to be invalid", d)
}
}
}
func TestParseLimit(t *testing.T) {
cases := []struct {
query string
deflt int
expected int
}{
{"", 100, 100},
{"?limit=50", 100, 50},
{"?limit=0", 100, 100},
{"?limit=-5", 100, 100},
{"?limit=abc", 100, 100},
{"?limit=2000", 100, maxQueryLimit},
{"?limit=1000", 100, 1000},
{"?limit=1", 50, 1},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, "/test"+tc.query, nil)
got := parseLimit(req, tc.deflt)
if got != tc.expected {
t.Fatalf("parseLimit(%q, %d) = %d, want %d", tc.query, tc.deflt, got, tc.expected)
}
}
}
func TestCorsMiddleware_Headers(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(inner, "https://myapp.example.com")
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
origin := rr.Header().Get("Access-Control-Allow-Origin")
if origin != "https://myapp.example.com" {
t.Fatalf("expected custom origin, got %q", origin)
}
allow := rr.Header().Get("Access-Control-Allow-Headers")
if allow == "" {
t.Fatal("expected Allow-Headers to include Authorization")
}
}
func TestCorsMiddleware_Preflight(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("inner handler should not be called for OPTIONS")
})
handler := corsMiddleware(inner, "*")
req := httptest.NewRequest(http.MethodOptions, "/v1/prices/latest", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for preflight, got %d", rr.Code)
}
}

View File

@@ -0,0 +1,79 @@
package api
import (
"fmt"
"net/http"
"github.com/kjannette/trahn-backend/internal/repository"
)
type priceJSON struct {
T int64 `json:"t"`
P float64 `json:"p"`
}
func (s *Server) handlePricesToday(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
today := repository.TradingDayNow()
prices, err := s.priceRepo.GetByDay(ctx, today)
if err != nil {
fmt.Printf("Error fetching today's prices: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch prices")
return
}
out := make([]priceJSON, len(prices))
for i, p := range prices {
out[i] = priceJSON{T: p.Timestamp.UnixMilli(), P: p.Price}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handlePricesByDay(w http.ResponseWriter, r *http.Request) {
date := r.PathValue("date")
if !validateDate(date) {
writeError(w, http.StatusBadRequest, "invalid date format, expected YYYY-MM-DD")
return
}
ctx := r.Context()
prices, err := s.priceRepo.GetByDay(ctx, date)
if err != nil {
fmt.Printf("Error fetching prices for %s: %v\n", date, err)
writeError(w, http.StatusInternalServerError, "failed to fetch prices")
return
}
out := make([]priceJSON, len(prices))
for i, p := range prices {
out[i] = priceJSON{T: p.Timestamp.UnixMilli(), P: p.Price}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleAvailableDays(w http.ResponseWriter, r *http.Request) {
days, err := s.priceRepo.GetAvailableDays(r.Context())
if err != nil {
fmt.Printf("Error fetching available days: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch available days")
return
}
if days == nil {
days = []string{}
}
writeJSON(w, http.StatusOK, days)
}
func (s *Server) handleLatestPrice(w http.ResponseWriter, r *http.Request) {
price, err := s.priceRepo.GetLatest(r.Context())
if err != nil {
fmt.Printf("Error fetching latest price: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch latest price")
return
}
if price == nil {
writeError(w, http.StatusNotFound, "no price data available")
return
}
writeJSON(w, http.StatusOK, priceJSON{T: price.Timestamp.UnixMilli(), P: price.Price})
}

View File

@@ -0,0 +1,169 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/trahn-backend/internal/repository"
)
const maxQueryLimit = 1000
var dateRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
type Server struct {
pool *pgxpool.Pool
priceRepo *repository.PriceRepo
tradeRepo *repository.TradeRepo
srRepo *repository.SRRepo
gridRepo *repository.GridStateRepo
httpServer *http.Server
apiKey string
}
func NewServer(pool *pgxpool.Pool, port int, apiKey, corsOrigin string) *Server {
s := &Server{
pool: pool,
priceRepo: repository.NewPriceRepo(pool),
tradeRepo: repository.NewTradeRepo(pool),
srRepo: repository.NewSRRepo(pool),
gridRepo: repository.NewGridStateRepo(pool),
apiKey: apiKey,
}
mux := http.NewServeMux()
// Price routes
mux.HandleFunc("GET /v1/prices/today", s.handlePricesToday)
mux.HandleFunc("GET /v1/prices/day/{date}", s.handlePricesByDay)
mux.HandleFunc("GET /v1/prices/days", s.handleAvailableDays)
mux.HandleFunc("GET /v1/prices/latest", s.handleLatestPrice)
// Trade routes
mux.HandleFunc("GET /v1/trades/today", s.handleTradesToday)
mux.HandleFunc("GET /v1/trades/day/{date}", s.handleTradesByDay)
mux.HandleFunc("GET /v1/trades/all", s.handleAllTrades)
mux.HandleFunc("GET /v1/trades/stats", s.handleTradeStats)
// Grid routes
mux.HandleFunc("GET /v1/grid/current", s.handleGridCurrent)
// S/R routes
mux.HandleFunc("GET /v1/support-resistance/latest", s.handleSRLatest)
mux.HandleFunc("GET /v1/support-resistance/history", s.handleSRHistory)
// Health check (no auth required)
mux.HandleFunc("GET /health", s.handleHealth)
handler := s.authMiddleware(corsMiddleware(mux, corsOrigin))
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
return s
}
func (s *Server) Start() error {
fmt.Printf("[API] REST API server started on http://localhost%s\n", s.httpServer.Addr)
fmt.Printf("[API] Health check: http://localhost%s/health\n", s.httpServer.Addr)
if s.apiKey != "" {
fmt.Println("[API] Authentication: enabled (Bearer token)")
} else {
fmt.Println("[API] Authentication: disabled (no API_KEY configured)")
}
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
// --- middleware ---
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.apiKey == "" || r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
if auth == "" {
writeError(w, http.StatusUnauthorized, "missing Authorization header")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == auth || token != s.apiKey {
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
next.ServeHTTP(w, r)
})
}
func corsMiddleware(next http.Handler, allowOrigin string) http.Handler {
if allowOrigin == "" {
allowOrigin = "*"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// --- validation helpers ---
func validateDate(date string) bool {
if !dateRegexp.MatchString(date) {
return false
}
_, err := time.Parse("2006-01-02", date)
return err == nil
}
func parseLimit(r *http.Request, defaultLimit int) int {
v := r.URL.Query().Get("limit")
if v == "" {
return defaultLimit
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return defaultLimit
}
if n > maxQueryLimit {
return maxQueryLimit
}
return n
}
// --- response helpers ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

View File

@@ -0,0 +1,52 @@
package api
import (
"fmt"
"net/http"
"time"
)
type srLatestResponse struct {
Support float64 `json:"support"`
Resistance float64 `json:"resistance"`
Midpoint float64 `json:"midpoint"`
AvgPrice *float64 `json:"avgPrice"`
Method string `json:"method"`
LookbackDays int `json:"lookbackDays"`
Timestamp time.Time `json:"timestamp"`
}
func (s *Server) handleSRLatest(w http.ResponseWriter, r *http.Request) {
sr, err := s.srRepo.GetLatest(r.Context())
if err != nil {
fmt.Printf("Error fetching latest S/R: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch S/R data")
return
}
if sr == nil {
writeError(w, http.StatusNotFound, "no S/R data available")
return
}
writeJSON(w, http.StatusOK, srLatestResponse{
Support: sr.Support,
Resistance: sr.Resistance,
Midpoint: sr.Midpoint,
AvgPrice: sr.AvgPrice,
Method: sr.Method,
LookbackDays: sr.LookbackDays,
Timestamp: sr.Timestamp,
})
}
func (s *Server) handleSRHistory(w http.ResponseWriter, r *http.Request) {
limit := parseLimit(r, 100)
history, err := s.srRepo.GetHistory(r.Context(), limit)
if err != nil {
fmt.Printf("Error fetching S/R history: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch S/R history")
return
}
writeJSON(w, http.StatusOK, history)
}

View File

@@ -0,0 +1,132 @@
package api
import (
"fmt"
"net/http"
"github.com/kjannette/trahn-backend/internal/repository"
)
type tradeJSON struct {
T int64 `json:"t"`
Side string `json:"side"`
Price float64 `json:"price"`
Qty float64 `json:"qty"`
GridLevel *int `json:"gridLevel,omitempty"`
USDValue float64 `json:"usdValue"`
IsPaperTrade bool `json:"isPaperTrade"`
}
// parseTradeMode extracts the ?mode= query parameter.
// Returns a *bool: nil = all, true = paper, false = live.
func parseTradeMode(r *http.Request) (*bool, error) {
v := r.URL.Query().Get("mode")
switch v {
case "", "all":
return nil, nil
case "paper":
b := true
return &b, nil
case "live":
b := false
return &b, nil
default:
return nil, fmt.Errorf("invalid mode %q, expected paper|live|all", v)
}
}
func (s *Server) handleTradesToday(w http.ResponseWriter, r *http.Request) {
mode, err := parseTradeMode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
today := repository.TradingDayNow()
trades, err := s.tradeRepo.GetByDay(ctx, today, mode)
if err != nil {
fmt.Printf("Error fetching today's trades: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch trades")
return
}
out := make([]tradeJSON, len(trades))
for i, t := range trades {
out[i] = tradeJSON{
T: t.Timestamp.UnixMilli(), Side: t.Side,
Price: t.Price, Qty: t.Quantity,
GridLevel: t.GridLevel, USDValue: t.USDValue,
IsPaperTrade: t.IsPaperTrade,
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleTradesByDay(w http.ResponseWriter, r *http.Request) {
date := r.PathValue("date")
if !validateDate(date) {
writeError(w, http.StatusBadRequest, "invalid date format, expected YYYY-MM-DD")
return
}
mode, err := parseTradeMode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
trades, err := s.tradeRepo.GetByDay(ctx, date, mode)
if err != nil {
fmt.Printf("Error fetching trades for %s: %v\n", date, err)
writeError(w, http.StatusInternalServerError, "failed to fetch trades")
return
}
out := make([]tradeJSON, len(trades))
for i, t := range trades {
out[i] = tradeJSON{
T: t.Timestamp.UnixMilli(), Side: t.Side,
Price: t.Price, Qty: t.Quantity,
GridLevel: t.GridLevel, USDValue: t.USDValue,
IsPaperTrade: t.IsPaperTrade,
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleAllTrades(w http.ResponseWriter, r *http.Request) {
limit := parseLimit(r, 100)
mode, err := parseTradeMode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
trades, err := s.tradeRepo.GetAll(r.Context(), limit, mode)
if err != nil {
fmt.Printf("Error fetching all trades: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch trades")
return
}
writeJSON(w, http.StatusOK, trades)
}
func (s *Server) handleTradeStats(w http.ResponseWriter, r *http.Request) {
mode, err := parseTradeMode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
stats, err := s.tradeRepo.GetStats(r.Context(), mode)
if err != nil {
fmt.Printf("Error fetching trade stats: %v\n", err)
writeError(w, http.StatusInternalServerError, "failed to fetch trade stats")
return
}
writeJSON(w, http.StatusOK, stats)
}

View 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
}

View 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

View 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)
}
}

View File

@@ -0,0 +1,259 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
// Secrets (from .env)
DuneAPIKey string
WalletAddress string
PrivateKey string
EthereumAPIEndpoint string
WebhookURL string
BotName string
APIKey string
CORSAllowOrigin string
// Database
DBHost string
DBPort int
DBName string
DBUser string
DBPassword string
// Blockchain
ChainID int
QuoteTokenAddress string
QuoteTokenSymbol string
QuoteTokenDecimals int
WETHAddress string
UniswapRouterAddress string
// Support/Resistance
SRMethod string
SRRefreshHours int
SRLookbackDays int
// Risk Management
MaxDailyTrades int
MaxPositionSizeUSD float64
StopLossPercent float64
TakeProfitPercent float64
// Paper Trading
PaperTradingEnabled bool
PaperInitialETH float64
PaperInitialUSDC float64
PaperSlippagePercent float64
PaperSimulateGas bool
// Grid Configuration
GridLevels int
GridSpacingPercent float64
GridBasePrice float64
AmountPerGrid float64
// Trading Parameters
SlippageTolerance float64
GasMultiplier float64
MinProfitPercent float64
GasLimit int
// Timing
PriceCheckIntervalSeconds int
StatusReportIntervalMinutes int
PostTradeCooldownSeconds int
}
func Load() (*Config, error) {
_ = godotenv.Load()
cfg := &Config{
// Secrets
DuneAPIKey: envStr("DUNE_API_KEY", ""),
WalletAddress: envStr("WALLET_ADDRESS", ""),
PrivateKey: envStr("PRIVATE_KEY", ""),
EthereumAPIEndpoint: envStr("ETHEREUM_API_ENDPOINT", ""),
WebhookURL: envStr("WEBHOOK_URL", ""),
BotName: envStr("BOT_NAME", "TrahnGridTrader"),
APIKey: envStr("API_KEY", ""),
CORSAllowOrigin: envStr("CORS_ALLOW_ORIGIN", "*"),
// Database
DBHost: envStr("DB_HOST", "localhost"),
DBPort: envInt("DB_PORT", 5432),
DBName: envStr("DB_NAME", "trahn_grid_trader"),
DBUser: envStr("DB_USER", ""),
DBPassword: envStr("DB_PASSWORD", ""),
// Blockchain
ChainID: envInt("CHAIN_ID", 1),
QuoteTokenAddress: envStr("QUOTE_TOKEN_ADDRESS", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
QuoteTokenSymbol: envStr("QUOTE_TOKEN_SYMBOL", "USDC"),
QuoteTokenDecimals: envInt("QUOTE_TOKEN_DECIMALS", 6),
WETHAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
UniswapRouterAddress: "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
// Support/Resistance
SRMethod: envStr("SR_METHOD", "simple"),
SRRefreshHours: envInt("SR_REFRESH_HOURS", 48),
SRLookbackDays: envInt("SR_LOOKBACK_DAYS", 14),
// Risk Management
MaxDailyTrades: envInt("MAX_DAILY_TRADES", 50),
MaxPositionSizeUSD: envFloat("MAX_POSITION_SIZE_USD", 10000),
StopLossPercent: envFloat("STOP_LOSS_PERCENT", 0),
TakeProfitPercent: envFloat("TAKE_PROFIT_PERCENT", 0),
// Paper Trading
PaperTradingEnabled: envBool("PAPER_TRADING_ENABLED", true),
PaperInitialETH: envFloat("PAPER_INITIAL_ETH", 1.0),
PaperInitialUSDC: envFloat("PAPER_INITIAL_USDC", 1000),
PaperSlippagePercent: envFloat("PAPER_SLIPPAGE_PERCENT", 0.5),
PaperSimulateGas: envBool("PAPER_SIMULATE_GAS", true),
// Grid
GridLevels: envInt("GRID_LEVELS", 10),
GridSpacingPercent: envFloat("GRID_SPACING_PERCENT", 2),
GridBasePrice: envFloat("GRID_BASE_PRICE", 0),
AmountPerGrid: envFloat("AMOUNT_PER_GRID", 100),
// Trading Parameters
SlippageTolerance: envFloat("SLIPPAGE_TOLERANCE", 1.5),
GasMultiplier: envFloat("GAS_MULTIPLIER", 1.2),
MinProfitPercent: envFloat("MIN_PROFIT_PERCENT", 0.5),
GasLimit: envInt("GAS_LIMIT", 250000),
// Timing
PriceCheckIntervalSeconds: envInt("PRICE_CHECK_INTERVAL_SECONDS", 30),
StatusReportIntervalMinutes: envInt("STATUS_REPORT_INTERVAL_MINUTES", 60),
PostTradeCooldownSeconds: envInt("POST_TRADE_COOLDOWN_SECONDS", 60),
}
return cfg, nil
}
func (c *Config) Validate() error {
var errs []string
if c.WalletAddress == "" {
errs = append(errs, "WALLET_ADDRESS is required")
}
if !c.PaperTradingEnabled && c.PrivateKey == "" {
errs = append(errs, "PRIVATE_KEY is required for live trading")
}
if c.DuneAPIKey == "" {
fmt.Println("[WARN] DUNE_API_KEY not set — will use current price for grid center (fallback mode)")
}
if c.StopLossPercent == 0 && c.TakeProfitPercent == 0 {
fmt.Println("[WARN] STOP_LOSS_PERCENT and TAKE_PROFIT_PERCENT are both 0 — no portfolio circuit breakers active")
}
if c.MaxDailyTrades == 0 && c.MaxPositionSizeUSD == 0 {
fmt.Println("[WARN] MAX_DAILY_TRADES and MAX_POSITION_SIZE_USD are both 0 — no per-trade limits active")
}
if c.APIKey == "" {
fmt.Println("[WARN] API_KEY not set — REST API has no authentication")
}
if len(errs) > 0 {
return fmt.Errorf("config validation failed:\n %s", strings.Join(errs, "\n "))
}
return nil
}
func (c *Config) Print() {
fmt.Println("=== ETH Grid Trading Bot Configuration ===")
if c.PaperTradingEnabled {
fmt.Println("════════════════════════════════════════")
fmt.Println(" PAPER TRADING MODE ENABLED")
fmt.Println(" No real transactions will execute")
fmt.Println("════════════════════════════════════════")
fmt.Printf("Paper Initial ETH: %.4f\n", c.PaperInitialETH)
fmt.Printf("Paper Initial %s: %.2f\n", c.QuoteTokenSymbol, c.PaperInitialUSDC)
fmt.Printf("Paper Slippage: 0-%.1f%%\n", c.PaperSlippagePercent)
fmt.Printf("Paper Gas Simulation: %v\n", c.PaperSimulateGas)
} else {
fmt.Println(" LIVE TRADING MODE")
}
fmt.Println("--------------------------------------")
fmt.Printf("Chain ID: %d\n", c.ChainID)
if len(c.WalletAddress) > 16 {
fmt.Printf("Wallet: %s...%s\n", c.WalletAddress[:10], c.WalletAddress[len(c.WalletAddress)-6:])
}
fmt.Printf("Trading Pair: ETH/%s\n", c.QuoteTokenSymbol)
fmt.Printf("Quote Token: %s (%s...)\n", c.QuoteTokenSymbol, truncAddr(c.QuoteTokenAddress))
fmt.Println("--------------------------------------")
fmt.Println("Grid Configuration:")
fmt.Printf(" Levels: %d\n", c.GridLevels)
fmt.Printf(" Spacing: %.1f%%\n", c.GridSpacingPercent)
fmt.Printf(" Amount/Grid: $%.0f\n", c.AmountPerGrid)
fmt.Println("--------------------------------------")
fmt.Println("Support/Resistance Configuration:")
fmt.Printf(" S/R Method: %s\n", c.SRMethod)
fmt.Printf(" S/R Refresh: every %d hours\n", c.SRRefreshHours)
fmt.Printf(" S/R Lookback: %d days\n", c.SRLookbackDays)
fmt.Printf(" Dune API: %s\n", boolLabel(c.DuneAPIKey != "", "configured", "not set (fallback mode)"))
fmt.Println("======================================")
}
func (c *Config) DSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName)
}
// --- helpers ---
func envStr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func envFloat(key string, fallback float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
v = strings.ToLower(v)
return v == "true" || v == "1" || v == "yes"
}
return fallback
}
func truncAddr(addr string) string {
if len(addr) > 10 {
return addr[:10]
}
return addr
}
func boolLabel(cond bool, ifTrue, ifFalse string) string {
if cond {
return ifTrue
}
return ifFalse
}

View File

@@ -0,0 +1,49 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
cfg.MaxConnIdleTime = 30 * time.Second
cfg.MaxConnLifetime = 5 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := p.Ping(ctx); err != nil {
p.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return p, nil
}
func TestConnection(p *pgxpool.Pool) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var now time.Time
err := p.QueryRow(ctx, "SELECT NOW()").Scan(&now)
if err != nil {
return fmt.Errorf("test query: %w", err)
}
fmt.Printf("[DB] Connection successful at %s\n", now.Format(time.RFC3339))
return nil
}

View File

@@ -0,0 +1,74 @@
package ethereum
import (
"io"
"strings"
)
// Minimal ABIs for Uniswap V2 Router02 and ERC20 — only the methods we call.
func mustRouterABI() io.Reader {
return strings.NewReader(`[
{
"name": "swapExactTokensForETH",
"type": "function",
"stateMutability": "nonpayable",
"inputs": [
{"name": "amountIn", "type": "uint256"},
{"name": "amountOutMin", "type": "uint256"},
{"name": "path", "type": "address[]"},
{"name": "to", "type": "address"},
{"name": "deadline", "type": "uint256"}
],
"outputs": [
{"name": "amounts", "type": "uint256[]"}
]
},
{
"name": "swapExactETHForTokens",
"type": "function",
"stateMutability": "payable",
"inputs": [
{"name": "amountOutMin", "type": "uint256"},
{"name": "path", "type": "address[]"},
{"name": "to", "type": "address"},
{"name": "deadline", "type": "uint256"}
],
"outputs": [
{"name": "amounts", "type": "uint256[]"}
]
}
]`)
}
func mustERC20ABI() io.Reader {
return strings.NewReader(`[
{
"name": "balanceOf",
"type": "function",
"stateMutability": "view",
"inputs": [{"name": "_owner", "type": "address"}],
"outputs": [{"name": "balance", "type": "uint256"}]
},
{
"name": "allowance",
"type": "function",
"stateMutability": "view",
"inputs": [
{"name": "_owner", "type": "address"},
{"name": "_spender", "type": "address"}
],
"outputs": [{"name": "", "type": "uint256"}]
},
{
"name": "approve",
"type": "function",
"stateMutability": "nonpayable",
"inputs": [
{"name": "_spender", "type": "address"},
{"name": "_value", "type": "uint256"}
],
"outputs": [{"name": "", "type": "bool"}]
}
]`)
}

View File

@@ -0,0 +1,118 @@
package ethereum
import (
"context"
"crypto/ecdsa"
"fmt"
"math/big"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
type Client struct {
rpc *ethclient.Client
privateKey *ecdsa.PrivateKey
wallet common.Address
chainID *big.Int
gasLimit uint64
gasMul float64
}
func NewClient(rpcURL, privateKeyHex string, chainID int64, gasLimit int, gasMultiplier float64) (*Client, error) {
rpc, err := ethclient.Dial(rpcURL)
if err != nil {
return nil, fmt.Errorf("dial RPC: %w", err)
}
pkHex := strings.TrimPrefix(privateKeyHex, "0x")
pk, err := crypto.HexToECDSA(pkHex)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
addr := crypto.PubkeyToAddress(pk.PublicKey)
return &Client{
rpc: rpc,
privateKey: pk,
wallet: addr,
chainID: big.NewInt(chainID),
gasLimit: uint64(gasLimit),
gasMul: gasMultiplier,
}, nil
}
func (c *Client) WalletAddress() common.Address { return c.wallet }
func (c *Client) GasLimit() uint64 { return c.gasLimit }
func (c *Client) Close() { c.rpc.Close() }
func (c *Client) ETHBalance(ctx context.Context) (*big.Int, error) {
return c.rpc.BalanceAt(ctx, c.wallet, nil)
}
func (c *Client) GasPrice(ctx context.Context) (*big.Int, error) {
price, err := c.rpc.SuggestGasPrice(ctx)
if err != nil {
return nil, err
}
// Apply multiplier
mul := new(big.Float).SetFloat64(c.gasMul)
adjusted := new(big.Float).Mul(new(big.Float).SetInt(price), mul)
result, _ := adjusted.Int(nil)
return result, nil
}
func (c *Client) Nonce(ctx context.Context) (uint64, error) {
return c.rpc.PendingNonceAt(ctx, c.wallet)
}
// SignAndSend signs a legacy transaction and broadcasts it, returning the tx hash.
func (c *Client) SignAndSend(ctx context.Context, to common.Address, value *big.Int, data []byte) (string, error) {
nonce, err := c.Nonce(ctx)
if err != nil {
return "", fmt.Errorf("get nonce: %w", err)
}
gasPrice, err := c.GasPrice(ctx)
if err != nil {
return "", fmt.Errorf("get gas price: %w", err)
}
tx := types.NewTx(&types.LegacyTx{
Nonce: nonce,
To: &to,
Value: value,
Gas: c.gasLimit,
GasPrice: gasPrice,
Data: data,
})
signer := types.NewEIP155Signer(c.chainID)
signed, err := types.SignTx(tx, signer, c.privateKey)
if err != nil {
return "", fmt.Errorf("sign tx: %w", err)
}
if err := c.rpc.SendTransaction(ctx, signed); err != nil {
return "", fmt.Errorf("send tx: %w", err)
}
return signed.Hash().Hex(), nil
}
// CallContract performs a read-only eth_call and returns the raw result.
func (c *Client) CallContract(ctx context.Context, to common.Address, data []byte) ([]byte, error) {
msg := map[string]interface{}{
"to": to.Hex(),
"data": fmt.Sprintf("0x%x", data),
}
var result string
err := c.rpc.Client().CallContext(ctx, &result, "eth_call", msg, "latest")
if err != nil {
return nil, err
}
return common.FromHex(result), nil
}

View File

@@ -0,0 +1,184 @@
package ethereum
import (
"context"
"fmt"
"math"
"math/big"
"time"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
const explorerTxPrefix = "https://etherscan.io/tx/"
// UniswapV2 wraps an Ethereum Client and provides Uniswap V2 Router swap methods.
type UniswapV2 struct {
client *Client
routerAddr common.Address
wethAddr common.Address
quoteAddr common.Address
quoteSymbol string
quoteDec int
slippagePct float64
routerABI abi.ABI
erc20ABI abi.ABI
}
func NewUniswapV2(
client *Client,
routerAddr, wethAddr, quoteAddr string,
quoteSymbol string,
quoteDecimals int,
slippagePct float64,
) (*UniswapV2, error) {
rABI, err := abi.JSON(mustRouterABI())
if err != nil {
return nil, fmt.Errorf("parse router ABI: %w", err)
}
eABI, err := abi.JSON(mustERC20ABI())
if err != nil {
return nil, fmt.Errorf("parse ERC20 ABI: %w", err)
}
return &UniswapV2{
client: client,
routerAddr: common.HexToAddress(routerAddr),
wethAddr: common.HexToAddress(wethAddr),
quoteAddr: common.HexToAddress(quoteAddr),
quoteSymbol: quoteSymbol,
quoteDec: quoteDecimals,
slippagePct: slippagePct,
routerABI: rABI,
erc20ABI: eABI,
}, nil
}
func (u *UniswapV2) ExplorerURL(txHash string) string {
return explorerTxPrefix + txHash
}
// TokenBalance returns the ERC20 token balance as a human-readable float.
func (u *UniswapV2) TokenBalance(ctx context.Context) (float64, error) {
data, err := u.erc20ABI.Pack("balanceOf", u.client.wallet)
if err != nil {
return 0, err
}
result, err := u.client.CallContract(ctx, u.quoteAddr, data)
if err != nil {
return 0, fmt.Errorf("balanceOf call: %w", err)
}
bal := new(big.Int).SetBytes(result)
divisor := math.Pow10(u.quoteDec)
f, _ := new(big.Float).Quo(new(big.Float).SetInt(bal), new(big.Float).SetFloat64(divisor)).Float64()
return f, nil
}
// ETHBalance returns wallet ETH balance as a human-readable float.
func (u *UniswapV2) ETHBalance(ctx context.Context) (float64, error) {
bal, err := u.client.ETHBalance(ctx)
if err != nil {
return 0, err
}
f, _ := new(big.Float).Quo(new(big.Float).SetInt(bal), new(big.Float).SetFloat64(1e18)).Float64()
return f, nil
}
// EnsureAllowance checks the router's allowance for the quote token and approves max if needed.
func (u *UniswapV2) EnsureAllowance(ctx context.Context, requiredAmount float64) error {
data, err := u.erc20ABI.Pack("allowance", u.client.wallet, u.routerAddr)
if err != nil {
return err
}
result, err := u.client.CallContract(ctx, u.quoteAddr, data)
if err != nil {
return fmt.Errorf("allowance call: %w", err)
}
current := new(big.Int).SetBytes(result)
requiredWei := toTokenWei(requiredAmount*2, u.quoteDec)
if current.Cmp(requiredWei) >= 0 {
return nil
}
fmt.Printf("Setting %s allowance for Uniswap Router...\n", u.quoteSymbol)
maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
approveData, err := u.erc20ABI.Pack("approve", u.routerAddr, maxUint256)
if err != nil {
return err
}
txHash, err := u.client.SignAndSend(ctx, u.quoteAddr, big.NewInt(0), approveData)
if err != nil {
return fmt.Errorf("approve tx: %w", err)
}
fmt.Printf("Allowance TX confirmed: %s\n", u.ExplorerURL(txHash))
return nil
}
// SwapUSDCForETH executes swapExactTokensForETH on the Uniswap V2 Router.
// Returns the transaction hash.
func (u *UniswapV2) SwapUSDCForETH(ctx context.Context, usdcAmount, minETHOut float64) (string, error) {
if err := u.EnsureAllowance(ctx, usdcAmount); err != nil {
return "", err
}
path := []common.Address{u.quoteAddr, u.wethAddr}
deadline := big.NewInt(time.Now().Unix() + 20*60)
amountIn := toTokenWei(usdcAmount, u.quoteDec)
// Apply slippage to minETHOut
minOutWei := toEthWei(minETHOut * (1 - u.slippagePct/100))
data, err := u.routerABI.Pack("swapExactTokensForETH",
amountIn, minOutWei, path, u.client.wallet, deadline)
if err != nil {
return "", fmt.Errorf("pack swapExactTokensForETH: %w", err)
}
return u.client.SignAndSend(ctx, u.routerAddr, big.NewInt(0), data)
}
// SwapETHForUSDC executes swapExactETHForTokens on the Uniswap V2 Router.
// Returns the transaction hash.
func (u *UniswapV2) SwapETHForUSDC(ctx context.Context, ethAmount float64) (string, error) {
path := []common.Address{u.wethAddr, u.quoteAddr}
deadline := big.NewInt(time.Now().Unix() + 20*60)
value := toEthWei(ethAmount)
data, err := u.routerABI.Pack("swapExactETHForTokens",
big.NewInt(0), path, u.client.wallet, deadline)
if err != nil {
return "", fmt.Errorf("pack swapExactETHForTokens: %w", err)
}
return u.client.SignAndSend(ctx, u.routerAddr, value, data)
}
// GasCostETH estimates the gas cost for a transaction in ETH.
func (u *UniswapV2) GasCostETH(ctx context.Context) (float64, error) {
gasPrice, err := u.client.GasPrice(ctx)
if err != nil {
return 0, err
}
cost := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(u.client.GasLimit()))
f, _ := new(big.Float).Quo(new(big.Float).SetInt(cost), new(big.Float).SetFloat64(1e18)).Float64()
return f, nil
}
// --- helpers ---
func toEthWei(eth float64) *big.Int {
// eth * 1e18
f := new(big.Float).Mul(new(big.Float).SetFloat64(eth), new(big.Float).SetFloat64(1e18))
i, _ := f.Int(nil)
return i
}
func toTokenWei(amount float64, decimals int) *big.Int {
f := new(big.Float).Mul(
new(big.Float).SetFloat64(amount),
new(big.Float).SetFloat64(math.Pow10(decimals)),
)
i, _ := f.Int(nil)
return i
}

View File

@@ -0,0 +1,58 @@
package external
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/kjannette/trahn-backend/internal/httputil"
)
const coingeckoURL = "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd"
type CoinGeckoClient struct {
httpClient *http.Client
retry httputil.RetryConfig
}
func NewCoinGeckoClient() *CoinGeckoClient {
return &CoinGeckoClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
retry: httputil.RetryConfig{
MaxAttempts: 3,
BaseDelay: 2 * time.Second,
MaxDelay: 10 * time.Second,
},
}
}
func (c *CoinGeckoClient) GetETHPrice(ctx context.Context) (float64, error) {
resp, err := httputil.Do(ctx, c.httpClient, c.retry, func() (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, coingeckoURL, nil)
})
if err != nil {
return 0, fmt.Errorf("coingecko fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("coingecko returned status %d", resp.StatusCode)
}
var data struct {
Ethereum struct {
USD float64 `json:"usd"`
} `json:"ethereum"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return 0, fmt.Errorf("decode: %w", err)
}
if data.Ethereum.USD <= 0 {
return 0, fmt.Errorf("invalid price: %f", data.Ethereum.USD)
}
return data.Ethereum.USD, nil
}

View File

@@ -0,0 +1,325 @@
package external
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"sync"
"time"
"github.com/kjannette/trahn-backend/internal/httputil"
)
type DuneClient struct {
apiKey string
baseURL string
method string // "simple" or "percentile"
lookbackDays int
httpClient *http.Client
retry httputil.RetryConfig
mu sync.Mutex
cachedResult *SRResult
lastFetch time.Time
cacheTTL time.Duration
}
type SRResult struct {
Support float64 `json:"support"`
Resistance float64 `json:"resistance"`
Midpoint float64 `json:"midpoint"`
AvgPrice float64 `json:"avgPrice"`
Method string `json:"method"`
LookbackDays int `json:"lookbackDays"`
FetchedAt time.Time `json:"fetchedAt"`
}
type DuneOptions struct {
Method string
LookbackDays int
RefreshHours int
}
func NewDuneClient(apiKey string, opts DuneOptions) *DuneClient {
method := opts.Method
if method == "" {
method = "simple"
}
lookback := opts.LookbackDays
if lookback <= 0 {
lookback = 14
}
refreshHours := opts.RefreshHours
if refreshHours <= 0 {
refreshHours = 48
}
return &DuneClient{
apiKey: apiKey,
baseURL: "https://api.dune.com/api/v1",
method: method,
lookbackDays: lookback,
httpClient: &http.Client{Timeout: 90 * time.Second},
cacheTTL: time.Duration(refreshHours) * time.Hour,
retry: httputil.RetryConfig{
MaxAttempts: 3,
BaseDelay: 3 * time.Second,
MaxDelay: 15 * time.Second,
},
}
}
func (d *DuneClient) FetchSupportResistance(ctx context.Context, forceRefresh bool) (*SRResult, error) {
d.mu.Lock()
if !forceRefresh && d.cachedResult != nil && time.Since(d.lastFetch) < d.cacheTTL {
cached := *d.cachedResult
d.mu.Unlock()
age := time.Since(d.lastFetch)
fmt.Printf("[DUNE] Using cached S/R data (age: %.1f min)\n", age.Minutes())
return &cached, nil
}
d.mu.Unlock()
sql := d.buildSRQuery()
rows, err := d.executeQuery(ctx, sql)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("dune returned no data for S/R query")
}
row := rows[0]
result := &SRResult{
Support: jsonFloat(row, "support"),
Resistance: jsonFloat(row, "resistance"),
Midpoint: jsonFloat(row, "midpoint"),
AvgPrice: jsonFloat(row, "avg_price"),
Method: d.method,
LookbackDays: d.lookbackDays,
FetchedAt: time.Now(),
}
if math.IsNaN(result.Support) || math.IsNaN(result.Resistance) || math.IsNaN(result.Midpoint) {
return nil, fmt.Errorf("invalid S/R data from Dune")
}
if result.Support >= result.Resistance {
return nil, fmt.Errorf("invalid S/R range: support %.2f >= resistance %.2f", result.Support, result.Resistance)
}
d.mu.Lock()
d.cachedResult = result
d.lastFetch = time.Now()
d.mu.Unlock()
fmt.Printf("[DUNE] S/R fetched successfully:\n")
fmt.Printf(" Support: $%.2f\n", result.Support)
fmt.Printf(" Resistance: $%.2f\n", result.Resistance)
fmt.Printf(" Midpoint: $%.2f\n", result.Midpoint)
fmt.Printf(" Method: %s, Lookback: %d days\n", result.Method, result.LookbackDays)
return result, nil
}
// SeedCache pre-populates the in-memory cache from a previously persisted
// S/R result (e.g. loaded from the database on startup). The cached entry
// is only used if it falls within the configured TTL.
func (d *DuneClient) SeedCache(sr *SRResult) {
if sr == nil {
return
}
d.mu.Lock()
defer d.mu.Unlock()
age := time.Since(sr.FetchedAt)
if age >= d.cacheTTL {
fmt.Printf("[DUNE] DB S/R data too old (%.1f hours), not seeding cache\n", age.Hours())
return
}
d.cachedResult = sr
d.lastFetch = sr.FetchedAt
fmt.Printf("[DUNE] Cache seeded from DB (age: %.1f min): midpoint $%.2f\n",
age.Minutes(), sr.Midpoint)
}
func (d *DuneClient) NeedsRefresh() bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.cachedResult == nil {
return true
}
return time.Since(d.lastFetch) >= d.cacheTTL
}
func (d *DuneClient) buildSRQuery() string {
if d.method == "percentile" {
return fmt.Sprintf(`
SELECT
approx_percentile(price, 0.05) as support,
approx_percentile(price, 0.95) as resistance,
approx_percentile(price, 0.50) as midpoint,
AVG(price) as avg_price,
MIN(price) as absolute_low,
MAX(price) as absolute_high
FROM prices.usd
WHERE symbol = 'WETH'
AND blockchain = 'ethereum'
AND minute > now() - interval '%d' day
`, d.lookbackDays)
}
return fmt.Sprintf(`
SELECT
MIN(price) as support,
MAX(price) as resistance,
(MIN(price) + MAX(price)) / 2 as midpoint,
AVG(price) as avg_price
FROM prices.usd
WHERE symbol = 'WETH'
AND blockchain = 'ethereum'
AND minute > now() - interval '%d' day
`, d.lookbackDays)
}
func (d *DuneClient) executeQuery(ctx context.Context, sql string) ([]map[string]any, error) {
if d.apiKey == "" {
return nil, fmt.Errorf("dune API key not configured")
}
fmt.Println("[DUNE] Executing S/R query...")
body, _ := json.Marshal(map[string]string{
"sql": sql,
"performance": "medium",
})
resp, err := httputil.Do(ctx, d.httpClient, d.retry, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.baseURL+"/sql/execute", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-Dune-API-Key", d.apiKey)
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("submit query: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dune query execution failed: status %d", resp.StatusCode)
}
var execResult struct {
ExecutionID string `json:"execution_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&execResult); err != nil {
return nil, fmt.Errorf("decode execution response: %w", err)
}
if execResult.ExecutionID == "" {
return nil, fmt.Errorf("dune did not return an execution ID")
}
fmt.Printf("[DUNE] Query submitted, execution ID: %s\n", execResult.ExecutionID)
const maxAttempts = 30
const pollInterval = 2 * time.Second
for attempt := range maxAttempts {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(pollInterval):
}
statusReq, _ := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/execution/%s/status", d.baseURL, execResult.ExecutionID), nil)
statusReq.Header.Set("X-Dune-API-Key", d.apiKey)
statusResp, err := d.httpClient.Do(statusReq)
if err != nil {
fmt.Printf("[DUNE] Status check failed (attempt %d), retrying...\n", attempt+1)
continue
}
var statusData struct {
State string `json:"state"`
Error string `json:"error"`
}
json.NewDecoder(statusResp.Body).Decode(&statusData)
statusResp.Body.Close()
switch statusData.State {
case "QUERY_STATE_COMPLETED", "completed":
return d.fetchResults(ctx, execResult.ExecutionID)
case "QUERY_STATE_FAILED", "failed":
errMsg := statusData.Error
if errMsg == "" {
errMsg = "unknown error"
}
return nil, fmt.Errorf("dune query failed: %s", errMsg)
default:
fmt.Printf("[DUNE] Query state: %s, waiting...\n", statusData.State)
}
}
return nil, fmt.Errorf("dune query timed out after %d seconds", maxAttempts*int(pollInterval.Seconds()))
}
func (d *DuneClient) fetchResults(ctx context.Context, executionID string) ([]map[string]any, error) {
resp, err := httputil.Do(ctx, d.httpClient, d.retry, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/execution/%s/results", d.baseURL, executionID), nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Dune-API-Key", d.apiKey)
return req, nil
})
if err != nil {
return nil, fmt.Errorf("fetch results: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch dune results: status %d", resp.StatusCode)
}
var data struct {
Result struct {
Rows []map[string]any `json:"rows"`
} `json:"result"`
Rows []map[string]any `json:"rows"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("decode results: %w", err)
}
rows := data.Result.Rows
if rows == nil {
rows = data.Rows
}
return rows, nil
}
// jsonFloat extracts a float64 from a map[string]any, handling both float64 and json.Number.
func jsonFloat(m map[string]any, key string) float64 {
v, ok := m[key]
if !ok {
return math.NaN()
}
switch n := v.(type) {
case float64:
return n
case json.Number:
f, _ := n.Float64()
return f
default:
return math.NaN()
}
}

View File

@@ -0,0 +1,80 @@
package external_test
import (
"context"
"os"
"testing"
"time"
"github.com/joho/godotenv"
"github.com/kjannette/trahn-backend/internal/external"
)
func init() {
_ = godotenv.Load("../../.env")
}
func TestCoinGeckoGetETHPrice(t *testing.T) {
client := external.NewCoinGeckoClient()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
price, err := client.GetETHPrice(ctx)
if err != nil {
t.Fatalf("GetETHPrice: %v", err)
}
if price <= 0 {
t.Fatalf("expected positive price, got %f", price)
}
t.Logf("ETH price: $%.2f", price)
}
func TestDuneFetchSupportResistance(t *testing.T) {
apiKey := os.Getenv("DUNE_API_KEY")
if apiKey == "" {
t.Skip("DUNE_API_KEY not set, skipping")
}
client := external.NewDuneClient(apiKey, external.DuneOptions{
Method: "simple",
LookbackDays: 14,
RefreshHours: 48,
})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
sr, err := client.FetchSupportResistance(ctx, true)
if err != nil {
t.Fatalf("FetchSupportResistance: %v", err)
}
if sr.Support <= 0 || sr.Resistance <= 0 || sr.Midpoint <= 0 {
t.Fatalf("invalid S/R values: %+v", sr)
}
if sr.Support >= sr.Resistance {
t.Fatalf("support (%.2f) >= resistance (%.2f)", sr.Support, sr.Resistance)
}
t.Logf("Support: $%.2f", sr.Support)
t.Logf("Resistance: $%.2f", sr.Resistance)
t.Logf("Midpoint: $%.2f", sr.Midpoint)
t.Logf("AvgPrice: $%.2f", sr.AvgPrice)
t.Logf("Method: %s, Lookback: %d days", sr.Method, sr.LookbackDays)
// Test cache hit
sr2, err := client.FetchSupportResistance(ctx, false)
if err != nil {
t.Fatalf("cached FetchSupportResistance: %v", err)
}
if sr2.Midpoint != sr.Midpoint {
t.Fatalf("cache mismatch: %.2f != %.2f", sr2.Midpoint, sr.Midpoint)
}
t.Log("Cache hit verified")
// NeedsRefresh should be false right after fetch
if client.NeedsRefresh() {
t.Fatal("should not need refresh right after fetch")
}
t.Log("NeedsRefresh: false (correct)")
}

View File

@@ -0,0 +1,73 @@
package httputil
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
var DefaultRetry = RetryConfig{
MaxAttempts: 3,
BaseDelay: 1 * time.Second,
MaxDelay: 10 * time.Second,
}
// Do executes an HTTP request with exponential backoff retry.
// The buildReq function is called on each attempt to produce a fresh request
// (required because request bodies are consumed on each attempt).
func Do(ctx context.Context, client *http.Client, cfg RetryConfig, buildReq func() (*http.Request, error)) (*http.Response, error) {
if cfg.MaxAttempts <= 0 {
cfg.MaxAttempts = DefaultRetry.MaxAttempts
}
var lastErr error
delay := cfg.BaseDelay
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
req, err := buildReq()
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if err != nil {
lastErr = err
} else {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
if attempt == cfg.MaxAttempts {
break
}
fmt.Printf("[RETRY] Attempt %d/%d failed: %v — retrying in %s\n",
attempt, cfg.MaxAttempts, lastErr, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
delay *= 2
if delay > cfg.MaxDelay {
delay = cfg.MaxDelay
}
}
return nil, fmt.Errorf("all %d attempts failed, last error: %w", cfg.MaxAttempts, lastErr)
}

View File

@@ -0,0 +1,136 @@
package httputil
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestDo_SuccessFirstAttempt(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 100 * time.Millisecond, MaxDelay: 1 * time.Second}
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_RetriesOnServerError(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if n < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on third attempt, got %d", resp.StatusCode)
}
if attempts.Load() != 3 {
t.Fatalf("expected 3 attempts, got %d", attempts.Load())
}
}
func TestDo_AllAttemptsFail(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("upstream error"))
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
_, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err == nil {
t.Fatal("expected error after all attempts failed")
}
if attempts.Load() != 3 {
t.Fatalf("expected 3 attempts, got %d", attempts.Load())
}
t.Logf("Error after retries: %v", err)
}
func TestDo_NoRetryOnClientError(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if attempts.Load() != 1 {
t.Fatalf("should not retry on 4xx, expected 1 attempt, got %d", attempts.Load())
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestDo_RespectsContextCancellation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 10, BaseDelay: 500 * time.Millisecond, MaxDelay: 2 * time.Second}
_, err := Do(ctx, client, cfg, func() (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
})
if err == nil {
t.Fatal("expected error from context cancellation")
}
t.Logf("Cancelled: %v", err)
}

View File

@@ -0,0 +1,37 @@
package models
import (
"encoding/json"
"time"
)
type GridState struct {
ID int `json:"id"`
BasePrice *float64 `json:"basePrice,omitempty"`
GridLevelsJSON json.RawMessage `json:"gridLevelsJson,omitempty"`
TradesExecuted int `json:"tradesExecuted"`
TotalProfit float64 `json:"totalProfit"`
LastSRRefresh *time.Time `json:"lastSrRefresh,omitempty"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Paper wallet fields (NULL for live trading)
PaperETHBalance *float64 `json:"paperEthBalance,omitempty"`
PaperUSDCBalance *float64 `json:"paperUsdcBalance,omitempty"`
PaperTotalGasSpent *float64 `json:"paperTotalGasSpent,omitempty"`
PaperTradesJSON json.RawMessage `json:"paperTradesJson,omitempty"`
PaperStartTime *time.Time `json:"paperStartTime,omitempty"`
PaperInitialETH *float64 `json:"paperInitialEth,omitempty"`
PaperInitialUSDC *float64 `json:"paperInitialUsdc,omitempty"`
}
type PaperWallet struct {
ETHBalance float64 `json:"ethBalance"`
USDCBalance float64 `json:"usdcBalance"`
TotalGasSpent float64 `json:"totalGasSpent"`
Trades json.RawMessage `json:"trades"`
StartTime *time.Time `json:"startTime"`
InitialETH float64 `json:"initialEth"`
InitialUSDC float64 `json:"initialUsdc"`
}

View File

@@ -0,0 +1,12 @@
package models
import "time"
type PricePoint struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Price float64 `json:"price"`
TradingDay string `json:"tradingDay"`
Source string `json:"source"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,27 @@
package models
import (
"math"
"time"
)
type SupportResistance struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
LookbackDays int `json:"lookbackDays"`
Support float64 `json:"support"`
Resistance float64 `json:"resistance"`
Midpoint float64 `json:"midpoint"`
AvgPrice *float64 `json:"avgPrice,omitempty"`
GridRecalculated bool `json:"gridRecalculated"`
CreatedAt time.Time `json:"createdAt"`
}
func (sr *SupportResistance) HasChangedSignificantly(previous *SupportResistance, thresholdPercent float64) bool {
if previous == nil || previous.Midpoint == 0 {
return true
}
change := math.Abs((sr.Midpoint - previous.Midpoint) / previous.Midpoint * 100)
return change >= thresholdPercent
}

View File

@@ -0,0 +1,29 @@
package models
import "time"
type Trade struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
TradingDay string `json:"tradingDay"`
Side string `json:"side"` // "buy" or "sell"
Price float64 `json:"price"`
Quantity float64 `json:"quantity"`
USDValue float64 `json:"usdValue"`
GridLevel *int `json:"gridLevel,omitempty"`
TxHash *string `json:"txHash,omitempty"`
IsPaperTrade bool `json:"isPaperTrade"`
SlippagePercent *float64 `json:"slippagePercent,omitempty"`
GasCostETH *float64 `json:"gasCostEth,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type TradeStats struct {
TotalTrades int64 `json:"totalTrades"`
BuyCount int64 `json:"buyCount"`
SellCount int64 `json:"sellCount"`
TotalVolume *float64 `json:"totalVolume"`
AvgPrice *float64 `json:"avgPrice"`
FirstTrade *time.Time `json:"firstTrade"`
LastTrade *time.Time `json:"lastTrade"`
}

View File

@@ -0,0 +1,86 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/kjannette/trahn-backend/internal/httputil"
)
type Sender struct {
webhookURL string
botName string
httpClient *http.Client
retry httputil.RetryConfig
}
func NewSender(webhookURL, botName string) *Sender {
if botName == "" {
botName = "TrahnGridTrader"
}
return &Sender{
webhookURL: webhookURL,
botName: botName,
httpClient: &http.Client{Timeout: 10 * time.Second},
retry: httputil.RetryConfig{
MaxAttempts: 3,
BaseDelay: 1 * time.Second,
MaxDelay: 5 * time.Second,
},
}
}
func (s *Sender) Send(msg string) {
formatted := fmt.Sprintf("[%s] %s", s.botName, msg)
fmt.Printf("[%s] %s\n", time.Now().UTC().Format(time.RFC3339), formatted)
if s.webhookURL == "" {
return
}
payload := s.formatPayload(formatted)
body, err := json.Marshal(payload)
if err != nil {
fmt.Printf("[CHAT ERROR] marshal: %v\n", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := httputil.Do(ctx, s.httpClient, s.retry, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.webhookURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
fmt.Printf("[CHAT ERROR] Failed to send notification after retries: %v\n", err)
return
}
resp.Body.Close()
}
func (s *Sender) formatPayload(msg string) map[string]string {
if strings.Contains(s.webhookURL, "discord") {
return map[string]string{
"content": msg,
"username": s.botName,
}
}
return map[string]string{
"text": fmt.Sprintf("`%s`", msg),
"username": s.botName,
}
}
func (s *Sender) Enabled() bool {
return s.webhookURL != ""
}

View File

@@ -0,0 +1,83 @@
package notifications
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestSend_NoWebhook(t *testing.T) {
s := NewSender("", "TestBot")
if s.Enabled() {
t.Fatal("should not be enabled with empty URL")
}
// Should log to console without error
s.Send("hello from test")
t.Log("Send with no webhook: OK (console only)")
}
func TestSend_SlackFormat(t *testing.T) {
var received map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &received)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
s := NewSender(srv.URL, "TestBot")
if !s.Enabled() {
t.Fatal("should be enabled")
}
s.Send("grid recalculated")
if received["username"] != "TestBot" {
t.Fatalf("username: got %s", received["username"])
}
if received["text"] == "" {
t.Fatal("text should not be empty")
}
t.Logf("Slack payload: %+v", received)
}
func TestSend_DiscordFormat(t *testing.T) {
var received map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &received)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// URL containing "discord" triggers Discord format
s := NewSender(srv.URL+"/discord/webhook", "TrahnBot")
s.Send("trade executed: buy 0.04 ETH @ $2600")
if received["content"] == "" {
t.Fatal("content should not be empty for Discord")
}
if received["username"] != "TrahnBot" {
t.Fatalf("username: got %s", received["username"])
}
if _, hasText := received["text"]; hasText {
t.Fatal("Discord payload should not have 'text' field")
}
t.Logf("Discord payload: %+v", received)
}
func TestSend_WebhookError(t *testing.T) {
s := NewSender("http://localhost:1/bogus", "TestBot")
// Should not panic, just log the error
s.Send("this will fail gracefully")
t.Log("Webhook error handled gracefully")
}
func TestDefaultBotName(t *testing.T) {
s := NewSender("", "")
if s.botName != "TrahnGridTrader" {
t.Fatalf("expected default bot name, got %s", s.botName)
}
}

View File

@@ -0,0 +1,202 @@
package repository
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/trahn-backend/internal/models"
)
type GridStateRepo struct {
pool *pgxpool.Pool
}
func NewGridStateRepo(pool *pgxpool.Pool) *GridStateRepo {
return &GridStateRepo{pool: pool}
}
func (r *GridStateRepo) GetActive(ctx context.Context) (*models.GridState, error) {
row := r.pool.QueryRow(ctx,
`SELECT * FROM grid_state WHERE is_active = true ORDER BY updated_at DESC LIMIT 1`,
)
gs, err := scanGridState(row)
if err != nil {
if err.Error() == "no rows in result set" {
return nil, nil
}
return nil, err
}
return gs, nil
}
func (r *GridStateRepo) Save(ctx context.Context, data *models.GridState) (*models.GridState, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `UPDATE grid_state SET is_active = false WHERE is_active = true`)
if err != nil {
return nil, err
}
row := tx.QueryRow(ctx,
`INSERT INTO grid_state
(base_price, grid_levels_json, trades_executed, total_profit,
last_sr_refresh, is_active, updated_at)
VALUES ($1,$2,$3,$4,$5,true,NOW())
RETURNING *`,
data.BasePrice,
data.GridLevelsJSON,
data.TradesExecuted,
data.TotalProfit,
data.LastSRRefresh,
)
gs, err := scanGridState(row)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gs, nil
}
func (r *GridStateRepo) UpdateGridLevels(ctx context.Context, levels json.RawMessage) error {
_, err := r.pool.Exec(ctx,
`UPDATE grid_state SET grid_levels_json = $1, updated_at = NOW() WHERE is_active = true`,
levels,
)
return err
}
func (r *GridStateRepo) UpdateTradeStats(ctx context.Context, tradesExecuted int, totalProfit float64) error {
_, err := r.pool.Exec(ctx,
`UPDATE grid_state SET trades_executed = $1, total_profit = $2, updated_at = NOW() WHERE is_active = true`,
tradesExecuted, totalProfit,
)
return err
}
func (r *GridStateRepo) UpdatePaperWallet(ctx context.Context, pw *models.PaperWallet) error {
tradesJSON, err := json.Marshal(pw.Trades)
if err != nil {
tradesJSON = []byte("[]")
}
_, err = r.pool.Exec(ctx,
`UPDATE grid_state
SET paper_eth_balance = $1,
paper_usdc_balance = $2,
paper_total_gas_spent = $3,
paper_trades_json = $4,
updated_at = NOW()
WHERE is_active = true`,
pw.ETHBalance, pw.USDCBalance, pw.TotalGasSpent, tradesJSON,
)
return err
}
func (r *GridStateRepo) InitializePaperWallet(ctx context.Context, initialETH, initialUSDC float64) error {
state, err := r.GetActive(ctx)
if err != nil {
return err
}
if state != nil && state.PaperETHBalance != nil {
return nil // already initialized
}
_, err = r.pool.Exec(ctx,
`UPDATE grid_state
SET paper_eth_balance = $1,
paper_usdc_balance = $2,
paper_initial_eth = $1,
paper_initial_usdc = $2,
paper_total_gas_spent = 0,
paper_trades_json = '[]'::jsonb,
paper_start_time = NOW(),
updated_at = NOW()
WHERE is_active = true`,
initialETH, initialUSDC,
)
return err
}
func (r *GridStateRepo) GetPaperWallet(ctx context.Context) (*models.PaperWallet, error) {
state, err := r.GetActive(ctx)
if err != nil {
return nil, err
}
if state == nil || state.PaperETHBalance == nil {
return nil, nil
}
pw := &models.PaperWallet{
ETHBalance: valOr(state.PaperETHBalance, 0),
USDCBalance: valOr(state.PaperUSDCBalance, 0),
TotalGasSpent: valOr(state.PaperTotalGasSpent, 0),
Trades: state.PaperTradesJSON,
StartTime: state.PaperStartTime,
InitialETH: valOr(state.PaperInitialETH, 0),
InitialUSDC: valOr(state.PaperInitialUSDC, 0),
}
return pw, nil
}
func (r *GridStateRepo) GetHistory(ctx context.Context, limit int) ([]models.GridState, error) {
rows, err := r.pool.Query(ctx,
`SELECT * FROM grid_state ORDER BY updated_at DESC LIMIT $1`,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
return collectGridStates(rows)
}
// --- scan helpers ---
func scanGridState(row scannable) (*models.GridState, error) {
var gs models.GridState
err := row.Scan(
&gs.ID, &gs.BasePrice, &gs.GridLevelsJSON,
&gs.TradesExecuted, &gs.TotalProfit, &gs.LastSRRefresh,
&gs.IsActive, &gs.CreatedAt, &gs.UpdatedAt,
// paper wallet columns
&gs.PaperETHBalance, &gs.PaperUSDCBalance, &gs.PaperTotalGasSpent,
&gs.PaperTradesJSON, &gs.PaperStartTime,
&gs.PaperInitialETH, &gs.PaperInitialUSDC,
)
if err != nil {
return nil, err
}
return &gs, nil
}
func collectGridStates(rows rowsIter) ([]models.GridState, error) {
var out []models.GridState
for rows.Next() {
var gs models.GridState
if err := rows.Scan(
&gs.ID, &gs.BasePrice, &gs.GridLevelsJSON,
&gs.TradesExecuted, &gs.TotalProfit, &gs.LastSRRefresh,
&gs.IsActive, &gs.CreatedAt, &gs.UpdatedAt,
&gs.PaperETHBalance, &gs.PaperUSDCBalance, &gs.PaperTotalGasSpent,
&gs.PaperTradesJSON, &gs.PaperStartTime,
&gs.PaperInitialETH, &gs.PaperInitialUSDC,
); err != nil {
return nil, err
}
out = append(out, gs)
}
return out, rows.Err()
}
func valOr(p *float64, fallback float64) float64 {
if p != nil {
return *p
}
return fallback
}

View File

@@ -0,0 +1,110 @@
package repository
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/trahn-backend/internal/models"
)
type PriceRepo struct {
pool *pgxpool.Pool
}
func NewPriceRepo(pool *pgxpool.Pool) *PriceRepo {
return &PriceRepo{pool: pool}
}
func (r *PriceRepo) Record(ctx context.Context, price float64, ts time.Time) (*models.PricePoint, error) {
td := TradingDay(ts)
row := r.pool.QueryRow(ctx,
`INSERT INTO price_history (timestamp, price, trading_day, source)
VALUES ($1, $2, $3, $4) RETURNING *`,
ts, price, td, "coingecko",
)
return scanPrice(row)
}
func (r *PriceRepo) GetByDay(ctx context.Context, tradingDay string) ([]models.PricePoint, error) {
rows, err := r.pool.Query(ctx,
`SELECT * FROM price_history WHERE trading_day = $1 ORDER BY timestamp ASC`,
tradingDay,
)
if err != nil {
return nil, err
}
defer rows.Close()
return collectPrices(rows)
}
func (r *PriceRepo) GetAvailableDays(ctx context.Context) ([]string, error) {
rows, err := r.pool.Query(ctx,
`SELECT DISTINCT trading_day FROM price_history ORDER BY trading_day DESC LIMIT 30`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var days []string
for rows.Next() {
var d time.Time
if err := rows.Scan(&d); err != nil {
return nil, err
}
days = append(days, d.Format("2006-01-02"))
}
return days, rows.Err()
}
func (r *PriceRepo) GetLatest(ctx context.Context) (*models.PricePoint, error) {
row := r.pool.QueryRow(ctx,
`SELECT * FROM price_history ORDER BY timestamp DESC LIMIT 1`,
)
p, err := scanPrice(row)
if err != nil {
if err.Error() == "no rows in result set" {
return nil, nil
}
return nil, err
}
return p, nil
}
// --- scan helpers ---
type scannable interface {
Scan(dest ...any) error
}
func scanPrice(row scannable) (*models.PricePoint, error) {
var p models.PricePoint
var td time.Time
err := row.Scan(&p.ID, &p.Timestamp, &p.Price, &td, &p.Source, &p.CreatedAt)
if err != nil {
return nil, err
}
p.TradingDay = td.Format("2006-01-02")
return &p, nil
}
type rowsIter interface {
Next() bool
Scan(dest ...any) error
Err() error
}
func collectPrices(rows rowsIter) ([]models.PricePoint, error) {
var out []models.PricePoint
for rows.Next() {
var p models.PricePoint
var td time.Time
if err := rows.Scan(&p.ID, &p.Timestamp, &p.Price, &td, &p.Source, &p.CreatedAt); err != nil {
return nil, err
}
p.TradingDay = td.Format("2006-01-02")
out = append(out, p)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,319 @@
package repository_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/kjannette/trahn-backend/internal/models"
"github.com/kjannette/trahn-backend/internal/repository"
"github.com/kjannette/trahn-backend/internal/testutil"
)
// ---------- PriceRepo ----------
func TestPriceRepo(t *testing.T) {
pool := testutil.SetupPool(t)
repo := repository.NewPriceRepo(pool)
ctx := context.Background()
// Record
ts := time.Now()
p, err := repo.Record(ctx, 2650.42, ts)
if err != nil {
t.Fatalf("Record: %v", err)
}
if p.ID == 0 {
t.Fatal("expected non-zero ID")
}
if p.Price != 2650.42 {
t.Fatalf("price mismatch: got %f", p.Price)
}
t.Logf("Recorded price: id=%d price=%.2f day=%s", p.ID, p.Price, p.TradingDay)
// GetLatest
latest, err := repo.GetLatest(ctx)
if err != nil {
t.Fatalf("GetLatest: %v", err)
}
if latest == nil {
t.Fatal("expected latest price")
}
t.Logf("Latest: id=%d price=%.2f", latest.ID, latest.Price)
// GetByDay
prices, err := repo.GetByDay(ctx, p.TradingDay)
if err != nil {
t.Fatalf("GetByDay: %v", err)
}
if len(prices) == 0 {
t.Fatal("expected prices for trading day")
}
t.Logf("GetByDay(%s): %d rows", p.TradingDay, len(prices))
// GetAvailableDays
days, err := repo.GetAvailableDays(ctx)
if err != nil {
t.Fatalf("GetAvailableDays: %v", err)
}
if len(days) == 0 {
t.Fatal("expected at least one day")
}
t.Logf("Available days: %v", days)
}
// ---------- TradeRepo ----------
func TestTradeRepo(t *testing.T) {
pool := testutil.SetupPool(t)
repo := repository.NewTradeRepo(pool)
ctx := context.Background()
slippage := 0.35
gasCost := 0.002
gridLvl := 3
trade := &models.Trade{
Timestamp: time.Now(),
Side: "buy",
Price: 2600.00,
Quantity: 0.0385,
USDValue: 100.00,
GridLevel: &gridLvl,
IsPaperTrade: true,
SlippagePercent: &slippage,
GasCostETH: &gasCost,
}
recorded, err := repo.Record(ctx, trade)
if err != nil {
t.Fatalf("Record: %v", err)
}
if recorded.ID == 0 {
t.Fatal("expected non-zero ID")
}
if recorded.Side != "buy" {
t.Fatalf("side mismatch: got %s", recorded.Side)
}
t.Logf("Recorded trade: id=%d side=%s price=%.2f qty=%.4f", recorded.ID, recorded.Side, recorded.Price, recorded.Quantity)
// GetAll (no filter)
all, err := repo.GetAll(ctx, 10, nil)
if err != nil {
t.Fatalf("GetAll: %v", err)
}
if len(all) == 0 {
t.Fatal("expected trades")
}
t.Logf("GetAll: %d trades", len(all))
// GetAll (paper only)
paperMode := true
paperTrades, err := repo.GetAll(ctx, 10, &paperMode)
if err != nil {
t.Fatalf("GetAll(paper): %v", err)
}
for _, pt := range paperTrades {
if !pt.IsPaperTrade {
t.Fatalf("expected paper trade, got live trade id=%d", pt.ID)
}
}
t.Logf("GetAll(paper): %d trades", len(paperTrades))
// GetStats (no filter)
stats, err := repo.GetStats(ctx, nil)
if err != nil {
t.Fatalf("GetStats: %v", err)
}
t.Logf("Stats(all): total=%d buys=%d sells=%d", stats.TotalTrades, stats.BuyCount, stats.SellCount)
// GetStats (paper only)
paperStats, err := repo.GetStats(ctx, &paperMode)
if err != nil {
t.Fatalf("GetStats(paper): %v", err)
}
t.Logf("Stats(paper): total=%d buys=%d sells=%d", paperStats.TotalTrades, paperStats.BuyCount, paperStats.SellCount)
}
// ---------- SRRepo ----------
func TestSRRepo(t *testing.T) {
pool := testutil.SetupPool(t)
repo := repository.NewSRRepo(pool)
ctx := context.Background()
avgPrice := 2700.0
sr := &models.SupportResistance{
Timestamp: time.Now(),
Method: "simple",
LookbackDays: 14,
Support: 2400.00,
Resistance: 3000.00,
Midpoint: 2700.00,
AvgPrice: &avgPrice,
GridRecalculated: false,
}
recorded, err := repo.Record(ctx, sr)
if err != nil {
t.Fatalf("Record: %v", err)
}
if recorded.ID == 0 {
t.Fatal("expected non-zero ID")
}
t.Logf("Recorded S/R: id=%d support=%.2f resistance=%.2f mid=%.2f", recorded.ID, recorded.Support, recorded.Resistance, recorded.Midpoint)
// GetLatest
latest, err := repo.GetLatest(ctx)
if err != nil {
t.Fatalf("GetLatest: %v", err)
}
if latest == nil {
t.Fatal("expected latest S/R")
}
t.Logf("Latest S/R: mid=%.2f", latest.Midpoint)
// GetHistory
history, err := repo.GetHistory(ctx, 10)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
t.Logf("S/R history: %d rows", len(history))
// NeedsRefresh (just recorded, so should NOT need refresh)
needs, err := repo.NeedsRefresh(ctx, 48)
if err != nil {
t.Fatalf("NeedsRefresh: %v", err)
}
if needs {
t.Fatal("should NOT need refresh right after recording")
}
t.Logf("NeedsRefresh(48h): %v", needs)
// CheckSignificantChange
shifted := &models.SupportResistance{Midpoint: 2900.00}
analysis, err := repo.CheckSignificantChange(ctx, shifted, 5)
if err != nil {
t.Fatalf("CheckSignificantChange: %v", err)
}
t.Logf("Change analysis: changed=%v reason=%s", analysis.HasChanged, analysis.Reason)
}
// ---------- GridStateRepo ----------
func TestGridStateRepo(t *testing.T) {
pool := testutil.SetupPool(t)
repo := repository.NewGridStateRepo(pool)
ctx := context.Background()
basePrice := 2700.0
levels := json.RawMessage(`[{"index":0,"price":2600,"side":"buy","filled":false}]`)
gs := &models.GridState{
BasePrice: &basePrice,
GridLevelsJSON: levels,
TradesExecuted: 0,
TotalProfit: 0,
}
saved, err := repo.Save(ctx, gs)
if err != nil {
t.Fatalf("Save: %v", err)
}
if saved.ID == 0 {
t.Fatal("expected non-zero ID")
}
if !saved.IsActive {
t.Fatal("expected active state")
}
t.Logf("Saved grid state: id=%d active=%v", saved.ID, saved.IsActive)
// GetActive
active, err := repo.GetActive(ctx)
if err != nil {
t.Fatalf("GetActive: %v", err)
}
if active == nil {
t.Fatal("expected active state")
}
t.Logf("Active grid state: id=%d", active.ID)
// UpdateGridLevels
newLevels := json.RawMessage(`[{"index":0,"price":2600,"side":"buy","filled":true}]`)
if err := repo.UpdateGridLevels(ctx, newLevels); err != nil {
t.Fatalf("UpdateGridLevels: %v", err)
}
t.Log("UpdateGridLevels: OK")
// UpdateTradeStats
if err := repo.UpdateTradeStats(ctx, 5, 12.50); err != nil {
t.Fatalf("UpdateTradeStats: %v", err)
}
t.Log("UpdateTradeStats: OK")
// InitializePaperWallet
if err := repo.InitializePaperWallet(ctx, 1.0, 1000.0); err != nil {
t.Fatalf("InitializePaperWallet: %v", err)
}
t.Log("InitializePaperWallet: OK")
// GetPaperWallet
pw, err := repo.GetPaperWallet(ctx)
if err != nil {
t.Fatalf("GetPaperWallet: %v", err)
}
if pw == nil {
t.Fatal("expected paper wallet")
}
if pw.ETHBalance != 1.0 {
t.Fatalf("ETH balance mismatch: got %f", pw.ETHBalance)
}
t.Logf("PaperWallet: ETH=%.4f USDC=%.2f", pw.ETHBalance, pw.USDCBalance)
// UpdatePaperWallet
pw.ETHBalance = 1.05
pw.USDCBalance = 870.00
pw.TotalGasSpent = 0.003
if err := repo.UpdatePaperWallet(ctx, pw); err != nil {
t.Fatalf("UpdatePaperWallet: %v", err)
}
t.Log("UpdatePaperWallet: OK")
// Verify update
pw2, err := repo.GetPaperWallet(ctx)
if err != nil {
t.Fatalf("GetPaperWallet after update: %v", err)
}
if pw2.ETHBalance != 1.05 {
t.Fatalf("ETH balance mismatch after update: got %f", pw2.ETHBalance)
}
t.Logf("PaperWallet after update: ETH=%.4f USDC=%.2f gas=%.4f", pw2.ETHBalance, pw2.USDCBalance, pw2.TotalGasSpent)
// GetHistory
history, err := repo.GetHistory(ctx, 10)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
t.Logf("Grid state history: %d rows", len(history))
}
// ---------- TradingDay ----------
func TestTradingDay(t *testing.T) {
// 2024-01-15 at 16:00 UTC (before 17:00 cutoff) => trading day = Jan 14
ts := time.Date(2024, 1, 15, 16, 0, 0, 0, time.UTC)
got := repository.TradingDay(ts)
if got != "2024-01-14" {
t.Fatalf("expected 2024-01-14, got %s", got)
}
// 2024-01-15 at 18:00 UTC (after 17:00 cutoff) => trading day = Jan 15
ts2 := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
got2 := repository.TradingDay(ts2)
if got2 != "2024-01-15" {
t.Fatalf("expected 2024-01-15, got %s", got2)
}
t.Logf("TradingDay tests passed")
}

View File

@@ -0,0 +1,139 @@
package repository
import (
"context"
"fmt"
"math"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/trahn-backend/internal/models"
)
type SRRepo struct {
pool *pgxpool.Pool
}
func NewSRRepo(pool *pgxpool.Pool) *SRRepo {
return &SRRepo{pool: pool}
}
func (r *SRRepo) Record(ctx context.Context, sr *models.SupportResistance) (*models.SupportResistance, error) {
ts := sr.Timestamp
if ts.IsZero() {
ts = time.Now()
}
row := r.pool.QueryRow(ctx,
`INSERT INTO support_resistance_history
(timestamp, method, lookback_days, support, resistance, midpoint, avg_price, grid_recalculated)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
ts, sr.Method, sr.LookbackDays, sr.Support, sr.Resistance,
sr.Midpoint, sr.AvgPrice, sr.GridRecalculated,
)
return scanSR(row)
}
func (r *SRRepo) GetLatest(ctx context.Context) (*models.SupportResistance, error) {
row := r.pool.QueryRow(ctx,
`SELECT * FROM support_resistance_history ORDER BY timestamp DESC LIMIT 1`,
)
sr, err := scanSR(row)
if err != nil {
if err.Error() == "no rows in result set" {
return nil, nil
}
return nil, err
}
return sr, nil
}
func (r *SRRepo) GetHistory(ctx context.Context, limit int) ([]models.SupportResistance, error) {
rows, err := r.pool.Query(ctx,
`SELECT * FROM support_resistance_history ORDER BY timestamp DESC LIMIT $1`,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
return collectSRs(rows)
}
func (r *SRRepo) NeedsRefresh(ctx context.Context, refreshHours int) (bool, error) {
latest, err := r.GetLatest(ctx)
if err != nil {
return false, err
}
if latest == nil {
return true, nil
}
age := time.Since(latest.Timestamp)
return age >= time.Duration(refreshHours)*time.Hour, nil
}
type ChangeAnalysis struct {
HasChanged bool `json:"hasChanged"`
ChangePercent *float64 `json:"changePercent"`
Previous *models.SupportResistance `json:"previous"`
Reason string `json:"reason"`
}
func (r *SRRepo) CheckSignificantChange(ctx context.Context, newSR *models.SupportResistance, thresholdPercent float64) (*ChangeAnalysis, error) {
previous, err := r.GetLatest(ctx)
if err != nil {
return nil, err
}
if previous == nil {
return &ChangeAnalysis{
HasChanged: true,
Reason: "First S/R fetch",
}, nil
}
pct := math.Abs((newSR.Midpoint - previous.Midpoint) / previous.Midpoint * 100)
changed := pct >= thresholdPercent
reason := "S/R stable"
if changed {
reason = fmt.Sprintf("Midpoint changed %.2f%%", pct)
}
return &ChangeAnalysis{
HasChanged: changed,
ChangePercent: &pct,
Previous: previous,
Reason: reason,
}, nil
}
// --- scan helpers ---
func scanSR(row scannable) (*models.SupportResistance, error) {
var sr models.SupportResistance
err := row.Scan(
&sr.ID, &sr.Timestamp, &sr.Method, &sr.LookbackDays,
&sr.Support, &sr.Resistance, &sr.Midpoint, &sr.AvgPrice,
&sr.GridRecalculated, &sr.CreatedAt,
)
if err != nil {
return nil, err
}
return &sr, nil
}
func collectSRs(rows rowsIter) ([]models.SupportResistance, error) {
var out []models.SupportResistance
for rows.Next() {
var sr models.SupportResistance
if err := rows.Scan(
&sr.ID, &sr.Timestamp, &sr.Method, &sr.LookbackDays,
&sr.Support, &sr.Resistance, &sr.Midpoint, &sr.AvgPrice,
&sr.GridRecalculated, &sr.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, sr)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,155 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/trahn-backend/internal/models"
)
type TradeRepo struct {
pool *pgxpool.Pool
}
func NewTradeRepo(pool *pgxpool.Pool) *TradeRepo {
return &TradeRepo{pool: pool}
}
func (r *TradeRepo) Record(ctx context.Context, t *models.Trade) (*models.Trade, error) {
ts := t.Timestamp
if ts.IsZero() {
ts = time.Now()
}
td := TradingDay(ts)
row := r.pool.QueryRow(ctx,
`INSERT INTO trade_history
(timestamp, trading_day, side, price, quantity, usd_value,
grid_level, tx_hash, is_paper_trade, slippage_percent, gas_cost_eth)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
ts, td, t.Side, t.Price, t.Quantity, t.USDValue,
t.GridLevel, t.TxHash, t.IsPaperTrade, t.SlippagePercent, t.GasCostETH,
)
return scanTrade(row)
}
// GetByDay returns trades for a given trading day.
// If paperMode is non-nil, filters by is_paper_trade.
func (r *TradeRepo) GetByDay(ctx context.Context, tradingDay string, paperMode *bool) ([]models.Trade, error) {
query, args := buildFilteredQuery(
`SELECT * FROM trade_history WHERE trading_day = $1`,
[]any{tradingDay},
paperMode,
)
query += " ORDER BY timestamp ASC"
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return collectTrades(rows)
}
// GetAll returns the most recent trades.
// If paperMode is non-nil, filters by is_paper_trade.
func (r *TradeRepo) GetAll(ctx context.Context, limit int, paperMode *bool) ([]models.Trade, error) {
query, args := buildFilteredQuery(
`SELECT * FROM trade_history WHERE 1=1`,
nil,
paperMode,
)
args = append(args, limit)
query += fmt.Sprintf(" ORDER BY timestamp DESC LIMIT $%d", len(args))
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return collectTrades(rows)
}
// GetStats returns aggregate trade statistics.
// If paperMode is non-nil, filters by is_paper_trade.
func (r *TradeRepo) GetStats(ctx context.Context, paperMode *bool) (*models.TradeStats, error) {
query, args := buildFilteredQuery(
`SELECT
COUNT(*),
COUNT(CASE WHEN side = 'buy' THEN 1 END),
COUNT(CASE WHEN side = 'sell' THEN 1 END),
SUM(usd_value),
AVG(price),
MIN(timestamp),
MAX(timestamp)
FROM trade_history WHERE 1=1`,
nil,
paperMode,
)
var s models.TradeStats
err := r.pool.QueryRow(ctx, query, args...).Scan(
&s.TotalTrades, &s.BuyCount, &s.SellCount,
&s.TotalVolume, &s.AvgPrice, &s.FirstTrade, &s.LastTrade,
)
if err != nil {
return nil, err
}
return &s, nil
}
func (r *TradeRepo) CountToday(ctx context.Context) (int, error) {
var count int
err := r.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM trade_history WHERE trading_day = $1`,
TradingDayNow(),
).Scan(&count)
return count, err
}
// buildFilteredQuery appends an is_paper_trade clause when paperMode is non-nil.
func buildFilteredQuery(baseQuery string, baseArgs []any, paperMode *bool) (string, []any) {
if paperMode == nil {
return baseQuery, baseArgs
}
args := append(baseArgs, *paperMode)
return baseQuery + fmt.Sprintf(" AND is_paper_trade = $%d", len(args)), args
}
// --- scan helpers ---
func scanTrade(row scannable) (*models.Trade, error) {
var t models.Trade
var td time.Time
err := row.Scan(
&t.ID, &t.Timestamp, &td, &t.Side, &t.Price, &t.Quantity, &t.USDValue,
&t.GridLevel, &t.TxHash, &t.IsPaperTrade, &t.SlippagePercent, &t.GasCostETH,
&t.CreatedAt,
)
if err != nil {
return nil, err
}
t.TradingDay = td.Format("2006-01-02")
return &t, nil
}
func collectTrades(rows rowsIter) ([]models.Trade, error) {
var out []models.Trade
for rows.Next() {
var t models.Trade
var td time.Time
if err := rows.Scan(
&t.ID, &t.Timestamp, &td, &t.Side, &t.Price, &t.Quantity, &t.USDValue,
&t.GridLevel, &t.TxHash, &t.IsPaperTrade, &t.SlippagePercent, &t.GasCostETH,
&t.CreatedAt,
); err != nil {
return nil, err
}
t.TradingDay = td.Format("2006-01-02")
out = append(out, t)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,22 @@
package repository
import "time"
// TradingDay returns the trading day (YYYY-MM-DD) for a given timestamp.
// Trading day boundary is 12:00 EST (17:00 UTC).
func TradingDay(ts time.Time) string {
utc := ts.UTC()
cutoff := 17 * 60 // 17:00 UTC in minutes
utcMinutes := utc.Hour()*60 + utc.Minute()
day := utc
if utcMinutes < cutoff {
day = day.AddDate(0, 0, -1)
}
return day.Format("2006-01-02")
}
// TradingDayNow returns the trading day for the current moment.
func TradingDayNow() string {
return TradingDay(time.Now())
}

View File

@@ -0,0 +1,69 @@
package risk
import (
"context"
"fmt"
)
// DailyTradeCounter abstracts the trade-counting dependency so Guardian
// can be tested without a real database.
type DailyTradeCounter interface {
CountToday(ctx context.Context) (int, error)
}
// Limits holds the four risk thresholds from config.
// A zero value for any field means that check is disabled.
type Limits struct {
MaxDailyTrades int
MaxPositionSizeUSD float64
StopLossPercent float64
TakeProfitPercent float64
}
type Guardian struct {
limits Limits
counter DailyTradeCounter
}
func NewGuardian(limits Limits, counter DailyTradeCounter) *Guardian {
return &Guardian{limits: limits, counter: counter}
}
// PreTradeCheck validates per-trade constraints before execution.
// Returns nil if the trade is allowed, a descriptive error if blocked.
func (g *Guardian) PreTradeCheck(ctx context.Context, tradeUSDValue float64) error {
if g.limits.MaxPositionSizeUSD > 0 && tradeUSDValue > g.limits.MaxPositionSizeUSD {
return fmt.Errorf("trade blocked: position size $%.2f exceeds max $%.2f",
tradeUSDValue, g.limits.MaxPositionSizeUSD)
}
if g.limits.MaxDailyTrades > 0 && g.counter != nil {
count, err := g.counter.CountToday(ctx)
if err != nil {
return fmt.Errorf("trade blocked: unable to verify daily trade count: %w", err)
}
if count >= g.limits.MaxDailyTrades {
return fmt.Errorf("trade blocked: daily limit of %d trades reached (%d executed today)",
g.limits.MaxDailyTrades, count)
}
}
return nil
}
// PortfolioCheck evaluates portfolio-level circuit breakers.
// pnlPercent is the unrealized P&L as a percentage (e.g. -8.5 means down 8.5%).
// Returns nil if trading should continue, a descriptive error if a breaker tripped.
func (g *Guardian) PortfolioCheck(pnlPercent float64) error {
if g.limits.StopLossPercent > 0 && pnlPercent <= -g.limits.StopLossPercent {
return fmt.Errorf("STOP-LOSS triggered: portfolio down %.2f%% (threshold: -%.2f%%)",
pnlPercent, g.limits.StopLossPercent)
}
if g.limits.TakeProfitPercent > 0 && pnlPercent >= g.limits.TakeProfitPercent {
return fmt.Errorf("TAKE-PROFIT triggered: portfolio up %.2f%% (threshold: +%.2f%%)",
pnlPercent, g.limits.TakeProfitPercent)
}
return nil
}

View File

@@ -0,0 +1,153 @@
package risk
import (
"context"
"fmt"
"testing"
)
type mockCounter struct {
count int
err error
}
func (m *mockCounter) CountToday(_ context.Context) (int, error) {
return m.count, m.err
}
// --- PreTradeCheck ---
func TestPreTradeCheck_PositionSize_Allowed(t *testing.T) {
g := NewGuardian(Limits{MaxPositionSizeUSD: 500}, &mockCounter{})
if err := g.PreTradeCheck(context.Background(), 499.99); err != nil {
t.Fatalf("expected trade to be allowed, got: %v", err)
}
}
func TestPreTradeCheck_PositionSize_Blocked(t *testing.T) {
g := NewGuardian(Limits{MaxPositionSizeUSD: 500}, &mockCounter{})
err := g.PreTradeCheck(context.Background(), 500.01)
if err == nil {
t.Fatal("expected trade to be blocked")
}
t.Logf("Correctly blocked: %v", err)
}
func TestPreTradeCheck_PositionSize_DisabledWhenZero(t *testing.T) {
g := NewGuardian(Limits{MaxPositionSizeUSD: 0}, &mockCounter{})
if err := g.PreTradeCheck(context.Background(), 999999); err != nil {
t.Fatalf("zero limit should disable check, got: %v", err)
}
}
func TestPreTradeCheck_DailyTrades_Allowed(t *testing.T) {
g := NewGuardian(Limits{MaxDailyTrades: 50}, &mockCounter{count: 49})
if err := g.PreTradeCheck(context.Background(), 100); err != nil {
t.Fatalf("expected trade to be allowed (49/50), got: %v", err)
}
}
func TestPreTradeCheck_DailyTrades_Blocked(t *testing.T) {
g := NewGuardian(Limits{MaxDailyTrades: 50}, &mockCounter{count: 50})
err := g.PreTradeCheck(context.Background(), 100)
if err == nil {
t.Fatal("expected trade to be blocked (50/50)")
}
t.Logf("Correctly blocked: %v", err)
}
func TestPreTradeCheck_DailyTrades_CounterError(t *testing.T) {
g := NewGuardian(Limits{MaxDailyTrades: 50}, &mockCounter{err: fmt.Errorf("db down")})
err := g.PreTradeCheck(context.Background(), 100)
if err == nil {
t.Fatal("expected error when counter fails")
}
t.Logf("Correctly blocked on counter error: %v", err)
}
func TestPreTradeCheck_DailyTrades_DisabledWhenZero(t *testing.T) {
g := NewGuardian(Limits{MaxDailyTrades: 0}, &mockCounter{count: 9999})
if err := g.PreTradeCheck(context.Background(), 100); err != nil {
t.Fatalf("zero limit should disable check, got: %v", err)
}
}
func TestPreTradeCheck_BothChecks_PositionSizeFailsFirst(t *testing.T) {
g := NewGuardian(Limits{
MaxPositionSizeUSD: 100,
MaxDailyTrades: 50,
}, &mockCounter{count: 49})
err := g.PreTradeCheck(context.Background(), 200)
if err == nil {
t.Fatal("expected trade to be blocked by position size")
}
t.Logf("Correctly blocked: %v", err)
}
func TestPreTradeCheck_AllDisabled(t *testing.T) {
g := NewGuardian(Limits{}, &mockCounter{count: 9999})
if err := g.PreTradeCheck(context.Background(), 999999); err != nil {
t.Fatalf("all-zero limits should allow everything, got: %v", err)
}
}
// --- PortfolioCheck ---
func TestPortfolioCheck_StopLoss_Triggered(t *testing.T) {
g := NewGuardian(Limits{StopLossPercent: 10}, nil)
err := g.PortfolioCheck(-10.0)
if err == nil {
t.Fatal("expected stop-loss to trigger at -10%")
}
t.Logf("Correctly triggered: %v", err)
}
func TestPortfolioCheck_StopLoss_NotTriggered(t *testing.T) {
g := NewGuardian(Limits{StopLossPercent: 10}, nil)
if err := g.PortfolioCheck(-9.99); err != nil {
t.Fatalf("expected no trigger at -9.99%%, got: %v", err)
}
}
func TestPortfolioCheck_TakeProfit_Triggered(t *testing.T) {
g := NewGuardian(Limits{TakeProfitPercent: 20}, nil)
err := g.PortfolioCheck(20.0)
if err == nil {
t.Fatal("expected take-profit to trigger at +20%")
}
t.Logf("Correctly triggered: %v", err)
}
func TestPortfolioCheck_TakeProfit_NotTriggered(t *testing.T) {
g := NewGuardian(Limits{TakeProfitPercent: 20}, nil)
if err := g.PortfolioCheck(19.99); err != nil {
t.Fatalf("expected no trigger at +19.99%%, got: %v", err)
}
}
func TestPortfolioCheck_BothDisabled(t *testing.T) {
g := NewGuardian(Limits{}, nil)
if err := g.PortfolioCheck(-99); err != nil {
t.Fatalf("zero limits should disable all checks, got: %v", err)
}
if err := g.PortfolioCheck(99); err != nil {
t.Fatalf("zero limits should disable all checks, got: %v", err)
}
}
func TestPortfolioCheck_StopLoss_ExactBoundary(t *testing.T) {
g := NewGuardian(Limits{StopLossPercent: 5}, nil)
err := g.PortfolioCheck(-5.0)
if err == nil {
t.Fatal("expected stop-loss to trigger at exactly -5%")
}
}
func TestPortfolioCheck_TakeProfit_ExactBoundary(t *testing.T) {
g := NewGuardian(Limits{TakeProfitPercent: 15}, nil)
err := g.PortfolioCheck(15.0)
if err == nil {
t.Fatal("expected take-profit to trigger at exactly +15%")
}
}

View File

@@ -0,0 +1,232 @@
package scheduler
import (
"context"
"fmt"
"math"
"sync"
"time"
"github.com/kjannette/trahn-backend/internal/external"
"github.com/kjannette/trahn-backend/internal/models"
"github.com/kjannette/trahn-backend/internal/repository"
"github.com/kjannette/trahn-backend/internal/strategy"
)
// BotState is the subset of bot state the scheduler needs for decision-making.
type BotState struct {
Grid []strategy.GridLevel
LastETHPrice float64
}
// BotStateProvider returns the current bot state, or nil if unavailable.
type BotStateProvider func() *BotState
type SRSchedulerConfig struct {
CronInterval time.Duration // e.g. 1*time.Hour
SRChangeThreshold float64 // e.g. 5.0 (percent)
GetBotState BotStateProvider
OnSRUpdate func(sr *external.SRResult)
OnGridRecalculate func(sr *external.SRResult)
}
type SRScheduler struct {
dune *external.DuneClient
srRepo *repository.SRRepo
cfg SRSchedulerConfig
mu sync.Mutex
running bool
stopCh chan struct{}
}
func NewSRScheduler(dune *external.DuneClient, srRepo *repository.SRRepo, cfg SRSchedulerConfig) *SRScheduler {
if cfg.CronInterval <= 0 {
cfg.CronInterval = 1 * time.Hour
}
if cfg.SRChangeThreshold <= 0 {
cfg.SRChangeThreshold = 5
}
return &SRScheduler{
dune: dune,
srRepo: srRepo,
cfg: cfg,
}
}
func (s *SRScheduler) Start() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
fmt.Println("[SR-SCHEDULER] Already running")
return
}
s.running = true
s.stopCh = make(chan struct{})
s.mu.Unlock()
// Initial fetch on startup (fire-and-forget)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if err := s.fetchAndProcess(ctx); err != nil {
fmt.Printf("[SR-SCHEDULER] Initial S/R fetch failed: %v\n", err)
}
}()
// Recurring ticker
go func() {
ticker := time.NewTicker(s.cfg.CronInterval)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
if err := s.fetchAndProcess(ctx); err != nil {
fmt.Printf("[SR-SCHEDULER] S/R fetch failed: %v\n", err)
}
cancel()
}
}
}()
fmt.Printf("[SR-SCHEDULER] Started (every %s with intelligent recalculation)\n", s.cfg.CronInterval)
}
func (s *SRScheduler) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
close(s.stopCh)
s.running = false
fmt.Println("[SR-SCHEDULER] Stopped")
}
func (s *SRScheduler) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// FetchNow manually triggers a fetch outside the normal schedule.
func (s *SRScheduler) FetchNow(ctx context.Context) error {
fmt.Println("[SR-SCHEDULER] Manual S/R fetch triggered")
return s.fetchAndProcess(ctx)
}
func (s *SRScheduler) fetchAndProcess(ctx context.Context) error {
fmt.Println("[SR-SCHEDULER] Fetching S/R levels from Dune...")
sr, err := s.dune.FetchSupportResistance(ctx, true)
if err != nil {
return fmt.Errorf("fetch S/R: %w", err)
}
shouldRecalculate := false
var reasons []string
// Condition 1: S/R midpoint changed > threshold
newSR := &models.SupportResistance{Midpoint: sr.Midpoint}
change, err := s.srRepo.CheckSignificantChange(ctx, newSR, s.cfg.SRChangeThreshold)
if err != nil {
fmt.Printf("[SR-SCHEDULER] Warning: could not check S/R change: %v\n", err)
} else if change.HasChanged {
shouldRecalculate = true
pct := ""
if change.ChangePercent != nil {
pct = fmt.Sprintf("%.2f%%", *change.ChangePercent)
}
reasons = append(reasons, fmt.Sprintf("S/R midpoint changed %s", pct))
}
// Conditions 2 & 3: need bot state
if s.cfg.GetBotState != nil {
if bot := s.cfg.GetBotState(); bot != nil && len(bot.Grid) > 0 {
// Condition 2: Price outside grid range
if bot.LastETHPrice > 0 && strategy.IsPriceOutsideGrid(bot.LastETHPrice, bot.Grid) {
shouldRecalculate = true
lo, hi := gridRange(bot.Grid)
reasons = append(reasons, fmt.Sprintf("Price $%.2f outside grid range ($%.2f - $%.2f)",
bot.LastETHPrice, lo, hi))
}
// Condition 3: All buys or all sells filled
if strategy.AreAllSideFilled(bot.Grid, "buy") {
shouldRecalculate = true
reasons = append(reasons, "All buy levels filled - opportunity to reset")
}
if strategy.AreAllSideFilled(bot.Grid, "sell") {
shouldRecalculate = true
reasons = append(reasons, "All sell levels filled - opportunity to reset")
}
}
}
// Store S/R in database
avgPrice := sr.AvgPrice
_, err = s.srRepo.Record(ctx, &models.SupportResistance{
Timestamp: time.Now(),
Method: sr.Method,
LookbackDays: sr.LookbackDays,
Support: sr.Support,
Resistance: sr.Resistance,
Midpoint: sr.Midpoint,
AvgPrice: &avgPrice,
GridRecalculated: shouldRecalculate,
})
if err != nil {
return fmt.Errorf("record S/R: %w", err)
}
fmt.Printf("[SR-SCHEDULER] S/R stored: Support $%.2f | Resistance $%.2f | Midpoint $%.2f\n",
sr.Support, sr.Resistance, sr.Midpoint)
if s.cfg.OnSRUpdate != nil {
s.cfg.OnSRUpdate(sr)
}
if shouldRecalculate {
fmt.Printf("[SR-SCHEDULER] RECALCULATING GRID - Reasons: %s\n", joinReasons(reasons))
if s.cfg.OnGridRecalculate != nil {
s.cfg.OnGridRecalculate(sr)
}
} else {
pctStr := "0"
if change != nil && change.ChangePercent != nil {
pctStr = fmt.Sprintf("%.2f", *change.ChangePercent)
}
fmt.Printf("[SR-SCHEDULER] Grid stable - no recalculation needed\n")
fmt.Printf(" S/R change: %s%% (threshold: %.0f%%)\n", pctStr, s.cfg.SRChangeThreshold)
}
return nil
}
func gridRange(grid []strategy.GridLevel) (lo, hi float64) {
lo = math.MaxFloat64
hi = -math.MaxFloat64
for _, g := range grid {
if g.Price < lo {
lo = g.Price
}
if g.Price > hi {
hi = g.Price
}
}
return
}
func joinReasons(reasons []string) string {
if len(reasons) == 0 {
return "none"
}
out := reasons[0]
for _, r := range reasons[1:] {
out += ", " + r
}
return out
}

View File

@@ -0,0 +1,150 @@
package scheduler_test
import (
"context"
"os"
"sync/atomic"
"testing"
"time"
"github.com/kjannette/trahn-backend/internal/external"
"github.com/kjannette/trahn-backend/internal/repository"
"github.com/kjannette/trahn-backend/internal/scheduler"
"github.com/kjannette/trahn-backend/internal/strategy"
"github.com/kjannette/trahn-backend/internal/testutil"
)
func TestSRScheduler_FetchNow(t *testing.T) {
apiKey := os.Getenv("DUNE_API_KEY")
if apiKey == "" {
t.Skip("DUNE_API_KEY not set, skipping")
}
pool := testutil.SetupPool(t)
srRepo := repository.NewSRRepo(pool)
dune := external.NewDuneClient(apiKey, external.DuneOptions{
Method: "simple",
LookbackDays: 14,
RefreshHours: 48,
})
var srUpdated atomic.Bool
var recalculated atomic.Bool
sched := scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
CronInterval: 1 * time.Hour,
SRChangeThreshold: 5,
OnSRUpdate: func(sr *external.SRResult) {
srUpdated.Store(true)
},
OnGridRecalculate: func(sr *external.SRResult) {
recalculated.Store(true)
},
})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
err := sched.FetchNow(ctx)
if err != nil {
t.Fatalf("FetchNow: %v", err)
}
if !srUpdated.Load() {
t.Fatal("OnSRUpdate callback was not called")
}
t.Log("OnSRUpdate: called")
t.Logf("OnGridRecalculate called: %v", recalculated.Load())
// Verify it was stored in DB
latest, err := srRepo.GetLatest(ctx)
if err != nil {
t.Fatalf("GetLatest: %v", err)
}
if latest == nil {
t.Fatal("expected S/R record in DB")
}
t.Logf("DB record: support=$%.2f resistance=$%.2f midpoint=$%.2f recalc=%v",
latest.Support, latest.Resistance, latest.Midpoint, latest.GridRecalculated)
}
func TestSRScheduler_WithBotState_PriceOutside(t *testing.T) {
apiKey := os.Getenv("DUNE_API_KEY")
if apiKey == "" {
t.Skip("DUNE_API_KEY not set, skipping")
}
pool := testutil.SetupPool(t)
srRepo := repository.NewSRRepo(pool)
dune := external.NewDuneClient(apiKey, external.DuneOptions{
Method: "simple",
LookbackDays: 14,
RefreshHours: 48,
})
var recalculated atomic.Bool
var recalcReasons string
// Simulate a grid that is far from current market price
fakeBotState := &scheduler.BotState{
Grid: []strategy.GridLevel{
{Index: 0, Price: 1000, Side: "buy"},
{Index: 1, Price: 1050, Side: "buy"},
{Index: 2, Price: 1100, Side: "sell"},
{Index: 3, Price: 1150, Side: "sell"},
},
LastETHPrice: 1962, // current real price, way outside the grid
}
sched := scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
CronInterval: 1 * time.Hour,
SRChangeThreshold: 50, // high threshold so only bot state triggers recalc
GetBotState: func() *scheduler.BotState {
return fakeBotState
},
OnGridRecalculate: func(sr *external.SRResult) {
recalculated.Store(true)
recalcReasons = "price outside grid"
},
})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
err := sched.FetchNow(ctx)
if err != nil {
t.Fatalf("FetchNow: %v", err)
}
if !recalculated.Load() {
t.Fatal("expected recalculation due to price outside grid")
}
t.Logf("Recalculated: true (reason: %s)", recalcReasons)
}
func TestSRScheduler_StartStop(t *testing.T) {
pool := testutil.SetupPool(t)
srRepo := repository.NewSRRepo(pool)
// Use a dummy Dune client (no API key — won't actually fetch)
dune := external.NewDuneClient("", external.DuneOptions{})
sched := scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
CronInterval: 1 * time.Hour,
})
sched.Start()
if !sched.Running() {
t.Fatal("expected running after Start")
}
// Give initial goroutine a moment (it will fail due to no API key, that's fine)
time.Sleep(200 * time.Millisecond)
sched.Stop()
if sched.Running() {
t.Fatal("expected not running after Stop")
}
t.Log("Start/Stop lifecycle: OK")
}

View File

@@ -0,0 +1,247 @@
package strategy
import (
"fmt"
"math"
"sort"
"strings"
"time"
)
type GridLevel struct {
Index int `json:"index"`
Price float64 `json:"price"`
Side string `json:"side"` // "buy" or "sell"
Quantity float64 `json:"quantity"`
Filled bool `json:"filled"`
FilledAt *time.Time `json:"filledAt,omitempty"`
TxHash *string `json:"txHash,omitempty"`
}
type GridStats struct {
Levels int `json:"levels"`
LowestPrice *float64 `json:"lowestPrice"`
HighestPrice *float64 `json:"highestPrice"`
FilledLevels int `json:"filledLevels"`
PendingBuys int `json:"pendingBuys"`
PendingSells int `json:"pendingSells"`
FilledBuys int `json:"filledBuys"`
FilledSells int `json:"filledSells"`
}
type FallbackSR struct {
Support float64 `json:"support"`
Resistance float64 `json:"resistance"`
Midpoint float64 `json:"midpoint"`
Method string `json:"method"`
LookbackDays int `json:"lookbackDays"`
}
func CalculateMidpoint(support, resistance float64) (float64, error) {
if support >= resistance {
return 0, fmt.Errorf("invalid S/R: support (%.2f) >= resistance (%.2f)", support, resistance)
}
return (support + resistance) / 2, nil
}
type GridParams struct {
CenterPrice float64
LevelCount int
SpacingPercent float64
AmountPerGrid float64
}
func CalculateGridLevels(p GridParams) ([]GridLevel, error) {
if p.CenterPrice <= 0 {
return nil, fmt.Errorf("center price must be positive")
}
if p.LevelCount < 2 {
return nil, fmt.Errorf("level count must be at least 2")
}
if p.SpacingPercent <= 0 {
return nil, fmt.Errorf("spacing percent must be positive")
}
if p.AmountPerGrid <= 0 {
return nil, fmt.Errorf("amount per grid must be positive")
}
halfLevels := p.LevelCount / 2
even := p.LevelCount%2 == 0
var grid []GridLevel
for i := -halfLevels; i <= halfLevels; i++ {
if i == 0 && even {
continue
}
multiplier := math.Pow(1+p.SpacingPercent/100, float64(i))
levelPrice := p.CenterPrice * multiplier
side := "sell"
if i < 0 {
side = "buy"
}
quantity := p.AmountPerGrid / levelPrice
grid = append(grid, GridLevel{
Price: levelPrice,
Side: side,
Quantity: quantity,
})
}
sort.Slice(grid, func(i, j int) bool {
return grid[i].Price < grid[j].Price
})
for i := range grid {
grid[i].Index = i
}
return grid, nil
}
func FindTriggeredLevel(currentPrice float64, grid []GridLevel) *GridLevel {
for i := range grid {
if grid[i].Filled {
continue
}
if grid[i].Side == "buy" && currentPrice <= grid[i].Price {
return &grid[i]
}
if grid[i].Side == "sell" && currentPrice >= grid[i].Price {
return &grid[i]
}
}
return nil
}
func GetOppositeLevelIndex(filledLevel *GridLevel, gridLength int) *int {
var idx int
if filledLevel.Side == "buy" {
idx = filledLevel.Index + 1
} else {
idx = filledLevel.Index - 1
}
if idx >= 0 && idx < gridLength {
return &idx
}
return nil
}
func GetGridStats(grid []GridLevel) GridStats {
if len(grid) == 0 {
return GridStats{}
}
s := GridStats{Levels: len(grid)}
lo := grid[0].Price
hi := grid[len(grid)-1].Price
s.LowestPrice = &lo
s.HighestPrice = &hi
for _, l := range grid {
switch {
case l.Side == "buy" && l.Filled:
s.FilledBuys++
s.FilledLevels++
case l.Side == "buy" && !l.Filled:
s.PendingBuys++
case l.Side == "sell" && l.Filled:
s.FilledSells++
s.FilledLevels++
case l.Side == "sell" && !l.Filled:
s.PendingSells++
}
}
return s
}
func FormatGridDisplay(grid []GridLevel, centerPrice, amountPerGrid float64) string {
if len(grid) == 0 {
return "No grid levels initialized."
}
sorted := make([]GridLevel, len(grid))
copy(sorted, grid)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Price > sorted[j].Price
})
var b strings.Builder
b.WriteString("┌─────────────────────────────────────────────────┐\n")
b.WriteString("│ GRID LEVELS (USD) │\n")
b.WriteString("├─────────────────────────────────────────────────┤\n")
for _, level := range sorted {
sideIcon := "BUY "
if level.Side == "sell" {
sideIcon = "SELL"
}
status := "[ ]"
if level.Filled {
status = "[X]"
}
fmt.Fprintf(&b, "│ %s %s @ %10.2f │ %15s │\n",
status, sideIcon, level.Price,
fmt.Sprintf("%.6f ETH", level.Quantity))
}
b.WriteString("├─────────────────────────────────────────────────┤\n")
fmt.Fprintf(&b, "│ Center: $%8.2f │ $%.0f/level │\n", centerPrice, amountPerGrid)
b.WriteString("└─────────────────────────────────────────────────┘")
return b.String()
}
func CreateFallbackSR(currentPrice float64) FallbackSR {
return FallbackSR{
Support: currentPrice * 0.9,
Resistance: currentPrice * 1.1,
Midpoint: currentPrice,
Method: "fallback",
}
}
func IsPriceOutsideGrid(currentPrice float64, grid []GridLevel) bool {
if len(grid) == 0 {
return true
}
lo := grid[0].Price
hi := grid[0].Price
for _, l := range grid[1:] {
if l.Price < lo {
lo = l.Price
}
if l.Price > hi {
hi = l.Price
}
}
return currentPrice < lo || currentPrice > hi
}
func AreAllSideFilled(grid []GridLevel, side string) bool {
count := 0
filled := 0
for _, l := range grid {
if l.Side == side {
count++
if l.Filled {
filled++
}
}
}
if count == 0 {
return false
}
return filled == count
}
func CalculateSRChange(newMidpoint, oldMidpoint float64) float64 {
if oldMidpoint == 0 {
return 100
}
return math.Abs((newMidpoint - oldMidpoint) / oldMidpoint * 100)
}

View File

@@ -0,0 +1,323 @@
package strategy
import (
"math"
"testing"
)
func TestCalculateMidpoint(t *testing.T) {
mid, err := CalculateMidpoint(2400, 3000)
if err != nil {
t.Fatal(err)
}
if mid != 2700 {
t.Fatalf("expected 2700, got %f", mid)
}
_, err = CalculateMidpoint(3000, 2400)
if err == nil {
t.Fatal("expected error for support >= resistance")
}
_, err = CalculateMidpoint(2500, 2500)
if err == nil {
t.Fatal("expected error for support == resistance")
}
}
func TestCalculateGridLevels(t *testing.T) {
grid, err := CalculateGridLevels(GridParams{
CenterPrice: 2700,
LevelCount: 10,
SpacingPercent: 2,
AmountPerGrid: 100,
})
if err != nil {
t.Fatal(err)
}
if len(grid) != 10 {
t.Fatalf("expected 10 levels, got %d", len(grid))
}
// Sorted ascending
for i := 1; i < len(grid); i++ {
if grid[i].Price <= grid[i-1].Price {
t.Fatalf("not sorted ascending at index %d: %.2f <= %.2f", i, grid[i].Price, grid[i-1].Price)
}
}
// Indices sequential
for i, l := range grid {
if l.Index != i {
t.Fatalf("index mismatch at %d: got %d", i, l.Index)
}
}
// Lower half = buy, upper half = sell
buys := 0
sells := 0
for _, l := range grid {
if l.Side == "buy" {
buys++
} else {
sells++
}
if l.Quantity <= 0 {
t.Fatalf("quantity must be positive: %.6f", l.Quantity)
}
if l.Filled {
t.Fatal("new levels should not be filled")
}
}
if buys != 5 || sells != 5 {
t.Fatalf("expected 5 buys + 5 sells, got %d buys + %d sells", buys, sells)
}
// Buy levels should have prices below center, sell above
for _, l := range grid {
if l.Side == "buy" && l.Price >= 2700 {
t.Fatalf("buy level at %.2f should be below center 2700", l.Price)
}
if l.Side == "sell" && l.Price <= 2700 {
t.Fatalf("sell level at %.2f should be above center 2700", l.Price)
}
}
t.Logf("Grid levels (center=2700, 10 levels, 2%% spacing):")
for _, l := range grid {
t.Logf(" [%d] %s @ $%.2f qty=%.6f ETH", l.Index, l.Side, l.Price, l.Quantity)
}
}
func TestCalculateGridLevels_OddCount(t *testing.T) {
grid, err := CalculateGridLevels(GridParams{
CenterPrice: 2000,
LevelCount: 7,
SpacingPercent: 3,
AmountPerGrid: 50,
})
if err != nil {
t.Fatal(err)
}
if len(grid) != 7 {
t.Fatalf("expected 7 levels, got %d", len(grid))
}
t.Logf("Odd grid: %d levels", len(grid))
}
func TestCalculateGridLevels_Validation(t *testing.T) {
cases := []GridParams{
{CenterPrice: -1, LevelCount: 10, SpacingPercent: 2, AmountPerGrid: 100},
{CenterPrice: 2700, LevelCount: 1, SpacingPercent: 2, AmountPerGrid: 100},
{CenterPrice: 2700, LevelCount: 10, SpacingPercent: 0, AmountPerGrid: 100},
{CenterPrice: 2700, LevelCount: 10, SpacingPercent: 2, AmountPerGrid: -5},
}
for i, c := range cases {
_, err := CalculateGridLevels(c)
if err == nil {
t.Fatalf("case %d: expected validation error", i)
}
}
}
func TestFindTriggeredLevel(t *testing.T) {
grid := []GridLevel{
{Index: 0, Price: 2550, Side: "buy"},
{Index: 1, Price: 2600, Side: "buy"},
{Index: 2, Price: 2700, Side: "sell"},
{Index: 3, Price: 2750, Side: "sell"},
}
// Price at 2540 triggers buy at 2550 (index 0)
triggered := FindTriggeredLevel(2540, grid)
if triggered == nil {
t.Fatal("expected a triggered level")
}
if triggered.Index != 0 {
t.Fatalf("expected index 0 (buy at 2550), got %d", triggered.Index)
}
// Price at 2590 triggers buy at 2600 (index 1), not 2550
triggered = FindTriggeredLevel(2590, grid)
if triggered == nil {
t.Fatal("expected a triggered level")
}
if triggered.Index != 1 {
t.Fatalf("expected index 1 (buy at 2600), got %d", triggered.Index)
}
// Price at 2710 triggers sell at 2700
triggered = FindTriggeredLevel(2710, grid)
if triggered == nil {
t.Fatal("expected a triggered level")
}
if triggered.Index != 2 {
t.Fatalf("expected index 2 (sell at 2700), got %d", triggered.Index)
}
// Price at 2650 — no trigger (between buy and sell)
triggered = FindTriggeredLevel(2650, grid)
if triggered != nil {
t.Fatalf("expected no trigger at 2650, got index %d", triggered.Index)
}
// Filled levels are skipped
grid[0].Filled = true
triggered = FindTriggeredLevel(2540, grid)
if triggered == nil {
t.Fatal("expected triggered level")
}
if triggered.Index != 1 {
t.Fatalf("expected index 1 (skipping filled 0), got %d", triggered.Index)
}
}
func TestGetOppositeLevelIndex(t *testing.T) {
buy := &GridLevel{Index: 2, Side: "buy"}
sell := &GridLevel{Index: 3, Side: "sell"}
idx := GetOppositeLevelIndex(buy, 6)
if idx == nil || *idx != 3 {
t.Fatalf("buy at 2: expected opposite 3, got %v", idx)
}
idx = GetOppositeLevelIndex(sell, 6)
if idx == nil || *idx != 2 {
t.Fatalf("sell at 3: expected opposite 2, got %v", idx)
}
// Out of bounds
edge := &GridLevel{Index: 0, Side: "sell"}
idx = GetOppositeLevelIndex(edge, 5)
if idx != nil {
t.Fatalf("expected nil for out-of-bounds, got %d", *idx)
}
top := &GridLevel{Index: 4, Side: "buy"}
idx = GetOppositeLevelIndex(top, 5)
if idx != nil {
t.Fatalf("expected nil for out-of-bounds, got %d", *idx)
}
}
func TestGetGridStats(t *testing.T) {
grid := []GridLevel{
{Index: 0, Price: 2500, Side: "buy", Filled: true},
{Index: 1, Price: 2600, Side: "buy", Filled: false},
{Index: 2, Price: 2700, Side: "sell", Filled: false},
{Index: 3, Price: 2800, Side: "sell", Filled: true},
}
s := GetGridStats(grid)
if s.Levels != 4 {
t.Fatalf("expected 4 levels, got %d", s.Levels)
}
if s.FilledBuys != 1 || s.PendingBuys != 1 {
t.Fatalf("buys: filled=%d pending=%d", s.FilledBuys, s.PendingBuys)
}
if s.FilledSells != 1 || s.PendingSells != 1 {
t.Fatalf("sells: filled=%d pending=%d", s.FilledSells, s.PendingSells)
}
if *s.LowestPrice != 2500 || *s.HighestPrice != 2800 {
t.Fatalf("price range: %.2f - %.2f", *s.LowestPrice, *s.HighestPrice)
}
// Empty grid
empty := GetGridStats(nil)
if empty.Levels != 0 {
t.Fatal("expected 0 levels for nil grid")
}
}
func TestFormatGridDisplay(t *testing.T) {
grid := []GridLevel{
{Index: 0, Price: 2600, Side: "buy", Quantity: 0.0385},
{Index: 1, Price: 2700, Side: "sell", Quantity: 0.0370, Filled: true},
}
out := FormatGridDisplay(grid, 2650, 100)
if out == "" {
t.Fatal("expected non-empty display")
}
t.Logf("\n%s", out)
empty := FormatGridDisplay(nil, 0, 0)
if empty != "No grid levels initialized." {
t.Fatalf("expected empty message, got: %s", empty)
}
}
func TestCreateFallbackSR(t *testing.T) {
sr := CreateFallbackSR(2000)
if sr.Support != 1800 {
t.Fatalf("expected support 1800, got %.2f", sr.Support)
}
if sr.Resistance != 2200 {
t.Fatalf("expected resistance 2200, got %.2f", sr.Resistance)
}
if sr.Midpoint != 2000 {
t.Fatalf("expected midpoint 2000, got %.2f", sr.Midpoint)
}
if sr.Method != "fallback" {
t.Fatalf("expected method fallback, got %s", sr.Method)
}
}
func TestIsPriceOutsideGrid(t *testing.T) {
grid := []GridLevel{
{Price: 2500},
{Price: 2600},
{Price: 2700},
{Price: 2800},
}
if IsPriceOutsideGrid(2650, grid) {
t.Fatal("2650 should be inside [2500, 2800]")
}
if !IsPriceOutsideGrid(2400, grid) {
t.Fatal("2400 should be outside")
}
if !IsPriceOutsideGrid(2900, grid) {
t.Fatal("2900 should be outside")
}
if !IsPriceOutsideGrid(1000, nil) {
t.Fatal("empty grid should return outside")
}
}
func TestAreAllSideFilled(t *testing.T) {
grid := []GridLevel{
{Side: "buy", Filled: true},
{Side: "buy", Filled: true},
{Side: "sell", Filled: false},
{Side: "sell", Filled: true},
}
if !AreAllSideFilled(grid, "buy") {
t.Fatal("all buys are filled")
}
if AreAllSideFilled(grid, "sell") {
t.Fatal("not all sells are filled")
}
if AreAllSideFilled(nil, "buy") {
t.Fatal("empty grid should return false")
}
}
func TestCalculateSRChange(t *testing.T) {
pct := CalculateSRChange(2800, 2700)
expected := math.Abs((2800 - 2700) / 2700.0 * 100)
if math.Abs(pct-expected) > 0.001 {
t.Fatalf("expected %.4f, got %.4f", expected, pct)
}
pct = CalculateSRChange(2700, 0)
if pct != 100 {
t.Fatalf("expected 100 for zero old midpoint, got %.2f", pct)
}
pct = CalculateSRChange(2700, 2700)
if pct != 0 {
t.Fatalf("expected 0 for no change, got %.2f", pct)
}
}

View File

@@ -0,0 +1,42 @@
package testutil
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
// SetupPool creates a pgxpool.Pool for integration tests.
// Connection details come from env vars or sensible defaults.
func SetupPool(t *testing.T) *pgxpool.Pool {
t.Helper()
_ = godotenv.Load("../../.env")
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
host := EnvOr("DB_HOST", "localhost")
port := EnvOr("DB_PORT", "5432")
name := EnvOr("DB_NAME", "trahn_grid_trader")
user := EnvOr("DB_USER", "postgres")
pass := EnvOr("DB_PASSWORD", "")
dsn = "postgres://" + user + ":" + pass + "@" + host + ":" + port + "/" + name + "?sslmode=disable"
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { pool.Close() })
return pool
}
func EnvOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}