added
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(go build *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import Dashboard from './routes/Dashboard.svelte'
|
import Dashboard from './routes/Dashboard.svelte'
|
||||||
import Watchlist from './routes/Watchlist.svelte'
|
import Watchlist from './routes/Watchlist.svelte'
|
||||||
import Screener from './routes/Screener.svelte'
|
import Screener from './routes/Screener.svelte'
|
||||||
|
import Discover from './routes/Discover.svelte'
|
||||||
import News from './routes/News.svelte'
|
import News from './routes/News.svelte'
|
||||||
import Settings from './routes/Settings.svelte'
|
import Settings from './routes/Settings.svelte'
|
||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
'/': Dashboard,
|
'/': Dashboard,
|
||||||
'/watchlist': Watchlist,
|
'/watchlist': Watchlist,
|
||||||
'/screener': Screener,
|
'/screener': Screener,
|
||||||
|
'/discover': Discover,
|
||||||
'/news': News,
|
'/news': News,
|
||||||
'/settings': Settings,
|
'/settings': Settings,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
{ path: '/', label: 'Dashboard', icon: '◈' },
|
{ path: '/', label: 'Dashboard', icon: '◈' },
|
||||||
{ path: '/watchlist', label: 'Watchlist', icon: '☆' },
|
{ path: '/watchlist', label: 'Watchlist', icon: '☆' },
|
||||||
{ path: '/screener', label: 'Screener', 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: '⚙' },
|
{ path: '/settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,4 +21,15 @@ export const api = {
|
|||||||
addTicker: (ticker) => request('POST', '/watchlist', { ticker }),
|
addTicker: (ticker) => request('POST', '/watchlist', { ticker }),
|
||||||
removeTicker: (ticker) => request('DELETE', `/watchlist/${ticker}`),
|
removeTicker: (ticker) => request('DELETE', `/watchlist/${ticker}`),
|
||||||
getNews: (ticker) => request('GET', `/news${ticker ? `?ticker=${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'
|
import { serverStatus } from '../lib/store.js'
|
||||||
|
|
||||||
let health = null
|
let health = null
|
||||||
|
let signals = []
|
||||||
let error = null
|
let error = null
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -14,7 +15,37 @@
|
|||||||
error = e.message
|
error = e.message
|
||||||
serverStatus.set('error')
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
@@ -28,23 +59,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Port</div>
|
<div class="card-label">Tickers suivis</div>
|
||||||
<div class="card-value">{health?.port ?? '—'}</div>
|
<div class="card-value">{signals.length || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card muted">
|
<div class="card" class:highlight={alerts.length > 0}>
|
||||||
<div class="card-label">Signaux actifs</div>
|
<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">
|
||||||
|
<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 class="card muted">
|
|
||||||
<div class="card-label">News aujourd'hui</div>
|
|
||||||
<div class="card-value">—</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if alerts.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Activité récente</h2>
|
<h2>Signaux actifs</h2>
|
||||||
<p class="empty">Aucune donnée pour l'instant — configure les clés API dans <a href="#/settings">Settings</a>.</p>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -63,14 +158,65 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.25rem;
|
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 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; }
|
||||||
.card-value.green { color: #3fb950; }
|
.card-value.green { color: #3fb950; }
|
||||||
.card-value.red { color: #f85149; }
|
.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 { color: #484f58; font-size: 0.875rem; }
|
||||||
.empty a { color: #58a6ff; }
|
.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>
|
||||||
+242
-43
@@ -3,67 +3,130 @@
|
|||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { notify } from '../lib/store.js'
|
import { notify } from '../lib/store.js'
|
||||||
|
|
||||||
|
let tab = 'news' // 'news' | 'insider'
|
||||||
let news = []
|
let news = []
|
||||||
|
let insider = []
|
||||||
let loading = true
|
let loading = true
|
||||||
|
let syncing = false
|
||||||
let filter = ''
|
let filter = ''
|
||||||
|
|
||||||
onMount(load)
|
onMount(() => loadAll())
|
||||||
|
|
||||||
async function load() {
|
async function loadAll() {
|
||||||
loading = true
|
loading = true
|
||||||
try {
|
try {
|
||||||
news = await api.getNews(filter || null)
|
[news, insider] = await Promise.all([
|
||||||
|
api.getNews(null),
|
||||||
|
api.getInsiderTrades(null),
|
||||||
|
])
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
notify('Erreur chargement news : ' + e.message, 'error')
|
notify('Erreur chargement : ' + e.message, 'error')
|
||||||
news = []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentimentColor(s) {
|
async function syncNews() {
|
||||||
if (!s) return ''
|
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 === 'positive') return 'green'
|
||||||
if (s === 'negative') return 'red'
|
if (s === 'negative') return 'red'
|
||||||
return 'neutral'
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr) {
|
function timeAgo(dateStr) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
const diff = Date.now() - new Date(dateStr).getTime()
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
const h = Math.floor(diff / 3600000)
|
const h = Math.floor(diff / 3600000)
|
||||||
if (h < 1) return 'il y a < 1h'
|
if (h < 1) return '< 1h'
|
||||||
if (h < 24) return `il y a ${h}h`
|
if (h < 24) return `${h}h`
|
||||||
return `il y a ${Math.floor(h / 24)}j`
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>News</h1>
|
<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">
|
<div class="toolbar">
|
||||||
<input
|
<input bind:value={filter} placeholder="Filtrer…" />
|
||||||
bind:value={filter}
|
{#if tab === 'news'}
|
||||||
placeholder="Filtrer par ticker…"
|
<button class="btn-sync" on:click={syncNews} disabled={syncing}>
|
||||||
on:keydown={(e) => e.key === 'Enter' && load()}
|
{syncing ? '…' : '↻ Finnhub'}
|
||||||
/>
|
</button>
|
||||||
<button class="btn-reload" on:click={load}>↻</button>
|
{:else}
|
||||||
|
<button class="btn-sync" on:click={syncInsider} disabled={syncing}>
|
||||||
|
{syncing ? '…' : '↻ EDGAR'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="muted">Chargement…</p>
|
<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}
|
{:else}
|
||||||
<div class="news-list">
|
<div class="news-list">
|
||||||
{#each news as item}
|
{#each filteredNews as item}
|
||||||
<div class="news-card">
|
<div class="news-card">
|
||||||
<div class="news-meta">
|
<div class="news-meta">
|
||||||
<span class="ticker">{item.ticker ?? '—'}</span>
|
<span class="ticker">{item.ticker || '—'}</span>
|
||||||
<span class="source">{item.source ?? ''}</span>
|
<span class="source">{item.source || ''}</span>
|
||||||
<span class="time">{timeAgo(item.published_at)}</span>
|
<span class="time">{timeAgo(item.published_at)}</span>
|
||||||
{#if item.sentiment}
|
{#if item.sentiment}
|
||||||
<span class="sentiment {sentimentColor(item.sentiment)}">{item.sentiment}</span>
|
<span class="sentiment {sentimentClass(item.sentiment)}">{item.sentiment}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="news-headline">
|
<div class="news-headline">
|
||||||
@@ -77,60 +140,149 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1.5rem;
|
align-items: center;
|
||||||
max-width: 360px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
|
||||||
background: #161b22;
|
background: #161b22;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
color: #e6edf3;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
width: 200px;
|
||||||
}
|
}
|
||||||
input:focus { border-color: #58a6ff; }
|
input:focus { border-color: #58a6ff; }
|
||||||
input::placeholder { color: #484f58; }
|
input::placeholder { color: #484f58; }
|
||||||
|
|
||||||
.btn-reload {
|
.btn-sync {
|
||||||
background: #21262d;
|
background: #21262d;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #8b949e;
|
color: #58a6ff;
|
||||||
padding: 0.5rem 0.75rem;
|
border-color: #1f6feb;
|
||||||
font-size: 1rem;
|
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 {
|
.news-card {
|
||||||
background: #161b22;
|
background: #161b22;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #21262d;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 0.85rem 1.1rem;
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
}
|
||||||
.news-card:hover { border-color: #30363d; }
|
.news-card:hover { border-color: #30363d; }
|
||||||
|
|
||||||
.news-meta {
|
.news-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.65rem;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.35rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticker { color: #58a6ff; font-weight: 600; }
|
.ticker { color: #58a6ff; font-weight: 600; }
|
||||||
.source { color: #8b949e; }
|
.source { color: #8b949e; }
|
||||||
.time { color: #484f58; margin-left: auto; }
|
.time { color: #484f58; margin-left: auto; }
|
||||||
@@ -138,11 +290,58 @@
|
|||||||
.sentiment { padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 500; }
|
.sentiment { padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 500; }
|
||||||
.sentiment.green { background: #0d2c1a; color: #3fb950; }
|
.sentiment.green { background: #0d2c1a; color: #3fb950; }
|
||||||
.sentiment.red { background: #2c0d0d; color: #f85149; }
|
.sentiment.red { background: #2c0d0d; color: #f85149; }
|
||||||
.sentiment.neutral { background: #1c1c1c; color: #8b949e; }
|
|
||||||
|
|
||||||
.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 { color: #c9d1d9; text-decoration: none; }
|
||||||
.news-headline a:hover { color: #58a6ff; }
|
.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; }
|
.empty, .muted { color: #484f58; font-size: 0.875rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,70 +1,323 @@
|
|||||||
<script>
|
<script>
|
||||||
// Screener — sera branché sur les données réelles une fois les API intégrées
|
import { onMount } from 'svelte'
|
||||||
let filters = { minRsi: '', maxRsi: '', minVolume: '', sector: '' }
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
<h1>Screener</h1>
|
<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">
|
<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">
|
<div class="filter-group">
|
||||||
<label for="rsi-min">RSI min</label>
|
<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>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="rsi-max">RSI max</label>
|
<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>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="vol-min">Volume min</label>
|
<label for="alert-filter">Alerte</label>
|
||||||
<input id="vol-min" type="number" bind:value={filters.minVolume} placeholder="ex: 1000000" />
|
<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>
|
||||||
<div class="filter-group">
|
|
||||||
<label for="sector">Secteur</label>
|
<button class="btn-reset" on:click={() => { filterMinRsi=''; filterMaxRsi=''; filterAlert=''; filterCap=''; filterMinScore=''; filterEtoro=false; load() }}>
|
||||||
<input id="sector" type="text" bind:value={filters.sector} placeholder="Technology…" />
|
Reset
|
||||||
</div>
|
|
||||||
<button class="btn-scan" disabled>
|
|
||||||
Scan (bientôt disponible)
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="placeholder">
|
{#if loading}
|
||||||
|
<p class="muted">Chargement…</p>
|
||||||
|
{:else if signals.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
<div class="icon">⊞</div>
|
<div class="icon">⊞</div>
|
||||||
<p>Le screener sera disponible après l'intégration Yahoo Finance / Finnhub.</p>
|
<p>Aucun signal — ajoute des tickers dans la <a href="#/watchlist">Watchlist</a> puis clique <strong>⊙ Scanner</strong>.</p>
|
||||||
<p class="hint">Il filtrera l'univers eToro sur RSI, MACD, volume et catalyseurs news.</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page h1 { color: #e6edf3; font-size: 1.4rem; }
|
.page h1 { color: #e6edf3; font-size: 1.4rem; margin: 0; }
|
||||||
|
|
||||||
.filters {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 1rem;
|
justify-content: space-between;
|
||||||
align-items: flex-end;
|
margin-bottom: 1.25rem;
|
||||||
margin-bottom: 2rem;
|
}
|
||||||
padding: 1.25rem;
|
|
||||||
background: #161b22;
|
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
|
||||||
|
.etoro-badge {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #484f58;
|
||||||
border: 1px solid #21262d;
|
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;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
padding: 0.35rem 0.75rem;
|
||||||
padding: 0.45rem 0.65rem;
|
display: flex;
|
||||||
font-size: 0.875rem;
|
align-items: center;
|
||||||
outline: none;
|
gap: 0.5rem;
|
||||||
width: 130px;
|
}
|
||||||
|
.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 {
|
.btn-scan {
|
||||||
background: #1f6feb;
|
background: #1f6feb;
|
||||||
@@ -74,16 +327,144 @@
|
|||||||
padding: 0.5rem 1.25rem;
|
padding: 0.5rem 1.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
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;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
.btn-scan:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn-reset:hover { color: #e6edf3; }
|
||||||
|
|
||||||
.placeholder {
|
.count { font-size: 0.78rem; color: #484f58; margin-bottom: 0.75rem; }
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
.table-wrap { overflow-x: auto; }
|
||||||
color: #484f58;
|
|
||||||
|
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; }
|
th.num { text-align: right; }
|
||||||
.placeholder p { margin: 0.3rem 0; font-size: 0.9rem; }
|
|
||||||
.placeholder .hint { font-size: 0.8rem; color: #30363d; }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -64,6 +64,48 @@ func (db *DB) migrate() error {
|
|||||||
published_at DATETIME,
|
published_at DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS prices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
open REAL,
|
||||||
|
high REAL,
|
||||||
|
low REAL,
|
||||||
|
close REAL,
|
||||||
|
volume INTEGER,
|
||||||
|
UNIQUE(ticker, date)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS signals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL UNIQUE,
|
||||||
|
price REAL,
|
||||||
|
change_pct REAL,
|
||||||
|
rsi14 REAL,
|
||||||
|
macd REAL,
|
||||||
|
macd_signal REAL,
|
||||||
|
macd_hist REAL,
|
||||||
|
sma20 REAL,
|
||||||
|
sma50 REAL,
|
||||||
|
volume INTEGER,
|
||||||
|
avg_volume20 INTEGER,
|
||||||
|
alert TEXT DEFAULT '',
|
||||||
|
computed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS insider_trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
insider_name TEXT,
|
||||||
|
insider_title TEXT,
|
||||||
|
transaction_code TEXT,
|
||||||
|
shares REAL,
|
||||||
|
price REAL,
|
||||||
|
total_value REAL,
|
||||||
|
transaction_date DATE,
|
||||||
|
accession_no TEXT UNIQUE,
|
||||||
|
filing_url TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_insider_ticker ON insider_trades(ticker)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
@@ -71,5 +113,27 @@ func (db *DB) migrate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrations additives — on ignore les erreurs si la colonne/index existe déjà
|
||||||
|
additive := []string{
|
||||||
|
`ALTER TABLE news ADD COLUMN finnhub_id INTEGER`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_news_finnhub_id ON news(finnhub_id) WHERE finnhub_id IS NOT NULL`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN market_cap INTEGER DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN short_ratio REAL DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN score INTEGER DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN on_etoro INTEGER DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN week52_high REAL DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN week52_low REAL DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN pct_from_high REAL DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN insider_value_30d REAL DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN source TEXT DEFAULT 'watchlist'`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_instruments_ticker ON instruments(ticker)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_signals_score ON signals(score DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_signals_source ON signals(source)`,
|
||||||
|
}
|
||||||
|
for _, q := range additive {
|
||||||
|
db.Exec(q) // intentionnellement sans vérification d'erreur
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package edgar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "https://data.sec.gov"
|
||||||
|
archiveURL = "https://www.sec.gov/Archives/edgar/data"
|
||||||
|
userAgent = "StockRadar legreg002@hotmail.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
http *http.Client
|
||||||
|
cikMap map[string]string // ticker → CIK (zero-padded 10 digits)
|
||||||
|
cikOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsiderTrade représente une transaction Form 4 parsée.
|
||||||
|
type InsiderTrade struct {
|
||||||
|
Ticker string
|
||||||
|
InsiderName string
|
||||||
|
InsiderTitle string
|
||||||
|
TransactionCode string // P=purchase, S=sale, A=award, etc.
|
||||||
|
Shares float64
|
||||||
|
PricePerShare float64
|
||||||
|
TotalValue float64
|
||||||
|
TransactionDate string
|
||||||
|
AccessionNo string
|
||||||
|
FilingURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- types pour le parsing JSON/XML ----
|
||||||
|
|
||||||
|
type tickerEntry struct {
|
||||||
|
CIK int `json:"cik_str"`
|
||||||
|
Ticker string `json:"ticker"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type submissionsResponse struct {
|
||||||
|
Filings struct {
|
||||||
|
Recent struct {
|
||||||
|
Form []string `json:"form"`
|
||||||
|
AccessionNumber []string `json:"accessionNumber"`
|
||||||
|
FilingDate []string `json:"filingDate"`
|
||||||
|
PrimaryDocument []string `json:"primaryDocument"`
|
||||||
|
} `json:"recent"`
|
||||||
|
} `json:"filings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type form4Doc struct {
|
||||||
|
Issuer struct {
|
||||||
|
Symbol string `xml:"issuerTradingSymbol"`
|
||||||
|
} `xml:"issuer"`
|
||||||
|
ReportingOwner struct {
|
||||||
|
ID struct {
|
||||||
|
Name string `xml:"rptOwnerName"`
|
||||||
|
} `xml:"reportingOwnerId"`
|
||||||
|
Relationship struct {
|
||||||
|
IsDirector int `xml:"isDirector"`
|
||||||
|
IsOfficer int `xml:"isOfficer"`
|
||||||
|
Title string `xml:"officerTitle"`
|
||||||
|
} `xml:"reportingOwnerRelationship"`
|
||||||
|
} `xml:"reportingOwner"`
|
||||||
|
NonDerivativeTable struct {
|
||||||
|
Transactions []nonDerivativeTx `xml:"nonDerivativeTransaction"`
|
||||||
|
} `xml:"nonDerivativeTable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nonDerivativeTx struct {
|
||||||
|
Date struct {
|
||||||
|
Value string `xml:"value"`
|
||||||
|
} `xml:"transactionDate"`
|
||||||
|
Coding struct {
|
||||||
|
Code string `xml:"transactionCode"`
|
||||||
|
} `xml:"transactionCoding"`
|
||||||
|
Amounts struct {
|
||||||
|
Shares struct {
|
||||||
|
Value float64 `xml:"value"`
|
||||||
|
} `xml:"transactionShares"`
|
||||||
|
Price struct {
|
||||||
|
Value float64 `xml:"value"`
|
||||||
|
} `xml:"transactionPricePerShare"`
|
||||||
|
AcqDisp struct {
|
||||||
|
Value string `xml:"value"`
|
||||||
|
} `xml:"transactionAcquiredDisposedCode"`
|
||||||
|
} `xml:"transactionAmounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- constructeur ----
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- API publique ----
|
||||||
|
|
||||||
|
// RecentInsiderBuys retourne les achats d'initiés (code P) pour un ticker
|
||||||
|
// sur les 30 derniers jours.
|
||||||
|
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
|
||||||
|
cik, err := c.lookupCIK(ticker)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CIK not found for %s: %w", ticker, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessions, docs, dates, err := c.recentForm4Filings(cik, 30)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
|
||||||
|
var trades []InsiderTrade
|
||||||
|
|
||||||
|
for i, acc := range accessions {
|
||||||
|
if i >= len(dates) || dates[i] < cutoff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primaryDoc := ""
|
||||||
|
if i < len(docs) {
|
||||||
|
primaryDoc = docs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
form4Trades, err := c.parseForm4(cik, acc, primaryDoc, ticker)
|
||||||
|
if err != nil {
|
||||||
|
continue // on skip les erreurs de parsing individuelles
|
||||||
|
}
|
||||||
|
trades = append(trades, form4Trades...)
|
||||||
|
time.Sleep(120 * time.Millisecond) // EDGAR rate limit
|
||||||
|
}
|
||||||
|
|
||||||
|
return trades, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- méthodes internes ----
|
||||||
|
|
||||||
|
func (c *Client) lookupCIK(ticker string) (string, error) {
|
||||||
|
if err := c.loadCIKMap(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cik, ok := c.cikMap[strings.ToUpper(ticker)]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("ticker %s not found", ticker)
|
||||||
|
}
|
||||||
|
return cik, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loadCIKMap() error {
|
||||||
|
var loadErr error
|
||||||
|
c.cikOnce.Do(func() {
|
||||||
|
resp, err := c.get("https://www.sec.gov/files/company_tickers.json")
|
||||||
|
if err != nil {
|
||||||
|
loadErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var raw map[string]tickerEntry
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||||
|
loadErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cikMap = make(map[string]string, len(raw))
|
||||||
|
for _, entry := range raw {
|
||||||
|
padded := fmt.Sprintf("%010d", entry.CIK)
|
||||||
|
c.cikMap[strings.ToUpper(entry.Ticker)] = padded
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) recentForm4Filings(cik string, maxDays int) (accessions, docs, dates []string, err error) {
|
||||||
|
url := fmt.Sprintf("%s/submissions/CIK%s.json", baseURL, cik)
|
||||||
|
resp, err := c.get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var sub submissionsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -maxDays).Format("2006-01-02")
|
||||||
|
forms := sub.Filings.Recent.Form
|
||||||
|
accs := sub.Filings.Recent.AccessionNumber
|
||||||
|
pdocs := sub.Filings.Recent.PrimaryDocument
|
||||||
|
fdates := sub.Filings.Recent.FilingDate
|
||||||
|
|
||||||
|
for i, form := range forms {
|
||||||
|
if form != "4" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i < len(fdates) && fdates[i] < cutoff {
|
||||||
|
break // filings are sorted newest first, stop when too old
|
||||||
|
}
|
||||||
|
if i < len(accs) {
|
||||||
|
accessions = append(accessions, accs[i])
|
||||||
|
}
|
||||||
|
if i < len(pdocs) {
|
||||||
|
docs = append(docs, pdocs[i])
|
||||||
|
}
|
||||||
|
if i < len(fdates) {
|
||||||
|
dates = append(dates, fdates[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseForm4(cik, accessionNo, primaryDoc, ticker string) ([]InsiderTrade, error) {
|
||||||
|
// Construire l'URL du document XML
|
||||||
|
accNoDashes := strings.ReplaceAll(accessionNo, "-", "")
|
||||||
|
|
||||||
|
xmlFile := primaryDoc
|
||||||
|
if xmlFile == "" || !strings.HasSuffix(xmlFile, ".xml") {
|
||||||
|
// Fallback : essayer le nom conventionnel
|
||||||
|
xmlFile = accessionNo + ".xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/%s/%s/%s", archiveURL, cik, accNoDashes, xmlFile)
|
||||||
|
filingURL := fmt.Sprintf("https://www.sec.gov/Archives/edgar/data/%s/%s/%s", cik, accNoDashes, xmlFile)
|
||||||
|
|
||||||
|
resp, err := c.get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var doc form4Doc
|
||||||
|
if err := xml.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
insiderName := doc.ReportingOwner.ID.Name
|
||||||
|
insiderTitle := doc.ReportingOwner.Relationship.Title
|
||||||
|
if insiderTitle == "" {
|
||||||
|
if doc.ReportingOwner.Relationship.IsDirector == 1 {
|
||||||
|
insiderTitle = "Director"
|
||||||
|
} else if doc.ReportingOwner.Relationship.IsOfficer == 1 {
|
||||||
|
insiderTitle = "Officer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var trades []InsiderTrade
|
||||||
|
for _, tx := range doc.NonDerivativeTable.Transactions {
|
||||||
|
code := tx.Coding.Code
|
||||||
|
// On garde achats (P) et attributions significatives (A avec prix > 0)
|
||||||
|
if code != "P" && !(code == "A" && tx.Amounts.Price.Value > 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shares := tx.Amounts.Shares.Value
|
||||||
|
price := tx.Amounts.Price.Value
|
||||||
|
if shares <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trades = append(trades, InsiderTrade{
|
||||||
|
Ticker: ticker,
|
||||||
|
InsiderName: insiderName,
|
||||||
|
InsiderTitle: insiderTitle,
|
||||||
|
TransactionCode: code,
|
||||||
|
Shares: shares,
|
||||||
|
PricePerShare: price,
|
||||||
|
TotalValue: shares * price,
|
||||||
|
TransactionDate: tx.Date.Value,
|
||||||
|
AccessionNo: accessionNo,
|
||||||
|
FilingURL: filingURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return trades, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(url string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "application/json, application/xml, text/xml")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("EDGAR HTTP %d: %s", resp.StatusCode, url)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package edgar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Poller struct {
|
||||||
|
db *db.DB
|
||||||
|
client *Client
|
||||||
|
ticker *time.Ticker
|
||||||
|
done chan struct{}
|
||||||
|
lastRun time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoller(database *db.DB) *Poller {
|
||||||
|
return &Poller{
|
||||||
|
db: database,
|
||||||
|
client: New(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Start() {
|
||||||
|
p.ticker = time.NewTicker(6 * time.Hour)
|
||||||
|
go func() {
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("edgar poller: initial sync: %v", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.ticker.C:
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("edgar poller: sync: %v", err)
|
||||||
|
}
|
||||||
|
case <-p.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Stop() {
|
||||||
|
if p.ticker != nil {
|
||||||
|
p.ticker.Stop()
|
||||||
|
}
|
||||||
|
close(p.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Sync() error {
|
||||||
|
tickers, err := p.watchlistTickers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(tickers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("edgar: scanning %d tickers for insider trades…", len(tickers))
|
||||||
|
total := 0
|
||||||
|
|
||||||
|
for _, sym := range tickers {
|
||||||
|
trades, err := p.client.RecentInsiderBuys(sym)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("edgar: %s: %v", sym, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, t := range trades {
|
||||||
|
if p.insertTrade(t) {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond) // respecter le rate limit EDGAR
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lastRun = time.Now()
|
||||||
|
if total > 0 {
|
||||||
|
log.Printf("edgar: sync done — %d nouveaux insider trades", total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) watchlistTickers() ([]string, error) {
|
||||||
|
rows, err := p.db.Query(`SELECT ticker FROM watchlist WHERE active=1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tickers []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tickers = append(tickers, t)
|
||||||
|
}
|
||||||
|
return tickers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) insertTrade(t InsiderTrade) bool {
|
||||||
|
res, err := p.db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO insider_trades
|
||||||
|
(ticker, insider_name, insider_title, transaction_code,
|
||||||
|
shares, price, total_value, transaction_date, accession_no, filing_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, t.Ticker, t.InsiderName, t.InsiderTitle, t.TransactionCode,
|
||||||
|
t.Shares, t.PricePerShare, t.TotalValue, t.TransactionDate,
|
||||||
|
t.AccessionNo, t.FilingURL)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package etoro
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const instrumentsURL = "https://api.etoro.com/metadata/instruments"
|
||||||
|
|
||||||
|
// InstrumentTypeID connus sur eToro
|
||||||
|
const (
|
||||||
|
TypeStock = 5
|
||||||
|
TypeETF = 10
|
||||||
|
TypeCrypto = 12
|
||||||
|
TypeIndex = 21
|
||||||
|
TypeCFD = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Instrument struct {
|
||||||
|
InstrumentID int `json:"InstrumentID"`
|
||||||
|
InstrumentDisplayName string `json:"InstrumentDisplayName"`
|
||||||
|
SymbolFull string `json:"SymbolFull"`
|
||||||
|
InstrumentTypeID int `json:"InstrumentTypeID"`
|
||||||
|
IsActive bool `json:"IsActive"`
|
||||||
|
StockIndustryID int `json:"StockIndustryID"`
|
||||||
|
StockExchangeID int `json:"StockExchangeID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{
|
||||||
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchStocks retourne tous les instruments de type Stock actifs sur eToro.
|
||||||
|
func (c *Client) FetchStocks() ([]Instrument, error) {
|
||||||
|
all, err := c.fetchAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stocks []Instrument
|
||||||
|
for _, inst := range all {
|
||||||
|
if inst.IsActive && inst.InstrumentTypeID == TypeStock {
|
||||||
|
stocks = append(stocks, inst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stocks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAll retourne tous les instruments (stocks + ETFs + crypto + indices).
|
||||||
|
func (c *Client) FetchAll() ([]Instrument, error) {
|
||||||
|
return c.fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchAll() ([]Instrument, error) {
|
||||||
|
req, err := http.NewRequest("GET", instrumentsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers qui imitent le client web eToro
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
req.Header.Set("accounttype", "Demo")
|
||||||
|
req.Header.Set("ApplicationIdentifier", "ReToro")
|
||||||
|
req.Header.Set("Version", "1.211.0")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("etoro: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("etoro: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var instruments []Instrument
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&instruments); err != nil {
|
||||||
|
return nil, fmt.Errorf("etoro: parse error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(instruments) == 0 {
|
||||||
|
return nil, fmt.Errorf("etoro: empty response — l'API a peut-être changé")
|
||||||
|
}
|
||||||
|
|
||||||
|
return instruments, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package etoro
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncStatus struct {
|
||||||
|
Syncing bool `json:"syncing"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
LastSync time.Time `json:"last_sync"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Poller struct {
|
||||||
|
db *db.DB
|
||||||
|
client *Client
|
||||||
|
ticker *time.Ticker
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
syncing bool
|
||||||
|
progress int
|
||||||
|
total int
|
||||||
|
lastSync time.Time
|
||||||
|
lastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoller(database *db.DB) *Poller {
|
||||||
|
return &Poller{
|
||||||
|
db: database,
|
||||||
|
client: New(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Start() {
|
||||||
|
p.ticker = time.NewTicker(24 * time.Hour)
|
||||||
|
go func() {
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("etoro poller: initial sync: %v", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.ticker.C:
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("etoro poller: sync: %v", err)
|
||||||
|
}
|
||||||
|
case <-p.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Stop() {
|
||||||
|
if p.ticker != nil {
|
||||||
|
p.ticker.Stop()
|
||||||
|
}
|
||||||
|
close(p.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Status() SyncStatus {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return SyncStatus{
|
||||||
|
Syncing: p.syncing,
|
||||||
|
Progress: p.progress,
|
||||||
|
Total: p.total,
|
||||||
|
Count: p.dbCount(),
|
||||||
|
LastSync: p.lastSync,
|
||||||
|
LastError: p.lastError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Sync() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.syncing {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil // déjà en cours
|
||||||
|
}
|
||||||
|
p.syncing = true
|
||||||
|
p.progress = 0
|
||||||
|
p.total = 0
|
||||||
|
p.lastError = ""
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.syncing = false
|
||||||
|
p.lastSync = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Println("etoro: fetching instruments…")
|
||||||
|
stocks, err := p.client.FetchStocks()
|
||||||
|
if err != nil {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.lastError = err.Error()
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("etoro: fetch error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.total = len(stocks)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("etoro: %d stocks à synchroniser", len(stocks))
|
||||||
|
|
||||||
|
inserted := 0
|
||||||
|
for i, s := range stocks {
|
||||||
|
_, err := p.db.Exec(`
|
||||||
|
INSERT INTO instruments (instrument_id, ticker, name, exchange_id, asset_class_id, synced_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(instrument_id) DO UPDATE SET
|
||||||
|
ticker = excluded.ticker,
|
||||||
|
name = excluded.name,
|
||||||
|
exchange_id = excluded.exchange_id,
|
||||||
|
synced_at = CURRENT_TIMESTAMP
|
||||||
|
`, s.InstrumentID, s.SymbolFull, s.InstrumentDisplayName,
|
||||||
|
s.StockExchangeID, s.InstrumentTypeID)
|
||||||
|
if err == nil {
|
||||||
|
inserted++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i+1)%100 == 0 || i+1 == len(stocks) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.progress = i + 1
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("etoro: %d/%d instruments traités", i+1, len(stocks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("etoro: sync terminée — %d/%d instruments en DB", inserted, len(stocks))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) dbCount() int {
|
||||||
|
var n int
|
||||||
|
p.db.QueryRow(`SELECT COUNT(*) FROM instruments`).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEtoro vérifie si un ticker est dans l'univers eToro.
|
||||||
|
func IsEtoro(database *db.DB, ticker string) bool {
|
||||||
|
var count int
|
||||||
|
database.QueryRow(`SELECT COUNT(*) FROM instruments WHERE ticker = ?`, ticker).Scan(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package finnhub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://finnhub.io/api/v1"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
apiKey string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewsItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Datetime int64 `json:"datetime"`
|
||||||
|
Headline string `json:"headline"`
|
||||||
|
Related string `json:"related"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(apiKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
apiKey: apiKey,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CompanyNews(symbol, from, to string) ([]NewsItem, error) {
|
||||||
|
url := fmt.Sprintf("%s/company-news?symbol=%s&from=%s&to=%s&token=%s",
|
||||||
|
baseURL, symbol, from, to, c.apiKey)
|
||||||
|
return c.fetchNews(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MarketNews() ([]NewsItem, error) {
|
||||||
|
url := fmt.Sprintf("%s/news?category=general&token=%s", baseURL, c.apiKey)
|
||||||
|
return c.fetchNews(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Ping() error {
|
||||||
|
url := fmt.Sprintf("%s/news?category=general&minId=999999999&token=%s", baseURL, c.apiKey)
|
||||||
|
resp, err := c.http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||||
|
return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchNews(url string) ([]NewsItem, error) {
|
||||||
|
resp, err := c.http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("finnhub: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []NewsItem
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package finnhub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Poller struct {
|
||||||
|
db *db.DB
|
||||||
|
getKey func() (string, error)
|
||||||
|
ticker *time.Ticker
|
||||||
|
done chan struct{}
|
||||||
|
lastRun time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoller(database *db.DB, getKey func() (string, error)) *Poller {
|
||||||
|
return &Poller{
|
||||||
|
db: database,
|
||||||
|
getKey: getKey,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Start() {
|
||||||
|
p.ticker = time.NewTicker(15 * time.Minute)
|
||||||
|
go func() {
|
||||||
|
// Run immediately on start
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("finnhub poller: initial sync: %v", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.ticker.C:
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("finnhub poller: sync: %v", err)
|
||||||
|
}
|
||||||
|
case <-p.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Stop() {
|
||||||
|
if p.ticker != nil {
|
||||||
|
p.ticker.Stop()
|
||||||
|
}
|
||||||
|
close(p.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Sync() error {
|
||||||
|
apiKey, err := p.getKey()
|
||||||
|
if err != nil || apiKey == "" {
|
||||||
|
return nil // pas de clé configurée, on skip silencieusement
|
||||||
|
}
|
||||||
|
|
||||||
|
client := New(apiKey)
|
||||||
|
|
||||||
|
tickers, err := p.watchlistTickers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
from := now.AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
to := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
for _, sym := range tickers {
|
||||||
|
items, err := client.CompanyNews(sym, from, to)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("finnhub: news %s: %v", sym, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if p.insertNews(sym, item) {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond) // Finnhub free tier: 60 req/min
|
||||||
|
}
|
||||||
|
|
||||||
|
// News marché général (sans ticker spécifique)
|
||||||
|
market, err := client.MarketNews()
|
||||||
|
if err == nil {
|
||||||
|
for _, item := range market {
|
||||||
|
p.insertNews("", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lastRun = now
|
||||||
|
if total > 0 {
|
||||||
|
log.Printf("finnhub: sync done — %d nouvelles news", total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) LastRun() time.Time { return p.lastRun }
|
||||||
|
|
||||||
|
func (p *Poller) watchlistTickers() ([]string, error) {
|
||||||
|
rows, err := p.db.Query(`SELECT ticker FROM watchlist WHERE active=1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tickers []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tickers = append(tickers, t)
|
||||||
|
}
|
||||||
|
return tickers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) insertNews(ticker string, item NewsItem) bool {
|
||||||
|
published := time.Unix(item.Datetime, 0).UTC().Format(time.RFC3339)
|
||||||
|
res, err := p.db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO news (finnhub_id, ticker, headline, source, url, published_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, item.ID, ticker, item.Headline, item.Source, item.URL, published)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package indicators
|
||||||
|
|
||||||
|
// MACDResult contient MACD line, signal line et histogramme.
|
||||||
|
type MACDResult struct {
|
||||||
|
MACD float64
|
||||||
|
Signal float64
|
||||||
|
Histogram float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MACD calcule le Moving Average Convergence Divergence (12/26/9 standard).
|
||||||
|
// Retourne zéro-value si pas assez de données.
|
||||||
|
func MACD(closes []float64) MACDResult {
|
||||||
|
return MACDCustom(closes, 12, 26, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MACDCustom(closes []float64, fast, slow, signal int) MACDResult {
|
||||||
|
if len(closes) < slow+signal {
|
||||||
|
return MACDResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
emaFast := emaSlice(closes, fast)
|
||||||
|
emaSlow := emaSlice(closes, slow)
|
||||||
|
|
||||||
|
// Aligner les deux séries (emaSlow est plus courte)
|
||||||
|
offset := len(emaFast) - len(emaSlow)
|
||||||
|
macdLine := make([]float64, len(emaSlow))
|
||||||
|
for i := range emaSlow {
|
||||||
|
macdLine[i] = emaFast[offset+i] - emaSlow[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(macdLine) < signal {
|
||||||
|
return MACDResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
signalLine := emaSlice(macdLine, signal)
|
||||||
|
last := macdLine[len(macdLine)-1]
|
||||||
|
sig := signalLine[len(signalLine)-1]
|
||||||
|
|
||||||
|
return MACDResult{
|
||||||
|
MACD: last,
|
||||||
|
Signal: sig,
|
||||||
|
Histogram: last - sig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMA calcule la moyenne mobile simple sur les n dernières valeurs.
|
||||||
|
func SMA(closes []float64, period int) float64 {
|
||||||
|
if len(closes) < period {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
slice := closes[len(closes)-period:]
|
||||||
|
sum := 0.0
|
||||||
|
for _, v := range slice {
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
return sum / float64(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvgVolume calcule le volume moyen sur les n dernières barres.
|
||||||
|
func AvgVolume(volumes []int64, period int) int64 {
|
||||||
|
if len(volumes) < period {
|
||||||
|
period = len(volumes)
|
||||||
|
}
|
||||||
|
if period == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
slice := volumes[len(volumes)-period:]
|
||||||
|
var sum int64
|
||||||
|
for _, v := range slice {
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
return sum / int64(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emaSlice(data []float64, period int) []float64 {
|
||||||
|
if len(data) < period {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
k := 2.0 / float64(period+1)
|
||||||
|
|
||||||
|
// Première valeur = SMA des `period` premières
|
||||||
|
sum := 0.0
|
||||||
|
for i := 0; i < period; i++ {
|
||||||
|
sum += data[i]
|
||||||
|
}
|
||||||
|
ema := make([]float64, 0, len(data)-period+1)
|
||||||
|
ema = append(ema, sum/float64(period))
|
||||||
|
|
||||||
|
for i := period; i < len(data); i++ {
|
||||||
|
ema = append(ema, data[i]*k+ema[len(ema)-1]*(1-k))
|
||||||
|
}
|
||||||
|
return ema
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package indicators
|
||||||
|
|
||||||
|
// RSI calcule le Relative Strength Index (Wilder's smoothing, période 14).
|
||||||
|
// Retourne NaN si pas assez de données.
|
||||||
|
func RSI(closes []float64, period int) float64 {
|
||||||
|
if period <= 0 {
|
||||||
|
period = 14
|
||||||
|
}
|
||||||
|
if len(closes) < period+1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var gains, losses float64
|
||||||
|
for i := 1; i <= period; i++ {
|
||||||
|
delta := closes[i] - closes[i-1]
|
||||||
|
if delta > 0 {
|
||||||
|
gains += delta
|
||||||
|
} else {
|
||||||
|
losses -= delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
avgGain := gains / float64(period)
|
||||||
|
avgLoss := losses / float64(period)
|
||||||
|
|
||||||
|
// Wilder's smoothing pour le reste
|
||||||
|
for i := period + 1; i < len(closes); i++ {
|
||||||
|
delta := closes[i] - closes[i-1]
|
||||||
|
if delta > 0 {
|
||||||
|
avgGain = (avgGain*float64(period-1) + delta) / float64(period)
|
||||||
|
avgLoss = (avgLoss * float64(period-1)) / float64(period)
|
||||||
|
} else {
|
||||||
|
avgGain = (avgGain * float64(period-1)) / float64(period)
|
||||||
|
avgLoss = (avgLoss*float64(period-1) - delta) / float64(period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if avgLoss == 0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
rs := avgGain / avgLoss
|
||||||
|
return 100 - (100 / (1 + rs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/indicators"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/yahoo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoveryStatus expose l'avancement du scan en cours.
|
||||||
|
type DiscoveryStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Found int `json:"found"` // tickers avec score > 0
|
||||||
|
LastRun time.Time `json:"last_run"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveryScanner parcourt tout l'univers eToro pour trouver des opportunités.
|
||||||
|
type DiscoveryScanner struct {
|
||||||
|
db *db.DB
|
||||||
|
yahoo *yahoo.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
progress int
|
||||||
|
total int
|
||||||
|
found int
|
||||||
|
lastRun time.Time
|
||||||
|
lastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscovery(database *db.DB) *DiscoveryScanner {
|
||||||
|
return &DiscoveryScanner{
|
||||||
|
db: database,
|
||||||
|
yahoo: yahoo.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscoveryScanner) Status() DiscoveryStatus {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return DiscoveryStatus{
|
||||||
|
Running: d.running,
|
||||||
|
Progress: d.progress,
|
||||||
|
Total: d.total,
|
||||||
|
Found: d.found,
|
||||||
|
LastRun: d.lastRun,
|
||||||
|
LastError: d.lastError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run lance le scan de découverte en arrière-plan.
|
||||||
|
// Retourne false si un scan est déjà en cours.
|
||||||
|
func (d *DiscoveryScanner) Run() bool {
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.running {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d.running = true
|
||||||
|
d.progress = 0
|
||||||
|
d.found = 0
|
||||||
|
d.lastError = ""
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
go d.scan()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscoveryScanner) scan() {
|
||||||
|
defer func() {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.running = false
|
||||||
|
d.lastRun = time.Now()
|
||||||
|
d.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
tickers, err := d.etoroTickers()
|
||||||
|
if err != nil {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.lastError = err.Error()
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
d.total = len(tickers)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("discovery: démarrage scan %d tickers eToro…", len(tickers))
|
||||||
|
|
||||||
|
found := 0
|
||||||
|
for i, sym := range tickers {
|
||||||
|
score, alert, err := d.scanTicker(sym)
|
||||||
|
if err == nil && score > 0 {
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
_ = alert
|
||||||
|
|
||||||
|
if (i+1)%50 == 0 {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.progress = i + 1
|
||||||
|
d.found = found
|
||||||
|
d.mu.Unlock()
|
||||||
|
log.Printf("discovery: %d/%d (opportunités: %d)", i+1, len(tickers), found)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(120 * time.Millisecond) // ~8 req/s sur Yahoo Finance
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
d.progress = len(tickers)
|
||||||
|
d.found = found
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("discovery: terminé — %d opportunités sur %d tickers", found, len(tickers))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscoveryScanner) scanTicker(sym string) (score int, alert string, err error) {
|
||||||
|
bars, err := d.yahoo.History(sym, 60)
|
||||||
|
if err != nil || len(bars) < 20 {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
closes := make([]float64, len(bars))
|
||||||
|
volumes := make([]int64, len(bars))
|
||||||
|
for i, b := range bars {
|
||||||
|
closes[i] = b.Close
|
||||||
|
volumes[i] = b.Volume
|
||||||
|
}
|
||||||
|
|
||||||
|
last := bars[len(bars)-1]
|
||||||
|
prevClose := bars[len(bars)-2].Close
|
||||||
|
changePct := 0.0
|
||||||
|
if prevClose > 0 {
|
||||||
|
changePct = (last.Close-prevClose)/prevClose*100
|
||||||
|
}
|
||||||
|
|
||||||
|
rsi := indicators.RSI(closes, 14)
|
||||||
|
macdRes := indicators.MACD(closes)
|
||||||
|
sma20 := indicators.SMA(closes, 20)
|
||||||
|
sma50 := indicators.SMA(closes, 50)
|
||||||
|
avgVol := indicators.AvgVolume(volumes, 20)
|
||||||
|
|
||||||
|
// 52 semaines depuis les barres reçues
|
||||||
|
week52High, week52Low := highLow(closes)
|
||||||
|
pctFromHigh := 0.0
|
||||||
|
if week52High > 0 {
|
||||||
|
pctFromHigh = (last.Close - week52High) / week52High * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score simplifié (pas de insider/news pour la découverte — trop lent)
|
||||||
|
score = computeScore(scoreInput{
|
||||||
|
rsi: rsi,
|
||||||
|
macd: macdRes,
|
||||||
|
volume: last.Volume,
|
||||||
|
avgVolume: avgVol,
|
||||||
|
pctFromHigh: pctFromHigh,
|
||||||
|
})
|
||||||
|
|
||||||
|
if score == 0 {
|
||||||
|
return 0, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alert = detectAlert(rsi, macdRes, last.Volume, avgVol, 0, pctFromHigh)
|
||||||
|
|
||||||
|
_, err = d.db.Exec(`
|
||||||
|
INSERT INTO signals
|
||||||
|
(ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist,
|
||||||
|
sma20, sma50, volume, avg_volume20,
|
||||||
|
week52_high, week52_low, pct_from_high,
|
||||||
|
score, on_etoro, alert, source, computed_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ticker) DO UPDATE SET
|
||||||
|
price = excluded.price,
|
||||||
|
change_pct = excluded.change_pct,
|
||||||
|
rsi14 = excluded.rsi14,
|
||||||
|
macd = excluded.macd,
|
||||||
|
macd_signal = excluded.macd_signal,
|
||||||
|
macd_hist = excluded.macd_hist,
|
||||||
|
sma20 = excluded.sma20,
|
||||||
|
sma50 = excluded.sma50,
|
||||||
|
volume = excluded.volume,
|
||||||
|
avg_volume20 = excluded.avg_volume20,
|
||||||
|
week52_high = excluded.week52_high,
|
||||||
|
week52_low = excluded.week52_low,
|
||||||
|
pct_from_high = excluded.pct_from_high,
|
||||||
|
score = excluded.score,
|
||||||
|
on_etoro = 1,
|
||||||
|
alert = excluded.alert,
|
||||||
|
source = 'discovery',
|
||||||
|
computed_at = CURRENT_TIMESTAMP
|
||||||
|
`, sym, last.Close, changePct, rsi,
|
||||||
|
macdRes.MACD, macdRes.Signal, macdRes.Histogram,
|
||||||
|
sma20, sma50, last.Volume, avgVol,
|
||||||
|
week52High, week52Low, pctFromHigh,
|
||||||
|
score, alert, "discovery")
|
||||||
|
|
||||||
|
return score, alert, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscoveryScanner) etoroTickers() ([]string, error) {
|
||||||
|
rows, err := d.db.Query(`SELECT ticker FROM instruments ORDER BY ticker`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tickers []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tickers = append(tickers, t)
|
||||||
|
}
|
||||||
|
return tickers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func highLow(closes []float64) (high, low float64) {
|
||||||
|
if len(closes) == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
high, low = closes[0], closes[0]
|
||||||
|
for _, v := range closes[1:] {
|
||||||
|
if v > high {
|
||||||
|
high = v
|
||||||
|
}
|
||||||
|
if v < low {
|
||||||
|
low = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/indicators"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/yahoo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Signal struct {
|
||||||
|
Ticker string `json:"ticker"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
ChangePct float64 `json:"change_pct"`
|
||||||
|
RSI14 float64 `json:"rsi14"`
|
||||||
|
MACD float64 `json:"macd"`
|
||||||
|
MACDSignal float64 `json:"macd_signal"`
|
||||||
|
MACDHist float64 `json:"macd_hist"`
|
||||||
|
SMA20 float64 `json:"sma20"`
|
||||||
|
SMA50 float64 `json:"sma50"`
|
||||||
|
Volume int64 `json:"volume"`
|
||||||
|
AvgVolume20 int64 `json:"avg_volume20"`
|
||||||
|
MarketCap int64 `json:"market_cap"`
|
||||||
|
ShortRatio float64 `json:"short_ratio"`
|
||||||
|
Week52High float64 `json:"week52_high"`
|
||||||
|
Week52Low float64 `json:"week52_low"`
|
||||||
|
PctFromHigh float64 `json:"pct_from_high"` // négatif = % sous le 52w high
|
||||||
|
InsiderValue30d float64 `json:"insider_value_30d"` // $ total d'achats insider sur 30j
|
||||||
|
Score int `json:"score"`
|
||||||
|
OnEtoro bool `json:"on_etoro"`
|
||||||
|
Alert string `json:"alert"`
|
||||||
|
ComputedAt string `json:"computed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scanner struct {
|
||||||
|
db *db.DB
|
||||||
|
yahoo *yahoo.Client
|
||||||
|
ticker *time.Ticker
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database *db.DB) *Scanner {
|
||||||
|
return &Scanner{
|
||||||
|
db: database,
|
||||||
|
yahoo: yahoo.New(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) Start() {
|
||||||
|
s.ticker = time.NewTicker(30 * time.Minute)
|
||||||
|
go func() {
|
||||||
|
if err := s.Scan(); err != nil {
|
||||||
|
log.Printf("scanner: initial scan: %v", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ticker.C:
|
||||||
|
if err := s.Scan(); err != nil {
|
||||||
|
log.Printf("scanner: scan: %v", err)
|
||||||
|
}
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) Stop() {
|
||||||
|
if s.ticker != nil {
|
||||||
|
s.ticker.Stop()
|
||||||
|
}
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) Scan() error {
|
||||||
|
tickers, err := s.watchlistTickers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(tickers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("scanner: scanning %d tickers…", len(tickers))
|
||||||
|
ok := 0
|
||||||
|
for _, sym := range tickers {
|
||||||
|
if err := s.scanTicker(sym); err != nil {
|
||||||
|
log.Printf("scanner: %s: %v", sym, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok++
|
||||||
|
time.Sleep(400 * time.Millisecond) // rate limit Yahoo
|
||||||
|
}
|
||||||
|
log.Printf("scanner: done — %d/%d ok", ok, len(tickers))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) scanTicker(sym string) error {
|
||||||
|
bars, err := s.yahoo.History(sym, 100)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(bars) < 30 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.storePrices(sym, bars)
|
||||||
|
|
||||||
|
closes := make([]float64, len(bars))
|
||||||
|
volumes := make([]int64, len(bars))
|
||||||
|
for i, b := range bars {
|
||||||
|
closes[i] = b.Close
|
||||||
|
volumes[i] = b.Volume
|
||||||
|
}
|
||||||
|
|
||||||
|
last := bars[len(bars)-1]
|
||||||
|
prevClose := bars[len(bars)-2].Close
|
||||||
|
|
||||||
|
changePct := 0.0
|
||||||
|
if prevClose > 0 {
|
||||||
|
changePct = (last.Close - prevClose) / prevClose * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
rsi := indicators.RSI(closes, 14)
|
||||||
|
macdRes := indicators.MACD(closes)
|
||||||
|
sma20 := indicators.SMA(closes, 20)
|
||||||
|
sma50 := indicators.SMA(closes, 50)
|
||||||
|
avgVol := indicators.AvgVolume(volumes, 20)
|
||||||
|
|
||||||
|
// Market cap (on tolère l'erreur — pas bloquant)
|
||||||
|
var marketCap int64
|
||||||
|
var shortRatio float64
|
||||||
|
if info, err := s.yahoo.GetMarketCap(sym); err == nil {
|
||||||
|
marketCap = info.MarketCap
|
||||||
|
shortRatio = info.ShortRatio
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
// 52 semaines depuis les prix stockés
|
||||||
|
week52High, week52Low := s.week52Range(sym)
|
||||||
|
pctFromHigh := 0.0
|
||||||
|
if week52High > 0 {
|
||||||
|
pctFromHigh = (last.Close - week52High) / week52High * 100 // négatif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insider buys sur 30 jours — par VALEUR
|
||||||
|
insiderValue30d := s.insiderBuyValue30d(sym)
|
||||||
|
insiderDays := s.lastInsiderBuyDays(sym)
|
||||||
|
|
||||||
|
// eToro universe check
|
||||||
|
onEtoro := s.isOnEtoro(sym)
|
||||||
|
|
||||||
|
// Score composite
|
||||||
|
score := computeScore(scoreInput{
|
||||||
|
rsi: rsi,
|
||||||
|
macd: macdRes,
|
||||||
|
volume: last.Volume,
|
||||||
|
avgVolume: avgVol,
|
||||||
|
marketCap: marketCap,
|
||||||
|
shortRatio: shortRatio,
|
||||||
|
insiderDays: insiderDays,
|
||||||
|
insiderValue30d: insiderValue30d,
|
||||||
|
newsDays: s.lastPositiveNewsDays(sym),
|
||||||
|
price: last.Close,
|
||||||
|
sma20: sma20,
|
||||||
|
sma50: sma50,
|
||||||
|
pctFromHigh: pctFromHigh,
|
||||||
|
})
|
||||||
|
|
||||||
|
alert := detectAlert(rsi, macdRes, last.Volume, avgVol, insiderValue30d, pctFromHigh)
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO signals
|
||||||
|
(ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist,
|
||||||
|
sma20, sma50, volume, avg_volume20, market_cap, short_ratio,
|
||||||
|
week52_high, week52_low, pct_from_high, insider_value_30d,
|
||||||
|
score, on_etoro, alert, computed_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ticker) DO UPDATE SET
|
||||||
|
price = excluded.price,
|
||||||
|
change_pct = excluded.change_pct,
|
||||||
|
rsi14 = excluded.rsi14,
|
||||||
|
macd = excluded.macd,
|
||||||
|
macd_signal = excluded.macd_signal,
|
||||||
|
macd_hist = excluded.macd_hist,
|
||||||
|
sma20 = excluded.sma20,
|
||||||
|
sma50 = excluded.sma50,
|
||||||
|
volume = excluded.volume,
|
||||||
|
avg_volume20 = excluded.avg_volume20,
|
||||||
|
market_cap = excluded.market_cap,
|
||||||
|
short_ratio = excluded.short_ratio,
|
||||||
|
week52_high = excluded.week52_high,
|
||||||
|
week52_low = excluded.week52_low,
|
||||||
|
pct_from_high = excluded.pct_from_high,
|
||||||
|
insider_value_30d = excluded.insider_value_30d,
|
||||||
|
score = excluded.score,
|
||||||
|
on_etoro = excluded.on_etoro,
|
||||||
|
alert = excluded.alert,
|
||||||
|
computed_at = CURRENT_TIMESTAMP
|
||||||
|
`, sym, last.Close, changePct, rsi,
|
||||||
|
macdRes.MACD, macdRes.Signal, macdRes.Histogram,
|
||||||
|
sma20, sma50, last.Volume, avgVol,
|
||||||
|
marketCap, shortRatio,
|
||||||
|
week52High, week52Low, pctFromHigh, insiderValue30d,
|
||||||
|
score, boolToInt(onEtoro), alert)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scoring ----
|
||||||
|
|
||||||
|
type scoreInput struct {
|
||||||
|
rsi float64
|
||||||
|
macd indicators.MACDResult
|
||||||
|
volume int64
|
||||||
|
avgVolume int64
|
||||||
|
marketCap int64
|
||||||
|
shortRatio float64
|
||||||
|
insiderDays int // jours depuis dernier insider buy (-1 = aucun)
|
||||||
|
insiderValue30d float64 // $ total d'achats insider sur 30j
|
||||||
|
newsDays int // jours depuis dernière news positive (-1 = aucune)
|
||||||
|
price float64
|
||||||
|
sma20 float64
|
||||||
|
sma50 float64
|
||||||
|
pctFromHigh float64 // % sous le 52w high (négatif)
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeScore(in scoreInput) int {
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
// RSI oversold recovery (0-20 pts)
|
||||||
|
if in.rsi > 0 {
|
||||||
|
if in.rsi >= 25 && in.rsi < 30 {
|
||||||
|
score += 20 // profond oversold
|
||||||
|
} else if in.rsi >= 30 && in.rsi < 40 {
|
||||||
|
score += 15 // sortie d'oversold récente
|
||||||
|
} else if in.rsi >= 40 && in.rsi < 50 {
|
||||||
|
score += 8 // momentum neutre haussier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MACD signal (0-15 pts)
|
||||||
|
if in.macd.Histogram > 0 {
|
||||||
|
if in.macd.MACD < 0 {
|
||||||
|
score += 15 // cross haussier early signal (le meilleur)
|
||||||
|
} else {
|
||||||
|
score += 8 // momentum haussier confirmé
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume spike (0-15 pts)
|
||||||
|
if in.avgVolume > 0 {
|
||||||
|
ratio := float64(in.volume) / float64(in.avgVolume)
|
||||||
|
if ratio >= 3.0 {
|
||||||
|
score += 15
|
||||||
|
} else if ratio >= 2.0 {
|
||||||
|
score += 10
|
||||||
|
} else if ratio >= 1.5 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insider buy — pondéré par VALEUR (0-30 pts) ← le signal le plus fort
|
||||||
|
if in.insiderValue30d > 0 {
|
||||||
|
switch {
|
||||||
|
case in.insiderValue30d >= 100_000_000: // ≥ $100M → signal exceptionnel (TTD CEO)
|
||||||
|
score += 30
|
||||||
|
case in.insiderValue30d >= 10_000_000: // ≥ $10M
|
||||||
|
score += 22
|
||||||
|
case in.insiderValue30d >= 1_000_000: // ≥ $1M
|
||||||
|
score += 15
|
||||||
|
case in.insiderValue30d >= 100_000: // ≥ $100K
|
||||||
|
score += 8
|
||||||
|
default:
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
// Bonus recency : si achat < 7 jours
|
||||||
|
if in.insiderDays >= 0 && in.insiderDays <= 7 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// News positive récente (0-10 pts)
|
||||||
|
if in.newsDays >= 0 {
|
||||||
|
if in.newsDays <= 3 {
|
||||||
|
score += 10
|
||||||
|
} else if in.newsDays <= 7 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position sur 52 semaines (0-10 pts) — titre très déprimé = potentiel rebond
|
||||||
|
if in.pctFromHigh < -40 {
|
||||||
|
score += 10 // comme TTD à -54%
|
||||||
|
} else if in.pctFromHigh < -25 {
|
||||||
|
score += 6
|
||||||
|
} else if in.pctFromHigh < -15 {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small cap bonus (+5) — bouge plus fort
|
||||||
|
if in.marketCap > 0 && in.marketCap < 2_000_000_000 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if score > 100 {
|
||||||
|
score = 100
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
func detectAlert(rsi float64, m indicators.MACDResult, vol, avgVol int64, insiderValue30d, pctFromHigh float64) string {
|
||||||
|
// Priorité 1 : mega insider buy (signal le plus fort)
|
||||||
|
if insiderValue30d >= 1_000_000 {
|
||||||
|
return "mega_insider_buy"
|
||||||
|
}
|
||||||
|
// Priorité 2 : RSI oversold
|
||||||
|
if rsi > 0 && rsi < 30 {
|
||||||
|
return "oversold"
|
||||||
|
}
|
||||||
|
// Priorité 3 : MACD cross haussier
|
||||||
|
if m.Histogram > 0 && m.MACD < 0 {
|
||||||
|
return "macd_cross_up"
|
||||||
|
}
|
||||||
|
// Priorité 4 : volume spike
|
||||||
|
if avgVol > 0 && float64(vol)/float64(avgVol) >= 3.0 {
|
||||||
|
return "volume_spike"
|
||||||
|
}
|
||||||
|
// Priorité 5 : rebond depuis creux 52 semaines + RSI en remontée
|
||||||
|
if pctFromHigh < -40 && rsi > 30 && rsi < 50 {
|
||||||
|
return "deep_value_reversal"
|
||||||
|
}
|
||||||
|
if rsi > 70 {
|
||||||
|
return "overbought"
|
||||||
|
}
|
||||||
|
if m.Histogram < 0 && m.MACD > 0 {
|
||||||
|
return "macd_cross_down"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) week52Range(ticker string) (high, low float64) {
|
||||||
|
cutoff := time.Now().AddDate(-1, 0, 0).Format("2006-01-02")
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT MAX(high), MIN(low) FROM prices
|
||||||
|
WHERE ticker = ? AND date >= ?
|
||||||
|
`, ticker, cutoff)
|
||||||
|
row.Scan(&high, &low)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) insiderBuyValue30d(ticker string) float64 {
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
|
||||||
|
var total float64
|
||||||
|
s.db.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(total_value), 0) FROM insider_trades
|
||||||
|
WHERE ticker = ? AND transaction_code = 'P' AND transaction_date >= ?
|
||||||
|
`, ticker, cutoff).Scan(&total)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) isOnEtoro(ticker string) bool {
|
||||||
|
var count int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM instruments WHERE ticker = ?`, ticker).Scan(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) lastInsiderBuyDays(ticker string) int {
|
||||||
|
var dateStr string
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT transaction_date FROM insider_trades
|
||||||
|
WHERE ticker = ? AND transaction_code = 'P'
|
||||||
|
ORDER BY transaction_date DESC LIMIT 1
|
||||||
|
`, ticker).Scan(&dateStr)
|
||||||
|
if err != nil || dateStr == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return int(time.Since(t).Hours() / 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) lastPositiveNewsDays(ticker string) int {
|
||||||
|
var dateStr string
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT published_at FROM news
|
||||||
|
WHERE ticker = ? AND sentiment = 'positive'
|
||||||
|
ORDER BY published_at DESC LIMIT 1
|
||||||
|
`, ticker).Scan(&dateStr)
|
||||||
|
if err != nil || dateStr == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return int(time.Since(t).Hours() / 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) storePrices(ticker string, bars []yahoo.Bar) {
|
||||||
|
for _, b := range bars {
|
||||||
|
s.db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO prices (ticker, date, open, high, low, close, volume)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, ticker, b.Date.Format("2006-01-02"), b.Open, b.High, b.Low, b.Close, b.Volume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) watchlistTickers() ([]string, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT ticker FROM watchlist WHERE active=1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tickers []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tickers = append(tickers, t)
|
||||||
|
}
|
||||||
|
return tickers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/scanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- eToro ----
|
||||||
|
|
||||||
|
func (s *Server) handleSyncEtoro(w http.ResponseWriter, r *http.Request) {
|
||||||
|
go func() { s.etoroPoller.Sync() }()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"syncing"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEtoroStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := s.etoroPoller.Status()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Discovery ----
|
||||||
|
|
||||||
|
func (s *Server) handleRunDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
started := s.discovery.Run()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if started {
|
||||||
|
w.Write([]byte(`{"status":"started"}`))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(`{"status":"already_running"}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDiscoveryStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := s.discovery.Status()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
minScore := r.URL.Query().Get("min_score")
|
||||||
|
if minScore == "" {
|
||||||
|
minScore = "30"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT sig.ticker, COALESCE(inst.name, sig.ticker),
|
||||||
|
sig.price, sig.change_pct, sig.rsi14,
|
||||||
|
sig.macd_hist, sig.volume, sig.avg_volume20,
|
||||||
|
COALESCE(sig.week52_high, 0), COALESCE(sig.pct_from_high, 0),
|
||||||
|
COALESCE(sig.market_cap, 0),
|
||||||
|
COALESCE(sig.score, 0), COALESCE(sig.alert,''), sig.computed_at
|
||||||
|
FROM signals sig
|
||||||
|
LEFT JOIN instruments inst ON inst.ticker = sig.ticker
|
||||||
|
WHERE sig.source = 'discovery'
|
||||||
|
AND sig.on_etoro = 1
|
||||||
|
AND sig.score >= ?
|
||||||
|
ORDER BY sig.score DESC
|
||||||
|
LIMIT 200
|
||||||
|
`, minScore)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type discoveryRow struct {
|
||||||
|
Ticker string `json:"ticker"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
ChangePct float64 `json:"change_pct"`
|
||||||
|
RSI14 float64 `json:"rsi14"`
|
||||||
|
MACDHist float64 `json:"macd_hist"`
|
||||||
|
Volume int64 `json:"volume"`
|
||||||
|
AvgVolume20 int64 `json:"avg_volume20"`
|
||||||
|
Week52High float64 `json:"week52_high"`
|
||||||
|
PctFromHigh float64 `json:"pct_from_high"`
|
||||||
|
MarketCap int64 `json:"market_cap"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Alert string `json:"alert"`
|
||||||
|
ComputedAt string `json:"computed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []discoveryRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var row discoveryRow
|
||||||
|
var vol sql.NullInt64
|
||||||
|
var avg sql.NullInt64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.Ticker, &row.Name,
|
||||||
|
&row.Price, &row.ChangePct, &row.RSI14,
|
||||||
|
&row.MACDHist, &vol, &avg,
|
||||||
|
&row.Week52High, &row.PctFromHigh,
|
||||||
|
&row.MarketCap,
|
||||||
|
&row.Score, &row.Alert, &row.ComputedAt,
|
||||||
|
); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if vol.Valid {
|
||||||
|
row.Volume = vol.Int64
|
||||||
|
}
|
||||||
|
if avg.Valid {
|
||||||
|
row.AvgVolume20 = avg.Int64
|
||||||
|
}
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEtoroStats garde la compatibilité avec l'ancien endpoint
|
||||||
|
func (s *Server) handleEtoroStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.handleEtoroStatus(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan watchlist signal - déjà dans handlers_scanner.go, on ajoute juste
|
||||||
|
// un champ source à la query existante
|
||||||
|
|
||||||
|
func signalFromRow(rows interface {
|
||||||
|
Scan(...any) error
|
||||||
|
}) (scanner.Signal, int, error) {
|
||||||
|
var sig scanner.Signal
|
||||||
|
var onEtoro int
|
||||||
|
err := rows.Scan(
|
||||||
|
&sig.Ticker, &sig.Name,
|
||||||
|
&sig.Price, &sig.ChangePct,
|
||||||
|
&sig.RSI14, &sig.MACD, &sig.MACDSignal, &sig.MACDHist,
|
||||||
|
&sig.SMA20, &sig.SMA50, &sig.Volume, &sig.AvgVolume20,
|
||||||
|
&sig.MarketCap, &sig.ShortRatio,
|
||||||
|
&sig.Week52High, &sig.Week52Low,
|
||||||
|
&sig.PctFromHigh, &sig.InsiderValue30d,
|
||||||
|
&sig.Score, &onEtoro,
|
||||||
|
&sig.Alert, &sig.ComputedAt,
|
||||||
|
)
|
||||||
|
return sig, onEtoro, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type insiderTradeRow struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Ticker string `json:"ticker"`
|
||||||
|
InsiderName string `json:"insider_name"`
|
||||||
|
InsiderTitle string `json:"insider_title"`
|
||||||
|
TransactionCode string `json:"transaction_code"`
|
||||||
|
Shares float64 `json:"shares"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
TotalValue float64 `json:"total_value"`
|
||||||
|
TransactionDate string `json:"transaction_date"`
|
||||||
|
FilingURL string `json:"filing_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetInsiderTrades(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticker := r.URL.Query().Get("ticker")
|
||||||
|
|
||||||
|
base := `
|
||||||
|
SELECT id, ticker, COALESCE(insider_name,''), COALESCE(insider_title,''),
|
||||||
|
COALESCE(transaction_code,''), COALESCE(shares,0), COALESCE(price,0),
|
||||||
|
COALESCE(total_value,0), COALESCE(transaction_date,''), COALESCE(filing_url,'')
|
||||||
|
FROM insider_trades`
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if ticker != "" {
|
||||||
|
rows, err = s.db.Query(base+` WHERE ticker = ? ORDER BY transaction_date DESC LIMIT 100`, ticker)
|
||||||
|
} else {
|
||||||
|
rows, err = s.db.Query(base + ` ORDER BY transaction_date DESC LIMIT 200`)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
trades := []insiderTradeRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t insiderTradeRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.Ticker, &t.InsiderName, &t.InsiderTitle,
|
||||||
|
&t.TransactionCode, &t.Shares, &t.Price, &t.TotalValue,
|
||||||
|
&t.TransactionDate, &t.FilingURL,
|
||||||
|
); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trades = append(trades, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(trades)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSyncInsider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
go func() { s.edgarPoller.Sync() }()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"syncing"}`))
|
||||||
|
}
|
||||||
@@ -6,6 +6,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleNewsSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
go func() {
|
||||||
|
if err := s.poller.Sync(); err != nil {
|
||||||
|
// logged inside Sync()
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"syncing"}`))
|
||||||
|
}
|
||||||
|
|
||||||
type newsItem struct {
|
type newsItem struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Ticker string `json:"ticker"`
|
Ticker string `json:"ticker"`
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/scanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) {
|
||||||
|
onlyEtoro := r.URL.Query().Get("etoro") == "1"
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT sig.ticker, COALESCE(inst.name, sig.ticker),
|
||||||
|
sig.price, sig.change_pct, sig.rsi14,
|
||||||
|
sig.macd, sig.macd_signal, sig.macd_hist,
|
||||||
|
sig.sma20, sig.sma50, sig.volume, sig.avg_volume20,
|
||||||
|
COALESCE(sig.market_cap, 0), COALESCE(sig.short_ratio, 0),
|
||||||
|
COALESCE(sig.week52_high, 0), COALESCE(sig.week52_low, 0),
|
||||||
|
COALESCE(sig.pct_from_high, 0), COALESCE(sig.insider_value_30d, 0),
|
||||||
|
COALESCE(sig.score, 0), COALESCE(sig.on_etoro, 0),
|
||||||
|
COALESCE(sig.alert,''), sig.computed_at
|
||||||
|
FROM signals sig
|
||||||
|
LEFT JOIN instruments inst ON inst.ticker = sig.ticker`
|
||||||
|
|
||||||
|
if onlyEtoro {
|
||||||
|
query += ` WHERE sig.on_etoro = 1`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY sig.score DESC, CASE WHEN sig.alert != '' THEN 0 ELSE 1 END`
|
||||||
|
|
||||||
|
rows, err := s.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
signals := []scanner.Signal{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sig scanner.Signal
|
||||||
|
var onEtoro int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&sig.Ticker, &sig.Name,
|
||||||
|
&sig.Price, &sig.ChangePct,
|
||||||
|
&sig.RSI14, &sig.MACD, &sig.MACDSignal, &sig.MACDHist,
|
||||||
|
&sig.SMA20, &sig.SMA50, &sig.Volume, &sig.AvgVolume20,
|
||||||
|
&sig.MarketCap, &sig.ShortRatio,
|
||||||
|
&sig.Week52High, &sig.Week52Low,
|
||||||
|
&sig.PctFromHigh, &sig.InsiderValue30d,
|
||||||
|
&sig.Score, &onEtoro,
|
||||||
|
&sig.Alert, &sig.ComputedAt,
|
||||||
|
); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig.OnEtoro = onEtoro == 1
|
||||||
|
signals = append(signals, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(signals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTriggerScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
go func() { s.scanner.Scan() }()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"scanning"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetPrices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticker := r.URL.Query().Get("ticker")
|
||||||
|
if ticker == "" {
|
||||||
|
http.Error(w, "ticker required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT date, open, high, low, close, volume
|
||||||
|
FROM prices WHERE ticker = ?
|
||||||
|
ORDER BY date ASC LIMIT 90
|
||||||
|
`, ticker)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type bar struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Open float64 `json:"open"`
|
||||||
|
High float64 `json:"high"`
|
||||||
|
Low float64 `json:"low"`
|
||||||
|
Close float64 `json:"close"`
|
||||||
|
Volume int64 `json:"volume"`
|
||||||
|
}
|
||||||
|
|
||||||
|
bars := []bar{}
|
||||||
|
for rows.Next() {
|
||||||
|
var b bar
|
||||||
|
var vol sql.NullInt64
|
||||||
|
if err := rows.Scan(&b.Date, &b.Open, &b.High, &b.Low, &b.Close, &vol); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if vol.Valid {
|
||||||
|
b.Volume = vol.Int64
|
||||||
|
}
|
||||||
|
bars = append(bars, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(bars)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.rouggy.com/rouggy/stockradar/internal/db"
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/edgar"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/etoro"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/finnhub"
|
||||||
|
"git.rouggy.com/rouggy/stockradar/internal/scanner"
|
||||||
"git.rouggy.com/rouggy/stockradar/internal/settings"
|
"git.rouggy.com/rouggy/stockradar/internal/settings"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
@@ -15,6 +19,11 @@ type Server struct {
|
|||||||
port string
|
port string
|
||||||
router *mux.Router
|
router *mux.Router
|
||||||
settings *settings.Settings
|
settings *settings.Settings
|
||||||
|
poller *finnhub.Poller
|
||||||
|
scanner *scanner.Scanner
|
||||||
|
discovery *scanner.DiscoveryScanner
|
||||||
|
edgarPoller *edgar.Poller
|
||||||
|
etoroPoller *etoro.Poller
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(database *db.DB, port string) (*Server, error) {
|
func New(database *db.DB, port string) (*Server, error) {
|
||||||
@@ -29,6 +38,23 @@ func New(database *db.DB, port string) (*Server, error) {
|
|||||||
router: mux.NewRouter(),
|
router: mux.NewRouter(),
|
||||||
settings: svc,
|
settings: svc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.poller = finnhub.NewPoller(database, func() (string, error) {
|
||||||
|
return svc.Get("finnhub_api_key")
|
||||||
|
})
|
||||||
|
s.poller.Start()
|
||||||
|
|
||||||
|
s.scanner = scanner.New(database)
|
||||||
|
s.scanner.Start()
|
||||||
|
|
||||||
|
s.discovery = scanner.NewDiscovery(database)
|
||||||
|
|
||||||
|
s.edgarPoller = edgar.NewPoller(database)
|
||||||
|
s.edgarPoller.Start()
|
||||||
|
|
||||||
|
s.etoroPoller = etoro.NewPoller(database)
|
||||||
|
s.etoroPoller.Start()
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -51,6 +77,25 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// News
|
// News
|
||||||
api.HandleFunc("/news", s.handleGetNews).Methods("GET", "OPTIONS")
|
api.HandleFunc("/news", s.handleGetNews).Methods("GET", "OPTIONS")
|
||||||
|
api.HandleFunc("/news/sync", s.handleNewsSync).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// Scanner / Signals
|
||||||
|
api.HandleFunc("/signals", s.handleGetSignals).Methods("GET", "OPTIONS")
|
||||||
|
api.HandleFunc("/signals/scan", s.handleTriggerScan).Methods("POST", "OPTIONS")
|
||||||
|
api.HandleFunc("/prices", s.handleGetPrices).Methods("GET", "OPTIONS")
|
||||||
|
|
||||||
|
// Insider trades (SEC EDGAR)
|
||||||
|
api.HandleFunc("/insider-trades", s.handleGetInsiderTrades).Methods("GET", "OPTIONS")
|
||||||
|
api.HandleFunc("/insider-trades/sync", s.handleSyncInsider).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// eToro universe
|
||||||
|
api.HandleFunc("/etoro/sync", s.handleSyncEtoro).Methods("POST", "OPTIONS")
|
||||||
|
api.HandleFunc("/etoro/status", s.handleEtoroStatus).Methods("GET", "OPTIONS")
|
||||||
|
|
||||||
|
// Discovery
|
||||||
|
api.HandleFunc("/discover", s.handleGetDiscovery).Methods("GET", "OPTIONS")
|
||||||
|
api.HandleFunc("/discover/run", s.handleRunDiscovery).Methods("POST", "OPTIONS")
|
||||||
|
api.HandleFunc("/discover/status", s.handleDiscoveryStatus).Methods("GET", "OPTIONS")
|
||||||
|
|
||||||
s.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
s.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintf(w, "StockRadar API running")
|
fmt.Fprintf(w, "StockRadar API running")
|
||||||
@@ -137,8 +182,19 @@ func (s *Server) handleTestKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour l'instant on vérifie juste que la clé existe
|
if provider == "finnhub" {
|
||||||
// On branchera le vrai ping API plus tard
|
apiKey, err := s.settings.Get(keyName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := finnhub.New(apiKey).Ping(); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"status":"error","message":%q}`, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
fmt.Fprintf(w, `{"status":"ok","provider":"%s"}`, provider)
|
fmt.Fprintf(w, `{"status":"ok","provider":"%s"}`, provider)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package yahoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://query1.finance.yahoo.com/v8/finance/chart"
|
||||||
|
const summaryURL = "https://query1.finance.yahoo.com/v10/finance/quoteSummary"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bar struct {
|
||||||
|
Date time.Time
|
||||||
|
Open float64
|
||||||
|
High float64
|
||||||
|
Low float64
|
||||||
|
Close float64
|
||||||
|
Volume int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quote struct {
|
||||||
|
Symbol string
|
||||||
|
Price float64
|
||||||
|
PrevClose float64
|
||||||
|
ChangePct float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type chartResponse struct {
|
||||||
|
Chart struct {
|
||||||
|
Result []struct {
|
||||||
|
Meta struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||||
|
PreviousClose float64 `json:"previousClose"`
|
||||||
|
} `json:"meta"`
|
||||||
|
Timestamps []int64 `json:"timestamp"`
|
||||||
|
Indicators struct {
|
||||||
|
Quote []struct {
|
||||||
|
Open []float64 `json:"open"`
|
||||||
|
High []float64 `json:"high"`
|
||||||
|
Low []float64 `json:"low"`
|
||||||
|
Close []float64 `json:"close"`
|
||||||
|
Volume []int64 `json:"volume"`
|
||||||
|
} `json:"quote"`
|
||||||
|
} `json:"indicators"`
|
||||||
|
} `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"error"`
|
||||||
|
} `json:"chart"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) History(symbol string, days int) ([]Bar, error) {
|
||||||
|
rangeStr := "3mo"
|
||||||
|
if days > 90 {
|
||||||
|
rangeStr = "6mo"
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s/%s?interval=1d&range=%s", baseURL, symbol, rangeStr)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("yahoo: HTTP %d for %s", resp.StatusCode, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data chartResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Chart.Error != nil {
|
||||||
|
return nil, fmt.Errorf("yahoo: %s — %s", data.Chart.Error.Code, data.Chart.Error.Description)
|
||||||
|
}
|
||||||
|
if len(data.Chart.Result) == 0 {
|
||||||
|
return nil, fmt.Errorf("yahoo: no data for %s", symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := data.Chart.Result[0]
|
||||||
|
quotes := result.Indicators.Quote
|
||||||
|
if len(quotes) == 0 {
|
||||||
|
return nil, fmt.Errorf("yahoo: empty quotes for %s", symbol)
|
||||||
|
}
|
||||||
|
q := quotes[0]
|
||||||
|
|
||||||
|
bars := make([]Bar, 0, len(result.Timestamps))
|
||||||
|
for i, ts := range result.Timestamps {
|
||||||
|
if i >= len(q.Close) || q.Close[i] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bar := Bar{
|
||||||
|
Date: time.Unix(ts, 0).UTC(),
|
||||||
|
Close: safeFloat(q.Close, i),
|
||||||
|
Open: safeFloat(q.Open, i),
|
||||||
|
High: safeFloat(q.High, i),
|
||||||
|
Low: safeFloat(q.Low, i),
|
||||||
|
Volume: safeInt(q.Volume, i),
|
||||||
|
}
|
||||||
|
bars = append(bars, bar)
|
||||||
|
}
|
||||||
|
return bars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetQuote(symbol string) (*Quote, error) {
|
||||||
|
url := fmt.Sprintf("%s/%s?interval=1d&range=5d", baseURL, symbol)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data chartResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Chart.Result) == 0 {
|
||||||
|
return nil, fmt.Errorf("yahoo: no result for %s", symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := data.Chart.Result[0].Meta
|
||||||
|
changePct := 0.0
|
||||||
|
if meta.PreviousClose > 0 {
|
||||||
|
changePct = (meta.RegularMarketPrice - meta.PreviousClose) / meta.PreviousClose * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Quote{
|
||||||
|
Symbol: meta.Symbol,
|
||||||
|
Price: meta.RegularMarketPrice,
|
||||||
|
PrevClose: meta.PreviousClose,
|
||||||
|
ChangePct: changePct,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarketCapInfo contient les données fondamentales clés.
|
||||||
|
type MarketCapInfo struct {
|
||||||
|
MarketCap int64 // en USD
|
||||||
|
FloatShares int64
|
||||||
|
ShortRatio float64
|
||||||
|
ForwardPE float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteSummaryResponse struct {
|
||||||
|
QuoteSummary struct {
|
||||||
|
Result []struct {
|
||||||
|
SummaryDetail struct {
|
||||||
|
MarketCap struct {
|
||||||
|
Raw int64 `json:"raw"`
|
||||||
|
} `json:"marketCap"`
|
||||||
|
ForwardPE struct {
|
||||||
|
Raw float64 `json:"raw"`
|
||||||
|
} `json:"forwardPE"`
|
||||||
|
} `json:"summaryDetail"`
|
||||||
|
DefaultKeyStatistics struct {
|
||||||
|
FloatShares struct {
|
||||||
|
Raw int64 `json:"raw"`
|
||||||
|
} `json:"floatShares"`
|
||||||
|
ShortRatio struct {
|
||||||
|
Raw float64 `json:"raw"`
|
||||||
|
} `json:"shortRatio"`
|
||||||
|
} `json:"defaultKeyStatistics"`
|
||||||
|
} `json:"result"`
|
||||||
|
} `json:"quoteSummary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMarketCap retourne les données fondamentales d'un ticker.
|
||||||
|
func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) {
|
||||||
|
url := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics", summaryURL, symbol)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("yahoo quoteSummary: HTTP %d for %s", resp.StatusCode, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data quoteSummaryResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := data.QuoteSummary.Result
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("yahoo: no summary for %s", symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := results[0]
|
||||||
|
return &MarketCapInfo{
|
||||||
|
MarketCap: r.SummaryDetail.MarketCap.Raw,
|
||||||
|
FloatShares: r.DefaultKeyStatistics.FloatShares.Raw,
|
||||||
|
ShortRatio: r.DefaultKeyStatistics.ShortRatio.Raw,
|
||||||
|
ForwardPE: r.SummaryDetail.ForwardPE.Raw,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeFloat(s []float64, i int) float64 {
|
||||||
|
if i < len(s) {
|
||||||
|
return s[i]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeInt(s []int64, i int) int64 {
|
||||||
|
if i < len(s) {
|
||||||
|
return s[i]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user