added
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user