From a35182ead0db57cf07d2824b696d6fc539f43d84 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 15 Sep 2026 20:23:40 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20fase=203=20awam=20=E2=80=94=20Screener?= =?UTF-8?q?=20preset,=20Alerts=20template,=20Routines=20manfaat,=20Portfol?= =?UTF-8?q?io=20saran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/Alerts.tsx | 108 +++++++++++----------------- web/src/pages/Portfolio.tsx | 92 +++++++++--------------- web/src/pages/Routines.tsx | 79 ++++++++++---------- web/src/pages/Screener.tsx | 139 ++++++++++++------------------------ 4 files changed, 162 insertions(+), 256 deletions(-) diff --git a/web/src/pages/Alerts.tsx b/web/src/pages/Alerts.tsx index ff20300..09d6241 100644 --- a/web/src/pages/Alerts.tsx +++ b/web/src/pages/Alerts.tsx @@ -1,25 +1,25 @@ import { createResource, createSignal, For, Show } from "solid-js"; import { api } from "../lib/api"; +import { Term, IstilahStrip } from "../components/Awam"; import { EmptyState, PageHead } from "../components/ui"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { TextField, TextFieldInput } from "../components/ui/text-field"; -const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"]; -const OPS = [">=", "<=", ">", "<", "=="]; - -type Cond = { field: string; op: string; value: number }; +// Template siap pakai -> rule JSON backend. +const TEMPLATES: { label: string; desc: string; rule: Record }[] = [ + { label: "πŸ‹ Bandar mulai borong", desc: "Beri tahu kalau broker besar net-beli di saham watchlist", rule: { all: [{ field: "broker_score", op: ">=", value: 30 }] } }, + { label: "🌍 Asing masuk deras", desc: "Beri tahu kalau uang asing inflow besar", rule: { all: [{ field: "foreign_net", op: ">=", value: 50000000000 }] } }, + { label: "🚨 Bandar mulai jualan", desc: "Beri tahu kalau broker besar net-jual", rule: { all: [{ field: "broker_score", op: "<=", value: -30 }] } }, + { label: "πŸ“’ Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } }, +]; export default function Alerts() { const [alerts, { refetch }] = createResource(() => api.alerts()); const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30))); const [dests, { refetch: refetchDests }] = createResource(() => api.destinations()); - const [name, setName] = createSignal("accumulation watcher"); - const [field, setField] = createSignal("score"); - const [op, setOp] = createSignal(">="); - const [value, setValue] = createSignal(60); - const [conds, setConds] = createSignal([]); + const [tpl, setTpl] = createSignal(TEMPLATES[0]); const [channels, setChannels] = createSignal(""); const [busy, setBusy] = createSignal(false); const [dkind, setDkind] = createSignal("telegram"); @@ -28,19 +28,10 @@ export default function Alerts() { const [dchat, setDchat] = createSignal(""); const [dhook, setDhook] = createSignal(""); const [derr, setDerr] = createSignal(""); - const rulePreview = () => JSON.stringify(conds().length ? { all: conds() } : { all: true }); - function addCond() { - setConds((p) => [...p, { field: field(), op: op(), value: value() }]); - } async function create() { setBusy(true); try { - await api.createAlert({ - name: name(), - rule: JSON.parse(rulePreview()), - channels: channels().split(",").map((c) => c.trim()).filter(Boolean), - }); - setConds([]); + await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) }); refetch(); } finally { setBusy(false); } } @@ -56,35 +47,43 @@ export default function Alerts() { refetchDests(); } catch (e) { setDerr(String(e)); } } - async function toggleDest(id: number, enabled: boolean) { - await api.updateDestination(id, { enabled: !enabled }); - refetchDests(); - } + async function toggleDest(id: number, enabled: boolean) { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); } async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); } return (
- + - - Push destinations (per login) - Secrets stay server-side β€” the list never shows tokens. - + 1. Mau dikabari soal apa?Pilih satu template . + +
+ {(t) => ( + + )} +
+
+ setChannels(e.currentTarget.value)} /> + +
+
+
+ + 2. Dikirim ke mana?Telegram / Discord milikmu. Token tersimpan aman di server.
- setDlabel(e.currentTarget.value)} /> + setDlabel(e.currentTarget.value)} /> {dkind() === "telegram" ? ( - <> - setDbot(e.currentTarget.value)} /> - setDchat(e.currentTarget.value)} /> - + <> setDbot(e.currentTarget.value)} /> + setDchat(e.currentTarget.value)} /> ) : ( setDhook(e.currentTarget.value)} /> )} - +

