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

View File

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

View File

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

View File

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

View File

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

View File

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