mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-10 15:03:32 -07:00
website: redesign part 11
This commit is contained in:
+107
-16
@@ -1,9 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const DEFAULT_BUY_LEVELS = new Map([
|
||||
[50, 1.0],
|
||||
[45, 1.5],
|
||||
[40, 2.0],
|
||||
[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;
|
||||
@@ -11,6 +21,7 @@ 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,
|
||||
@@ -18,6 +29,8 @@ function parseArgs(argv) {
|
||||
initialCash: 10_000,
|
||||
monthlyTopup: 1_000,
|
||||
dailyBuy: null,
|
||||
buyRunwayMonths: 0,
|
||||
minCashReserveMonths: 0,
|
||||
initialDeployDays: 365,
|
||||
buyTriggerPct: 50,
|
||||
buyLevels: DEFAULT_BUY_LEVELS,
|
||||
@@ -25,7 +38,7 @@ function parseArgs(argv) {
|
||||
sellArmPct: 100,
|
||||
sellBandLowerPct: 95,
|
||||
sellBandUpperPct: 100,
|
||||
sellBandMultiple: 5,
|
||||
sellBandMultiple: 2.75,
|
||||
sellAthMultiple: 3,
|
||||
sellMap: null,
|
||||
maxDailySellFraction: 0.005,
|
||||
@@ -45,6 +58,8 @@ function parseArgs(argv) {
|
||||
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") {
|
||||
@@ -62,6 +77,10 @@ function parseArgs(argv) {
|
||||
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") {
|
||||
@@ -143,23 +162,25 @@ function printHelp() {
|
||||
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
|
||||
- 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 50:1,45:1.5,40:2
|
||||
--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 5
|
||||
--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
|
||||
@@ -167,11 +188,13 @@ Options:
|
||||
--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 multiplier
|
||||
--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
|
||||
@@ -212,8 +235,10 @@ async function main() {
|
||||
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,
|
||||
@@ -289,7 +314,7 @@ async function loadData(opts) {
|
||||
|
||||
async function fetchSeries(opts, series) {
|
||||
const url = new URL(`/api/series/${series}/day1`, normalizeBaseUrl(opts.baseUrl));
|
||||
url.searchParams.set("start", opts.start);
|
||||
url.searchParams.set("start", opts.dataStart);
|
||||
if (opts.end) url.searchParams.set("end", opts.end);
|
||||
|
||||
const response = await fetch(url);
|
||||
@@ -332,13 +357,15 @@ function resolveStartPoints(rows, opts) {
|
||||
date: opts.start,
|
||||
kind: "single",
|
||||
epoch: null,
|
||||
index: 0,
|
||||
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 });
|
||||
@@ -392,14 +419,15 @@ function findDateIndex(rows, date) {
|
||||
function simulateSignal(rows, startIndex, opts, sellEnabled) {
|
||||
let cash = opts.initialCash;
|
||||
let btc = 0;
|
||||
let buyActive = false;
|
||||
let sellArmed = false;
|
||||
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;
|
||||
@@ -440,7 +468,10 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
|
||||
? opts.initialCash / opts.initialDeployDays
|
||||
: 0;
|
||||
const buyBudget = (baseDailyBuy + initialBudget) * buyWeight(row, opts);
|
||||
const usd = Math.min(cash, buyBudget);
|
||||
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;
|
||||
@@ -451,6 +482,7 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -460,8 +492,10 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
|
||||
finalDate: finalRow.date,
|
||||
finalValue: cash + btc * finalRow.price,
|
||||
cash,
|
||||
minCash,
|
||||
btc,
|
||||
buys,
|
||||
cappedBuyDays,
|
||||
sells,
|
||||
boughtUsd,
|
||||
soldUsd,
|
||||
@@ -470,6 +504,43 @@ function simulateSignal(rows, startIndex, opts, sellEnabled) {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -524,7 +595,7 @@ function simulateSimpleDailyDca(rows, startIndex, opts) {
|
||||
}
|
||||
|
||||
function buyWeight(row, opts) {
|
||||
let weight = 1.0;
|
||||
let weight = 0;
|
||||
for (const [pct, pctWeight] of [...opts.buyLevels.entries()].sort(
|
||||
([a], [b]) => b - a,
|
||||
)) {
|
||||
@@ -603,7 +674,8 @@ function printTable(results, opts) {
|
||||
[
|
||||
`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`,
|
||||
`Buy runway: ${formatBuyRunway(opts)}`,
|
||||
`Buy trigger: p${opts.buyTriggerPct} touch starts DCA-in; p${opts.sellArmPct} increase stops it`,
|
||||
`Sell: ${
|
||||
opts.mode === "hold"
|
||||
? "disabled"
|
||||
@@ -624,8 +696,10 @@ function printTable(results, opts) {
|
||||
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)}%`,
|
||||
@@ -639,8 +713,10 @@ function printTable(results, opts) {
|
||||
["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"],
|
||||
@@ -648,6 +724,21 @@ function printTable(results, opts) {
|
||||
]);
|
||||
}
|
||||
|
||||
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(","));
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const PCTS = [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40];
|
||||
const DAYS_PER_MONTH = 365.2425 / 12;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
baseUrl: "https://bitview.space",
|
||||
dataStart: "2014-01-01",
|
||||
end: "2026-06-06",
|
||||
startFrom: "2017-12-01",
|
||||
startTo: "2025-08-01",
|
||||
starts: null,
|
||||
initialCash: 10_000,
|
||||
monthlyTopup: 1_000,
|
||||
initialDeployDays: 365,
|
||||
topMin: 0.25,
|
||||
topMax: 8,
|
||||
bottomMin: 0.25,
|
||||
bottomMax: 16,
|
||||
step: 0.25,
|
||||
sellMin: 0,
|
||||
sellMax: 10,
|
||||
sellStep: 1,
|
||||
topN: 12,
|
||||
};
|
||||
|
||||
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 === "--base-url") opts.baseUrl = next();
|
||||
else if (arg === "--data-start") opts.dataStart = next();
|
||||
else if (arg === "--end") opts.end = next();
|
||||
else if (arg === "--start-from") opts.startFrom = next();
|
||||
else if (arg === "--start-to") opts.startTo = next();
|
||||
else if (arg === "--starts") opts.starts = next().split(",").filter(Boolean);
|
||||
else if (arg === "--top-min") opts.topMin = Number(next());
|
||||
else if (arg === "--top-max") opts.topMax = Number(next());
|
||||
else if (arg === "--bottom-min") opts.bottomMin = Number(next());
|
||||
else if (arg === "--bottom-max") opts.bottomMax = Number(next());
|
||||
else if (arg === "--step") opts.step = Number(next());
|
||||
else if (arg === "--sell-min") opts.sellMin = Number(next());
|
||||
else if (arg === "--sell-max") opts.sellMax = Number(next());
|
||||
else if (arg === "--sell-step") opts.sellStep = Number(next());
|
||||
else if (arg === "--top-n") opts.topN = Number(next());
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const rows = await loadRows(opts);
|
||||
const starts = resolveStarts(rows, opts);
|
||||
const startStates = starts.map((start) => ({
|
||||
...start,
|
||||
phase: inferPhase(rows, start.index),
|
||||
dca: simulateSimpleDailyDca(rows, start.index, opts),
|
||||
lump: simulateLumpAndTopup(rows, start.index, opts),
|
||||
}));
|
||||
const baselineWeights = linearWeights(1, 7);
|
||||
const baseline = scoreVariant(rows, startStates, baselineWeights, 1, opts);
|
||||
|
||||
const candidates = [];
|
||||
for (const top of range(opts.topMin, opts.topMax, opts.step)) {
|
||||
const bottomStart = Math.max(opts.bottomMin, top);
|
||||
for (const bottom of range(bottomStart, opts.bottomMax, opts.step)) {
|
||||
const weights = linearWeights(top, bottom);
|
||||
for (const sellMultiple of range(opts.sellMin, opts.sellMax, opts.sellStep)) {
|
||||
const score = scoreVariant(rows, startStates, weights, sellMultiple, opts);
|
||||
candidates.push({
|
||||
top,
|
||||
bottom,
|
||||
sellMultiple,
|
||||
...score,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.avgVsDca - a.avgVsDca);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`Starts: ${starts[0].date} to ${starts.at(-1).date} (${starts.length})`,
|
||||
`Data end: ${rows.at(-1).date}`,
|
||||
`Baseline p100=1 -> p40=7, sell x1: avg vs DCA ${fmtPct(baseline.avgVsDca)}, median vs DCA ${fmtPct(baseline.medianVsDca)}, worst vs DCA ${fmtPct(baseline.worstVsDca)}, avg final ${usd(baseline.avgFinal)}`,
|
||||
"",
|
||||
"rank,p100,p40,sell_x,avg_vs_dca,median_vs_dca,worst_vs_dca,avg_vs_lump,avg_final,wins_vs_baseline",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
for (const [index, candidate] of candidates.slice(0, opts.topN).entries()) {
|
||||
console.log(
|
||||
[
|
||||
index + 1,
|
||||
fmt(candidate.top),
|
||||
fmt(candidate.bottom),
|
||||
fmt(candidate.sellMultiple),
|
||||
fmtPct(candidate.avgVsDca),
|
||||
fmtPct(candidate.medianVsDca),
|
||||
fmtPct(candidate.worstVsDca),
|
||||
fmtPct(candidate.avgVsLump),
|
||||
usd(candidate.avgFinal),
|
||||
candidate.finals.filter((value, i) => value > baseline.finals[i]).length,
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRows(opts) {
|
||||
const names = [
|
||||
"date",
|
||||
"price",
|
||||
"price_ath",
|
||||
...PCTS.map(costBasisSeriesName),
|
||||
];
|
||||
const loaded = new Map(
|
||||
await Promise.all(names.map(async (name) => [name, await fetchSeries(opts, name)])),
|
||||
);
|
||||
const len = Math.min(...names.map((name) => loaded.get(name).data.length));
|
||||
const rows = [];
|
||||
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const row = {
|
||||
date: loaded.get("date").data[i],
|
||||
price: loaded.get("price").data[i],
|
||||
ath: loaded.get("price_ath").data[i],
|
||||
previousAth: i > 0 ? loaded.get("price_ath").data[i - 1] : loaded.get("price_ath").data[i],
|
||||
levels: {},
|
||||
};
|
||||
for (const pct of PCTS) {
|
||||
row.levels[pct] = loaded.get(costBasisSeriesName(pct)).data[i];
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
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) throw new Error(`Failed to fetch ${series}: ${response.status}`);
|
||||
const json = await response.json();
|
||||
if (!Array.isArray(json.data)) throw new Error(`${series} returned no data array`);
|
||||
return json;
|
||||
}
|
||||
|
||||
function resolveStarts(rows, opts) {
|
||||
const dates = opts.starts ?? monthStarts(opts.startFrom, opts.startTo);
|
||||
return dates.map((date) => ({
|
||||
date,
|
||||
index: findDateIndex(rows, date),
|
||||
}));
|
||||
}
|
||||
|
||||
function monthStarts(from, to) {
|
||||
const dates = [];
|
||||
for (
|
||||
const cursor = new Date(`${from}T00:00:00Z`), end = new Date(`${to}T00:00:00Z`);
|
||||
cursor <= end;
|
||||
cursor.setUTCMonth(cursor.getUTCMonth() + 1)
|
||||
) {
|
||||
dates.push(cursor.toISOString().slice(0, 10));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function findDateIndex(rows, date) {
|
||||
const index = rows.findIndex((row) => row.date >= date);
|
||||
if (index === -1) throw new Error(`Date ${date} is outside loaded data`);
|
||||
return index;
|
||||
}
|
||||
|
||||
function inferPhase(rows, startIndex) {
|
||||
let buyActive = false;
|
||||
let sellArmed = false;
|
||||
|
||||
for (let i = 0; i < startIndex; i += 1) {
|
||||
const row = rows[i];
|
||||
const p50 = row.levels[50];
|
||||
|
||||
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
|
||||
buyActive = true;
|
||||
sellArmed = false;
|
||||
}
|
||||
|
||||
if (buyActive && isP100Increase(row, rows[i - 1])) {
|
||||
buyActive = false;
|
||||
sellArmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { buyActive, sellArmed };
|
||||
}
|
||||
|
||||
function scoreVariant(rows, starts, weights, sellMultiple, opts) {
|
||||
const finals = [];
|
||||
const vsDca = [];
|
||||
const vsLump = [];
|
||||
const drawdowns = [];
|
||||
|
||||
for (const start of starts) {
|
||||
const result = simulateSignal(rows, start.index, start.phase, weights, sellMultiple, opts);
|
||||
finals.push(result.finalValue);
|
||||
vsDca.push(result.finalValue / start.dca.finalValue - 1);
|
||||
vsLump.push(result.finalValue / start.lump.finalValue - 1);
|
||||
drawdowns.push(result.maxDrawdown);
|
||||
}
|
||||
|
||||
return {
|
||||
finals,
|
||||
avgFinal: avg(finals),
|
||||
avgVsDca: avg(vsDca),
|
||||
medianVsDca: median(vsDca),
|
||||
worstVsDca: Math.min(...vsDca),
|
||||
avgVsLump: avg(vsLump),
|
||||
avgDrawdown: avg(drawdowns),
|
||||
};
|
||||
}
|
||||
|
||||
function simulateSignal(rows, startIndex, phase, weights, sellMultiple, opts) {
|
||||
let cash = opts.initialCash;
|
||||
let btc = 0;
|
||||
let buyActive = phase.buyActive;
|
||||
let sellArmed = phase.sellArmed;
|
||||
let initialDeployActiveDays = 0;
|
||||
let peakValue = cash;
|
||||
let maxDrawdown = 0;
|
||||
const baseDailyBuy = 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;
|
||||
|
||||
const p50 = row.levels[50];
|
||||
if (Number.isFinite(p50) && p50 > 0 && row.price <= p50) {
|
||||
buyActive = true;
|
||||
sellArmed = false;
|
||||
}
|
||||
|
||||
if (buyActive && isP100Increase(row, rows[i - 1])) {
|
||||
buyActive = false;
|
||||
sellArmed = true;
|
||||
}
|
||||
|
||||
if (sellMultiple > 0 && sellArmed && isInsideSellBand(row) && btc > 0) {
|
||||
const btcToSell = btc * Math.min(1, 0.005 * sellMultiple);
|
||||
btc -= btcToSell;
|
||||
cash += btcToSell * row.price;
|
||||
}
|
||||
|
||||
if (buyActive && cash > 0) {
|
||||
const initialBudget =
|
||||
initialDeployActiveDays < opts.initialDeployDays
|
||||
? opts.initialCash / opts.initialDeployDays
|
||||
: 0;
|
||||
const usd = Math.min(
|
||||
cash,
|
||||
(baseDailyBuy + initialBudget) * buyWeight(row, weights),
|
||||
);
|
||||
if (usd > 0) {
|
||||
btc += usd / row.price;
|
||||
cash -= usd;
|
||||
initialDeployActiveDays += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const value = cash + btc * row.price;
|
||||
peakValue = Math.max(peakValue, value);
|
||||
maxDrawdown = Math.max(maxDrawdown, peakValue === 0 ? 0 : 1 - value / peakValue);
|
||||
}
|
||||
|
||||
return {
|
||||
finalValue: cash + btc * rows.at(-1).price,
|
||||
maxDrawdown,
|
||||
};
|
||||
}
|
||||
|
||||
function simulateLumpAndTopup(rows, startIndex, opts) {
|
||||
let btc = opts.initialCash / rows[startIndex].price;
|
||||
for (let i = startIndex + 1; i < rows.length; i += 1) {
|
||||
if (isMonthStart(rows[i].date)) btc += opts.monthlyTopup / rows[i].price;
|
||||
}
|
||||
return { finalValue: btc * rows.at(-1).price };
|
||||
}
|
||||
|
||||
function simulateSimpleDailyDca(rows, startIndex, opts) {
|
||||
let cash = opts.initialCash;
|
||||
let btc = 0;
|
||||
const baseDailyBuy = 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;
|
||||
|
||||
const elapsedDays = i - startIndex;
|
||||
const initialBudget =
|
||||
elapsedDays < opts.initialDeployDays ? opts.initialCash / opts.initialDeployDays : 0;
|
||||
const usd = Math.min(cash, baseDailyBuy + initialBudget);
|
||||
if (usd > 0) {
|
||||
btc += usd / row.price;
|
||||
cash -= usd;
|
||||
}
|
||||
}
|
||||
|
||||
return { finalValue: cash + btc * rows.at(-1).price };
|
||||
}
|
||||
|
||||
function linearWeights(top, bottom) {
|
||||
const weights = {};
|
||||
for (let i = 0; i < PCTS.length; i += 1) {
|
||||
weights[PCTS[i]] = top + ((bottom - top) * i) / (PCTS.length - 1);
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
function buyWeight(row, weights) {
|
||||
let weight = 0;
|
||||
for (const pct of PCTS) {
|
||||
const level = row.levels[pct];
|
||||
if (Number.isFinite(level) && level > 0 && row.price <= level) {
|
||||
weight = Math.max(weight, weights[pct]);
|
||||
}
|
||||
}
|
||||
return weight;
|
||||
}
|
||||
|
||||
function isP100Increase(row, previousRow) {
|
||||
const level = row.levels[100];
|
||||
const previousLevel = previousRow?.levels[100];
|
||||
return Number.isFinite(level) && Number.isFinite(previousLevel) && level > previousLevel;
|
||||
}
|
||||
|
||||
function isInsideSellBand(row) {
|
||||
const p95 = row.levels[95];
|
||||
const p100 = row.levels[100];
|
||||
if (!Number.isFinite(p95) || !Number.isFinite(p100) || p95 <= 0 || p100 <= 0) {
|
||||
return false;
|
||||
}
|
||||
const lower = Math.min(p95, p100);
|
||||
const upper = Math.max(p95, p100);
|
||||
return row.price >= lower && row.price <= upper;
|
||||
}
|
||||
|
||||
function costBasisSeriesName(pct) {
|
||||
if (pct === 100) return "cost_basis_max";
|
||||
return `cost_basis_per_coin_pct${String(pct).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
|
||||
function isMonthStart(date) {
|
||||
return date.endsWith("-01");
|
||||
}
|
||||
|
||||
function range(min, max, step) {
|
||||
const values = [];
|
||||
const scale = 1 / step;
|
||||
for (let value = min; value <= max + step / 10; value += step) {
|
||||
values.push(Math.round(value * scale) / scale);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function avg(values) {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
return String(Number(value.toFixed(2)));
|
||||
}
|
||||
|
||||
function fmtPct(value) {
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function usd(value) {
|
||||
return `$${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user