mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-10 06:53:33 -07:00
website: redesign part 10
This commit is contained in:
Executable
+702
@@ -0,0 +1,702 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const DEFAULT_BUY_LEVELS = new Map([
|
||||
[50, 1.0],
|
||||
[45, 1.5],
|
||||
[40, 2.0],
|
||||
]);
|
||||
|
||||
const DAYS_PER_MONTH = 365.2425 / 12;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
baseUrl: "https://bitview.space",
|
||||
start: "2014-01-01",
|
||||
end: null,
|
||||
starts: null,
|
||||
startSet: "cycle-extremes",
|
||||
initialCash: 10_000,
|
||||
monthlyTopup: 1_000,
|
||||
dailyBuy: null,
|
||||
initialDeployDays: 365,
|
||||
buyTriggerPct: 50,
|
||||
buyLevels: DEFAULT_BUY_LEVELS,
|
||||
sellRule: "percentile-band",
|
||||
sellArmPct: 100,
|
||||
sellBandLowerPct: 95,
|
||||
sellBandUpperPct: 100,
|
||||
sellBandMultiple: 5,
|
||||
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 === "--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 === "--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
|
||||
- ATH touch stops DCA-in
|
||||
- optional DCA-out uses percentile * multiplier thresholds
|
||||
- start with cash, then add a monthly top-up
|
||||
|
||||
Defaults:
|
||||
--start 2014-01-01
|
||||
--initial-cash 10000
|
||||
--monthly-topup 1000
|
||||
--buy-levels 50:1,45:1.5,40:2
|
||||
--sell-band 95:100
|
||||
--sell-band-multiple 5
|
||||
--max-daily-sell-fraction 0.005
|
||||
--mode both
|
||||
--start-set cycle-extremes
|
||||
|
||||
Options:
|
||||
--start YYYY-MM-DD
|
||||
--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
|
||||
--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 multiplier
|
||||
--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,
|
||||
btc: signal.btc,
|
||||
buys: signal.buys,
|
||||
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.start);
|
||||
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: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const byEpoch = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
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 = false;
|
||||
let sellArmed = false;
|
||||
let initialDeployActiveDays = 0;
|
||||
let contributed = opts.initialCash;
|
||||
let buys = 0;
|
||||
let sells = 0;
|
||||
let boughtUsd = 0;
|
||||
let soldUsd = 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 = Math.min(cash, buyBudget);
|
||||
if (usd > 0) {
|
||||
btc += usd / row.price;
|
||||
cash -= usd;
|
||||
boughtUsd += usd;
|
||||
buys += 1;
|
||||
initialDeployActiveDays += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const value = cash + btc * row.price;
|
||||
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,
|
||||
btc,
|
||||
buys,
|
||||
sells,
|
||||
boughtUsd,
|
||||
soldUsd,
|
||||
contributed,
|
||||
maxDrawdown,
|
||||
};
|
||||
}
|
||||
|
||||
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 = 1.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 trigger: p${opts.buyTriggerPct} touch starts DCA-in; ATH touch 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),
|
||||
btc: formatNumber(result.btc, 6),
|
||||
buys: String(result.buys),
|
||||
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"],
|
||||
["btc", "btc"],
|
||||
["buys", "buys"],
|
||||
["sells", "sells"],
|
||||
["dd", "max dd"],
|
||||
["vs_lump", "vs lump"],
|
||||
["vs_dca", "vs dca"],
|
||||
]);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -3,14 +3,15 @@ main.learn {
|
||||
counter-reset: content-theme;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: var(--top-offset);
|
||||
padding-bottom: calc(var(--top-offset) / 2);
|
||||
padding-block: var(--offset);
|
||||
max-height: 100dvh;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: uppercase;
|
||||
padding-left: 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
|
||||
ol {
|
||||
list-style: none;
|
||||
@@ -38,16 +39,37 @@ main.learn {
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
scroll-margin-block: var(--offset);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
margin-right: 1rem;
|
||||
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:is(:hover, [aria-current="location"]) {
|
||||
&:is(:hover, :active) {
|
||||
margin-block: -0.25rem;
|
||||
margin-left: -0.5rem;
|
||||
padding: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--white);
|
||||
background-color: var(--dark-gray);
|
||||
}
|
||||
|
||||
&[aria-current="location"] {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--black);
|
||||
background-color: var(--orange);
|
||||
}
|
||||
}
|
||||
|
||||
> ol > li > a::before {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/** @param {HTMLElement} main */
|
||||
export function initScrollSpy(main) {
|
||||
const headings = [...main.querySelectorAll("article h1, article h2")];
|
||||
const visibleHeadings = new Set();
|
||||
const sections = [...main.querySelectorAll("section[id]")];
|
||||
const sectionStates = sections.map((section) => ({
|
||||
section,
|
||||
children: [...section.querySelectorAll(":scope > section")],
|
||||
intersecting: false,
|
||||
}));
|
||||
const stateBySection = new Map(
|
||||
sectionStates.map((state) => [state.section, state]),
|
||||
);
|
||||
const links = new Map(
|
||||
[...main.querySelectorAll('nav a[href^="#"]')].map((link) => [
|
||||
link.getAttribute("href"),
|
||||
@@ -12,12 +19,24 @@ export function initScrollSpy(main) {
|
||||
/** @type {string | null} */
|
||||
let current = null;
|
||||
|
||||
/** @param {Element} heading */
|
||||
function getHash(heading) {
|
||||
const section = /** @type {HTMLElement} */ (
|
||||
heading.closest("section[id]")
|
||||
/** @param {Element} section */
|
||||
function getVisibleHeight(section) {
|
||||
const rect = section.getBoundingClientRect();
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0),
|
||||
);
|
||||
return `#${section.id}`;
|
||||
}
|
||||
|
||||
/** @param {{ section: Element, children: Element[] }} state */
|
||||
function getOwnVisibleHeight(state) {
|
||||
let height = getVisibleHeight(state.section);
|
||||
|
||||
for (const child of state.children) {
|
||||
height -= getVisibleHeight(child);
|
||||
}
|
||||
|
||||
return Math.max(0, height);
|
||||
}
|
||||
|
||||
/** @param {string} hash */
|
||||
@@ -26,38 +45,62 @@ export function initScrollSpy(main) {
|
||||
}
|
||||
|
||||
/** @param {string} hash */
|
||||
function setCurrent(hash) {
|
||||
function setCurrentHash(hash) {
|
||||
if (hash === current) return;
|
||||
|
||||
if (current) getLink(current).removeAttribute("aria-current");
|
||||
getLink(hash).setAttribute("aria-current", "location");
|
||||
|
||||
const link = getLink(hash);
|
||||
link.setAttribute("aria-current", "location");
|
||||
link.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
|
||||
history.replaceState(null, "", hash);
|
||||
current = hash;
|
||||
}
|
||||
|
||||
function getCurrentSection() {
|
||||
/** @type {{ section: Element, children: Element[] } | undefined} */
|
||||
let currentState;
|
||||
let currentHeight = 0;
|
||||
|
||||
for (const state of sectionStates) {
|
||||
if (!state.intersecting) continue;
|
||||
|
||||
const height = getOwnVisibleHeight(state);
|
||||
|
||||
if (height > currentHeight) {
|
||||
currentState = state;
|
||||
currentHeight = height;
|
||||
}
|
||||
}
|
||||
|
||||
return currentState?.section;
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (main.hidden) return;
|
||||
|
||||
const heading = headings.findLast((heading) =>
|
||||
visibleHeadings.has(heading),
|
||||
);
|
||||
if (heading) setCurrent(getHash(heading));
|
||||
const section = getCurrentSection();
|
||||
if (section) setCurrentHash(`#${section.id}`);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
visibleHeadings.add(entry.target);
|
||||
} else {
|
||||
visibleHeadings.delete(entry.target);
|
||||
}
|
||||
const state = /** @type {{ intersecting: boolean }} */ (
|
||||
stateBySection.get(entry.target)
|
||||
);
|
||||
state.intersecting = entry.isIntersecting;
|
||||
}
|
||||
|
||||
update();
|
||||
},
|
||||
{ rootMargin: "0px 0px -80% 0px" },
|
||||
{
|
||||
threshold: [
|
||||
0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
for (const heading of headings) observer.observe(heading);
|
||||
for (const section of sections) observer.observe(section);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
main.learn {
|
||||
--top-offset: 6rem;
|
||||
--offset: 6rem;
|
||||
--content-width: 52rem;
|
||||
|
||||
display: grid;
|
||||
@@ -9,8 +9,7 @@ main.learn {
|
||||
|
||||
article {
|
||||
counter-reset: theme;
|
||||
padding-top: var(--top-offset);
|
||||
padding-bottom: calc(var(--top-offset) / 2);
|
||||
padding-block: var(--offset);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
@@ -18,10 +17,10 @@ main.learn {
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
height: var(--top-offset);
|
||||
margin-top: calc(-1 * var(--top-offset));
|
||||
height: var(--offset);
|
||||
margin-top: calc(-1 * var(--offset));
|
||||
margin-inline: auto;
|
||||
margin-bottom: calc(-1 * var(--top-offset));
|
||||
margin-bottom: calc(-1 * var(--offset));
|
||||
background: var(--black);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -31,12 +30,12 @@ main.learn {
|
||||
counter-reset: topic;
|
||||
width: min(100%, var(--content-width));
|
||||
margin-inline: auto;
|
||||
scroll-margin-top: var(--top-offset);
|
||||
scroll-margin-top: var(--offset);
|
||||
}
|
||||
|
||||
> section:first-of-type {
|
||||
margin-top: calc(-1 * var(--top-offset));
|
||||
padding-top: var(--top-offset);
|
||||
margin-top: calc(-1 * var(--offset));
|
||||
padding-top: var(--offset);
|
||||
}
|
||||
|
||||
> section + section {
|
||||
@@ -45,14 +44,14 @@ main.learn {
|
||||
|
||||
section section {
|
||||
counter-increment: topic;
|
||||
scroll-margin-top: var(--top-offset);
|
||||
scroll-margin-top: var(--offset);
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
position: sticky;
|
||||
top: var(--top-offset);
|
||||
top: var(--offset);
|
||||
padding-bottom: 0.5rem;
|
||||
background: var(--black);
|
||||
line-height: 1;
|
||||
|
||||
Reference in New Issue
Block a user