mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-10 15:03:32 -07:00
794 lines
23 KiB
JavaScript
Executable File
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;
|
|
});
|