fix: IREN/ASTS/IRE stuck-pending + corridor feature

Fix stuck adapter_queue jobs (ASTS/IRE/IREN stuck pending forever):

1. fetchSpec early-return paths (cooldown checks) now update job status
   to 'backoff' with last_attempt set and 30s backoff_until, instead of
   returning without any status change. Prevents jobs from being
   re-processed every drain cycle indefinitely.

2. Wrap adapter.fetchOne() in 30s Promise.race timeout. A hung HTTP
   request no longer blocks the entire per-source promise chain
   forever, preventing all subsequent jobs for that source.

Also includes corridor/confluence feature, tiered quote schedules,
cache improvements, and HoldingsBookView refinements.
This commit is contained in:
Investor Flow Build
2026-08-11 22:25:17 -04:00
parent 24349a8b6d
commit 9b41890d33
28 changed files with 2576 additions and 184 deletions
+26 -2
View File
@@ -1,15 +1,39 @@
'use client';
import { useState } from 'react';
import { LayoutShell } from '@/components/LayoutShell';
import { ConfluencePanel } from '@/components/ConfluencePanel';
import { CorridorPanel } from '@/components/CorridorPanel';
const TABS = [
{ id: 'confluence', label: 'Slot Picture' },
{ id: 'corridor', label: 'Price Corridor' },
] as const;
/**
* Confluence — entry/exit picture quality from the34-slot confluence rack.
* Confluence — entry/exit picture quality from the confluence rack, plus the
* Price Corridor view (valuation corridor + corridor-method backtest ledger).
* Evidence-based descriptions of a symbol's current setup (ADR-0007).
*/
export default function ConfluencePage() {
const [tab, setTab] = useState<'confluence' | 'corridor'>('confluence');
return (
<LayoutShell>
<ConfluencePanel />
<div className="mb-4 flex gap-2">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`rounded border px-3 py-1.5 text-sm ${
tab === t.id
? 'border-fg-muted/40 bg-bg-secondary text-fg'
: 'border-fg-muted/15 bg-transparent text-fg-muted hover:text-fg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'confluence' ? <ConfluencePanel /> : <CorridorPanel />}
</LayoutShell>
);
}
+4 -1
View File
@@ -65,7 +65,10 @@ export default function FundDetailPage() {
} catch { /* ignore */ }
};
const sortedBook = [...book].sort((a, b) => b.as_of.localeCompare(a.as_of));
// Deduplicate to most recent entry per symbol (fixes React duplicate-key warning)
const sortedBook = [...book]
.sort((a, b) => b.as_of.localeCompare(a.as_of))
.filter((p, i, arr) => arr.findIndex((q) => q.symbol === p.symbol) === i);
const resetIdx = sortedBook.findIndex((p) => p.notes === 'book_reset');
const [tip, setTip] = useState<{ symbol: string; text: string; x: number; y: number } | null>(null);
+354
View File
@@ -0,0 +1,354 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/trpc';
import { useActiveSymbol } from '@/stores/active-symbol-store';
import { CollapsibleSection } from '@/components/CollapsibleSection';
// ---------------------------------------------------------------------------
// Types (subset of server types for display)
// ---------------------------------------------------------------------------
interface SnapshotRow {
snapshotDate: string;
currentPrice: number;
forwardPE: number | null;
trailingPE: number | null;
corridor1yHigh: number | null;
corridor1yLow: number | null;
corridor1yMedian: number | null;
fairValue1y: number | null;
impliedUpside1y: number | null;
pePercentile1y: number | null;
dataSource: string;
}
interface CorridorData {
symbol: string;
snapshot: SnapshotRow | null;
spy: SnapshotRow | null;
series: SnapshotRow[];
corridor: {
low: number | null;
high: number | null;
median: number | null;
currentPE: number | null;
fairValue1y: number | null;
impliedUpside1y: number | null;
pePercentile1y: number | null;
} | null;
}
interface BacktestAggregate {
entries: number;
resolved: number;
resolved1y: number;
resolved90d: number;
hitRate: number | null;
avgSpread: number | null;
bestSpread: number | null;
worstSpread: number | null;
avgTopReturn: number | null;
avgBottomReturn: number | null;
}
interface BacktestRow {
id: string;
articleDate: string;
rankingType: string;
topSymbols: string[];
bottomSymbols: string[];
topAvgReturn: number | null;
bottomAvgReturn: number | null;
spread: number | null;
isWin: boolean;
horizonDays: number;
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
const pct = (v: number | null | undefined, digits = 1): string =>
v === null || v === undefined || !Number.isFinite(v) ? '-' : `${(v * 100).toFixed(digits)}%`;
const num = (v: number | null | undefined, digits = 2): string =>
v === null || v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(digits);
const price = (v: number | null | undefined): string =>
v === null || v === undefined || !Number.isFinite(v) ? '-' : `$${v.toFixed(2)}`;
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function CorridorPanel() {
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
const [symbol, setSymbol] = useState(activeSymbol);
const [data, setData] = useState<CorridorData | null>(null);
const [watchlist, setWatchlist] = useState<string[]>([]);
const [addInput, setAddInput] = useState('');
const [backtest, setBacktest] = useState<{ rows: BacktestRow[]; aggregate: BacktestAggregate } | null>(null);
const [running, setRunning] = useState(false);
const loadCorridor = useCallback((sym: string) => {
if (!sym) return;
api.confluence.corridorSnapshot(sym)
.then((r) => setData(r as unknown as CorridorData))
.catch(() => setData(null));
}, []);
useEffect(() => { loadCorridor(symbol); }, [symbol, loadCorridor]);
useEffect(() => {
api.confluence.corridorWatchlist()
.then((r) => setWatchlist(r.symbols))
.catch(() => {});
}, []);
useEffect(() => {
api.confluence.corridorBacktest()
.then((r) => setBacktest(r))
.catch(() => {});
}, []);
const addToWatchlist = async () => {
const s = addInput.trim().toUpperCase();
if (!s) return;
const r = await api.confluence.corridorWatchlistAdd(s).catch(() => null);
if (r) { setWatchlist(r.symbols); setAddInput(''); }
};
const removeFromWatchlist = async (s: string) => {
const r = await api.confluence.corridorWatchlistRemove(s).catch(() => null);
if (r) setWatchlist(r.symbols);
};
const runBacktest = async () => {
setRunning(true);
try {
await api.confluence.corridorBacktestRun();
const r = await api.confluence.corridorBacktest();
if (r) setBacktest(r);
} finally {
setRunning(false);
}
};
const c = data?.corridor;
const currentPE = c?.currentPE ?? null;
const pctPosition = c?.pePercentile1y ?? null;
// Position within [low, high] as a fraction (for a mini corridor bar).
const low = c?.low ?? null;
const high = c?.high ?? null;
const barPct = low !== null && high !== null && currentPE !== null && high > low
? Math.max(0, Math.min(100, ((currentPE - low) / (high - low)) * 100))
: null;
const cheap = pctPosition !== null && pctPosition < 0.33;
const stretched = pctPosition !== null && pctPosition > 0.67;
return (
<div className="space-y-6">
{/* Header: symbol + watchlist */}
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="block text-xs text-fg-muted mb-1">Symbol</label>
<input
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value.toUpperCase().trim())}
className="w-24 rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg"
maxLength={12}
/>
</div>
<div>
<label className="block text-xs text-fg-muted mb-1">Watchlist</label>
<div className="flex items-center gap-2">
<input
type="text"
value={addInput}
onChange={(e) => setAddInput(e.target.value.toUpperCase().trim())}
placeholder="TICKER"
className="w-28 rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg"
maxLength={12}
onKeyDown={(e) => { if (e.key === 'Enter') addToWatchlist(); }}
/>
<button
onClick={addToWatchlist}
className="rounded bg-bg-secondary border border-fg-muted/30 px-3 py-1.5 text-xs text-fg hover:border-fg-muted/60"
>
Add
</button>
</div>
{watchlist.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{watchlist.map((s) => (
<span key={s} className="inline-flex items-center gap-1 rounded border border-fg-muted/20 bg-bg-secondary px-2 py-0.5 text-xs text-fg">
<button onClick={() => loadCorridor(s)} className="hover:text-green-400">{s}</button>
<button onClick={() => removeFromWatchlist(s)} className="text-fg-muted hover:text-red-400" aria-label={`Remove ${s}`}>×</button>
</span>
))}
</div>
)}
</div>
</div>
{/* Corridor snapshots */}
{data && data.snapshot && (
<CollapsibleSection title={`Valuation Corridor — ${symbol}`} defaultOpen>
<div className="space-y-4">
{/* Corridor band + current position */}
{barPct !== null && low !== null && high !== null && (
<div>
<div className="flex justify-between text-[11px] text-fg-muted mb-1">
<span>{num(low, 1)}x (low)</span>
<span>{num(high, 1)}x (high)</span>
</div>
<div className="relative h-2.5 rounded-full bg-bg-secondary border border-fg-muted/20">
<div className="absolute inset-y-0 left-0 rounded-l-full bg-green-600/30" style={{ width: '33%' }} />
<div className="absolute inset-y-0 right-0 rounded-r-full bg-amber-600/30" style={{ width: '33%' }} />
<div
className={`absolute top-1/2 -translate-y-1/2 w-0.5 h-4 ${cheap ? 'bg-green-400' : stretched ? 'bg-amber-400' : 'bg-zinc-300'}`}
style={{ left: `${barPct}%` }}
/>
</div>
<div className="mt-1 text-xs">
<span className="text-fg">Current P/E {num(currentPE, 1)}x</span>
<span className="text-fg-muted"> — at the {pctPosition !== null ? `${(pctPosition * 100).toFixed(0)}th` : '?'} percentile of its 1-year corridor</span>
{cheap && <span className="ml-2 text-green-400">(cheap zone)</span>}
{stretched && <span className="ml-2 text-amber-400">(stretched zone)</span>}
</div>
</div>
)}
{/* Key numbers table */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Stat label="1y Median" value={`${num(c?.median ?? null, 1)}x`} />
<Stat label="Implied Upside (1y)" value={pct(c?.impliedUpside1y ?? null)} tone={(c?.impliedUpside1y ?? 0) >= 0.15 ? 'green' : (c?.impliedUpside1y ?? 0) < -0.1 ? 'amber' : 'neutral'} />
<Stat label="Fair Value (1y)" value={price(c?.fairValue1y ?? null)} />
<Stat label="SPY fwd P/E" value={`${num(data.spy?.forwardPE ?? null, 1)}x ${data.spy && data.spy.forwardPE != null && data.spy.forwardPE < 20.5 ? '· cheap' : ''}`} tone={data.spy && data.spy.forwardPE != null && data.spy.forwardPE < 20.5 ? 'green' : 'neutral'} />
</div>
{data.series.length > 0 && (
<details className="text-xs text-fg-muted">
<summary className="cursor-pointer hover:text-fg">Recent snapshots ({data.series.length})</summary>
<table className="mt-2 w-full text-xs">
<thead>
<tr className="text-fg-muted border-b border-fg-muted/20">
<th className="text-left py-1">Date</th>
<th className="text-right py-1">Close</th>
<th className="text-right py-1">P/E pos</th>
<th className="text-right py-1">Implied upside</th>
<th className="text-right py-1">Source</th>
</tr>
</thead>
<tbody>
{data.series.slice(0, 12).map((s) => (
<tr key={s.snapshotDate} className="border-b border-fg-muted/10">
<td className="py-1">{s.snapshotDate}</td>
<td className="text-right py-1">{price(s.currentPrice)}</td>
<td className="text-right py-1">{s.pePercentile1y != null ? `${(s.pePercentile1y * 100).toFixed(0)}%` : '-'}</td>
<td className="text-right py-1">{pct(s.impliedUpside1y ?? null)}</td>
<td className="text-right py-1">{s.dataSource}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
</CollapsibleSection>
)}
{data && !data.snapshot && (
<div className="text-sm text-fg-muted">
No corridor snapshot for {symbol} yet. Snapshot it via the confluence evaluator, or pick a symbol from the watchlist.
</div>
)}
{/* Corridor-method backtest scorecard */}
<CollapsibleSection title="Corridor Method Backtest (weekly rankings vs actual returns)" defaultOpen>
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
onClick={runBacktest}
disabled={running}
className="rounded bg-bg-secondary border border-fg-muted/30 px-3 py-1.5 text-xs text-fg hover:border-fg-muted/60 disabled:opacity-50"
>
{running ? 'Running…' : backtest && backtest.rows.length > 0 ? 'Re-run with fresh candles' : 'Run backtest'}
</button>
{backtest && (
<span className="text-xs text-fg-muted">
{backtest.rows.length} graded weeks ({backtest.aggregate.resolved} resolved with enough forward data)
</span>
)}
</div>
{backtest && backtest.aggregate.resolved > 0 && (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Stat label="Hit Rate (top > bottom)" value={pct(backtest.aggregate.hitRate, 0)} tone={(backtest.aggregate.hitRate ?? 0) >= 0.6 ? 'green' : 'neutral'} />
<Stat label="Avg Spread (1wk)" value={pct(backtest.aggregate.avgSpread, 2)} tone={(backtest.aggregate.avgSpread ?? 0) > 0 ? 'green' : (backtest.aggregate.avgSpread ?? 0) < 0 ? 'amber' : 'neutral'} />
<Stat label="Best Spread" value={pct(backtest.aggregate.bestSpread, 2)} tone="green" />
<Stat label="Worst Spread" value={pct(backtest.aggregate.worstSpread, 2)} tone="amber" />
</div>
<p className="text-[11px] text-fg-muted">
Method: @alojoh's weekly &quot;U.S. Tech Coverage&quot; entry rankings. Each graded week holds the top-3 entry-attractive names against
the bottom-3 least-attractive names and measures 7-day forward returns from actual prices. Spread = mean(top) − mean(bottom).
This is a judgment of the methodology itself, not a participation signal.
</p>
</>
)}
{backtest && backtest.rows.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="text-fg-muted border-b border-fg-muted/20">
<th className="text-left py-1">Article Date</th>
<th className="text-left py-1">Window</th>
<th className="text-left py-1">Top 3</th>
<th className="text-left py-1">Bottom 3</th>
<th className="text-right py-1">Top avg</th>
<th className="text-right py-1">Bottom avg</th>
<th className="text-right py-1">Spread</th>
<th className="text-right py-1">Result</th>
</tr>
</thead>
<tbody>
{backtest.rows.map((r) => (
<tr key={r.id} className="border-b border-fg-muted/10">
<td className="py-1">{r.articleDate}</td>
<td className="py-1">{r.rankingType === 'entry_1y' ? '1y rank' : '90d rank'}</td>
<td className="py-1 text-green-400/90">{r.topSymbols.join(', ')}</td>
<td className="py-1 text-amber-400/80">{r.bottomSymbols.join(', ')}</td>
<td className="text-right py-1">{pct(r.topAvgReturn, 2)}</td>
<td className="text-right py-1">{pct(r.bottomAvgReturn, 2)}</td>
<td className={`text-right py-1 font-medium ${(r.spread ?? 0) > 0 ? 'text-green-400' : 'text-red-400'}`}>{pct(r.spread, 2)}</td>
<td className={`text-right py-1 ${r.spread !== null && r.isWin ? 'text-green-400' : r.spread !== null ? 'text-red-400' : 'text-fg-muted'}`}>
{r.spread === null ? 'n/a' : r.isWin ? 'Top won' : 'Bottom won'}
</td>
</tr>
))}
</tbody>
</table>
) : (
!backtest && <p className="text-xs text-fg-muted">Backtest not yet run. Click &quot;Run backtest&quot; to grade the weekly rankings against actual forward returns.</p>
)}
</div>
</CollapsibleSection>
</div>
);
}
// ---------------------------------------------------------------------------
// Small stat card
// ---------------------------------------------------------------------------
function Stat({ label, value, tone = 'neutral' }: { label: string; value: string; tone?: 'green' | 'amber' | 'neutral' }) {
const color = tone === 'green' ? 'text-green-400' : tone === 'amber' ? 'text-amber-400' : 'text-fg';
return (
<div className="rounded border border-fg-muted/20 bg-bg-secondary px-2.5 py-2">
<div className="text-[10px] uppercase tracking-wide text-fg-muted">{label}</div>
<div className={`text-sm font-medium ${color}`}>{value}</div>
</div>
);
}
+266 -103
View File
@@ -54,6 +54,11 @@ export function HoldingsBookView() {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Edit holding state
const [editingSymbol, setEditingSymbol] = useState<string | null>(null);
const [editShares, setEditShares] = useState("");
const [editAvgCost, setEditAvgCost] = useState("");
const loadQuotes = useCallback(async (list: PortfolioHolding[]) => {
if (list.length === 0) {
setQuotes({});
@@ -158,6 +163,46 @@ export function HoldingsBookView() {
}
}
function handleStartEdit(h: PortfolioHolding) {
setEditingSymbol(h.symbol);
setEditShares(String(h.shares));
setEditAvgCost(String(h.avg_cost));
setError(null);
}
function handleCancelEdit() {
setEditingSymbol(null);
setEditShares("");
setEditAvgCost("");
setError(null);
}
async function handleSaveEdit(symbol: string) {
setError(null);
const shares = parseFloat(editShares);
const avgCost = parseFloat(editAvgCost);
if (!Number.isFinite(shares) || shares <= 0) {
setError("Shares must be a positive number.");
return;
}
if (!Number.isFinite(avgCost) || avgCost < 0) {
setError("Average cost must be >= 0.");
return;
}
setBusy(true);
try {
await api.portfolio.updateHolding(symbol, { shares, avgCost });
setEditingSymbol(null);
setEditShares("");
setEditAvgCost("");
await load(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not update holding.");
} finally {
setBusy(false);
}
}
function openResearch(symbol: string) {
setActive(symbol);
// scroll=false: shell owns nested <main> scroll; push alone leaves mid-page.
@@ -317,6 +362,7 @@ export function HoldingsBookView() {
const pnl = marketValue != null ? marketValue - costBasis : null;
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
const dayUp = (q?.change ?? 0) >= 0;
const isEditing = editingSymbol === h.symbol;
return (
<article
@@ -330,73 +376,132 @@ export function HoldingsBookView() {
className="text-left min-h-[44px] flex flex-col justify-center"
>
<span className="font-mono text-base font-semibold text-fg">{h.symbol}</span>
<span className="text-xs text-fg-muted tnum">
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)} sh · avg $
{fmtMoney(h.avg_cost)}
</span>
{!isEditing && (
<span className="text-xs text-fg-muted tnum">
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)} sh · avg $
{fmtMoney(h.avg_cost)}
</span>
)}
</button>
<div className="text-right shrink-0">
<p className="font-mono text-sm font-semibold tnum text-fg">
{q ? `$${fmtMoney(q.price)}` : "—"}
</p>
<p
className={`text-xs tnum ${
!q ? "text-fg-faint" : dayUp ? "text-up" : "text-danger"
}`}
>
{q ? fmtSignedPct(q.changePercent) : "—"}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">Market</p>
<p className="tnum font-medium text-fg">
{marketValue != null ? `$${fmtMoney(marketValue)}` : "—"}
</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">P&amp;L</p>
<p
className={`tnum font-medium ${
pnl == null ? "text-fg-faint" : pnl >= 0 ? "text-up" : "text-danger"
}`}
>
{pnl == null
? "—"
: `${fmtSignedMoney(pnl)}${
pnlPct != null ? ` (${fmtSignedPct(pnlPct)})` : ""
}`}
</p>
</div>
</div>
<div className="flex gap-2 pt-1 border-t border-line">
<button
type="button"
onClick={() => openResearch(h.symbol)}
className="flex-1 min-h-[44px] rounded-md border border-line text-sm font-medium text-accent active:bg-surface-sunken"
>
Research
</button>
{showConsiderations && (
<a
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
className="flex-1 min-h-[44px] inline-flex items-center justify-center rounded-md border border-line text-sm text-fg-muted active:bg-surface-sunken"
>
Consider
</a>
{!isEditing && (
<div className="text-right shrink-0">
<p className="font-mono text-sm font-semibold tnum text-fg">
{q ? `$${fmtMoney(q.price)}` : "—"}
</p>
<p
className={`text-xs tnum ${
!q ? "text-fg-faint" : dayUp ? "text-up" : "text-danger"
}`}
>
{q ? fmtSignedPct(q.changePercent) : "—"}
</p>
</div>
)}
<button
type="button"
disabled={busy}
onClick={() => handleRemove(h.symbol)}
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-danger disabled:opacity-50"
>
Remove
</button>
</div>
{isEditing ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="block text-[11px] text-fg-muted">
Shares
<input
type="number"
value={editShares}
onChange={(e) => setEditShares(e.target.value)}
min="0"
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm tnum text-fg"
/>
</label>
<label className="block text-[11px] text-fg-muted">
Avg cost
<input
type="number"
value={editAvgCost}
onChange={(e) => setEditAvgCost(e.target.value)}
min="0"
step="any"
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm tnum text-fg"
/>
</label>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy}
onClick={() => handleSaveEdit(h.symbol)}
className="flex-1 min-h-[44px] rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={handleCancelEdit}
className="flex-1 min-h-[44px] rounded-md border border-line text-sm text-fg-muted"
>
Cancel
</button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">Market</p>
<p className="tnum font-medium text-fg">
{marketValue != null ? `$${fmtMoney(marketValue)}` : "—"}
</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">P&amp;L</p>
<p
className={`tnum font-medium ${
pnl == null ? "text-fg-faint" : pnl >= 0 ? "text-up" : "text-danger"
}`}
>
{pnl == null
? "—"
: `${fmtSignedMoney(pnl)}${
pnlPct != null ? ` (${fmtSignedPct(pnlPct)})` : ""
}`}
</p>
</div>
</div>
<div className="flex gap-2 pt-1 border-t border-line">
<button
type="button"
onClick={() => openResearch(h.symbol)}
className="flex-1 min-h-[44px] rounded-md border border-line text-sm font-medium text-accent active:bg-surface-sunken"
>
Research
</button>
{showConsiderations && (
<a
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
className="flex-1 min-h-[44px] inline-flex items-center justify-center rounded-md border border-line text-sm text-fg-muted active:bg-surface-sunken"
>
Consider
</a>
)}
<button
type="button"
disabled={busy}
onClick={() => handleStartEdit(h)}
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-accent disabled:opacity-50"
>
Edit
</button>
<button
type="button"
disabled={busy}
onClick={() => handleRemove(h.symbol)}
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-danger disabled:opacity-50"
>
Remove
</button>
</div>
</>
)}
</article>
);
})}
@@ -426,6 +531,7 @@ export function HoldingsBookView() {
const pnl = marketValue != null ? marketValue - costBasis : null;
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
const dayUp = (q?.change ?? 0) >= 0;
const isEditing = editingSymbol === h.symbol;
return (
<tr
@@ -441,12 +547,37 @@ export function HoldingsBookView() {
{h.symbol}
</button>
</td>
<td className="px-3 py-3 text-right tnum text-fg align-middle">
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)}
</td>
<td className="px-3 py-3 text-right tnum text-fg-muted align-middle">
${fmtMoney(h.avg_cost)}
</td>
{isEditing ? (
<td className="px-3 py-3 text-right align-middle">
<input
type="number"
value={editShares}
onChange={(e) => setEditShares(e.target.value)}
min="0"
className="w-20 rounded-md border border-line bg-surface px-2 py-1 text-sm tnum text-fg text-right"
/>
</td>
) : (
<td className="px-3 py-3 text-right tnum text-fg align-middle">
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)}
</td>
)}
{isEditing ? (
<td className="px-3 py-3 text-right align-middle">
<input
type="number"
value={editAvgCost}
onChange={(e) => setEditAvgCost(e.target.value)}
min="0"
step="any"
className="w-20 rounded-md border border-line bg-surface px-2 py-1 text-sm tnum text-fg text-right"
/>
</td>
) : (
<td className="px-3 py-3 text-right tnum text-fg-muted align-middle">
${fmtMoney(h.avg_cost)}
</td>
)}
<td className="px-3 py-3 text-right tnum text-fg align-middle">
{q ? `$${fmtMoney(q.price)}` : "—"}
</td>
@@ -477,39 +608,71 @@ export function HoldingsBookView() {
}`}
</td>
<td className="px-4 py-3 text-right align-middle whitespace-nowrap">
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
<button
type="button"
onClick={() => openResearch(h.symbol)}
className="text-accent hover:underline"
>
Research
</button>
<span className="text-fg-faint" aria-hidden>
·
</span>
<button
type="button"
disabled={busy}
onClick={() => handleRemove(h.symbol)}
className="text-fg-muted hover:text-danger hover:underline disabled:opacity-50"
>
Remove
</button>
{showConsiderations && (
<>
<span className="text-fg-faint" aria-hidden>
·
</span>
<a
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
className="text-fg-muted hover:text-fg hover:underline"
>
Consider
</a>
</>
)}
</div>
{isEditing ? (
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
<button
type="button"
disabled={busy}
onClick={() => handleSaveEdit(h.symbol)}
className="text-accent hover:underline disabled:opacity-50"
>
Save
</button>
<span className="text-fg-faint" aria-hidden>·</span>
<button
type="button"
onClick={handleCancelEdit}
className="text-fg-muted hover:text-fg hover:underline"
>
Cancel
</button>
</div>
) : (
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
<button
type="button"
onClick={() => openResearch(h.symbol)}
className="text-accent hover:underline"
>
Research
</button>
<span className="text-fg-faint" aria-hidden>
·
</span>
<button
type="button"
disabled={busy}
onClick={() => handleStartEdit(h)}
className="text-fg-muted hover:text-accent hover:underline disabled:opacity-50"
>
Edit
</button>
<span className="text-fg-faint" aria-hidden>
·
</span>
<button
type="button"
disabled={busy}
onClick={() => handleRemove(h.symbol)}
className="text-fg-muted hover:text-danger hover:underline disabled:opacity-50"
>
Remove
</button>
{showConsiderations && (
<>
<span className="text-fg-faint" aria-hidden>
·
</span>
<a
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
className="text-fg-muted hover:text-fg hover:underline"
>
Consider
</a>
</>
)}
</div>
)}
</td>
</tr>
);
+1
View File
@@ -48,6 +48,7 @@ const SECTIONS: NavSection[] = [
{ label: "Screener", href: "/screener", navId: "screener" },
{ label: "Institutional", href: "/institutional", navId: "institutional" },
{ label: "Dealer Flow", href: "/dealer-flow", navId: "dealer-flow" },
{ label: "Confluence", href: "/confluence", navId: "confluence" },
],
},
{
+55
View File
@@ -1474,6 +1474,61 @@ export const api = {
trpcQuery<{ slots: unknown[]; note: string }>("confluence.scorecard", symbol ? { symbol } : {}),
saveRack: (input: { id?: string; name: string; description?: string; slotIds: string[] }) =>
trpcMutate<{ id: string; name: string; slotIds: string[]; isSystem: boolean; description: string | null }>("confluence.saveRack", input),
corridorSnapshot: (symbol: string) =>
trpcQuery<{
symbol: string;
snapshot: Record<string, unknown> | null;
spy: Record<string, unknown> | null;
series: Array<Record<string, unknown>>;
corridor: {
low: number | null;
high: number | null;
median: number | null;
currentPE: number | null;
fairValue1y: number | null;
impliedUpside1y: number | null;
pePercentile1y: number | null;
} | null;
}>("confluence.corridorSnapshot", { symbol }),
corridorWatchlist: () =>
trpcQuery<{ symbols: string[] }>("confluence.corridorWatchlist"),
corridorWatchlistAdd: (symbol: string) =>
trpcMutate<{ symbols: string[] }>("confluence.corridorWatchlistAdd", { symbol }),
corridorWatchlistRemove: (symbol: string) =>
trpcMutate<{ symbols: string[] }>("confluence.corridorWatchlistRemove", { symbol }),
corridorBacktest: () =>
trpcQuery<{
rows: Array<{
id: string;
articleDate: string;
articleId: string;
rankingType: string;
topSymbols: string[];
bottomSymbols: string[];
topAvgReturn: number | null;
bottomAvgReturn: number | null;
spread: number | null;
isWin: boolean;
horizonDays: number;
gradedAt: string;
}>;
aggregate: {
entries: number;
resolved: number;
resolved1y: number;
resolved90d: number;
hitRate: number | null;
avgSpread: number | null;
bestSpread: number | null;
worstSpread: number | null;
avgTopReturn: number | null;
avgBottomReturn: number | null;
};
}>("confluence.corridorBacktest"),
corridorBacktestRun: () =>
trpcMutate<{ grades: number; resolved: number; aggregate: Record<string, unknown> }>("confluence.corridorBacktestRun", {}),
runEvaluationNow: (opts?: { symbol?: string }) =>
trpcMutate<Record<string, unknown>>("confluence.runEvaluationNow", opts ?? {}),
},
};
+2
View File
@@ -51,6 +51,7 @@ export type NavItemId =
| "filings"
| "options"
| "dealer-flow"
| "confluence"
| "alerts"
| "settings"
| "admin"
@@ -79,6 +80,7 @@ export const NAV_DENSITY: Record<NavItemId, DensityGate> = {
filings: "full",
options: "full",
"dealer-flow": "focused",
confluence: "standard",
alerts: "focused",
settings: "focused",
admin: "focused",