diff --git a/app/server/src/trpc/__tests__/router.test.ts b/app/server/src/trpc/__tests__/router.test.ts index 4d9f400..7d7dce6 100644 --- a/app/server/src/trpc/__tests__/router.test.ts +++ b/app/server/src/trpc/__tests__/router.test.ts @@ -190,3 +190,67 @@ test('onboarding.complete writes complexity/risk/drawdown + starter watchlist + const demand = await cache.demandSet(); assert.ok(demand.includes('NVDA') && demand.includes('BTC'), 'starter symbols subscribed to demand set'); }); + +// --- Slice 5: market.candles + market.indicators --- +test('market.candles returns cached candles with isStale=false after drain', async () => { + const { queue, freshCtx } = setup(); + await appRouter.createCaller(freshCtx()).market.candles({ symbol: 'NVDA', timeframe: '1d' }); + await queue.drain(); + const res = await appRouter.createCaller(freshCtx()).market.candles({ symbol: 'NVDA', timeframe: '1d' }); + assert.equal(res.symbol, 'NVDA'); + assert.equal(res.timeframe, '1d'); + assert.equal(res.candles.length, 1); + assert.equal(res.isStale, false); +}); + +test('market.candles returns empty candles + isStale=true when cache is empty', async () => { + const { freshCtx } = setup(); + const res = await appRouter.createCaller(freshCtx()).market.candles({ symbol: 'ZZZZ' }); + assert.equal(res.symbol, 'ZZZZ'); + assert.deepEqual(res.candles, []); + assert.equal(res.isStale, true); +}); + +test('market.indicators returns arrays aligned to candle length', async () => { + const { queue, freshCtx } = setup(); + await appRouter.createCaller(freshCtx()).market.indicators({ symbol: 'NVDA', timeframe: '1d' }); + await queue.drain(); + const res = await appRouter.createCaller(freshCtx()).market.indicators({ symbol: 'NVDA', timeframe: '1d' }); + // Single candle: all indicators should be undefined arrays of length 1 + assert.equal(res.ema['9']!.length, 1); + assert.equal(res.ema['21']!.length, 1); + assert.equal(res.ema['50']!.length, 1); + assert.equal(res.ema['200']!.length, 1); + assert.equal(res.rsi.length, 1); + assert.equal(res.relativeVolume.length, 1); + // With only 1 candle, nothing can be computed yet + assert.equal(res.ema['9']![0], undefined); + assert.equal(res.rsi[0], undefined); + assert.equal(res.relativeVolume[0], undefined); +}); + +test('market.indicators computes real values when enough candles exist', async () => { + const { db, cache, queue, freshCtx } = setup(); + // Pre-populate 50 candles so EMA-9, RSI-14, and relativeVolume-20 all have computed values + const candles: PriceCandle[] = []; + for (let i = 0; i < 50; i++) { + candles.push({ ts: `2026-06-${String(1 + i).padStart(2, '0')}`, o: 100 + i * 0.5, h: 102 + i * 0.5, l: 99 + i * 0.5, c: 100 + i, v: 1e6 + i * 1e4, adjClose: 100 + i }); + } + // Write candles directly to the cache + await cache.set(`yfinance:candles:TESTSYM:1d`, candles, 'daily_permanent', { fetchedAt: new Date().toISOString(), sourceKind: 'yfinance' }); + const res = await appRouter.createCaller(freshCtx()).market.indicators({ symbol: 'TESTSYM', timeframe: '1d', periods: { ema: [9], rsi: 14, relvol: 20 } }); + // EMA-9: seed SMA at index 8, then recursive EMA from index 9 onward + const ema9 = res.ema['9']!; + assert.equal(ema9.length, 50); + assert.equal(ema9[7], undefined, 'EMA-9 undefined before period-1'); + assert.ok(typeof ema9[8] === 'number', 'EMA-9 seed SMA at index 8'); + assert.ok(typeof ema9[9] === 'number', 'EMA-9 recursive value at index 9'); + // RSI-14: first computed value at index 14 + assert.equal(res.rsi.length, 50); + assert.equal(res.rsi[13], undefined, 'RSI undefined before period'); + assert.ok(typeof res.rsi[14] === 'number', 'RSI computed at index 14'); + // RelativeVolume-20: first computed value at index 20 + assert.equal(res.relativeVolume.length, 50); + assert.equal(res.relativeVolume[19], undefined, 'relvol undefined before period'); + assert.ok(typeof res.relativeVolume[20] === 'number', 'relativeVolume computed at index 20'); +}); diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index ea5dccf..edf921c 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -8,6 +8,7 @@ import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts'; import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts'; import { STARTER_WATCHLIST, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts'; import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts'; +import { emaFromCandles, rsi as rsiFn, relativeVolume } from '../analysis/indicators.ts'; const t = initTRPC.context().create(); const router = t.router; @@ -171,6 +172,42 @@ const marketRouter = router({ stale: { quote: stale(k.quote), candles: stale(k.candles), sector: stale(k.sector) }, }; }), + candles: publicProcedure + .input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk']).default('1d') })) + .query(async ({ ctx, input }) => { + const symbol = input.symbol.toUpperCase(); + const key = `yfinance:candles:${symbol}:${input.timeframe}`; + const entry = await ctx.cache.get(key); + return { symbol, timeframe: input.timeframe, candles: (entry.value ?? []), isStale: entry.isStale }; + }), + indicators: publicProcedure + .input(z.object({ + symbol: z.string().min(1), + timeframe: z.enum(['1d', '1wk']).default('1d'), + periods: z.object({ + ema: z.array(z.number().int()).default([9, 21, 50, 200]), + rsi: z.number().int().default(14), + relvol: z.number().int().default(20), + }).default(() => ({ ema: [9, 21, 50, 200], rsi: 14, relvol: 20 } as const)), + })) + .query(async ({ ctx, input }) => { + const symbol = input.symbol.toUpperCase(); + const key = `yfinance:candles:${symbol}:${input.timeframe}`; + const entry = await ctx.cache.get(key); + const candles = entry.value ?? []; + const periods = input.periods ?? { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 }; + const closes = candles.map((c) => c.c); + const volumes = candles.map((c) => c.v); + const emaObj: Record = {}; + for (const p of periods.ema) { + emaObj[String(p)] = emaFromCandles(candles, 'adjClose', p); + } + return { + ema: emaObj, + rsi: rsiFn(closes, periods.rsi), + relativeVolume: relativeVolume(volumes, periods.relvol), + }; + }), }); export const appRouter = router({ auth: authRouter, onboarding: onboardingRouter, market: marketRouter });