first commit of restructured project
This commit is contained in:
58
trahn-trade-backend/Makefile
Normal file
58
trahn-trade-backend/Makefile
Normal 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"
|
||||
91
trahn-trade-backend/README.md
Normal file
91
trahn-trade-backend/README.md
Normal 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 ./...
|
||||
```
|
||||
151
trahn-trade-backend/cmd/server/main.go
Normal file
151
trahn-trade-backend/cmd/server/main.go
Normal 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")
|
||||
}
|
||||
129
trahn-trade-backend/db/README.md
Normal file
129
trahn-trade-backend/db/README.md
Normal 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
80
trahn-trade-backend/db/schema.sql
Normal file
80
trahn-trade-backend/db/schema.sql
Normal 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;
|
||||
|
||||
18
trahn-trade-backend/db/setup.sql
Normal file
18
trahn-trade-backend/db/setup.sql
Normal 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
|
||||
|
||||
61
trahn-trade-backend/env.example
Normal file
61
trahn-trade-backend/env.example
Normal 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=
|
||||
|
||||
42
trahn-trade-backend/go.mod
Normal file
42
trahn-trade-backend/go.mod
Normal 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
216
trahn-trade-backend/go.sum
Normal 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=
|
||||
48
trahn-trade-backend/internal/api/grid_routes.go
Normal file
48
trahn-trade-backend/internal/api/grid_routes.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type gridCurrentResponse struct {
|
||||
BasePrice *float64 `json:"basePrice"`
|
||||
Grid json.RawMessage `json:"grid"`
|
||||
TradesExecuted int `json:"tradesExecuted"`
|
||||
TotalProfit float64 `json:"totalProfit"`
|
||||
LastUpdate *string `json:"lastUpdate,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGridCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.gridRepo.GetActive(r.Context())
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching grid state: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
writeJSON(w, http.StatusOK, gridCurrentResponse{
|
||||
Grid: json.RawMessage("[]"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
grid := state.GridLevelsJSON
|
||||
if grid == nil {
|
||||
grid = json.RawMessage("[]")
|
||||
}
|
||||
|
||||
var lastUpdate *string
|
||||
ts := state.UpdatedAt.Format("2006-01-02T15:04:05.000Z")
|
||||
lastUpdate = &ts
|
||||
|
||||
writeJSON(w, http.StatusOK, gridCurrentResponse{
|
||||
BasePrice: state.BasePrice,
|
||||
Grid: grid,
|
||||
TradesExecuted: state.TradesExecuted,
|
||||
TotalProfit: state.TotalProfit,
|
||||
LastUpdate: lastUpdate,
|
||||
})
|
||||
}
|
||||
29
trahn-trade-backend/internal/api/health.go
Normal file
29
trahn-trade-backend/internal/api/health.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Services healthServices `json:"services"`
|
||||
}
|
||||
|
||||
type healthServices struct {
|
||||
Database string `json:"database"`
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
dbStatus := "connected"
|
||||
if err := s.pool.Ping(r.Context()); err != nil {
|
||||
dbStatus = "disconnected"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, healthResponse{
|
||||
Status: "ok",
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Services: healthServices{Database: dbStatus},
|
||||
})
|
||||
}
|
||||
187
trahn-trade-backend/internal/api/middleware_test.go
Normal file
187
trahn-trade-backend/internal/api/middleware_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthMiddleware_NoKeyConfigured(t *testing.T) {
|
||||
s := &Server{apiKey: ""}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := s.authMiddleware(inner)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/trades/stats", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 when no API key configured, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_HealthBypass(t *testing.T) {
|
||||
s := &Server{apiKey: "secret123"}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := s.authMiddleware(inner)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for /health without auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_MissingHeader(t *testing.T) {
|
||||
s := &Server{apiKey: "secret123"}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := s.authMiddleware(inner)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_WrongKey(t *testing.T) {
|
||||
s := &Server{apiKey: "secret123"}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := s.authMiddleware(inner)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong_key")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_CorrectKey(t *testing.T) {
|
||||
s := &Server{apiKey: "secret123"}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := s.authMiddleware(inner)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_MalformedBearer(t *testing.T) {
|
||||
s := &Server{apiKey: "secret123"}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := s.authMiddleware(inner)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
|
||||
req.Header.Set("Authorization", "Basic secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for non-Bearer auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDate(t *testing.T) {
|
||||
valid := []string{"2024-01-15", "2025-12-31", "2020-02-29"}
|
||||
for _, d := range valid {
|
||||
if !validateDate(d) {
|
||||
t.Fatalf("expected %q to be valid", d)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"", "2024", "01-15-2024", "2024/01/15",
|
||||
"abcd-ef-gh", "2024-13-01", "2024-01-32",
|
||||
"2024-1-5", "20240115",
|
||||
}
|
||||
for _, d := range invalid {
|
||||
if validateDate(d) {
|
||||
t.Fatalf("expected %q to be invalid", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
deflt int
|
||||
expected int
|
||||
}{
|
||||
{"", 100, 100},
|
||||
{"?limit=50", 100, 50},
|
||||
{"?limit=0", 100, 100},
|
||||
{"?limit=-5", 100, 100},
|
||||
{"?limit=abc", 100, 100},
|
||||
{"?limit=2000", 100, maxQueryLimit},
|
||||
{"?limit=1000", 100, 1000},
|
||||
{"?limit=1", 50, 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test"+tc.query, nil)
|
||||
got := parseLimit(req, tc.deflt)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("parseLimit(%q, %d) = %d, want %d", tc.query, tc.deflt, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsMiddleware_Headers(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := corsMiddleware(inner, "https://myapp.example.com")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/prices/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
origin := rr.Header().Get("Access-Control-Allow-Origin")
|
||||
if origin != "https://myapp.example.com" {
|
||||
t.Fatalf("expected custom origin, got %q", origin)
|
||||
}
|
||||
|
||||
allow := rr.Header().Get("Access-Control-Allow-Headers")
|
||||
if allow == "" {
|
||||
t.Fatal("expected Allow-Headers to include Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsMiddleware_Preflight(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("inner handler should not be called for OPTIONS")
|
||||
})
|
||||
handler := corsMiddleware(inner, "*")
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/v1/prices/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for preflight, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
79
trahn-trade-backend/internal/api/price_routes.go
Normal file
79
trahn-trade-backend/internal/api/price_routes.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
)
|
||||
|
||||
type priceJSON struct {
|
||||
T int64 `json:"t"`
|
||||
P float64 `json:"p"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePricesToday(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
today := repository.TradingDayNow()
|
||||
prices, err := s.priceRepo.GetByDay(ctx, today)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching today's prices: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch prices")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]priceJSON, len(prices))
|
||||
for i, p := range prices {
|
||||
out[i] = priceJSON{T: p.Timestamp.UnixMilli(), P: p.Price}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePricesByDay(w http.ResponseWriter, r *http.Request) {
|
||||
date := r.PathValue("date")
|
||||
if !validateDate(date) {
|
||||
writeError(w, http.StatusBadRequest, "invalid date format, expected YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prices, err := s.priceRepo.GetByDay(ctx, date)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching prices for %s: %v\n", date, err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch prices")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]priceJSON, len(prices))
|
||||
for i, p := range prices {
|
||||
out[i] = priceJSON{T: p.Timestamp.UnixMilli(), P: p.Price}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleAvailableDays(w http.ResponseWriter, r *http.Request) {
|
||||
days, err := s.priceRepo.GetAvailableDays(r.Context())
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching available days: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch available days")
|
||||
return
|
||||
}
|
||||
if days == nil {
|
||||
days = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, days)
|
||||
}
|
||||
|
||||
func (s *Server) handleLatestPrice(w http.ResponseWriter, r *http.Request) {
|
||||
price, err := s.priceRepo.GetLatest(r.Context())
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching latest price: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch latest price")
|
||||
return
|
||||
}
|
||||
if price == nil {
|
||||
writeError(w, http.StatusNotFound, "no price data available")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, priceJSON{T: price.Timestamp.UnixMilli(), P: price.Price})
|
||||
}
|
||||
169
trahn-trade-backend/internal/api/server.go
Normal file
169
trahn-trade-backend/internal/api/server.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
)
|
||||
|
||||
const maxQueryLimit = 1000
|
||||
|
||||
var dateRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
type Server struct {
|
||||
pool *pgxpool.Pool
|
||||
priceRepo *repository.PriceRepo
|
||||
tradeRepo *repository.TradeRepo
|
||||
srRepo *repository.SRRepo
|
||||
gridRepo *repository.GridStateRepo
|
||||
httpServer *http.Server
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewServer(pool *pgxpool.Pool, port int, apiKey, corsOrigin string) *Server {
|
||||
s := &Server{
|
||||
pool: pool,
|
||||
priceRepo: repository.NewPriceRepo(pool),
|
||||
tradeRepo: repository.NewTradeRepo(pool),
|
||||
srRepo: repository.NewSRRepo(pool),
|
||||
gridRepo: repository.NewGridStateRepo(pool),
|
||||
apiKey: apiKey,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Price routes
|
||||
mux.HandleFunc("GET /v1/prices/today", s.handlePricesToday)
|
||||
mux.HandleFunc("GET /v1/prices/day/{date}", s.handlePricesByDay)
|
||||
mux.HandleFunc("GET /v1/prices/days", s.handleAvailableDays)
|
||||
mux.HandleFunc("GET /v1/prices/latest", s.handleLatestPrice)
|
||||
|
||||
// Trade routes
|
||||
mux.HandleFunc("GET /v1/trades/today", s.handleTradesToday)
|
||||
mux.HandleFunc("GET /v1/trades/day/{date}", s.handleTradesByDay)
|
||||
mux.HandleFunc("GET /v1/trades/all", s.handleAllTrades)
|
||||
mux.HandleFunc("GET /v1/trades/stats", s.handleTradeStats)
|
||||
|
||||
// Grid routes
|
||||
mux.HandleFunc("GET /v1/grid/current", s.handleGridCurrent)
|
||||
|
||||
// S/R routes
|
||||
mux.HandleFunc("GET /v1/support-resistance/latest", s.handleSRLatest)
|
||||
mux.HandleFunc("GET /v1/support-resistance/history", s.handleSRHistory)
|
||||
|
||||
// Health check (no auth required)
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
|
||||
handler := s.authMiddleware(corsMiddleware(mux, corsOrigin))
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: handler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
fmt.Printf("[API] REST API server started on http://localhost%s\n", s.httpServer.Addr)
|
||||
fmt.Printf("[API] Health check: http://localhost%s/health\n", s.httpServer.Addr)
|
||||
if s.apiKey != "" {
|
||||
fmt.Println("[API] Authentication: enabled (Bearer token)")
|
||||
} else {
|
||||
fmt.Println("[API] Authentication: disabled (no API_KEY configured)")
|
||||
}
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// --- middleware ---
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.apiKey == "" || r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing Authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
if token == auth || token != s.apiKey {
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func corsMiddleware(next http.Handler, allowOrigin string) http.Handler {
|
||||
if allowOrigin == "" {
|
||||
allowOrigin = "*"
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- validation helpers ---
|
||||
|
||||
func validateDate(date string) bool {
|
||||
if !dateRegexp.MatchString(date) {
|
||||
return false
|
||||
}
|
||||
_, err := time.Parse("2006-01-02", date)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func parseLimit(r *http.Request, defaultLimit int) int {
|
||||
v := r.URL.Query().Get("limit")
|
||||
if v == "" {
|
||||
return defaultLimit
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if n > maxQueryLimit {
|
||||
return maxQueryLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- response helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
52
trahn-trade-backend/internal/api/sr_routes.go
Normal file
52
trahn-trade-backend/internal/api/sr_routes.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type srLatestResponse struct {
|
||||
Support float64 `json:"support"`
|
||||
Resistance float64 `json:"resistance"`
|
||||
Midpoint float64 `json:"midpoint"`
|
||||
AvgPrice *float64 `json:"avgPrice"`
|
||||
Method string `json:"method"`
|
||||
LookbackDays int `json:"lookbackDays"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSRLatest(w http.ResponseWriter, r *http.Request) {
|
||||
sr, err := s.srRepo.GetLatest(r.Context())
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching latest S/R: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch S/R data")
|
||||
return
|
||||
}
|
||||
if sr == nil {
|
||||
writeError(w, http.StatusNotFound, "no S/R data available")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, srLatestResponse{
|
||||
Support: sr.Support,
|
||||
Resistance: sr.Resistance,
|
||||
Midpoint: sr.Midpoint,
|
||||
AvgPrice: sr.AvgPrice,
|
||||
Method: sr.Method,
|
||||
LookbackDays: sr.LookbackDays,
|
||||
Timestamp: sr.Timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSRHistory(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseLimit(r, 100)
|
||||
|
||||
history, err := s.srRepo.GetHistory(r.Context(), limit)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching S/R history: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch S/R history")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, history)
|
||||
}
|
||||
132
trahn-trade-backend/internal/api/trade_routes.go
Normal file
132
trahn-trade-backend/internal/api/trade_routes.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
)
|
||||
|
||||
type tradeJSON struct {
|
||||
T int64 `json:"t"`
|
||||
Side string `json:"side"`
|
||||
Price float64 `json:"price"`
|
||||
Qty float64 `json:"qty"`
|
||||
GridLevel *int `json:"gridLevel,omitempty"`
|
||||
USDValue float64 `json:"usdValue"`
|
||||
IsPaperTrade bool `json:"isPaperTrade"`
|
||||
}
|
||||
|
||||
// parseTradeMode extracts the ?mode= query parameter.
|
||||
// Returns a *bool: nil = all, true = paper, false = live.
|
||||
func parseTradeMode(r *http.Request) (*bool, error) {
|
||||
v := r.URL.Query().Get("mode")
|
||||
switch v {
|
||||
case "", "all":
|
||||
return nil, nil
|
||||
case "paper":
|
||||
b := true
|
||||
return &b, nil
|
||||
case "live":
|
||||
b := false
|
||||
return &b, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid mode %q, expected paper|live|all", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTradesToday(w http.ResponseWriter, r *http.Request) {
|
||||
mode, err := parseTradeMode(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
today := repository.TradingDayNow()
|
||||
|
||||
trades, err := s.tradeRepo.GetByDay(ctx, today, mode)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching today's trades: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch trades")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]tradeJSON, len(trades))
|
||||
for i, t := range trades {
|
||||
out[i] = tradeJSON{
|
||||
T: t.Timestamp.UnixMilli(), Side: t.Side,
|
||||
Price: t.Price, Qty: t.Quantity,
|
||||
GridLevel: t.GridLevel, USDValue: t.USDValue,
|
||||
IsPaperTrade: t.IsPaperTrade,
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleTradesByDay(w http.ResponseWriter, r *http.Request) {
|
||||
date := r.PathValue("date")
|
||||
if !validateDate(date) {
|
||||
writeError(w, http.StatusBadRequest, "invalid date format, expected YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
|
||||
mode, err := parseTradeMode(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
trades, err := s.tradeRepo.GetByDay(ctx, date, mode)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching trades for %s: %v\n", date, err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch trades")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]tradeJSON, len(trades))
|
||||
for i, t := range trades {
|
||||
out[i] = tradeJSON{
|
||||
T: t.Timestamp.UnixMilli(), Side: t.Side,
|
||||
Price: t.Price, Qty: t.Quantity,
|
||||
GridLevel: t.GridLevel, USDValue: t.USDValue,
|
||||
IsPaperTrade: t.IsPaperTrade,
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleAllTrades(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseLimit(r, 100)
|
||||
|
||||
mode, err := parseTradeMode(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
trades, err := s.tradeRepo.GetAll(r.Context(), limit, mode)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching all trades: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch trades")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trades)
|
||||
}
|
||||
|
||||
func (s *Server) handleTradeStats(w http.ResponseWriter, r *http.Request) {
|
||||
mode, err := parseTradeMode(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.tradeRepo.GetStats(r.Context(), mode)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching trade stats: %v\n", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch trade stats")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
548
trahn-trade-backend/internal/bot/gridbot.go
Normal file
548
trahn-trade-backend/internal/bot/gridbot.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/config"
|
||||
"github.com/kjannette/trahn-backend/internal/ethereum"
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/notifications"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/risk"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
)
|
||||
|
||||
type GridBot struct {
|
||||
cfg *config.Config
|
||||
coingecko *external.CoinGeckoClient
|
||||
dune *external.DuneClient
|
||||
priceRepo *repository.PriceRepo
|
||||
tradeRepo *repository.TradeRepo
|
||||
gridRepo *repository.GridStateRepo
|
||||
notify *notifications.Sender
|
||||
|
||||
Grid []strategy.GridLevel
|
||||
LastETHPrice float64
|
||||
BasePrice float64
|
||||
TradesExecuted int
|
||||
TotalProfit float64
|
||||
PriceChecks int
|
||||
LastStatusReport time.Time
|
||||
LastSRRefresh *time.Time
|
||||
|
||||
guardian *risk.Guardian
|
||||
paperWallet *PaperWallet
|
||||
uniswap *ethereum.UniswapV2
|
||||
ethClient *ethereum.Client
|
||||
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewGridBot(
|
||||
cfg *config.Config,
|
||||
priceRepo *repository.PriceRepo,
|
||||
tradeRepo *repository.TradeRepo,
|
||||
gridRepo *repository.GridStateRepo,
|
||||
notify *notifications.Sender,
|
||||
dune *external.DuneClient,
|
||||
) *GridBot {
|
||||
b := &GridBot{
|
||||
cfg: cfg,
|
||||
coingecko: external.NewCoinGeckoClient(),
|
||||
dune: dune,
|
||||
priceRepo: priceRepo,
|
||||
tradeRepo: tradeRepo,
|
||||
gridRepo: gridRepo,
|
||||
notify: notify,
|
||||
stopCh: make(chan struct{}),
|
||||
guardian: risk.NewGuardian(risk.Limits{
|
||||
MaxDailyTrades: cfg.MaxDailyTrades,
|
||||
MaxPositionSizeUSD: cfg.MaxPositionSizeUSD,
|
||||
StopLossPercent: cfg.StopLossPercent,
|
||||
TakeProfitPercent: cfg.TakeProfitPercent,
|
||||
}, tradeRepo),
|
||||
}
|
||||
|
||||
if dune != nil {
|
||||
fmt.Printf("[S/R] Dune Analytics configured: %s method, %d-day lookback\n", cfg.SRMethod, cfg.SRLookbackDays)
|
||||
} else {
|
||||
fmt.Println("[S/R] Dune API key not set - using fallback (current price as midpoint)")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *GridBot) Init(ctx context.Context) error {
|
||||
if err := b.loadState(ctx); err != nil {
|
||||
fmt.Printf("Warning: failed to load state: %v\n", err)
|
||||
}
|
||||
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
b.paperWallet = NewPaperWallet(b.gridRepo, b.cfg.PaperInitialETH, b.cfg.PaperInitialUSDC)
|
||||
if err := b.paperWallet.Init(ctx); err != nil {
|
||||
return fmt.Errorf("paper wallet init: %w", err)
|
||||
}
|
||||
} else {
|
||||
ethC, err := ethereum.NewClient(
|
||||
b.cfg.EthereumAPIEndpoint,
|
||||
b.cfg.PrivateKey,
|
||||
int64(b.cfg.ChainID),
|
||||
b.cfg.GasLimit,
|
||||
b.cfg.GasMultiplier,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ethereum client: %w", err)
|
||||
}
|
||||
b.ethClient = ethC
|
||||
|
||||
uni, err := ethereum.NewUniswapV2(
|
||||
ethC,
|
||||
b.cfg.UniswapRouterAddress,
|
||||
b.cfg.WETHAddress,
|
||||
b.cfg.QuoteTokenAddress,
|
||||
b.cfg.QuoteTokenSymbol,
|
||||
b.cfg.QuoteTokenDecimals,
|
||||
b.cfg.SlippageTolerance,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uniswap client: %w", err)
|
||||
}
|
||||
b.uniswap = uni
|
||||
fmt.Printf("[LIVE] Ethereum client connected, wallet %s\n", ethC.WalletAddress().Hex())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- state management ---
|
||||
|
||||
func (b *GridBot) loadState(ctx context.Context) error {
|
||||
state, err := b.gridRepo.GetActive(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state == nil || state.GridLevelsJSON == nil {
|
||||
fmt.Println("No existing state found in DB - will initialize fresh")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(state.GridLevelsJSON, &b.Grid); err != nil {
|
||||
return fmt.Errorf("unmarshal grid: %w", err)
|
||||
}
|
||||
b.TradesExecuted = state.TradesExecuted
|
||||
b.TotalProfit = state.TotalProfit
|
||||
if state.BasePrice != nil {
|
||||
b.BasePrice = *state.BasePrice
|
||||
}
|
||||
b.LastSRRefresh = state.LastSRRefresh
|
||||
|
||||
fmt.Printf("Loaded state from DB: %d grid levels, %d trades\n", len(b.Grid), b.TradesExecuted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GridBot) saveState(ctx context.Context) {
|
||||
levelsJSON, err := json.Marshal(b.Grid)
|
||||
if err != nil {
|
||||
fmt.Printf("[STATE] Failed to marshal grid levels: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = b.gridRepo.Save(ctx, &models.GridState{
|
||||
BasePrice: &b.BasePrice,
|
||||
GridLevelsJSON: levelsJSON,
|
||||
TradesExecuted: b.TradesExecuted,
|
||||
TotalProfit: b.TotalProfit,
|
||||
LastSRRefresh: b.LastSRRefresh,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[STATE] Failed to save state to DB: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- price ---
|
||||
|
||||
func (b *GridBot) fetchETHPrice(ctx context.Context) float64 {
|
||||
price, err := b.coingecko.GetETHPrice(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to fetch ETH price: %v\n", err)
|
||||
return b.LastETHPrice
|
||||
}
|
||||
if price < 100 || price > 100000 {
|
||||
fmt.Printf("ETH price %.2f failed sanity check\n", price)
|
||||
return b.LastETHPrice
|
||||
}
|
||||
b.LastETHPrice = price
|
||||
|
||||
_, _ = b.priceRepo.Record(ctx, price, time.Now())
|
||||
return price
|
||||
}
|
||||
|
||||
// --- grid initialization ---
|
||||
|
||||
func (b *GridBot) InitializeGrid(ctx context.Context) error {
|
||||
sr := b.fetchSR(ctx)
|
||||
|
||||
center := b.BasePrice
|
||||
if center == 0 {
|
||||
center = sr.Midpoint
|
||||
}
|
||||
b.BasePrice = center
|
||||
|
||||
price := b.fetchETHPrice(ctx)
|
||||
if price <= 0 {
|
||||
return fmt.Errorf("cannot initialize grid: invalid ETH price")
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf("S/R Analysis (%s, %dd): Support $%.2f | Resistance $%.2f | Midpoint $%.2f",
|
||||
sr.Method, sr.LookbackDays, sr.Support, sr.Resistance, sr.Midpoint))
|
||||
|
||||
grid, err := strategy.CalculateGridLevels(strategy.GridParams{
|
||||
CenterPrice: center,
|
||||
LevelCount: b.cfg.GridLevels,
|
||||
SpacingPercent: b.cfg.GridSpacingPercent,
|
||||
AmountPerGrid: b.cfg.AmountPerGrid,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("calculate grid: %w", err)
|
||||
}
|
||||
b.Grid = grid
|
||||
b.saveState(ctx)
|
||||
|
||||
b.notify.Send(fmt.Sprintf("Grid initialized: %d levels from $%.2f to $%.2f, center at $%.2f",
|
||||
len(grid), grid[0].Price, grid[len(grid)-1].Price, center))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GridBot) fetchSR(ctx context.Context) *external.SRResult {
|
||||
if b.dune == nil {
|
||||
price := b.fetchETHPrice(ctx)
|
||||
fb := strategy.CreateFallbackSR(price)
|
||||
return &external.SRResult{
|
||||
Support: fb.Support, Resistance: fb.Resistance,
|
||||
Midpoint: fb.Midpoint, Method: fb.Method,
|
||||
}
|
||||
}
|
||||
|
||||
sr, err := b.dune.FetchSupportResistance(ctx, false)
|
||||
if err != nil {
|
||||
fmt.Printf("[S/R] Dune fetch failed: %v — falling back to current price\n", err)
|
||||
price := b.fetchETHPrice(ctx)
|
||||
fb := strategy.CreateFallbackSR(price)
|
||||
return &external.SRResult{
|
||||
Support: fb.Support, Resistance: fb.Resistance,
|
||||
Midpoint: fb.Midpoint, Method: fb.Method,
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
b.LastSRRefresh = &now
|
||||
return sr
|
||||
}
|
||||
|
||||
// --- trading ---
|
||||
|
||||
func (b *GridBot) executeTrade(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
|
||||
tradeUSD := level.Quantity * currentPrice
|
||||
if err := b.guardian.PreTradeCheck(ctx, tradeUSD); err != nil {
|
||||
b.notify.Send(fmt.Sprintf("[RISK] %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if level.Side == "buy" {
|
||||
return b.executeBuy(ctx, level, currentPrice)
|
||||
}
|
||||
return b.executeSell(ctx, level, currentPrice)
|
||||
}
|
||||
|
||||
func (b *GridBot) executeBuy(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
|
||||
return b.executeSwap(ctx, level, currentPrice, "buy")
|
||||
}
|
||||
|
||||
func (b *GridBot) executeSell(ctx context.Context, level *strategy.GridLevel, currentPrice float64) error {
|
||||
return b.executeSwap(ctx, level, currentPrice, "sell")
|
||||
}
|
||||
|
||||
func (b *GridBot) executeSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string) error {
|
||||
ethAmount := level.Quantity
|
||||
usdcAmount := ethAmount * currentPrice
|
||||
prefix := ""
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
prefix = "[PAPER] "
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf("%sExecuting %s at grid level %d: ~%.6f ETH for ~%.2f USDC (@ $%.2f/ETH)",
|
||||
prefix, side, level.Index, ethAmount, usdcAmount, currentPrice))
|
||||
|
||||
var txHash string
|
||||
var slippagePct, gasCost *float64
|
||||
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
hash, slip, gas, err := b.executePaperSwap(ctx, level, currentPrice, side, ethAmount, usdcAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txHash = hash
|
||||
slippagePct = slip
|
||||
gasCost = gas
|
||||
} else {
|
||||
hash, gas, err := b.executeLiveSwap(ctx, side, ethAmount, usdcAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txHash = hash
|
||||
gasCost = gas
|
||||
}
|
||||
|
||||
level.Filled = true
|
||||
now := time.Now()
|
||||
level.FilledAt = &now
|
||||
level.TxHash = &txHash
|
||||
b.TradesExecuted++
|
||||
b.saveState(ctx)
|
||||
|
||||
gridLevel := level.Index
|
||||
_, _ = b.tradeRepo.Record(ctx, &models.Trade{
|
||||
Timestamp: now,
|
||||
Side: side,
|
||||
Price: currentPrice,
|
||||
Quantity: ethAmount,
|
||||
USDValue: usdcAmount,
|
||||
GridLevel: &gridLevel,
|
||||
TxHash: &txHash,
|
||||
IsPaperTrade: b.cfg.PaperTradingEnabled,
|
||||
SlippagePercent: slippagePct,
|
||||
GasCostETH: gasCost,
|
||||
})
|
||||
|
||||
b.resetOppositeLevel(ctx, level)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GridBot) executePaperSwap(ctx context.Context, level *strategy.GridLevel, currentPrice float64, side string, ethAmount, usdcAmount float64) (txHash string, slippagePct, gasCost *float64, err error) {
|
||||
slip := randomSlippage(b.cfg.PaperSlippagePercent)
|
||||
gas := defaultPaperGasCost
|
||||
|
||||
if side == "buy" {
|
||||
actualETH := ethAmount * (1 - slip)
|
||||
if err := b.paperWallet.ExecuteBuy(ctx, usdcAmount, actualETH); err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
ethAmount = actualETH
|
||||
} else {
|
||||
if b.paperWallet.ETHBalance < ethAmount+gas {
|
||||
return "", nil, nil, fmt.Errorf("insufficient ETH: have %.6f, need %.6f", b.paperWallet.ETHBalance, ethAmount+gas)
|
||||
}
|
||||
usdcAmount = usdcAmount * (1 - slip)
|
||||
if err := b.paperWallet.ExecuteSell(ctx, ethAmount, usdcAmount); err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
b.paperWallet.DeductGas(ctx, gas)
|
||||
b.paperWallet.RecordTrade(ctx, PaperTrade{
|
||||
Side: side, GridLevel: level.Index,
|
||||
TriggerPrice: level.Price, ExecutionPrice: currentPrice,
|
||||
ETHAmount: ethAmount, USDCAmount: usdcAmount,
|
||||
SlippagePct: slip * 100, GasCost: gas,
|
||||
})
|
||||
|
||||
txHash = fmt.Sprintf("0xPAPER_%s_%x", side, time.Now().UnixNano())
|
||||
s := slip * 100
|
||||
slippagePct = &s
|
||||
gasCost = &gas
|
||||
fmt.Printf("[PAPER] %s executed: %.6f ETH for %.2f USDC (slippage: %.3f%%, gas: %.6f ETH)\n",
|
||||
side, ethAmount, usdcAmount, s, gas)
|
||||
return txHash, slippagePct, gasCost, nil
|
||||
}
|
||||
|
||||
func (b *GridBot) executeLiveSwap(ctx context.Context, side string, ethAmount, usdcAmount float64) (txHash string, gasCost *float64, err error) {
|
||||
var hash string
|
||||
var swapErr error
|
||||
|
||||
if side == "buy" {
|
||||
b.notify.Send(fmt.Sprintf("Broadcasting BUY TX: %.6f ETH for %.2f USDC...", ethAmount, usdcAmount))
|
||||
hash, swapErr = b.uniswap.SwapUSDCForETH(ctx, usdcAmount, ethAmount)
|
||||
} else {
|
||||
b.notify.Send(fmt.Sprintf("Broadcasting SELL TX: %.6f ETH for ~%.2f USDC...", ethAmount, usdcAmount))
|
||||
hash, swapErr = b.uniswap.SwapETHForUSDC(ctx, ethAmount)
|
||||
}
|
||||
if swapErr != nil {
|
||||
b.notify.Send(fmt.Sprintf("%s TX failed: %v", side, swapErr))
|
||||
return "", nil, fmt.Errorf("swap failed (%s): %w", side, swapErr)
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf("%s TX confirmed: %s", side, b.uniswap.ExplorerURL(hash)))
|
||||
gas, _ := b.uniswap.GasCostETH(ctx)
|
||||
gasCost = &gas
|
||||
return hash, gasCost, nil
|
||||
}
|
||||
|
||||
func (b *GridBot) resetOppositeLevel(ctx context.Context, filled *strategy.GridLevel) {
|
||||
idx := strategy.GetOppositeLevelIndex(filled, len(b.Grid))
|
||||
if idx == nil {
|
||||
return
|
||||
}
|
||||
adj := &b.Grid[*idx]
|
||||
if adj.Filled {
|
||||
adj.Filled = false
|
||||
adj.FilledAt = nil
|
||||
adj.TxHash = nil
|
||||
b.saveState(ctx)
|
||||
fmt.Printf("Reset grid level %d for opposite trade\n", *idx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- risk ---
|
||||
|
||||
// portfolioPnLPercent returns the current unrealized P&L as a percentage.
|
||||
// The second return value is false when P&L cannot be determined (e.g. live
|
||||
// mode without balance tracking), in which case the caller should skip the
|
||||
// portfolio-level check.
|
||||
func (b *GridBot) portfolioPnLPercent(currentPrice float64) (float64, bool) {
|
||||
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
|
||||
return b.paperWallet.Stats(currentPrice).UnrealizedPnLPct, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// --- main loop ---
|
||||
|
||||
func (b *GridBot) Run(ctx context.Context) {
|
||||
b.running = true
|
||||
|
||||
b.notify.Send(fmt.Sprintf("Starting ETH grid trader with %d levels, %.1f%% spacing",
|
||||
b.cfg.GridLevels, b.cfg.GridSpacingPercent))
|
||||
|
||||
if len(b.Grid) == 0 {
|
||||
if err := b.InitializeGrid(ctx); err != nil {
|
||||
fmt.Printf("Failed to initialize grid: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
display := strategy.FormatGridDisplay(b.Grid, b.BasePrice, b.cfg.AmountPerGrid)
|
||||
fmt.Println("\n" + display + "\n")
|
||||
|
||||
ticker := time.NewTicker(time.Duration(b.cfg.PriceCheckIntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Do one immediate tick
|
||||
b.tick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-b.stopCh:
|
||||
b.running = false
|
||||
b.notify.Send("Grid trader shutting down")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
b.running = false
|
||||
return
|
||||
case <-ticker.C:
|
||||
b.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *GridBot) tick(ctx context.Context) {
|
||||
b.PriceChecks++
|
||||
|
||||
price := b.fetchETHPrice(ctx)
|
||||
if price <= 0 {
|
||||
fmt.Println("Could not fetch ETH price, skipping tick")
|
||||
return
|
||||
}
|
||||
|
||||
if pnl, ok := b.portfolioPnLPercent(price); ok {
|
||||
if err := b.guardian.PortfolioCheck(pnl); err != nil {
|
||||
b.notify.Send(fmt.Sprintf("CIRCUIT BREAKER: %v — halting trading", err))
|
||||
fmt.Printf("[RISK] %v\n", err)
|
||||
close(b.stopCh)
|
||||
b.running = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
triggered := strategy.FindTriggeredLevel(price, b.Grid)
|
||||
if triggered != nil {
|
||||
fmt.Printf("Grid level %d triggered: %s ETH at $%.2f\n",
|
||||
triggered.Index, triggered.Side, triggered.Price)
|
||||
|
||||
if err := b.executeTrade(ctx, triggered, price); err != nil {
|
||||
fmt.Printf("Trade execution failed: %v\n", err)
|
||||
} else {
|
||||
// Post-trade cooldown
|
||||
select {
|
||||
case <-time.After(time.Duration(b.cfg.PostTradeCooldownSeconds) * time.Second):
|
||||
case <-b.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.maybeReportStatus(ctx, price)
|
||||
}
|
||||
|
||||
func (b *GridBot) maybeReportStatus(ctx context.Context, currentPrice float64) {
|
||||
interval := time.Duration(b.cfg.StatusReportIntervalMinutes) * time.Minute
|
||||
if time.Since(b.LastStatusReport) < interval {
|
||||
return
|
||||
}
|
||||
|
||||
stats := strategy.GetGridStats(b.Grid)
|
||||
prefix := ""
|
||||
if b.cfg.PaperTradingEnabled {
|
||||
prefix = "[PAPER] "
|
||||
}
|
||||
|
||||
var ethBal, usdcBal float64
|
||||
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
|
||||
ethBal = b.paperWallet.ETHBalance
|
||||
usdcBal = b.paperWallet.USDCBalance
|
||||
} else if b.uniswap != nil {
|
||||
ethBal, _ = b.uniswap.ETHBalance(ctx)
|
||||
usdcBal, _ = b.uniswap.TokenBalance(ctx)
|
||||
}
|
||||
|
||||
b.notify.Send(fmt.Sprintf(
|
||||
"%sStatus: ETH @ $%.2f | ETH: %.4f ($%.2f) | USDC: %.2f | Grid: %d/%d buys, %d/%d sells | Checks: %d | Trades: %d",
|
||||
prefix, currentPrice,
|
||||
ethBal, ethBal*currentPrice, usdcBal,
|
||||
stats.FilledBuys, stats.FilledBuys+stats.PendingBuys,
|
||||
stats.FilledSells, stats.FilledSells+stats.PendingSells,
|
||||
b.PriceChecks, b.TradesExecuted,
|
||||
))
|
||||
|
||||
if b.cfg.PaperTradingEnabled && b.paperWallet != nil {
|
||||
ps := b.paperWallet.Stats(currentPrice)
|
||||
sign := "+"
|
||||
if ps.UnrealizedPnL < 0 {
|
||||
sign = ""
|
||||
}
|
||||
b.notify.Send(fmt.Sprintf(
|
||||
"[PAPER P&L] Initial: $%.2f -> Current: $%.2f | P&L: %s$%.2f (%s%.2f%%) | Gas: %.6f ETH ($%.2f) | Running: %.1fh",
|
||||
ps.InitialValueUSD, ps.CurrentValueUSD,
|
||||
sign, ps.UnrealizedPnL, sign, ps.UnrealizedPnLPct,
|
||||
ps.TotalGasSpent, ps.GasSpentUSD, ps.RunningTimeHours,
|
||||
))
|
||||
}
|
||||
|
||||
b.LastStatusReport = time.Now()
|
||||
}
|
||||
|
||||
func (b *GridBot) Shutdown() {
|
||||
if b.running {
|
||||
close(b.stopCh)
|
||||
}
|
||||
if b.ethClient != nil {
|
||||
b.ethClient.Close()
|
||||
}
|
||||
fmt.Println("[BOT] Shutting down gracefully")
|
||||
}
|
||||
|
||||
func (b *GridBot) IsRunning() bool {
|
||||
return b.running
|
||||
}
|
||||
187
trahn-trade-backend/internal/bot/paper_wallet.go
Normal file
187
trahn-trade-backend/internal/bot/paper_wallet.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
)
|
||||
|
||||
type PaperWallet struct {
|
||||
gridRepo *repository.GridStateRepo
|
||||
initialETH float64
|
||||
initialUSDC float64
|
||||
ETHBalance float64
|
||||
USDCBalance float64
|
||||
Trades []PaperTrade
|
||||
TotalGas float64
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
type PaperTrade struct {
|
||||
ID int `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Side string `json:"side"`
|
||||
GridLevel int `json:"gridLevel"`
|
||||
TriggerPrice float64 `json:"triggerPrice"`
|
||||
ExecutionPrice float64 `json:"executionPrice"`
|
||||
ETHAmount float64 `json:"ethAmount"`
|
||||
USDCAmount float64 `json:"usdcAmount"`
|
||||
SlippagePct float64 `json:"slippagePercent"`
|
||||
GasCost float64 `json:"gasCost"`
|
||||
BalanceAfter BalSnap `json:"balanceAfter"`
|
||||
}
|
||||
|
||||
type BalSnap struct {
|
||||
ETH float64 `json:"eth"`
|
||||
USDC float64 `json:"usdc"`
|
||||
}
|
||||
|
||||
func NewPaperWallet(gridRepo *repository.GridStateRepo, initialETH, initialUSDC float64) *PaperWallet {
|
||||
return &PaperWallet{
|
||||
gridRepo: gridRepo,
|
||||
initialETH: initialETH,
|
||||
initialUSDC: initialUSDC,
|
||||
ETHBalance: initialETH,
|
||||
USDCBalance: initialUSDC,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) Init(ctx context.Context) error {
|
||||
state, err := pw.gridRepo.GetPaperWallet(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load paper state: %w", err)
|
||||
}
|
||||
|
||||
if state != nil && state.ETHBalance > 0 {
|
||||
pw.ETHBalance = state.ETHBalance
|
||||
pw.USDCBalance = state.USDCBalance
|
||||
pw.TotalGas = state.TotalGasSpent
|
||||
pw.initialETH = state.InitialETH
|
||||
pw.initialUSDC = state.InitialUSDC
|
||||
if state.StartTime != nil {
|
||||
pw.StartTime = *state.StartTime
|
||||
}
|
||||
if state.Trades != nil {
|
||||
_ = json.Unmarshal(state.Trades, &pw.Trades)
|
||||
}
|
||||
fmt.Printf("[PAPER] Loaded from DB: %.6f ETH, %.2f USDC, %d trades\n",
|
||||
pw.ETHBalance, pw.USDCBalance, len(pw.Trades))
|
||||
} else {
|
||||
fmt.Printf("[PAPER] Starting fresh paper wallet: %.4f ETH, %.2f USDC\n",
|
||||
pw.initialETH, pw.initialUSDC)
|
||||
if err := pw.gridRepo.InitializePaperWallet(ctx, pw.initialETH, pw.initialUSDC); err != nil {
|
||||
return fmt.Errorf("initialize paper wallet: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) save(ctx context.Context) {
|
||||
tradesJSON, _ := json.Marshal(pw.Trades)
|
||||
err := pw.gridRepo.UpdatePaperWallet(ctx, &models.PaperWallet{
|
||||
ETHBalance: pw.ETHBalance,
|
||||
USDCBalance: pw.USDCBalance,
|
||||
TotalGasSpent: pw.TotalGas,
|
||||
Trades: tradesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[PAPER] Failed to save paper state: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) ExecuteBuy(ctx context.Context, usdcAmount, ethAmount float64) error {
|
||||
if pw.USDCBalance < usdcAmount {
|
||||
return fmt.Errorf("insufficient USDC: have %.2f, need %.2f", pw.USDCBalance, usdcAmount)
|
||||
}
|
||||
pw.USDCBalance -= usdcAmount
|
||||
pw.ETHBalance += ethAmount
|
||||
pw.save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) ExecuteSell(ctx context.Context, ethAmount, usdcAmount float64) error {
|
||||
if pw.ETHBalance < ethAmount {
|
||||
return fmt.Errorf("insufficient ETH: have %.6f, need %.6f", pw.ETHBalance, ethAmount)
|
||||
}
|
||||
pw.ETHBalance -= ethAmount
|
||||
pw.USDCBalance += usdcAmount
|
||||
pw.save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) DeductGas(ctx context.Context, gasETH float64) {
|
||||
pw.ETHBalance -= gasETH
|
||||
pw.TotalGas += gasETH
|
||||
pw.save(ctx)
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) RecordTrade(ctx context.Context, t PaperTrade) {
|
||||
t.ID = len(pw.Trades) + 1
|
||||
t.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
t.BalanceAfter = BalSnap{ETH: pw.ETHBalance, USDC: pw.USDCBalance}
|
||||
pw.Trades = append(pw.Trades, t)
|
||||
pw.save(ctx)
|
||||
}
|
||||
|
||||
type PaperStats struct {
|
||||
InitialETH float64
|
||||
InitialUSDC float64
|
||||
CurrentETH float64
|
||||
CurrentUSDC float64
|
||||
InitialValueUSD float64
|
||||
CurrentValueUSD float64
|
||||
UnrealizedPnL float64
|
||||
UnrealizedPnLPct float64
|
||||
TotalTrades int
|
||||
BuyTrades int
|
||||
SellTrades int
|
||||
TotalGasSpent float64
|
||||
GasSpentUSD float64
|
||||
RunningTimeHours float64
|
||||
}
|
||||
|
||||
func (pw *PaperWallet) Stats(currentETHPrice float64) PaperStats {
|
||||
initialVal := pw.initialETH*currentETHPrice + pw.initialUSDC
|
||||
currentVal := pw.ETHBalance*currentETHPrice + pw.USDCBalance
|
||||
pnl := currentVal - initialVal
|
||||
pnlPct := 0.0
|
||||
if initialVal > 0 {
|
||||
pnlPct = pnl / initialVal * 100
|
||||
}
|
||||
buys, sells := 0, 0
|
||||
for _, t := range pw.Trades {
|
||||
if t.Side == "buy" {
|
||||
buys++
|
||||
} else {
|
||||
sells++
|
||||
}
|
||||
}
|
||||
return PaperStats{
|
||||
InitialETH: pw.initialETH,
|
||||
InitialUSDC: pw.initialUSDC,
|
||||
CurrentETH: pw.ETHBalance,
|
||||
CurrentUSDC: pw.USDCBalance,
|
||||
InitialValueUSD: initialVal,
|
||||
CurrentValueUSD: currentVal,
|
||||
UnrealizedPnL: pnl,
|
||||
UnrealizedPnLPct: pnlPct,
|
||||
TotalTrades: len(pw.Trades),
|
||||
BuyTrades: buys,
|
||||
SellTrades: sells,
|
||||
TotalGasSpent: pw.TotalGas,
|
||||
GasSpentUSD: pw.TotalGas * currentETHPrice,
|
||||
RunningTimeHours: time.Since(pw.StartTime).Hours(),
|
||||
}
|
||||
}
|
||||
|
||||
func randomSlippage(maxPct float64) float64 {
|
||||
return rand.Float64() * maxPct / 100
|
||||
}
|
||||
|
||||
const defaultPaperGasCost = 0.005
|
||||
108
trahn-trade-backend/internal/bot/service.go
Normal file
108
trahn-trade-backend/internal/bot/service.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/config"
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/notifications"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/scheduler"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
bot *GridBot
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, cfg *config.Config,
|
||||
priceRepo *repository.PriceRepo,
|
||||
tradeRepo *repository.TradeRepo,
|
||||
gridRepo *repository.GridStateRepo,
|
||||
notify *notifications.Sender,
|
||||
dune *external.DuneClient,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot != nil && s.bot.IsRunning() {
|
||||
fmt.Println("[BOT] Already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
mode := "LIVE MODE"
|
||||
if cfg.PaperTradingEnabled {
|
||||
mode = "PAPER MODE"
|
||||
}
|
||||
notify.Send(fmt.Sprintf("Starting ETH Grid Trader (ETH/%s) - %s", cfg.QuoteTokenSymbol, mode))
|
||||
|
||||
b := NewGridBot(cfg, priceRepo, tradeRepo, gridRepo, notify, dune)
|
||||
if err := b.Init(ctx); err != nil {
|
||||
return fmt.Errorf("bot init: %w", err)
|
||||
}
|
||||
s.bot = b
|
||||
|
||||
fmt.Println("[BOT] Grid trading bot initialized")
|
||||
fmt.Println("[BOT] State loaded from database")
|
||||
|
||||
go func() {
|
||||
b.Run(ctx)
|
||||
fmt.Println("[BOT] Run loop exited")
|
||||
}()
|
||||
|
||||
fmt.Println("[BOT] Started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot != nil {
|
||||
s.bot.Shutdown()
|
||||
s.bot = nil
|
||||
}
|
||||
fmt.Println("[BOT] Stopped")
|
||||
}
|
||||
|
||||
// BotState returns the current bot state for the scheduler's decision-making.
|
||||
func (s *Service) BotState() *scheduler.BotState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.bot == nil || len(s.bot.Grid) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy grid to avoid data races
|
||||
grid := make([]strategy.GridLevel, len(s.bot.Grid))
|
||||
copy(grid, s.bot.Grid)
|
||||
|
||||
return &scheduler.BotState{
|
||||
Grid: grid,
|
||||
LastETHPrice: s.bot.LastETHPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeGrid triggers a grid recalculation (called by scheduler).
|
||||
func (s *Service) InitializeGrid(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
b := s.bot
|
||||
s.mu.Unlock()
|
||||
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
fmt.Println("[BOT] Recalculating grid with new S/R midpoint...")
|
||||
b.BasePrice = 0 // force recalculation from S/R
|
||||
if err := b.InitializeGrid(ctx); err != nil {
|
||||
fmt.Printf("[BOT] Grid recalculation failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
259
trahn-trade-backend/internal/config/config.go
Normal file
259
trahn-trade-backend/internal/config/config.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Secrets (from .env)
|
||||
DuneAPIKey string
|
||||
WalletAddress string
|
||||
PrivateKey string
|
||||
EthereumAPIEndpoint string
|
||||
WebhookURL string
|
||||
BotName string
|
||||
APIKey string
|
||||
CORSAllowOrigin string
|
||||
|
||||
// Database
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
|
||||
// Blockchain
|
||||
ChainID int
|
||||
QuoteTokenAddress string
|
||||
QuoteTokenSymbol string
|
||||
QuoteTokenDecimals int
|
||||
WETHAddress string
|
||||
UniswapRouterAddress string
|
||||
|
||||
// Support/Resistance
|
||||
SRMethod string
|
||||
SRRefreshHours int
|
||||
SRLookbackDays int
|
||||
|
||||
// Risk Management
|
||||
MaxDailyTrades int
|
||||
MaxPositionSizeUSD float64
|
||||
StopLossPercent float64
|
||||
TakeProfitPercent float64
|
||||
|
||||
// Paper Trading
|
||||
PaperTradingEnabled bool
|
||||
PaperInitialETH float64
|
||||
PaperInitialUSDC float64
|
||||
PaperSlippagePercent float64
|
||||
PaperSimulateGas bool
|
||||
|
||||
// Grid Configuration
|
||||
GridLevels int
|
||||
GridSpacingPercent float64
|
||||
GridBasePrice float64
|
||||
AmountPerGrid float64
|
||||
|
||||
// Trading Parameters
|
||||
SlippageTolerance float64
|
||||
GasMultiplier float64
|
||||
MinProfitPercent float64
|
||||
GasLimit int
|
||||
|
||||
// Timing
|
||||
PriceCheckIntervalSeconds int
|
||||
StatusReportIntervalMinutes int
|
||||
PostTradeCooldownSeconds int
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
// Secrets
|
||||
DuneAPIKey: envStr("DUNE_API_KEY", ""),
|
||||
WalletAddress: envStr("WALLET_ADDRESS", ""),
|
||||
PrivateKey: envStr("PRIVATE_KEY", ""),
|
||||
EthereumAPIEndpoint: envStr("ETHEREUM_API_ENDPOINT", ""),
|
||||
WebhookURL: envStr("WEBHOOK_URL", ""),
|
||||
BotName: envStr("BOT_NAME", "TrahnGridTrader"),
|
||||
APIKey: envStr("API_KEY", ""),
|
||||
CORSAllowOrigin: envStr("CORS_ALLOW_ORIGIN", "*"),
|
||||
|
||||
// Database
|
||||
DBHost: envStr("DB_HOST", "localhost"),
|
||||
DBPort: envInt("DB_PORT", 5432),
|
||||
DBName: envStr("DB_NAME", "trahn_grid_trader"),
|
||||
DBUser: envStr("DB_USER", ""),
|
||||
DBPassword: envStr("DB_PASSWORD", ""),
|
||||
|
||||
// Blockchain
|
||||
ChainID: envInt("CHAIN_ID", 1),
|
||||
QuoteTokenAddress: envStr("QUOTE_TOKEN_ADDRESS", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
|
||||
QuoteTokenSymbol: envStr("QUOTE_TOKEN_SYMBOL", "USDC"),
|
||||
QuoteTokenDecimals: envInt("QUOTE_TOKEN_DECIMALS", 6),
|
||||
WETHAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
||||
UniswapRouterAddress: "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
|
||||
|
||||
// Support/Resistance
|
||||
SRMethod: envStr("SR_METHOD", "simple"),
|
||||
SRRefreshHours: envInt("SR_REFRESH_HOURS", 48),
|
||||
SRLookbackDays: envInt("SR_LOOKBACK_DAYS", 14),
|
||||
|
||||
// Risk Management
|
||||
MaxDailyTrades: envInt("MAX_DAILY_TRADES", 50),
|
||||
MaxPositionSizeUSD: envFloat("MAX_POSITION_SIZE_USD", 10000),
|
||||
StopLossPercent: envFloat("STOP_LOSS_PERCENT", 0),
|
||||
TakeProfitPercent: envFloat("TAKE_PROFIT_PERCENT", 0),
|
||||
|
||||
// Paper Trading
|
||||
PaperTradingEnabled: envBool("PAPER_TRADING_ENABLED", true),
|
||||
PaperInitialETH: envFloat("PAPER_INITIAL_ETH", 1.0),
|
||||
PaperInitialUSDC: envFloat("PAPER_INITIAL_USDC", 1000),
|
||||
PaperSlippagePercent: envFloat("PAPER_SLIPPAGE_PERCENT", 0.5),
|
||||
PaperSimulateGas: envBool("PAPER_SIMULATE_GAS", true),
|
||||
|
||||
// Grid
|
||||
GridLevels: envInt("GRID_LEVELS", 10),
|
||||
GridSpacingPercent: envFloat("GRID_SPACING_PERCENT", 2),
|
||||
GridBasePrice: envFloat("GRID_BASE_PRICE", 0),
|
||||
AmountPerGrid: envFloat("AMOUNT_PER_GRID", 100),
|
||||
|
||||
// Trading Parameters
|
||||
SlippageTolerance: envFloat("SLIPPAGE_TOLERANCE", 1.5),
|
||||
GasMultiplier: envFloat("GAS_MULTIPLIER", 1.2),
|
||||
MinProfitPercent: envFloat("MIN_PROFIT_PERCENT", 0.5),
|
||||
GasLimit: envInt("GAS_LIMIT", 250000),
|
||||
|
||||
// Timing
|
||||
PriceCheckIntervalSeconds: envInt("PRICE_CHECK_INTERVAL_SECONDS", 30),
|
||||
StatusReportIntervalMinutes: envInt("STATUS_REPORT_INTERVAL_MINUTES", 60),
|
||||
PostTradeCooldownSeconds: envInt("POST_TRADE_COOLDOWN_SECONDS", 60),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var errs []string
|
||||
|
||||
if c.WalletAddress == "" {
|
||||
errs = append(errs, "WALLET_ADDRESS is required")
|
||||
}
|
||||
if !c.PaperTradingEnabled && c.PrivateKey == "" {
|
||||
errs = append(errs, "PRIVATE_KEY is required for live trading")
|
||||
}
|
||||
if c.DuneAPIKey == "" {
|
||||
fmt.Println("[WARN] DUNE_API_KEY not set — will use current price for grid center (fallback mode)")
|
||||
}
|
||||
if c.StopLossPercent == 0 && c.TakeProfitPercent == 0 {
|
||||
fmt.Println("[WARN] STOP_LOSS_PERCENT and TAKE_PROFIT_PERCENT are both 0 — no portfolio circuit breakers active")
|
||||
}
|
||||
if c.MaxDailyTrades == 0 && c.MaxPositionSizeUSD == 0 {
|
||||
fmt.Println("[WARN] MAX_DAILY_TRADES and MAX_POSITION_SIZE_USD are both 0 — no per-trade limits active")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
fmt.Println("[WARN] API_KEY not set — REST API has no authentication")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("config validation failed:\n %s", strings.Join(errs, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) Print() {
|
||||
fmt.Println("=== ETH Grid Trading Bot Configuration ===")
|
||||
|
||||
if c.PaperTradingEnabled {
|
||||
fmt.Println("════════════════════════════════════════")
|
||||
fmt.Println(" PAPER TRADING MODE ENABLED")
|
||||
fmt.Println(" No real transactions will execute")
|
||||
fmt.Println("════════════════════════════════════════")
|
||||
fmt.Printf("Paper Initial ETH: %.4f\n", c.PaperInitialETH)
|
||||
fmt.Printf("Paper Initial %s: %.2f\n", c.QuoteTokenSymbol, c.PaperInitialUSDC)
|
||||
fmt.Printf("Paper Slippage: 0-%.1f%%\n", c.PaperSlippagePercent)
|
||||
fmt.Printf("Paper Gas Simulation: %v\n", c.PaperSimulateGas)
|
||||
} else {
|
||||
fmt.Println(" LIVE TRADING MODE")
|
||||
}
|
||||
|
||||
fmt.Println("--------------------------------------")
|
||||
fmt.Printf("Chain ID: %d\n", c.ChainID)
|
||||
if len(c.WalletAddress) > 16 {
|
||||
fmt.Printf("Wallet: %s...%s\n", c.WalletAddress[:10], c.WalletAddress[len(c.WalletAddress)-6:])
|
||||
}
|
||||
fmt.Printf("Trading Pair: ETH/%s\n", c.QuoteTokenSymbol)
|
||||
fmt.Printf("Quote Token: %s (%s...)\n", c.QuoteTokenSymbol, truncAddr(c.QuoteTokenAddress))
|
||||
fmt.Println("--------------------------------------")
|
||||
fmt.Println("Grid Configuration:")
|
||||
fmt.Printf(" Levels: %d\n", c.GridLevels)
|
||||
fmt.Printf(" Spacing: %.1f%%\n", c.GridSpacingPercent)
|
||||
fmt.Printf(" Amount/Grid: $%.0f\n", c.AmountPerGrid)
|
||||
fmt.Println("--------------------------------------")
|
||||
fmt.Println("Support/Resistance Configuration:")
|
||||
fmt.Printf(" S/R Method: %s\n", c.SRMethod)
|
||||
fmt.Printf(" S/R Refresh: every %d hours\n", c.SRRefreshHours)
|
||||
fmt.Printf(" S/R Lookback: %d days\n", c.SRLookbackDays)
|
||||
fmt.Printf(" Dune API: %s\n", boolLabel(c.DuneAPIKey != "", "configured", "not set (fallback mode)"))
|
||||
fmt.Println("======================================")
|
||||
}
|
||||
|
||||
func (c *Config) DSN() string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func envStr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envFloat(key string, fallback float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
v = strings.ToLower(v)
|
||||
return v == "true" || v == "1" || v == "yes"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func truncAddr(addr string) string {
|
||||
if len(addr) > 10 {
|
||||
return addr[:10]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func boolLabel(cond bool, ifTrue, ifFalse string) string {
|
||||
if cond {
|
||||
return ifTrue
|
||||
}
|
||||
return ifFalse
|
||||
}
|
||||
49
trahn-trade-backend/internal/db/connection.go
Normal file
49
trahn-trade-backend/internal/db/connection.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnIdleTime = 30 * time.Second
|
||||
cfg.MaxConnLifetime = 5 * time.Minute
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
p, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := p.Ping(ctx); err != nil {
|
||||
p.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func TestConnection(p *pgxpool.Pool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var now time.Time
|
||||
err := p.QueryRow(ctx, "SELECT NOW()").Scan(&now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("test query: %w", err)
|
||||
}
|
||||
fmt.Printf("[DB] Connection successful at %s\n", now.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
74
trahn-trade-backend/internal/ethereum/abi.go
Normal file
74
trahn-trade-backend/internal/ethereum/abi.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Minimal ABIs for Uniswap V2 Router02 and ERC20 — only the methods we call.
|
||||
|
||||
func mustRouterABI() io.Reader {
|
||||
return strings.NewReader(`[
|
||||
{
|
||||
"name": "swapExactTokensForETH",
|
||||
"type": "function",
|
||||
"stateMutability": "nonpayable",
|
||||
"inputs": [
|
||||
{"name": "amountIn", "type": "uint256"},
|
||||
{"name": "amountOutMin", "type": "uint256"},
|
||||
{"name": "path", "type": "address[]"},
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "deadline", "type": "uint256"}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "amounts", "type": "uint256[]"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "swapExactETHForTokens",
|
||||
"type": "function",
|
||||
"stateMutability": "payable",
|
||||
"inputs": [
|
||||
{"name": "amountOutMin", "type": "uint256"},
|
||||
{"name": "path", "type": "address[]"},
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "deadline", "type": "uint256"}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "amounts", "type": "uint256[]"}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
}
|
||||
|
||||
func mustERC20ABI() io.Reader {
|
||||
return strings.NewReader(`[
|
||||
{
|
||||
"name": "balanceOf",
|
||||
"type": "function",
|
||||
"stateMutability": "view",
|
||||
"inputs": [{"name": "_owner", "type": "address"}],
|
||||
"outputs": [{"name": "balance", "type": "uint256"}]
|
||||
},
|
||||
{
|
||||
"name": "allowance",
|
||||
"type": "function",
|
||||
"stateMutability": "view",
|
||||
"inputs": [
|
||||
{"name": "_owner", "type": "address"},
|
||||
{"name": "_spender", "type": "address"}
|
||||
],
|
||||
"outputs": [{"name": "", "type": "uint256"}]
|
||||
},
|
||||
{
|
||||
"name": "approve",
|
||||
"type": "function",
|
||||
"stateMutability": "nonpayable",
|
||||
"inputs": [
|
||||
{"name": "_spender", "type": "address"},
|
||||
{"name": "_value", "type": "uint256"}
|
||||
],
|
||||
"outputs": [{"name": "", "type": "bool"}]
|
||||
}
|
||||
]`)
|
||||
}
|
||||
118
trahn-trade-backend/internal/ethereum/client.go
Normal file
118
trahn-trade-backend/internal/ethereum/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rpc *ethclient.Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
wallet common.Address
|
||||
chainID *big.Int
|
||||
gasLimit uint64
|
||||
gasMul float64
|
||||
}
|
||||
|
||||
func NewClient(rpcURL, privateKeyHex string, chainID int64, gasLimit int, gasMultiplier float64) (*Client, error) {
|
||||
rpc, err := ethclient.Dial(rpcURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial RPC: %w", err)
|
||||
}
|
||||
|
||||
pkHex := strings.TrimPrefix(privateKeyHex, "0x")
|
||||
pk, err := crypto.HexToECDSA(pkHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
addr := crypto.PubkeyToAddress(pk.PublicKey)
|
||||
|
||||
return &Client{
|
||||
rpc: rpc,
|
||||
privateKey: pk,
|
||||
wallet: addr,
|
||||
chainID: big.NewInt(chainID),
|
||||
gasLimit: uint64(gasLimit),
|
||||
gasMul: gasMultiplier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) WalletAddress() common.Address { return c.wallet }
|
||||
func (c *Client) GasLimit() uint64 { return c.gasLimit }
|
||||
func (c *Client) Close() { c.rpc.Close() }
|
||||
|
||||
func (c *Client) ETHBalance(ctx context.Context) (*big.Int, error) {
|
||||
return c.rpc.BalanceAt(ctx, c.wallet, nil)
|
||||
}
|
||||
|
||||
func (c *Client) GasPrice(ctx context.Context) (*big.Int, error) {
|
||||
price, err := c.rpc.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Apply multiplier
|
||||
mul := new(big.Float).SetFloat64(c.gasMul)
|
||||
adjusted := new(big.Float).Mul(new(big.Float).SetInt(price), mul)
|
||||
result, _ := adjusted.Int(nil)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Nonce(ctx context.Context) (uint64, error) {
|
||||
return c.rpc.PendingNonceAt(ctx, c.wallet)
|
||||
}
|
||||
|
||||
// SignAndSend signs a legacy transaction and broadcasts it, returning the tx hash.
|
||||
func (c *Client) SignAndSend(ctx context.Context, to common.Address, value *big.Int, data []byte) (string, error) {
|
||||
nonce, err := c.Nonce(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get nonce: %w", err)
|
||||
}
|
||||
gasPrice, err := c.GasPrice(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get gas price: %w", err)
|
||||
}
|
||||
|
||||
tx := types.NewTx(&types.LegacyTx{
|
||||
Nonce: nonce,
|
||||
To: &to,
|
||||
Value: value,
|
||||
Gas: c.gasLimit,
|
||||
GasPrice: gasPrice,
|
||||
Data: data,
|
||||
})
|
||||
|
||||
signer := types.NewEIP155Signer(c.chainID)
|
||||
signed, err := types.SignTx(tx, signer, c.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign tx: %w", err)
|
||||
}
|
||||
|
||||
if err := c.rpc.SendTransaction(ctx, signed); err != nil {
|
||||
return "", fmt.Errorf("send tx: %w", err)
|
||||
}
|
||||
|
||||
return signed.Hash().Hex(), nil
|
||||
}
|
||||
|
||||
// CallContract performs a read-only eth_call and returns the raw result.
|
||||
func (c *Client) CallContract(ctx context.Context, to common.Address, data []byte) ([]byte, error) {
|
||||
msg := map[string]interface{}{
|
||||
"to": to.Hex(),
|
||||
"data": fmt.Sprintf("0x%x", data),
|
||||
}
|
||||
var result string
|
||||
err := c.rpc.Client().CallContext(ctx, &result, "eth_call", msg, "latest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return common.FromHex(result), nil
|
||||
}
|
||||
184
trahn-trade-backend/internal/ethereum/uniswap.go
Normal file
184
trahn-trade-backend/internal/ethereum/uniswap.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
)
|
||||
|
||||
const explorerTxPrefix = "https://etherscan.io/tx/"
|
||||
|
||||
// UniswapV2 wraps an Ethereum Client and provides Uniswap V2 Router swap methods.
|
||||
type UniswapV2 struct {
|
||||
client *Client
|
||||
routerAddr common.Address
|
||||
wethAddr common.Address
|
||||
quoteAddr common.Address
|
||||
quoteSymbol string
|
||||
quoteDec int
|
||||
slippagePct float64
|
||||
routerABI abi.ABI
|
||||
erc20ABI abi.ABI
|
||||
}
|
||||
|
||||
func NewUniswapV2(
|
||||
client *Client,
|
||||
routerAddr, wethAddr, quoteAddr string,
|
||||
quoteSymbol string,
|
||||
quoteDecimals int,
|
||||
slippagePct float64,
|
||||
) (*UniswapV2, error) {
|
||||
rABI, err := abi.JSON(mustRouterABI())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse router ABI: %w", err)
|
||||
}
|
||||
eABI, err := abi.JSON(mustERC20ABI())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ERC20 ABI: %w", err)
|
||||
}
|
||||
return &UniswapV2{
|
||||
client: client,
|
||||
routerAddr: common.HexToAddress(routerAddr),
|
||||
wethAddr: common.HexToAddress(wethAddr),
|
||||
quoteAddr: common.HexToAddress(quoteAddr),
|
||||
quoteSymbol: quoteSymbol,
|
||||
quoteDec: quoteDecimals,
|
||||
slippagePct: slippagePct,
|
||||
routerABI: rABI,
|
||||
erc20ABI: eABI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *UniswapV2) ExplorerURL(txHash string) string {
|
||||
return explorerTxPrefix + txHash
|
||||
}
|
||||
|
||||
// TokenBalance returns the ERC20 token balance as a human-readable float.
|
||||
func (u *UniswapV2) TokenBalance(ctx context.Context) (float64, error) {
|
||||
data, err := u.erc20ABI.Pack("balanceOf", u.client.wallet)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
result, err := u.client.CallContract(ctx, u.quoteAddr, data)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("balanceOf call: %w", err)
|
||||
}
|
||||
bal := new(big.Int).SetBytes(result)
|
||||
divisor := math.Pow10(u.quoteDec)
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(bal), new(big.Float).SetFloat64(divisor)).Float64()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ETHBalance returns wallet ETH balance as a human-readable float.
|
||||
func (u *UniswapV2) ETHBalance(ctx context.Context) (float64, error) {
|
||||
bal, err := u.client.ETHBalance(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(bal), new(big.Float).SetFloat64(1e18)).Float64()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// EnsureAllowance checks the router's allowance for the quote token and approves max if needed.
|
||||
func (u *UniswapV2) EnsureAllowance(ctx context.Context, requiredAmount float64) error {
|
||||
data, err := u.erc20ABI.Pack("allowance", u.client.wallet, u.routerAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := u.client.CallContract(ctx, u.quoteAddr, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("allowance call: %w", err)
|
||||
}
|
||||
current := new(big.Int).SetBytes(result)
|
||||
|
||||
requiredWei := toTokenWei(requiredAmount*2, u.quoteDec)
|
||||
if current.Cmp(requiredWei) >= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Setting %s allowance for Uniswap Router...\n", u.quoteSymbol)
|
||||
maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
approveData, err := u.erc20ABI.Pack("approve", u.routerAddr, maxUint256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txHash, err := u.client.SignAndSend(ctx, u.quoteAddr, big.NewInt(0), approveData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("approve tx: %w", err)
|
||||
}
|
||||
fmt.Printf("Allowance TX confirmed: %s\n", u.ExplorerURL(txHash))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapUSDCForETH executes swapExactTokensForETH on the Uniswap V2 Router.
|
||||
// Returns the transaction hash.
|
||||
func (u *UniswapV2) SwapUSDCForETH(ctx context.Context, usdcAmount, minETHOut float64) (string, error) {
|
||||
if err := u.EnsureAllowance(ctx, usdcAmount); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := []common.Address{u.quoteAddr, u.wethAddr}
|
||||
deadline := big.NewInt(time.Now().Unix() + 20*60)
|
||||
amountIn := toTokenWei(usdcAmount, u.quoteDec)
|
||||
// Apply slippage to minETHOut
|
||||
minOutWei := toEthWei(minETHOut * (1 - u.slippagePct/100))
|
||||
|
||||
data, err := u.routerABI.Pack("swapExactTokensForETH",
|
||||
amountIn, minOutWei, path, u.client.wallet, deadline)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pack swapExactTokensForETH: %w", err)
|
||||
}
|
||||
|
||||
return u.client.SignAndSend(ctx, u.routerAddr, big.NewInt(0), data)
|
||||
}
|
||||
|
||||
// SwapETHForUSDC executes swapExactETHForTokens on the Uniswap V2 Router.
|
||||
// Returns the transaction hash.
|
||||
func (u *UniswapV2) SwapETHForUSDC(ctx context.Context, ethAmount float64) (string, error) {
|
||||
path := []common.Address{u.wethAddr, u.quoteAddr}
|
||||
deadline := big.NewInt(time.Now().Unix() + 20*60)
|
||||
value := toEthWei(ethAmount)
|
||||
|
||||
data, err := u.routerABI.Pack("swapExactETHForTokens",
|
||||
big.NewInt(0), path, u.client.wallet, deadline)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pack swapExactETHForTokens: %w", err)
|
||||
}
|
||||
|
||||
return u.client.SignAndSend(ctx, u.routerAddr, value, data)
|
||||
}
|
||||
|
||||
// GasCostETH estimates the gas cost for a transaction in ETH.
|
||||
func (u *UniswapV2) GasCostETH(ctx context.Context) (float64, error) {
|
||||
gasPrice, err := u.client.GasPrice(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cost := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(u.client.GasLimit()))
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(cost), new(big.Float).SetFloat64(1e18)).Float64()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func toEthWei(eth float64) *big.Int {
|
||||
// eth * 1e18
|
||||
f := new(big.Float).Mul(new(big.Float).SetFloat64(eth), new(big.Float).SetFloat64(1e18))
|
||||
i, _ := f.Int(nil)
|
||||
return i
|
||||
}
|
||||
|
||||
func toTokenWei(amount float64, decimals int) *big.Int {
|
||||
f := new(big.Float).Mul(
|
||||
new(big.Float).SetFloat64(amount),
|
||||
new(big.Float).SetFloat64(math.Pow10(decimals)),
|
||||
)
|
||||
i, _ := f.Int(nil)
|
||||
return i
|
||||
}
|
||||
58
trahn-trade-backend/internal/external/coingecko.go
vendored
Normal file
58
trahn-trade-backend/internal/external/coingecko.go
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/httputil"
|
||||
)
|
||||
|
||||
const coingeckoURL = "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd"
|
||||
|
||||
type CoinGeckoClient struct {
|
||||
httpClient *http.Client
|
||||
retry httputil.RetryConfig
|
||||
}
|
||||
|
||||
func NewCoinGeckoClient() *CoinGeckoClient {
|
||||
return &CoinGeckoClient{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
retry: httputil.RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
BaseDelay: 2 * time.Second,
|
||||
MaxDelay: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CoinGeckoClient) GetETHPrice(ctx context.Context) (float64, error) {
|
||||
resp, err := httputil.Do(ctx, c.httpClient, c.retry, func() (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, coingeckoURL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("coingecko fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("coingecko returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Ethereum struct {
|
||||
USD float64 `json:"usd"`
|
||||
} `json:"ethereum"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return 0, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
if data.Ethereum.USD <= 0 {
|
||||
return 0, fmt.Errorf("invalid price: %f", data.Ethereum.USD)
|
||||
}
|
||||
|
||||
return data.Ethereum.USD, nil
|
||||
}
|
||||
325
trahn-trade-backend/internal/external/dune.go
vendored
Normal file
325
trahn-trade-backend/internal/external/dune.go
vendored
Normal file
@@ -0,0 +1,325 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/httputil"
|
||||
)
|
||||
|
||||
type DuneClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
method string // "simple" or "percentile"
|
||||
lookbackDays int
|
||||
httpClient *http.Client
|
||||
retry httputil.RetryConfig
|
||||
|
||||
mu sync.Mutex
|
||||
cachedResult *SRResult
|
||||
lastFetch time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
type SRResult struct {
|
||||
Support float64 `json:"support"`
|
||||
Resistance float64 `json:"resistance"`
|
||||
Midpoint float64 `json:"midpoint"`
|
||||
AvgPrice float64 `json:"avgPrice"`
|
||||
Method string `json:"method"`
|
||||
LookbackDays int `json:"lookbackDays"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
}
|
||||
|
||||
type DuneOptions struct {
|
||||
Method string
|
||||
LookbackDays int
|
||||
RefreshHours int
|
||||
}
|
||||
|
||||
func NewDuneClient(apiKey string, opts DuneOptions) *DuneClient {
|
||||
method := opts.Method
|
||||
if method == "" {
|
||||
method = "simple"
|
||||
}
|
||||
lookback := opts.LookbackDays
|
||||
if lookback <= 0 {
|
||||
lookback = 14
|
||||
}
|
||||
refreshHours := opts.RefreshHours
|
||||
if refreshHours <= 0 {
|
||||
refreshHours = 48
|
||||
}
|
||||
|
||||
return &DuneClient{
|
||||
apiKey: apiKey,
|
||||
baseURL: "https://api.dune.com/api/v1",
|
||||
method: method,
|
||||
lookbackDays: lookback,
|
||||
httpClient: &http.Client{Timeout: 90 * time.Second},
|
||||
cacheTTL: time.Duration(refreshHours) * time.Hour,
|
||||
retry: httputil.RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
BaseDelay: 3 * time.Second,
|
||||
MaxDelay: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DuneClient) FetchSupportResistance(ctx context.Context, forceRefresh bool) (*SRResult, error) {
|
||||
d.mu.Lock()
|
||||
if !forceRefresh && d.cachedResult != nil && time.Since(d.lastFetch) < d.cacheTTL {
|
||||
cached := *d.cachedResult
|
||||
d.mu.Unlock()
|
||||
age := time.Since(d.lastFetch)
|
||||
fmt.Printf("[DUNE] Using cached S/R data (age: %.1f min)\n", age.Minutes())
|
||||
return &cached, nil
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
sql := d.buildSRQuery()
|
||||
rows, err := d.executeQuery(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("dune returned no data for S/R query")
|
||||
}
|
||||
|
||||
row := rows[0]
|
||||
result := &SRResult{
|
||||
Support: jsonFloat(row, "support"),
|
||||
Resistance: jsonFloat(row, "resistance"),
|
||||
Midpoint: jsonFloat(row, "midpoint"),
|
||||
AvgPrice: jsonFloat(row, "avg_price"),
|
||||
Method: d.method,
|
||||
LookbackDays: d.lookbackDays,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
if math.IsNaN(result.Support) || math.IsNaN(result.Resistance) || math.IsNaN(result.Midpoint) {
|
||||
return nil, fmt.Errorf("invalid S/R data from Dune")
|
||||
}
|
||||
if result.Support >= result.Resistance {
|
||||
return nil, fmt.Errorf("invalid S/R range: support %.2f >= resistance %.2f", result.Support, result.Resistance)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.cachedResult = result
|
||||
d.lastFetch = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
fmt.Printf("[DUNE] S/R fetched successfully:\n")
|
||||
fmt.Printf(" Support: $%.2f\n", result.Support)
|
||||
fmt.Printf(" Resistance: $%.2f\n", result.Resistance)
|
||||
fmt.Printf(" Midpoint: $%.2f\n", result.Midpoint)
|
||||
fmt.Printf(" Method: %s, Lookback: %d days\n", result.Method, result.LookbackDays)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SeedCache pre-populates the in-memory cache from a previously persisted
|
||||
// S/R result (e.g. loaded from the database on startup). The cached entry
|
||||
// is only used if it falls within the configured TTL.
|
||||
func (d *DuneClient) SeedCache(sr *SRResult) {
|
||||
if sr == nil {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
age := time.Since(sr.FetchedAt)
|
||||
if age >= d.cacheTTL {
|
||||
fmt.Printf("[DUNE] DB S/R data too old (%.1f hours), not seeding cache\n", age.Hours())
|
||||
return
|
||||
}
|
||||
|
||||
d.cachedResult = sr
|
||||
d.lastFetch = sr.FetchedAt
|
||||
fmt.Printf("[DUNE] Cache seeded from DB (age: %.1f min): midpoint $%.2f\n",
|
||||
age.Minutes(), sr.Midpoint)
|
||||
}
|
||||
|
||||
func (d *DuneClient) NeedsRefresh() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.cachedResult == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(d.lastFetch) >= d.cacheTTL
|
||||
}
|
||||
|
||||
func (d *DuneClient) buildSRQuery() string {
|
||||
if d.method == "percentile" {
|
||||
return fmt.Sprintf(`
|
||||
SELECT
|
||||
approx_percentile(price, 0.05) as support,
|
||||
approx_percentile(price, 0.95) as resistance,
|
||||
approx_percentile(price, 0.50) as midpoint,
|
||||
AVG(price) as avg_price,
|
||||
MIN(price) as absolute_low,
|
||||
MAX(price) as absolute_high
|
||||
FROM prices.usd
|
||||
WHERE symbol = 'WETH'
|
||||
AND blockchain = 'ethereum'
|
||||
AND minute > now() - interval '%d' day
|
||||
`, d.lookbackDays)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
SELECT
|
||||
MIN(price) as support,
|
||||
MAX(price) as resistance,
|
||||
(MIN(price) + MAX(price)) / 2 as midpoint,
|
||||
AVG(price) as avg_price
|
||||
FROM prices.usd
|
||||
WHERE symbol = 'WETH'
|
||||
AND blockchain = 'ethereum'
|
||||
AND minute > now() - interval '%d' day
|
||||
`, d.lookbackDays)
|
||||
}
|
||||
|
||||
func (d *DuneClient) executeQuery(ctx context.Context, sql string) ([]map[string]any, error) {
|
||||
if d.apiKey == "" {
|
||||
return nil, fmt.Errorf("dune API key not configured")
|
||||
}
|
||||
|
||||
fmt.Println("[DUNE] Executing S/R query...")
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"sql": sql,
|
||||
"performance": "medium",
|
||||
})
|
||||
|
||||
resp, err := httputil.Do(ctx, d.httpClient, d.retry, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.baseURL+"/sql/execute", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Dune-API-Key", d.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submit query: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("dune query execution failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var execResult struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&execResult); err != nil {
|
||||
return nil, fmt.Errorf("decode execution response: %w", err)
|
||||
}
|
||||
if execResult.ExecutionID == "" {
|
||||
return nil, fmt.Errorf("dune did not return an execution ID")
|
||||
}
|
||||
|
||||
fmt.Printf("[DUNE] Query submitted, execution ID: %s\n", execResult.ExecutionID)
|
||||
|
||||
const maxAttempts = 30
|
||||
const pollInterval = 2 * time.Second
|
||||
|
||||
for attempt := range maxAttempts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
|
||||
statusReq, _ := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("%s/execution/%s/status", d.baseURL, execResult.ExecutionID), nil)
|
||||
statusReq.Header.Set("X-Dune-API-Key", d.apiKey)
|
||||
|
||||
statusResp, err := d.httpClient.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("[DUNE] Status check failed (attempt %d), retrying...\n", attempt+1)
|
||||
continue
|
||||
}
|
||||
|
||||
var statusData struct {
|
||||
State string `json:"state"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
json.NewDecoder(statusResp.Body).Decode(&statusData)
|
||||
statusResp.Body.Close()
|
||||
|
||||
switch statusData.State {
|
||||
case "QUERY_STATE_COMPLETED", "completed":
|
||||
return d.fetchResults(ctx, execResult.ExecutionID)
|
||||
case "QUERY_STATE_FAILED", "failed":
|
||||
errMsg := statusData.Error
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown error"
|
||||
}
|
||||
return nil, fmt.Errorf("dune query failed: %s", errMsg)
|
||||
default:
|
||||
fmt.Printf("[DUNE] Query state: %s, waiting...\n", statusData.State)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("dune query timed out after %d seconds", maxAttempts*int(pollInterval.Seconds()))
|
||||
}
|
||||
|
||||
func (d *DuneClient) fetchResults(ctx context.Context, executionID string) ([]map[string]any, error) {
|
||||
resp, err := httputil.Do(ctx, d.httpClient, d.retry, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("%s/execution/%s/results", d.baseURL, executionID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Dune-API-Key", d.apiKey)
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch results: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch dune results: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Result struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
} `json:"result"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("decode results: %w", err)
|
||||
}
|
||||
|
||||
rows := data.Result.Rows
|
||||
if rows == nil {
|
||||
rows = data.Rows
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// jsonFloat extracts a float64 from a map[string]any, handling both float64 and json.Number.
|
||||
func jsonFloat(m map[string]any, key string) float64 {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case json.Number:
|
||||
f, _ := n.Float64()
|
||||
return f
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
80
trahn-trade-backend/internal/external/external_test.go
vendored
Normal file
80
trahn-trade-backend/internal/external/external_test.go
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = godotenv.Load("../../.env")
|
||||
}
|
||||
|
||||
func TestCoinGeckoGetETHPrice(t *testing.T) {
|
||||
client := external.NewCoinGeckoClient()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
price, err := client.GetETHPrice(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetETHPrice: %v", err)
|
||||
}
|
||||
if price <= 0 {
|
||||
t.Fatalf("expected positive price, got %f", price)
|
||||
}
|
||||
t.Logf("ETH price: $%.2f", price)
|
||||
}
|
||||
|
||||
func TestDuneFetchSupportResistance(t *testing.T) {
|
||||
apiKey := os.Getenv("DUNE_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("DUNE_API_KEY not set, skipping")
|
||||
}
|
||||
|
||||
client := external.NewDuneClient(apiKey, external.DuneOptions{
|
||||
Method: "simple",
|
||||
LookbackDays: 14,
|
||||
RefreshHours: 48,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sr, err := client.FetchSupportResistance(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchSupportResistance: %v", err)
|
||||
}
|
||||
|
||||
if sr.Support <= 0 || sr.Resistance <= 0 || sr.Midpoint <= 0 {
|
||||
t.Fatalf("invalid S/R values: %+v", sr)
|
||||
}
|
||||
if sr.Support >= sr.Resistance {
|
||||
t.Fatalf("support (%.2f) >= resistance (%.2f)", sr.Support, sr.Resistance)
|
||||
}
|
||||
|
||||
t.Logf("Support: $%.2f", sr.Support)
|
||||
t.Logf("Resistance: $%.2f", sr.Resistance)
|
||||
t.Logf("Midpoint: $%.2f", sr.Midpoint)
|
||||
t.Logf("AvgPrice: $%.2f", sr.AvgPrice)
|
||||
t.Logf("Method: %s, Lookback: %d days", sr.Method, sr.LookbackDays)
|
||||
|
||||
// Test cache hit
|
||||
sr2, err := client.FetchSupportResistance(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("cached FetchSupportResistance: %v", err)
|
||||
}
|
||||
if sr2.Midpoint != sr.Midpoint {
|
||||
t.Fatalf("cache mismatch: %.2f != %.2f", sr2.Midpoint, sr.Midpoint)
|
||||
}
|
||||
t.Log("Cache hit verified")
|
||||
|
||||
// NeedsRefresh should be false right after fetch
|
||||
if client.NeedsRefresh() {
|
||||
t.Fatal("should not need refresh right after fetch")
|
||||
}
|
||||
t.Log("NeedsRefresh: false (correct)")
|
||||
}
|
||||
73
trahn-trade-backend/internal/httputil/retry.go
Normal file
73
trahn-trade-backend/internal/httputil/retry.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RetryConfig struct {
|
||||
MaxAttempts int
|
||||
BaseDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
}
|
||||
|
||||
var DefaultRetry = RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
BaseDelay: 1 * time.Second,
|
||||
MaxDelay: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Do executes an HTTP request with exponential backoff retry.
|
||||
// The buildReq function is called on each attempt to produce a fresh request
|
||||
// (required because request bodies are consumed on each attempt).
|
||||
func Do(ctx context.Context, client *http.Client, cfg RetryConfig, buildReq func() (*http.Request, error)) (*http.Response, error) {
|
||||
if cfg.MaxAttempts <= 0 {
|
||||
cfg.MaxAttempts = DefaultRetry.MaxAttempts
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
delay := cfg.BaseDelay
|
||||
|
||||
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
|
||||
req, err := buildReq()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err == nil && resp.StatusCode < 500 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if attempt == cfg.MaxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
fmt.Printf("[RETRY] Attempt %d/%d failed: %v — retrying in %s\n",
|
||||
attempt, cfg.MaxAttempts, lastErr, delay)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
|
||||
delay *= 2
|
||||
if delay > cfg.MaxDelay {
|
||||
delay = cfg.MaxDelay
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all %d attempts failed, last error: %w", cfg.MaxAttempts, lastErr)
|
||||
}
|
||||
136
trahn-trade-backend/internal/httputil/retry_test.go
Normal file
136
trahn-trade-backend/internal/httputil/retry_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDo_SuccessFirstAttempt(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 100 * time.Millisecond, MaxDelay: 1 * time.Second}
|
||||
|
||||
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_RetriesOnServerError(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
|
||||
|
||||
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 on third attempt, got %d", resp.StatusCode)
|
||||
}
|
||||
if attempts.Load() != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", attempts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_AllAttemptsFail(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("upstream error"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
|
||||
|
||||
_, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error after all attempts failed")
|
||||
}
|
||||
if attempts.Load() != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", attempts.Load())
|
||||
}
|
||||
t.Logf("Error after retries: %v", err)
|
||||
}
|
||||
|
||||
func TestDo_NoRetryOnClientError(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
|
||||
|
||||
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if attempts.Load() != 1 {
|
||||
t.Fatalf("should not retry on 4xx, expected 1 attempt, got %d", attempts.Load())
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_RespectsContextCancellation(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
cfg := RetryConfig{MaxAttempts: 10, BaseDelay: 500 * time.Millisecond, MaxDelay: 2 * time.Second}
|
||||
|
||||
_, err := Do(ctx, client, cfg, func() (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from context cancellation")
|
||||
}
|
||||
t.Logf("Cancelled: %v", err)
|
||||
}
|
||||
37
trahn-trade-backend/internal/models/gridstate.go
Normal file
37
trahn-trade-backend/internal/models/gridstate.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GridState struct {
|
||||
ID int `json:"id"`
|
||||
BasePrice *float64 `json:"basePrice,omitempty"`
|
||||
GridLevelsJSON json.RawMessage `json:"gridLevelsJson,omitempty"`
|
||||
TradesExecuted int `json:"tradesExecuted"`
|
||||
TotalProfit float64 `json:"totalProfit"`
|
||||
LastSRRefresh *time.Time `json:"lastSrRefresh,omitempty"`
|
||||
IsActive bool `json:"isActive"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Paper wallet fields (NULL for live trading)
|
||||
PaperETHBalance *float64 `json:"paperEthBalance,omitempty"`
|
||||
PaperUSDCBalance *float64 `json:"paperUsdcBalance,omitempty"`
|
||||
PaperTotalGasSpent *float64 `json:"paperTotalGasSpent,omitempty"`
|
||||
PaperTradesJSON json.RawMessage `json:"paperTradesJson,omitempty"`
|
||||
PaperStartTime *time.Time `json:"paperStartTime,omitempty"`
|
||||
PaperInitialETH *float64 `json:"paperInitialEth,omitempty"`
|
||||
PaperInitialUSDC *float64 `json:"paperInitialUsdc,omitempty"`
|
||||
}
|
||||
|
||||
type PaperWallet struct {
|
||||
ETHBalance float64 `json:"ethBalance"`
|
||||
USDCBalance float64 `json:"usdcBalance"`
|
||||
TotalGasSpent float64 `json:"totalGasSpent"`
|
||||
Trades json.RawMessage `json:"trades"`
|
||||
StartTime *time.Time `json:"startTime"`
|
||||
InitialETH float64 `json:"initialEth"`
|
||||
InitialUSDC float64 `json:"initialUsdc"`
|
||||
}
|
||||
12
trahn-trade-backend/internal/models/price.go
Normal file
12
trahn-trade-backend/internal/models/price.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PricePoint struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Price float64 `json:"price"`
|
||||
TradingDay string `json:"tradingDay"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
27
trahn-trade-backend/internal/models/sr.go
Normal file
27
trahn-trade-backend/internal/models/sr.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SupportResistance struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
LookbackDays int `json:"lookbackDays"`
|
||||
Support float64 `json:"support"`
|
||||
Resistance float64 `json:"resistance"`
|
||||
Midpoint float64 `json:"midpoint"`
|
||||
AvgPrice *float64 `json:"avgPrice,omitempty"`
|
||||
GridRecalculated bool `json:"gridRecalculated"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (sr *SupportResistance) HasChangedSignificantly(previous *SupportResistance, thresholdPercent float64) bool {
|
||||
if previous == nil || previous.Midpoint == 0 {
|
||||
return true
|
||||
}
|
||||
change := math.Abs((sr.Midpoint - previous.Midpoint) / previous.Midpoint * 100)
|
||||
return change >= thresholdPercent
|
||||
}
|
||||
29
trahn-trade-backend/internal/models/trade.go
Normal file
29
trahn-trade-backend/internal/models/trade.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Trade struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TradingDay string `json:"tradingDay"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Price float64 `json:"price"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
USDValue float64 `json:"usdValue"`
|
||||
GridLevel *int `json:"gridLevel,omitempty"`
|
||||
TxHash *string `json:"txHash,omitempty"`
|
||||
IsPaperTrade bool `json:"isPaperTrade"`
|
||||
SlippagePercent *float64 `json:"slippagePercent,omitempty"`
|
||||
GasCostETH *float64 `json:"gasCostEth,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TradeStats struct {
|
||||
TotalTrades int64 `json:"totalTrades"`
|
||||
BuyCount int64 `json:"buyCount"`
|
||||
SellCount int64 `json:"sellCount"`
|
||||
TotalVolume *float64 `json:"totalVolume"`
|
||||
AvgPrice *float64 `json:"avgPrice"`
|
||||
FirstTrade *time.Time `json:"firstTrade"`
|
||||
LastTrade *time.Time `json:"lastTrade"`
|
||||
}
|
||||
86
trahn-trade-backend/internal/notifications/webhook.go
Normal file
86
trahn-trade-backend/internal/notifications/webhook.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/httputil"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
webhookURL string
|
||||
botName string
|
||||
httpClient *http.Client
|
||||
retry httputil.RetryConfig
|
||||
}
|
||||
|
||||
func NewSender(webhookURL, botName string) *Sender {
|
||||
if botName == "" {
|
||||
botName = "TrahnGridTrader"
|
||||
}
|
||||
return &Sender{
|
||||
webhookURL: webhookURL,
|
||||
botName: botName,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
retry: httputil.RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
BaseDelay: 1 * time.Second,
|
||||
MaxDelay: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(msg string) {
|
||||
formatted := fmt.Sprintf("[%s] %s", s.botName, msg)
|
||||
fmt.Printf("[%s] %s\n", time.Now().UTC().Format(time.RFC3339), formatted)
|
||||
|
||||
if s.webhookURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
payload := s.formatPayload(formatted)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fmt.Printf("[CHAT ERROR] marshal: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := httputil.Do(ctx, s.httpClient, s.retry, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[CHAT ERROR] Failed to send notification after retries: %v\n", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func (s *Sender) formatPayload(msg string) map[string]string {
|
||||
if strings.Contains(s.webhookURL, "discord") {
|
||||
return map[string]string{
|
||||
"content": msg,
|
||||
"username": s.botName,
|
||||
}
|
||||
}
|
||||
return map[string]string{
|
||||
"text": fmt.Sprintf("`%s`", msg),
|
||||
"username": s.botName,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Enabled() bool {
|
||||
return s.webhookURL != ""
|
||||
}
|
||||
83
trahn-trade-backend/internal/notifications/webhook_test.go
Normal file
83
trahn-trade-backend/internal/notifications/webhook_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSend_NoWebhook(t *testing.T) {
|
||||
s := NewSender("", "TestBot")
|
||||
if s.Enabled() {
|
||||
t.Fatal("should not be enabled with empty URL")
|
||||
}
|
||||
// Should log to console without error
|
||||
s.Send("hello from test")
|
||||
t.Log("Send with no webhook: OK (console only)")
|
||||
}
|
||||
|
||||
func TestSend_SlackFormat(t *testing.T) {
|
||||
var received map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := NewSender(srv.URL, "TestBot")
|
||||
if !s.Enabled() {
|
||||
t.Fatal("should be enabled")
|
||||
}
|
||||
|
||||
s.Send("grid recalculated")
|
||||
|
||||
if received["username"] != "TestBot" {
|
||||
t.Fatalf("username: got %s", received["username"])
|
||||
}
|
||||
if received["text"] == "" {
|
||||
t.Fatal("text should not be empty")
|
||||
}
|
||||
t.Logf("Slack payload: %+v", received)
|
||||
}
|
||||
|
||||
func TestSend_DiscordFormat(t *testing.T) {
|
||||
var received map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// URL containing "discord" triggers Discord format
|
||||
s := NewSender(srv.URL+"/discord/webhook", "TrahnBot")
|
||||
s.Send("trade executed: buy 0.04 ETH @ $2600")
|
||||
|
||||
if received["content"] == "" {
|
||||
t.Fatal("content should not be empty for Discord")
|
||||
}
|
||||
if received["username"] != "TrahnBot" {
|
||||
t.Fatalf("username: got %s", received["username"])
|
||||
}
|
||||
if _, hasText := received["text"]; hasText {
|
||||
t.Fatal("Discord payload should not have 'text' field")
|
||||
}
|
||||
t.Logf("Discord payload: %+v", received)
|
||||
}
|
||||
|
||||
func TestSend_WebhookError(t *testing.T) {
|
||||
s := NewSender("http://localhost:1/bogus", "TestBot")
|
||||
// Should not panic, just log the error
|
||||
s.Send("this will fail gracefully")
|
||||
t.Log("Webhook error handled gracefully")
|
||||
}
|
||||
|
||||
func TestDefaultBotName(t *testing.T) {
|
||||
s := NewSender("", "")
|
||||
if s.botName != "TrahnGridTrader" {
|
||||
t.Fatalf("expected default bot name, got %s", s.botName)
|
||||
}
|
||||
}
|
||||
202
trahn-trade-backend/internal/repository/gridstate.go
Normal file
202
trahn-trade-backend/internal/repository/gridstate.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
)
|
||||
|
||||
type GridStateRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewGridStateRepo(pool *pgxpool.Pool) *GridStateRepo {
|
||||
return &GridStateRepo{pool: pool}
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) GetActive(ctx context.Context) (*models.GridState, error) {
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`SELECT * FROM grid_state WHERE is_active = true ORDER BY updated_at DESC LIMIT 1`,
|
||||
)
|
||||
gs, err := scanGridState(row)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return gs, nil
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) Save(ctx context.Context, data *models.GridState) (*models.GridState, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `UPDATE grid_state SET is_active = false WHERE is_active = true`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx,
|
||||
`INSERT INTO grid_state
|
||||
(base_price, grid_levels_json, trades_executed, total_profit,
|
||||
last_sr_refresh, is_active, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,true,NOW())
|
||||
RETURNING *`,
|
||||
data.BasePrice,
|
||||
data.GridLevelsJSON,
|
||||
data.TradesExecuted,
|
||||
data.TotalProfit,
|
||||
data.LastSRRefresh,
|
||||
)
|
||||
gs, err := scanGridState(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gs, nil
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) UpdateGridLevels(ctx context.Context, levels json.RawMessage) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`UPDATE grid_state SET grid_levels_json = $1, updated_at = NOW() WHERE is_active = true`,
|
||||
levels,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) UpdateTradeStats(ctx context.Context, tradesExecuted int, totalProfit float64) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`UPDATE grid_state SET trades_executed = $1, total_profit = $2, updated_at = NOW() WHERE is_active = true`,
|
||||
tradesExecuted, totalProfit,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) UpdatePaperWallet(ctx context.Context, pw *models.PaperWallet) error {
|
||||
tradesJSON, err := json.Marshal(pw.Trades)
|
||||
if err != nil {
|
||||
tradesJSON = []byte("[]")
|
||||
}
|
||||
_, err = r.pool.Exec(ctx,
|
||||
`UPDATE grid_state
|
||||
SET paper_eth_balance = $1,
|
||||
paper_usdc_balance = $2,
|
||||
paper_total_gas_spent = $3,
|
||||
paper_trades_json = $4,
|
||||
updated_at = NOW()
|
||||
WHERE is_active = true`,
|
||||
pw.ETHBalance, pw.USDCBalance, pw.TotalGasSpent, tradesJSON,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) InitializePaperWallet(ctx context.Context, initialETH, initialUSDC float64) error {
|
||||
state, err := r.GetActive(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state != nil && state.PaperETHBalance != nil {
|
||||
return nil // already initialized
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx,
|
||||
`UPDATE grid_state
|
||||
SET paper_eth_balance = $1,
|
||||
paper_usdc_balance = $2,
|
||||
paper_initial_eth = $1,
|
||||
paper_initial_usdc = $2,
|
||||
paper_total_gas_spent = 0,
|
||||
paper_trades_json = '[]'::jsonb,
|
||||
paper_start_time = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE is_active = true`,
|
||||
initialETH, initialUSDC,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) GetPaperWallet(ctx context.Context) (*models.PaperWallet, error) {
|
||||
state, err := r.GetActive(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state == nil || state.PaperETHBalance == nil {
|
||||
return nil, nil
|
||||
}
|
||||
pw := &models.PaperWallet{
|
||||
ETHBalance: valOr(state.PaperETHBalance, 0),
|
||||
USDCBalance: valOr(state.PaperUSDCBalance, 0),
|
||||
TotalGasSpent: valOr(state.PaperTotalGasSpent, 0),
|
||||
Trades: state.PaperTradesJSON,
|
||||
StartTime: state.PaperStartTime,
|
||||
InitialETH: valOr(state.PaperInitialETH, 0),
|
||||
InitialUSDC: valOr(state.PaperInitialUSDC, 0),
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
func (r *GridStateRepo) GetHistory(ctx context.Context, limit int) ([]models.GridState, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT * FROM grid_state ORDER BY updated_at DESC LIMIT $1`,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectGridStates(rows)
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
func scanGridState(row scannable) (*models.GridState, error) {
|
||||
var gs models.GridState
|
||||
err := row.Scan(
|
||||
&gs.ID, &gs.BasePrice, &gs.GridLevelsJSON,
|
||||
&gs.TradesExecuted, &gs.TotalProfit, &gs.LastSRRefresh,
|
||||
&gs.IsActive, &gs.CreatedAt, &gs.UpdatedAt,
|
||||
// paper wallet columns
|
||||
&gs.PaperETHBalance, &gs.PaperUSDCBalance, &gs.PaperTotalGasSpent,
|
||||
&gs.PaperTradesJSON, &gs.PaperStartTime,
|
||||
&gs.PaperInitialETH, &gs.PaperInitialUSDC,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gs, nil
|
||||
}
|
||||
|
||||
func collectGridStates(rows rowsIter) ([]models.GridState, error) {
|
||||
var out []models.GridState
|
||||
for rows.Next() {
|
||||
var gs models.GridState
|
||||
if err := rows.Scan(
|
||||
&gs.ID, &gs.BasePrice, &gs.GridLevelsJSON,
|
||||
&gs.TradesExecuted, &gs.TotalProfit, &gs.LastSRRefresh,
|
||||
&gs.IsActive, &gs.CreatedAt, &gs.UpdatedAt,
|
||||
&gs.PaperETHBalance, &gs.PaperUSDCBalance, &gs.PaperTotalGasSpent,
|
||||
&gs.PaperTradesJSON, &gs.PaperStartTime,
|
||||
&gs.PaperInitialETH, &gs.PaperInitialUSDC,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, gs)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func valOr(p *float64, fallback float64) float64 {
|
||||
if p != nil {
|
||||
return *p
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
110
trahn-trade-backend/internal/repository/price.go
Normal file
110
trahn-trade-backend/internal/repository/price.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
)
|
||||
|
||||
type PriceRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPriceRepo(pool *pgxpool.Pool) *PriceRepo {
|
||||
return &PriceRepo{pool: pool}
|
||||
}
|
||||
|
||||
func (r *PriceRepo) Record(ctx context.Context, price float64, ts time.Time) (*models.PricePoint, error) {
|
||||
td := TradingDay(ts)
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO price_history (timestamp, price, trading_day, source)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
ts, price, td, "coingecko",
|
||||
)
|
||||
return scanPrice(row)
|
||||
}
|
||||
|
||||
func (r *PriceRepo) GetByDay(ctx context.Context, tradingDay string) ([]models.PricePoint, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT * FROM price_history WHERE trading_day = $1 ORDER BY timestamp ASC`,
|
||||
tradingDay,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectPrices(rows)
|
||||
}
|
||||
|
||||
func (r *PriceRepo) GetAvailableDays(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT DISTINCT trading_day FROM price_history ORDER BY trading_day DESC LIMIT 30`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var days []string
|
||||
for rows.Next() {
|
||||
var d time.Time
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days = append(days, d.Format("2006-01-02"))
|
||||
}
|
||||
return days, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PriceRepo) GetLatest(ctx context.Context) (*models.PricePoint, error) {
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`SELECT * FROM price_history ORDER BY timestamp DESC LIMIT 1`,
|
||||
)
|
||||
p, err := scanPrice(row)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPrice(row scannable) (*models.PricePoint, error) {
|
||||
var p models.PricePoint
|
||||
var td time.Time
|
||||
err := row.Scan(&p.ID, &p.Timestamp, &p.Price, &td, &p.Source, &p.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TradingDay = td.Format("2006-01-02")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
type rowsIter interface {
|
||||
Next() bool
|
||||
Scan(dest ...any) error
|
||||
Err() error
|
||||
}
|
||||
|
||||
func collectPrices(rows rowsIter) ([]models.PricePoint, error) {
|
||||
var out []models.PricePoint
|
||||
for rows.Next() {
|
||||
var p models.PricePoint
|
||||
var td time.Time
|
||||
if err := rows.Scan(&p.ID, &p.Timestamp, &p.Price, &td, &p.Source, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TradingDay = td.Format("2006-01-02")
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
319
trahn-trade-backend/internal/repository/repo_test.go
Normal file
319
trahn-trade-backend/internal/repository/repo_test.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/testutil"
|
||||
)
|
||||
|
||||
// ---------- PriceRepo ----------
|
||||
|
||||
func TestPriceRepo(t *testing.T) {
|
||||
pool := testutil.SetupPool(t)
|
||||
repo := repository.NewPriceRepo(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
// Record
|
||||
ts := time.Now()
|
||||
p, err := repo.Record(ctx, 2650.42, ts)
|
||||
if err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if p.ID == 0 {
|
||||
t.Fatal("expected non-zero ID")
|
||||
}
|
||||
if p.Price != 2650.42 {
|
||||
t.Fatalf("price mismatch: got %f", p.Price)
|
||||
}
|
||||
t.Logf("Recorded price: id=%d price=%.2f day=%s", p.ID, p.Price, p.TradingDay)
|
||||
|
||||
// GetLatest
|
||||
latest, err := repo.GetLatest(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatest: %v", err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatal("expected latest price")
|
||||
}
|
||||
t.Logf("Latest: id=%d price=%.2f", latest.ID, latest.Price)
|
||||
|
||||
// GetByDay
|
||||
prices, err := repo.GetByDay(ctx, p.TradingDay)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByDay: %v", err)
|
||||
}
|
||||
if len(prices) == 0 {
|
||||
t.Fatal("expected prices for trading day")
|
||||
}
|
||||
t.Logf("GetByDay(%s): %d rows", p.TradingDay, len(prices))
|
||||
|
||||
// GetAvailableDays
|
||||
days, err := repo.GetAvailableDays(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAvailableDays: %v", err)
|
||||
}
|
||||
if len(days) == 0 {
|
||||
t.Fatal("expected at least one day")
|
||||
}
|
||||
t.Logf("Available days: %v", days)
|
||||
}
|
||||
|
||||
// ---------- TradeRepo ----------
|
||||
|
||||
func TestTradeRepo(t *testing.T) {
|
||||
pool := testutil.SetupPool(t)
|
||||
repo := repository.NewTradeRepo(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
slippage := 0.35
|
||||
gasCost := 0.002
|
||||
gridLvl := 3
|
||||
|
||||
trade := &models.Trade{
|
||||
Timestamp: time.Now(),
|
||||
Side: "buy",
|
||||
Price: 2600.00,
|
||||
Quantity: 0.0385,
|
||||
USDValue: 100.00,
|
||||
GridLevel: &gridLvl,
|
||||
IsPaperTrade: true,
|
||||
SlippagePercent: &slippage,
|
||||
GasCostETH: &gasCost,
|
||||
}
|
||||
|
||||
recorded, err := repo.Record(ctx, trade)
|
||||
if err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if recorded.ID == 0 {
|
||||
t.Fatal("expected non-zero ID")
|
||||
}
|
||||
if recorded.Side != "buy" {
|
||||
t.Fatalf("side mismatch: got %s", recorded.Side)
|
||||
}
|
||||
t.Logf("Recorded trade: id=%d side=%s price=%.2f qty=%.4f", recorded.ID, recorded.Side, recorded.Price, recorded.Quantity)
|
||||
|
||||
// GetAll (no filter)
|
||||
all, err := repo.GetAll(ctx, 10, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAll: %v", err)
|
||||
}
|
||||
if len(all) == 0 {
|
||||
t.Fatal("expected trades")
|
||||
}
|
||||
t.Logf("GetAll: %d trades", len(all))
|
||||
|
||||
// GetAll (paper only)
|
||||
paperMode := true
|
||||
paperTrades, err := repo.GetAll(ctx, 10, &paperMode)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAll(paper): %v", err)
|
||||
}
|
||||
for _, pt := range paperTrades {
|
||||
if !pt.IsPaperTrade {
|
||||
t.Fatalf("expected paper trade, got live trade id=%d", pt.ID)
|
||||
}
|
||||
}
|
||||
t.Logf("GetAll(paper): %d trades", len(paperTrades))
|
||||
|
||||
// GetStats (no filter)
|
||||
stats, err := repo.GetStats(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStats: %v", err)
|
||||
}
|
||||
t.Logf("Stats(all): total=%d buys=%d sells=%d", stats.TotalTrades, stats.BuyCount, stats.SellCount)
|
||||
|
||||
// GetStats (paper only)
|
||||
paperStats, err := repo.GetStats(ctx, &paperMode)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStats(paper): %v", err)
|
||||
}
|
||||
t.Logf("Stats(paper): total=%d buys=%d sells=%d", paperStats.TotalTrades, paperStats.BuyCount, paperStats.SellCount)
|
||||
}
|
||||
|
||||
// ---------- SRRepo ----------
|
||||
|
||||
func TestSRRepo(t *testing.T) {
|
||||
pool := testutil.SetupPool(t)
|
||||
repo := repository.NewSRRepo(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
avgPrice := 2700.0
|
||||
sr := &models.SupportResistance{
|
||||
Timestamp: time.Now(),
|
||||
Method: "simple",
|
||||
LookbackDays: 14,
|
||||
Support: 2400.00,
|
||||
Resistance: 3000.00,
|
||||
Midpoint: 2700.00,
|
||||
AvgPrice: &avgPrice,
|
||||
GridRecalculated: false,
|
||||
}
|
||||
|
||||
recorded, err := repo.Record(ctx, sr)
|
||||
if err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if recorded.ID == 0 {
|
||||
t.Fatal("expected non-zero ID")
|
||||
}
|
||||
t.Logf("Recorded S/R: id=%d support=%.2f resistance=%.2f mid=%.2f", recorded.ID, recorded.Support, recorded.Resistance, recorded.Midpoint)
|
||||
|
||||
// GetLatest
|
||||
latest, err := repo.GetLatest(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatest: %v", err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatal("expected latest S/R")
|
||||
}
|
||||
t.Logf("Latest S/R: mid=%.2f", latest.Midpoint)
|
||||
|
||||
// GetHistory
|
||||
history, err := repo.GetHistory(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
t.Logf("S/R history: %d rows", len(history))
|
||||
|
||||
// NeedsRefresh (just recorded, so should NOT need refresh)
|
||||
needs, err := repo.NeedsRefresh(ctx, 48)
|
||||
if err != nil {
|
||||
t.Fatalf("NeedsRefresh: %v", err)
|
||||
}
|
||||
if needs {
|
||||
t.Fatal("should NOT need refresh right after recording")
|
||||
}
|
||||
t.Logf("NeedsRefresh(48h): %v", needs)
|
||||
|
||||
// CheckSignificantChange
|
||||
shifted := &models.SupportResistance{Midpoint: 2900.00}
|
||||
analysis, err := repo.CheckSignificantChange(ctx, shifted, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckSignificantChange: %v", err)
|
||||
}
|
||||
t.Logf("Change analysis: changed=%v reason=%s", analysis.HasChanged, analysis.Reason)
|
||||
}
|
||||
|
||||
// ---------- GridStateRepo ----------
|
||||
|
||||
func TestGridStateRepo(t *testing.T) {
|
||||
pool := testutil.SetupPool(t)
|
||||
repo := repository.NewGridStateRepo(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
basePrice := 2700.0
|
||||
levels := json.RawMessage(`[{"index":0,"price":2600,"side":"buy","filled":false}]`)
|
||||
|
||||
gs := &models.GridState{
|
||||
BasePrice: &basePrice,
|
||||
GridLevelsJSON: levels,
|
||||
TradesExecuted: 0,
|
||||
TotalProfit: 0,
|
||||
}
|
||||
|
||||
saved, err := repo.Save(ctx, gs)
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if saved.ID == 0 {
|
||||
t.Fatal("expected non-zero ID")
|
||||
}
|
||||
if !saved.IsActive {
|
||||
t.Fatal("expected active state")
|
||||
}
|
||||
t.Logf("Saved grid state: id=%d active=%v", saved.ID, saved.IsActive)
|
||||
|
||||
// GetActive
|
||||
active, err := repo.GetActive(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActive: %v", err)
|
||||
}
|
||||
if active == nil {
|
||||
t.Fatal("expected active state")
|
||||
}
|
||||
t.Logf("Active grid state: id=%d", active.ID)
|
||||
|
||||
// UpdateGridLevels
|
||||
newLevels := json.RawMessage(`[{"index":0,"price":2600,"side":"buy","filled":true}]`)
|
||||
if err := repo.UpdateGridLevels(ctx, newLevels); err != nil {
|
||||
t.Fatalf("UpdateGridLevels: %v", err)
|
||||
}
|
||||
t.Log("UpdateGridLevels: OK")
|
||||
|
||||
// UpdateTradeStats
|
||||
if err := repo.UpdateTradeStats(ctx, 5, 12.50); err != nil {
|
||||
t.Fatalf("UpdateTradeStats: %v", err)
|
||||
}
|
||||
t.Log("UpdateTradeStats: OK")
|
||||
|
||||
// InitializePaperWallet
|
||||
if err := repo.InitializePaperWallet(ctx, 1.0, 1000.0); err != nil {
|
||||
t.Fatalf("InitializePaperWallet: %v", err)
|
||||
}
|
||||
t.Log("InitializePaperWallet: OK")
|
||||
|
||||
// GetPaperWallet
|
||||
pw, err := repo.GetPaperWallet(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPaperWallet: %v", err)
|
||||
}
|
||||
if pw == nil {
|
||||
t.Fatal("expected paper wallet")
|
||||
}
|
||||
if pw.ETHBalance != 1.0 {
|
||||
t.Fatalf("ETH balance mismatch: got %f", pw.ETHBalance)
|
||||
}
|
||||
t.Logf("PaperWallet: ETH=%.4f USDC=%.2f", pw.ETHBalance, pw.USDCBalance)
|
||||
|
||||
// UpdatePaperWallet
|
||||
pw.ETHBalance = 1.05
|
||||
pw.USDCBalance = 870.00
|
||||
pw.TotalGasSpent = 0.003
|
||||
if err := repo.UpdatePaperWallet(ctx, pw); err != nil {
|
||||
t.Fatalf("UpdatePaperWallet: %v", err)
|
||||
}
|
||||
t.Log("UpdatePaperWallet: OK")
|
||||
|
||||
// Verify update
|
||||
pw2, err := repo.GetPaperWallet(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPaperWallet after update: %v", err)
|
||||
}
|
||||
if pw2.ETHBalance != 1.05 {
|
||||
t.Fatalf("ETH balance mismatch after update: got %f", pw2.ETHBalance)
|
||||
}
|
||||
t.Logf("PaperWallet after update: ETH=%.4f USDC=%.2f gas=%.4f", pw2.ETHBalance, pw2.USDCBalance, pw2.TotalGasSpent)
|
||||
|
||||
// GetHistory
|
||||
history, err := repo.GetHistory(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
t.Logf("Grid state history: %d rows", len(history))
|
||||
}
|
||||
|
||||
// ---------- TradingDay ----------
|
||||
|
||||
func TestTradingDay(t *testing.T) {
|
||||
// 2024-01-15 at 16:00 UTC (before 17:00 cutoff) => trading day = Jan 14
|
||||
ts := time.Date(2024, 1, 15, 16, 0, 0, 0, time.UTC)
|
||||
got := repository.TradingDay(ts)
|
||||
if got != "2024-01-14" {
|
||||
t.Fatalf("expected 2024-01-14, got %s", got)
|
||||
}
|
||||
|
||||
// 2024-01-15 at 18:00 UTC (after 17:00 cutoff) => trading day = Jan 15
|
||||
ts2 := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
|
||||
got2 := repository.TradingDay(ts2)
|
||||
if got2 != "2024-01-15" {
|
||||
t.Fatalf("expected 2024-01-15, got %s", got2)
|
||||
}
|
||||
|
||||
t.Logf("TradingDay tests passed")
|
||||
}
|
||||
139
trahn-trade-backend/internal/repository/sr.go
Normal file
139
trahn-trade-backend/internal/repository/sr.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
)
|
||||
|
||||
type SRRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSRRepo(pool *pgxpool.Pool) *SRRepo {
|
||||
return &SRRepo{pool: pool}
|
||||
}
|
||||
|
||||
func (r *SRRepo) Record(ctx context.Context, sr *models.SupportResistance) (*models.SupportResistance, error) {
|
||||
ts := sr.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO support_resistance_history
|
||||
(timestamp, method, lookback_days, support, resistance, midpoint, avg_price, grid_recalculated)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING *`,
|
||||
ts, sr.Method, sr.LookbackDays, sr.Support, sr.Resistance,
|
||||
sr.Midpoint, sr.AvgPrice, sr.GridRecalculated,
|
||||
)
|
||||
return scanSR(row)
|
||||
}
|
||||
|
||||
func (r *SRRepo) GetLatest(ctx context.Context) (*models.SupportResistance, error) {
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`SELECT * FROM support_resistance_history ORDER BY timestamp DESC LIMIT 1`,
|
||||
)
|
||||
sr, err := scanSR(row)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
func (r *SRRepo) GetHistory(ctx context.Context, limit int) ([]models.SupportResistance, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT * FROM support_resistance_history ORDER BY timestamp DESC LIMIT $1`,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectSRs(rows)
|
||||
}
|
||||
|
||||
func (r *SRRepo) NeedsRefresh(ctx context.Context, refreshHours int) (bool, error) {
|
||||
latest, err := r.GetLatest(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if latest == nil {
|
||||
return true, nil
|
||||
}
|
||||
age := time.Since(latest.Timestamp)
|
||||
return age >= time.Duration(refreshHours)*time.Hour, nil
|
||||
}
|
||||
|
||||
type ChangeAnalysis struct {
|
||||
HasChanged bool `json:"hasChanged"`
|
||||
ChangePercent *float64 `json:"changePercent"`
|
||||
Previous *models.SupportResistance `json:"previous"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (r *SRRepo) CheckSignificantChange(ctx context.Context, newSR *models.SupportResistance, thresholdPercent float64) (*ChangeAnalysis, error) {
|
||||
previous, err := r.GetLatest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if previous == nil {
|
||||
return &ChangeAnalysis{
|
||||
HasChanged: true,
|
||||
Reason: "First S/R fetch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
pct := math.Abs((newSR.Midpoint - previous.Midpoint) / previous.Midpoint * 100)
|
||||
changed := pct >= thresholdPercent
|
||||
|
||||
reason := "S/R stable"
|
||||
if changed {
|
||||
reason = fmt.Sprintf("Midpoint changed %.2f%%", pct)
|
||||
}
|
||||
|
||||
return &ChangeAnalysis{
|
||||
HasChanged: changed,
|
||||
ChangePercent: &pct,
|
||||
Previous: previous,
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
func scanSR(row scannable) (*models.SupportResistance, error) {
|
||||
var sr models.SupportResistance
|
||||
err := row.Scan(
|
||||
&sr.ID, &sr.Timestamp, &sr.Method, &sr.LookbackDays,
|
||||
&sr.Support, &sr.Resistance, &sr.Midpoint, &sr.AvgPrice,
|
||||
&sr.GridRecalculated, &sr.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sr, nil
|
||||
}
|
||||
|
||||
func collectSRs(rows rowsIter) ([]models.SupportResistance, error) {
|
||||
var out []models.SupportResistance
|
||||
for rows.Next() {
|
||||
var sr models.SupportResistance
|
||||
if err := rows.Scan(
|
||||
&sr.ID, &sr.Timestamp, &sr.Method, &sr.LookbackDays,
|
||||
&sr.Support, &sr.Resistance, &sr.Midpoint, &sr.AvgPrice,
|
||||
&sr.GridRecalculated, &sr.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
155
trahn-trade-backend/internal/repository/trade.go
Normal file
155
trahn-trade-backend/internal/repository/trade.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
)
|
||||
|
||||
type TradeRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTradeRepo(pool *pgxpool.Pool) *TradeRepo {
|
||||
return &TradeRepo{pool: pool}
|
||||
}
|
||||
|
||||
func (r *TradeRepo) Record(ctx context.Context, t *models.Trade) (*models.Trade, error) {
|
||||
ts := t.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
td := TradingDay(ts)
|
||||
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO trade_history
|
||||
(timestamp, trading_day, side, price, quantity, usd_value,
|
||||
grid_level, tx_hash, is_paper_trade, slippage_percent, gas_cost_eth)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING *`,
|
||||
ts, td, t.Side, t.Price, t.Quantity, t.USDValue,
|
||||
t.GridLevel, t.TxHash, t.IsPaperTrade, t.SlippagePercent, t.GasCostETH,
|
||||
)
|
||||
return scanTrade(row)
|
||||
}
|
||||
|
||||
// GetByDay returns trades for a given trading day.
|
||||
// If paperMode is non-nil, filters by is_paper_trade.
|
||||
func (r *TradeRepo) GetByDay(ctx context.Context, tradingDay string, paperMode *bool) ([]models.Trade, error) {
|
||||
query, args := buildFilteredQuery(
|
||||
`SELECT * FROM trade_history WHERE trading_day = $1`,
|
||||
[]any{tradingDay},
|
||||
paperMode,
|
||||
)
|
||||
query += " ORDER BY timestamp ASC"
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectTrades(rows)
|
||||
}
|
||||
|
||||
// GetAll returns the most recent trades.
|
||||
// If paperMode is non-nil, filters by is_paper_trade.
|
||||
func (r *TradeRepo) GetAll(ctx context.Context, limit int, paperMode *bool) ([]models.Trade, error) {
|
||||
query, args := buildFilteredQuery(
|
||||
`SELECT * FROM trade_history WHERE 1=1`,
|
||||
nil,
|
||||
paperMode,
|
||||
)
|
||||
args = append(args, limit)
|
||||
query += fmt.Sprintf(" ORDER BY timestamp DESC LIMIT $%d", len(args))
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectTrades(rows)
|
||||
}
|
||||
|
||||
// GetStats returns aggregate trade statistics.
|
||||
// If paperMode is non-nil, filters by is_paper_trade.
|
||||
func (r *TradeRepo) GetStats(ctx context.Context, paperMode *bool) (*models.TradeStats, error) {
|
||||
query, args := buildFilteredQuery(
|
||||
`SELECT
|
||||
COUNT(*),
|
||||
COUNT(CASE WHEN side = 'buy' THEN 1 END),
|
||||
COUNT(CASE WHEN side = 'sell' THEN 1 END),
|
||||
SUM(usd_value),
|
||||
AVG(price),
|
||||
MIN(timestamp),
|
||||
MAX(timestamp)
|
||||
FROM trade_history WHERE 1=1`,
|
||||
nil,
|
||||
paperMode,
|
||||
)
|
||||
|
||||
var s models.TradeStats
|
||||
err := r.pool.QueryRow(ctx, query, args...).Scan(
|
||||
&s.TotalTrades, &s.BuyCount, &s.SellCount,
|
||||
&s.TotalVolume, &s.AvgPrice, &s.FirstTrade, &s.LastTrade,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *TradeRepo) CountToday(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM trade_history WHERE trading_day = $1`,
|
||||
TradingDayNow(),
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// buildFilteredQuery appends an is_paper_trade clause when paperMode is non-nil.
|
||||
func buildFilteredQuery(baseQuery string, baseArgs []any, paperMode *bool) (string, []any) {
|
||||
if paperMode == nil {
|
||||
return baseQuery, baseArgs
|
||||
}
|
||||
args := append(baseArgs, *paperMode)
|
||||
return baseQuery + fmt.Sprintf(" AND is_paper_trade = $%d", len(args)), args
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
func scanTrade(row scannable) (*models.Trade, error) {
|
||||
var t models.Trade
|
||||
var td time.Time
|
||||
err := row.Scan(
|
||||
&t.ID, &t.Timestamp, &td, &t.Side, &t.Price, &t.Quantity, &t.USDValue,
|
||||
&t.GridLevel, &t.TxHash, &t.IsPaperTrade, &t.SlippagePercent, &t.GasCostETH,
|
||||
&t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.TradingDay = td.Format("2006-01-02")
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func collectTrades(rows rowsIter) ([]models.Trade, error) {
|
||||
var out []models.Trade
|
||||
for rows.Next() {
|
||||
var t models.Trade
|
||||
var td time.Time
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Timestamp, &td, &t.Side, &t.Price, &t.Quantity, &t.USDValue,
|
||||
&t.GridLevel, &t.TxHash, &t.IsPaperTrade, &t.SlippagePercent, &t.GasCostETH,
|
||||
&t.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.TradingDay = td.Format("2006-01-02")
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
22
trahn-trade-backend/internal/repository/tradingday.go
Normal file
22
trahn-trade-backend/internal/repository/tradingday.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package repository
|
||||
|
||||
import "time"
|
||||
|
||||
// TradingDay returns the trading day (YYYY-MM-DD) for a given timestamp.
|
||||
// Trading day boundary is 12:00 EST (17:00 UTC).
|
||||
func TradingDay(ts time.Time) string {
|
||||
utc := ts.UTC()
|
||||
cutoff := 17 * 60 // 17:00 UTC in minutes
|
||||
utcMinutes := utc.Hour()*60 + utc.Minute()
|
||||
|
||||
day := utc
|
||||
if utcMinutes < cutoff {
|
||||
day = day.AddDate(0, 0, -1)
|
||||
}
|
||||
return day.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// TradingDayNow returns the trading day for the current moment.
|
||||
func TradingDayNow() string {
|
||||
return TradingDay(time.Now())
|
||||
}
|
||||
69
trahn-trade-backend/internal/risk/guardian.go
Normal file
69
trahn-trade-backend/internal/risk/guardian.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package risk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DailyTradeCounter abstracts the trade-counting dependency so Guardian
|
||||
// can be tested without a real database.
|
||||
type DailyTradeCounter interface {
|
||||
CountToday(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// Limits holds the four risk thresholds from config.
|
||||
// A zero value for any field means that check is disabled.
|
||||
type Limits struct {
|
||||
MaxDailyTrades int
|
||||
MaxPositionSizeUSD float64
|
||||
StopLossPercent float64
|
||||
TakeProfitPercent float64
|
||||
}
|
||||
|
||||
type Guardian struct {
|
||||
limits Limits
|
||||
counter DailyTradeCounter
|
||||
}
|
||||
|
||||
func NewGuardian(limits Limits, counter DailyTradeCounter) *Guardian {
|
||||
return &Guardian{limits: limits, counter: counter}
|
||||
}
|
||||
|
||||
// PreTradeCheck validates per-trade constraints before execution.
|
||||
// Returns nil if the trade is allowed, a descriptive error if blocked.
|
||||
func (g *Guardian) PreTradeCheck(ctx context.Context, tradeUSDValue float64) error {
|
||||
if g.limits.MaxPositionSizeUSD > 0 && tradeUSDValue > g.limits.MaxPositionSizeUSD {
|
||||
return fmt.Errorf("trade blocked: position size $%.2f exceeds max $%.2f",
|
||||
tradeUSDValue, g.limits.MaxPositionSizeUSD)
|
||||
}
|
||||
|
||||
if g.limits.MaxDailyTrades > 0 && g.counter != nil {
|
||||
count, err := g.counter.CountToday(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trade blocked: unable to verify daily trade count: %w", err)
|
||||
}
|
||||
if count >= g.limits.MaxDailyTrades {
|
||||
return fmt.Errorf("trade blocked: daily limit of %d trades reached (%d executed today)",
|
||||
g.limits.MaxDailyTrades, count)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PortfolioCheck evaluates portfolio-level circuit breakers.
|
||||
// pnlPercent is the unrealized P&L as a percentage (e.g. -8.5 means down 8.5%).
|
||||
// Returns nil if trading should continue, a descriptive error if a breaker tripped.
|
||||
func (g *Guardian) PortfolioCheck(pnlPercent float64) error {
|
||||
if g.limits.StopLossPercent > 0 && pnlPercent <= -g.limits.StopLossPercent {
|
||||
return fmt.Errorf("STOP-LOSS triggered: portfolio down %.2f%% (threshold: -%.2f%%)",
|
||||
pnlPercent, g.limits.StopLossPercent)
|
||||
}
|
||||
|
||||
if g.limits.TakeProfitPercent > 0 && pnlPercent >= g.limits.TakeProfitPercent {
|
||||
return fmt.Errorf("TAKE-PROFIT triggered: portfolio up %.2f%% (threshold: +%.2f%%)",
|
||||
pnlPercent, g.limits.TakeProfitPercent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
153
trahn-trade-backend/internal/risk/guardian_test.go
Normal file
153
trahn-trade-backend/internal/risk/guardian_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package risk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockCounter struct {
|
||||
count int
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockCounter) CountToday(_ context.Context) (int, error) {
|
||||
return m.count, m.err
|
||||
}
|
||||
|
||||
// --- PreTradeCheck ---
|
||||
|
||||
func TestPreTradeCheck_PositionSize_Allowed(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxPositionSizeUSD: 500}, &mockCounter{})
|
||||
if err := g.PreTradeCheck(context.Background(), 499.99); err != nil {
|
||||
t.Fatalf("expected trade to be allowed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_PositionSize_Blocked(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxPositionSizeUSD: 500}, &mockCounter{})
|
||||
err := g.PreTradeCheck(context.Background(), 500.01)
|
||||
if err == nil {
|
||||
t.Fatal("expected trade to be blocked")
|
||||
}
|
||||
t.Logf("Correctly blocked: %v", err)
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_PositionSize_DisabledWhenZero(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxPositionSizeUSD: 0}, &mockCounter{})
|
||||
if err := g.PreTradeCheck(context.Background(), 999999); err != nil {
|
||||
t.Fatalf("zero limit should disable check, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_DailyTrades_Allowed(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxDailyTrades: 50}, &mockCounter{count: 49})
|
||||
if err := g.PreTradeCheck(context.Background(), 100); err != nil {
|
||||
t.Fatalf("expected trade to be allowed (49/50), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_DailyTrades_Blocked(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxDailyTrades: 50}, &mockCounter{count: 50})
|
||||
err := g.PreTradeCheck(context.Background(), 100)
|
||||
if err == nil {
|
||||
t.Fatal("expected trade to be blocked (50/50)")
|
||||
}
|
||||
t.Logf("Correctly blocked: %v", err)
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_DailyTrades_CounterError(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxDailyTrades: 50}, &mockCounter{err: fmt.Errorf("db down")})
|
||||
err := g.PreTradeCheck(context.Background(), 100)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when counter fails")
|
||||
}
|
||||
t.Logf("Correctly blocked on counter error: %v", err)
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_DailyTrades_DisabledWhenZero(t *testing.T) {
|
||||
g := NewGuardian(Limits{MaxDailyTrades: 0}, &mockCounter{count: 9999})
|
||||
if err := g.PreTradeCheck(context.Background(), 100); err != nil {
|
||||
t.Fatalf("zero limit should disable check, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_BothChecks_PositionSizeFailsFirst(t *testing.T) {
|
||||
g := NewGuardian(Limits{
|
||||
MaxPositionSizeUSD: 100,
|
||||
MaxDailyTrades: 50,
|
||||
}, &mockCounter{count: 49})
|
||||
|
||||
err := g.PreTradeCheck(context.Background(), 200)
|
||||
if err == nil {
|
||||
t.Fatal("expected trade to be blocked by position size")
|
||||
}
|
||||
t.Logf("Correctly blocked: %v", err)
|
||||
}
|
||||
|
||||
func TestPreTradeCheck_AllDisabled(t *testing.T) {
|
||||
g := NewGuardian(Limits{}, &mockCounter{count: 9999})
|
||||
if err := g.PreTradeCheck(context.Background(), 999999); err != nil {
|
||||
t.Fatalf("all-zero limits should allow everything, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PortfolioCheck ---
|
||||
|
||||
func TestPortfolioCheck_StopLoss_Triggered(t *testing.T) {
|
||||
g := NewGuardian(Limits{StopLossPercent: 10}, nil)
|
||||
err := g.PortfolioCheck(-10.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected stop-loss to trigger at -10%")
|
||||
}
|
||||
t.Logf("Correctly triggered: %v", err)
|
||||
}
|
||||
|
||||
func TestPortfolioCheck_StopLoss_NotTriggered(t *testing.T) {
|
||||
g := NewGuardian(Limits{StopLossPercent: 10}, nil)
|
||||
if err := g.PortfolioCheck(-9.99); err != nil {
|
||||
t.Fatalf("expected no trigger at -9.99%%, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortfolioCheck_TakeProfit_Triggered(t *testing.T) {
|
||||
g := NewGuardian(Limits{TakeProfitPercent: 20}, nil)
|
||||
err := g.PortfolioCheck(20.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected take-profit to trigger at +20%")
|
||||
}
|
||||
t.Logf("Correctly triggered: %v", err)
|
||||
}
|
||||
|
||||
func TestPortfolioCheck_TakeProfit_NotTriggered(t *testing.T) {
|
||||
g := NewGuardian(Limits{TakeProfitPercent: 20}, nil)
|
||||
if err := g.PortfolioCheck(19.99); err != nil {
|
||||
t.Fatalf("expected no trigger at +19.99%%, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortfolioCheck_BothDisabled(t *testing.T) {
|
||||
g := NewGuardian(Limits{}, nil)
|
||||
if err := g.PortfolioCheck(-99); err != nil {
|
||||
t.Fatalf("zero limits should disable all checks, got: %v", err)
|
||||
}
|
||||
if err := g.PortfolioCheck(99); err != nil {
|
||||
t.Fatalf("zero limits should disable all checks, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortfolioCheck_StopLoss_ExactBoundary(t *testing.T) {
|
||||
g := NewGuardian(Limits{StopLossPercent: 5}, nil)
|
||||
err := g.PortfolioCheck(-5.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected stop-loss to trigger at exactly -5%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortfolioCheck_TakeProfit_ExactBoundary(t *testing.T) {
|
||||
g := NewGuardian(Limits{TakeProfitPercent: 15}, nil)
|
||||
err := g.PortfolioCheck(15.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected take-profit to trigger at exactly +15%")
|
||||
}
|
||||
}
|
||||
232
trahn-trade-backend/internal/scheduler/sr.go
Normal file
232
trahn-trade-backend/internal/scheduler/sr.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/models"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
)
|
||||
|
||||
// BotState is the subset of bot state the scheduler needs for decision-making.
|
||||
type BotState struct {
|
||||
Grid []strategy.GridLevel
|
||||
LastETHPrice float64
|
||||
}
|
||||
|
||||
// BotStateProvider returns the current bot state, or nil if unavailable.
|
||||
type BotStateProvider func() *BotState
|
||||
|
||||
type SRSchedulerConfig struct {
|
||||
CronInterval time.Duration // e.g. 1*time.Hour
|
||||
SRChangeThreshold float64 // e.g. 5.0 (percent)
|
||||
GetBotState BotStateProvider
|
||||
OnSRUpdate func(sr *external.SRResult)
|
||||
OnGridRecalculate func(sr *external.SRResult)
|
||||
}
|
||||
|
||||
type SRScheduler struct {
|
||||
dune *external.DuneClient
|
||||
srRepo *repository.SRRepo
|
||||
cfg SRSchedulerConfig
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewSRScheduler(dune *external.DuneClient, srRepo *repository.SRRepo, cfg SRSchedulerConfig) *SRScheduler {
|
||||
if cfg.CronInterval <= 0 {
|
||||
cfg.CronInterval = 1 * time.Hour
|
||||
}
|
||||
if cfg.SRChangeThreshold <= 0 {
|
||||
cfg.SRChangeThreshold = 5
|
||||
}
|
||||
return &SRScheduler{
|
||||
dune: dune,
|
||||
srRepo: srRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SRScheduler) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
fmt.Println("[SR-SCHEDULER] Already running")
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.stopCh = make(chan struct{})
|
||||
s.mu.Unlock()
|
||||
|
||||
// Initial fetch on startup (fire-and-forget)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if err := s.fetchAndProcess(ctx); err != nil {
|
||||
fmt.Printf("[SR-SCHEDULER] Initial S/R fetch failed: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Recurring ticker
|
||||
go func() {
|
||||
ticker := time.NewTicker(s.cfg.CronInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
if err := s.fetchAndProcess(ctx); err != nil {
|
||||
fmt.Printf("[SR-SCHEDULER] S/R fetch failed: %v\n", err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("[SR-SCHEDULER] Started (every %s with intelligent recalculation)\n", s.cfg.CronInterval)
|
||||
}
|
||||
|
||||
func (s *SRScheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
close(s.stopCh)
|
||||
s.running = false
|
||||
fmt.Println("[SR-SCHEDULER] Stopped")
|
||||
}
|
||||
|
||||
func (s *SRScheduler) Running() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
|
||||
// FetchNow manually triggers a fetch outside the normal schedule.
|
||||
func (s *SRScheduler) FetchNow(ctx context.Context) error {
|
||||
fmt.Println("[SR-SCHEDULER] Manual S/R fetch triggered")
|
||||
return s.fetchAndProcess(ctx)
|
||||
}
|
||||
|
||||
func (s *SRScheduler) fetchAndProcess(ctx context.Context) error {
|
||||
fmt.Println("[SR-SCHEDULER] Fetching S/R levels from Dune...")
|
||||
|
||||
sr, err := s.dune.FetchSupportResistance(ctx, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch S/R: %w", err)
|
||||
}
|
||||
|
||||
shouldRecalculate := false
|
||||
var reasons []string
|
||||
|
||||
// Condition 1: S/R midpoint changed > threshold
|
||||
newSR := &models.SupportResistance{Midpoint: sr.Midpoint}
|
||||
change, err := s.srRepo.CheckSignificantChange(ctx, newSR, s.cfg.SRChangeThreshold)
|
||||
if err != nil {
|
||||
fmt.Printf("[SR-SCHEDULER] Warning: could not check S/R change: %v\n", err)
|
||||
} else if change.HasChanged {
|
||||
shouldRecalculate = true
|
||||
pct := ""
|
||||
if change.ChangePercent != nil {
|
||||
pct = fmt.Sprintf("%.2f%%", *change.ChangePercent)
|
||||
}
|
||||
reasons = append(reasons, fmt.Sprintf("S/R midpoint changed %s", pct))
|
||||
}
|
||||
|
||||
// Conditions 2 & 3: need bot state
|
||||
if s.cfg.GetBotState != nil {
|
||||
if bot := s.cfg.GetBotState(); bot != nil && len(bot.Grid) > 0 {
|
||||
// Condition 2: Price outside grid range
|
||||
if bot.LastETHPrice > 0 && strategy.IsPriceOutsideGrid(bot.LastETHPrice, bot.Grid) {
|
||||
shouldRecalculate = true
|
||||
lo, hi := gridRange(bot.Grid)
|
||||
reasons = append(reasons, fmt.Sprintf("Price $%.2f outside grid range ($%.2f - $%.2f)",
|
||||
bot.LastETHPrice, lo, hi))
|
||||
}
|
||||
|
||||
// Condition 3: All buys or all sells filled
|
||||
if strategy.AreAllSideFilled(bot.Grid, "buy") {
|
||||
shouldRecalculate = true
|
||||
reasons = append(reasons, "All buy levels filled - opportunity to reset")
|
||||
}
|
||||
if strategy.AreAllSideFilled(bot.Grid, "sell") {
|
||||
shouldRecalculate = true
|
||||
reasons = append(reasons, "All sell levels filled - opportunity to reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store S/R in database
|
||||
avgPrice := sr.AvgPrice
|
||||
_, err = s.srRepo.Record(ctx, &models.SupportResistance{
|
||||
Timestamp: time.Now(),
|
||||
Method: sr.Method,
|
||||
LookbackDays: sr.LookbackDays,
|
||||
Support: sr.Support,
|
||||
Resistance: sr.Resistance,
|
||||
Midpoint: sr.Midpoint,
|
||||
AvgPrice: &avgPrice,
|
||||
GridRecalculated: shouldRecalculate,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("record S/R: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[SR-SCHEDULER] S/R stored: Support $%.2f | Resistance $%.2f | Midpoint $%.2f\n",
|
||||
sr.Support, sr.Resistance, sr.Midpoint)
|
||||
|
||||
if s.cfg.OnSRUpdate != nil {
|
||||
s.cfg.OnSRUpdate(sr)
|
||||
}
|
||||
|
||||
if shouldRecalculate {
|
||||
fmt.Printf("[SR-SCHEDULER] RECALCULATING GRID - Reasons: %s\n", joinReasons(reasons))
|
||||
if s.cfg.OnGridRecalculate != nil {
|
||||
s.cfg.OnGridRecalculate(sr)
|
||||
}
|
||||
} else {
|
||||
pctStr := "0"
|
||||
if change != nil && change.ChangePercent != nil {
|
||||
pctStr = fmt.Sprintf("%.2f", *change.ChangePercent)
|
||||
}
|
||||
fmt.Printf("[SR-SCHEDULER] Grid stable - no recalculation needed\n")
|
||||
fmt.Printf(" S/R change: %s%% (threshold: %.0f%%)\n", pctStr, s.cfg.SRChangeThreshold)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func gridRange(grid []strategy.GridLevel) (lo, hi float64) {
|
||||
lo = math.MaxFloat64
|
||||
hi = -math.MaxFloat64
|
||||
for _, g := range grid {
|
||||
if g.Price < lo {
|
||||
lo = g.Price
|
||||
}
|
||||
if g.Price > hi {
|
||||
hi = g.Price
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func joinReasons(reasons []string) string {
|
||||
if len(reasons) == 0 {
|
||||
return "none"
|
||||
}
|
||||
out := reasons[0]
|
||||
for _, r := range reasons[1:] {
|
||||
out += ", " + r
|
||||
}
|
||||
return out
|
||||
}
|
||||
150
trahn-trade-backend/internal/scheduler/sr_test.go
Normal file
150
trahn-trade-backend/internal/scheduler/sr_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/external"
|
||||
"github.com/kjannette/trahn-backend/internal/repository"
|
||||
"github.com/kjannette/trahn-backend/internal/scheduler"
|
||||
"github.com/kjannette/trahn-backend/internal/strategy"
|
||||
"github.com/kjannette/trahn-backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestSRScheduler_FetchNow(t *testing.T) {
|
||||
apiKey := os.Getenv("DUNE_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("DUNE_API_KEY not set, skipping")
|
||||
}
|
||||
|
||||
pool := testutil.SetupPool(t)
|
||||
srRepo := repository.NewSRRepo(pool)
|
||||
dune := external.NewDuneClient(apiKey, external.DuneOptions{
|
||||
Method: "simple",
|
||||
LookbackDays: 14,
|
||||
RefreshHours: 48,
|
||||
})
|
||||
|
||||
var srUpdated atomic.Bool
|
||||
var recalculated atomic.Bool
|
||||
|
||||
sched := scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
|
||||
CronInterval: 1 * time.Hour,
|
||||
SRChangeThreshold: 5,
|
||||
OnSRUpdate: func(sr *external.SRResult) {
|
||||
srUpdated.Store(true)
|
||||
},
|
||||
OnGridRecalculate: func(sr *external.SRResult) {
|
||||
recalculated.Store(true)
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sched.FetchNow(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchNow: %v", err)
|
||||
}
|
||||
|
||||
if !srUpdated.Load() {
|
||||
t.Fatal("OnSRUpdate callback was not called")
|
||||
}
|
||||
t.Log("OnSRUpdate: called")
|
||||
t.Logf("OnGridRecalculate called: %v", recalculated.Load())
|
||||
|
||||
// Verify it was stored in DB
|
||||
latest, err := srRepo.GetLatest(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatest: %v", err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatal("expected S/R record in DB")
|
||||
}
|
||||
t.Logf("DB record: support=$%.2f resistance=$%.2f midpoint=$%.2f recalc=%v",
|
||||
latest.Support, latest.Resistance, latest.Midpoint, latest.GridRecalculated)
|
||||
}
|
||||
|
||||
func TestSRScheduler_WithBotState_PriceOutside(t *testing.T) {
|
||||
apiKey := os.Getenv("DUNE_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("DUNE_API_KEY not set, skipping")
|
||||
}
|
||||
|
||||
pool := testutil.SetupPool(t)
|
||||
srRepo := repository.NewSRRepo(pool)
|
||||
dune := external.NewDuneClient(apiKey, external.DuneOptions{
|
||||
Method: "simple",
|
||||
LookbackDays: 14,
|
||||
RefreshHours: 48,
|
||||
})
|
||||
|
||||
var recalculated atomic.Bool
|
||||
var recalcReasons string
|
||||
|
||||
// Simulate a grid that is far from current market price
|
||||
fakeBotState := &scheduler.BotState{
|
||||
Grid: []strategy.GridLevel{
|
||||
{Index: 0, Price: 1000, Side: "buy"},
|
||||
{Index: 1, Price: 1050, Side: "buy"},
|
||||
{Index: 2, Price: 1100, Side: "sell"},
|
||||
{Index: 3, Price: 1150, Side: "sell"},
|
||||
},
|
||||
LastETHPrice: 1962, // current real price, way outside the grid
|
||||
}
|
||||
|
||||
sched := scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
|
||||
CronInterval: 1 * time.Hour,
|
||||
SRChangeThreshold: 50, // high threshold so only bot state triggers recalc
|
||||
GetBotState: func() *scheduler.BotState {
|
||||
return fakeBotState
|
||||
},
|
||||
OnGridRecalculate: func(sr *external.SRResult) {
|
||||
recalculated.Store(true)
|
||||
recalcReasons = "price outside grid"
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sched.FetchNow(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchNow: %v", err)
|
||||
}
|
||||
|
||||
if !recalculated.Load() {
|
||||
t.Fatal("expected recalculation due to price outside grid")
|
||||
}
|
||||
t.Logf("Recalculated: true (reason: %s)", recalcReasons)
|
||||
}
|
||||
|
||||
func TestSRScheduler_StartStop(t *testing.T) {
|
||||
pool := testutil.SetupPool(t)
|
||||
srRepo := repository.NewSRRepo(pool)
|
||||
|
||||
// Use a dummy Dune client (no API key — won't actually fetch)
|
||||
dune := external.NewDuneClient("", external.DuneOptions{})
|
||||
|
||||
sched := scheduler.NewSRScheduler(dune, srRepo, scheduler.SRSchedulerConfig{
|
||||
CronInterval: 1 * time.Hour,
|
||||
})
|
||||
|
||||
sched.Start()
|
||||
if !sched.Running() {
|
||||
t.Fatal("expected running after Start")
|
||||
}
|
||||
|
||||
// Give initial goroutine a moment (it will fail due to no API key, that's fine)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
sched.Stop()
|
||||
if sched.Running() {
|
||||
t.Fatal("expected not running after Stop")
|
||||
}
|
||||
|
||||
t.Log("Start/Stop lifecycle: OK")
|
||||
}
|
||||
247
trahn-trade-backend/internal/strategy/grid.go
Normal file
247
trahn-trade-backend/internal/strategy/grid.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GridLevel struct {
|
||||
Index int `json:"index"`
|
||||
Price float64 `json:"price"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Quantity float64 `json:"quantity"`
|
||||
Filled bool `json:"filled"`
|
||||
FilledAt *time.Time `json:"filledAt,omitempty"`
|
||||
TxHash *string `json:"txHash,omitempty"`
|
||||
}
|
||||
|
||||
type GridStats struct {
|
||||
Levels int `json:"levels"`
|
||||
LowestPrice *float64 `json:"lowestPrice"`
|
||||
HighestPrice *float64 `json:"highestPrice"`
|
||||
FilledLevels int `json:"filledLevels"`
|
||||
PendingBuys int `json:"pendingBuys"`
|
||||
PendingSells int `json:"pendingSells"`
|
||||
FilledBuys int `json:"filledBuys"`
|
||||
FilledSells int `json:"filledSells"`
|
||||
}
|
||||
|
||||
type FallbackSR struct {
|
||||
Support float64 `json:"support"`
|
||||
Resistance float64 `json:"resistance"`
|
||||
Midpoint float64 `json:"midpoint"`
|
||||
Method string `json:"method"`
|
||||
LookbackDays int `json:"lookbackDays"`
|
||||
}
|
||||
|
||||
func CalculateMidpoint(support, resistance float64) (float64, error) {
|
||||
if support >= resistance {
|
||||
return 0, fmt.Errorf("invalid S/R: support (%.2f) >= resistance (%.2f)", support, resistance)
|
||||
}
|
||||
return (support + resistance) / 2, nil
|
||||
}
|
||||
|
||||
type GridParams struct {
|
||||
CenterPrice float64
|
||||
LevelCount int
|
||||
SpacingPercent float64
|
||||
AmountPerGrid float64
|
||||
}
|
||||
|
||||
func CalculateGridLevels(p GridParams) ([]GridLevel, error) {
|
||||
if p.CenterPrice <= 0 {
|
||||
return nil, fmt.Errorf("center price must be positive")
|
||||
}
|
||||
if p.LevelCount < 2 {
|
||||
return nil, fmt.Errorf("level count must be at least 2")
|
||||
}
|
||||
if p.SpacingPercent <= 0 {
|
||||
return nil, fmt.Errorf("spacing percent must be positive")
|
||||
}
|
||||
if p.AmountPerGrid <= 0 {
|
||||
return nil, fmt.Errorf("amount per grid must be positive")
|
||||
}
|
||||
|
||||
halfLevels := p.LevelCount / 2
|
||||
even := p.LevelCount%2 == 0
|
||||
|
||||
var grid []GridLevel
|
||||
|
||||
for i := -halfLevels; i <= halfLevels; i++ {
|
||||
if i == 0 && even {
|
||||
continue
|
||||
}
|
||||
|
||||
multiplier := math.Pow(1+p.SpacingPercent/100, float64(i))
|
||||
levelPrice := p.CenterPrice * multiplier
|
||||
|
||||
side := "sell"
|
||||
if i < 0 {
|
||||
side = "buy"
|
||||
}
|
||||
|
||||
quantity := p.AmountPerGrid / levelPrice
|
||||
|
||||
grid = append(grid, GridLevel{
|
||||
Price: levelPrice,
|
||||
Side: side,
|
||||
Quantity: quantity,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(grid, func(i, j int) bool {
|
||||
return grid[i].Price < grid[j].Price
|
||||
})
|
||||
|
||||
for i := range grid {
|
||||
grid[i].Index = i
|
||||
}
|
||||
|
||||
return grid, nil
|
||||
}
|
||||
|
||||
func FindTriggeredLevel(currentPrice float64, grid []GridLevel) *GridLevel {
|
||||
for i := range grid {
|
||||
if grid[i].Filled {
|
||||
continue
|
||||
}
|
||||
if grid[i].Side == "buy" && currentPrice <= grid[i].Price {
|
||||
return &grid[i]
|
||||
}
|
||||
if grid[i].Side == "sell" && currentPrice >= grid[i].Price {
|
||||
return &grid[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetOppositeLevelIndex(filledLevel *GridLevel, gridLength int) *int {
|
||||
var idx int
|
||||
if filledLevel.Side == "buy" {
|
||||
idx = filledLevel.Index + 1
|
||||
} else {
|
||||
idx = filledLevel.Index - 1
|
||||
}
|
||||
if idx >= 0 && idx < gridLength {
|
||||
return &idx
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetGridStats(grid []GridLevel) GridStats {
|
||||
if len(grid) == 0 {
|
||||
return GridStats{}
|
||||
}
|
||||
|
||||
s := GridStats{Levels: len(grid)}
|
||||
lo := grid[0].Price
|
||||
hi := grid[len(grid)-1].Price
|
||||
s.LowestPrice = &lo
|
||||
s.HighestPrice = &hi
|
||||
|
||||
for _, l := range grid {
|
||||
switch {
|
||||
case l.Side == "buy" && l.Filled:
|
||||
s.FilledBuys++
|
||||
s.FilledLevels++
|
||||
case l.Side == "buy" && !l.Filled:
|
||||
s.PendingBuys++
|
||||
case l.Side == "sell" && l.Filled:
|
||||
s.FilledSells++
|
||||
s.FilledLevels++
|
||||
case l.Side == "sell" && !l.Filled:
|
||||
s.PendingSells++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FormatGridDisplay(grid []GridLevel, centerPrice, amountPerGrid float64) string {
|
||||
if len(grid) == 0 {
|
||||
return "No grid levels initialized."
|
||||
}
|
||||
|
||||
sorted := make([]GridLevel, len(grid))
|
||||
copy(sorted, grid)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Price > sorted[j].Price
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("┌─────────────────────────────────────────────────┐\n")
|
||||
b.WriteString("│ GRID LEVELS (USD) │\n")
|
||||
b.WriteString("├─────────────────────────────────────────────────┤\n")
|
||||
|
||||
for _, level := range sorted {
|
||||
sideIcon := "BUY "
|
||||
if level.Side == "sell" {
|
||||
sideIcon = "SELL"
|
||||
}
|
||||
status := "[ ]"
|
||||
if level.Filled {
|
||||
status = "[X]"
|
||||
}
|
||||
fmt.Fprintf(&b, "│ %s %s @ %10.2f │ %15s │\n",
|
||||
status, sideIcon, level.Price,
|
||||
fmt.Sprintf("%.6f ETH", level.Quantity))
|
||||
}
|
||||
|
||||
b.WriteString("├─────────────────────────────────────────────────┤\n")
|
||||
fmt.Fprintf(&b, "│ Center: $%8.2f │ $%.0f/level │\n", centerPrice, amountPerGrid)
|
||||
b.WriteString("└─────────────────────────────────────────────────┘")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func CreateFallbackSR(currentPrice float64) FallbackSR {
|
||||
return FallbackSR{
|
||||
Support: currentPrice * 0.9,
|
||||
Resistance: currentPrice * 1.1,
|
||||
Midpoint: currentPrice,
|
||||
Method: "fallback",
|
||||
}
|
||||
}
|
||||
|
||||
func IsPriceOutsideGrid(currentPrice float64, grid []GridLevel) bool {
|
||||
if len(grid) == 0 {
|
||||
return true
|
||||
}
|
||||
lo := grid[0].Price
|
||||
hi := grid[0].Price
|
||||
for _, l := range grid[1:] {
|
||||
if l.Price < lo {
|
||||
lo = l.Price
|
||||
}
|
||||
if l.Price > hi {
|
||||
hi = l.Price
|
||||
}
|
||||
}
|
||||
return currentPrice < lo || currentPrice > hi
|
||||
}
|
||||
|
||||
func AreAllSideFilled(grid []GridLevel, side string) bool {
|
||||
count := 0
|
||||
filled := 0
|
||||
for _, l := range grid {
|
||||
if l.Side == side {
|
||||
count++
|
||||
if l.Filled {
|
||||
filled++
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return false
|
||||
}
|
||||
return filled == count
|
||||
}
|
||||
|
||||
func CalculateSRChange(newMidpoint, oldMidpoint float64) float64 {
|
||||
if oldMidpoint == 0 {
|
||||
return 100
|
||||
}
|
||||
return math.Abs((newMidpoint - oldMidpoint) / oldMidpoint * 100)
|
||||
}
|
||||
323
trahn-trade-backend/internal/strategy/grid_test.go
Normal file
323
trahn-trade-backend/internal/strategy/grid_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateMidpoint(t *testing.T) {
|
||||
mid, err := CalculateMidpoint(2400, 3000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mid != 2700 {
|
||||
t.Fatalf("expected 2700, got %f", mid)
|
||||
}
|
||||
|
||||
_, err = CalculateMidpoint(3000, 2400)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for support >= resistance")
|
||||
}
|
||||
|
||||
_, err = CalculateMidpoint(2500, 2500)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for support == resistance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateGridLevels(t *testing.T) {
|
||||
grid, err := CalculateGridLevels(GridParams{
|
||||
CenterPrice: 2700,
|
||||
LevelCount: 10,
|
||||
SpacingPercent: 2,
|
||||
AmountPerGrid: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(grid) != 10 {
|
||||
t.Fatalf("expected 10 levels, got %d", len(grid))
|
||||
}
|
||||
|
||||
// Sorted ascending
|
||||
for i := 1; i < len(grid); i++ {
|
||||
if grid[i].Price <= grid[i-1].Price {
|
||||
t.Fatalf("not sorted ascending at index %d: %.2f <= %.2f", i, grid[i].Price, grid[i-1].Price)
|
||||
}
|
||||
}
|
||||
|
||||
// Indices sequential
|
||||
for i, l := range grid {
|
||||
if l.Index != i {
|
||||
t.Fatalf("index mismatch at %d: got %d", i, l.Index)
|
||||
}
|
||||
}
|
||||
|
||||
// Lower half = buy, upper half = sell
|
||||
buys := 0
|
||||
sells := 0
|
||||
for _, l := range grid {
|
||||
if l.Side == "buy" {
|
||||
buys++
|
||||
} else {
|
||||
sells++
|
||||
}
|
||||
if l.Quantity <= 0 {
|
||||
t.Fatalf("quantity must be positive: %.6f", l.Quantity)
|
||||
}
|
||||
if l.Filled {
|
||||
t.Fatal("new levels should not be filled")
|
||||
}
|
||||
}
|
||||
if buys != 5 || sells != 5 {
|
||||
t.Fatalf("expected 5 buys + 5 sells, got %d buys + %d sells", buys, sells)
|
||||
}
|
||||
|
||||
// Buy levels should have prices below center, sell above
|
||||
for _, l := range grid {
|
||||
if l.Side == "buy" && l.Price >= 2700 {
|
||||
t.Fatalf("buy level at %.2f should be below center 2700", l.Price)
|
||||
}
|
||||
if l.Side == "sell" && l.Price <= 2700 {
|
||||
t.Fatalf("sell level at %.2f should be above center 2700", l.Price)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Grid levels (center=2700, 10 levels, 2%% spacing):")
|
||||
for _, l := range grid {
|
||||
t.Logf(" [%d] %s @ $%.2f qty=%.6f ETH", l.Index, l.Side, l.Price, l.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateGridLevels_OddCount(t *testing.T) {
|
||||
grid, err := CalculateGridLevels(GridParams{
|
||||
CenterPrice: 2000,
|
||||
LevelCount: 7,
|
||||
SpacingPercent: 3,
|
||||
AmountPerGrid: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grid) != 7 {
|
||||
t.Fatalf("expected 7 levels, got %d", len(grid))
|
||||
}
|
||||
t.Logf("Odd grid: %d levels", len(grid))
|
||||
}
|
||||
|
||||
func TestCalculateGridLevels_Validation(t *testing.T) {
|
||||
cases := []GridParams{
|
||||
{CenterPrice: -1, LevelCount: 10, SpacingPercent: 2, AmountPerGrid: 100},
|
||||
{CenterPrice: 2700, LevelCount: 1, SpacingPercent: 2, AmountPerGrid: 100},
|
||||
{CenterPrice: 2700, LevelCount: 10, SpacingPercent: 0, AmountPerGrid: 100},
|
||||
{CenterPrice: 2700, LevelCount: 10, SpacingPercent: 2, AmountPerGrid: -5},
|
||||
}
|
||||
for i, c := range cases {
|
||||
_, err := CalculateGridLevels(c)
|
||||
if err == nil {
|
||||
t.Fatalf("case %d: expected validation error", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTriggeredLevel(t *testing.T) {
|
||||
grid := []GridLevel{
|
||||
{Index: 0, Price: 2550, Side: "buy"},
|
||||
{Index: 1, Price: 2600, Side: "buy"},
|
||||
{Index: 2, Price: 2700, Side: "sell"},
|
||||
{Index: 3, Price: 2750, Side: "sell"},
|
||||
}
|
||||
|
||||
// Price at 2540 triggers buy at 2550 (index 0)
|
||||
triggered := FindTriggeredLevel(2540, grid)
|
||||
if triggered == nil {
|
||||
t.Fatal("expected a triggered level")
|
||||
}
|
||||
if triggered.Index != 0 {
|
||||
t.Fatalf("expected index 0 (buy at 2550), got %d", triggered.Index)
|
||||
}
|
||||
|
||||
// Price at 2590 triggers buy at 2600 (index 1), not 2550
|
||||
triggered = FindTriggeredLevel(2590, grid)
|
||||
if triggered == nil {
|
||||
t.Fatal("expected a triggered level")
|
||||
}
|
||||
if triggered.Index != 1 {
|
||||
t.Fatalf("expected index 1 (buy at 2600), got %d", triggered.Index)
|
||||
}
|
||||
|
||||
// Price at 2710 triggers sell at 2700
|
||||
triggered = FindTriggeredLevel(2710, grid)
|
||||
if triggered == nil {
|
||||
t.Fatal("expected a triggered level")
|
||||
}
|
||||
if triggered.Index != 2 {
|
||||
t.Fatalf("expected index 2 (sell at 2700), got %d", triggered.Index)
|
||||
}
|
||||
|
||||
// Price at 2650 — no trigger (between buy and sell)
|
||||
triggered = FindTriggeredLevel(2650, grid)
|
||||
if triggered != nil {
|
||||
t.Fatalf("expected no trigger at 2650, got index %d", triggered.Index)
|
||||
}
|
||||
|
||||
// Filled levels are skipped
|
||||
grid[0].Filled = true
|
||||
triggered = FindTriggeredLevel(2540, grid)
|
||||
if triggered == nil {
|
||||
t.Fatal("expected triggered level")
|
||||
}
|
||||
if triggered.Index != 1 {
|
||||
t.Fatalf("expected index 1 (skipping filled 0), got %d", triggered.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOppositeLevelIndex(t *testing.T) {
|
||||
buy := &GridLevel{Index: 2, Side: "buy"}
|
||||
sell := &GridLevel{Index: 3, Side: "sell"}
|
||||
|
||||
idx := GetOppositeLevelIndex(buy, 6)
|
||||
if idx == nil || *idx != 3 {
|
||||
t.Fatalf("buy at 2: expected opposite 3, got %v", idx)
|
||||
}
|
||||
|
||||
idx = GetOppositeLevelIndex(sell, 6)
|
||||
if idx == nil || *idx != 2 {
|
||||
t.Fatalf("sell at 3: expected opposite 2, got %v", idx)
|
||||
}
|
||||
|
||||
// Out of bounds
|
||||
edge := &GridLevel{Index: 0, Side: "sell"}
|
||||
idx = GetOppositeLevelIndex(edge, 5)
|
||||
if idx != nil {
|
||||
t.Fatalf("expected nil for out-of-bounds, got %d", *idx)
|
||||
}
|
||||
|
||||
top := &GridLevel{Index: 4, Side: "buy"}
|
||||
idx = GetOppositeLevelIndex(top, 5)
|
||||
if idx != nil {
|
||||
t.Fatalf("expected nil for out-of-bounds, got %d", *idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGridStats(t *testing.T) {
|
||||
grid := []GridLevel{
|
||||
{Index: 0, Price: 2500, Side: "buy", Filled: true},
|
||||
{Index: 1, Price: 2600, Side: "buy", Filled: false},
|
||||
{Index: 2, Price: 2700, Side: "sell", Filled: false},
|
||||
{Index: 3, Price: 2800, Side: "sell", Filled: true},
|
||||
}
|
||||
|
||||
s := GetGridStats(grid)
|
||||
if s.Levels != 4 {
|
||||
t.Fatalf("expected 4 levels, got %d", s.Levels)
|
||||
}
|
||||
if s.FilledBuys != 1 || s.PendingBuys != 1 {
|
||||
t.Fatalf("buys: filled=%d pending=%d", s.FilledBuys, s.PendingBuys)
|
||||
}
|
||||
if s.FilledSells != 1 || s.PendingSells != 1 {
|
||||
t.Fatalf("sells: filled=%d pending=%d", s.FilledSells, s.PendingSells)
|
||||
}
|
||||
if *s.LowestPrice != 2500 || *s.HighestPrice != 2800 {
|
||||
t.Fatalf("price range: %.2f - %.2f", *s.LowestPrice, *s.HighestPrice)
|
||||
}
|
||||
|
||||
// Empty grid
|
||||
empty := GetGridStats(nil)
|
||||
if empty.Levels != 0 {
|
||||
t.Fatal("expected 0 levels for nil grid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGridDisplay(t *testing.T) {
|
||||
grid := []GridLevel{
|
||||
{Index: 0, Price: 2600, Side: "buy", Quantity: 0.0385},
|
||||
{Index: 1, Price: 2700, Side: "sell", Quantity: 0.0370, Filled: true},
|
||||
}
|
||||
out := FormatGridDisplay(grid, 2650, 100)
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty display")
|
||||
}
|
||||
t.Logf("\n%s", out)
|
||||
|
||||
empty := FormatGridDisplay(nil, 0, 0)
|
||||
if empty != "No grid levels initialized." {
|
||||
t.Fatalf("expected empty message, got: %s", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFallbackSR(t *testing.T) {
|
||||
sr := CreateFallbackSR(2000)
|
||||
if sr.Support != 1800 {
|
||||
t.Fatalf("expected support 1800, got %.2f", sr.Support)
|
||||
}
|
||||
if sr.Resistance != 2200 {
|
||||
t.Fatalf("expected resistance 2200, got %.2f", sr.Resistance)
|
||||
}
|
||||
if sr.Midpoint != 2000 {
|
||||
t.Fatalf("expected midpoint 2000, got %.2f", sr.Midpoint)
|
||||
}
|
||||
if sr.Method != "fallback" {
|
||||
t.Fatalf("expected method fallback, got %s", sr.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPriceOutsideGrid(t *testing.T) {
|
||||
grid := []GridLevel{
|
||||
{Price: 2500},
|
||||
{Price: 2600},
|
||||
{Price: 2700},
|
||||
{Price: 2800},
|
||||
}
|
||||
|
||||
if IsPriceOutsideGrid(2650, grid) {
|
||||
t.Fatal("2650 should be inside [2500, 2800]")
|
||||
}
|
||||
if !IsPriceOutsideGrid(2400, grid) {
|
||||
t.Fatal("2400 should be outside")
|
||||
}
|
||||
if !IsPriceOutsideGrid(2900, grid) {
|
||||
t.Fatal("2900 should be outside")
|
||||
}
|
||||
if !IsPriceOutsideGrid(1000, nil) {
|
||||
t.Fatal("empty grid should return outside")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreAllSideFilled(t *testing.T) {
|
||||
grid := []GridLevel{
|
||||
{Side: "buy", Filled: true},
|
||||
{Side: "buy", Filled: true},
|
||||
{Side: "sell", Filled: false},
|
||||
{Side: "sell", Filled: true},
|
||||
}
|
||||
|
||||
if !AreAllSideFilled(grid, "buy") {
|
||||
t.Fatal("all buys are filled")
|
||||
}
|
||||
if AreAllSideFilled(grid, "sell") {
|
||||
t.Fatal("not all sells are filled")
|
||||
}
|
||||
if AreAllSideFilled(nil, "buy") {
|
||||
t.Fatal("empty grid should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSRChange(t *testing.T) {
|
||||
pct := CalculateSRChange(2800, 2700)
|
||||
expected := math.Abs((2800 - 2700) / 2700.0 * 100)
|
||||
if math.Abs(pct-expected) > 0.001 {
|
||||
t.Fatalf("expected %.4f, got %.4f", expected, pct)
|
||||
}
|
||||
|
||||
pct = CalculateSRChange(2700, 0)
|
||||
if pct != 100 {
|
||||
t.Fatalf("expected 100 for zero old midpoint, got %.2f", pct)
|
||||
}
|
||||
|
||||
pct = CalculateSRChange(2700, 2700)
|
||||
if pct != 0 {
|
||||
t.Fatalf("expected 0 for no change, got %.2f", pct)
|
||||
}
|
||||
}
|
||||
42
trahn-trade-backend/internal/testutil/db.go
Normal file
42
trahn-trade-backend/internal/testutil/db.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// SetupPool creates a pgxpool.Pool for integration tests.
|
||||
// Connection details come from env vars or sensible defaults.
|
||||
func SetupPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
|
||||
_ = godotenv.Load("../../.env")
|
||||
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
host := EnvOr("DB_HOST", "localhost")
|
||||
port := EnvOr("DB_PORT", "5432")
|
||||
name := EnvOr("DB_NAME", "trahn_grid_trader")
|
||||
user := EnvOr("DB_USER", "postgres")
|
||||
pass := EnvOr("DB_PASSWORD", "")
|
||||
dsn = "postgres://" + user + ":" + pass + "@" + host + ":" + port + "/" + name + "?sslmode=disable"
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
return pool
|
||||
}
|
||||
|
||||
func EnvOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
BIN
trahn-trade-backend/server
Executable file
BIN
trahn-trade-backend/server
Executable file
Binary file not shown.
Reference in New Issue
Block a user