up
This commit is contained in:
@@ -2,7 +2,10 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run *)",
|
"Bash(npm run *)",
|
||||||
"Bash(go build *)"
|
"Bash(go build *)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" \"https://api.etoro.com/api/logininfo/v1.1/metadata\")",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" \"https://www.etoro.com/trading/market-hours\")",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" \"https://api.etorostatic.com/sapi/candles/desc.json\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
:global(*, *::before, *::after) { box-sizing: border-box; }
|
:global(*, *::before, *::after) { box-sizing: border-box; }
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #0d1117;
|
background: #f6f8fa;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
nav {
|
nav {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d1117;
|
background: #24292f;
|
||||||
border-right: 1px solid #21262d;
|
border-right: 1px solid #32383f;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0 1.25rem 1.5rem;
|
padding: 0 1.25rem 1.5rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #32383f;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,14 +76,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
color: #8b949e;
|
color: #adbac7;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
li a:hover { background: #161b22; color: #e6edf3; }
|
li a:hover { background: #2d333b; color: #e6edf3; }
|
||||||
li.active a { background: #161b22; color: #58a6ff; }
|
li.active a { background: #2d333b; color: #58a6ff; }
|
||||||
|
|
||||||
.icon { font-size: 1rem; width: 1.2rem; text-align: center; }
|
.icon { font-size: 1rem; width: 1.2rem; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,15 +23,15 @@
|
|||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
border-left: 3px solid #58a6ff;
|
border-left: 3px solid #0969da;
|
||||||
animation: slide-in 0.2s ease;
|
animation: slide-in 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif.success { border-color: #3fb950; }
|
.notif.success { border-color: #1a7f37; }
|
||||||
.notif.error { border-color: #f85149; }
|
.notif.error { border-color: #cf222e; }
|
||||||
.notif.warning { border-color: #d29922; }
|
.notif.warning { border-color: #9a6700; }
|
||||||
|
|
||||||
@keyframes slide-in {
|
@keyframes slide-in {
|
||||||
from { opacity: 0; transform: translateX(1rem); }
|
from { opacity: 0; transform: translateX(1rem); }
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const api = {
|
|||||||
getDiscovery: (minScore) => request('GET', `/discover?min_score=${minScore ?? 30}`),
|
getDiscovery: (minScore) => request('GET', `/discover?min_score=${minScore ?? 30}`),
|
||||||
runDiscovery: () => request('POST', '/discover/run'),
|
runDiscovery: () => request('POST', '/discover/run'),
|
||||||
discoveryStatus: () => request('GET', '/discover/status'),
|
discoveryStatus: () => request('GET', '/discover/status'),
|
||||||
|
analyzeDeep: (tickers) => request('POST', '/discover/analyze', { tickers }),
|
||||||
|
analyzeStatus: () => request('GET', '/discover/analyze/status'),
|
||||||
getInsiderTrades: (ticker) => request('GET', `/insider-trades${ticker ? `?ticker=${ticker}` : ''}`),
|
getInsiderTrades: (ticker) => request('GET', `/insider-trades${ticker ? `?ticker=${ticker}` : ''}`),
|
||||||
syncInsider: () => request('POST', '/insider-trades/sync'),
|
syncInsider: () => request('POST', '/insider-trades/sync'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page h1 { color: #e6edf3; font-size: 1.4rem; }
|
.page h1 { color: #1f2328; font-size: 1.4rem; }
|
||||||
|
|
||||||
.cards {
|
.cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -153,23 +153,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
.card.highlight { border-color: #d29922; }
|
.card.highlight { border-color: #9a6700; }
|
||||||
|
|
||||||
.card-label { font-size: 0.72rem; color: #8b949e; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
.card-label { font-size: 0.72rem; color: #57606a; 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: #1f2328; }
|
||||||
.card-value.green { color: #3fb950; }
|
.card-value.green { color: #1a7f37; }
|
||||||
.card-value.red { color: #f85149; }
|
.card-value.red { color: #cf222e; }
|
||||||
.card-value.orange { color: #d29922; }
|
.card-value.orange { color: #9a6700; }
|
||||||
|
|
||||||
.sep { color: #484f58; }
|
.sep { color: #8c959f; }
|
||||||
|
|
||||||
.section { margin-bottom: 2rem; }
|
.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; }
|
.section h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.06em; color: #57606a; border-bottom: 1px solid #d0d7de; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
.alert-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
.alert-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
.alert-row {
|
.alert-row {
|
||||||
@@ -177,14 +177,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticker { font-weight: 600; color: #58a6ff; min-width: 60px; }
|
.ticker { font-weight: 600; color: #0969da; min-width: 60px; }
|
||||||
.price { color: #e6edf3; font-variant-numeric: tabular-nums; }
|
.price { color: #1f2328; font-variant-numeric: tabular-nums; }
|
||||||
.rsi { font-size: 0.8rem; color: #8b949e; }
|
.rsi { font-size: 0.8rem; color: #57606a; }
|
||||||
.chg { font-size: 0.8rem; margin-left: auto; font-variant-numeric: tabular-nums; }
|
.chg { font-size: 0.8rem; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -193,31 +193,31 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
}
|
}
|
||||||
.badge.green { background: #0d2c1a; color: #3fb950; }
|
.badge.green { background: #dafbe1; color: #1a7f37; }
|
||||||
.badge.red { background: #2c0d0d; color: #f85149; }
|
.badge.red { background: #ffebe9; color: #cf222e; }
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #d0d7de;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
th.num { text-align: right; }
|
th.num { text-align: right; }
|
||||||
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
|
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #ffffff; color: #2c3138; }
|
||||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
tr:hover td { background: #161b22; }
|
tr:hover td { background: #ffffff; }
|
||||||
|
|
||||||
.green { color: #3fb950; }
|
.green { color: #1a7f37; }
|
||||||
.red { color: #f85149; }
|
.red { color: #cf222e; }
|
||||||
|
|
||||||
.empty { color: #484f58; font-size: 0.875rem; }
|
.empty { color: #8c959f; font-size: 0.875rem; }
|
||||||
.empty a { color: #58a6ff; }
|
.empty a { color: #0969da; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,21 +7,41 @@
|
|||||||
let etoroStatus = { syncing: false, count: 0, progress: 0, total: 0, last_error: '' }
|
let etoroStatus = { syncing: false, count: 0, progress: 0, total: 0, last_error: '' }
|
||||||
// Statut discovery
|
// Statut discovery
|
||||||
let discStatus = { running: false, progress: 0, total: 0, found: 0 }
|
let discStatus = { running: false, progress: 0, total: 0, found: 0 }
|
||||||
|
// Statut analyse en profondeur
|
||||||
|
let anaStatus = { running: false, progress: 0, total: 0 }
|
||||||
// Résultats
|
// Résultats
|
||||||
let results = []
|
let results = []
|
||||||
let loading = false
|
let loading = false
|
||||||
let minScore = 30
|
let minScore = 30
|
||||||
let filterAlert = ''
|
let filterAlert = ''
|
||||||
|
|
||||||
|
// Sélection pour analyse en profondeur
|
||||||
|
let selected = new Set()
|
||||||
|
$: allSelected = filtered.length > 0 && filtered.every(r => selected.has(r.ticker))
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (allSelected) {
|
||||||
|
filtered.forEach(r => selected.delete(r.ticker))
|
||||||
|
} else {
|
||||||
|
filtered.forEach(r => selected.add(r.ticker))
|
||||||
|
}
|
||||||
|
selected = new Set(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOne(ticker) {
|
||||||
|
if (selected.has(ticker)) selected.delete(ticker)
|
||||||
|
else selected.add(ticker)
|
||||||
|
selected = new Set(selected)
|
||||||
|
}
|
||||||
|
|
||||||
let pollInterval = null
|
let pollInterval = null
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
await loadResults()
|
await loadResults()
|
||||||
// Poll toutes les 3s si un process tourne
|
|
||||||
pollInterval = setInterval(async () => {
|
pollInterval = setInterval(async () => {
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
if (discStatus.running) await loadResults()
|
if (discStatus.running || anaStatus.running) await loadResults()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,13 +49,31 @@
|
|||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
try {
|
try {
|
||||||
[etoroStatus, discStatus] = await Promise.all([
|
[etoroStatus, discStatus, anaStatus] = await Promise.all([
|
||||||
api.etoroStatus(),
|
api.etoroStatus(),
|
||||||
api.discoveryStatus(),
|
api.discoveryStatus(),
|
||||||
|
api.analyzeStatus(),
|
||||||
])
|
])
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function analyzeDeep() {
|
||||||
|
const tickers = [...selected]
|
||||||
|
if (tickers.length === 0) return
|
||||||
|
try {
|
||||||
|
const r = await api.analyzeDeep(tickers)
|
||||||
|
if (r.status === 'already_running') {
|
||||||
|
notify('Une analyse est déjà en cours…', 'info')
|
||||||
|
} else {
|
||||||
|
notify(`Analyse EDGAR + market cap lancée sur ${tickers.length} ticker(s)…`, 'info')
|
||||||
|
selected = new Set()
|
||||||
|
}
|
||||||
|
await refreshStatus()
|
||||||
|
} catch(e) {
|
||||||
|
notify('Erreur : ' + e.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadResults() {
|
async function loadResults() {
|
||||||
loading = true
|
loading = true
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +104,7 @@
|
|||||||
if (r.status === 'already_running') {
|
if (r.status === 'already_running') {
|
||||||
notify('Scan déjà en cours…', 'info')
|
notify('Scan déjà en cours…', 'info')
|
||||||
} else {
|
} else {
|
||||||
notify(`Scan lancé sur ${etoroStatus.count.toLocaleString()} tickers eToro — ~${Math.ceil(etoroStatus.count * 0.12 / 60)} min`, 'info')
|
notify(`Scan lancé sur ${etoroStatus.count.toLocaleString()} tickers eToro — ~${estMinutes} min`, 'info')
|
||||||
}
|
}
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -127,6 +165,10 @@
|
|||||||
$: etoroPct = etoroStatus.total > 0
|
$: etoroPct = etoroStatus.total > 0
|
||||||
? Math.round(etoroStatus.progress / etoroStatus.total * 100)
|
? Math.round(etoroStatus.progress / etoroStatus.total * 100)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
// Estimation stable : total API (fixe dès la 1ère page), pas le count DB qui monte
|
||||||
|
$: scanBase = etoroStatus.total > 0 ? etoroStatus.total : etoroStatus.count
|
||||||
|
$: estMinutes = Math.ceil(scanBase * 0.12 / 60)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
@@ -169,6 +211,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="status-block" class:active={!anaStatus.running && anaStatus.total > 0} class:syncing={anaStatus.running}>
|
||||||
|
<div class="status-label">Analyse en profondeur</div>
|
||||||
|
<div class="status-value">
|
||||||
|
{#if anaStatus.running}
|
||||||
|
{@const anaPct = Math.round(anaStatus.progress / anaStatus.total * 100)}
|
||||||
|
<span class="spinner">↻</span>
|
||||||
|
{anaStatus.progress} / {anaStatus.total}
|
||||||
|
<div class="progress-bar"><div class="progress-fill orange" style="width:{anaPct}%"></div></div>
|
||||||
|
{:else if anaStatus.total > 0}
|
||||||
|
<span class="ok">● {anaStatus.total} analysés</span>
|
||||||
|
{:else}
|
||||||
|
<span class="empty-val">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="status-actions">
|
<div class="status-actions">
|
||||||
<button class="btn-etoro" on:click={syncEtoro} disabled={etoroStatus.syncing}>
|
<button class="btn-etoro" on:click={syncEtoro} disabled={etoroStatus.syncing}>
|
||||||
{etoroStatus.syncing ? '↻ Chargement…' : '↻ Sync eToro'}
|
{etoroStatus.syncing ? '↻ Chargement…' : '↻ Sync eToro'}
|
||||||
@@ -210,17 +268,26 @@
|
|||||||
<p>Commence par <strong>Sync eToro</strong> pour charger la liste des instruments disponibles (~3 000 stocks).</p>
|
<p>Commence par <strong>Sync eToro</strong> pour charger la liste des instruments disponibles (~3 000 stocks).</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Clique <strong>Lancer le scan</strong> pour analyser les {etoroStatus.count.toLocaleString()} stocks eToro et trouver les meilleures opportunités.</p>
|
<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>
|
<p class="hint">Durée estimée : ~{estMinutes} minutes</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="results-header">
|
<div class="results-header">
|
||||||
<span class="count">{filtered.length} opportunités (score ≥ {minScore})</span>
|
<span class="count">{filtered.length} opportunités (score ≥ {minScore})</span>
|
||||||
|
{#if selected.size > 0}
|
||||||
|
<button class="btn-analyze" on:click={analyzeDeep} disabled={anaStatus.running}>
|
||||||
|
{anaStatus.running ? '↻ Analyse en cours…' : `⚡ Analyser en profondeur (${selected.size})`}
|
||||||
|
</button>
|
||||||
|
<button class="btn-clear-sel" on:click={() => selected = new Set()}>✕ Désélectionner</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="col-check">
|
||||||
|
<input type="checkbox" checked={allSelected} on:change={toggleAll} />
|
||||||
|
</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
<th>Ticker</th>
|
<th>Ticker</th>
|
||||||
<th class="num">Prix</th>
|
<th class="num">Prix</th>
|
||||||
@@ -236,7 +303,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each filtered as r}
|
{#each filtered as r}
|
||||||
{@const ratio = volRatio(r.volume, r.avg_volume20)}
|
{@const ratio = volRatio(r.volume, r.avg_volume20)}
|
||||||
<tr class:has-alert={r.alert}>
|
<tr class:has-alert={r.alert} class:selected={selected.has(r.ticker)}>
|
||||||
|
<td class="col-check">
|
||||||
|
<input type="checkbox" checked={selected.has(r.ticker)}
|
||||||
|
on:change={() => toggleOne(r.ticker)} />
|
||||||
|
</td>
|
||||||
<td><span class="score-pill {scoreClass(r.score)}">{r.score}</span></td>
|
<td><span class="score-pill {scoreClass(r.score)}">{r.score}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="ticker">{r.ticker}</div>
|
<div class="ticker">{r.ticker}</div>
|
||||||
@@ -275,8 +346,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page h1 { color: #e6edf3; font-size: 1.4rem; margin: 0 0 0.25rem; }
|
.page h1 { color: #1f2328; font-size: 1.4rem; margin: 0 0 0.25rem; }
|
||||||
.subtitle { color: #484f58; font-size: 0.85rem; margin: 0 0 1.5rem; }
|
.subtitle { color: #8c959f; font-size: 0.85rem; margin: 0 0 1.5rem; }
|
||||||
|
|
||||||
/* Status bar */
|
/* Status bar */
|
||||||
.status-bar {
|
.status-bar {
|
||||||
@@ -285,8 +356,8 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
padding: 1.1rem 1.25rem;
|
padding: 1.1rem 1.25rem;
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -298,7 +369,7 @@
|
|||||||
|
|
||||||
.status-label {
|
.status-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
@@ -306,46 +377,47 @@
|
|||||||
|
|
||||||
.status-value {
|
.status-value {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #484f58;
|
color: #8c959f;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-block.active .status-value { color: #c9d1d9; }
|
.status-block.active .status-value { color: #2c3138; }
|
||||||
|
|
||||||
.ok { color: #3fb950; }
|
.ok { color: #1a7f37; }
|
||||||
.empty-val { color: #30363d; }
|
.empty-val { color: #c6cdd5; }
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
color: #58a6ff;
|
color: #0969da;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
.found-count { color: #3fb950; font-size: 0.8rem; }
|
.found-count { color: #1a7f37; font-size: 0.8rem; }
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #58a6ff;
|
background: #0969da;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width 0.5s ease;
|
transition: width 0.5s ease;
|
||||||
}
|
}
|
||||||
.progress-fill.green { background: #3fb950; }
|
.progress-fill.green { background: #1a7f37; }
|
||||||
|
.progress-fill.orange { background: #9a6700; }
|
||||||
|
|
||||||
.error-hint {
|
.error-hint {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: #f85149;
|
color: #cf222e;
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,11 +438,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-etoro {
|
.btn-etoro {
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
}
|
}
|
||||||
.btn-etoro:hover:not(:disabled) { color: #e6edf3; }
|
.btn-etoro:hover:not(:disabled) { color: #1f2328; }
|
||||||
.btn-discover {
|
.btn-discover {
|
||||||
background: #1f6feb;
|
background: #1f6feb;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -386,50 +458,81 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
label { font-size: 0.72rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.04em; }
|
label { font-size: 0.72rem; color: #57606a; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
input, select {
|
input, select {
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
input:focus, select:focus { border-color: #58a6ff; }
|
input:focus, select:focus { border-color: #0969da; }
|
||||||
select option { background: #161b22; }
|
select option { background: #ffffff; }
|
||||||
.btn-refresh {
|
.btn-refresh {
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
.btn-refresh:hover { color: #e6edf3; }
|
.btn-refresh:hover { color: #1f2328; }
|
||||||
|
|
||||||
.results-header { margin-bottom: 0.75rem; }
|
.results-header {
|
||||||
.count { font-size: 0.78rem; color: #484f58; }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.count { font-size: 0.78rem; color: #8c959f; }
|
||||||
|
|
||||||
|
.btn-analyze {
|
||||||
|
background: #9a6700;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-analyze:hover:not(:disabled) { background: #7d5500; }
|
||||||
|
.btn-analyze:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-clear-sel {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #57606a;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.btn-clear-sel:hover { color: #1f2328; }
|
||||||
|
|
||||||
|
.col-check { width: 32px; padding: 0.45rem 0.5rem; }
|
||||||
|
tr.selected td { background: #fff8e6; }
|
||||||
|
|
||||||
/* Table */
|
/* Table */
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
|
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #d0d7de;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
th.num { text-align: right; }
|
th.num { text-align: right; }
|
||||||
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
|
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #ffffff; color: #2c3138; }
|
||||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
tr:hover td { background: #161b22; }
|
tr:hover td { background: #ffffff; }
|
||||||
tr.has-alert td { background: #0d180d; }
|
tr.has-alert td { background: #eafbec; }
|
||||||
|
|
||||||
.score-pill {
|
.score-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -440,19 +543,19 @@
|
|||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.score-pill.high { background: #0d3320; color: #3fb950; }
|
.score-pill.high { background: #dafbe1; color: #1a7f37; }
|
||||||
.score-pill.mid { background: #2d2200; color: #d29922; }
|
.score-pill.mid { background: #fff8c5; color: #9a6700; }
|
||||||
.score-pill.low { background: #1c1c1c; color: #484f58; }
|
.score-pill.low { background: #eaeef2; color: #8c959f; }
|
||||||
|
|
||||||
.ticker { font-weight: 600; color: #58a6ff; }
|
.ticker { font-weight: 600; color: #0969da; }
|
||||||
.ticker-name { font-size: 0.72rem; color: #484f58; }
|
.ticker-name { font-size: 0.72rem; color: #8c959f; }
|
||||||
|
|
||||||
.green { color: #3fb950; }
|
.green { color: #1a7f37; }
|
||||||
.red { color: #f85149; }
|
.red { color: #cf222e; }
|
||||||
.rsi.oversold { color: #3fb950; font-weight: 600; }
|
.rsi.oversold { color: #1a7f37; font-weight: 600; }
|
||||||
.rsi.overbought { color: #f85149; font-weight: 600; }
|
.rsi.overbought { color: #cf222e; font-weight: 600; }
|
||||||
.vol-spike { color: #d29922; font-weight: 600; }
|
.vol-spike { color: #9a6700; font-weight: 600; }
|
||||||
.deep-value { color: #d29922; font-weight: 600; }
|
.deep-value { color: #9a6700; font-weight: 600; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -460,18 +563,18 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
}
|
}
|
||||||
.badge.green { background: #0d2c1a; color: #3fb950; }
|
.badge.green { background: #dafbe1; color: #1a7f37; }
|
||||||
.badge.red { background: #2c0d0d; color: #f85149; }
|
.badge.red { background: #ffebe9; color: #cf222e; }
|
||||||
.badge.orange { background: #2d1f00; color: #d29922; }
|
.badge.orange { background: #fff3c4; color: #9a6700; }
|
||||||
|
|
||||||
.empty-state { text-align: center; padding: 3rem; color: #484f58; }
|
.empty-state { text-align: center; padding: 3rem; color: #8c959f; }
|
||||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
||||||
.empty-state p { font-size: 0.9rem; margin: 0.4rem 0; }
|
.empty-state p { font-size: 0.9rem; margin: 0.4rem 0; }
|
||||||
.empty-state strong { color: #8b949e; }
|
.empty-state strong { color: #57606a; }
|
||||||
.empty-state .hint { font-size: 0.8rem; color: #30363d; }
|
.empty-state .hint { font-size: 0.8rem; color: #c6cdd5; }
|
||||||
|
|
||||||
.muted { color: #484f58; font-size: 0.875rem; }
|
.muted { color: #8c959f; font-size: 0.875rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@
|
|||||||
.tab {
|
.tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #484f58;
|
color: #8c959f;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
@@ -217,13 +217,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.tab:hover { color: #8b949e; background: #161b22; }
|
.tab:hover { color: #57606a; background: #ffffff; }
|
||||||
.tab.active { color: #e6edf3; background: #161b22; }
|
.tab.active { color: #1f2328; background: #ffffff; }
|
||||||
|
|
||||||
.badge-count {
|
.badge-count {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
margin-left: 0.3rem;
|
margin-left: 0.3rem;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.badge-count.badge-orange { background: #2d1f00; color: #d29922; }
|
.badge-count.badge-orange { background: #2d1f00; color: #9a6700; }
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -240,23 +240,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
input:focus { border-color: #58a6ff; }
|
input:focus { border-color: #0969da; }
|
||||||
input::placeholder { color: #484f58; }
|
input::placeholder { color: #8c959f; }
|
||||||
|
|
||||||
.btn-sync {
|
.btn-sync {
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #58a6ff;
|
color: #0969da;
|
||||||
border-color: #1f6feb;
|
border-color: #1f6feb;
|
||||||
padding: 0.45rem 0.85rem;
|
padding: 0.45rem 0.85rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -269,12 +269,12 @@
|
|||||||
.news-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
.news-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
|
||||||
.news-card {
|
.news-card {
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.85rem 1.1rem;
|
padding: 0.85rem 1.1rem;
|
||||||
}
|
}
|
||||||
.news-card:hover { border-color: #30363d; }
|
.news-card:hover { border-color: #c6cdd5; }
|
||||||
|
|
||||||
.news-meta {
|
.news-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -283,17 +283,17 @@
|
|||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.ticker { color: #58a6ff; font-weight: 600; }
|
.ticker { color: #0969da; font-weight: 600; }
|
||||||
.source { color: #8b949e; }
|
.source { color: #57606a; }
|
||||||
.time { color: #484f58; margin-left: auto; }
|
.time { color: #8c959f; margin-left: auto; }
|
||||||
|
|
||||||
.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: #1a7f37; }
|
||||||
.sentiment.red { background: #2c0d0d; color: #f85149; }
|
.sentiment.red { background: #2c0d0d; color: #cf222e; }
|
||||||
|
|
||||||
.news-headline { font-size: 0.875rem; color: #c9d1d9; line-height: 1.4; }
|
.news-headline { font-size: 0.875rem; color: #2c3138; line-height: 1.4; }
|
||||||
.news-headline a { color: #c9d1d9; text-decoration: none; }
|
.news-headline a { color: #2c3138; text-decoration: none; }
|
||||||
.news-headline a:hover { color: #58a6ff; }
|
.news-headline a:hover { color: #0969da; }
|
||||||
|
|
||||||
/* Insider trades */
|
/* Insider trades */
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
@@ -302,26 +302,26 @@
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #d0d7de;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
th.num { text-align: right; }
|
th.num { text-align: right; }
|
||||||
|
|
||||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
|
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #ffffff; color: #2c3138; }
|
||||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
tr:hover td { background: #161b22; }
|
tr:hover td { background: #ffffff; }
|
||||||
tr.purchase td { background: #0a1a0a; }
|
tr.purchase td { background: #0a1a0a; }
|
||||||
tr.purchase:hover td { background: #0d2210; }
|
tr.purchase:hover td { background: #0d2210; }
|
||||||
|
|
||||||
td.ticker { font-weight: 600; color: #58a6ff; }
|
td.ticker { font-weight: 600; color: #0969da; }
|
||||||
td.title-col { color: #8b949e; font-size: 0.78rem; }
|
td.title-col { color: #57606a; font-size: 0.78rem; }
|
||||||
td.date { color: #8b949e; }
|
td.date { color: #57606a; }
|
||||||
|
|
||||||
.tx-badge {
|
.tx-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -329,19 +329,19 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
}
|
}
|
||||||
.tx-badge.green { background: #0d2c1a; color: #3fb950; }
|
.tx-badge.green { background: #0d2c1a; color: #1a7f37; }
|
||||||
|
|
||||||
.value.big { color: #d29922; font-weight: 600; }
|
.value.big { color: #9a6700; font-weight: 600; }
|
||||||
|
|
||||||
.link-sec {
|
.link-sec {
|
||||||
color: #58a6ff;
|
color: #0969da;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
.link-sec:hover { text-decoration: underline; }
|
.link-sec:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.empty, .muted { color: #484f58; font-size: 0.875rem; }
|
.empty, .muted { color: #8c959f; font-size: 0.875rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
load()
|
load()
|
||||||
try {
|
try {
|
||||||
const stats = await api.etoroStats()
|
const stats = await api.etoroStatus()
|
||||||
etoroCount = stats.instruments ?? 0
|
etoroCount = stats.instruments ?? 0
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
await api.syncEtoro()
|
await api.syncEtoro()
|
||||||
notify('Sync eToro lancé — ~5000 instruments à charger', 'info')
|
notify('Sync eToro lancé — ~5000 instruments à charger', 'info')
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const stats = await api.etoroStats()
|
const stats = await api.etoroStatus()
|
||||||
etoroCount = stats.instruments ?? 0
|
etoroCount = stats.instruments ?? 0
|
||||||
}, 8000)
|
}, 8000)
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -87,23 +87,35 @@
|
|||||||
function alertLabel(alert) {
|
function alertLabel(alert) {
|
||||||
const map = {
|
const map = {
|
||||||
mega_insider_buy: '🐋 Mega Insider',
|
mega_insider_buy: '🐋 Mega Insider',
|
||||||
|
ceo_change: '👔 New CEO',
|
||||||
deep_value_reversal: '📉→↑ Deep Value',
|
deep_value_reversal: '📉→↑ Deep Value',
|
||||||
oversold: '▼ Oversold',
|
oversold: '▼ Oversold',
|
||||||
overbought: '▲ Overbought',
|
overbought: '▲ Overbought',
|
||||||
macd_cross_up: '↑ MACD bull',
|
macd_cross_up: '↑ MACD bull',
|
||||||
macd_cross_down: '↓ MACD bear',
|
macd_cross_down: '↓ MACD bear',
|
||||||
volume_spike: '⚡ Vol spike',
|
volume_spike: '⚡ Vol spike',
|
||||||
|
insider_sell: '⚠ Insider sell',
|
||||||
}
|
}
|
||||||
return map[alert] || alert
|
return map[alert] || alert
|
||||||
}
|
}
|
||||||
|
|
||||||
function alertClass(alert) {
|
function alertClass(alert) {
|
||||||
if (['mega_insider_buy','oversold','macd_cross_up','deep_value_reversal'].includes(alert)) return 'green'
|
if (['mega_insider_buy','oversold','macd_cross_up','deep_value_reversal','ceo_change'].includes(alert)) return 'green'
|
||||||
if (['overbought','macd_cross_down'].includes(alert)) return 'red'
|
if (['overbought','macd_cross_down','insider_sell'].includes(alert)) return 'red'
|
||||||
if (alert === 'volume_spike') return 'orange'
|
if (alert === 'volume_spike') return 'orange'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function earningsDaysLabel(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = Math.round((d - now) / 86400000)
|
||||||
|
if (diff < 0 || diff > 90) return ''
|
||||||
|
if (diff === 0) return 'Auj.'
|
||||||
|
return diff + 'j'
|
||||||
|
}
|
||||||
|
|
||||||
function fmtInsider(v) {
|
function fmtInsider(v) {
|
||||||
if (!v) return ''
|
if (!v) return ''
|
||||||
if (v >= 1e6) return '$' + (v/1e6).toFixed(1) + 'M'
|
if (v >= 1e6) return '$' + (v/1e6).toFixed(1) + 'M'
|
||||||
@@ -199,6 +211,8 @@
|
|||||||
<option value="macd_cross_up">↑ MACD bull</option>
|
<option value="macd_cross_up">↑ MACD bull</option>
|
||||||
<option value="macd_cross_down">↓ MACD bear</option>
|
<option value="macd_cross_down">↓ MACD bear</option>
|
||||||
<option value="volume_spike">⚡ Vol spike</option>
|
<option value="volume_spike">⚡ Vol spike</option>
|
||||||
|
<option value="ceo_change">👔 New CEO</option>
|
||||||
|
<option value="insider_sell">⚠ Insider sell</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,7 +243,9 @@
|
|||||||
<th class="num">MACD histo</th>
|
<th class="num">MACD histo</th>
|
||||||
<th class="num">Vol/Avg</th>
|
<th class="num">Vol/Avg</th>
|
||||||
<th class="num">52w%</th>
|
<th class="num">52w%</th>
|
||||||
<th class="num">Insider 30j</th>
|
<th class="num">Insider Buy</th>
|
||||||
|
<th class="num">Insider Sell</th>
|
||||||
|
<th class="num">Earnings</th>
|
||||||
<th class="num">Mkt Cap</th>
|
<th class="num">Mkt Cap</th>
|
||||||
<th class="num">Short</th>
|
<th class="num">Short</th>
|
||||||
<th>Alerte</th>
|
<th>Alerte</th>
|
||||||
@@ -249,7 +265,7 @@
|
|||||||
<div class="ticker-name">{s.name}</div>
|
<div class="ticker-name">{s.name}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td><span class="cap-label">{capLabel(s.market_cap)}</span></td>
|
<td>{#if capLabel(s.market_cap)}<span class="cap-label">{capLabel(s.market_cap)}</span>{:else}—{/if}</td>
|
||||||
<td class="num">${fmt(s.price)}</td>
|
<td class="num">${fmt(s.price)}</td>
|
||||||
<td class="num" class:green={s.change_pct > 0} class:red={s.change_pct < 0}>
|
<td class="num" class:green={s.change_pct > 0} class:red={s.change_pct < 0}>
|
||||||
{s.change_pct > 0 ? '+' : ''}{fmt(s.change_pct)}%
|
{s.change_pct > 0 ? '+' : ''}{fmt(s.change_pct)}%
|
||||||
@@ -267,6 +283,14 @@
|
|||||||
<td class="num" class:insider-big={s.insider_value_30d >= 1_000_000}>
|
<td class="num" class:insider-big={s.insider_value_30d >= 1_000_000}>
|
||||||
{fmtInsider(s.insider_value_30d)}
|
{fmtInsider(s.insider_value_30d)}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" class:insider-sell-big={s.insider_sell_value_30d >= 1_000_000}>
|
||||||
|
{fmtInsider(s.insider_sell_value_30d)}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
{#if earningsDaysLabel(s.earnings_date)}
|
||||||
|
<span class="earnings-badge">{earningsDaysLabel(s.earnings_date)}</span>
|
||||||
|
{:else}—{/if}
|
||||||
|
</td>
|
||||||
<td class="num">{fmtCap(s.market_cap)}</td>
|
<td class="num">{fmtCap(s.market_cap)}</td>
|
||||||
<td class="num">{s.short_ratio > 0 ? fmt(s.short_ratio, 1) + 'd' : '—'}</td>
|
<td class="num">{s.short_ratio > 0 ? fmt(s.short_ratio, 1) + 'd' : '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -286,7 +310,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page h1 { color: #e6edf3; font-size: 1.4rem; margin: 0; }
|
.page h1 { color: #1f2328; font-size: 1.4rem; margin: 0; }
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -299,20 +323,20 @@
|
|||||||
|
|
||||||
.etoro-badge {
|
.etoro-badge {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: #484f58;
|
color: #8c959f;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.etoro-badge.active { color: #3fb950; border-color: #3fb950; }
|
.etoro-badge.active { color: #1a7f37; border-color: #1a7f37; }
|
||||||
|
|
||||||
.btn-load-etoro {
|
.btn-load-etoro {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #58a6ff;
|
color: #0969da;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -337,8 +361,8 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,40 +372,40 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
padding-bottom: 0.1rem;
|
padding-bottom: 0.1rem;
|
||||||
}
|
}
|
||||||
.toggle input { accent-color: #3fb950; }
|
.toggle input { accent-color: #1a7f37; }
|
||||||
|
|
||||||
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
label { font-size: 0.72rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; }
|
label { font-size: 0.72rem; color: #57606a; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|
||||||
input[type="number"], select {
|
input[type="number"], select {
|
||||||
background: #0d1117;
|
background: #f6f8fa;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 110px;
|
width: 110px;
|
||||||
}
|
}
|
||||||
input:focus, select:focus { border-color: #58a6ff; }
|
input:focus, select:focus { border-color: #0969da; }
|
||||||
select option { background: #161b22; }
|
select option { background: #ffffff; }
|
||||||
|
|
||||||
.btn-reset {
|
.btn-reset {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
.btn-reset:hover { color: #e6edf3; }
|
.btn-reset:hover { color: #1f2328; }
|
||||||
|
|
||||||
.count { font-size: 0.78rem; color: #484f58; margin-bottom: 0.75rem; }
|
.count { font-size: 0.78rem; color: #8c959f; margin-bottom: 0.75rem; }
|
||||||
|
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
@@ -389,24 +413,24 @@
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #d0d7de;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
th.num { text-align: right; }
|
th.num { text-align: right; }
|
||||||
|
|
||||||
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
|
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #ffffff; color: #2c3138; }
|
||||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
td.center { text-align: center; }
|
td.center { text-align: center; }
|
||||||
|
|
||||||
tr:hover td { background: #161b22; }
|
tr:hover td { background: #ffffff; }
|
||||||
tr.has-alert td { background: #0d180d; }
|
tr.has-alert td { background: #eafbec; }
|
||||||
tr.high-score td { background: #0d1a10; }
|
tr.high-score td { background: #f0fdf4; }
|
||||||
tr.high-score:hover td { background: #112214; }
|
tr.high-score:hover td { background: #dcfce7; }
|
||||||
|
|
||||||
.score-pill {
|
.score-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -417,28 +441,28 @@
|
|||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.score-high { background: #0d3320; color: #3fb950; }
|
.score-high { background: #dafbe1; color: #1a7f37; }
|
||||||
.score-mid { background: #2d2200; color: #d29922; }
|
.score-mid { background: #fff8c5; color: #9a6700; }
|
||||||
.score-low { background: #1c1c1c; color: #484f58; }
|
.score-low { background: #eaeef2; color: #8c959f; }
|
||||||
|
|
||||||
.ticker { font-weight: 600; color: #58a6ff; }
|
.ticker { font-weight: 600; color: #0969da; }
|
||||||
.ticker-name { font-size: 0.72rem; color: #484f58; margin-top: 1px; }
|
.ticker-name { font-size: 0.72rem; color: #8c959f; margin-top: 1px; }
|
||||||
|
|
||||||
.cap-label {
|
.cap-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.green { color: #3fb950; }
|
.green { color: #1a7f37; }
|
||||||
.red { color: #f85149; }
|
.red { color: #cf222e; }
|
||||||
|
|
||||||
.rsi.oversold { color: #3fb950; font-weight: 600; }
|
.rsi.oversold { color: #1a7f37; font-weight: 600; }
|
||||||
.rsi.overbought { color: #f85149; font-weight: 600; }
|
.rsi.overbought { color: #cf222e; font-weight: 600; }
|
||||||
|
|
||||||
.vol-spike { color: #d29922; font-weight: 600; }
|
.vol-spike { color: #9a6700; font-weight: 600; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -446,25 +470,36 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
}
|
}
|
||||||
.badge.green { background: #0d2c1a; color: #3fb950; }
|
.badge.green { background: #dafbe1; color: #1a7f37; }
|
||||||
.badge.red { background: #2c0d0d; color: #f85149; }
|
.badge.red { background: #ffebe9; color: #cf222e; }
|
||||||
.badge.orange { background: #2d1f00; color: #d29922; }
|
.badge.orange { background: #fff3c4; color: #9a6700; }
|
||||||
|
|
||||||
.etoro-dot { color: #3fb950; font-size: 0.9rem; }
|
.etoro-dot { color: #1a7f37; font-size: 0.9rem; }
|
||||||
|
|
||||||
.pct-from-high { color: #8b949e; }
|
.pct-from-high { color: #57606a; }
|
||||||
.pct-from-high.deep-value { color: #d29922; font-weight: 600; }
|
.pct-from-high.deep-value { color: #9a6700; font-weight: 600; }
|
||||||
|
|
||||||
.insider-big { color: #3fb950; font-weight: 700; }
|
.insider-big { color: #1a7f37; font-weight: 700; }
|
||||||
|
.insider-sell-big { color: #cf222e; font-weight: 700; }
|
||||||
|
|
||||||
.empty-state { text-align: center; padding: 3rem; color: #484f58; }
|
.earnings-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fff3c4;
|
||||||
|
color: #9a6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state { text-align: center; padding: 3rem; color: #8c959f; }
|
||||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
||||||
.empty-state p { font-size: 0.9rem; }
|
.empty-state p { font-size: 0.9rem; }
|
||||||
.empty-state a { color: #58a6ff; }
|
.empty-state a { color: #0969da; }
|
||||||
.empty-state strong { color: #8b949e; }
|
.empty-state strong { color: #57606a; }
|
||||||
|
|
||||||
.muted { color: #484f58; font-size: 0.875rem; }
|
.muted { color: #8c959f; font-size: 0.875rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
import { notify } from '../lib/store.js'
|
import { notify } from '../lib/store.js'
|
||||||
|
|
||||||
const providers = [
|
const providers = [
|
||||||
{ key: 'finnhub_api_key', label: 'Finnhub', provider: 'finnhub', placeholder: 'c_xxxxxxxxxxxxxxxx' },
|
{ key: 'finnhub_api_key', label: 'Finnhub', provider: 'finnhub', placeholder: 'c_xxxxxxxxxxxxxxxx' },
|
||||||
{ key: 'alphavantage_key', label: 'Alpha Vantage', provider: 'alphavantage', placeholder: 'XXXXXXXXXXXX' },
|
{ key: 'alphavantage_key', label: 'Alpha Vantage', provider: 'alphavantage', placeholder: 'XXXXXXXXXXXX' },
|
||||||
{ key: 'etoro_api_key', label: 'eToro', provider: 'etoro', placeholder: 'eToro API key' },
|
{ key: 'etoro_api_key', label: 'eToro — Public Key', provider: 'etoro', placeholder: 'x-api-key (clé publique)' },
|
||||||
|
{ key: 'etoro_user_key', label: 'eToro — User Key', provider: null, placeholder: 'eyJjaSI6… (clé utilisateur)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
let values = {}
|
let values = {}
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
placeholder={p.placeholder}
|
placeholder={p.placeholder}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
|
{#if p.provider}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-test"
|
class="btn-test"
|
||||||
@@ -86,6 +88,7 @@
|
|||||||
>
|
>
|
||||||
{testing[p.provider] ? '…' : testResults[p.provider] === 'ok' ? '✓' : testResults[p.provider] === 'error' ? '✗' : 'Tester'}
|
{testing[p.provider] ? '…' : testResults[p.provider] === 'ok' ? '✓' : testResults[p.provider] === 'error' ? '✗' : 'Tester'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -100,47 +103,47 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page h1 { color: #e6edf3; font-size: 1.4rem; }
|
.page h1 { color: #1f2328; font-size: 1.4rem; }
|
||||||
|
|
||||||
form { max-width: 560px; }
|
form { max-width: 560px; }
|
||||||
|
|
||||||
section { margin-bottom: 2rem; }
|
section { margin-bottom: 2rem; }
|
||||||
h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b949e; border-bottom: 1px solid #21262d; padding-bottom: 0.5rem; margin-bottom: 1.25rem; }
|
h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.08em; color: #57606a; border-bottom: 1px solid #d0d7de; padding-bottom: 0.5rem; margin-bottom: 1.25rem; }
|
||||||
|
|
||||||
.hint { font-size: 0.8rem; color: #484f58; margin: -0.75rem 0 1.25rem; }
|
.hint { font-size: 0.8rem; color: #8c959f; margin: -0.75rem 0 1.25rem; }
|
||||||
|
|
||||||
.field { margin-bottom: 1.25rem; }
|
.field { margin-bottom: 1.25rem; }
|
||||||
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.4rem; }
|
label { display: block; font-size: 0.875rem; color: #57606a; margin-bottom: 0.4rem; }
|
||||||
|
|
||||||
.input-row { display: flex; gap: 0.5rem; }
|
.input-row { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
input:focus { border-color: #58a6ff; }
|
input:focus { border-color: #0969da; }
|
||||||
input::placeholder { color: #484f58; }
|
input::placeholder { color: #8c959f; }
|
||||||
|
|
||||||
.btn-test {
|
.btn-test {
|
||||||
background: #21262d;
|
background: #d0d7de;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.btn-test:hover:not(:disabled) { background: #30363d; color: #e6edf3; }
|
.btn-test:hover:not(:disabled) { background: #c6cdd5; color: #1f2328; }
|
||||||
.btn-test.ok { color: #3fb950; border-color: #3fb950; }
|
.btn-test.ok { color: #1a7f37; border-color: #1a7f37; }
|
||||||
.btn-test.error { color: #f85149; border-color: #f85149; }
|
.btn-test.error { color: #cf222e; border-color: #cf222e; }
|
||||||
.btn-test:disabled { opacity: 0.5; }
|
.btn-test:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
.actions { padding-top: 1rem; }
|
.actions { padding-top: 1rem; }
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page h1 { color: #e6edf3; font-size: 1.4rem; }
|
.page h1 { color: #1f2328; font-size: 1.4rem; }
|
||||||
|
|
||||||
.add-form {
|
.add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -105,17 +105,17 @@
|
|||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #161b22;
|
background: #ffffff;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #c6cdd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
input:focus { border-color: #58a6ff; }
|
input:focus { border-color: #0969da; }
|
||||||
input::placeholder { color: #484f58; text-transform: none; }
|
input::placeholder { color: #8c959f; text-transform: none; }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #1f6feb;
|
background: #1f6feb;
|
||||||
@@ -137,10 +137,10 @@
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #8b949e;
|
color: #57606a;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #d0d7de;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -148,24 +148,24 @@
|
|||||||
|
|
||||||
td {
|
td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #161b22;
|
border-bottom: 1px solid #ffffff;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover td { background: #161b22; }
|
tr:hover td { background: #ffffff; }
|
||||||
|
|
||||||
.ticker { font-weight: 600; color: #58a6ff; }
|
.ticker { font-weight: 600; color: #0969da; }
|
||||||
|
|
||||||
.btn-remove {
|
.btn-remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #484f58;
|
color: #8c959f;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.btn-remove:hover { color: #f85149; background: #1c1c1c; }
|
.btn-remove:hover { color: #cf222e; background: #eaeef2; }
|
||||||
|
|
||||||
.empty, .muted { color: #484f58; font-size: 0.875rem; }
|
.empty, .muted { color: #8c959f; font-size: 0.875rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+26
-1
@@ -11,15 +11,23 @@ type DB struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Init(path string) (*DB, error) {
|
func Init(path string) (*DB, error) {
|
||||||
sqldb, err := sql.Open("sqlite", path) // "sqlite" au lieu de "sqlite3"
|
sqldb, err := sql.Open("sqlite", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Une seule connexion — évite les SQLITE_BUSY entre goroutines concurrentes
|
||||||
|
sqldb.SetMaxOpenConns(1)
|
||||||
|
sqldb.SetMaxIdleConns(1)
|
||||||
|
|
||||||
if err := sqldb.Ping(); err != nil {
|
if err := sqldb.Ping(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WAL : lectures non bloquantes + timeout 10s avant BUSY
|
||||||
|
sqldb.Exec("PRAGMA journal_mode=WAL")
|
||||||
|
sqldb.Exec("PRAGMA busy_timeout=10000")
|
||||||
|
|
||||||
database := &DB{sqldb}
|
database := &DB{sqldb}
|
||||||
if err := database.migrate(); err != nil {
|
if err := database.migrate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -114,6 +122,9 @@ func (db *DB) migrate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nettoyage : supprime les signaux watchlist pour les tickers retirés de la watchlist
|
||||||
|
db.Exec(`DELETE FROM signals WHERE source='watchlist' AND ticker NOT IN (SELECT ticker FROM watchlist WHERE active=1)`)
|
||||||
|
|
||||||
// Migrations additives — on ignore les erreurs si la colonne/index existe déjà
|
// Migrations additives — on ignore les erreurs si la colonne/index existe déjà
|
||||||
additive := []string{
|
additive := []string{
|
||||||
`ALTER TABLE news ADD COLUMN finnhub_id INTEGER`,
|
`ALTER TABLE news ADD COLUMN finnhub_id INTEGER`,
|
||||||
@@ -130,6 +141,20 @@ func (db *DB) migrate() error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_instruments_ticker ON instruments(ticker)`,
|
`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_score ON signals(score DESC)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_signals_source ON signals(source)`,
|
`CREATE INDEX IF NOT EXISTS idx_signals_source ON signals(source)`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN insider_sell_value_30d REAL DEFAULT 0`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN earnings_date TEXT DEFAULT ''`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS company_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
accession_no TEXT UNIQUE,
|
||||||
|
filing_date DATE,
|
||||||
|
filing_url TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_company_events_ticker ON company_events(ticker)`,
|
||||||
|
`ALTER TABLE signals ADD COLUMN ceo_change INTEGER DEFAULT 0`,
|
||||||
}
|
}
|
||||||
for _, q := range additive {
|
for _, q := range additive {
|
||||||
db.Exec(q) // intentionnellement sans vérification d'erreur
|
db.Exec(q) // intentionnellement sans vérification d'erreur
|
||||||
|
|||||||
+100
-6
@@ -47,14 +47,25 @@ type tickerEntry struct {
|
|||||||
type submissionsResponse struct {
|
type submissionsResponse struct {
|
||||||
Filings struct {
|
Filings struct {
|
||||||
Recent struct {
|
Recent struct {
|
||||||
Form []string `json:"form"`
|
Form []string `json:"form"`
|
||||||
AccessionNumber []string `json:"accessionNumber"`
|
AccessionNumber []string `json:"accessionNumber"`
|
||||||
FilingDate []string `json:"filingDate"`
|
FilingDate []string `json:"filingDate"`
|
||||||
PrimaryDocument []string `json:"primaryDocument"`
|
PrimaryDocument []string `json:"primaryDocument"`
|
||||||
|
Items []string `json:"items"`
|
||||||
} `json:"recent"`
|
} `json:"recent"`
|
||||||
} `json:"filings"`
|
} `json:"filings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompanyEvent représente un événement 8-K significatif (ex: changement de direction).
|
||||||
|
type CompanyEvent struct {
|
||||||
|
Ticker string
|
||||||
|
EventType string // "ceo_change"
|
||||||
|
Title string
|
||||||
|
AccessionNo string
|
||||||
|
FilingDate string
|
||||||
|
FilingURL string
|
||||||
|
}
|
||||||
|
|
||||||
type form4Doc struct {
|
type form4Doc struct {
|
||||||
Issuer struct {
|
Issuer struct {
|
||||||
Symbol string `xml:"issuerTradingSymbol"`
|
Symbol string `xml:"issuerTradingSymbol"`
|
||||||
@@ -106,7 +117,24 @@ func New() *Client {
|
|||||||
|
|
||||||
// RecentInsiderBuys retourne les achats d'initiés (code P) pour un ticker
|
// RecentInsiderBuys retourne les achats d'initiés (code P) pour un ticker
|
||||||
// sur les 30 derniers jours.
|
// sur les 30 derniers jours.
|
||||||
|
// isUSListed retourne false pour les tickers européens avec suffixe de bourse (.L, .PA, .DE…)
|
||||||
|
func isUSListed(ticker string) bool {
|
||||||
|
if idx := strings.LastIndex(ticker, "."); idx > 0 {
|
||||||
|
suffix := ticker[idx+1:]
|
||||||
|
// Suffixes US valides : A, B (BRK.A, BRK.B) → longueur 1 mais lettre unique
|
||||||
|
// Suffixes européens : L, PA, DE, AS, BR, HE, OL, ST, CO, MC, MI, VI…
|
||||||
|
if len(suffix) >= 2 {
|
||||||
|
return false // .PA, .DE, .AS etc. → non-US
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
|
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
|
||||||
|
if !isUSListed(ticker) {
|
||||||
|
return nil, nil // silencieux pour les titres non-US
|
||||||
|
}
|
||||||
|
|
||||||
cik, err := c.lookupCIK(ticker)
|
cik, err := c.lookupCIK(ticker)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("CIK not found for %s: %w", ticker, err)
|
return nil, fmt.Errorf("CIK not found for %s: %w", ticker, err)
|
||||||
@@ -140,6 +168,72 @@ func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
|
|||||||
return trades, nil
|
return trades, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recent8KEvents retourne les 8-K Item 5.02 (changements de direction) des 30 derniers jours.
|
||||||
|
func (c *Client) Recent8KEvents(ticker string) ([]CompanyEvent, error) {
|
||||||
|
if !isUSListed(ticker) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cik, err := c.lookupCIK(ticker)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil // ticker inconnu → silencieux
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/submissions/CIK%s.json", baseURL, cik)
|
||||||
|
resp, err := c.get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var sub submissionsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
|
||||||
|
forms := sub.Filings.Recent.Form
|
||||||
|
accs := sub.Filings.Recent.AccessionNumber
|
||||||
|
dates := sub.Filings.Recent.FilingDate
|
||||||
|
items := sub.Filings.Recent.Items
|
||||||
|
|
||||||
|
var events []CompanyEvent
|
||||||
|
for i, form := range forms {
|
||||||
|
if form != "8-K" && form != "8-K/A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
date := ""
|
||||||
|
if i < len(dates) {
|
||||||
|
date = dates[i]
|
||||||
|
}
|
||||||
|
if date != "" && date < cutoff {
|
||||||
|
break // filings triés du plus récent au plus ancien
|
||||||
|
}
|
||||||
|
itemStr := ""
|
||||||
|
if i < len(items) {
|
||||||
|
itemStr = items[i]
|
||||||
|
}
|
||||||
|
if !strings.Contains(itemStr, "5.02") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc := ""
|
||||||
|
if i < len(accs) {
|
||||||
|
acc = accs[i]
|
||||||
|
}
|
||||||
|
accNoDashes := strings.ReplaceAll(acc, "-", "")
|
||||||
|
filingURL := fmt.Sprintf("https://www.sec.gov/Archives/edgar/data/%s/%s/", cik, accNoDashes)
|
||||||
|
|
||||||
|
events = append(events, CompanyEvent{
|
||||||
|
Ticker: ticker,
|
||||||
|
EventType: "ceo_change",
|
||||||
|
Title: fmt.Sprintf("Executive change (8-K §5.02) — %s", ticker),
|
||||||
|
AccessionNo: acc,
|
||||||
|
FilingDate: date,
|
||||||
|
FilingURL: filingURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- méthodes internes ----
|
// ---- méthodes internes ----
|
||||||
|
|
||||||
func (c *Client) lookupCIK(ticker string) (string, error) {
|
func (c *Client) lookupCIK(ticker string) (string, error) {
|
||||||
@@ -254,8 +348,8 @@ func (c *Client) parseForm4(cik, accessionNo, primaryDoc, ticker string) ([]Insi
|
|||||||
var trades []InsiderTrade
|
var trades []InsiderTrade
|
||||||
for _, tx := range doc.NonDerivativeTable.Transactions {
|
for _, tx := range doc.NonDerivativeTable.Transactions {
|
||||||
code := tx.Coding.Code
|
code := tx.Coding.Code
|
||||||
// On garde achats (P) et attributions significatives (A avec prix > 0)
|
// P = achat, S = vente, A = attribution avec prix > 0
|
||||||
if code != "P" && !(code == "A" && tx.Amounts.Price.Value > 0) {
|
if code != "P" && code != "S" && !(code == "A" && tx.Amounts.Price.Value > 0) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
shares := tx.Amounts.Shares.Value
|
shares := tx.Amounts.Shares.Value
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ func (p *Poller) Stop() {
|
|||||||
close(p.done)
|
close(p.done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncTicker récupère les insider trades et les events 8-K pour un ticker spécifique.
|
||||||
|
func (p *Poller) SyncTicker(sym string) error {
|
||||||
|
trades, err := p.client.RecentInsiderBuys(sym)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, t := range trades {
|
||||||
|
p.insertTrade(t)
|
||||||
|
}
|
||||||
|
events, _ := p.client.Recent8KEvents(sym)
|
||||||
|
for _, e := range events {
|
||||||
|
p.insertEvent(e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRecentCEOChange retourne true si un 8-K Item 5.02 a été déposé dans les N derniers jours.
|
||||||
|
func (p *Poller) HasRecentCEOChange(ticker string, days int) bool {
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
|
var count int
|
||||||
|
p.db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM company_events
|
||||||
|
WHERE ticker=? AND event_type='ceo_change' AND filing_date >= ?
|
||||||
|
`, ticker, cutoff).Scan(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Poller) Sync() error {
|
func (p *Poller) Sync() error {
|
||||||
tickers, err := p.watchlistTickers()
|
tickers, err := p.watchlistTickers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,13 +92,17 @@ func (p *Poller) Sync() error {
|
|||||||
trades, err := p.client.RecentInsiderBuys(sym)
|
trades, err := p.client.RecentInsiderBuys(sym)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("edgar: %s: %v", sym, err)
|
log.Printf("edgar: %s: %v", sym, err)
|
||||||
continue
|
} else {
|
||||||
}
|
for _, t := range trades {
|
||||||
for _, t := range trades {
|
if p.insertTrade(t) {
|
||||||
if p.insertTrade(t) {
|
total++
|
||||||
total++
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
events, _ := p.client.Recent8KEvents(sym)
|
||||||
|
for _, e := range events {
|
||||||
|
p.insertEvent(e)
|
||||||
|
}
|
||||||
time.Sleep(500 * time.Millisecond) // respecter le rate limit EDGAR
|
time.Sleep(500 * time.Millisecond) // respecter le rate limit EDGAR
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +131,14 @@ func (p *Poller) watchlistTickers() ([]string, error) {
|
|||||||
return tickers, nil
|
return tickers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Poller) insertEvent(e CompanyEvent) {
|
||||||
|
p.db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO company_events
|
||||||
|
(ticker, event_type, title, accession_no, filing_date, filing_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, e.Ticker, e.EventType, e.Title, e.AccessionNo, e.FilingDate, e.FilingURL)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Poller) insertTrade(t InsiderTrade) bool {
|
func (p *Poller) insertTrade(t InsiderTrade) bool {
|
||||||
res, err := p.db.Exec(`
|
res, err := p.db.Exec(`
|
||||||
INSERT OR IGNORE INTO insider_trades
|
INSERT OR IGNORE INTO insider_trades
|
||||||
|
|||||||
+90
-59
@@ -5,31 +5,35 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const instrumentsURL = "https://api.etoro.com/metadata/instruments"
|
const baseURL = "https://public-api.etoro.com/api/v1"
|
||||||
|
|
||||||
// InstrumentTypeID connus sur eToro
|
|
||||||
const (
|
|
||||||
TypeStock = 5
|
|
||||||
TypeETF = 10
|
|
||||||
TypeCrypto = 12
|
|
||||||
TypeIndex = 21
|
|
||||||
TypeCFD = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
apiKey string
|
||||||
|
userKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Instrument struct {
|
type Instrument struct {
|
||||||
InstrumentID int `json:"InstrumentID"`
|
InstrumentID int `json:"internalInstrumentId"`
|
||||||
InstrumentDisplayName string `json:"InstrumentDisplayName"`
|
InstrumentDisplayName string `json:"internalInstrumentDisplayName"`
|
||||||
SymbolFull string `json:"SymbolFull"`
|
SymbolFull string `json:"internalSymbolFull"`
|
||||||
InstrumentTypeID int `json:"InstrumentTypeID"`
|
AssetClassID int `json:"internalAssetClassId"`
|
||||||
IsActive bool `json:"IsActive"`
|
ExchangeID int `json:"internalExchangeId"`
|
||||||
StockIndustryID int `json:"StockIndustryID"`
|
IsHidden bool `json:"isHiddenFromClient"`
|
||||||
StockExchangeID int `json:"StockExchangeID"`
|
IsDelisted bool `json:"isDelisted"`
|
||||||
|
IsActiveInPlatform bool `json:"isActiveInPlatform"`
|
||||||
|
IsBuyEnabled bool `json:"isBuyEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchResponse struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
TotalItems int `json:"totalItems"`
|
||||||
|
Items []Instrument `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Client {
|
func New() *Client {
|
||||||
@@ -38,58 +42,85 @@ func New() *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchStocks retourne tous les instruments de type Stock actifs sur eToro.
|
func NewWithKeys(apiKey, userKey string) *Client {
|
||||||
func (c *Client) FetchStocks() ([]Instrument, error) {
|
return &Client{
|
||||||
all, err := c.fetchAll()
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
apiKey: apiKey,
|
||||||
|
userKey: userKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetKeys(apiKey, userKey string) {
|
||||||
|
c.apiKey = apiKey
|
||||||
|
c.userKey = userKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(path string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest("GET", baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("x-api-key", c.apiKey)
|
||||||
var stocks []Instrument
|
req.Header.Set("x-user-key", c.userKey)
|
||||||
for _, inst := range all {
|
req.Header.Set("x-request-id", uuid.NewString())
|
||||||
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")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
return c.http.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
func (c *Client) fetchPage(assetClassID, pageSize, page int) (*searchResponse, error) {
|
||||||
|
resp, err := c.get(fmt.Sprintf("/market-data/search?internalAssetClassId=%d&pageSize=%d&page=%d", assetClassID, pageSize, page))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("etoro: %w", err)
|
return nil, fmt.Errorf("etoro: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("etoro: HTTP %d", resp.StatusCode)
|
return nil, fmt.Errorf("etoro: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
var sr searchResponse
|
||||||
var instruments []Instrument
|
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&instruments); err != nil {
|
return nil, fmt.Errorf("etoro: parse: %w", err)
|
||||||
return nil, fmt.Errorf("etoro: parse error: %w", err)
|
|
||||||
}
|
}
|
||||||
|
return &sr, nil
|
||||||
if len(instruments) == 0 {
|
}
|
||||||
return nil, fmt.Errorf("etoro: empty response — l'API a peut-être changé")
|
|
||||||
}
|
// FetchStocks retourne tous les stocks actifs disponibles sur eToro (toutes pages).
|
||||||
|
func (c *Client) FetchStocks() ([]Instrument, error) {
|
||||||
return instruments, nil
|
if c.apiKey == "" || c.userKey == "" {
|
||||||
|
return nil, fmt.Errorf("etoro: clés API non configurées")
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 500
|
||||||
|
var all []Instrument
|
||||||
|
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
resp, err := c.get(fmt.Sprintf("/market-data/search?internalAssetClassId=5&pageSize=%d&page=%d", pageSize, page))
|
||||||
|
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 sr searchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
||||||
|
return nil, fmt.Errorf("etoro: parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inst := range sr.Items {
|
||||||
|
if !inst.IsHidden && !inst.IsDelisted && inst.IsBuyEnabled {
|
||||||
|
all = append(all, inst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if page*pageSize >= sr.TotalItems {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, fmt.Errorf("etoro: aucun stock retourné")
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-38
@@ -1,6 +1,7 @@
|
|||||||
package etoro
|
package etoro
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,10 +19,11 @@ type SyncStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Poller struct {
|
type Poller struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
client *Client
|
client *Client
|
||||||
ticker *time.Ticker
|
getKeys func() (apiKey, userKey string, err error)
|
||||||
done chan struct{}
|
ticker *time.Ticker
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
syncing bool
|
syncing bool
|
||||||
@@ -31,19 +33,25 @@ type Poller struct {
|
|||||||
lastError string
|
lastError string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPoller(database *db.DB) *Poller {
|
func NewPoller(database *db.DB, getKeys func() (string, string, error)) *Poller {
|
||||||
return &Poller{
|
return &Poller{
|
||||||
db: database,
|
db: database,
|
||||||
client: New(),
|
client: New(),
|
||||||
done: make(chan struct{}),
|
getKeys: getKeys,
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Poller) Start() {
|
func (p *Poller) Start() {
|
||||||
p.ticker = time.NewTicker(24 * time.Hour)
|
p.ticker = time.NewTicker(24 * time.Hour)
|
||||||
go func() {
|
go func() {
|
||||||
if err := p.Sync(); err != nil {
|
// Sync uniquement si la DB est vide
|
||||||
log.Printf("etoro poller: initial sync: %v", err)
|
if p.dbCount() == 0 {
|
||||||
|
if err := p.Sync(); err != nil {
|
||||||
|
log.Printf("etoro poller: initial sync: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("etoro: %d instruments déjà en DB, sync ignorée au démarrage", p.dbCount())
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -97,47 +105,70 @@ func (p *Poller) Sync() error {
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Println("etoro: fetching instruments…")
|
apiKey, userKey, err := p.getKeys()
|
||||||
stocks, err := p.client.FetchStocks()
|
if err != nil || apiKey == "" || userKey == "" {
|
||||||
if err != nil {
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.lastError = err.Error()
|
p.lastError = "clés API eToro non configurées (Settings)"
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
log.Printf("etoro: fetch error: %v", err)
|
return fmt.Errorf("etoro: clés manquantes")
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
p.client.SetKeys(apiKey, userKey)
|
||||||
|
|
||||||
p.mu.Lock()
|
log.Println("etoro: fetching instruments…")
|
||||||
p.total = len(stocks)
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
log.Printf("etoro: %d stocks à synchroniser", len(stocks))
|
|
||||||
|
|
||||||
|
const pageSize = 500
|
||||||
inserted := 0
|
inserted := 0
|
||||||
for i, s := range stocks {
|
fetched := 0
|
||||||
_, err := p.db.Exec(`
|
|
||||||
INSERT INTO instruments (instrument_id, ticker, name, exchange_id, asset_class_id, synced_at)
|
for page := 1; ; page++ {
|
||||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
sr, err := p.client.fetchPage(5, pageSize, page)
|
||||||
ON CONFLICT(instrument_id) DO UPDATE SET
|
if err != nil {
|
||||||
ticker = excluded.ticker,
|
p.mu.Lock()
|
||||||
name = excluded.name,
|
p.lastError = err.Error()
|
||||||
exchange_id = excluded.exchange_id,
|
p.mu.Unlock()
|
||||||
synced_at = CURRENT_TIMESTAMP
|
log.Printf("etoro: fetch error page %d: %v", page, err)
|
||||||
`, s.InstrumentID, s.SymbolFull, s.InstrumentDisplayName,
|
return err
|
||||||
s.StockExchangeID, s.InstrumentTypeID)
|
|
||||||
if err == nil {
|
|
||||||
inserted++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i+1)%100 == 0 || i+1 == len(stocks) {
|
// On connaît le total dès la première page
|
||||||
|
if page == 1 {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.progress = i + 1
|
p.total = sr.TotalItems
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
log.Printf("etoro: %d/%d instruments traités", i+1, len(stocks))
|
log.Printf("etoro: %d stocks à synchroniser", sr.TotalItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range sr.Items {
|
||||||
|
if s.IsHidden || s.IsDelisted || !s.IsBuyEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, 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.ExchangeID, s.AssetClassID)
|
||||||
|
if err == nil {
|
||||||
|
inserted++
|
||||||
|
}
|
||||||
|
fetched++
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.progress = fetched
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("etoro: page %d — %d/%d traités", page, fetched, sr.TotalItems)
|
||||||
|
|
||||||
|
if page*pageSize >= sr.TotalItems {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("etoro: sync terminée — %d/%d instruments en DB", inserted, len(stocks))
|
log.Printf("etoro: sync terminée — %d instruments en DB", inserted)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,37 @@ func (c *Client) MarketNews() ([]NewsItem, error) {
|
|||||||
return c.fetchNews(url)
|
return c.fetchNews(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NextEarningsDate retourne la prochaine date d'annonce des résultats (90 jours max).
|
||||||
|
func (c *Client) NextEarningsDate(symbol string) (string, error) {
|
||||||
|
from := time.Now().Format("2006-01-02")
|
||||||
|
to := time.Now().AddDate(0, 3, 0).Format("2006-01-02")
|
||||||
|
url := fmt.Sprintf("%s/calendar/earnings?from=%s&to=%s&symbol=%s&token=%s",
|
||||||
|
baseURL, from, to, symbol, c.apiKey)
|
||||||
|
|
||||||
|
resp, err := c.http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("finnhub earnings: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
EarningsCalendar []struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
} `json:"earningsCalendar"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(result.EarningsCalendar) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return result.EarningsCalendar[0].Date, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Ping() error {
|
func (c *Client) Ping() error {
|
||||||
url := fmt.Sprintf("%s/news?category=general&minId=999999999&token=%s", baseURL, c.apiKey)
|
url := fmt.Sprintf("%s/news?category=general&minId=999999999&token=%s", baseURL, c.apiKey)
|
||||||
resp, err := c.http.Get(url)
|
resp, err := c.http.Get(url)
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ func (p *Poller) Sync() error {
|
|||||||
|
|
||||||
func (p *Poller) LastRun() time.Time { return p.lastRun }
|
func (p *Poller) LastRun() time.Time { return p.lastRun }
|
||||||
|
|
||||||
|
// NextEarningsDate implémente scanner.EarningsFetcher.
|
||||||
|
func (p *Poller) NextEarningsDate(symbol string) (string, error) {
|
||||||
|
apiKey, err := p.getKey()
|
||||||
|
if err != nil || apiKey == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return New(apiKey).NextEarningsDate(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Poller) watchlistTickers() ([]string, error) {
|
func (p *Poller) watchlistTickers() ([]string, error) {
|
||||||
rows, err := p.db.Query(`SELECT ticker FROM watchlist WHERE active=1`)
|
rows, err := p.db.Query(`SELECT ticker FROM watchlist WHERE active=1`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func (d *DiscoveryScanner) scanTicker(sym string) (score int, alert string, err
|
|||||||
return 0, "", nil
|
return 0, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
alert = detectAlert(rsi, macdRes, last.Volume, avgVol, 0, pctFromHigh)
|
alert = detectAlert(rsi, macdRes, last.Volume, avgVol, 0, 0, pctFromHigh, false)
|
||||||
|
|
||||||
_, err = d.db.Exec(`
|
_, err = d.db.Exec(`
|
||||||
INSERT INTO signals
|
INSERT INTO signals
|
||||||
|
|||||||
+199
-40
@@ -2,6 +2,8 @@ package scanner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.rouggy.com/rouggy/stockradar/internal/db"
|
"git.rouggy.com/rouggy/stockradar/internal/db"
|
||||||
@@ -9,6 +11,17 @@ import (
|
|||||||
"git.rouggy.com/rouggy/stockradar/internal/yahoo"
|
"git.rouggy.com/rouggy/stockradar/internal/yahoo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// EdgarSyncer permet au scanner de déclencher une sync EDGAR par ticker.
|
||||||
|
type EdgarSyncer interface {
|
||||||
|
SyncTicker(sym string) error
|
||||||
|
HasRecentCEOChange(ticker string, days int) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarningsFetcher retourne la prochaine date d'earnings pour un ticker.
|
||||||
|
type EarningsFetcher interface {
|
||||||
|
NextEarningsDate(symbol string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Signal struct {
|
type Signal struct {
|
||||||
Ticker string `json:"ticker"`
|
Ticker string `json:"ticker"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -27,18 +40,37 @@ type Signal struct {
|
|||||||
Week52High float64 `json:"week52_high"`
|
Week52High float64 `json:"week52_high"`
|
||||||
Week52Low float64 `json:"week52_low"`
|
Week52Low float64 `json:"week52_low"`
|
||||||
PctFromHigh float64 `json:"pct_from_high"` // négatif = % sous le 52w high
|
PctFromHigh float64 `json:"pct_from_high"` // négatif = % sous le 52w high
|
||||||
InsiderValue30d float64 `json:"insider_value_30d"` // $ total d'achats insider sur 30j
|
InsiderValue30d float64 `json:"insider_value_30d"`
|
||||||
Score int `json:"score"`
|
InsiderSell30d float64 `json:"insider_sell_value_30d"`
|
||||||
OnEtoro bool `json:"on_etoro"`
|
EarningsDate string `json:"earnings_date"`
|
||||||
Alert string `json:"alert"`
|
CEOChange bool `json:"ceo_change"`
|
||||||
ComputedAt string `json:"computed_at"`
|
Score int `json:"score"`
|
||||||
|
OnEtoro bool `json:"on_etoro"`
|
||||||
|
Alert string `json:"alert"`
|
||||||
|
ComputedAt string `json:"computed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeStatus expose l'avancement d'une analyse en profondeur.
|
||||||
|
type AnalyzeStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
yahoo *yahoo.Client
|
yahoo *yahoo.Client
|
||||||
ticker *time.Ticker
|
edgar EdgarSyncer
|
||||||
done chan struct{}
|
earnings EarningsFetcher
|
||||||
|
ticker *time.Ticker
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
analyzing bool
|
||||||
|
anaProgress int
|
||||||
|
anaTotal int
|
||||||
|
anaError string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(database *db.DB) *Scanner {
|
func New(database *db.DB) *Scanner {
|
||||||
@@ -49,6 +81,66 @@ func New(database *db.DB) *Scanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) SetEdgar(e EdgarSyncer) { s.edgar = e }
|
||||||
|
func (s *Scanner) SetEarnings(e EarningsFetcher) { s.earnings = e }
|
||||||
|
|
||||||
|
func (s *Scanner) AnalyzeStatus() AnalyzeStatus {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return AnalyzeStatus{
|
||||||
|
Running: s.analyzing,
|
||||||
|
Progress: s.anaProgress,
|
||||||
|
Total: s.anaTotal,
|
||||||
|
LastError: s.anaError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze lance une analyse complète (EDGAR + market cap + score) sur une liste de tickers.
|
||||||
|
// Retourne false si une analyse est déjà en cours.
|
||||||
|
func (s *Scanner) Analyze(tickers []string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.analyzing {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.analyzing = true
|
||||||
|
s.anaProgress = 0
|
||||||
|
s.anaTotal = len(tickers)
|
||||||
|
s.anaError = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.analyzing = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("analyzer: analyse en profondeur de %d tickers…", len(tickers))
|
||||||
|
for i, sym := range tickers {
|
||||||
|
// 1. Sync EDGAR pour ce ticker (silencieux si non-US ou CIK inconnu)
|
||||||
|
if s.edgar != nil {
|
||||||
|
s.edgar.SyncTicker(sym)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Full scan (OHLCV + market cap + score complet) — garde source='discovery'
|
||||||
|
if err := s.scanTickerWithSource(sym, "discovery"); err != nil {
|
||||||
|
log.Printf("analyzer: scan %s: %v", sym, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.anaProgress = i + 1
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
log.Printf("analyzer: terminé — %d tickers analysés", len(tickers))
|
||||||
|
}()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scanner) Start() {
|
func (s *Scanner) Start() {
|
||||||
s.ticker = time.NewTicker(30 * time.Minute)
|
s.ticker = time.NewTicker(30 * time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -99,6 +191,10 @@ func (s *Scanner) Scan() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) scanTicker(sym string) error {
|
func (s *Scanner) scanTicker(sym string) error {
|
||||||
|
return s.scanTickerWithSource(sym, "watchlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) scanTickerWithSource(sym, source string) error {
|
||||||
bars, err := s.yahoo.History(sym, 100)
|
bars, err := s.yahoo.History(sym, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -146,9 +242,24 @@ func (s *Scanner) scanTicker(sym string) error {
|
|||||||
pctFromHigh = (last.Close - week52High) / week52High * 100 // négatif
|
pctFromHigh = (last.Close - week52High) / week52High * 100 // négatif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insider buys sur 30 jours — par VALEUR
|
// Insider buys + sells sur 30 jours
|
||||||
insiderValue30d := s.insiderBuyValue30d(sym)
|
insiderValue30d := s.insiderBuyValue30d(sym)
|
||||||
insiderDays := s.lastInsiderBuyDays(sym)
|
insiderSell30d := s.insiderSellValue30d(sym)
|
||||||
|
insiderDays := s.lastInsiderBuyDays(sym)
|
||||||
|
|
||||||
|
// Changement de CEO (8-K Item 5.02) dans les 14 derniers jours
|
||||||
|
ceoChange := false
|
||||||
|
if s.edgar != nil && isUSListed(sym) {
|
||||||
|
ceoChange = s.edgar.HasRecentCEOChange(sym, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prochaine date d'earnings
|
||||||
|
earningsDate := ""
|
||||||
|
if s.earnings != nil && isUSListed(sym) {
|
||||||
|
if d, err := s.earnings.NextEarningsDate(sym); err == nil {
|
||||||
|
earningsDate = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// eToro universe check
|
// eToro universe check
|
||||||
onEtoro := s.isOnEtoro(sym)
|
onEtoro := s.isOnEtoro(sym)
|
||||||
@@ -163,6 +274,8 @@ func (s *Scanner) scanTicker(sym string) error {
|
|||||||
shortRatio: shortRatio,
|
shortRatio: shortRatio,
|
||||||
insiderDays: insiderDays,
|
insiderDays: insiderDays,
|
||||||
insiderValue30d: insiderValue30d,
|
insiderValue30d: insiderValue30d,
|
||||||
|
insiderSell30d: insiderSell30d,
|
||||||
|
ceoChange: ceoChange,
|
||||||
newsDays: s.lastPositiveNewsDays(sym),
|
newsDays: s.lastPositiveNewsDays(sym),
|
||||||
price: last.Close,
|
price: last.Close,
|
||||||
sma20: sma20,
|
sma20: sma20,
|
||||||
@@ -170,42 +283,48 @@ func (s *Scanner) scanTicker(sym string) error {
|
|||||||
pctFromHigh: pctFromHigh,
|
pctFromHigh: pctFromHigh,
|
||||||
})
|
})
|
||||||
|
|
||||||
alert := detectAlert(rsi, macdRes, last.Volume, avgVol, insiderValue30d, pctFromHigh)
|
alert := detectAlert(rsi, macdRes, last.Volume, avgVol, insiderValue30d, insiderSell30d, pctFromHigh, ceoChange)
|
||||||
|
|
||||||
_, err = s.db.Exec(`
|
_, err = s.db.Exec(`
|
||||||
INSERT INTO signals
|
INSERT INTO signals
|
||||||
(ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist,
|
(ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist,
|
||||||
sma20, sma50, volume, avg_volume20, market_cap, short_ratio,
|
sma20, sma50, volume, avg_volume20, market_cap, short_ratio,
|
||||||
week52_high, week52_low, pct_from_high, insider_value_30d,
|
week52_high, week52_low, pct_from_high, insider_value_30d,
|
||||||
score, on_etoro, alert, computed_at)
|
insider_sell_value_30d, earnings_date, ceo_change,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
|
score, on_etoro, alert, source, computed_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(ticker) DO UPDATE SET
|
ON CONFLICT(ticker) DO UPDATE SET
|
||||||
price = excluded.price,
|
price = excluded.price,
|
||||||
change_pct = excluded.change_pct,
|
change_pct = excluded.change_pct,
|
||||||
rsi14 = excluded.rsi14,
|
rsi14 = excluded.rsi14,
|
||||||
macd = excluded.macd,
|
macd = excluded.macd,
|
||||||
macd_signal = excluded.macd_signal,
|
macd_signal = excluded.macd_signal,
|
||||||
macd_hist = excluded.macd_hist,
|
macd_hist = excluded.macd_hist,
|
||||||
sma20 = excluded.sma20,
|
sma20 = excluded.sma20,
|
||||||
sma50 = excluded.sma50,
|
sma50 = excluded.sma50,
|
||||||
volume = excluded.volume,
|
volume = excluded.volume,
|
||||||
avg_volume20 = excluded.avg_volume20,
|
avg_volume20 = excluded.avg_volume20,
|
||||||
market_cap = excluded.market_cap,
|
market_cap = excluded.market_cap,
|
||||||
short_ratio = excluded.short_ratio,
|
short_ratio = excluded.short_ratio,
|
||||||
week52_high = excluded.week52_high,
|
week52_high = excluded.week52_high,
|
||||||
week52_low = excluded.week52_low,
|
week52_low = excluded.week52_low,
|
||||||
pct_from_high = excluded.pct_from_high,
|
pct_from_high = excluded.pct_from_high,
|
||||||
insider_value_30d = excluded.insider_value_30d,
|
insider_value_30d = excluded.insider_value_30d,
|
||||||
score = excluded.score,
|
insider_sell_value_30d = excluded.insider_sell_value_30d,
|
||||||
on_etoro = excluded.on_etoro,
|
earnings_date = excluded.earnings_date,
|
||||||
alert = excluded.alert,
|
ceo_change = excluded.ceo_change,
|
||||||
computed_at = CURRENT_TIMESTAMP
|
score = excluded.score,
|
||||||
|
on_etoro = excluded.on_etoro,
|
||||||
|
alert = excluded.alert,
|
||||||
|
source = excluded.source,
|
||||||
|
computed_at = CURRENT_TIMESTAMP
|
||||||
`, sym, last.Close, changePct, rsi,
|
`, sym, last.Close, changePct, rsi,
|
||||||
macdRes.MACD, macdRes.Signal, macdRes.Histogram,
|
macdRes.MACD, macdRes.Signal, macdRes.Histogram,
|
||||||
sma20, sma50, last.Volume, avgVol,
|
sma20, sma50, last.Volume, avgVol,
|
||||||
marketCap, shortRatio,
|
marketCap, shortRatio,
|
||||||
week52High, week52Low, pctFromHigh, insiderValue30d,
|
week52High, week52Low, pctFromHigh, insiderValue30d,
|
||||||
score, boolToInt(onEtoro), alert)
|
insiderSell30d, earningsDate, boolToInt(ceoChange),
|
||||||
|
score, boolToInt(onEtoro), alert, source)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -219,13 +338,15 @@ type scoreInput struct {
|
|||||||
avgVolume int64
|
avgVolume int64
|
||||||
marketCap int64
|
marketCap int64
|
||||||
shortRatio float64
|
shortRatio float64
|
||||||
insiderDays int // jours depuis dernier insider buy (-1 = aucun)
|
insiderDays int
|
||||||
insiderValue30d float64 // $ total d'achats insider sur 30j
|
insiderValue30d float64
|
||||||
newsDays int // jours depuis dernière news positive (-1 = aucune)
|
insiderSell30d float64
|
||||||
|
ceoChange bool
|
||||||
|
newsDays int
|
||||||
price float64
|
price float64
|
||||||
sma20 float64
|
sma20 float64
|
||||||
sma50 float64
|
sma50 float64
|
||||||
pctFromHigh float64 // % sous le 52w high (négatif)
|
pctFromHigh float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeScore(in scoreInput) int {
|
func computeScore(in scoreInput) int {
|
||||||
@@ -306,6 +427,16 @@ func computeScore(in scoreInput) int {
|
|||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CEO change récent (8-K §5.02) → +20 pts signal catalyseur
|
||||||
|
if in.ceoChange {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pénalité insider selling (-10 si ventes >> achats)
|
||||||
|
if in.insiderSell30d >= 1_000_000 && in.insiderSell30d > in.insiderValue30d*2 {
|
||||||
|
score -= 10
|
||||||
|
}
|
||||||
|
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
}
|
}
|
||||||
@@ -314,11 +445,19 @@ func computeScore(in scoreInput) int {
|
|||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
func detectAlert(rsi float64, m indicators.MACDResult, vol, avgVol int64, insiderValue30d, pctFromHigh float64) string {
|
func detectAlert(rsi float64, m indicators.MACDResult, vol, avgVol int64, insiderValue30d, insiderSell30d, pctFromHigh float64, ceoChange bool) string {
|
||||||
// Priorité 1 : mega insider buy (signal le plus fort)
|
// Priorité 1 : mega insider buy (signal le plus fort)
|
||||||
if insiderValue30d >= 1_000_000 {
|
if insiderValue30d >= 1_000_000 {
|
||||||
return "mega_insider_buy"
|
return "mega_insider_buy"
|
||||||
}
|
}
|
||||||
|
// Priorité 2 : changement de CEO récent (catalyseur de retournement)
|
||||||
|
if ceoChange {
|
||||||
|
return "ceo_change"
|
||||||
|
}
|
||||||
|
// Priorité 3 : insider selling massif (signal négatif)
|
||||||
|
if insiderSell30d >= 1_000_000 && insiderSell30d > insiderValue30d*2 {
|
||||||
|
return "insider_sell"
|
||||||
|
}
|
||||||
// Priorité 2 : RSI oversold
|
// Priorité 2 : RSI oversold
|
||||||
if rsi > 0 && rsi < 30 {
|
if rsi > 0 && rsi < 30 {
|
||||||
return "oversold"
|
return "oversold"
|
||||||
@@ -364,6 +503,26 @@ func (s *Scanner) insiderBuyValue30d(ticker string) float64 {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) insiderSellValue30d(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 = 'S' AND transaction_date >= ?
|
||||||
|
`, ticker, cutoff).Scan(&total)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUSListed : false pour les tickers avec suffixe de bourse européen (.L, .PA…)
|
||||||
|
func isUSListed(ticker string) bool {
|
||||||
|
if idx := strings.LastIndex(ticker, "."); idx > 0 {
|
||||||
|
if len(ticker[idx+1:]) >= 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scanner) isOnEtoro(ticker string) bool {
|
func (s *Scanner) isOnEtoro(ticker string) bool {
|
||||||
var count int
|
var count int
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM instruments WHERE ticker = ?`, ticker).Scan(&count)
|
s.db.QueryRow(`SELECT COUNT(*) FROM instruments WHERE ticker = ?`, ticker).Scan(&count)
|
||||||
|
|||||||
@@ -118,6 +118,31 @@ func (s *Server) handleEtoroStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleEtoroStatus(w, r)
|
s.handleEtoroStatus(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAnalyzeDeep(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Tickers []string `json:"tickers"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Tickers) == 0 {
|
||||||
|
http.Error(w, "tickers required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Tickers) > 50 {
|
||||||
|
body.Tickers = body.Tickers[:50] // limite de sécurité
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if s.scanner.Analyze(body.Tickers) {
|
||||||
|
w.Write([]byte(`{"status":"started"}`))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(`{"status":"already_running"}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAnalyzeStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(s.scanner.AnalyzeStatus())
|
||||||
|
}
|
||||||
|
|
||||||
// Scan watchlist signal - déjà dans handlers_scanner.go, on ajoute juste
|
// Scan watchlist signal - déjà dans handlers_scanner.go, on ajoute juste
|
||||||
// un champ source à la query existante
|
// un champ source à la query existante
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
COALESCE(sig.market_cap, 0), COALESCE(sig.short_ratio, 0),
|
COALESCE(sig.market_cap, 0), COALESCE(sig.short_ratio, 0),
|
||||||
COALESCE(sig.week52_high, 0), COALESCE(sig.week52_low, 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.pct_from_high, 0), COALESCE(sig.insider_value_30d, 0),
|
||||||
|
COALESCE(sig.insider_sell_value_30d, 0), COALESCE(sig.earnings_date, ''),
|
||||||
|
COALESCE(sig.ceo_change, 0),
|
||||||
COALESCE(sig.score, 0), COALESCE(sig.on_etoro, 0),
|
COALESCE(sig.score, 0), COALESCE(sig.on_etoro, 0),
|
||||||
COALESCE(sig.alert,''), sig.computed_at
|
COALESCE(sig.alert,''), sig.computed_at
|
||||||
FROM signals sig
|
FROM signals sig
|
||||||
LEFT JOIN instruments inst ON inst.ticker = sig.ticker`
|
LEFT JOIN instruments inst ON inst.ticker = sig.ticker`
|
||||||
|
|
||||||
|
query += ` WHERE sig.source = 'watchlist'`
|
||||||
if onlyEtoro {
|
if onlyEtoro {
|
||||||
query += ` WHERE sig.on_etoro = 1`
|
query += ` AND sig.on_etoro = 1`
|
||||||
}
|
}
|
||||||
query += ` ORDER BY sig.score DESC, CASE WHEN sig.alert != '' THEN 0 ELSE 1 END`
|
query += ` ORDER BY sig.score DESC, CASE WHEN sig.alert != '' THEN 0 ELSE 1 END`
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
signals := []scanner.Signal{}
|
signals := []scanner.Signal{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sig scanner.Signal
|
var sig scanner.Signal
|
||||||
var onEtoro int
|
var onEtoro, ceoChange int
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&sig.Ticker, &sig.Name,
|
&sig.Ticker, &sig.Name,
|
||||||
&sig.Price, &sig.ChangePct,
|
&sig.Price, &sig.ChangePct,
|
||||||
@@ -48,6 +51,8 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
&sig.MarketCap, &sig.ShortRatio,
|
&sig.MarketCap, &sig.ShortRatio,
|
||||||
&sig.Week52High, &sig.Week52Low,
|
&sig.Week52High, &sig.Week52Low,
|
||||||
&sig.PctFromHigh, &sig.InsiderValue30d,
|
&sig.PctFromHigh, &sig.InsiderValue30d,
|
||||||
|
&sig.InsiderSell30d, &sig.EarningsDate,
|
||||||
|
&ceoChange,
|
||||||
&sig.Score, &onEtoro,
|
&sig.Score, &onEtoro,
|
||||||
&sig.Alert, &sig.ComputedAt,
|
&sig.Alert, &sig.ComputedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -55,6 +60,7 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sig.OnEtoro = onEtoro == 1
|
sig.OnEtoro = onEtoro == 1
|
||||||
|
sig.CEOChange = ceoChange == 1
|
||||||
signals = append(signals, sig)
|
signals = append(signals, sig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +118,3 @@ func (s *Server) handleGetPrices(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(bars)
|
json.NewEncoder(w).Encode(bars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,11 +60,8 @@ func (s *Server) handleAddWatchlist(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleRemoveWatchlist(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||||
ticker := mux.Vars(r)["ticker"]
|
ticker := mux.Vars(r)["ticker"]
|
||||||
_, err := s.db.Exec(`DELETE FROM watchlist WHERE ticker = ?`, ticker)
|
s.db.Exec(`DELETE FROM watchlist WHERE ticker = ?`, ticker)
|
||||||
if err != nil {
|
s.db.Exec(`DELETE FROM signals WHERE ticker = ? AND source = 'watchlist'`, ticker)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"status":"removed"}`))
|
w.Write([]byte(`{"status":"removed"}`))
|
||||||
|
|||||||
@@ -46,13 +46,24 @@ func New(database *db.DB, port string) (*Server, error) {
|
|||||||
|
|
||||||
s.scanner = scanner.New(database)
|
s.scanner = scanner.New(database)
|
||||||
s.scanner.Start()
|
s.scanner.Start()
|
||||||
|
s.edgarPoller = edgar.NewPoller(database)
|
||||||
|
s.edgarPoller.Start()
|
||||||
|
s.scanner.SetEdgar(s.edgarPoller)
|
||||||
|
s.scanner.SetEarnings(s.poller)
|
||||||
|
|
||||||
s.discovery = scanner.NewDiscovery(database)
|
s.discovery = scanner.NewDiscovery(database)
|
||||||
|
|
||||||
s.edgarPoller = edgar.NewPoller(database)
|
s.etoroPoller = etoro.NewPoller(database, func() (string, string, error) {
|
||||||
s.edgarPoller.Start()
|
apiKey, err := svc.Get("etoro_api_key")
|
||||||
|
if err != nil {
|
||||||
s.etoroPoller = etoro.NewPoller(database)
|
return "", "", err
|
||||||
|
}
|
||||||
|
userKey, err := svc.Get("etoro_user_key")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return apiKey, userKey, nil
|
||||||
|
})
|
||||||
s.etoroPoller.Start()
|
s.etoroPoller.Start()
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
@@ -96,6 +107,8 @@ func (s *Server) setupRoutes() {
|
|||||||
api.HandleFunc("/discover", s.handleGetDiscovery).Methods("GET", "OPTIONS")
|
api.HandleFunc("/discover", s.handleGetDiscovery).Methods("GET", "OPTIONS")
|
||||||
api.HandleFunc("/discover/run", s.handleRunDiscovery).Methods("POST", "OPTIONS")
|
api.HandleFunc("/discover/run", s.handleRunDiscovery).Methods("POST", "OPTIONS")
|
||||||
api.HandleFunc("/discover/status", s.handleDiscoveryStatus).Methods("GET", "OPTIONS")
|
api.HandleFunc("/discover/status", s.handleDiscoveryStatus).Methods("GET", "OPTIONS")
|
||||||
|
api.HandleFunc("/discover/analyze", s.handleAnalyzeDeep).Methods("POST", "OPTIONS")
|
||||||
|
api.HandleFunc("/discover/analyze/status", s.handleAnalyzeStatus).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")
|
||||||
@@ -140,6 +153,7 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Clés API → chiffrées, reste → plain text
|
// Clés API → chiffrées, reste → plain text
|
||||||
encryptedKeys := map[string]bool{
|
encryptedKeys := map[string]bool{
|
||||||
"etoro_api_key": true,
|
"etoro_api_key": true,
|
||||||
|
"etoro_user_key": true,
|
||||||
"finnhub_api_key": true,
|
"finnhub_api_key": true,
|
||||||
"alphavantage_key": true,
|
"alphavantage_key": true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package yahoo
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +15,9 @@ const baseURL = "https://query1.finance.yahoo.com/v8/finance/chart"
|
|||||||
const summaryURL = "https://query1.finance.yahoo.com/v10/finance/quoteSummary"
|
const summaryURL = "https://query1.finance.yahoo.com/v10/finance/quoteSummary"
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
crumb string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Bar struct {
|
type Bar struct {
|
||||||
@@ -57,11 +63,51 @@ type chartResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() *Client {
|
func New() *Client {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
return &Client{
|
return &Client{
|
||||||
http: &http.Client{Timeout: 10 * time.Second},
|
http: &http.Client{Timeout: 10 * time.Second, Jar: jar},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initCrumb obtient un cookie de session Yahoo Finance puis récupère le crumb.
|
||||||
|
func (c *Client) initCrumb() error {
|
||||||
|
// 1. Visite Yahoo Finance pour obtenir les cookies
|
||||||
|
req, _ := http.NewRequest("GET", "https://finance.yahoo.com", nil)
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
req.Header.Set("Accept", "text/html")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// 2. Récupère le crumb
|
||||||
|
req2, _ := http.NewRequest("GET", "https://query1.finance.yahoo.com/v1/test/getcrumb", nil)
|
||||||
|
req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
resp2, err := c.http.Do(req2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp2.Body)
|
||||||
|
crumb := strings.TrimSpace(string(body))
|
||||||
|
if crumb == "" || strings.Contains(crumb, "Unauthorized") {
|
||||||
|
return fmt.Errorf("yahoo: crumb invalide")
|
||||||
|
}
|
||||||
|
c.crumb = crumb
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getCrumb() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.crumb == "" {
|
||||||
|
c.initCrumb()
|
||||||
|
}
|
||||||
|
return c.crumb
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) History(symbol string, days int) ([]Bar, error) {
|
func (c *Client) History(symbol string, days int) ([]Bar, error) {
|
||||||
rangeStr := "3mo"
|
rangeStr := "3mo"
|
||||||
if days > 90 {
|
if days > 90 {
|
||||||
@@ -193,13 +239,14 @@ type quoteSummaryResponse struct {
|
|||||||
|
|
||||||
// GetMarketCap retourne les données fondamentales d'un ticker.
|
// GetMarketCap retourne les données fondamentales d'un ticker.
|
||||||
func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) {
|
func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) {
|
||||||
url := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics", summaryURL, symbol)
|
crumb := c.getCrumb()
|
||||||
|
url := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics&crumb=%s", summaryURL, symbol, crumb)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,6 +254,21 @@ func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 401 {
|
||||||
|
// Crumb expiré — on le renouvelle et on réessaie une fois
|
||||||
|
resp.Body.Close()
|
||||||
|
c.mu.Lock()
|
||||||
|
c.crumb = ""
|
||||||
|
c.mu.Unlock()
|
||||||
|
newCrumb := c.getCrumb()
|
||||||
|
url2 := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics&crumb=%s", summaryURL, symbol, newCrumb)
|
||||||
|
req2, _ := http.NewRequest("GET", url2, nil)
|
||||||
|
req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
resp, err = c.http.Do(req2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("yahoo quoteSummary: HTTP %d for %s", resp.StatusCode, symbol)
|
return nil, fmt.Errorf("yahoo quoteSummary: HTTP %d for %s", resp.StatusCode, symbol)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user