This commit is contained in:
2026-04-20 22:51:41 +02:00
parent 89fc0119f3
commit 81eec53978
27 changed files with 1067 additions and 399 deletions
+199 -40
View File
@@ -2,6 +2,8 @@ package scanner
import (
"log"
"strings"
"sync"
"time"
"git.rouggy.com/rouggy/stockradar/internal/db"
@@ -9,6 +11,17 @@ import (
"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"`
@@ -27,18 +40,37 @@ type Signal struct {
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"`
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
ticker *time.Ticker
done chan 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 {
@@ -49,6 +81,66 @@ func New(database *db.DB) *Scanner {
}
}
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() {
@@ -99,6 +191,10 @@ func (s *Scanner) Scan() error {
}
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
@@ -146,9 +242,24 @@ func (s *Scanner) scanTicker(sym string) error {
pctFromHigh = (last.Close - week52High) / week52High * 100 // négatif
}
// Insider buys sur 30 jours — par VALEUR
// Insider buys + sells sur 30 jours
insiderValue30d := s.insiderBuyValue30d(sym)
insiderDays := s.lastInsiderBuyDays(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)
@@ -163,6 +274,8 @@ func (s *Scanner) scanTicker(sym string) error {
shortRatio: shortRatio,
insiderDays: insiderDays,
insiderValue30d: insiderValue30d,
insiderSell30d: insiderSell30d,
ceoChange: ceoChange,
newsDays: s.lastPositiveNewsDays(sym),
price: last.Close,
sma20: sma20,
@@ -170,42 +283,48 @@ func (s *Scanner) scanTicker(sym string) error {
pctFromHigh: pctFromHigh,
})
alert := detectAlert(rsi, macdRes, last.Volume, avgVol, insiderValue30d, 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,
score, on_etoro, alert, computed_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
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,
score = excluded.score,
on_etoro = excluded.on_etoro,
alert = excluded.alert,
computed_at = CURRENT_TIMESTAMP
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,
score, boolToInt(onEtoro), alert)
insiderSell30d, earningsDate, boolToInt(ceoChange),
score, boolToInt(onEtoro), alert, source)
return err
}
@@ -219,13 +338,15 @@ type scoreInput struct {
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)
insiderDays int
insiderValue30d float64
insiderSell30d float64
ceoChange bool
newsDays int
price float64
sma20 float64
sma50 float64
pctFromHigh float64 // % sous le 52w high (négatif)
pctFromHigh float64
}
func computeScore(in scoreInput) int {
@@ -306,6 +427,16 @@ func computeScore(in scoreInput) int {
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
}
@@ -314,11 +445,19 @@ func computeScore(in scoreInput) int {
// ---- Helpers ----
func detectAlert(rsi float64, m indicators.MACDResult, vol, avgVol int64, insiderValue30d, pctFromHigh float64) string {
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"
@@ -364,6 +503,26 @@ func (s *Scanner) insiderBuyValue30d(ticker string) float64 {
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)