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,58 @@
.PHONY: build run test lint format clean help db-setup db-migrate
BINARY := server
CMD := ./cmd/server
# Default target
help:
@echo "Trahn Grid Trader - Available Commands"
@echo "======================================="
@echo " make build Build the server binary"
@echo " make run Build and run the trading bot"
@echo " make dev Run directly without building (go run)"
@echo " make test Run all tests"
@echo " make test-v Run all tests (verbose)"
@echo " make lint Check code formatting (go vet + staticcheck)"
@echo " make format Format all Go source files"
@echo " make clean Remove build artifacts"
@echo " make db-setup Create database and run schema"
@echo " make db-migrate Run pending migrations"
@echo ""
build:
go build -o $(BINARY) $(CMD)
run: build
./$(BINARY)
dev:
go run $(CMD)
test:
go test ./...
test-v:
go test -v ./...
lint:
go vet ./...
@which staticcheck > /dev/null 2>&1 && staticcheck ./... || echo "staticcheck not installed, skipping (go install honnef.co/go/tools/cmd/staticcheck@latest)"
format:
gofmt -w .
clean:
rm -f $(BINARY)
@echo "Cleaned!"
db-setup:
psql -U postgres -f db/setup.sql
psql -U postgres -d trahn_grid_trader -f db/schema.sql
@echo "Database setup complete"
db-migrate:
@for f in db/migrations/*.sql; do \
echo "Running $$f ..."; \
psql -U postgres -d trahn_grid_trader -f "$$f"; \
done
@echo "Migrations complete"

View File

@@ -0,0 +1,91 @@
# 📊 Trahn Grid Trader
A sophisticated grid trading bot for Uniswap V2, designed to profit from price oscillations in cryptocurrency markets.
```
================================================================
|| ||
|| 7 R 4 H N ||
|| ||
|| G R I D T R A D E R v0.2 ||
================================================================
```
## Grid Trading
An algorthmic trading strategy that places buy and sell orders at predetermined price intervals (a "grid"). The algorthm raalizes gains over cost basis from price oscillations within the grid range.
- **When price drops**: Buy orders are triggered at lower grid levels
- **When price rises**: Sell orders are triggered at higher grid levels
- **Profit**: Is derved from the spread
```
Price
│ ──────── SELL Level 5 ($0.035)
│ ──────── SELL Level 4 ($0.034)
│ ──────── SELL Level 3 ($0.033)
│ ════════ CENTER PRICE ($0.032) ← Grid initialized here
│ ──────── BUY Level 2 ($0.031)
│ ──────── BUY Level 1 ($0.030)
│ ──────── BUY Level 0 ($0.029)
└─────────────────────────────────────►
```
## Quick Start
### Prerequisites
- [Go 1.26+](https://go.dev/dl/)
- PostgreSQL 15+
### Setup
1. Clone the repository and install dependencies:
```bash
git clone https://github.com/kjannette/trahn-backend.git
cd trahn-trade-backend
go mod download
```
2. Create a `.env` file in the project root with your configuration:
```bash
# Required
WALLET_ADDRESS=0xYourWalletAddress
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=trahn_grid_trader
DB_USER=postgres
DB_PASSWORD=yourpassword
# Optional - enables support/resistance scheduling
DUNE_API_KEY=your_dune_api_key
# Paper trading is enabled by default.
# Set to false and provide PRIVATE_KEY for live trading.
PAPER_TRADING_ENABLED=true
```
3. Build and run:
```bash
go build -o trahn-bot ./cmd/server
./trahn-bot
```
Or run directly without building:
```bash
go run ./cmd/server
```
### Running Tests
```bash
go test ./...
```

View File

@@ -0,0 +1,151 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/kjannette/trahn-backend/internal/api"
"github.com/kjannette/trahn-backend/internal/bot"
"github.com/kjannette/trahn-backend/internal/config"
"github.com/kjannette/trahn-backend/internal/db"
"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"
)
const banner = `
╔══════════════════════════════════════╗
║ TRAHN Grid Trading Bot v0.2 ║
║ ║
╚══════════════════════════════════════╝
`
const apiPort = 3001
func main() {
fmt.Print(banner)
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config load error: %v\n", err)
os.Exit(1)
}
if err := cfg.Validate(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
cfg.Print()
// Database
fmt.Printf("\n[DB] Connecting to %s:%d/%s ...\n", cfg.DBHost, cfg.DBPort, cfg.DBName)
pool, err := db.Connect(cfg.DSN())
if err != nil {
fmt.Fprintf(os.Stderr, "[DB] Connection failed: %v\n", err)
os.Exit(1)
}
defer func() {
pool.Close()
fmt.Println("[DB] Connection pool closed")
}()
if err := db.TestConnection(pool); err != nil {
fmt.Fprintf(os.Stderr, "[DB] Test query failed: %v\n", err)
os.Exit(1)
}
// Repos
priceRepo := repository.NewPriceRepo(pool)
tradeRepo := repository.NewTradeRepo(pool)
srRepo := repository.NewSRRepo(pool)
gridRepo := repository.NewGridStateRepo(pool)
// Shared Dune client (single instance for bot + scheduler)
var dune *external.DuneClient
if cfg.DuneAPIKey != "" {
dune = external.NewDuneClient(cfg.DuneAPIKey, external.DuneOptions{
Method: cfg.SRMethod,
LookbackDays: cfg.SRLookbackDays,
RefreshHours: cfg.SRRefreshHours,
})
// Warm cache from DB if a recent S/R record exists
if latest, err := srRepo.GetLatest(context.Background()); err == nil && latest != nil {
dune.SeedCache(&external.SRResult{
Support: latest.Support,
Resistance: latest.Resistance,
Midpoint: latest.Midpoint,
Method: latest.Method,
LookbackDays: latest.LookbackDays,
FetchedAt: latest.Timestamp,
})
}
}
// Notifications
notify := notifications.NewSender(cfg.WebhookURL, cfg.BotName)
// Graceful shutdown context
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 1. API server
srv := api.NewServer(pool, apiPort, cfg.APIKey, cfg.CORSAllowOrigin)
go func() {
if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(os.Stderr, "[API] Server error: %v\n", err)
os.Exit(1)
}
}()
// 2. Grid bot (shares the Dune client)
botService := bot.NewService()
if err := botService.Start(ctx, cfg, priceRepo, tradeRepo, gridRepo, notify, dune); err != nil {
fmt.Fprintf(os.Stderr, "[BOT] Start failed: %v\n", err)
os.Exit(1)
}
// 3. S/R Scheduler (shares the same Dune client)
var srSched *scheduler.SRScheduler
if dune != nil {
srSched = scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
CronInterval: 1 * time.Hour,
SRChangeThreshold: 5,
GetBotState: botService.BotState,
OnGridRecalculate: func(sr *external.SRResult) {
botService.InitializeGrid(ctx)
},
})
srSched.Start()
} else {
fmt.Println("[SCHEDULER] Skipped - no Dune API key configured")
}
fmt.Println("\nAll services started successfully")
// Wait for shutdown signal
<-ctx.Done()
fmt.Println("\nShutting down gracefully...")
if srSched != nil {
srSched.Stop()
}
botService.Stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "[API] Shutdown error: %v\n", err)
}
fmt.Println("[API] Server closed")
fmt.Println("Shutdown complete")
}

View File

@@ -0,0 +1,129 @@
# Database Setup
## Quick Start
### 1. Install And Run PostgreSQL
**macOS:**
```bash
brew install postgresql@15
brew services start postgresql@15
```
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install postgresql postgresql-contrib
sudo systemctl start postgresql
```
**Docker:**
```bash
docker run --name trahn-postgres \
-e POSTGRES_PASSWORD=your_password \
-p 5432:5432 \
-d postgres:15
```
### 2. Create Database and Schema
```bash
# Connect as postgres user
psql -U postgres
# Create database
CREATE DATABASE trahn_grid_trader;
# Exit psql
\q
# Run schema
psql -U postgres -d trahn_grid_trader -f db/schema.sql
```
### 3. Configure .env
Add to your `.env` file:
```bash
DB_HOST=localhost
DB_PORT=5432
DB_NAME=trahn_grid_trader
DB_USER=postgres
DB_PASSWORD=your_secure_password_here
```
### 4. Test Connection
```bash
make dev
# Verify "[DB] Connection successful" appears in the output
```
## Schema Overview
### Tables
1. **price_history** - ETH price data points
- Timestamp-indexed for fast queries
- Organized by trading day (12:00 EST boundary)
2. **trade_history** - Executed trades
- Buy/sell records with full details
- Paper trade flag for simulation tracking
3. **support_resistance_history** - S/R levels over time
- Historical record of Dune API fetches
- Tracks when grid was recalculated
4. **grid_state** - Current grid configuration
- Grid levels stored as JSONB
- State tracking for bot restarts
### Indexes
- All tables indexed on `timestamp` for time-series queries
- `trading_day` indexed for day-based queries
- Optimized for append-heavy workloads
## Maintenance
### View Data
```sql
-- Recent prices
SELECT * FROM price_history ORDER BY timestamp DESC LIMIT 10;
-- Recent trades
SELECT * FROM trade_history ORDER BY timestamp DESC LIMIT 10;
-- S/R history
SELECT * FROM support_resistance_history ORDER BY timestamp DESC LIMIT 10;
-- Latest S/R (using view)
SELECT * FROM latest_support_resistance;
```
### Cleanup Old Data (optional)
```sql
-- Delete price data older than 90 days
DELETE FROM price_history WHERE timestamp < NOW() - INTERVAL '90 days';
-- Keep all trade history (don't delete)
```
## Troubleshooting
### Connection Failed
- Check PostgreSQL is running: `pg_isready`
- Verify credentials in `.env`
- Check firewall/network settings
### Schema Errors
- Drop and recreate: `DROP DATABASE trahn_grid_trader; CREATE DATABASE trahn_grid_trader;`
- Re-run schema.sql
### Performance
- If queries slow, add more indexes
- Consider partitioning price_history by month (for large datasets)

View File

@@ -0,0 +1,15 @@
-- Migration: Add Paper Wallet Columns to grid_state
-- This allows grid_state to store paper trading virtual wallet data
ALTER TABLE grid_state
ADD COLUMN IF NOT EXISTS paper_eth_balance DECIMAL(18, 8),
ADD COLUMN IF NOT EXISTS paper_usdc_balance DECIMAL(12, 2),
ADD COLUMN IF NOT EXISTS paper_total_gas_spent DECIMAL(18, 8),
ADD COLUMN IF NOT EXISTS paper_trades_json JSONB,
ADD COLUMN IF NOT EXISTS paper_start_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS paper_initial_eth DECIMAL(18, 8),
ADD COLUMN IF NOT EXISTS paper_initial_usdc DECIMAL(12, 2);
-- For live trading mode, these columns will be NULL
-- For paper mode, these columns store the virtual wallet state

View File

@@ -0,0 +1,80 @@
-- Trahn Grid Trader Database Schema
-- PostgreSQL
-- Table 1: Price History
-- Stores ETH price data points from CoinGecko
CREATE TABLE IF NOT EXISTS price_history (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
price DECIMAL(12, 2) NOT NULL,
trading_day DATE NOT NULL,
source VARCHAR(50) DEFAULT 'coingecko',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_price_timestamp ON price_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_price_trading_day ON price_history(trading_day);
-- Table 2: Trade History
-- Stores executed trades (buys and sells)
CREATE TABLE IF NOT EXISTS trade_history (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
trading_day DATE NOT NULL,
side VARCHAR(10) NOT NULL CHECK (side IN ('buy', 'sell')),
price DECIMAL(12, 2) NOT NULL,
quantity DECIMAL(18, 8) NOT NULL,
usd_value DECIMAL(12, 2) NOT NULL,
grid_level INTEGER,
tx_hash VARCHAR(66),
is_paper_trade BOOLEAN DEFAULT FALSE,
slippage_percent DECIMAL(5, 3),
gas_cost_eth DECIMAL(18, 8),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trade_timestamp ON trade_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_trade_trading_day ON trade_history(trading_day);
CREATE INDEX IF NOT EXISTS idx_trade_side ON trade_history(side);
-- Table 3: Support/Resistance History
-- Stores S/R levels fetched from Dune Analytics over time
CREATE TABLE IF NOT EXISTS support_resistance_history (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
method VARCHAR(20) NOT NULL,
lookback_days INTEGER NOT NULL,
support DECIMAL(12, 2) NOT NULL,
resistance DECIMAL(12, 2) NOT NULL,
midpoint DECIMAL(12, 2) NOT NULL,
avg_price DECIMAL(12, 2),
grid_recalculated BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sr_timestamp ON support_resistance_history(timestamp);
-- Table 4: Grid State
-- Stores current grid configuration and state
-- Only one active row at a time (or keyed by bot instance)
CREATE TABLE IF NOT EXISTS grid_state (
id SERIAL PRIMARY KEY,
base_price DECIMAL(12, 2),
grid_levels_json JSONB,
trades_executed INTEGER DEFAULT 0,
total_profit DECIMAL(12, 2) DEFAULT 0,
last_sr_refresh TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_grid_active ON grid_state(is_active);
-- View: Latest S/R
-- Convenience view for getting the most recent S/R data
CREATE OR REPLACE VIEW latest_support_resistance AS
SELECT * FROM support_resistance_history
ORDER BY timestamp DESC
LIMIT 1;

View File

@@ -0,0 +1,18 @@
-- Setup script for Trahn Grid Trader database
-- Run this as PostgreSQL superuser or database owner
-- Create database (if it doesn't exist)
-- Run manually: CREATE DATABASE trahn_grid_trader;
-- Connect to the database and run schema.sql
-- psql -U postgres -d trahn_grid_trader -f schema.sql
-- Or run this combined script:
-- psql -U postgres -f setup.sql
CREATE DATABASE IF NOT EXISTS trahn_grid_trader;
\c trahn_grid_trader;
-- Now load the schema
\i schema.sql

View File

@@ -0,0 +1,61 @@
# Trahn Grid Trader - Environment Variables
#
# This file contains ONLY secrets and sensitive data.
# All other configuration is in the appropriate service/config files.
#
# Copy this file to .env and fill in your values.
# ============================================
# DATABASE CREDENTIALS - REQUIRED
# ============================================
# PostgreSQL connection details
DB_HOST=localhost
DB_PORT=5432
DB_NAME=trahn_grid_trader
DB_USER=postgres
DB_PASSWORD=your_secure_password_here
# ============================================
# BLOCKCHAIN SECRETS - REQUIRED
# ============================================
# Your Ethereum wallet address
WALLET_ADDRESS=0x...
# Your wallet private key (with 0x prefix) - KEEP THIS SECRET!
# Only required for LIVE trading (not needed for paper trading)
PRIVATE_KEY=0x...
# Ethereum RPC endpoint (Infura, Alchemy, etc.)
ETHEREUM_API_ENDPOINT=https://mainnet.infura.io/v3/YOUR_PROJECT_ID
# ============================================
# API KEYS - OPTIONAL
# ============================================
# Dune Analytics API key (get one at https://dune.com/settings/api)
# Optional: Leave empty to use fallback mode (current price as midpoint)
DUNE_API_KEY=
# ============================================
# OPTIONAL - API SECURITY
# ============================================
# Bearer token for REST API authentication.
# All /v1/* endpoints require "Authorization: Bearer <key>" when set.
# Leave empty to allow unauthenticated access (development only).
API_KEY=
# CORS allowed origin. Default "*" (any origin).
# Set to your frontend URL in production (e.g., https://trahn.example.com)
CORS_ALLOW_ORIGIN=*
# ============================================
# OPTIONAL - NOTIFICATIONS
# ============================================
# Webhook URL for notifications (Slack, Discord, etc.)
# Leave empty to disable notifications
WEBHOOK_URL=

View File

@@ -0,0 +1,42 @@
module github.com/kjannette/trahn-backend
go 1.26
require (
github.com/ethereum/go-ethereum v1.17.0
github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.20.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/consensys/gnark-crypto v0.18.1 // indirect
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
)

216
trahn-trade-backend/go.sum Normal file
View File

@@ -0,0 +1,216 @@
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI=
github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes=
github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw=
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,548 @@
package bot
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/kjannette/trahn-backend/internal/config"
"github.com/kjannette/trahn-backend/internal/ethereum"
"github.com/kjannette/trahn-backend/internal/external"
"github.com/kjannette/trahn-backend/internal/models"
"github.com/kjannette/trahn-backend/internal/notifications"
"github.com/kjannette/trahn-backend/internal/repository"
"github.com/kjannette/trahn-backend/internal/risk"
"github.com/kjannette/trahn-backend/internal/strategy"
)
type GridBot struct {
cfg *config.Config
coingecko *external.CoinGeckoClient
dune *external.DuneClient
priceRepo *repository.PriceRepo
tradeRepo *repository.TradeRepo
gridRepo *repository.GridStateRepo
notify *notifications.Sender
Grid []strategy.GridLevel
LastETHPrice float64
BasePrice float64
TradesExecuted int
TotalProfit float64
PriceChecks int
LastStatusReport time.Time
LastSRRefresh *time.Time
guardian *risk.Guardian
paperWallet *PaperWallet
uniswap *ethereum.UniswapV2
ethClient *ethereum.Client
running bool
stopCh chan struct{}
}
func NewGridBot(
cfg *config.Config,
priceRepo *repository.PriceRepo,
tradeRepo *repository.TradeRepo,
gridRepo *repository.GridStateRepo,
notify *notifications.Sender,
dune *external.DuneClient,
) *GridBot {
b := &GridBot{
cfg: cfg,
coingecko: external.NewCoinGeckoClient(),
dune: dune,
priceRepo: priceRepo,
tradeRepo: tradeRepo,
gridRepo: gridRepo,
notify: notify,
stopCh: make(chan struct{}),
guardian: risk.NewGuardian(risk.Limits{
MaxDailyTrades: cfg.MaxDailyTrades,
MaxPositionSizeUSD: cfg.MaxPositionSizeUSD,
StopLossPercent: cfg.StopLossPercent,
TakeProfitPercent: cfg.TakeProfitPercent,
}, tradeRepo),
}
if dune != nil {
fmt.Printf("[S/R] Dune Analytics configured: %s method, %d-day lookback\n", cfg.SRMethod, cfg.SRLookbackDays)
} else {
fmt.Println("[S/R] Dune API key not set - using fallback (current price as midpoint)")
}
return b
}
func (b *GridBot) Init(ctx context.Context) error {
if err := b.loadState(ctx); err != nil {
fmt.Printf("Warning: failed to load state: %v\n", err)
}
if b.cfg.PaperTradingEnabled {
b.paperWallet = NewPaperWallet(b.gridRepo, b.cfg.PaperInitialETH, b.cfg.PaperInitialUSDC)
if err := b.paperWallet.Init(ctx); err != nil {
return fmt.Errorf("paper wallet init: %w", err)
}
} else {
ethC, err := ethereum.NewClient(
b.cfg.EthereumAPIEndpoint,
b.cfg.PrivateKey,
int64(b.cfg.ChainID),
b.cfg.GasLimit,
b.cfg.GasMultiplier,
)
if err != nil {
return fmt.Errorf("ethereum client: %w", err)
}
b.ethClient = ethC
uni, err := ethereum.NewUniswapV2(
ethC,
b.cfg.UniswapRouterAddress,
b.cfg.WETHAddress,
b.cfg.QuoteTokenAddress,
b.cfg.QuoteTokenSymbol,
b.cfg.QuoteTokenDecimals,
b.cfg.SlippageTolerance,
)
if err != nil {
return fmt.Errorf("uniswap client: %w", err)
}
b.uniswap = uni
fmt.Printf("[LIVE] Ethereum client connected, wallet %s\n", ethC.WalletAddress().Hex())
}
return nil
}
// --- state management ---
func (b *GridBot) loadState(ctx context.Context) error {
state, err := b.gridRepo.GetActive(ctx)
if err != nil {
return err
}
if state == nil || state.GridLevelsJSON == nil {
fmt.Println("No existing state found in DB - will initialize fresh")
return nil
}
if err := json.Unmarshal(state.GridLevelsJSON, &b.Grid); err != nil {
return fmt.Errorf("unmarshal grid: %w", err)
}
b.TradesExecuted = state.TradesExecuted
b.TotalProfit = state.TotalProfit
if state.BasePrice != nil {
b.BasePrice = *state.BasePrice
}
b.LastSRRefresh = state.LastSRRefresh
fmt.Printf("Loaded state from DB: %d grid levels, %d trades\n", len(b.Grid), b.TradesExecuted)
return nil
}
func (b *GridBot) saveState(ctx context.Context) {
levelsJSON, err := json.Marshal(b.Grid)
if err != nil {
fmt.Printf("[STATE] Failed to marshal grid levels: %v\n", err)
return
}
_, err = b.gridRepo.Save(ctx, &models.GridState{
BasePrice: &b.BasePrice,
GridLevelsJSON: levelsJSON,
TradesExecuted: b.TradesExecuted,
TotalProfit: b.TotalProfit,
LastSRRefresh: b.LastSRRefresh,
})
if err != nil {
fmt.Printf("[STATE] Failed to save state to DB: %v\n", err)
}
}
// --- price ---
func (b *GridBot) fetchETHPrice(ctx context.Context) float64 {
price, err := b.coingecko.GetETHPrice(ctx)
if err != nil {
fmt.Printf("Failed to fetch ETH price: %v\n", err)
return b.LastETHPrice
}
if price < 100 || price > 100000 {
fmt.Printf("ETH price %.2f failed sanity check\n", price)
return b.LastETHPrice
}
b.LastETHPrice = price
_, _ = b.priceRepo.Record(ctx, price, time.Now())
return price
}
// --- grid initialization ---
func (b *GridBot) InitializeGrid(ctx context.Context) error {
sr := b.fetchSR(ctx)
center := b.BasePrice
if center == 0 {
center = sr.Midpoint
}
b.BasePrice = center
price := b.fetchETHPrice(ctx)
if price <= 0 {
return fmt.Errorf("cannot initialize grid: invalid ETH price")
}
b.notify.Send(fmt.Sprintf("S/R Analysis (%s, %dd): Support $%.2f | Resistance $%.2f | Midpoint $%.2f",
sr.Method, sr.LookbackDays, sr.Support, sr.Resistance, sr.Midpoint))
grid, err := strategy.CalculateGridLevels(strategy.GridParams{
CenterPrice: center,
LevelCount: b.cfg.GridLevels,
SpacingPercent: b.cfg.GridSpacingPercent,
AmountPerGrid: b.cfg.AmountPerGrid,
})
if err != nil {
return fmt.Errorf("calculate grid: %w", err)
}
b.Grid = grid
b.saveState(ctx)
b.notify.Send(fmt.Sprintf("Grid initialized: %d levels from $%.2f to $%.2f, center at $%.2f",
len(grid), grid[0].Price, grid[len(grid)-1].Price, center))
return nil
}
func (b *GridBot) fetchSR(ctx context.Context) *external.SRResult {
if b.dune == nil {
price := b.fetchETHPrice(ctx)
fb := strategy.CreateFallbackSR(price)
return &external.SRResult{
Support: fb.Support, Resistance: fb.Resistance,
Midpoint: fb.Midpoint, Method: fb.Method,
}
}
sr, err := b.dune.FetchSupportResistance(ctx, false)
if err != nil {
fmt.Printf("[S/R] Dune fetch failed: %v — falling back to current price\n", err)
price := b.fetchETHPrice(ctx)
fb := strategy.CreateFallbackSR(price)
return &external.SRResult{
Support: fb.Support, Resistance: fb.Resistance,
Midpoint: fb.Midpoint, Method: fb.Method,
}
}
now := time.Now()
b.LastSRRefresh = &now
return sr
}
// --- trading ---
func (b *GridBot) executeTrade(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
tradeUSD := level.Quantity * currentPrice
if err := b.guardian.PreTradeCheck(ctx, tradeUSD); err != nil {
b.notify.Send(fmt.Sprintf("[RISK] %v", err))
return err
}
if level.Side == "buy" {
return b.executeBuy(ctx, level, currentPrice)
}
return b.executeSell(ctx, level, currentPrice)
}
func (b *GridBot) executeBuy(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
return b.executeSwap(ctx, level, currentPrice, "buy")
}
func (b *GridBot) executeSell(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
return b.executeSwap(ctx, level, currentPrice, "sell")
}
func (b *GridBot) executeSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string) error {
ethAmount := level.Quantity
usdcAmount := ethAmount * currentPrice
prefix := ""
if b.cfg.PaperTradingEnabled {
prefix = "[PAPER] "
}
b.notify.Send(fmt.Sprintf("%sExecuting %s at grid level %d: ~%.6f ETH for ~%.2f USDC (@ $%.2f/ETH)",
prefix, side, level.Index, ethAmount, usdcAmount, currentPrice))
var txHash string
var slippagePct, gasCost *float64
if b.cfg.PaperTradingEnabled {
hash, slip, gas, err := b.executePaperSwap(ctx, level, currentPrice, side, ethAmount, usdcAmount)
if err != nil {
return err
}
txHash = hash
slippagePct = slip
gasCost = gas
} else {
hash, gas, err := b.executeLiveSwap(ctx, side, ethAmount, usdcAmount)
if err != nil {
return err
}
txHash = hash
gasCost = gas
}
level.Filled = true
now := time.Now()
level.FilledAt = &now
level.TxHash = &txHash
b.TradesExecuted++
b.saveState(ctx)
gridLevel := level.Index
_, _ = b.tradeRepo.Record(ctx, &models.Trade{
Timestamp: now,
Side: side,
Price: currentPrice,
Quantity: ethAmount,
USDValue: usdcAmount,
GridLevel: &gridLevel,
TxHash: &txHash,
IsPaperTrade: b.cfg.PaperTradingEnabled,
SlippagePercent: slippagePct,
GasCostETH: gasCost,
})
b.resetOppositeLevel(ctx, level)
return nil
}
func (b *GridBot) executePaperSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string, ethAmount, usdcAmount float64) (txHash string, slippagePct, gasCost *float64, err error) {
slip := randomSlippage(b.cfg.PaperSlippagePercent)
gas := defaultPaperGasCost
if side == "buy" {
actualETH := ethAmount * (1 - slip)
if err := b.paperWallet.ExecuteBuy(ctx, usdcAmount, actualETH); err != nil {
return "", nil, nil, err
}
ethAmount = actualETH
} else {
if b.paperWallet.ETHBalance < ethAmount+gas {
return "", nil, nil, fmt.Errorf("insufficient ETH: have %.6f, need %.6f", b.paperWallet.ETHBalance, ethAmount+gas)
}
usdcAmount = usdcAmount * (1 - slip)
if err := b.paperWallet.ExecuteSell(ctx, ethAmount, usdcAmount); err != nil {
return "", nil, nil, err
}
}
b.paperWallet.DeductGas(ctx, gas)
b.paperWallet.RecordTrade(ctx, PaperTrade{
Side: side, GridLevel: level.Index,
TriggerPrice: level.Price, ExecutionPrice: currentPrice,
ETHAmount: ethAmount, USDCAmount: usdcAmount,
SlippagePct: slip * 100, GasCost: gas,
})
txHash = fmt.Sprintf("0xPAPER_%s_%x", side, time.Now().UnixNano())
s := slip * 100
slippagePct = &s
gasCost = &gas
fmt.Printf("[PAPER] %s executed: %.6f ETH for %.2f USDC (slippage: %.3f%%, gas: %.6f ETH)\n",
side, ethAmount, usdcAmount, s, gas)
return txHash, slippagePct, gasCost, nil
}
func (b *GridBot) executeLiveSwap(ctx context.Context, side string, ethAmount, usdcAmount float64) (txHash string, gasCost *float64, err error) {
var hash string
var swapErr error
if side == "buy" {
b.notify.Send(fmt.Sprintf("Broadcasting BUY TX: %.6f ETH for %.2f USDC...", ethAmount, usdcAmount))
hash, swapErr = b.uniswap.SwapUSDCForETH(ctx, usdcAmount, ethAmount)
} else {
b.notify.Send(fmt.Sprintf("Broadcasting SELL TX: %.6f ETH for ~%.2f USDC...", ethAmount, usdcAmount))
hash, swapErr = b.uniswap.SwapETHForUSDC(ctx, ethAmount)
}
if swapErr != nil {
b.notify.Send(fmt.Sprintf("%s TX failed: %v", side, swapErr))
return "", nil, fmt.Errorf("swap failed (%s): %w", side, swapErr)
}
b.notify.Send(fmt.Sprintf("%s TX confirmed: %s", side, b.uniswap.ExplorerURL(hash)))
gas, _ := b.uniswap.GasCostETH(ctx)
gasCost = &gas
return hash, gasCost, nil
}
func (b *GridBot) resetOppositeLevel(ctx context.Context, filled *strategy.GridLevel) {
idx := strategy.GetOppositeLevelIndex(filled, len(b.Grid))
if idx == nil {
return
}
adj := &b.Grid[*idx]
if adj.Filled {
adj.Filled = false
adj.FilledAt = nil
adj.TxHash = nil
b.saveState(ctx)
fmt.Printf("Reset grid level %d for opposite trade\n", *idx)
}
}
// --- risk ---
// portfolioPnLPercent returns the current unrealized P&L as a percentage.
// The second return value is false when P&L cannot be determined (e.g. live
// mode without balance tracking), in which case the caller should skip the
// portfolio-level check.
func (b *GridBot) portfolioPnLPercent(currentPrice float64) (float64, bool) {
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
return b.paperWallet.Stats(currentPrice).UnrealizedPnLPct, true
}
return 0, false
}
// --- main loop ---
func (b *GridBot) Run(ctx context.Context) {
b.running = true
b.notify.Send(fmt.Sprintf("Starting ETH grid trader with %d levels, %.1f%% spacing",
b.cfg.GridLevels, b.cfg.GridSpacingPercent))
if len(b.Grid) == 0 {
if err := b.InitializeGrid(ctx); err != nil {
fmt.Printf("Failed to initialize grid: %v\n", err)
return
}
}
display := strategy.FormatGridDisplay(b.Grid, b.BasePrice, b.cfg.AmountPerGrid)
fmt.Println("\n" + display + "\n")
ticker := time.NewTicker(time.Duration(b.cfg.PriceCheckIntervalSeconds) * time.Second)
defer ticker.Stop()
// Do one immediate tick
b.tick(ctx)
for {
select {
case <-b.stopCh:
b.running = false
b.notify.Send("Grid trader shutting down")
return
case <-ctx.Done():
b.running = false
return
case <-ticker.C:
b.tick(ctx)
}
}
}
func (b *GridBot) tick(ctx context.Context) {
b.PriceChecks++
price := b.fetchETHPrice(ctx)
if price <= 0 {
fmt.Println("Could not fetch ETH price, skipping tick")
return
}
if pnl, ok := b.portfolioPnLPercent(price); ok {
if err := b.guardian.PortfolioCheck(pnl); err != nil {
b.notify.Send(fmt.Sprintf("CIRCUIT BREAKER: %v — halting trading", err))
fmt.Printf("[RISK] %v\n", err)
close(b.stopCh)
b.running = false
return
}
}
triggered := strategy.FindTriggeredLevel(price, b.Grid)
if triggered != nil {
fmt.Printf("Grid level %d triggered: %s ETH at $%.2f\n",
triggered.Index, triggered.Side, triggered.Price)
if err := b.executeTrade(ctx, triggered, price); err != nil {
fmt.Printf("Trade execution failed: %v\n", err)
} else {
// Post-trade cooldown
select {
case <-time.After(time.Duration(b.cfg.PostTradeCooldownSeconds) * time.Second):
case <-b.stopCh:
return
}
}
}
b.maybeReportStatus(ctx, price)
}
func (b *GridBot) maybeReportStatus(ctx context.Context, currentPrice float64) {
interval := time.Duration(b.cfg.StatusReportIntervalMinutes) * time.Minute
if time.Since(b.LastStatusReport) < interval {
return
}
stats := strategy.GetGridStats(b.Grid)
prefix := ""
if b.cfg.PaperTradingEnabled {
prefix = "[PAPER] "
}
var ethBal, usdcBal float64
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
ethBal = b.paperWallet.ETHBalance
usdcBal = b.paperWallet.USDCBalance
} else if b.uniswap != nil {
ethBal, _ = b.uniswap.ETHBalance(ctx)
usdcBal, _ = b.uniswap.TokenBalance(ctx)
}
b.notify.Send(fmt.Sprintf(
"%sStatus: ETH @ $%.2f | ETH: %.4f ($%.2f) | USDC: %.2f | Grid: %d/%d buys, %d/%d sells | Checks: %d | Trades: %d",
prefix, currentPrice,
ethBal, ethBal*currentPrice, usdcBal,
stats.FilledBuys, stats.FilledBuys+stats.PendingBuys,
stats.FilledSells, stats.FilledSells+stats.PendingSells,
b.PriceChecks, b.TradesExecuted,
))
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
ps := b.paperWallet.Stats(currentPrice)
sign := "+"
if ps.UnrealizedPnL < 0 {
sign = ""
}
b.notify.Send(fmt.Sprintf(
"[PAPER P&L] Initial: $%.2f -> Current: $%.2f | P&L: %s$%.2f (%s%.2f%%) | Gas: %.6f ETH ($%.2f) | Running: %.1fh",
ps.InitialValueUSD, ps.CurrentValueUSD,
sign, ps.UnrealizedPnL, sign, ps.UnrealizedPnLPct,
ps.TotalGasSpent, ps.GasSpentUSD, ps.RunningTimeHours,
))
}
b.LastStatusReport = time.Now()
}
func (b *GridBot) Shutdown() {
if b.running {
close(b.stopCh)
}
if b.ethClient != nil {
b.ethClient.Close()
}
fmt.Println("[BOT] Shutting down gracefully")
}
func (b *GridBot) IsRunning() bool {
return b.running
}

View File

@@ -0,0 +1,187 @@
package bot
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"time"
"github.com/kjannette/trahn-backend/internal/models"
"github.com/kjannette/trahn-backend/internal/repository"
)
type PaperWallet struct {
gridRepo *repository.GridStateRepo
initialETH float64
initialUSDC float64
ETHBalance float64
USDCBalance float64
Trades []PaperTrade
TotalGas float64
StartTime time.Time
}
type PaperTrade struct {
ID int `json:"id"`
Timestamp string `json:"timestamp"`
Side string `json:"side"`
GridLevel int `json:"gridLevel"`
TriggerPrice float64 `json:"triggerPrice"`
ExecutionPrice float64 `json:"executionPrice"`
ETHAmount float64 `json:"ethAmount"`
USDCAmount float64 `json:"usdcAmount"`
SlippagePct float64 `json:"slippagePercent"`
GasCost float64 `json:"gasCost"`
BalanceAfter BalSnap `json:"balanceAfter"`
}
type BalSnap struct {
ETH float64 `json:"eth"`
USDC float64 `json:"usdc"`
}
func NewPaperWallet(gridRepo *repository.GridStateRepo, initialETH, initialUSDC float64) *PaperWallet {
return &PaperWallet{
gridRepo: gridRepo,
initialETH: initialETH,
initialUSDC: initialUSDC,
ETHBalance: initialETH,
USDCBalance: initialUSDC,
StartTime: time.Now(),
}
}
func (pw *PaperWallet) Init(ctx context.Context) error {
state, err := pw.gridRepo.GetPaperWallet(ctx)
if err != nil {
return fmt.Errorf("load paper state: %w", err)
}
if state != nil && state.ETHBalance > 0 {
pw.ETHBalance = state.ETHBalance
pw.USDCBalance = state.USDCBalance
pw.TotalGas = state.TotalGasSpent
pw.initialETH = state.InitialETH
pw.initialUSDC = state.InitialUSDC
if state.StartTime != nil {
pw.StartTime = *state.StartTime
}
if state.Trades != nil {
_ = json.Unmarshal(state.Trades, &pw.Trades)
}
fmt.Printf("[PAPER] Loaded from DB: %.6f ETH, %.2f USDC, %d trades\n",
pw.ETHBalance, pw.USDCBalance, len(pw.Trades))
} else {
fmt.Printf("[PAPER] Starting fresh paper wallet: %.4f ETH, %.2f USDC\n",
pw.initialETH, pw.initialUSDC)
if err := pw.gridRepo.InitializePaperWallet(ctx, pw.initialETH, pw.initialUSDC); err != nil {
return fmt.Errorf("initialize paper wallet: %w", err)
}
}
return nil
}
func (pw *PaperWallet) save(ctx context.Context) {
tradesJSON, _ := json.Marshal(pw.Trades)
err := pw.gridRepo.UpdatePaperWallet(ctx, &models.PaperWallet{
ETHBalance: pw.ETHBalance,
USDCBalance: pw.USDCBalance,
TotalGasSpent: pw.TotalGas,
Trades: tradesJSON,
})
if err != nil {
fmt.Printf("[PAPER] Failed to save paper state: %v\n", err)
}
}
func (pw *PaperWallet) ExecuteBuy(ctx context.Context, usdcAmount, ethAmount float64) error {
if pw.USDCBalance < usdcAmount {
return fmt.Errorf("insufficient USDC: have %.2f, need %.2f", pw.USDCBalance, usdcAmount)
}
pw.USDCBalance -= usdcAmount
pw.ETHBalance += ethAmount
pw.save(ctx)
return nil
}
func (pw *PaperWallet) ExecuteSell(ctx context.Context, ethAmount, usdcAmount float64) error {
if pw.ETHBalance < ethAmount {
return fmt.Errorf("insufficient ETH: have %.6f, need %.6f", pw.ETHBalance, ethAmount)
}
pw.ETHBalance -= ethAmount
pw.USDCBalance += usdcAmount
pw.save(ctx)
return nil
}
func (pw *PaperWallet) DeductGas(ctx context.Context, gasETH float64) {
pw.ETHBalance -= gasETH
pw.TotalGas += gasETH
pw.save(ctx)
}
func (pw *PaperWallet) RecordTrade(ctx context.Context, t PaperTrade) {
t.ID = len(pw.Trades) + 1
t.Timestamp = time.Now().UTC().Format(time.RFC3339)
t.BalanceAfter = BalSnap{ETH: pw.ETHBalance, USDC: pw.USDCBalance}
pw.Trades = append(pw.Trades, t)
pw.save(ctx)
}
type PaperStats struct {
InitialETH float64
InitialUSDC float64
CurrentETH float64
CurrentUSDC float64
InitialValueUSD float64
CurrentValueUSD float64
UnrealizedPnL float64
UnrealizedPnLPct float64
TotalTrades int
BuyTrades int
SellTrades int
TotalGasSpent float64
GasSpentUSD float64
RunningTimeHours float64
}
func (pw *PaperWallet) Stats(currentETHPrice float64) PaperStats {
initialVal := pw.initialETH*currentETHPrice + pw.initialUSDC
currentVal := pw.ETHBalance*currentETHPrice + pw.USDCBalance
pnl := currentVal - initialVal
pnlPct := 0.0
if initialVal > 0 {
pnlPct = pnl / initialVal * 100
}
buys, sells := 0, 0
for _, t := range pw.Trades {
if t.Side == "buy" {
buys++
} else {
sells++
}
}
return PaperStats{
InitialETH: pw.initialETH,
InitialUSDC: pw.initialUSDC,
CurrentETH: pw.ETHBalance,
CurrentUSDC: pw.USDCBalance,
InitialValueUSD: initialVal,
CurrentValueUSD: currentVal,
UnrealizedPnL: pnl,
UnrealizedPnLPct: pnlPct,
TotalTrades: len(pw.Trades),
BuyTrades: buys,
SellTrades: sells,
TotalGasSpent: pw.TotalGas,
GasSpentUSD: pw.TotalGas * currentETHPrice,
RunningTimeHours: time.Since(pw.StartTime).Hours(),
}
}
func randomSlippage(maxPct float64) float64 {
return rand.Float64() * maxPct / 100
}
const defaultPaperGasCost = 0.005

View File

@@ -0,0 +1,108 @@
package bot
import (
"context"
"fmt"
"sync"
"github.com/kjannette/trahn-backend/internal/config"
"github.com/kjannette/trahn-backend/internal/external"
"github.com/kjannette/trahn-backend/internal/notifications"
"github.com/kjannette/trahn-backend/internal/repository"
"github.com/kjannette/trahn-backend/internal/scheduler"
"github.com/kjannette/trahn-backend/internal/strategy"
)
type Service struct {
mu sync.Mutex
bot *GridBot
}
func NewService() *Service {
return &Service{}
}
func (s *Service) Start(ctx context.Context, cfg *config.Config,
priceRepo *repository.PriceRepo,
tradeRepo *repository.TradeRepo,
gridRepo *repository.GridStateRepo,
notify *notifications.Sender,
dune *external.DuneClient,
) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.bot != nil && s.bot.IsRunning() {
fmt.Println("[BOT] Already running")
return nil
}
mode := "LIVE MODE"
if cfg.PaperTradingEnabled {
mode = "PAPER MODE"
}
notify.Send(fmt.Sprintf("Starting ETH Grid Trader (ETH/%s) - %s", cfg.QuoteTokenSymbol, mode))
b := NewGridBot(cfg, priceRepo, tradeRepo, gridRepo, notify, dune)
if err := b.Init(ctx); err != nil {
return fmt.Errorf("bot init: %w", err)
}
s.bot = b
fmt.Println("[BOT] Grid trading bot initialized")
fmt.Println("[BOT] State loaded from database")
go func() {
b.Run(ctx)
fmt.Println("[BOT] Run loop exited")
}()
fmt.Println("[BOT] Started successfully")
return nil
}
func (s *Service) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.bot != nil {
s.bot.Shutdown()
s.bot = nil
}
fmt.Println("[BOT] Stopped")
}
// BotState returns the current bot state for the scheduler's decision-making.
func (s *Service) BotState() *scheduler.BotState {
s.mu.Lock()
defer s.mu.Unlock()
if s.bot == nil || len(s.bot.Grid) == 0 {
return nil
}
// Copy grid to avoid data races
grid := make([]strategy.GridLevel, len(s.bot.Grid))
copy(grid, s.bot.Grid)
return &scheduler.BotState{
Grid: grid,
LastETHPrice: s.bot.LastETHPrice,
}
}
// InitializeGrid triggers a grid recalculation (called by scheduler).
func (s *Service) InitializeGrid(ctx context.Context) {
s.mu.Lock()
b := s.bot
s.mu.Unlock()
if b == nil {
return
}
fmt.Println("[BOT] Recalculating grid with new S/R midpoint...")
b.BasePrice = 0 // force recalculation from S/R
if err := b.InitializeGrid(ctx); err != nil {
fmt.Printf("[BOT] Grid recalculation failed: %v\n", err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
trahn-trade-backend/server Executable file

Binary file not shown.