diff --git a/backend/internal/api/flow.go b/backend/internal/api/flow.go index 5bfcba5..f5bb02c 100644 --- a/backend/internal/api/flow.go +++ b/backend/internal/api/flow.go @@ -28,6 +28,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) { } var accs []accRow foreignTotal := 0.0 + var closes []map[string]any var cites []model.Citation for _, tk := range wl { 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] 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 }) 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{ "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{ "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])}, + "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] +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 18f5769..d4ea0f3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -17,17 +17,18 @@ async function req(path: string, init?: RequestInit): Promise { } 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 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; 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 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 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 = { health: (force = false) => req(`/api/health${force ? "?force=1" : ""}`), flowSummary: () => req("/api/flow/summary"), 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(`/api/flow/foreign?ticker=${ticker}`), screen: (body: Record) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }), routines: () => req<{ routines: Routine[] }>("/api/routines"), createRoutine: (body: Record) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }), diff --git a/web/src/lib/awam.ts b/web/src/lib/awam.ts index c32fcef..51ac0c1 100644 --- a/web/src/lib/awam.ts +++ b/web/src/lib/awam.ts @@ -1,4 +1,4 @@ -import type { ScreenRow } from "./api"; +import type { ScreenRow, FlowSummary, ForeignSeries } from "./api"; // Kamus istilah: kata teknis -> penjelasan santai 1 kalimat. export const ISTILAH: Record = { @@ -68,3 +68,50 @@ export function heroSummary(briefing: string): string[] { if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220)); 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; +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 8271d46..f61c5b5 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import { createResource, createSignal, For, Show } from "solid-js"; import { A, useNavigate } from "@solidjs/router"; -import { api, fmtIDR } from "../lib/api"; -import { verdictFor, heroSummary } from "../lib/awam"; +import { api } from "../lib/api"; +import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam"; import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam"; import { Badge } from "../components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; @@ -27,10 +27,32 @@ export default function Dashboard() { return p ? heroSummary(p) : []; }; const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) })); + const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c])); const chartData = () => ({ 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 (
@@ -48,6 +70,9 @@ export default function Dashboard() { + +

🧭 {konteksPasar(sorotan(), flow()?.foreign_net_total)}

+
@@ -55,22 +80,29 @@ export default function Dashboard() { {/* SOROTAN SAHAM */}

Saham sorotan hari ini

-
}> +
}> Belum ada data sorotan.

}>
{(s) => { const b = (s.row.breakdown || {}) as Record; + const cx = closeMap()[s.row.symbol]; return ( - + - {s.row.symbol} + + {s.row.symbol} + {fmtHarga(cx.close)} + -

Karena: {s.alasan}.

+

+ Harga terakhir {cx?.close ? fmtHarga(cx.close) : "—"} + {" "}— {s.alasan}. +

- +
@@ -95,7 +127,8 @@ export default function Dashboard() { }
Belum ada data grafik.

}> - + +

Periode {periode()} · sumber: Sectors API (lihat sitasi report).

⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.

@@ -115,10 +148,10 @@ export default function Dashboard() { - Yang paling diborongTotal beli broker hari ini: {flow() ? fmtIDR(flow()!.foreign_net_total) : "…"} + Yang paling diborongTotal beli broker 5 hari: {flow() ? fmtRp(flow()!.foreign_net_total) : "…"}
    - {(t) =>
  • {t.ticker}{fmtIDR(t.net_sum)} · {t.brokers} broker
  • }
    + {(t) =>
  • {t.ticker}{fmtRp(t.net_sum)} · {t.brokers} broker
  • }
Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️