@timecell/web 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components.json +23 -0
- package/index.html +13 -0
- package/package.json +32 -0
- package/public/logo.png +0 -0
- package/public/timecell.svg +4 -0
- package/src/App.tsx +117 -0
- package/src/components/ConvictionLadder.tsx +113 -0
- package/src/components/CrashCard.tsx +180 -0
- package/src/components/CrashChart.tsx +139 -0
- package/src/components/CrashGrid.tsx +12 -0
- package/src/components/InfoPanel.tsx +44 -0
- package/src/components/PortfolioForm.tsx +134 -0
- package/src/components/PositionSizing.tsx +382 -0
- package/src/components/SurvivalHero.tsx +90 -0
- package/src/components/SurvivalSummary.tsx +47 -0
- package/src/components/TemperatureGauge.tsx +283 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/card.tsx +76 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/slider.tsx +26 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/hooks/usePortfolio.ts +110 -0
- package/src/index.css +115 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +20 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
2
|
+
|
|
3
|
+
const sections = [
|
|
4
|
+
{
|
|
5
|
+
title: "Crash Model",
|
|
6
|
+
body: "Non-BTC assets drop at ~50% of BTC\u2019s drawdown (correlation model). If BTC drops 80%, non-BTC assets drop ~40%.",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
title: "Survival Threshold",
|
|
10
|
+
body: "18+ months of runway = safe. Less than 6 months = forced seller territory.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
title: "Ruin Test",
|
|
14
|
+
body: "The ultimate stress test: BTC drops 80% AND everything else drops 40% simultaneously. If you survive this, you survive anything.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
title: "Runway",
|
|
18
|
+
body: "Net position (portfolio after crash + cash reserve) divided by monthly burn rate.",
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function InfoPanel() {
|
|
23
|
+
return (
|
|
24
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
25
|
+
<CardContent className="p-4 sm:p-6">
|
|
26
|
+
<h3 className="text-base sm:text-lg font-semibold text-white mb-4">
|
|
27
|
+
How Does This Work?
|
|
28
|
+
</h3>
|
|
29
|
+
<div className="space-y-4">
|
|
30
|
+
{sections.map((s) => (
|
|
31
|
+
<div key={s.title}>
|
|
32
|
+
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">
|
|
33
|
+
{s.title}
|
|
34
|
+
</h4>
|
|
35
|
+
<p className="text-sm text-slate-300 leading-relaxed">
|
|
36
|
+
{s.body}
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
</CardContent>
|
|
42
|
+
</Card>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { PortfolioInput } from "../hooks/usePortfolio";
|
|
3
|
+
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
4
|
+
import { Slider } from "@/components/ui/slider";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
portfolio: PortfolioInput;
|
|
8
|
+
onUpdate: (updates: Partial<PortfolioInput>) => void;
|
|
9
|
+
savedAt?: number | null;
|
|
10
|
+
currencySymbol?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatLabel(value: number, symbol = "$"): string {
|
|
14
|
+
if (value >= 1_000_000) return `${symbol}${(value / 1_000_000).toFixed(1)}M`;
|
|
15
|
+
if (value >= 1_000) return `${symbol}${(value / 1_000).toFixed(0)}K`;
|
|
16
|
+
return `${symbol}${value.toFixed(0)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function InputField({
|
|
20
|
+
label,
|
|
21
|
+
value,
|
|
22
|
+
onChange,
|
|
23
|
+
min = 0,
|
|
24
|
+
max,
|
|
25
|
+
step = 1,
|
|
26
|
+
format,
|
|
27
|
+
suffix,
|
|
28
|
+
}: {
|
|
29
|
+
label: string;
|
|
30
|
+
value: number;
|
|
31
|
+
onChange: (v: number) => void;
|
|
32
|
+
min?: number;
|
|
33
|
+
max?: number;
|
|
34
|
+
step?: number;
|
|
35
|
+
format?: (v: number) => string;
|
|
36
|
+
suffix?: string;
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<div className="flex justify-between mb-2 sm:mb-1.5">
|
|
41
|
+
<label className="text-xs sm:text-sm text-slate-300">{label}</label>
|
|
42
|
+
<span className="text-xs sm:text-sm font-mono text-white">
|
|
43
|
+
{format ? format(value) : value}
|
|
44
|
+
{suffix}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
<Slider
|
|
48
|
+
min={min}
|
|
49
|
+
max={max}
|
|
50
|
+
step={step}
|
|
51
|
+
value={[value]}
|
|
52
|
+
onValueChange={(vals) => onChange(vals[0])}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function PortfolioForm({ portfolio, onUpdate, savedAt, currencySymbol = "$" }: Props) {
|
|
59
|
+
const [visible, setVisible] = useState(false);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!savedAt) return;
|
|
63
|
+
setVisible(true);
|
|
64
|
+
const timer = setTimeout(() => setVisible(false), 2000);
|
|
65
|
+
return () => clearTimeout(timer);
|
|
66
|
+
}, [savedAt]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
70
|
+
<CardHeader className="p-4 sm:p-6 pb-0 sm:pb-0">
|
|
71
|
+
<div className="flex items-center justify-between">
|
|
72
|
+
<CardTitle className="text-base sm:text-lg text-white">Portfolio</CardTitle>
|
|
73
|
+
<span
|
|
74
|
+
className={`text-xs text-green-400 transition-opacity duration-500 ${visible ? "opacity-100" : "opacity-0"}`}
|
|
75
|
+
>
|
|
76
|
+
✓ Saved
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
</CardHeader>
|
|
80
|
+
|
|
81
|
+
<CardContent className="p-4 sm:p-6 pt-4 sm:pt-5 space-y-4 sm:space-y-5">
|
|
82
|
+
<InputField
|
|
83
|
+
label="Total Value"
|
|
84
|
+
value={portfolio.totalValueUsd}
|
|
85
|
+
onChange={(v) => onUpdate({ totalValueUsd: v })}
|
|
86
|
+
min={100_000}
|
|
87
|
+
max={50_000_000}
|
|
88
|
+
step={100_000}
|
|
89
|
+
format={(v) => formatLabel(v, currencySymbol)}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
<InputField
|
|
93
|
+
label="Bitcoin Allocation"
|
|
94
|
+
value={portfolio.btcPercentage}
|
|
95
|
+
onChange={(v) => onUpdate({ btcPercentage: v })}
|
|
96
|
+
min={0}
|
|
97
|
+
max={100}
|
|
98
|
+
step={1}
|
|
99
|
+
suffix="%"
|
|
100
|
+
/>
|
|
101
|
+
|
|
102
|
+
<InputField
|
|
103
|
+
label="Monthly Burn"
|
|
104
|
+
value={portfolio.monthlyBurnUsd}
|
|
105
|
+
onChange={(v) => onUpdate({ monthlyBurnUsd: v })}
|
|
106
|
+
min={0}
|
|
107
|
+
max={500_000}
|
|
108
|
+
step={1_000}
|
|
109
|
+
format={(v) => formatLabel(v, currencySymbol)}
|
|
110
|
+
/>
|
|
111
|
+
|
|
112
|
+
<InputField
|
|
113
|
+
label="Liquid Reserve"
|
|
114
|
+
value={portfolio.liquidReserveUsd}
|
|
115
|
+
onChange={(v) => onUpdate({ liquidReserveUsd: v })}
|
|
116
|
+
min={0}
|
|
117
|
+
max={5_000_000}
|
|
118
|
+
step={10_000}
|
|
119
|
+
format={(v) => formatLabel(v, currencySymbol)}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<InputField
|
|
123
|
+
label="BTC Price"
|
|
124
|
+
value={portfolio.btcPriceUsd}
|
|
125
|
+
onChange={(v) => onUpdate({ btcPriceUsd: v })}
|
|
126
|
+
min={10_000}
|
|
127
|
+
max={200_000}
|
|
128
|
+
step={1_000}
|
|
129
|
+
format={(v) => formatLabel(v, currencySymbol)}
|
|
130
|
+
/>
|
|
131
|
+
</CardContent>
|
|
132
|
+
</Card>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
3
|
+
|
|
4
|
+
const API_BASE = "/api";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Types (mirroring engine — no direct engine dep in web)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
interface PositionSizingInput {
|
|
11
|
+
totalValueUsd: number;
|
|
12
|
+
currentBtcPct: number;
|
|
13
|
+
targetBtcPct: number;
|
|
14
|
+
monthlyBurnUsd: number;
|
|
15
|
+
liquidReserveUsd: number;
|
|
16
|
+
btcPriceUsd: number;
|
|
17
|
+
dcaMonths: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PositionSizingResult {
|
|
21
|
+
currentBtcUsd: number;
|
|
22
|
+
targetBtcUsd: number;
|
|
23
|
+
gapUsd: number;
|
|
24
|
+
gapBtc: number;
|
|
25
|
+
dcaMonthlyUsd: number;
|
|
26
|
+
dcaMonthlyBtc: number;
|
|
27
|
+
dcaMonths: number;
|
|
28
|
+
postReallocationRuinTest: boolean;
|
|
29
|
+
postReallocationRunwayMonths: number;
|
|
30
|
+
convictionRung: string;
|
|
31
|
+
currentConvictionRung: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function fmt(n: number, decimals = 0): string {
|
|
39
|
+
if (!Number.isFinite(n)) return "\u221e";
|
|
40
|
+
return n.toLocaleString("en-US", {
|
|
41
|
+
minimumFractionDigits: decimals,
|
|
42
|
+
maximumFractionDigits: decimals,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmtUsd(n: number, symbol = "$"): string {
|
|
47
|
+
const abs = Math.abs(n);
|
|
48
|
+
if (abs >= 1_000_000) return `${symbol}${fmt(n / 1_000_000, 2)}M`;
|
|
49
|
+
if (abs >= 1_000) return `${symbol}${fmt(n / 1_000, 1)}K`;
|
|
50
|
+
return `${symbol}${fmt(n)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fmtBtc(n: number): string {
|
|
54
|
+
const abs = Math.abs(n);
|
|
55
|
+
const prefix = n < 0 ? "-" : "";
|
|
56
|
+
if (abs < 0.001) return `${prefix}${(abs * 1_000_000).toFixed(0)}\u00a0sats`;
|
|
57
|
+
return `${prefix}${abs.toFixed(4)}\u00a0BTC`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Allocation bar: fills a horizontal bar with a BTC (orange) and non-BTC segment
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function AllocationBar({
|
|
65
|
+
btcPct,
|
|
66
|
+
label,
|
|
67
|
+
}: {
|
|
68
|
+
btcPct: number;
|
|
69
|
+
label: string;
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="space-y-1.5">
|
|
73
|
+
<div className="flex justify-between text-xs text-slate-400">
|
|
74
|
+
<span>{label}</span>
|
|
75
|
+
<span className="font-mono text-slate-300">
|
|
76
|
+
BTC <span className="text-orange-400 font-bold">{btcPct.toFixed(1)}%</span>
|
|
77
|
+
{" / "}Other {(100 - btcPct).toFixed(1)}%
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="relative h-6 rounded-md overflow-hidden bg-slate-700/50">
|
|
81
|
+
{/* BTC segment */}
|
|
82
|
+
<div
|
|
83
|
+
className="absolute left-0 top-0 h-full bg-orange-500/70 transition-all duration-500 rounded-l-md"
|
|
84
|
+
style={{ width: `${Math.min(btcPct, 100)}%` }}
|
|
85
|
+
/>
|
|
86
|
+
{/* Non-BTC segment fills the rest — already the default bg */}
|
|
87
|
+
{/* Marker at BTC pct */}
|
|
88
|
+
{btcPct > 0 && btcPct < 100 && (
|
|
89
|
+
<div
|
|
90
|
+
className="absolute top-0 h-full w-px bg-slate-300/40"
|
|
91
|
+
style={{ left: `${btcPct}%` }}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
{/* Label inside bar */}
|
|
95
|
+
<div className="absolute inset-0 flex items-center px-2 gap-1">
|
|
96
|
+
{btcPct >= 12 && (
|
|
97
|
+
<span className="text-xs font-semibold text-orange-100 drop-shadow">
|
|
98
|
+
BTC
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Slider with label
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function LabeledSlider({
|
|
112
|
+
label,
|
|
113
|
+
value,
|
|
114
|
+
min,
|
|
115
|
+
max,
|
|
116
|
+
step,
|
|
117
|
+
onChange,
|
|
118
|
+
display,
|
|
119
|
+
}: {
|
|
120
|
+
label: string;
|
|
121
|
+
value: number;
|
|
122
|
+
min: number;
|
|
123
|
+
max: number;
|
|
124
|
+
step: number;
|
|
125
|
+
onChange: (v: number) => void;
|
|
126
|
+
display: string;
|
|
127
|
+
}) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="space-y-1.5">
|
|
130
|
+
<div className="flex justify-between items-center">
|
|
131
|
+
<span className="text-xs text-slate-400">{label}</span>
|
|
132
|
+
<span className="text-sm font-mono font-semibold text-orange-400">{display}</span>
|
|
133
|
+
</div>
|
|
134
|
+
<input
|
|
135
|
+
type="range"
|
|
136
|
+
min={min}
|
|
137
|
+
max={max}
|
|
138
|
+
step={step}
|
|
139
|
+
value={value}
|
|
140
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
141
|
+
className="w-full h-1.5 rounded-full accent-orange-500 bg-slate-700 cursor-pointer"
|
|
142
|
+
/>
|
|
143
|
+
<div className="flex justify-between text-xs text-slate-600">
|
|
144
|
+
<span>{min}%</span>
|
|
145
|
+
<span>{max}%</span>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Main component
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
export interface PositionSizingProps {
|
|
156
|
+
totalValueUsd: number;
|
|
157
|
+
currentBtcPct: number;
|
|
158
|
+
monthlyBurnUsd: number;
|
|
159
|
+
liquidReserveUsd: number;
|
|
160
|
+
btcPriceUsd: number;
|
|
161
|
+
currencySymbol?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DCA_OPTIONS = [1, 3, 6, 12, 24] as const;
|
|
165
|
+
|
|
166
|
+
export function PositionSizing({
|
|
167
|
+
totalValueUsd,
|
|
168
|
+
currentBtcPct,
|
|
169
|
+
monthlyBurnUsd,
|
|
170
|
+
liquidReserveUsd,
|
|
171
|
+
btcPriceUsd,
|
|
172
|
+
currencySymbol = "$",
|
|
173
|
+
}: PositionSizingProps) {
|
|
174
|
+
const [targetBtcPct, setTargetBtcPct] = useState(() => Math.min(currentBtcPct + 5, 100));
|
|
175
|
+
const [dcaMonths, setDcaMonths] = useState<(typeof DCA_OPTIONS)[number]>(6);
|
|
176
|
+
const [result, setResult] = useState<PositionSizingResult | null>(null);
|
|
177
|
+
const [loading, setLoading] = useState(false);
|
|
178
|
+
|
|
179
|
+
const calculate = useCallback(
|
|
180
|
+
async (target: number, months: number) => {
|
|
181
|
+
if (!btcPriceUsd || !totalValueUsd) return;
|
|
182
|
+
setLoading(true);
|
|
183
|
+
try {
|
|
184
|
+
const body: PositionSizingInput = {
|
|
185
|
+
totalValueUsd,
|
|
186
|
+
currentBtcPct,
|
|
187
|
+
targetBtcPct: target,
|
|
188
|
+
monthlyBurnUsd,
|
|
189
|
+
liquidReserveUsd,
|
|
190
|
+
btcPriceUsd,
|
|
191
|
+
dcaMonths: months,
|
|
192
|
+
};
|
|
193
|
+
const res = await fetch(`${API_BASE}/position-sizing`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify(body),
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) throw new Error(`API ${res.status}`);
|
|
199
|
+
setResult(await res.json());
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.error("position-sizing error:", err);
|
|
202
|
+
} finally {
|
|
203
|
+
setLoading(false);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[totalValueUsd, currentBtcPct, monthlyBurnUsd, liquidReserveUsd, btcPriceUsd],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Recalculate whenever inputs change
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
calculate(targetBtcPct, dcaMonths);
|
|
212
|
+
}, [calculate, targetBtcPct, dcaMonths]);
|
|
213
|
+
|
|
214
|
+
const isBuying = result ? result.gapUsd >= 0 : targetBtcPct >= currentBtcPct;
|
|
215
|
+
const gapAbs = result ? Math.abs(result.gapUsd) : 0;
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
219
|
+
<CardContent className="p-4 sm:p-6 space-y-6">
|
|
220
|
+
{/* Header */}
|
|
221
|
+
<div>
|
|
222
|
+
<h3 className="text-base sm:text-lg font-semibold text-white mb-0.5">
|
|
223
|
+
Position Sizing
|
|
224
|
+
</h3>
|
|
225
|
+
<p className="text-xs text-slate-400">
|
|
226
|
+
Plan your path from current to target Bitcoin allocation
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Target slider */}
|
|
231
|
+
<LabeledSlider
|
|
232
|
+
label="Target BTC allocation"
|
|
233
|
+
value={targetBtcPct}
|
|
234
|
+
min={0}
|
|
235
|
+
max={100}
|
|
236
|
+
step={1}
|
|
237
|
+
onChange={setTargetBtcPct}
|
|
238
|
+
display={`${targetBtcPct}%`}
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
{/* Allocation comparison bars */}
|
|
242
|
+
<div className="space-y-3">
|
|
243
|
+
<AllocationBar btcPct={currentBtcPct} label="Current" />
|
|
244
|
+
<AllocationBar btcPct={targetBtcPct} label="Target" />
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Gap summary */}
|
|
248
|
+
{result && (
|
|
249
|
+
<div
|
|
250
|
+
className={`rounded-xl px-4 py-3 border transition-colors duration-300 ${
|
|
251
|
+
isBuying
|
|
252
|
+
? "bg-emerald-950/50 border-emerald-500/30"
|
|
253
|
+
: "bg-amber-950/50 border-amber-500/30"
|
|
254
|
+
}`}
|
|
255
|
+
>
|
|
256
|
+
<div className="flex items-start gap-3">
|
|
257
|
+
<span
|
|
258
|
+
className={`text-2xl mt-0.5 ${isBuying ? "text-emerald-400" : "text-amber-400"}`}
|
|
259
|
+
>
|
|
260
|
+
{isBuying ? "\u2191" : "\u2193"}
|
|
261
|
+
</span>
|
|
262
|
+
<div className="flex-1 min-w-0">
|
|
263
|
+
<p
|
|
264
|
+
className={`text-sm font-semibold ${isBuying ? "text-emerald-400" : "text-amber-400"}`}
|
|
265
|
+
>
|
|
266
|
+
{isBuying ? "Buy" : "Sell"}{" "}
|
|
267
|
+
{fmtUsd(gapAbs, currencySymbol)}{" "}
|
|
268
|
+
of Bitcoin
|
|
269
|
+
</p>
|
|
270
|
+
<p className="text-xs text-slate-400 mt-0.5">
|
|
271
|
+
{fmtBtc(Math.abs(result.gapBtc))} at current price
|
|
272
|
+
</p>
|
|
273
|
+
<div className="mt-1 flex gap-3 text-xs text-slate-500">
|
|
274
|
+
<span>
|
|
275
|
+
{result.currentConvictionRung}
|
|
276
|
+
{" \u2192 "}
|
|
277
|
+
<span className="text-orange-400 font-medium">{result.convictionRung}</span>
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* DCA breakdown */}
|
|
286
|
+
<div className="space-y-3">
|
|
287
|
+
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
|
288
|
+
DCA Schedule
|
|
289
|
+
</p>
|
|
290
|
+
{/* Month selector */}
|
|
291
|
+
<div className="flex gap-2">
|
|
292
|
+
{DCA_OPTIONS.map((m) => (
|
|
293
|
+
<button
|
|
294
|
+
key={m}
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={() => setDcaMonths(m)}
|
|
297
|
+
className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
|
|
298
|
+
dcaMonths === m
|
|
299
|
+
? "bg-orange-500/20 border-orange-500/50 text-orange-400"
|
|
300
|
+
: "bg-slate-800 border-slate-700 text-slate-500 hover:border-slate-600 hover:text-slate-400"
|
|
301
|
+
}`}
|
|
302
|
+
>
|
|
303
|
+
{m}mo
|
|
304
|
+
</button>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* Monthly amounts */}
|
|
309
|
+
{result && (
|
|
310
|
+
<div className="grid grid-cols-2 gap-3">
|
|
311
|
+
<div className="rounded-lg bg-slate-900/60 px-3 py-2.5 border border-slate-700/60">
|
|
312
|
+
<p className="text-xs text-slate-500">Monthly {isBuying ? "buy" : "sell"}</p>
|
|
313
|
+
<p className="text-lg font-bold font-mono text-white mt-0.5">
|
|
314
|
+
{fmtUsd(Math.abs(result.dcaMonthlyUsd), currencySymbol)}
|
|
315
|
+
</p>
|
|
316
|
+
<p className="text-xs text-slate-500 mt-0.5">
|
|
317
|
+
{fmtBtc(Math.abs(result.dcaMonthlyBtc))} / mo
|
|
318
|
+
</p>
|
|
319
|
+
</div>
|
|
320
|
+
<div className="rounded-lg bg-slate-900/60 px-3 py-2.5 border border-slate-700/60">
|
|
321
|
+
<p className="text-xs text-slate-500">Duration</p>
|
|
322
|
+
<p className="text-lg font-bold font-mono text-white mt-0.5">
|
|
323
|
+
{result.dcaMonths} months
|
|
324
|
+
</p>
|
|
325
|
+
<p className="text-xs text-slate-500 mt-0.5">
|
|
326
|
+
Total {fmtUsd(gapAbs, currencySymbol)}
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Post-reallocation ruin test */}
|
|
334
|
+
{result && (
|
|
335
|
+
<div
|
|
336
|
+
className={`flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-300 ${
|
|
337
|
+
result.postReallocationRuinTest
|
|
338
|
+
? "bg-emerald-500/10 border-emerald-500/30"
|
|
339
|
+
: "bg-red-500/10 border-red-500/30"
|
|
340
|
+
}`}
|
|
341
|
+
>
|
|
342
|
+
<span
|
|
343
|
+
className={`text-2xl flex-shrink-0 ${
|
|
344
|
+
result.postReallocationRuinTest ? "text-emerald-400" : "text-red-400"
|
|
345
|
+
}`}
|
|
346
|
+
>
|
|
347
|
+
{result.postReallocationRuinTest ? "\u2713" : "\u2717"}
|
|
348
|
+
</span>
|
|
349
|
+
<div className="flex-1 min-w-0">
|
|
350
|
+
<p
|
|
351
|
+
className={`text-sm font-semibold ${
|
|
352
|
+
result.postReallocationRuinTest ? "text-emerald-400" : "text-red-400"
|
|
353
|
+
}`}
|
|
354
|
+
>
|
|
355
|
+
Ruin Test After Reallocation:{" "}
|
|
356
|
+
{result.postReallocationRuinTest ? "PASS" : "FAIL"}
|
|
357
|
+
</p>
|
|
358
|
+
<p className="text-xs text-slate-400 mt-0.5">
|
|
359
|
+
{result.postReallocationRunwayMonths === Infinity
|
|
360
|
+
? "\u221e months"
|
|
361
|
+
: `${fmt(result.postReallocationRunwayMonths)} months`}{" "}
|
|
362
|
+
runway at {targetBtcPct}% BTC under worst-case crash
|
|
363
|
+
</p>
|
|
364
|
+
{!result.postReallocationRuinTest && (
|
|
365
|
+
<p className="text-xs text-red-400/70 mt-1">
|
|
366
|
+
Target allocation leaves insufficient runway — consider a lower target or larger reserve
|
|
367
|
+
</p>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{/* Loading state overlay */}
|
|
374
|
+
{loading && (
|
|
375
|
+
<div className="flex justify-center py-2">
|
|
376
|
+
<div className="w-4 h-4 rounded-full border-2 border-orange-400/30 border-t-orange-400 animate-spin" />
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</CardContent>
|
|
380
|
+
</Card>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { SurvivalResult } from "../hooks/usePortfolio";
|
|
2
|
+
|
|
3
|
+
function formatMonths(months: number): string {
|
|
4
|
+
if (months === Infinity) return "\u221e";
|
|
5
|
+
if (months > 120) return "120+";
|
|
6
|
+
return `${Math.round(months)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getScoreColor(maxDrawdown: number, ruinPassed: boolean) {
|
|
10
|
+
if (!ruinPassed) return { bg: "from-red-950/80 to-red-900/40", border: "border-red-500/30", text: "text-red-400", glow: "shadow-red-500/20" };
|
|
11
|
+
if (maxDrawdown >= 80) return { bg: "from-emerald-950/80 to-emerald-900/40", border: "border-emerald-500/30", text: "text-emerald-400", glow: "shadow-emerald-500/20" };
|
|
12
|
+
if (maxDrawdown >= 70) return { bg: "from-emerald-950/60 to-emerald-900/30", border: "border-emerald-500/20", text: "text-emerald-400", glow: "shadow-emerald-500/10" };
|
|
13
|
+
if (maxDrawdown >= 50) return { bg: "from-amber-950/60 to-amber-900/30", border: "border-amber-500/20", text: "text-amber-400", glow: "shadow-amber-500/10" };
|
|
14
|
+
return { bg: "from-red-950/60 to-red-900/30", border: "border-red-500/20", text: "text-red-400", glow: "shadow-red-500/10" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SurvivalHero({ result }: { result: SurvivalResult }) {
|
|
18
|
+
const { maxSurvivableDrawdown, ruinTestPassed, scenarios } = result;
|
|
19
|
+
const worstCase = scenarios[scenarios.length - 1]; // 80% drawdown
|
|
20
|
+
const colors = getScoreColor(maxSurvivableDrawdown, ruinTestPassed);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className={`relative overflow-hidden rounded-2xl border ${colors.border} bg-gradient-to-br ${colors.bg} p-6 sm:p-8 transition-all duration-500 shadow-lg ${colors.glow}`}>
|
|
24
|
+
{/* Background decoration */}
|
|
25
|
+
<div className="absolute inset-0 opacity-5">
|
|
26
|
+
<div className="absolute top-0 right-0 w-64 h-64 rounded-full bg-white blur-3xl translate-x-1/3 -translate-y-1/3" />
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div className="relative flex flex-col sm:flex-row items-center gap-6 sm:gap-10">
|
|
30
|
+
{/* Survival Score */}
|
|
31
|
+
<div className="text-center sm:text-left flex-shrink-0">
|
|
32
|
+
<p className="text-xs uppercase tracking-widest text-slate-400 mb-2">Max Survivable Crash</p>
|
|
33
|
+
<div className="flex items-baseline gap-1">
|
|
34
|
+
<span className={`text-7xl sm:text-8xl font-black tabular-nums tracking-tight ${colors.text} transition-colors duration-500`}>
|
|
35
|
+
{maxSurvivableDrawdown}
|
|
36
|
+
</span>
|
|
37
|
+
<span className={`text-3xl sm:text-4xl font-bold ${colors.text} opacity-70`}>%</span>
|
|
38
|
+
</div>
|
|
39
|
+
<p className="text-sm text-slate-400 mt-1">drawdown before forced selling</p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{/* Divider */}
|
|
43
|
+
<div className="hidden sm:block w-px h-24 bg-slate-700/50" />
|
|
44
|
+
|
|
45
|
+
{/* Ruin test + key stats */}
|
|
46
|
+
<div className="flex-1 space-y-4">
|
|
47
|
+
{/* Ruin test badge */}
|
|
48
|
+
<div className={`inline-flex items-center gap-3 rounded-xl px-4 py-3 ${
|
|
49
|
+
ruinTestPassed
|
|
50
|
+
? "bg-emerald-500/10 border border-emerald-500/30"
|
|
51
|
+
: "bg-red-500/10 border border-red-500/30"
|
|
52
|
+
} transition-all duration-500`}>
|
|
53
|
+
<span className={`text-3xl ${ruinTestPassed ? "text-emerald-400" : "text-red-400"}`}>
|
|
54
|
+
{ruinTestPassed ? "\u2713" : "\u2717"}
|
|
55
|
+
</span>
|
|
56
|
+
<div>
|
|
57
|
+
<div className={`text-sm font-bold ${ruinTestPassed ? "text-emerald-400" : "text-red-400"}`}>
|
|
58
|
+
Ruin Test {ruinTestPassed ? "PASSED" : "FAILED"}
|
|
59
|
+
</div>
|
|
60
|
+
<div className="text-xs text-slate-400">
|
|
61
|
+
BTC -80% & assets -40% simultaneously
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Key stats row */}
|
|
67
|
+
<div className="flex gap-6">
|
|
68
|
+
<div>
|
|
69
|
+
<p className="text-xs text-slate-500 uppercase tracking-wider">Worst-Case Runway</p>
|
|
70
|
+
<p className={`text-2xl font-bold tabular-nums ${
|
|
71
|
+
worstCase.runwayMonths >= 18 ? "text-emerald-400"
|
|
72
|
+
: worstCase.runwayMonths >= 6 ? "text-amber-400"
|
|
73
|
+
: "text-red-400"
|
|
74
|
+
} transition-colors duration-500`}>
|
|
75
|
+
{formatMonths(worstCase.runwayMonths)}
|
|
76
|
+
<span className="text-sm font-normal text-slate-500 ml-1">months</span>
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<p className="text-xs text-slate-500 uppercase tracking-wider">Survival Threshold</p>
|
|
81
|
+
<p className="text-2xl font-bold text-slate-300">
|
|
82
|
+
18<span className="text-sm font-normal text-slate-500 ml-1">months</span>
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SurvivalResult } from "../hooks/usePortfolio";
|
|
2
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
3
|
+
import { Badge } from "@/components/ui/badge";
|
|
4
|
+
|
|
5
|
+
export function SurvivalSummary({ result }: { result: SurvivalResult; currencySymbol?: string }) {
|
|
6
|
+
const { maxSurvivableDrawdown, ruinTestPassed } = result;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
10
|
+
<CardContent className="p-4 sm:p-6">
|
|
11
|
+
<div className="flex flex-col gap-4 sm:gap-0 sm:flex-row sm:items-center sm:justify-between">
|
|
12
|
+
<div>
|
|
13
|
+
<h3 className="text-base sm:text-lg font-semibold text-white mb-2 sm:mb-1">Survival Summary</h3>
|
|
14
|
+
<p className="text-xs sm:text-sm text-slate-300">
|
|
15
|
+
You survive down to a{" "}
|
|
16
|
+
<span
|
|
17
|
+
className={`font-bold text-lg sm:text-xl ${maxSurvivableDrawdown >= 70 ? "text-emerald-400" : maxSurvivableDrawdown >= 50 ? "text-amber-400" : "text-red-400"}`}
|
|
18
|
+
>
|
|
19
|
+
{maxSurvivableDrawdown}%
|
|
20
|
+
</span>{" "}
|
|
21
|
+
crash before needing to sell
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<Badge
|
|
26
|
+
variant={ruinTestPassed ? "outline" : "destructive"}
|
|
27
|
+
className={`flex items-center gap-2 px-3 sm:px-4 py-2 rounded-lg flex-shrink-0 h-auto text-sm ${
|
|
28
|
+
ruinTestPassed
|
|
29
|
+
? "bg-emerald-900/30 border-emerald-500/50 text-emerald-400 hover:bg-emerald-900/30"
|
|
30
|
+
: "bg-red-900/30 border-red-500/50 text-red-400 hover:bg-red-900/30"
|
|
31
|
+
}`}
|
|
32
|
+
>
|
|
33
|
+
<span className="text-xl sm:text-2xl">
|
|
34
|
+
{ruinTestPassed ? "\u2713" : "\u2717"}
|
|
35
|
+
</span>
|
|
36
|
+
<div className="text-left">
|
|
37
|
+
<div className={`text-xs sm:text-sm font-bold ${ruinTestPassed ? "text-emerald-400" : "text-red-400"}`}>
|
|
38
|
+
Ruin Test {ruinTestPassed ? "PASSED" : "FAILED"}
|
|
39
|
+
</div>
|
|
40
|
+
<div className="text-xs text-slate-400 font-normal">BTC -80% + Assets -40%</div>
|
|
41
|
+
</div>
|
|
42
|
+
</Badge>
|
|
43
|
+
</div>
|
|
44
|
+
</CardContent>
|
|
45
|
+
</Card>
|
|
46
|
+
);
|
|
47
|
+
}
|