Skip to content

Commit ea74b6b

Browse files
authored
Live dashboard comparison (#73)
* Live dashboard comparison * refactor * refactor * Fixing strategy highlight bug * Removing extra add your strategy card
1 parent aaaaed3 commit ea74b6b

File tree

14 files changed

+571
-154
lines changed

14 files changed

+571
-154
lines changed

src/app/api/dashboard/live-config/route.ts

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import { NextResponse } from 'next/server';
1+
import { NextRequest, NextResponse } from 'next/server';
22
import { get1DTargetDate } from '@/features/market/lib/marketHours';
33
import { get1DLiveConfig } from '@/features/dashboard/lib/performance';
44
import { getTestStrategyLinkIds } from '@/features/dashboard/lib/testStrategies';
55
import {
6+
selectLatestEvaluationsByLinkIds,
67
selectPortfolioStateBatch,
78
selectStrategyPerformanceBatch,
89
} from '@/features/dashboard/lib/storage';
10+
import {
11+
parseRequestedLinkIds,
12+
isCustomCompareSet,
13+
} from '@/features/dashboard/lib/compare-input';
914
import { selectStrategiesByLinkIds } from '@/features/core/lib/storage';
1015
import { fetchStrategy } from '@/features/core/lib/fetcher';
16+
import { withCache } from '@/lib/api-helpers';
1117

1218
/**
1319
* GET /api/dashboard/live-config
@@ -17,14 +23,27 @@ import { fetchStrategy } from '@/features/core/lib/fetcher';
1723
*
1824
* When isLive: false, returns { isLive: false } only.
1925
*/
20-
export async function GET() {
26+
const MAX_COMPARE_LINK_IDS = 10;
27+
28+
export async function GET(req: NextRequest) {
2129
try {
2230
const meta = await get1DTargetDate();
2331
if (!meta?.isLive) {
24-
return NextResponse.json({ isLive: false });
32+
return withCache(NextResponse.json({ isLive: false }));
2533
}
2634

27-
const linkIds = getTestStrategyLinkIds().slice(0, 10);
35+
const requestedLinkIds = parseRequestedLinkIds(
36+
req.nextUrl.searchParams.get('linkIds'),
37+
MAX_COMPARE_LINK_IDS,
38+
);
39+
const defaultLinkIds = getTestStrategyLinkIds().slice(0, MAX_COMPARE_LINK_IDS);
40+
const defaultLinkIdSet = new Set(defaultLinkIds);
41+
const linkIds =
42+
requestedLinkIds && requestedLinkIds.length > 0
43+
? requestedLinkIds
44+
: defaultLinkIds;
45+
const hasCustomLinkIds = isCustomCompareSet(linkIds, defaultLinkIds);
46+
const customLinkIds = linkIds.filter((linkId) => !defaultLinkIdSet.has(linkId));
2847
const previousTradingDay = meta.previousTradingDay;
2948

3049
const { strategiesMap, strategyIdsMap } = await selectStrategiesByLinkIds(linkIds);
@@ -48,15 +67,55 @@ export async function GET() {
4867
const strategyIds = linkIds
4968
.map((lid) => strategyIdsMap.get(lid))
5069
.filter((id): id is number => id != null);
70+
const customStrategyIds = new Set(
71+
customLinkIds
72+
.map((linkId) => strategyIdsMap.get(linkId))
73+
.filter((id): id is number => id != null),
74+
);
5175

5276
const portfolioStateByStrategyId = await selectPortfolioStateBatch(
5377
strategyIds,
5478
previousTradingDay,
5579
);
5680

57-
const strategiesWithEmptyWeights = strategyIds.filter(
81+
// If a strategy has no persisted portfolio_state yet (common for user-added strategies),
82+
// fall back to latest evaluation allocation holdings as weights.
83+
const strategyIdsWithEmptyWeights = strategyIds.filter(
5884
(sid) => (portfolioStateByStrategyId.get(sid)?.size ?? 0) === 0,
5985
);
86+
if (strategyIdsWithEmptyWeights.length > 0) {
87+
const byStrategyId = new Map<number, string[]>();
88+
for (const linkId of linkIds) {
89+
const sid = strategyIdsMap.get(linkId);
90+
if (sid != null && strategyIdsWithEmptyWeights.includes(sid)) {
91+
const arr = byStrategyId.get(sid) ?? [];
92+
arr.push(linkId);
93+
byStrategyId.set(sid, arr);
94+
}
95+
}
96+
97+
const evals = await selectLatestEvaluationsByLinkIds(linkIds);
98+
for (const [sid, lids] of byStrategyId) {
99+
if (portfolioStateByStrategyId.get(sid)?.size) continue;
100+
// Prefer the first linkId (should be one-to-one).
101+
const linkId = lids[0];
102+
if (!linkId) continue;
103+
const latestEval = evals.get(linkId);
104+
if (!latestEval?.holdings?.length) continue;
105+
const weights = new Map<string, number>();
106+
for (const h of latestEval.holdings) {
107+
const symbol = h.ticker?.symbol;
108+
if (!symbol) continue;
109+
weights.set(symbol, h.weight);
110+
}
111+
if (weights.size > 0) portfolioStateByStrategyId.set(sid, weights);
112+
}
113+
}
114+
115+
const strategiesWithEmptyWeights = strategyIds.filter((sid) => {
116+
if (customStrategyIds.has(sid)) return false;
117+
return (portfolioStateByStrategyId.get(sid)?.size ?? 0) === 0;
118+
});
60119
if (strategiesWithEmptyWeights.length > 0) {
61120
const ninetyDaysAgo = new Date();
62121
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
@@ -100,13 +159,16 @@ export async function GET() {
100159
},
101160
);
102161

103-
return NextResponse.json({
104-
isLive: true,
105-
weights,
106-
prevClose,
107-
});
162+
return withCache(
163+
NextResponse.json({
164+
isLive: true,
165+
weights,
166+
prevClose,
167+
}),
168+
{ skipCache: hasCustomLinkIds },
169+
);
108170
} catch (error) {
109171
console.error('Error fetching live config:', error);
110-
return NextResponse.json({ isLive: false });
172+
return withCache(NextResponse.json({ isLive: false }), { skipCache: true });
111173
}
112174
}

src/app/api/dashboard/performance/route.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import {
44
getTestStrategyLinkIds,
55
getStrategyDisplayName,
66
} from '@/features/dashboard/lib/testStrategies';
7+
import { getTestfolioBacktestSeriesByLinkId } from '@/features/core/lib/testfolio-backtest-series';
8+
import {
9+
parseRequestedLinkIds,
10+
isCustomCompareSet,
11+
} from '@/features/dashboard/lib/compare-input';
712
import { calculateSPYBenchmark } from '@/features/dashboard/lib/performance';
813
import {
914
getStrategyIdsByLinkIds,
@@ -28,8 +33,16 @@ export async function GET(request: NextRequest) {
2833
const searchParams = request.nextUrl.searchParams;
2934
const range = (searchParams.get('range') || 'ytd') as Range;
3035
const limit = parseInt(searchParams.get('limit') || '10', 10);
36+
const requestedLinkIds = parseRequestedLinkIds(searchParams.get('linkIds'));
37+
const defaultLinkIds = getTestStrategyLinkIds().slice(0, limit);
38+
const defaultLinkIdSet = new Set(defaultLinkIds);
3139

32-
const linkIds = getTestStrategyLinkIds().slice(0, limit);
40+
const linkIds =
41+
requestedLinkIds && requestedLinkIds.length > 0
42+
? requestedLinkIds
43+
: defaultLinkIds;
44+
const hasCustomLinkIds = isCustomCompareSet(linkIds, defaultLinkIds);
45+
const dbLinkIds = linkIds.filter((linkId) => defaultLinkIdSet.has(linkId));
3346
const now = new Date();
3447
const meta = await get1DTargetDate();
3548
const endDate =
@@ -42,10 +55,10 @@ export async function GET(request: NextRequest) {
4255

4356
// Pure read path: no calendar, no testfol.io, no gap check
4457
const [strategyIdsMap, latestEvaluationsMap] = await Promise.all([
45-
getStrategyIdsByLinkIds(linkIds),
58+
getStrategyIdsByLinkIds(dbLinkIds),
4659
selectLatestEvaluationsByLinkIds(linkIds),
4760
]);
48-
const strategyIds = linkIds
61+
const strategyIds = dbLinkIds
4962
.map((lid) => strategyIdsMap.get(lid))
5063
.filter((id): id is number => id != null);
5164
const [perfBatchRes, spyBenchmarkRaw] = await Promise.all([
@@ -56,16 +69,30 @@ export async function GET(request: NextRequest) {
5669
]);
5770

5871
// Build raw points per strategy
59-
const rawStrategyPoints = linkIds.map((linkId) => {
72+
const rawStrategyPoints = await Promise.all(linkIds.map(async (linkId) => {
73+
const isCustomLinkId = !defaultLinkIdSet.has(linkId);
6074
const strategyId = strategyIdsMap.get(linkId);
6175
const dbPoints =
6276
strategyId != null ? perfBatchRes.get(strategyId) ?? [] : [];
63-
const points = buildPointsFromBatch(dbPoints);
6477
const latestEval = latestEvaluationsMap.get(linkId);
65-
const name =
78+
let name =
6679
getStrategyDisplayName(linkId) ?? latestEval?.strategyName ?? linkId;
80+
let points = isCustomLinkId ? [] : buildPointsFromBatch(dbPoints);
81+
82+
// User-provided strategies are treated as brand-new by default; use Testfolio backtest first.
83+
if (points.length === 0) {
84+
const backtestData = await getTestfolioBacktestSeriesByLinkId(linkId);
85+
if (backtestData) {
86+
points = backtestData.points;
87+
name = getStrategyDisplayName(linkId)
88+
?? latestEval?.strategyName
89+
?? backtestData.name
90+
?? linkId;
91+
}
92+
}
93+
6794
return { id: linkId, name, points };
68-
});
95+
}));
6996

7097
// Compute returnYTD, return1y, return3y from ALIGNED series so card matches chart
7198
const allSeriesForAlignment = [
@@ -141,7 +168,7 @@ export async function GET(request: NextRequest) {
141168
_devMessage: 'Data not initialized. Run /api/dashboard/initialize.',
142169
}
143170
: { asOf: new Date().toISOString().split('T')[0], range, series: allSeries };
144-
return withCache(NextResponse.json(body));
171+
return withCache(NextResponse.json(body), { skipCache: hasCustomLinkIds });
145172
} catch (error) {
146173
console.error('Error calculating performance:', error);
147174
return NextResponse.json(

src/app/api/dashboard/strategy-stats/route.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { withCache } from '@/lib/api-helpers';
33
import { getTestStrategyLinkIds } from '@/features/dashboard/lib/testStrategies';
4+
import { getTestfolioBacktestSeriesByLinkId } from '@/features/core/lib/testfolio-backtest-series';
5+
import {
6+
parseRequestedLinkIds,
7+
isCustomCompareSet,
8+
} from '@/features/dashboard/lib/compare-input';
49
import {
510
getTradesPerYear,
611
computeSeriesStats,
@@ -65,18 +70,28 @@ export async function GET(request: NextRequest) {
6570
}
6671
}
6772

68-
const linkIds = getTestStrategyLinkIds();
73+
const requestedLinkIds = parseRequestedLinkIds(
74+
request.nextUrl.searchParams.get('linkIds'),
75+
);
76+
const defaultLinkIds = getTestStrategyLinkIds();
77+
const defaultLinkIdSet = new Set(defaultLinkIds);
78+
const linkIds =
79+
requestedLinkIds && requestedLinkIds.length > 0
80+
? requestedLinkIds
81+
: defaultLinkIds;
82+
const hasCustomLinkIds = isCustomCompareSet(linkIds, defaultLinkIds);
83+
const dbLinkIds = linkIds.filter((linkId) => defaultLinkIdSet.has(linkId));
6984
const dbStart = get3YearsAgoET();
7085
const meta = await get1DTargetDate();
7186
const endDate =
7287
meta?.isLive === true ? meta.previousTradingDay : getCurrentDateET();
7388

7489
// Pure read path: no calendar, no testfol.io
7590
const [strategyIdsMap, latestEvaluationsMap] = await Promise.all([
76-
getStrategyIdsByLinkIds(linkIds),
91+
getStrategyIdsByLinkIds(dbLinkIds),
7792
selectLatestEvaluationsByLinkIds(linkIds),
7893
]);
79-
const strategyIds = linkIds
94+
const strategyIds = dbLinkIds
8095
.map((lid) => strategyIdsMap.get(lid))
8196
.filter((id): id is number => id != null);
8297
const [spyPoints, perfBatch] = await Promise.all([
@@ -88,7 +103,7 @@ export async function GET(request: NextRequest) {
88103

89104
// One-time batch fetch for allocation fallback (allocation is static for the day)
90105
const allocationFallbackPairs: Array<{ strategyId: number; allocationName: string }> = [];
91-
for (const linkId of linkIds) {
106+
for (const linkId of dbLinkIds) {
92107
const latestEval = latestEvaluationsMap.get(linkId);
93108
if (latestEval) continue;
94109
const strategyId = strategyIdsMap.get(linkId);
@@ -107,6 +122,7 @@ export async function GET(request: NextRequest) {
107122
linkIds.map(async (linkId) => {
108123
let currentAllocation: StrategyStatsResponse['allocation'] = null;
109124
let strategyName: string | undefined;
125+
const isCustomLinkId = !defaultLinkIdSet.has(linkId);
110126

111127
const latestEval: LatestEvaluationMeta | undefined = latestEvaluationsMap.get(linkId);
112128
if (latestEval) {
@@ -141,16 +157,48 @@ export async function GET(request: NextRequest) {
141157
};
142158
}
143159
}
144-
const points = buildPointsFromBatch(perfPoints3y, true);
160+
let points = isCustomLinkId ? [] : buildPointsFromBatch(perfPoints3y, true);
161+
if (points.length === 0) {
162+
const backtestData = await getTestfolioBacktestSeriesByLinkId(linkId);
163+
if (backtestData) {
164+
points = backtestData.points.map((p) => ({
165+
date: p.date,
166+
value: p.value,
167+
allocation: currentAllocation?.name ?? 'Backtest',
168+
}));
169+
strategyName = strategyName ?? backtestData.name;
170+
}
171+
}
145172
const tradesPoints = perfPoints3y.map((p) => ({
146173
date: p.date,
147174
allocation_name: p.allocation_name,
148175
}));
149176

150-
const tradesPerYear = await getTradesPerYear(linkId, undefined, undefined, {
151-
strategyId: strategyId ?? undefined,
152-
performancePoints: tradesPoints.length > 0 ? tradesPoints : undefined,
153-
});
177+
// Prefer current allocation from latest evaluation. For user-added strategies,
178+
// fallback to evaluating once on-demand so cards can render holdings.
179+
if (!currentAllocation) {
180+
try {
181+
const evaluation = await getEvaluation(linkId);
182+
strategyName = strategyName ?? evaluation.strategy.name;
183+
currentAllocation = {
184+
name: evaluation.allocation.name,
185+
holdings: evaluation.allocation.holdings.map((h) => ({
186+
symbol: h.ticker.symbol,
187+
leverage: h.ticker.leverage,
188+
weight: h.weight,
189+
})),
190+
};
191+
} catch {
192+
// Keep allocation null if evaluation fails.
193+
}
194+
}
195+
196+
const tradesPerYear = !isCustomLinkId && strategyId
197+
? await getTradesPerYear(linkId, undefined, undefined, {
198+
strategyId,
199+
performancePoints: tradesPoints.length > 0 ? tradesPoints : undefined,
200+
})
201+
: {};
154202
const seriesStats = computeSeriesStats(points);
155203
const alphaVsSpy = computeAlphaVsSpy(points, spyPoints);
156204
const { returnYTD } = computeReturnsFromPoints(points);
@@ -185,7 +233,7 @@ export async function GET(request: NextRequest) {
185233
process.env.NODE_ENV === 'development' && allEmpty
186234
? { stats, _devMessage: 'Data not initialized. Run /api/dashboard/initialize.' }
187235
: stats;
188-
return withCache(NextResponse.json(body));
236+
return withCache(NextResponse.json(body), { skipCache: hasCustomLinkIds });
189237
} catch (error) {
190238
console.error('Error fetching strategy stats:', error);
191239
return NextResponse.json(

src/features/core/components/BacktestChart.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Area, AreaChart, Tooltip, XAxis, YAxis } from 'recharts';
66
import NumberFlow from '@number-flow/react';
77
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
88
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
9+
import { extractTestfolioBacktestSeries, toEpochMsPoints } from '@/features/core/lib/testfolio-backtest-series';
910

1011
type Duration = '1W' | '1M' | '3M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
1112
export type { Duration };
@@ -52,22 +53,17 @@ export default function BacktestChart({ charts, duration, onDurationChange }: Re
5253
const [hoveredPoint, setHoveredPoint] = useState<{ date: number; value: number } | null>(null);
5354

5455
const data = useMemo(() => {
55-
const timestamps = charts[0];
56-
const values = charts[charts.length - 1];
56+
const points = toEpochMsPoints(extractTestfolioBacktestSeries(charts), 1);
5757
const startDate = getStartDate(duration);
5858
const startTime = startDate.getTime();
5959

60-
const filtered = timestamps
61-
.map((timestamp, i) => ({
62-
date: timestamp * 1000 + 24 * 60 * 60 * 1000,
63-
value: values[i],
64-
}))
65-
.filter((d) => d.date >= startTime);
60+
const filtered = points.filter((point) => point.date >= startTime);
6661

6762
if (filtered.length === 0) return filtered;
6863

6964
// Normalize so first value is $10,000
7065
const firstValue = filtered[0].value;
66+
if (!firstValue || !Number.isFinite(firstValue)) return filtered;
7167
return filtered.map((d) => ({
7268
...d,
7369
value: (d.value / firstValue) * 10000,

0 commit comments

Comments
 (0)