diff --git a/.claude/settings.json b/.claude/settings.json index e5ff7f4..45117a9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,10 @@ "permissions": { "allow": [ "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\")" ] } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a2f2d9c..88ede13 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -43,8 +43,8 @@ :global(*, *::before, *::after) { box-sizing: border-box; } :global(body) { margin: 0; - background: #0d1117; - color: #e6edf3; + background: #f6f8fa; + color: #1f2328; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; } diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index 7df786a..dd174d2 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -37,8 +37,8 @@ nav { width: 200px; min-height: 100vh; - background: #0d1117; - border-right: 1px solid #21262d; + background: #24292f; + border-right: 1px solid #32383f; display: flex; flex-direction: column; padding: 1.5rem 0; @@ -50,7 +50,7 @@ align-items: center; gap: 0.5rem; padding: 0 1.25rem 1.5rem; - border-bottom: 1px solid #21262d; + border-bottom: 1px solid #32383f; margin-bottom: 1rem; } @@ -76,14 +76,14 @@ align-items: center; gap: 0.75rem; padding: 0.6rem 1.25rem; - color: #8b949e; + color: #adbac7; text-decoration: none; font-size: 0.9rem; transition: background 0.15s, color 0.15s; } - li a:hover { background: #161b22; color: #e6edf3; } - li.active a { background: #161b22; color: #58a6ff; } + li a:hover { background: #2d333b; color: #e6edf3; } + li.active a { background: #2d333b; color: #58a6ff; } .icon { font-size: 1rem; width: 1.2rem; text-align: center; } diff --git a/frontend/src/components/Notifications.svelte b/frontend/src/components/Notifications.svelte index 56a48fb..c517a68 100644 --- a/frontend/src/components/Notifications.svelte +++ b/frontend/src/components/Notifications.svelte @@ -23,15 +23,15 @@ padding: 0.75rem 1.25rem; border-radius: 6px; font-size: 0.875rem; - color: #e6edf3; - background: #21262d; - border-left: 3px solid #58a6ff; + color: #1f2328; + background: #d0d7de; + border-left: 3px solid #0969da; animation: slide-in 0.2s ease; } - .notif.success { border-color: #3fb950; } - .notif.error { border-color: #f85149; } - .notif.warning { border-color: #d29922; } + .notif.success { border-color: #1a7f37; } + .notif.error { border-color: #cf222e; } + .notif.warning { border-color: #9a6700; } @keyframes slide-in { from { opacity: 0; transform: translateX(1rem); } diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 993eb9c..ace29bf 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -30,6 +30,8 @@ export const api = { getDiscovery: (minScore) => request('GET', `/discover?min_score=${minScore ?? 30}`), runDiscovery: () => request('POST', '/discover/run'), 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}` : ''}`), syncInsider: () => request('POST', '/insider-trades/sync'), } diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 4570a68..7bc2fbf 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -143,7 +143,7 @@ diff --git a/frontend/src/routes/Discover.svelte b/frontend/src/routes/Discover.svelte index a12e74d..eb7e641 100644 --- a/frontend/src/routes/Discover.svelte +++ b/frontend/src/routes/Discover.svelte @@ -7,21 +7,41 @@ let etoroStatus = { syncing: false, count: 0, progress: 0, total: 0, last_error: '' } // Statut discovery let discStatus = { running: false, progress: 0, total: 0, found: 0 } + // Statut analyse en profondeur + let anaStatus = { running: false, progress: 0, total: 0 } // Résultats let results = [] let loading = false let minScore = 30 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 onMount(async () => { await refreshStatus() await loadResults() - // Poll toutes les 3s si un process tourne pollInterval = setInterval(async () => { await refreshStatus() - if (discStatus.running) await loadResults() + if (discStatus.running || anaStatus.running) await loadResults() }, 3000) }) @@ -29,13 +49,31 @@ async function refreshStatus() { try { - [etoroStatus, discStatus] = await Promise.all([ + [etoroStatus, discStatus, anaStatus] = await Promise.all([ api.etoroStatus(), api.discoveryStatus(), + api.analyzeStatus(), ]) } 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() { loading = true try { @@ -66,7 +104,7 @@ if (r.status === 'already_running') { notify('Scan déjà en cours…', 'info') } else { - notify(`Scan lancé sur ${etoroStatus.count.toLocaleString()} tickers eToro — ~${Math.ceil(etoroStatus.count * 0.12 / 60)} min`, 'info') + notify(`Scan lancé sur ${etoroStatus.count.toLocaleString()} tickers eToro — ~${estMinutes} min`, 'info') } await refreshStatus() } catch(e) { @@ -127,6 +165,10 @@ $: etoroPct = etoroStatus.total > 0 ? Math.round(etoroStatus.progress / etoroStatus.total * 100) : 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)
| + + | Score | Ticker | Prix | @@ -236,7 +303,11 @@|||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + toggleOne(r.ticker)} /> + | {r.score} |
{r.ticker}
@@ -275,8 +346,8 @@
diff --git a/frontend/src/routes/News.svelte b/frontend/src/routes/News.svelte
index a039a41..89d7f0c 100644
--- a/frontend/src/routes/News.svelte
+++ b/frontend/src/routes/News.svelte
@@ -209,7 +209,7 @@
.tab {
background: none;
border: none;
- color: #484f58;
+ color: #8c959f;
font-size: 1rem;
font-weight: 600;
padding: 0.4rem 0.75rem;
@@ -217,13 +217,13 @@
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
- .tab:hover { color: #8b949e; background: #161b22; }
- .tab.active { color: #e6edf3; background: #161b22; }
+ .tab:hover { color: #57606a; background: #ffffff; }
+ .tab.active { color: #1f2328; background: #ffffff; }
.badge-count {
display: inline-block;
- background: #21262d;
- color: #8b949e;
+ background: #d0d7de;
+ color: #57606a;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
@@ -231,7 +231,7 @@
margin-left: 0.3rem;
vertical-align: middle;
}
- .badge-count.badge-orange { background: #2d1f00; color: #d29922; }
+ .badge-count.badge-orange { background: #2d1f00; color: #9a6700; }
.toolbar {
display: flex;
@@ -240,23 +240,23 @@
}
input {
- background: #161b22;
- border: 1px solid #30363d;
+ background: #ffffff;
+ border: 1px solid #c6cdd5;
border-radius: 6px;
- color: #e6edf3;
+ color: #1f2328;
padding: 0.45rem 0.75rem;
font-size: 0.875rem;
outline: none;
width: 200px;
}
- input:focus { border-color: #58a6ff; }
- input::placeholder { color: #484f58; }
+ input:focus { border-color: #0969da; }
+ input::placeholder { color: #8c959f; }
.btn-sync {
- background: #21262d;
- border: 1px solid #30363d;
+ background: #d0d7de;
+ border: 1px solid #c6cdd5;
border-radius: 6px;
- color: #58a6ff;
+ color: #0969da;
border-color: #1f6feb;
padding: 0.45rem 0.85rem;
font-size: 0.8rem;
@@ -269,12 +269,12 @@
.news-list { display: flex; flex-direction: column; gap: 0.6rem; }
.news-card {
- background: #161b22;
- border: 1px solid #21262d;
+ background: #ffffff;
+ border: 1px solid #d0d7de;
border-radius: 8px;
padding: 0.85rem 1.1rem;
}
- .news-card:hover { border-color: #30363d; }
+ .news-card:hover { border-color: #c6cdd5; }
.news-meta {
display: flex;
@@ -283,17 +283,17 @@
margin-bottom: 0.35rem;
font-size: 0.75rem;
}
- .ticker { color: #58a6ff; font-weight: 600; }
- .source { color: #8b949e; }
- .time { color: #484f58; margin-left: auto; }
+ .ticker { color: #0969da; font-weight: 600; }
+ .source { color: #57606a; }
+ .time { color: #8c959f; margin-left: auto; }
.sentiment { padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 500; }
- .sentiment.green { background: #0d2c1a; color: #3fb950; }
- .sentiment.red { background: #2c0d0d; color: #f85149; }
+ .sentiment.green { background: #0d2c1a; color: #1a7f37; }
+ .sentiment.red { background: #2c0d0d; color: #cf222e; }
- .news-headline { font-size: 0.875rem; color: #c9d1d9; line-height: 1.4; }
- .news-headline a { color: #c9d1d9; text-decoration: none; }
- .news-headline a:hover { color: #58a6ff; }
+ .news-headline { font-size: 0.875rem; color: #2c3138; line-height: 1.4; }
+ .news-headline a { color: #2c3138; text-decoration: none; }
+ .news-headline a:hover { color: #0969da; }
/* Insider trades */
.table-wrap { overflow-x: auto; }
@@ -302,26 +302,26 @@
th {
text-align: left;
- color: #8b949e;
+ color: #57606a;
font-weight: 500;
padding: 0.45rem 0.75rem;
- border-bottom: 1px solid #21262d;
+ border-bottom: 1px solid #d0d7de;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
th.num { text-align: right; }
- td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #161b22; color: #c9d1d9; }
+ td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #ffffff; color: #2c3138; }
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:hover td { background: #0d2210; }
- td.ticker { font-weight: 600; color: #58a6ff; }
- td.title-col { color: #8b949e; font-size: 0.78rem; }
- td.date { color: #8b949e; }
+ td.ticker { font-weight: 600; color: #0969da; }
+ td.title-col { color: #57606a; font-size: 0.78rem; }
+ td.date { color: #57606a; }
.tx-badge {
display: inline-block;
@@ -329,19 +329,19 @@
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
- background: #21262d;
- color: #8b949e;
+ background: #d0d7de;
+ 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 {
- color: #58a6ff;
+ color: #0969da;
text-decoration: none;
font-size: 0.78rem;
}
.link-sec:hover { text-decoration: underline; }
- .empty, .muted { color: #484f58; font-size: 0.875rem; }
+ .empty, .muted { color: #8c959f; font-size: 0.875rem; }
diff --git a/frontend/src/routes/Screener.svelte b/frontend/src/routes/Screener.svelte
index 6be3c0a..29d24c2 100644
--- a/frontend/src/routes/Screener.svelte
+++ b/frontend/src/routes/Screener.svelte
@@ -19,7 +19,7 @@
onMount(async () => {
load()
try {
- const stats = await api.etoroStats()
+ const stats = await api.etoroStatus()
etoroCount = stats.instruments ?? 0
} catch {}
})
@@ -54,7 +54,7 @@
await api.syncEtoro()
notify('Sync eToro lancé — ~5000 instruments à charger', 'info')
setTimeout(async () => {
- const stats = await api.etoroStats()
+ const stats = await api.etoroStatus()
etoroCount = stats.instruments ?? 0
}, 8000)
} catch(e) {
@@ -87,23 +87,35 @@
function alertLabel(alert) {
const map = {
mega_insider_buy: '🐋 Mega Insider',
+ ceo_change: '👔 New CEO',
deep_value_reversal: '📉→↑ Deep Value',
oversold: '▼ Oversold',
overbought: '▲ Overbought',
macd_cross_up: '↑ MACD bull',
macd_cross_down: '↓ MACD bear',
volume_spike: '⚡ Vol spike',
+ insider_sell: '⚠ Insider sell',
}
return map[alert] || alert
}
function alertClass(alert) {
- if (['mega_insider_buy','oversold','macd_cross_up','deep_value_reversal'].includes(alert)) return 'green'
- if (['overbought','macd_cross_down'].includes(alert)) return 'red'
+ if (['mega_insider_buy','oversold','macd_cross_up','deep_value_reversal','ceo_change'].includes(alert)) return 'green'
+ if (['overbought','macd_cross_down','insider_sell'].includes(alert)) return 'red'
if (alert === 'volume_spike') return 'orange'
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) {
if (!v) return ''
if (v >= 1e6) return '$' + (v/1e6).toFixed(1) + 'M'
@@ -199,6 +211,8 @@
+
+
@@ -229,7 +243,9 @@
| MACD histo | Vol/Avg | 52w% | -Insider 30j | +Insider Buy | +Insider Sell | +Earnings | Mkt Cap | Short | Alerte | @@ -249,7 +265,7 @@{capLabel(s.market_cap)} | +{#if capLabel(s.market_cap)}{capLabel(s.market_cap)}{:else}—{/if} | ${fmt(s.price)} | 0} class:red={s.change_pct < 0}> {s.change_pct > 0 ? '+' : ''}{fmt(s.change_pct)}% @@ -267,6 +283,14 @@ | = 1_000_000}> {fmtInsider(s.insider_value_30d)} | += 1_000_000}> + {fmtInsider(s.insider_sell_value_30d)} + | ++ {#if earningsDaysLabel(s.earnings_date)} + {earningsDaysLabel(s.earnings_date)} + {:else}—{/if} + | {fmtCap(s.market_cap)} | {s.short_ratio > 0 ? fmt(s.short_ratio, 1) + 'd' : '—'} | @@ -286,7 +310,7 @@ diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index f895be8..fd44b50 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -4,9 +4,10 @@ import { notify } from '../lib/store.js' const providers = [ - { key: 'finnhub_api_key', label: 'Finnhub', provider: 'finnhub', placeholder: 'c_xxxxxxxxxxxxxxxx' }, - { key: 'alphavantage_key', label: 'Alpha Vantage', provider: 'alphavantage', placeholder: 'XXXXXXXXXXXX' }, - { key: 'etoro_api_key', label: 'eToro', provider: 'etoro', placeholder: 'eToro API key' }, + { key: 'finnhub_api_key', label: 'Finnhub', provider: 'finnhub', placeholder: 'c_xxxxxxxxxxxxxxxx' }, + { key: 'alphavantage_key', label: 'Alpha Vantage', provider: 'alphavantage', placeholder: 'XXXXXXXXXXXX' }, + { 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 = {} @@ -76,6 +77,7 @@ placeholder={p.placeholder} autocomplete="off" /> + {#if p.provider} + {/if} {/each} @@ -100,47 +103,47 @@ diff --git a/internal/db/db.go b/internal/db/db.go index f4c5c1c..5ced2d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -11,15 +11,23 @@ type DB struct { } 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 { 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 { 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} if err := database.migrate(); err != nil { 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à additive := []string{ `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_signals_score ON signals(score DESC)`, `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 { db.Exec(q) // intentionnellement sans vérification d'erreur diff --git a/internal/edgar/client.go b/internal/edgar/client.go index 9837d59..1bdd924 100644 --- a/internal/edgar/client.go +++ b/internal/edgar/client.go @@ -47,14 +47,25 @@ type tickerEntry struct { type submissionsResponse struct { Filings struct { Recent struct { - Form []string `json:"form"` - AccessionNumber []string `json:"accessionNumber"` - FilingDate []string `json:"filingDate"` - PrimaryDocument []string `json:"primaryDocument"` + Form []string `json:"form"` + AccessionNumber []string `json:"accessionNumber"` + FilingDate []string `json:"filingDate"` + PrimaryDocument []string `json:"primaryDocument"` + Items []string `json:"items"` } `json:"recent"` } `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 { Issuer struct { Symbol string `xml:"issuerTradingSymbol"` @@ -106,7 +117,24 @@ func New() *Client { // RecentInsiderBuys retourne les achats d'initiés (code P) pour un ticker // 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) { + if !isUSListed(ticker) { + return nil, nil // silencieux pour les titres non-US + } + cik, err := c.lookupCIK(ticker) if err != nil { 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 } +// 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 ---- 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 for _, tx := range doc.NonDerivativeTable.Transactions { code := tx.Coding.Code - // On garde achats (P) et attributions significatives (A avec prix > 0) - if code != "P" && !(code == "A" && tx.Amounts.Price.Value > 0) { + // P = achat, S = vente, A = attribution avec prix > 0 + if code != "P" && code != "S" && !(code == "A" && tx.Amounts.Price.Value > 0) { continue } shares := tx.Amounts.Shares.Value diff --git a/internal/edgar/poller.go b/internal/edgar/poller.go index 543eb0a..0331170 100644 --- a/internal/edgar/poller.go +++ b/internal/edgar/poller.go @@ -49,6 +49,33 @@ func (p *Poller) Stop() { 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 { tickers, err := p.watchlistTickers() if err != nil { @@ -65,13 +92,17 @@ func (p *Poller) Sync() error { trades, err := p.client.RecentInsiderBuys(sym) if err != nil { log.Printf("edgar: %s: %v", sym, err) - continue - } - for _, t := range trades { - if p.insertTrade(t) { - total++ + } else { + for _, t := range trades { + if p.insertTrade(t) { + total++ + } } } + events, _ := p.client.Recent8KEvents(sym) + for _, e := range events { + p.insertEvent(e) + } time.Sleep(500 * time.Millisecond) // respecter le rate limit EDGAR } @@ -100,6 +131,14 @@ func (p *Poller) watchlistTickers() ([]string, error) { 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 { res, err := p.db.Exec(` INSERT OR IGNORE INTO insider_trades diff --git a/internal/etoro/client.go b/internal/etoro/client.go index 2c1a3f6..7589c1b 100644 --- a/internal/etoro/client.go +++ b/internal/etoro/client.go @@ -5,31 +5,35 @@ import ( "fmt" "net/http" "time" + + "github.com/google/uuid" ) -const instrumentsURL = "https://api.etoro.com/metadata/instruments" - -// InstrumentTypeID connus sur eToro -const ( - TypeStock = 5 - TypeETF = 10 - TypeCrypto = 12 - TypeIndex = 21 - TypeCFD = 6 -) +const baseURL = "https://public-api.etoro.com/api/v1" type Client struct { - http *http.Client + http *http.Client + apiKey string + userKey string } type Instrument struct { - InstrumentID int `json:"InstrumentID"` - InstrumentDisplayName string `json:"InstrumentDisplayName"` - SymbolFull string `json:"SymbolFull"` - InstrumentTypeID int `json:"InstrumentTypeID"` - IsActive bool `json:"IsActive"` - StockIndustryID int `json:"StockIndustryID"` - StockExchangeID int `json:"StockExchangeID"` + InstrumentID int `json:"internalInstrumentId"` + InstrumentDisplayName string `json:"internalInstrumentDisplayName"` + SymbolFull string `json:"internalSymbolFull"` + AssetClassID int `json:"internalAssetClassId"` + ExchangeID int `json:"internalExchangeId"` + IsHidden bool `json:"isHiddenFromClient"` + 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 { @@ -38,58 +42,85 @@ func New() *Client { } } -// FetchStocks retourne tous les instruments de type Stock actifs sur eToro. -func (c *Client) FetchStocks() ([]Instrument, error) { - all, err := c.fetchAll() +func NewWithKeys(apiKey, userKey string) *Client { + return &Client{ + 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 { return nil, err } - - var stocks []Instrument - for _, inst := range all { - if inst.IsActive && inst.InstrumentTypeID == TypeStock { - stocks = append(stocks, inst) - } - } - return stocks, nil -} - -// FetchAll retourne tous les instruments (stocks + ETFs + crypto + indices). -func (c *Client) FetchAll() ([]Instrument, error) { - return c.fetchAll() -} - -func (c *Client) fetchAll() ([]Instrument, error) { - req, err := http.NewRequest("GET", instrumentsURL, nil) - if err != nil { - return nil, err - } - - // Headers qui imitent le client web eToro - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - req.Header.Set("accounttype", "Demo") - req.Header.Set("ApplicationIdentifier", "ReToro") - req.Header.Set("Version", "1.211.0") + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("x-user-key", c.userKey) + req.Header.Set("x-request-id", uuid.NewString()) 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 { return nil, fmt.Errorf("etoro: %w", err) } defer resp.Body.Close() - if resp.StatusCode != 200 { return nil, fmt.Errorf("etoro: HTTP %d", resp.StatusCode) } - - var instruments []Instrument - if err := json.NewDecoder(resp.Body).Decode(&instruments); err != nil { - return nil, fmt.Errorf("etoro: parse error: %w", err) + var sr searchResponse + if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { + return nil, fmt.Errorf("etoro: parse: %w", err) } - - if len(instruments) == 0 { - return nil, fmt.Errorf("etoro: empty response — l'API a peut-être changé") - } - - return instruments, nil + return &sr, nil +} + +// FetchStocks retourne tous les stocks actifs disponibles sur eToro (toutes pages). +func (c *Client) FetchStocks() ([]Instrument, error) { + 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 } diff --git a/internal/etoro/poller.go b/internal/etoro/poller.go index 583c26e..6c06607 100644 --- a/internal/etoro/poller.go +++ b/internal/etoro/poller.go @@ -1,6 +1,7 @@ package etoro import ( + "fmt" "log" "sync" "time" @@ -18,10 +19,11 @@ type SyncStatus struct { } type Poller struct { - db *db.DB - client *Client - ticker *time.Ticker - done chan struct{} + db *db.DB + client *Client + getKeys func() (apiKey, userKey string, err error) + ticker *time.Ticker + done chan struct{} mu sync.Mutex syncing bool @@ -31,19 +33,25 @@ type Poller struct { lastError string } -func NewPoller(database *db.DB) *Poller { +func NewPoller(database *db.DB, getKeys func() (string, string, error)) *Poller { return &Poller{ - db: database, - client: New(), - done: make(chan struct{}), + db: database, + client: New(), + getKeys: getKeys, + done: make(chan struct{}), } } func (p *Poller) Start() { p.ticker = time.NewTicker(24 * time.Hour) go func() { - if err := p.Sync(); err != nil { - log.Printf("etoro poller: initial sync: %v", err) + // Sync uniquement si la DB est vide + 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 { select { @@ -97,47 +105,70 @@ func (p *Poller) Sync() error { p.mu.Unlock() }() - log.Println("etoro: fetching instruments…") - stocks, err := p.client.FetchStocks() - if err != nil { + apiKey, userKey, err := p.getKeys() + if err != nil || apiKey == "" || userKey == "" { p.mu.Lock() - p.lastError = err.Error() + p.lastError = "clés API eToro non configurées (Settings)" p.mu.Unlock() - log.Printf("etoro: fetch error: %v", err) - return err + return fmt.Errorf("etoro: clés manquantes") } + p.client.SetKeys(apiKey, userKey) - p.mu.Lock() - p.total = len(stocks) - p.mu.Unlock() - - log.Printf("etoro: %d stocks à synchroniser", len(stocks)) + log.Println("etoro: fetching instruments…") + const pageSize = 500 inserted := 0 - for i, s := range stocks { - _, err := p.db.Exec(` - INSERT INTO instruments (instrument_id, ticker, name, exchange_id, asset_class_id, synced_at) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(instrument_id) DO UPDATE SET - ticker = excluded.ticker, - name = excluded.name, - exchange_id = excluded.exchange_id, - synced_at = CURRENT_TIMESTAMP - `, s.InstrumentID, s.SymbolFull, s.InstrumentDisplayName, - s.StockExchangeID, s.InstrumentTypeID) - if err == nil { - inserted++ + fetched := 0 + + for page := 1; ; page++ { + sr, err := p.client.fetchPage(5, pageSize, page) + if err != nil { + p.mu.Lock() + p.lastError = err.Error() + p.mu.Unlock() + log.Printf("etoro: fetch error page %d: %v", page, err) + return err } - 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.progress = i + 1 + p.total = sr.TotalItems 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 } diff --git a/internal/finnhub/client.go b/internal/finnhub/client.go index 04c5f50..b020d77 100644 --- a/internal/finnhub/client.go +++ b/internal/finnhub/client.go @@ -43,6 +43,37 @@ func (c *Client) MarketNews() ([]NewsItem, error) { 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 { url := fmt.Sprintf("%s/news?category=general&minId=999999999&token=%s", baseURL, c.apiKey) resp, err := c.http.Get(url) diff --git a/internal/finnhub/poller.go b/internal/finnhub/poller.go index 77933f8..e5772c9 100644 --- a/internal/finnhub/poller.go +++ b/internal/finnhub/poller.go @@ -99,6 +99,15 @@ func (p *Poller) Sync() error { 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) { rows, err := p.db.Query(`SELECT ticker FROM watchlist WHERE active=1`) if err != nil { diff --git a/internal/scanner/discovery.go b/internal/scanner/discovery.go index b4aa42c..e9fbb82 100644 --- a/internal/scanner/discovery.go +++ b/internal/scanner/discovery.go @@ -167,7 +167,7 @@ func (d *DiscoveryScanner) scanTicker(sym string) (score int, alert string, err 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(` INSERT INTO signals diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 69307d6..5d5005e 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -2,6 +2,8 @@ package scanner import ( "log" + "strings" + "sync" "time" "git.rouggy.com/rouggy/stockradar/internal/db" @@ -9,6 +11,17 @@ import ( "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 { Ticker string `json:"ticker"` Name string `json:"name"` @@ -27,18 +40,37 @@ type Signal struct { Week52High float64 `json:"week52_high"` Week52Low float64 `json:"week52_low"` PctFromHigh float64 `json:"pct_from_high"` // négatif = % sous le 52w high - InsiderValue30d float64 `json:"insider_value_30d"` // $ total d'achats insider sur 30j - Score int `json:"score"` - OnEtoro bool `json:"on_etoro"` - Alert string `json:"alert"` - ComputedAt string `json:"computed_at"` + InsiderValue30d float64 `json:"insider_value_30d"` + InsiderSell30d float64 `json:"insider_sell_value_30d"` + EarningsDate string `json:"earnings_date"` + CEOChange bool `json:"ceo_change"` + 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 { - db *db.DB - yahoo *yahoo.Client - ticker *time.Ticker - done chan struct{} + db *db.DB + yahoo *yahoo.Client + edgar EdgarSyncer + 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 { @@ -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() { s.ticker = time.NewTicker(30 * time.Minute) go func() { @@ -99,6 +191,10 @@ func (s *Scanner) Scan() 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) if err != nil { return err @@ -146,9 +242,24 @@ func (s *Scanner) scanTicker(sym string) error { 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) - 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 onEtoro := s.isOnEtoro(sym) @@ -163,6 +274,8 @@ func (s *Scanner) scanTicker(sym string) error { shortRatio: shortRatio, insiderDays: insiderDays, insiderValue30d: insiderValue30d, + insiderSell30d: insiderSell30d, + ceoChange: ceoChange, newsDays: s.lastPositiveNewsDays(sym), price: last.Close, sma20: sma20, @@ -170,42 +283,48 @@ func (s *Scanner) scanTicker(sym string) error { 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(` INSERT INTO signals (ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist, sma20, sma50, volume, avg_volume20, market_cap, short_ratio, week52_high, week52_low, pct_from_high, insider_value_30d, - score, on_etoro, alert, computed_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP) + insider_sell_value_30d, earnings_date, ceo_change, + score, on_etoro, alert, source, computed_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP) ON CONFLICT(ticker) DO UPDATE SET - price = excluded.price, - change_pct = excluded.change_pct, - rsi14 = excluded.rsi14, - macd = excluded.macd, - macd_signal = excluded.macd_signal, - macd_hist = excluded.macd_hist, - sma20 = excluded.sma20, - sma50 = excluded.sma50, - volume = excluded.volume, - avg_volume20 = excluded.avg_volume20, - market_cap = excluded.market_cap, - short_ratio = excluded.short_ratio, - week52_high = excluded.week52_high, - week52_low = excluded.week52_low, - pct_from_high = excluded.pct_from_high, - insider_value_30d = excluded.insider_value_30d, - score = excluded.score, - on_etoro = excluded.on_etoro, - alert = excluded.alert, - computed_at = CURRENT_TIMESTAMP + price = excluded.price, + change_pct = excluded.change_pct, + rsi14 = excluded.rsi14, + macd = excluded.macd, + macd_signal = excluded.macd_signal, + macd_hist = excluded.macd_hist, + sma20 = excluded.sma20, + sma50 = excluded.sma50, + volume = excluded.volume, + avg_volume20 = excluded.avg_volume20, + market_cap = excluded.market_cap, + short_ratio = excluded.short_ratio, + week52_high = excluded.week52_high, + week52_low = excluded.week52_low, + pct_from_high = excluded.pct_from_high, + insider_value_30d = excluded.insider_value_30d, + insider_sell_value_30d = excluded.insider_sell_value_30d, + earnings_date = excluded.earnings_date, + ceo_change = excluded.ceo_change, + score = excluded.score, + on_etoro = excluded.on_etoro, + alert = excluded.alert, + source = excluded.source, + computed_at = CURRENT_TIMESTAMP `, sym, last.Close, changePct, rsi, macdRes.MACD, macdRes.Signal, macdRes.Histogram, sma20, sma50, last.Volume, avgVol, marketCap, shortRatio, week52High, week52Low, pctFromHigh, insiderValue30d, - score, boolToInt(onEtoro), alert) + insiderSell30d, earningsDate, boolToInt(ceoChange), + score, boolToInt(onEtoro), alert, source) return err } @@ -219,13 +338,15 @@ type scoreInput struct { avgVolume int64 marketCap int64 shortRatio float64 - insiderDays int // jours depuis dernier insider buy (-1 = aucun) - insiderValue30d float64 // $ total d'achats insider sur 30j - newsDays int // jours depuis dernière news positive (-1 = aucune) + insiderDays int + insiderValue30d float64 + insiderSell30d float64 + ceoChange bool + newsDays int price float64 sma20 float64 sma50 float64 - pctFromHigh float64 // % sous le 52w high (négatif) + pctFromHigh float64 } func computeScore(in scoreInput) int { @@ -306,6 +427,16 @@ func computeScore(in scoreInput) int { 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 { score = 100 } @@ -314,11 +445,19 @@ func computeScore(in scoreInput) int { // ---- 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) if insiderValue30d >= 1_000_000 { 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 if rsi > 0 && rsi < 30 { return "oversold" @@ -364,6 +503,26 @@ func (s *Scanner) insiderBuyValue30d(ticker string) float64 { 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 { var count int s.db.QueryRow(`SELECT COUNT(*) FROM instruments WHERE ticker = ?`, ticker).Scan(&count) diff --git a/internal/server/handlers_discovery.go b/internal/server/handlers_discovery.go index 6d88022..ef09999 100644 --- a/internal/server/handlers_discovery.go +++ b/internal/server/handlers_discovery.go @@ -118,6 +118,31 @@ func (s *Server) handleEtoroStats(w http.ResponseWriter, r *http.Request) { 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 // un champ source à la query existante diff --git a/internal/server/handlers_scanner.go b/internal/server/handlers_scanner.go index 17a50fe..9ffc211 100644 --- a/internal/server/handlers_scanner.go +++ b/internal/server/handlers_scanner.go @@ -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.week52_high, 0), COALESCE(sig.week52_low, 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.alert,''), sig.computed_at FROM signals sig LEFT JOIN instruments inst ON inst.ticker = sig.ticker` + query += ` WHERE sig.source = 'watchlist'` 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` @@ -39,7 +42,7 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) { signals := []scanner.Signal{} for rows.Next() { var sig scanner.Signal - var onEtoro int + var onEtoro, ceoChange int if err := rows.Scan( &sig.Ticker, &sig.Name, &sig.Price, &sig.ChangePct, @@ -48,6 +51,8 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) { &sig.MarketCap, &sig.ShortRatio, &sig.Week52High, &sig.Week52Low, &sig.PctFromHigh, &sig.InsiderValue30d, + &sig.InsiderSell30d, &sig.EarningsDate, + &ceoChange, &sig.Score, &onEtoro, &sig.Alert, &sig.ComputedAt, ); err != nil { @@ -55,6 +60,7 @@ func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) { return } sig.OnEtoro = onEtoro == 1 + sig.CEOChange = ceoChange == 1 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") json.NewEncoder(w).Encode(bars) } - diff --git a/internal/server/handlers_watchlist.go b/internal/server/handlers_watchlist.go index 35f924b..e43fcd0 100644 --- a/internal/server/handlers_watchlist.go +++ b/internal/server/handlers_watchlist.go @@ -60,11 +60,8 @@ func (s *Server) handleAddWatchlist(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRemoveWatchlist(w http.ResponseWriter, r *http.Request) { ticker := mux.Vars(r)["ticker"] - _, err := s.db.Exec(`DELETE FROM watchlist WHERE ticker = ?`, ticker) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + s.db.Exec(`DELETE FROM watchlist WHERE ticker = ?`, ticker) + s.db.Exec(`DELETE FROM signals WHERE ticker = ? AND source = 'watchlist'`, ticker) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"removed"}`)) diff --git a/internal/server/server.go b/internal/server/server.go index 0b60b0d..60b82d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -46,13 +46,24 @@ func New(database *db.DB, port string) (*Server, error) { s.scanner = scanner.New(database) 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.edgarPoller = edgar.NewPoller(database) - s.edgarPoller.Start() - - s.etoroPoller = etoro.NewPoller(database) + s.etoroPoller = etoro.NewPoller(database, func() (string, string, error) { + apiKey, err := svc.Get("etoro_api_key") + if err != nil { + return "", "", err + } + userKey, err := svc.Get("etoro_user_key") + if err != nil { + return "", "", err + } + return apiKey, userKey, nil + }) s.etoroPoller.Start() s.setupRoutes() @@ -96,6 +107,8 @@ func (s *Server) setupRoutes() { api.HandleFunc("/discover", s.handleGetDiscovery).Methods("GET", "OPTIONS") api.HandleFunc("/discover/run", s.handleRunDiscovery).Methods("POST", "OPTIONS") api.HandleFunc("/discover/status", s.handleDiscoveryStatus).Methods("GET", "OPTIONS") + 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) { 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 encryptedKeys := map[string]bool{ "etoro_api_key": true, + "etoro_user_key": true, "finnhub_api_key": true, "alphavantage_key": true, } diff --git a/internal/yahoo/client.go b/internal/yahoo/client.go index 106f0f3..0300523 100644 --- a/internal/yahoo/client.go +++ b/internal/yahoo/client.go @@ -3,7 +3,11 @@ package yahoo import ( "encoding/json" "fmt" + "io" "net/http" + "net/http/cookiejar" + "strings" + "sync" "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" type Client struct { - http *http.Client + http *http.Client + mu sync.Mutex + crumb string } type Bar struct { @@ -57,11 +63,51 @@ type chartResponse struct { } func New() *Client { + jar, _ := cookiejar.New(nil) 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) { rangeStr := "3mo" if days > 90 { @@ -193,13 +239,14 @@ type quoteSummaryResponse struct { // GetMarketCap retourne les données fondamentales d'un ticker. func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) { - url := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics", summaryURL, symbol) + crumb := c.getCrumb() + url := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics&crumb=%s", summaryURL, symbol, crumb) req, err := http.NewRequest("GET", url, nil) if err != nil { 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) if err != nil { @@ -207,6 +254,21 @@ func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) { } 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 { return nil, fmt.Errorf("yahoo quoteSummary: HTTP %d for %s", resp.StatusCode, symbol) } diff --git a/stockradar.db-shm b/stockradar.db-shm new file mode 100644 index 0000000..1e08e04 Binary files /dev/null and b/stockradar.db-shm differ diff --git a/stockradar.db-wal b/stockradar.db-wal new file mode 100644 index 0000000..52e8db2 Binary files /dev/null and b/stockradar.db-wal differ |