Files
StockRadar/internal/scanner/scanner.go
T
2026-04-20 21:29:22 +02:00

440 lines
11 KiB
Go

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
}