Initial commit: BTC Bot with dashboard, TA analysis, and 14 timeframes
This commit is contained in:
842
src/api/dashboard/static/index.html
Normal file
842
src/api/dashboard/static/index.html
Normal file
@ -0,0 +1,842 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BTC Trading Dashboard</title>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.1.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--tv-bg: #131722;
|
||||
--tv-panel-bg: #1e222d;
|
||||
--tv-border: #2a2e39;
|
||||
--tv-text: #d1d4dc;
|
||||
--tv-text-secondary: #787b86;
|
||||
--tv-green: #26a69a;
|
||||
--tv-red: #ef5350;
|
||||
--tv-blue: #2962ff;
|
||||
--tv-hover: #2a2e39;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--tv-bg);
|
||||
color: var(--tv-text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: var(--tv-panel-bg);
|
||||
border-bottom: 1px solid var(--tv-border);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.symbol-badge {
|
||||
background: var(--tv-bg);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--tv-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeframe-scroll {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tv-border) transparent;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeframe-scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.timeframe-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.timeframe-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--tv-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.timeframe-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--tv-text-secondary);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.timeframe-btn:hover {
|
||||
background: var(--tv-hover);
|
||||
color: var(--tv-text);
|
||||
}
|
||||
|
||||
.timeframe-btn.active {
|
||||
background: var(--tv-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--tv-green);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: var(--tv-text-secondary);
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
background: var(--tv-panel-bg);
|
||||
border-bottom: 1px solid var(--tv-border);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--tv-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value.positive { color: var(--tv-green); }
|
||||
.stat-value.negative { color: var(--tv-red); }
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
flex: 2;
|
||||
position: relative;
|
||||
background: var(--tv-bg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ta-panel {
|
||||
flex: 1;
|
||||
background: var(--tv-panel-bg);
|
||||
border-top: 1px solid var(--tv-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.ta-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--tv-border);
|
||||
background: var(--tv-bg);
|
||||
}
|
||||
|
||||
.ta-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ta-interval {
|
||||
background: var(--tv-blue);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ta-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ta-btn {
|
||||
background: var(--tv-bg);
|
||||
border: 1px solid var(--tv-border);
|
||||
color: var(--tv-text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ta-btn:hover {
|
||||
background: var(--tv-hover);
|
||||
border-color: var(--tv-blue);
|
||||
}
|
||||
|
||||
.ta-btn.ai-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ta-btn.ai-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ta-last-update {
|
||||
font-size: 11px;
|
||||
color: var(--tv-text-secondary);
|
||||
}
|
||||
|
||||
.ta-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ta-section {
|
||||
background: var(--tv-bg);
|
||||
border: 1px solid var(--tv-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ta-section-title {
|
||||
font-size: 11px;
|
||||
color: var(--tv-text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ta-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ta-trend.bullish { color: var(--tv-green); }
|
||||
.ta-trend.bearish { color: var(--tv-red); }
|
||||
.ta-trend.neutral { color: var(--tv-text-secondary); }
|
||||
|
||||
.ta-strength {
|
||||
font-size: 12px;
|
||||
color: var(--tv-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ta-signal {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ta-signal.buy {
|
||||
background: rgba(38, 166, 154, 0.2);
|
||||
color: var(--tv-green);
|
||||
}
|
||||
|
||||
.ta-signal.sell {
|
||||
background: rgba(239, 83, 80, 0.2);
|
||||
color: var(--tv-red);
|
||||
}
|
||||
|
||||
.ta-signal.hold {
|
||||
background: rgba(120, 123, 134, 0.2);
|
||||
color: var(--tv-text-secondary);
|
||||
}
|
||||
|
||||
.ta-ma-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--tv-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ta-ma-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ta-ma-label {
|
||||
color: var(--tv-text-secondary);
|
||||
}
|
||||
|
||||
.ta-ma-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ta-ma-change {
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ta-ma-change.positive { color: var(--tv-green); }
|
||||
.ta-ma-change.negative { color: var(--tv-red); }
|
||||
|
||||
.ta-level {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ta-level-label {
|
||||
color: var(--tv-text-secondary);
|
||||
}
|
||||
|
||||
.ta-level-value {
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.ta-position-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--tv-border);
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ta-position-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--tv-blue);
|
||||
border-radius: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid var(--tv-bg);
|
||||
}
|
||||
|
||||
.ta-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--tv-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ta-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--tv-red);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.ta-content {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ta-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="symbol-badge">BTC/USD</span>
|
||||
|
||||
<div class="timeframe-scroll" id="timeframeContainer">
|
||||
<!-- Timeframes will be inserted here by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span class="status-text" id="statusText">Live</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-panel">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Price</span>
|
||||
<span class="stat-value" id="currentPrice">--</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Change</span>
|
||||
<span class="stat-value" id="priceChange">--</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">High</span>
|
||||
<span class="stat-value" id="dailyHigh">--</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Low</span>
|
||||
<span class="stat-value" id="dailyLow">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="chart-wrapper">
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="ta-panel" id="taPanel">
|
||||
<div class="ta-header">
|
||||
<div class="ta-title">
|
||||
Technical Analysis
|
||||
<span class="ta-interval" id="taInterval">1D</span>
|
||||
</div>
|
||||
<div class="ta-actions">
|
||||
<span class="ta-last-update" id="taLastUpdate">--</span>
|
||||
<button class="ta-btn ai-btn" id="aiBtn" onclick="openAIAnalysis()">
|
||||
🤖 AI Analysis
|
||||
</button>
|
||||
<button class="ta-btn" onclick="refreshTA()">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ta-content" id="taContent">
|
||||
<div class="ta-loading">Loading technical analysis...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class TradingDashboard {
|
||||
constructor() {
|
||||
this.chart = null;
|
||||
this.candleSeries = null;
|
||||
this.currentInterval = '1d';
|
||||
this.intervals = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '3d', '1w', '1M'];
|
||||
this.allData = new Map();
|
||||
this.isLoading = false;
|
||||
this.hasInitialLoad = false;
|
||||
this.taData = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createTimeframeButtons();
|
||||
this.initChart();
|
||||
this.initEventListeners();
|
||||
this.loadInitialData();
|
||||
this.loadTA();
|
||||
|
||||
setInterval(() => this.loadNewData(), 15000);
|
||||
}
|
||||
|
||||
createTimeframeButtons() {
|
||||
const container = document.getElementById('timeframeContainer');
|
||||
this.intervals.forEach(interval => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'timeframe-btn';
|
||||
btn.dataset.interval = interval;
|
||||
btn.textContent = interval;
|
||||
if (interval === this.currentInterval) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
btn.addEventListener('click', () => this.switchTimeframe(interval));
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
initChart() {
|
||||
const chartContainer = document.getElementById('chart');
|
||||
|
||||
this.chart = LightweightCharts.createChart(chartContainer, {
|
||||
layout: {
|
||||
background: { color: '#131722' },
|
||||
textColor: '#d1d4dc',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#2a2e39' },
|
||||
horzLines: { color: '#2a2e39' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#2a2e39',
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#2a2e39',
|
||||
timeVisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.candleSeries = this.chart.addCandlestickSeries({
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderUpColor: '#26a69a',
|
||||
borderDownColor: '#ef5350',
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350',
|
||||
});
|
||||
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.onVisibleRangeChange.bind(this));
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
this.chart.applyOptions({
|
||||
width: chartContainer.clientWidth,
|
||||
height: chartContainer.clientHeight,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
|
||||
|
||||
const shortcuts = {
|
||||
'1': '1m', '2': '3m', '3': '5m', '4': '15m', '5': '30m',
|
||||
'6': '1h', '7': '2h', '8': '4h', '9': '8h', '0': '12h',
|
||||
'd': '1d', 'D': '1d', 'w': '1w', 'W': '1w', 'm': '1M', 'M': '1M'
|
||||
};
|
||||
|
||||
if (shortcuts[e.key]) {
|
||||
this.switchTimeframe(shortcuts[e.key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
await this.loadData(500, true);
|
||||
this.hasInitialLoad = true;
|
||||
}
|
||||
|
||||
async loadData(limit = 500, fitToContent = false) {
|
||||
if (this.isLoading) return;
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
|
||||
|
||||
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=${limit}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.candles && data.candles.length > 0) {
|
||||
const chartData = data.candles.reverse().map(c => ({
|
||||
time: Math.floor(new Date(c.time).getTime() / 1000),
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close)
|
||||
}));
|
||||
|
||||
const existingData = this.allData.get(this.currentInterval) || [];
|
||||
const mergedData = this.mergeData(existingData, chartData);
|
||||
this.allData.set(this.currentInterval, mergedData);
|
||||
|
||||
this.candleSeries.setData(mergedData);
|
||||
|
||||
if (fitToContent) {
|
||||
this.chart.timeScale().fitContent();
|
||||
} else if (visibleRange) {
|
||||
this.chart.timeScale().setVisibleLogicalRange(visibleRange);
|
||||
}
|
||||
|
||||
const latest = mergedData[mergedData.length - 1];
|
||||
this.updateStats(latest);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadNewData() {
|
||||
if (!this.hasInitialLoad) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=100`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.candles && data.candles.length > 0) {
|
||||
const chartData = data.candles.reverse().map(c => ({
|
||||
time: Math.floor(new Date(c.time).getTime() / 1000),
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close)
|
||||
}));
|
||||
|
||||
const existingData = this.allData.get(this.currentInterval) || [];
|
||||
const mergedData = this.mergeData(existingData, chartData);
|
||||
this.allData.set(this.currentInterval, mergedData);
|
||||
|
||||
this.candleSeries.setData(mergedData);
|
||||
|
||||
const latest = mergedData[mergedData.length - 1];
|
||||
this.updateStats(latest);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading new data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
mergeData(existing, newData) {
|
||||
const dataMap = new Map();
|
||||
existing.forEach(c => dataMap.set(c.time, c));
|
||||
newData.forEach(c => dataMap.set(c.time, c));
|
||||
return Array.from(dataMap.values()).sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
onVisibleRangeChange() {
|
||||
if (!this.hasInitialLoad || this.isLoading) return;
|
||||
|
||||
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
|
||||
if (!visibleRange) return;
|
||||
|
||||
const data = this.candleSeries.data();
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
if (visibleRange.from < 10) {
|
||||
const oldestCandle = data[0];
|
||||
if (oldestCandle) {
|
||||
this.loadHistoricalData(oldestCandle.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadHistoricalData(beforeTime) {
|
||||
if (this.isLoading) return;
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const endTime = new Date(beforeTime * 1000);
|
||||
const startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&start=${startTime.toISOString()}&end=${endTime.toISOString()}&limit=500`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.candles && data.candles.length > 0) {
|
||||
const chartData = data.candles.reverse().map(c => ({
|
||||
time: Math.floor(new Date(c.time).getTime() / 1000),
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close)
|
||||
}));
|
||||
|
||||
const existingData = this.allData.get(this.currentInterval) || [];
|
||||
const mergedData = this.mergeData(existingData, chartData);
|
||||
this.allData.set(this.currentInterval, mergedData);
|
||||
|
||||
this.candleSeries.setData(mergedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading historical data:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadTA() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ta?symbol=BTC&interval=${this.currentInterval}`);
|
||||
this.taData = await response.json();
|
||||
this.renderTA();
|
||||
} catch (error) {
|
||||
console.error('Error loading TA:', error);
|
||||
document.getElementById('taContent').innerHTML = '<div class="ta-error">Failed to load technical analysis</div>';
|
||||
}
|
||||
}
|
||||
|
||||
renderTA() {
|
||||
if (!this.taData || this.taData.error) {
|
||||
document.getElementById('taContent').innerHTML = `<div class="ta-error">${this.taData?.error || 'No data available'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = this.taData;
|
||||
const trendClass = data.trend.direction.toLowerCase();
|
||||
const signalClass = data.trend.signal.toLowerCase();
|
||||
|
||||
const ma44Change = data.moving_averages.price_vs_ma44;
|
||||
const ma125Change = data.moving_averages.price_vs_ma125;
|
||||
|
||||
document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase();
|
||||
document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
|
||||
document.getElementById('taContent').innerHTML = `
|
||||
<div class="ta-section">
|
||||
<div class="ta-section-title">Trend Analysis</div>
|
||||
<div class="ta-trend ${trendClass}">
|
||||
${data.trend.direction} ${trendClass === 'bullish' ? '↑' : trendClass === 'bearish' ? '↓' : '→'}
|
||||
</div>
|
||||
<div class="ta-strength">${data.trend.strength}</div>
|
||||
<span class="ta-signal ${signalClass}">${data.trend.signal}</span>
|
||||
</div>
|
||||
|
||||
<div class="ta-section">
|
||||
<div class="ta-section-title">Moving Averages</div>
|
||||
<div class="ta-ma-row">
|
||||
<span class="ta-ma-label">MA 44</span>
|
||||
<span class="ta-ma-value">
|
||||
${data.moving_averages.ma_44 ? data.moving_averages.ma_44.toFixed(2) : 'N/A'}
|
||||
${ma44Change !== null ? `<span class="ta-ma-change ${ma44Change >= 0 ? 'positive' : 'negative'}">${ma44Change >= 0 ? '+' : ''}${ma44Change.toFixed(1)}%</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ta-ma-row">
|
||||
<span class="ta-ma-label">MA 125</span>
|
||||
<span class="ta-ma-value">
|
||||
${data.moving_averages.ma_125 ? data.moving_averages.ma_125.toFixed(2) : 'N/A'}
|
||||
${ma125Change !== null ? `<span class="ta-ma-change ${ma125Change >= 0 ? 'positive' : 'negative'}">${ma125Change >= 0 ? '+' : ''}${ma125Change.toFixed(1)}%</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-section">
|
||||
<div class="ta-section-title">Key Levels</div>
|
||||
<div class="ta-level">
|
||||
<span class="ta-level-label">Resistance</span>
|
||||
<span class="ta-level-value">$${data.levels.resistance.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ta-level">
|
||||
<span class="ta-level-label">Support</span>
|
||||
<span class="ta-level-value">$${data.levels.support.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ta-position-bar">
|
||||
<div class="ta-position-marker" style="left: ${data.levels.position_in_range}%"></div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--tv-text-secondary); margin-top: 4px; text-align: center;">
|
||||
Position in range: ${data.levels.position_in_range.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-section">
|
||||
<div class="ta-section-title">Price Info</div>
|
||||
<div class="ta-level">
|
||||
<span class="ta-level-label">Current</span>
|
||||
<span class="ta-level-value">$${data.current_price.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--tv-text-secondary); margin-top: 8px;">
|
||||
Based on last 200 candles<br>
|
||||
Strategy: Trend following with MA crossovers
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateStats(candle) {
|
||||
const price = candle.close;
|
||||
const change = ((price - candle.open) / candle.open * 100);
|
||||
|
||||
document.getElementById('currentPrice').textContent = price.toFixed(2);
|
||||
document.getElementById('currentPrice').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
|
||||
document.getElementById('priceChange').textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
|
||||
document.getElementById('priceChange').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
|
||||
document.getElementById('dailyHigh').textContent = candle.high.toFixed(2);
|
||||
document.getElementById('dailyLow').textContent = candle.low.toFixed(2);
|
||||
}
|
||||
|
||||
switchTimeframe(interval) {
|
||||
if (!this.intervals.includes(interval) || interval === this.currentInterval) return;
|
||||
|
||||
this.currentInterval = interval;
|
||||
this.hasInitialLoad = false;
|
||||
|
||||
document.querySelectorAll('.timeframe-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.interval === interval);
|
||||
});
|
||||
|
||||
this.allData.delete(interval);
|
||||
this.loadInitialData();
|
||||
this.loadTA();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshTA() {
|
||||
if (window.dashboard) {
|
||||
window.dashboard.loadTA();
|
||||
}
|
||||
}
|
||||
|
||||
function openAIAnalysis() {
|
||||
const symbol = 'BTC';
|
||||
const interval = window.dashboard?.currentInterval || '1d';
|
||||
const prompt = `Analyze Bitcoin (${symbol}) ${interval} chart. Current trend, support/resistance levels, and trading recommendation. Technical indicators: MA44, MA125.`;
|
||||
|
||||
const geminiUrl = `https://gemini.google.com/app?prompt=${encodeURIComponent(prompt)}`;
|
||||
window.open(geminiUrl, '_blank');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.dashboard = new TradingDashboard();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
376
src/api/server.py
Normal file
376
src/api/server.py
Normal file
@ -0,0 +1,376 @@
|
||||
"""
|
||||
Simplified FastAPI server - working version
|
||||
Removes the complex WebSocket manager that was causing issues
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncpg
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Database connection settings
|
||||
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||
DB_PORT = int(os.getenv('DB_PORT', 5432))
|
||||
DB_NAME = os.getenv('DB_NAME', 'btc_data')
|
||||
DB_USER = os.getenv('DB_USER', 'btc_bot')
|
||||
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
|
||||
|
||||
|
||||
async def get_db_pool():
|
||||
"""Create database connection pool"""
|
||||
return await asyncpg.create_pool(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
database=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
min_size=1,
|
||||
max_size=10
|
||||
)
|
||||
|
||||
|
||||
pool = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifespan"""
|
||||
global pool
|
||||
pool = await get_db_pool()
|
||||
logger.info("API Server started successfully")
|
||||
yield
|
||||
if pool:
|
||||
await pool.close()
|
||||
logger.info("API Server stopped")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="BTC Bot Data API",
|
||||
description="REST API for accessing BTC candle data",
|
||||
version="1.1.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Enable CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "BTC Bot Data API",
|
||||
"docs": "/docs",
|
||||
"dashboard": "/dashboard",
|
||||
"status": "operational"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/candles")
|
||||
async def get_candles(
|
||||
symbol: str = Query("BTC", description="Trading pair symbol"),
|
||||
interval: str = Query("1m", description="Candle interval"),
|
||||
start: Optional[datetime] = Query(None, description="Start time (ISO format)"),
|
||||
end: Optional[datetime] = Query(None, description="End time (ISO format)"),
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Maximum number of candles")
|
||||
):
|
||||
"""Get candle data for a symbol"""
|
||||
async with pool.acquire() as conn:
|
||||
query = """
|
||||
SELECT time, symbol, interval, open, high, low, close, volume, validated
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = $2
|
||||
"""
|
||||
params = [symbol, interval]
|
||||
|
||||
if start:
|
||||
query += f" AND time >= ${len(params) + 1}"
|
||||
params.append(start)
|
||||
|
||||
if end:
|
||||
query += f" AND time <= ${len(params) + 1}"
|
||||
params.append(end)
|
||||
|
||||
query += f" ORDER BY time DESC LIMIT ${len(params) + 1}"
|
||||
params.append(limit)
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"count": len(rows),
|
||||
"candles": [dict(row) for row in rows]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/candles/latest")
|
||||
async def get_latest_candle(symbol: str = "BTC", interval: str = "1m"):
|
||||
"""Get the most recent candle"""
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT time, symbol, interval, open, high, low, close, volume
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = $2
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
""", symbol, interval)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="No data found")
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
@app.get("/api/v1/stats")
|
||||
async def get_stats(symbol: str = "BTC"):
|
||||
"""Get trading statistics"""
|
||||
async with pool.acquire() as conn:
|
||||
# Get latest price and 24h stats
|
||||
latest = await conn.fetchrow("""
|
||||
SELECT close, time
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = '1m'
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
""", symbol)
|
||||
|
||||
day_ago = await conn.fetchrow("""
|
||||
SELECT close
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = '1m' AND time <= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
""", symbol)
|
||||
|
||||
stats_24h = await conn.fetchrow("""
|
||||
SELECT
|
||||
MAX(high) as high_24h,
|
||||
MIN(low) as low_24h,
|
||||
SUM(volume) as volume_24h
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = '1m' AND time > NOW() - INTERVAL '24 hours'
|
||||
""", symbol)
|
||||
|
||||
if not latest:
|
||||
raise HTTPException(status_code=404, detail="No data found")
|
||||
|
||||
current_price = float(latest['close'])
|
||||
previous_price = float(day_ago['close']) if day_ago else current_price
|
||||
change_24h = ((current_price - previous_price) / previous_price * 100) if previous_price else 0
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"current_price": current_price,
|
||||
"change_24h": round(change_24h, 2),
|
||||
"high_24h": float(stats_24h['high_24h']) if stats_24h['high_24h'] else current_price,
|
||||
"low_24h": float(stats_24h['low_24h']) if stats_24h['low_24h'] else current_price,
|
||||
"volume_24h": float(stats_24h['volume_24h']) if stats_24h['volume_24h'] else 0,
|
||||
"last_update": latest['time'].isoformat()
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
"""System health check"""
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
latest = await conn.fetchrow("""
|
||||
SELECT symbol, MAX(time) as last_time, COUNT(*) as count
|
||||
FROM candles
|
||||
WHERE time > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY symbol
|
||||
""")
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"latest_candles": dict(latest) if latest else None,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Health check failed: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/v1/ta")
|
||||
async def get_technical_analysis(
|
||||
symbol: str = Query("BTC", description="Trading pair symbol"),
|
||||
interval: str = Query("1d", description="Candle interval")
|
||||
):
|
||||
"""
|
||||
Get technical analysis for a symbol
|
||||
Calculates MA 44, MA 125, trend, support/resistance
|
||||
"""
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
# Get enough candles for MA 125 calculation
|
||||
rows = await conn.fetch("""
|
||||
SELECT time, open, high, low, close, volume
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = $2
|
||||
ORDER BY time DESC
|
||||
LIMIT 200
|
||||
""", symbol, interval)
|
||||
|
||||
if len(rows) < 50:
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"error": "Not enough data for technical analysis",
|
||||
"min_required": 50,
|
||||
"available": len(rows)
|
||||
}
|
||||
|
||||
# Reverse to chronological order
|
||||
candles = list(reversed(rows))
|
||||
closes = [float(c['close']) for c in candles]
|
||||
|
||||
# Calculate Moving Averages
|
||||
def calculate_ma(data, period):
|
||||
if len(data) < period:
|
||||
return None
|
||||
return sum(data[-period:]) / period
|
||||
|
||||
ma_44 = calculate_ma(closes, 44)
|
||||
ma_125 = calculate_ma(closes, 125)
|
||||
|
||||
current_price = closes[-1]
|
||||
|
||||
# Determine trend
|
||||
if ma_44 and ma_125:
|
||||
if current_price > ma_44 > ma_125:
|
||||
trend = "Bullish"
|
||||
trend_strength = "Strong" if current_price > ma_44 * 1.05 else "Moderate"
|
||||
elif current_price < ma_44 < ma_125:
|
||||
trend = "Bearish"
|
||||
trend_strength = "Strong" if current_price < ma_44 * 0.95 else "Moderate"
|
||||
else:
|
||||
trend = "Neutral"
|
||||
trend_strength = "Consolidation"
|
||||
else:
|
||||
trend = "Unknown"
|
||||
trend_strength = "Insufficient data"
|
||||
|
||||
# Find support and resistance (recent swing points)
|
||||
highs = [float(c['high']) for c in candles[-20:]]
|
||||
lows = [float(c['low']) for c in candles[-20:]]
|
||||
|
||||
resistance = max(highs)
|
||||
support = min(lows)
|
||||
|
||||
# Calculate price position
|
||||
price_range = resistance - support
|
||||
if price_range > 0:
|
||||
position = (current_price - support) / price_range * 100
|
||||
else:
|
||||
position = 50
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"current_price": round(current_price, 2),
|
||||
"moving_averages": {
|
||||
"ma_44": round(ma_44, 2) if ma_44 else None,
|
||||
"ma_125": round(ma_125, 2) if ma_125 else None,
|
||||
"price_vs_ma44": round((current_price / ma_44 - 1) * 100, 2) if ma_44 else None,
|
||||
"price_vs_ma125": round((current_price / ma_125 - 1) * 100, 2) if ma_125 else None
|
||||
},
|
||||
"trend": {
|
||||
"direction": trend,
|
||||
"strength": trend_strength,
|
||||
"signal": "Buy" if trend == "Bullish" and trend_strength == "Strong" else
|
||||
"Sell" if trend == "Bearish" and trend_strength == "Strong" else "Hold"
|
||||
},
|
||||
"levels": {
|
||||
"resistance": round(resistance, 2),
|
||||
"support": round(support, 2),
|
||||
"position_in_range": round(position, 1)
|
||||
},
|
||||
"ai_placeholder": {
|
||||
"available": False,
|
||||
"message": "AI analysis available via Gemini or local LLM",
|
||||
"action": "Click to analyze with AI"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Technical analysis error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Technical analysis failed: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/v1/export/csv")
|
||||
async def export_csv(
|
||||
symbol: str = "BTC",
|
||||
interval: str = "1m",
|
||||
days: int = Query(7, ge=1, le=365, description="Number of days to export")
|
||||
):
|
||||
"""Export candle data to CSV"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
query = """
|
||||
SELECT time, open, high, low, close, volume
|
||||
FROM candles
|
||||
WHERE symbol = $1 AND interval = $2 AND time >= $3
|
||||
ORDER BY time
|
||||
"""
|
||||
rows = await conn.fetch(query, symbol, interval, start_date)
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="No data found for export")
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
||||
|
||||
for row in rows:
|
||||
writer.writerow([
|
||||
row['time'].isoformat(),
|
||||
row['open'],
|
||||
row['high'],
|
||||
row['low'],
|
||||
row['close'],
|
||||
row['volume']
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode()),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={symbol}_{interval}_{days}d.csv"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Serve static files for dashboard
|
||||
app.mount("/dashboard", StaticFiles(directory="src/api/dashboard/static", html=True), name="dashboard")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
Reference in New Issue
Block a user