Compare commits

..

No commits in common. "b3b798ff2f86ab46e1f5fd28f8dc750c6b398abd" and "d10bb3dd7879c9cb232788f21af753b294fa91f7" have entirely different histories.

17 changed files with 91 additions and 189 deletions

View File

@ -6,10 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "npm run typecheck",
"preview": "vite preview",
"start": "vite --host 0.0.0.0 --port 3001"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",

21
src/api/strategy.js Normal file
View File

@ -0,0 +1,21 @@
import { apiRequest } from "@/lib/queryClient";
export async function startStrategy(data) {
const res = await apiRequest("POST", "/strategy/start", data);
return res.json();
}
export async function stopStrategy() {
const res = await apiRequest("POST", "/strategy/stop");
return res.json();
}
export async function resumeStrategy() {
const res = await apiRequest("POST", "/strategy/resume");
return res.json();
}
export async function getStrategyStatus() {
const res = await apiRequest("GET", "/strategy/status");
return res.json();
}

View File

@ -1,64 +0,0 @@
import { apiRequest } from "@/lib/queryClient";
export type StrategyFrequencyUnit = "days" | "minutes";
export type StrategyStartRequest = {
strategy_name: string;
sip_amount: number;
sip_frequency: {
value: number;
unit: StrategyFrequencyUnit;
};
mode: "LIVE" | "PAPER";
initial_cash?: number;
};
export type StrategyActionResponse = {
status: string;
run_id?: string | null;
message?: string;
redirect_url?: string | null;
broker?: string | null;
warning?: string;
};
export type StrategyStatusResponse = {
status?: string;
last_updated?: string | null;
last_execution_ts?: string | null;
next_eligible_ts?: string | null;
run_id?: string | null;
can_resume?: boolean;
can_restart?: boolean;
config?: {
strategy?: string | null;
sip_amount?: number | null;
sip_frequency?: {
value?: number | null;
unit?: StrategyFrequencyUnit | null;
} | null;
mode?: string | null;
broker?: string | null;
active?: boolean | null;
} | null;
};
export async function startStrategy(data: StrategyStartRequest): Promise<StrategyActionResponse> {
const res = await apiRequest("POST", "/strategy/start", data);
return res.json();
}
export async function stopStrategy(): Promise<StrategyActionResponse> {
const res = await apiRequest("POST", "/strategy/stop");
return res.json();
}
export async function resumeStrategy(): Promise<StrategyActionResponse> {
const res = await apiRequest("POST", "/strategy/resume");
return res.json();
}
export async function getStrategyStatus(): Promise<StrategyStatusResponse> {
const res = await apiRequest("GET", "/strategy/status");
return res.json();
}

View File