{derr()}

    @@ -93,61 +92,38 @@ export default function Alerts() {
  • {d.kind} {d.label || `#${d.id}`} - {d.enabled ? "on" : "off"} Β· {d.configured ? "configured" : "missing secret"} + {d.enabled ? "aktif" : "mati"} Β· {d.configured ? "siap" : "belum ada token"} - - + +
  • )}
- - New rule - - setName(e.currentTarget.value)} /> -
- - - setValue(Number(e.currentTarget.value))} /> - -
- -
    {(c, i) =>
  • {c.field} {c.op} {c.value}
  • }
-
-

Rule JSON: {rulePreview()}

-
- setChannels(e.currentTarget.value)} /> - -
-
-
- Rules + Notifikasi terpasang - }> -
    {(a) =>
  • {a.name} last: {a.last_fired || "never"}
  • }
+ }> +
    {(a) =>
  • {a.name} terakhir: {a.last_fired || "belum pernah"}
  • }
- Event history + Kejadian terakhir - No events yet.

}> + Belum ada kejadian.

}>
    {(e) =>
  • {String(e.ticker)} {String(e.message)}
  • }
+
); } diff --git a/web/src/pages/Portfolio.tsx b/web/src/pages/Portfolio.tsx index f89840c..699d62d 100644 --- a/web/src/pages/Portfolio.tsx +++ b/web/src/pages/Portfolio.tsx @@ -1,87 +1,65 @@ import { createResource, For, Show } from "solid-js"; import { api } from "../lib/api"; +import { Term, IstilahStrip } from "../components/Awam"; import { Bar } from "solid-chartjs"; import { Chart, registerables } from "chart.js"; import { PageHead } from "../components/ui"; -import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; +import { Badge } from "../components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Skeleton } from "../components/ui/skeleton"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; Chart.register(...registerables); export default function Portfolio() { const [risk] = createResource(() => api.risk()); - const [acc] = createResource(() => api.accuracy()); + const conc = () => risk()?.concentration || []; + const betaN = () => Number(risk()?.beta ?? 1); + const betaArti = () => { + const b = betaN(); + if (b > 1.2) return "bergerak lebih liar dari pasar β€” naik lebih kencang, turun lebih dalam."; + if (b < 0.8) return "lebih kalem dari pasar β€” cocok buat yang tidak suka deg-degan."; + return "geraknya seirama pasar β€” tidak terlalu liar, tidak terlalu kalem."; + }; + const saran = () => { + const c = conc(); + if (!c.length) return ""; + const top = c[0]; + if (top.weight > 0.4) return `${top.ticker} porsinya ${(top.weight * 100).toFixed(0)}% β€” kebanyakan telur di satu keranjang. Pertimbangkan tambah saham beda sektor.`; + const sek = [...new Set(c.map((x) => x.sector))]; + if (sek.length <= 2) return `Cuma ${sek.length} sektor (${sek.join(", ")}) β€” kalau sektor itu jatuh, semua ikut. Coba lirik sektor lain.`; + return "Sebarannya sudah lumayan β€” tidak numpuk di satu tempat. Pertahankan. πŸ‘"; + }; const chartData = () => ({ - labels: (risk()?.concentration || []).map((c) => c.ticker), - datasets: [{ label: "Weight %", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }], + labels: conc().map((c) => c.ticker), + datasets: [{ label: "Porsi %", data: conc().map((c) => +(c.weight * 100).toFixed(1)) }], }); return (
- -
}> + + }> + + Saran 1 kalimat πŸ’‘ +

{saran() || "Tambah saham ke watchlist dulu biar bisa dinilai."}

+
- Concentration + Porsi tiap sahamSiapa yang paling dominan di -mu. - Add tickers to your watchlist to see concentration.

}> + Tambah saham ke watchlist dulu.

}>
- {(w) =>

⚠️ {w}

}
+ {(w) =>

⚠️ {w}

}
- Beta vs index + Kelakuan vs pasarSkor beta = {betaN().toFixed(2)} -

{risk() ? String(risk()!.beta) : "…"}

-

Correlation heatmap

- -
-
- - Agent accuracy - - - AgentCallsResolvedHit % - - - {(a) => {a.agent}{a.calls}{a.resolved}{(a.hit_rate * 100).toFixed(0)}%} - - -
+

Portofolio kamu {betaArti()}

