package scanner import ( "log" "strings" "sync" "time" "git.rouggy.com/rouggy/stockradar/internal/db" "git.rouggy.com/rouggy/stockradar/internal/indicators" "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"` Price float64 `json:"price"` ChangePct float64 `json:"change_pct"` RSI14 float64 `json:"rsi14"` MACD float64 `json:"macd"` MACDSignal float64 `json:"macd_signal"` MACDHist float64 `json:"macd_hist"` SMA20 float64 `json:"sma20"` SMA50 float64 `json:"sma50"` Volume int64 `json:"volume"` AvgVolume20 int64 `json:"avg_volume20"` MarketCap int64 `json:"market_cap"` ShortRatio float64 `json:"short_ratio"` Week52High float64 `json:"week52_high"` Week52Low float64 `json:"week52_low"` PctFromHigh float64 `json:"pct_from_high"` // négatif = % sous le 52w high InsiderValue30d float64 `json:"insider_value_30d"` 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 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 { return &Scanner{ db: database, yahoo: yahoo.New(), done: make(chan struct{}), } } 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() { if err := s.Scan(); err != nil { log.Printf("scanner: initial scan: %v", err) } for { select { case <-s.ticker.C: if err := s.Scan(); err != nil { log.Printf("scanner: scan: %v", err) } case <-s.done: return } } }() } func (s *Scanner) Stop() { if s.ticker != nil { s.ticker.Stop() } close(s.done) } func (s *Scanner) Scan() error { tickers, err := s.watchlistTickers() if err != nil { return err } if len(tickers) == 0 { return nil } log.Printf("scanner: scanning %d tickers…", len(tickers)) ok := 0 for _, sym := range tickers { if err := s.scanTicker(sym); err != nil { log.Printf("scanner: %s: %v", sym, err) continue } ok++ time.Sleep(400 * time.Millisecond) // rate limit Yahoo } log.Printf("scanner: done — %d/%d ok", ok, len(tickers)) return nil } func (s *Scanner) scanTicker(sym string) error { 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 } if len(bars) < 30 { return nil } s.storePrices(sym, bars) closes := make([]float64, len(bars)) volumes := make([]int64, len(bars)) for i, b := range bars { closes[i] = b.Close volumes[i] = b.Volume } last := bars[len(bars)-1] prevClose := bars[len(bars)-2].Close changePct := 0.0 if prevClose > 0 { changePct = (last.Close - prevClose) / prevClose * 100 } rsi := indicators.RSI(closes, 14) macdRes := indicators.MACD(closes) sma20 := indicators.SMA(closes, 20) sma50 := indicators.SMA(closes, 50) avgVol := indicators.AvgVolume(volumes, 20) // Market cap (on tolère l'erreur — pas bloquant) var marketCap int64 var shortRatio float64 if info, err := s.yahoo.GetMarketCap(sym); err == nil { marketCap = info.MarketCap shortRatio = info.ShortRatio } time.Sleep(150 * time.Millisecond) // 52 semaines depuis les prix stockés week52High, week52Low := s.week52Range(sym) pctFromHigh := 0.0 if week52High > 0 { pctFromHigh = (last.Close - week52High) / week52High * 100 // négatif } // Insider buys + sells sur 30 jours insiderValue30d := s.insiderBuyValue30d(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) // Score composite score := computeScore(scoreInput{ rsi: rsi, macd: macdRes, volume: last.Volume, avgVolume: avgVol, marketCap: marketCap, shortRatio: shortRatio, insiderDays: insiderDays, insiderValue30d: insiderValue30d, insiderSell30d: insiderSell30d, ceoChange: ceoChange, newsDays: s.lastPositiveNewsDays(sym), price: last.Close, sma20: sma20, sma50: sma50, pctFromHigh: 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, 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, 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, insiderSell30d, earningsDate, boolToInt(ceoChange), score, boolToInt(onEtoro), alert, source) return err } // ---- Scoring ---- type scoreInput struct { rsi float64 macd indicators.MACDResult volume int64 avgVolume int64 marketCap int64 shortRatio float64 insiderDays int insiderValue30d float64 insiderSell30d float64 ceoChange bool newsDays int price float64 sma20 float64 sma50 float64 pctFromHigh float64 } func computeScore(in scoreInput) int { score := 0 // RSI oversold recovery (0-20 pts) if in.rsi > 0 { if in.rsi >= 25 && in.rsi < 30 { score += 20 // profond oversold } else if in.rsi >= 30 && in.rsi < 40 { score += 15 // sortie d'oversold récente } else if in.rsi >= 40 && in.rsi < 50 { score += 8 // momentum neutre haussier } } // MACD signal (0-15 pts) if in.macd.Histogram > 0 { if in.macd.MACD < 0 { score += 15 // cross haussier early signal (le meilleur) } else { score += 8 // momentum haussier confirmé } } // Volume spike (0-15 pts) if in.avgVolume > 0 { ratio := float64(in.volume) / float64(in.avgVolume) if ratio >= 3.0 { score += 15 } else if ratio >= 2.0 { score += 10 } else if ratio >= 1.5 { score += 5 } } // Insider buy — pondéré par VALEUR (0-30 pts) ← le signal le plus fort if in.insiderValue30d > 0 { switch { case in.insiderValue30d >= 100_000_000: // ≥ $100M → signal exceptionnel (TTD CEO) score += 30 case in.insiderValue30d >= 10_000_000: // ≥ $10M score += 22 case in.insiderValue30d >= 1_000_000: // ≥ $1M score += 15 case in.insiderValue30d >= 100_000: // ≥ $100K score += 8 default: score += 3 } // Bonus recency : si achat < 7 jours if in.insiderDays >= 0 && in.insiderDays <= 7 { score += 5 } } // News positive récente (0-10 pts) if in.newsDays >= 0 { if in.newsDays <= 3 { score += 10 } else if in.newsDays <= 7 { score += 5 } } // Position sur 52 semaines (0-10 pts) — titre très déprimé = potentiel rebond if in.pctFromHigh < -40 { score += 10 // comme TTD à -54% } else if in.pctFromHigh < -25 { score += 6 } else if in.pctFromHigh < -15 { score += 3 } // Small cap bonus (+5) — bouge plus fort if in.marketCap > 0 && in.marketCap < 2_000_000_000 { score += 5 } // 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 } return score } // ---- Helpers ---- 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" } // Priorité 3 : MACD cross haussier if m.Histogram > 0 && m.MACD < 0 { return "macd_cross_up" } // Priorité 4 : volume spike if avgVol > 0 && float64(vol)/float64(avgVol) >= 3.0 { return "volume_spike" } // Priorité 5 : rebond depuis creux 52 semaines + RSI en remontée if pctFromHigh < -40 && rsi > 30 && rsi < 50 { return "deep_value_reversal" } if rsi > 70 { return "overbought" } if m.Histogram < 0 && m.MACD > 0 { return "macd_cross_down" } return "" } func (s *Scanner) week52Range(ticker string) (high, low float64) { cutoff := time.Now().AddDate(-1, 0, 0).Format("2006-01-02") row := s.db.QueryRow(` SELECT MAX(high), MIN(low) FROM prices WHERE ticker = ? AND date >= ? `, ticker, cutoff) row.Scan(&high, &low) return } func (s *Scanner) insiderBuyValue30d(ticker string) float64 { cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02") var total float64 s.db.QueryRow(` SELECT COALESCE(SUM(total_value), 0) FROM insider_trades WHERE ticker = ? AND transaction_code = 'P' AND transaction_date >= ? `, ticker, cutoff).Scan(&total) return total } func (s *Scanner) 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) return count > 0 } func (s *Scanner) lastInsiderBuyDays(ticker string) int { var dateStr string err := s.db.QueryRow(` SELECT transaction_date FROM insider_trades WHERE ticker = ? AND transaction_code = 'P' ORDER BY transaction_date DESC LIMIT 1 `, ticker).Scan(&dateStr) if err != nil || dateStr == "" { return -1 } t, err := time.Parse("2006-01-02", dateStr) if err != nil { return -1 } return int(time.Since(t).Hours() / 24) } func (s *Scanner) lastPositiveNewsDays(ticker string) int { var dateStr string err := s.db.QueryRow(` SELECT published_at FROM news WHERE ticker = ? AND sentiment = 'positive' ORDER BY published_at DESC LIMIT 1 `, ticker).Scan(&dateStr) if err != nil || dateStr == "" { return -1 } t, err := time.Parse(time.RFC3339, dateStr) if err != nil { return -1 } return int(time.Since(t).Hours() / 24) } func (s *Scanner) storePrices(ticker string, bars []yahoo.Bar) { for _, b := range bars { s.db.Exec(` INSERT OR IGNORE INTO prices (ticker, date, open, high, low, close, volume) VALUES (?, ?, ?, ?, ?, ?, ?) `, ticker, b.Date.Format("2006-01-02"), b.Open, b.High, b.Low, b.Close, b.Volume) } } func (s *Scanner) watchlistTickers() ([]string, error) { rows, err := s.db.Query(`SELECT ticker FROM watchlist WHERE active=1`) if err != nil { return nil, err } defer rows.Close() var tickers []string for rows.Next() { var t string if err := rows.Scan(&t); err != nil { return nil, err } tickers = append(tickers, t) } return tickers, nil } func boolToInt(b bool) int { if b { return 1 } return 0 }