feat: dashboard substansial — harga close per kartu, konteks pasar, grafik berformat Rp + periode
This commit is contained in:
@@ -28,6 +28,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var accs []accRow
|
var accs []accRow
|
||||||
foreignTotal := 0.0
|
foreignTotal := 0.0
|
||||||
|
var closes []map[string]any
|
||||||
var cites []model.Citation
|
var cites []model.Citation
|
||||||
for _, tk := range wl {
|
for _, tk := range wl {
|
||||||
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
|
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
|
||||||
@@ -45,6 +46,9 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
foreignTotal += nets[len(nets)-1]
|
foreignTotal += nets[len(nets)-1]
|
||||||
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
|
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
|
||||||
}
|
}
|
||||||
|
if px, dx, err := s.DB.LatestClose(tk); err == nil {
|
||||||
|
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
|
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
|
||||||
if len(accs) > 5 {
|
if len(accs) > 5 {
|
||||||
@@ -52,7 +56,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"date": date, "foreign_net_total": foreignTotal,
|
"date": date, "foreign_net_total": foreignTotal,
|
||||||
"top_accumulation": accs, "citations": cites,
|
"top_accumulation": accs, "closes": closes, "citations": cites,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,5 +136,14 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
|
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
|
||||||
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
|
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
|
||||||
|
"start": startOf(dates), "end": dates[len(dates)-1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startOf returns the first series date ("" when empty).
|
||||||
|
func startOf(dates []string) string {
|
||||||
|
if len(dates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return dates[0]
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -17,17 +17,18 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean }
|
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean }
|
||||||
export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] }
|
export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] }
|
||||||
export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; citations: Citation[] }
|
export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; closes: { ticker: string; close: number; date: string }[]; citations: Citation[] }
|
||||||
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] }
|
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] }
|
||||||
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
|
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
|
||||||
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
|
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
|
||||||
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string }
|
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string }
|
||||||
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
|
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
|
||||||
|
export interface ForeignSeries { dates: string[]; nets: number[]; reversal: boolean; start?: string; end?: string; citations: Citation[] }
|
||||||
export const api = {
|
export const api = {
|
||||||
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
||||||
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
||||||
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
|
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
|
||||||
flowForeign: (ticker: string) => req<{ dates: string[]; nets: number[]; reversal: boolean; citations: Citation[] }>(`/api/flow/foreign?ticker=${ticker}`),
|
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${ticker}`),
|
||||||
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
|
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
|
||||||
routines: () => req<{ routines: Routine[] }>("/api/routines"),
|
routines: () => req<{ routines: Routine[] }>("/api/routines"),
|
||||||
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
|
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|||||||
+48
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { ScreenRow } from "./api";
|
import type { ScreenRow, FlowSummary, ForeignSeries } from "./api";
|
||||||
|
|
||||||
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
|
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
|
||||||
export const ISTILAH: Record<string, string> = {
|
export const ISTILAH: Record<string, string> = {
|
||||||
@@ -68,3 +68,50 @@ export function heroSummary(briefing: string): string[] {
|
|||||||
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
|
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
|
||||||
return out.slice(0, 3);
|
return out.slice(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtRp: 1.78T / 305B / 25M — untuk harga pakai fmtHarga.
|
||||||
|
export function fmtRp(v: number): string {
|
||||||
|
const neg = v < 0; const a = Math.abs(v);
|
||||||
|
let s: string;
|
||||||
|
if (a >= 1e12) s = `Rp${(a / 1e12).toFixed(2)}T`;
|
||||||
|
else if (a >= 1e9) s = `Rp${Math.round(a / 1e9)}B`;
|
||||||
|
else if (a >= 1e6) s = `Rp${Math.round(a / 1e6)}M`;
|
||||||
|
else s = `Rp${Math.round(a)}`;
|
||||||
|
return neg ? "-" + s : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtHarga(v: number): string {
|
||||||
|
return `Rp${Math.round(v).toLocaleString("id-ID")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerita 1 kalimat per kartu sorotan: harga + arah asing + broker.
|
||||||
|
export function ceritaKartu(args: {
|
||||||
|
symbol: string; verdict: Verdict; alasan: string;
|
||||||
|
close?: number; closeDate?: string; foreignNet?: number;
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (args.close) parts.push(`terakhir ${fmtHarga(args.close)}`);
|
||||||
|
if (args.foreignNet !== undefined) {
|
||||||
|
parts.push(args.foreignNet >= 0
|
||||||
|
? `asing beli bersih ${fmtRp(args.foreignNet)}`
|
||||||
|
: `asing jual bersih ${fmtRp(Math.abs(args.foreignNet))}`);
|
||||||
|
}
|
||||||
|
parts.push(args.alasan);
|
||||||
|
return parts.join(" · ") + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konteks pasar: berapa Dilirik vs Dilepas + total asing hari ini.
|
||||||
|
export function konteksPasar(sorotan: { verdict: Verdict }[], foreignTotal?: number): string {
|
||||||
|
const up = sorotan.filter((s) => s.verdict === "Dilirik").length;
|
||||||
|
const down = sorotan.filter((s) => s.verdict === "Dilepas").length;
|
||||||
|
const mood = up > down ? "mayoritas dilirik — pasar lagi berani."
|
||||||
|
: down > up ? "mayoritas dilepas — pasar lagi hati-hati."
|
||||||
|
: "pasar campur aduk — belum ada arah jelas.";
|
||||||
|
let s = `Dari ${sorotan.length} saham pantauan: ${up} dilirik, ${down} dilepas — ${mood}`;
|
||||||
|
if (foreignTotal !== undefined) {
|
||||||
|
s += foreignTotal >= 0
|
||||||
|
? ` Total asing hari ini beli bersih ${fmtRp(foreignTotal)}.`
|
||||||
|
: ` Total asing hari ini jual bersih ${fmtRp(Math.abs(foreignTotal))}.`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|||||||
+44
-11
@@ -1,7 +1,7 @@
|
|||||||
import { createResource, createSignal, For, Show } from "solid-js";
|
import { createResource, createSignal, For, Show } from "solid-js";
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
import { A, useNavigate } from "@solidjs/router";
|
||||||
import { api, fmtIDR } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { verdictFor, heroSummary } from "../lib/awam";
|
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
|
||||||
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
||||||
import { Badge } from "../components/ui/badge";
|
import { Badge } from "../components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
@@ -27,10 +27,32 @@ export default function Dashboard() {
|
|||||||
return p ? heroSummary(p) : [];
|
return p ? heroSummary(p) : [];
|
||||||
};
|
};
|
||||||
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
|
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
|
||||||
|
const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c]));
|
||||||
const chartData = () => ({
|
const chartData = () => ({
|
||||||
labels: (foreign()?.dates || []) as string[],
|
labels: (foreign()?.dates || []) as string[],
|
||||||
datasets: [{ label: `${chartTiker()} — uang asing harian`, data: (foreign()?.nets || []) as number[] }],
|
datasets: [{
|
||||||
|
label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`,
|
||||||
|
data: (foreign()?.nets || []) as number[],
|
||||||
|
borderColor: "#3b82f6",
|
||||||
|
backgroundColor: "rgba(59,130,246,.15)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.25,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
const chartOpts = () => ({
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
tooltip: { callbacks: { label: (c: { parsed: { y: number } }) => ` ${fmtRp(c.parsed.y)}` } },
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { ticks: { callback: (v: number | string) => fmtRp(Number(v)), maxTicksLimit: 5 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const periode = () => {
|
||||||
|
const f = foreign();
|
||||||
|
return f?.start && f?.end ? `${f.start} → ${f.end}` : "";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -48,6 +70,9 @@ export default function Dashboard() {
|
|||||||
</ul>
|
</ul>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={sorotan().length}>
|
||||||
|
<p class="mt-3 border-t pt-3 text-sm text-muted-foreground">🧭 {konteksPasar(sorotan(), flow()?.foreign_net_total)}</p>
|
||||||
|
</Show>
|
||||||
<div class="mt-3"><IstilahStrip /></div>
|
<div class="mt-3"><IstilahStrip /></div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -55,22 +80,29 @@ export default function Dashboard() {
|
|||||||
{/* SOROTAN SAHAM */}
|
{/* SOROTAN SAHAM */}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-3 text-lg font-semibold">Saham sorotan hari ini</h2>
|
<h2 class="mb-3 text-lg font-semibold">Saham sorotan hari ini</h2>
|
||||||
<Show when={!screen.loading} fallback={<div class="grid gap-4 md:grid-cols-2"><Skeleton class="h-40" /><Skeleton class="h-40" /></div>}>
|
<Show when={!screen.loading} fallback={<div class="grid gap-4 md:grid-cols-2"><Skeleton class="h-44" /><Skeleton class="h-44" /></div>}>
|
||||||
<Show when={sorotan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada data sorotan.</p>}>
|
<Show when={sorotan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada data sorotan.</p>}>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<For each={sorotan()}>{(s) => {
|
<For each={sorotan()}>{(s) => {
|
||||||
const b = (s.row.breakdown || {}) as Record<string, unknown>;
|
const b = (s.row.breakdown || {}) as Record<string, unknown>;
|
||||||
|
const cx = closeMap()[s.row.symbol];
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card class={s.verdict === "Dilirik" ? "border-l-4 border-l-emerald-500" : s.verdict === "Dilepas" ? "border-l-4 border-l-red-500" : ""}>
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle>
|
<CardTitle class="text-xl font-bold">
|
||||||
|
{s.row.symbol}
|
||||||
|
<Show when={cx?.close}><span class="ml-2 font-mono text-sm font-normal text-muted-foreground">{fmtHarga(cx.close)}</span></Show>
|
||||||
|
</CardTitle>
|
||||||
<VerdictBadge verdict={s.verdict} />
|
<VerdictBadge verdict={s.verdict} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
<p class="text-sm">Karena: <strong>{s.alasan}</strong>.</p>
|
<p class="text-sm">
|
||||||
|
Harga terakhir <strong class="font-mono">{cx?.close ? fmtHarga(cx.close) : "—"}</strong>
|
||||||
|
{" "}— {s.alasan}.
|
||||||
|
</p>
|
||||||
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
|
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button size="sm" onClick={() => navigate(`/report?t=${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
|
<Button size="sm" onClick={() => navigate(`/report/${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
|
||||||
<Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button>
|
<Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -95,7 +127,8 @@ export default function Dashboard() {
|
|||||||
}</For>
|
}</For>
|
||||||
</div>
|
</div>
|
||||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}>
|
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}>
|
||||||
<Line data={chartData()} options={{ responsive: true }} />
|
<Line data={chartData()} options={chartOpts() as never} />
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Periode {periode()} · sumber: Sectors API (lihat sitasi report).</p>
|
||||||
<Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show>
|
<Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show>
|
||||||
</Show>
|
</Show>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -115,10 +148,10 @@ export default function Dashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Yang paling diborong</CardTitle><CardDescription>Total <Term kata="net" /> beli broker hari ini: <strong>{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</strong></CardDescription></CardHeader>
|
<CardHeader><CardTitle>Yang paling diborong</CardTitle><CardDescription>Total <Term kata="net" /> beli broker 5 hari: <strong>{flow() ? fmtRp(flow()!.foreign_net_total) : "…"}</strong></CardDescription></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul class="space-y-1 text-sm">
|
<ul class="space-y-1 text-sm">
|
||||||
<For each={flow()?.top_accumulation || []}>{(t) => <li class="flex justify-between"><A class="font-medium underline-offset-2 hover:underline" href={`/report?t=${t.ticker}`}>{t.ticker}</A><span class="font-mono">{fmtIDR(t.net_sum)} · {t.brokers} broker</span></li>}</For>
|
<For each={flow()?.top_accumulation || []}>{(t) => <li class="flex justify-between"><A class="font-medium underline-offset-2 hover:underline" href={`/report/${t.ticker}`}>{t.ticker}</A><span class="font-mono">{fmtRp(t.net_sum)} · {t.brokers} broker</span></li>}</For>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div>
|
<div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user