added
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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"`
|
||||
} `json:"recent"`
|
||||
} `json:"filings"`
|
||||
}
|
||||
|
||||
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.
|
||||
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
// On garde achats (P) et attributions significatives (A avec prix > 0)
|
||||
if code != "P" && !(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
|
||||
}
|
||||
Reference in New Issue
Block a user