<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Indodax Paper Trading</title>
<style>
:root {
--bg: #0d1117;
--bg-elevated: #161b22;
--border: #30363d;
--text: #c9d1d9;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-dim: #1f6feb;
--success: #3fb950;
--success-dim: rgba(63, 185, 80, 0.15);
--danger: #f85149;
--danger-dim: rgba(248, 81, 73, 0.15);
--warning: #d29922;
--code-bg: #21262d;
--orange: #f0883e;
--purple: #a371f7;
--pink: #db61a2;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo { font-size: 32px; }
h1 { font-size: 28px; font-weight: 800; }
.subtitle { color: var(--text-muted); font-size: 14px; }
.header-right { display: flex; gap: 12px; align-items: center; }
.sim-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--warning);
border-radius: 20px;
font-size: 12px;
color: #000;
transition: background 0.3s, color 0.3s;
}
.sim-dot {
width: 8px;
height: 8px;
background: #000;
border-radius: 50%;
animation: pulse 2s infinite;
}
.live-dot {
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.btn {
display: inline-flex;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent-dim); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--accent); transform: translateY(-1px); }
.btn-secondary { background: var(--code-bg); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover:not(:disabled) { background: var(--bg-elevated); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover:not(:disabled) { filter: brightness(1.1); }
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
.alert {
padding: 14px 20px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 14px;
font-weight: 500;
animation: slideIn 0.3s ease;
}
@keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.alert-success { background: var(--success-dim); border: 1px solid var(--success); color: var(--success); }
.alert-error { background: var(--danger-dim); border: 1px solid var(--danger); color: var(--danger); }
.main-grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 24px;
}
@media (max-width: 1000px) { .main-grid { grid-template-columns: 1fr; } }
.sidebar { display: flex; flex-direction: column; gap: 20px; }
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-title { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.balance-grid { display: flex; flex-direction: column; gap: 10px; }
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--code-bg);
border-radius: 10px;
font-family: 'SF Mono', monospace;
}
.balance-currency {
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.balance-currency .icon { font-size: 18px; }
.balance-amount { color: var(--success); font-weight: 600; font-size: 14px; }
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.stat-box {
background: var(--code-bg);
padding: 16px;
border-radius: 10px;
text-align: center;
}
.stat-value { font-size: 24px; font-weight: 700; color: var(--accent); font-family: 'SF Mono', monospace; }
.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
.trade-form { display: flex; flex-direction: column; gap: 16px; }
.trade-type-toggle { display: flex; gap: 8px; }
.trade-type-btn {
flex: 1;
padding: 14px;
border: 2px solid var(--border);
background: transparent;
color: var(--text);
border-radius: 10px;
cursor: pointer;
font-weight: 700;
font-size: 14px;
transition: all 0.2s;
}
.trade-type-btn.active.buy { border-color: var(--success); background: var(--success-dim); color: var(--success); }
.trade-type-btn.active.sell { border-color: var(--danger); background: var(--danger-dim); color: var(--danger); }
.pair-select {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pair-btn {
padding: 8px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.pair-btn:hover { border-color: var(--accent); }
.pair-btn.active { border-color: var(--accent); background: var(--accent-dim); color: #fff; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.form-group input {
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--code-bg);
color: var(--text);
font-size: 16px;
font-family: 'SF Mono', monospace;
}
.form-group input:focus { outline: none; border-color: var(--accent); }
.orders-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.orders-table th, .orders-table td { padding: 12px 8px; text-align: left; border-bottom: 1px solid var(--border); }
.orders-table th { color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
.orders-table tr:hover { background: var(--code-bg); }
.side-buy { color: var(--success); font-weight: 700; }
.side-sell { color: var(--danger); font-weight: 700; }
.status-pending { color: var(--warning); }
.status-filled { color: var(--success); }
.status-cancelled { color: var(--text-muted); text-decoration: line-through; }
.charts-section { display: flex; flex-direction: column; gap: 20px; }
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
@media (max-width: 500px) { .charts-grid { grid-template-columns: 1fr; } }
.chart-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
transition: transform 0.2s, border-color 0.2s;
}
.chart-card:hover { transform: translateY(-2px); border-color: var(--accent-dim); }
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.chart-pair { display: flex; align-items: center; gap: 10px; }
.pair-icon { font-size: 24px; }
.pair-name { font-size: 18px; font-weight: 700; }
.pair-symbol { color: var(--text-muted); font-size: 13px; }
.price-info { text-align: right; }
.current-price { font-size: 20px; font-weight: 700; font-family: 'SF Mono', monospace; }
.price-change { font-size: 13px; font-weight: 600; }
.price-change.positive { color: var(--success); }
.price-change.negative { color: var(--danger); }
.chart-container { position: relative; height: 150px; }
.chart-svg { width: 100%; height: 100%; }
.mini-stats {
display: flex;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.mini-stat { text-align: center; }
.mini-stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.mini-stat-value { font-size: 12px; font-weight: 600; font-family: 'SF Mono', monospace; }
#loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 20px;
}
.spinner {
width: 60px;
height: 60px;
border: 4px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { color: var(--text-muted); font-size: 16px; }
.last-update { font-size: 12px; color: var(--text-muted); text-align: center; margin-top: 12px; }
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-left">
<span class="logo">🧪</span>
<div>
<h1>Paper Trading</h1>
<p class="subtitle">Simulated trading with real market prices</p>
</div>
</div>
<div class="header-right">
<div class="sim-indicator">
<span class="sim-dot"></span>
Simulated
</div>
<button class="btn btn-secondary" id="btn-reset">↻ Reset</button>
<a href="index.html" class="btn btn-secondary">← Back</a>
</div>
</header>
<div id="loading">
<div class="spinner"></div>
<div class="loading-text">Loading market data...</div>
</div>
<div id="app" style="display: none;">
<div id="alert"></div>
<div class="main-grid">
<div class="sidebar">
<div class="card">
<div class="card-header">
<span class="card-title">💰 Balances</span>
</div>
<div id="balances" class="balance-grid"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">💳 Top Up Balance</span>
</div>
<div class="trade-form">
<button id="btn-test-balances" class="btn btn-secondary" style="width:100%;margin-bottom:8px">Test Balances</button>
<div class="form-group">
<label>Currency</label>
<select id="topup-currency" style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);">
<option value="idr">IDR</option>
<option value="btc">BTC</option>
<option value="eth">ETH</option>
<option value="usdt">USDT</option>
</select>
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" id="topup-amount" step="any" placeholder="0" style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);">
</div>
<button class="btn btn-primary" id="btn-topup" style="width:100%">Top Up</button>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📊 Statistics</span>
</div>
<div class="stat-grid">
<div class="stat-box">
<div class="stat-value" id="stat-trades">0</div>
<div class="stat-label">Total Orders</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-filled">0</div>
<div class="stat-label">Filled</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-pnl">0</div>
<div class="stat-label">P&L</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-volume">0</div>
<div class="stat-label">Volume</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📈 Place Order</span>
</div>
<div class="trade-form">
<div class="trade-type-toggle">
<button class="trade-type-btn buy active" id="btn-buy">Buy ↑</button>
<button class="trade-type-btn sell" id="btn-sell">Sell ↓</button>
</div>
<div class="pair-select" id="trade-pair-select"></div>
<div class="form-group">
<label>Price (IDR)</label>
<input type="number" id="order-price" step="any" placeholder="0">
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" id="order-amount" step="any" placeholder="0">
</div>
<button class="btn btn-primary" id="btn-place-order" style="width:100%">Place Order</button>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📋 Orders</span>
</div>
<div style="max-height: 300px; overflow-y: auto;">
<table class="orders-table">
<thead>
<tr>
<th>Pair</th>
<th>Side</th>
<th>Price</th>
<th>Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="orders-table"></tbody>
</table>
</div>
</div>
</div>
<div class="charts-section">
<div class="card">
<div class="card-header">
<span class="card-title">📊 Market Charts</span>
<span class="subtitle" id="last-update"></span>
</div>
<div class="charts-grid" id="charts-grid"></div>
</div>
</div>
</div>
</div>
</div>
<script type="module">
let trader = null;
let currentPair = 'btc_idr';
let tradeType = 'buy';
let marketData = {};
let priceHistory = {};
let placingOrder = false;
let ws = null;
let wsReconnectTimer = null;
let useSimulated = false;
let wsFailCount = 0;
let wsOpenTime = 0;
const WS_MAX_FAILURES = 3;
const CORS_PROXY = 'https://corsproxy.io/?key=47986306&url=';
const INDO_TICKER_ALL = 'https://indodax.com/api/ticker_all';
const WS_URL = 'wss://ws3.indodax.com/ws/';
const WS_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5NDY2MTg0MTV9.UR1lBM6Eqh0yWz-PVirw1uPCxe60FdchR8eNVdsskeo';
const FILL_CHECK_INTERVAL = 3000;
const API_POLL_INTERVAL = 5000;
const SIMULATED_FALLBACK_MS = 8000;
const PAIRS = [
{ id: 'btc_idr', name: 'Bitcoin', symbol: 'BTC', icon: '₿', base: 1000000000 },
{ id: 'eth_idr', name: 'Ethereum', symbol: 'ETH', icon: 'Ξ', base: 50000000 },
{ id: 'usdt_idr', name: 'Tether', symbol: 'USDT', icon: '₮', base: 17000 },
{ id: 'bnb_idr', name: 'BNB', symbol: 'BNB', icon: '⬡', base: 5000000 },
{ id: 'xrp_idr', name: 'XRP', symbol: 'XRP', icon: '✕', base: 5000 },
{ id: 'ada_idr', name: 'Cardano', symbol: 'ADA', icon: '₳', base: 5000 },
{ id: 'doge_idr', name: 'Dogecoin', symbol: 'DOGE', icon: 'Ð', base: 500 },
{ id: 'sol_idr', name: 'Solana', symbol: 'SOL', icon: '◎', base: 1500000 },
{ id: 'dot_idr', name: 'Polkadot', symbol: 'DOT', icon: '●', base: 150000 },
{ id: 'matic_idr', name: 'Polygon', symbol: 'MATIC', icon: '⬟', base: 15000 },
{ id: 'avax_idr', name: 'Avalanche', symbol: 'AVAX', icon: '▲', base: 400000 },
{ id: 'link_idr', name: 'Chainlink', symbol: 'LINK', icon: '⬡', base: 200000 },
{ id: 'uni_idr', name: 'Uniswap', symbol: 'UNI', icon: '🦄', base: 150000 },
{ id: 'ltc_idr', name: 'Litecoin', symbol: 'LTC', icon: 'Ł', base: 2000000 },
{ id: 'near_idr', name: 'NEAR', symbol: 'NEAR', icon: '⬡', base: 300000 }
];
const PAIR_MAP = Object.fromEntries(PAIRS.map(p => [p.id, p]));
const ASSET_ORDER = ['idr', 'usdt', 'btc', 'eth', 'bnb', 'sol', 'ltc', 'xrp', 'ada', 'dot', 'matic', 'avax', 'link', 'uni', 'near', 'doge'];
window.init = async function() {
try {
const wasm = await import('./pkg/paper_wasm.js');
await wasm.default();
const { PaperTrader } = wasm;
trader = new PaperTrader();
let balances = trader.get_balances();
console.log('Initial balances after constructor:', JSON.stringify(balances));
if (!balances || Object.keys(balances).length === 0) {
console.log('No balances found, calling init()');
trader.init();
balances = trader.get_balances();
console.log('Balances after init:', JSON.stringify(balances));
}
const saved = localStorage.getItem('paper-trading-state');
if (saved) {
try {
console.log('Loading saved state:', saved.substring(0, 100) + '...');
trader.load_state(saved);
balances = trader.get_balances();
console.log('Balances after load:', JSON.stringify(balances));
} catch(e) {
console.warn('Failed to load saved state, using defaults:', e);
trader.init();
}
}
balances = trader.get_balances();
console.log('Final balances:', JSON.stringify(balances));
if (!balances || Object.keys(balances).length === 0) {
console.error('ERROR: No balances available!');
document.getElementById('balances').innerHTML = '<div style="color:var(--error);padding:12px">Error: No balances. <button id="btn-init-trader" style="padding:5px 10px;margin-top:5px;cursor:pointer;">Initialize</button></div>';
}
renderPairSelectors();
document.getElementById('loading').style.display = 'none';
document.getElementById('app').style.display = 'block';
render();
startFillChecks();
let hasRealData = false;
const originalHandle = handleMarketData;
handleMarketData = function(data) {
hasRealData = true;
originalHandle(data);
};
setTimeout(() => {
if (!hasRealData && !useSimulated) {
console.warn('No real data after ' + SIMULATED_FALLBACK_MS + 'ms, falling back to simulated prices');
fallbackToSimulated();
}
}, SIMULATED_FALLBACK_MS);
try {
await fetchTickerAll();
} catch(e) {
console.warn('CORS proxy fetch failed, trying WebSocket...');
}
connectWebSocket();
startApiPolling();
} catch (e) {
console.error(e);
document.getElementById('loading').innerHTML = '<div class="alert alert-error">WASM load failed: ' + e.message + '</div>';
}
};
async function fetchTickerAll() {
const url = CORS_PROXY + encodeURIComponent(INDO_TICKER_ALL);
const resp = await fetch(url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const json = await resp.json();
const tickers = json.tickers || json;
if (tickers && Object.keys(tickers).length > 0) {
handleMarketData(tickers);
updateConnectionIndicator('live');
}
}
function startApiPolling() {
setInterval(async () => {
if (ws && ws.readyState === WebSocket.OPEN) return;
try { await fetchTickerAll(); } catch(e) {}
}, API_POLL_INTERVAL);
}
function connectWebSocket() {
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
if (wsFailCount >= WS_MAX_FAILURES) {
console.log('WebSocket abandoned after ' + WS_MAX_FAILURES + ' failures, using API polling');
return;
}
try {
ws = new WebSocket(WS_URL);
wsOpenTime = Date.now();
let wsAuthed = false;
ws.onopen = () => {
console.log('WebSocket connected to Indodax');
updateConnectionIndicator('live');
ws.send(JSON.stringify({
params: { token: WS_TOKEN },
id: 1
}));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.id === 1 && msg.result) {
wsAuthed = true;
wsFailCount = 0;
console.log('WebSocket authenticated, subscribing...');
ws.send(JSON.stringify({
method: 1,
params: { channel: 'market:summary-24h' },
id: 2
}));
} else if (msg.result && msg.result.data) {
handleWsMarketData(msg.result.data);
wsFailCount = 0;
} else if (msg.method === 7 || msg.id === 7) {
ws.send(JSON.stringify({ method: 7, id: 3 }));
}
} catch(e) { console.warn('WS message parse error:', e); }
};
ws.onclose = () => {
const aliveMs = Date.now() - wsOpenTime;
if (aliveMs < 3000) {
wsFailCount++;
console.log('WebSocket disconnected (alive ' + aliveMs + 'ms), failures: ' + wsFailCount + '/' + WS_MAX_FAILURES);
}
if (wsFailCount >= WS_MAX_FAILURES) {
console.log('WebSocket abandoned, using API polling');
updateConnectionIndicator('reconnecting');
return;
}
console.log('WebSocket disconnected, reconnecting in 5s...');
updateConnectionIndicator('reconnecting');
wsReconnectTimer = setTimeout(connectWebSocket, 5000);
};
ws.onerror = (err) => {
console.warn('WebSocket error, will retry...');
};
} catch(e) {
wsFailCount++;
console.warn('WebSocket connection failed, using simulated prices');
if (wsFailCount >= WS_MAX_FAILURES) return;
fallbackToSimulated();
}
}
function handleWsMarketData(data) {
if (!data || !data.data || !Array.isArray(data.data)) return;
const tickers = {};
for (const row of data.data) {
const rawPair = row[0];
const last = parseFloat(row[2]) || 0;
const low = parseFloat(row[3]) || last;
const high = parseFloat(row[4]) || last;
const price24h = parseFloat(row[5]) || last;
const volBase = parseFloat(row[7]) || 0;
const change = price24h > 0 ? ((last - price24h) / price24h * 100) : 0;
if (last > 0) {
tickers[rawPair] = { last, high, low, vol_base: volBase, change };
}
}
if (Object.keys(tickers).length > 0) {
handleMarketData(tickers);
}
}
function updateConnectionIndicator(state) {
const indicator = document.querySelector('.sim-indicator');
if (!indicator) return;
if (state === 'live') {
indicator.innerHTML = '<span class="live-dot"></span> Live';
indicator.style.background = 'var(--success-dim)';
indicator.style.color = 'var(--success)';
} else {
indicator.innerHTML = '<span class="sim-dot"></span> Simulated';
indicator.style.background = 'var(--warning)';
indicator.style.color = '#000';
}
}
function fallbackToSimulated() {
useSimulated = true;
updateConnectionIndicator('simulated');
scheduleNextUpdate();
}
function scheduleNextUpdate() {
setTimeout(() => {
updateSimulatedPrices();
renderCharts();
document.getElementById('last-update').textContent = 'Simulated · ' + new Date().toLocaleTimeString();
scheduleNextUpdate();
}, 3000);
}
function updateSimulatedPrices() {
PAIRS.forEach(pair => {
const prevPrice = marketData[pair.id]?.last || pair.base;
const ct = prevPrice * (Math.random() - 0.5) * 0.02;
const newPrice = prevPrice + ct;
marketData[pair.id] = {
last: newPrice,
high: Math.max((marketData[pair.id]?.high || prevPrice * 1.01), newPrice),
low: Math.min((marketData[pair.id]?.low || prevPrice * 0.99), newPrice),
vol: marketData[pair.id]?.vol || Math.random() * 100,
change: (newPrice - pair.base) / pair.base * 100
};
if (!priceHistory[pair.id]) priceHistory[pair.id] = [];
priceHistory[pair.id].push(newPrice);
if (priceHistory[pair.id].length > 50) priceHistory[pair.id].shift();
});
}
function normalizePair(raw) {
const known = PAIR_MAP[raw];
if (known) return raw;
const withUnderscore = raw.replace(/([a-z]+)(idr)$/, '$1_$2');
if (PAIR_MAP[withUnderscore]) return withUnderscore;
return raw;
}
function handleMarketData(data) {
let updated = 0;
const pairIds = new Set(PAIRS.map(p => p.id));
for (const [rawPair, info] of Object.entries(data)) {
const pairId = normalizePair(rawPair);
if (!pairIds.has(pairId)) continue;
const last = parseFloat(info.last) || 0;
const high = parseFloat(info.high) || last;
const low = parseFloat(info.low) || last;
const change = parseFloat(info.change) || 0;
const volBase = parseFloat(info.vol_base) || parseFloat(info.volume) || 0;
if (last > 0) {
marketData[pairId] = { last, high, low, vol: volBase, change };
if (!priceHistory[pairId]) priceHistory[pairId] = [];
const prev = priceHistory[pairId];
if (prev.length === 0 || Math.abs(prev[prev.length - 1] - last) > 0.001) {
priceHistory[pairId].push(last);
if (priceHistory[pairId].length > 50) priceHistory[pairId].shift();
}
updated++;
}
}
if (updated > 0) {
document.getElementById('last-update').textContent = 'Live · ' + new Date().toLocaleTimeString();
renderCharts();
if (trader) { checkOrderFills(); render(); }
}
}
function startFillChecks() {
setInterval(async () => {
if (trader) {
await checkOrderFills();
render();
}
}, FILL_CHECK_INTERVAL);
}
async function checkOrderFills() {
if (!trader) return;
const priceMap = {};
PAIRS.forEach(p => {
if (marketData[p.id]) priceMap[p.id] = marketData[p.id].last;
});
try {
trader.check_fills(priceMap);
saveState();
} catch(e) { }
}
function renderPairSelectors() {
const tradeSelect = document.getElementById('trade-pair-select');
tradeSelect.innerHTML = PAIRS.map(p =>
`<button class="pair-btn ${p.id === currentPair ? 'active' : ''}" data-pair="${p.id}">${p.icon} ${p.symbol}</button>`
).join('');
tradeSelect.querySelectorAll('[data-pair]').forEach(btn => {
btn.addEventListener('click', (e) => {
const pairId = (e.target as HTMLElement).dataset.pair;
if (pairId) setPair(pairId);
});
});
}
function renderCharts() {
const grid = document.getElementById('charts-grid');
grid.innerHTML = PAIRS.map(pair => {
const data = marketData[pair.id];
if (!data) return '';
const prices = priceHistory[pair.id] || [];
const chartSVG = generateChart(pair.id, prices, data.last);
const changeClass = data.change >= 0 ? 'positive' : 'negative';
const changeSign = data.change >= 0 ? '+' : '';
return `
<div class="chart-card">
<div class="chart-header">
<div class="chart-pair">
<span class="pair-icon">${pair.icon}</span>
<div>
<div class="pair-name">${pair.name}</div>
<div class="pair-symbol">${pair.symbol}/IDR</div>
</div>
</div>
<div class="price-info">
<div class="current-price">${formatNumber(data.last)}</div>
<div class="price-change ${changeClass}">${changeSign}${data.change?.toFixed(2) || 0}%</div>
</div>
</div>
<div class="chart-container">
<svg class="chart-svg" viewBox="0 0 400 150" preserveAspectRatio="none">
${chartSVG}
</svg>
</div>
<div class="mini-stats">
<div class="mini-stat">
<div class="mini-stat-label">High</div>
<div class="mini-stat-value">${formatNumber(data.high)}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-label">Low</div>
<div class="mini-stat-value">${formatNumber(data.low)}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-label">Vol</div>
<div class="mini-stat-value">${data.vol?.toFixed(2) || 0}</div>
</div>
</div>
</div>
`;
}).join('');
}
function generateChart(pairId, prices, currentPrice) {
if (prices.length < 2) {
return `<text x="200" y="75" fill="var(--text-muted)" text-anchor="middle">Loading...</text>`;
}
const min = Math.min(...prices);
const max = Math.max(...prices);
const range = max - min || 1;
const width = 400, height = 150, padding = 5;
const points = prices.map((p, i) => {
const x = (i / (prices.length - 1)) * (width - padding * 2) + padding;
const y = height - padding - ((p - min) / range) * (height - padding * 2);
return `${x},${y}`;
}).join(' ');
const areaPoints = `${padding},${height - padding} ${points} ${width - padding},${height - padding}`;
const color = currentPrice >= prices[0] ? 'var(--success)' : 'var(--danger)';
return `
<defs>
<linearGradient id="grad-${pairId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.3"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="var(--code-bg)" rx="4"/>
<polygon fill="url(#grad-${pairId})" points="${areaPoints}"/>
<polyline fill="none" stroke="${color}" stroke-width="2" points="${points}"/>
`;
}
function render() {
renderStats();
updateQuickPrices();
renderOrders();
}
function updateQuickPrices() {
if (!trader) {
document.getElementById('balances').innerHTML = '<div style="color:var(--error);text-align:center;padding:12px">Trader not initialized</div>';
return;
}
const b = trader.get_balances();
console.log('updateQuickPrices - balances:', JSON.stringify(b));
if (!b || (typeof b === 'object' && Object.keys(b).length === 0)) {
console.warn('No balances, calling init()');
trader.init();
const b2 = trader.get_balances();
if (!b2 || Object.keys(b2).length === 0) {
document.getElementById('balances').innerHTML = '<div style="color:var(--error);text-align:center;padding:12px">No balances. <button id="btn-init-balances">Initialize</button></div>';
return;
}
}
let html = '';
if (b['idr'] !== undefined) {
html += `<div class="balance-row" style="border: 2px solid var(--accent);">
<span class="balance-currency"><span class="icon">🇮🇩</span> IDR</span>
<span class="balance-amount">${formatNumber(b['idr'])}</span>
</div>`;
}
const sortedKeys = Object.keys(b)
.filter(k => k !== 'idr')
.sort((a, b) => {
const ai = ASSET_ORDER.indexOf(a.toLowerCase());
const bi = ASSET_ORDER.indexOf(b.toLowerCase());
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
sortedKeys.forEach(key => {
const balance = b[key];
if (balance === undefined) return;
const pairId = key.toLowerCase() + '_idr';
const pairInfo = PAIR_MAP[pairId] || { icon: '', symbol: key.toUpperCase() };
html += `<div class="balance-row">
<span class="balance-currency"><span class="icon">${pairInfo.icon}</span> ${pairInfo.symbol || key.toUpperCase()}</span>
<span class="balance-amount">${balance > 0 ? balance.toFixed(6) : '0.000000'}</span>
</div>`;
});
document.getElementById('balances').innerHTML = html || '<div style="color:var(--text-muted);text-align:center;padding:12px">No balances</div>';
}
function renderStats() {
if (!trader) return;
const s = trader.get_status();
const orders = trader.get_orders() || [];
document.getElementById('stat-trades').textContent = s.total_orders || s.trade_count || 0;
document.getElementById('stat-filled').textContent = s.filled_count || 0;
document.getElementById('stat-volume').textContent = Math.round(
orders.filter(o => o.status === 'filled').reduce((sum, o) => sum + (o.price * o.amount), 0)
);
computePnl();
}
function computePnl() {
if (!trader) return;
const b = trader.get_balances() || {};
const ib = trader.get_initial_balances();
if (!ib) { document.getElementById('stat-pnl').textContent = 'N/A'; return; }
let initialTotal = ib.idr || 0;
let currentTotal = b.idr || 0;
for (const key of Object.keys(ib)) {
if (key === 'idr') continue;
const pairId = key.toLowerCase() + '_idr';
const priceNow = marketData[pairId]?.last;
const initBalance = ib[key] || 0;
const curBalance = b[key] || 0;
if (priceNow && initBalance > 0) {
initialTotal += initBalance * priceNow;
}
if (priceNow && curBalance > 0) {
currentTotal += curBalance * priceNow;
}
}
const pnl = currentTotal - initialTotal;
const pct = initialTotal > 0 ? (pnl / initialTotal * 100) : 0;
const el = document.getElementById('stat-pnl');
el.textContent = formatNumber(pnl);
el.style.color = pnl >= 0 ? 'var(--success)' : 'var(--danger)';
el.title = (pnl >= 0 ? '+' : '') + pct.toFixed(2) + '%';
}
function renderOrders() {
if (!trader) return;
const o = trader.get_orders() || [];
document.getElementById('orders-table').innerHTML = o.length ? o.slice(0, 50).map(x => {
const pairInfo = PAIR_MAP[x.pair] || { symbol: x.pair.split('_')[0]?.toUpperCase() || '?', icon: '' };
const cancelBtn = x.status === 'pending'
? `<button class="btn btn-danger btn-sm" data-order-id="${x.id}">✕</button>`
: '';
return `
<tr>
<td><span style="font-size:16px">${pairInfo.icon}</span> ${pairInfo.symbol}</td>
<td class="side-${x.side}">${x.side.toUpperCase()}</td>
<td>${formatNumber(x.price)}</td>
<td>${x.amount?.toFixed(6) || '0'}</td>
<td class="status-${x.status}">${x.status}</td>
<td>${cancelBtn}</td>
</tr>
`;
}).join('') : '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);padding:20px">No orders yet</td></tr>';
}
function showAlert(m, t = 'success') {
document.getElementById('alert').innerHTML = `<div class="alert alert-${t}">${m}</div>`;
setTimeout(() => document.getElementById('alert').innerHTML = '', 5000);
}
window.setTradeType = function(t) {
tradeType = t;
document.getElementById('btn-buy').className = `trade-type-btn buy ${t === 'buy' ? 'active' : ''}`;
document.getElementById('btn-sell').className = `trade-type-btn sell ${t === 'sell' ? 'active' : ''}`;
};
window.setPair = function(p) {
currentPair = p;
document.querySelectorAll('#trade-pair-select .pair-btn').forEach(b => {
b.classList.toggle('active', b.dataset.pair === p);
});
const data = marketData[p];
if (data) {
document.getElementById('order-price').value = Math.round(data.last);
}
};
window.placeOrder = async function() {
if (placingOrder) return;
const price = parseFloat(document.getElementById('order-price').value);
const amount = parseFloat(document.getElementById('order-amount').value);
if (!price || !amount) return showAlert('Please enter both price and amount', 'error');
if (!trader) return showAlert('Trading not ready', 'error');
placingOrder = true;
const btn = document.getElementById('btn-place-order');
btn.disabled = true;
btn.textContent = 'Placing...';
try {
const side = tradeType === 'buy' ? 'Buy' : 'Sell';
let r;
if (tradeType === 'buy') {
r = trader.buy(currentPair, price, amount);
} else {
r = trader.sell(currentPair, price, amount);
}
if (r && r.success) {
saveState();
render();
const pairSymbol = currentPair.split('_')[0].toUpperCase();
showAlert(`${side} ${amount} ${pairSymbol} @ ${formatNumber(price)} — order placed`);
const prices = {};
PAIRS.forEach(p => { if (marketData[p.id]) prices[p.id] = marketData[p.id].last; });
try { trader.check_fills(prices); saveState(); render(); } catch(e) {}
document.getElementById('order-price').value = '';
document.getElementById('order-amount').value = '';
} else {
showAlert('Order failed: ' + (r?.message || 'Unknown error'), 'error');
}
} catch (e) {
showAlert('Error: ' + e.toString(), 'error');
} finally {
placingOrder = false;
btn.disabled = false;
btn.textContent = 'Place Order';
}
};
window.reinitTrader = function() {
if (!trader) {
showAlert('Trader not initialized', 'error');
return;
}
try {
trader.init();
saveState();
render();
showAlert('Trader reinitialized with default balances');
} catch(e) {
console.error('Reinit error:', e);
showAlert('Failed to reinitialize: ' + e, 'error');
}
};
window.testBalances = function() {
if (!trader) {
showAlert('Trader not initialized', 'error');
return;
}
try {
const balances = trader.get_balances();
console.log('Balances:', JSON.stringify(balances));
if (balances && typeof balances === 'object') {
const keys = Object.keys(balances);
if (keys.length > 0) {
showAlert('Balances: ' + JSON.stringify(balances).substring(0, 100));
} else {
showAlert('No balances found. Calling init()...', 'error');
trader.init();
setTimeout(() => {
const b2 = trader.get_balances();
showAlert('After init: ' + JSON.stringify(b2).substring(0, 100));
render();
}, 500);
}
} else {
showAlert('Invalid balances object', 'error');
}
} catch(e) {
console.error('Test error:', e);
showAlert('Error: ' + e, 'error');
}
};
window.cancelOrder = function(orderId) {
if (!trader) return;
try {
const result = trader.cancel_order(orderId);
if (result && result.success) {
saveState();
render();
showAlert('Order #' + orderId + ' cancelled');
}
} catch(e) {
showAlert('Cancel failed: ' + e.toString(), 'error');
}
};
window.resetTrading = function() {
if (confirm('Reset all trading data?')) {
trader.reset();
localStorage.removeItem('paper-trading-state');
render();
showAlert('Trading data reset');
}
};
function topupBalance() {
if (!trader) {
showAlert('Trader not initialized. Please refresh the page.', 'error');
return;
}
const currency = document.getElementById('topup-currency').value.toLowerCase();
const amount = parseFloat(document.getElementById('topup-amount').value);
if (!currency || isNaN(amount) || amount <= 0) {
showAlert('Please enter a valid currency and amount', 'error');
return;
}
try {
const result = trader.topup(currency, amount);
console.log('Topup returned:', JSON.stringify(result));
const balances = trader.get_balances();
console.log('Balances after topup:', JSON.stringify(balances));
if (balances && balances[currency]) {
showAlert(`Added ${amount} to ${currency.toUpperCase()}. New balance: ${parseFloat(balances[currency]).toFixed(2)}`);
} else {
showAlert(`Added ${amount} to ${currency.toUpperCase()}. Check balances above.`);
}
saveState();
render();
document.getElementById('topup-amount').value = '';
} catch (e) {
console.error('Topup error:', e);
showAlert('Topup failed: ' + (e.message || e), 'error');
}
};
function saveState() {
if (!trader) return;
try {
localStorage.setItem('paper-trading-state', trader.save_state());
} catch (e) {
console.warn('Failed to save state (localStorage may be full):', e);
}
}
function formatNumber(n) {
if (n === undefined || n === null) return '0';
if (n >= 1000000000) return n.toLocaleString('id-ID', { maximumFractionDigits: 0 });
if (n >= 1000000) return n.toLocaleString('id-ID', { maximumFractionDigits: 0 });
if (n >= 1000) return n.toLocaleString('id-ID', { maximumFractionDigits: 0 });
if (n >= 1) return n.toLocaleString('id-ID', { maximumFractionDigits: 2 });
return n.toLocaleString('id-ID', { maximumFractionDigits: 6 });
}
document.addEventListener('DOMContentLoaded', function() {
const btnTest = document.getElementById('btn-test-balances');
if (btnTest) { btnTest.addEventListener('click', testBalances); }
const btnInit1 = document.getElementById('btn-init-trader');
if (btnInit1) { btnInit1.addEventListener('click', reinitTrader); }
const btnInit2 = document.getElementById('btn-init-balances');
if (btnInit2) { btnInit2.addEventListener('click', reinitTrader); }
const btnReset = document.getElementById('btn-reset');
if (btnReset) { btnReset.addEventListener('click', resetTrading); }
const btnTopup = document.getElementById('btn-topup');
if (btnTopup) { btnTopup.addEventListener('click', topupBalance); }
const ordersTable = document.getElementById('orders-table');
if (ordersTable) {
ordersTable.addEventListener('click', function(e) {
const target = e.target as HTMLElement;
if (target && target.dataset && target.dataset.orderId) {
cancelOrder(parseInt(target.dataset.orderId));
}
});
}
});
init();
</script>
</body>
</html>