240 lines
5.4 KiB
Go
240 lines
5.4 KiB
Go
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, 0, pctFromHigh, false)
|
|
|
|
_, 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
|
|
}
|