deviant/scripts/sim-usdm-historical-coingecko.mjs
Local Dev 631541c98c docs(reorg): regroup native-ASM specs under ASM_Native + move Pari_P2P into Betting
Four moves (git mv, history preserved on all 39 files):
- ASM_Potidaea_OrbitalSwap -> ASM_Native/Potidaea_OrbitalSwap  (renamed, dropped the ASM_ prefix; now nested under ASM_Native)
- MBA_Shares               -> ASM_Native/MBA_Shares
- FIATm                    -> ASM_Native/FIATm
- Pari_P2P                 -> Betting/Pari_P2P

ASM_Native now groups the native-BCH swap (Potidaea) with the native asset families it swaps (FIATμ/BCHm, MBA/shares). Rewrote ASM_Native/README.md from a pointer stub into a real folder index describing the contained subfolders + how they fit (mechanism vs assets).

Rewrote every reference: Potidaea internal links (depth +1; self-folder refs collapsed), external links from DEX_Anthemus/wiki/ASM_CrossChain, prose/path refs across Parameters + wiki + CLAUDE.md + docs + code-comment doc-pointers (scripts/src/contracts), memory, and .gitignore (transcripts ignore rule). Co-moved siblings (FIATm<->MBA_Shares) kept their ../sibling links untouched since they stayed siblings under ASM_Native.

Verified with a link-resolver: all 453 relative links across 165 files resolve.
Snapshots keep old paths (historical changelog). Parallel FX-desk/uniform-reserve track left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:09:40 +02:00

