mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +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 {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>NOFXi — AI Trading Agent</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lightweight-charts@4.1.3/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
@@ -392,6 +393,91 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* === Chart Panel === */
|
||||
.chart-panel {
|
||||
flex: 1;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
.chart-panel.active { display: flex; }
|
||||
.chat-area.hidden { display: none; }
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg2);
|
||||
}
|
||||
.chart-symbol-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.chart-symbol {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.chart-price {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.chart-price.up { color: var(--green); }
|
||||
.chart-price.down { color: var(--red); }
|
||||
.chart-change {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.chart-change.up { color: var(--green); background: rgba(34,197,94,0.1); }
|
||||
.chart-change.down { color: var(--red); background: rgba(239,68,68,0.1); }
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tf-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.tf-btn:hover { color: var(--text); border-color: var(--border2); }
|
||||
.tf-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.symbol-select {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.chart-volume-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* === Mobile === */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
@@ -412,9 +498,9 @@
|
||||
<span>NOFXi</span>
|
||||
</div>
|
||||
<div class="nav-pills">
|
||||
<button class="nav-pill active">Chat</button>
|
||||
<button class="nav-pill" onclick="send('/positions')">Positions</button>
|
||||
<button class="nav-pill" onclick="send('/status')">Status</button>
|
||||
<button class="nav-pill active" onclick="switchTab('chat')">Chat</button>
|
||||
<button class="nav-pill" onclick="switchTab('chart')">Chart</button>
|
||||
<button class="nav-pill" onclick="send('/positions');switchTab('chat')">Positions</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -496,7 +582,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-area">
|
||||
<div class="chart-panel" id="chartPanel">
|
||||
<div class="chart-header">
|
||||
<div class="chart-symbol-group">
|
||||
<select class="symbol-select" id="symbolSelect" onchange="loadChart()">
|
||||
<option value="BTCUSDT">BTC/USDT</option>
|
||||
<option value="ETHUSDT">ETH/USDT</option>
|
||||
<option value="SOLUSDT">SOL/USDT</option>
|
||||
<option value="BNBUSDT">BNB/USDT</option>
|
||||
<option value="XRPUSDT">XRP/USDT</option>
|
||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
||||
<option value="ADAUSDT">ADA/USDT</option>
|
||||
<option value="AVAXUSDT">AVAX/USDT</option>
|
||||
</select>
|
||||
<span class="chart-price" id="chartPrice">--</span>
|
||||
<span class="chart-change" id="chartChange">--</span>
|
||||
</div>
|
||||
<div class="chart-controls">
|
||||
<button class="tf-btn" onclick="setTF('15m')">15m</button>
|
||||
<button class="tf-btn active" onclick="setTF('1h')">1H</button>
|
||||
<button class="tf-btn" onclick="setTF('4h')">4H</button>
|
||||
<button class="tf-btn" onclick="setTF('1d')">1D</button>
|
||||
<button class="tf-btn" onclick="setTF('1w')">1W</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container" id="chartContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="chat-area" id="chatPanel">
|
||||
<div class="messages" id="messages">
|
||||
<div class="msg bot">
|
||||
<div class="msg-avatar">⚡</div>
|
||||
@@ -614,6 +727,139 @@ async function send(text) {
|
||||
}
|
||||
|
||||
inputEl.focus();
|
||||
|
||||
// === Chart ===
|
||||
let chart = null;
|
||||
let candleSeries = null;
|
||||
let volumeSeries = null;
|
||||
let currentTF = '1h';
|
||||
let chartRefreshTimer = null;
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.nav-pill').forEach((p,i) => {
|
||||
p.classList.toggle('active', (tab==='chat'&&i===0)||(tab==='chart'&&i===1));
|
||||
});
|
||||
const chatPanel = document.getElementById('chatPanel');
|
||||
const chartPanel = document.getElementById('chartPanel');
|
||||
if (tab === 'chart') {
|
||||
chatPanel.classList.add('hidden');
|
||||
chartPanel.classList.add('active');
|
||||
if (!chart) initChart();
|
||||
loadChart();
|
||||
} else {
|
||||
chatPanel.classList.remove('hidden');
|
||||
chartPanel.classList.remove('active');
|
||||
if (chartRefreshTimer) clearInterval(chartRefreshTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
const container = document.getElementById('chartContainer');
|
||||
chart = LightweightCharts.createChart(container, {
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight,
|
||||
layout: {
|
||||
background: { color: '#09090b' },
|
||||
textColor: '#666680',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#1a1a24' },
|
||||
horzLines: { color: '#1a1a24' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
vertLine: { color: '#333345', labelBackgroundColor: '#252530' },
|
||||
horzLine: { color: '#333345', labelBackgroundColor: '#252530' },
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#252530',
|
||||
scaleMargins: { top: 0.1, bottom: 0.25 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#252530',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
});
|
||||
|
||||
candleSeries = chart.addCandlestickSeries({
|
||||
upColor: '#22c55e',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#22c55e',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
|
||||
volumeSeries = chart.addHistogramSeries({
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: 'volume',
|
||||
});
|
||||
chart.priceScale('volume').applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
|
||||
new ResizeObserver(() => {
|
||||
chart.applyOptions({ width: container.clientWidth, height: container.clientHeight });
|
||||
}).observe(container);
|
||||
}
|
||||
|
||||
function setTF(tf) {
|
||||
currentTF = tf;
|
||||
document.querySelectorAll('.tf-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.textContent.toLowerCase() === tf.toLowerCase() || b.textContent === tf.toUpperCase());
|
||||
});
|
||||
loadChart();
|
||||
}
|
||||
|
||||
async function loadChart() {
|
||||
const symbol = document.getElementById('symbolSelect').value;
|
||||
try {
|
||||
const [klineRes, tickerRes] = await Promise.all([
|
||||
fetch(`${API}/api/klines?symbol=${symbol}&interval=${currentTF}&limit=300`),
|
||||
fetch(`${API}/api/ticker?symbol=${symbol}`)
|
||||
]);
|
||||
const klines = await klineRes.json();
|
||||
const ticker = await tickerRes.json();
|
||||
|
||||
if (klines && klines.length > 0) {
|
||||
candleSeries.setData(klines.map(k => ({
|
||||
time: k.time,
|
||||
open: k.open,
|
||||
high: k.high,
|
||||
low: k.low,
|
||||
close: k.close,
|
||||
})));
|
||||
volumeSeries.setData(klines.map(k => ({
|
||||
time: k.time,
|
||||
value: k.volume,
|
||||
color: k.close >= k.open ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)',
|
||||
})));
|
||||
}
|
||||
|
||||
// Update price display
|
||||
if (ticker && ticker.lastPrice) {
|
||||
const price = parseFloat(ticker.lastPrice);
|
||||
const change = parseFloat(ticker.priceChangePercent);
|
||||
const priceEl = document.getElementById('chartPrice');
|
||||
const changeEl = document.getElementById('chartChange');
|
||||
|
||||
priceEl.textContent = '$' + price.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: price > 100 ? 2 : 4});
|
||||
priceEl.className = 'chart-price ' + (change >= 0 ? 'up' : 'down');
|
||||
|
||||
changeEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
|
||||
changeEl.className = 'chart-change ' + (change >= 0 ? 'up' : 'down');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load chart error:', e);
|
||||
}
|
||||
|
||||
// Auto refresh every 30s
|
||||
if (chartRefreshTimer) clearInterval(chartRefreshTimer);
|
||||
chartRefreshTimer = setInterval(loadChart, 30000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user