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:
shinchan-zhai
2026-03-22 22:18:48 +08:00
parent c6f078b5cb
commit b72ef9e6df
3 changed files with 379 additions and 4 deletions

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

View File

@@ -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 {

View File

@@ -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>