440 lines
11 KiB
Go
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
|
|
}
|