This commit is contained in:
2026-04-20 22:51:41 +02:00
parent 89fc0119f3
commit 81eec53978
27 changed files with 1067 additions and 399 deletions
+26 -1
View File
@@ -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
+100 -6
View File
@@ -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
+44 -5
View File
@@ -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
+90 -59
View File
@@ -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
}
+69 -38
View File
@@ -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
}
+31
View File
@@ -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)
+9
View File
@@ -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 {
+1 -1
View File
@@ -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
+199 -40
View File
@@ -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)
+25
View File
@@ -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
+8 -3
View File
@@ -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)
}
+2 -5
View File
@@ -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"}`))
+18 -4
View File
@@ -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,
}
+66 -4
View File
@@ -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)
}