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() } }