This commit is contained in:
2026-04-20 21:29:22 +02:00
parent 53dd49612d
commit 89fc0119f3
25 changed files with 3744 additions and 134 deletions
+239
View File
@@ -0,0 +1,239 @@
package scanner
import (
"log"
"sync"
"time"
"git.rouggy.com/rouggy/stockradar/internal/db"
"git.rouggy.com/rouggy/stockradar/internal/indicators"
"git.rouggy.com/rouggy/stockradar/internal/yahoo"
)
// DiscoveryStatus expose l'avancement du scan en cours.
type DiscoveryStatus struct {
Running bool `json:"running"`
Progress int `json:"progress"`
Total int `json:"total"`
Found int `json:"found"` // tickers avec score > 0
LastRun time.Time `json:"last_run"`
LastError string `json:"last_error,omitempty"`
}
// DiscoveryScanner parcourt tout l'univers eToro pour trouver des opportunités.
type DiscoveryScanner struct {
db *db.DB
yahoo *yahoo.Client
mu sync.Mutex
running bool
progress int
total int
found int
lastRun time.Time
lastError string
}
func NewDiscovery(database *db.DB) *DiscoveryScanner {
return &DiscoveryScanner{
db: database,
yahoo: yahoo.New(),
}
}
func (d *DiscoveryScanner) Status() DiscoveryStatus {
d.mu.Lock()
defer d.mu.Unlock()
return DiscoveryStatus{
Running: d.running,
Progress: d.progress,
Total: d.total,
Found: d.found,
LastRun: d.lastRun,
LastError: d.lastError,
}
}
// Run lance le scan de découverte en arrière-plan.
// Retourne false si un scan est déjà en cours.
func (d *DiscoveryScanner) Run() bool {
d.mu.Lock()
if d.running {
d.mu.Unlock()
return false
}
d.running = true
d.progress = 0
d.found = 0
d.lastError = ""
d.mu.Unlock()
go d.scan()
return true
}
func (d *DiscoveryScanner) scan() {
defer func() {
d.mu.Lock()
d.running = false
d.lastRun = time.Now()
d.mu.Unlock()
}()
tickers, err := d.etoroTickers()
if err != nil {
d.mu.Lock()
d.lastError = err.Error()
d.mu.Unlock()
return
}
d.mu.Lock()
d.total = len(tickers)
d.mu.Unlock()
log.Printf("discovery: démarrage scan %d tickers eToro…", len(tickers))
found := 0
for i, sym := range tickers {
score, alert, err := d.scanTicker(sym)
if err == nil && score > 0 {
found++
}
_ = alert
if (i+1)%50 == 0 {
d.mu.Lock()
d.progress = i + 1
d.found = found
d.mu.Unlock()
log.Printf("discovery: %d/%d (opportunités: %d)", i+1, len(tickers), found)
}
time.Sleep(120 * time.Millisecond) // ~8 req/s sur Yahoo Finance
}
d.mu.Lock()
d.progress = len(tickers)
d.found = found
d.mu.Unlock()
log.Printf("discovery: terminé — %d opportunités sur %d tickers", found, len(tickers))
}
func (d *DiscoveryScanner) scanTicker(sym string) (score int, alert string, err error) {
bars, err := d.yahoo.History(sym, 60)
if err != nil || len(bars) < 20 {
return 0, "", err
}
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)
// 52 semaines depuis les barres reçues
week52High, week52Low := highLow(closes)
pctFromHigh := 0.0
if week52High > 0 {
pctFromHigh = (last.Close - week52High) / week52High * 100
}
// Score simplifié (pas de insider/news pour la découverte — trop lent)
score = computeScore(scoreInput{
rsi: rsi,
macd: macdRes,
volume: last.Volume,
avgVolume: avgVol,
pctFromHigh: pctFromHigh,
})
if score == 0 {
return 0, "", nil
}
alert = detectAlert(rsi, macdRes, last.Volume, avgVol, 0, pctFromHigh)
_, err = d.db.Exec(`
INSERT INTO signals
(ticker, price, change_pct, rsi14, macd, macd_signal, macd_hist,
sma20, sma50, volume, avg_volume20,
week52_high, week52_low, pct_from_high,
score, on_etoro, alert, source, computed_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,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,
week52_high = excluded.week52_high,
week52_low = excluded.week52_low,
pct_from_high = excluded.pct_from_high,
score = excluded.score,
on_etoro = 1,
alert = excluded.alert,
source = 'discovery',
computed_at = CURRENT_TIMESTAMP
`, sym, last.Close, changePct, rsi,
macdRes.MACD, macdRes.Signal, macdRes.Histogram,
sma20, sma50, last.Volume, avgVol,
week52High, week52Low, pctFromHigh,
score, alert, "discovery")
return score, alert, err
}
func (d *DiscoveryScanner) etoroTickers() ([]string, error) {
rows, err := d.db.Query(`SELECT ticker FROM instruments ORDER BY ticker`)
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 highLow(closes []float64) (high, low float64) {
if len(closes) == 0 {
return 0, 0
}
high, low = closes[0], closes[0]
for _, v := range closes[1:] {
if v > high {
high = v
}
if v < low {
low = v
}
}
return
}