added
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import Dashboard from './routes/Dashboard.svelte'
|
||||
import Watchlist from './routes/Watchlist.svelte'
|
||||
import Screener from './routes/Screener.svelte'
|
||||
import Discover from './routes/Discover.svelte'
|
||||
import News from './routes/News.svelte'
|
||||
import Settings from './routes/Settings.svelte'
|
||||
import { api } from './lib/api.js'
|
||||
@@ -15,6 +16,7 @@
|
||||
'/': Dashboard,
|
||||
'/watchlist': Watchlist,
|
||||
'/screener': Screener,
|
||||
'/discover': Discover,
|
||||
'/news': News,
|
||||
'/settings': Settings,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{ path: '/', label: 'Dashboard', icon: '◈' },
|
||||
{ path: '/watchlist', label: 'Watchlist', icon: '☆' },
|
||||
{ path: '/screener', label: 'Screener', icon: '⊞' },
|
||||
{ path: '/news', label: 'News', icon: '⊙' },
|
||||
{ path: '/discover', label: 'Découverte', icon: '⊙' },
|
||||
{ path: '/news', label: 'News', icon: '◎' },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -21,4 +21,15 @@ export const api = {
|
||||
addTicker: (ticker) => request('POST', '/watchlist', { ticker }),
|
||||
removeTicker: (ticker) => request('DELETE', `/watchlist/${ticker}`),
|
||||
getNews: (ticker) => request('GET', `/news${ticker ? `?ticker=${ticker}` : ''}`),
|
||||
syncNews: () => request('POST', '/news/sync'),
|
||||
getSignals: (etoroOnly) => request('GET', `/signals${etoroOnly ? '?etoro=1' : ''}`),
|
||||
triggerScan: () => request('POST', '/signals/scan'),
|
||||
getPrices: (ticker) => request('GET', `/prices?ticker=${ticker}`),
|
||||
syncEtoro: () => request('POST', '/etoro/sync'),
|
||||
etoroStatus: () => request('GET', '/etoro/status'),
|
||||
getDiscovery: (minScore) => request('GET', `/discover?min_score=${minScore ?? 30}`),
|
||||
runDiscovery: () => request('POST', '/discover/run'),
|
||||
discoveryStatus: () => request('GET', '/discover/status'),
|
||||
getInsiderTrades: (ticker) => request('GET', `/insider-trades${ticker ? `?ticker=${ticker}` : ''}`),
|
||||
syncInsider: () => request('POST', '/insider-trades/sync'),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { serverStatus } from '../lib/store.js'
|
||||
|
||||
let health = null
|
||||
let signals = []
|
||||
let error = null
|
||||
|
||||
onMount(async () => {
|
||||
@@ -14,7 +15,37 @@
|
||||
error = e.message
|
||||
serverStatus.set('error')
|
||||
}
|
||||
try {
|
||||
signals = await api.getSignals()
|
||||
} catch {
|
||||
// pas bloquant
|
||||
}
|
||||
})
|
||||
|
||||
$: alerts = signals.filter(s => s.alert)
|
||||
$: oversold = signals.filter(s => s.alert === 'oversold')
|
||||
$: overbought = signals.filter(s => s.alert === 'overbought')
|
||||
|
||||
function alertLabel(alert) {
|
||||
const map = {
|
||||
oversold: '▼ Oversold',
|
||||
overbought: '▲ Overbought',
|
||||
macd_cross_up: '↑ MACD bullish',
|
||||
macd_cross_down: '↓ MACD bearish',
|
||||
}
|
||||
return map[alert] || alert
|
||||
}
|
||||
|
||||
function alertClass(alert) {
|
||||
if (alert === 'oversold' || alert === 'macd_cross_up') return 'green'
|
||||
if (alert === 'overbought' || alert === 'macd_cross_down') return 'red'
|
||||
return ''
|
||||
}
|
||||
|
||||
function fmt(v, dec = 2) {
|
||||
if (v == null) return '—'
|
||||
return (+v).toFixed(dec)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
@@ -28,23 +59,87 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Port</div>
|
||||
<div class="card-value">{health?.port ?? '—'}</div>
|
||||
<div class="card-label">Tickers suivis</div>
|
||||
<div class="card-value">{signals.length || '—'}</div>
|
||||
</div>
|
||||
<div class="card muted">
|
||||
<div class="card" class:highlight={alerts.length > 0}>
|
||||
<div class="card-label">Signaux actifs</div>
|
||||
<div class="card-value">—</div>
|
||||
<div class="card-value" class:orange={alerts.length > 0}>{alerts.length || '0'}</div>
|
||||
</div>
|
||||
<div class="card muted">
|
||||
<div class="card-label">News aujourd'hui</div>
|
||||
<div class="card-value">—</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Oversold / Overbought</div>
|
||||
<div class="card-value">
|
||||
<span class="green">{oversold.length}</span>
|
||||
<span class="sep"> / </span>
|
||||
<span class="red">{overbought.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Activité récente</h2>
|
||||
<p class="empty">Aucune donnée pour l'instant — configure les clés API dans <a href="#/settings">Settings</a>.</p>
|
||||
</div>
|
||||
{#if alerts.length > 0}
|
||||
<div class="section">
|
||||
<h2>Signaux actifs</h2>
|
||||
<div class="alert-list">
|
||||
{#each alerts as s}
|
||||
<div class="alert-row">
|
||||
<span class="ticker">{s.ticker}</span>
|
||||
<span class="badge {alertClass(s.alert)}">{alertLabel(s.alert)}</span>
|
||||
<span class="price">${fmt(s.price)}</span>
|
||||
<span class="rsi {s.rsi14 < 30 ? 'green' : s.rsi14 > 70 ? 'red' : ''}">
|
||||
RSI {fmt(s.rsi14, 1)}
|
||||
</span>
|
||||
<span class="chg" class:green={s.change_pct > 0} class:red={s.change_pct < 0}>
|
||||
{s.change_pct > 0 ? '+' : ''}{fmt(s.change_pct)}%
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if signals.length > 0}
|
||||
<div class="section">
|
||||
<h2>Watchlist — aperçu</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th class="num">Prix</th>
|
||||
<th class="num">Chg%</th>
|
||||
<th class="num">RSI(14)</th>
|
||||
<th class="num">MACD histo</th>
|
||||
<th>Signal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each signals.slice(0, 10) as s}
|
||||
<tr>
|
||||
<td class="ticker">{s.ticker}</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" class:green={s.rsi14 < 30} class:red={s.rsi14 > 70}>
|
||||
{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>
|
||||
{#if s.alert}
|
||||
<span class="badge {alertClass(s.alert)}">{alertLabel(s.alert)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if signals.length === 0 && health}
|
||||
<div class="section">
|
||||
<p class="empty">Aucune donnée — ajoute des tickers dans <a href="#/watchlist">Watchlist</a> puis lance un scan depuis le <a href="#/screener">Screener</a>.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -63,14 +158,65 @@
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.card.highlight { border-color: #d29922; }
|
||||
|
||||
.card-label { font-size: 0.75rem; color: #8b949e; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.card-label { font-size: 0.72rem; color: #8b949e; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.card-value { font-size: 1.25rem; font-weight: 600; color: #e6edf3; }
|
||||
.card-value.green { color: #3fb950; }
|
||||
.card-value.red { color: #f85149; }
|
||||
.card.muted .card-value { color: #484f58; }
|
||||
.card-value.orange { color: #d29922; }
|
||||
|
||||
.section h2 { font-size: 1rem; color: #8b949e; border-bottom: 1px solid #21262d; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
||||
.sep { color: #484f58; }
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.06em; color: #8b949e; border-bottom: 1px solid #21262d; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
||||
|
||||
.alert-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.alert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ticker { font-weight: 600; color: #58a6ff; min-width: 60px; }
|
||||
.price { color: #e6edf3; font-variant-numeric: tabular-nums; }
|
||||
.rsi { font-size: 0.8rem; color: #8b949e; }
|
||||
.chg { font-size: 0.8rem; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.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; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th {
|
||||
text-align: left;
|
||||
color: #8b949e;
|
||||
font-weight: 500;
|
||||
padding: 0.45rem 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; }
|
||||
tr:hover td { background: #161b22; }
|
||||
|
||||
.green { color: #3fb950; }
|
||||
.red { color: #f85149; }
|
||||
|
||||
.empty { color: #484f58; font-size: 0.875rem; }
|
||||
.empty a { color: #58a6ff; }
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api } from '../lib/api.js'
|
||||
import { notify } from '../lib/store.js'
|
||||
|
||||
// Statut eToro
|
||||
let etoroStatus = { syncing: false, count: 0, progress: 0, total: 0, last_error: '' }
|
||||
// Statut discovery
|
||||
let discStatus = { running: false, progress: 0, total: 0, found: 0 }
|
||||
// Résultats
|
||||
let results = []
|
||||
let loading = false
|
||||
let minScore = 30
|
||||
let filterAlert = ''
|
||||
|
||||
let pollInterval = null
|
||||
|
||||
onMount(async () => {
|
||||
await refreshStatus()
|
||||
await loadResults()
|
||||
// Poll toutes les 3s si un process tourne
|
||||
pollInterval = setInterval(async () => {
|
||||
await refreshStatus()
|
||||
if (discStatus.running) await loadResults()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
onDestroy(() => clearInterval(pollInterval))
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
[etoroStatus, discStatus] = await Promise.all([
|
||||
api.etoroStatus(),
|
||||
api.discoveryStatus(),
|
||||
])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
loading = true
|
||||
try {
|
||||
results = await api.getDiscovery(minScore)
|
||||
} catch(e) {
|
||||
notify('Erreur chargement : ' + e.message, 'error')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncEtoro() {
|
||||
try {
|
||||
await api.syncEtoro()
|
||||
notify('Sync eToro lancé — chargement des instruments en cours…', 'info')
|
||||
} catch(e) {
|
||||
notify('Erreur : ' + e.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function runDiscovery() {
|
||||
if (etoroStatus.count === 0) {
|
||||
notify('Lance d\'abord la sync eToro pour charger les instruments', 'warning')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await api.runDiscovery()
|
||||
if (r.status === 'already_running') {
|
||||
notify('Scan déjà en cours…', 'info')
|
||||
} else {
|
||||
notify(`Scan lancé sur ${etoroStatus.count.toLocaleString()} tickers eToro — ~${Math.ceil(etoroStatus.count * 0.12 / 60)} min`, 'info')
|
||||
}
|
||||
await refreshStatus()
|
||||
} catch(e) {
|
||||
notify('Erreur : ' + e.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
$: filtered = filterAlert
|
||||
? results.filter(r => r.alert === filterAlert)
|
||||
: results
|
||||
|
||||
function alertLabel(a) {
|
||||
const map = {
|
||||
mega_insider_buy: '🐋 Mega Insider',
|
||||
deep_value_reversal: '📉→↑ Deep Value',
|
||||
oversold: '▼ Oversold',
|
||||
overbought: '▲ Overbought',
|
||||
macd_cross_up: '↑ MACD bull',
|
||||
volume_spike: '⚡ Vol spike',
|
||||
}
|
||||
return map[a] || a
|
||||
}
|
||||
|
||||
function alertClass(a) {
|
||||
if (['mega_insider_buy','oversold','macd_cross_up','deep_value_reversal'].includes(a)) return 'green'
|
||||
if (['overbought'].includes(a)) return 'red'
|
||||
if (a === 'volume_spike') return 'orange'
|
||||
return ''
|
||||
}
|
||||
|
||||
function scoreClass(s) {
|
||||
if (s >= 60) return 'high'
|
||||
if (s >= 35) return 'mid'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function fmt(v, d = 2) {
|
||||
if (!v && v !== 0) return '—'
|
||||
return (+v).toFixed(d)
|
||||
}
|
||||
|
||||
function fmtCap(v) {
|
||||
if (!v) return '—'
|
||||
if (v >= 1e9) return '$' + (v/1e9).toFixed(1) + 'B'
|
||||
if (v >= 1e6) return '$' + (v/1e6).toFixed(0) + 'M'
|
||||
return '$' + v
|
||||
}
|
||||
|
||||
function volRatio(vol, avg) {
|
||||
if (!avg) return null
|
||||
return vol / avg
|
||||
}
|
||||
|
||||
$: progressPct = discStatus.total > 0
|
||||
? Math.round(discStatus.progress / discStatus.total * 100)
|
||||
: 0
|
||||
|
||||
$: etoroPct = etoroStatus.total > 0
|
||||
? Math.round(etoroStatus.progress / etoroStatus.total * 100)
|
||||
: 0
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>Découverte</h1>
|
||||
<p class="subtitle">Scan de l'univers eToro complet pour trouver des opportunités avant qu'elles explosent.</p>
|
||||
|
||||
<!-- Statut eToro -->
|
||||
<div class="status-bar">
|
||||
<div class="status-block" class:active={etoroStatus.count > 0} class:syncing={etoroStatus.syncing}>
|
||||
<div class="status-label">Instruments eToro</div>
|
||||
<div class="status-value">
|
||||
{#if etoroStatus.syncing}
|
||||
<span class="spinner">↻</span>
|
||||
{etoroStatus.progress.toLocaleString()} / {etoroStatus.total.toLocaleString()}
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:{etoroPct}%"></div></div>
|
||||
{:else if etoroStatus.count > 0}
|
||||
<span class="ok">● {etoroStatus.count.toLocaleString()} stocks</span>
|
||||
{:else}
|
||||
<span class="empty-val">Non chargé</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if etoroStatus.last_error}
|
||||
<div class="error-hint">⚠ {etoroStatus.last_error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="status-block" class:active={discStatus.found > 0} class:syncing={discStatus.running}>
|
||||
<div class="status-label">Scan découverte</div>
|
||||
<div class="status-value">
|
||||
{#if discStatus.running}
|
||||
<span class="spinner">↻</span>
|
||||
{discStatus.progress.toLocaleString()} / {discStatus.total.toLocaleString()}
|
||||
<span class="found-count">{discStatus.found} opport.</span>
|
||||
<div class="progress-bar"><div class="progress-fill green" style="width:{progressPct}%"></div></div>
|
||||
{:else if discStatus.found > 0}
|
||||
<span class="ok">● {discStatus.found} opportunités</span>
|
||||
{:else}
|
||||
<span class="empty-val">Jamais lancé</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-actions">
|
||||
<button class="btn-etoro" on:click={syncEtoro} disabled={etoroStatus.syncing}>
|
||||
{etoroStatus.syncing ? '↻ Chargement…' : '↻ Sync eToro'}
|
||||
</button>
|
||||
<button class="btn-discover" on:click={runDiscovery}
|
||||
disabled={discStatus.running || etoroStatus.count === 0}>
|
||||
{discStatus.running ? `↻ Scan ${progressPct}%…` : '⊙ Lancer le scan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="min-score">Score min</label>
|
||||
<input id="min-score" type="number" bind:value={minScore}
|
||||
min="0" max="100" on:change={loadResults} />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="alert-f">Alerte</label>
|
||||
<select id="alert-f" bind:value={filterAlert}>
|
||||
<option value="">Toutes</option>
|
||||
<option value="oversold">▼ Oversold</option>
|
||||
<option value="macd_cross_up">↑ MACD bull</option>
|
||||
<option value="volume_spike">⚡ Vol spike</option>
|
||||
<option value="deep_value_reversal">📉→↑ Deep Value</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-refresh" on:click={loadResults}>↻</button>
|
||||
</div>
|
||||
|
||||
<!-- Résultats -->
|
||||
{#if loading}
|
||||
<p class="muted">Chargement…</p>
|
||||
{:else if results.length === 0 && !discStatus.running}
|
||||
<div class="empty-state">
|
||||
<div class="icon">⊙</div>
|
||||
{#if etoroStatus.count === 0}
|
||||
<p>Commence par <strong>Sync eToro</strong> pour charger la liste des instruments disponibles (~3 000 stocks).</p>
|
||||
{:else}
|
||||
<p>Clique <strong>Lancer le scan</strong> pour analyser les {etoroStatus.count.toLocaleString()} stocks eToro et trouver les meilleures opportunités.</p>
|
||||
<p class="hint">Durée estimée : ~{Math.ceil(etoroStatus.count * 0.12 / 60)} minutes</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="results-header">
|
||||
<span class="count">{filtered.length} opportunités (score ≥ {minScore})</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Score</th>
|
||||
<th>Ticker</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">Mkt Cap</th>
|
||||
<th>Alerte</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as r}
|
||||
{@const ratio = volRatio(r.volume, r.avg_volume20)}
|
||||
<tr class:has-alert={r.alert}>
|
||||
<td><span class="score-pill {scoreClass(r.score)}">{r.score}</span></td>
|
||||
<td>
|
||||
<div class="ticker">{r.ticker}</div>
|
||||
{#if r.name && r.name !== r.ticker}
|
||||
<div class="ticker-name">{r.name}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">${fmt(r.price)}</td>
|
||||
<td class="num" class:green={r.change_pct > 0} class:red={r.change_pct < 0}>
|
||||
{r.change_pct > 0 ? '+' : ''}{fmt(r.change_pct)}%
|
||||
</td>
|
||||
<td class="num rsi" class:oversold={r.rsi14 < 30} class:overbought={r.rsi14 > 70}>
|
||||
{fmt(r.rsi14, 1)}
|
||||
</td>
|
||||
<td class="num" class:green={r.macd_hist > 0} class:red={r.macd_hist < 0}>
|
||||
{fmt(r.macd_hist, 3)}
|
||||
</td>
|
||||
<td class="num" class:vol-spike={ratio && ratio > 2}>
|
||||
{ratio ? ratio.toFixed(1) + 'x' : '—'}
|
||||
</td>
|
||||
<td class="num" class:deep-value={r.pct_from_high < -40}>
|
||||
{r.pct_from_high ? fmt(r.pct_from_high, 1) + '%' : '—'}
|
||||
</td>
|
||||
<td class="num">{fmtCap(r.market_cap)}</td>
|
||||
<td>
|
||||
{#if r.alert}
|
||||
<span class="badge {alertClass(r.alert)}">{alertLabel(r.alert)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page h1 { color: #e6edf3; font-size: 1.4rem; margin: 0 0 0.25rem; }
|
||||
.subtitle { color: #484f58; font-size: 0.85rem; margin: 0 0 1.5rem; }
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-block {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.72rem;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 0.9rem;
|
||||
color: #484f58;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-block.active .status-value { color: #c9d1d9; }
|
||||
|
||||
.ok { color: #3fb950; }
|
||||
.empty-val { color: #30363d; }
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
color: #58a6ff;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.found-count { color: #3fb950; font-size: 0.8rem; }
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #21262d;
|
||||
border-radius: 2px;
|
||||
margin-top: 0.4rem;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #58a6ff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.progress-fill.green { background: #3fb950; }
|
||||
|
||||
.error-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #f85149;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn-etoro, .btn-discover {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-etoro {
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
.btn-etoro:hover:not(:disabled) { color: #e6edf3; }
|
||||
.btn-discover {
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-discover:hover:not(:disabled) { background: #388bfd; }
|
||||
.btn-etoro:disabled, .btn-discover:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Filtres */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
label { font-size: 0.72rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
input, select {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
width: 100px;
|
||||
}
|
||||
input:focus, select:focus { border-color: #58a6ff; }
|
||||
select option { background: #161b22; }
|
||||
.btn-refresh {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #8b949e;
|
||||
padding: 0.4rem 0.65rem;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.btn-refresh:hover { color: #e6edf3; }
|
||||
|
||||
.results-header { margin-bottom: 0.75rem; }
|
||||
.count { font-size: 0.78rem; color: #484f58; }
|
||||
|
||||
/* Table */
|
||||
.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.45rem 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; }
|
||||
tr:hover td { background: #161b22; }
|
||||
tr.has-alert td { background: #0d180d; }
|
||||
|
||||
.score-pill {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.score-pill.high { background: #0d3320; color: #3fb950; }
|
||||
.score-pill.mid { background: #2d2200; color: #d29922; }
|
||||
.score-pill.low { background: #1c1c1c; color: #484f58; }
|
||||
|
||||
.ticker { font-weight: 600; color: #58a6ff; }
|
||||
.ticker-name { font-size: 0.72rem; color: #484f58; }
|
||||
|
||||
.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; }
|
||||
.deep-value { color: #d29922; font-weight: 600; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.45rem;
|
||||
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; }
|
||||
|
||||
.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; margin: 0.4rem 0; }
|
||||
.empty-state strong { color: #8b949e; }
|
||||
.empty-state .hint { font-size: 0.8rem; color: #30363d; }
|
||||
|
||||
.muted { color: #484f58; font-size: 0.875rem; }
|
||||
</style>
|
||||
+262
-63
@@ -3,146 +3,345 @@
|
||||
import { api } from '../lib/api.js'
|
||||
import { notify } from '../lib/store.js'
|
||||
|
||||
let tab = 'news' // 'news' | 'insider'
|
||||
let news = []
|
||||
let insider = []
|
||||
let loading = true
|
||||
let syncing = false
|
||||
let filter = ''
|
||||
|
||||
onMount(load)
|
||||
onMount(() => loadAll())
|
||||
|
||||
async function load() {
|
||||
async function loadAll() {
|
||||
loading = true
|
||||
try {
|
||||
news = await api.getNews(filter || null)
|
||||
[news, insider] = await Promise.all([
|
||||
api.getNews(null),
|
||||
api.getInsiderTrades(null),
|
||||
])
|
||||
} catch(e) {
|
||||
notify('Erreur chargement news : ' + e.message, 'error')
|
||||
news = []
|
||||
notify('Erreur chargement : ' + e.message, 'error')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function sentimentColor(s) {
|
||||
if (!s) return ''
|
||||
async function syncNews() {
|
||||
syncing = true
|
||||
try {
|
||||
await api.syncNews()
|
||||
notify('Sync Finnhub lancé', 'info')
|
||||
setTimeout(() => api.getNews(null).then(d => news = d), 3000)
|
||||
} catch(e) {
|
||||
notify('Erreur : ' + e.message, 'error')
|
||||
} finally {
|
||||
syncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncInsider() {
|
||||
syncing = true
|
||||
try {
|
||||
await api.syncInsider()
|
||||
notify('Sync EDGAR lancé — peut prendre 1-2 min selon la watchlist', 'info')
|
||||
setTimeout(() => api.getInsiderTrades(null).then(d => insider = d), 15000)
|
||||
} catch(e) {
|
||||
notify('Erreur : ' + e.message, 'error')
|
||||
} finally {
|
||||
syncing = false
|
||||
}
|
||||
}
|
||||
|
||||
$: filteredNews = filter
|
||||
? news.filter(n => n.ticker?.toLowerCase().includes(filter.toLowerCase()) || n.headline?.toLowerCase().includes(filter.toLowerCase()))
|
||||
: news
|
||||
|
||||
$: filteredInsider = filter
|
||||
? insider.filter(t => t.ticker?.toLowerCase().includes(filter.toLowerCase()) || t.insider_name?.toLowerCase().includes(filter.toLowerCase()))
|
||||
: insider
|
||||
|
||||
function sentimentClass(s) {
|
||||
if (s === 'positive') return 'green'
|
||||
if (s === 'negative') return 'red'
|
||||
return 'neutral'
|
||||
return ''
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const h = Math.floor(diff / 3600000)
|
||||
if (h < 1) return 'il y a < 1h'
|
||||
if (h < 24) return `il y a ${h}h`
|
||||
return `il y a ${Math.floor(h / 24)}j`
|
||||
if (h < 1) return '< 1h'
|
||||
if (h < 24) return `${h}h`
|
||||
return `${Math.floor(h / 24)}j`
|
||||
}
|
||||
|
||||
function fmtValue(v) {
|
||||
if (!v) return '—'
|
||||
if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M'
|
||||
if (v >= 1e3) return '$' + (v / 1e3).toFixed(0) + 'K'
|
||||
return '$' + (+v).toFixed(0)
|
||||
}
|
||||
|
||||
function txLabel(code) {
|
||||
return code === 'P' ? 'Achat marché' : code === 'A' ? 'Attribution' : code
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>News</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
bind:value={filter}
|
||||
placeholder="Filtrer par ticker…"
|
||||
on:keydown={(e) => e.key === 'Enter' && load()}
|
||||
/>
|
||||
<button class="btn-reload" on:click={load}>↻</button>
|
||||
<div class="header">
|
||||
<h1>
|
||||
<button class="tab" class:active={tab === 'news'} on:click={() => tab = 'news'}>
|
||||
Actualités {#if news.length}<span class="badge-count">{news.length}</span>{/if}
|
||||
</button>
|
||||
<button class="tab" class:active={tab === 'insider'} on:click={() => tab = 'insider'}>
|
||||
Insider Trades {#if insider.length}<span class="badge-count badge-orange">{insider.length}</span>{/if}
|
||||
</button>
|
||||
</h1>
|
||||
<div class="toolbar">
|
||||
<input bind:value={filter} placeholder="Filtrer…" />
|
||||
{#if tab === 'news'}
|
||||
<button class="btn-sync" on:click={syncNews} disabled={syncing}>
|
||||
{syncing ? '…' : '↻ Finnhub'}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn-sync" on:click={syncInsider} disabled={syncing}>
|
||||
{syncing ? '…' : '↻ EDGAR'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Chargement…</p>
|
||||
{:else if news.length === 0}
|
||||
<p class="empty">Aucune news disponible. Les données arriveront une fois les intégrations API branchées.</p>
|
||||
|
||||
{:else if tab === 'news'}
|
||||
{#if filteredNews.length === 0}
|
||||
<p class="empty">Aucune news — configure ta clé Finnhub et clique ↻ Finnhub.</p>
|
||||
{:else}
|
||||
<div class="news-list">
|
||||
{#each filteredNews as item}
|
||||
<div class="news-card">
|
||||
<div class="news-meta">
|
||||
<span class="ticker">{item.ticker || '—'}</span>
|
||||
<span class="source">{item.source || ''}</span>
|
||||
<span class="time">{timeAgo(item.published_at)}</span>
|
||||
{#if item.sentiment}
|
||||
<span class="sentiment {sentimentClass(item.sentiment)}">{item.sentiment}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="news-headline">
|
||||
{#if item.url}
|
||||
<a href={item.url} target="_blank" rel="noopener">{item.headline}</a>
|
||||
{:else}
|
||||
{item.headline}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="news-list">
|
||||
{#each news as item}
|
||||
<div class="news-card">
|
||||
<div class="news-meta">
|
||||
<span class="ticker">{item.ticker ?? '—'}</span>
|
||||
<span class="source">{item.source ?? ''}</span>
|
||||
<span class="time">{timeAgo(item.published_at)}</span>
|
||||
{#if item.sentiment}
|
||||
<span class="sentiment {sentimentColor(item.sentiment)}">{item.sentiment}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="news-headline">
|
||||
{#if item.url}
|
||||
<a href={item.url} target="_blank" rel="noopener">{item.headline}</a>
|
||||
{:else}
|
||||
{item.headline}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filteredInsider.length === 0}
|
||||
<p class="empty">Aucun insider trade — ajoute des tickers dans la Watchlist et clique ↻ EDGAR.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Initié</th>
|
||||
<th>Titre</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Actions</th>
|
||||
<th class="num">Prix</th>
|
||||
<th class="num">Valeur totale</th>
|
||||
<th>Date</th>
|
||||
<th>Filing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredInsider as t}
|
||||
<tr class:purchase={t.transaction_code === 'P'}>
|
||||
<td class="ticker">{t.ticker}</td>
|
||||
<td>{t.insider_name}</td>
|
||||
<td class="title-col">{t.insider_title || '—'}</td>
|
||||
<td>
|
||||
<span class="tx-badge" class:green={t.transaction_code === 'P'}>
|
||||
{txLabel(t.transaction_code)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{(+t.shares).toLocaleString()}</td>
|
||||
<td class="num">{t.price ? '$' + (+t.price).toFixed(2) : '—'}</td>
|
||||
<td class="num value" class:big={t.total_value >= 500000}>{fmtValue(t.total_value)}</td>
|
||||
<td class="date">{t.transaction_date}</td>
|
||||
<td>
|
||||
{#if t.filing_url}
|
||||
<a href={t.filing_url} target="_blank" rel="noopener" class="link-sec">SEC ↗</a>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page h1 { color: #e6edf3; font-size: 1.4rem; }
|
||||
.page h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #484f58;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tab:hover { color: #8b949e; background: #161b22; }
|
||||
.tab.active { color: #e6edf3; background: #161b22; }
|
||||
|
||||
.badge-count {
|
||||
display: inline-block;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
margin-left: 0.3rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge-count.badge-orange { background: #2d1f00; color: #d29922; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 360px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
width: 200px;
|
||||
}
|
||||
input:focus { border-color: #58a6ff; }
|
||||
input::placeholder { color: #484f58; }
|
||||
|
||||
.btn-reload {
|
||||
.btn-sync {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #8b949e;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: #58a6ff;
|
||||
border-color: #1f6feb;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-reload:hover { color: #e6edf3; background: #30363d; }
|
||||
.btn-sync:hover:not(:disabled) { background: #1f2937; }
|
||||
.btn-sync:disabled { opacity: 0.5; }
|
||||
|
||||
.news-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
/* News */
|
||||
.news-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
|
||||
.news-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
transition: border-color 0.15s;
|
||||
padding: 0.85rem 1.1rem;
|
||||
}
|
||||
.news-card:hover { border-color: #30363d; }
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ticker { color: #58a6ff; font-weight: 600; }
|
||||
.source { color: #8b949e; }
|
||||
.time { color: #484f58; margin-left: auto; }
|
||||
|
||||
.sentiment { padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 500; }
|
||||
.sentiment.green { background: #0d2c1a; color: #3fb950; }
|
||||
.sentiment.red { background: #2c0d0d; color: #f85149; }
|
||||
.sentiment.neutral { background: #1c1c1c; color: #8b949e; }
|
||||
.sentiment.green { background: #0d2c1a; color: #3fb950; }
|
||||
.sentiment.red { background: #2c0d0d; color: #f85149; }
|
||||
|
||||
.news-headline { font-size: 0.9rem; color: #c9d1d9; line-height: 1.4; }
|
||||
.news-headline { font-size: 0.875rem; color: #c9d1d9; line-height: 1.4; }
|
||||
.news-headline a { color: #c9d1d9; text-decoration: none; }
|
||||
.news-headline a:hover { color: #58a6ff; }
|
||||
|
||||
/* Insider trades */
|
||||
.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.45rem 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.6rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
tr:hover td { background: #161b22; }
|
||||
tr.purchase td { background: #0a1a0a; }
|
||||
tr.purchase:hover td { background: #0d2210; }
|
||||
|
||||
td.ticker { font-weight: 600; color: #58a6ff; }
|
||||
td.title-col { color: #8b949e; font-size: 0.78rem; }
|
||||
td.date { color: #8b949e; }
|
||||
|
||||
.tx-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
}
|
||||
.tx-badge.green { background: #0d2c1a; color: #3fb950; }
|
||||
|
||||
.value.big { color: #d29922; font-weight: 600; }
|
||||
|
||||
.link-sec {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.link-sec:hover { text-decoration: underline; }
|
||||
|
||||
.empty, .muted { color: #484f58; font-size: 0.875rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,70 +1,323 @@
|
||||
<script>
|
||||
// Screener — sera branché sur les données réelles une fois les API intégrées
|
||||
let filters = { minRsi: '', maxRsi: '', minVolume: '', sector: '' }
|
||||
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">
|
||||
<h1>Screener</h1>
|
||||
<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 (< $2B)</option>
|
||||
<option value="mid">Mid ($2B–$10B)</option>
|
||||
<option value="large">Large (> $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={filters.minRsi} placeholder="ex: 30" min="0" max="100" />
|
||||
<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={filters.maxRsi} placeholder="ex: 70" min="0" max="100" />
|
||||
<input id="rsi-max" type="number" bind:value={filterMaxRsi} placeholder="100" min="0" max="100" />
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="vol-min">Volume min</label>
|
||||
<input id="vol-min" type="number" bind:value={filters.minVolume} placeholder="ex: 1000000" />
|
||||
<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>
|
||||
<div class="filter-group">
|
||||
<label for="sector">Secteur</label>
|
||||
<input id="sector" type="text" bind:value={filters.sector} placeholder="Technology…" />
|
||||
</div>
|
||||
<button class="btn-scan" disabled>
|
||||
Scan (bientôt disponible)
|
||||
|
||||
<button class="btn-reset" on:click={() => { filterMinRsi=''; filterMaxRsi=''; filterAlert=''; filterCap=''; filterMinScore=''; filterEtoro=false; load() }}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="placeholder">
|
||||
<div class="icon">⊞</div>
|
||||
<p>Le screener sera disponible après l'intégration Yahoo Finance / Finnhub.</p>
|
||||
<p class="hint">Il filtrera l'univers eToro sur RSI, MACD, volume et catalyseurs news.</p>
|
||||
</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; }
|
||||
.page h1 { color: #e6edf3; font-size: 1.4rem; margin: 0; }
|
||||
|
||||
.filters {
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.25rem;
|
||||
background: #161b22;
|
||||
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: 8px;
|
||||
}
|
||||
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
label { font-size: 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
input {
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
width: 130px;
|
||||
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;
|
||||
}
|
||||
input:focus { border-color: #58a6ff; }
|
||||
input::placeholder { color: #484f58; }
|
||||
|
||||
.btn-scan {
|
||||
background: #1f6feb;
|
||||
@@ -74,16 +327,144 @@
|
||||
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-scan:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-reset:hover { color: #e6edf3; }
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #484f58;
|
||||
.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;
|
||||
}
|
||||
.placeholder .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
||||
.placeholder p { margin: 0.3rem 0; font-size: 0.9rem; }
|
||||
.placeholder .hint { font-size: 0.8rem; color: #30363d; }
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user