commit 9fca234606729deadf86962d64a57daae2307d3e Author: KS Jannette Date: Sun Feb 22 15:21:18 2026 -0500 first commit of restructured project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..724549c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/trahn-trade-backend/Makefile b/trahn-trade-backend/Makefile new file mode 100644 index 0000000..dfa30b4 --- /dev/null +++ b/trahn-trade-backend/Makefile @@ -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" diff --git a/trahn-trade-backend/README.md b/trahn-trade-backend/README.md new file mode 100644 index 0000000..d574f0e --- /dev/null +++ b/trahn-trade-backend/README.md @@ -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 ./... +``` diff --git a/trahn-trade-backend/cmd/server/main.go b/trahn-trade-backend/cmd/server/main.go new file mode 100644 index 0000000..2493ece --- /dev/null +++ b/trahn-trade-backend/cmd/server/main.go @@ -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") +} diff --git a/trahn-trade-backend/db/README.md b/trahn-trade-backend/db/README.md new file mode 100644 index 0000000..8445df2 --- /dev/null +++ b/trahn-trade-backend/db/README.md @@ -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) + diff --git a/trahn-trade-backend/db/migrations/002_add_paper_wallet_columns.sql b/trahn-trade-backend/db/migrations/002_add_paper_wallet_columns.sql new file mode 100644 index 0000000..390a359 --- /dev/null +++ b/trahn-trade-backend/db/migrations/002_add_paper_wallet_columns.sql @@ -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 + diff --git a/trahn-trade-backend/db/schema.sql b/trahn-trade-backend/db/schema.sql new file mode 100644 index 0000000..a991265 --- /dev/null +++ b/trahn-trade-backend/db/schema.sql @@ -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; + diff --git a/trahn-trade-backend/db/setup.sql b/trahn-trade-backend/db/setup.sql new file mode 100644 index 0000000..e5642b6 --- /dev/null +++ b/trahn-trade-backend/db/setup.sql @@ -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 + diff --git a/trahn-trade-backend/env.example b/trahn-trade-backend/env.example new file mode 100644 index 0000000..0b82574 --- /dev/null +++ b/trahn-trade-backend/env.example @@ -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 " 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= + diff --git a/trahn-trade-backend/go.mod b/trahn-trade-backend/go.mod new file mode 100644 index 0000000..60132fb --- /dev/null +++ b/trahn-trade-backend/go.mod @@ -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 +) diff --git a/trahn-trade-backend/go.sum b/trahn-trade-backend/go.sum new file mode 100644 index 0000000..c4473f4 --- /dev/null +++ b/trahn-trade-backend/go.sum @@ -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= diff --git a/trahn-trade-backend/internal/api/grid_routes.go b/trahn-trade-backend/internal/api/grid_routes.go new file mode 100644 index 0000000..d2c950e --- /dev/null +++ b/trahn-trade-backend/internal/api/grid_routes.go @@ -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, + }) +} diff --git a/trahn-trade-backend/internal/api/health.go b/trahn-trade-backend/internal/api/health.go new file mode 100644 index 0000000..e877721 --- /dev/null +++ b/trahn-trade-backend/internal/api/health.go @@ -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}, + }) +} diff --git a/trahn-trade-backend/internal/api/middleware_test.go b/trahn-trade-backend/internal/api/middleware_test.go new file mode 100644 index 0000000..b335470 --- /dev/null +++ b/trahn-trade-backend/internal/api/middleware_test.go @@ -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) + } +} diff --git a/trahn-trade-backend/internal/api/price_routes.go b/trahn-trade-backend/internal/api/price_routes.go new file mode 100644 index 0000000..d6e3ec5 --- /dev/null +++ b/trahn-trade-backend/internal/api/price_routes.go @@ -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}) +} diff --git a/trahn-trade-backend/internal/api/server.go b/trahn-trade-backend/internal/api/server.go new file mode 100644 index 0000000..6faa001 --- /dev/null +++ b/trahn-trade-backend/internal/api/server.go @@ -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}) +} diff --git a/trahn-trade-backend/internal/api/sr_routes.go b/trahn-trade-backend/internal/api/sr_routes.go new file mode 100644 index 0000000..3bd8d67 --- /dev/null +++ b/trahn-trade-backend/internal/api/sr_routes.go @@ -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) +} diff --git a/trahn-trade-backend/internal/api/trade_routes.go b/trahn-trade-backend/internal/api/trade_routes.go new file mode 100644 index 0000000..b946e86 --- /dev/null +++ b/trahn-trade-backend/internal/api/trade_routes.go @@ -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) +} diff --git a/trahn-trade-backend/internal/bot/gridbot.go b/trahn-trade-backend/internal/bot/gridbot.go new file mode 100644 index 0000000..0fb556a --- /dev/null +++ b/trahn-trade-backend/internal/bot/gridbot.go @@ -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 +} diff --git a/trahn-trade-backend/internal/bot/paper_wallet.go b/trahn-trade-backend/internal/bot/paper_wallet.go new file mode 100644 index 0000000..9784d1e --- /dev/null +++ b/trahn-trade-backend/internal/bot/paper_wallet.go @@ -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 diff --git a/trahn-trade-backend/internal/bot/service.go b/trahn-trade-backend/internal/bot/service.go new file mode 100644 index 0000000..359e861 --- /dev/null +++ b/trahn-trade-backend/internal/bot/service.go @@ -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) + } +} diff --git a/trahn-trade-backend/internal/config/config.go b/trahn-trade-backend/internal/config/config.go new file mode 100644 index 0000000..34560ce --- /dev/null +++ b/trahn-trade-backend/internal/config/config.go @@ -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 +} diff --git a/trahn-trade-backend/internal/db/connection.go b/trahn-trade-backend/internal/db/connection.go new file mode 100644 index 0000000..222f530 --- /dev/null +++ b/trahn-trade-backend/internal/db/connection.go @@ -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 +} diff --git a/trahn-trade-backend/internal/ethereum/abi.go b/trahn-trade-backend/internal/ethereum/abi.go new file mode 100644 index 0000000..76b8106 --- /dev/null +++ b/trahn-trade-backend/internal/ethereum/abi.go @@ -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"}] + } + ]`) +} diff --git a/trahn-trade-backend/internal/ethereum/client.go b/trahn-trade-backend/internal/ethereum/client.go new file mode 100644 index 0000000..bbc1062 --- /dev/null +++ b/trahn-trade-backend/internal/ethereum/client.go @@ -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 +} diff --git a/trahn-trade-backend/internal/ethereum/uniswap.go b/trahn-trade-backend/internal/ethereum/uniswap.go new file mode 100644 index 0000000..4eb38ef --- /dev/null +++ b/trahn-trade-backend/internal/ethereum/uniswap.go @@ -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 +} diff --git a/trahn-trade-backend/internal/external/coingecko.go b/trahn-trade-backend/internal/external/coingecko.go new file mode 100644 index 0000000..3f60fd5 --- /dev/null +++ b/trahn-trade-backend/internal/external/coingecko.go @@ -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 +} diff --git a/trahn-trade-backend/internal/external/dune.go b/trahn-trade-backend/internal/external/dune.go new file mode 100644 index 0000000..0addd48 --- /dev/null +++ b/trahn-trade-backend/internal/external/dune.go @@ -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() + } +} diff --git a/trahn-trade-backend/internal/external/external_test.go b/trahn-trade-backend/internal/external/external_test.go new file mode 100644 index 0000000..63e99c2 --- /dev/null +++ b/trahn-trade-backend/internal/external/external_test.go @@ -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)") +} diff --git a/trahn-trade-backend/internal/httputil/retry.go b/trahn-trade-backend/internal/httputil/retry.go new file mode 100644 index 0000000..2d04612 --- /dev/null +++ b/trahn-trade-backend/internal/httputil/retry.go @@ -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) +} diff --git a/trahn-trade-backend/internal/httputil/retry_test.go b/trahn-trade-backend/internal/httputil/retry_test.go new file mode 100644 index 0000000..57c9f84 --- /dev/null +++ b/trahn-trade-backend/internal/httputil/retry_test.go @@ -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) +} diff --git a/trahn-trade-backend/internal/models/gridstate.go b/trahn-trade-backend/internal/models/gridstate.go new file mode 100644 index 0000000..1414fc6 --- /dev/null +++ b/trahn-trade-backend/internal/models/gridstate.go @@ -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"` +} diff --git a/trahn-trade-backend/internal/models/price.go b/trahn-trade-backend/internal/models/price.go new file mode 100644 index 0000000..f085b2e --- /dev/null +++ b/trahn-trade-backend/internal/models/price.go @@ -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"` +} diff --git a/trahn-trade-backend/internal/models/sr.go b/trahn-trade-backend/internal/models/sr.go new file mode 100644 index 0000000..b8a6ceb --- /dev/null +++ b/trahn-trade-backend/internal/models/sr.go @@ -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 +} diff --git a/trahn-trade-backend/internal/models/trade.go b/trahn-trade-backend/internal/models/trade.go new file mode 100644 index 0000000..127d518 --- /dev/null +++ b/trahn-trade-backend/internal/models/trade.go @@ -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"` +} diff --git a/trahn-trade-backend/internal/notifications/webhook.go b/trahn-trade-backend/internal/notifications/webhook.go new file mode 100644 index 0000000..110654e --- /dev/null +++ b/trahn-trade-backend/internal/notifications/webhook.go @@ -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 != "" +} diff --git a/trahn-trade-backend/internal/notifications/webhook_test.go b/trahn-trade-backend/internal/notifications/webhook_test.go new file mode 100644 index 0000000..48ea7ac --- /dev/null +++ b/trahn-trade-backend/internal/notifications/webhook_test.go @@ -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) + } +} diff --git a/trahn-trade-backend/internal/repository/gridstate.go b/trahn-trade-backend/internal/repository/gridstate.go new file mode 100644 index 0000000..49776f0 --- /dev/null +++ b/trahn-trade-backend/internal/repository/gridstate.go @@ -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 +} diff --git a/trahn-trade-backend/internal/repository/price.go b/trahn-trade-backend/internal/repository/price.go new file mode 100644 index 0000000..021a2f3 --- /dev/null +++ b/trahn-trade-backend/internal/repository/price.go @@ -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() +} diff --git a/trahn-trade-backend/internal/repository/repo_test.go b/trahn-trade-backend/internal/repository/repo_test.go new file mode 100644 index 0000000..31bdfb3 --- /dev/null +++ b/trahn-trade-backend/internal/repository/repo_test.go @@ -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") +} diff --git a/trahn-trade-backend/internal/repository/sr.go b/trahn-trade-backend/internal/repository/sr.go new file mode 100644 index 0000000..5d8cbaa --- /dev/null +++ b/trahn-trade-backend/internal/repository/sr.go @@ -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() +} diff --git a/trahn-trade-backend/internal/repository/trade.go b/trahn-trade-backend/internal/repository/trade.go new file mode 100644 index 0000000..326a81e --- /dev/null +++ b/trahn-trade-backend/internal/repository/trade.go @@ -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() +} diff --git a/trahn-trade-backend/internal/repository/tradingday.go b/trahn-trade-backend/internal/repository/tradingday.go new file mode 100644 index 0000000..0584981 --- /dev/null +++ b/trahn-trade-backend/internal/repository/tradingday.go @@ -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()) +} diff --git a/trahn-trade-backend/internal/risk/guardian.go b/trahn-trade-backend/internal/risk/guardian.go new file mode 100644 index 0000000..6e190bd --- /dev/null +++ b/trahn-trade-backend/internal/risk/guardian.go @@ -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 +} diff --git a/trahn-trade-backend/internal/risk/guardian_test.go b/trahn-trade-backend/internal/risk/guardian_test.go new file mode 100644 index 0000000..b62771f --- /dev/null +++ b/trahn-trade-backend/internal/risk/guardian_test.go @@ -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%") + } +} diff --git a/trahn-trade-backend/internal/scheduler/sr.go b/trahn-trade-backend/internal/scheduler/sr.go new file mode 100644 index 0000000..1517479 --- /dev/null +++ b/trahn-trade-backend/internal/scheduler/sr.go @@ -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 +} diff --git a/trahn-trade-backend/internal/scheduler/sr_test.go b/trahn-trade-backend/internal/scheduler/sr_test.go new file mode 100644 index 0000000..d1c3fb4 --- /dev/null +++ b/trahn-trade-backend/internal/scheduler/sr_test.go @@ -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") +} diff --git a/trahn-trade-backend/internal/strategy/grid.go b/trahn-trade-backend/internal/strategy/grid.go new file mode 100644 index 0000000..90d93cb --- /dev/null +++ b/trahn-trade-backend/internal/strategy/grid.go @@ -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) +} diff --git a/trahn-trade-backend/internal/strategy/grid_test.go b/trahn-trade-backend/internal/strategy/grid_test.go new file mode 100644 index 0000000..d260cde --- /dev/null +++ b/trahn-trade-backend/internal/strategy/grid_test.go @@ -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) + } +} diff --git a/trahn-trade-backend/internal/testutil/db.go b/trahn-trade-backend/internal/testutil/db.go new file mode 100644 index 0000000..360f3d6 --- /dev/null +++ b/trahn-trade-backend/internal/testutil/db.go @@ -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 +} diff --git a/trahn-trade-backend/server b/trahn-trade-backend/server new file mode 100755 index 0000000..5cab3ae Binary files /dev/null and b/trahn-trade-backend/server differ diff --git a/trahn-trade-frontend/README.md b/trahn-trade-frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/trahn-trade-frontend/README.md @@ -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. diff --git a/trahn-trade-frontend/eslint.config.js b/trahn-trade-frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/trahn-trade-frontend/eslint.config.js @@ -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_]' }], + }, + }, +]) diff --git a/trahn-trade-frontend/index.html b/trahn-trade-frontend/index.html new file mode 100644 index 0000000..8bf5084 --- /dev/null +++ b/trahn-trade-frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + TRAHN Grid Trader + + + + +
+ + + diff --git a/trahn-trade-frontend/package.json b/trahn-trade-frontend/package.json new file mode 100644 index 0000000..6a77055 --- /dev/null +++ b/trahn-trade-frontend/package.json @@ -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" + } +} diff --git a/trahn-trade-frontend/src/App.css b/trahn-trade-frontend/src/App.css new file mode 100644 index 0000000..3fff0d5 --- /dev/null +++ b/trahn-trade-frontend/src/App.css @@ -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; +} diff --git a/trahn-trade-frontend/src/App.jsx b/trahn-trade-frontend/src/App.jsx new file mode 100644 index 0000000..efc21df --- /dev/null +++ b/trahn-trade-frontend/src/App.jsx @@ -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 ; + } + + return children; +} + +function AppRoutes() { + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} + +function App() { + return ( + + + + + + + + + + ); +} + +export default App; diff --git a/trahn-trade-frontend/src/api/tradingApi.js b/trahn-trade-frontend/src/api/tradingApi.js new file mode 100644 index 0000000..f1d1ba9 --- /dev/null +++ b/trahn-trade-frontend/src/api/tradingApi.js @@ -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(); + }, +}; + diff --git a/trahn-trade-frontend/src/assets/react.svg b/trahn-trade-frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/trahn-trade-frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/trahn-trade-frontend/src/auth/firebase.js b/trahn-trade-frontend/src/auth/firebase.js new file mode 100644 index 0000000..03ee791 --- /dev/null +++ b/trahn-trade-frontend/src/auth/firebase.js @@ -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; + diff --git a/trahn-trade-frontend/src/auth/secrets.example.js b/trahn-trade-frontend/src/auth/secrets.example.js new file mode 100644 index 0000000..f6b79df --- /dev/null +++ b/trahn-trade-frontend/src/auth/secrets.example.js @@ -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" +}; + diff --git a/trahn-trade-frontend/src/components/CarouselNav.jsx b/trahn-trade-frontend/src/components/CarouselNav.jsx new file mode 100644 index 0000000..0b420a1 --- /dev/null +++ b/trahn-trade-frontend/src/components/CarouselNav.jsx @@ -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 ( +
+ + +
+ {formatDate(currentDay)} + + {isLive ? 'LIVE' : '24h snapshot'} + +
+ + +
+ ); +} + +export function DayIndicators({ availableDays, currentDayIndex, onNavigateTo }) { + return ( +
+ {availableDays.map((day, i) => ( +
onNavigateTo(i)} + /> + ))} +
+ ); +} + +export function ConnectionStatus({ status }) { + const statusText = { + connected: 'Live', + waiting: 'Waiting for backend...', + connecting: 'Connecting...', + }; + + return ( +
+ + {statusText[status] || 'Unknown'} +
+ ); +} + diff --git a/trahn-trade-frontend/src/components/Chart.jsx b/trahn-trade-frontend/src/components/Chart.jsx new file mode 100644 index 0000000..649fa97 --- /dev/null +++ b/trahn-trade-frontend/src/components/Chart.jsx @@ -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 ( +
+ setTooltip(null)} + /> + {tooltip && ( +
+
+ ${tooltip.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ {new Date(tooltip.timestamp).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+
+ )} +
+ ); +} + +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(); +} + diff --git a/trahn-trade-frontend/src/components/ProtectedRoute.jsx b/trahn-trade-frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..a33a34a --- /dev/null +++ b/trahn-trade-frontend/src/components/ProtectedRoute.jsx @@ -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 ; + } + + return children; +} + diff --git a/trahn-trade-frontend/src/components/Stats.jsx b/trahn-trade-frontend/src/components/Stats.jsx new file mode 100644 index 0000000..304f308 --- /dev/null +++ b/trahn-trade-frontend/src/components/Stats.jsx @@ -0,0 +1,48 @@ +export function Stats({ prices, trades }) { + if (!prices || prices.length === 0) { + return ( +
+ + + + + +
+ ); + } + + 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 ( +
+ + + + + +
+ ); +} + +function Stat({ label, value, className = '' }) { + return ( +
+ {label} + {value} +
+ ); +} + diff --git a/trahn-trade-frontend/src/contexts/AuthContext.jsx b/trahn-trade-frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..275ebcc --- /dev/null +++ b/trahn-trade-frontend/src/contexts/AuthContext.jsx @@ -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 ( + + {!loading && children} + + ); +} + diff --git a/trahn-trade-frontend/src/contexts/TradingDataContext.jsx b/trahn-trade-frontend/src/contexts/TradingDataContext.jsx new file mode 100644 index 0000000..155ff6a --- /dev/null +++ b/trahn-trade-frontend/src/contexts/TradingDataContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useTradingData() { + const context = useContext(TradingDataContext); + if (!context) { + throw new Error('useTradingData must be used within TradingDataProvider'); + } + return context; +} + diff --git a/trahn-trade-frontend/src/hooks/useChartData.js b/trahn-trade-frontend/src/hooks/useChartData.js new file mode 100644 index 0000000..71a30e7 --- /dev/null +++ b/trahn-trade-frontend/src/hooks/useChartData.js @@ -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.'); +} diff --git a/trahn-trade-frontend/src/hooks/useTradingData.js b/trahn-trade-frontend/src/hooks/useTradingData.js new file mode 100644 index 0000000..c5ca334 --- /dev/null +++ b/trahn-trade-frontend/src/hooks/useTradingData.js @@ -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, + }; +} + diff --git a/trahn-trade-frontend/src/index.css b/trahn-trade-frontend/src/index.css new file mode 100644 index 0000000..ea1372d --- /dev/null +++ b/trahn-trade-frontend/src/index.css @@ -0,0 +1 @@ +/* Reset handled in App.css */ diff --git a/trahn-trade-frontend/src/main.jsx b/trahn-trade-frontend/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/trahn-trade-frontend/src/main.jsx @@ -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( + + + , +) diff --git a/trahn-trade-frontend/src/pages/Dashboard.jsx b/trahn-trade-frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..61d1f09 --- /dev/null +++ b/trahn-trade-frontend/src/pages/Dashboard.jsx @@ -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 ( +
+
+
{`
+   ████████╗██████╗  █████╗ ██╗  ██╗███╗   ██╗
+   ╚══██╔══╝██╔══██╗██╔══██╗██║  ██║████╗  ██║
+      ██║   ██████╔╝███████║███████║██╔██╗ ██║
+      ██║   ██╔══██╗██╔══██║██╔══██║██║╚██╗██║
+      ██║   ██║  ██║██║  ██║██║  ██║██║ ╚████║
+      ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝`}
+ +
+ + + +
+ + {loading && ( +
+
+ Waiting for data... +
+ )} +
+ + + + + +
+ + Buy (USDC → ETH) + + + Sell (ETH → USDC) + +
+ + {grid && grid.length > 0 && ( +
+
Grid Levels
+
+
+
Buy Levels
+ {grid + .filter(level => level.side === 'buy') + .sort((a, b) => b.price - a.price) + .map((level, idx) => ( +
+ ${level.price.toFixed(2)} +
+ ))} +
+
+
Sell Levels
+ {grid + .filter(level => level.side === 'sell') + .sort((a, b) => a.price - b.price) + .map((level, idx) => ( +
+ ${level.price.toFixed(2)} +
+ ))} +
+
+
+ )} + + +
+ ); +} + diff --git a/trahn-trade-frontend/src/pages/Login.jsx b/trahn-trade-frontend/src/pages/Login.jsx new file mode 100644 index 0000000..9f74cc9 --- /dev/null +++ b/trahn-trade-frontend/src/pages/Login.jsx @@ -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 ( +
+
+
{`
+   ████████╗██████╗  █████╗ ██╗  ██╗███╗   ██╗
+   ╚══██╔══╝██╔══██╗██╔══██╗██║  ██║████╗  ██║
+      ██║   ██████╔╝███████║███████║██╔██╗ ██║
+      ██║   ██╔══██╗██╔══██║██╔══██║██║╚██╗██║
+      ██║   ██║  ██║██║  ██║██║  ██║██║ ╚████║
+      ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝`}
+ +

Welcome Back

+ + {successMessage &&
{successMessage}
} + {error &&
{error}
} + +
+
+ + setEmail(e.target.value)} + required + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="••••••••" + /> +
+ + +
+ +

+ Don't have an account? Sign Up +

+
+
+ ); +} + diff --git a/trahn-trade-frontend/src/pages/Signup.jsx b/trahn-trade-frontend/src/pages/Signup.jsx new file mode 100644 index 0000000..64e9988 --- /dev/null +++ b/trahn-trade-frontend/src/pages/Signup.jsx @@ -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 ( +
+
+
{`
+   ████████╗██████╗  █████╗ ██╗  ██╗███╗   ██╗
+   ╚══██╔══╝██╔══██╗██╔══██╗██║  ██║████╗  ██║
+      ██║   ██████╔╝███████║███████║██╔██╗ ██║
+      ██║   ██╔══██╗██╔══██║██╔══██║██║╚██╗██║
+      ██║   ██║  ██║██║  ██║██║  ██║██║ ╚████║
+      ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝`}
+ + {submitted ? ( + <> +

Request Received

+
+

+ 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. +

+

Thank you for your interest!

+
+

+ Already have an account? Log In +

+ + ) : ( + <> +

Join Waitlist

+ + {error &&
{error}
} + +
+
+ + setEmail(e.target.value)} + required + placeholder="you@example.com" + /> +
+ + +
+ +

+ Already have an account? Log In +

+ + )} +
+
+ ); +} diff --git a/trahn-trade-frontend/vite.config.js b/trahn-trade-frontend/vite.config.js new file mode 100644 index 0000000..525c222 --- /dev/null +++ b/trahn-trade-frontend/vite.config.js @@ -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', +})