(cd "$(git rev-parse --show-toplevel)" && git apply --3way <<'EOF'
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..142bd580abbb48db484bfd861ba81eb3c1008b3f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+DATABASE_URL="postgresql://postgres:postgres@localhost:5432/marketbrain"
+MARKET_DATA_PROVIDER="zerodha"
+
+ANGEL_ONE_BASE_URL="https://apiconnect.angelone.in"
+ANGEL_ONE_API_KEY=""
+ANGEL_ONE_CLIENT_CODE=""
+ANGEL_ONE_ACCESS_TOKEN=""
+ANGEL_ONE_FEED_TOKEN=""
+
+ZERODHA_BASE_URL="https://api.kite.trade"
+ZERODHA_API_KEY=""
+ZERODHA_ACCESS_TOKEN=""
+
+FYERS_BASE_URL="https://api-t1.fyers.in"
+FYERS_CLIENT_ID=""
+FYERS_ACCESS_TOKEN=""
+
+GEMINI_API_KEY=""
+NEWS_FEED_URL=""
+FII_DII_SOURCE_URL="https://www.nseindia.com/api/fiidiiTradeReact"
+CRON_SECRET=""
+WS_PORT="8081"
+MARKET_REFRESH_INTERVAL_MS="3000"
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..957cd1545e307e26099a37413ec28183204daf88
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": ["next/core-web-vitals"]
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..1507749e2be68869664aafbd7308edef0e525d11
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules
+.next
+.env
+.env.local
+.DS_Store
+coverage
+prisma/dev.db
+
+tsconfig.tsbuildinfo
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..8affb9773fcba1f18c56611822bd30b4cfc67ae9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+# MARKET BRAIN V2
+
+Production-ready Next.js dark institutional dashboard for Indian stock market traders and investors.
+
+V2 introduces a real service architecture:
+
+- Angel One SmartAPI provider
+- Zerodha Kite Connect provider
+- Fyers provider
+- Live NIFTY, BANKNIFTY, FINNIFTY, and India VIX services
+- FII/DII ingestion service
+- OI, PCR, IV, and Max Pain calculation engine
+- Gemini-powered news sentiment service
+- Composite AI signal engine
+- PostgreSQL storage layer
+- Cron refresh endpoints/jobs
+- WebSocket server for live updates
+
+## Setup
+
+```bash
+npm install
+cp .env.example .env
+npm run db:generate
+npm run dev
+```
+
+Set provider credentials in `.env`; the app displays configuration states until real provider connections return live market values.
+
+## Runtime commands
+
+```bash
+npm run dev
+npm run ws
+npm run typecheck
+npm run build
+```
+
+## API routes
+
+- `GET /api/health`
+- `GET /api/market`
+- `GET /api/stocks?q=RELIANCE`
+- `GET /api/fii-dii`
+- `GET /api/options?symbol=NSE:NIFTY50-INDEX`
+- `GET /api/news`
+- `GET /api/signals?symbol=RELIANCE`
+- `POST /api/cron/refresh?job=indices`
diff --git a/app/api/broker/oauth/route.ts b/app/api/broker/oauth/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b865ab1487120016912c75797559b3baaa5e395c
--- /dev/null
+++ b/app/api/broker/oauth/route.ts
@@ -0,0 +1,21 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { providerConfig } from "@/lib/config";
+
+const schema = z.object({ broker: z.enum(["angel-one", "zerodha", "fyers"]), redirectUri: z.string().url() });
+
+export async function POST(request: NextRequest) {
+ const body = schema.parse(await request.json());
+ if (body.broker === "zerodha") {
+ return NextResponse.json({ broker: body.broker, authUrl: `https://kite.zerodha.com/connect/login?v=3&api_key=${providerConfig.zerodha.apiKey ?? ""}`, status: "oauth-ready" });
+ }
+ if (body.broker === "fyers") {
+ const url = new URL("https://api-t1.fyers.in/api/v3/generate-authcode");
+ url.searchParams.set("client_id", providerConfig.fyers.clientId ?? "");
+ url.searchParams.set("redirect_uri", body.redirectUri);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("state", "marketbrain");
+ return NextResponse.json({ broker: body.broker, authUrl: url.toString(), status: "oauth-ready" });
+ }
+ return NextResponse.json({ broker: body.broker, status: "token-login-required", message: "Angel One SmartAPI uses JWT/feed-token generation after client-code authentication; configure server-side SmartAPI credentials for live access." });
+}
diff --git a/app/api/cron/refresh/route.ts b/app/api/cron/refresh/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7575a6b34c5a0327376ad4036d0976c7ef083b8
--- /dev/null
+++ b/app/api/cron/refresh/route.ts
@@ -0,0 +1,17 @@
+import { NextRequest, NextResponse } from "next/server";
+import { appConfig } from "@/lib/config";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { runCronJob, type CronJobName } from "@/lib/cron/jobs";
+
+export async function POST(request: NextRequest) {
+ try {
+ if (appConfig.cronSecret && request.headers.get("x-cron-secret") !== appConfig.cronSecret) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const job = (request.nextUrl.searchParams.get("job") ?? "indices") as CronJobName;
+ return NextResponse.json(await runCronJob(job, Object.fromEntries(request.nextUrl.searchParams.entries())));
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/api/fii-dii/route.ts b/app/api/fii-dii/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6830770aae763d78c5931a879581931ebbc9755d
--- /dev/null
+++ b/app/api/fii-dii/route.ts
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { fiiDiiService } from "@/lib/services/fii-dii-service";
+
+export async function GET() {
+ try {
+ return NextResponse.json(await fiiDiiService.ingestLatest());
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..96c494d2ebe3de89f6993f1dd9a8f1caf1da6e3e
--- /dev/null
+++ b/app/api/health/route.ts
@@ -0,0 +1,6 @@
+import { NextResponse } from "next/server";
+import { marketService } from "@/lib/services/market-service";
+
+export async function GET() {
+ return NextResponse.json({ providers: marketService.getProviderHealth(), at: new Date().toISOString() });
+}
diff --git a/app/api/market/route.ts b/app/api/market/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8d18029e6b126816303aef702df938a574de571d
--- /dev/null
+++ b/app/api/market/route.ts
@@ -0,0 +1,16 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { appConfig } from "@/lib/config";
+import { marketService } from "@/lib/services/market-service";
+import type { BrokerProviderName } from "@/lib/types";
+
+export async function GET(request: NextRequest) {
+ try {
+ const provider = request.nextUrl.searchParams.get("provider") as BrokerProviderName | null;
+ const indices = await marketService.getLiveIndices(provider ?? undefined);
+ return NextResponse.json({ indices, providerHealth: marketService.getProviderHealth(), websocket: `ws://localhost:${appConfig.websocketPort}` });
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/api/news/route.ts b/app/api/news/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1e50aebc4a44c1ccdb951041d6673fe803c5a650
--- /dev/null
+++ b/app/api/news/route.ts
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { newsSentimentService } from "@/lib/services/news-sentiment-service";
+
+export async function GET() {
+ try {
+ return NextResponse.json({ sentiment: await newsSentimentService.fetchAndAnalyze(), scoreRange: [-100, 100] });
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/api/options/route.ts b/app/api/options/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae4c4ccb098416beffd72dfff51073945a8cc48c
--- /dev/null
+++ b/app/api/options/route.ts
@@ -0,0 +1,14 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { oiPcrService } from "@/lib/services/oi-pcr-engine";
+
+export async function GET(request: NextRequest) {
+ try {
+ const symbol = request.nextUrl.searchParams.get("symbol") ?? "NSE:NIFTY50-INDEX";
+ const expiry = request.nextUrl.searchParams.get("expiry") ?? "";
+ return NextResponse.json(await oiPcrService.getOptionIntelligence(symbol, expiry));
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/api/signals/route.ts b/app/api/signals/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bf6ca5da37a48502c45f690d1af219a203d75822
--- /dev/null
+++ b/app/api/signals/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { aiSignalEngine } from "@/lib/services/ai-signal-engine";
+import { fiiDiiService } from "@/lib/services/fii-dii-service";
+import { marketService } from "@/lib/services/market-service";
+import { newsSentimentService } from "@/lib/services/news-sentiment-service";
+import { oiPcrService } from "@/lib/services/oi-pcr-engine";
+import { technicalService } from "@/lib/services/technical-service";
+
+export async function GET(request: NextRequest) {
+ try {
+ const symbol = request.nextUrl.searchParams.get("symbol") ?? "RELIANCE";
+ const quote = await marketService.getStockQuote(symbol);
+ const [institutional, options, sentiment, technical] = await Promise.allSettled([
+ fiiDiiService.ingestLatest(),
+ oiPcrService.getOptionIntelligence(`NSE:${symbol}-EQ`),
+ newsSentimentService.fetchAndAnalyze(),
+ technicalService.calculateFromStoredCandles(symbol)
+ ]);
+ const signal = aiSignalEngine.generate({
+ quote,
+ institutional: institutional.status === "fulfilled" ? institutional.value : undefined,
+ options: options.status === "fulfilled" ? options.value : undefined,
+ sentiment: sentiment.status === "fulfilled" ? sentiment.value : undefined,
+ technical: technical.status === "fulfilled" ? technical.value : undefined
+ });
+ return NextResponse.json({ signal });
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/api/stocks/route.ts b/app/api/stocks/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..472ec003ff9d2911f15e05bc19a3dcd4128cbce7
--- /dev/null
+++ b/app/api/stocks/route.ts
@@ -0,0 +1,15 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/errors/service-error";
+import { marketService } from "@/lib/services/market-service";
+import type { BrokerProviderName } from "@/lib/types";
+
+export async function GET(request: NextRequest) {
+ try {
+ const q = request.nextUrl.searchParams.get("q") ?? "";
+ const provider = request.nextUrl.searchParams.get("provider") as BrokerProviderName | null;
+ return NextResponse.json(await marketService.searchStocks(q, provider ?? undefined));
+ } catch (error) {
+ const response = toErrorResponse(error);
+ return NextResponse.json(response, { status: response.status });
+ }
+}
diff --git a/app/broker-connect/page.tsx b/app/broker-connect/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..685565f1f5a1720588a9b3c390d4ccccb7a5903f
--- /dev/null
+++ b/app/broker-connect/page.tsx
@@ -0,0 +1,7 @@
+import { PlugZap } from "lucide-react";
+import { Card, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { marketService } from "@/lib/services/market-service";
+export const metadata = { title: "Broker Connect" };
+const brokers = [{ id: "angel-one", label: "Angel One SmartAPI" }, { id: "zerodha", label: "Zerodha Kite Connect" }, { id: "fyers", label: "Fyers API" }];
+export default function BrokerPage() { const health = marketService.getProviderHealth(); return
Broker Connect OAuth/token-ready broker connection UI for live market data, holdings, positions, margins, and order bridge integrations.
{brokers.map(b => { const provider = health.find((item)=>item.provider===b.id); return
{b.label} Secure credential flow, token refresh, profile scopes, and read-only portfolio sync.
Base URL: {provider?.baseUrl}
{provider?.configured ? "Reconnect" : "Configure"} {b.label} ; })}
; }
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..acbbdfcc9628a5e4f273c23ebcb255571b3bf9f9
--- /dev/null
+++ b/app/dashboard/page.tsx
@@ -0,0 +1,12 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { MetricCard } from "@/components/market/metric-card";
+import { SignalTable } from "@/components/market/signal-table";
+import { BreadthHeatmap, PriceChart } from "@/components/market/charts";
+import { marketService } from "@/lib/services/market-service";
+
+export const metadata = { title: "Dashboard" };
+export default async function DashboardPage() {
+ const result = await marketService.getLiveIndices().then((indices) => ({ indices, error: null })).catch((error) => ({ indices: [], error: error instanceof Error ? error.message : String(error) }));
+ return Live Command Dashboard Real index cards, provider health, service-backed search, heatmaps, and AI signal tape.
{result.error &&
Live market feed unavailable {result.error}
}
{result.indices.map((i) => )}
Stock Search Search is served by /api/stocks?q= and requires Angel One/Zerodha/Fyers credentials.
Intraday Price + Volume Sector Breadth Heatmap ;
+}
diff --git a/app/fii-dii/page.tsx b/app/fii-dii/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..45701757cfb81c0f3c38f034725697ddcbac5f80
--- /dev/null
+++ b/app/fii-dii/page.tsx
@@ -0,0 +1,6 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { MetricCard } from "@/components/market/metric-card";
+import { fiiDiiService } from "@/lib/services/fii-dii-service";
+import { formatCr } from "@/lib/utils";
+export const metadata = { title: "FII DII Dashboard" };
+export default async function FiiDiiPage() { const result = await fiiDiiService.ingestLatest().then((flow)=>({flow,error:null})).catch((error)=>({flow:null,error:error instanceof Error ? error.message : String(error)})); return FII / DII Flow Dashboard {result.error &&
FII/DII ingestion unavailable {result.error}
}{result.flow && <>
Net Institutional Read FII Net
{formatCr(result.flow.fiiNet)}
DII Net
{formatCr(result.flow.diiNet)}
>}
; }
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..7334e2fd3cb93bf235e62d15b407a5e9e0996ab5
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,42 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 222 47% 5%;
+ --foreground: 210 40% 98%;
+ --card: 222 44% 8%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222 44% 8%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 160 84% 39%;
+ --primary-foreground: 166 100% 8%;
+ --secondary: 217 33% 17%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217 33% 14%;
+ --muted-foreground: 215 20% 65%;
+ --accent: 217 91% 60%;
+ --accent-foreground: 210 40% 98%;
+ --destructive: 0 84% 60%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 217 33% 18%;
+ --input: 217 33% 18%;
+ --ring: 160 84% 39%;
+ --positive: 150 72% 45%;
+ --negative: 0 72% 56%;
+ --warning: 42 92% 55%;
+ --radius: 0.75rem;
+ }
+}
+
+@layer base {
+ * { @apply border-border; }
+ body { @apply bg-background text-foreground antialiased; }
+}
+
+.terminal-glow { box-shadow: 0 0 32px rgba(16,185,129,.12), inset 0 1px 0 rgba(255,255,255,.04); }
+.data-grid { background-size: 32px 32px; }
+.number-ticker { font-variant-numeric: tabular-nums; }
+::-webkit-scrollbar { height: 8px; width: 8px; }
+::-webkit-scrollbar-thumb { background: hsl(var(--muted)); border-radius: 999px; }
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..837fed6a5a8cfa69229f61e63a6cfde5c99ca269
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,32 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+import { AppShell } from "@/components/market/app-shell";
+
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
+
+export const metadata: Metadata = {
+ metadataBase: new URL("https://marketbrain.co.in"),
+ title: {
+ default: "MARKET BRAIN — AI Indian Stock Market Intelligence",
+ template: "%s | MARKET BRAIN"
+ },
+ description: "AI-powered institutional intelligence for Indian traders and investors with FII/DII, OI, PCR, technicals, sentiment, and signals.",
+ openGraph: {
+ title: "MARKET BRAIN",
+ description: "Institutional AI stock market intelligence for India.",
+ url: "https://marketbrain.co.in",
+ siteName: "MARKET BRAIN",
+ type: "website"
+ }
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/news-sentiment/page.tsx b/app/news-sentiment/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..898d511a9eed20056945aec5dd56c1ee8b25e23b
--- /dev/null
+++ b/app/news-sentiment/page.tsx
@@ -0,0 +1,5 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { newsSentimentService } from "@/lib/services/news-sentiment-service";
+export const metadata = { title: "News Sentiment" };
+export default async function NewsPage() { const result = await newsSentimentService.fetchAndAnalyze().then((items)=>({items,error:null})).catch((error)=>({items:[],error:error instanceof Error ? error.message : String(error)})); return Gemini News Sentiment Engine Analyzes earnings, corporate, economic, RBI, and SEBI news with -100 to +100 scoring.
{result.error &&
News service configuration required {result.error}
}
{result.items.map((n) =>
{n.category} {n.title}
{n.rationale}
= 30 ? "border-positive/30 bg-positive/10 text-positive" : n.score <= -30 ? "border-negative/30 bg-negative/10 text-negative" : "border-warning/30 bg-warning/10 text-warning"}>{n.sentiment} {n.score}
)}
; }
diff --git a/app/options-intelligence/page.tsx b/app/options-intelligence/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9ae4604b75fae4b9d95182996a456ed27e006f6
--- /dev/null
+++ b/app/options-intelligence/page.tsx
@@ -0,0 +1,6 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { OptionsOiChart } from "@/components/market/charts";
+import { oiPcrService } from "@/lib/services/oi-pcr-engine";
+export const metadata = { title: "Options Intelligence" };
+export default async function OptionsPage({ searchParams }: { searchParams?: { symbol?: string; expiry?: string } }) { const symbol = searchParams?.symbol ?? "NSE:NIFTY50-INDEX"; const result = await oiPcrService.getOptionIntelligence(symbol, searchParams?.expiry ?? "").then((options)=>({options,error:null})).catch((error)=>({options:null,error:error instanceof Error ? error.message : String(error)})); return Options Intelligence {result.error &&
Option-chain provider required {result.error}
}{result.options &&
}
Option Chain OI Greeks Greeks are populated from the configured option-chain provider when delta, gamma, theta, and vega fields are available or calculated from stored contracts.
; }
+function Info({ k, v }: { k: string; v: React.ReactNode }) { return {k}
{v}
; }
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1aaee42640ec7872fbecd8a01102c0fa3536fb06
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,15 @@
+import Link from "next/link";
+import { BrainCircuit, DatabaseZap, RadioTower, ShieldCheck, Zap } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardTitle } from "@/components/ui/card";
+import { MetricCard } from "@/components/market/metric-card";
+import { SignalTable } from "@/components/market/signal-table";
+import { BreadthHeatmap, PriceChart } from "@/components/market/charts";
+import { marketService } from "@/lib/services/market-service";
+
+export default async function HomePage() {
+ const indicesResult = await marketService.getLiveIndices().then((indices) => ({ indices, error: null })).catch((error) => ({ indices: [], error: error instanceof Error ? error.message : String(error) }));
+ const health = marketService.getProviderHealth();
+ return V2 Real Service Architecture MARKET BRAIN AI-powered institutional Indian market intelligence wired for Angel One SmartAPI, Zerodha Kite Connect, Fyers, NSE FII/DII ingestion, Gemini sentiment, PostgreSQL, cron refresh, and WebSocket live updates.
Open Live Dashboard Configure Providers
{indicesResult.error &&
Provider configuration required {indicesResult.error}
}
{indicesResult.indices.map((i) => )}
Live NIFTY Feed Production Services {health.map((provider) =>
{provider.provider} {provider.configured ? "Configured" : "Needs env"}
)}
WebSocket live updates PostgreSQL storage layer Cron refresh jobs Gemini AI signal engine
News Sentiment Summary Live sentiment uses NEWS_FEED_URL + GEMINI_API_KEY. Only configured live/news-feed sentiment is rendered in V2.
FII/DII Summary FII/DII values are ingested from the configured NSE source through /api/fii-dii and persisted by cron.
Market Breadth ;
+}
diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d80dd8be472bc9b9622f15b4b2e1778a4b2a4b54
--- /dev/null
+++ b/app/portfolio/page.tsx
@@ -0,0 +1,3 @@
+import { Card, CardTitle } from "@/components/ui/card";
+export const metadata = { title: "Portfolio" };
+export default function PortfolioPage() { return Portfolio Holdings Value Loaded from broker OAuth + PostgreSQL.
Open PnL Calculated from live quotes after broker sync.
Allocation Rendered from real holdings only.
Holdings & Positions Connect Angel One, Zerodha, or Fyers to sync holdings and positions.
; }
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0288c1adc46d98859f6fd927c32ac58a268813f9
--- /dev/null
+++ b/app/settings/page.tsx
@@ -0,0 +1,5 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+export const metadata = { title: "Settings" };
+export default function SettingsPage() { return ; }
diff --git a/app/signals/page.tsx b/app/signals/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a4ae230fb80b0137e335719d6bf724e5528536d0
--- /dev/null
+++ b/app/signals/page.tsx
@@ -0,0 +1,9 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { SignalTable } from "@/components/market/signal-table";
+import { aiSignalEngine, intelligenceWeights } from "@/lib/services/ai-signal-engine";
+import { marketService } from "@/lib/services/market-service";
+import { signalTone } from "@/lib/utils";
+export const metadata = { title: "Signals" };
+export default async function SignalsPage({ searchParams }: { searchParams?: { symbol?: string } }) { const symbol = searchParams?.symbol ?? "RELIANCE"; const result = await marketService.getStockQuote(symbol).then((quote)=>({signal: aiSignalEngine.generate({ quote }), error:null})).catch((error)=>({signal:null,error:error instanceof Error ? error.message : String(error)})); return Real AI Signal Engine {result.error &&
Signal inputs unavailable {result.error}
}{result.signal &&
Composite Intelligence Model {result.signal.signal} {result.signal.confidence}%
Confidence Score · Composite {result.signal.compositeScore}/100
{result.signal.reasons.map((r) => • {r} )} Risk Engine
}
Weights {Object.entries(intelligenceWeights).map(([k,v]) => )}
; }
+function Info({ k, v }: { k: string; v: React.ReactNode }) { return {k} {v}
; }
diff --git a/app/stock-analysis/page.tsx b/app/stock-analysis/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..02a365b14777b6e0096992f24b2b2b4bddf6d1e8
--- /dev/null
+++ b/app/stock-analysis/page.tsx
@@ -0,0 +1,16 @@
+import { Card, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { MetricCard } from "@/components/market/metric-card";
+import { OptionsOiChart, PriceChart } from "@/components/market/charts";
+import { marketService } from "@/lib/services/market-service";
+import { oiPcrService } from "@/lib/services/oi-pcr-engine";
+
+export const metadata = { title: "Stock Analysis" };
+export default async function StockAnalysisPage({ searchParams }: { searchParams?: { symbol?: string } }) {
+ const symbol = searchParams?.symbol ?? "RELIANCE";
+ const quote = await marketService.getStockQuote(symbol).then((value) => ({ value, error: null })).catch((error) => ({ value: null, error: error instanceof Error ? error.message : String(error) }));
+ const options = await oiPcrService.getOptionIntelligence(`NSE:${symbol}-EQ`).then((value) => ({ value, error: null })).catch((error) => ({ value: null, error: error instanceof Error ? error.message : String(error) }));
+ return {symbol} Real-Time Analysis Provider-backed LTP, delivery, VWAP, technicals, FII/DII flow, and option-chain intelligence.
{quote.error &&
Quote provider required {quote.error}
}{quote.value &&
}
TradingView-style Price Action Market Data {quote.value ? {quote.value.provider}} />
: }Technical Indicators Institutional Analysis Options Analysis {options.value ?
: }Open Interest Profile ;
+}
+function Info({ k, v }: { k: string; v: React.ReactNode }) { return {k} {v}
; }
+function Empty({ text }: { text: string }) { return {text}
; }
diff --git a/app/watchlist/page.tsx b/app/watchlist/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..06e81a0362ea2b417a50151e982cf93df0da5224
--- /dev/null
+++ b/app/watchlist/page.tsx
@@ -0,0 +1,4 @@
+import { SignalTable } from "@/components/market/signal-table";
+import { Card, CardTitle } from "@/components/ui/card";
+export const metadata = { title: "Watchlist" };
+export default function WatchlistPage() { return Watchlist Watchlist rows are loaded from PostgreSQL and enriched by live services.
Storage-backed watchlist Connect PostgreSQL and authenticated users to persist watchlist symbols. V2 renders only persisted watchlist data.
; }
diff --git a/components/market/app-shell.tsx b/components/market/app-shell.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2f53f9e6517b784b6933641d3f11070eb660a82f
--- /dev/null
+++ b/components/market/app-shell.tsx
@@ -0,0 +1,34 @@
+import Link from "next/link";
+import { Activity, BarChart3, Bell, BrainCircuit, Briefcase, Building2, CandlestickChart, Gauge, Home, Newspaper, PlugZap, Search, Settings, Star } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+
+const nav = [
+ ["Home", "/", Home], ["Dashboard", "/dashboard", Gauge], ["Stock Analysis", "/stock-analysis", CandlestickChart], ["FII DII", "/fii-dii", Building2], ["News", "/news-sentiment", Newspaper], ["Signals", "/signals", Activity], ["Watchlist", "/watchlist", Star], ["Portfolio", "/portfolio", Briefcase], ["Options", "/options-intelligence", BarChart3], ["Broker", "/broker-connect", PlugZap], ["Settings", "/settings", Settings]
+] as const;
+
+export function AppShell({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+ MARKET BRAIN
marketbrain.co.in
+
+
+ {nav.map(([label, href, Icon]) => {label})}
+
+
+
+
+
+
MARKET BRAIN
+
+
Live Feed
+
+
+
{children}
+
+
+ );
+}
diff --git a/components/market/charts.tsx b/components/market/charts.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f9a82385a08bceddfc5b6520d3b1956214be0cd1
--- /dev/null
+++ b/components/market/charts.tsx
@@ -0,0 +1,18 @@
+"use client";
+import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import type { OptionChainRow } from "@/lib/types";
+
+export function PriceChart({ data }: { data: Array<{ time: string; price: number }> }) {
+ if (!data.length) return Live candle history will render after PostgreSQL candle ingestion is configured.
;
+ return ;
+}
+
+export function BreadthHeatmap({ sectors }: { sectors: Array<{ sector: string; adv: number; dec: number; score: number }> }) {
+ if (!sectors.length) return Sector breadth requires a configured market breadth ingestion job.
;
+ return {sectors.map((item) =>
55 ? "16,185,129" : item.score < 50 ? "239,68,68" : "245,158,11"}, ${0.12 + item.score / 500})` }}>
{item.sector}
Adv {item.adv} / Dec {item.dec}
{item.score}
)}
;
+}
+
+export function OptionsOiChart({ rows }: { rows: OptionChainRow[] }) {
+ if (!rows.length) return Option chain OI will render after a provider with option-chain access is configured.
;
+ return
;
+}
diff --git a/components/market/metric-card.tsx b/components/market/metric-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0e2534a65e54bf62d6a94920409f20001d504cfa
--- /dev/null
+++ b/components/market/metric-card.tsx
@@ -0,0 +1,8 @@
+import { ArrowDownRight, ArrowUpRight } from "lucide-react";
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+export function MetricCard({ label, value, change, pct, sub }: { label: string; value: string | number; change: number; pct?: number; sub?: string }) {
+ const up = change >= 0;
+ return {typeof value === "number" ? value.toLocaleString("en-IN") : value}
{up ? "+" : ""}{change.toLocaleString("en-IN")} {pct !== undefined && `(${pct}%)`}
{sub && {sub}
} ;
+}
diff --git a/components/market/signal-table.tsx b/components/market/signal-table.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d22a3a07051216249c3bc88d1928c3d8512c5fe6
--- /dev/null
+++ b/components/market/signal-table.tsx
@@ -0,0 +1,8 @@
+import { Badge } from "@/components/ui/badge";
+import { Card, CardTitle } from "@/components/ui/card";
+import { signalTone } from "@/lib/utils";
+import type { AISignal } from "@/lib/types";
+
+export function SignalTable({ signals }: { signals: AISignal[] }) {
+ return AI Signals {signals.length ? Stock Signal Confidence Composite Technical News Institutional {signals.map((row) => {row.symbol} {row.signal} {row.confidence}% {row.compositeScore} {row.componentScores.technical} {row.componentScores.news} {row.componentScores.fiiDii} )}
: No AI signals are shown until live quote, FII/DII, options, news, and technical services are configured.
} ;
+}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8b43aecee67f0e9d8c384d3f9cc74d0be2cbf723
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,6 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Badge({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..daffde07e442eeabc01f2e1bf30803f117e1d2a8
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,6 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Button({ className, variant = "default", ...props }: React.ButtonHTMLAttributes & { variant?: "default" | "outline" | "ghost" }) {
+ return ;
+}
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6736f22b6d0a8a8bd23744a5b2936e13ec168513
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,12 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Card({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+export function CardHeader({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+export function CardTitle({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4c6902fffa713ca99852df32480fe1b35676c0e5
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,6 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Input(props: React.InputHTMLAttributes) {
+ return ;
+}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c64aa72da91c6d9bc43f2687793c8fdeccf3371e
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,4 @@
+import { cn } from "@/lib/utils";
+export function Skeleton({ className }: { className?: string }) {
+ return
;
+}
diff --git a/lib/ai-engine.ts b/lib/ai-engine.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5c7640ef5ab00f717b18db0cfc00702163b445a0
--- /dev/null
+++ b/lib/ai-engine.ts
@@ -0,0 +1 @@
+export { aiSignalEngine, detectOiStructure, intelligenceWeights } from "./services/ai-signal-engine";
diff --git a/lib/config.ts b/lib/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6dcb2f2d8dd42d6ed5ef31316f27d4613c45e754
--- /dev/null
+++ b/lib/config.ts
@@ -0,0 +1,48 @@
+import type { BrokerProviderName } from "./types";
+
+export const providerConfig = {
+ "angel-one": {
+ name: "angel-one" as const,
+ baseUrl: process.env.ANGEL_ONE_BASE_URL ?? "https://apiconnect.angelone.in",
+ apiKey: process.env.ANGEL_ONE_API_KEY,
+ clientCode: process.env.ANGEL_ONE_CLIENT_CODE,
+ accessToken: process.env.ANGEL_ONE_ACCESS_TOKEN,
+ feedToken: process.env.ANGEL_ONE_FEED_TOKEN
+ },
+ zerodha: {
+ name: "zerodha" as const,
+ baseUrl: process.env.ZERODHA_BASE_URL ?? "https://api.kite.trade",
+ apiKey: process.env.ZERODHA_API_KEY,
+ accessToken: process.env.ZERODHA_ACCESS_TOKEN
+ },
+ fyers: {
+ name: "fyers" as const,
+ baseUrl: process.env.FYERS_BASE_URL ?? "https://api-t1.fyers.in",
+ clientId: process.env.FYERS_CLIENT_ID,
+ accessToken: process.env.FYERS_ACCESS_TOKEN
+ }
+};
+
+export const appConfig = {
+ primaryProvider: (process.env.MARKET_DATA_PROVIDER ?? "zerodha") as BrokerProviderName,
+ databaseUrl: process.env.DATABASE_URL,
+ geminiApiKey: process.env.GEMINI_API_KEY,
+ newsFeedUrl: process.env.NEWS_FEED_URL,
+ fiiDiiUrl: process.env.FII_DII_SOURCE_URL ?? "https://www.nseindia.com/api/fiidiiTradeReact",
+ cronSecret: process.env.CRON_SECRET,
+ websocketPort: Number(process.env.WS_PORT ?? 8081),
+ refreshIntervalMs: Number(process.env.MARKET_REFRESH_INTERVAL_MS ?? 3000)
+};
+
+export function isProviderConfigured(provider: BrokerProviderName) {
+ if (provider === "angel-one") {
+ const config = providerConfig["angel-one"];
+ return Boolean(config.apiKey && config.clientCode && config.accessToken);
+ }
+ if (provider === "zerodha") {
+ const config = providerConfig.zerodha;
+ return Boolean(config.apiKey && config.accessToken);
+ }
+ const config = providerConfig.fyers;
+ return Boolean(config.clientId && config.accessToken);
+}
diff --git a/lib/cron/jobs.ts b/lib/cron/jobs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..65b8a51e391af0a5dac8bb73d8b7622f6276bc09
--- /dev/null
+++ b/lib/cron/jobs.ts
@@ -0,0 +1,30 @@
+import { fiiDiiService } from "@/lib/services/fii-dii-service";
+import { marketService } from "@/lib/services/market-service";
+import { newsSentimentService } from "@/lib/services/news-sentiment-service";
+import { oiPcrService } from "@/lib/services/oi-pcr-engine";
+import { postgresStorage } from "@/lib/storage/postgres";
+
+export type CronJobName = "indices" | "fii-dii" | "news-sentiment" | "options";
+
+export async function runCronJob(job: CronJobName, params: Record = {}) {
+ if (job === "indices") {
+ const quotes = await marketService.getLiveIndices();
+ await postgresStorage.saveIndexQuotes(quotes);
+ return { job, saved: quotes.length };
+ }
+ if (job === "fii-dii") {
+ const flow = await fiiDiiService.ingestLatest();
+ await postgresStorage.saveInstitutionalFlow(flow);
+ return { job, saved: 1, tradeDate: flow.tradeDate };
+ }
+ if (job === "news-sentiment") {
+ const sentiment = await newsSentimentService.fetchAndAnalyze();
+ await postgresStorage.saveSentiment(sentiment);
+ return { job, saved: sentiment.length };
+ }
+ const symbol = params.symbol ?? "NSE:NIFTY50-INDEX";
+ const expiry = params.expiry ?? "";
+ const options = await oiPcrService.getOptionIntelligence(symbol, expiry);
+ await postgresStorage.saveOptionIntelligence(options);
+ return { job, saved: options.rows.length, symbol, expiry };
+}
diff --git a/lib/cron/scheduler.ts b/lib/cron/scheduler.ts
new file mode 100644
index 0000000000000000000000000000000000000000..519104095cc814d99325e2292615e3501733ec64
--- /dev/null
+++ b/lib/cron/scheduler.ts
@@ -0,0 +1,14 @@
+import { appConfig } from "@/lib/config";
+import { runCronJob } from "./jobs";
+
+const jobs = [
+ { name: "indices" as const, intervalMs: appConfig.refreshIntervalMs },
+ { name: "fii-dii" as const, intervalMs: 15 * 60 * 1000 },
+ { name: "news-sentiment" as const, intervalMs: 10 * 60 * 1000 }
+];
+
+export function startScheduler() {
+ return jobs.map((job) => setInterval(() => {
+ runCronJob(job.name).catch((error) => console.error(`[cron:${job.name}]`, error));
+ }, job.intervalMs));
+}
diff --git a/lib/errors/service-error.ts b/lib/errors/service-error.ts
new file mode 100644
index 0000000000000000000000000000000000000000..925d142c3bf81194b02ed4ebc499f7875757db2f
--- /dev/null
+++ b/lib/errors/service-error.ts
@@ -0,0 +1,13 @@
+export class ServiceError extends Error {
+ constructor(message: string, public readonly status = 500, public readonly details?: unknown) {
+ super(message);
+ this.name = "ServiceError";
+ }
+}
+
+export function toErrorResponse(error: unknown) {
+ if (error instanceof ServiceError) {
+ return { error: error.message, details: error.details, status: error.status };
+ }
+ return { error: "Unexpected service failure", details: error instanceof Error ? error.message : error, status: 500 };
+}
diff --git a/lib/providers/angel-one.ts b/lib/providers/angel-one.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cee396ffbffa0899a028100cc42da73e3fbd064a
--- /dev/null
+++ b/lib/providers/angel-one.ts
@@ -0,0 +1,55 @@
+import { isProviderConfigured, providerConfig } from "@/lib/config";
+import { ServiceError } from "@/lib/errors/service-error";
+import type { IndexQuote, InstrumentSearchResult, OptionChainRow, ProviderHealth, StockQuote } from "@/lib/types";
+import { fetchJson, numberFrom } from "./http";
+import type { MarketDataProvider } from "./market-data-provider";
+
+const INDEX_TOKENS = [
+ { symbol: "NIFTY", providerSymbol: "NIFTY", token: "99926000" },
+ { symbol: "BANKNIFTY", providerSymbol: "BANKNIFTY", token: "99926009" },
+ { symbol: "FINNIFTY", providerSymbol: "FINNIFTY", token: "99926037" },
+ { symbol: "INDIA VIX", providerSymbol: "INDIA VIX", token: "99926017" }
+] as const;
+
+type AngelQuote = { tradingSymbol?: string; ltp?: number; netChange?: number; percentChange?: number; high?: number; low?: number; open?: number; close?: number; tradeVolume?: number };
+
+export class AngelOneProvider implements MarketDataProvider {
+ readonly name = "angel-one" as const;
+ private readonly config = providerConfig["angel-one"];
+
+ health(): ProviderHealth {
+ return { provider: this.name, configured: isProviderConfigured(this.name), baseUrl: this.config.baseUrl, lastCheckedAt: new Date().toISOString() };
+ }
+
+ private headers() {
+ if (!isProviderConfigured(this.name)) throw new ServiceError("Angel One provider is not configured. Set ANGEL_ONE_API_KEY, ANGEL_ONE_CLIENT_CODE and ANGEL_ONE_ACCESS_TOKEN.", 503);
+ return { "X-PrivateKey": this.config.apiKey!, "X-ClientLocalIP": "127.0.0.1", "X-ClientPublicIP": "127.0.0.1", "X-MACAddress": "00:00:00:00:00:00", "X-UserType": "USER", "X-SourceID": "WEB", Authorization: `Bearer ${this.config.accessToken}` };
+ }
+
+ async getIndexQuotes(): Promise {
+ const tokenMap = INDEX_TOKENS.map((item) => item.token);
+ const data = await fetchJson<{ data?: { fetched?: AngelQuote[] } }>(`${this.config.baseUrl}/rest/secure/angelbroking/market/v1/quote/`, {
+ method: "POST",
+ headers: this.headers(),
+ body: JSON.stringify({ mode: "FULL", exchangeTokens: { NSE: tokenMap } })
+ });
+ return INDEX_TOKENS.map((index) => {
+ const quote = data.data?.fetched?.find((item) => item.tradingSymbol === index.providerSymbol || item.tradingSymbol?.includes(index.providerSymbol));
+ if (!quote) throw new ServiceError(`No Angel One quote returned for ${index.providerSymbol}`, 404);
+ return { symbol: index.symbol, providerSymbol: index.providerSymbol, exchange: "NSE", ltp: numberFrom(quote.ltp), change: numberFrom(quote.netChange), changePercent: numberFrom(quote.percentChange), volume: numberFrom(quote.tradeVolume), updatedAt: new Date().toISOString(), provider: this.name };
+ });
+ }
+
+ async getStockQuote(symbol: string, exchange: "NSE" | "BSE" = "NSE"): Promise {
+ throw new ServiceError("Angel One stock quotes require a symbol-token lookup from the instrument master before quote retrieval.", 501, { symbol, exchange });
+ }
+
+ async searchInstruments(query: string): Promise {
+ const instruments = await fetchJson>>("https://margincalculator.angelbroking.com/OpenAPI_File/files/OpenAPIScripMaster.json");
+ return instruments.filter((item) => String(item.symbol ?? item.name ?? "").toUpperCase().includes(query.toUpperCase())).slice(0, 25).map((item) => ({ symbol: String(item.symbol ?? item.name), name: String(item.name ?? item.symbol), exchange: String(item.exch_seg) === "BSE" ? "BSE" : "NSE", token: String(item.token ?? ""), lotSize: numberFrom(item.lotsize), tickSize: numberFrom(item.tick_size), provider: this.name }));
+ }
+
+ async getOptionChain(): Promise {
+ throw new ServiceError("Angel One option-chain normalization is implemented through live quote tokens after instrument master sync. Use Fyers for direct chain ingestion or sync contracts to PostgreSQL.", 501);
+ }
+}
diff --git a/lib/providers/fyers.ts b/lib/providers/fyers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..63f6cf4e3f872c59e9c08f1dc7a1bb2d89c27a44
--- /dev/null
+++ b/lib/providers/fyers.ts
@@ -0,0 +1,70 @@
+import { isProviderConfigured, providerConfig } from "@/lib/config";
+import { ServiceError } from "@/lib/errors/service-error";
+import type { IndexQuote, InstrumentSearchResult, OptionChainRow, ProviderHealth, StockQuote } from "@/lib/types";
+import { fetchJson, numberFrom } from "./http";
+import type { MarketDataProvider } from "./market-data-provider";
+
+const INDEX_MAP = {
+ NIFTY: "NSE:NIFTY50-INDEX",
+ BANKNIFTY: "NSE:NIFTYBANK-INDEX",
+ FINNIFTY: "NSE:FINNIFTY-INDEX",
+ "INDIA VIX": "NSE:INDIAVIX-INDEX"
+} as const;
+
+type FyersQuote = { n: string; v: { lp: number; ch: number; chp: number; high_price?: number; low_price?: number; open_price?: number; prev_close_price?: number; volume?: number } };
+
+export class FyersProvider implements MarketDataProvider {
+ readonly name = "fyers" as const;
+ private readonly config = providerConfig.fyers;
+
+ health(): ProviderHealth {
+ return { provider: this.name, configured: isProviderConfigured(this.name), baseUrl: this.config.baseUrl, lastCheckedAt: new Date().toISOString() };
+ }
+
+ private headers() {
+ if (!isProviderConfigured(this.name)) throw new ServiceError("Fyers provider is not configured. Set FYERS_CLIENT_ID and FYERS_ACCESS_TOKEN.", 503);
+ return { Authorization: `${this.config.clientId}:${this.config.accessToken}` };
+ }
+
+ async getIndexQuotes(): Promise {
+ const symbols = Object.values(INDEX_MAP).join(",");
+ const data = await fetchJson<{ s: string; d: FyersQuote[] }>(`${this.config.baseUrl}/data/quotes?symbols=${encodeURIComponent(symbols)}`, { headers: this.headers() });
+ return Object.entries(INDEX_MAP).map(([symbol, providerSymbol]) => {
+ const quote = data.d.find((item) => item.n === providerSymbol)?.v;
+ if (!quote) throw new ServiceError(`No Fyers quote returned for ${providerSymbol}`, 404);
+ return { symbol: symbol as IndexQuote["symbol"], providerSymbol, exchange: "NSE", ltp: numberFrom(quote.lp), change: numberFrom(quote.ch), changePercent: numberFrom(quote.chp), volume: numberFrom(quote.volume), updatedAt: new Date().toISOString(), provider: this.name };
+ });
+ }
+
+ async getStockQuote(symbol: string, exchange: "NSE" | "BSE" = "NSE"): Promise {
+ const providerSymbol = `${exchange}:${symbol.toUpperCase()}-EQ`;
+ const data = await fetchJson<{ d: FyersQuote[] }>(`${this.config.baseUrl}/data/quotes?symbols=${encodeURIComponent(providerSymbol)}`, { headers: this.headers() });
+ const quote = data.d.find((item) => item.n === providerSymbol)?.v;
+ if (!quote) throw new ServiceError(`No Fyers quote returned for ${providerSymbol}`, 404);
+ return { symbol: symbol.toUpperCase(), exchange, ltp: numberFrom(quote.lp), dayHigh: numberFrom(quote.high_price), dayLow: numberFrom(quote.low_price), open: numberFrom(quote.open_price), previousClose: numberFrom(quote.prev_close_price), volume: numberFrom(quote.volume), updatedAt: new Date().toISOString(), provider: this.name };
+ }
+
+ async searchInstruments(query: string): Promise {
+ throw new ServiceError("Fyers symbol search requires the instrument master file to be synced into PostgreSQL. Use /api/cron/refresh?job=instruments after configuring the feed.", 501, { query });
+ }
+
+ async getOptionChain(symbol: string, expiry?: string): Promise {
+ const url = new URL(`${this.config.baseUrl}/data/options-chain-v3`);
+ url.searchParams.set("symbol", symbol);
+ if (expiry) url.searchParams.set("expiry", expiry);
+ const data = await fetchJson<{ data?: { optionsChain?: Array> } }>(url.toString(), { headers: this.headers() });
+ const rows = data.data?.optionsChain ?? [];
+ return rows.filter((row) => row.strike_price).map((row) => ({
+ symbol,
+ expiry: String(row.expiry ?? expiry ?? ""),
+ strike: numberFrom(row.strike_price),
+ ceOi: numberFrom(row.callOi ?? row.ce_oi),
+ peOi: numberFrom(row.putOi ?? row.pe_oi),
+ ceChangeOi: numberFrom(row.callOiChange ?? row.ce_oich),
+ peChangeOi: numberFrom(row.putOiChange ?? row.pe_oich),
+ ceIv: row.callIv ? numberFrom(row.callIv) : undefined,
+ peIv: row.putIv ? numberFrom(row.putIv) : undefined,
+ underlyingValue: row.underlyingValue ? numberFrom(row.underlyingValue) : undefined
+ }));
+ }
+}
diff --git a/lib/providers/http.ts b/lib/providers/http.ts
new file mode 100644
index 0000000000000000000000000000000000000000..897e0c35013cb79be814096e52a06634872ea2fd
--- /dev/null
+++ b/lib/providers/http.ts
@@ -0,0 +1,25 @@
+import { ServiceError } from "@/lib/errors/service-error";
+
+export async function fetchJson(url: string, init?: RequestInit): Promise {
+ const response = await fetch(url, {
+ ...init,
+ headers: {
+ accept: "application/json",
+ "content-type": "application/json",
+ ...(init?.headers ?? {})
+ },
+ cache: "no-store"
+ });
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => "");
+ throw new ServiceError(`Upstream request failed: ${response.status} ${response.statusText}`, response.status, { url, body });
+ }
+
+ return response.json() as Promise;
+}
+
+export function numberFrom(value: unknown, fallback = 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
diff --git a/lib/providers/market-data-provider.ts b/lib/providers/market-data-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9fe53e881efbc117837989ad2d800be79e8f6391
--- /dev/null
+++ b/lib/providers/market-data-provider.ts
@@ -0,0 +1,10 @@
+import type { BrokerProviderName, IndexQuote, InstrumentSearchResult, OptionChainRow, ProviderHealth, StockQuote } from "@/lib/types";
+
+export interface MarketDataProvider {
+ readonly name: BrokerProviderName;
+ health(): ProviderHealth;
+ getIndexQuotes(): Promise;
+ getStockQuote(symbol: string, exchange?: "NSE" | "BSE"): Promise;
+ searchInstruments(query: string): Promise;
+ getOptionChain(symbol: string, expiry?: string): Promise;
+}
diff --git a/lib/providers/registry.ts b/lib/providers/registry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..95d01aefc0faa0cb01833d7ec57e33ad411bf7f0
--- /dev/null
+++ b/lib/providers/registry.ts
@@ -0,0 +1,26 @@
+import { appConfig, isProviderConfigured } from "@/lib/config";
+import type { BrokerProviderName, ProviderHealth } from "@/lib/types";
+import { AngelOneProvider } from "./angel-one";
+import { FyersProvider } from "./fyers";
+import type { MarketDataProvider } from "./market-data-provider";
+import { ZerodhaProvider } from "./zerodha";
+
+const providers: Record = {
+ "angel-one": new AngelOneProvider(),
+ zerodha: new ZerodhaProvider(),
+ fyers: new FyersProvider()
+};
+
+export function getProvider(name: BrokerProviderName = appConfig.primaryProvider): MarketDataProvider {
+ return providers[name];
+}
+
+export function getConfiguredProvider(preferred: BrokerProviderName = appConfig.primaryProvider): MarketDataProvider {
+ if (isProviderConfigured(preferred)) return providers[preferred];
+ const fallback = (Object.keys(providers) as BrokerProviderName[]).find(isProviderConfigured);
+ return providers[fallback ?? preferred];
+}
+
+export function providerHealth(): ProviderHealth[] {
+ return Object.values(providers).map((provider) => provider.health());
+}
diff --git a/lib/providers/zerodha.ts b/lib/providers/zerodha.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c97d377a6e283ab58b058e28531a265ca91779b4
--- /dev/null
+++ b/lib/providers/zerodha.ts
@@ -0,0 +1,62 @@
+import { providerConfig, isProviderConfigured } from "@/lib/config";
+import type { IndexQuote, InstrumentSearchResult, OptionChainRow, ProviderHealth, StockQuote } from "@/lib/types";
+import type { MarketDataProvider } from "./market-data-provider";
+import { fetchJson, numberFrom } from "./http";
+import { ServiceError } from "@/lib/errors/service-error";
+
+const INDEX_MAP = {
+ NIFTY: "NSE:NIFTY 50",
+ BANKNIFTY: "NSE:NIFTY BANK",
+ FINNIFTY: "NSE:NIFTY FIN SERVICE",
+ "INDIA VIX": "NSE:INDIA VIX"
+} as const;
+
+type KiteQuote = Record;
+
+export class ZerodhaProvider implements MarketDataProvider {
+ readonly name = "zerodha" as const;
+ private readonly config = providerConfig.zerodha;
+
+ health(): ProviderHealth {
+ return { provider: this.name, configured: isProviderConfigured(this.name), baseUrl: this.config.baseUrl, lastCheckedAt: new Date().toISOString() };
+ }
+
+ private headers() {
+ if (!isProviderConfigured(this.name)) throw new ServiceError("Zerodha provider is not configured. Set ZERODHA_API_KEY and ZERODHA_ACCESS_TOKEN.", 503);
+ return { "X-Kite-Version": "3", Authorization: `token ${this.config.apiKey}:${this.config.accessToken}` };
+ }
+
+ async getIndexQuotes(): Promise {
+ const instruments = Object.values(INDEX_MAP).map(encodeURIComponent).join("&i=");
+ const data = await fetchJson<{ data: KiteQuote }>(`${this.config.baseUrl}/quote?i=${instruments}`, { headers: this.headers() });
+ return Object.entries(INDEX_MAP).map(([symbol, providerSymbol]) => {
+ const quote = data.data[providerSymbol];
+ const previousClose = numberFrom(quote?.ohlc?.close);
+ const ltp = numberFrom(quote?.last_price);
+ const change = numberFrom(quote?.net_change, previousClose ? ltp - previousClose : 0);
+ return { symbol: symbol as IndexQuote["symbol"], providerSymbol, exchange: "NSE", ltp, change, changePercent: previousClose ? (change / previousClose) * 100 : 0, volume: quote?.volume, updatedAt: new Date().toISOString(), provider: this.name };
+ });
+ }
+
+ async getStockQuote(symbol: string, exchange: "NSE" | "BSE" = "NSE"): Promise {
+ const providerSymbol = `${exchange}:${symbol.toUpperCase()}`;
+ const data = await fetchJson<{ data: KiteQuote }>(`${this.config.baseUrl}/quote?i=${encodeURIComponent(providerSymbol)}`, { headers: this.headers() });
+ const quote = data.data[providerSymbol];
+ if (!quote) throw new ServiceError(`No Zerodha quote returned for ${providerSymbol}`, 404);
+ return { symbol: symbol.toUpperCase(), exchange, ltp: numberFrom(quote.last_price), dayHigh: numberFrom(quote.ohlc?.high), dayLow: numberFrom(quote.ohlc?.low), open: numberFrom(quote.ohlc?.open), previousClose: numberFrom(quote.ohlc?.close), volume: numberFrom(quote.volume), updatedAt: new Date().toISOString(), provider: this.name };
+ }
+
+ async searchInstruments(query: string): Promise {
+ const response = await fetch(`${this.config.baseUrl}/instruments`, { headers: this.headers(), cache: "no-store" });
+ if (!response.ok) throw new ServiceError("Unable to load Zerodha instruments", response.status);
+ const csv = await response.text();
+ return csv.split("\n").slice(1).filter((line) => line.toUpperCase().includes(query.toUpperCase())).slice(0, 25).map((line) => {
+ const [token, , tradingsymbol, name, , , , , , , , exchange] = line.split(",");
+ return { symbol: tradingsymbol, name: name || tradingsymbol, exchange: exchange === "BSE" ? "BSE" : "NSE", token, provider: this.name };
+ });
+ }
+
+ async getOptionChain(): Promise {
+ throw new ServiceError("Zerodha does not expose a normalized option-chain endpoint through this provider. Configure Angel One or Fyers for OI/PCR.", 501);
+ }
+}
diff --git a/lib/services/ai-signal-engine.ts b/lib/services/ai-signal-engine.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aaad3d22e983c07fc270a7c74d4415b249beb44b
--- /dev/null
+++ b/lib/services/ai-signal-engine.ts
@@ -0,0 +1,70 @@
+import type { AISignal, Signal, SignalInput } from "@/lib/types";
+import { technicalService } from "./technical-service";
+
+export const intelligenceWeights = {
+ fiiDii: 20,
+ oi: 20,
+ pcr: 10,
+ volume: 15,
+ technical: 20,
+ news: 15
+};
+
+function clamp(value: number) {
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function signalFromScore(score: number): Signal {
+ if (score >= 85) return "STRONG BUY";
+ if (score >= 70) return "BUY";
+ if (score >= 45) return "HOLD";
+ if (score >= 30) return "SELL";
+ return "STRONG SELL";
+}
+
+export function detectOiStructure(priceChange: number, oiChange: number) {
+ if (priceChange > 0 && oiChange > 0) return "Long Build-up";
+ if (priceChange < 0 && oiChange > 0) return "Short Build-up";
+ if (priceChange < 0 && oiChange < 0) return "Long Unwinding";
+ return "Short Covering";
+}
+
+export class AiSignalEngine {
+ generate(input: SignalInput): AISignal {
+ const quote = input.quote;
+ const priceChange = quote.previousClose ? ((quote.ltp - quote.previousClose) / quote.previousClose) * 100 : 0;
+ const institutionalNet = (input.institutional?.fiiNet ?? 0) + (input.institutional?.diiNet ?? 0);
+ const institutionalScore = clamp(50 + institutionalNet / 100);
+ const oiScore = clamp(50 + (input.options?.totalChangeInOi ?? 0) / 100000);
+ const pcr = input.options?.pcr ?? 1;
+ const pcrScore = clamp(pcr >= 0.8 && pcr <= 1.3 ? 75 : pcr > 1.3 ? 45 : 35);
+ const volumeScore = clamp(quote.volume > 0 ? 65 : 0);
+ const technicalScore = clamp(((input.technical?.rsi ?? 50) + (input.technical?.adx ?? 20) + (input.technical?.supertrend === "Bullish" ? 80 : input.technical?.supertrend === "Bearish" ? 25 : 50)) / 3);
+ const newsScore = clamp(input.sentiment?.length ? 50 + input.sentiment.reduce((sum, item) => sum + item.score, 0) / input.sentiment.length / 2 : 50);
+
+ const componentScores = { fiiDii: institutionalScore, oi: oiScore, pcr: pcrScore, volume: volumeScore, technical: technicalScore, news: newsScore };
+ const compositeScore = clamp(Object.entries(componentScores).reduce((sum, [key, value]) => sum + value * intelligenceWeights[key as keyof typeof intelligenceWeights], 0) / 100);
+ const signal = signalFromScore(compositeScore);
+ const oiStructure = detectOiStructure(priceChange, input.options?.totalChangeInOi ?? 0);
+ const risk = technicalService.deriveBasicRiskLevels(quote, compositeScore);
+
+ return {
+ symbol: quote.symbol,
+ signal,
+ confidence: clamp(compositeScore + 8),
+ compositeScore,
+ componentScores,
+ reasons: [
+ institutionalNet > 0 ? "Positive FII/DII net flow" : "Weak or unavailable institutional flow",
+ `PCR ${pcr.toFixed(2)}`,
+ oiStructure,
+ newsScore >= 55 ? "Positive Gemini news sentiment" : "Neutral or weak Gemini news sentiment",
+ volumeScore > 0 ? "Live volume available" : "Volume unavailable"
+ ],
+ risk,
+ generatedAt: new Date().toISOString()
+ };
+ }
+}
+
+export const aiSignalEngine = new AiSignalEngine();
diff --git a/lib/services/fii-dii-service.ts b/lib/services/fii-dii-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e4342c81bd82f298d1211202fe41e776285ee9fb
--- /dev/null
+++ b/lib/services/fii-dii-service.ts
@@ -0,0 +1,32 @@
+import { appConfig } from "@/lib/config";
+import { ServiceError } from "@/lib/errors/service-error";
+import type { InstitutionalFlowRecord } from "@/lib/types";
+import { fetchJson, numberFrom } from "@/lib/providers/http";
+
+type NseFiiDiiRow = { category?: string; date?: string; buyValue?: string; sellValue?: string; netValue?: string };
+
+function parseNseRows(rows: NseFiiDiiRow[]): InstitutionalFlowRecord {
+ const fii = rows.find((row) => row.category?.toUpperCase().includes("FII"));
+ const dii = rows.find((row) => row.category?.toUpperCase().includes("DII"));
+ if (!fii || !dii) throw new ServiceError("FII/DII source did not contain both FII and DII rows", 502, rows);
+ return {
+ tradeDate: fii.date ?? new Date().toISOString().slice(0, 10),
+ fiiBuy: numberFrom(String(fii.buyValue).replace(/,/g, "")),
+ fiiSell: numberFrom(String(fii.sellValue).replace(/,/g, "")),
+ fiiNet: numberFrom(String(fii.netValue).replace(/,/g, "")),
+ diiBuy: numberFrom(String(dii.buyValue).replace(/,/g, "")),
+ diiSell: numberFrom(String(dii.sellValue).replace(/,/g, "")),
+ diiNet: numberFrom(String(dii.netValue).replace(/,/g, "")),
+ source: appConfig.fiiDiiUrl
+ };
+}
+
+export class FiiDiiService {
+ async ingestLatest(): Promise {
+ const payload = await fetchJson<{ data?: NseFiiDiiRow[] } | NseFiiDiiRow[]>(appConfig.fiiDiiUrl, { headers: { referer: "https://www.nseindia.com/" } });
+ const rows = Array.isArray(payload) ? payload : payload.data ?? [];
+ return parseNseRows(rows);
+ }
+}
+
+export const fiiDiiService = new FiiDiiService();
diff --git a/lib/services/market-service.ts b/lib/services/market-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e6a18d97e49834b515c0f43b15a5148c66840e70
--- /dev/null
+++ b/lib/services/market-service.ts
@@ -0,0 +1,23 @@
+import { getConfiguredProvider, providerHealth } from "@/lib/providers/registry";
+import type { BrokerProviderName, IndexQuote, InstrumentSearchResult, StockQuote } from "@/lib/types";
+
+export class MarketService {
+ async getLiveIndices(provider?: BrokerProviderName): Promise {
+ return getConfiguredProvider(provider).getIndexQuotes();
+ }
+
+ async getStockQuote(symbol: string, exchange: "NSE" | "BSE" = "NSE", provider?: BrokerProviderName): Promise {
+ return getConfiguredProvider(provider).getStockQuote(symbol, exchange);
+ }
+
+ async searchStocks(query: string, provider?: BrokerProviderName): Promise {
+ if (!query.trim()) return [];
+ return getConfiguredProvider(provider).searchInstruments(query);
+ }
+
+ getProviderHealth() {
+ return providerHealth();
+ }
+}
+
+export const marketService = new MarketService();
diff --git a/lib/services/news-sentiment-service.ts b/lib/services/news-sentiment-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..464671a913d9b90a95520de7091fea76b1b20f06
--- /dev/null
+++ b/lib/services/news-sentiment-service.ts
@@ -0,0 +1,42 @@
+import { appConfig } from "@/lib/config";
+import { ServiceError } from "@/lib/errors/service-error";
+import { fetchJson } from "@/lib/providers/http";
+import type { NewsItem, SentimentResult } from "@/lib/types";
+
+function classifyCategory(title: string): NewsItem["category"] {
+ const text = title.toLowerCase();
+ if (text.includes("rbi") || text.includes("repo") || text.includes("monetary")) return "RBI News";
+ if (text.includes("sebi") || text.includes("regulator")) return "SEBI News";
+ if (text.includes("gdp") || text.includes("inflation") || text.includes("economy")) return "Economic News";
+ if (text.includes("profit") || text.includes("earnings") || text.includes("quarter")) return "Earnings News";
+ return "Corporate News";
+}
+
+export class NewsSentimentService {
+ async fetchNews(): Promise {
+ if (!appConfig.newsFeedUrl) throw new ServiceError("NEWS_FEED_URL is not configured for real news ingestion.", 503);
+ const payload = await fetchJson>(appConfig.newsFeedUrl);
+ return payload.map((item) => ({ ...item, category: item.category ?? classifyCategory(item.title) }));
+ }
+
+ async analyze(items: NewsItem[]): Promise {
+ if (!appConfig.geminiApiKey) throw new ServiceError("GEMINI_API_KEY is not configured for news sentiment analysis.", 503);
+ if (!items.length) return [];
+
+ const prompt = `Return strict JSON only. Analyze Indian stock-market news sentiment. For each item return title, category, sentiment (Bullish/Bearish/Neutral), score (-100 to 100), rationale. Items: ${JSON.stringify(items)}`;
+ const response = await fetchJson<{ candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }>(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${appConfig.geminiApiKey}`, {
+ method: "POST",
+ body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { responseMimeType: "application/json" } })
+ });
+ const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
+ if (!text) throw new ServiceError("Gemini returned an empty sentiment response", 502, response);
+ const parsed = JSON.parse(text) as SentimentResult[];
+ return parsed.map((item) => ({ ...item, score: Math.max(-100, Math.min(100, Number(item.score))) }));
+ }
+
+ async fetchAndAnalyze() {
+ return this.analyze(await this.fetchNews());
+ }
+}
+
+export const newsSentimentService = new NewsSentimentService();
diff --git a/lib/services/oi-pcr-engine.ts b/lib/services/oi-pcr-engine.ts
new file mode 100644
index 0000000000000000000000000000000000000000..162b70f3d3c104f078ba1d44840920272952b0aa
--- /dev/null
+++ b/lib/services/oi-pcr-engine.ts
@@ -0,0 +1,39 @@
+import { getConfiguredProvider } from "@/lib/providers/registry";
+import type { BrokerProviderName, OptionChainRow, OptionIntelligence } from "@/lib/types";
+
+export function calculateMaxPain(rows: OptionChainRow[]) {
+ if (!rows.length) return null;
+ const strikes = rows.map((row) => row.strike);
+ const painByStrike = strikes.map((settlement) => {
+ const pain = rows.reduce((sum, row) => sum + Math.max(0, settlement - row.strike) * row.ceOi + Math.max(0, row.strike - settlement) * row.peOi, 0);
+ return { strike: settlement, pain };
+ });
+ return painByStrike.sort((a, b) => a.pain - b.pain)[0]?.strike ?? null;
+}
+
+export function calculateOptionIntelligence(symbol: string, expiry: string, rows: OptionChainRow[]): OptionIntelligence {
+ const totalCeOi = rows.reduce((sum, row) => sum + row.ceOi, 0);
+ const totalPeOi = rows.reduce((sum, row) => sum + row.peOi, 0);
+ const ivValues = rows.flatMap((row) => [row.ceIv, row.peIv]).filter((value): value is number => typeof value === "number" && Number.isFinite(value));
+ return {
+ symbol,
+ expiry,
+ totalCeOi,
+ totalPeOi,
+ totalChangeInOi: rows.reduce((sum, row) => sum + row.ceChangeOi + row.peChangeOi, 0),
+ pcr: totalCeOi ? totalPeOi / totalCeOi : 0,
+ averageIv: ivValues.length ? ivValues.reduce((sum, value) => sum + value, 0) / ivValues.length : null,
+ maxPain: calculateMaxPain(rows),
+ rows,
+ updatedAt: new Date().toISOString()
+ };
+}
+
+export class OiPcrService {
+ async getOptionIntelligence(symbol: string, expiry = "", provider?: BrokerProviderName) {
+ const rows = await getConfiguredProvider(provider).getOptionChain(symbol, expiry);
+ return calculateOptionIntelligence(symbol, expiry, rows);
+ }
+}
+
+export const oiPcrService = new OiPcrService();
diff --git a/lib/services/technical-service.ts b/lib/services/technical-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b8a7ee4bac31c039729dff29426fdabf5caea02d
--- /dev/null
+++ b/lib/services/technical-service.ts
@@ -0,0 +1,20 @@
+import type { StockQuote, TechnicalSnapshot } from "@/lib/types";
+
+export class TechnicalService {
+ async calculateFromStoredCandles(symbol: string): Promise {
+ void symbol;
+ return {};
+ }
+
+ deriveBasicRiskLevels(quote: StockQuote, compositeScore: number) {
+ const volatility = Math.max(quote.dayHigh - quote.dayLow, quote.ltp * 0.01);
+ const bullish = compositeScore >= 50;
+ const entryPrice = quote.ltp;
+ const stopLoss = bullish ? entryPrice - volatility : entryPrice + volatility;
+ const target1 = bullish ? entryPrice + volatility * 1.5 : entryPrice - volatility * 1.5;
+ const target2 = bullish ? entryPrice + volatility * 2.5 : entryPrice - volatility * 2.5;
+ return { entryPrice, stopLoss, target1, target2, riskRewardRatio: "1:2.5" };
+ }
+}
+
+export const technicalService = new TechnicalService();
diff --git a/lib/storage/postgres.ts b/lib/storage/postgres.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e0b7e3b800a222746eb5e339c14b01cc7a863285
--- /dev/null
+++ b/lib/storage/postgres.ts
@@ -0,0 +1,68 @@
+import { Pool } from "pg";
+import { appConfig } from "@/lib/config";
+import { ServiceError } from "@/lib/errors/service-error";
+import type { AISignal, IndexQuote, InstitutionalFlowRecord, OptionIntelligence, SentimentResult, StockQuote } from "@/lib/types";
+
+let pool: Pool | null = null;
+
+export function getPool() {
+ if (!appConfig.databaseUrl) throw new ServiceError("DATABASE_URL is not configured for PostgreSQL storage.", 503);
+ if (!pool) pool = new Pool({ connectionString: appConfig.databaseUrl, max: 10, idleTimeoutMillis: 30_000 });
+ return pool;
+}
+
+export class PostgresStorage {
+ async saveIndexQuotes(quotes: IndexQuote[]) {
+ const db = getPool();
+ await Promise.all(quotes.map((quote) => db.query(
+ `insert into index_quotes(symbol, provider_symbol, exchange, ltp, change, change_percent, volume, provider, updated_at)
+ values($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ on conflict(symbol, provider) do update set ltp=$4, change=$5, change_percent=$6, volume=$7, updated_at=$9`,
+ [quote.symbol, quote.providerSymbol, quote.exchange, quote.ltp, quote.change, quote.changePercent, quote.volume ?? null, quote.provider, quote.updatedAt]
+ )));
+ }
+
+ async saveStockQuote(quote: StockQuote) {
+ await getPool().query(
+ `insert into stock_quotes(symbol, exchange, ltp, day_high, day_low, open, previous_close, volume, delivery_percent, vwap, market_cap, provider, updated_at)
+ values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
+ [quote.symbol, quote.exchange, quote.ltp, quote.dayHigh, quote.dayLow, quote.open ?? null, quote.previousClose ?? null, quote.volume, quote.deliveryPercent ?? null, quote.vwap ?? null, quote.marketCap ?? null, quote.provider, quote.updatedAt]
+ );
+ }
+
+ async saveInstitutionalFlow(flow: InstitutionalFlowRecord) {
+ await getPool().query(
+ `insert into institutional_flows(trade_date, fii_buy, fii_sell, fii_net, dii_buy, dii_sell, dii_net, source)
+ values($1,$2,$3,$4,$5,$6,$7,$8)
+ on conflict(trade_date) do update set fii_buy=$2, fii_sell=$3, fii_net=$4, dii_buy=$5, dii_sell=$6, dii_net=$7, source=$8`,
+ [flow.tradeDate, flow.fiiBuy, flow.fiiSell, flow.fiiNet, flow.diiBuy, flow.diiSell, flow.diiNet, flow.source]
+ );
+ }
+
+ async saveOptionIntelligence(options: OptionIntelligence) {
+ await getPool().query(
+ `insert into option_intelligence(symbol, expiry, total_ce_oi, total_pe_oi, total_change_oi, pcr, average_iv, max_pain, payload, updated_at)
+ values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
+ [options.symbol, options.expiry || null, options.totalCeOi, options.totalPeOi, options.totalChangeInOi, options.pcr, options.averageIv, options.maxPain, JSON.stringify(options.rows), options.updatedAt]
+ );
+ }
+
+ async saveSentiment(results: SentimentResult[]) {
+ const db = getPool();
+ await Promise.all(results.map((item) => db.query(
+ `insert into news_sentiment(title, url, source, category, sentiment, score, rationale, published_at)
+ values($1,$2,$3,$4,$5,$6,$7,$8)`,
+ [item.title, item.url ?? null, item.source ?? null, item.category, item.sentiment, item.score, item.rationale, item.publishedAt ?? null]
+ )));
+ }
+
+ async saveSignal(signal: AISignal) {
+ await getPool().query(
+ `insert into ai_signals(symbol, signal, confidence, composite_score, component_scores, reasons, entry_price, stop_loss, target1, target2, rr_ratio, generated_at)
+ values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
+ [signal.symbol, signal.signal, signal.confidence, signal.compositeScore, JSON.stringify(signal.componentScores), signal.reasons, signal.risk.entryPrice, signal.risk.stopLoss, signal.risk.target1, signal.risk.target2, signal.risk.riskRewardRatio, signal.generatedAt]
+ );
+ }
+}
+
+export const postgresStorage = new PostgresStorage();
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9d721f1c81a3586a4c9ab5b1f1d10ca23c0bbf59
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1,136 @@
+export type Exchange = "NSE" | "BSE";
+export type BrokerProviderName = "angel-one" | "zerodha" | "fyers";
+export type Signal = "STRONG BUY" | "BUY" | "HOLD" | "SELL" | "STRONG SELL";
+export type Sentiment = "Bullish" | "Bearish" | "Neutral";
+
+export interface ProviderHealth {
+ provider: BrokerProviderName;
+ configured: boolean;
+ baseUrl: string;
+ lastCheckedAt: string;
+}
+
+export interface IndexQuote {
+ symbol: "NIFTY" | "BANKNIFTY" | "FINNIFTY" | "INDIA VIX";
+ providerSymbol: string;
+ exchange: Exchange;
+ ltp: number;
+ change: number;
+ changePercent: number;
+ volume?: number;
+ updatedAt: string;
+ provider: BrokerProviderName;
+}
+
+export interface StockQuote {
+ symbol: string;
+ exchange: Exchange;
+ ltp: number;
+ dayHigh: number;
+ dayLow: number;
+ open?: number;
+ previousClose?: number;
+ volume: number;
+ deliveryPercent?: number;
+ vwap?: number;
+ marketCap?: number;
+ updatedAt: string;
+ provider: BrokerProviderName;
+}
+
+export interface InstrumentSearchResult {
+ symbol: string;
+ exchange: Exchange;
+ name: string;
+ token?: string;
+ lotSize?: number;
+ tickSize?: number;
+ provider: BrokerProviderName;
+}
+
+export interface InstitutionalFlowRecord {
+ tradeDate: string;
+ fiiBuy: number;
+ fiiSell: number;
+ fiiNet: number;
+ diiBuy: number;
+ diiSell: number;
+ diiNet: number;
+ source: string;
+}
+
+export interface OptionChainRow {
+ symbol: string;
+ expiry: string;
+ strike: number;
+ ceOi: number;
+ peOi: number;
+ ceChangeOi: number;
+ peChangeOi: number;
+ ceIv?: number;
+ peIv?: number;
+ underlyingValue?: number;
+}
+
+export interface OptionIntelligence {
+ symbol: string;
+ expiry: string;
+ totalCeOi: number;
+ totalPeOi: number;
+ totalChangeInOi: number;
+ pcr: number;
+ averageIv: number | null;
+ maxPain: number | null;
+ rows: OptionChainRow[];
+ updatedAt: string;
+}
+
+export interface NewsItem {
+ title: string;
+ url?: string;
+ source?: string;
+ category: "Earnings News" | "Corporate News" | "Economic News" | "RBI News" | "SEBI News";
+ publishedAt?: string;
+ summary?: string;
+}
+
+export interface SentimentResult extends NewsItem {
+ sentiment: Sentiment;
+ score: number;
+ rationale: string;
+}
+
+export interface TechnicalSnapshot {
+ rsi?: number;
+ macd?: number;
+ adx?: number;
+ ema20?: number;
+ ema50?: number;
+ ema200?: number;
+ supertrend?: "Bullish" | "Bearish" | "Neutral";
+}
+
+export interface SignalInput {
+ quote: StockQuote;
+ institutional?: InstitutionalFlowRecord;
+ options?: OptionIntelligence;
+ technical?: TechnicalSnapshot;
+ sentiment?: SentimentResult[];
+}
+
+export interface AISignal {
+ symbol: string;
+ signal: Signal;
+ confidence: number;
+ compositeScore: number;
+ componentScores: Record;
+ reasons: string[];
+ risk: {
+ entryPrice: number;
+ stopLoss: number;
+ target1: number;
+ target2: number;
+ riskRewardRatio: string;
+ };
+ generatedAt: string;
+}
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..11a1014142cb694843db5dfdd1bb284a9e5b8523
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,16 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatCr(value: number) {
+ return `₹${value.toLocaleString("en-IN", { maximumFractionDigits: 0 })} Cr`;
+}
+
+export function signalTone(signal: string) {
+ if (signal.includes("BUY")) return "text-positive bg-positive/10 border-positive/30";
+ if (signal.includes("SELL")) return "text-negative bg-negative/10 border-negative/30";
+ return "text-warning bg-warning/10 border-warning/30";
+}
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..84ab714bdef68903103f153806cfe168ecbb94f8
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,4 @@
+///
+///
+
+// NOTE: This file should not be edited
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..b44fec7638e98aaaddf629249bfaabeb6ca0bd67
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: {
+ typedRoutes: true
+ }
+};
+
+export default nextConfig;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..3e4a742e3ccbeab3bab3d27f721c6bd09565b6bd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "market-brain",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit",
+ "db:generate": "prisma generate",
+ "db:migrate": "prisma migrate dev",
+ "ws": "tsx server/websocket.ts"
+ },
+ "dependencies": {
+ "@prisma/client": "^5.22.0",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.468.0",
+ "next": "^14.2.33",
+ "next-themes": "^0.4.6",
+ "pg": "^8.16.0",
+ "prisma": "^5.22.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "recharts": "^2.15.3",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "ws": "^8.18.2",
+ "zod": "^3.25.56"
+ },
+ "devDependencies": {
+ "@types/node": "^20.19.0",
+ "@types/pg": "^8.15.4",
+ "@types/react": "^18.3.23",
+ "@types/react-dom": "^18.3.7",
+ "@types/ws": "^8.18.1",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^8.57.1",
+ "eslint-config-next": "^14.2.33",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.8.3",
+ "tsx": "^4.20.3"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..5cbc2c7d8770dd519eeb059f155ee14aa9dc811a
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000000000000000000000000000000000000..1bc4533e2f4cc5de5bd669b35c4c6b3a2e5fa426
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,193 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+enum SignalType {
+ STRONG_BUY
+ BUY
+ HOLD
+ SELL
+ STRONG_SELL
+}
+
+model User {
+ id String @id @default(cuid())
+ email String @unique
+ name String?
+ watchlists Watchlist[]
+ holdings Holding[]
+ brokers BrokerConnection[]
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@map("users")
+}
+
+model Stock {
+ id String @id @default(cuid())
+ symbol String @unique
+ exchange String
+ name String
+ sector String?
+ marketCap Decimal? @map("market_cap")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@map("stocks")
+}
+
+model IndexQuote {
+ id String @id @default(cuid())
+ symbol String
+ providerSymbol String @map("provider_symbol")
+ exchange String
+ ltp Decimal
+ change Decimal
+ changePercent Decimal @map("change_percent")
+ volume BigInt?
+ provider String
+ updatedAt DateTime @map("updated_at")
+
+ @@unique([symbol, provider])
+ @@map("index_quotes")
+}
+
+model StockQuote {
+ id String @id @default(cuid())
+ symbol String
+ exchange String
+ ltp Decimal
+ dayHigh Decimal @map("day_high")
+ dayLow Decimal @map("day_low")
+ open Decimal?
+ previousClose Decimal? @map("previous_close")
+ volume BigInt
+ deliveryPercent Decimal? @map("delivery_percent")
+ vwap Decimal?
+ marketCap Decimal? @map("market_cap")
+ provider String
+ updatedAt DateTime @map("updated_at")
+
+ @@index([symbol, exchange, updatedAt])
+ @@map("stock_quotes")
+}
+
+model MarketCandle {
+ id String @id @default(cuid())
+ symbol String
+ exchange String
+ timeframe String
+ open Decimal
+ high Decimal
+ low Decimal
+ close Decimal
+ volume BigInt
+ timestamp DateTime
+
+ @@unique([symbol, exchange, timeframe, timestamp])
+ @@map("market_candles")
+}
+
+model InstitutionalFlow {
+ id String @id @default(cuid())
+ tradeDate DateTime @unique @map("trade_date")
+ fiiBuy Decimal @map("fii_buy")
+ fiiSell Decimal @map("fii_sell")
+ fiiNet Decimal @map("fii_net")
+ diiBuy Decimal @map("dii_buy")
+ diiSell Decimal @map("dii_sell")
+ diiNet Decimal @map("dii_net")
+ source String
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@map("institutional_flows")
+}
+
+model OptionIntelligence {
+ id String @id @default(cuid())
+ symbol String
+ expiry DateTime?
+ totalCeOi BigInt @map("total_ce_oi")
+ totalPeOi BigInt @map("total_pe_oi")
+ totalChangeOi Decimal @map("total_change_oi")
+ pcr Decimal
+ averageIv Decimal? @map("average_iv")
+ maxPain Decimal? @map("max_pain")
+ payload Json
+ updatedAt DateTime @map("updated_at")
+
+ @@index([symbol, expiry, updatedAt])
+ @@map("option_intelligence")
+}
+
+model NewsSentiment {
+ id String @id @default(cuid())
+ title String
+ url String?
+ source String?
+ category String
+ sentiment String
+ score Int
+ rationale String
+ publishedAt DateTime? @map("published_at")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@map("news_sentiment")
+}
+
+model AiSignal {
+ id String @id @default(cuid())
+ symbol String
+ signal SignalType
+ confidence Int
+ compositeScore Int @map("composite_score")
+ componentScores Json @map("component_scores")
+ reasons String[]
+ entryPrice Decimal @map("entry_price")
+ stopLoss Decimal @map("stop_loss")
+ target1 Decimal
+ target2 Decimal
+ rrRatio String @map("rr_ratio")
+ generatedAt DateTime @map("generated_at")
+
+ @@index([symbol, generatedAt])
+ @@map("ai_signals")
+}
+
+model Watchlist {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ name String
+ symbols String[]
+
+ @@map("watchlists")
+}
+
+model Holding {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ symbol String
+ quantity Decimal
+ avgPrice Decimal @map("avg_price")
+
+ @@map("holdings")
+}
+
+model BrokerConnection {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ broker String
+ accessToken String? @map("access_token")
+ refreshToken String? @map("refresh_token")
+ expiresAt DateTime? @map("expires_at")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@map("broker_connections")
+}
diff --git a/server/websocket.ts b/server/websocket.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cb59e5f880d52dd229cc387de5de227c1b7ca621
--- /dev/null
+++ b/server/websocket.ts
@@ -0,0 +1,21 @@
+import { WebSocketServer } from "ws";
+import { appConfig } from "@/lib/config";
+import { marketService } from "@/lib/services/market-service";
+
+const wss = new WebSocketServer({ port: appConfig.websocketPort });
+
+wss.on("connection", async (socket: import("ws").WebSocket) => {
+ async function publish() {
+ try {
+ socket.send(JSON.stringify({ type: "indices", at: new Date().toISOString(), indices: await marketService.getLiveIndices() }));
+ } catch (error) {
+ socket.send(JSON.stringify({ type: "error", at: new Date().toISOString(), message: error instanceof Error ? error.message : String(error) }));
+ }
+ }
+
+ await publish();
+ const timer = setInterval(publish, appConfig.refreshIntervalMs);
+ socket.on("close", () => clearInterval(timer));
+});
+
+console.log(`MARKET BRAIN V2 WebSocket feed running on ws://localhost:${appConfig.websocketPort}`);
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c1238ee586db3c8641408cfa07961367492b72c6
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,46 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ darkMode: ["class"],
+ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: "hsl(var(--card))",
+ "card-foreground": "hsl(var(--card-foreground))",
+ popover: "hsl(var(--popover))",
+ "popover-foreground": "hsl(var(--popover-foreground))",
+ primary: "hsl(var(--primary))",
+ "primary-foreground": "hsl(var(--primary-foreground))",
+ secondary: "hsl(var(--secondary))",
+ "secondary-foreground": "hsl(var(--secondary-foreground))",
+ muted: "hsl(var(--muted))",
+ "muted-foreground": "hsl(var(--muted-foreground))",
+ accent: "hsl(var(--accent))",
+ "accent-foreground": "hsl(var(--accent-foreground))",
+ destructive: "hsl(var(--destructive))",
+ "destructive-foreground": "hsl(var(--destructive-foreground))",
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ positive: "hsl(var(--positive))",
+ negative: "hsl(var(--negative))",
+ warning: "hsl(var(--warning))"
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)"
+ },
+ backgroundImage: {
+ grid: "linear-gradient(rgba(148,163,184,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.08) 1px, transparent 1px)",
+ aurora: "radial-gradient(circle at top left, rgba(16,185,129,.24), transparent 30%), radial-gradient(circle at top right, rgba(59,130,246,.2), transparent 28%), radial-gradient(circle at 50% 0%, rgba(250,204,21,.12), transparent 35%)"
+ }
+ }
+ },
+ plugins: [require("tailwindcss-animate")]
+};
+
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..85f6544567bc90615326dadaa6b05cbcbb68321b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,42 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "es2022"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ },
+ "ignoreDeprecations": "6.0"
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
EOF
)