Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/app/api/finance/asset/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* GET /api/finance/asset?symbol=NVDA
*
* Company / asset metadata for the ticker page (name, exchange, class, and the
* Alpaca tradability flags). Paid-gated; resolves the per-profile provider
* (connected Alpaca → app default) and read-through caches the result.
*/

import { NextRequest, NextResponse } from 'next/server';
import { requireActiveSubscription } from '@/lib/subscription/guard';
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
import { getActiveBrokerCreds } from '@/lib/finance/brokers/service';
import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile';
import { readThrough, QUOTE_TTL_SECONDS } from '@/lib/finance/market-data/cache';
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
import type { AssetInfo } from '@/lib/finance/market-data/types';

export const dynamic = 'force-dynamic';

const SYMBOL_RE = /^[A-Z][A-Z0-9.\-]{0,9}$/;

export async function GET(request: NextRequest): Promise<NextResponse> {
const gate = await requireActiveSubscription(request);
if (gate) return gate;

const symbol = normalizeSymbol(request.nextUrl.searchParams.get('symbol') ?? '');
if (!SYMBOL_RE.test(symbol)) {
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });
}

const profileId = await getActiveProfileId();
const provider = await getMarketDataProviderForProfile(profileId);
if (typeof provider.getAsset !== 'function') {
return NextResponse.json({ asset: null });
}

// Asset metadata is account-agnostic, but a connected broker's keys can serve
// it; vary the cache key by whether per-profile creds back the lookup.
const hasBroker = profileId ? Boolean(await getActiveBrokerCreds(profileId, 'alpaca')) : false;
const cacheKey = `asset:${provider.id}:${hasBroker ? 'broker' : 'app'}`;

try {
const asset = await readThrough<AssetInfo | null>(symbol, cacheKey, QUOTE_TTL_SECONDS, () =>
provider.getAsset!(symbol),
);
return NextResponse.json({ asset });
} catch {
return NextResponse.json({ asset: null });
}
}
51 changes: 51 additions & 0 deletions src/app/api/finance/watchlist/changes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* GET /api/finance/watchlist/changes?symbols=NVDA,AAPL,…
*
* Returns trailing 1 / 5 / 30-day percent gain/loss per symbol for the watchlist
* cards. Paid-gated; uses the per-profile market-data provider (connected
* broker → Yahoo fallback) and read-through caches each symbol's 1M candles.
*/

import { NextRequest, NextResponse } from 'next/server';
import { requireActiveSubscription } from '@/lib/subscription/guard';
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
import { type MarketDataProvider } from '@/lib/finance/market-data';
import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile';
import { readThrough, CANDLES_TTL_SECONDS } from '@/lib/finance/market-data/cache';
import { parseSymbolList } from '@/lib/finance/watchlist';
import { computeChanges, EMPTY_CHANGES, type WatchlistChanges } from '@/lib/finance/performance';

export const dynamic = 'force-dynamic';

const MAX_SYMBOLS = 60;

async function changesFor(provider: MarketDataProvider, symbol: string): Promise<WatchlistChanges> {
return readThrough<WatchlistChanges>(symbol, `changes:${provider.id}`, CANDLES_TTL_SECONDS, async () => {
const candles = await provider.getCandles(symbol, '1M');
return computeChanges(candles);
});
}

export async function GET(request: NextRequest): Promise<NextResponse> {
const gate = await requireActiveSubscription(request);
if (gate) return gate;

const { valid } = parseSymbolList(request.nextUrl.searchParams.get('symbols') ?? '');
if (valid.length === 0) return NextResponse.json({ changes: {} });

const profileId = await getActiveProfileId();
const provider = await getMarketDataProviderForProfile(profileId);

const symbols = valid.slice(0, MAX_SYMBOLS);
const entries = await Promise.all(
symbols.map(async (symbol): Promise<[string, WatchlistChanges]> => {
try {
return [symbol, await changesFor(provider, symbol)];
} catch {
return [symbol, EMPTY_CHANGES];
}
}),
);

return NextResponse.json({ changes: Object.fromEntries(entries) });
}
3 changes: 2 additions & 1 deletion src/app/api/news/summarize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,12 +473,13 @@ describe('News Summarize API Route', () => {
response_format: { type: 'json_object' },
max_completion_tokens: 1500,
reasoning_effort: 'high',
temperature: 0.3,
}),
expect.objectContaining({
timeout: 60000,
})
);
// gpt-5.x reasoning models reject a custom temperature; we must not send one.
expect(mockOpenAICreate.mock.calls[0][0]).not.toHaveProperty('temperature');
});

