Files
brk/scripts/cycle-dca-sim.mjs
T
2026-06-06 22:29:33 +02:00

794 lines
23 KiB
JavaScript
Executable File

#!/usr/bin/env node
const DEFAULT_BUY_LEVELS = new Map([
[100, 10.875],
[95, 11.7395833],
[90, 12.6041667],
[85, 13.46875],
[80, 14.3333333],
[75, 15.1979167],
[70, 16.0625],
[65, 16.9270833],
[60, 17.7916667],
[55, 18.65625],
[50, 19.5208333],
[45, 20.3854167],
[40, 21.25],
]);
const DAYS_PER_MONTH = 365.2425 / 12;
function parseArgs(argv) {
const opts = {
baseUrl: "https://bitview.space",
dataStart: "2014-01-01",
start: "2014-01-01",
end: null,
starts: null,
startSet: "cycle-extremes",
initialCash: 10_000,
monthlyTopup: 1_000,
dailyBuy: null,
buyRunwayMonths: 0,
minCashReserveMonths: 0,
initialDeployDays: 365,
buyTriggerPct: 50,
buyLevels: DEFAULT_BUY_LEVELS,
sellRule: "percentile-band",
sellArmPct: 100,
sellBandLowerPct: 95,
sellBandUpperPct: 100,
sellBandMultiple: 2.75,
sellAthMultiple: 3,
sellMap: null,
maxDailySellFraction: 0.005,
mode: "both",
output: "table",
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
i += 1;
if (i >= argv.length) throw new Error(`Missing value for ${arg}`);
return argv[i];
};
if (arg === "--help" || arg === "-h") {
opts.help = true;
} else if (arg === "--base-url") {
opts.baseUrl = next();
} else if (arg === "--data-start") {
opts.dataStart = next();
} else if (arg === "--start") {
opts.start = next();
} else if (arg === "--end") {
opts.end = next();
} else if (arg === "--starts") {
opts.starts = next()
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (arg === "--start-set") {
opts.startSet = next();
} else if (arg === "--initial-cash") {
opts.initialCash = parseNumber(next(), arg);
} else if (arg === "--monthly-topup") {
opts.monthlyTopup = parseNumber(next(), arg);
} else if (arg === "--daily-buy") {
opts.dailyBuy = parseNumber(next(), arg);
} else if (arg === "--buy-runway-months") {
opts.buyRunwayMonths = parseNumber(next(), arg);
} else if (arg === "--min-cash-reserve-months") {
opts.minCashReserveMonths = parseNumber(next(), arg);
} else if (arg === "--initial-deploy-days") {
opts.initialDeployDays = parseInteger(next(), arg);
} else if (arg === "--buy-trigger-pct") {
opts.buyTriggerPct = parseInteger(next(), arg);
} else if (arg === "--buy-levels") {
opts.buyLevels = parsePctMap(next(), arg);
} else if (arg === "--sell-map") {
opts.sellMap = parsePctMap(next(), arg);
opts.sellRule = "percentile-map";
} else if (arg === "--sell-band") {
const [lower, upper] = next().split(":");
if (!lower || !upper) {
throw new Error("--sell-band must look like lowerPct:upperPct");
}
opts.sellBandLowerPct = parseInteger(lower, arg);
opts.sellBandUpperPct = parseInteger(upper, arg);
opts.sellRule = "percentile-band";
} else if (arg === "--sell-arm-pct") {
opts.sellArmPct = parseInteger(next(), arg);
} else if (arg === "--sell-band-multiple") {
opts.sellBandMultiple = parseNumber(next(), arg);
opts.sellRule = "percentile-band";
} else if (arg === "--sell-ath-multiple") {
opts.sellAthMultiple = parseNumber(next(), arg);
opts.sellRule = "ath";
} else if (arg === "--max-daily-sell-fraction") {
opts.maxDailySellFraction = parseNumber(next(), arg);
} else if (arg === "--mode") {
opts.mode = next();
} else if (arg === "--csv") {
opts.output = "csv";
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!["both", "hold", "sell"].includes(opts.mode)) {
throw new Error("--mode must be one of: both, hold, sell");
}
if (!["cycle-extremes", "single", "custom"].includes(opts.startSet)) {
throw new Error("--start-set must be one of: cycle-extremes, single, custom");
}
if (opts.starts?.length) {
opts.startSet = "custom";
}
return opts;
}
function parseNumber(value, label) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number`);
return parsed;
}
function parseInteger(value, label) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) throw new Error(`${label} must be an integer`);
return parsed;
}
function parsePctMap(value, label) {
const map = new Map();
for (const part of value.split(",")) {
const [pct, weight] = part.split(":");
if (!pct || !weight) {
throw new Error(`${label} entries must look like pct:value,pct:value`);
}
map.set(parseInteger(pct, label), parseNumber(weight, label));
}
return map;
}
function printHelp() {
console.log(`Usage: node scripts/cycle-dca-sim.mjs [options]
Fetches Bitview daily price + BTC-weighted cost-basis percentile data and
simulates the image rule:
- p50 touch starts daily DCA-in
- p100 increase stops DCA-in and arms DCA-out
- optional DCA-out sells inside the selected percentile band after arming
- start with cash, then add a monthly top-up
Defaults:
--start 2014-01-01
--data-start 2014-01-01
--initial-cash 10000
--monthly-topup 1000
--buy-levels 100:10.875,95:11.7395833,90:12.6041667,85:13.46875,80:14.3333333,75:15.1979167,70:16.0625,65:16.9270833,60:17.7916667,55:18.65625,50:19.5208333,45:20.3854167,40:21.25
--sell-band 95:100
--sell-band-multiple 2.75
--max-daily-sell-fraction 0.005
--mode both
--start-set cycle-extremes
Options:
--start YYYY-MM-DD
--data-start YYYY-MM-DD Warmup data start for cycle phase inference
--end YYYY-MM-DD
--starts YYYY-MM-DD,YYYY-MM-DD Explicit start dates
--start-set cycle-extremes|single|custom
--mode both|hold|sell
--initial-cash USD
--monthly-topup USD
--daily-buy USD Default: monthly top-up / average month
--buy-runway-months N Cap daily buys to preserve N months of spend runway
--min-cash-reserve-months N Never spend below N months of top-up cash
--initial-deploy-days N Adds initial cash / N to buy budget on active buy days
--buy-trigger-pct N
--buy-levels pct:weight,...
--sell-arm-pct N Arms sell phase once p100 increases, or price touches other pct
--sell-band lowerPct:upperPct Sell only inside percentile band after arming
--sell-band-multiple N Multiplies daily sell size while inside the band
--sell-ath-multiple N Sell when price >= previous ATH * N
--sell-map pct:multiplier,... Alternative: sell on cost-basis percentile thresholds
--max-daily-sell-fraction N Max BTC fraction sold per day when all sell thresholds fire
--csv
`);
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
printHelp();
return;
}
const data = await loadData(opts);
const starts = resolveStartPoints(data.rows, opts);
const results = [];
for (const startPoint of starts) {
const startIndex = startPoint.index;
const benchmarkLump = simulateLumpAndTopup(data.rows, startIndex, opts);
const benchmarkDca = simulateSimpleDailyDca(data.rows, startIndex, opts);
const modes =
opts.mode === "both" ? [false, true] : opts.mode === "sell" ? [true] : [false];
for (const sellEnabled of modes) {
const signal = simulateSignal(data.rows, startIndex, opts, sellEnabled);
results.push({
start_label: startPoint.label,
start_date: data.rows[startIndex].date,
start_kind: startPoint.kind,
start_epoch: startPoint.epoch,
mode: sellEnabled ? "sell" : "hold",
final_date: signal.finalDate,
contributed: signal.contributed,
final_value: signal.finalValue,
return_pct: pct(signal.finalValue / signal.contributed - 1),
cash: signal.cash,
min_cash: signal.minCash,
btc: signal.btc,
buys: signal.buys,
capped_buy_days: signal.cappedBuyDays,
sells: signal.sells,
bought_usd: signal.boughtUsd,
sold_usd: signal.soldUsd,
max_drawdown_pct: pct(signal.maxDrawdown),
lump_value: benchmarkLump.finalValue,
lump_delta_pct: pct(signal.finalValue / benchmarkLump.finalValue - 1),
daily_dca_value: benchmarkDca.finalValue,
daily_dca_delta_pct: pct(signal.finalValue / benchmarkDca.finalValue - 1),
});
}
}
if (opts.output === "csv") {
printCsv(results);
} else {
printTable(results, opts);
}
}
async function loadData(opts) {
const pctSet = new Set([
opts.buyTriggerPct,
...opts.buyLevels.keys(),
opts.sellArmPct,
...(opts.sellRule === "percentile-band"
? [opts.sellBandLowerPct, opts.sellBandUpperPct]
: []),
...(opts.sellMap?.keys() ?? []),
]);
const seriesNames = [
"date",
"price",
"price_ath",
"halving_epoch",
...[...pctSet]
.sort((a, b) => a - b)
.map((pct) => costBasisSeriesName(pct)),
];
const loaded = new Map(
await Promise.all(
seriesNames.map(async (name) => [name, await fetchSeries(opts, name)]),
),
);
const dates = loaded.get("date").data;
const price = loaded.get("price").data;
const ath = loaded.get("price_ath").data;
const epoch = loaded.get("halving_epoch").data;
const len = Math.min(dates.length, price.length, ath.length, epoch.length);
const rows = [];
for (let i = 0; i < len; i += 1) {
const percentiles = new Map();
for (const pct of pctSet) {
const series = loaded.get(costBasisSeriesName(pct)).data;
percentiles.set(pct, series[i]);
}
rows.push({
date: dates[i],
price: price[i],
ath: ath[i],
previousAth: i > 0 ? ath[i - 1] : ath[i],
epoch: epoch[i],
percentiles,
});
}
return { rows };
}
async function fetchSeries(opts, series) {
const url = new URL(`/api/series/${series}/day1`, normalizeBaseUrl(opts.baseUrl));
url.searchParams.set("start", opts.dataStart);
if (opts.end) url.searchParams.set("end", opts.end);
const response = await fetch(url);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch ${series}: ${response.status} ${text}`);
}
const json = await response.json();
if (!Array.isArray(json.data)) {
throw new Error(`Series ${series} did not return a data array`);
}
return json;
}
function normalizeBaseUrl(baseUrl) {
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
}
function costBasisSeriesName(pct) {
if (pct === 100) return "cost_basis_max";
return `cost_basis_per_coin_pct${String(pct).padStart(2, "0")}`;
}
function resolveStartPoints(rows, opts) {
if (opts.startSet === "custom") {
return opts.starts.map((date) => ({
label: date,
date,
kind: "custom",
epoch: null,
index: findDateIndex(rows, date),
}));
}
if (opts.startSet === "single") {
return [
{
label: opts.start,
date: opts.start,
kind: "single",
epoch: null,
index: findDateIndex(rows, opts.start),
},
];
}
const firstIndex = findDateIndex(rows, opts.start);
const byEpoch = new Map();
rows.forEach((row, index) => {
if (index < firstIndex) return;
if (!Number.isFinite(row.price) || row.price <= 0) return;
if (!byEpoch.has(row.epoch)) byEpoch.set(row.epoch, []);
byEpoch.get(row.epoch).push({ row, index });
});
const points = [];
for (const [epoch, items] of [...byEpoch.entries()].sort(([a], [b]) => a - b)) {
if (!items.length) continue;
const top = items.reduce((best, item) =>
item.row.price > best.row.price ? item : best,
);
const bottom = items.reduce((best, item) =>
item.row.price < best.row.price ? item : best,
);
points.push({
label: `epoch-${epoch}-bottom`,
date: bottom.row.date,
kind: "bottom",
epoch,
index: bottom.index,
});
points.push({
label: `epoch-${epoch}-top`,
date: top.row.date,
kind: "top",
epoch,
index: top.index,
});
}
return uniqueByIndex(points).sort((a, b) => a.index - b.index);
}
function uniqueByIndex(points) {
const seen = new Set();
return points.filter((point) => {
if (seen.has(point.index)) return false;
seen.add(point.index);
return true;
});
}
function findDateIndex(rows, date) {
const index = rows.findIndex((row) => row.date >= date);
if (index === -1) throw new Error(`Start date ${date} is outside loaded data`);
return index;
}
function simulateSignal(rows, startIndex, opts, sellEnabled) {
let cash = opts.initialCash;
let btc = 0;
let { buyActive, sellArmed } = inferPhase(rows, startIndex, opts);
let initialDeployActiveDays = 0;
let contributed = opts.initialCash;
let buys = 0;
let sells = 0;
let boughtUsd = 0;
let soldUsd = 0;
let minCash = cash;
let cappedBuyDays = 0;
let peakValue = cash;
let maxDrawdown = 0;
const baseDailyBuy = opts.dailyBuy ?? opts.monthlyTopup / DAYS_PER_MONTH;
for (let i = startIndex; i < rows.length; i += 1) {
const row = rows[i];
if (i > startIndex && isMonthStart(row.date)) {
cash += opts.monthlyTopup;
contributed += opts.monthlyTopup;
}
const p50 = row.percentiles.get(opts.buyTriggerPct);
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
buyActive = true;
sellArmed = false;
}
if (buyActive && isSellArmTouch(row, rows[i - 1], opts)) {
buyActive = false;
sellArmed = true;
}
if (sellEnabled && sellArmed) {
const sellFraction = dailySellFraction(row, opts);
if (sellFraction > 0 && btc > 0) {
const btcToSell = btc * sellFraction;
const usd = btcToSell * row.price;
btc -= btcToSell;
cash += usd;
soldUsd += usd;
sells += 1;
}
}
if (buyActive && cash > 0) {
const initialBudget =
initialDeployActiveDays < opts.initialDeployDays && opts.initialDeployDays > 0
? opts.initialCash / opts.initialDeployDays
: 0;
const buyBudget = (baseDailyBuy + initialBudget) * buyWeight(row, opts);
const usd = cappedBuyUsd(cash, buyBudget, opts);
if (usd + 1e-9 < Math.min(cash, buyBudget)) {
cappedBuyDays += 1;
}
if (usd > 0) {
btc += usd / row.price;
cash -= usd;
boughtUsd += usd;
buys += 1;
initialDeployActiveDays += 1;
}
}
const value = cash + btc * row.price;
minCash = Math.min(minCash, cash);
peakValue = Math.max(peakValue, value);
maxDrawdown = Math.max(maxDrawdown, peakValue === 0 ? 0 : 1 - value / peakValue);
}
const finalRow = rows.at(-1);
return {
finalDate: finalRow.date,
finalValue: cash + btc * finalRow.price,
cash,
minCash,
btc,
buys,
cappedBuyDays,
sells,
boughtUsd,
soldUsd,
contributed,
maxDrawdown,
};
}
function cappedBuyUsd(cash, buyBudget, opts) {
let cap = buyBudget;
if (opts.buyRunwayMonths > 0) {
cap = Math.min(cap, cash / (DAYS_PER_MONTH * opts.buyRunwayMonths));
}
if (opts.minCashReserveMonths > 0) {
const reserve = opts.monthlyTopup * opts.minCashReserveMonths;
cap = Math.min(cap, Math.max(0, cash - reserve));
}
return Math.min(cash, cap);
}
function inferPhase(rows, startIndex, opts) {
let buyActive = false;
let sellArmed = false;
for (let i = 0; i < startIndex; i += 1) {
const row = rows[i];
const p50 = row.percentiles.get(opts.buyTriggerPct);
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
buyActive = true;
sellArmed = false;
}
if (buyActive && isSellArmTouch(row, rows[i - 1], opts)) {
buyActive = false;
sellArmed = true;
}
}
return { buyActive, sellArmed };
}
function simulateLumpAndTopup(rows, startIndex, opts) {
let cash = opts.initialCash;
let btc = cash / rows[startIndex].price;
let contributed = cash;
cash = 0;
for (let i = startIndex + 1; i < rows.length; i += 1) {
const row = rows[i];
if (isMonthStart(row.date)) {
contributed += opts.monthlyTopup;
btc += opts.monthlyTopup / row.price;
}
}
const finalRow = rows.at(-1);
return {
finalValue: btc * finalRow.price,
contributed,
};
}
function simulateSimpleDailyDca(rows, startIndex, opts) {
let cash = opts.initialCash;
let btc = 0;
let contributed = cash;
const baseDailyBuy = opts.dailyBuy ?? opts.monthlyTopup / DAYS_PER_MONTH;
for (let i = startIndex; i < rows.length; i += 1) {
const row = rows[i];
if (i > startIndex && isMonthStart(row.date)) {
cash += opts.monthlyTopup;
contributed += opts.monthlyTopup;
}
const elapsedDays = i - startIndex;
const initialBudget =
elapsedDays < opts.initialDeployDays && opts.initialDeployDays > 0
? opts.initialCash / opts.initialDeployDays
: 0;
const usd = Math.min(cash, baseDailyBuy + initialBudget);
if (usd > 0) {
btc += usd / row.price;
cash -= usd;
}
}
const finalRow = rows.at(-1);
return {
finalValue: cash + btc * finalRow.price,
contributed,
};
}
function buyWeight(row, opts) {
let weight = 0;
for (const [pct, pctWeight] of [...opts.buyLevels.entries()].sort(
([a], [b]) => b - a,
)) {
const level = row.percentiles.get(pct);
if (Number.isFinite(level) && level > 0 && row.price <= level) {
weight = Math.max(weight, pctWeight);
}
}
return weight;
}
function isSellArmTouch(row, previousRow, opts) {
const level = row.percentiles.get(opts.sellArmPct);
if (!Number.isFinite(level) || level <= 0) return false;
if (opts.sellArmPct === 100) {
const previousLevel = previousRow?.percentiles.get(opts.sellArmPct);
return Number.isFinite(previousLevel) && level > previousLevel;
}
return row.price >= level;
}
function dailySellFraction(row, opts) {
if (opts.sellRule === "percentile-band") {
const lowerLevel = row.percentiles.get(opts.sellBandLowerPct);
const upperLevel = row.percentiles.get(opts.sellBandUpperPct);
if (
!Number.isFinite(lowerLevel) ||
!Number.isFinite(upperLevel) ||
lowerLevel <= 0 ||
upperLevel <= 0
) {
return 0;
}
const lower = Math.min(lowerLevel, upperLevel);
const upper = Math.max(lowerLevel, upperLevel);
return row.price >= lower && row.price <= upper
? Math.min(1, opts.maxDailySellFraction * opts.sellBandMultiple)
: 0;
}
if (opts.sellRule === "ath") {
const threshold = row.previousAth * opts.sellAthMultiple;
return Number.isFinite(threshold) && threshold > 0 && row.price >= threshold
? opts.maxDailySellFraction
: 0;
}
const totalWeight = [...opts.sellMap.values()].reduce((sum, weight) => sum + weight, 0);
if (totalWeight <= 0) return 0;
let triggeredWeight = 0;
for (const [pct, multiplier] of opts.sellMap) {
const level = row.percentiles.get(pct);
if (Number.isFinite(level) && level > 0 && row.price >= level * multiplier) {
triggeredWeight += multiplier;
}
}
if (triggeredWeight <= 0) return 0;
return opts.maxDailySellFraction * (triggeredWeight / totalWeight);
}
function isMonthStart(date) {
return date.endsWith("-01");
}
function pct(value) {
return value * 100;
}
function printTable(results, opts) {
console.log(
[
`Data: ${opts.start}${opts.end ? ` to ${opts.end}` : " to latest"}`,
`Cash: ${usd(opts.initialCash)} initial + ${usd(opts.monthlyTopup)} monthly`,
`Buy runway: ${formatBuyRunway(opts)}`,
`Buy trigger: p${opts.buyTriggerPct} touch starts DCA-in; p${opts.sellArmPct} increase stops it`,
`Sell: ${
opts.mode === "hold"
? "disabled"
: opts.sellRule === "ath"
? `optional, previous ATH x${formatNumber(opts.sellAthMultiple, 2)}, max ${formatNumber(opts.maxDailySellFraction * 100, 3)}% BTC/day`
: opts.sellRule === "percentile-band"
? `optional, armed by p${opts.sellArmPct} touch, p${opts.sellBandLowerPct}-p${opts.sellBandUpperPct}, sell size x${formatNumber(opts.sellBandMultiple, 2)}, base ${formatNumber(opts.maxDailySellFraction * 100, 3)}% BTC/day`
: `optional, percentile map, max ${formatNumber(opts.maxDailySellFraction * 100, 3)}% BTC/day`
}`,
"",
].join("\n"),
);
const rows = results.map((result) => ({
start: `${result.start_label} ${result.start_date}`,
mode: result.mode,
contributed: usd(result.contributed),
final: usd(result.final_value),
ret: `${formatNumber(result.return_pct, 2)}%`,
cash: usd(result.cash),
min_cash: usd(result.min_cash),
btc: formatNumber(result.btc, 6),
buys: String(result.buys),
capped_buys: String(result.capped_buy_days),
sells: String(result.sells),
dd: `${formatNumber(result.max_drawdown_pct, 2)}%`,
vs_lump: `${formatNumber(result.lump_delta_pct, 2)}%`,
vs_dca: `${formatNumber(result.daily_dca_delta_pct, 2)}%`,
}));
printFixedWidthTable(rows, [
["start", "start"],
["mode", "mode"],
["contributed", "contributed"],
["final", "final"],
["ret", "return"],
["cash", "cash"],
["min_cash", "min cash"],
["btc", "btc"],
["buys", "buys"],
["capped_buys", "capped buys"],
["sells", "sells"],
["dd", "max dd"],
["vs_lump", "vs lump"],
["vs_dca", "vs dca"],
]);
}
function formatBuyRunway(opts) {
const parts = [];
if (opts.buyRunwayMonths > 0) {
parts.push(
`daily cap preserves ${formatNumber(opts.buyRunwayMonths, 2)} month(s)`,
);
}
if (opts.minCashReserveMonths > 0) {
parts.push(
`cash floor ${formatNumber(opts.minCashReserveMonths, 2)} month(s) top-up`,
);
}
return parts.length ? parts.join("; ") : "none";
}
function printCsv(results) {
const keys = Object.keys(results[0] ?? {});
console.log(keys.join(","));
for (const result of results) {
console.log(
keys
.map((key) => {
const value = result[key];
if (typeof value === "string") return `"${value.replaceAll('"', '""')}"`;
return value;
})
.join(","),
);
}
}
function printFixedWidthTable(rows, columns) {
const widths = new Map();
for (const [key, label] of columns) {
widths.set(
key,
Math.max(label.length, ...rows.map((row) => String(row[key]).length)),
);
}
const formatRow = (row) =>
columns
.map(([key]) => String(row[key]).padEnd(widths.get(key)))
.join(" ");
console.log(formatRow(Object.fromEntries(columns.map(([key, label]) => [key, label]))));
console.log(
columns.map(([key]) => "-".repeat(widths.get(key))).join(" "),
);
for (const row of rows) console.log(formatRow(row));
}
function usd(value) {
return `$${formatNumber(value, 2)}`;
}
function formatNumber(value, digits) {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});