// Pairs page — coin-by-coin ratio heatmap, color by effective score. const { Card, Pill, fmtNum, fmtPct, fmtTs } = window.UI; // adapt one apiPair (string decimals + updated_at_ms) to the table-cell // shape this page already renders. function adaptPair(p) { return { from: p.from, to: p.to, current: parseFloat(p.current_ratio || '0') || 0, baseline: parseFloat(p.baseline || '0') || 0, raw_score: parseFloat(p.raw_score || '0') || 0, eff_score: parseFloat(p.eff_score || '0') || 0, last_decision: 'observed', margin: 0, multiplier: 0, ema_alpha: 0, updated_at: p.updated_at_ms || 0, }; } function PagePairs({ app }) { const acct = (app.accounts || []).find(a => a.id === app.account) || null; const accountID = acct && acct.accountID; const [selected, setSelected] = React.useState(null); const { data: rawPairs, error, loading, refresh } = window.API.useAPI( () => accountID ? window.API.get('/api/v1/pairs', { account_id: accountID }) : Promise.resolve([]), [accountID], ); // Pairs come exclusively from the backend. The matrix is empty until // the strategy scout has populated baselines (M11.2). No mock fallback. const pairs = (rawPairs && Array.isArray(rawPairs)) ? rawPairs.map(adaptPair) : []; // Coin axis prefers any coin we've actually seen on a pair; otherwise // fall back to the configured account's coin list so the matrix // shows the empty grid rather than nothing at all. const coins = React.useMemo(() => { const set = new Set(); for (const p of pairs) { set.add(p.from); set.add(p.to); } if (set.size > 0) return Array.from(set).sort(); if (acct && Array.isArray(acct.coins)) { return acct.coins.filter(c => c !== acct.bridge).sort(); } return []; }, [pairs.length, acct && (acct.coins || []).join(','), acct && acct.bridge]); const findPair = (a, b) => pairs.find(p => p.from === a && p.to === b); // map effective score to color: -0.05 red → 0 grey → +0.05 green const cellColor = (eff) => { if (eff == null) return null; const t = Math.max(-0.05, Math.min(0.05, eff)) / 0.05; // -1..1 if (t > 0) { const a = Math.abs(t); return `rgba(95, 207, 149, ${0.06 + a * 0.45})`; } else { const a = Math.abs(t); return `rgba(224, 114, 98, ${0.06 + a * 0.45})`; } }; return (
effective score = raw_score − 2·fee − slippage_from − slippage_to − spread − safety. positive = jump candidate. {' · '} {loading && loading…} {error && api error} {!loading && !error && (pairs.length ? live : no pairs scouted yet)}
| from \ to | {coins.map(c => ({c} | ))}|
|---|---|---|
| {from} | {coins.map(to => { if (from === to) { const isHeld = !!(acct && from === acct.current_coin); return (
{isHeld ? (
held
{from}
) : (
·
)}
|
);
}
const p = findPair(from, to);
const isSelected = selected && selected.from === from && selected.to === to;
return (
setSelected(p)}
className={`text-center align-middle border-l border-t border-line-0 w-[88px] h-[58px] cursor-pointer transition-all ${isSelected ? 'outline outline-1 outline-inf-0 outline-offset-[-1px]' : 'hover:outline hover:outline-1 hover:outline-line-2 hover:outline-offset-[-1px]'}`}
style={{ backgroundColor: cellColor(p?.eff_score) }}
>
{p && (
0.005 ? 'text-pos-0' : p.eff_score < -0.005 ? 'text-neg-0' : 'text-ink-1'}`}>
{p.eff_score >= 0 ? '+' : ''}{(p.eff_score*100).toFixed(2)}
{fmtNum(p.current, 4)}
)}
|
);
})}