Files
StockRadar/frontend/src/routes/Screener.svelte
T
2026-04-20 21:29:22 +02:00

471 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte'
import { api } from '../lib/api.js'
import { notify } from '../lib/store.js'
let signals = []
let loading = true
let scanning = false
let etoroCount = 0
// Filtres
let filterEtoro = false
let filterMinRsi = ''
let filterMaxRsi = ''
let filterAlert = ''
let filterCap = '' // 'small' | 'mid' | 'large' | ''
let filterMinScore = ''
onMount(async () => {
load()
try {
const stats = await api.etoroStats()
etoroCount = stats.instruments ?? 0
} catch {}
})
async function load() {
loading = true
try {
signals = await api.getSignals(filterEtoro)
} catch(e) {
notify('Erreur chargement : ' + e.message, 'error')
signals = []
} finally {
loading = false
}
}
async function scan() {
scanning = true
try {
await api.triggerScan()
notify('Scan lancé — résultats dans quelques secondes', 'info')
setTimeout(load, 10000)
} catch(e) {
notify('Erreur scan : ' + e.message, 'error')
} finally {
scanning = false
}
}
async function syncEtoro() {
try {
await api.syncEtoro()
notify('Sync eToro lancé — ~5000 instruments à charger', 'info')
setTimeout(async () => {
const stats = await api.etoroStats()
etoroCount = stats.instruments ?? 0
}, 8000)
} catch(e) {
notify('Erreur sync eToro : ' + e.message, 'error')
}
}
$: filtered = signals.filter(s => {
if (filterEtoro && !s.on_etoro) return false
if (filterMinRsi !== '' && s.rsi14 < +filterMinRsi) return false
if (filterMaxRsi !== '' && s.rsi14 > +filterMaxRsi) return false
if (filterAlert && s.alert !== filterAlert) return false
if (filterMinScore !== '' && s.score < +filterMinScore) return false
if (filterCap) {
const cap = s.market_cap
if (filterCap === 'small' && !(cap > 0 && cap < 2e9)) return false
if (filterCap === 'mid' && !(cap >= 2e9 && cap < 10e9)) return false
if (filterCap === 'large' && !(cap >= 10e9)) return false
}
return true
})
function rsiClass(rsi) {
if (rsi <= 0) return ''
if (rsi < 30) return 'oversold'
if (rsi > 70) return 'overbought'
return ''
}
function alertLabel(alert) {
const map = {
mega_insider_buy: '🐋 Mega Insider',
deep_value_reversal: '📉→↑ Deep Value',
oversold: '▼ Oversold',
overbought: '▲ Overbought',
macd_cross_up: '↑ MACD bull',
macd_cross_down: '↓ MACD bear',
volume_spike: '⚡ Vol spike',
}
return map[alert] || alert
}
function alertClass(alert) {
if (['mega_insider_buy','oversold','macd_cross_up','deep_value_reversal'].includes(alert)) return 'green'
if (['overbought','macd_cross_down'].includes(alert)) return 'red'
if (alert === 'volume_spike') return 'orange'
return ''
}
function fmtInsider(v) {
if (!v) return ''
if (v >= 1e6) return '$' + (v/1e6).toFixed(1) + 'M'
if (v >= 1e3) return '$' + (v/1e3).toFixed(0) + 'K'
return '$' + v
}
function scoreClass(score) {
if (score >= 60) return 'score-high'
if (score >= 35) return 'score-mid'
return 'score-low'
}
function fmtCap(v) {
if (!v) return '—'
if (v >= 1e12) return '$' + (v / 1e12).toFixed(1) + 'T'
if (v >= 1e9) return '$' + (v / 1e9).toFixed(1) + 'B'
if (v >= 1e6) return '$' + (v / 1e6).toFixed(0) + 'M'
return '$' + v
}
function fmt(v, d = 2) {
if (v == null || v === 0) return '—'
return (+v).toFixed(d)
}
function volRatio(vol, avg) {
if (!avg) return null
return vol / avg
}
function capLabel(cap) {
if (!cap) return ''
if (cap < 2e9) return 'Small'
if (cap < 10e9) return 'Mid'
return 'Large'
}
</script>
<div class="page">
<div class="header">
<h1>Screener</h1>
<div class="header-actions">
<div class="etoro-badge" class:active={etoroCount > 0} title="Instruments eToro chargés">
eToro {etoroCount > 0 ? etoroCount.toLocaleString() : '—'}
{#if etoroCount === 0}
<button class="btn-load-etoro" on:click={syncEtoro}>Charger</button>
{/if}
</div>
<button class="btn-scan" on:click={scan} disabled={scanning}>
{scanning ? '…' : '⊙ Scanner'}
</button>
</div>
</div>
<div class="filters">
<label class="toggle">
<input type="checkbox" bind:checked={filterEtoro} on:change={load} />
<span>eToro uniquement</span>
</label>
<div class="filter-group">
<label for="cap-filter">Market Cap</label>
<select id="cap-filter" bind:value={filterCap}>
<option value="">Toutes</option>
<option value="small">Small (&lt; $2B)</option>
<option value="mid">Mid ($2B$10B)</option>
<option value="large">Large (&gt; $10B)</option>
</select>
</div>
<div class="filter-group">
<label for="score-min">Score min</label>
<input id="score-min" type="number" bind:value={filterMinScore} placeholder="ex: 40" min="0" max="100" />
</div>
<div class="filter-group">
<label for="rsi-min">RSI min</label>
<input id="rsi-min" type="number" bind:value={filterMinRsi} placeholder="0" min="0" max="100" />
</div>
<div class="filter-group">
<label for="rsi-max">RSI max</label>
<input id="rsi-max" type="number" bind:value={filterMaxRsi} placeholder="100" min="0" max="100" />
</div>
<div class="filter-group">
<label for="alert-filter">Alerte</label>
<select id="alert-filter" bind:value={filterAlert}>
<option value="">Toutes</option>
<option value="oversold">▼ Oversold</option>
<option value="overbought">▲ Overbought</option>
<option value="macd_cross_up">↑ MACD bull</option>
<option value="macd_cross_down">↓ MACD bear</option>
<option value="volume_spike">⚡ Vol spike</option>
</select>
</div>
<button class="btn-reset" on:click={() => { filterMinRsi=''; filterMaxRsi=''; filterAlert=''; filterCap=''; filterMinScore=''; filterEtoro=false; load() }}>
Reset
</button>
</div>
{#if loading}
<p class="muted">Chargement…</p>
{:else if signals.length === 0}
<div class="empty-state">
<div class="icon"></div>
<p>Aucun signal — ajoute des tickers dans la <a href="#/watchlist">Watchlist</a> puis clique <strong>⊙ Scanner</strong>.</p>
</div>
{:else}
<div class="count">{filtered.length} / {signals.length} tickers</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Score</th>
<th>Ticker</th>
<th>Cap</th>
<th class="num">Prix</th>
<th class="num">Chg%</th>
<th class="num">RSI(14)</th>
<th class="num">MACD histo</th>
<th class="num">Vol/Avg</th>
<th class="num">52w%</th>
<th class="num">Insider 30j</th>
<th class="num">Mkt Cap</th>
<th class="num">Short</th>
<th>Alerte</th>
<th>eToro</th>
</tr>
</thead>
<tbody>
{#each filtered as s}
{@const ratio = volRatio(s.volume, s.avg_volume20)}
<tr class:has-alert={s.alert} class:high-score={s.score >= 60}>
<td>
<span class="score-pill {scoreClass(s.score)}">{s.score}</span>
</td>
<td>
<div class="ticker">{s.ticker}</div>
{#if s.name && s.name !== s.ticker}
<div class="ticker-name">{s.name}</div>
{/if}
</td>
<td><span class="cap-label">{capLabel(s.market_cap)}</span></td>
<td class="num">${fmt(s.price)}</td>
<td class="num" class:green={s.change_pct > 0} class:red={s.change_pct < 0}>
{s.change_pct > 0 ? '+' : ''}{fmt(s.change_pct)}%
</td>
<td class="num rsi {rsiClass(s.rsi14)}">{fmt(s.rsi14, 1)}</td>
<td class="num" class:green={s.macd_hist > 0} class:red={s.macd_hist < 0}>
{fmt(s.macd_hist, 3)}
</td>
<td class="num" class:vol-spike={ratio && ratio > 2}>
{ratio ? ratio.toFixed(1) + 'x' : '—'}
</td>
<td class="num pct-from-high" class:deep-value={s.pct_from_high < -40}>
{s.pct_from_high ? fmt(s.pct_from_high, 1) + '%' : '—'}
</td>
<td class="num" class:insider-big={s.insider_value_30d >= 1_000_000}>
{fmtInsider(s.insider_value_30d)}
</td>
<td class="num">{fmtCap(s.market_cap)}</td>
<td class="num">{s.short_ratio > 0 ? fmt(s.short_ratio, 1) + 'd' : '—'}</td>
<td>
{#if s.alert}
<span class="badge {alertClass(s.alert)}">{alertLabel(s.alert)}</span>
{/if}
</td>
<td class="center">
{#if s.on_etoro}<span class="etoro-dot" title="Disponible sur eToro"></span>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.page h1 { color: #e6edf3; font-size: 1.4rem; margin: 0; }
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.etoro-badge {
font-size: 0.78rem;
color: #484f58;
border: 1px solid #21262d;
border-radius: 6px;
padding: 0.35rem 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.etoro-badge.active { color: #3fb950; border-color: #3fb950; }
.btn-load-etoro {
background: none;
border: none;
color: #58a6ff;
font-size: 0.78rem;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.btn-scan {
background: #1f6feb;
border: none;
border-radius: 6px;
color: #fff;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.btn-scan:disabled { opacity: 0.5; }
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin-bottom: 1.25rem;
padding: 1rem 1.25rem;
background: #161b22;
border: 1px solid #21262d;
border-radius: 8px;
}
.toggle {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
font-size: 0.875rem;
color: #8b949e;
align-self: flex-end;
padding-bottom: 0.1rem;
}
.toggle input { accent-color: #3fb950; }
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
label { font-size: 0.72rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; }
input[type="number"], select {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
outline: none;
width: 110px;
}
input:focus, select:focus { border-color: #58a6ff; }
select option { background: #161b22; }
.btn-reset {
background: none;
border: 1px solid #30363d;
border-radius: 6px;
color: #8b949e;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
align-self: flex-end;
}
.btn-reset:hover { color: #e6edf3; }
.count { font-size: 0.78rem; color: #484f58; margin-bottom: 0.75rem; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
th {
text-align: left;
color: #8b949e;
font-weight: 500;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #21262d;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
th.num { text-align: right; }
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
td.center { text-align: center; }
tr:hover td { background: #161b22; }
tr.has-alert td { background: #0d180d; }
tr.high-score td { background: #0d1a10; }
tr.high-score:hover td { background: #112214; }
.score-pill {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 12px;
font-size: 0.78rem;
font-weight: 700;
min-width: 32px;
text-align: center;
}
.score-high { background: #0d3320; color: #3fb950; }
.score-mid { background: #2d2200; color: #d29922; }
.score-low { background: #1c1c1c; color: #484f58; }
.ticker { font-weight: 600; color: #58a6ff; }
.ticker-name { font-size: 0.72rem; color: #484f58; margin-top: 1px; }
.cap-label {
font-size: 0.7rem;
color: #8b949e;
border: 1px solid #30363d;
border-radius: 3px;
padding: 0.1rem 0.35rem;
}
.green { color: #3fb950; }
.red { color: #f85149; }
.rsi.oversold { color: #3fb950; font-weight: 600; }
.rsi.overbought { color: #f85149; font-weight: 600; }
.vol-spike { color: #d29922; font-weight: 600; }
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
background: #21262d;
color: #8b949e;
}
.badge.green { background: #0d2c1a; color: #3fb950; }
.badge.red { background: #2c0d0d; color: #f85149; }
.badge.orange { background: #2d1f00; color: #d29922; }
.etoro-dot { color: #3fb950; font-size: 0.9rem; }
.pct-from-high { color: #8b949e; }
.pct-from-high.deep-value { color: #d29922; font-weight: 600; }
.insider-big { color: #3fb950; font-weight: 700; }
.empty-state { text-align: center; padding: 3rem; color: #484f58; }
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; }
.empty-state p { font-size: 0.9rem; }
.empty-state a { color: #58a6ff; }
.empty-state strong { color: #8b949e; }
.muted { color: #484f58; font-size: 0.875rem; }
</style>