package scanner import ( "log" "sync" "time" "git.rouggy.com/rouggy/stockradar/internal/db" "git.rouggy.com/rouggy/stockradar/internal/indicators" "git.rouggy.com/rouggy/stockradar/internal/yahoo" ) // DiscoveryStatus expose l'avancement du scan en cours. type DiscoveryStatus struct { Running bool `json:"running"` Progress int `json:"progress"` Total int `json:"total"` Found int `json:"found"` // tickers avec score > 0 LastRun time.Time `json:"last_run"` LastError string `json:"last_error,omitempty"` } // DiscoveryScanner parcourt tout l'univers eToro pour trouver des opportunités. type DiscoveryScanner struct { db *db.DB yahoo *yahoo.Client mu sync.Mutex running bool progress int total int found int lastRun time.Time lastError string } func NewDiscovery(database *db.DB) *DiscoveryScanner { return &DiscoveryScanner{ db: database, yahoo: yahoo.New(), } } func (d *DiscoveryScanner) Status() DiscoveryStatus { d.mu.Lock() defer d.mu.Unlock() return DiscoveryStatus{ Running: d.running, Progress: d.progress, Total: d.total, Found: d.found, LastRun: d.lastRun, LastError: d.lastError, } } // Run lance le scan de découverte en arrière-plan. // Retourne false si un scan est déjà en cours. func (d *DiscoveryScanner) Run() bool { d.mu.Lock() if d.running { d.mu.Unlock() return false } d.running = true d.progress = 0 d.found = 0 d.lastError = "" d.mu.Unlock() go d.scan() return true } func (d *DiscoveryScanner) scan() { defer func() { d.mu.Lock() d.running = false d.lastRun = time.Now() d.mu.Unlock() }() tickers, err := d.etoroTickers() if err != nil { d.mu.Lock() d.lastError = err.Error() d.mu.Unlock() return } d.mu.Lock() d.total = len(tickers) d.mu.Unlock() log.Printf("discovery: démarrage scan %d tickers eToro…", len(tickers)) found := 0 for i, sym := range tickers { score, alert, err := d.scanTicker(sym) if err == nil && score > 0 { found++ } _ = alert if (i+1)%50 == 0 { d.mu.Lock() d.progress = i + 1 d.found = found d.mu.Unlock() log.Printf("discovery: %d/%d (opportunités: %d)", i+1, len(tickers), found) } time.Sleep(120 * time.Millisecond) // ~8 req/s sur Yahoo Finance } d.mu.Lock() d.progress = len(tickers) d.found = found d.mu.Unlock() log.Printf("discovery: terminé — %d opportunités sur %d tickers", found, len(tickers)) } func (d *DiscoveryScanner) scanTicker(sym string) (score int, alert string, err error) { bars, err := d.yahoo.History(sym, 60) if err != nil || len(bars) < 20 { return 0, "", err } closes := make([]float64, len(bars)) volumes := make([]int64, len(bars)) for i, b := range bars { closes[i] = b.Close volumes[i] = b.Volume } last := bars[len(bars)-1] prevClose := bars[len(bars)-2].Close changePct := 0.0 if prevClose > 0 { changePct = (last.Close-prevClose)/prevClose*100 } rsi := indicators.RSI(closes, 14) macdRes := indicators.MACD(closes) sma20 := indicators.SMA(closes, 20) sma50 := indicators.SMA(closes, 50) avgVol := indicators.AvgVolume(volumes, 20) // 52 semaines depuis les barres reçues week52High, week52Low := highLow(closes) pctFromHigh := 0.0 if week52High > 0 { pctFromHigh = (last.Close - week52High) / week52High * 100 } // Score simplifié (pas de insider/news pour la découverte — trop lent) score = computeScore(scoreInput{ rsi: rsi, macd: macdRes, volume: last.Volume, avgVolume: avgVol, pctFromHigh: pctFromHigh, }) if score == 0 { return 0, "", nil } alert = detectAlert(rsi, macdRes, last.Volume, avgVol, 0, pctFromHigh) _, err = d.db.Exec(` INSERT INTO signals (ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist, sma20, sma50, volume, avg_volume20, week52_high, week52_low, pct_from_high, score, on_etoro, alert, source, computed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,CURRENT_TIMESTAMP) ON CONFLICT(ticker) DO UPDATE SET price = excluded.price, change_pct = excluded.change_pct, rsi14 = excluded.rsi14, macd = excluded.macd, macd_signal = excluded.macd_signal, macd_hist = excluded.macd_hist, sma20 = excluded.sma20, sma50 = excluded.sma50, volume = excluded.volume, avg_volume20 = excluded.avg_volume20, week52_high = excluded.week52_high, week52_low = excluded.week52_low, pct_from_high = excluded.pct_from_high, score = excluded.score, on_etoro = 1, alert = excluded.alert, source = 'discovery', computed_at = CURRENT_TIMESTAMP `, sym, last.Close, changePct, rsi, macdRes.MACD, macdRes.Signal, macdRes.Histogram, sma20, sma50, last.Volume, avgVol, week52High, week52Low, pctFromHigh, score, alert, "discovery") return score, alert, err } func (d *DiscoveryScanner) etoroTickers() ([]string, error) { rows, err := d.db.Query(`SELECT ticker FROM instruments ORDER BY ticker`) if err != nil { return nil, err } defer rows.Close() var tickers []string for rows.Next() { var t string if err := rows.Scan(&t); err != nil { return nil, err } tickers = append(tickers, t) } return tickers, nil } func highLow(closes []float64) (high, low float64) { if len(closes) == 0 { return 0, 0 } high, low = closes[0], closes[0] for _, v := range closes[1:] { if v > high { high = v } if v < low { low = v } } return }