feat: briefing auto-generate, pages overhaul (dashboard/screener/report/routines), delete routine
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -11,6 +12,9 @@ import (
|
||||
"flowsight/internal/store"
|
||||
)
|
||||
|
||||
// todayUTC returns today's date (UTC) for briefing persistence.
|
||||
func todayUTC() string { return time.Now().UTC().Format("2006-01-02") }
|
||||
|
||||
// ListRoutines serves GET /api/routines with last-run status.
|
||||
func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.DB.ListRoutines(s.userKey(r))
|
||||
@@ -108,11 +112,23 @@ func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// BriefingToday serves GET /api/briefing/today: latest payload + citations.
|
||||
// When no briefing exists yet it generates one on demand from current
|
||||
// snapshots (so the UI never shows an empty page), then persists it.
|
||||
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
|
||||
date, payload, cites, err := s.DB.LatestBriefing()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "no briefing yet — run the morning-briefing routine")
|
||||
return
|
||||
uk := s.userKey(r)
|
||||
p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk)
|
||||
if gerr != nil {
|
||||
writeErr(w, http.StatusNotFound, "no briefing yet — subscribe to the morning-briefing routine")
|
||||
return
|
||||
}
|
||||
ccJSON, _ := json.Marshal(cc)
|
||||
if err := s.DB.SaveBriefing(todayUTC(), p, string(ccJSON)); err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
||||
return
|
||||
}
|
||||
date, payload, cites = todayUTC(), p, string(ccJSON)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"date": date, "payload": payload, "citations": cites,
|
||||
|
||||
@@ -146,6 +146,16 @@ func (e *Engine) deliver(ctx context.Context, r store.Routine, payload string, c
|
||||
return e.Notifier.SendTo(ctx, e.UserKey, f, string(cc))
|
||||
}
|
||||
|
||||
// BriefingFor runs the morning briefing scoped to owner (watchlist +
|
||||
// delivery) without needing a routines row.
|
||||
func (e *Engine) BriefingFor(ctx context.Context, owner string) (string, []model.Citation, error) {
|
||||
scoped := *e
|
||||
if owner != "" {
|
||||
scoped.UserKey = owner
|
||||
}
|
||||
return scoped.Briefing(ctx)
|
||||
}
|
||||
|
||||
// tickers returns the user's watchlist (never empty: falls back to demo set).
|
||||
func (e *Engine) tickers() []string {
|
||||
wl, err := e.DB.Watchlist(e.UserKey)
|
||||
|
||||
+32
-23
@@ -2,6 +2,7 @@ import { createResource, createSignal, onCleanup, For, Show } from "solid-js";
|
||||
import { api, subscribeSSE, fmtIDR } from "../lib/api";
|
||||
import { AccumulationTable, ActivityFeed, RotationMap } from "../components/Cards";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { StatCard, EmptyState, Skeleton } from "../components/ui";
|
||||
import { Line } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
Chart.register(...registerables);
|
||||
@@ -11,6 +12,7 @@ export default function Dashboard() {
|
||||
const [flow, { refetch }] = createResource(() => api.flowSummary());
|
||||
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 10)));
|
||||
const [foreign] = createResource(() => api.flowForeign("BBCA").catch(() => null));
|
||||
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
||||
const [live, setLive] = createSignal<string[]>([]);
|
||||
const stop = subscribeSSE((ch, data) => {
|
||||
setLive((p) => [`${ch}: ${data.slice(0, 120)}`, ...p].slice(0, 5));
|
||||
@@ -23,34 +25,41 @@ export default function Dashboard() {
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<h1>Smart Money Dashboard</h1>
|
||||
<div class="fs-page-head">
|
||||
<h1>Smart Money Dashboard</h1>
|
||||
<p class="fs-muted">Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}</p>
|
||||
</div>
|
||||
<Show when={health()?.stale_flags?.length}>
|
||||
<p class="fs-muted">Stale: <For each={health()!.stale_flags}>{(f) => <span>{f}; </span>}</For></p>
|
||||
</Show>
|
||||
<p class="fs-muted">
|
||||
Credits today: {health()?.credits_today ?? "…"} · Scheduler: {health()?.scheduler_ok ? "ok" : "down"} ·
|
||||
Last cycle: {health()?.last_cycle_at || "never"}
|
||||
</p>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-card">
|
||||
<h3>Foreign net total</h3>
|
||||
<p style={{ "font-size": "24px" }}>{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</p>
|
||||
<Citations items={flow()?.citations} />
|
||||
<Show when={!flow.loading} fallback={<Skeleton rows={4} />}>
|
||||
<div class="fs-grid">
|
||||
<StatCard title="Foreign net total" value={flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}>
|
||||
<Citations items={flow()?.citations} />
|
||||
</StatCard>
|
||||
<AccumulationTable rows={flow()?.top_accumulation || []} />
|
||||
<RotationMap signal={undefined} citations={flow()?.citations} />
|
||||
<div class="fs-card">
|
||||
<h3>Foreign flow (BBCA)</h3>
|
||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="fs-muted">No foreign-flow series yet.</p>}>
|
||||
<Line data={flowChart()} options={{ responsive: true }} />
|
||||
<Show when={foreign()?.reversal}><p>Reversal flag set.</p></Show>
|
||||
</Show>
|
||||
</div>
|
||||
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
|
||||
<div class="fs-card">
|
||||
<h3>Live agent panel</h3>
|
||||
<Show when={live().length} fallback={<p class="fs-muted">Waiting for SSE events…</p>}>
|
||||
<ul><For each={live()}>{(l) => <li class="fs-muted">{l}</li>}</For></ul>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<AccumulationTable rows={flow()?.top_accumulation || []} />
|
||||
<RotationMap signal={undefined} citations={flow()?.citations} />
|
||||
</Show>
|
||||
<div style={{ "margin-top": "14px" }}>
|
||||
<div class="fs-card">
|
||||
<h3>Foreign flow (BBCA)</h3>
|
||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="fs-muted">No foreign-flow series yet.</p>}>
|
||||
<Line data={flowChart()} options={{ responsive: true }} />
|
||||
<Show when={foreign()?.reversal}><p>Reversal flag set.</p></Show>
|
||||
</Show>
|
||||
</div>
|
||||
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
|
||||
<div class="fs-card">
|
||||
<h3>Live agent panel</h3>
|
||||
<Show when={live().length} fallback={<p class="fs-muted">Waiting for SSE events…</p>}>
|
||||
<ul><For each={live()}>{(l) => <li class="fs-muted">{l}</li>}</For></ul>
|
||||
<h3>Morning briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet — subscribe to the morning-briefing routine.</p>}>
|
||||
<pre class="fs-muted">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+57
-36
@@ -1,67 +1,88 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { createSignal, For, Show, onMount } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { api, type ReportPayload } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { RecBadge, Skeleton } from "../components/ui";
|
||||
|
||||
export default function Report() {
|
||||
const params = useParams();
|
||||
const [rep, setRep] = createSignal<ReportPayload | null>(null);
|
||||
const [md, setMd] = createSignal("");
|
||||
const [question, setQuestion] = createSignal("kenapa conviction segitu?");
|
||||
const [question, setQuestion] = createSignal("");
|
||||
const [answer, setAnswer] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(true);
|
||||
const [asking, setAsking] = createSignal(false);
|
||||
const [err, setErr] = createSignal("");
|
||||
const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" });
|
||||
async function load() {
|
||||
setErr("");
|
||||
setErr(""); setBusy(true);
|
||||
try { setRep(await api.report(params.ticker)); }
|
||||
catch (e) { setErr(String(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
onMount(load);
|
||||
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
|
||||
async function ask() {
|
||||
const r = await api.interrogate(params.ticker, question());
|
||||
setAnswer(r.answer);
|
||||
if (!question().trim()) return;
|
||||
setAsking(true);
|
||||
try {
|
||||
const r = await api.interrogate(params.ticker, question());
|
||||
setAnswer(r.answer);
|
||||
} finally { setAsking(false); }
|
||||
}
|
||||
function dl(url: string, name: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = name; a.click();
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1>Report: {params.ticker}</h1>
|
||||
<button class="fs-btn" onClick={load}>Generate report</button>
|
||||
<button class="fs-btn ghost" onClick={exportMd}>Export MD</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
|
||||
const html = await r.text();
|
||||
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
|
||||
window.open(url, "_blank");
|
||||
}}>HTML</button>
|
||||
<button class="fs-btn ghost" onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${params.ticker}-report.json`;
|
||||
a.click();
|
||||
}}>JSON</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}}>PDF</button>
|
||||
<Show when={err()}><p class="fs-muted">{err()}</p></Show>
|
||||
<div class="fs-page-head">
|
||||
<h1>Report: {params.ticker}</h1>
|
||||
<button class="fs-btn ghost" onClick={load}>↻ Regenerate</button>
|
||||
</div>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>{err()}</p></Show>
|
||||
<Show when={busy()}><Skeleton rows={5} /></Show>
|
||||
<Show when={rep()}>
|
||||
<div class="fs-card">
|
||||
<h2 class={`rec-${rep()!.synthesis.recommendation}`}>{rep()!.synthesis.recommendation} · conviction {rep()!.synthesis.conviction}/5</h2>
|
||||
<div class="fs-card" style={{ "border-left": "4px solid var(--fs-accent)" }}>
|
||||
<div class="fs-row">
|
||||
<RecBadge rec={rep()!.synthesis.recommendation} />
|
||||
<strong>conviction {rep()!.synthesis.conviction}/5</strong>
|
||||
<Show when={rep()!.synthesis.conflict}><span class="fs-badge info">conflict flagged</span></Show>
|
||||
<span class="fs-muted">position {rep()!.synthesis.position_pct}%</span>
|
||||
</div>
|
||||
<p>{rep()!.synthesis.thesis}</p>
|
||||
<Citations items={rep()!.citations} />
|
||||
</div>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Export</h3>
|
||||
<div class="fs-row">
|
||||
<button class="fs-btn ghost" onClick={exportMd}>Markdown</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
|
||||
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
|
||||
}}>HTML</button>
|
||||
<button class="fs-btn ghost" onClick={() => {
|
||||
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
|
||||
}}>JSON</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
|
||||
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
|
||||
}}>PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<For each={rep()!.sections}>
|
||||
{(s) => <div class="fs-card"><h3>{s.name}</h3><p>{s.body}</p><Citations items={s.citations} /></div>}
|
||||
{(s) => <div class="fs-card" style={{ "margin-top": "14px" }}><h3>{s.name}</h3><p style={{ "white-space": "pre-wrap" }}>{s.body}</p><Citations items={s.citations} /></div>}
|
||||
</For>
|
||||
<div class="fs-card">
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Interrogate this report</h3>
|
||||
<input class="fs-input" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} style={{ width: "320px" }} />
|
||||
<button class="fs-btn" onClick={ask}>Ask</button>
|
||||
<Show when={answer()}><p>{answer()}</p></Show>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="Ask about this report…" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" onClick={ask} disabled={asking()}>{asking() ? "…" : "Ask"}</button>
|
||||
</div>
|
||||
<Show when={answer()}><p style={{ "margin-top": "10px" }}>{answer()}</p></Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={md()}><div class="fs-card"><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
|
||||
<Show when={md()}><div class="fs-card" style={{ "margin-top": "14px" }}><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+62
-37
@@ -1,6 +1,9 @@
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { EmptyState } from "../components/ui";
|
||||
|
||||
const TYPES = ["morning-briefing", "accumulation-radar", "foreign-reversal", "insider-tape", "earnings-countdown", "dividend-calendar", "weekend-review"];
|
||||
|
||||
export default function Routines() {
|
||||
const [data, { refetch }] = createResource(() => api.routines());
|
||||
@@ -8,55 +11,77 @@ export default function Routines() {
|
||||
const [type_, setType] = createSignal("morning-briefing");
|
||||
const [schedule, setSchedule] = createSignal("");
|
||||
const [channels, setChannels] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [runNowId, setRunNowId] = createSignal<number | null>(null);
|
||||
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
||||
async function subscribe() {
|
||||
await api.createRoutine({
|
||||
type: type_(),
|
||||
schedule_cron: schedule().trim() || undefined,
|
||||
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
|
||||
});
|
||||
refetch();
|
||||
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();
|
||||
}
|
||||
async function del(id: number) {
|
||||
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
|
||||
refetch();
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1>Routine Manager</h1>
|
||||
<div class="fs-page-head"><h1>Routine Manager</h1><p class="fs-muted">Scheduled deliveries with citations.</p></div>
|
||||
<div class="fs-card">
|
||||
<h3>Subscribe</h3>
|
||||
<select class="fs-input" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
|
||||
<For each={["morning-briefing","accumulation-radar","foreign-reversal","insider-tape","earnings-countdown","dividend-calendar","weekend-review"]}>
|
||||
{(t) => <option value={t}>{t}</option>}
|
||||
<div class="fs-row">
|
||||
<select class="fs-input" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
|
||||
<For each={TYPES}>{(t) => <option value={t}>{t}</option>}</For>
|
||||
</select>
|
||||
<input class="fs-input" placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} style={{ width: "240px" }} />
|
||||
<input class="fs-input" placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "200px" }} />
|
||||
<button class="fs-btn" onClick={subscribe} disabled={busy()}>Subscribe</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(data()?.routines || []).length} fallback={
|
||||
<div style={{ "margin-top": "14px" }}><EmptyState icon="🗓️" title="No routines yet" hint="Subscribe above — the morning briefing runs 07:30 WIB daily." /></div>
|
||||
}>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<For each={data()?.routines || []}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<div class="fs-row" style={{ "justify-content": "space-between" }}>
|
||||
<strong>{r.type}</strong>
|
||||
<span class={`fs-badge ${r.enabled ? "buy" : "info"}`}>{r.enabled ? "on" : "off"}</span>
|
||||
</div>
|
||||
<p class="fs-muted">{r.schedule_cron}</p>
|
||||
<div class="fs-row">
|
||||
<button class="fs-btn ghost" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</button>
|
||||
<button class="fs-btn ghost" disabled={runNowId() === r.id}>Run</button>
|
||||
<button class="fs-btn danger" onClick={() => del(r.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<input class="fs-input" placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} style={{ width: "260px" }} />
|
||||
<input class="fs-input" placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "220px" }} />
|
||||
<button class="fs-btn" onClick={subscribe}>Subscribe</button>
|
||||
</div>
|
||||
<div class="fs-grid">
|
||||
<For each={data()?.routines || []}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<strong>{r.type}</strong> <span class="fs-muted">{r.schedule_cron} · {r.enabled ? "on" : "off"}</span>
|
||||
<div><button class="fs-btn ghost" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</button></div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={!data()?.routines?.length}><p class="fs-muted">No routines yet.</p></Show>
|
||||
<div class="fs-card">
|
||||
<h3>Latest briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet.</p>}>
|
||||
<pre class="fs-muted">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
<Citations items={(briefing() as { citations: string })?.citations} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<h3>Run history</h3>
|
||||
<button class="fs-btn ghost" onClick={() => refetchRuns()}>Refresh</button>
|
||||
<ul><For each={runs() || []}>{(r) => <li class="fs-muted">{String(r.started_at)} — {String(r.status)}</li>}</For></ul>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<div class="fs-card">
|
||||
<h3>Latest briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet — it generates after the first morning-briefing run.</p>}>
|
||||
<pre class="fs-muted" style={{ "white-space": "pre-wrap" }}>{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
<Citations items={(briefing() as { citations: string })?.citations} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<h3>Run history</h3>
|
||||
<button class="fs-btn ghost" onClick={() => refetchRuns()}>Refresh</button>
|
||||
<ul><For each={runs() || []}>{(r) => <li class="fs-muted">{String(r.started_at)} — {String(r.status)}</li>}</For></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+65
-32
@@ -1,14 +1,28 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { api, type ScreenRow } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { BreakdownBars, EmptyState, RecBadge, Skeleton } from "../components/ui";
|
||||
|
||||
const SAVED_KEY = "fs-saved-screeners";
|
||||
|
||||
// scoreOf extracts a -100..+100 composite from the row breakdown for the badge.
|
||||
function scoreOf(r: ScreenRow): number {
|
||||
const b = r.breakdown as Record<string, unknown>;
|
||||
for (const k of ["composite", "score", "broker_score", "broker"]) {
|
||||
if (typeof b[k] === "number") return b[k] as number;
|
||||
}
|
||||
return r.composite || 0;
|
||||
}
|
||||
|
||||
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 [rows, setRows] = createSignal<ScreenRow[]>([]);
|
||||
const [ran, setRan] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [err, setErr] = createSignal("");
|
||||
const loadSaved = (): Record<string, string> => {
|
||||
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; }
|
||||
@@ -16,14 +30,18 @@ export default function Screener() {
|
||||
const [saved, setSaved] = createSignal<Record<string, string>>(loadSaved());
|
||||
const [sname, setSname] = createSignal("");
|
||||
async function run() {
|
||||
setErr("");
|
||||
setErr(""); setBusy(true);
|
||||
try {
|
||||
const institutional: Record<string, unknown> = { broker_score_min: brokerMin() };
|
||||
if (foreignOnly()) institutional.foreign_inflow = true;
|
||||
if (insiderOnly()) institutional.insider_buying = true;
|
||||
const body = mode() === "q"
|
||||
? { q: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 }
|
||||
: { where: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 };
|
||||
? { q: q() || undefined, institutional, limit: 20 }
|
||||
: { where: q() || undefined, institutional, limit: 20 };
|
||||
const r = await api.screen(body);
|
||||
setRows(r.rows);
|
||||
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() };
|
||||
@@ -32,39 +50,54 @@ export default function Screener() {
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1>Institutional Screener</h1>
|
||||
<div class="fs-page-head"><h1>Institutional Screener</h1><p class="fs-muted">Rank tickers by smart-money signals.</p></div>
|
||||
<div class="fs-card">
|
||||
<select class="fs-input" value={mode()} onChange={(e) => setMode(e.currentTarget.value as "where" | "q")}>
|
||||
<option value="q">Natural language (q)</option>
|
||||
<option value="where">SQL-like (where)</option>
|
||||
</select>
|
||||
<input class="fs-input" placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} style={{ width: "320px" }} />
|
||||
<label class="fs-muted"> broker ≥ <input class="fs-input" type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} style={{ width: "80px" }} /></label>
|
||||
<button class="fs-btn" onClick={run}>Screen</button>
|
||||
<Show when={err()}><p class="fs-muted">{err()}</p></Show>
|
||||
<div class="fs-row">
|
||||
<select class="fs-input" value={mode()} onChange={(e) => setMode(e.currentTarget.value as "where" | "q")}>
|
||||
<option value="q">Natural language (q)</option>
|
||||
<option value="where">SQL-like (where)</option>
|
||||
</select>
|
||||
<input class="fs-input" placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} style={{ flex: 1, "min-width": "200px" }} />
|
||||
<button class="fs-btn" onClick={run} disabled={busy()}>{busy() ? " screening…" : "Screen"}</button>
|
||||
</div>
|
||||
<div class="fs-row" style={{ "margin-top": "10px" }}>
|
||||
<label class="fs-muted">broker ≥ <input class="fs-input" type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} style={{ width: "80px" }} /></label>
|
||||
<label class="fs-muted"><input type="checkbox" checked={foreignOnly()} onChange={(e) => setForeignOnly(e.currentTarget.checked)} /> foreign inflow</label>
|
||||
<label class="fs-muted"><input type="checkbox" checked={insiderOnly()} onChange={(e) => setInsiderOnly(e.currentTarget.checked)} /> insider buying</label>
|
||||
</div>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>{err()}</p></Show>
|
||||
</div>
|
||||
<div class="fs-scroll"><table class="fs-table">
|
||||
<thead><tr><th>Symbol</th><th>Composite</th><th>Breakdown</th><th>Citations</th></tr></thead>
|
||||
<tbody>
|
||||
<For each={rows()}>
|
||||
{(r) => (
|
||||
<tr>
|
||||
<td><a href={`/report/${r.symbol}`}>{r.symbol}</a></td>
|
||||
<td>{r.composite.toFixed(1)}</td>
|
||||
<td class="fs-muted">{JSON.stringify(r.breakdown)}</td>
|
||||
<td><Citations items={r.citations} /></td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="fs-card">
|
||||
<Show when={busy()}><div style={{ "margin-top": "14px" }}><Skeleton rows={3} /></div></Show>
|
||||
<Show when={ran() && !busy()}>
|
||||
<Show when={rows().length} fallback={<div style={{ "margin-top": "14px" }}><EmptyState icon="🔍" title="No matches" hint="Loosen the filters and try again." /></div>}>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<For each={rows()}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<div class="fs-row" style={{ "justify-content": "space-between" }}>
|
||||
<strong><a href={`/report/${r.symbol}`}>{r.symbol}</a></strong>
|
||||
<span class="fs-muted">{r.composite.toFixed(1)}</span>
|
||||
</div>
|
||||
<div style={{ "margin": "8px 0" }}><BreakdownBars breakdown={r.breakdown as Record<string, unknown>} /></div>
|
||||
<Citations items={r.citations} />
|
||||
<Show when={scoreOf(r) !== 0}><div style={{ "margin-top": "6px" }}><RecBadge rec={scoreOf(r) >= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /></div></Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!ran() && !busy()}>
|
||||
<div style={{ "margin-top": "14px" }}><EmptyState icon="🔍" title="Run a screen" hint="Ranked rows with per-signal bars appear here." /></div>
|
||||
</Show>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Saved screeners</h3>
|
||||
<input class="fs-input" placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} style={{ width: "140px" }} />
|
||||
<button class="fs-btn ghost" onClick={save}>Save current</button>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} style={{ width: "140px" }} />
|
||||
<button class="fs-btn ghost" onClick={save}>Save current</button>
|
||||
</div>
|
||||
<ul><For each={Object.entries(saved())}>{([n, query]) => <li><button class="fs-btn ghost" onClick={() => { setQ(query); run(); }}>{n}</button> <span class="fs-muted">{query.slice(0, 60)}</span></li>}</For></ul>
|
||||
</div>
|
||||
<Show when={!rows().length}><p class="fs-muted">Run a screen to see ranked rows with per-row signal breakdown.</p></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user