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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 "U.S. Tech Coverage" 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 "Run backtest" 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>
|
||||
);
|
||||
}
|
||||
@@ -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&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&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>
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user