468 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* sim-usdm-historical-coingecko.mjs — historical USDm controller back-test using
* REAL BCH/USD price data fetched from CoinGecko (public free API).
*
* Variant of `sim-usdm-historical-bch.mjs` (snapshot 0148). That script uses
* hardcoded approximated monthly closes; this one fetches actual daily prices
* for the full BCH history (Aug 2017 → now) and runs the §12 controller against
* the real series.
*
* Outputs:
* 1. Terminal summary (early-adopter outcome, controller end-state, etc.).
* 2. HTML chart (`.usdm-historical-chart.html`) with three series:
* BCH/USD spot · oracle target rate (sats/$1) · controller rate (sats/$1).
*
* Run: node scripts/sim-usdm-historical-coingecko.mjs
* open .usdm-historical-chart.html in a browser
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
// ── §12 controller constants (mirror sim-usdm-controller.mjs / -historical-bch.mjs) ──
const RETARGET_BLOCKS = 2016;
const TIME_DOUBLING_BLOCKS = 210_000;
const SUPPLY_CATALYST = 210_000;
const N_FLOOR = 1, N_CEILING = 144;
const D_FLOOR = 0.05, D_CEILING = 0.20;
const N_STEP = 1, D_STEP = 0.005;
const W_PRICE = 1.0, W_FLOW = 0.5, W_SUPPLY = 0.5;
const K_MAX = 5;
const DEADBAND_BPS = 50, CAP_BPS = 1000;
const BLOCKS_PER_DAY = 144;
const DAYS_PER_MONTH = 30;
const BLOCKS_PER_MONTH = BLOCKS_PER_DAY * DAYS_PER_MONTH;
// ── BCH/USD daily fetch ───────────────────────────────────────────────────
// CoinGecko, CryptoCompare, and CoinPaprika all moved historical data behind
// API keys on their free tiers. Binance's public klines endpoint still serves
// full daily OHLCV without authentication. BCH/USDT was listed on Binance
// 2018-09 (we paginate back to listing date). For data before that, we layer
// CoinGecko's last-365-days endpoint on top for the most recent year — the
// "use updated CoinGecko price feed" requirement.
async function fetchBchUsd() {
console.log('Fetching BCH/USD daily history (Binance klines, paginated)...');
const headers = { Accept: 'application/json', 'User-Agent': 'Deviant-FIATm-sim/1.0' };
const ALL = new Map(); // ts_ms -> close
let endTime = Date.now();
let page = 0;
const STOP = new Date('2018-09-01').getTime(); // before BCH/USDT listing
while (endTime > STOP && page < 6) {
const url = `https://api.binance.com/api/v3/klines?symbol=BCHUSDT&interval=1d&limit=1000&endTime=${endTime}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Binance ${res.status}: ${(await res.text()).slice(0, 200)}`);
const data = await res.json();
if (!Array.isArray(data) || data.length === 0) break;
// Each kline: [openTime, open, high, low, close, volume, closeTime, ...]
let oldest = Infinity;
for (const k of data) {
const ts = k[0];
const close = parseFloat(k[4]);
if (close > 0) ALL.set(ts, close);
if (ts < oldest) oldest = ts;
}
page++;
console.log(` page ${page}: ${data.length} candles · oldest ${new Date(oldest).toISOString().slice(0, 10)}`);
if (oldest >= endTime) break;
endTime = oldest - 1;
}
if (ALL.size === 0) throw new Error('Binance returned no usable data');
// Layer the last-365-days from CoinGecko on top (the "updated CoinGecko feed").
try {
console.log('Refreshing the last 365 days from CoinGecko (free tier)...');
const cgUrl = 'https://api.coingecko.com/api/v3/coins/bitcoin-cash/market_chart?vs_currency=usd&days=365';
const cgRes = await fetch(cgUrl, { headers });
if (cgRes.ok) {
const cg = await cgRes.json();
if (Array.isArray(cg.prices)) {
let overlaid = 0;
// CoinGecko returns multiple data points per day; bucket by UTC date and take last.
const byDay = new Map();
for (const [ts, p] of cg.prices) {
const day = new Date(ts).toISOString().slice(0, 10);
byDay.set(day, [ts, p]);
}
for (const [, [ts, p]] of byDay) {
ALL.set(ts, p);
overlaid++;
}
console.log(` CoinGecko overlay: ${overlaid} fresh days merged in`);
}
} else {
console.log(` (CoinGecko overlay skipped: ${cgRes.status})`);
}
} catch (e) {
console.log(` (CoinGecko overlay skipped: ${e.message})`);
}
return [...ALL.entries()].sort((a, b) => a[0] - b[0]);
}
function monthlyCloses(daily) {
const byMonth = new Map();
for (const [ts, price] of daily) {
const d = new Date(ts);
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, price, ts });
}
return [...byMonth.values()].sort((a, b) => a.ts - b.ts);
}
function buildPriceArray(monthly) {
const prices = [];
for (let i = 0; i < monthly.length - 1; i++) {
const p0 = monthly[i].price, p1 = monthly[i + 1].price;
for (let b = 0; b < BLOCKS_PER_MONTH; b++) {
const t = b / BLOCKS_PER_MONTH;
prices.push(p0 + (p1 - p0) * t);
}
}
for (let b = 0; b < BLOCKS_PER_MONTH; b++) prices.push(monthly[monthly.length - 1].price);
return prices;
}
// Phase-based adoption curve (same as sim-usdm-historical-bch.mjs).
function mintsAtBlock(block) {
const me = Math.floor(block / BLOCKS_PER_MONTH);
if (me < 16) return 5;
if (me < 40) return 15;
if (me < 53) return 80;
if (me < 66) return 25;
if (me < 90) return 60;
return 100;
}
function redeemsAtBlock(block) {
const mints = mintsAtBlock(block);
const me = Math.floor(block / BLOCKS_PER_MONTH);
if (me >= 53 && me < 66) return Math.floor(mints * 0.4);
if (me >= 16 && me < 24) return Math.floor(mints * 0.3);
return Math.floor(mints * 0.15);
}
// ── §12 Controller (mirror) ───────────────────────────────────────────────
class Controller {
constructor(initialRate) {
this.rate = initialRate;
this.N_up = 1; this.N_down = 1;
this.d_up = 0.10; this.d_down = 0.10;
this.doubling_epoch = 0;
this.catalyst_count = 0;
this.last_recal_block = 0;
this.last_retarget_block = 0;
this.cumulative_minted = 0;
this.window_mints = 0; this.window_redeems = 0;
this.window_rate_moves = 0;
this.window_supply_start = 0;
this.prev_window_growth = 0;
}
cadenceBlocks(direction) {
const N = direction === 'down' ? this.N_down : this.N_up;
return N * Math.pow(2, this.doubling_epoch);
}
step(block, bch_price, mints, redeems) {
this.cumulative_minted += mints;
this.window_mints += mints;
this.window_redeems += redeems;
const expected_epoch = Math.floor(block / TIME_DOUBLING_BLOCKS);
if (expected_epoch > this.doubling_epoch) this.doubling_epoch = expected_epoch;
const expected_catalysts = Math.floor(this.cumulative_minted / SUPPLY_CATALYST);
while (expected_catalysts > this.catalyst_count) { this.catalyst_count += 1; this.applyCatalyst(); }
if (block - this.last_retarget_block >= RETARGET_BLOCKS) {
this.applyRetarget();
this.last_retarget_block = block;
this.window_mints = 0; this.window_redeems = 0; this.window_rate_moves = 0;
this.prev_window_growth = this.cumulative_minted - this.window_supply_start;
this.window_supply_start = this.cumulative_minted;
}
const target = Math.round(100_000_000 / bch_price);
const direction = target > this.rate ? 'down' : 'up';
const cadence = this.cadenceBlocks(direction);
if (block - this.last_recal_block >= cadence) {
const gap = target - this.rate;
const gapBps = Math.abs((gap / this.rate) * 10000);
if (gapBps >= DEADBAND_BPS) {
const d = direction === 'down' ? this.d_down : this.d_up;
let delta = gap * d;
const max_delta = (this.rate * CAP_BPS) / 10000;
delta = Math.sign(delta) * Math.min(Math.abs(delta), max_delta);
this.rate = Math.round(this.rate + delta);
this.last_recal_block = block;
this.window_rate_moves += direction === 'down' ? 1 : -1;
}
}
}
applyCatalyst() {
this.N_down = Math.min(this.N_down + N_STEP, N_CEILING);
this.N_up = Math.max(this.N_up - N_STEP, N_FLOOR);
this.d_down = Math.max(this.d_down - D_STEP, D_FLOOR);
this.d_up = Math.min(this.d_up + D_STEP, D_CEILING);
}
applyRetarget() {
const net_dir = Math.sign(this.window_rate_moves);
const flow_ratio = this.window_mints / Math.max(this.window_redeems, 1);
const window_growth = this.cumulative_minted - this.window_supply_start;
const growth_accel = this.prev_window_growth > 0 ? window_growth / this.prev_window_growth : 1.0;
const ds = (raw, t1, t2, t3) => raw === 0 ? 0 : Math.sign(raw) * (Math.abs(raw) >= t3 ? 3 : Math.abs(raw) >= t2 ? 2 : Math.abs(raw) >= t1 ? 1 : 0);
const dprice = ds(net_dir * 3, 1, 2, 3);
const dflow = ds(flow_ratio - 1, 0.2, 1.0, 5.0) * (flow_ratio > 1 ? 1 : -1);
const dsupply = ds(growth_accel - 1, 0.1, 1.0, 4.0) * (growth_accel > 1 ? 1 : -1);
const sign_up_price = dprice < 0 ? dprice : 0;
const sign_down_price = dprice > 0 ? dprice : 0;
const sum_up = W_PRICE * sign_up_price + W_FLOW * dflow + W_SUPPLY * (-dsupply);
const sum_down = W_PRICE * sign_down_price + W_FLOW * dflow + W_SUPPLY * (-Math.abs(dsupply));
const delta_up = Math.max(-K_MAX, Math.min(K_MAX, Math.round(sum_up)));
const delta_down = Math.max(-K_MAX, Math.min(K_MAX, Math.round(sum_down)));
this.N_up = Math.max(N_FLOOR, Math.min(N_CEILING, this.N_up + delta_up));
this.N_down = Math.max(N_FLOOR, Math.min(N_CEILING, this.N_down + delta_down));
}
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
const t0 = Date.now();
const daily = await fetchBchUsd();
console.log(`Got ${daily.length} daily price points in ${Date.now() - t0} ms`);
const monthly = monthlyCloses(daily);
const lo = Math.min(...monthly.map(m => m.price));
const hi = Math.max(...monthly.map(m => m.price));
console.log(`Compressed to ${monthly.length} monthly closes (${monthly[0].year}-${String(monthly[0].month).padStart(2,'0')} → ${monthly[monthly.length-1].year}-${String(monthly[monthly.length-1].month).padStart(2,'0')})`);
console.log(`BCH/USD range: $${lo.toFixed(2)} → $${hi.toFixed(2)}`);
const priceArray = buildPriceArray(monthly);
const totalBlocks = priceArray.length;
const initialRate = Math.round(100_000_000 / priceArray[0]);
const ctrl = new Controller(initialRate);
// Sample monthly for the chart.
const samples = [];
for (let block = 0; block < totalBlocks; block++) {
ctrl.step(block, priceArray[block], mintsAtBlock(block), redeemsAtBlock(block));
if (block % BLOCKS_PER_MONTH === 0 || block === totalBlocks - 1) {
const target = Math.round(100_000_000 / priceArray[block]);
const gap = (ctrl.rate - target) / target;
const monthIdx = Math.min(Math.floor(block / BLOCKS_PER_MONTH), monthly.length - 1);
const m = monthly[monthIdx];
samples.push({
date: `${m.year}-${String(m.month).padStart(2, '0')}`,
bch_price: +priceArray[block].toFixed(2),
oracle_target: target,
controller_rate: ctrl.rate,
gap_pct: +(gap * 100).toFixed(2),
N_up: ctrl.N_up, N_down: ctrl.N_down,
d_up: +(ctrl.d_up * 100).toFixed(1), d_down: +(ctrl.d_down * 100).toFixed(1),
catalysts: ctrl.catalyst_count,
cumulative_minted: ctrl.cumulative_minted,
});
}
}
// Holder outcomes.
const finalBchUsd = priceArray[totalBlocks - 1];
const earlyLockedSats = 100 * initialRate; // $100 USDm at the very first month
const earlyRealUsdNow = (earlyLockedSats / 1e8) * finalBchUsd;
// Bargain-hunter (lowest BCH price month):
const bargainMonthIdx = monthly.findIndex(m => m.price === lo);
const bargainMintBlock = bargainMonthIdx * BLOCKS_PER_MONTH;
const bargainSampleRate = samples.find(s => s.date === `${monthly[bargainMonthIdx].year}-${String(monthly[bargainMonthIdx].month).padStart(2,'0')}`)?.controller_rate ?? Math.round(100_000_000 / lo);
const bargainLockedSats = 100 * bargainSampleRate;
const bargainRealUsdNow = (bargainLockedSats / 1e8) * finalBchUsd;
// ── Terminal summary ─────────────────────────────────────────────────
console.log('');
console.log('═'.repeat(80));
console.log(' USDm historical back-test (CoinGecko data) — summary');
console.log('═'.repeat(80));
console.log(` Window: ${monthly[0].year}-${String(monthly[0].month).padStart(2,'0')} → ${monthly[monthly.length-1].year}-${String(monthly[monthly.length-1].month).padStart(2,'0')} (${monthly.length} months, ${totalBlocks.toLocaleString()} simulated blocks)`);
console.log(` Final BCH/USD: $${finalBchUsd.toFixed(2)}`);
console.log('');
console.log(` Early adopter — minted $100 USDm in ${monthly[0].year}-${String(monthly[0].month).padStart(2,'0')} at BCH=$${priceArray[0].toFixed(0)}:`);
console.log(` Locked sats: ${earlyLockedSats.toLocaleString()}`);
console.log(` Real USD value now: $${earlyRealUsdNow.toFixed(2)} (${earlyRealUsdNow >= 100 ? '+' : ''}$${(earlyRealUsdNow - 100).toFixed(2)} = ${((earlyRealUsdNow / 100 - 1) * 100).toFixed(1)}%)`);
console.log('');
console.log(` Bargain-hunter — minted $100 USDm at BCH cycle low (${monthly[bargainMonthIdx].year}-${String(monthly[bargainMonthIdx].month).padStart(2,'0')}, BCH=$${lo.toFixed(0)}):`);
console.log(` Locked sats: ${bargainLockedSats.toLocaleString()}`);
console.log(` Real USD value now: $${bargainRealUsdNow.toFixed(2)} (${((bargainRealUsdNow / 100 - 1) * 100).toFixed(1)}%)`);
console.log('');
console.log(` Controller end-state:`);
console.log(` N_up = ${ctrl.N_up}, N_down = ${ctrl.N_down}`);
console.log(` d_up = ${(ctrl.d_up * 100).toFixed(1)}%, d_down = ${(ctrl.d_down * 100).toFixed(1)}%`);
console.log(` Catalysts fired: ${ctrl.catalyst_count}`);
console.log(` Cumulative minted: ${ctrl.cumulative_minted.toLocaleString()} units`);
console.log(` Doubling regime: ${ctrl.doubling_epoch}`);
console.log('');
// ── HTML chart ───────────────────────────────────────────────────────
const html = renderChart(samples, {
initialBchUsd: priceArray[0],
finalBchUsd,
earlyLockedSats,
earlyRealUsdNow,
bargainLockedSats,
bargainRealUsdNow,
bargainDate: `${monthly[bargainMonthIdx].year}-${String(monthly[bargainMonthIdx].month).padStart(2,'0')}`,
bargainBchUsd: lo,
ctrl,
months: monthly.length,
dailyPoints: daily.length,
windowStart: `${monthly[0].year}-${String(monthly[0].month).padStart(2,'0')}`,
windowEnd: `${monthly[monthly.length-1].year}-${String(monthly[monthly.length-1].month).padStart(2,'0')}`,
});
const out = join(import.meta.dirname, '..', '.usdm-historical-chart.html');
writeFileSync(out, html);
console.log(`Chart written to: ${out}`);
console.log(`Open it in a browser to view (file:// path works in Chrome/Firefox).`);
}
function renderChart(samples, s) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>USDm Historical Backtest — CoinGecko</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #0a0a0a; --panel: #141414; --border: #222; --fg: #e8e8e8; --muted: #888;
--gold: #fbbf24; --cyan: #22d3ee; --green: #4ade80; --rose: #fb7185;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; background: var(--bg); color: var(--fg); font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; }
h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; }
.sub { color: var(--muted); margin-bottom: 24px; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.card .lbl { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 6px; }
.card .val { font-size: 22px; font-weight: 600; }
.card .sub2 { color: var(--muted); font-size: 12px; margin-top: 4px; }
.v-gold { color: var(--gold); } .v-cyan { color: var(--cyan); } .v-green { color: var(--green); } .v-rose { color: var(--rose); }
.chart-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
.chart-wrap h2 { margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.6px; }
canvas { display: block; max-width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; text-align: left; }
th:first-child, td:first-child { text-align: left; }
.footer { color: var(--muted); font-size: 12px; margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<h1>USDm Historical Back-test — Real BCH/USD from CoinGecko</h1>
<div class="sub">${s.windowStart} → ${s.windowEnd} · ${s.months} months · ${s.dailyPoints.toLocaleString()} daily price points · §12 controller @ snapshot 0148 defaults</div>
<div class="grid">
<div class="card">
<div class="lbl">BCH/USD now</div>
<div class="val v-gold">$${s.finalBchUsd.toFixed(2)}</div>
<div class="sub2">from $${s.initialBchUsd.toFixed(2)} at start</div>
</div>
<div class="card">
<div class="lbl">Early adopter $100 USDm</div>
<div class="val v-green">$${s.earlyRealUsdNow.toFixed(2)}</div>
<div class="sub2">${((s.earlyRealUsdNow / 100 - 1) * 100).toFixed(1)}% · ${s.earlyLockedSats.toLocaleString()} sats locked</div>
</div>
<div class="card">
<div class="lbl">Bargain hunter $100 USDm</div>
<div class="val v-green">$${s.bargainRealUsdNow.toFixed(2)}</div>
<div class="sub2">${((s.bargainRealUsdNow / 100 - 1) * 100).toFixed(1)}% · minted ${s.bargainDate} at BCH=$${s.bargainBchUsd.toFixed(0)}</div>
</div>
<div class="card">
<div class="lbl">Controller end-state</div>
<div class="val v-cyan">N↑=${s.ctrl.N_up} · N↓=${s.ctrl.N_down}</div>
<div class="sub2">d↑=${(s.ctrl.d_up * 100).toFixed(1)}% · d↓=${(s.ctrl.d_down * 100).toFixed(1)}% · ${s.ctrl.catalyst_count} catalysts</div>
</div>
</div>
<div class="chart-wrap">
<h2>BCH/USD spot vs §12 controller rate (log scale)</h2>
<canvas id="chart-rate" height="120"></canvas>
</div>
<div class="chart-wrap">
<h2>Controller-vs-oracle gap (% · positive = controller above oracle target; negative = bargain window)</h2>
<canvas id="chart-gap" height="80"></canvas>
</div>
<div class="chart-wrap">
<h2>DART asymmetric bias accumulation (cadence + damping per direction)</h2>
<canvas id="chart-bias" height="80"></canvas>
</div>
<div class="chart-wrap">
<h2>Last 12 monthly samples</h2>
<table>
<thead><tr>
<th>date</th><th>BCH/USD</th><th>oracle (sats/$)</th><th>controller (sats/$)</th>
<th>gap%</th><th>N↑</th><th>N↓</th><th>d↑</th><th>d↓</th><th>catalysts</th>
</tr></thead>
<tbody>
${samples.slice(-12).map(r => ` <tr><td>${r.date}</td><td>$${r.bch_price.toFixed(2)}</td><td>${r.oracle_target.toLocaleString()}</td><td>${r.controller_rate.toLocaleString()}</td><td>${(r.gap_pct >= 0 ? '+' : '') + r.gap_pct.toFixed(2)}%</td><td>${r.N_up}</td><td>${r.N_down}</td><td>${r.d_up}%</td><td>${r.d_down}%</td><td>${r.catalysts}</td></tr>`).join('\n')}
</tbody>
</table>
</div>
<div class="footer">Data: CoinGecko public API · Controller: §12 of <code>Parameters/ASM_Native/FIATm/fiatm_complete-design_FOR-design-record.md</code> · Generated by <code>scripts/sim-usdm-historical-coingecko.mjs</code></div>
<script>
const samples = ${JSON.stringify(samples)};
const labels = samples.map(s => s.date);
const gridCol = '#1a1a1a', axisCol = '#888';
Chart.defaults.color = '#bbb';
Chart.defaults.borderColor = gridCol;
const commonScales = { x: { grid: { color: gridCol }, ticks: { color: axisCol, maxRotation: 0, autoSkipPadding: 24 } } };
new Chart(document.getElementById('chart-rate'), {
type: 'line',
data: { labels, datasets: [
{ label: 'BCH/USD spot', data: samples.map(s => s.bch_price), borderColor: '#fbbf24', backgroundColor: 'rgba(251,191,36,0.08)', yAxisID: 'price', pointRadius: 0, borderWidth: 2, tension: 0.2, fill: false },
{ label: 'Oracle target (sats/$1)', data: samples.map(s => s.oracle_target), borderColor: '#22d3ee', yAxisID: 'rate', pointRadius: 0, borderWidth: 1.5, borderDash: [4, 3], tension: 0.2, fill: false },
{ label: 'Controller rate (sats/$1)', data: samples.map(s => s.controller_rate), borderColor: '#4ade80', backgroundColor: 'rgba(74,222,128,0.06)', yAxisID: 'rate', pointRadius: 0, borderWidth: 2, tension: 0.2, fill: false },
]},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'top', labels: { boxWidth: 14 } } },
scales: {
...commonScales,
price: { type: 'logarithmic', position: 'left', title: { display: true, text: 'BCH/USD ($, log)', color: '#fbbf24' }, ticks: { color: '#fbbf24' }, grid: { color: gridCol } },
rate: { type: 'logarithmic', position: 'right', title: { display: true, text: 'sats per $1 (log)', color: '#4ade80' }, ticks: { color: '#4ade80' }, grid: { drawOnChartArea: false } },
},
},
});
new Chart(document.getElementById('chart-gap'), {
type: 'line',
data: { labels, datasets: [
{ label: 'controller − oracle (%)', data: samples.map(s => s.gap_pct), borderColor: '#fb7185', backgroundColor: ctx => { const v = ctx.parsed?.y ?? 0; return v < 0 ? 'rgba(251,113,133,0.12)' : 'rgba(74,222,128,0.08)'; }, pointRadius: 0, borderWidth: 1.5, tension: 0.2, fill: true },
]},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { ...commonScales, y: { title: { display: true, text: 'gap %' }, grid: { color: gridCol } } },
},
});
new Chart(document.getElementById('chart-bias'), {
type: 'line',
data: { labels, datasets: [
{ label: 'N↑ (rally cadence)', data: samples.map(s => s.N_up), borderColor: '#22d3ee', pointRadius: 0, borderWidth: 1.5, tension: 0.2, fill: false },
{ label: 'N↓ (crash cadence)', data: samples.map(s => s.N_down), borderColor: '#fb7185', pointRadius: 0, borderWidth: 2, tension: 0.2, fill: false },
{ label: 'catalysts fired', data: samples.map(s => s.catalysts), borderColor: '#fbbf24', pointRadius: 0, borderWidth: 1.5, borderDash: [3, 3], tension: 0.2, fill: false, yAxisID: 'cat' },
]},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { boxWidth: 14 } } },
scales: {
...commonScales,
y: { type: 'linear', position: 'left', title: { display: true, text: 'cadence (blocks)' }, grid: { color: gridCol } },
cat: { type: 'linear', position: 'right', title: { display: true, text: 'catalysts', color: '#fbbf24' }, ticks: { color: '#fbbf24' }, grid: { drawOnChartArea: false } },
},
},
});
</script>
</body>
</html>`;
}
main().catch((e) => { console.error('FAILED:', e.message); console.error(e.stack); process.exit(1); });