window.GDD = window.GDD || {};
const { useState, useEffect, useRef, useCallback, useMemo } = React;
/* ──────────────────────────────────────────────
Commodities Exchange — Contract Market
────────────────────────────────────────────── */
/* ── Static contract catalogue ── */
const CATEGORIES = [
{ id: 'all', label: 'All Contracts' },
{ id: 'ore', label: 'Raw Ores' },
{ id: 'mineral', label: 'Refined Minerals' },
{ id: 'gas', label: 'Gas Products' },
{ id: 'isotope', label: 'Isotopes' },
{ id: 'exotic', label: 'Exotic Matter' },
];
const CONTRACTS = [
/* Ores */
{ symbol:'VLD', name:'Veldspar', category:'ore', lotSize:1000, tickSize:0.01, marginPct:8, basePrice:14, hub:'Jita IV – Moon 4' },
{ symbol:'SCR', name:'Scordite', category:'ore', lotSize:500, tickSize:0.02, marginPct:10, basePrice:32, hub:'Jita IV – Moon 4' },
{ symbol:'PYX', name:'Pyroxeres', category:'ore', lotSize:500, tickSize:0.05, marginPct:10, basePrice:45, hub:'Amarr VIII – Emperor' },
{ symbol:'KRN', name:'Kernite', category:'ore', lotSize:250, tickSize:0.10, marginPct:12, basePrice:90, hub:'Amarr VIII – Emperor' },
{ symbol:'OMB', name:'Omber', category:'ore', lotSize:250, tickSize:0.10, marginPct:12, basePrice:135, hub:'Rens VI – Moon 8' },
{ symbol:'JSP', name:'Jaspet', category:'ore', lotSize:200, tickSize:0.10, marginPct:15, basePrice:190, hub:'Dodixie IX – Moon 20' },
{ symbol:'HEM', name:'Hemorphite', category:'ore', lotSize:100, tickSize:0.50, marginPct:18, basePrice:360, hub:'Jita IV – Moon 4' },
{ symbol:'ARK', name:'Arkonor', category:'ore', lotSize:50, tickSize:1.00, marginPct:20, basePrice:620, hub:'Jita IV – Moon 4' },
/* Minerals */
{ symbol:'TRI', name:'Tritanium', category:'mineral', lotSize:5000, tickSize:0.01, marginPct:6, basePrice:5, hub:'Jita IV – Moon 4' },
{ symbol:'PYE', name:'Pyerite', category:'mineral', lotSize:2000, tickSize:0.01, marginPct:8, basePrice:12, hub:'Jita IV – Moon 4' },
{ symbol:'MLX', name:'Mexallon', category:'mineral', lotSize:1000, tickSize:0.02, marginPct:8, basePrice:35, hub:'Jita IV – Moon 4' },
{ symbol:'ISO', name:'Isogen', category:'mineral', lotSize:500, tickSize:0.05, marginPct:12, basePrice:110, hub:'Amarr VIII – Emperor' },
{ symbol:'NCX', name:'Nocxium', category:'mineral', lotSize:250, tickSize:0.10, marginPct:15, basePrice:380, hub:'Jita IV – Moon 4' },
{ symbol:'ZDR', name:'Zydrine', category:'mineral', lotSize:100, tickSize:0.50, marginPct:18, basePrice:950, hub:'Jita IV – Moon 4' },
{ symbol:'MEG', name:'Megacyte', category:'mineral', lotSize:50, tickSize:1.00, marginPct:20, basePrice:2800,hub:'Jita IV – Moon 4' },
{ symbol:'MOR', name:'Morphite', category:'mineral', lotSize:10, tickSize:5.00, marginPct:25, basePrice:8500,hub:'Jita IV – Moon 4' },
/* Gas */
{ symbol:'ATM', name:'Atmospheric', category:'gas', lotSize:2000, tickSize:0.01, marginPct:10, basePrice:8, hub:'Jita IV – Moon 4' },
{ symbol:'NEB', name:'Nebular', category:'gas', lotSize:500, tickSize:0.05, marginPct:12, basePrice:45, hub:'Amarr VIII – Emperor' },
{ symbol:'ION', name:'Ionized', category:'gas', lotSize:200, tickSize:0.10, marginPct:15, basePrice:120, hub:'Jita IV – Moon 4' },
{ symbol:'FUL', name:'Fullerides', category:'gas', lotSize:100, tickSize:0.50, marginPct:18, basePrice:450, hub:'Jita IV – Moon 4' },
/* Isotopes */
{ symbol:'H3', name:'Hydrogen-3', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:85, hub:'Jita IV – Moon 4' },
{ symbol:'HE4', name:'Helium-4', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:92, hub:'Amarr VIII – Emperor' },
{ symbol:'N15', name:'Nitrogen-15', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:78, hub:'Rens VI – Moon 8' },
{ symbol:'O18', name:'Oxygen-18', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:88, hub:'Dodixie IX – Moon 20' },
/* Exotic */
{ symbol:'RDB', name:'Reedstone', category:'exotic', lotSize:10, tickSize:5.00, marginPct:25, basePrice:12000,hub:'Jita IV – Moon 4' },
{ symbol:'TCH', name:'Tachyon Salt', category:'exotic', lotSize:5, tickSize:10.0, marginPct:30, basePrice:45000,hub:'Jita IV – Moon 4' },
];
/* ── Sparkline ── */
function Sparkline({ data, width = 80, height = 24, color = 'var(--green)' }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c || !data || data.length < 2) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
c.width = width * dpr; c.height = height * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const min = Math.min(...data); const max = Math.max(...data);
const range = max - min || 1;
const step = width / (data.length - 1);
ctx.beginPath();
data.forEach((v, i) => {
const x = i * step;
const y = height - ((v - min) / range) * (height - 4) - 2;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
const grad = ctx.createLinearGradient(0, 0, 0, height);
const isGreen = color.includes('green');
grad.addColorStop(0, isGreen ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
}, [data, width, height, color]);
return ;
}
/* ── Ticker Tape ── */
function TickerTape({ items }) {
const [offset, setOffset] = useState(0);
const raf = useRef(null);
useEffect(() => {
const speed = 0.5;
const tick = () => {
setOffset(o => { const n = o - speed; return n < -3000 ? 0 : n; });
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
}, []);
const dup = [...items, ...items, ...items];
return (
{dup.map((t, i) => (
{t.symbol}
₢{t.price.toLocaleString()}
= 0 ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>
{t.change >= 0 ? '▲' : '▼'} {Math.abs(t.changePct).toFixed(1)}%
))}
);
}
/* ── Category Tabs ── */
function CategoryTabs({ active, onChange }) {
return (
{CATEGORIES.map(cat => (
))}
);
}
/* ── Contract Board — main table ── */
function ContractBoard({ commodities, selected, onSelect, category }) {
const filtered = category === 'all' ? commodities : commodities.filter(c => c.category === category);
return (
{/* Table header */}
TickerNameLastChg%
BidAskVolumeOpen Int.
{/* Rows */}
{filtered.map(c => {
const up = c.change >= 0;
const isSel = selected === c.symbol;
const spread = c.bestAsk - c.bestBid;
return (
onSelect(c.symbol)} style={{
display: 'grid',
gridTemplateColumns: '90px 120px 80px 60px 70px 70px 80px 90px 70px',
gap: '4px', alignItems: 'center',
padding: '7px 12px', cursor: 'pointer',
background: isSel ? 'var(--surface-hover)' : 'transparent',
borderLeft: isSel ? '2px solid var(--accent)' : '2px solid transparent',
borderBottom: '1px solid var(--border)',
transition: 'background var(--transition-fast)',
fontSize: '0.75rem',
}}>
{c.symbol}
{c.name}
₢{c.price.toLocaleString()}
{up ? '+' : ''}{c.changePct.toFixed(1)}%
₢{c.bestBid}
₢{c.bestAsk}
{(c.volume / 1000).toFixed(1)}K
{(c.openInterest / 1000).toFixed(1)}K
);
})}
);
}
/* ── Contract Spec Panel ── */
function ContractSpec({ contract, commodity }) {
if (!contract || !commodity) return null;
const marginPerLot = Math.ceil(commodity.price * contract.lotSize * (contract.marginPct / 100));
const notionalPerLot = commodity.price * contract.lotSize;
return (
Contract Specification
{[
['Ticker', contract.symbol, 'var(--accent)'],
['Unit', contract.name, 'var(--fg-bright)'],
['Lot Size', contract.lotSize.toLocaleString() + ' units', 'var(--fg-bright)'],
['Tick Size', '₢' + contract.tickSize, 'var(--fg-bright)'],
['Margin Req.', contract.marginPct + '%', 'var(--cyan)'],
['Margin / Lot', '₢' + marginPerLot.toLocaleString(), 'var(--cyan)'],
['Notional / Lot', '₢' +notionalPerLot.toLocaleString(), 'var(--fg-dim)'],
['Delivery', contract.hub, 'var(--fg-dim)'],
['Settlement', commodity.settlement ? '₢' + commodity.settlement : '—', 'var(--fg-dim)'],
['Expiry', '30 DTE', 'var(--muted)'],
['Supply', (commodity.supply / 1000).toFixed(0) + 'K/day', 'var(--green)'],
['Demand', (commodity.demand / 1000).toFixed(0) + 'K/day', commodity.demand > commodity.supply ? 'var(--red)' : 'var(--green)'],
].map(([label, value, color], i) => (
{label}
{value}
))}
{/* Supply/Demand bar */}
SUPPLY
commodity.supply ? 'var(--red)' : 'var(--green)' }}>
{commodity.demand > commodity.supply ? 'DEFICIT' : 'SURPLUS'}: {Math.abs(commodity.supply - commodity.demand).toLocaleString()}
DEMAND
);
}
/* ── Depth Chart (canvas) ── */
function DepthChart({ bids, asks, midPrice }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const W = c.clientWidth; const H = c.clientHeight;
c.width = W * dpr; c.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
if (!bids.length || !asks.length) return;
const maxCum = Math.max(
bids.reduce((s, b) => Math.max(s, b.cumulative), 0),
asks.reduce((s, a) => Math.max(s, a.cumulative), 0)
);
const priceRange = midPrice * 0.06;
const pMin = midPrice - priceRange;
const pMax = midPrice + priceRange;
const toX = p => ((p - pMin) / (pMax - pMin)) * W;
const toY = cum => H - (cum / (maxCum * 1.1)) * H;
/* Grid */
ctx.strokeStyle = 'rgba(28,42,63,0.4)'; ctx.lineWidth = 0.5;
for (let i = 0; i < 4; i++) {
const y = (H / 3) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
/* Bids (green area, left of mid) */
ctx.beginPath();
ctx.moveTo(toX(bids[0].price), H);
bids.forEach(b => ctx.lineTo(toX(b.price), toY(b.cumulative)));
ctx.lineTo(toX(bids[bids.length - 1].price), H);
ctx.closePath();
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, 'rgba(34,197,94,0.25)'); bg.addColorStop(1, 'rgba(34,197,94,0.03)');
ctx.fillStyle = bg; ctx.fill();
/* Asks (red area, right of mid) */
ctx.beginPath();
ctx.moveTo(toX(asks[0].price), H);
asks.forEach(a => ctx.lineTo(toX(a.price), toY(a.cumulative)));
ctx.lineTo(toX(asks[asks.length - 1].price), H);
ctx.closePath();
const ag = ctx.createLinearGradient(0, 0, 0, H);
ag.addColorStop(0, 'rgba(239,68,68,0.25)'); ag.addColorStop(1, 'rgba(239,68,68,0.03)');
ctx.fillStyle = ag; ctx.fill();
/* Mid line */
const mx = toX(midPrice);
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(mx, 0); ctx.lineTo(mx, H);
ctx.strokeStyle = 'var(--accent)'; ctx.lineWidth = 1; ctx.stroke();
ctx.setLineDash([]);
/* Price label */
ctx.fillStyle = 'var(--accent)'; ctx.font = 'bold 9px var(--font-mono)';
ctx.textAlign = 'center';
ctx.fillText('₢' + midPrice.toFixed(0), mx, H - 4);
}, [bids, asks, midPrice]);
return ;
}
/* ── Order Book ── */
function OrderBook({ bids, asks, spread, spreadPct }) {
const maxBid = Math.max(...bids.map(b => b.volume));
const maxAsk = Math.max(...asks.map(a => a.volume));
return (
Market Depth
Spread: ₢{spread} ({spreadPct.toFixed(2)}%)
PriceSizeCum
{/* Asks (reversed) */}
{[...asks].reverse().map((a, i) => (
₢{a.price}
{a.volume.toLocaleString()}
{a.cumulative.toLocaleString()}
))}
{/* Mid */}
₢{((bids[0]?.price || 0 + asks[0]?.price || 0) / 2).toLocaleString()}
MID
{/* Bids */}
{bids.map((b, i) => (
₢{b.price}
{b.volume.toLocaleString()}
{b.cumulative.toLocaleString()}
))}
);
}
/* ── Price Chart (canvas) ── */
function PriceChart({ data, symbol }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c || !data || data.length < 2) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const W = c.clientWidth; const H = c.clientHeight;
c.width = W * dpr; c.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const prices = data.map(d => d.close);
const highs = data.map(d => d.high);
const lows = data.map(d => d.low);
const volumes = data.map(d => d.volume);
const allP = [...highs, ...lows];
const minP = Math.min(...allP); const maxP = Math.max(...allP);
const rangeP = maxP - minP || 1;
const maxV = Math.max(...volumes);
const chartH = H * 0.72; const volH = H * 0.22; const barW = Math.max(2, (W / data.length) - 1);
/* Grid */
ctx.strokeStyle = 'rgba(28,42,63,0.5)'; ctx.lineWidth = 0.5;
for (let i = 0; i < 5; i++) {
const y = (chartH / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillStyle = 'var(--muted)'; ctx.font = '8px var(--font-mono)';
ctx.fillText('₢' + (maxP - (rangeP / 4) * i).toFixed(0), W - 42, y + 9);
}
/* Volume bars */
data.forEach((d, i) => {
const x = (i / data.length) * W;
const vh = (d.volume / maxV) * volH;
ctx.fillStyle = d.close >= d.open ? 'rgba(34,197,94,0.18)' : 'rgba(239,68,68,0.18)';
ctx.fillRect(x, H - vh, barW, vh);
});
/* Price line */
ctx.beginPath();
data.forEach((d, i) => {
const x = (i / (data.length - 1)) * W;
const y = chartH - ((d.close - minP) / rangeP) * (chartH - 10) - 5;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
const lastC = data[data.length - 1].close;
const firstC = data[0].close;
const lineColor = lastC >= firstC ? '#22c55e' : '#ef4444';
ctx.strokeStyle = lineColor; ctx.lineWidth = 1.8; ctx.lineJoin = 'round'; ctx.stroke();
/* Area fill */
const lastX = W;
ctx.lineTo(lastX, chartH); ctx.lineTo(0, chartH); ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, chartH);
grad.addColorStop(0, lastC >= firstC ? 'rgba(34,197,94,0.10)' : 'rgba(239,68,68,0.10)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad; ctx.fill();
/* Current price line */
const curY = chartH - ((lastC - minP) / rangeP) * (chartH - 10) - 5;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, curY); ctx.lineTo(W, curY);
ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = lineColor;
ctx.beginPath(); ctx.roundRect(W - 55, curY - 8, 52, 16, 3); ctx.fill();
ctx.fillStyle = '#080c14'; ctx.font = 'bold 8px var(--font-mono)';
ctx.fillText('₢' + lastC.toFixed(0), W - 52, curY + 3);
}, [data, symbol]);
return ;
}
/* ── Order Form (commodities-style: lots, margin, long/short) ── */
function OrderForm({ contract, commodity, credits, positions, onTrade }) {
const [direction, setDirection] = useState('long'); // long or short
const [orderType, setOrderType] = useState('market'); // market, limit, stop
const [lots, setLots] = useState('');
const [limitPrice, setLimitPrice] = useState('');
const [error, setError] = useState('');
const [confirmation, setConfirmation] = useState(null);
if (!contract || !commodity) return null;
const effectivePrice = orderType === 'market' ? commodity.price : (parseFloat(limitPrice) || commodity.price);
const numLots = parseInt(lots) || 0;
const totalUnits = numLots * contract.lotSize;
const notional = totalUnits * effectivePrice;
const marginRequired = Math.ceil(notional * (contract.marginPct / 100));
const commission = Math.ceil(notional * 0.015);
const pos = positions.find(p => p.symbol === contract.symbol);
const existingDirection = pos ? pos.direction : null;
const canAfford = marginRequired + commission <= credits;
const handleSubmit = () => {
if (numLots <= 0) { setError('Enter lot quantity'); return; }
if (orderType !== 'market' && !limitPrice) { setError('Set limit/stop price'); return; }
if (!canAfford) { setError('Insufficient margin + commission'); return; }
setError('');
setConfirmation({ direction, orderType, lots: numLots, price: effectivePrice, notional, marginRequired, commission, totalUnits });
};
const confirmTrade = () => {
onTrade(direction, contract.symbol, effectivePrice, numLots, totalUnits, marginRequired, commission);
setConfirmation(null); setLots(''); setLimitPrice(''); setError('');
};
return (
Place Order — {contract.symbol}
Lot: {contract.lotSize.toLocaleString()} units
{/* Long / Short toggle */}
{['long', 'short'].map(d => (
))}
{/* Order type pills */}
{['market', 'limit', 'stop'].map(t => (
))}
{/* Limit/Stop price */}
{orderType !== 'market' && (
setLimitPrice(e.target.value)}
placeholder={`Spot: ₢${commodity.price}`}
style={{
width: '100%', padding: '7px 10px', background: 'var(--bg)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem',
outline: 'none', boxSizing: 'border-box',
}}
/>
)}
{/* Lots */}
setLots(e.target.value)}
placeholder="0" min="1"
style={{
flex: 1, padding: '7px 10px', background: 'var(--bg)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', outline: 'none',
}}
/>
{[1, 5, 10, 25].map(n => (
))}
{/* Summary */}
Price / Unit₢{effectivePrice.toLocaleString()}
Total Units{totalUnits.toLocaleString()}
Notional Value₢{notional.toLocaleString()}
Margin ({contract.marginPct}%)₢{marginRequired.toLocaleString()}
Commission₢{commission.toLocaleString()}
Required
₢{(marginRequired + commission).toLocaleString()}
{error &&
{error}
}
{/* Confirmation */}
{confirmation && (
setConfirmation(null)}>
e.stopPropagation()}>
Confirm {confirmation.orderType} Order
Direction: {confirmation.direction.toUpperCase()}
Contract: {contract.symbol} ({contract.name})
Lots: {confirmation.lots} ({confirmation.totalUnits.toLocaleString()} units)
Price: ₢{confirmation.price.toLocaleString()}/unit
Notional: ₢{confirmation.notional.toLocaleString()}
Margin: ₢{confirmation.marginRequired.toLocaleString()}
Commission: ₢{confirmation.commission.toLocaleString()}
)}
);
}
/* ── Positions Panel (margin account + open positions) ── */
function PositionsPanel({ positions, commodities, credits, usedMargin }) {
const totalUnrealized = positions.reduce((sum, p) => {
const c = commodities.find(c => c.symbol === p.symbol);
if (!c) return sum;
const currentValue = c.price * p.totalUnits;
const entryValue = p.avgEntry * p.totalUnits;
const diff = p.direction === 'long' ? currentValue - entryValue : entryValue - currentValue;
return sum + diff;
}, 0);
const totalPositionValue = positions.reduce((sum, p) => {
const c = commodities.find(c => c.symbol === p.symbol);
return sum + (c ? c.price * p.totalUnits : 0);
}, 0);
const marginUtilization = credits > 0 ? ((usedMargin / credits) * 100) : 0;
return (
Open Positions
= 0 ? 'var(--green)' : 'var(--red)' }}>
{totalUnrealized >= 0 ? '▲' : '▼'} ₢{Math.abs(totalUnrealized).toLocaleString()}
{/* Account summary */}
Balance
₢{credits.toLocaleString()}
Used Margin
₢{usedMargin.toLocaleString()}
Exposure
₢{totalPositionValue.toLocaleString()}
{/* Margin utilization bar */}
MARGIN UTILIZATION
80 ? 'var(--red)' : marginUtilization > 50 ? 'var(--accent)' : 'var(--green)' }}>
{marginUtilization.toFixed(1)}%
80 ? 'var(--red)' : marginUtilization > 50 ? 'var(--accent)' : 'var(--green)',
transition: 'width 0.3s ease',
}} />
{/* Position rows */}
{positions.length === 0 && (
No open positions
)}
{positions.map((p, i) => {
const c = commodities.find(c => c.symbol === p.symbol);
if (!c) return null;
const currentVal = c.price * p.totalUnits;
const entryVal = p.avgEntry * p.totalUnits;
const pnl = p.direction === 'long' ? currentVal - entryVal : entryVal - currentVal;
const pnlPct = entryVal > 0 ? (pnl / p.margin) * 100 : 0;
const contract = CONTRACTS.find(ct => ct.symbol === p.symbol);
return (
{p.symbol}
{p.direction === 'long' ? 'LONG' : 'SHORT'}
= 0 ? 'var(--green)' : 'var(--red)',
}}>
{pnl >= 0 ? '+' : ''}₢{pnl.toLocaleString()}
{p.lots} lot{p.lots > 1 ? 's' : ''} ({p.totalUnits.toLocaleString()} u.) @ ₢{p.avgEntry}
Margin: ₢{p.margin.toLocaleString()}
= 0 ? 'var(--green)' : 'var(--red)',
opacity: 0.6,
}} />
);
})}
);
}
/* ── Trade Feed ── */
function TradeFeed({ trades }) {
return (
Market Trades
{trades.map((t, i) => (
{t.time}
{t.lots}× {t.symbol}
₢{t.price.toLocaleString()}
{t.side === 'long' ? 'BID' : 'ASK'}
))}
);
}
/* ──────────────────────────────────────────────
Main MarketDemo component
────────────────────────────────────────────── */
function MarketDemo() {
const [credits, setCredits] = useState(250000);
const [selectedSymbol, setSelectedSymbol] = useState('VLD');
const [category, setCategory] = useState('all');
const [notifications, setNotifications] = useState([]);
/* Generate live commodity data */
const [commodities, setCommodities] = useState(() => {
return CONTRACTS.map(ct => {
const history = []; let p = ct.basePrice;
for (let i = 0; i < 60; i++) {
p = Math.max(ct.basePrice * 0.7, Math.min(ct.basePrice * 1.3, p + (Math.random() - 0.48) * ct.basePrice * 0.04));
history.push(Math.round(p * 100) / 100);
}
const price = history[history.length - 1];
const prev = history[history.length - 2];
const settlement = history[Math.floor(history.length / 2)];
const bestBid = Math.round((price - ct.basePrice * 0.005) * 100) / 100;
const bestAsk = Math.round((price + ct.basePrice * 0.005) * 100) / 100;
return {
...ct,
price, prevPrice: prev,
change: price - prev,
changePct: ((price - prev) / prev) * 100,
history, settlement, bestBid, bestAsk,
volume: Math.floor(Math.random() * 80000 + 5000),
openInterest: Math.floor(Math.random() * 200000 + 10000),
open: history[0],
high: Math.max(...history),
low: Math.min(...history),
supply: Math.floor(Math.random() * 50000 + 10000),
demand: Math.floor(Math.random() * 60000 + 8000),
};
});
});
/* Open positions */
const [positions, setPositions] = useState([
{ symbol: 'VLD', direction: 'long', lots: 5, totalUnits: 5000, avgEntry: 13.20, margin: 5280 },
{ symbol: 'TRI', direction: 'long', lots: 3, totalUnits: 15000, avgEntry: 4.80, margin: 2160 },
{ symbol: 'ARK', direction: 'short', lots: 2, totalUnits: 100, avgEntry: 635, margin: 25400 },
]);
/* Order book for selected */
const selected = commodities.find(c => c.symbol === selectedSymbol) || commodities[0];
const selectedContract = CONTRACTS.find(c => c.symbol === selectedSymbol) || CONTRACTS[0];
const orderBook = useMemo(() => {
const p = selected.price;
const bids = []; const asks = [];
let bidCum = 0; let askCum = 0;
for (let i = 0; i < 10; i++) {
const bv = Math.floor(Math.random() * 12000 + 500); bidCum += bv;
bids.push({ price: Math.max(0.01, Math.round((p - (i + 1) * selectedContract.tickSize * 3) * 100) / 100), volume: bv, cumulative: bidCum });
const av = Math.floor(Math.random() * 12000 + 500); askCum += av;
asks.push({ price: Math.round((p + (i + 1) * selectedContract.tickSize * 3) * 100) / 100, volume: av, cumulative: askCum });
}
return { bids, asks };
}, [selected.price, selectedSymbol, selectedContract.tickSize]);
const usedMargin = positions.reduce((s, p) => s + p.margin, 0);
/* Price chart data */
const chartData = useMemo(() => {
return selected.history.map((c, i) => {
const noise = selected.price * 0.01;
return {
open: selected.history[Math.max(0, i - 1)] || c,
high: c + Math.random() * noise * 2,
low: Math.max(0.01, c - Math.random() * noise * 2),
close: c,
volume: Math.floor(Math.random() * 5000 + 500),
};
});
}, [selected]);
/* Trade feed */
const [tradeFeed, setTradeFeed] = useState(() => {
const feed = [];
const syms = CONTRACTS.map(c => c.symbol);
for (let i = 0; i < 20; i++) {
const sym = syms[Math.floor(Math.random() * syms.length)];
const ct = CONTRACTS.find(c => c.symbol === sym);
const c = commodities.find(c => c.symbol === sym);
feed.push({
time: `${14}:${String(22 + i).padStart(2, '0')}`,
symbol: sym,
lots: Math.floor(Math.random() * 10 + 1),
price: c ? Math.round(c.price * 100) / 100 : ct.basePrice,
side: Math.random() > 0.5 ? 'long' : 'short',
});
}
return feed.reverse();
});
/* Live price ticks */
useEffect(() => {
const interval = setInterval(() => {
setCommodities(prev => prev.map(c => {
const ct = CONTRACTS.find(x => x.symbol === c.symbol);
const delta = (Math.random() - 0.48) * c.price * 0.006;
const newPrice = Math.max(0.01, Math.round((c.price + delta) * 100) / 100);
const history = [...c.history.slice(1), newPrice];
const change = newPrice - c.prevPrice;
const spread = ct.basePrice * 0.005;
return {
...c, price: newPrice, history,
volume: c.volume + Math.floor(Math.random() * 300),
change, changePct: c.prevPrice > 0 ? (change / c.prevPrice) * 100 : 0,
high: Math.max(c.high, newPrice), low: Math.min(c.low, newPrice),
bestBid: Math.round((newPrice - spread) * 100) / 100,
bestAsk: Math.round((newPrice + spread) * 100) / 100,
openInterest: c.openInterest + Math.floor(Math.random() * 200 - 80),
supply: c.supply + Math.floor(Math.random() * 200 - 100),
demand: c.demand + Math.floor(Math.random() * 200 - 80),
};
}));
}, 2500);
return () => clearInterval(interval);
}, []);
/* New trades in feed */
useEffect(() => {
const interval = setInterval(() => {
const syms = CONTRACTS.map(c => c.symbol);
const sym = syms[Math.floor(Math.random() * syms.length)];
const c = commodities.find(c => c.symbol === sym);
const ct = CONTRACTS.find(c => c.symbol === sym);
const now = new Date();
setTradeFeed(prev => [{
time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`,
symbol: sym,
lots: Math.floor(Math.random() * 15 + 1),
price: c ? c.price : ct.basePrice,
side: Math.random() > 0.5 ? 'long' : 'short',
}, ...prev].slice(0, 40));
}, 3000);
return () => clearInterval(interval);
}, [commodities]);
const addNotif = (msg, color) => {
const id = Date.now();
setNotifications(prev => [...prev, { id, msg, color }]);
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3500);
};
const handleTrade = (direction, symbol, price, lots, totalUnits, marginRequired, commission) => {
/* Deduct commission + margin from credits */
setCredits(prev => prev - commission - marginRequired);
setPositions(prev => {
const existing = prev.find(p => p.symbol === symbol && p.direction === direction);
if (existing) {
/* Merge into existing position — weighted average entry */
const newUnits = existing.totalUnits + totalUnits;
const newAvg = Math.round(((existing.avgEntry * existing.totalUnits) + (price * totalUnits)) / newUnits * 100) / 100;
const newMargin = existing.margin + marginRequired;
return prev.map(p => p.symbol === symbol && p.direction === direction
? { ...p, lots: p.lots + lots, totalUnits: newUnits, avgEntry: newAvg, margin: newMargin }
: p);
}
return [...prev, { symbol, direction, lots, totalUnits, avgEntry: price, margin: marginRequired }];
});
addNotif(
`${direction === 'long' ? 'LONG' : 'SHORT'} ${lots}× ${symbol} (${totalUnits.toLocaleString()} u.) @ ₢${price}`,
direction === 'long' ? 'var(--green)' : 'var(--red)'
);
const now = new Date();
setTradeFeed(prev => [{
time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`,
symbol, lots, price, side: direction,
}, ...prev].slice(0, 40));
};
/* Market stats */
const totalVolume = commodities.reduce((s, c) => s + c.volume, 0);
const totalOI = commodities.reduce((s, c) => s + c.openInterest, 0);
const advancers = commodities.filter(c => c.change >= 0).length;
const decliners = commodities.length - advancers;
const tickerItems = commodities.slice(0, 12).map(c => ({
symbol: c.symbol, price: c.price, change: c.change, changePct: c.changePct,
}));
const spread = selectedContract ? Math.round((selected.bestAsk - selected.bestBid) * 100) / 100 : 0;
const spreadPct = selected.bestBid > 0 ? (spread / selected.bestBid) * 100 : 0;
const midPrice = Math.round(((selected.bestBid + selected.bestAsk) / 2) * 100) / 100;
return (
e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}>← Back to Docs
{/* Ticker */}
{/* Header bar */}
COMMODITIES EXCHANGE
SESSION
OPEN
{advancers} ▲
{decliners} ▼
TOTAL VOL
{(totalVolume / 1e6).toFixed(2)}M
OPEN INT.
{(totalOI / 1e6).toFixed(1)}M
ACCOUNT (₢ = ISK)
₢{credits.toLocaleString()}
{/* Category tabs */}
{/* Notifications */}
{notifications.map(n => (
{n.msg}
))}
{/* Main grid: Left (board + chart + feed) | Right (specs + depth + order + positions) */}
{/* Left column */}
{/* Contract board */}
{/* Price chart */}
{selected.symbol}
{selected.name}
O:₢{selected.open} H:₢{selected.high} L:₢{selected.low}
₢{selected.price.toLocaleString()}
= 0 ? 'var(--green)' : 'var(--red)',
}}>
{selected.change >= 0 ? '+' : ''}{selected.change.toFixed(2)} ({selected.changePct >= 0 ? '+' : ''}{selected.changePct.toFixed(2)}%)
{/* Trade feed */}
{/* Right column */}
{/* Contract spec */}
{/* Depth chart */}
{/* Order book */}
{/* Order form */}
{/* Positions */}
);
}
window.GDD.MarketDemo = MarketDemo;