Show live broker positions in portfolio
This commit is contained in:
parent
c3686a6e4c
commit
4f17fb166d
@ -47,6 +47,10 @@ type HoldingsResponse = {
|
|||||||
holdings: any[];
|
holdings: any[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PositionsResponse = {
|
||||||
|
positions: any[];
|
||||||
|
};
|
||||||
|
|
||||||
type MarketStatusResponse = {
|
type MarketStatusResponse = {
|
||||||
status?: "OPEN" | "CLOSED";
|
status?: "OPEN" | "CLOSED";
|
||||||
checked_at?: string;
|
checked_at?: string;
|
||||||
@ -202,6 +206,49 @@ function getHoldingValue(item: any) {
|
|||||||
return firstNumber(item?.holding_value, getEffectiveQuantity(item) * getLastPrice(item));
|
return firstNumber(item?.holding_value, getEffectiveQuantity(item) * getLastPrice(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPortfolioItemKey(item: any, idx: number) {
|
||||||
|
return `${item.tradingsymbol || item.symbol || item.instrument_token || item.exchange || "item"}-${idx}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPortfolioRows(items: any[]) {
|
||||||
|
return items.map((item, idx) => {
|
||||||
|
const qty = getEffectiveQuantity(item);
|
||||||
|
const settledQty = getSettledQuantity(item);
|
||||||
|
const t1Qty = getT1Quantity(item);
|
||||||
|
const avg = getAveragePrice(item);
|
||||||
|
const ltp = getLastPrice(item);
|
||||||
|
const pnl = getDisplayPnl(item);
|
||||||
|
return (
|
||||||
|
<tr key={getPortfolioItemKey(item, idx)}>
|
||||||
|
<td className="px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{item.tradingsymbol || item.symbol || "Instrument"}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge>
|
||||||
|
{item.product ? <Badge variant="secondary">{item.product}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{qty}</span>
|
||||||
|
{t1Qty > 0 && settledQty <= 0 ? (
|
||||||
|
<span className="text-xs text-amber-300">T1 pending</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 sm:px-6">{formatCurrency(avg, { decimals: 2 })}</td>
|
||||||
|
<td className="px-4 py-3 sm:px-6">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
|
||||||
|
<td className="px-4 py-3 sm:px-6">
|
||||||
|
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}>
|
||||||
|
{formatCurrency(pnl, { decimals: 2 })}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getAvailableFundsValue(funds?: FundsResponse["funds"] | null) {
|
function getAvailableFundsValue(funds?: FundsResponse["funds"] | null) {
|
||||||
return firstNumber(
|
return firstNumber(
|
||||||
funds?.available?.live_balance,
|
funds?.available?.live_balance,
|
||||||
@ -251,6 +298,7 @@ export default function PortfolioSection() {
|
|||||||
const [sessionExpired, setSessionExpired] = useState(false);
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
const [reconnectAttempted, setReconnectAttempted] = useState(false);
|
const [reconnectAttempted, setReconnectAttempted] = useState(false);
|
||||||
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
|
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
|
||||||
|
const [cachedPositions, setCachedPositions] = useState<any[]>([]);
|
||||||
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
|
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
|
||||||
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
|
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
|
||||||
const {
|
const {
|
||||||
@ -341,9 +389,11 @@ export default function PortfolioSection() {
|
|||||||
setSessionExpired(false);
|
setSessionExpired(false);
|
||||||
setReconnectAttempted(false);
|
setReconnectAttempted(false);
|
||||||
setCachedHoldings([]);
|
setCachedHoldings([]);
|
||||||
|
setCachedPositions([]);
|
||||||
setCachedFunds(null);
|
setCachedFunds(null);
|
||||||
setCachedEquityCurve(null);
|
setCachedEquityCurve(null);
|
||||||
queryClient.removeQueries({ queryKey: ["/broker/holdings"] });
|
queryClient.removeQueries({ queryKey: ["/broker/holdings"] });
|
||||||
|
queryClient.removeQueries({ queryKey: ["/broker/positions"] });
|
||||||
queryClient.removeQueries({ queryKey: ["/broker/funds"] });
|
queryClient.removeQueries({ queryKey: ["/broker/funds"] });
|
||||||
queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] });
|
queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] });
|
||||||
await refetchBrokerStatus();
|
await refetchBrokerStatus();
|
||||||
@ -551,6 +601,27 @@ export default function PortfolioSection() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const positionsQuery = useQuery<PositionsResponse>({
|
||||||
|
queryKey: ["/broker/positions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", "/broker/positions");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!brokerStatus?.connected,
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: 600,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCachedPositions(data?.positions || []);
|
||||||
|
setSessionExpired(false);
|
||||||
|
setReconnectAttempted(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
if (brokerStatus?.connected) {
|
||||||
|
setSessionExpired(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const refreshBrokerData = useCallback(
|
const refreshBrokerData = useCallback(
|
||||||
async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => {
|
async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => {
|
||||||
if (!brokerStatus?.connected) {
|
if (!brokerStatus?.connected) {
|
||||||
@ -558,6 +629,7 @@ export default function PortfolioSection() {
|
|||||||
}
|
}
|
||||||
const tasks = [
|
const tasks = [
|
||||||
holdingsQuery.refetch(),
|
holdingsQuery.refetch(),
|
||||||
|
positionsQuery.refetch(),
|
||||||
fundsQuery.refetch(),
|
fundsQuery.refetch(),
|
||||||
refetchBrokerStatus(),
|
refetchBrokerStatus(),
|
||||||
];
|
];
|
||||||
@ -569,6 +641,7 @@ export default function PortfolioSection() {
|
|||||||
[
|
[
|
||||||
brokerStatus?.connected,
|
brokerStatus?.connected,
|
||||||
holdingsQuery.refetch,
|
holdingsQuery.refetch,
|
||||||
|
positionsQuery.refetch,
|
||||||
fundsQuery.refetch,
|
fundsQuery.refetch,
|
||||||
equityCurveQuery.refetch,
|
equityCurveQuery.refetch,
|
||||||
refetchBrokerStatus,
|
refetchBrokerStatus,
|
||||||
@ -578,8 +651,10 @@ export default function PortfolioSection() {
|
|||||||
const isConnected = !!brokerStatus?.connected;
|
const isConnected = !!brokerStatus?.connected;
|
||||||
const isAuthed = brokerStatus !== null;
|
const isAuthed = brokerStatus !== null;
|
||||||
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
||||||
|
const positions = positionsQuery.data ? positionsQuery.data.positions : cachedPositions;
|
||||||
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
|
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
|
||||||
const noHoldings = holdings.length === 0;
|
const noHoldings = holdings.length === 0;
|
||||||
|
const noPositions = positions.length === 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
setSessionExpired(false);
|
setSessionExpired(false);
|
||||||
@ -635,7 +710,7 @@ export default function PortfolioSection() {
|
|||||||
const availableFunds = getAvailableFundsValue(fundsSnapshot);
|
const availableFunds = getAvailableFundsValue(fundsSnapshot);
|
||||||
|
|
||||||
const { totalValue, totalPnl } = useMemo(() => {
|
const { totalValue, totalPnl } = useMemo(() => {
|
||||||
return holdings.reduce(
|
return [...holdings, ...positions].reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
return {
|
return {
|
||||||
totalValue: acc.totalValue + getHoldingValue(item),
|
totalValue: acc.totalValue + getHoldingValue(item),
|
||||||
@ -644,12 +719,16 @@ export default function PortfolioSection() {
|
|||||||
},
|
},
|
||||||
{ totalValue: 0, totalPnl: 0 },
|
{ totalValue: 0, totalPnl: 0 },
|
||||||
);
|
);
|
||||||
}, [holdings]);
|
}, [holdings, positions]);
|
||||||
|
|
||||||
const activeHoldingCount = useMemo(
|
const activeHoldingCount = useMemo(
|
||||||
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
|
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
|
||||||
[holdings],
|
[holdings],
|
||||||
);
|
);
|
||||||
|
const activePositionCount = useMemo(
|
||||||
|
() => positions.filter((item) => getEffectiveQuantity(item) !== 0).length,
|
||||||
|
[positions],
|
||||||
|
);
|
||||||
|
|
||||||
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
|
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
|
||||||
const equityCurvePoints = equityCurve?.points ?? [];
|
const equityCurvePoints = equityCurve?.points ?? [];
|
||||||
@ -1003,11 +1082,15 @@ export default function PortfolioSection() {
|
|||||||
label: "Portfolio value",
|
label: "Portfolio value",
|
||||||
value: formatCurrency(totalValue, { decimals: 2 }),
|
value: formatCurrency(totalValue, { decimals: 2 }),
|
||||||
muted: !isConnected,
|
muted: !isConnected,
|
||||||
|
subText:
|
||||||
|
isConnected && (activeHoldingCount > 0 || activePositionCount > 0)
|
||||||
|
? `Holdings: ${activeHoldingCount} | Positions: ${activePositionCount}`
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BarChart3 className="h-5 w-5" />,
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
label: "Positions",
|
label: "Positions",
|
||||||
value: activeHoldingCount.toString(),
|
value: activePositionCount.toString(),
|
||||||
muted: !isConnected,
|
muted: !isConnected,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1127,11 +1210,11 @@ export default function PortfolioSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRefreshBrokerData}
|
onClick={handleRefreshBrokerData}
|
||||||
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
|
disabled={holdingsQuery.isFetching || positionsQuery.isFetching || equityCurveQuery.isFetching}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
{holdingsQuery.isFetching || equityCurveQuery.isFetching
|
{holdingsQuery.isFetching || positionsQuery.isFetching || equityCurveQuery.isFetching
|
||||||
? "Refreshing..."
|
? "Refreshing..."
|
||||||
: "Refresh data"}
|
: "Refresh data"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -1188,7 +1271,7 @@ export default function PortfolioSection() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-semibold">Holdings</p>
|
<p className="text-sm font-semibold">Holdings</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Current positions pulled from your connected broker.
|
Settled holdings synced from your connected broker.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={isConnected ? "secondary" : "outline"}>
|
<Badge variant={isConnected ? "secondary" : "outline"}>
|
||||||
@ -1235,41 +1318,61 @@ export default function PortfolioSection() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-border/60">
|
||||||
{holdings.map((item, idx) => {
|
{renderPortfolioRows(holdings)}
|
||||||
const qty = getEffectiveQuantity(item);
|
</tbody>
|
||||||
const settledQty = getSettledQuantity(item);
|
</table>
|
||||||
const t1Qty = getT1Quantity(item);
|
</div>
|
||||||
const avg = getAveragePrice(item);
|
)}
|
||||||
const ltp = getLastPrice(item);
|
</div>
|
||||||
const pnl = getDisplayPnl(item);
|
|
||||||
return (
|
<div
|
||||||
<tr key={`${item.tradingsymbol || item.instrument_token || idx}`}>
|
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
||||||
<td className="px-4 py-3 sm:px-6">
|
style={prefersReducedMotion ? undefined : { transitionDelay: "550ms" }}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<span className="font-semibold">
|
<div className="flex flex-col gap-3 border-b border-border/50 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||||
{item.tradingsymbol || item.symbol || "Instrument"}
|
<div className="space-y-1">
|
||||||
</span>
|
<p className="text-sm font-semibold">Live positions</p>
|
||||||
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge>
|
<p className="text-xs text-muted-foreground">
|
||||||
</div>
|
Same-day broker positions appear here before they move into holdings.
|
||||||
</td>
|
</p>
|
||||||
<td className="px-4 py-3 sm:px-6">
|
</div>
|
||||||
<div className="flex flex-col">
|
<Badge variant={isConnected ? "secondary" : "outline"}>
|
||||||
<span>{qty}</span>
|
{activePositionCount} open
|
||||||
{t1Qty > 0 && settledQty <= 0 ? (
|
</Badge>
|
||||||
<span className="text-xs text-amber-300">T1 pending</span>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
{!isAuthed ? (
|
||||||
</td>
|
<ZeroState message="Log in and connect your broker to see live positions." />
|
||||||
<td className="px-4 py-3 sm:px-6">{formatCurrency(avg, { decimals: 2 })}</td>
|
) : brokerStatusLoading ? (
|
||||||
<td className="px-4 py-3 sm:px-6">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
|
<ZeroState message="Checking broker status..." />
|
||||||
<td className="px-4 py-3 sm:px-6">
|
) : !isConnected ? (
|
||||||
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}>
|
<ZeroState message="Connect to broker to see live positions." />
|
||||||
{formatCurrency(pnl, { decimals: 2 })}
|
) : positionsQuery.isLoading ? (
|
||||||
</span>
|
<ZeroState message="Fetching positions..." />
|
||||||
</td>
|
) : positionsQuery.isError && positions.length === 0 ? (
|
||||||
</tr>
|
<ZeroState
|
||||||
);
|
message={
|
||||||
})}
|
showSessionExpired
|
||||||
|
? "Session expired. Reconnect to refresh positions."
|
||||||
|
: "Could not fetch positions. Try refreshing."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : noPositions ? (
|
||||||
|
<ZeroState message="No live positions yet. Same-day buys and sells will appear here." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium sm:px-6">Symbol</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium sm:px-6">Qty</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium sm:px-6">Avg price</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium sm:px-6">LTP</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium sm:px-6">P&L</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{renderPortfolioRows(positions)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user