395 lines
9.8 KiB
Go
395 lines
9.8 KiB
Go
package edgar
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
baseURL = "https://data.sec.gov"
|
|
archiveURL = "https://www.sec.gov/Archives/edgar/data"
|
|
userAgent = "StockRadar legreg002@hotmail.com"
|
|
)
|
|
|
|
type Client struct {
|
|
http *http.Client
|
|
cikMap map[string]string // ticker → CIK (zero-padded 10 digits)
|
|
cikOnce sync.Once
|
|
}
|
|
|
|
// InsiderTrade représente une transaction Form 4 parsée.
|
|
type InsiderTrade struct {
|
|
Ticker string
|
|
InsiderName string
|
|
InsiderTitle string
|
|
TransactionCode string // P=purchase, S=sale, A=award, etc.
|
|
Shares float64
|
|
PricePerShare float64
|
|
TotalValue float64
|
|
TransactionDate string
|
|
AccessionNo string
|
|
FilingURL string
|
|
}
|
|
|
|
// ---- types pour le parsing JSON/XML ----
|
|
|
|
type tickerEntry struct {
|
|
CIK int `json:"cik_str"`
|
|
Ticker string `json:"ticker"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
type submissionsResponse struct {
|
|
Filings struct {
|
|
Recent struct {
|
|
Form []string `json:"form"`
|
|
AccessionNumber []string `json:"accessionNumber"`
|
|
FilingDate []string `json:"filingDate"`
|
|
PrimaryDocument []string `json:"primaryDocument"`
|
|
Items []string `json:"items"`
|
|
} `json:"recent"`
|
|
} `json:"filings"`
|
|
}
|
|
|
|
// CompanyEvent représente un événement 8-K significatif (ex: changement de direction).
|
|
type CompanyEvent struct {
|
|
Ticker string
|
|
EventType string // "ceo_change"
|
|
Title string
|
|
AccessionNo string
|
|
FilingDate string
|
|
FilingURL string
|
|
}
|
|
|
|
type form4Doc struct {
|
|
Issuer struct {
|
|
Symbol string `xml:"issuerTradingSymbol"`
|
|
} `xml:"issuer"`
|
|
ReportingOwner struct {
|
|
ID struct {
|
|
Name string `xml:"rptOwnerName"`
|
|
} `xml:"reportingOwnerId"`
|
|
Relationship struct {
|
|
IsDirector int `xml:"isDirector"`
|
|
IsOfficer int `xml:"isOfficer"`
|
|
Title string `xml:"officerTitle"`
|
|
} `xml:"reportingOwnerRelationship"`
|
|
} `xml:"reportingOwner"`
|
|
NonDerivativeTable struct {
|
|
Transactions []nonDerivativeTx `xml:"nonDerivativeTransaction"`
|
|
} `xml:"nonDerivativeTable"`
|
|
}
|
|
|
|
type nonDerivativeTx struct {
|
|
Date struct {
|
|
Value string `xml:"value"`
|
|
} `xml:"transactionDate"`
|
|
Coding struct {
|
|
Code string `xml:"transactionCode"`
|
|
} `xml:"transactionCoding"`
|
|
Amounts struct {
|
|
Shares struct {
|
|
Value float64 `xml:"value"`
|
|
} `xml:"transactionShares"`
|
|
Price struct {
|
|
Value float64 `xml:"value"`
|
|
} `xml:"transactionPricePerShare"`
|
|
AcqDisp struct {
|
|
Value string `xml:"value"`
|
|
} `xml:"transactionAcquiredDisposedCode"`
|
|
} `xml:"transactionAmounts"`
|
|
}
|
|
|
|
// ---- constructeur ----
|
|
|
|
func New() *Client {
|
|
return &Client{
|
|
http: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
// ---- API publique ----
|
|
|
|
// RecentInsiderBuys retourne les achats d'initiés (code P) pour un ticker
|
|
// sur les 30 derniers jours.
|
|
// isUSListed retourne false pour les tickers européens avec suffixe de bourse (.L, .PA, .DE…)
|
|
func isUSListed(ticker string) bool {
|
|
if idx := strings.LastIndex(ticker, "."); idx > 0 {
|
|
suffix := ticker[idx+1:]
|
|
// Suffixes US valides : A, B (BRK.A, BRK.B) → longueur 1 mais lettre unique
|
|
// Suffixes européens : L, PA, DE, AS, BR, HE, OL, ST, CO, MC, MI, VI…
|
|
if len(suffix) >= 2 {
|
|
return false // .PA, .DE, .AS etc. → non-US
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
|
|
if !isUSListed(ticker) {
|
|
return nil, nil // silencieux pour les titres non-US
|
|
}
|
|
|
|
cik, err := c.lookupCIK(ticker)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CIK not found for %s: %w", ticker, err)
|
|
}
|
|
|
|
accessions, docs, dates, err := c.recentForm4Filings(cik, 30)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
|
|
var trades []InsiderTrade
|
|
|
|
for i, acc := range accessions {
|
|
if i >= len(dates) || dates[i] < cutoff {
|
|
continue
|
|
}
|
|
primaryDoc := ""
|
|
if i < len(docs) {
|
|
primaryDoc = docs[i]
|
|
}
|
|
|
|
form4Trades, err := c.parseForm4(cik, acc, primaryDoc, ticker)
|
|
if err != nil {
|
|
continue // on skip les erreurs de parsing individuelles
|
|
}
|
|
trades = append(trades, form4Trades...)
|
|
time.Sleep(120 * time.Millisecond) // EDGAR rate limit
|
|
}
|
|
|
|
return trades, nil
|
|
}
|
|
|
|
// Recent8KEvents retourne les 8-K Item 5.02 (changements de direction) des 30 derniers jours.
|
|
func (c *Client) Recent8KEvents(ticker string) ([]CompanyEvent, error) {
|
|
if !isUSListed(ticker) {
|
|
return nil, nil
|
|
}
|
|
cik, err := c.lookupCIK(ticker)
|
|
if err != nil {
|
|
return nil, nil // ticker inconnu → silencieux
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/submissions/CIK%s.json", baseURL, cik)
|
|
resp, err := c.get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var sub submissionsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
|
|
forms := sub.Filings.Recent.Form
|
|
accs := sub.Filings.Recent.AccessionNumber
|
|
dates := sub.Filings.Recent.FilingDate
|
|
items := sub.Filings.Recent.Items
|
|
|
|
var events []CompanyEvent
|
|
for i, form := range forms {
|
|
if form != "8-K" && form != "8-K/A" {
|
|
continue
|
|
}
|
|
date := ""
|
|
if i < len(dates) {
|
|
date = dates[i]
|
|
}
|
|
if date != "" && date < cutoff {
|
|
break // filings triés du plus récent au plus ancien
|
|
}
|
|
itemStr := ""
|
|
if i < len(items) {
|
|
itemStr = items[i]
|
|
}
|
|
if !strings.Contains(itemStr, "5.02") {
|
|
continue
|
|
}
|
|
acc := ""
|
|
if i < len(accs) {
|
|
acc = accs[i]
|
|
}
|
|
accNoDashes := strings.ReplaceAll(acc, "-", "")
|
|
filingURL := fmt.Sprintf("https://www.sec.gov/Archives/edgar/data/%s/%s/", cik, accNoDashes)
|
|
|
|
events = append(events, CompanyEvent{
|
|
Ticker: ticker,
|
|
EventType: "ceo_change",
|
|
Title: fmt.Sprintf("Executive change (8-K §5.02) — %s", ticker),
|
|
AccessionNo: acc,
|
|
FilingDate: date,
|
|
FilingURL: filingURL,
|
|
})
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// ---- méthodes internes ----
|
|
|
|
func (c *Client) lookupCIK(ticker string) (string, error) {
|
|
if err := c.loadCIKMap(); err != nil {
|
|
return "", err
|
|
}
|
|
cik, ok := c.cikMap[strings.ToUpper(ticker)]
|
|
if !ok {
|
|
return "", fmt.Errorf("ticker %s not found", ticker)
|
|
}
|
|
return cik, nil
|
|
}
|
|
|
|
func (c *Client) loadCIKMap() error {
|
|
var loadErr error
|
|
c.cikOnce.Do(func() {
|
|
resp, err := c.get("https://www.sec.gov/files/company_tickers.json")
|
|
if err != nil {
|
|
loadErr = err
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var raw map[string]tickerEntry
|
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
|
loadErr = err
|
|
return
|
|
}
|
|
|
|
c.cikMap = make(map[string]string, len(raw))
|
|
for _, entry := range raw {
|
|
padded := fmt.Sprintf("%010d", entry.CIK)
|
|
c.cikMap[strings.ToUpper(entry.Ticker)] = padded
|
|
}
|
|
})
|
|
return loadErr
|
|
}
|
|
|
|
func (c *Client) recentForm4Filings(cik string, maxDays int) (accessions, docs, dates []string, err error) {
|
|
url := fmt.Sprintf("%s/submissions/CIK%s.json", baseURL, cik)
|
|
resp, err := c.get(url)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var sub submissionsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -maxDays).Format("2006-01-02")
|
|
forms := sub.Filings.Recent.Form
|
|
accs := sub.Filings.Recent.AccessionNumber
|
|
pdocs := sub.Filings.Recent.PrimaryDocument
|
|
fdates := sub.Filings.Recent.FilingDate
|
|
|
|
for i, form := range forms {
|
|
if form != "4" {
|
|
continue
|
|
}
|
|
if i < len(fdates) && fdates[i] < cutoff {
|
|
break // filings are sorted newest first, stop when too old
|
|
}
|
|
if i < len(accs) {
|
|
accessions = append(accessions, accs[i])
|
|
}
|
|
if i < len(pdocs) {
|
|
docs = append(docs, pdocs[i])
|
|
}
|
|
if i < len(fdates) {
|
|
dates = append(dates, fdates[i])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Client) parseForm4(cik, accessionNo, primaryDoc, ticker string) ([]InsiderTrade, error) {
|
|
// Construire l'URL du document XML
|
|
accNoDashes := strings.ReplaceAll(accessionNo, "-", "")
|
|
|
|
xmlFile := primaryDoc
|
|
if xmlFile == "" || !strings.HasSuffix(xmlFile, ".xml") {
|
|
// Fallback : essayer le nom conventionnel
|
|
xmlFile = accessionNo + ".xml"
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/%s/%s/%s", archiveURL, cik, accNoDashes, xmlFile)
|
|
filingURL := fmt.Sprintf("https://www.sec.gov/Archives/edgar/data/%s/%s/%s", cik, accNoDashes, xmlFile)
|
|
|
|
resp, err := c.get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var doc form4Doc
|
|
if err := xml.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
insiderName := doc.ReportingOwner.ID.Name
|
|
insiderTitle := doc.ReportingOwner.Relationship.Title
|
|
if insiderTitle == "" {
|
|
if doc.ReportingOwner.Relationship.IsDirector == 1 {
|
|
insiderTitle = "Director"
|
|
} else if doc.ReportingOwner.Relationship.IsOfficer == 1 {
|
|
insiderTitle = "Officer"
|
|
}
|
|
}
|
|
|
|
var trades []InsiderTrade
|
|
for _, tx := range doc.NonDerivativeTable.Transactions {
|
|
code := tx.Coding.Code
|
|
// P = achat, S = vente, A = attribution avec prix > 0
|
|
if code != "P" && code != "S" && !(code == "A" && tx.Amounts.Price.Value > 0) {
|
|
continue
|
|
}
|
|
shares := tx.Amounts.Shares.Value
|
|
price := tx.Amounts.Price.Value
|
|
if shares <= 0 {
|
|
continue
|
|
}
|
|
|
|
trades = append(trades, InsiderTrade{
|
|
Ticker: ticker,
|
|
InsiderName: insiderName,
|
|
InsiderTitle: insiderTitle,
|
|
TransactionCode: code,
|
|
Shares: shares,
|
|
PricePerShare: price,
|
|
TotalValue: shares * price,
|
|
TransactionDate: tx.Date.Value,
|
|
AccessionNo: accessionNo,
|
|
FilingURL: filingURL,
|
|
})
|
|
}
|
|
return trades, nil
|
|
}
|
|
|
|
func (c *Client) get(url string) (*http.Response, error) {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
req.Header.Set("Accept", "application/json, application/xml, text/xml")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("EDGAR HTTP %d: %s", resp.StatusCode, url)
|
|
}
|
|
return resp, nil
|
|
}
|