it('should include article content in the prompt', async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/news/summarize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ ${truncatedContent}`;
response_format: { type: 'json_object' },
max_completion_tokens: 1500,
reasoning_effort: 'high',
temperature: 0.3,
// gpt-5.x reasoning models only support the default temperature (1);
// passing a custom value returns a 400.
},
{
timeout: 60000, // 60 second timeout
Expand Down
38 changes: 38 additions & 0 deletions src/app/finance/finance-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Link from 'next/link';
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
import { BrokerConnect } from './broker-connect';
import { Sparkline } from '@/components/finance/sparkline';
import type { WatchlistChanges } from '@/lib/finance/performance';

const RECENT_KEY = 'finance:recent';

Expand All @@ -19,6 +20,19 @@ interface WatchlistRow {
exchange: string | null;
}

function PctChange({ label, value }: { label: string; value: number | null }): React.ReactElement {
const known = value !== null && Number.isFinite(value);
const up = known && (value as number) >= 0;
const color = !known ? 'text-text-muted' : up ? 'text-green-400' : 'text-red-400';
const text = !known ? '—' : `${up ? '+' : ''}${(value as number).toFixed(2)}%`;
return (
<div className="flex flex-col items-center leading-tight">
<span className="text-[10px] uppercase tracking-wide text-text-muted">{label}</span>
<span className={`text-xs font-medium tabular-nums ${color}`}>{text}</span>
</div>
);
}

export function FinanceHub(): React.ReactElement {
const router = useRouter();
const [query, setQuery] = useState('');
Expand All @@ -28,6 +42,7 @@ export function FinanceHub(): React.ReactElement {
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkMsg, setBulkMsg] = useState<string | null>(null);
const [sparklines, setSparklines] = useState<Record<string, number[]>>({});
const [changes, setChanges] = useState<Record<string, WatchlistChanges>>({});

useEffect(() => {
try {
Expand Down Expand Up @@ -68,6 +83,24 @@ export function FinanceHub(): React.ReactElement {
};
}, [watchlistKey]);

// Fetch trailing 1/5/30-day % changes for the watchlist symbols.
useEffect(() => {
if (!watchlistKey) {
setChanges({});
return;
}
let cancelled = false;
fetch(`/api/finance/watchlist/changes?symbols=${encodeURIComponent(watchlistKey)}`, { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : { changes: {} }))
.then((body: { changes?: Record<string, WatchlistChanges> }) => {
if (!cancelled) setChanges(body.changes ?? {});
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [watchlistKey]);

const addBulk = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -190,6 +223,11 @@ export function FinanceHub(): React.ReactElement {
<Sparkline samples={sparklines[row.symbol]} width={56} />
</div>
{row.exchange ? <div className="text-xs text-text-muted">{row.exchange}</div> : null}
<div className="mt-3 flex items-center justify-between gap-1 border-t border-border-primary pt-2">
<PctChange label="1D" value={changes[row.symbol]?.d1 ?? null} />
<PctChange label="5D" value={changes[row.symbol]?.d5 ?? null} />
<PctChange label="30D" value={changes[row.symbol]?.d30 ?? null} />
</div>
</Link>
))}
</div>
Expand Down
103 changes: 102 additions & 1 deletion src/app/finance/ticker/[ticker]/ticker-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
import { useCallback, useEffect, useState } from 'react';
import { FinanceChart } from './finance-chart';
import { ReportPanel } from './report-panel';
import { NewsSection } from '@/components/news';
// Import from the SDK-free `types` module (not the index) so the Alpaca SDK is
// never pulled into the client bundle.
import { TICKER_RANGES, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
import { TICKER_RANGES, type AssetInfo, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
import type { WatchlistChanges } from '@/lib/finance/performance';

const RECENT_KEY = 'finance:recent';
const RECENT_MAX = 12;
Expand Down Expand Up @@ -58,6 +60,8 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
const [error, setError] = useState<string | null>(null);
const [inWatchlist, setInWatchlist] = useState<boolean | null>(null);
const [holding, setHolding] = useState<Holding | null>(null);
const [changes, setChanges] = useState<WatchlistChanges | null>(null);
const [asset, setAsset] = useState<AssetInfo | null>(null);

useEffect(() => {
rememberRecent(symbol);
Expand Down Expand Up @@ -132,6 +136,36 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
};
}, [symbol]);

// Trailing 1/5/30-day % change (reuses the watchlist changes endpoint).
useEffect(() => {
let cancelled = false;
setChanges(null);
fetch(`/api/finance/watchlist/changes?symbols=${encodeURIComponent(symbol)}`, { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : { changes: {} }))
.then((body: { changes?: Record<string, WatchlistChanges> }) => {
if (!cancelled) setChanges(body.changes?.[symbol] ?? null);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [symbol]);

// Company / asset metadata (Alpaca assets endpoint).
useEffect(() => {
let cancelled = false;
setAsset(null);
fetch(`/api/finance/asset?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : { asset: null }))
.then((body: { asset?: AssetInfo | null }) => {
if (!cancelled) setAsset(body.asset ?? null);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [symbol]);

const toggleWatchlist = useCallback(async () => {
const next = !inWatchlist;
setInWatchlist(next);
Expand Down Expand Up @@ -167,6 +201,12 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
{inWatchlist ? '★ In watchlist' : '☆ Add to watchlist'}
</button>
</div>
{asset?.name ? (
<div className="mt-1 text-sm text-text-secondary">
{asset.name}
{asset.exchange ? <span className="text-text-muted"> · {asset.exchange}</span> : null}
</div>
) : null}
{quote ? <div className="mt-2 flex items-baseline gap-3">
<span className="text-2xl font-semibold text-text-primary">
${formatNumber(quote.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
Expand All @@ -177,6 +217,11 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
{formatNumber(quote.changePercent, { maximumFractionDigits: 2 })}%)
</span>
</div> : null}
<div className="mt-3 flex items-center gap-5">
<TrailingChange label="1D" value={changes?.d1 ?? null} />
<TrailingChange label="5D" value={changes?.d5 ?? null} />
<TrailingChange label="30D" value={changes?.d30 ?? null} />
</div>
</div>
<div className="flex flex-wrap gap-1">
{TICKER_RANGES.map((r) => (
Expand Down Expand Up @@ -223,8 +268,35 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
/>
</div>

{/* Company info from the broker (Alpaca) assets endpoint. */}
{asset ? (
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-text-primary">About {symbol}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{asset.name ? <Stat label="Name" value={asset.name} /> : null}
{asset.exchange ? <Stat label="Exchange" value={asset.exchange} /> : null}
{asset.assetClass ? <Stat label="Class" value={formatAssetClass(asset.assetClass)} /> : null}
{asset.status ? <Stat label="Status" value={capitalize(asset.status)} /> : null}
<Stat label="Tradable" value={formatBool(asset.tradable)} />
<Stat label="Fractionable" value={formatBool(asset.fractionable)} />
<Stat label="Marginable" value={formatBool(asset.marginable)} />
<Stat label="Shortable" value={formatBool(asset.shortable)} />
<Stat label="Easy to borrow" value={formatBool(asset.easyToBorrow)} />
{asset.hasOptions !== null ? <Stat label="Options" value={formatBool(asset.hasOptions)} /> : null}
</div>
</section>
) : null}

{/* AI report area — never auto-runs; the Analyze button is the cost boundary. */}
<ReportPanel symbol={symbol} />

{/* Ticker news — pulls from our /news API, searched by symbol. */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-text-primary">
News for {symbol}
</h2>
<NewsSection searchTerm={symbol} limit={10} />
</section>
</div>
);
}
Expand All @@ -237,3 +309,32 @@ function Stat({ label, value }: { label: string; value: string }): React.ReactEl
</div>
);
}

function TrailingChange({ label, value }: { label: string; value: number | null }): React.ReactElement {
const known = value !== null && Number.isFinite(value);
const up = known && (value as number) >= 0;
const color = !known ? 'text-text-muted' : up ? 'text-green-400' : 'text-red-400';
const text = !known ? '—' : `${up ? '+' : ''}${(value as number).toFixed(2)}%`;
return (
<div className="flex items-baseline gap-1.5">
<span className="text-xs uppercase tracking-wider text-text-muted">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${color}`}>{text}</span>
</div>
);
}

function formatBool(value: boolean | null): string {
if (value === null) return '—';
return value ? 'Yes' : 'No';
}

function capitalize(value: string): string {
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
}

function formatAssetClass(value: string): string {
return value
.split('_')
.map((part) => (part === 'us' ? 'US' : capitalize(part)))
.join(' ');
}
Loading
Loading