up
This commit is contained in:
+199
-40
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user