Add live testing controls and portfolio refresh fixes
This commit is contained in:
parent
aa93225ef7
commit
2097489053
@ -11,6 +11,13 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import StrategyTimeline from "@/components/StrategyTimeline";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@ -52,13 +59,19 @@ type FundsResponse = {
|
||||
withdrawable?: number;
|
||||
utilized?: number;
|
||||
balance?: number;
|
||||
available?: {
|
||||
live_balance?: number;
|
||||
cash?: number;
|
||||
opening_balance?: number;
|
||||
};
|
||||
raw?: any;
|
||||
};
|
||||
};
|
||||
|
||||
type EquityCurveResponse = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
accountOpenDate?: string;
|
||||
exactFrom?: string | null;
|
||||
points: { date: string; value: number }[];
|
||||
};
|
||||
|
||||
@ -73,6 +86,7 @@ type EngineStatus = {
|
||||
type SessionUser = Pick<User, "id" | "username">;
|
||||
|
||||
const MotionButton = motion(Button);
|
||||
const BROKER_DATA_REFRESH_MS = 30_000;
|
||||
|
||||
function formatCurrency(amount: number, options?: { decimals?: number }) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
@ -113,6 +127,65 @@ function formatRelativeSeconds(seconds: number) {
|
||||
return `in ${parts.join(" ")}`;
|
||||
}
|
||||
|
||||
function firstNumber(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getSettledQuantity(item: any) {
|
||||
return firstNumber(item?.settled_quantity, item?.quantity, item?.qty);
|
||||
}
|
||||
|
||||
function getT1Quantity(item: any) {
|
||||
return firstNumber(item?.t1_quantity);
|
||||
}
|
||||
|
||||
function getEffectiveQuantity(item: any) {
|
||||
return firstNumber(item?.effective_quantity, getSettledQuantity(item) + getT1Quantity(item));
|
||||
}
|
||||
|
||||
function getAveragePrice(item: any) {
|
||||
return firstNumber(item?.average_price, item?.avg_price);
|
||||
}
|
||||
|
||||
function getLastPrice(item: any) {
|
||||
return firstNumber(item?.last_price, item?.close_price, getAveragePrice(item));
|
||||
}
|
||||
|
||||
function getDisplayPnl(item: any) {
|
||||
return firstNumber(
|
||||
item?.display_pnl,
|
||||
getEffectiveQuantity(item) * (getLastPrice(item) - getAveragePrice(item)),
|
||||
);
|
||||
}
|
||||
|
||||
function getHoldingValue(item: any) {
|
||||
return firstNumber(item?.holding_value, getEffectiveQuantity(item) * getLastPrice(item));
|
||||
}
|
||||
|
||||
function getAvailableFundsValue(funds?: FundsResponse["funds"] | null) {
|
||||
return firstNumber(
|
||||
funds?.available?.live_balance,
|
||||
funds?.raw?.equity?.available?.live_balance,
|
||||
funds?.raw?.equity?.available?.cash,
|
||||
funds?.raw?.available?.live_balance,
|
||||
funds?.balance,
|
||||
funds?.cash,
|
||||
funds?.withdrawable,
|
||||
funds?.net,
|
||||
funds?.raw?.equity?.net,
|
||||
funds?.raw?.net,
|
||||
funds?.available?.opening_balance,
|
||||
funds?.raw?.equity?.available?.opening_balance,
|
||||
funds?.raw?.available?.opening_balance,
|
||||
);
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
@ -266,27 +339,14 @@ export default function PortfolioSection() {
|
||||
formatDateInput(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)),
|
||||
);
|
||||
const [sipAmount, setSipAmount] = useState(5000);
|
||||
const [frequencyDays, setFrequencyDays] = useState(30);
|
||||
const [frequencyValue, setFrequencyValue] = useState(30);
|
||||
const [frequencyUnit, setFrequencyUnit] = useState<"days" | "minutes">("days");
|
||||
const [strategyStatus, setStrategyStatus] = useState("STOPPED");
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
||||
const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null);
|
||||
|
||||
const linkedAtDate = brokerStatus?.connected_at
|
||||
? new Date(brokerStatus.connected_at)
|
||||
: null;
|
||||
const linkedAtInput =
|
||||
linkedAtDate && !isNaN(linkedAtDate.getTime())
|
||||
? formatDateInput(linkedAtDate)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (linkedAtInput) {
|
||||
setStartDate((prev) => (prev === linkedAtInput ? prev : linkedAtInput));
|
||||
}
|
||||
}, [linkedAtInput]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await getStrategyStatus();
|
||||
@ -406,6 +466,30 @@ export default function PortfolioSection() {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshBrokerData = useCallback(
|
||||
async ({ includeEquityCurve = false }: { includeEquityCurve?: boolean } = {}) => {
|
||||
if (!brokerStatus?.connected) {
|
||||
return;
|
||||
}
|
||||
const tasks = [
|
||||
holdingsQuery.refetch(),
|
||||
fundsQuery.refetch(),
|
||||
refetchBrokerStatus(),
|
||||
];
|
||||
if (includeEquityCurve) {
|
||||
tasks.push(equityCurveQuery.refetch());
|
||||
}
|
||||
await Promise.allSettled(tasks);
|
||||
},
|
||||
[
|
||||
brokerStatus?.connected,
|
||||
holdingsQuery.refetch,
|
||||
fundsQuery.refetch,
|
||||
equityCurveQuery.refetch,
|
||||
refetchBrokerStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const isConnected = !!brokerStatus?.connected;
|
||||
const isAuthed = brokerStatus !== null;
|
||||
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
||||
@ -425,12 +509,7 @@ export default function PortfolioSection() {
|
||||
setReconnectAttempted(true);
|
||||
(async () => {
|
||||
try {
|
||||
await refetchBrokerStatus();
|
||||
await Promise.all([
|
||||
holdingsQuery.refetch(),
|
||||
fundsQuery.refetch(),
|
||||
equityCurveQuery.refetch(),
|
||||
]);
|
||||
await refreshBrokerData({ includeEquityCurve: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@ -439,36 +518,54 @@ export default function PortfolioSection() {
|
||||
sessionExpired,
|
||||
reconnectAttempted,
|
||||
isConnected,
|
||||
refetchBrokerStatus,
|
||||
holdingsQuery,
|
||||
fundsQuery,
|
||||
equityCurveQuery,
|
||||
refreshBrokerData,
|
||||
]);
|
||||
const availableFunds =
|
||||
fundsSnapshot?.balance ??
|
||||
fundsSnapshot?.net ??
|
||||
fundsSnapshot?.withdrawable ??
|
||||
fundsSnapshot?.cash ??
|
||||
fundsSnapshot?.raw?.net ??
|
||||
fundsSnapshot?.raw?.available?.live_balance ??
|
||||
fundsSnapshot?.raw?.available?.opening_balance ??
|
||||
0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshIfVisible = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
void refreshBrokerData();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refreshBrokerData();
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(refreshIfVisible, BROKER_DATA_REFRESH_MS);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [isConnected, refreshBrokerData]);
|
||||
const availableFunds = getAvailableFundsValue(fundsSnapshot);
|
||||
|
||||
const { totalValue, totalPnl } = useMemo(() => {
|
||||
return holdings.reduce(
|
||||
(acc, item) => {
|
||||
const qty = Number(item.quantity ?? item.qty ?? 0);
|
||||
const last = Number(item.last_price ?? item.average_price ?? 0);
|
||||
const pnl = Number(item.pnl ?? 0);
|
||||
return {
|
||||
totalValue: acc.totalValue + qty * last,
|
||||
totalPnl: acc.totalPnl + pnl,
|
||||
totalValue: acc.totalValue + getHoldingValue(item),
|
||||
totalPnl: acc.totalPnl + getDisplayPnl(item),
|
||||
};
|
||||
},
|
||||
{ totalValue: 0, totalPnl: 0 },
|
||||
);
|
||||
}, [holdings]);
|
||||
|
||||
const activeHoldingCount = useMemo(
|
||||
() => holdings.filter((item) => getEffectiveQuantity(item) > 0).length,
|
||||
[holdings],
|
||||
);
|
||||
|
||||
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
|
||||
const equityCurvePoints = equityCurve?.points ?? [];
|
||||
const showSessionExpired = sessionExpired && isConnected;
|
||||
@ -569,11 +666,8 @@ export default function PortfolioSection() {
|
||||
}, [brokerStatus, requireLogin]);
|
||||
|
||||
const handleRefreshBrokerData = useCallback(() => {
|
||||
holdingsQuery.refetch();
|
||||
fundsQuery.refetch();
|
||||
equityCurveQuery.refetch();
|
||||
refetchBrokerStatus();
|
||||
}, [equityCurveQuery, fundsQuery, holdingsQuery, refetchBrokerStatus]);
|
||||
void refreshBrokerData({ includeEquityCurve: true });
|
||||
}, [refreshBrokerData]);
|
||||
|
||||
const handleDisconnectBroker = useCallback(() => {
|
||||
disconnectBrokerMutation.mutate();
|
||||
@ -601,8 +695,8 @@ export default function PortfolioSection() {
|
||||
strategy_name: "Golden Nifty",
|
||||
sip_amount: sipAmount,
|
||||
sip_frequency: {
|
||||
value: frequencyDays,
|
||||
unit: "days",
|
||||
value: frequencyValue,
|
||||
unit: frequencyUnit,
|
||||
},
|
||||
mode: "LIVE",
|
||||
});
|
||||
@ -632,7 +726,10 @@ export default function PortfolioSection() {
|
||||
}
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
await refreshStatus();
|
||||
await Promise.allSettled([
|
||||
refreshStatus(),
|
||||
refreshBrokerData({ includeEquityCurve: true }),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@ -642,7 +739,10 @@ export default function PortfolioSection() {
|
||||
await stopStrategy();
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
await refreshStatus();
|
||||
await Promise.allSettled([
|
||||
refreshStatus(),
|
||||
refreshBrokerData({ includeEquityCurve: true }),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@ -666,7 +766,7 @@ export default function PortfolioSection() {
|
||||
{
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
label: "Positions",
|
||||
value: holdings.length.toString(),
|
||||
value: activeHoldingCount.toString(),
|
||||
muted: !isConnected,
|
||||
},
|
||||
{
|
||||
@ -888,10 +988,12 @@ export default function PortfolioSection() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{holdings.map((item, idx) => {
|
||||
const qty = Number(item.quantity ?? item.qty ?? 0);
|
||||
const avg = Number(item.average_price ?? item.avg_price ?? 0);
|
||||
const ltp = Number(item.last_price ?? 0);
|
||||
const pnl = Number(item.pnl ?? 0);
|
||||
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={`${item.tradingsymbol || item.instrument_token || idx}`}>
|
||||
<td className="px-6 py-3">
|
||||
@ -902,7 +1004,14 @@ export default function PortfolioSection() {
|
||||
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3">{qty}</td>
|
||||
<td className="px-6 py-3">
|
||||
<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-6 py-3">{formatCurrency(avg, { decimals: 2 })}</td>
|
||||
<td className="px-6 py-3">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
|
||||
<td className="px-6 py-3">
|
||||
@ -972,21 +1081,43 @@ export default function PortfolioSection() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy-frequency">Frequency (days)</Label>
|
||||
<Input
|
||||
id="strategy-frequency"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={frequencyDays}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
setFrequencyDays(Number.isNaN(value) ? 1 : value);
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy-frequency">Frequency</Label>
|
||||
<Input
|
||||
id="strategy-frequency"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={frequencyValue}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
setFrequencyValue(Number.isNaN(value) ? 1 : value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Unit</Label>
|
||||
<Select
|
||||
value={frequencyUnit}
|
||||
onValueChange={(value) => setFrequencyUnit(value as "days" | "minutes")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
<SelectItem value="minutes">Minutes (testing)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{frequencyUnit === "minutes" ? (
|
||||
<div className="text-xs text-amber-300">
|
||||
Minutes mode is for live testing only. The engine checks every few seconds in this mode.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MotionButton
|
||||
{...ctaMotionProps}
|
||||
@ -1012,7 +1143,7 @@ export default function PortfolioSection() {
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Equity curve</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Track your account value over time from your demat open date.
|
||||
Track exact stored daily broker snapshots from the day recording began.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -1024,7 +1155,6 @@ export default function PortfolioSection() {
|
||||
type="date"
|
||||
value={startDate}
|
||||
max={formatDateInput(new Date())}
|
||||
min={linkedAtInput}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-[180px]"
|
||||
disabled={!isConnected}
|
||||
@ -1039,7 +1169,7 @@ export default function PortfolioSection() {
|
||||
) : equityCurveQuery.isError && equityCurvePoints.length === 0 ? (
|
||||
<ZeroState message="Could not load equity curve." />
|
||||
) : equityCurvePoints.length === 0 ? (
|
||||
<ZeroState message="Could not load equity curve." />
|
||||
<ZeroState message="No exact equity snapshots yet. The curve starts from the first recorded daily snapshot." />
|
||||
) : (
|
||||
<div className="h-80">
|
||||
<ChartContainer
|
||||
@ -1088,9 +1218,15 @@ export default function PortfolioSection() {
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Account open date: {equityCurve?.accountOpenDate
|
||||
? new Date(equityCurve.accountOpenDate).toLocaleDateString("en-IN")
|
||||
: "unknown"}
|
||||
Exact daily values from{" "}
|
||||
{equityCurve?.exactFrom
|
||||
? new Date(equityCurve.exactFrom).toLocaleDateString("en-IN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "the first recorded snapshot"}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user