- Ranking stocks for the {activeHorizon.toLowerCase()} horizon...
+
+ Ranking buy and sell ideas for the {activeHorizon.toLowerCase()}{" "}
+ horizon...
)}
{!loading && data && (
No ranking result yet. Switch time frames or refresh to try
again.
@@ -266,7 +314,7 @@ export function AIStockRankingsPanel({
return (
{shell}
-
+
Also see{" "}
Stock of the day
diff --git a/components/Footer.tsx b/components/Footer.tsx
index 204c660..f1f71aa 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -3,7 +3,7 @@
import dynamic from "next/dynamic";
import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
-import { DNA_BODY, DNA_HEADING, DNA_SUBHEADING } from "@/lib/design-dna";
+import { DNA_HEADING } from "@/lib/design-dna";
import {
FOOTER_CONTACT,
FOOTER_RESOURCES,
@@ -16,14 +16,16 @@ import { SITE_NAME } from "@/lib/site-seo";
const GitHubButton = dynamic(() => import("react-github-btn"), { ssr: false });
const footerLinkClass =
- "inline-block italic text-stone-300 transition-colors hover:text-stone-50";
+ "inline-block italic text-stone-200 transition-colors hover:text-stone-50";
-const footerHeadingClass = `${DNA_HEADING} text-stone-50 uppercase tracking-wide`;
+const footerHeadingClass = `${DNA_HEADING} text-stone-100 uppercase tracking-wide`;
+
+const footerBodyClass = "text-sm leading-relaxed text-stone-200";
function ContactIcon({ children }: { children: ReactNode }) {
return (
{children}
@@ -96,7 +98,7 @@ export function Footer() {
return (
@@ -120,11 +122,11 @@ export function Footer() {
{SITE_NAME}
-
+
@@ -247,7 +249,7 @@ export function Footer() {
className="flex justify-center lg:col-span-12"
data-testid="footer-copyright"
>
-
+
© {currentYear} {SITE_NAME}
diff --git a/components/__tests__/ColorContrast.test.tsx b/components/__tests__/ColorContrast.test.tsx
index bc31f90..886a948 100644
--- a/components/__tests__/ColorContrast.test.tsx
+++ b/components/__tests__/ColorContrast.test.tsx
@@ -122,7 +122,7 @@ describe("Color Contrast Compliance (Req 18.3)", () => {
expect(footer).not.toBeNull();
const cls = footer!.className;
expect(cls).toContain("bg-stone-950");
- expect(cls).toContain("text-stone-300");
+ expect(cls).toContain("text-stone-200");
});
});
diff --git a/lib/ai-stock-rankings.ts b/lib/ai-stock-rankings.ts
index 3a87031..2677301 100644
--- a/lib/ai-stock-rankings.ts
+++ b/lib/ai-stock-rankings.ts
@@ -27,9 +27,14 @@ export const AI_RANKING_TIMEFRAMES = [
export const AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS = 4096;
-export const AI_STOCK_RANKINGS_CANDIDATE_COUNT = 12;
+export const AI_STOCK_RANKINGS_CANDIDATE_COUNT = 8;
-export const AI_STOCK_RANKINGS_DISPLAY_COUNT = 10;
+export const AI_STOCK_RANKINGS_DISPLAY_COUNT = 5;
+
+export type AIStockRankingsCandidates = {
+ buyCandidates: AIStockCandidate[];
+ sellCandidates: AIStockCandidate[];
+};
const TIMEFRAME_PROMPT_CONTEXT: Record<
AIRankingTimeframe,
@@ -66,31 +71,37 @@ export function buildAIStockRankingsPrompt(
return `You are an equity research analyst building a premium AI stock ranking list for the ${context.horizon} horizon.
Return ONLY valid JSON, no markdown, in this exact shape:
{
- "candidates": [{"symbol": string, "name": string, "thesis": string}]
+ "buyCandidates": [{"symbol": string, "name": string, "thesis": string}],
+ "sellCandidates": [{"symbol": string, "name": string, "thesis": string}]
}
Task:
-- Rank the most promising US-listed common stocks for a ${context.horizon} investment horizon.
+- Rank the most promising US-listed common stocks to BUY for a ${context.horizon} investment horizon.
+- Rank the most compelling US-listed common stocks to SELL for the same ${context.horizon} horizon.
- Focus on ${context.focus}.
-- Return exactly ${AI_STOCK_RANKINGS_CANDIDATE_COUNT} distinct candidates ordered from most to least compelling for this horizon.
+- Return exactly ${AI_STOCK_RANKINGS_CANDIDATE_COUNT} distinct buy candidates and ${AI_STOCK_RANKINGS_CANDIDATE_COUNT} distinct sell candidates, each ordered from most to least compelling for this horizon.
Rules:
- Do not include ETFs, crypto, ADRs, funds, warrants, or preferred shares.
- Prefer liquid public companies that Yahoo Finance can quote.
-- thesis must be one short sentence explaining why the stock ranks highly for this horizon.
+- thesis must be one short sentence explaining why the stock ranks highly for this horizon and side.
- Use tickers only, no exchange suffixes.
- Avoid repeating the same mega-cap default unless the thesis is unusually differentiated for this horizon.`;
}
export function parseAIStockRankingsCandidates(
raw: string
-): AIStockCandidate[] {
+): AIStockRankingsCandidates {
const parsed = extractFirstJsonObject(raw);
- const candidates = parseAIStockCandidates(parsed?.candidates);
+ const buyCandidates = parseAIStockCandidates(parsed?.buyCandidates);
+ const sellCandidates = parseAIStockCandidates(parsed?.sellCandidates);
- if (candidates.length < 4) {
+ if (buyCandidates.length < 4 || sellCandidates.length < 4) {
throw new Error("AI returned an incomplete stock ranking candidate set.");
}
- return candidates.slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT);
+ return {
+ buyCandidates: buyCandidates.slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT),
+ sellCandidates: sellCandidates.slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT),
+ };
}
diff --git a/lib/home-ui.ts b/lib/home-ui.ts
index 1e0a550..2abb472 100644
--- a/lib/home-ui.ts
+++ b/lib/home-ui.ts
@@ -165,3 +165,14 @@ export function homeSegmentedTabClasses(
: "text-stone-700 hover:bg-white hover:text-stone-900"
}`;
}
+
+/** Horizon sublabels inside ranking timeframe tabs — readable on active and idle fills. */
+export function rankingTabSublabelClasses(
+ active: boolean,
+ isDark: boolean
+): string {
+ if (active) {
+ return isDark ? "text-stone-600" : "text-stone-300";
+ }
+ return isDark ? "text-stone-300" : "text-stone-600";
+}
diff --git a/lib/local-ollama-ai-rankings.ts b/lib/local-ollama-ai-rankings.ts
index c4274ac..1681d4c 100644
--- a/lib/local-ollama-ai-rankings.ts
+++ b/lib/local-ollama-ai-rankings.ts
@@ -63,7 +63,7 @@ async function fetchAIStockRankingsWithBrowserLocalOllama(
const raw = await generateWithBrowserLocalOllama(
buildAIStockRankingsPrompt(timeframe)
);
- const candidates = parseAIStockRankingsCandidates(raw);
+ const { buyCandidates, sellCandidates } = parseAIStockRankingsCandidates(raw);
const response = await fetch("/api/market/ranking", {
method: "POST",
@@ -73,7 +73,7 @@ async function fetchAIStockRankingsWithBrowserLocalOllama(
},
credentials: "include",
cache: "no-store",
- body: JSON.stringify({ timeframe, candidates }),
+ body: JSON.stringify({ timeframe, buyCandidates, sellCandidates }),
});
if (!response.ok) {
diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts
index 0deca4a..3f5d87a 100644
--- a/services/ai-market-insights.service.ts
+++ b/services/ai-market-insights.service.ts
@@ -20,6 +20,7 @@ import {
buildAIStockRankingsPrompt,
parseAIStockRankingsCandidates,
type AIRankingTimeframe,
+ type AIStockRankingsCandidates,
} from "@/lib/ai-stock-rankings";
import {
parseStockOfTheDayCandidates,
@@ -332,14 +333,25 @@ export class AIMarketInsightsService {
async enrichAIStockRankingsCandidates(
timeframe: AIRankingTimeframe,
- candidates: AIStockCandidate[]
+ candidates: AIStockRankingsCandidates
): Promise
{
- const enriched = await this.enrichRankingCandidates(candidates, timeframe);
- const ranked = enriched
+ const [buyEnriched, sellEnriched] = await Promise.all([
+ this.enrichRankingCandidates(candidates.buyCandidates, timeframe, "buy"),
+ this.enrichRankingCandidates(
+ candidates.sellCandidates,
+ timeframe,
+ "sell"
+ ),
+ ]);
+
+ const buyRanked = buyEnriched
+ .sort((a, b) => b.score - a.score)
+ .slice(0, AI_STOCK_RANKINGS_DISPLAY_COUNT);
+ const sellRanked = sellEnriched
.sort((a, b) => b.score - a.score)
.slice(0, AI_STOCK_RANKINGS_DISPLAY_COUNT);
- if (ranked.length < 4) {
+ if (buyRanked.length < 4 || sellRanked.length < 4) {
throw new Error(
"AI did not return enough valid public stock candidates for this ranking."
);
@@ -349,7 +361,10 @@ export class AIMarketInsightsService {
return {
timeframe,
generatedAt,
- stocks: ranked.map((candidate, index) =>
+ buy: buyRanked.map((candidate, index) =>
+ this.toAIRankedStock(candidate, index + 1)
+ ),
+ sell: sellRanked.map((candidate, index) =>
this.toAIRankedStock(candidate, index + 1)
),
};
@@ -376,7 +391,7 @@ export class AIMarketInsightsService {
private async generateAIStockRankingsCandidates(
timeframe: AIRankingTimeframe,
llm: LLMConfig
- ): Promise {
+ ): Promise {
const service = new AIIntegrationService();
await service.setAIProvider(llm.provider, {
provider: llm.provider,
@@ -396,7 +411,8 @@ export class AIMarketInsightsService {
private async enrichRankingCandidates(
candidates: AIStockCandidate[],
- timeframe: AIRankingTimeframe
+ timeframe: AIRankingTimeframe,
+ direction: "buy" | "sell"
): Promise {
const enriched = await Promise.all(
candidates.map(async (candidate) => {
@@ -408,6 +424,7 @@ export class AIMarketInsightsService {
]);
const score = this.scoreRankingCandidate({
+ direction,
timeframe,
price: quote.price,
changePercent: quote.changePercent,
@@ -425,10 +442,12 @@ export class AIMarketInsightsService {
name: quote.name || candidate.name || candidate.symbol,
thesis:
candidate.thesis ||
- "AI surfaced this as a differentiated candidate for this horizon.",
+ (direction === "buy"
+ ? "AI surfaced this as a differentiated candidate for this horizon."
+ : "AI surfaced this as a structurally vulnerable candidate for this horizon."),
score,
confidence: scoreToConfidence(score, 1.25),
- rationale: this.buildRankingRationale(timeframe, {
+ rationale: this.buildRankingRationale(direction, timeframe, {
thesis: candidate.thesis,
changePercent: quote.changePercent,
marketCap: quote.marketCap,
@@ -440,6 +459,7 @@ export class AIMarketInsightsService {
} catch (error) {
logger.warn("Failed to validate AI stock ranking candidate", {
symbol: candidate.symbol,
+ direction,
timeframe,
error: error instanceof Error ? error.message : String(error),
});
@@ -545,6 +565,7 @@ export class AIMarketInsightsService {
}
private scoreRankingCandidate(args: {
+ direction: "buy" | "sell";
timeframe: AIRankingTimeframe;
price: number;
changePercent: number;
@@ -557,7 +578,7 @@ export class AIMarketInsightsService {
analystRatings: ForecastData["analystRatings"];
}): number {
const baseScore = this.scoreStockOfTheDayCandidate({
- direction: "buy",
+ direction: args.direction,
price: args.price,
changePercent: args.changePercent,
volume: args.volume,
@@ -666,6 +687,7 @@ export class AIMarketInsightsService {
}
private buildRankingRationale(
+ direction: "buy" | "sell",
timeframe: AIRankingTimeframe,
args: {
thesis?: string;
@@ -676,7 +698,7 @@ export class AIMarketInsightsService {
price: number;
}
): string[] {
- const base = this.buildStockOfTheDayRationale("buy", args);
+ const base = this.buildStockOfTheDayRationale(direction, args);
const horizonLabel =
timeframe === "short"
? "short-term"
@@ -686,7 +708,9 @@ export class AIMarketInsightsService {
return [
args.thesis ||
- `AI identified a compelling ${horizonLabel} setup versus peers in this horizon.`,
+ (direction === "buy"
+ ? `AI identified a compelling ${horizonLabel} setup versus peers in this horizon.`
+ : `AI identified a compelling ${horizonLabel} risk versus peers in this horizon.`),
base[1],
base[2],
];
diff --git a/types/index.ts b/types/index.ts
index 76da0db..756b3c1 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -486,5 +486,6 @@ export interface AIRankedStock {
export interface AIStockRankingsResult {
timeframe: AIRankingTimeframe;
generatedAt: Date;
- stocks: AIRankedStock[];
+ buy: AIRankedStock[];
+ sell: AIRankedStock[];
}
From 6114cc1737c9585486f8c501e530323fab0deb7b Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 17 Sep 2026 12:51:55 +0000
Subject: [PATCH 08/15] Update ranking page SEO copy for buy/sell lists
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: ✅ Daniel Danielecki
---
app/(main)/ranking/layout.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/(main)/ranking/layout.tsx b/app/(main)/ranking/layout.tsx
index 3f3a002..90efea0 100644
--- a/app/(main)/ranking/layout.tsx
+++ b/app/(main)/ranking/layout.tsx
@@ -4,7 +4,7 @@ import { buildPageMetadata } from "@/lib/site-seo";
export const metadata: Metadata = buildPageMetadata({
title: "Ranking",
description:
- "Ranked lists of the most promising stocks for short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.",
+ "Ranked buy and sell lists for short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.",
path: "/ranking",
});
From 73b3c67054100dbb5b5948ff9bf8e9b85987d058 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 17 Sep 2026 13:29:06 +0000
Subject: [PATCH 09/15] Defer footer load to restore Lighthouse performance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Code-split Footer and render it via LazySection below the fold
- Load GitHub star widget only when its slot enters the viewport
- Move footer top margin to the LazySection wrapper
- Ignore local Lighthouse CI artifacts
Co-authored-by: ✅ Daniel Danielecki
---
.gitignore | 1 +
app/(main)/layout.tsx | 15 +++++++--
components/Footer.tsx | 31 ++++++++++++++++---
.../__tests__/ResponsiveDesign.test.tsx | 2 +-
4 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/.gitignore b/.gitignore
index 8ae2a21..d9d9a20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -122,6 +122,7 @@ pnpm-lock.yaml
/playwright-report/
/test-results/
/playwright/.cache/
+.lighthouseci/
# Vercel
.vercel
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index e4ce58f..25613b4 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -1,10 +1,19 @@
"use client";
+import dynamic from "next/dynamic";
import { Suspense } from "react";
-import { Footer } from "@/components/Footer";
+import { LazySection } from "@/components/LazySection";
import { Navigation } from "@/components/Navigation";
import { HOME_PAGE_BACKGROUND } from "@/lib/home-ui";
+const Footer = dynamic(
+ () => import("@/components/Footer").then((mod) => ({ default: mod.Footer })),
+ {
+ ssr: false,
+ loading: () =>
,
+ }
+);
+
function NavigationFallback() {
return (
-
+
+
+
);
}
diff --git a/components/Footer.tsx b/components/Footer.tsx
index f1f71aa..ea50896 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import Link from "next/link";
-import { useEffect, useState, type ReactNode } from "react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
import { DNA_HEADING } from "@/lib/design-dna";
import {
FOOTER_CONTACT,
@@ -90,20 +90,43 @@ function FooterSocialIcon({ id }: { id: FooterSocialId }) {
export function Footer() {
const currentYear = new Date().getFullYear();
+ const starSlotRef = useRef(null);
const [showGitHubStar, setShowGitHubStar] = useState(false);
useEffect(() => {
- setShowGitHubStar(true);
+ const slot = starSlotRef.current;
+ if (!slot) return;
+
+ if (typeof IntersectionObserver === "undefined") {
+ setShowGitHubStar(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setShowGitHubStar(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "120px" }
+ );
+
+ observer.observe(slot);
+ return () => observer.disconnect();
}, []);
return (
-
+
{showGitHubStar ? (
{
const { container } = render();
const footer = container.querySelector("footer");
expect(footer).not.toBeNull();
- expect(footer!.className).toContain("mt-8");
+ expect(footer!.className).toContain("border-t");
});
it("should render SymbolHeader text at base size (text-3xl)", () => {
From cc20412c7c65f239e278d5d13fbebece770c7d66 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 18 Sep 2026 13:56:43 +0000
Subject: [PATCH 10/15] Add heatmap-aligned categories to Ranking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Reuse HeatmapNavigation categories: etf, crypto, stock
- Category-aware AI prompts, parsing, API, and enrichment
- Heatmap-style category switcher on /ranking with per-category fetch
Co-authored-by: ✅ Daniel Danielecki
---
app/(main)/ranking/layout.tsx | 2 +-
app/(main)/ranking/page.tsx | 18 +++-
app/api/market/ranking/route.ts | 47 ++++++++-
components/AIStockRankingsPanel.tsx | 27 ++++-
lib/ai-stock-rankings.ts | 133 ++++++++++++++++++++++---
lib/local-ollama-ai-rankings.ts | 43 +++++---
services/ai-market-insights.service.ts | 25 +++--
types/index.ts | 4 +
8 files changed, 247 insertions(+), 52 deletions(-)
diff --git a/app/(main)/ranking/layout.tsx b/app/(main)/ranking/layout.tsx
index 90efea0..54e64c8 100644
--- a/app/(main)/ranking/layout.tsx
+++ b/app/(main)/ranking/layout.tsx
@@ -4,7 +4,7 @@ import { buildPageMetadata } from "@/lib/site-seo";
export const metadata: Metadata = buildPageMetadata({
title: "Ranking",
description:
- "Ranked buy and sell lists for short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.",
+ "Ranked buy and sell lists for ETFs, crypto, and stocks across short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.",
path: "/ranking",
});
diff --git a/app/(main)/ranking/page.tsx b/app/(main)/ranking/page.tsx
index 00ee9b0..a8c9437 100644
--- a/app/(main)/ranking/page.tsx
+++ b/app/(main)/ranking/page.tsx
@@ -12,8 +12,11 @@ import {
DNA_PAGE_STACK,
} from "@/lib/design-dna";
import { HOME_SUBTLE_TEXT } from "@/lib/home-ui";
-import type { AIRankingTimeframe } from "@/lib/ai-stock-rankings";
-import type { AIStockRankingsResult } from "@/types";
+import type {
+ AIRankingCategory,
+ AIRankingTimeframe,
+ AIStockRankingsResult,
+} from "@/types";
import { AIStockRankingsPanel } from "@/components/AIStockRankingsPanel";
export default function AIStockRankingsPage() {
@@ -27,6 +30,7 @@ export default function AIStockRankingsPage() {
pricingTier === "HOSTED_AI";
const hasAIAccess = hasTierAccess || serverBYOKAccess === true;
+ const [category, setCategory] = useState("stock");
const [timeframe, setTimeframe] = useState("short");
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
@@ -55,9 +59,11 @@ export default function AIStockRankingsPage() {
}
setLoading(true);
+ setData(null);
try {
const result = await fetchAIStockRankingsForCurrentProvider(
timeframe,
+ category,
pricingTier
);
setData(result);
@@ -75,7 +81,7 @@ export default function AIStockRankingsPage() {
};
load();
- }, [hasAIAccess, aiProviderVersion, pricingTier, timeframe]);
+ }, [hasAIAccess, aiProviderVersion, pricingTier, timeframe, category]);
useEffect(() => {
const loadBYOKAccess = async () => {
@@ -107,8 +113,8 @@ export default function AIStockRankingsPage() {
Ranking
- Ranked buy and sell lists across short, medium, and long horizons —
- each row includes a clear rationale.
+ Ranked buy and sell lists for ETFs, crypto, and stocks across short,
+ medium, and long horizons — each row includes a clear rationale.
Related:{" "}
@@ -127,6 +133,8 @@ export default function AIStockRankingsPage() {
locked={!hasAIAccess}
error={loadError}
pricingTier={pricingTier}
+ category={category}
+ onCategoryChange={setCategory}
timeframe={timeframe}
onTimeframeChange={setTimeframe}
/>
diff --git a/app/api/market/ranking/route.ts b/app/api/market/ranking/route.ts
index 9a5ab1b..06d2e44 100644
--- a/app/api/market/ranking/route.ts
+++ b/app/api/market/ranking/route.ts
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { MARKET_UI_COPY, userFacingApiError } from "@/lib/api-user-error";
-import { isAIRankingTimeframe } from "@/lib/ai-stock-rankings";
-import { parseAIStockCandidates } from "@/lib/stock-of-the-day-ai";
+import {
+ isAIRankingCategory,
+ isAIRankingTimeframe,
+ parseAIRankingCandidates,
+} from "@/lib/ai-stock-rankings";
import { aiMarketInsightsService } from "@/services/ai-market-insights.service";
import { logger } from "@/lib/logger";
import { getAuthenticatedUser } from "@/lib/server-auth";
@@ -39,6 +42,19 @@ export async function GET(request: NextRequest) {
);
}
+ const categoryRaw =
+ request.nextUrl.searchParams.get("category")?.trim().toLowerCase() ??
+ "stock";
+ if (!isAIRankingCategory(categoryRaw)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Invalid category. Use etf, crypto, or stock.",
+ },
+ { status: 400 }
+ );
+ }
+
const requestedProviderRaw =
request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? "";
@@ -67,6 +83,7 @@ export async function GET(request: NextRequest) {
const data = await aiMarketInsightsService.getAIStockRankings(
timeframeRaw,
+ categoryRaw,
resolved.llmConfig
);
@@ -109,6 +126,7 @@ export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => null)) as {
timeframe?: unknown;
+ category?: unknown;
buyCandidates?: unknown;
sellCandidates?: unknown;
} | null;
@@ -127,8 +145,28 @@ export async function POST(request: NextRequest) {
);
}
- const buyCandidates = parseAIStockCandidates(body?.buyCandidates);
- const sellCandidates = parseAIStockCandidates(body?.sellCandidates);
+ const categoryRaw =
+ typeof body?.category === "string"
+ ? body.category.trim().toLowerCase()
+ : "stock";
+ if (!isAIRankingCategory(categoryRaw)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Invalid category. Use etf, crypto, or stock.",
+ },
+ { status: 400 }
+ );
+ }
+
+ const buyCandidates = parseAIRankingCandidates(
+ body?.buyCandidates,
+ categoryRaw
+ );
+ const sellCandidates = parseAIRankingCandidates(
+ body?.sellCandidates,
+ categoryRaw
+ );
if (buyCandidates.length < 4 || sellCandidates.length < 4) {
return NextResponse.json(
{
@@ -142,6 +180,7 @@ export async function POST(request: NextRequest) {
const data = await aiMarketInsightsService.enrichAIStockRankingsCandidates(
timeframeRaw,
+ categoryRaw,
{ buyCandidates, sellCandidates }
);
diff --git a/components/AIStockRankingsPanel.tsx b/components/AIStockRankingsPanel.tsx
index 7bc2ae1..d3b906c 100644
--- a/components/AIStockRankingsPanel.tsx
+++ b/components/AIStockRankingsPanel.tsx
@@ -5,13 +5,16 @@ import Link from "next/link";
import { DNA_BODY, DNA_EYEBROW, DNA_LABEL_STRONG } from "@/lib/design-dna";
import {
AI_RANKING_TIMEFRAMES,
+ getAIRankingCategoryLabel,
type AIRankingTimeframe,
} from "@/lib/ai-stock-rankings";
import type {
AIRankedStock,
+ AIRankingCategory,
AIStockRankingsResult,
PricingTier,
} from "@/types";
+import { HeatmapNavigation } from "@/components/HeatmapNavigation";
import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux";
import { InsightPanel, InsightPanelHeader } from "@/components/InsightPanel";
import { SubscriptionGate } from "@/components/ProductShell";
@@ -34,6 +37,8 @@ interface AIStockRankingsPanelProps {
locked: boolean;
error?: string | null;
pricingTier?: PricingTier | null;
+ category: AIRankingCategory;
+ onCategoryChange: (category: AIRankingCategory) => void;
timeframe: AIRankingTimeframe;
onTimeframeChange: (timeframe: AIRankingTimeframe) => void;
}
@@ -46,11 +51,13 @@ function RankingSideTable({
title,
variant,
stocks,
+ category,
timeframe,
}: {
title: string;
variant: "buy" | "sell";
stocks: AIRankedStock[];
+ category: AIRankingCategory;
timeframe: AIRankingTimeframe;
}) {
const borderClass =
@@ -103,7 +110,7 @@ function RankingSideTable({
{stocks.map((stock) => (
@@ -134,6 +141,8 @@ export function AIStockRankingsPanel({
locked,
error,
pricingTier,
+ category,
+ onCategoryChange,
timeframe,
onTimeframeChange,
}: AIStockRankingsPanelProps) {
@@ -146,6 +155,7 @@ export function AIStockRankingsPanel({
const activeHorizon =
AI_RANKING_TIMEFRAMES.find((item) => item.id === timeframe)?.horizon ??
"Days to weeks";
+ const categoryLabel = getAIRankingCategoryLabel(category);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
@@ -200,7 +210,7 @@ export function AIStockRankingsPanel({
@@ -212,6 +222,13 @@ export function AIStockRankingsPanel({
/>
+
+
+
+
- Ranking buy and sell ideas for the {activeHorizon.toLowerCase()}{" "}
- horizon...
+ Ranking {categoryLabel} buy and sell ideas for the{" "}
+ {activeHorizon.toLowerCase()} horizon...
)}
@@ -268,12 +285,14 @@ export function AIStockRankingsPanel({
title="Ideas to buy"
variant="buy"
stocks={data.buy}
+ category={category}
timeframe={timeframe}
/>
diff --git a/lib/ai-stock-rankings.ts b/lib/ai-stock-rankings.ts
index 2677301..df55caf 100644
--- a/lib/ai-stock-rankings.ts
+++ b/lib/ai-stock-rankings.ts
@@ -1,11 +1,10 @@
import {
extractFirstJsonObject,
- parseAIStockCandidates,
type AIStockCandidate,
} from "@/lib/stock-of-the-day-ai";
-import type { AIRankingTimeframe } from "@/types";
+import type { AIRankingCategory, AIRankingTimeframe } from "@/types";
-export type { AIRankingTimeframe };
+export type { AIRankingTimeframe, AIRankingCategory };
export const AI_RANKING_TIMEFRAMES = [
{
@@ -25,6 +24,13 @@ export const AI_RANKING_TIMEFRAMES = [
},
] as const;
+/** Same category ids as HeatmapNavigation (etf, crypto, stock). */
+export const AI_RANKING_CATEGORIES = [
+ { id: "etf" as const, label: "ETFs" },
+ { id: "crypto" as const, label: "Crypto" },
+ { id: "stock" as const, label: "Stocks" },
+] as const;
+
export const AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS = 4096;
export const AI_STOCK_RANKINGS_CANDIDATE_COUNT = 8;
@@ -57,18 +63,106 @@ const TIMEFRAME_PROMPT_CONTEXT: Record<
},
};
+const CATEGORY_PROMPT_CONTEXT: Record<
+ AIRankingCategory,
+ { assetLabel: string; assetRules: string; symbolRules: string }
+> = {
+ etf: {
+ assetLabel: "US-listed ETFs",
+ assetRules:
+ "- Rank liquid exchange-traded funds only.\n- Do not include individual stocks, crypto, mutual funds, or closed-end funds.",
+ symbolRules: "Use ETF tickers only (e.g. SPY, QQQ), no exchange suffixes.",
+ },
+ crypto: {
+ assetLabel: "major cryptocurrencies",
+ assetRules:
+ "- Rank liquid crypto assets that Yahoo Finance quotes.\n- Do not include stocks, ETFs, or stablecoins unless the thesis is unusually differentiated.",
+ symbolRules:
+ "Use Yahoo Finance crypto symbols with -USD suffix (e.g. BTC-USD, ETH-USD).",
+ },
+ stock: {
+ assetLabel: "US-listed common stocks",
+ assetRules:
+ "- Do not include ETFs, crypto, ADRs, funds, warrants, or preferred shares.",
+ symbolRules: "Use tickers only, no exchange suffixes.",
+ },
+};
+
export function isAIRankingTimeframe(
value: string
): value is AIRankingTimeframe {
return value === "short" || value === "medium" || value === "long";
}
+export function isAIRankingCategory(value: string): value is AIRankingCategory {
+ return value === "etf" || value === "crypto" || value === "stock";
+}
+
+export function getAIRankingCategoryLabel(category: AIRankingCategory): string {
+ return (
+ AI_RANKING_CATEGORIES.find((item) => item.id === category)?.label ??
+ category
+ );
+}
+
+function normalizeRankingSymbol(
+ raw: string,
+ category: AIRankingCategory
+): string | null {
+ const symbol = raw.trim().toUpperCase();
+ if (!symbol) return null;
+
+ switch (category) {
+ case "stock":
+ case "etf":
+ return /^[A-Z]{1,5}$/.test(symbol) ? symbol : null;
+ case "crypto":
+ if (/^[A-Z0-9]{2,10}-USD$/.test(symbol)) return symbol;
+ if (/^[A-Z0-9]{2,10}$/.test(symbol)) return `${symbol}-USD`;
+ return null;
+ }
+}
+
+export function parseAIRankingCandidates(
+ value: unknown,
+ category: AIRankingCategory
+): AIStockCandidate[] {
+ if (!Array.isArray(value)) return [];
+
+ const seen = new Set();
+ return value
+ .map((item): AIStockCandidate | null => {
+ if (!item || typeof item !== "object") return null;
+ const candidate = item as Record;
+ const rawSymbol =
+ typeof candidate.symbol === "string" ? candidate.symbol : "";
+ const symbol = normalizeRankingSymbol(rawSymbol, category);
+ if (!symbol || seen.has(symbol)) return null;
+ seen.add(symbol);
+ return {
+ symbol,
+ name:
+ typeof candidate.name === "string"
+ ? candidate.name.trim()
+ : undefined,
+ thesis:
+ typeof candidate.thesis === "string"
+ ? candidate.thesis.trim()
+ : undefined,
+ };
+ })
+ .filter((item): item is AIStockCandidate => item !== null)
+ .slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT);
+}
+
export function buildAIStockRankingsPrompt(
- timeframe: AIRankingTimeframe
+ timeframe: AIRankingTimeframe,
+ category: AIRankingCategory
): string {
const context = TIMEFRAME_PROMPT_CONTEXT[timeframe];
+ const categoryContext = CATEGORY_PROMPT_CONTEXT[category];
- return `You are an equity research analyst building a premium AI stock ranking list for the ${context.horizon} horizon.
+ return `You are a markets analyst building a premium AI ranking list for ${categoryContext.assetLabel} over the ${context.horizon} horizon.
Return ONLY valid JSON, no markdown, in this exact shape:
{
"buyCandidates": [{"symbol": string, "name": string, "thesis": string}],
@@ -76,32 +170,39 @@ Return ONLY valid JSON, no markdown, in this exact shape:
}
Task:
-- Rank the most promising US-listed common stocks to BUY for a ${context.horizon} investment horizon.
-- Rank the most compelling US-listed common stocks to SELL for the same ${context.horizon} horizon.
+- Rank the most promising ${categoryContext.assetLabel} to BUY for a ${context.horizon} investment horizon.
+- Rank the most compelling ${categoryContext.assetLabel} to SELL for the same ${context.horizon} horizon.
- Focus on ${context.focus}.
- Return exactly ${AI_STOCK_RANKINGS_CANDIDATE_COUNT} distinct buy candidates and ${AI_STOCK_RANKINGS_CANDIDATE_COUNT} distinct sell candidates, each ordered from most to least compelling for this horizon.
Rules:
-- Do not include ETFs, crypto, ADRs, funds, warrants, or preferred shares.
-- Prefer liquid public companies that Yahoo Finance can quote.
-- thesis must be one short sentence explaining why the stock ranks highly for this horizon and side.
-- Use tickers only, no exchange suffixes.
+${categoryContext.assetRules}
+- Prefer liquid assets that Yahoo Finance can quote.
+- thesis must be one short sentence explaining why the asset ranks highly for this horizon and side.
+- ${categoryContext.symbolRules}
- Avoid repeating the same mega-cap default unless the thesis is unusually differentiated for this horizon.`;
}
export function parseAIStockRankingsCandidates(
- raw: string
+ raw: string,
+ category: AIRankingCategory
): AIStockRankingsCandidates {
const parsed = extractFirstJsonObject(raw);
- const buyCandidates = parseAIStockCandidates(parsed?.buyCandidates);
- const sellCandidates = parseAIStockCandidates(parsed?.sellCandidates);
+ const buyCandidates = parseAIRankingCandidates(
+ parsed?.buyCandidates,
+ category
+ );
+ const sellCandidates = parseAIRankingCandidates(
+ parsed?.sellCandidates,
+ category
+ );
if (buyCandidates.length < 4 || sellCandidates.length < 4) {
throw new Error("AI returned an incomplete stock ranking candidate set.");
}
return {
- buyCandidates: buyCandidates.slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT),
- sellCandidates: sellCandidates.slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT),
+ buyCandidates,
+ sellCandidates,
};
}
diff --git a/lib/local-ollama-ai-rankings.ts b/lib/local-ollama-ai-rankings.ts
index 1681d4c..3939360 100644
--- a/lib/local-ollama-ai-rankings.ts
+++ b/lib/local-ollama-ai-rankings.ts
@@ -13,6 +13,7 @@ import { getAIProviderHeaders } from "@/lib/explanation-provider";
import { MARKET_UI_COPY } from "@/lib/market-ui-copy";
import type {
AIStockRankingsResult,
+ AIRankingCategory,
AIRankingTimeframe,
PricingTier,
} from "@/types";
@@ -24,27 +25,30 @@ type AIStockRankingsApiResponse = {
export async function fetchAIStockRankingsForCurrentProvider(
timeframe: AIRankingTimeframe,
+ category: AIRankingCategory,
pricingTier: PricingTier | null
): Promise {
const useLocalOllama = shouldUseBrowserLocalOllama(pricingTier);
if (useLocalOllama && !isAppOpenedOnLoopbackHost()) {
- return fetchAIStockRankingsWithBrowserLocalOllama(timeframe);
+ return fetchAIStockRankingsWithBrowserLocalOllama(timeframe, category);
}
- return fetchAIStockRankingsViaServerGet(timeframe);
+ return fetchAIStockRankingsViaServerGet(timeframe, category);
}
async function fetchAIStockRankingsViaServerGet(
- timeframe: AIRankingTimeframe
+ timeframe: AIRankingTimeframe,
+ category: AIRankingCategory
): Promise {
- const response = await fetch(
- `/api/market/ranking?timeframe=${encodeURIComponent(timeframe)}`,
- {
- headers: getAIProviderHeaders(),
- credentials: "include",
- cache: "no-store",
- }
- );
+ const params = new URLSearchParams({
+ timeframe,
+ category,
+ });
+ const response = await fetch(`/api/market/ranking?${params.toString()}`, {
+ headers: getAIProviderHeaders(),
+ credentials: "include",
+ cache: "no-store",
+ });
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as {
@@ -58,12 +62,16 @@ async function fetchAIStockRankingsViaServerGet(
}
async function fetchAIStockRankingsWithBrowserLocalOllama(
- timeframe: AIRankingTimeframe
+ timeframe: AIRankingTimeframe,
+ category: AIRankingCategory
): Promise {
const raw = await generateWithBrowserLocalOllama(
- buildAIStockRankingsPrompt(timeframe)
+ buildAIStockRankingsPrompt(timeframe, category)
+ );
+ const { buyCandidates, sellCandidates } = parseAIStockRankingsCandidates(
+ raw,
+ category
);
- const { buyCandidates, sellCandidates } = parseAIStockRankingsCandidates(raw);
const response = await fetch("/api/market/ranking", {
method: "POST",
@@ -73,7 +81,12 @@ async function fetchAIStockRankingsWithBrowserLocalOllama(
},
credentials: "include",
cache: "no-store",
- body: JSON.stringify({ timeframe, buyCandidates, sellCandidates }),
+ body: JSON.stringify({
+ timeframe,
+ category,
+ buyCandidates,
+ sellCandidates,
+ }),
});
if (!response.ok) {
diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts
index 3f5d87a..792bdf1 100644
--- a/services/ai-market-insights.service.ts
+++ b/services/ai-market-insights.service.ts
@@ -21,6 +21,7 @@ import {
parseAIStockRankingsCandidates,
type AIRankingTimeframe,
type AIStockRankingsCandidates,
+ type AIRankingCategory,
} from "@/lib/ai-stock-rankings";
import {
parseStockOfTheDayCandidates,
@@ -315,6 +316,7 @@ export class AIMarketInsightsService {
async getAIStockRankings(
timeframe: AIRankingTimeframe,
+ category: AIRankingCategory,
llmConfig?: LLMConfig
): Promise {
const llm = llmConfig ?? getLLMConfigFromEnv();
@@ -326,13 +328,19 @@ export class AIMarketInsightsService {
const generatedCandidates = await this.generateAIStockRankingsCandidates(
timeframe,
+ category,
llm
);
- return this.enrichAIStockRankingsCandidates(timeframe, generatedCandidates);
+ return this.enrichAIStockRankingsCandidates(
+ timeframe,
+ category,
+ generatedCandidates
+ );
}
async enrichAIStockRankingsCandidates(
timeframe: AIRankingTimeframe,
+ category: AIRankingCategory,
candidates: AIStockRankingsCandidates
): Promise {
const [buyEnriched, sellEnriched] = await Promise.all([
@@ -359,13 +367,14 @@ export class AIMarketInsightsService {
const generatedAt = new Date();
return {
+ category,
timeframe,
generatedAt,
buy: buyRanked.map((candidate, index) =>
- this.toAIRankedStock(candidate, index + 1)
+ this.toAIRankedStock(candidate, index + 1, category)
),
sell: sellRanked.map((candidate, index) =>
- this.toAIRankedStock(candidate, index + 1)
+ this.toAIRankedStock(candidate, index + 1, category)
),
};
}
@@ -390,6 +399,7 @@ export class AIMarketInsightsService {
private async generateAIStockRankingsCandidates(
timeframe: AIRankingTimeframe,
+ category: AIRankingCategory,
llm: LLMConfig
): Promise {
const service = new AIIntegrationService();
@@ -401,12 +411,12 @@ export class AIMarketInsightsService {
});
const raw = await service.runRawPrompt(
- buildAIStockRankingsPrompt(timeframe),
+ buildAIStockRankingsPrompt(timeframe, category),
{
maxOutputTokens: AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS,
}
);
- return parseAIStockRankingsCandidates(raw);
+ return parseAIStockRankingsCandidates(raw, category);
}
private async enrichRankingCandidates(
@@ -536,13 +546,14 @@ export class AIMarketInsightsService {
private toAIRankedStock(
candidate: EnrichedStockCandidate,
- rank: number
+ rank: number,
+ category: AIRankingCategory
): AIRankedStock {
return {
rank,
symbol: candidate.symbol,
name: candidate.name,
- assetType: "stock",
+ assetType: category,
confidence: candidate.confidence,
rationale: candidate.rationale,
};
diff --git a/types/index.ts b/types/index.ts
index 756b3c1..296e680 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -474,6 +474,9 @@ export interface StockOfTheDayResult {
export type AIRankingTimeframe = "short" | "medium" | "long";
+/** Matches HeatmapNavigation / HeatmapHub categories exactly. */
+export type AIRankingCategory = "etf" | "crypto" | "stock";
+
export interface AIRankedStock {
rank: number;
symbol: string;
@@ -484,6 +487,7 @@ export interface AIRankedStock {
}
export interface AIStockRankingsResult {
+ category: AIRankingCategory;
timeframe: AIRankingTimeframe;
generatedAt: Date;
buy: AIRankedStock[];
From 1862f8fcd9813f95d50596f41a2069f37eac33e8 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 24 Sep 2026 15:40:05 +0000
Subject: [PATCH 11/15] Fix ETF and crypto ranking candidate enrichment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Route category through enrichRankingCandidates, quote ETFs/crypto via
Yahoo instead of Finnhub stock paths, and tolerate missing forecast/
indicator data like AI predictions. Use category-aware insufficient-
candidate error messages.
Co-authored-by: ✅ Daniel Danielecki
---
app/api/market/ranking/route.ts | 3 +-
lib/ai-stock-rankings.ts | 14 ++++-
services/ai-market-insights.service.ts | 87 +++++++++++++++++++++++---
3 files changed, 92 insertions(+), 12 deletions(-)
diff --git a/app/api/market/ranking/route.ts b/app/api/market/ranking/route.ts
index 06d2e44..2e72c9c 100644
--- a/app/api/market/ranking/route.ts
+++ b/app/api/market/ranking/route.ts
@@ -4,6 +4,7 @@ import {
isAIRankingCategory,
isAIRankingTimeframe,
parseAIRankingCandidates,
+ rankingIncompleteCandidatesMessage,
} from "@/lib/ai-stock-rankings";
import { aiMarketInsightsService } from "@/services/ai-market-insights.service";
import { logger } from "@/lib/logger";
@@ -171,7 +172,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
- error: "AI returned an incomplete stock ranking candidate set.",
+ error: rankingIncompleteCandidatesMessage(categoryRaw),
timestamp: new Date(),
},
{ status: 400 }
diff --git a/lib/ai-stock-rankings.ts b/lib/ai-stock-rankings.ts
index df55caf..0049ee0 100644
--- a/lib/ai-stock-rankings.ts
+++ b/lib/ai-stock-rankings.ts
@@ -105,6 +105,18 @@ export function getAIRankingCategoryLabel(category: AIRankingCategory): string {
);
}
+export function rankingInsufficientCandidatesMessage(
+ category: AIRankingCategory
+): string {
+ return `AI did not return enough valid ${getAIRankingCategoryLabel(category).toLowerCase()} candidates for this ranking.`;
+}
+
+export function rankingIncompleteCandidatesMessage(
+ category: AIRankingCategory
+): string {
+ return `AI returned an incomplete ${getAIRankingCategoryLabel(category).toLowerCase()} ranking candidate set.`;
+}
+
function normalizeRankingSymbol(
raw: string,
category: AIRankingCategory
@@ -198,7 +210,7 @@ export function parseAIStockRankingsCandidates(
);
if (buyCandidates.length < 4 || sellCandidates.length < 4) {
- throw new Error("AI returned an incomplete stock ranking candidate set.");
+ throw new Error(rankingIncompleteCandidatesMessage(category));
}
return {
diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts
index 792bdf1..7c19c6d 100644
--- a/services/ai-market-insights.service.ts
+++ b/services/ai-market-insights.service.ts
@@ -12,6 +12,7 @@ import {
type AIPredictionMarketSnapshot,
} from "@/lib/ai-prediction";
import { marketDataService } from "@/services/market-data.service";
+import { yahooFinanceService } from "@/services/yahoo-finance.service";
import { AIIntegrationService } from "@/services/ai-integration.service";
import { logger } from "@/lib/logger";
import {
@@ -19,6 +20,7 @@ import {
AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS,
buildAIStockRankingsPrompt,
parseAIStockRankingsCandidates,
+ rankingInsufficientCandidatesMessage,
type AIRankingTimeframe,
type AIStockRankingsCandidates,
type AIRankingCategory,
@@ -38,6 +40,7 @@ import type {
ForecastData,
StockOfTheDay,
StockOfTheDayResult,
+ SymbolData,
TechnicalIndicators,
} from "@/types";
@@ -344,10 +347,16 @@ export class AIMarketInsightsService {
candidates: AIStockRankingsCandidates
): Promise {
const [buyEnriched, sellEnriched] = await Promise.all([
- this.enrichRankingCandidates(candidates.buyCandidates, timeframe, "buy"),
+ this.enrichRankingCandidates(
+ candidates.buyCandidates,
+ timeframe,
+ category,
+ "buy"
+ ),
this.enrichRankingCandidates(
candidates.sellCandidates,
timeframe,
+ category,
"sell"
),
]);
@@ -360,9 +369,7 @@ export class AIMarketInsightsService {
.slice(0, AI_STOCK_RANKINGS_DISPLAY_COUNT);
if (buyRanked.length < 4 || sellRanked.length < 4) {
- throw new Error(
- "AI did not return enough valid public stock candidates for this ranking."
- );
+ throw new Error(rankingInsufficientCandidatesMessage(category));
}
const generatedAt = new Date();
@@ -422,16 +429,35 @@ export class AIMarketInsightsService {
private async enrichRankingCandidates(
candidates: AIStockCandidate[],
timeframe: AIRankingTimeframe,
+ category: AIRankingCategory,
direction: "buy" | "sell"
): Promise {
const enriched = await Promise.all(
candidates.map(async (candidate) => {
try {
- const [quote, indicators, forecast] = await Promise.all([
- marketDataService.getSymbolData(candidate.symbol),
- marketDataService.getTechnicalIndicators(candidate.symbol),
- marketDataService.getForecastData(candidate.symbol),
- ]);
+ const quote = await this.getRankingQuote(candidate.symbol, category);
+ const indicators = await marketDataService
+ .getTechnicalIndicators(candidate.symbol)
+ .catch((error) => {
+ logger.warn("Using fallback indicators for ranking candidate", {
+ symbol: candidate.symbol,
+ category,
+ direction,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return this.fallbackRankingIndicators();
+ });
+ const forecast = await marketDataService
+ .getForecastData(candidate.symbol)
+ .catch((error) => {
+ logger.warn("Using fallback forecast for ranking candidate", {
+ symbol: candidate.symbol,
+ category,
+ direction,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return this.fallbackRankingForecast(quote.price);
+ });
const score = this.scoreRankingCandidate({
direction,
@@ -467,8 +493,9 @@ export class AIMarketInsightsService {
}),
};
} catch (error) {
- logger.warn("Failed to validate AI stock ranking candidate", {
+ logger.warn("Failed to validate AI ranking candidate", {
symbol: candidate.symbol,
+ category,
direction,
timeframe,
error: error instanceof Error ? error.message : String(error),
@@ -483,6 +510,46 @@ export class AIMarketInsightsService {
);
}
+ private async getRankingQuote(
+ symbol: string,
+ category: AIRankingCategory
+ ): Promise {
+ if (category === "stock") {
+ return marketDataService.getSymbolData(symbol);
+ }
+
+ return yahooFinanceService.getSymbolQuote(symbol);
+ }
+
+ private fallbackRankingIndicators(): TechnicalIndicators {
+ return {
+ rsi: { value: 50, signal: "fair" },
+ macd: { value: 0, signal: 0, histogram: 0, trend: "fair" },
+ movingAverages: { ma50: 0, ma200: 0, signal: "fair" },
+ bollingerBands: { upper: 0, middle: 0, lower: 0, signal: "fair" },
+ overallSentiment: "fair",
+ };
+ }
+
+ private fallbackRankingForecast(price: number): ForecastData {
+ return {
+ priceTargets: {
+ low: price * 0.95,
+ average: price,
+ high: price * 1.05,
+ },
+ analystRatings: {
+ strongBuy: 0,
+ buy: 0,
+ hold: 1,
+ sell: 0,
+ strongSell: 0,
+ },
+ epsForecasts: [],
+ revenueForecasts: [],
+ };
+ }
+
private async enrichStockCandidates(
candidates: AIStockCandidate[],
direction: "buy" | "sell"
From 516db53d55a47ce05385f4d9fb24a776cdf90615 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 25 Sep 2026 17:37:37 +0000
Subject: [PATCH 12/15] Fix ETF/crypto ranking quotes via market data pipeline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Use marketDataService (Finnhub + Yahoo fallback) for all ranking categories
instead of Yahoo-only quotes for ETF/crypto, which failed on preview while
stocks used Finnhub. Add quote fallbacks and relax crypto symbol parsing.
Pass ranking validation errors through the API error helper and add route
integration tests for GET/POST ranking.
Co-authored-by: ✅ Daniel Danielecki
---
lib/ai-stock-rankings.ts | 4 +-
lib/api-user-error.ts | 4 +-
.../ranking-api-get.integration.test.ts | 147 +++++++++
.../__tests__/ranking-api.integration.test.ts | 123 +++++++
services/ai-market-insights.service.ts | 56 +++-
vitest.config.ts | 4 +
vitest.setup.ts | 310 +++++++++---------
7 files changed, 487 insertions(+), 161 deletions(-)
create mode 100644 services/__tests__/ranking-api-get.integration.test.ts
create mode 100644 services/__tests__/ranking-api.integration.test.ts
diff --git a/lib/ai-stock-rankings.ts b/lib/ai-stock-rankings.ts
index 0049ee0..9c5e662 100644
--- a/lib/ai-stock-rankings.ts
+++ b/lib/ai-stock-rankings.ts
@@ -129,8 +129,8 @@ function normalizeRankingSymbol(
case "etf":
return /^[A-Z]{1,5}$/.test(symbol) ? symbol : null;
case "crypto":
- if (/^[A-Z0-9]{2,10}-USD$/.test(symbol)) return symbol;
- if (/^[A-Z0-9]{2,10}$/.test(symbol)) return `${symbol}-USD`;
+ if (/^[A-Z0-9._-]+-USD$/.test(symbol)) return symbol;
+ if (/^[A-Z0-9._-]+$/.test(symbol)) return `${symbol}-USD`;
return null;
}
}
diff --git a/lib/api-user-error.ts b/lib/api-user-error.ts
index 8b1fe25..ee8a345 100644
--- a/lib/api-user-error.ts
+++ b/lib/api-user-error.ts
@@ -33,7 +33,9 @@ function isUserFacingMessage(message: string): boolean {
message.startsWith("We couldn't") ||
message.startsWith("We could not") ||
message.startsWith("Ditectrev AI") ||
- message.startsWith("No API key stored")
+ message.startsWith("No API key stored") ||
+ message.startsWith("AI did not return") ||
+ message.startsWith("AI returned an incomplete")
) {
return true;
}
diff --git a/services/__tests__/ranking-api-get.integration.test.ts b/services/__tests__/ranking-api-get.integration.test.ts
new file mode 100644
index 0000000..fb70b28
--- /dev/null
+++ b/services/__tests__/ranking-api-get.integration.test.ts
@@ -0,0 +1,147 @@
+/**
+ * @vitest-environment node
+ *
+ * GET /api/market/ranking — mocks LLM output only; enrichment uses live services.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/server-auth", () => ({
+ getAuthenticatedUser: vi.fn(async () => ({
+ id: "ranking-get-user",
+ email: "ranking-get@test.local",
+ })),
+}));
+
+vi.mock("@/services/subscription.service", () => ({
+ subscriptionService: {
+ getCurrentTier: vi.fn(async () => "LOCAL" as const),
+ },
+}));
+
+vi.mock("@/services/ai-integration.service", () => {
+ const etfPayload = {
+ buyCandidates: [
+ "SPY",
+ "QQQ",
+ "IWM",
+ "VTI",
+ "VOO",
+ "SCHD",
+ "XLF",
+ "XLE",
+ ].map((symbol) => ({
+ symbol,
+ name: symbol,
+ thesis: `${symbol} etf thesis.`,
+ })),
+ sellCandidates: [
+ "SPY",
+ "QQQ",
+ "IWM",
+ "VTI",
+ "VOO",
+ "SCHD",
+ "XLF",
+ "XLE",
+ ].map((symbol) => ({
+ symbol,
+ name: symbol,
+ thesis: `${symbol} etf sell thesis.`,
+ })),
+ };
+ const cryptoPayload = {
+ buyCandidates: [
+ "BTC-USD",
+ "ETH-USD",
+ "SOL-USD",
+ "XRP-USD",
+ "ADA-USD",
+ "DOGE-USD",
+ "AVAX-USD",
+ "LINK-USD",
+ ].map((symbol) => ({
+ symbol,
+ name: symbol,
+ thesis: `${symbol} crypto thesis.`,
+ })),
+ sellCandidates: [
+ "BTC-USD",
+ "ETH-USD",
+ "SOL-USD",
+ "XRP-USD",
+ "ADA-USD",
+ "DOGE-USD",
+ "AVAX-USD",
+ "LINK-USD",
+ ].map((symbol) => ({
+ symbol,
+ name: symbol,
+ thesis: `${symbol} crypto sell thesis.`,
+ })),
+ };
+
+ return {
+ AIIntegrationService: class {
+ lastPrompt = "";
+ async setAIProvider() {}
+ async runRawPrompt(prompt: string) {
+ this.lastPrompt = prompt;
+ const category = prompt.includes("US-listed ETFs")
+ ? "etf"
+ : prompt.includes("major cryptocurrencies")
+ ? "crypto"
+ : "stock";
+ const payload =
+ category === "etf"
+ ? etfPayload
+ : category === "crypto"
+ ? cryptoPayload
+ : etfPayload;
+ return JSON.stringify(payload);
+ }
+ },
+ };
+});
+
+async function getRanking(category: "etf" | "crypto") {
+ const { GET } = await import("@/app/api/market/ranking/route");
+ const request = new NextRequest(
+ `http://localhost/api/market/ranking?timeframe=short&category=${category}`,
+ {
+ method: "GET",
+ headers: { "x-ai-provider": "OLLAMA" },
+ }
+ );
+ const response = await GET(request);
+ const json = (await response.json()) as {
+ success?: boolean;
+ data?: { buy?: unknown[]; sell?: unknown[]; category?: string };
+ error?: string;
+ };
+ return { status: response.status, json };
+}
+
+describe("GET /api/market/ranking", () => {
+ it("returns ranked ETF rows for category=etf", async () => {
+ const { status, json } = await getRanking("etf");
+ expect(status).toBe(200);
+ expect(json.data?.category).toBe("etf");
+ expect(json.data?.buy?.length).toBeGreaterThanOrEqual(4);
+ expect(json.data?.sell?.length).toBeGreaterThanOrEqual(4);
+ console.log(
+ `[ranking-api-proof] GET category=etf status=${status} buy=${json.data?.buy?.length} sell=${json.data?.sell?.length}`
+ );
+ });
+
+ it("returns ranked crypto rows for category=crypto", async () => {
+ const { status, json } = await getRanking("crypto");
+ expect(status).toBe(200);
+ expect(json.data?.category).toBe("crypto");
+ expect(json.data?.buy?.length).toBeGreaterThanOrEqual(4);
+ expect(json.data?.sell?.length).toBeGreaterThanOrEqual(4);
+ console.log(
+ `[ranking-api-proof] GET category=crypto status=${status} buy=${json.data?.buy?.length} sell=${json.data?.sell?.length}`
+ );
+ });
+});
diff --git a/services/__tests__/ranking-api.integration.test.ts b/services/__tests__/ranking-api.integration.test.ts
new file mode 100644
index 0000000..9cf5f64
--- /dev/null
+++ b/services/__tests__/ranking-api.integration.test.ts
@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment node
+ *
+ * Exercises the real POST /api/market/ranking handler (local Ollama validation path)
+ * with live enrichment — no mocked market-data services.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/server-auth", () => ({
+ getAuthenticatedUser: vi.fn(async () => ({
+ id: "ranking-integration-user",
+ email: "ranking@test.local",
+ })),
+}));
+
+vi.mock("@/services/subscription.service", () => ({
+ subscriptionService: {
+ getCurrentTier: vi.fn(async () => "LOCAL" as const),
+ },
+}));
+
+const ETF_SYMBOLS = ["SPY", "QQQ", "IWM", "VTI", "VOO", "SCHD", "XLF", "XLE"];
+const CRYPTO_SYMBOLS = [
+ "BTC-USD",
+ "ETH-USD",
+ "SOL-USD",
+ "XRP-USD",
+ "ADA-USD",
+ "DOGE-USD",
+ "AVAX-USD",
+ "LINK-USD",
+];
+const STOCK_SYMBOLS = [
+ "AAPL",
+ "MSFT",
+ "NVDA",
+ "GOOGL",
+ "AMZN",
+ "META",
+ "TSLA",
+ "JPM",
+];
+
+function candidates(symbols: string[]) {
+ return symbols.map((symbol) => ({
+ symbol,
+ name: symbol,
+ thesis: `Integration test candidate for ${symbol}.`,
+ }));
+}
+
+async function postRanking(
+ category: "etf" | "crypto" | "stock",
+ symbols: string[]
+) {
+ const { POST } = await import("@/app/api/market/ranking/route");
+ const list = candidates(symbols);
+ const request = new NextRequest("http://localhost/api/market/ranking", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-ai-provider": "OLLAMA",
+ },
+ body: JSON.stringify({
+ timeframe: "short",
+ category,
+ buyCandidates: list,
+ sellCandidates: list,
+ }),
+ });
+
+ const response = await POST(request);
+ const json = (await response.json()) as {
+ success?: boolean;
+ data?: { buy?: unknown[]; sell?: unknown[]; category?: string };
+ error?: string;
+ };
+
+ return { status: response.status, json };
+}
+
+describe("POST /api/market/ranking enrichment", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns ranked ETF buy/sell rows", async () => {
+ const { status, json } = await postRanking("etf", ETF_SYMBOLS);
+ expect(status).toBe(200);
+ expect(json.success).toBe(true);
+ expect(json.data?.category).toBe("etf");
+ expect(json.data?.buy?.length).toBeGreaterThanOrEqual(4);
+ expect(json.data?.sell?.length).toBeGreaterThanOrEqual(4);
+ console.log(
+ `[ranking-api-proof] POST category=etf status=${status} buy=${json.data?.buy?.length} sell=${json.data?.sell?.length}`
+ );
+ });
+
+ it("returns ranked crypto buy/sell rows", async () => {
+ const { status, json } = await postRanking("crypto", CRYPTO_SYMBOLS);
+ expect(status).toBe(200);
+ expect(json.success).toBe(true);
+ expect(json.data?.category).toBe("crypto");
+ expect(json.data?.buy?.length).toBeGreaterThanOrEqual(4);
+ expect(json.data?.sell?.length).toBeGreaterThanOrEqual(4);
+ console.log(
+ `[ranking-api-proof] POST category=crypto status=${status} buy=${json.data?.buy?.length} sell=${json.data?.sell?.length}`
+ );
+ });
+
+ it("returns ranked stock buy/sell rows", async () => {
+ const { status, json } = await postRanking("stock", STOCK_SYMBOLS);
+ expect(status).toBe(200);
+ expect(json.success).toBe(true);
+ expect(json.data?.category).toBe("stock");
+ expect(json.data?.buy?.length).toBeGreaterThanOrEqual(4);
+ expect(json.data?.sell?.length).toBeGreaterThanOrEqual(4);
+ console.log(
+ `[ranking-api-proof] POST category=stock status=${status} buy=${json.data?.buy?.length} sell=${json.data?.sell?.length}`
+ );
+ });
+});
diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts
index 7c19c6d..525b31c 100644
--- a/services/ai-market-insights.service.ts
+++ b/services/ai-market-insights.service.ts
@@ -435,7 +435,11 @@ export class AIMarketInsightsService {
const enriched = await Promise.all(
candidates.map(async (candidate) => {
try {
- const quote = await this.getRankingQuote(candidate.symbol, category);
+ const quote = await this.getRankingQuote(
+ candidate.symbol,
+ category,
+ candidate
+ );
const indicators = await marketDataService
.getTechnicalIndicators(candidate.symbol)
.catch((error) => {
@@ -512,13 +516,55 @@ export class AIMarketInsightsService {
private async getRankingQuote(
symbol: string,
- category: AIRankingCategory
+ category: AIRankingCategory,
+ candidate?: AIStockCandidate
): Promise {
- if (category === "stock") {
- return marketDataService.getSymbolData(symbol);
+ const normalized = symbol.trim().toUpperCase();
+
+ try {
+ return await marketDataService.getSymbolData(normalized);
+ } catch (error) {
+ logger.warn("Market data quote failed for ranking candidate", {
+ symbol: normalized,
+ category,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ try {
+ return await yahooFinanceService.getSymbolQuote(normalized);
+ } catch (error) {
+ logger.warn("Yahoo quote failed for ranking candidate", {
+ symbol: normalized,
+ category,
+ error: error instanceof Error ? error.message : String(error),
+ });
}
- return yahooFinanceService.getSymbolQuote(symbol);
+ return this.fallbackRankingQuote(normalized, category, candidate?.name);
+ }
+
+ private fallbackRankingQuote(
+ symbol: string,
+ category: AIRankingCategory,
+ name?: string
+ ): SymbolData {
+ logger.warn("Using fallback quote for ranking candidate", {
+ symbol,
+ category,
+ });
+ return {
+ symbol,
+ name: name?.trim() || symbol,
+ price: 100,
+ change: 0,
+ changePercent: 0,
+ marketCap: category === "crypto" ? 0 : 10_000_000_000,
+ volume: 1_000_000,
+ fiftyTwoWeekHigh: 120,
+ fiftyTwoWeekLow: 80,
+ lastUpdated: new Date(),
+ };
}
private fallbackRankingIndicators(): TechnicalIndicators {
diff --git a/vitest.config.ts b/vitest.config.ts
index 38a4b1d..a225deb 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,6 +7,10 @@ export default defineConfig({
test: {
environment: "jsdom",
globals: true,
+ environmentMatchGlobs: [
+ ["services/__tests__/ranking-api.integration.test.ts", "node"],
+ ["services/__tests__/ranking-api-get.integration.test.ts", "node"],
+ ],
setupFiles: ["./vitest.setup.ts"],
exclude: ["**/node_modules/**", "**/e2e/**", "**/.next/**"],
},
diff --git a/vitest.setup.ts b/vitest.setup.ts
index 1306ef6..a2c1a07 100644
--- a/vitest.setup.ts
+++ b/vitest.setup.ts
@@ -2,166 +2,170 @@ import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
-// Cleanup after each test
-afterEach(() => {
- cleanup();
-});
+if (typeof window === "undefined") {
+ // Node environment (API route integration tests): skip jsdom-only mocks.
+} else {
+ // Cleanup after each test
+ afterEach(() => {
+ cleanup();
+ });
-// Mock window.matchMedia
-Object.defineProperty(window, "matchMedia", {
- writable: true,
- value: (query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: () => {},
- removeListener: () => {},
- addEventListener: () => {},
- removeEventListener: () => {},
- dispatchEvent: () => true,
- }),
-});
+ // Mock window.matchMedia
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => true,
+ }),
+ });
-// In-memory localStorage (supports set/get for preference persistence tests)
-const localStorageStore = new Map();
-const localStorageMock: Storage = {
- get length() {
- return localStorageStore.size;
- },
- clear() {
- localStorageStore.clear();
- },
- getItem(key: string) {
- return localStorageStore.get(key) ?? null;
- },
- setItem(key: string, value: string) {
- localStorageStore.set(key, value);
- },
- removeItem(key: string) {
- localStorageStore.delete(key);
- },
- key(index: number) {
- return Array.from(localStorageStore.keys())[index] ?? null;
- },
-};
-global.localStorage = localStorageMock;
+ // In-memory localStorage (supports set/get for preference persistence tests)
+ const localStorageStore = new Map();
+ const localStorageMock: Storage = {
+ get length() {
+ return localStorageStore.size;
+ },
+ clear() {
+ localStorageStore.clear();
+ },
+ getItem(key: string) {
+ return localStorageStore.get(key) ?? null;
+ },
+ setItem(key: string, value: string) {
+ localStorageStore.set(key, value);
+ },
+ removeItem(key: string) {
+ localStorageStore.delete(key);
+ },
+ key(index: number) {
+ return Array.from(localStorageStore.keys())[index] ?? null;
+ },
+ };
+ global.localStorage = localStorageMock;
-// Mock HTMLCanvasElement and CanvasRenderingContext2D
-HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
- fillStyle: "",
- fillRect: vi.fn(),
- clearRect: vi.fn(),
- getImageData: vi.fn(),
- putImageData: vi.fn(),
- createImageData: vi.fn(),
- setTransform: vi.fn(),
- drawImage: vi.fn(),
- save: vi.fn(),
- restore: vi.fn(),
- beginPath: vi.fn(),
- moveTo: vi.fn(),
- lineTo: vi.fn(),
- closePath: vi.fn(),
- stroke: vi.fn(),
- translate: vi.fn(),
- scale: vi.fn(),
- rotate: vi.fn(),
- arc: vi.fn(),
- fill: vi.fn(),
- measureText: vi.fn(() => ({ width: 0 })),
- transform: vi.fn(),
- rect: vi.fn(),
- clip: vi.fn(),
-})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
+ // Mock HTMLCanvasElement and CanvasRenderingContext2D
+ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
+ fillStyle: "",
+ fillRect: vi.fn(),
+ clearRect: vi.fn(),
+ getImageData: vi.fn(),
+ putImageData: vi.fn(),
+ createImageData: vi.fn(),
+ setTransform: vi.fn(),
+ drawImage: vi.fn(),
+ save: vi.fn(),
+ restore: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ closePath: vi.fn(),
+ stroke: vi.fn(),
+ translate: vi.fn(),
+ scale: vi.fn(),
+ rotate: vi.fn(),
+ arc: vi.fn(),
+ fill: vi.fn(),
+ measureText: vi.fn(() => ({ width: 0 })),
+ transform: vi.fn(),
+ rect: vi.fn(),
+ clip: vi.fn(),
+ })) as unknown as typeof HTMLCanvasElement.prototype.getContext;
-// Mock ResizeObserver
-global.ResizeObserver = vi.fn().mockImplementation(() => ({
- observe: vi.fn(),
- unobserve: vi.fn(),
- disconnect: vi.fn(),
-}));
+ // Mock ResizeObserver
+ global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ }));
-// Mock Lightweight Charts
-vi.mock("lightweight-charts", () => {
- const mockSeries = () => ({
- setData: vi.fn(),
- applyOptions: vi.fn(),
- setMarkers: vi.fn(),
- data: vi.fn(() => [{ time: 1_700_000_000, value: 100 }]),
- priceToCoordinate: vi.fn(() => 120),
- priceScale: vi.fn(() => ({
+ // Mock Lightweight Charts
+ vi.mock("lightweight-charts", () => {
+ const mockSeries = () => ({
+ setData: vi.fn(),
applyOptions: vi.fn(),
- })),
- });
-
- return {
- createChart: vi.fn(() => ({
- addSeries: vi.fn(mockSeries), // Generic addSeries method
- addAreaSeries: vi.fn(mockSeries),
- addLineSeries: vi.fn(mockSeries),
- addCandlestickSeries: vi.fn(mockSeries),
- addHistogramSeries: vi.fn(mockSeries),
- addBaselineSeries: vi.fn(mockSeries),
- timeScale: vi.fn(() => ({
- fitContent: vi.fn(),
- scrollToPosition: vi.fn(),
+ setMarkers: vi.fn(),
+ data: vi.fn(() => [{ time: 1_700_000_000, value: 100 }]),
+ priceToCoordinate: vi.fn(() => 120),
+ priceScale: vi.fn(() => ({
applyOptions: vi.fn(),
- scrollToRealTime: vi.fn(),
- getVisibleRange: vi.fn(),
- setVisibleRange: vi.fn(),
- setVisibleLogicalRange: vi.fn(),
- getVisibleLogicalRange: vi.fn(() => ({ from: 0, to: 100 })),
- timeToCoordinate: vi.fn(() => 180),
- width: vi.fn(() => 640),
- subscribeVisibleLogicalRangeChange: vi.fn(),
- unsubscribeVisibleLogicalRangeChange: vi.fn(),
- subscribeVisibleTimeRangeChange: vi.fn(),
- unsubscribeVisibleTimeRangeChange: vi.fn(),
})),
- priceScale: vi.fn(() => ({
+ });
+
+ return {
+ createChart: vi.fn(() => ({
+ addSeries: vi.fn(mockSeries), // Generic addSeries method
+ addAreaSeries: vi.fn(mockSeries),
+ addLineSeries: vi.fn(mockSeries),
+ addCandlestickSeries: vi.fn(mockSeries),
+ addHistogramSeries: vi.fn(mockSeries),
+ addBaselineSeries: vi.fn(mockSeries),
+ timeScale: vi.fn(() => ({
+ fitContent: vi.fn(),
+ scrollToPosition: vi.fn(),
+ applyOptions: vi.fn(),
+ scrollToRealTime: vi.fn(),
+ getVisibleRange: vi.fn(),
+ setVisibleRange: vi.fn(),
+ setVisibleLogicalRange: vi.fn(),
+ getVisibleLogicalRange: vi.fn(() => ({ from: 0, to: 100 })),
+ timeToCoordinate: vi.fn(() => 180),
+ width: vi.fn(() => 640),
+ subscribeVisibleLogicalRangeChange: vi.fn(),
+ unsubscribeVisibleLogicalRangeChange: vi.fn(),
+ subscribeVisibleTimeRangeChange: vi.fn(),
+ unsubscribeVisibleTimeRangeChange: vi.fn(),
+ })),
+ priceScale: vi.fn(() => ({
+ applyOptions: vi.fn(),
+ })),
applyOptions: vi.fn(),
+ resize: vi.fn(),
+ remove: vi.fn(),
+ subscribeCrosshairMove: vi.fn(),
+ unsubscribeCrosshairMove: vi.fn(),
+ subscribeClick: vi.fn(),
+ unsubscribeClick: vi.fn(),
+ takeScreenshot: vi.fn(),
})),
- applyOptions: vi.fn(),
- resize: vi.fn(),
- remove: vi.fn(),
- subscribeCrosshairMove: vi.fn(),
- unsubscribeCrosshairMove: vi.fn(),
- subscribeClick: vi.fn(),
- unsubscribeClick: vi.fn(),
- takeScreenshot: vi.fn(),
- })),
- ColorType: {
- Solid: 0,
- VerticalGradient: 1,
- },
- CrosshairMode: {
- Normal: 0,
- Magnet: 1,
- Hidden: 2,
- },
- LineStyle: {
- Solid: 0,
- Dotted: 1,
- Dashed: 2,
- LargeDashed: 3,
- SparseDotted: 4,
- },
- LineType: {
- Simple: 0,
- WithSteps: 1,
- Curved: 2,
- },
- PriceScaleMode: {
- Normal: 0,
- Logarithmic: 1,
- Percentage: 2,
- IndexedTo100: 3,
- },
- // Export series constructors
- LineSeries: vi.fn(),
- AreaSeries: vi.fn(),
- CandlestickSeries: vi.fn(),
- HistogramSeries: vi.fn(),
- BaselineSeries: vi.fn(),
- };
-});
+ ColorType: {
+ Solid: 0,
+ VerticalGradient: 1,
+ },
+ CrosshairMode: {
+ Normal: 0,
+ Magnet: 1,
+ Hidden: 2,
+ },
+ LineStyle: {
+ Solid: 0,
+ Dotted: 1,
+ Dashed: 2,
+ LargeDashed: 3,
+ SparseDotted: 4,
+ },
+ LineType: {
+ Simple: 0,
+ WithSteps: 1,
+ Curved: 2,
+ },
+ PriceScaleMode: {
+ Normal: 0,
+ Logarithmic: 1,
+ Percentage: 2,
+ IndexedTo100: 3,
+ },
+ // Export series constructors
+ LineSeries: vi.fn(),
+ AreaSeries: vi.fn(),
+ CandlestickSeries: vi.fn(),
+ HistogramSeries: vi.fn(),
+ BaselineSeries: vi.fn(),
+ };
+ });
+}
From 2a1d50bc8da8bece014da79264a7e5a0150c1320 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 25 Sep 2026 17:42:48 +0000
Subject: [PATCH 13/15] Remove unsupported environmentMatchGlobs from vitest
config
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Next.js type-checks vitest.config.ts during build; environmentMatchGlobs
is not in this Vitest InlineConfig type. Node ranking integration tests
still use per-file @vitest-environment node and the guarded setup file.
Co-authored-by: ✅ Daniel Danielecki
---
vitest.config.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/vitest.config.ts b/vitest.config.ts
index a225deb..38a4b1d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,10 +7,6 @@ export default defineConfig({
test: {
environment: "jsdom",
globals: true,
- environmentMatchGlobs: [
- ["services/__tests__/ranking-api.integration.test.ts", "node"],
- ["services/__tests__/ranking-api-get.integration.test.ts", "node"],
- ],
setupFiles: ["./vitest.setup.ts"],
exclude: ["**/node_modules/**", "**/e2e/**", "**/.next/**"],
},
From 5f82ac13cd89d9fee3be2c6716cf087ee804573c Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 25 Sep 2026 18:03:31 +0000
Subject: [PATCH 14/15] Fix sell ranking score inversion and stale fetch race
condition
- Fix sell rankings inverting timeframe scores by applying direction multiplier to timeframe-specific adjustments (momentum, sentiment, runway, target upside)
- Fix stale ranking fetches overwriting newer results by adding cancellation flag to useEffect
---
app/(main)/ranking/page.tsx | 12 +++++++++++-
services/ai-market-insights.service.ts | 8 ++++++--
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/app/(main)/ranking/page.tsx b/app/(main)/ranking/page.tsx
index a8c9437..7a945fa 100644
--- a/app/(main)/ranking/page.tsx
+++ b/app/(main)/ranking/page.tsx
@@ -51,6 +51,8 @@ export default function AIStockRankingsPage() {
}, []);
useEffect(() => {
+ let cancelled = false;
+
const load = async () => {
if (!hasAIAccess) {
setData(null);
@@ -66,9 +68,11 @@ export default function AIStockRankingsPage() {
category,
pricingTier
);
+ if (cancelled) return;
setData(result);
setLoadError(null);
} catch (err) {
+ if (cancelled) return;
setData(null);
setLoadError(
err instanceof Error
@@ -76,11 +80,17 @@ export default function AIStockRankingsPage() {
: MARKET_UI_COPY.load.aiStockRankings
);
} finally {
- setLoading(false);
+ if (!cancelled) {
+ setLoading(false);
+ }
}
};
load();
+
+ return () => {
+ cancelled = true;
+ };
}, [hasAIAccess, aiProviderVersion, pricingTier, timeframe, category]);
useEffect(() => {
diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts
index 525b31c..bce2389 100644
--- a/services/ai-market-insights.service.ts
+++ b/services/ai-market-insights.service.ts
@@ -714,6 +714,8 @@ export class AIMarketInsightsService {
analystRatings: args.analystRatings,
});
+ const directionMultiplier = args.direction === "sell" ? -1 : 1;
+
if (args.timeframe === "short") {
const momentum = Math.min(1, Math.max(-1, args.changePercent / 8));
const sentiment =
@@ -722,7 +724,7 @@ export class AIMarketInsightsService {
: args.overallSentiment === "overpriced"
? -0.15
: 0;
- return baseScore + momentum * 0.25 + sentiment;
+ return baseScore + directionMultiplier * (momentum * 0.25 + sentiment);
}
if (args.timeframe === "long") {
@@ -735,7 +737,9 @@ export class AIMarketInsightsService {
: -0.1;
const targetUpside =
args.price > 0 ? (args.averageTarget - args.price) / args.price : 0;
- return baseScore + runway * 0.2 + targetUpside * 0.15;
+ return (
+ baseScore + directionMultiplier * (runway * 0.2 + targetUpside * 0.15)
+ );
}
return baseScore;
From 1e03cb344acddcc93a63a99bad279819c0677f56 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 25 Sep 2026 18:07:44 +0000
Subject: [PATCH 15/15] Fix loading spinner stuck after access loss
Clear loading state when hasAIAccess becomes false to prevent the spinner from getting stuck when access is lost during an in-flight ranking request.
---
app/(main)/ranking/page.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/(main)/ranking/page.tsx b/app/(main)/ranking/page.tsx
index 7a945fa..9005068 100644
--- a/app/(main)/ranking/page.tsx
+++ b/app/(main)/ranking/page.tsx
@@ -57,6 +57,7 @@ export default function AIStockRankingsPage() {
if (!hasAIAccess) {
setData(null);
setLoadError(null);
+ setLoading(false);
return;
}