From b72ef9e6dfa27f6f24a02a5ca20fceef436e734d Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Sun, 22 Mar 2026 22:18:48 +0800 Subject: [PATCH] feat(nofxi): K-line chart with TradingView lightweight-charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Candlestick chart + volume histogram - 8 trading pairs: BTC/ETH/SOL/BNB/XRP/DOGE/ADA/AVAX - 5 timeframes: 15m/1H/4H/1D/1W - Real-time price & 24h change display - Auto-refresh every 30s - Backend: /api/klines + /api/ticker (Binance futures API) - Dark trading terminal theme, responsive resize - Tab switching: Chat ↔ Chart --- nofxi/internal/interaction/kline.go | 126 ++++++++++++++ nofxi/internal/interaction/web.go | 3 + nofxi/web/index.html | 254 +++++++++++++++++++++++++++- 3 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 nofxi/internal/interaction/kline.go diff --git a/nofxi/internal/interaction/kline.go b/nofxi/internal/interaction/kline.go new file mode 100644 index 00000000..c0dcc5d7 --- /dev/null +++ b/nofxi/internal/interaction/kline.go @@ -0,0 +1,126 @@ +package interaction + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// KlineBar represents a single candlestick. +type KlineBar struct { + Time int64 `json:"time"` + Open float64 `json:"open"` + High float64 `json:"high"` + Low float64 `json:"low"` + Close float64 `json:"close"` + Volume float64 `json:"volume"` +} + +// fetchKlines gets kline data from Binance public API (no auth needed). +func fetchKlines(symbol, interval string, limit int) ([]KlineBar, error) { + if limit <= 0 { + limit = 200 + } + url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=%d", + symbol, interval, limit) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("fetch klines: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var raw [][]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse klines: %w", err) + } + + bars := make([]KlineBar, 0, len(raw)) + for _, k := range raw { + if len(k) < 6 { + continue + } + bar := KlineBar{ + Time: int64(k[0].(float64)) / 1000, // ms → seconds for lightweight-charts + } + bar.Open, _ = strconv.ParseFloat(k[1].(string), 64) + bar.High, _ = strconv.ParseFloat(k[2].(string), 64) + bar.Low, _ = strconv.ParseFloat(k[3].(string), 64) + bar.Close, _ = strconv.ParseFloat(k[4].(string), 64) + bar.Volume, _ = strconv.ParseFloat(k[5].(string), 64) + bars = append(bars, bar) + } + return bars, nil +} + +// RegisterKlineRoutes adds kline API endpoints to the mux. +func RegisterKlineRoutes(mux *http.ServeMux) { + // GET /api/klines?symbol=BTCUSDT&interval=1h&limit=200 + mux.HandleFunc("/api/klines", func(w http.ResponseWriter, r *http.Request) { + symbol := r.URL.Query().Get("symbol") + if symbol == "" { + symbol = "BTCUSDT" + } + interval := r.URL.Query().Get("interval") + if interval == "" { + interval = "1h" + } + limitStr := r.URL.Query().Get("limit") + limit := 200 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + if limit > 1000 { + limit = 1000 + } + + bars, err := fetchKlines(symbol, interval, limit) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + json.NewEncoder(w).Encode(bars) + }) + + // GET /api/ticker?symbol=BTCUSDT — current price + 24h change + mux.HandleFunc("/api/ticker", func(w http.ResponseWriter, r *http.Request) { + symbol := r.URL.Query().Get("symbol") + if symbol == "" { + symbol = "BTCUSDT" + } + + url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(body) + }) +} diff --git a/nofxi/internal/interaction/web.go b/nofxi/internal/interaction/web.go index 06018b99..17b1ca5c 100644 --- a/nofxi/internal/interaction/web.go +++ b/nofxi/internal/interaction/web.go @@ -140,6 +140,9 @@ func (w *WebServer) Start(ctx context.Context) error { }) }) + // Register kline/market data API + RegisterKlineRoutes(mux) + // Serve web UI static files if w.webDir != "" { if _, err := os.Stat(filepath.Join(w.webDir, "index.html")); err == nil { diff --git a/nofxi/web/index.html b/nofxi/web/index.html index 5f82aa0f..db9495c0 100644 --- a/nofxi/web/index.html +++ b/nofxi/web/index.html @@ -6,6 +6,7 @@ NOFXi — AI Trading Agent +