first commit of restructured project
This commit is contained in:
48
trahn-trade-backend/internal/api/grid_routes.go
Normal file
48
trahn-trade-backend/internal/api/grid_routes.go
Normal 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,
|
||||
})
|
||||
}
|
||||
29
trahn-trade-backend/internal/api/health.go
Normal file
29
trahn-trade-backend/internal/api/health.go
Normal 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},
|
||||
})
|
||||
}
|
||||
187
trahn-trade-backend/internal/api/middleware_test.go
Normal file
187
trahn-trade-backend/internal/api/middleware_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
79
trahn-trade-backend/internal/api/price_routes.go
Normal file
79
trahn-trade-backend/internal/api/price_routes.go
Normal 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})
|
||||
}
|
||||
169
trahn-trade-backend/internal/api/server.go
Normal file
169
trahn-trade-backend/internal/api/server.go
Normal 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})
|
||||
}
|
||||
52
trahn-trade-backend/internal/api/sr_routes.go
Normal file
52
trahn-trade-backend/internal/api/sr_routes.go
Normal 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)
|
||||
}
|
||||
132
trahn-trade-backend/internal/api/trade_routes.go
Normal file
132
trahn-trade-backend/internal/api/trade_routes.go
Normal 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)
|
||||
}
|
||||
548
trahn-trade-backend/internal/bot/gridbot.go
Normal file
548
trahn-trade-backend/internal/bot/gridbot.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/config"
|
||||
"github.com/kjannette/trahn-backend/internal/ethereum"
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/notifications"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/risk"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
)
|
||||
|
||||
type GridBot struct {
|
||||
cfg *config.Config
|
||||
coingecko *external.CoinGeckoClient
|
||||
dune *external.DuneClient
|
||||
priceRepo *repository.PriceRepo
|
||||
tradeRepo *repository.TradeRepo
|
||||
gridRepo *repository.GridStateRepo
|
||||
notify *notifications.Sender
|
||||
|
||||
Grid []strategy.GridLevel
|
||||
LastETHPrice float64
|
||||
BasePrice float64
|
||||
TradesExecuted int
|
||||
TotalProfit float64
|
||||
PriceChecks int
|
||||
LastStatusReport time.Time
|
||||
LastSRRefresh *time.Time
|
||||
|
||||
guardian *risk.Guardian
|
||||
paperWallet *PaperWallet
|
||||
uniswap *ethereum.UniswapV2
|
||||
ethClient *ethereum.Client
|
||||
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewGridBot(
|
||||
cfg *config.Config,
|
||||
priceRepo *repository.PriceRepo,
|
||||
tradeRepo *repository.TradeRepo,
|
||||
gridRepo *repository.GridStateRepo,
|
||||
notify *notifications.Sender,
|
||||
dune *external.DuneClient,
|
||||
) *GridBot {
|
||||
b := &GridBot{
|
||||
cfg: cfg,
|
||||
coingecko: external.NewCoinGeckoClient(),
|
||||
dune: dune,
|
||||
priceRepo: priceRepo,
|
||||
tradeRepo: tradeRepo,
|
||||
gridRepo: gridRepo,
|
||||
notify: notify,
|
||||
stopCh: make(chan struct{}),
|
||||
guardian: risk.NewGuardian(risk.Limits{
|
||||
MaxDailyTrades: cfg.MaxDailyTrades,
|
||||
MaxPositionSizeUSD: cfg.MaxPositionSizeUSD,
|
||||
StopLossPercent: cfg.StopLossPercent,
|
||||
TakeProfitPercent: cfg.TakeProfitPercent,
|
||||
}, tradeRepo),
|
||||
}
|
||||
|
||||
if dune != nil {
|
||||
fmt.Printf("[S/R] Dune Analytics configured: %s method, %d-day lookback\n", cfg.SRMethod, cfg.SRLookbackDays)
|
||||
} else {
|
||||
fmt.Println("[S/R] Dune API key not set - using fallback (current price as midpoint)")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *GridBot) Init(ctx context.Context) error {
|
||||
if err := b.loadState(ctx); err != nil {
|
||||
fmt.Printf("Warning: failed to load state: %v\n", err)
|
||||
}
|
||||
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
b.paperWallet = NewPaperWallet(b.gridRepo, b.cfg.PaperInitialETH, b.cfg.PaperInitialUSDC)
|
||||
if err := b.paperWallet.Init(ctx); err != nil {
|
||||
return fmt.Errorf("paper wallet init: %w", err)
|
||||
}
|
||||
} else {
|
||||
ethC, err := ethereum.NewClient(
|
||||
b.cfg.EthereumAPIEndpoint,
|
||||
b.cfg.PrivateKey,
|
||||
int64(b.cfg.ChainID),
|
||||
b.cfg.GasLimit,
|
||||
b.cfg.GasMultiplier,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ethereum client: %w", err)
|
||||
}
|
||||
b.ethClient = ethC
|
||||
|
||||
uni, err := ethereum.NewUniswapV2(
|
||||
ethC,
|
||||
b.cfg.UniswapRouterAddress,
|
||||
b.cfg.WETHAddress,
|
||||
b.cfg.QuoteTokenAddress,
|
||||
b.cfg.QuoteTokenSymbol,
|
||||
b.cfg.QuoteTokenDecimals,
|
||||
b.cfg.SlippageTolerance,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uniswap client: %w", err)
|
||||
}
|
||||
b.uniswap = uni
|
||||
fmt.Printf("[LIVE] Ethereum client connected, wallet %s\n", ethC.WalletAddress().Hex())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- state management ---
|
||||
|
||||
func (b *GridBot) loadState(ctx context.Context) error {
|
||||
state, err := b.gridRepo.GetActive(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state == nil || state.GridLevelsJSON == nil {
|
||||
fmt.Println("No existing state found in DB - will initialize fresh")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(state.GridLevelsJSON, &b.Grid); err != nil {
|
||||
return fmt.Errorf("unmarshal grid: %w", err)
|
||||
}
|
||||
b.TradesExecuted = state.TradesExecuted
|
||||
b.TotalProfit = state.TotalProfit
|
||||
if state.BasePrice != nil {
|
||||
b.BasePrice = *state.BasePrice
|
||||
}
|
||||
b.LastSRRefresh = state.LastSRRefresh
|
||||
|
||||
fmt.Printf("Loaded state from DB: %d grid levels, %d trades\n", len(b.Grid), b.TradesExecuted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GridBot) saveState(ctx context.Context) {
|
||||
levelsJSON, err := json.Marshal(b.Grid)
|
||||
if err != nil {
|
||||
fmt.Printf("[STATE] Failed to marshal grid levels: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = b.gridRepo.Save(ctx, &models.GridState{
|
||||
BasePrice: &b.BasePrice,
|
||||
GridLevelsJSON: levelsJSON,
|
||||
TradesExecuted: b.TradesExecuted,
|
||||
TotalProfit: b.TotalProfit,
|
||||
LastSRRefresh: b.LastSRRefresh,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[STATE] Failed to save state to DB: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- price ---
|
||||
|
||||
func (b *GridBot) fetchETHPrice(ctx context.Context) float64 {
|
||||
price, err := b.coingecko.GetETHPrice(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to fetch ETH price: %v\n", err)
|
||||
return b.LastETHPrice
|
||||
}
|
||||
if price < 100 || price > 100000 {
|
||||
fmt.Printf("ETH price %.2f failed sanity check\n", price)
|
||||
return b.LastETHPrice
|
||||
}
|
||||
b.LastETHPrice = price
|
||||
|
||||
_, _ = b.priceRepo.Record(ctx, price, time.Now())
|
||||
return price
|
||||
}
|
||||
|
||||
// --- grid initialization ---
|
||||
|
||||
func (b *GridBot) InitializeGrid(ctx context.Context) error {
|
||||
sr := b.fetchSR(ctx)
|
||||
|
||||
center := b.BasePrice
|
||||
if center == 0 {
|
||||
center = sr.Midpoint
|
||||
}
|
||||
b.BasePrice = center
|
||||
|
||||
price := b.fetchETHPrice(ctx)
|
||||
if price <= 0 {
|
||||
return fmt.Errorf("cannot initialize grid: invalid ETH price")
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf("S/R Analysis (%s, %dd): Support $%.2f | Resistance $%.2f | Midpoint $%.2f",
|
||||
sr.Method, sr.LookbackDays, sr.Support, sr.Resistance, sr.Midpoint))
|
||||
|
||||
grid, err := strategy.CalculateGridLevels(strategy.GridParams{
|
||||
CenterPrice: center,
|
||||
LevelCount: b.cfg.GridLevels,
|
||||
SpacingPercent: b.cfg.GridSpacingPercent,
|
||||
AmountPerGrid: b.cfg.AmountPerGrid,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("calculate grid: %w", err)
|
||||
}
|
||||
b.Grid = grid
|
||||
b.saveState(ctx)
|
||||
|
||||
b.notify.Send(fmt.Sprintf("Grid initialized: %d levels from $%.2f to $%.2f, center at $%.2f",
|
||||
len(grid), grid[0].Price, grid[len(grid)-1].Price, center))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GridBot) fetchSR(ctx context.Context) *external.SRResult {
|
||||
if b.dune == nil {
|
||||
price := b.fetchETHPrice(ctx)
|
||||
fb := strategy.CreateFallbackSR(price)
|
||||
return &external.SRResult{
|
||||
Support: fb.Support, Resistance: fb.Resistance,
|
||||
Midpoint: fb.Midpoint, Method: fb.Method,
|
||||
}
|
||||
}
|
||||
|
||||
sr, err := b.dune.FetchSupportResistance(ctx, false)
|
||||
if err != nil {
|
||||
fmt.Printf("[S/R] Dune fetch failed: %v — falling back to current price\n", err)
|
||||
price := b.fetchETHPrice(ctx)
|
||||
fb := strategy.CreateFallbackSR(price)
|
||||
return &external.SRResult{
|
||||
Support: fb.Support, Resistance: fb.Resistance,
|
||||
Midpoint: fb.Midpoint, Method: fb.Method,
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
b.LastSRRefresh = &now
|
||||
return sr
|
||||
}
|
||||
|
||||
// --- trading ---
|
||||
|
||||
func (b *GridBot) executeTrade(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
|
||||
tradeUSD := level.Quantity * currentPrice
|
||||
if err := b.guardian.PreTradeCheck(ctx, tradeUSD); err != nil {
|
||||
b.notify.Send(fmt.Sprintf("[RISK] %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if level.Side == "buy" {
|
||||
return b.executeBuy(ctx, level, currentPrice)
|
||||
}
|
||||
return b.executeSell(ctx, level, currentPrice)
|
||||
}
|
||||
|
||||
func (b *GridBot) executeBuy(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
|
||||
return b.executeSwap(ctx, level, currentPrice, "buy")
|
||||
}
|
||||
|
||||
func (b *GridBot) executeSell(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
|
||||
return b.executeSwap(ctx, level, currentPrice, "sell")
|
||||
}
|
||||
|
||||
func (b *GridBot) executeSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string) error {
|
||||
ethAmount := level.Quantity
|
||||
usdcAmount := ethAmount * currentPrice
|
||||
prefix := ""
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
prefix = "[PAPER] "
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf("%sExecuting %s at grid level %d: ~%.6f ETH for ~%.2f USDC (@ $%.2f/ETH)",
|
||||
prefix, side, level.Index, ethAmount, usdcAmount, currentPrice))
|
||||
|
||||
var txHash string
|
||||
var slippagePct, gasCost *float64
|
||||
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
hash, slip, gas, err := b.executePaperSwap(ctx, level, currentPrice, side, ethAmount, usdcAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txHash = hash
|
||||
slippagePct = slip
|
||||
gasCost = gas
|
||||
} else {
|
||||
hash, gas, err := b.executeLiveSwap(ctx, side, ethAmount, usdcAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txHash = hash
|
||||
gasCost = gas
|
||||
}
|
||||
|
||||
level.Filled = true
|
||||
now := time.Now()
|
||||
level.FilledAt = &now
|
||||
level.TxHash = &txHash
|
||||
b.TradesExecuted++
|
||||
b.saveState(ctx)
|
||||
|
||||
gridLevel := level.Index
|
||||
_, _ = b.tradeRepo.Record(ctx, &models.Trade{
|
||||
Timestamp: now,
|
||||
Side: side,
|
||||
Price: currentPrice,
|
||||
Quantity: ethAmount,
|
||||
USDValue: usdcAmount,
|
||||
GridLevel: &gridLevel,
|
||||
TxHash: &txHash,
|
||||
IsPaperTrade: b.cfg.PaperTradingEnabled,
|
||||
SlippagePercent: slippagePct,
|
||||
GasCostETH: gasCost,
|
||||
})
|
||||
|
||||
b.resetOppositeLevel(ctx, level)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GridBot) executePaperSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string, ethAmount, usdcAmount float64) (txHash string, slippagePct, gasCost *float64, err error) {
|
||||
slip := randomSlippage(b.cfg.PaperSlippagePercent)
|
||||
gas := defaultPaperGasCost
|
||||
|
||||
if side == "buy" {
|
||||
actualETH := ethAmount * (1 - slip)
|
||||
if err := b.paperWallet.ExecuteBuy(ctx, usdcAmount, actualETH); err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
ethAmount = actualETH
|
||||
} else {
|
||||
if b.paperWallet.ETHBalance < ethAmount+gas {
|
||||
return "", nil, nil, fmt.Errorf("insufficient ETH: have %.6f, need %.6f", b.paperWallet.ETHBalance, ethAmount+gas)
|
||||
}
|
||||
usdcAmount = usdcAmount * (1 - slip)
|
||||
if err := b.paperWallet.ExecuteSell(ctx, ethAmount, usdcAmount); err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
b.paperWallet.DeductGas(ctx, gas)
|
||||
b.paperWallet.RecordTrade(ctx, PaperTrade{
|
||||
Side: side, GridLevel: level.Index,
|
||||
TriggerPrice: level.Price, ExecutionPrice: currentPrice,
|
||||
ETHAmount: ethAmount, USDCAmount: usdcAmount,
|
||||
SlippagePct: slip * 100, GasCost: gas,
|
||||
})
|
||||
|
||||
txHash = fmt.Sprintf("0xPAPER_%s_%x", side, time.Now().UnixNano())
|
||||
s := slip * 100
|
||||
slippagePct = &s
|
||||
gasCost = &gas
|
||||
fmt.Printf("[PAPER] %s executed: %.6f ETH for %.2f USDC (slippage: %.3f%%, gas: %.6f ETH)\n",
|
||||
side, ethAmount, usdcAmount, s, gas)
|
||||
return txHash, slippagePct, gasCost, nil
|
||||
}
|
||||
|
||||
func (b *GridBot) executeLiveSwap(ctx context.Context, side string, ethAmount, usdcAmount float64) (txHash string, gasCost *float64, err error) {
|
||||
var hash string
|
||||
var swapErr error
|
||||
|
||||
if side == "buy" {
|
||||
b.notify.Send(fmt.Sprintf("Broadcasting BUY TX: %.6f ETH for %.2f USDC...", ethAmount, usdcAmount))
|
||||
hash, swapErr = b.uniswap.SwapUSDCForETH(ctx, usdcAmount, ethAmount)
|
||||
} else {
|
||||
b.notify.Send(fmt.Sprintf("Broadcasting SELL TX: %.6f ETH for ~%.2f USDC...", ethAmount, usdcAmount))
|
||||
hash, swapErr = b.uniswap.SwapETHForUSDC(ctx, ethAmount)
|
||||
}
|
||||
if swapErr != nil {
|
||||
b.notify.Send(fmt.Sprintf("%s TX failed: %v", side, swapErr))
|
||||
return "", nil, fmt.Errorf("swap failed (%s): %w", side, swapErr)
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf("%s TX confirmed: %s", side, b.uniswap.ExplorerURL(hash)))
|
||||
gas, _ := b.uniswap.GasCostETH(ctx)
|
||||
gasCost = &gas
|
||||
return hash, gasCost, nil
|
||||
}
|
||||
|
||||
func (b *GridBot) resetOppositeLevel(ctx context.Context, filled *strategy.GridLevel) {
|
||||
idx := strategy.GetOppositeLevelIndex(filled, len(b.Grid))
|
||||
if idx == nil {
|
||||
return
|
||||
}
|
||||
adj := &b.Grid[*idx]
|
||||
if adj.Filled {
|
||||
adj.Filled = false
|
||||
adj.FilledAt = nil
|
||||
adj.TxHash = nil
|
||||
b.saveState(ctx)
|
||||
fmt.Printf("Reset grid level %d for opposite trade\n", *idx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- risk ---
|
||||
|
||||
// portfolioPnLPercent returns the current unrealized P&L as a percentage.
|
||||
// The second return value is false when P&L cannot be determined (e.g. live
|
||||
// mode without balance tracking), in which case the caller should skip the
|
||||
// portfolio-level check.
|
||||
func (b *GridBot) portfolioPnLPercent(currentPrice float64) (float64, bool) {
|
||||
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
|
||||
return b.paperWallet.Stats(currentPrice).UnrealizedPnLPct, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// --- main loop ---
|
||||
|
||||
func (b *GridBot) Run(ctx context.Context) {
|
||||
b.running = true
|
||||
|
||||
b.notify.Send(fmt.Sprintf("Starting ETH grid trader with %d levels, %.1f%% spacing",
|
||||
b.cfg.GridLevels, b.cfg.GridSpacingPercent))
|
||||
|
||||
if len(b.Grid) == 0 {
|
||||
if err := b.InitializeGrid(ctx); err != nil {
|
||||
fmt.Printf("Failed to initialize grid: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
display := strategy.FormatGridDisplay(b.Grid, b.BasePrice, b.cfg.AmountPerGrid)
|
||||
fmt.Println("\n" + display + "\n")
|
||||
|
||||
ticker := time.NewTicker(time.Duration(b.cfg.PriceCheckIntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Do one immediate tick
|
||||
b.tick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-b.stopCh:
|
||||
b.running = false
|
||||
b.notify.Send("Grid trader shutting down")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
b.running = false
|
||||
return
|
||||
case <-ticker.C:
|
||||
b.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *GridBot) tick(ctx context.Context) {
|
||||
b.PriceChecks++
|
||||
|
||||
price := b.fetchETHPrice(ctx)
|
||||
if price <= 0 {
|
||||
fmt.Println("Could not fetch ETH price, skipping tick")
|
||||
return
|
||||
}
|
||||
|
||||
if pnl, ok := b.portfolioPnLPercent(price); ok {
|
||||
if err := b.guardian.PortfolioCheck(pnl); err != nil {
|
||||
b.notify.Send(fmt.Sprintf("CIRCUIT BREAKER: %v — halting trading", err))
|
||||
fmt.Printf("[RISK] %v\n", err)
|
||||
close(b.stopCh)
|
||||
b.running = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
triggered := strategy.FindTriggeredLevel(price, b.Grid)
|
||||
if triggered != nil {
|
||||
fmt.Printf("Grid level %d triggered: %s ETH at $%.2f\n",
|
||||
triggered.Index, triggered.Side, triggered.Price)
|
||||
|
||||
if err := b.executeTrade(ctx, triggered, price); err != nil {
|
||||
fmt.Printf("Trade execution failed: %v\n", err)
|
||||
} else {
|
||||
// Post-trade cooldown
|
||||
select {
|
||||
case <-time.After(time.Duration(b.cfg.PostTradeCooldownSeconds) * time.Second):
|
||||
case <-b.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.maybeReportStatus(ctx, price)
|
||||
}
|
||||
|
||||
func (b *GridBot) maybeReportStatus(ctx context.Context, currentPrice float64) {
|
||||
interval := time.Duration(b.cfg.StatusReportIntervalMinutes) * time.Minute
|
||||
if time.Since(b.LastStatusReport) < interval {
|
||||
return
|
||||
}
|
||||
|
||||
stats := strategy.GetGridStats(b.Grid)
|
||||
prefix := ""
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
prefix = "[PAPER] "
|
||||
}
|
||||
|
||||
var ethBal, usdcBal float64
|
||||
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
|
||||
ethBal = b.paperWallet.ETHBalance
|
||||
usdcBal = b.paperWallet.USDCBalance
|
||||
} else if b.uniswap != nil {
|
||||
ethBal, _ = b.uniswap.ETHBalance(ctx)
|
||||
usdcBal, _ = b.uniswap.TokenBalance(ctx)
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf(
|
||||
"%sStatus: ETH @ $%.2f | ETH: %.4f ($%.2f) | USDC: %.2f | Grid: %d/%d buys, %d/%d sells | Checks: %d | Trades: %d",
|
||||
prefix, currentPrice,
|
||||
ethBal, ethBal*currentPrice, usdcBal,
|
||||
stats.FilledBuys, stats.FilledBuys+stats.PendingBuys,
|
||||
stats.FilledSells, stats.FilledSells+stats.PendingSells,
|
||||
b.PriceChecks, b.TradesExecuted,
|
||||
))
|
||||
|
||||
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
|
||||
ps := b.paperWallet.Stats(currentPrice)
|
||||
sign := "+"
|
||||
if ps.UnrealizedPnL < 0 {
|
||||
sign = ""
|
||||
}
|
||||
b.notify.Send(fmt.Sprintf(
|
||||
"[PAPER P&L] Initial: $%.2f -> Current: $%.2f | P&L: %s$%.2f (%s%.2f%%) | Gas: %.6f ETH ($%.2f) | Running: %.1fh",
|
||||
ps.InitialValueUSD, ps.CurrentValueUSD,
|
||||
sign, ps.UnrealizedPnL, sign, ps.UnrealizedPnLPct,
|
||||
ps.TotalGasSpent, ps.GasSpentUSD, ps.RunningTimeHours,
|
||||
))
|
||||
}
|
||||
|
||||
b.LastStatusReport = time.Now()
|
||||
}
|
||||
|
||||
func (b *GridBot) Shutdown() {
|
||||
if b.running {
|
||||
close(b.stopCh)
|
||||
}
|
||||
if b.ethClient != nil {
|
||||
b.ethClient.Close()
|
||||
}
|
||||
fmt.Println("[BOT] Shutting down gracefully")
|
||||
}
|
||||
|
||||
func (b *GridBot) IsRunning() bool {
|
||||
return b.running
|
||||
}
|
||||
187
trahn-trade-backend/internal/bot/paper_wallet.go
Normal file
187
trahn-trade-backend/internal/bot/paper_wallet.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
)
|
||||
|
||||
type PaperWallet struct {
|
||||
gridRepo *repository.GridStateRepo
|
||||
initialETH float64
|
||||
initialUSDC float64
|
||||
ETHBalance float64
|
||||
USDCBalance float64
|
||||
Trades []PaperTrade
|
||||
TotalGas float64
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
type PaperTrade struct {
|
||||
ID int `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Side string `json:"side"`
|
||||
GridLevel int `json:"gridLevel"`
|
||||
TriggerPrice float64 `json:"triggerPrice"`
|
||||
ExecutionPrice float64 `json:"executionPrice"`
|
||||
ETHAmount float64 `json:"ethAmount"`
|
||||
USDCAmount float64 `json:"usdcAmount"`
|
||||
SlippagePct float64 `json:"slippagePercent"`
|
||||
GasCost float64 `json:"gasCost"`
|
||||
BalanceAfter BalSnap `json:"balanceAfter"`
|
||||
}
|
||||
|
||||
type BalSnap struct {
|
||||
ETH float64 `json:"eth"`
|
||||
USDC float64 `json:"usdc"`
|
||||
}
|
||||
|
||||
func NewPaperWallet(gridRepo *repository.GridStateRepo, initialETH, initialUSDC float64) *PaperWallet {
|
||||
return &PaperWallet{
|
||||
gridRepo: gridRepo,
|
||||
initialETH: initialETH,
|
||||
initialUSDC: initialUSDC,
|
||||
ETHBalance: initialETH,
|
||||
USDCBalance: initialUSDC,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) Init(ctx context.Context) error {
|
||||
state, err := pw.gridRepo.GetPaperWallet(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load paper state: %w", err)
|
||||
}
|
||||
|
||||
if state != nil && state.ETHBalance > 0 {
|
||||
pw.ETHBalance = state.ETHBalance
|
||||
pw.USDCBalance = state.USDCBalance
|
||||
pw.TotalGas = state.TotalGasSpent
|
||||
pw.initialETH = state.InitialETH
|
||||
pw.initialUSDC = state.InitialUSDC
|
||||
if state.StartTime != nil {
|
||||
pw.StartTime = *state.StartTime
|
||||
}
|
||||
if state.Trades != nil {
|
||||
_ = json.Unmarshal(state.Trades, &pw.Trades)
|
||||
}
|
||||
fmt.Printf("[PAPER] Loaded from DB: %.6f ETH, %.2f USDC, %d trades\n",
|
||||
pw.ETHBalance, pw.USDCBalance, len(pw.Trades))
|
||||
} else {
|
||||
fmt.Printf("[PAPER] Starting fresh paper wallet: %.4f ETH, %.2f USDC\n",
|
||||
pw.initialETH, pw.initialUSDC)
|
||||
if err := pw.gridRepo.InitializePaperWallet(ctx, pw.initialETH, pw.initialUSDC); err != nil {
|
||||
return fmt.Errorf("initialize paper wallet: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) save(ctx context.Context) {
|
||||
tradesJSON, _ := json.Marshal(pw.Trades)
|
||||
err := pw.gridRepo.UpdatePaperWallet(ctx, &models.PaperWallet{
|
||||
ETHBalance: pw.ETHBalance,
|
||||
USDCBalance: pw.USDCBalance,
|
||||
TotalGasSpent: pw.TotalGas,
|
||||
Trades: tradesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[PAPER] Failed to save paper state: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) ExecuteBuy(ctx context.Context, usdcAmount, ethAmount float64) error {
|
||||
if pw.USDCBalance < usdcAmount {
|
||||
return fmt.Errorf("insufficient USDC: have %.2f, need %.2f", pw.USDCBalance, usdcAmount)
|
||||
}
|
||||
pw.USDCBalance -= usdcAmount
|
||||
pw.ETHBalance += ethAmount
|
||||
pw.save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) ExecuteSell(ctx context.Context, ethAmount, usdcAmount float64) error {
|
||||
if pw.ETHBalance < ethAmount {
|
||||
return fmt.Errorf("insufficient ETH: have %.6f, need %.6f", pw.ETHBalance, ethAmount)
|
||||
}
|
||||
pw.ETHBalance -= ethAmount
|
||||
pw.USDCBalance += usdcAmount
|
||||
pw.save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) DeductGas(ctx context.Context, gasETH float64) {
|
||||
pw.ETHBalance -= gasETH
|
||||
pw.TotalGas += gasETH
|
||||
pw.save(ctx)
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) RecordTrade(ctx context.Context, t PaperTrade) {
|
||||
t.ID = len(pw.Trades) + 1
|
||||
t.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
t.BalanceAfter = BalSnap{ETH: pw.ETHBalance, USDC: pw.USDCBalance}
|
||||
pw.Trades = append(pw.Trades, t)
|
||||
pw.save(ctx)
|
||||
}
|
||||
|
||||
type PaperStats struct {
|
||||
InitialETH float64
|
||||
InitialUSDC float64
|
||||
CurrentETH float64
|
||||
CurrentUSDC float64
|
||||
InitialValueUSD float64
|
||||
CurrentValueUSD float64
|
||||
UnrealizedPnL float64
|
||||
UnrealizedPnLPct float64
|
||||
TotalTrades int
|
||||
BuyTrades int
|
||||
SellTrades int
|
||||
TotalGasSpent float64
|
||||
GasSpentUSD float64
|
||||
RunningTimeHours float64
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) Stats(currentETHPrice float64) PaperStats {
|
||||
initialVal := pw.initialETH*currentETHPrice + pw.initialUSDC
|
||||
currentVal := pw.ETHBalance*currentETHPrice + pw.USDCBalance
|
||||
pnl := currentVal - initialVal
|
||||
pnlPct := 0.0
|
||||
if initialVal > 0 {
|
||||
pnlPct = pnl / initialVal * 100
|
||||
}
|
||||
buys, sells := 0, 0
|
||||
for _, t := range pw.Trades {
|
||||
if t.Side == "buy" {
|
||||
buys++
|
||||
} else {
|
||||
sells++
|
||||
}
|
||||
}
|
||||
return PaperStats{
|
||||
InitialETH: pw.initialETH,
|
||||
InitialUSDC: pw.initialUSDC,
|
||||
CurrentETH: pw.ETHBalance,
|
||||
CurrentUSDC: pw.USDCBalance,
|
||||
InitialValueUSD: initialVal,
|
||||
CurrentValueUSD: currentVal,
|
||||
UnrealizedPnL: pnl,
|
||||
UnrealizedPnLPct: pnlPct,
|
||||
TotalTrades: len(pw.Trades),
|
||||
BuyTrades: buys,
|
||||
SellTrades: sells,
|
||||
TotalGasSpent: pw.TotalGas,
|
||||
GasSpentUSD: pw.TotalGas * currentETHPrice,
|
||||
RunningTimeHours: time.Since(pw.StartTime).Hours(),
|
||||
}
|
||||
}
|
||||
|
||||
func randomSlippage(maxPct float64) float64 {
|
||||
return rand.Float64() * maxPct / 100
|
||||
}
|
||||
|
||||
const defaultPaperGasCost = 0.005
|
||||
108
trahn-trade-backend/internal/bot/service.go
Normal file
108
trahn-trade-backend/internal/bot/service.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/config"
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/notifications"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/scheduler"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
bot *GridBot
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, cfg *config.Config,
|
||||
priceRepo *repository.PriceRepo,
|
||||
tradeRepo *repository.TradeRepo,
|
||||
gridRepo *repository.GridStateRepo,
|
||||
notify *notifications.Sender,
|
||||
dune *external.DuneClient,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot != nil && s.bot.IsRunning() {
|
||||
fmt.Println("[BOT] Already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
mode := "LIVE MODE"
|
||||
if cfg.PaperTradingEnabled {
|
||||
mode = "PAPER MODE"
|
||||
}
|
||||
notify.Send(fmt.Sprintf("Starting ETH Grid Trader (ETH/%s) - %s", cfg.QuoteTokenSymbol, mode))
|
||||
|
||||
b := NewGridBot(cfg, priceRepo, tradeRepo, gridRepo, notify, dune)
|
||||
if err := b.Init(ctx); err != nil {
|
||||
return fmt.Errorf("bot init: %w", err)
|
||||
}
|
||||
s.bot = b
|
||||
|
||||
fmt.Println("[BOT] Grid trading bot initialized")
|
||||
fmt.Println("[BOT] State loaded from database")
|
||||
|
||||
go func() {
|
||||
b.Run(ctx)
|
||||
fmt.Println("[BOT] Run loop exited")
|
||||
}()
|
||||
|
||||
fmt.Println("[BOT] Started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot != nil {
|
||||
s.bot.Shutdown()
|
||||
s.bot = nil
|
||||
}
|
||||
fmt.Println("[BOT] Stopped")
|
||||
}
|
||||
|
||||
// BotState returns the current bot state for the scheduler's decision-making.
|
||||
func (s *Service) BotState() *scheduler.BotState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot == nil || len(s.bot.Grid) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy grid to avoid data races
|
||||
grid := make([]strategy.GridLevel, len(s.bot.Grid))
|
||||
copy(grid, s.bot.Grid)
|
||||
|
||||
return &scheduler.BotState{
|
||||
Grid: grid,
|
||||
LastETHPrice: s.bot.LastETHPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeGrid triggers a grid recalculation (called by scheduler).
|
||||
func (s *Service) InitializeGrid(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
b := s.bot
|
||||
s.mu.Unlock()
|
||||
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
fmt.Println("[BOT] Recalculating grid with new S/R midpoint...")
|
||||
b.BasePrice = 0 // force recalculation from S/R
|
||||
if err := b.InitializeGrid(ctx); err != nil {
|
||||
fmt.Printf("[BOT] Grid recalculation failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
259
trahn-trade-backend/internal/config/config.go
Normal file
259
trahn-trade-backend/internal/config/config.go
Normal 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
|
||||
}
|
||||
49
trahn-trade-backend/internal/db/connection.go
Normal file
49
trahn-trade-backend/internal/db/connection.go
Normal 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
|
||||
}
|
||||
74
trahn-trade-backend/internal/ethereum/abi.go
Normal file
74
trahn-trade-backend/internal/ethereum/abi.go
Normal 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"}]
|
||||
}
|
||||
]`)
|
||||
}
|
||||
118
trahn-trade-backend/internal/ethereum/client.go
Normal file
118
trahn-trade-backend/internal/ethereum/client.go
Normal 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
|
||||
}
|
||||
184
trahn-trade-backend/internal/ethereum/uniswap.go
Normal file
184
trahn-trade-backend/internal/ethereum/uniswap.go
Normal 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
|
||||
}
|
||||
58
trahn-trade-backend/internal/external/coingecko.go
vendored
Normal file
58
trahn-trade-backend/internal/external/coingecko.go
vendored
Normal 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
|
||||
}
|
||||
325
trahn-trade-backend/internal/external/dune.go
vendored
Normal file
325
trahn-trade-backend/internal/external/dune.go
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
80
trahn-trade-backend/internal/external/external_test.go
vendored
Normal file
80
trahn-trade-backend/internal/external/external_test.go
vendored
Normal 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)")
|
||||
}
|
||||
73
trahn-trade-backend/internal/httputil/retry.go
Normal file
73
trahn-trade-backend/internal/httputil/retry.go
Normal 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)
|
||||
}
|
||||
136
trahn-trade-backend/internal/httputil/retry_test.go
Normal file
136
trahn-trade-backend/internal/httputil/retry_test.go
Normal 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)
|
||||
}
|
||||
37
trahn-trade-backend/internal/models/gridstate.go
Normal file
37
trahn-trade-backend/internal/models/gridstate.go
Normal 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"`
|
||||
}
|
||||
12
trahn-trade-backend/internal/models/price.go
Normal file
12
trahn-trade-backend/internal/models/price.go
Normal 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"`
|
||||
}
|
||||
27
trahn-trade-backend/internal/models/sr.go
Normal file
27
trahn-trade-backend/internal/models/sr.go
Normal 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
|
||||
}
|
||||
29
trahn-trade-backend/internal/models/trade.go
Normal file
29
trahn-trade-backend/internal/models/trade.go
Normal 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"`
|
||||
}
|
||||
86
trahn-trade-backend/internal/notifications/webhook.go
Normal file
86
trahn-trade-backend/internal/notifications/webhook.go
Normal 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 != ""
|
||||
}
|
||||
83
trahn-trade-backend/internal/notifications/webhook_test.go
Normal file
83
trahn-trade-backend/internal/notifications/webhook_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
202
trahn-trade-backend/internal/repository/gridstate.go
Normal file
202
trahn-trade-backend/internal/repository/gridstate.go
Normal 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
|
||||
}
|
||||
110
trahn-trade-backend/internal/repository/price.go
Normal file
110
trahn-trade-backend/internal/repository/price.go
Normal 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()
|
||||
}
|
||||
319
trahn-trade-backend/internal/repository/repo_test.go
Normal file
319
trahn-trade-backend/internal/repository/repo_test.go
Normal 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")
|
||||
}
|
||||
139
trahn-trade-backend/internal/repository/sr.go
Normal file
139
trahn-trade-backend/internal/repository/sr.go
Normal 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()
|
||||
}
|
||||
155
trahn-trade-backend/internal/repository/trade.go
Normal file
155
trahn-trade-backend/internal/repository/trade.go
Normal 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()
|
||||
}
|
||||
22
trahn-trade-backend/internal/repository/tradingday.go
Normal file
22
trahn-trade-backend/internal/repository/tradingday.go
Normal 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())
|
||||
}
|
||||
69
trahn-trade-backend/internal/risk/guardian.go
Normal file
69
trahn-trade-backend/internal/risk/guardian.go
Normal 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
|
||||
}
|
||||
153
trahn-trade-backend/internal/risk/guardian_test.go
Normal file
153
trahn-trade-backend/internal/risk/guardian_test.go
Normal 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%")
|
||||
}
|
||||
}
|
||||
232
trahn-trade-backend/internal/scheduler/sr.go
Normal file
232
trahn-trade-backend/internal/scheduler/sr.go
Normal 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
|
||||
}
|
||||
150
trahn-trade-backend/internal/scheduler/sr_test.go
Normal file
150
trahn-trade-backend/internal/scheduler/sr_test.go
Normal 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")
|
||||
}
|
||||
247
trahn-trade-backend/internal/strategy/grid.go
Normal file
247
trahn-trade-backend/internal/strategy/grid.go
Normal 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)
|
||||
}
|
||||
323
trahn-trade-backend/internal/strategy/grid_test.go
Normal file
323
trahn-trade-backend/internal/strategy/grid_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
trahn-trade-backend/internal/testutil/db.go
Normal file
42
trahn-trade-backend/internal/testutil/db.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user