package scanner import ( "log" "time" "git.rouggy.com/rouggy/stockradar/internal/db" "git.rouggy.com/rouggy/stockradar/internal/indicators" "git.rouggy.com/rouggy/stockradar/internal/yahoo" ) 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"` // $ total d'achats insider sur 30j Score int `json:"score"` OnEtoro bool `json:"on_etoro"` Alert string `json:"alert"` ComputedAt string `json:"computed_at"` } type Scanner struct { db *db.DB yahoo *yahoo.Client ticker *time.Ticker done chan struct{} } func New(database *db.DB) *Scanner { return &Scanner{ db: database, yahoo: yahoo.New(), done: make(chan struct{}), } } 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 { 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 sur 30 jours — par VALEUR insiderValue30d := s.insiderBuyValue30d(sym) insiderDays := s.lastInsiderBuyDays(sym) // 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, newsDays: s.lastPositiveNewsDays(sym), price: last.Close, sma20: sma20, sma50: sma50, pctFromHigh: pctFromHigh, }) alert := detectAlert(rsi, macdRes, last.Volume, avgVol, insiderValue30d, pctFromHigh) _, 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) 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 `, 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) return err } // ---- Scoring ---- type scoreInput struct { rsi float64 macd indicators.MACDResult volume int64 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) price float64 sma20 float64 sma50 float64 pctFromHigh float64 // % sous le 52w high (négatif) } 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 } if score > 100 { score = 100 } return score } // ---- Helpers ---- func detectAlert(rsi float64, m indicators.MACDResult, vol, avgVol int64, insiderValue30d, pctFromHigh float64) string { // Priorité 1 : mega insider buy (signal le plus fort) if insiderValue30d >= 1_000_000 { return "mega_insider_buy" } // 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) 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 }