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