303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* sim-usdm-controller.mjs — simulator for the five-layer USDm controller
|
||
|
|
* (snapshot 0147, §12 of usdm_complete-design_FOR-design-record.md).
|
||
|
|
*
|
||
|
|
* Walks the controller through 5 scenarios so the dynamics become concrete:
|
||
|
|
* 1. Calm growth — flat BCH, steady minting
|
||
|
|
* 2. Sustained rally — BCH +100% over 6 months
|
||
|
|
* 3. Sustained crash — BCH -50% over 6 months
|
||
|
|
* 4. Flash crash + recovery — BCH -50% then back in 1 week
|
||
|
|
* 5. Adoption boom — BCH stable, rapid minting (many catalyst events)
|
||
|
|
*
|
||
|
|
* For each scenario it prints the controller's response across the timeline
|
||
|
|
* and a summary showing how the five layers interact.
|
||
|
|
*
|
||
|
|
* Run: node scripts/sim-usdm-controller.mjs
|
||
|
|
*/
|
||
|
|
|
||
|
|
// ── Controller constants ────────────────────────────────────────────────
|
||
|
|
const BLOCK_MIN = 10; // one block ≈ 10 minutes
|
||
|
|
const RETARGET_BLOCKS = 2016; // ~2 weeks
|
||
|
|
const TIME_DOUBLING_BLOCKS = 210_000; // ~4 years per doubling
|
||
|
|
const SUPPLY_CATALYST = 210_000; // 210k cumulative minted per catalyst
|
||
|
|
|
||
|
|
// Per-direction bounds (asymptotic targets reached via catalysts)
|
||
|
|
const N_FLOOR = 1;
|
||
|
|
const N_CEILING = 144;
|
||
|
|
const D_FLOOR = 0.05;
|
||
|
|
const D_CEILING = 0.20;
|
||
|
|
|
||
|
|
// Catalyst step sizes
|
||
|
|
const N_STEP = 1;
|
||
|
|
const D_STEP = 0.005;
|
||
|
|
|
||
|
|
// DART time-trigger weights and max step
|
||
|
|
const W_PRICE = 1.0;
|
||
|
|
const W_FLOW = 0.5;
|
||
|
|
const W_SUPPLY = 0.5;
|
||
|
|
const K_MAX = 5;
|
||
|
|
|
||
|
|
// Deadband + cap (Layer 5, simplified — real controller has more)
|
||
|
|
const DEADBAND_BPS = 50; // 0.5%
|
||
|
|
const CAP_BPS = 1000; // 10% max single step
|
||
|
|
|
||
|
|
// ── Controller state ────────────────────────────────────────────────────
|
||
|
|
class Controller {
|
||
|
|
constructor() {
|
||
|
|
this.rate = 200_000; // sats per $1 USDm (BCH = $500)
|
||
|
|
this.N_up = 1; // cadence multiplier rally side
|
||
|
|
this.N_down = 1; // cadence multiplier crash side
|
||
|
|
this.d_up = 0.10;
|
||
|
|
this.d_down = 0.10;
|
||
|
|
this.doubling_epoch = 0; // Layer 3
|
||
|
|
this.catalyst_count = 0; // Layer 2 sub-trigger count
|
||
|
|
this.last_recal_block = 0;
|
||
|
|
this.last_retarget_block = 0;
|
||
|
|
this.cumulative_minted = 0;
|
||
|
|
// For DART time-trigger signals collected over window:
|
||
|
|
this.window_mints = 0;
|
||
|
|
this.window_redeems = 0;
|
||
|
|
this.window_rate_moves = 0; // net direction over window
|
||
|
|
this.window_supply_start = 0;
|
||
|
|
this.prev_window_growth = 0; // for velocity comparison
|
||
|
|
}
|
||
|
|
|
||
|
|
// Effective cadence (in blocks) for a direction, accounting for time-doubling
|
||
|
|
cadenceBlocks(direction) {
|
||
|
|
const N = direction === 'down' ? this.N_down : this.N_up;
|
||
|
|
return N * Math.pow(2, this.doubling_epoch); // each N already represents 10-min units (= 1 block); doubling adds 2^epoch
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step: process one block
|
||
|
|
step(block, bch_price_usd, mints, redeems) {
|
||
|
|
this.cumulative_minted += mints;
|
||
|
|
this.window_mints += mints;
|
||
|
|
this.window_redeems += redeems;
|
||
|
|
|
||
|
|
// Layer 3 — time-doubling check
|
||
|
|
const expected_epoch = Math.floor(block / TIME_DOUBLING_BLOCKS);
|
||
|
|
if (expected_epoch > this.doubling_epoch) this.doubling_epoch = expected_epoch;
|
||
|
|
|
||
|
|
// Layer 2 sub-trigger — supply catalyst (every 210k minted)
|
||
|
|
const expected_catalysts = Math.floor(this.cumulative_minted / SUPPLY_CATALYST);
|
||
|
|
while (expected_catalysts > this.catalyst_count) {
|
||
|
|
this.catalyst_count += 1;
|
||
|
|
this.applyCatalyst();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Layer 2 main — DART time trigger (every 2016 blocks)
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Recalibration check
|
||
|
|
const target = oracleTarget(bch_price_usd);
|
||
|
|
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;
|
||
|
|
// Layer 5 — cap
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Layer 2 sub — supply catalyst applied
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Layer 2 main — DART time-trigger
|
||
|
|
applyRetarget() {
|
||
|
|
// Compute signals from window observations
|
||
|
|
const net_dir = Math.sign(this.window_rate_moves); // -1 = predominantly rally, +1 = predominantly crash, 0 = neutral
|
||
|
|
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;
|
||
|
|
|
||
|
|
// Quantize each signal to ±{0,1,2,3} based on strength
|
||
|
|
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); // net_dir already ±1
|
||
|
|
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);
|
||
|
|
|
||
|
|
// Per the design record §12a.1 sign rules per direction
|
||
|
|
// Sustained crash (dprice > 0) → increase N_down (slower crash; positive)
|
||
|
|
// Sustained rally (dprice < 0) → decrease N_up (faster rally; negative)
|
||
|
|
// Net flow: mint>redeem → both N's increase; redeem>mint → both N's decrease
|
||
|
|
// Supply weak → N_up up, N_down down; supply strong → both N's down
|
||
|
|
|
||
|
|
let sign_up_price = dprice < 0 ? dprice : 0; // rally (negative dprice) → reduce N_up (also negative)
|
||
|
|
let sign_down_price = dprice > 0 ? dprice : 0; // crash (positive dprice) → increase N_down (positive)
|
||
|
|
|
||
|
|
let sign_up_flow = dflow; // direct
|
||
|
|
let sign_down_flow = dflow; // direct
|
||
|
|
|
||
|
|
let sign_up_supply = -dsupply; // weak (negative dsupply) → positive (raise N_up); strong → negative (reduce N_up)
|
||
|
|
let sign_down_supply = -Math.abs(dsupply); // weak and strong both reduce N_down (signal magnitude only, always negative)
|
||
|
|
|
||
|
|
// Sum with weights + clamp
|
||
|
|
const sum_up = W_PRICE * sign_up_price + W_FLOW * sign_up_flow + W_SUPPLY * sign_up_supply;
|
||
|
|
const sum_down = W_PRICE * sign_down_price + W_FLOW * sign_down_flow + W_SUPPLY * sign_down_supply;
|
||
|
|
|
||
|
|
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)));
|
||
|
|
|
||
|
|
// Apply
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Oracle target: sats per $1 from BCH price in USD
|
||
|
|
function oracleTarget(bch_usd) {
|
||
|
|
return Math.round((100_000_000 / bch_usd));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Format helpers
|
||
|
|
const fmt = {
|
||
|
|
rate: (r) => `${r.toLocaleString().padStart(7)} sats/$`,
|
||
|
|
pct: (n) => `${(n * 100).toFixed(1)}%`,
|
||
|
|
bch: (p) => `$${p.toFixed(0)}`,
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Scenario runner ─────────────────────────────────────────────────────
|
||
|
|
function runScenario(name, description, priceFn, mintFn, redeemFn, blocks) {
|
||
|
|
console.log(`\n${'═'.repeat(72)}`);
|
||
|
|
console.log(` ${name}`);
|
||
|
|
console.log(` ${description}`);
|
||
|
|
console.log('═'.repeat(72));
|
||
|
|
|
||
|
|
const c = new Controller();
|
||
|
|
const snapshots = [];
|
||
|
|
const checkpoints = [0, 0.05, 0.15, 0.30, 0.50, 0.75, 1.0].map((f) => Math.floor(f * blocks));
|
||
|
|
|
||
|
|
for (let block = 0; block < blocks; block++) {
|
||
|
|
const bch_price = priceFn(block, blocks);
|
||
|
|
const mints = mintFn(block, blocks);
|
||
|
|
const redeems = redeemFn(block, blocks);
|
||
|
|
c.step(block, bch_price, mints, redeems);
|
||
|
|
if (checkpoints.includes(block)) {
|
||
|
|
snapshots.push({
|
||
|
|
block, bch_price,
|
||
|
|
rate: c.rate, target: oracleTarget(bch_price),
|
||
|
|
N_up: c.N_up, N_down: c.N_down,
|
||
|
|
d_up: c.d_up, d_down: c.d_down,
|
||
|
|
catalysts: c.catalyst_count, doubling: c.doubling_epoch,
|
||
|
|
minted: c.cumulative_minted,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Final snapshot
|
||
|
|
const finalPrice = priceFn(blocks - 1, blocks);
|
||
|
|
snapshots.push({
|
||
|
|
block: blocks - 1, bch_price: finalPrice,
|
||
|
|
rate: c.rate, target: oracleTarget(finalPrice),
|
||
|
|
N_up: c.N_up, N_down: c.N_down,
|
||
|
|
d_up: c.d_up, d_down: c.d_down,
|
||
|
|
catalysts: c.catalyst_count, doubling: c.doubling_epoch,
|
||
|
|
minted: c.cumulative_minted,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Print trajectory
|
||
|
|
console.log(`\n ${'Block'.padEnd(8)} ${'BCH'.padStart(6)} ${'oracle$/rate'.padStart(14)} ${'controller rate'.padStart(16)} ${'gap%'.padStart(7)} ${'N↑'.padStart(4)} ${'N↓'.padStart(4)} ${'d↑'.padStart(5)} ${'d↓'.padStart(5)} ${'catalysts'.padStart(10)}`);
|
||
|
|
console.log(` ${'-'.repeat(105)}`);
|
||
|
|
for (const s of snapshots) {
|
||
|
|
const gap = ((s.rate - s.target) / s.target) * 100;
|
||
|
|
console.log(
|
||
|
|
` ${String(s.block).padEnd(8)} ${fmt.bch(s.bch_price).padStart(6)} ${s.target.toLocaleString().padStart(14)} ${s.rate.toLocaleString().padStart(16)} ${gap.toFixed(1).padStart(6)}% ${String(s.N_up).padStart(4)} ${String(s.N_down).padStart(4)} ${(s.d_up * 100).toFixed(1).padStart(4)}% ${(s.d_down * 100).toFixed(1).padStart(4)}% ${String(s.catalysts).padStart(10)}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const final = snapshots[snapshots.length - 1];
|
||
|
|
const finalGap = ((final.rate - final.target) / final.target) * 100;
|
||
|
|
console.log(`\n Summary: rate vs oracle target gap at end = ${finalGap >= 0 ? '+' : ''}${finalGap.toFixed(1)}%`);
|
||
|
|
console.log(` Asymmetric bias: N_down/N_up = ${(final.N_down / final.N_up).toFixed(1)}x · d_up/d_down = ${(final.d_up / final.d_down).toFixed(2)}x`);
|
||
|
|
console.log(` Total catalysts fired: ${final.catalysts} · doubling regime: ${final.doubling} · cumulative minted: ${final.minted.toLocaleString()}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Scenarios ───────────────────────────────────────────────────────────
|
||
|
|
const ONE_DAY = 144; // blocks
|
||
|
|
const ONE_WEEK = 7 * ONE_DAY;
|
||
|
|
const SIX_MONTHS = 26 * ONE_WEEK;
|
||
|
|
const TWO_YEARS = 104 * ONE_WEEK;
|
||
|
|
|
||
|
|
runScenario(
|
||
|
|
'SCENARIO 1 — Calm growth',
|
||
|
|
'BCH stable at $500; steady minting (50 units/block ≈ 7,200/day)',
|
||
|
|
() => 500,
|
||
|
|
() => 50,
|
||
|
|
() => 5,
|
||
|
|
SIX_MONTHS,
|
||
|
|
);
|
||
|
|
|
||
|
|
runScenario(
|
||
|
|
'SCENARIO 2 — Sustained rally',
|
||
|
|
'BCH climbs $500 → $1000 over 6 months; moderate minting + redemption pickup',
|
||
|
|
(b, total) => 500 + (500 * b) / total,
|
||
|
|
(b, total) => 30 + (50 * b) / total, // minting picks up toward end
|
||
|
|
(b, total) => 5 + (15 * b) / total, // redemption also rises
|
||
|
|
SIX_MONTHS,
|
||
|
|
);
|
||
|
|
|
||
|
|
runScenario(
|
||
|
|
'SCENARIO 3 — Sustained crash',
|
||
|
|
'BCH falls $500 → $250 over 6 months; mints climb (bargain buying), redeems flat',
|
||
|
|
(b, total) => 500 - (250 * b) / total,
|
||
|
|
(b, total) => 30 + (40 * b) / total,
|
||
|
|
() => 5,
|
||
|
|
SIX_MONTHS,
|
||
|
|
);
|
||
|
|
|
||
|
|
runScenario(
|
||
|
|
'SCENARIO 4 — Flash crash + recovery',
|
||
|
|
'BCH at $500, drops to $250 over 3 days, recovers over 4 days; normal flows',
|
||
|
|
(b) => {
|
||
|
|
if (b < 3 * ONE_DAY) return 500 - (250 * b) / (3 * ONE_DAY);
|
||
|
|
if (b < 7 * ONE_DAY) return 250 + (250 * (b - 3 * ONE_DAY)) / (4 * ONE_DAY);
|
||
|
|
return 500;
|
||
|
|
},
|
||
|
|
() => 50,
|
||
|
|
() => 5,
|
||
|
|
ONE_WEEK * 2, // 2 weeks to see the recovery + 1 week post
|
||
|
|
);
|
||
|
|
|
||
|
|
runScenario(
|
||
|
|
'SCENARIO 5 — Adoption boom (many catalysts)',
|
||
|
|
'BCH stable at $500; minting rapidly increases (50 → 500 units/block); many 210k catalysts fire',
|
||
|
|
() => 500,
|
||
|
|
(b, total) => 50 + (450 * b) / total,
|
||
|
|
() => 5,
|
||
|
|
SIX_MONTHS,
|
||
|
|
);
|
||
|
|
|
||
|
|
console.log(`\n${'═'.repeat(72)}`);
|
||
|
|
console.log(' All scenarios complete. Observe:');
|
||
|
|
console.log(' - Scenario 1: controller stays calm; no catalysts due to low minting');
|
||
|
|
console.log(' - Scenario 2: rate moves up gradually; up-side cadence reduces from DART signals');
|
||
|
|
console.log(' - Scenario 3: rate lag widens; mint-favored arbitrage opportunity emerges');
|
||
|
|
console.log(' - Scenario 4: rate barely moves during flash event; full recovery without trace');
|
||
|
|
console.log(' - Scenario 5: many catalysts fire; asymmetric bias accumulates rapidly');
|
||
|
|
console.log('═'.repeat(72));
|