feat: alerts rule builder + destinations UI, portfolio charts + accuracy ledger
This commit is contained in:
+76
-36
@@ -1,26 +1,44 @@
|
||||
import { createResource, createSignal, For } from "solid-js";
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { EmptyState, Skeleton } from "../components/ui";
|
||||
|
||||
const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"];
|
||||
const OPS = [">=", "<=", ">", "<", "=="];
|
||||
|
||||
type Cond = { field: string; op: string; value: number };
|
||||
|
||||
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 [field, setField] = createSignal("score");
|
||||
const [op, setOp] = createSignal(">=");
|
||||
const [value, setValue] = createSignal(60);
|
||||
const [conds, setConds] = createSignal<Cond[]>([]);
|
||||
const [channels, setChannels] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
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("");
|
||||
const rulePreview = () => JSON.stringify(conds().length ? { all: conds() } : { all: true });
|
||||
function addCond() {
|
||||
setConds((p) => [...p, { field: field(), op: op(), value: value() }]);
|
||||
}
|
||||
async function create() {
|
||||
await api.createAlert({
|
||||
name: name(),
|
||||
rule: JSON.parse(rule()),
|
||||
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
|
||||
});
|
||||
refetch();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createAlert({
|
||||
name: name(),
|
||||
rule: JSON.parse(rulePreview()),
|
||||
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
|
||||
});
|
||||
setConds([]);
|
||||
refetch();
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
async function del(id: number) { await api.deleteAlert(id); refetch(); }
|
||||
async function addDest() {
|
||||
@@ -41,49 +59,71 @@ export default function Alerts() {
|
||||
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
|
||||
return (
|
||||
<div>
|
||||
<h1>Alerts</h1>
|
||||
<div class="fs-page-head"><h1>Alerts</h1><p class="fs-muted">Rules evaluated over snapshots each cycle.</p></div>
|
||||
<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}
|
||||
<p class="fs-muted">Secrets stay server-side — the list never shows tokens.</p>
|
||||
<div class="fs-row">
|
||||
<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: "200px" }} />
|
||||
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "120px" }} />
|
||||
</>
|
||||
) : (
|
||||
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
)}
|
||||
<button class="fs-btn" onClick={addDest}>Add destination</button>
|
||||
</div>
|
||||
{derr() ? <p style={{ color: "var(--fs-avoid)" }}>{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>
|
||||
<li><span class={`fs-badge ${d.enabled ? "buy" : "info"}`}>{d.kind}</span> {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>
|
||||
<button class="fs-btn danger" onClick={() => delDest(d.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<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 class="fs-row">
|
||||
<input class="fs-input" placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div class="fs-row" style={{ "margin-top": "10px" }}>
|
||||
<select class="fs-input" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
|
||||
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
|
||||
</select>
|
||||
<select class="fs-input" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
|
||||
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
|
||||
</select>
|
||||
<input class="fs-input" type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} style={{ width: "100px" }} />
|
||||
<button class="fs-btn ghost" onClick={addCond}>+ condition</button>
|
||||
</div>
|
||||
<Show when={conds().length}>
|
||||
<ul><For each={conds()}>{(c, i) => <li class="fs-muted">{c.field} {c.op} {c.value} <button class="fs-btn ghost" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</button></li>}</For></ul>
|
||||
</Show>
|
||||
<p class="fs-muted">Rule JSON: <code>{rulePreview()}</code></p>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" onClick={create} disabled={busy()}>Create rule</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<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>
|
||||
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
|
||||
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn danger" onClick={() => del(a.id)}>Delete</button></li>}</For></ul>
|
||||
</Show>
|
||||
</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>
|
||||
<Show when={(events() || []).length} fallback={<p class="fs-muted">No events yet.</p>}>
|
||||
<ul><For each={events() || []}>{(e) => <li><strong>{String(e.ticker)}</strong> — {String(e.message)}</li>}</For></ul>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+49
-44
@@ -1,7 +1,8 @@
|
||||
import { createResource, For } from "solid-js";
|
||||
import { createResource, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Bar } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { StatCard, Skeleton } from "../components/ui";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Portfolio() {
|
||||
@@ -9,35 +10,37 @@ export default function Portfolio() {
|
||||
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)) }],
|
||||
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 class="fs-page-head"><h1>Portfolio Risk + Accuracy Ledger</h1><p class="fs-muted">Computed from your watchlist.</p></div>
|
||||
<Show when={!risk.loading && !acc.loading} fallback={<Skeleton rows={4} />}>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-card">
|
||||
<h3>Concentration</h3>
|
||||
<Show when={(risk()?.concentration || []).length} fallback={<p class="fs-muted">Add tickers to your watchlist to see concentration.</p>}>
|
||||
<Bar data={chartData()} options={{ responsive: true }} />
|
||||
</Show>
|
||||
<For each={risk()?.warnings || []}>{(w) => <p>⚠️ {w}</p>}</For>
|
||||
</div>
|
||||
<StatCard title="Beta vs index" value={risk() ? String(risk()!.beta) : "…"}>
|
||||
<h3 style={{ "margin-top": "12px" }}>Correlation heatmap</h3>
|
||||
<CorrHeatmap matrix={risk()?.correlation || {}} />
|
||||
</StatCard>
|
||||
<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 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>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,23 +53,25 @@ function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> })
|
||||
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>
|
||||
<Show when={keys().length} fallback={<p class="fs-muted">No correlation data yet.</p>}>
|
||||
<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>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user