feat: add Citations and WatchlistChat components, integrate with API
- Implemented Citations component to display citation data. - Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality. - Integrated API calls for watchlist management and chat interactions. - Updated index.tsx to include new components in the main application layout. - Added API client in lib/api.ts for structured API interactions. - Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components. - Introduced styles in tokens.css for consistent theming across the application. - Configured TypeScript and Vite for project setup and development.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { fmtIDR } from "../lib/api";
|
||||
import { Citations } from "./Citations";
|
||||
|
||||
export function AccumulationTable(props: { rows: { ticker: string; net_sum: number; brokers: number }[] }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Top accumulation</h3>
|
||||
<table class="fs-table">
|
||||
<thead><tr><th>Ticker</th><th>Net</th><th>Brokers</th></tr></thead>
|
||||
<tbody>
|
||||
<For each={props.rows}>
|
||||
{(r) => <tr><td><a href={`/report/${r.ticker}`}>{r.ticker}</a></td><td>{fmtIDR(r.net_sum)}</td><td>{r.brokers}</td></tr>}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={!props.rows?.length}><p class="fs-muted">No accumulation snapshots yet.</p></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RotationMap(props: { signal?: string; citations?: Parameters<typeof Citations>[0]["items"] }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Sector rotation</h3>
|
||||
<p>{props.signal || "No rotation flip detected."}</p>
|
||||
<Citations items={props.citations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityFeed(props: { events: { id?: number; ticker?: string; message?: string; date?: string }[] }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Activity</h3>
|
||||
<Show when={props.events?.length} fallback={<p class="fs-muted">No events yet.</p>}>
|
||||
<ul>
|
||||
<For each={props.events}>
|
||||
{(e) => <li><strong>{e.ticker}</strong> — {e.message} <span class="fs-muted">{e.date}</span></li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { For } from "solid-js";
|
||||
import type { Citation } from "../lib/api";
|
||||
|
||||
export function Citations(props: { items?: Citation[] | string }) {
|
||||
const list = (): Citation[] => {
|
||||
if (!props.items) return [];
|
||||
if (typeof props.items === "string") {
|
||||
try { return JSON.parse(props.items); } catch { return []; }
|
||||
}
|
||||
return props.items;
|
||||
};
|
||||
return (
|
||||
<span>
|
||||
<For each={list()}>
|
||||
{(c) => (
|
||||
<span class={`fs-cite${c.stale ? " stale" : ""}`} title={`${c.endpoint} @ ${c.snapshot_at}${c.stale ? " (stale)" : ""}`}>
|
||||
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
// WatchlistDrawer: global watchlist add/remove backed by /api/watchlist.
|
||||
export function WatchlistDrawer() {
|
||||
const [wl, { refetch }] = createResource(() => api.watchlist());
|
||||
const [ticker, setTicker] = createSignal("");
|
||||
const [err, setErr] = createSignal("");
|
||||
async function add() {
|
||||
setErr("");
|
||||
try {
|
||||
await api.addWatch(ticker().toUpperCase().trim());
|
||||
setTicker("");
|
||||
refetch();
|
||||
} catch (e) { setErr(String(e)); }
|
||||
}
|
||||
async function del(t: string) {
|
||||
await api.removeWatch(t);
|
||||
refetch();
|
||||
}
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Watchlist</h3>
|
||||
<input class="fs-input" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} style={{ width: "90px" }} />
|
||||
<button class="fs-btn" onClick={add}>Add</button>
|
||||
<Show when={err()}><p class="fs-muted">{err()}</p></Show>
|
||||
<ul><For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}>{t}</a> <button class="fs-btn ghost" onClick={() => del(t)}>×</button></li>}</For></ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ChatSidebar: context-aware Q&A backed by POST /api/chat.
|
||||
export function ChatSidebar() {
|
||||
const [msg, setMsg] = createSignal("");
|
||||
const [log, setLog] = createSignal<{ q: string; a: string }[]>([]);
|
||||
async function send() {
|
||||
const q = msg().trim();
|
||||
if (!q) return;
|
||||
setMsg("");
|
||||
try {
|
||||
const r = await api.chat(q);
|
||||
setLog((p) => [...p, { q, a: r.answer }].slice(-10));
|
||||
} catch (e) {
|
||||
setLog((p) => [...p, { q, a: String(e) }].slice(-10));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>AI Chat</h3>
|
||||
<For each={log()}>
|
||||
{(m) => <div><p><strong>You:</strong> {m.q}</p><p class="fs-muted">{m.a.slice(0, 500)}</p></div>}
|
||||
</For>
|
||||
<input class="fs-input" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} style={{ width: "220px" }} />
|
||||
<button class="fs-btn" onClick={send}>Send</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render } from "solid-js/web";
|
||||
import { Router, Route, useLocation } from "@solidjs/router";
|
||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Routines from "./pages/Routines";
|
||||
import Screener from "./pages/Screener";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import Report from "./pages/Report";
|
||||
import Portfolio from "./pages/Portfolio";
|
||||
import "./styles/tokens.css";
|
||||
|
||||
function Nav() {
|
||||
const loc = useLocation();
|
||||
const link = (href: string, label: string) => (
|
||||
<a href={href} class={loc.pathname === href ? "active" : ""}>{label}</a>
|
||||
);
|
||||
return (
|
||||
<nav class="fs-nav">
|
||||
<strong>FlowSight</strong>
|
||||
{link("/", "Dashboard")}
|
||||
{link("/routines", "Routines")}
|
||||
{link("/screener", "Screener")}
|
||||
{link("/alerts", "Alerts")}
|
||||
{link("/portfolio", "Portfolio")}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router root={(props) => (
|
||||
<div class="fs-app">
|
||||
<Nav />
|
||||
<main class="fs-main">{props.children}</main>
|
||||
<aside style={{ width: "300px", padding: "20px 16px 20px 0", display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<WatchlistDrawer />
|
||||
<ChatSidebar />
|
||||
</aside>
|
||||
</div>
|
||||
)}>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/routines" component={Routines} />
|
||||
<Route path="/screener" component={Screener} />
|
||||
<Route path="/alerts" component={Alerts} />
|
||||
<Route path="/report/:ticker" component={Report} />
|
||||
<Route path="/portfolio" component={Portfolio} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById("root")!,
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Typed backend client (backend is the only source of truth; web never calls Sectors directly).
|
||||
const BASE = "";
|
||||
function headers(): HeadersInit {
|
||||
return { "Content-Type": "application/json", "X-User-Key": localStorage.getItem("fs-key") || "demo" };
|
||||
}
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
const ct = r.headers.get("content-type") || "";
|
||||
if (ct.includes("application/pdf")) return (await r.blob()) as unknown as T;
|
||||
if (ct.includes("text/")) return (await r.text()) as unknown as T;
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
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 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 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[] }
|
||||
export const api = {
|
||||
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
||||
flowSummary: () => req<FlowSummary>("/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}`),
|
||||
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
|
||||
routines: () => req<{ routines: Routine[] }>("/api/routines"),
|
||||
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
|
||||
briefing: () => req<{ date: string; payload: string; citations: string }>("/api/briefing/today"),
|
||||
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
|
||||
createDestination: (body: Record<string, unknown>) => req<{ id: number }>("/api/destinations", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateDestination: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/destinations/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteDestination: (id: number) => req<unknown>(`/api/destinations/${id}`, { method: "DELETE" }),
|
||||
alerts: () => req<{ alerts: AlertItem[] }>("/api/alerts"),
|
||||
createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }),
|
||||
alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`),
|
||||
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${ticker}?profile=${profile}`, { method: "POST" }),
|
||||
reportMd: (ticker: string) => req<string>(`/api/report/${ticker}?format=md`, { method: "POST" }),
|
||||
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${ticker}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
|
||||
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
|
||||
addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }),
|
||||
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
|
||||
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
|
||||
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
|
||||
chat: (message: string) => req<{ answer: string }>("/api/chat", { method: "POST", body: JSON.stringify({ message }) }),
|
||||
};
|
||||
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
||||
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
||||
const es = new EventSource("/api/stream");
|
||||
es.addEventListener("agents", (e) => onEvent("agents", (e as MessageEvent).data));
|
||||
es.addEventListener("alerts", (e) => onEvent("alerts", (e as MessageEvent).data));
|
||||
es.addEventListener("activity", (e) => onEvent("activity", (e as MessageEvent).data));
|
||||
es.onmessage = (e) => onEvent("message", e.data);
|
||||
return () => es.close();
|
||||
}
|
||||
export function fmtIDR(v: number): string {
|
||||
const neg = v < 0; const a = Math.abs(v);
|
||||
let s = `${Math.round(a)}`;
|
||||
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`;
|
||||
return neg ? "-" + s : s;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createResource, createSignal, For } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
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 [rule, setRule] = createSignal('{"all": true}');
|
||||
const [channels, setChannels] = createSignal("");
|
||||
const [dkind, setDkind] = createSignal("telegram");
|
||||
const [dlabel, setDlabel] = createSignal("");
|
||||
const [dbot, setDbot] = createSignal("");
|
||||
const [dchat, setDchat] = createSignal("");
|
||||
const [dhook, setDhook] = createSignal("");
|
||||
const [derr, setDerr] = createSignal("");
|
||||
async function create() {
|
||||
await api.createAlert({
|
||||
name: name(),
|
||||
rule: JSON.parse(rule()),
|
||||
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
|
||||
});
|
||||
refetch();
|
||||
}
|
||||
async function del(id: number) { await api.deleteAlert(id); refetch(); }
|
||||
async function addDest() {
|
||||
setDerr("");
|
||||
try {
|
||||
const body: Record<string, unknown> = dkind() === "telegram"
|
||||
? { kind: "telegram", label: dlabel().trim(), bot_token: dbot().trim(), chat_id: dchat().trim() }
|
||||
: { kind: "discord", label: dlabel().trim(), webhook_url: dhook().trim() };
|
||||
await api.createDestination(body);
|
||||
setDbot(""); setDchat(""); setDhook(""); setDlabel("");
|
||||
refetchDests();
|
||||
} catch (e) { setDerr(String(e)); }
|
||||
}
|
||||
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 (
|
||||
<div>
|
||||
<h1>Alerts</h1>
|
||||
<div class="fs-card">
|
||||
<h3>Push destinations (per login)</h3>
|
||||
<p class="fs-muted">Secrets stay server-side — the list never shows tokens. Delivery uses your enabled destinations; server env is fallback.</p>
|
||||
<select class="fs-input" value={dkind()} onChange={(e) => setDkind(e.currentTarget.value)}>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="discord">Discord</option>
|
||||
</select>
|
||||
<input class="fs-input" placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} style={{ width: "160px" }} />
|
||||
{dkind() === "telegram" ? (
|
||||
<>
|
||||
<input class="fs-input" placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} style={{ width: "220px" }} />
|
||||
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "140px" }} />
|
||||
</>
|
||||
) : (
|
||||
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ width: "300px" }} />
|
||||
)}
|
||||
<button class="fs-btn" onClick={addDest}>Add destination</button>
|
||||
{derr() ? <p class="fs-muted">{derr()}</p> : null}
|
||||
<ul>
|
||||
{(dests()?.destinations || []).map((d) => (
|
||||
<li>{d.kind} — {d.label || `#${d.id}`} <span class="fs-muted">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
|
||||
<button class="fs-btn ghost" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</button>
|
||||
<button class="fs-btn ghost" onClick={() => delDest(d.id)}>del</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<h3>New rule</h3>
|
||||
<input class="fs-input" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
|
||||
<input class="fs-input" value={rule()} onInput={(e) => setRule(e.currentTarget.value)} style={{ width: "280px" }} />
|
||||
<input class="fs-input" placeholder="Telegram/Discord channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "300px" }} />
|
||||
<button class="fs-btn" onClick={create}>Create</button>
|
||||
<p class="fs-muted">Delivery: your enabled destinations above (server env fallback). Empty = record only.</p>
|
||||
</div>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-card"><h3>Rules</h3>
|
||||
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn ghost" onClick={() => del(a.id)}>del</button></li>}</For></ul>
|
||||
</div>
|
||||
<div class="fs-card"><h3>Event history</h3>
|
||||
<button class="fs-btn ghost" onClick={() => refetchEv()}>Refresh</button>
|
||||
<ul><For each={events() || []}>{(e) => <li><strong>{String(e.ticker)}</strong> — {String(e.message)}</li>}</For></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { Line } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Dashboard() {
|
||||
const [health] = createResource(() => api.health());
|
||||
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 [live, setLive] = createSignal<string[]>([]);
|
||||
const stop = subscribeSSE((ch, data) => {
|
||||
setLive((p) => [`${ch}: ${data.slice(0, 120)}`, ...p].slice(0, 5));
|
||||
if (ch === "alerts" || ch === "activity") { refetch(); refetchEv(); }
|
||||
});
|
||||
onCleanup(stop);
|
||||
const flowChart = () => ({
|
||||
labels: (foreign()?.dates || []) as string[],
|
||||
datasets: [{ label: "BBCA foreign net", data: (foreign()?.nets || []) as number[] }],
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<h1>Smart Money Dashboard</h1>
|
||||
<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} />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createResource, For } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Bar } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Portfolio() {
|
||||
const [risk] = createResource(() => api.risk());
|
||||
const [acc] = createResource(() => api.accuracy());
|
||||
const chartData = () => ({
|
||||
labels: (risk()?.concentration || []).map((c) => c.ticker),
|
||||
datasets: [{ label: "Weight", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<h1>Portfolio Risk + Accuracy Ledger</h1>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-card">
|
||||
<h3>Concentration</h3>
|
||||
<Bar data={chartData()} options={{ responsive: true }} />
|
||||
<ul><For each={risk()?.warnings || []}>{(w) => <li>{w}</li>}</For></ul>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<h3>Beta vs index</h3>
|
||||
<p style={{ "font-size": "24px" }}>{risk()?.beta ?? "…"}</p>
|
||||
<h3>Correlation heatmap</h3>
|
||||
<CorrHeatmap matrix={risk()?.correlation || {}} />
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<h3>Agent accuracy</h3>
|
||||
<table class="fs-table">
|
||||
<thead><tr><th>Agent</th><th>Calls</th><th>Resolved</th><th>Hit %</th></tr></thead>
|
||||
<tbody>
|
||||
<For each={acc()?.agents || []}>
|
||||
{(a) => <tr><td>{a.agent}</td><td>{a.calls}</td><td>{a.resolved}</td><td>{(a.hit_rate * 100).toFixed(0)}%</td></tr>}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> }) {
|
||||
const keys = () => Object.keys(props.matrix);
|
||||
const cell = (v: number) => {
|
||||
const a = Math.min(1, Math.abs(v));
|
||||
const bg = v >= 0 ? `rgba(63,185,80,${(0.15 + 0.65 * a).toFixed(2)})` : `rgba(248,81,73,${(0.15 + 0.65 * a).toFixed(2)})`;
|
||||
return bg;
|
||||
};
|
||||
return (
|
||||
<div class="fs-scroll"><table class="fs-table">
|
||||
<thead><tr><th></th><For each={keys()}>{(k) => <th>{k}</th>}</For></tr></thead>
|
||||
<tbody>
|
||||
<For each={keys()}>
|
||||
{(r) => (
|
||||
<tr>
|
||||
<td><strong>{r}</strong></td>
|
||||
<For each={keys()}>
|
||||
{(c) => {
|
||||
const v = props.matrix[r]?.[c] ?? 0;
|
||||
return <td style={{ background: cell(v) }} title={`${r}/${c} = ${v.toFixed(2)}`}>{v.toFixed(2)}</td>;
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table></div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { api, type ReportPayload } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
|
||||
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 [answer, setAnswer] = createSignal("");
|
||||
const [err, setErr] = createSignal("");
|
||||
async function load() {
|
||||
setErr("");
|
||||
try { setRep(await api.report(params.ticker)); }
|
||||
catch (e) { setErr(String(e)); }
|
||||
}
|
||||
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
|
||||
async function ask() {
|
||||
const r = await api.interrogate(params.ticker, question());
|
||||
setAnswer(r.answer);
|
||||
}
|
||||
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>
|
||||
<Show when={rep()}>
|
||||
<div class="fs-card">
|
||||
<h2 class={`rec-${rep()!.synthesis.recommendation}`}>{rep()!.synthesis.recommendation} · conviction {rep()!.synthesis.conviction}/5</h2>
|
||||
<p>{rep()!.synthesis.thesis}</p>
|
||||
<Citations items={rep()!.citations} />
|
||||
</div>
|
||||
<For each={rep()!.sections}>
|
||||
{(s) => <div class="fs-card"><h3>{s.name}</h3><p>{s.body}</p><Citations items={s.citations} /></div>}
|
||||
</For>
|
||||
<div class="fs-card">
|
||||
<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>
|
||||
</Show>
|
||||
<Show when={md()}><div class="fs-card"><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
|
||||
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 [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();
|
||||
}
|
||||
async function toggle(id: number, enabled: boolean) {
|
||||
await api.updateRoutine(id, { enabled: !enabled });
|
||||
refetch();
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1>Routine Manager</h1>
|
||||
<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>}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { api, type ScreenRow } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
|
||||
const SAVED_KEY = "fs-saved-screeners";
|
||||
|
||||
export default function Screener() {
|
||||
const [mode, setMode] = createSignal<"where" | "q">("q");
|
||||
const [q, setQ] = createSignal("");
|
||||
const [brokerMin, setBrokerMin] = createSignal(0);
|
||||
const [rows, setRows] = createSignal<ScreenRow[]>([]);
|
||||
const [err, setErr] = createSignal("");
|
||||
const loadSaved = (): Record<string, string> => {
|
||||
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; }
|
||||
};
|
||||
const [saved, setSaved] = createSignal<Record<string, string>>(loadSaved());
|
||||
const [sname, setSname] = createSignal("");
|
||||
async function run() {
|
||||
setErr("");
|
||||
try {
|
||||
const body = mode() === "q"
|
||||
? { q: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 }
|
||||
: { where: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 };
|
||||
const r = await api.screen(body);
|
||||
setRows(r.rows);
|
||||
} catch (e) { setErr(String(e)); }
|
||||
}
|
||||
function save() {
|
||||
const all = { ...saved(), [sname().trim() || q().slice(0, 24) || "untitled"]: q() };
|
||||
localStorage.setItem(SAVED_KEY, JSON.stringify(all));
|
||||
setSaved(all);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1>Institutional Screener</h1>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* FlowSight design tokens: single source of truth for color, type, spacing. */
|
||||
:root {
|
||||
--fs-bg: #0d1117;
|
||||
--fs-surface: #161b22;
|
||||
--fs-border: #30363d;
|
||||
--fs-text: #e6edf3;
|
||||
--fs-muted: #8b949e;
|
||||
--fs-accent: #2f81f7;
|
||||
--fs-buy: #3fb950;
|
||||
--fs-hold: #d29922;
|
||||
--fs-avoid: #f85149;
|
||||
--fs-stale: #9e7bea;
|
||||
--fs-font: "Inter", system-ui, sans-serif;
|
||||
--fs-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--fs-radius: 8px;
|
||||
--fs-gap: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--fs-bg); color: var(--fs-text); font-family: var(--fs-font); }
|
||||
a { color: var(--fs-accent); }
|
||||
.fs-app { display: flex; min-height: 100vh; }
|
||||
.fs-nav { width: 200px; padding: 16px; border-right: 1px solid var(--fs-border); display: flex; flex-direction: column; gap: 8px; }
|
||||
.fs-nav a { padding: 8px 12px; border-radius: var(--fs-radius); text-decoration: none; color: var(--fs-text); }
|
||||
.fs-nav a.active { background: var(--fs-surface); }
|
||||
.fs-main { flex: 1; padding: 20px; max-width: 1200px; }
|
||||
.fs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--fs-gap); }
|
||||
.fs-card { background: var(--fs-surface); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 14px; }
|
||||
.fs-muted { color: var(--fs-muted); font-size: 13px; }
|
||||
.fs-cite { display: inline-block; font-family: var(--fs-mono); font-size: 11px; border: 1px solid var(--fs-border); border-radius: 4px; padding: 1px 6px; margin: 2px 4px 2px 0; }
|
||||
.fs-cite.stale { border-color: var(--fs-stale); color: var(--fs-stale); }
|
||||
.fs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.fs-table th, .fs-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--fs-border); }
|
||||
.fs-input, .fs-btn { font: inherit; padding: 8px 12px; border-radius: var(--fs-radius); border: 1px solid var(--fs-border); }
|
||||
.fs-input { background: var(--fs-bg); color: var(--fs-text); }
|
||||
.fs-btn { background: var(--fs-accent); color: #fff; cursor: pointer; border: none; }
|
||||
.fs-btn.ghost { background: transparent; border: 1px solid var(--fs-border); color: var(--fs-text); }
|
||||
.fs-scroll { overflow-x: auto; }
|
||||
.rec-BUY { color: var(--fs-buy); } .rec-HOLD { color: var(--fs-hold); } .rec-AVOID { color: var(--fs-avoid); }
|
||||
Reference in New Issue
Block a user