first commit of restructured project
This commit is contained in:
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# ============================================
|
||||||
|
# Environment & Secrets
|
||||||
|
# ============================================
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.env
|
||||||
|
*secrets.js
|
||||||
|
trahn-trade-frontend/src/auth/secrets.js*
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Dependencies
|
||||||
|
# ============================================
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Bot State Files
|
||||||
|
# ============================================
|
||||||
|
*.state.json
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Logs
|
||||||
|
# ============================================
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Build & Output
|
||||||
|
# ============================================
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Test & Coverage
|
||||||
|
# ============================================
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# IDE & Editors
|
||||||
|
# ============================================
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.sw?
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# OS Files
|
||||||
|
# ============================================
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Misc
|
||||||
|
# ============================================
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
.parcel-cache/
|
||||||
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.
16
trahn-trade-frontend/README.md
Normal file
16
trahn-trade-frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
29
trahn-trade-frontend/eslint.config.js
Normal file
29
trahn-trade-frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
15
trahn-trade-frontend/index.html
Normal file
15
trahn-trade-frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TRAHN Grid Trader</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
trahn-trade-frontend/package.json
Normal file
30
trahn-trade-frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
"firebase": "^12.7.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
612
trahn-trade-frontend/src/App.css
Normal file
612
trahn-trade-frontend/src/App.css
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--text-primary: #e6edf3;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--text-muted: #484f58;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--green-fill: rgba(63, 185, 80, 0.15);
|
||||||
|
--red: #f85149;
|
||||||
|
--red-fill: rgba(248, 81, 73, 0.15);
|
||||||
|
--yellow: #f0c000;
|
||||||
|
--orange: #db6d28;
|
||||||
|
--border: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascii-logo {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel Navigation */
|
||||||
|
.carousel-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-display {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-status {
|
||||||
|
display: block;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-status.live {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-status.live::before {
|
||||||
|
content: '●';
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart */
|
||||||
|
.chart-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 100;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip .price {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip .time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.high { color: var(--green); }
|
||||||
|
.stat-value.low { color: var(--red); }
|
||||||
|
.stat-value.buy { color: var(--yellow); }
|
||||||
|
.stat-value.sell { color: var(--orange); }
|
||||||
|
|
||||||
|
/* Day indicators */
|
||||||
|
.day-indicators {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-dot:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-dot.active {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 8px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-dot {
|
||||||
|
background: var(--yellow);
|
||||||
|
box-shadow: 0 0 8px var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell-dot {
|
||||||
|
background: var(--orange);
|
||||||
|
box-shadow: 0 0 8px var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status */
|
||||||
|
.connection-status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status .status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected .status-dot {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 6px var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.waiting .status-dot {
|
||||||
|
background: var(--yellow);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
flex: 1 1 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Levels Display */
|
||||||
|
.grid-levels {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-levels-header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-levels-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-column-header {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-column-header.buy {
|
||||||
|
color: var(--yellow);
|
||||||
|
background: rgba(240, 192, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-column-header.sell {
|
||||||
|
color: var(--orange);
|
||||||
|
background: rgba(219, 109, 40, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-level {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-level.buy {
|
||||||
|
color: var(--yellow);
|
||||||
|
background: rgba(240, 192, 0, 0.08);
|
||||||
|
border: 1px solid rgba(240, 192, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-level.sell {
|
||||||
|
color: var(--orange);
|
||||||
|
background: rgba(219, 109, 40, 0.08);
|
||||||
|
border: 1px solid rgba(219, 109, 40, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-level.filled {
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Pages */
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .ascii-logo {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
background: var(--red-fill);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-success {
|
||||||
|
background: var(--green-fill);
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
color: var(--green);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Waitlist Message */
|
||||||
|
.waitlist-message {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waitlist-message p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waitlist-message .thank-you {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logout Button */
|
||||||
|
.logout-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
border-color: var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
80
trahn-trade-frontend/src/App.jsx
Normal file
80
trahn-trade-frontend/src/App.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
import { TradingDataProvider } from './contexts/TradingDataContext';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { Login } from './pages/Login';
|
||||||
|
import { Signup } from './pages/Signup';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
// Create React Query client
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 3,
|
||||||
|
retryDelay: 2000,
|
||||||
|
staleTime: 30000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect authenticated users away from auth pages
|
||||||
|
function PublicRoute({ children }) {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Login />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/signup"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Signup />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<TradingDataProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</TradingDataProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</Router>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
81
trahn-trade-frontend/src/api/tradingApi.js
Normal file
81
trahn-trade-frontend/src/api/tradingApi.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Trading API Client
|
||||||
|
* Communicates with backend REST API
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export const tradingApi = {
|
||||||
|
/**
|
||||||
|
* Get current trading day prices
|
||||||
|
*/
|
||||||
|
getCurrentPrices: async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/prices/today`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current trading day trades
|
||||||
|
*/
|
||||||
|
getCurrentTrades: async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/trades/today`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current grid state
|
||||||
|
*/
|
||||||
|
getCurrentGrid: async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/grid/current`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest support/resistance levels
|
||||||
|
*/
|
||||||
|
getLatestSR: async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/support-resistance/latest`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available trading days
|
||||||
|
*/
|
||||||
|
getAvailableDays: async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/prices/days`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical prices for a specific day
|
||||||
|
*/
|
||||||
|
getPricesByDay: async (date) => {
|
||||||
|
const response = await fetch(`${API_BASE}/prices/day/${date}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical trades for a specific day
|
||||||
|
*/
|
||||||
|
getTradesByDay: async (date) => {
|
||||||
|
const response = await fetch(`${API_BASE}/trades/day/${date}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trade statistics
|
||||||
|
*/
|
||||||
|
getTradeStats: async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/trades/stats`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
1
trahn-trade-frontend/src/assets/react.svg
Normal file
1
trahn-trade-frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
26
trahn-trade-frontend/src/auth/firebase.js
Normal file
26
trahn-trade-frontend/src/auth/firebase.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import { getAuth } from 'firebase/auth';
|
||||||
|
import { getFirestore, collection, addDoc, serverTimestamp } from 'firebase/firestore';
|
||||||
|
import { firebaseConfig } from './secrets';
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
export const auth = getAuth(app);
|
||||||
|
export const db = getFirestore(app);
|
||||||
|
|
||||||
|
// Add email to waitlist collection
|
||||||
|
export async function addToWaitlist(email) {
|
||||||
|
try {
|
||||||
|
await addDoc(collection(db, 'trahn-trade-signups'), {
|
||||||
|
email: email,
|
||||||
|
createdAt: serverTimestamp(),
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding to waitlist:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
13
trahn-trade-frontend/src/auth/secrets.example.js
Normal file
13
trahn-trade-frontend/src/auth/secrets.example.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Firebase Configuration
|
||||||
|
// Copy this file to secrets.js and fill in your Firebase project values
|
||||||
|
// Get these from: Firebase Console → Project Settings → Your apps → Web app
|
||||||
|
|
||||||
|
export const firebaseConfig = {
|
||||||
|
apiKey: "YOUR_API_KEY",
|
||||||
|
authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
|
||||||
|
projectId: "YOUR_PROJECT_ID",
|
||||||
|
storageBucket: "YOUR_PROJECT_ID.firebasestorage.app",
|
||||||
|
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
|
||||||
|
appId: "YOUR_APP_ID"
|
||||||
|
};
|
||||||
|
|
||||||
82
trahn-trade-frontend/src/components/CarouselNav.jsx
Normal file
82
trahn-trade-frontend/src/components/CarouselNav.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export function CarouselNav({
|
||||||
|
currentDay,
|
||||||
|
availableDays,
|
||||||
|
currentDayIndex,
|
||||||
|
isLive,
|
||||||
|
canGoPrev,
|
||||||
|
canGoNext,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onNavigateTo
|
||||||
|
}) {
|
||||||
|
const formatDate = (day) => {
|
||||||
|
if (!day) return 'No Data';
|
||||||
|
const date = new Date(day + 'T12:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carousel-nav">
|
||||||
|
<button
|
||||||
|
className="nav-arrow"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
title="Previous Day"
|
||||||
|
>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="date-display">
|
||||||
|
<span className="date-label">{formatDate(currentDay)}</span>
|
||||||
|
<span className={`date-status ${isLive ? 'live' : ''}`}>
|
||||||
|
{isLive ? 'LIVE' : '24h snapshot'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="nav-arrow"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
title="Next Day"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DayIndicators({ availableDays, currentDayIndex, onNavigateTo }) {
|
||||||
|
return (
|
||||||
|
<div className="day-indicators">
|
||||||
|
{availableDays.map((day, i) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={`day-dot ${i === currentDayIndex ? 'active' : ''}`}
|
||||||
|
title={day}
|
||||||
|
onClick={() => onNavigateTo(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionStatus({ status }) {
|
||||||
|
const statusText = {
|
||||||
|
connected: 'Live',
|
||||||
|
waiting: 'Waiting for backend...',
|
||||||
|
connecting: 'Connecting...',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`connection-status ${status}`}>
|
||||||
|
<span className="status-dot" />
|
||||||
|
<span className="status-text">{statusText[status] || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
350
trahn-trade-frontend/src/components/Chart.jsx
Normal file
350
trahn-trade-frontend/src/components/Chart.jsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
line: '#58a6ff',
|
||||||
|
green: '#3fb950',
|
||||||
|
greenFill: 'rgba(63, 185, 80, 0.12)',
|
||||||
|
red: '#f85149',
|
||||||
|
redFill: 'rgba(248, 81, 73, 0.12)',
|
||||||
|
grid: '#21262d',
|
||||||
|
text: '#8b949e',
|
||||||
|
textMuted: '#484f58',
|
||||||
|
baseline: '#30363d',
|
||||||
|
buy: '#f0c000',
|
||||||
|
sell: '#db6d28',
|
||||||
|
bg: '#0d1117',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PADDING = { top: 20, right: 80, bottom: 40, left: 20 };
|
||||||
|
|
||||||
|
export function Chart({ prices, trades }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [tooltip, setTooltip] = useState(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
setDimensions({ width: rect.width, height: rect.height });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
window.addEventListener('resize', updateDimensions);
|
||||||
|
return () => window.removeEventListener('resize', updateDimensions);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draw chart
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || dimensions.width === 0) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
canvas.width = dimensions.width * dpr;
|
||||||
|
canvas.height = dimensions.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const width = dimensions.width;
|
||||||
|
const height = dimensions.height;
|
||||||
|
const chartWidth = width - PADDING.left - PADDING.right;
|
||||||
|
const chartHeight = height - PADDING.top - PADDING.bottom;
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (!prices || prices.length === 0) {
|
||||||
|
ctx.fillStyle = COLORS.textMuted;
|
||||||
|
ctx.font = '14px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('No data for this day', width / 2, height / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounds
|
||||||
|
const priceValues = prices.map(d => d.p || d.price);
|
||||||
|
const times = prices.map(d => d.t || d.timestamp);
|
||||||
|
|
||||||
|
let minPrice = Math.min(...priceValues);
|
||||||
|
let maxPrice = Math.max(...priceValues);
|
||||||
|
const minTime = Math.min(...times);
|
||||||
|
const maxTime = Math.max(...times);
|
||||||
|
|
||||||
|
const pricePadding = (maxPrice - minPrice) * 0.05 || 10;
|
||||||
|
minPrice -= pricePadding;
|
||||||
|
maxPrice += pricePadding;
|
||||||
|
|
||||||
|
const baselinePrice = priceValues[0];
|
||||||
|
|
||||||
|
// Coordinate converters
|
||||||
|
const priceToY = (price) => {
|
||||||
|
const range = maxPrice - minPrice;
|
||||||
|
const normalized = (price - minPrice) / range;
|
||||||
|
return PADDING.top + chartHeight * (1 - normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeToX = (timestamp) => {
|
||||||
|
const range = maxTime - minTime;
|
||||||
|
const normalized = (timestamp - minTime) / range;
|
||||||
|
return PADDING.left + chartWidth * normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
ctx.strokeStyle = COLORS.grid;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
const priceStep = calculateNiceStep(maxPrice - minPrice, 5);
|
||||||
|
const startPrice = Math.ceil(minPrice / priceStep) * priceStep;
|
||||||
|
|
||||||
|
for (let price = startPrice; price <= maxPrice; price += priceStep) {
|
||||||
|
const y = priceToY(price);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PADDING.left, y);
|
||||||
|
ctx.lineTo(width - PADDING.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw baseline
|
||||||
|
const baselineY = priceToY(baselinePrice);
|
||||||
|
ctx.strokeStyle = COLORS.baseline;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PADDING.left, baselineY);
|
||||||
|
ctx.lineTo(width - PADDING.right, baselineY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Draw gradient fill
|
||||||
|
if (prices.length >= 2) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(timeToX(times[0]), baselineY);
|
||||||
|
ctx.lineTo(timeToX(times[0]), priceToY(priceValues[0]));
|
||||||
|
|
||||||
|
for (let i = 1; i < prices.length; i++) {
|
||||||
|
ctx.lineTo(timeToX(times[i]), priceToY(priceValues[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineTo(timeToX(times[times.length - 1]), baselineY);
|
||||||
|
ctx.closePath();
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, PADDING.top, 0, height - PADDING.bottom);
|
||||||
|
gradient.addColorStop(0, COLORS.greenFill);
|
||||||
|
gradient.addColorStop(0.5, 'rgba(0,0,0,0)');
|
||||||
|
gradient.addColorStop(1, COLORS.redFill);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw price line
|
||||||
|
if (prices.length >= 2) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = COLORS.line;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
ctx.moveTo(timeToX(times[0]), priceToY(priceValues[0]));
|
||||||
|
for (let i = 1; i < prices.length; i++) {
|
||||||
|
ctx.lineTo(timeToX(times[i]), priceToY(priceValues[i]));
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Y axis labels
|
||||||
|
ctx.fillStyle = COLORS.text;
|
||||||
|
ctx.font = '11px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
for (let price = startPrice; price <= maxPrice; price += priceStep) {
|
||||||
|
const y = priceToY(price);
|
||||||
|
ctx.fillText(formatPrice(price), width - PADDING.right + 8, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw X axis labels
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const hourMs = 60 * 60 * 1000;
|
||||||
|
const rangeMs = maxTime - minTime;
|
||||||
|
let stepMs = hourMs * 4;
|
||||||
|
if (rangeMs < hourMs * 6) stepMs = hourMs;
|
||||||
|
if (rangeMs < hourMs * 2) stepMs = hourMs / 2;
|
||||||
|
|
||||||
|
let current = Math.ceil(minTime / stepMs) * stepMs;
|
||||||
|
while (current <= maxTime) {
|
||||||
|
const x = timeToX(current);
|
||||||
|
if (x >= PADDING.left && x <= width - PADDING.right) {
|
||||||
|
const date = new Date(current);
|
||||||
|
const label = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
ctx.fillText(label, x, height - PADDING.bottom + 20);
|
||||||
|
}
|
||||||
|
current += stepMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw trades
|
||||||
|
for (const trade of (trades || [])) {
|
||||||
|
const x = timeToX(trade.t || trade.timestamp);
|
||||||
|
const y = priceToY(trade.price);
|
||||||
|
|
||||||
|
if (x < PADDING.left || x > width - PADDING.right) continue;
|
||||||
|
|
||||||
|
const color = trade.side === 'buy' ? COLORS.buy : COLORS.sell;
|
||||||
|
const radius = 7;
|
||||||
|
|
||||||
|
// Glow
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius + 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color + '33';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Outer
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Inner
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius - 3, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = COLORS.bg;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw current price tag
|
||||||
|
if (prices.length > 0) {
|
||||||
|
const lastPrice = priceValues[priceValues.length - 1];
|
||||||
|
const y = priceToY(lastPrice);
|
||||||
|
const x = width - PADDING.right;
|
||||||
|
|
||||||
|
const isUp = lastPrice >= baselinePrice;
|
||||||
|
const color = isUp ? COLORS.green : COLORS.red;
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
roundRect(ctx, x + 4, y - 11, 70, 22, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = 'bold 11px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(formatPrice(lastPrice), x + 10, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [prices, trades, dimensions]);
|
||||||
|
|
||||||
|
// Mouse handling for tooltip
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!prices || prices.length === 0) {
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const width = dimensions.width;
|
||||||
|
const height = dimensions.height;
|
||||||
|
const chartWidth = width - PADDING.left - PADDING.right;
|
||||||
|
|
||||||
|
if (x < PADDING.left || x > width - PADDING.right ||
|
||||||
|
y < PADDING.top || y > height - PADDING.bottom) {
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const times = prices.map(d => d.t || d.timestamp);
|
||||||
|
const minTime = Math.min(...times);
|
||||||
|
const maxTime = Math.max(...times);
|
||||||
|
|
||||||
|
const normalized = (x - PADDING.left) / chartWidth;
|
||||||
|
const timestamp = minTime + normalized * (maxTime - minTime);
|
||||||
|
|
||||||
|
let nearestPoint = null;
|
||||||
|
let nearestDistance = Infinity;
|
||||||
|
|
||||||
|
for (const point of prices) {
|
||||||
|
const t = point.t || point.timestamp;
|
||||||
|
const distance = Math.abs(t - timestamp);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestDistance = distance;
|
||||||
|
nearestPoint = point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearestPoint) {
|
||||||
|
setTooltip({
|
||||||
|
x: e.clientX - rect.left + 15,
|
||||||
|
y: e.clientY - rect.top - 10,
|
||||||
|
price: nearestPoint.p || nearestPoint.price,
|
||||||
|
timestamp: nearestPoint.t || nearestPoint.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-container" ref={containerRef}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
/>
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="tooltip visible"
|
||||||
|
style={{ left: tooltip.x, top: tooltip.y }}
|
||||||
|
>
|
||||||
|
<div className="price">
|
||||||
|
${tooltip.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
<div className="time">
|
||||||
|
{new Date(tooltip.timestamp).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNiceStep(range, targetSteps) {
|
||||||
|
const roughStep = range / targetSteps;
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
|
||||||
|
const normalized = roughStep / magnitude;
|
||||||
|
|
||||||
|
let niceStep;
|
||||||
|
if (normalized <= 1.5) niceStep = 1;
|
||||||
|
else if (normalized <= 3) niceStep = 2;
|
||||||
|
else if (normalized <= 7) niceStep = 5;
|
||||||
|
else niceStep = 10;
|
||||||
|
|
||||||
|
return niceStep * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price) {
|
||||||
|
if (price >= 10000) {
|
||||||
|
return '$' + (price / 1000).toFixed(1) + 'k';
|
||||||
|
}
|
||||||
|
return '$' + price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
13
trahn-trade-frontend/src/components/ProtectedRoute.jsx
Normal file
13
trahn-trade-frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }) {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return <Navigate to="/signup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
48
trahn-trade-frontend/src/components/Stats.jsx
Normal file
48
trahn-trade-frontend/src/components/Stats.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export function Stats({ prices, trades }) {
|
||||||
|
if (!prices || prices.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="stats">
|
||||||
|
<Stat label="Current Price" value="--" />
|
||||||
|
<Stat label="24H High" value="--" className="high" />
|
||||||
|
<Stat label="24H Low" value="--" className="low" />
|
||||||
|
<Stat label="Buys" value="0" className="buy" />
|
||||||
|
<Stat label="Sells" value="0" className="sell" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceValues = prices.map(d => d.p || d.price);
|
||||||
|
const currentPrice = priceValues[priceValues.length - 1];
|
||||||
|
const highPrice = Math.max(...priceValues);
|
||||||
|
const lowPrice = Math.min(...priceValues);
|
||||||
|
|
||||||
|
const buyCount = (trades || []).filter(t => t.side === 'buy').length;
|
||||||
|
const sellCount = (trades || []).filter(t => t.side === 'sell').length;
|
||||||
|
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
return '$' + price.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats">
|
||||||
|
<Stat label="Current Price" value={formatPrice(currentPrice)} />
|
||||||
|
<Stat label="24H High" value={formatPrice(highPrice)} className="high" />
|
||||||
|
<Stat label="24H Low" value={formatPrice(lowPrice)} className="low" />
|
||||||
|
<Stat label="Buys" value={buyCount} className="buy" />
|
||||||
|
<Stat label="Sells" value={sellCount} className="sell" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, className = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">{label}</span>
|
||||||
|
<span className={`stat-value ${className}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
54
trahn-trade-frontend/src/contexts/AuthContext.jsx
Normal file
54
trahn-trade-frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
createUserWithEmailAndPassword,
|
||||||
|
signInWithEmailAndPassword,
|
||||||
|
signOut,
|
||||||
|
onAuthStateChanged
|
||||||
|
} from 'firebase/auth';
|
||||||
|
import { auth } from '../auth/firebase';
|
||||||
|
|
||||||
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
function signup(email, password) {
|
||||||
|
return createUserWithEmailAndPassword(auth, email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(email, password) {
|
||||||
|
return signInWithEmailAndPassword(auth, email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
return signOut(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
currentUser,
|
||||||
|
signup,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{!loading && children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
125
trahn-trade-frontend/src/contexts/TradingDataContext.jsx
Normal file
125
trahn-trade-frontend/src/contexts/TradingDataContext.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { tradingApi } from '../api/tradingApi';
|
||||||
|
|
||||||
|
const TradingDataContext = createContext(undefined);
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
currentDay: null,
|
||||||
|
availableDays: [],
|
||||||
|
currentDayIndex: 0,
|
||||||
|
prices: [],
|
||||||
|
trades: [],
|
||||||
|
grid: [],
|
||||||
|
supportResistance: null,
|
||||||
|
isLive: true,
|
||||||
|
connectionStatus: 'connecting',
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'UPDATE_CURRENT_DAY':
|
||||||
|
const dayIndex = action.availableDays.length > 0 ? action.availableDays.length - 1 : 0;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
prices: action.prices || [],
|
||||||
|
trades: action.trades || [],
|
||||||
|
grid: action.grid || [],
|
||||||
|
availableDays: action.availableDays || [],
|
||||||
|
currentDay: action.currentDay,
|
||||||
|
currentDayIndex: dayIndex,
|
||||||
|
isLive: true,
|
||||||
|
connectionStatus: 'connected',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'LOAD_HISTORICAL_DAY':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
prices: action.prices,
|
||||||
|
trades: action.trades,
|
||||||
|
currentDayIndex: action.index,
|
||||||
|
isLive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_CONNECTION_STATUS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
connectionStatus: action.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SWITCH_TO_LIVE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLive: true,
|
||||||
|
currentDayIndex: state.availableDays.length - 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TradingDataProvider({ children }) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
// React Query: Poll current day data every 1 minute
|
||||||
|
const { data: currentData, error, isLoading } = useQuery({
|
||||||
|
queryKey: ['currentDay'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const [prices, trades, grid, days] = await Promise.all([
|
||||||
|
tradingApi.getCurrentPrices(),
|
||||||
|
tradingApi.getCurrentTrades(),
|
||||||
|
tradingApi.getCurrentGrid(),
|
||||||
|
tradingApi.getAvailableDays(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prices,
|
||||||
|
trades,
|
||||||
|
grid: grid.grid || [],
|
||||||
|
availableDays: days,
|
||||||
|
currentDay: days[days.length - 1] || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // Poll every 1 minute
|
||||||
|
staleTime: 30000, // Data fresh for 30 seconds
|
||||||
|
retry: 3,
|
||||||
|
retryDelay: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update context when live data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentData) {
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_CURRENT_DAY',
|
||||||
|
...currentData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentData]);
|
||||||
|
|
||||||
|
// Handle connection status
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
dispatch({ type: 'SET_CONNECTION_STATUS', status: 'error' });
|
||||||
|
} else if (isLoading) {
|
||||||
|
dispatch({ type: 'SET_CONNECTION_STATUS', status: 'connecting' });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'SET_CONNECTION_STATUS', status: 'connected' });
|
||||||
|
}
|
||||||
|
}, [error, isLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TradingDataContext.Provider value={{ state, dispatch }}>
|
||||||
|
{children}
|
||||||
|
</TradingDataContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTradingData() {
|
||||||
|
const context = useContext(TradingDataContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTradingData must be used within TradingDataProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
18
trahn-trade-frontend/src/hooks/useChartData.js
Normal file
18
trahn-trade-frontend/src/hooks/useChartData.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* DEPRECATED - Replaced by TradingDataContext + React Query
|
||||||
|
*
|
||||||
|
* This file is kept for reference but no longer used.
|
||||||
|
* The new implementation:
|
||||||
|
* - Uses React Query for polling
|
||||||
|
* - Uses Context for state management
|
||||||
|
* - Calls REST API instead of JSON files
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
* - contexts/TradingDataContext.jsx
|
||||||
|
* - hooks/useTradingData.js
|
||||||
|
* - api/tradingApi.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useChartData() {
|
||||||
|
throw new Error('useChartData is deprecated. Use useTradingData from TradingDataContext instead.');
|
||||||
|
}
|
||||||
80
trahn-trade-frontend/src/hooks/useTradingData.js
Normal file
80
trahn-trade-frontend/src/hooks/useTradingData.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Custom hooks for trading data management
|
||||||
|
* Handles carousel navigation and historical day loading
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTradingData } from '../contexts/TradingDataContext';
|
||||||
|
import { tradingApi } from '../api/tradingApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for loading historical day data (lazy load)
|
||||||
|
*/
|
||||||
|
export function useHistoricalDay(date, enabled = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['historicalDay', date],
|
||||||
|
queryFn: async () => {
|
||||||
|
const [prices, trades] = await Promise.all([
|
||||||
|
tradingApi.getPricesByDay(date),
|
||||||
|
tradingApi.getTradesByDay(date),
|
||||||
|
]);
|
||||||
|
return { prices, trades };
|
||||||
|
},
|
||||||
|
enabled: enabled && !!date,
|
||||||
|
staleTime: Infinity, // Historical data never changes
|
||||||
|
cacheTime: 600000, // Keep in cache for 10 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for carousel navigation
|
||||||
|
*/
|
||||||
|
export function useCarousel() {
|
||||||
|
const { state, dispatch } = useTradingData();
|
||||||
|
|
||||||
|
const navigateTo = (index) => {
|
||||||
|
if (index < 0 || index >= state.availableDays.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === state.availableDays.length - 1) {
|
||||||
|
// Navigate to live (current) day
|
||||||
|
dispatch({ type: 'SWITCH_TO_LIVE' });
|
||||||
|
} else {
|
||||||
|
// Load historical day
|
||||||
|
const date = state.availableDays[index];
|
||||||
|
// Historical data will be loaded via useHistoricalDay in Dashboard
|
||||||
|
dispatch({
|
||||||
|
type: 'LOAD_HISTORICAL_DAY',
|
||||||
|
prices: [],
|
||||||
|
trades: [],
|
||||||
|
index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigatePrev = () => {
|
||||||
|
if (state.currentDayIndex > 0) {
|
||||||
|
navigateTo(state.currentDayIndex - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateNext = () => {
|
||||||
|
if (state.currentDayIndex < state.availableDays.length - 1) {
|
||||||
|
navigateTo(state.currentDayIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentDayIndex: state.currentDayIndex,
|
||||||
|
availableDays: state.availableDays,
|
||||||
|
currentDay: state.availableDays[state.currentDayIndex],
|
||||||
|
canGoPrev: state.currentDayIndex > 0,
|
||||||
|
canGoNext: state.currentDayIndex < state.availableDays.length - 1,
|
||||||
|
navigatePrev,
|
||||||
|
navigateNext,
|
||||||
|
navigateTo,
|
||||||
|
isLive: state.isLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
1
trahn-trade-frontend/src/index.css
Normal file
1
trahn-trade-frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* Reset handled in App.css */
|
||||||
10
trahn-trade-frontend/src/main.jsx
Normal file
10
trahn-trade-frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
138
trahn-trade-frontend/src/pages/Dashboard.jsx
Normal file
138
trahn-trade-frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { Chart } from '../components/Chart';
|
||||||
|
import { CarouselNav, DayIndicators, ConnectionStatus } from '../components/CarouselNav';
|
||||||
|
import { Stats } from '../components/Stats';
|
||||||
|
import { useTradingData } from '../contexts/TradingDataContext';
|
||||||
|
import { useCarousel } from '../hooks/useTradingData';
|
||||||
|
import { useHistoricalDay } from '../hooks/useTradingData';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const { state, dispatch } = useTradingData();
|
||||||
|
const carousel = useCarousel();
|
||||||
|
|
||||||
|
// Load historical day data when viewing past days
|
||||||
|
const historicalDate = !state.isLive ? state.availableDays[state.currentDayIndex] : null;
|
||||||
|
const { data: historicalData } = useHistoricalDay(historicalDate, !state.isLive);
|
||||||
|
|
||||||
|
// Update context with historical data when loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (historicalData && !state.isLive) {
|
||||||
|
dispatch({
|
||||||
|
type: 'LOAD_HISTORICAL_DAY',
|
||||||
|
prices: historicalData.prices,
|
||||||
|
trades: historicalData.trades,
|
||||||
|
index: state.currentDayIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [historicalData, state.isLive, state.currentDayIndex, dispatch]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
prices,
|
||||||
|
trades,
|
||||||
|
grid,
|
||||||
|
availableDays,
|
||||||
|
currentDayIndex,
|
||||||
|
isLive,
|
||||||
|
connectionStatus,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
const {
|
||||||
|
navigatePrev,
|
||||||
|
navigateNext,
|
||||||
|
navigateTo,
|
||||||
|
canGoPrev,
|
||||||
|
canGoNext,
|
||||||
|
} = carousel;
|
||||||
|
|
||||||
|
const currentDay = availableDays[currentDayIndex] || null;
|
||||||
|
const loading = connectionStatus === 'connecting';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<header>
|
||||||
|
<pre className="ascii-logo">{`
|
||||||
|
████████╗██████╗ █████╗ ██╗ ██╗███╗ ██╗
|
||||||
|
╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║
|
||||||
|
██║ ██████╔╝███████║███████║██╔██╗ ██║
|
||||||
|
██║ ██╔══██╗██╔══██║██╔══██║██║╚██╗██║
|
||||||
|
██║ ██║ ██║██║ ██║██║ ██║██║ ╚████║
|
||||||
|
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝`}</pre>
|
||||||
|
<button className="logout-btn" onClick={logout}>Logout</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CarouselNav
|
||||||
|
currentDay={currentDay}
|
||||||
|
availableDays={availableDays}
|
||||||
|
currentDayIndex={currentDayIndex}
|
||||||
|
isLive={isLive}
|
||||||
|
canGoPrev={canGoPrev}
|
||||||
|
canGoNext={canGoNext}
|
||||||
|
onPrev={navigatePrev}
|
||||||
|
onNext={navigateNext}
|
||||||
|
onNavigateTo={navigateTo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<Chart prices={prices} trades={trades} />
|
||||||
|
{loading && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="spinner" />
|
||||||
|
<span>Waiting for data...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stats prices={prices} trades={trades} />
|
||||||
|
|
||||||
|
<DayIndicators
|
||||||
|
availableDays={availableDays}
|
||||||
|
currentDayIndex={currentDayIndex}
|
||||||
|
onNavigateTo={navigateTo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="legend">
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot buy-dot" /> Buy (USDC → ETH)
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot sell-dot" /> Sell (ETH → USDC)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{grid && grid.length > 0 && (
|
||||||
|
<div className="grid-levels">
|
||||||
|
<div className="grid-levels-header">Grid Levels</div>
|
||||||
|
<div className="grid-levels-container">
|
||||||
|
<div className="grid-column">
|
||||||
|
<div className="grid-column-header buy">Buy Levels</div>
|
||||||
|
{grid
|
||||||
|
.filter(level => level.side === 'buy')
|
||||||
|
.sort((a, b) => b.price - a.price)
|
||||||
|
.map((level, idx) => (
|
||||||
|
<div key={idx} className={`grid-level buy ${level.filled ? 'filled' : ''}`}>
|
||||||
|
${level.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid-column">
|
||||||
|
<div className="grid-column-header sell">Sell Levels</div>
|
||||||
|
{grid
|
||||||
|
.filter(level => level.side === 'sell')
|
||||||
|
.sort((a, b) => a.price - b.price)
|
||||||
|
.map((level, idx) => (
|
||||||
|
<div key={idx} className={`grid-level sell ${level.filled ? 'filled' : ''}`}>
|
||||||
|
${level.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConnectionStatus status={connectionStatus} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
82
trahn-trade-frontend/src/pages/Login.jsx
Normal file
82
trahn-trade-frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const successMessage = location.state?.message;
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to log in');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<pre className="ascii-logo">{`
|
||||||
|
████████╗██████╗ █████╗ ██╗ ██╗███╗ ██╗
|
||||||
|
╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║
|
||||||
|
██║ ██████╔╝███████║███████║██╔██╗ ██║
|
||||||
|
██║ ██╔══██╗██╔══██║██╔══██║██║╚██╗██║
|
||||||
|
██║ ██║ ██║██║ ██║██║ ██║██║ ╚████║
|
||||||
|
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝`}</pre>
|
||||||
|
|
||||||
|
<h2>Welcome Back</h2>
|
||||||
|
|
||||||
|
{successMessage && <div className="auth-success">{successMessage}</div>}
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="auth-btn" disabled={loading}>
|
||||||
|
{loading ? 'Logging In...' : 'Log In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-link">
|
||||||
|
Don't have an account? <Link to="/signup">Sign Up</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
96
trahn-trade-frontend/src/pages/Signup.jsx
Normal file
96
trahn-trade-frontend/src/pages/Signup.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { addToWaitlist } from '../auth/firebase';
|
||||||
|
//a comment
|
||||||
|
|
||||||
|
export function Signup() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return setError('Please enter your email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
const result = await addToWaitlist(email);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setSubmitted(true);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to join waitlist. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// some spacing
|
||||||
|
//more
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<pre className="ascii-logo">{`
|
||||||
|
████████╗██████╗ █████╗ ██╗ ██╗███╗ ██╗
|
||||||
|
╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║
|
||||||
|
██║ ██████╔╝███████║███████║██╔██╗ ██║
|
||||||
|
██║ ██╔══██╗██╔══██║██╔══██║██║╚██╗██║
|
||||||
|
██║ ██║ ██║██║ ██║██║ ██║██║ ╚████║
|
||||||
|
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝`}</pre>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<>
|
||||||
|
<h2>Request Received</h2>
|
||||||
|
<div className="waitlist-message">
|
||||||
|
<p>
|
||||||
|
A message has been sent requesting authorization for signup.
|
||||||
|
You will receive a response to your email when granted, or
|
||||||
|
further information regarding the waitlist.
|
||||||
|
</p>
|
||||||
|
<p className="thank-you">Thank you for your interest!</p>
|
||||||
|
</div>
|
||||||
|
<p className="auth-link">
|
||||||
|
Already have an account? <Link to="/login">Log In</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>Join Waitlist</h2>
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="auth-btn" disabled={loading}>
|
||||||
|
{loading ? 'Submitting...' : 'Request Access'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-link">
|
||||||
|
Already have an account? <Link to="/login">Log In</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
trahn-trade-frontend/vite.config.js
Normal file
15
trahn-trade-frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
// Serve the data folder
|
||||||
|
fs: {
|
||||||
|
allow: ['..'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Make data folder accessible
|
||||||
|
publicDir: 'public',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user