mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
feat(nofxi): K-line chart with TradingView lightweight-charts
- 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
This commit is contained in:
126
nofxi/internal/interaction/kline.go
Normal file
126
nofxi/internal/interaction/kline.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user