+
: {[...new Set(conc().map((x) => x.sector))].length} sektor
+
); } - -function CorrHeatmap(props: { matrix: Record> }) { - const keys = () => Object.keys(props.matrix); - const cell = (v: number) => { - const a = Math.min(1, Math.abs(v)); - return v >= 0 ? `rgba(34,197,94,${(0.12 + 0.6 * a).toFixed(2)})` : `rgba(239,68,68,${(0.12 + 0.6 * a).toFixed(2)})`; - }; - return ( - No correlation data yet.

}> -
- {(k) => {k}} - - - {(r) => ( - - {r} - - {(c) => { - const v = props.matrix[r]?.[c] ?? 0; - return {v.toFixed(2)}; - }} - - - )} - - -
-
- ); -} diff --git a/web/src/pages/Routines.tsx b/web/src/pages/Routines.tsx index 84ec00d..4e2e2e8 100644 --- a/web/src/pages/Routines.tsx +++ b/web/src/pages/Routines.tsx @@ -1,73 +1,70 @@ import { createResource, createSignal, For, Show } from "solid-js"; import { api } from "../lib/api"; -import { Citations } from "../components/Citations"; +import { Term, IstilahStrip } from "../components/Awam"; import { EmptyState, PageHead } from "../components/ui"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; -import { TextField, TextFieldInput } from "../components/ui/text-field"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; -const TYPES = ["morning-briefing", "accumulation-radar", "foreign-reversal", "insider-tape", "earnings-countdown", "dividend-calendar", "weekend-review"]; +// Tipe -> penjelasan manfaat bahasa awam. +const MANFAAT: Record = { + "morning-briefing": { judul: "β˜€οΈ Ringkasan pagi", desc: "Dapat kabar pasar tiap pagi jam 07:30 β€” siapa diborong, ke mana uang asing." }, + "accumulation-radar": { judul: "πŸ‹ Radar borongan bandar", desc: "Dikabari kalau broker besar mulai borong saham." }, + "foreign-reversal": { judul: "🌍 Pantau balik arah asing", desc: "Dikabari kalau uang asing berbalik arah (masuk ↔ keluar)." }, + "insider-tape": { judul: "πŸ•΅οΈ Gerak orang dalam", desc: "Dikabari kalau direksi/pemilik saham ikut beli." }, + "earnings-countdown": { judul: "πŸ“Š Pengingat laporan keuangan", desc: "Dikabari sebelum emiten rilis laporan keuangan." }, + "dividend-calendar": { judul: "πŸ’° Pengingat dividen", desc: "Dikabari sebelum tanggal bagi dividen (biar tidak kelewat)." }, + "weekend-review": { judul: "πŸ“ Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." }, +}; export default function Routines() { const [data, { refetch }] = createResource(() => api.routines()); const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20))); const [type_, setType] = createSignal("morning-briefing"); - const [schedule, setSchedule] = createSignal(""); - const [channels, setChannels] = createSignal(""); const [busy, setBusy] = createSignal(false); const [briefing] = createResource(() => api.briefing().catch(() => null)); async function subscribe() { setBusy(true); - try { - await api.createRoutine({ - type: type_(), - schedule_cron: schedule().trim() || undefined, - channels: channels().split(",").map((c) => c.trim()).filter(Boolean), - }); - refetch(); - } finally { setBusy(false); } - } - async function toggle(id: number, enabled: boolean) { - await api.updateRoutine(id, { enabled: !enabled }); - refetch(); + try { await api.createRoutine({ type: type_() }); refetch(); } + finally { setBusy(false); } } + async function toggle(id: number, enabled: boolean) { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } async function del(id: number) { await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } }); refetch(); } return (
- + - Subscribe - - - - - setSchedule(e.currentTarget.value)} /> - setChannels(e.currentTarget.value)} /> - + Mau dilayani apa?Jadwal standar sudah diatur (mis. ringkasan jam 07:30) β€” tidak perlu isi cron. + +
+ {([tipe, m]) => ( + + )} +
+
+ }>
{(r) => ( - {r.type} - {r.enabled ? "on" : "off"} + {MANFAAT[r.type]?.judul || r.type} + {r.enabled ? "jalan" : "mati"} -

{r.schedule_cron}

+

{MANFAAT[r.type]?.desc || ""}

- - + +
@@ -77,17 +74,18 @@ export default function Routines() {
- Latest briefing + Ringkasan terakhir pagi yang sudah terbit. - No briefing yet β€” it generates after the first morning-briefing run.

}> -
{(briefing() as { payload: string })?.payload?.slice(0, 800)}
-
+ Belum terbit β€” aktifkan "Ringkasan pagi" di atas.

}> + {(briefing() as { payload: string })?.payload?.slice(0, 600)}}> +

