initial commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Real-Time Chart — IBKR Dashboard{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="chart-section">
|
||||
<div id="controls">
|
||||
<div class="control-group">
|
||||
<input type="text" id="symbolInput" placeholder="Enter symbol (e.g. AAPL)" value="{{ symbol }}">
|
||||
<button onclick="subscribeSymbol()">Load Chart</button>
|
||||
</div>
|
||||
<div class="loading-indicator" id="loadingIndicator">Loading...</div>
|
||||
</div>
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const chart = LightweightCharts.createChart(document.getElementById('chart'), {
|
||||
width: document.getElementById('chart').clientWidth,
|
||||
height: 600,
|
||||
layout: { background: { color: '#ffffff' }, textColor: '#333' },
|
||||
grid: { vertLines: { color: '#f0f0f0' }, horzLines: { color: '#f0f0f0' } },
|
||||
timeScale: { timeVisible: true, secondsVisible: false }
|
||||
});
|
||||
const series = chart.addCandlestickSeries();
|
||||
|
||||
let eventSource = null;
|
||||
|
||||
function cleanupConnections() {
|
||||
if (eventSource) {
|
||||
console.log('Closing EventSource connection');
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeSymbol() {
|
||||
cleanupConnections();
|
||||
const symbol = document.getElementById("symbolInput").value.toUpperCase();
|
||||
document.getElementById("loadingIndicator").classList.add("show");
|
||||
document.getElementById("chart").classList.add("loading");
|
||||
|
||||
const historyResponse = await fetch(`/history?symbol=${symbol}`);
|
||||
const historicalData = await historyResponse.json();
|
||||
series.setData(historicalData);
|
||||
|
||||
await fetch("/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ symbol })
|
||||
});
|
||||
|
||||
eventSource = new EventSource("/stream");
|
||||
|
||||
eventSource.addEventListener("candle", (event) => {
|
||||
const candleData = JSON.parse(event.data);
|
||||
series.update(candleData);
|
||||
});
|
||||
|
||||
eventSource.addEventListener("error", (event) => {
|
||||
console.error('SSE Error:', event);
|
||||
});
|
||||
|
||||
eventSource.addEventListener("open", () => {
|
||||
console.log('SSE Connection Opened');
|
||||
document.getElementById("loadingIndicator").classList.remove("show");
|
||||
document.getElementById("chart").classList.remove("loading");
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const symbol = urlParams.get('symbol');
|
||||
if (symbol) {
|
||||
document.getElementById("symbolInput").value = symbol;
|
||||
subscribeSymbol();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
chart.applyOptions({ width: document.getElementById('chart').clientWidth });
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', cleanupConnections);
|
||||
window.addEventListener('pagehide', cleanupConnections);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IBKR Trading Dashboard{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-container">
|
||||
<div class="nav-brand">
|
||||
<a href="/">IBKR Dashboard</a>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Real-Time Chart</a></li>
|
||||
<li><a href="/scanner">Market Scanner</a></li>
|
||||
<li><a href="/tradelog">Trade Log</a></li>
|
||||
<li><a href="/portfolio">Portfolio</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Portfolio — IBKR Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Portfolio</h1>
|
||||
|
||||
<div class="portfolio-summary" id="pnlCards">
|
||||
<div class="summary-card">
|
||||
<div class="card-label">Net Liquidation</div>
|
||||
<div class="card-value" id="netLiq">—</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-label">Total Cash</div>
|
||||
<div class="card-value" id="totalCash">—</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-label">Unrealized P&L</div>
|
||||
<div class="card-value" id="unrealizedPnl">—</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-label">Realized P&L</div>
|
||||
<div class="card-value" id="realizedPnl">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-container" style="margin-top: 24px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Position</th>
|
||||
<th>Avg Cost</th>
|
||||
<th>Chart</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="positionsBody">
|
||||
<tr><td colspan="4" class="empty-state">Loading positions...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function fmt(val) {
|
||||
if (val === undefined || val === null) return '—';
|
||||
return '$' + parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function colorClass(val) {
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n)) return '';
|
||||
return n >= 0 ? 'positive' : 'negative';
|
||||
}
|
||||
|
||||
async function loadPnl() {
|
||||
try {
|
||||
const res = await fetch('/portfolio/pnl');
|
||||
const d = await res.json();
|
||||
document.getElementById('netLiq').textContent = fmt(d.NetLiquidation);
|
||||
document.getElementById('totalCash').textContent = fmt(d.TotalCashValue);
|
||||
|
||||
const upEl = document.getElementById('unrealizedPnl');
|
||||
upEl.textContent = fmt(d.UnrealizedPnL);
|
||||
upEl.className = 'card-value ' + colorClass(d.UnrealizedPnL);
|
||||
|
||||
const rpEl = document.getElementById('realizedPnl');
|
||||
rpEl.textContent = fmt(d.RealizedPnL);
|
||||
rpEl.className = 'card-value ' + colorClass(d.RealizedPnL);
|
||||
} catch (e) {
|
||||
console.error('PnL fetch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPositions() {
|
||||
try {
|
||||
const res = await fetch('/portfolio/data');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('positionsBody');
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No open positions.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.map(p => `
|
||||
<tr>
|
||||
<td><a class="symbol-link" href="/?symbol=${p.symbol}">${p.symbol}</a></td>
|
||||
<td>${p.position}</td>
|
||||
<td class="price-cell">${fmt(p.avg_cost)}</td>
|
||||
<td><a class="symbol-link" href="/?symbol=${p.symbol}">View Chart</a></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
console.error('Positions fetch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
loadPnl();
|
||||
loadPositions();
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,215 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Market Scanner — IBKR Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="scanner-container">
|
||||
<div class="scanner-header">
|
||||
<h1 class="scanner-title">Market Scanner</h1>
|
||||
<div class="scanner-controls">
|
||||
<div class="control-group">
|
||||
<label for="scanType">Scan Type</label>
|
||||
<select id="scanType">
|
||||
<option value="HOT_BY_VOLUME" selected>Unusual Volume</option>
|
||||
<option value="MOST_ACTIVE">Most Active</option>
|
||||
<option value="TOP_PERC_GAIN">Top Gainers</option>
|
||||
<option value="TOP_PERC_LOSE">Top Losers</option>
|
||||
<option value="HIGH_OPEN_GAP">High Open Gap</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="minPrice">Min Price ($)</label>
|
||||
<input type="number" id="minPrice" placeholder="e.g. 5.00" step="0.01" min="0">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="minVolume">Min Volume</label>
|
||||
<input type="number" id="minVolume" placeholder="e.g. 100000" step="1000" min="0">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="minMarketCap">Min Market Cap ($M)</label>
|
||||
<input type="number" id="minMarketCap" placeholder="e.g. 100" step="10" min="0">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button class="scan-button" id="scanButton" onclick="runScanner()">Run Scan</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button class="scan-button export-button" id="exportButton" onclick="exportCSV()">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results-container" id="resultsContainer">
|
||||
<div class="results-header">
|
||||
<span>Scanner Results</span>
|
||||
<span class="results-count" id="resultsCount" style="display:none;">0 results</span>
|
||||
</div>
|
||||
<div id="scanResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
runScanner();
|
||||
});
|
||||
|
||||
async function runScanner() {
|
||||
const button = document.getElementById('scanButton');
|
||||
const scanResults = document.getElementById('scanResults');
|
||||
const resultsCount = document.getElementById('resultsCount');
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Scanning...';
|
||||
|
||||
scanResults.innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="scanner-spinner"></div>
|
||||
<p>Running scanner...</p>
|
||||
</div>
|
||||
`;
|
||||
resultsCount.style.display = 'none';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
scan_type: document.getElementById('scanType').value,
|
||||
min_price: parseFloat(document.getElementById('minPrice').value) || 0,
|
||||
min_volume: parseInt(document.getElementById('minVolume').value) || 0,
|
||||
min_market_cap: parseFloat(document.getElementById('minMarketCap').value) || 0,
|
||||
};
|
||||
|
||||
const response = await fetch('/scanner', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
displayScanResults(data.results, payload.scan_type);
|
||||
} else {
|
||||
showError(data.message || 'Scanner request failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Network error: ' + err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Run Scan';
|
||||
}
|
||||
}
|
||||
|
||||
function displayScanResults(results, scanType) {
|
||||
const scanResults = document.getElementById('scanResults');
|
||||
const resultsCount = document.getElementById('resultsCount');
|
||||
|
||||
resultsCount.textContent = `${results.length} result${results.length !== 1 ? 's' : ''}`;
|
||||
resultsCount.style.display = 'inline-block';
|
||||
|
||||
if (results.length === 0) {
|
||||
scanResults.innerHTML = '<div class="loading-state"><p>No results found. Try adjusting your filters.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'results-grid';
|
||||
|
||||
results.forEach((result) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-card';
|
||||
card.onclick = () => loadSymbolFromScan(result.symbol);
|
||||
|
||||
card.innerHTML = `
|
||||
<img
|
||||
class="chart-thumbnail"
|
||||
src="${result.chart_image}"
|
||||
alt="${result.symbol} chart"
|
||||
onerror="handleChartError(this, '${result.symbol}')"
|
||||
>
|
||||
<div class="symbol-details">
|
||||
<div class="symbol-name">${result.symbol}</div>
|
||||
<div class="symbol-company">${result.company}</div>
|
||||
<div class="symbol-exchange">${result.exchange || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
scanResults.innerHTML = '';
|
||||
scanResults.appendChild(grid);
|
||||
}
|
||||
|
||||
function loadSymbolFromScan(symbol) {
|
||||
window.location.href = '/?symbol=' + symbol;
|
||||
}
|
||||
|
||||
function formatVolume(volume) {
|
||||
if (!volume) return '';
|
||||
if (volume >= 1_000_000) return (volume / 1_000_000).toFixed(1) + 'M';
|
||||
if (volume >= 1_000) return (volume / 1_000).toFixed(1) + 'K';
|
||||
return volume.toString();
|
||||
}
|
||||
|
||||
function handleChartError(imgElement, symbol) {
|
||||
imgElement.style.display = 'none';
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'chart-placeholder';
|
||||
placeholder.textContent = symbol;
|
||||
imgElement.parentNode.insertBefore(placeholder, imgElement);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const scanResults = document.getElementById('scanResults');
|
||||
scanResults.innerHTML = `
|
||||
<div class="loading-state">
|
||||
<p style="color: #e74c3c;">Error: ${message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
const button = document.getElementById('exportButton');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Exporting...';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
scan_type: document.getElementById('scanType').value,
|
||||
min_price: parseFloat(document.getElementById('minPrice').value) || 0,
|
||||
min_volume: parseInt(document.getElementById('minVolume').value) || 0,
|
||||
min_market_cap: parseFloat(document.getElementById('minMarketCap').value) || 0,
|
||||
};
|
||||
|
||||
const response = await fetch('/scanner/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
showError('Export failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename=([^\s;]+)/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : 'scanner_export.csv';
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
showError('Export error: ' + err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Export CSV';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Trade Log — IBKR Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Trade Log</h1>
|
||||
<div class="dashboard-container">
|
||||
{% if trades %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time (UTC)</th>
|
||||
<th>Symbol</th>
|
||||
<th>Action</th>
|
||||
<th>Quantity</th>
|
||||
<th>Fill Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for trade in trades %}
|
||||
<tr>
|
||||
<td>{{ trade.timestamp }}</td>
|
||||
<td class="symbol-cell">
|
||||
<a href="/?symbol={{ trade.symbol }}" class="symbol-link">{{ trade.symbol }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="action-tag {{ 'buy' if trade.action == 'BUY' else 'sell' }}">{{ trade.action }}</span>
|
||||
</td>
|
||||
<td>{{ trade.quantity }}</td>
|
||||
<td class="price-cell">${{ '%.2f'|format(trade.price) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No trades have been recorded yet. Executed trades from webhooks will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user