phase-1: optimize code
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<script>
|
||||
let { ctx, collapsible = false } = $props();
|
||||
|
||||
let expanded = $state(!collapsible); // collapsed by default when collapsible=true
|
||||
|
||||
const cards = $derived.by(() => {
|
||||
const b = ctx?.benchmarks ?? {};
|
||||
return [
|
||||
{
|
||||
label: '10Y Yield',
|
||||
value: ctx?.riskFreeRate != null ? ctx.riskFreeRate.toFixed(2) + '%' : '—',
|
||||
tip: 'US 10-year Treasury yield — the risk-free rate benchmark. Higher = tighter conditions for stocks and bonds.',
|
||||
},
|
||||
{
|
||||
label: 'VIX',
|
||||
value: ctx?.vixLevel?.toFixed(1) ?? '—',
|
||||
tip: 'CBOE Volatility Index — measures expected market volatility. Above 20 = elevated fear; above 30 = high stress.',
|
||||
},
|
||||
{
|
||||
label: 'S&P 500',
|
||||
value: ctx?.sp500Price?.toLocaleString() ?? '—',
|
||||
tip: 'Live S&P 500 index price — broad US large-cap benchmark.',
|
||||
},
|
||||
{
|
||||
label: 'S&P P/E',
|
||||
value: b.marketPE != null ? b.marketPE.toFixed(1) + 'x' : '—',
|
||||
tip: 'Trailing P/E ratio of SPY. Used to set the INFLATED mode P/E gate (S&P P/E × 1.5 in normal rates).',
|
||||
},
|
||||
{
|
||||
label: 'Tech P/E',
|
||||
value: b.techPE != null ? b.techPE.toFixed(1) + 'x' : '—',
|
||||
tip: 'Trailing P/E of XLK (tech sector ETF). Sets the tech-sector gate in INFLATED mode (XLK P/E × 1.3).',
|
||||
},
|
||||
{
|
||||
label: 'REIT Yield',
|
||||
value: b.reitYield != null ? b.reitYield.toFixed(2) + '%' : '—',
|
||||
tip: 'Dividend yield of XLRE (real estate ETF). Used as the REIT minimum yield gate in INFLATED mode.',
|
||||
},
|
||||
{
|
||||
label: 'IG Spread',
|
||||
value: b.igSpread != null ? b.igSpread.toFixed(2) + '%' : '—',
|
||||
tip: 'Investment-grade bond spread (LQD yield − 10Y yield). Sets the bond minimum spread gate in INFLATED mode.',
|
||||
},
|
||||
{
|
||||
label: 'Rate Regime',
|
||||
value: ctx?.rateRegime ?? '—',
|
||||
tip: 'HIGH (>4.5%) compresses P/E gates and tightens bond/REIT requirements. NORMAL uses looser INFLATED gates.',
|
||||
},
|
||||
{
|
||||
label: 'Volatility',
|
||||
value: ctx?.volatilityRegime ?? '—',
|
||||
tip: 'Derived from VIX level — LOW (<15), NORMAL (15–25), HIGH (>25). Informational; not currently gating scores.',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ctx-wrap">
|
||||
{#if collapsible}
|
||||
<button class="ctx-toggle" onclick={() => expanded = !expanded}>
|
||||
<span class="ctx-toggle-label">Market Context</span>
|
||||
<span class="ctx-toggle-chevron">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if expanded}
|
||||
<div class="grid">
|
||||
{#each cards as c}
|
||||
<div class="card">
|
||||
<div class="label-row">
|
||||
<span class="label">{c.label}</span>
|
||||
<span class="tip-wrap">
|
||||
<span class="tip-anchor">?</span>
|
||||
<span class="tip-box">{c.tip}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="value">{c.value}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ctx-wrap { margin-bottom: 20px; }
|
||||
|
||||
/* ── Collapsible toggle ─────────────────────────────────────────── */
|
||||
.ctx-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctx-toggle-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.ctx-toggle-chevron {
|
||||
font-size: 9px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* ── Cards grid ────────────────────────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Tooltip ──────────────────────────────────────────────────── */
|
||||
.tip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tip-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tip-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #334155;
|
||||
}
|
||||
|
||||
.tip-wrap:hover .tip-box { display: block; }
|
||||
|
||||
.value { font-size: 17px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
let { signal } = $props();
|
||||
|
||||
const cls = () => {
|
||||
if (signal?.includes('Strong')) return 'strong';
|
||||
if (signal?.includes('Momentum')) return 'momentum';
|
||||
if (signal?.includes('Speculation')) return 'spec';
|
||||
if (signal?.includes('Neutral')) return 'neutral';
|
||||
return 'avoid';
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="badge {cls()}">{signal ?? '—'}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.strong { background: #14532d33; color: #4ade80; }
|
||||
.momentum { background: #1e3a5f33; color: #60a5fa; }
|
||||
.spec { background: #7c2d1233; color: #fb923c; }
|
||||
.neutral { background: #1e293b; color: #94a3b8; }
|
||||
.avoid { background: #450a0a33; color: #f87171; }
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
// size: 'sm' | 'md' | 'lg'
|
||||
// label: optional text shown below (lg only)
|
||||
let { size = 'md', label = null } = $props();
|
||||
</script>
|
||||
|
||||
{#if size === 'sm'}
|
||||
<!-- Compact dot-pulse for buttons -->
|
||||
<span class="dot-pulse">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Market chart line animation for md / lg -->
|
||||
<div class="chart-wrap" data-size={size}>
|
||||
<svg
|
||||
viewBox="0 0 160 60"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="chart-svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Faint grid lines -->
|
||||
<line x1="0" y1="15" x2="160" y2="15" stroke="#1e293b" stroke-width="1" />
|
||||
<line x1="0" y1="30" x2="160" y2="30" stroke="#1e293b" stroke-width="1" />
|
||||
<line x1="0" y1="45" x2="160" y2="45" stroke="#1e293b" stroke-width="1" />
|
||||
|
||||
<!-- The market line — rises, dips, spikes, recovers -->
|
||||
<polyline
|
||||
class="chart-line"
|
||||
points="
|
||||
0,45
|
||||
12,38
|
||||
22,42
|
||||
32,28
|
||||
42,32
|
||||
52,18
|
||||
62,24
|
||||
72,14
|
||||
82,20
|
||||
92,10
|
||||
104,22
|
||||
114,16
|
||||
124,28
|
||||
134,20
|
||||
148,8
|
||||
160,12
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Glowing dot at the leading edge -->
|
||||
<circle class="chart-dot" cx="160" cy="12" r="3" />
|
||||
</svg>
|
||||
|
||||
{#if label}
|
||||
<span class="chart-label">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Dot pulse (sm) ─────────────────────────────────────────────── */
|
||||
.dot-pulse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot-pulse span {
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #60a5fa;
|
||||
animation: dot-bounce 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.dot-pulse span:nth-child(3) { animation-delay: 0.30s; }
|
||||
|
||||
@keyframes dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Chart wrap (md / lg) ───────────────────────────────────────── */
|
||||
.chart-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chart-wrap[data-size="md"] .chart-svg { width: 120px; height: 45px; }
|
||||
.chart-wrap[data-size="lg"] .chart-svg { width: 200px; height: 75px; }
|
||||
|
||||
.chart-svg { overflow: visible; }
|
||||
|
||||
/* The animated line */
|
||||
.chart-line {
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
/* total path length ≈ 220 — animate draw-in then loop */
|
||||
stroke-dasharray: 220;
|
||||
stroke-dashoffset: 220;
|
||||
animation: draw-line 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes draw-line {
|
||||
0% { stroke-dashoffset: 220; opacity: 1; }
|
||||
70% { stroke-dashoffset: 0; opacity: 1; }
|
||||
85% { stroke-dashoffset: 0; opacity: 0; }
|
||||
100% { stroke-dashoffset: 220; opacity: 0; }
|
||||
}
|
||||
|
||||
/* Glowing dot that appears when the line finishes drawing */
|
||||
.chart-dot {
|
||||
fill: #3b82f6;
|
||||
filter: drop-shadow(0 0 4px #3b82f6);
|
||||
opacity: 0;
|
||||
animation: dot-appear 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-appear {
|
||||
0% { opacity: 0; }
|
||||
60% { opacity: 0; }
|
||||
70% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
const BASE = '/api';
|
||||
|
||||
export async function screenTickers(tickers) {
|
||||
const res = await fetch(`${BASE}/screen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCatalysts() {
|
||||
const res = await fetch(`${BASE}/screen/catalysts`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function analyzeTickers(tickers) {
|
||||
const res = await fetch(`${BASE}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchPortfolio() {
|
||||
const res = await fetch(`${BASE}/finance/portfolio`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addHolding(holding) {
|
||||
const res = await fetch(`${BASE}/finance/holdings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(holding),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeHolding(ticker) {
|
||||
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMarketContext() {
|
||||
const res = await fetch(`${BASE}/finance/market-context`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Market Calls ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchCalls() {
|
||||
const res = await fetch(`${BASE}/calls`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCall(id) {
|
||||
const res = await fetch(`${BASE}/calls/${id}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createCall(payload) {
|
||||
const res = await fetch(`${BASE}/calls`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteCall(id) {
|
||||
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCallsCalendar(tickers = null) {
|
||||
const url = tickers?.length
|
||||
? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
|
||||
: `${BASE}/calls/calendar`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
Reference in New Issue
Block a user