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 <noreply@anthropic.com>
This commit is contained in:
parent
d4a3f2b869
commit
6127b6de24
3 changed files with 269 additions and 0 deletions
33
dashboard/src/app/api/positions/route.ts
Normal file
33
dashboard/src/app/api/positions/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
230
dashboard/src/app/positions/page.tsx
Normal file
230
dashboard/src/app/positions/page.tsx
Normal file
|
|
@ -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 (
|
||||
<span className={cn("font-mono tabular-nums", value > 0 ? "text-emerald-500" : value < 0 ? "text-rose-500" : "text-muted-foreground")}>
|
||||
{fmt(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PositionsPage() {
|
||||
const [positions, setPositions] = React.useState<Position[]>([])
|
||||
const [summary, setSummary] = React.useState<Summary | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BarChart2 className="h-6 w-6 text-primary" />
|
||||
Positions
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-rose-500/30 bg-rose-500/10">
|
||||
<CardContent className="pt-4 text-sm text-rose-400">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total P&L"
|
||||
value={summary ? fmt(summary.totalPnl) : "—"}
|
||||
icon={summary && summary.totalPnl >= 0 ? TrendingUp : TrendingDown}
|
||||
className={summary && summary.totalPnl < 0 ? "border-rose-500/30" : "border-emerald-500/30"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unrealised"
|
||||
value={summary ? fmt(summary.totalUnrealised) : "—"}
|
||||
icon={Activity}
|
||||
description="Open positions"
|
||||
/>
|
||||
<StatCard
|
||||
title="Realised"
|
||||
value={summary ? fmt(summary.totalRealised) : "—"}
|
||||
icon={DollarSign}
|
||||
description="Closed today"
|
||||
/>
|
||||
<StatCard
|
||||
title="Open Positions"
|
||||
value={loading ? "…" : open.length}
|
||||
icon={Layers}
|
||||
description={closed.length > 0 ? `${closed.length} closed today` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open positions table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Open Positions ({open.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
) : open.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">No open positions</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-xs">
|
||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Avg</th>
|
||||
<th className="px-4 py-2 text-right font-medium">LTP</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Unrealised</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Total P&L</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{open.map(p => (
|
||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono font-medium">{p.tradingsymbol}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
<span className={p.netqty > 0 ? "text-emerald-500" : "text-rose-500"}>
|
||||
{p.netqty > 0 ? "+" : ""}{p.netqty}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.avg_price.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.ltp.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.unrealised_pnl} /></td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.total_pnl} /></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{p.instrumenttype || p.producttype}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Closed today */}
|
||||
{!loading && closed.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-muted-foreground">Closed Today ({closed.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-xs">
|
||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Realised P&L</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{closed.map(p => (
|
||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono font-medium text-muted-foreground">{p.tradingsymbol}</td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.realised_pnl} /></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="outline" className="text-xs font-normal opacity-60">
|
||||
{p.instrumenttype || p.producttype}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue