mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 06:39:58 -07:00
1299 lines
36 KiB
JavaScript
1299 lines
36 KiB
JavaScript
/**
|
|
* Profitability section builders
|
|
*
|
|
* Capability tiers:
|
|
* - Full (All/STH/LTH): full unrealized with rel series, invested capital, sentiment;
|
|
* full realized with relToRcap, peakRegret, profitToLossRatio, grossPnl
|
|
* - Mid (AgeRange/MaxAge): unrealized profit/loss/netPnl/nupl (no rel, no invested, no sentiment);
|
|
* realized with netPnl + delta (no relToRcap, no peakRegret)
|
|
* - Basic (UtxoAmount, Empty, Address): nupl only unrealized;
|
|
* basic realized profit/loss (no netPnl, no relToRcap)
|
|
*/
|
|
|
|
import { Unit } from "../../utils/units.js";
|
|
import {
|
|
ROLLING_WINDOWS,
|
|
line,
|
|
dotted,
|
|
baseline,
|
|
percentRatio,
|
|
percentRatioBaseline,
|
|
sumsTreeBaseline,
|
|
rollingPercentRatioTree,
|
|
mapWindows,
|
|
} from "../series.js";
|
|
import { colors } from "../../utils/colors.js";
|
|
import { priceLine } from "../constants.js";
|
|
import {
|
|
mapCohortsWithAll,
|
|
flatMapCohortsWithAll,
|
|
groupedWindowsCumulativeUsd,
|
|
} from "../shared.js";
|
|
|
|
// ============================================================================
|
|
// Core Series Builders
|
|
// ============================================================================
|
|
|
|
|
|
// ============================================================================
|
|
// Unrealized P&L Builders
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Overview chart: net + profit + loss inverted (active), loss raw (hidden)
|
|
* @param {{ usd: AnySeriesPattern }} profit
|
|
* @param {{ usd: AnySeriesPattern, negative: AnySeriesPattern }} loss
|
|
* @param {AnySeriesPattern} netPnlUsd
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialChartOption}
|
|
*/
|
|
function unrealizedOverview(profit, loss, netPnlUsd, title) {
|
|
return {
|
|
name: "Overview",
|
|
title: title("Unrealized P&L"),
|
|
bottom: [
|
|
baseline({ series: netPnlUsd, name: "Net", unit: Unit.usd }),
|
|
dotted({
|
|
series: profit.usd,
|
|
name: "Profit",
|
|
color: colors.profit,
|
|
unit: Unit.usd,
|
|
}),
|
|
dotted({
|
|
series: loss.negative,
|
|
name: "Neg. Loss",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
}),
|
|
dotted({
|
|
series: loss.usd,
|
|
name: "Loss",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
defaultActive: false,
|
|
}),
|
|
priceLine({ unit: Unit.usd }),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Relative P&L chart: profit + loss as % of some denominator
|
|
* @param {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} profit
|
|
* @param {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} loss
|
|
* @param {string} name
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialChartOption}
|
|
*/
|
|
function relPnlChart(profit, loss, name, title) {
|
|
return {
|
|
name,
|
|
title: title(`Unrealized P&L (${name})`),
|
|
bottom: [
|
|
...percentRatio({
|
|
pattern: profit,
|
|
name: "Profit",
|
|
color: colors.profit,
|
|
}),
|
|
...percentRatio({ pattern: loss, name: "Loss", color: colors.loss }),
|
|
],
|
|
};
|
|
}
|
|
|
|
/** @param {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} net @param {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} profit @param {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} loss @param {string} name @param {(name: string) => string} title */
|
|
function relPnlChartWithNet(net, profit, loss, name, title) {
|
|
return {
|
|
name,
|
|
title: title(`Unrealized P&L (${name})`),
|
|
bottom: [
|
|
...percentRatioBaseline({ pattern: net, name: "Net" }),
|
|
...percentRatio({ pattern: profit, name: "Profit", color: colors.profit }),
|
|
...percentRatio({ pattern: loss, name: "Loss", color: colors.loss }),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Core unrealized items: Overview + Net + NUPL + Profit + Loss
|
|
* @param {{ profit: { usd: AnySeriesPattern }, loss: { usd: AnySeriesPattern, negative: AnySeriesPattern }, netPnl: { usd: AnySeriesPattern }, nupl: NuplPattern }} u
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function unrealizedCore(u, title) {
|
|
return [
|
|
unrealizedOverview(u.profit, u.loss, u.netPnl.usd, title),
|
|
{
|
|
name: "Net",
|
|
title: title("Net Unrealized P&L"),
|
|
bottom: [baseline({ series: u.netPnl.usd, name: "Net", unit: Unit.usd })],
|
|
},
|
|
{ name: "NUPL", title: title("NUPL"), bottom: nuplSeries(u.nupl) },
|
|
{
|
|
name: "Profit",
|
|
title: title("Unrealized Profit"),
|
|
bottom: [
|
|
line({
|
|
series: u.profit.usd,
|
|
name: "Profit",
|
|
color: colors.profit,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
name: "Loss",
|
|
title: title("Unrealized Loss"),
|
|
bottom: [
|
|
line({
|
|
series: u.loss.usd,
|
|
name: "Loss",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Core unrealized items + Gross
|
|
* @param {{ profit: { usd: AnySeriesPattern }, loss: { usd: AnySeriesPattern, negative: AnySeriesPattern }, netPnl: { usd: AnySeriesPattern }, grossPnl: { usd: AnySeriesPattern }, nupl: NuplPattern }} u
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function unrealizedCoreWithGross(u, title) {
|
|
return [
|
|
...unrealizedCore(u, title),
|
|
{
|
|
name: "Gross",
|
|
title: title("Gross Unrealized P&L"),
|
|
bottom: [
|
|
line({
|
|
series: u.grossPnl.usd,
|
|
name: "Gross",
|
|
color: colors.gross,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* % of Own P&L chart
|
|
* @param {AllRelativePattern | FullRelativePattern} u
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialChartOption}
|
|
*/
|
|
function ownPnlChart(u, title) {
|
|
return {
|
|
name: "% of Own P&L",
|
|
title: title("Unrealized P&L (% of Own P&L)"),
|
|
bottom: [
|
|
...percentRatioBaseline({ pattern: u.netPnl.toOwnGrossPnl, name: "Net" }),
|
|
...percentRatio({
|
|
pattern: u.profit.toOwnGrossPnl,
|
|
name: "Profit",
|
|
color: colors.profit,
|
|
defaultActive: false,
|
|
}),
|
|
...percentRatio({
|
|
pattern: u.loss.toOwnGrossPnl,
|
|
name: "Loss",
|
|
color: colors.loss,
|
|
defaultActive: false,
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
/** @param {AllRelativePattern} u @param {(name: string) => string} title */
|
|
function unrealizedTreeAll(u, title) {
|
|
return [
|
|
...unrealizedCoreWithGross(u, title),
|
|
ownPnlChart(u, title),
|
|
relPnlChart(u.profit.toMcap, u.loss.toMcap, "% of Market Cap", title),
|
|
];
|
|
}
|
|
|
|
/** @param {FullRelativePattern} u @param {(name: string) => string} title */
|
|
function unrealizedTreeFull(u, title) {
|
|
return [
|
|
...unrealizedCoreWithGross(u, title),
|
|
ownPnlChart(u, title),
|
|
relPnlChart(u.profit.toMcap, u.loss.toMcap, "% of Market Cap", title),
|
|
relPnlChartWithNet(u.netPnl.toOwnMcap, u.profit.toOwnMcap, u.loss.toOwnMcap, "% of Own Market Cap", title),
|
|
];
|
|
}
|
|
|
|
/** @param {FullRelativePattern} u @param {(name: string) => string} title */
|
|
function unrealizedTreeLongTerm(u, title) {
|
|
return [
|
|
...unrealizedCoreWithGross(u, title),
|
|
ownPnlChart(u, title),
|
|
{
|
|
name: "% of Market Cap",
|
|
title: title("Unrealized Loss (% of Market Cap)"),
|
|
bottom: percentRatio({
|
|
pattern: u.loss.toMcap,
|
|
name: "Loss",
|
|
color: colors.loss,
|
|
}),
|
|
},
|
|
relPnlChartWithNet(u.netPnl.toOwnMcap, u.profit.toOwnMcap, u.loss.toOwnMcap, "% of Own Market Cap", title),
|
|
];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Invested Capital, Sentiment, NUPL
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Sentiment (Full unrealized only)
|
|
* @param {FullRelativePattern | AllRelativePattern} u
|
|
* @returns {AnyFetchedSeriesBlueprint[]}
|
|
*/
|
|
function sentimentSeries(u) {
|
|
return [
|
|
baseline({
|
|
series: u.sentiment.net.usd,
|
|
name: "Net Sentiment",
|
|
unit: Unit.usd,
|
|
}),
|
|
line({
|
|
series: u.sentiment.greedIndex.usd,
|
|
name: "Greed Index",
|
|
color: colors.profit,
|
|
unit: Unit.usd,
|
|
defaultActive: false,
|
|
}),
|
|
line({
|
|
series: u.sentiment.painIndex.usd,
|
|
name: "Pain Index",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
defaultActive: false,
|
|
}),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* NUPL series
|
|
* @param {NuplPattern} nupl
|
|
* @returns {AnyFetchedSeriesBlueprint[]}
|
|
*/
|
|
function nuplSeries(nupl) {
|
|
return [baseline({ series: nupl.ratio, name: "NUPL", unit: Unit.ratio })];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Realized P&L Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Flat metric folder: Compare + windows + Cumulative + optional % of Realized Cap
|
|
* @param {Object} args
|
|
* @param {{ sum: Record<string, { usd: AnySeriesPattern }>, cumulative: { usd: AnySeriesPattern } }} args.pattern
|
|
* @param {string} args.metricTitle
|
|
* @param {Color} args.color
|
|
* @param {(name: string) => string} args.title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function realizedMetricFolder({ pattern, metricTitle, color, title }) {
|
|
return [
|
|
{
|
|
name: "Compare",
|
|
title: title(`Realized ${metricTitle}`),
|
|
bottom: ROLLING_WINDOWS.map((w) =>
|
|
line({
|
|
series: pattern.sum[w.key].usd,
|
|
name: w.name,
|
|
color: w.color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
...ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Realized ${metricTitle} (${w.title})`),
|
|
bottom: [
|
|
line({
|
|
series: pattern.sum[w.key].usd,
|
|
name: metricTitle,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
})),
|
|
{
|
|
name: "Cumulative",
|
|
title: title(`Cumulative Realized ${metricTitle}`),
|
|
bottom: [
|
|
line({
|
|
series: pattern.cumulative.usd,
|
|
name: metricTitle,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Net P&L folder: Compare + windows + Cumulative + optional % of Rcap + Change/
|
|
* @param {Object} args
|
|
* @param {NetPnlFullPattern | NetPnlBasicPattern} args.netPnl
|
|
* @param {(name: string) => string} args.title
|
|
* @param {PartialOptionsTree} [args.extraChange] - Additional change items (% of Mcap, % of Rcap)
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function realizedNetFolder({ netPnl, title, extraChange = [] }) {
|
|
return {
|
|
name: "Net",
|
|
tree: [
|
|
{
|
|
name: "Compare",
|
|
title: title("Net Realized P&L"),
|
|
bottom: ROLLING_WINDOWS.map((w) =>
|
|
baseline({
|
|
series: netPnl.sum[w.key].usd,
|
|
name: w.name,
|
|
color: w.color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
...ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Net Realized P&L (${w.title})`),
|
|
bottom: [
|
|
baseline({
|
|
series: netPnl.sum[w.key].usd,
|
|
name: "Net",
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
})),
|
|
{
|
|
name: "Cumulative",
|
|
title: title("Cumulative Net Realized P&L"),
|
|
bottom: [
|
|
baseline({
|
|
series: netPnl.cumulative.usd,
|
|
name: "Net",
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
...sumsTreeBaseline({
|
|
windows: mapWindows(netPnl.delta.absolute, (c) => c.usd),
|
|
title: title("Net Realized P&L Change"),
|
|
unit: Unit.usd,
|
|
}),
|
|
name: "Change",
|
|
},
|
|
{
|
|
name: "Growth Rate",
|
|
tree: [
|
|
...rollingPercentRatioTree({
|
|
windows: netPnl.delta.rate,
|
|
title: title("Net Realized P&L Growth Rate"),
|
|
}).tree,
|
|
...extraChange,
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Realized overview folder: one chart per window showing net + profit (dotted) + neg. loss (dotted) + loss (hidden) + gross (hidden)
|
|
* @param {Object} args
|
|
* @param {{ sum: Record<string, { usd: AnySeriesPattern }> }} args.profit
|
|
* @param {{ sum: Record<string, { usd: AnySeriesPattern }>, negative: { sum: Record<string, AnySeriesPattern> } }} args.loss
|
|
* @param {{ sum: Record<string, { usd: AnySeriesPattern }> }} args.netPnl
|
|
* @param {{ sum: Record<string, { usd: AnySeriesPattern }> }} [args.grossPnl]
|
|
* @param {{ sum: Record<string, { usd: AnySeriesPattern }> }} [args.peakRegret]
|
|
* @param {(name: string) => string} args.title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function realizedOverviewFolder({
|
|
profit,
|
|
loss,
|
|
netPnl,
|
|
grossPnl,
|
|
peakRegret,
|
|
title,
|
|
}) {
|
|
return {
|
|
name: "Overview",
|
|
tree: ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Realized P&L (${w.title})`),
|
|
bottom: [
|
|
baseline({
|
|
series: netPnl.sum[w.key].usd,
|
|
name: "Net",
|
|
unit: Unit.usd,
|
|
}),
|
|
dotted({
|
|
series: profit.sum[w.key].usd,
|
|
name: "Profit",
|
|
color: colors.profit,
|
|
unit: Unit.usd,
|
|
}),
|
|
dotted({
|
|
series: loss.negative.sum[w.key],
|
|
name: "Neg. Loss",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
}),
|
|
dotted({
|
|
series: loss.sum[w.key].usd,
|
|
name: "Loss",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
defaultActive: false,
|
|
}),
|
|
...(grossPnl
|
|
? [
|
|
dotted({
|
|
series: grossPnl.sum[w.key].usd,
|
|
name: "Gross",
|
|
color: colors.gross,
|
|
unit: Unit.usd,
|
|
defaultActive: false,
|
|
}),
|
|
]
|
|
: []),
|
|
...(peakRegret
|
|
? [
|
|
dotted({
|
|
series: peakRegret.sum[w.key].usd,
|
|
name: "Peak Regret",
|
|
color: colors.regret,
|
|
unit: Unit.usd,
|
|
defaultActive: false,
|
|
}),
|
|
]
|
|
: []),
|
|
priceLine({ unit: Unit.usd }),
|
|
],
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Realized Subfolder Builders
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Full realized subfolder (All/STH/LTH)
|
|
* @param {RealizedPattern | LthRealizedPattern} r
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function realizedSubfolderFull(r, title) {
|
|
return {
|
|
name: "Realized",
|
|
tree: [
|
|
realizedOverviewFolder({
|
|
profit: r.profit,
|
|
loss: r.loss,
|
|
netPnl: r.netPnl,
|
|
grossPnl: r.grossPnl,
|
|
peakRegret: r.peakRegret,
|
|
title,
|
|
}),
|
|
realizedNetFolder({
|
|
netPnl: r.netPnl,
|
|
title,
|
|
extraChange: [
|
|
{
|
|
name: "% of Market Cap",
|
|
title: title("Net Realized P&L Change (% of Market Cap)"),
|
|
bottom: percentRatioBaseline({
|
|
pattern: r.netPnl.change1m.toMcap,
|
|
name: "30d Change",
|
|
}),
|
|
},
|
|
{
|
|
name: "% of Realized Cap",
|
|
title: title("Net Realized P&L Change (% of Realized Cap)"),
|
|
bottom: percentRatioBaseline({
|
|
pattern: r.netPnl.change1m.toRcap,
|
|
name: "30d Change",
|
|
}),
|
|
},
|
|
],
|
|
}),
|
|
{
|
|
name: "Profit",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.profit,
|
|
metricTitle: "Profit",
|
|
color: colors.profit,
|
|
title,
|
|
}),
|
|
},
|
|
{
|
|
name: "Loss",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.loss,
|
|
metricTitle: "Loss",
|
|
color: colors.loss,
|
|
title,
|
|
}),
|
|
},
|
|
{
|
|
name: "Gross",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.grossPnl,
|
|
metricTitle: "Gross P&L",
|
|
color: colors.gross,
|
|
title,
|
|
}),
|
|
},
|
|
{
|
|
name: "P/L Ratio",
|
|
tree: [
|
|
{
|
|
name: "Compare",
|
|
title: title("Realized P/L Ratio"),
|
|
bottom: ROLLING_WINDOWS.map((w) =>
|
|
baseline({
|
|
series: r.profitToLossRatio[w.key],
|
|
name: w.name,
|
|
color: w.color,
|
|
unit: Unit.ratio,
|
|
base: 1,
|
|
}),
|
|
),
|
|
},
|
|
...ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Realized P/L Ratio (${w.title})`),
|
|
bottom: [
|
|
baseline({
|
|
series: r.profitToLossRatio[w.key],
|
|
name: "P/L Ratio",
|
|
unit: Unit.ratio,
|
|
base: 1,
|
|
}),
|
|
],
|
|
})),
|
|
],
|
|
},
|
|
{
|
|
name: "Peak Regret",
|
|
tree: [
|
|
{
|
|
name: "Compare",
|
|
title: title("Peak Regret"),
|
|
bottom: ROLLING_WINDOWS.map((w) =>
|
|
line({
|
|
series: r.peakRegret.sum[w.key].usd,
|
|
name: w.name,
|
|
color: w.color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
...ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Peak Regret (${w.title})`),
|
|
bottom: [
|
|
line({
|
|
series: r.peakRegret.sum[w.key].usd,
|
|
name: "Peak Regret",
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
})),
|
|
{
|
|
name: "Cumulative",
|
|
title: title("Cumulative Peak Regret"),
|
|
bottom: [
|
|
line({
|
|
series: r.peakRegret.cumulative.usd,
|
|
name: "Peak Regret",
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mid realized subfolder (AgeRange/MaxAge — has netPnl + delta, no relToRcap/peakRegret)
|
|
* @param {MidRealizedPattern} r
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function realizedSubfolderMid(r, title) {
|
|
return {
|
|
name: "Realized",
|
|
tree: [
|
|
realizedOverviewFolder({
|
|
profit: r.profit,
|
|
loss: r.loss,
|
|
netPnl: r.netPnl,
|
|
title,
|
|
}),
|
|
realizedNetFolder({ netPnl: r.netPnl, title }),
|
|
{
|
|
name: "Profit",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.profit,
|
|
metricTitle: "Profit",
|
|
color: colors.profit,
|
|
title,
|
|
}),
|
|
},
|
|
{
|
|
name: "Loss",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.loss,
|
|
metricTitle: "Loss",
|
|
color: colors.loss,
|
|
title,
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Basic realized subfolder (no netPnl, no relToRcap)
|
|
* @param {BasicRealizedPattern} r
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function realizedSubfolderBasic(r, title) {
|
|
return {
|
|
name: "Realized",
|
|
tree: [
|
|
{
|
|
name: "Profit",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.profit,
|
|
metricTitle: "Profit",
|
|
color: colors.profit,
|
|
title,
|
|
}),
|
|
},
|
|
{
|
|
name: "Loss",
|
|
tree: realizedMetricFolder({
|
|
pattern: r.loss,
|
|
metricTitle: "Loss",
|
|
color: colors.loss,
|
|
title,
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Single Cohort Section Builders
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Basic profitability section (NUPL only unrealized, basic realized)
|
|
* @param {{ cohort: UtxoCohortObject, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createProfitabilitySection({ cohort, title }) {
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: [{ name: "NUPL", title: title("NUPL"), bottom: nuplSeries(cohort.tree.unrealized.nupl) }] },
|
|
realizedSubfolderBasic(cohort.tree.realized, title),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Profitability section with unrealized P&L + NUPL (no netPnl, no rel)
|
|
* For: CohortWithoutRelative (p2ms, unknown, empty)
|
|
* @param {{ cohort: CohortWithoutRelative, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createProfitabilitySectionWithProfitLoss({ cohort, title }) {
|
|
const u = cohort.tree.unrealized;
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{
|
|
name: "Unrealized",
|
|
tree: [
|
|
{
|
|
name: "Overview",
|
|
title: title("Unrealized P&L"),
|
|
bottom: [
|
|
line({ series: u.profit.usd, name: "Profit", color: colors.profit, unit: Unit.usd }),
|
|
line({ series: u.loss.negative, name: "Neg. Loss", color: colors.loss, unit: Unit.usd }),
|
|
line({ series: u.loss.usd, name: "Loss", color: colors.loss, unit: Unit.usd, defaultActive: false }),
|
|
priceLine({ unit: Unit.usd }),
|
|
],
|
|
},
|
|
{ name: "NUPL", title: title("NUPL"), bottom: nuplSeries(u.nupl) },
|
|
{
|
|
name: "Profit",
|
|
title: title("Unrealized Profit"),
|
|
bottom: [
|
|
line({
|
|
series: u.profit.usd,
|
|
name: "Profit",
|
|
color: colors.profit,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
name: "Loss",
|
|
title: title("Unrealized Loss"),
|
|
bottom: [
|
|
line({
|
|
series: u.loss.usd,
|
|
name: "Loss",
|
|
color: colors.loss,
|
|
unit: Unit.usd,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
realizedSubfolderBasic(cohort.tree.realized, title),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Section for All cohort
|
|
* @param {{ cohort: CohortAll, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createProfitabilitySectionAll({ cohort, title }) {
|
|
const u = cohort.tree.unrealized;
|
|
const r = cohort.tree.realized;
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: unrealizedTreeAll(u, title) },
|
|
realizedSubfolderFull(r, title),
|
|
{
|
|
name: "Sentiment",
|
|
title: title("Market Sentiment"),
|
|
bottom: sentimentSeries(u),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Section for Full cohorts (STH)
|
|
* @param {{ cohort: CohortFull, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createProfitabilitySectionFull({ cohort, title }) {
|
|
const u = cohort.tree.unrealized;
|
|
const r = cohort.tree.realized;
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: unrealizedTreeFull(u, title) },
|
|
realizedSubfolderFull(r, title),
|
|
{
|
|
name: "Sentiment",
|
|
title: title("Market Sentiment"),
|
|
bottom: sentimentSeries(u),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Section for LongTerm cohort
|
|
* @param {{ cohort: CohortLongTerm, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createProfitabilitySectionLongTerm({ cohort, title }) {
|
|
const u = cohort.tree.unrealized;
|
|
const r = cohort.tree.realized;
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: unrealizedTreeLongTerm(u, title) },
|
|
realizedSubfolderFull(r, title),
|
|
{
|
|
name: "Sentiment",
|
|
title: title("Market Sentiment"),
|
|
bottom: sentimentSeries(u),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Section for AgeRange cohorts (mid-tier: has unrealized profit/loss/netPnl, mid realized)
|
|
* @param {{ cohort: CohortAgeRange, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createProfitabilitySectionWithInvestedCapitalPct({
|
|
cohort,
|
|
title,
|
|
}) {
|
|
const u = cohort.tree.unrealized;
|
|
const r = cohort.tree.realized;
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: unrealizedCore(u, title) },
|
|
realizedSubfolderMid(r, title),
|
|
],
|
|
};
|
|
}
|
|
|
|
|
|
|
|
// ============================================================================
|
|
// Grouped Cohort Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Grouped realized subfolder (basic)
|
|
* @template {{ name: string, color: Color, tree: { realized: { profit: RealizedProfitLossPattern, loss: RealizedProfitLossPattern } } }} T
|
|
* @template {{ name: string, color: Color, tree: { realized: { profit: RealizedProfitLossPattern, loss: RealizedProfitLossPattern } } }} A
|
|
* @param {readonly T[]} list
|
|
* @param {A} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
/**
|
|
* Grouped realized profit + loss items
|
|
* @param {readonly UtxoCohortObject[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function groupedRealizedProfitLossItems(list, all, title) {
|
|
return [
|
|
{ name: "Profit", tree: groupedWindowsCumulativeUsd({ list, all, title, metricTitle: "Realized Profit", getMetric: (c) => c.tree.realized.profit }) },
|
|
{ name: "Loss", tree: groupedWindowsCumulativeUsd({ list, all, title, metricTitle: "Realized Loss", getMetric: (c) => c.tree.realized.loss }) },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Grouped realized net item
|
|
* @param {readonly (CohortAgeRange | CohortWithAdjusted | CohortAll | CohortFull | CohortLongTerm)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function groupedRealizedNetItem(list, all, title) {
|
|
return { name: "Net", tree: groupedWindowsCumulativeUsd({ list, all, title, metricTitle: "Net Realized P&L", getMetric: (c) => c.tree.realized.netPnl, seriesFn: baseline }) };
|
|
}
|
|
|
|
/**
|
|
* @param {readonly UtxoCohortObject[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function groupedRealizedSubfolder(list, all, title) {
|
|
return { name: "Realized", tree: groupedRealizedProfitLossItems(list, all, title) };
|
|
}
|
|
|
|
/**
|
|
* @param {readonly (CohortAgeRange | CohortWithAdjusted)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function groupedRealizedSubfolderMid(list, all, title) {
|
|
return { name: "Realized", tree: [groupedRealizedNetItem(list, all, title), ...groupedRealizedProfitLossItems(list, all, title)] };
|
|
}
|
|
|
|
/**
|
|
* Grouped net realized P&L delta (Absolute + Rate with all rolling windows)
|
|
* @param {readonly (CohortAll | CohortFull | CohortLongTerm)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function groupedRealizedNetPnlDeltaItems(list, all, title) {
|
|
return [
|
|
{
|
|
name: "Change",
|
|
tree: ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Net Realized P&L Change (${w.title})`),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
baseline({
|
|
series: tree.realized.netPnl.delta.absolute[w.key].usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
})),
|
|
},
|
|
{
|
|
name: "Growth Rate",
|
|
tree: ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Net Realized P&L Growth Rate (${w.title})`),
|
|
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
percentRatioBaseline({
|
|
pattern: tree.realized.netPnl.delta.rate[w.key],
|
|
name,
|
|
color,
|
|
}),
|
|
),
|
|
})),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Grouped realized subfolder for full cohorts
|
|
* @param {readonly (CohortAll | CohortFull | CohortLongTerm)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function groupedRealizedSubfolderFull(list, all, title) {
|
|
return {
|
|
name: "Realized",
|
|
tree: [
|
|
{
|
|
name: "Net",
|
|
tree: [
|
|
...groupedWindowsCumulativeUsd({
|
|
list,
|
|
all,
|
|
title,
|
|
metricTitle: "Net Realized P&L",
|
|
getMetric: (c) => c.tree.realized.netPnl,
|
|
seriesFn: baseline,
|
|
}),
|
|
...groupedRealizedNetPnlDeltaItems(list, all, title),
|
|
],
|
|
},
|
|
...groupedRealizedProfitLossItems(list, all, title),
|
|
{
|
|
name: "Gross",
|
|
tree: groupedWindowsCumulativeUsd({
|
|
list,
|
|
all,
|
|
title,
|
|
metricTitle: "Gross Realized P&L",
|
|
getMetric: (c) => c.tree.realized.grossPnl,
|
|
}),
|
|
},
|
|
{
|
|
name: "P/L Ratio",
|
|
tree: ROLLING_WINDOWS.map((w) => ({
|
|
name: w.name,
|
|
title: title(`Realized P/L Ratio (${w.title})`),
|
|
bottom: mapCohortsWithAll(list, all, (c) =>
|
|
baseline({
|
|
series: c.tree.realized.profitToLossRatio[w.key],
|
|
name: c.name,
|
|
color: c.color,
|
|
unit: Unit.ratio,
|
|
base: 1,
|
|
}),
|
|
),
|
|
})),
|
|
},
|
|
{
|
|
name: "Peak Regret",
|
|
tree: groupedWindowsCumulativeUsd({
|
|
list,
|
|
all,
|
|
title,
|
|
metricTitle: "Peak Regret",
|
|
getMetric: (c) => c.tree.realized.peakRegret,
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Grouped NUPL chart
|
|
* @template {{ name: string, color: Color, tree: { unrealized: { nupl: NuplPattern } } }} T
|
|
* @template {{ name: string, color: Color, tree: { unrealized: { nupl: NuplPattern } } }} A
|
|
* @param {readonly T[]} list
|
|
* @param {A} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function groupedNuplCharts(list, all, title) {
|
|
return [
|
|
{
|
|
name: "NUPL",
|
|
title: title("NUPL"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
baseline({
|
|
series: tree.unrealized.nupl.ratio,
|
|
name,
|
|
color,
|
|
unit: Unit.ratio,
|
|
}),
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Grouped unrealized: Net → NUPL → Profit → Loss (no relative)
|
|
* @param {readonly (CohortAgeRange | CohortWithAdjusted)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function groupedUnrealizedMid(list, all, title) {
|
|
return [
|
|
{
|
|
name: "Net",
|
|
title: title("Net Unrealized P&L"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
baseline({
|
|
series: tree.unrealized.netPnl.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
...groupedNuplCharts(list, all, title),
|
|
{
|
|
name: "Profit",
|
|
title: title("Unrealized Profit"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
line({
|
|
series: tree.unrealized.profit.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "Loss",
|
|
title: title("Unrealized Loss"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
line({ series: tree.unrealized.loss.usd, name, color, unit: Unit.usd }),
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Grouped unrealized: Net → NUPL → Profit → Loss → Relative(Market Cap)
|
|
* @param {readonly (CohortFull | CohortLongTerm)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsTree}
|
|
*/
|
|
function groupedUnrealizedWithMarketCap(list, all, title) {
|
|
return [
|
|
...groupedUnrealizedMid(list, all, title),
|
|
{
|
|
name: "% of Market Cap",
|
|
tree: [
|
|
{
|
|
name: "Profit",
|
|
title: title("Unrealized Profit (% of Market Cap)"),
|
|
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
percentRatio({
|
|
pattern: tree.unrealized.profit.toMcap,
|
|
name,
|
|
color,
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "Loss",
|
|
title: title("Unrealized Loss (% of Market Cap)"),
|
|
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
percentRatio({ pattern: tree.unrealized.loss.toMcap, name, color }),
|
|
),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Grouped sentiment (full unrealized only)
|
|
* @param {readonly (CohortAll | CohortFull | CohortLongTerm)[]} list
|
|
* @param {CohortAll} all
|
|
* @param {(name: string) => string} title
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
function groupedSentiment(list, all, title) {
|
|
return {
|
|
name: "Sentiment",
|
|
tree: [
|
|
{
|
|
name: "Net",
|
|
title: title("Net Sentiment"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
baseline({
|
|
series: tree.unrealized.sentiment.net.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "Greed",
|
|
title: title("Greed Index"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
line({
|
|
series: tree.unrealized.sentiment.greedIndex.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "Pain",
|
|
title: title("Pain Index"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
line({
|
|
series: tree.unrealized.sentiment.painIndex.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Grouped Section Builders
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Grouped profitability section (basic — NUPL only)
|
|
* @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative)[], all: CohortAll, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createGroupedProfitabilitySection({ list, all, title }) {
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: groupedNuplCharts(list, all, title) },
|
|
groupedRealizedSubfolder(list, all, title),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Grouped profitability with unrealized profit/loss + NUPL
|
|
* For: CohortWithoutRelative (p2ms, unknown, empty)
|
|
* @param {{ list: readonly CohortWithoutRelative[], all: CohortAll, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createGroupedProfitabilitySectionWithProfitLoss({
|
|
list,
|
|
all,
|
|
title,
|
|
}) {
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{
|
|
name: "Unrealized",
|
|
tree: [
|
|
...groupedNuplCharts(list, all, title),
|
|
{
|
|
name: "Profit",
|
|
title: title("Unrealized Profit"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
line({
|
|
series: tree.unrealized.profit.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "Loss",
|
|
title: title("Unrealized Loss"),
|
|
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
|
|
line({
|
|
series: tree.unrealized.loss.usd,
|
|
name,
|
|
color,
|
|
unit: Unit.usd,
|
|
}),
|
|
),
|
|
},
|
|
],
|
|
},
|
|
groupedRealizedSubfolder(list, all, title),
|
|
],
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Grouped section for ageRange/maxAge cohorts
|
|
* @param {{ list: readonly (CohortAgeRange | CohortWithAdjusted)[], all: CohortAll, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createGroupedProfitabilitySectionWithInvestedCapitalPct({
|
|
list,
|
|
all,
|
|
title,
|
|
}) {
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{ name: "Unrealized", tree: groupedUnrealizedMid(list, all, title) },
|
|
groupedRealizedSubfolderMid(list, all, title),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Grouped section with NUPL + relToMcap
|
|
* @param {{ list: readonly (CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
|
|
* @returns {PartialOptionsGroup}
|
|
*/
|
|
export function createGroupedProfitabilitySectionWithNupl({
|
|
list,
|
|
all,
|
|
title,
|
|
}) {
|
|
return {
|
|
name: "Profitability",
|
|
tree: [
|
|
{
|
|
name: "Unrealized",
|
|
tree: groupedUnrealizedWithMarketCap(list, all, title),
|
|
},
|
|
groupedRealizedSubfolderFull(list, all, title),
|
|
groupedSentiment(list, all, title),
|
|
],
|
|
};
|
|
}
|