main
tsx 87 lines 6.16 KB
Raw
1 "use client";
2
3 import { useEffect, useState } from "react";
4 import { request } from "../lib/portfolio-api";
5
6 export type Opportunity = {
7 global_instrument_id: string; company_name?: string; symbol?: string; current_price: number | null;
8 opportunity_score: number | null; opportunity_confidence: number; score_coverage: number;
9 new_investor_action: string; existing_holder_action: string; current_short_action: string; current_long_action: string;
10 short_entry_low: number | null; short_entry_high: number | null; short_target_1: number | null;
11 short_target_2: number | null; short_invalidation: number | null; long_entry_low: number | null;
12 long_entry_high: number | null; long_fair_value: number | null; long_target: number | null; long_invalidation: number | null;
13 top_positive_reasons: string[]; top_negative_reasons: string[]; data_state: string;
14 missing_areas: string[]; stale_areas: string[]; short_horizon: string; long_horizon: string;
15 short_term_state: string; long_term_state: string; lifecycle_reasons?: string[]; evaluation_status?: string;
16 };
17 export type Radar = { generated_at: string | null; best_buy_today: Opportunity | null;
18 top_short_term: Opportunity[]; top_long_term: Opportunity[]; previous_recommendations: Opportunity[] };
19
20 export function price(value: number | null | undefined): string {
21 return value == null ? "Insufficient evidence" : `₹${value.toLocaleString("en-IN", { maximumFractionDigits: 2 })}`;
22 }
23 function range(low: number | null, high: number | null): string {
24 return low == null || high == null ? "Insufficient evidence" : `${price(low)} – ${price(high)}`;
25 }
26 function label(value: string | undefined) { return value?.replaceAll("_", " ") ?? "Unavailable"; }
27
28 export function OpportunityCard({ item, horizon, held, watchlisted }: {
29 item: Opportunity; horizon: "short" | "long"; held: boolean; watchlisted: boolean;
30 }) {
31 return <article className="opportunity-card">
32 <h4>{item.company_name ?? item.symbol} <small>{item.symbol}</small></h4>
33 <p>{price(item.current_price)} {held ? " · Held" : ""}{watchlisted ? " · Watchlisted" : ""}</p>
34 <p>Opportunity {item.opportunity_score?.toFixed(1) ?? "Unavailable"} · Confidence {item.opportunity_confidence.toFixed(1)}% · Coverage {item.score_coverage.toFixed(1)}%</p>
35 <p>New investor: {label(item.new_investor_action)}<br />Existing holder: {label(item.existing_holder_action)}</p>
36 <strong>{label(horizon === "short" ? item.current_short_action : item.current_long_action)}</strong>
37 <dl>{horizon === "short" ? <>
38 <dt>Entry range</dt><dd>{range(item.short_entry_low, item.short_entry_high)}</dd>
39 <dt>Target 1</dt><dd>{price(item.short_target_1)}</dd><dt>Target 2</dt><dd>{price(item.short_target_2)}</dd>
40 <dt>Invalidation</dt><dd>{price(item.short_invalidation)}</dd><dt>Horizon</dt><dd>{item.short_horizon}</dd>
41 </> : <>
42 <dt>Accumulation range</dt><dd>{range(item.long_entry_low, item.long_entry_high)}</dd>
43 <dt>Fair value</dt><dd>{price(item.long_fair_value)}</dd><dt>Target</dt><dd>{price(item.long_target)}</dd>
44 <dt>Invalidation</dt><dd>{price(item.long_invalidation)}</dd><dt>Horizon</dt><dd>{item.long_horizon}</dd>
45 </>}</dl>
46 <p><strong>Why</strong></p><ul>{item.top_positive_reasons.slice(0, 4).map(r => <li key={r}>{label(r)}</li>)}</ul>
47 <p><strong>Risks</strong></p><ul>{item.top_negative_reasons.slice(0, 3).map(r => <li key={r}>{label(r)}</li>)}</ul>
48 <details><summary>Data: {label(item.data_state)}</summary>
49 <p>Missing: {item.missing_areas.join(", ") || "None reported"}</p><p>Stale: {item.stale_areas.join(", ") || "None reported"}</p>
50 </details>
51 </article>;
52 }
53
54 export function RadarContent({ data, heldIds = [], watchlistedIds = [] }: { data: Radar; heldIds?: string[]; watchlistedIds?: string[] }) {
55 const card = (item: Opportunity, horizon: "short" | "long") => <OpportunityCard key={item.global_instrument_id} item={item} horizon={horizon}
56 held={heldIds.includes(item.global_instrument_id)} watchlisted={watchlistedIds.includes(item.global_instrument_id)} />;
57 return <>
58 <p>{data.generated_at ? `Last cycle: ${new Date(data.generated_at).toLocaleString()}` : "No persisted opportunity cycle yet."}</p>
59 <h3>BEST BUY TODAY</h3>
60 {data.best_buy_today ? <p>{data.best_buy_today.company_name ?? data.best_buy_today.symbol} · {price(data.best_buy_today.current_price)} · {label(data.best_buy_today.new_investor_action)}</p> : <p>No qualifying buy candidate.</p>}
61 <h3>TOP SHORT-TERM OPPORTUNITIES</h3>
62 <div className="opportunity-cards">{data.top_short_term.map(item => card(item, "short"))}</div>
63 {!data.top_short_term.length && <p>No qualifying short-term opportunities.</p>}
64 <h3>TOP LONG-TERM OPPORTUNITIES</h3>
65 <div className="opportunity-cards">{data.top_long_term.map(item => card(item, "long"))}</div>
66 {!data.top_long_term.length && <p>No qualifying long-term opportunities.</p>}
67 <h3>PREVIOUS RECOMMENDATIONS</h3>
68 {!data.previous_recommendations.length ? <p>No previous recommendations.</p> : <ul>{data.previous_recommendations.map(item =>
69 <li key={item.global_instrument_id}>{item.company_name ?? item.symbol} · Short: {label(item.current_short_action)} ({label(item.short_term_state)}) · Long: {label(item.current_long_action)} ({label(item.long_term_state)})
70 <p>{item.lifecycle_reasons?.map(label).join(" · ")}{item.evaluation_status ? ` · ${label(item.evaluation_status)}` : ""}</p>
71 </li>)}</ul>}
72 </>;
73 }
74
75 export function OpportunityRadar({ heldIds, watchlistedIds }: { heldIds: string[]; watchlistedIds: string[] }) {
76 const [data, setData] = useState<Radar | null>(null);
77 const [error, setError] = useState(false);
78 useEffect(() => {
79 let active = true;
80 request<Radar>("/api/v1/research/opportunities/current").then(value => { if (active) setData(value); }).catch(() => { if (active) setError(true); });
81 return () => { active = false; };
82 }, []);
83 return <section className="opportunity-radar" aria-label="Global opportunity radar">
84 <h2>GLOBAL OPPORTUNITY RADAR</h2>
85 {error ? <p role="alert">Opportunity radar unavailable.</p> : data ? <RadarContent data={data} heldIds={heldIds} watchlistedIds={watchlistedIds} /> : <p>Loading persisted opportunities…</p>}
86 </section>;
87 }