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) => (
+ setTpl(t)}>
+ {t.label} {t.desc}
+
+ )}
+
+
+ setChannels(e.currentTarget.value)} />
+ Pasang notifikasi
+
+
+
+
+ 2. Dikirim ke mana? Telegram / Discord milikmu. Token tersimpan aman di server.
setDkind("telegram")}>Telegram
setDkind("discord")}>Discord
-
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)} />
)}
-
Add destination
+
Tambah tujuan
{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"}
- toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}
- delDest(d.id)}>Delete
+ toggleDest(d.id, d.enabled)}>{d.enabled ? "Matikan" : "Nyalakan"}
+ delDest(d.id)}>Hapus
)}
-
- New rule
-
- setName(e.currentTarget.value)} />
-
- setField(e.currentTarget.value)}>
- {(f) => {f} }
-
- setOp(e.currentTarget.value)}>
- {(o) => {o} }
-
- setValue(Number(e.currentTarget.value))} />
- + condition
-
-
- {(c, i) => {c.field} {c.op} {c.value} setConds((p) => p.filter((_, j) => j !== i()))}>Γ }
-
- Rule JSON: {rulePreview()}
-
- setChannels(e.currentTarget.value)} />
- Create rule
-
-
-
- Rules
+ Notifikasi terpasang
- }>
- {(a) => {a.name} last: {a.last_fired || "never"} del(a.id)}>Delete }
+ }>
+ {(a) => {a.name} terakhir: {a.last_fired || "belum pernah"} del(a.id)}>Hapus }
- Event history
+ Kejadian terakhir
refetchEv()}>Refresh
- 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 saham Siapa 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 pasar Skor beta = {betaN().toFixed(2)}
- {risk() ? String(risk()!.beta) : "β¦"}
- Correlation heatmap
-
-
-
-
- Agent accuracy
-
-
- Agent Calls Resolved Hit %
-
-
- {(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
-
-
- setType(e.currentTarget.value)}>
- {(t) => {t} }
-
-
- setSchedule(e.currentTarget.value)} />
- setChannels(e.currentTarget.value)} />
- Subscribe
+ Mau dilayani apa? Jadwal standar sudah diatur (mis. ringkasan jam 07:30) β tidak perlu isi cron.
+
+
+ {([tipe, m]) => (
+ setType(tipe)}>
+ {m.judul} {m.desc}
+
+ )}
+
+ Aktifkan β
+
}>
{(r) => (
- {r.type}
- {r.enabled ? "on" : "off"}
+ {MANFAAT[r.type]?.judul || r.type}
+ {r.enabled ? "jalan" : "mati"}
- {r.schedule_cron}
+ {MANFAAT[r.type]?.desc || ""}
- toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}
- del(r.id)}>Delete
+ toggle(r.id, r.enabled)}>{r.enabled ? "Matikan" : "Nyalakan"}
+ del(r.id)}>Hapus
@@ -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
refetchRuns()}>Refresh
@@ -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 (
-
-
-
-
-
- setMode("q")}>Natural language
- setMode("where")}>SQL-like
-
-
- 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} />
-
-
{busy() ? "Screeningβ¦" : "Screen"}
-
-
- broker β₯
- setBrokerMin(Number(e.currentTarget.value))} />
-
-
-
- foreign inflow
-
-
-
- insider buying
-
-
- {err()}
-
-
+
+
Cari saham menarik π
+
Pilih satu kategori β sistem yang menyaring buat kamu.
+
+
+ {(p) => (
+
+ {p.label} {p.desc}
+ runPreset(p)} disabled={busy()}>{busy() && aktif() === p.label ? "Menyaringβ¦" : "Tampilkan"}
+
+ )}
+
+
{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} .
+
+
+ navigate(`/report/${s.row.symbol}`)}>Kenapa? Jelaskan
+
- )}
-
+ );
+ }}
-
-
-
-
- Saved screeners
-
-
- setSname(e.currentTarget.value)} />
- Save current
-
-
- {([n, query]) => { setQ(query); run(); }}>{n} {query.slice(0, 60)} }
-
-
-
+
);
}