@ -33,7 +33,9 @@ type StrategyTimelineProps = {
function parseTimestamp(value?: string | null) {
if (!value) return null;
const parsed = new Date(value);
const hasTimezone = /Z|[+-]\d{2}:?\d{2}$/.test(value);
const normalized = hasTimezone ? value : `${value}Z`;
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
@ -197,7 +199,7 @@ function VerboseStrategyTimeline() {
prev.map((entry) => entry.seq).filter((seq) => typeof seq === "number"),
);
const next = normalized.filter(
(entry: StrategyEvent) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq),
(entry) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq),
);
return [...prev, ...next];
});
@ -206,7 +208,7 @@ function VerboseStrategyTimeline() {
latestSeqRef.current = data.latest_seq;
} else {
const lastSeq = normalized.reduce(
(max: number, entry: StrategyEvent) => Math.max(max, entry.seq ?? 0),
(max, entry) => Math.max(max, entry.seq ?? 0),
latestSeqRef.current,
);
latestSeqRef.current = lastSeq;
@ -304,7 +306,7 @@ function VerboseStrategyTimeline() {
<div className="mt-1 text-xs text-green-300/80">Reason: {reason}</div>
) : null}
<div className="mt-3 space-y-3">
{run.events.map((entry: StrategyEvent, index: number) => {
{run.events.map((entry, index) => {
const badge = getBadge(entry);
const timestamp = entry.ts ?? entry.timestamp;
const message = entry.message ?? entry.event ?? "Unknown event";

View File

@ -1,5 +1,5 @@
import FinalCTA from "../landing/FinalCTA";
export default function FinalCTAExample() {
return <FinalCTA onExploreStrategies={() => {}} />;
return <FinalCTA />;
}

View File

@ -1,5 +1,5 @@
import HeroSection from "../landing/HeroSection";
export default function HeroSectionExample() {
return <HeroSection onExploreStrategies={() => {}} />;
return <HeroSection />;
}

View File

@ -252,7 +252,7 @@ export default function AuthDialogs({ layout = "desktop" }: AuthDialogsProps) {
</Button>
<Button
type="button"
variant="ghost"
variant="link"
className="w-full text-sm"
onClick={() => {
setResetEmail(loginForm.email.trim());

View File

@ -1,7 +1,7 @@
import { GOLDEN_NIFTY_PATH } from "@/lib/routes";
export default function Footer() {
const links: Record<string, { label: string; href: string; newTab?: boolean }[]> = {
const links = {
Product: [
{ label: "Strategies", href: GOLDEN_NIFTY_PATH, newTab: true },
{ label: "Pricing", href: "#" },

View File

@ -52,10 +52,8 @@ type PositionsResponse = {
};
type MarketStatusResponse = {
status?: "OPEN" | "CLOSED" | "HOLIDAY";
reason?: string;
status?: "OPEN" | "CLOSED";
checked_at?: string;
next_open_at?: string | null;
};
type FundsResponse = {
@ -197,16 +195,6 @@ function getNetQuantity(item: any) {
return getEffectiveQuantity(item);
}
function formatFrequencyLabel(value?: number | null, unit?: string | null) {
const safeValue = Number(value);
if (!Number.isFinite(safeValue) || safeValue <= 0 || !unit) {
return null;
}
const normalizedUnit = unit === "minutes" ? "minute" : unit === "days" ? "day" : unit;
const pluralizedUnit = safeValue === 1 ? normalizedUnit : `${normalizedUnit}s`;
return `${safeValue} ${pluralizedUnit}`;
}
function getAveragePrice(item: any) {
return firstNumber(item?.average_price, item?.avg_price);
}
@ -448,6 +436,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedHoldings(data?.holdings || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const fundsQuery = useQuery<FundsResponse>({
@ -459,6 +457,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedFunds(data?.funds ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const [startDate, setStartDate] = useState(() =>
@ -599,6 +607,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedEquityCurve(data ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const positionsQuery = useQuery<PositionsResponse>({
@ -610,6 +628,16 @@ export default function PortfolioSection() {
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(
@ -617,7 +645,7 @@ export default function PortfolioSection() {
if (!brokerStatus?.connected) {
return;
}
const tasks: Promise<unknown>[] = [
const tasks = [
holdingsQuery.refetch(),
positionsQuery.refetch(),
fundsQuery.refetch(),
@ -640,58 +668,11 @@ export default function PortfolioSection() {
const isConnected = !!brokerStatus?.connected;
const isAuthed = brokerStatus !== null;
const holdings = holdingsQuery.data?.holdings ?? cachedHoldings;
const positions = positionsQuery.data?.positions ?? cachedPositions;
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
const positions = positionsQuery.data ? positionsQuery.data.positions : cachedPositions;
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
const noHoldings = holdings.length === 0;
const noPositions = positions.length === 0;
useEffect(() => {
if (holdingsQuery.data) {
setCachedHoldings(holdingsQuery.data.holdings ?? []);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [holdingsQuery.data]);
useEffect(() => {
if (fundsQuery.data) {
setCachedFunds(fundsQuery.data.funds ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [fundsQuery.data]);
useEffect(() => {
if (equityCurveQuery.data) {
setCachedEquityCurve(equityCurveQuery.data);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [equityCurveQuery.data]);
useEffect(() => {
if (positionsQuery.data) {
setCachedPositions(positionsQuery.data.positions ?? []);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [positionsQuery.data]);
useEffect(() => {
if (
brokerStatus?.connected &&
(holdingsQuery.isError || fundsQuery.isError || equityCurveQuery.isError || positionsQuery.isError)
) {
setSessionExpired(true);
}
}, [
brokerStatus?.connected,
holdingsQuery.isError,
fundsQuery.isError,
equityCurveQuery.isError,
positionsQuery.isError,
]);
useEffect(() => {
if (!isConnected) {
setSessionExpired(false);
@ -781,38 +762,6 @@ export default function PortfolioSection() {
: strategyStatus === "WAITING"
? "WAITING"
: "STOPPED";
const savedStrategyAmount = Number(strategyDetails?.config?.sip_amount);
const savedStrategyFrequencyValue = Number(strategyDetails?.config?.sip_frequency?.value);
const savedStrategyFrequencyUnit =
strategyDetails?.config?.sip_frequency?.unit === "minutes"
? "minutes"
: strategyDetails?.config?.sip_frequency?.unit === "days"
? "days"
: null;
const savedStrategyCadence = formatFrequencyLabel(
savedStrategyFrequencyValue,
savedStrategyFrequencyUnit,
);
const savedStrategyAmountLabel =
Number.isFinite(savedStrategyAmount) && savedStrategyAmount > 0
? formatCurrency(savedStrategyAmount)
: null;
const savedStrategyMode =
strategyDetails?.config?.mode === "LIVE"
? "Live"
: strategyDetails?.config?.mode === "PAPER"
? "Paper"
: null;
const savedStrategyBroker =
strategyDetails?.config?.broker ? formatBrokerName(strategyDetails.config.broker) : null;
const savedStrategySummary = [
savedStrategyAmountLabel ? `${savedStrategyAmountLabel} SIP` : null,
savedStrategyCadence ? `every ${savedStrategyCadence}` : null,
savedStrategyMode,
savedStrategyBroker,
]
.filter(Boolean)
.join(" · ");
const isStrategyActive =
normalizedStrategyStatus === "RUNNING" ||
normalizedStrategyStatus === "WAITING";
@ -1534,12 +1483,6 @@ export default function PortfolioSection() {
Minutes mode is for live testing only. The engine checks every few seconds in this mode.
</div>
) : null}
{savedStrategySummary ? (
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2 text-xs text-muted-foreground">
{isStrategyActive ? "Current run" : showResumeStrategy ? "Resume will continue" : "Saved strategy config"}
: <span className="font-medium text-foreground"> {savedStrategySummary}</span>
</div>
) : null}
{showResumeStrategy ? (
<div className="text-xs text-muted-foreground">
Resume uses the previously saved SIP configuration. Choose restart to begin a fresh cycle.

View File

@ -43,11 +43,8 @@ async function getResponseErrorMessage(res: Response) {
if (contentType.includes("application/json")) {
try {
const data = await res.json();
const structuredDetail = typeof data?.detail === "object" && data?.detail !== null ? data.detail : null;
const detail =
(typeof data?.detail === "string" && data.detail) ||
(typeof structuredDetail?.message === "string" && structuredDetail.message) ||
(typeof structuredDetail?.detail === "string" && structuredDetail.detail) ||
(typeof data?.message === "string" && data.message) ||
(typeof data?.error === "string" && data.error);
if (detail) {
@ -164,7 +161,7 @@ export const queryClient = new QueryClient({
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: 5_000,
staleTime: Infinity,
retry: false,
},
mutations: {

View File

@ -43,6 +43,7 @@ const trustTimeline = [
export default function About() {
const prefersReducedMotion = useReducedMotion();
const heroMotion = prefersReducedMotion ? {} : pageEnterMotion;
return (
<div className="min-h-screen bg-background text-foreground">
@ -51,9 +52,9 @@ export default function About() {
<main className="pt-24 pb-16">
<motion.section
className="relative overflow-hidden"
{...(prefersReducedMotion
? {}
: { initial: pageEnterMotion.initial, animate: pageEnterMotion.animate })}
initial={heroMotion.initial}
animate={heroMotion.animate}
transition={heroMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent blur-[120px]" />

View File

@ -35,6 +35,7 @@ const posts: BlogPost[] = [
export default function Blog() {
const prefersReducedMotion = useReducedMotion();
const heroMotion = prefersReducedMotion ? {} : pageEnterMotion;
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -49,9 +50,9 @@ export default function Blog() {
<main className="pt-24 pb-16">
<motion.section
className="relative overflow-hidden px-6 py-16"
{...(prefersReducedMotion
? {}
: { initial: pageEnterMotion.initial, animate: pageEnterMotion.animate })}
initial={heroMotion.initial}
animate={heroMotion.animate}
transition={heroMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(59,130,246,0.12),transparent_0)] [background-size:120px_120px] opacity-40" />

View File

@ -51,6 +51,7 @@ export default function Disclosures() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/18 via-primary/5 to-transparent blur-[120px]" />

View File

@ -59,6 +59,7 @@ export default function LearnMore() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-16 top-0 h-80 w-80 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent blur-[120px]" />

View File

@ -88,10 +88,8 @@ type PaperMtmResponse = {
};
type MarketStatusResponse = {
status?: "OPEN" | "CLOSED" | "HOLIDAY";
reason?: string;
status?: "OPEN" | "CLOSED";
checked_at?: string;
next_open_at?: string | null;
};
type EngineStatus = {
@ -168,7 +166,7 @@ function formatSignedCurrency(value: number, decimals = 2) {
}
function formatTimestamp(value?: string) {
if (!value) return "-";
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;

View File

@ -63,6 +63,7 @@ export default function Privacy() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/18 via-primary/5 to-transparent blur-[120px]" />

View File

@ -51,6 +51,7 @@ export default function Terms() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/18 via-primary/5 to-transparent blur-[120px]" />