{(briefing() as { narasi?: string })?.narasi}

+
- Run history + Riwayat jalan @@ -95,6 +93,7 @@ export default function Routines() {
+
); } diff --git a/web/src/pages/Screener.tsx b/web/src/pages/Screener.tsx index 55e1821..bb364b0 100644 --- a/web/src/pages/Screener.tsx +++ b/web/src/pages/Screener.tsx @@ -1,126 +1,79 @@ import { createSignal, For, Show } from "solid-js"; import { api, type ScreenRow } from "../lib/api"; -import { Citations } from "../components/Citations"; -import { BreakdownBars, EmptyState, PageHead, RecBadge } from "../components/ui"; +import { verdictFor } from "../lib/awam"; +import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Skeleton } from "../components/ui/skeleton"; -import { TextField, TextFieldInput } from "../components/ui/text-field"; -import { Switch, SwitchControl, SwitchLabel, SwitchThumb } from "../components/ui/switch"; +import { useNavigate } from "@solidjs/router"; -const SAVED_KEY = "fs-saved-screeners"; - -function scoreOf(r: ScreenRow): number { - const b = r.breakdown as Record; - for (const k of ["composite", "score", "broker_score", "broker"]) { - if (typeof b[k] === "number") return b[k] as number; - } - return r.composite || 0; -} +// Preset awam -> body /api/screen. +const PRESETS: { label: string; desc: string; body: Record }[] = [ + { label: "πŸ”₯ Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } }, + { label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } }, + { label: "πŸ•΅οΈ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } }, + { label: "πŸ“¦ Semua β€” urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } }, +]; export default function Screener() { - const [mode, setMode] = createSignal<"where" | "q">("q"); - const [q, setQ] = createSignal(""); - const [brokerMin, setBrokerMin] = createSignal(0); - const [foreignOnly, setForeignOnly] = createSignal(false); - const [insiderOnly, setInsiderOnly] = createSignal(false); + const navigate = useNavigate(); const [rows, setRows] = createSignal([]); const [ran, setRan] = createSignal(false); const [busy, setBusy] = createSignal(false); const [err, setErr] = createSignal(""); - const loadSaved = (): Record => { - try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; } - }; - const [saved, setSaved] = createSignal>(loadSaved()); - const [sname, setSname] = createSignal(""); - async function run() { - setErr(""); setBusy(true); + const [aktif, setAktif] = createSignal(""); + async function runPreset(p: (typeof PRESETS)[number]) { + setErr(""); setBusy(true); setAktif(p.label); try { - const institutional: Record = { broker_score_min: brokerMin() }; - if (foreignOnly()) institutional.foreign_inflow = true; - if (insiderOnly()) institutional.insider_buying = true; - const body = mode() === "q" - ? { q: q() || undefined, institutional, limit: 20 } - : { where: q() || undefined, institutional, limit: 20 }; - const r = await api.screen(body); + const r = await api.screen(p.body); setRows(r.rows); setRan(true); } catch (e) { setErr(String(e)); } finally { setBusy(false); } } - function save() { - const all = { ...saved(), [sname().trim() || q().slice(0, 24) || "untitled"]: q() }; - localStorage.setItem(SAVED_KEY, JSON.stringify(all)); - setSaved(all); - } + const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) })); return (
- - - -
-
- - -
- - 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} /> - - -
-
- - - - foreign inflow - - - - insider buying - -
-

{err()}

-
-
+
+

Cari saham menarik πŸ”

+

Pilih satu kategori β€” sistem yang menyaring buat kamu.

+
+
+ {(p) => ( + + {p.label}{p.desc} + + + )} +
+

{err()}

- }> + Tidak ada yang cocok β€” coba kategori lain.

}> +

Hasil ({hasil().length})

- - {(r) => ( + {(s) => { + const b = (s.row.breakdown || {}) as Record; + return ( - {r.symbol} - {r.composite.toFixed(1)} + {s.row.symbol} + - - } /> - - = 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /> + +

Karena: {s.alasan}.

+ +
+ +
- )} -
+ ); + }}
- - - - - Saved screeners - -
- setSname(e.currentTarget.value)} /> - -
-
    - {([n, query]) =>
  • {query.slice(0, 60)}
  • }
    -
-
-
+
); }