first commit of restructured project

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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