first commit of restructured project
This commit is contained in:
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())
|
||||
}
|
||||
Reference in New Issue
Block a user