From 6127b6de245c22439ce856a28293024a431ea631 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 6 Jun 2026 09:40:56 +0530 Subject: [PATCH] feat: add /positions dashboard page with live P&L stats and table - New /api/positions route proxies to angel.manohargupta.com (positions + pnl-history) - Positions page: 4 stat cards (total/unrealised/realised/open count), open table, closed-today table - Auto-refreshes every 30s; manual refresh triggers force-poll on tracker - Add Positions nav item to app-sidebar Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/app/api/positions/route.ts | 33 ++++ dashboard/src/app/positions/page.tsx | 230 +++++++++++++++++++++++ dashboard/src/components/app-sidebar.tsx | 6 + 3 files changed, 269 insertions(+) create mode 100644 dashboard/src/app/api/positions/route.ts create mode 100644 dashboard/src/app/positions/page.tsx diff --git a/dashboard/src/app/api/positions/route.ts b/dashboard/src/app/api/positions/route.ts new file mode 100644 index 0000000..8f92869 --- /dev/null +++ b/dashboard/src/app/api/positions/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server" + +export const dynamic = "force-dynamic" + +const ANGEL_API_URL = process.env.ANGEL_API_URL || "https://angel.manohargupta.com" + +async function angelFetch(path: string) { + const res = await fetch(`${ANGEL_API_URL}${path}`, { cache: "no-store" }) + if (!res.ok) throw new Error(`angel API ${path} failed: ${res.status}`) + return res.json() +} + +export async function GET() { + try { + const [posData, histData] = await Promise.all([ + angelFetch("/api/positions"), + angelFetch("/api/pnl-history"), + ]) + return NextResponse.json({ ok: true, positions: posData.data ?? [], summary: histData.summary ?? {} }) + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }) + } +} + +export async function POST() { + try { + const res = await fetch(`${ANGEL_API_URL}/api/refresh`, { method: "POST", cache: "no-store" }) + if (!res.ok) throw new Error(`refresh failed: ${res.status}`) + return NextResponse.json({ ok: true }) + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }) + } +} diff --git a/dashboard/src/app/positions/page.tsx b/dashboard/src/app/positions/page.tsx new file mode 100644 index 0000000..4a528ab --- /dev/null +++ b/dashboard/src/app/positions/page.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react" +import { StatCard } from "@/components/stat-card" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +interface Position { + key: string + tradingsymbol: string + exchange: string + instrumenttype: string + producttype: string + netqty: number + ltp: number + avg_price: number + unrealised_pnl: number + realised_pnl: number + total_pnl: number + is_closed: number + updated_at: string +} + +interface Summary { + totalUnrealised: number + totalRealised: number + totalPnl: number + openPositions: number + asOf?: string +} + +function fmt(n: number) { + const sign = n >= 0 ? "+" : "" + return `${sign}₹${Math.abs(n).toLocaleString("en-IN", { maximumFractionDigits: 0 })}` +} + +function PnlCell({ value }: { value: number }) { + return ( + 0 ? "text-emerald-500" : value < 0 ? "text-rose-500" : "text-muted-foreground")}> + {fmt(value)} + + ) +} + +export default function PositionsPage() { + const [positions, setPositions] = React.useState([]) + const [summary, setSummary] = React.useState(null) + const [loading, setLoading] = React.useState(true) + const [refreshing, setRefreshing] = React.useState(false) + const [error, setError] = React.useState(null) + const [lastUpdated, setLastUpdated] = React.useState(null) + + const load = React.useCallback(async (silent = false) => { + if (!silent) setLoading(true) + setError(null) + try { + const res = await fetch("/api/positions", { cache: "no-store" }) + const data = await res.json() + if (!data.ok) throw new Error(data.error ?? "Failed to load") + setPositions(data.positions ?? []) + setSummary(data.summary ?? null) + setLastUpdated(new Date()) + } catch (e: any) { + setError(e.message) + } finally { + setLoading(false) + } + }, []) + + const handleRefresh = async () => { + setRefreshing(true) + try { + await fetch("/api/positions", { method: "POST" }) + } catch { /* ignore */ } + await load(true) + setRefreshing(false) + } + + React.useEffect(() => { + load() + const id = setInterval(() => load(true), 30_000) + return () => clearInterval(id) + }, [load]) + + const open = positions.filter(p => p.netqty !== 0 && !p.is_closed) + const closed = positions.filter(p => p.netqty === 0 && p.is_closed && p.realised_pnl !== 0) + + return ( +
+
+
+

+ + Positions +

+

+ {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"} +

+
+ +
+ + {error && ( + + {error} + + )} + + {/* Summary stat cards */} +
+ = 0 ? TrendingUp : TrendingDown} + className={summary && summary.totalPnl < 0 ? "border-rose-500/30" : "border-emerald-500/30"} + /> + + + 0 ? `${closed.length} closed today` : undefined} + /> +
+ + {/* Open positions table */} + + + Open Positions ({open.length}) + + + {loading ? ( +
Loading…
+ ) : open.length === 0 ? ( +
No open positions
+ ) : ( +
+ + + + + + + + + + + + + + {open.map(p => ( + + + + + + + + + + ))} + +
SymbolQtyAvgLTPUnrealisedTotal P&LType
{p.tradingsymbol} + 0 ? "text-emerald-500" : "text-rose-500"}> + {p.netqty > 0 ? "+" : ""}{p.netqty} + + ₹{p.avg_price.toFixed(2)}₹{p.ltp.toFixed(2)} + + {p.instrumenttype || p.producttype} + +
+
+ )} +
+
+ + {/* Closed today */} + {!loading && closed.length > 0 && ( + + + Closed Today ({closed.length}) + + +
+ + + + + + + + + + {closed.map(p => ( + + + + + + ))} + +
SymbolRealised P&LType
{p.tradingsymbol} + + {p.instrumenttype || p.producttype} + +
+
+
+
+ )} +
+ ) +} diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx index 2bbf438..5fa8e33 100644 --- a/dashboard/src/components/app-sidebar.tsx +++ b/dashboard/src/components/app-sidebar.tsx @@ -10,6 +10,7 @@ import { DollarSign, FolderOpen, Briefcase, + BarChart2, } from "lucide-react" import { useTigerLogs } from "@/hooks/use-bridge" @@ -46,6 +47,11 @@ const navMain = [ url: "/tasks", icon: CheckSquare, }, + { + title: "Positions", + url: "/positions", + icon: BarChart2, + }, { title: "Cost Monitor", url: "/cost",