mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat(rubick): rename project and add production release bundle
This commit is contained in:
commit
fab6cf26eb
33 changed files with 11554 additions and 0 deletions
324
msn/bing_client.go
Normal file
324
msn/bing_client.go
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
package msn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/enetx/g"
|
||||
"github.com/enetx/surf"
|
||||
)
|
||||
|
||||
// BingClient is the client for Bing Finance APIs (ownership data)
|
||||
type BingClient struct {
|
||||
client *surf.Client
|
||||
}
|
||||
|
||||
// NewBingClient creates a new Bing API client with Chrome impersonation
|
||||
func NewBingClient() *BingClient {
|
||||
client := surf.NewClient().
|
||||
Builder().
|
||||
Impersonate().
|
||||
Chrome().
|
||||
Build().
|
||||
Unwrap()
|
||||
|
||||
return &BingClient{client: client}
|
||||
}
|
||||
|
||||
// Close closes idle connections
|
||||
func (c *BingClient) Close() {
|
||||
c.client.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// commonHeaders returns common headers for Bing API requests
|
||||
func (c *BingClient) commonHeaders() map[string]string {
|
||||
return map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Origin": "https://www.msn.com",
|
||||
"Referer": "https://www.msn.com/",
|
||||
}
|
||||
}
|
||||
|
||||
// GetTopShareHolders fetches top institutional shareholders
|
||||
func (c *BingClient) GetTopShareHolders(id string, count int) ([]Holder, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%sGetSecurityTopShareHolders/%s?rangeStart=1&count=%d",
|
||||
BingAPIBaseURL,
|
||||
id,
|
||||
count,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("top shareholders request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("top shareholders API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var result OwnershipResponse
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse top shareholders response: %w", err)
|
||||
}
|
||||
|
||||
// Return whichever field has data
|
||||
if len(result.SecurityOwnerships) > 0 {
|
||||
return result.SecurityOwnerships, nil
|
||||
}
|
||||
return result.Records, nil
|
||||
}
|
||||
|
||||
// GetTopBuyers fetches recent top buyers
|
||||
func (c *BingClient) GetTopBuyers(id string, count int) ([]Holder, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%sGetSecurityTopBuyers/%s?rangeStart=1&count=%d",
|
||||
BingAPIBaseURL,
|
||||
id,
|
||||
count,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("top buyers request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("top buyers API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var result OwnershipResponse
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse top buyers response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.SecurityOwnerships) > 0 {
|
||||
return result.SecurityOwnerships, nil
|
||||
}
|
||||
return result.Records, nil
|
||||
}
|
||||
|
||||
// GetTopSellers fetches recent top sellers
|
||||
func (c *BingClient) GetTopSellers(id string, count int) ([]Holder, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%sGetSecurityTopSellers/%s?rangeStart=1&count=%d",
|
||||
BingAPIBaseURL,
|
||||
id,
|
||||
count,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("top sellers request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("top sellers API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var result OwnershipResponse
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse top sellers response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.SecurityOwnerships) > 0 {
|
||||
return result.SecurityOwnerships, nil
|
||||
}
|
||||
return result.Records, nil
|
||||
}
|
||||
|
||||
// GetNewShareHolders fetches new institutional holders
|
||||
func (c *BingClient) GetNewShareHolders(id string, count int) ([]Holder, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%sGetSecurityTopNewShareHolders/%s?rangeStart=1&count=%d",
|
||||
BingAPIBaseURL,
|
||||
id,
|
||||
count,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("new shareholders request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("new shareholders API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var result OwnershipResponse
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse new shareholders response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.SecurityOwnerships) > 0 {
|
||||
return result.SecurityOwnerships, nil
|
||||
}
|
||||
return result.Records, nil
|
||||
}
|
||||
|
||||
// GetExitedShareHolders fetches exited institutional holders
|
||||
func (c *BingClient) GetExitedShareHolders(id string, count int) ([]Holder, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%sGetSecurityTopExitedShareHolders/%s?rangeStart=1&count=%d",
|
||||
BingAPIBaseURL,
|
||||
id,
|
||||
count,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("exited shareholders request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("exited shareholders API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var result OwnershipResponse
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse exited shareholders response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.SecurityOwnerships) > 0 {
|
||||
return result.SecurityOwnerships, nil
|
||||
}
|
||||
return result.Records, nil
|
||||
}
|
||||
|
||||
// IsInvestorDataAvailable checks if investor data exists for a stock
|
||||
func (c *BingClient) IsInvestorDataAvailable(id string) (bool, error) {
|
||||
if id == "" {
|
||||
return false, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%sIsInvestorDataAvailable/%s",
|
||||
BingAPIBaseURL,
|
||||
id,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return false, fmt.Errorf("investor data check request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return false, fmt.Errorf("investor data check API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var available bool
|
||||
if err := json.Unmarshal([]byte(body), &available); err != nil {
|
||||
return false, fmt.Errorf("failed to parse investor data check response: %w", err)
|
||||
}
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// GetAllOwnership fetches all ownership data for a stock
|
||||
func (c *BingClient) GetAllOwnership(id string, count int) (*OwnershipData, error) {
|
||||
ownership := &OwnershipData{}
|
||||
|
||||
// Skip IsInvestorDataAvailable check as it often returns 404 even when data exists
|
||||
// Just try to fetch the data directly
|
||||
|
||||
// Fetch all ownership data sequentially
|
||||
if holders, err := c.GetTopShareHolders(id, count); err == nil {
|
||||
ownership.TopHolders = holders
|
||||
}
|
||||
|
||||
if buyers, err := c.GetTopBuyers(id, count); err == nil {
|
||||
ownership.TopBuyers = buyers
|
||||
}
|
||||
|
||||
if sellers, err := c.GetTopSellers(id, count); err == nil {
|
||||
ownership.TopSellers = sellers
|
||||
}
|
||||
|
||||
if newHolders, err := c.GetNewShareHolders(id, count); err == nil {
|
||||
ownership.NewHolders = newHolders
|
||||
}
|
||||
|
||||
if exited, err := c.GetExitedShareHolders(id, count); err == nil {
|
||||
ownership.ExitedHolders = exited
|
||||
}
|
||||
|
||||
return ownership, nil
|
||||
}
|
||||
1844
msn/idx_stocks.go
Normal file
1844
msn/idx_stocks.go
Normal file
File diff suppressed because it is too large
Load diff
652
msn/msn_client.go
Normal file
652
msn/msn_client.go
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
package msn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/enetx/g"
|
||||
"github.com/enetx/surf"
|
||||
)
|
||||
|
||||
// MSNClientConfig holds configuration for the MSN client
|
||||
type MSNClientConfig struct {
|
||||
Proxy string // Proxy URL (http://, https://, socks5://)
|
||||
RateLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// MSNClient is the base client for MSN Finance APIs
|
||||
type MSNClient struct {
|
||||
client *surf.Client
|
||||
proxy string
|
||||
rateLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// NewMSNClient creates a new MSN API client with Chrome impersonation
|
||||
func NewMSNClient() *MSNClient {
|
||||
return NewMSNClientWithConfig(MSNClientConfig{})
|
||||
}
|
||||
|
||||
// NewMSNClientWithConfig creates a new MSN API client with custom configuration
|
||||
func NewMSNClientWithConfig(config MSNClientConfig) *MSNClient {
|
||||
builder := surf.NewClient().
|
||||
Builder().
|
||||
Impersonate().
|
||||
Chrome()
|
||||
|
||||
// Add proxy if configured
|
||||
if config.Proxy != "" {
|
||||
builder = builder.Proxy(g.String(config.Proxy))
|
||||
}
|
||||
|
||||
client := builder.Build().Unwrap()
|
||||
|
||||
return &MSNClient{
|
||||
client: client,
|
||||
proxy: config.Proxy,
|
||||
rateLimiter: config.RateLimiter,
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRateLimit waits for rate limiter if configured
|
||||
func (c *MSNClient) waitForRateLimit() {
|
||||
if c.rateLimiter != nil {
|
||||
c.rateLimiter.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes idle connections
|
||||
func (c *MSNClient) Close() {
|
||||
c.client.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// commonHeaders returns common headers for MSN API requests
|
||||
func (c *MSNClient) commonHeaders() map[string]string {
|
||||
return map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9,id;q=0.8",
|
||||
"Origin": "https://www.msn.com",
|
||||
"Referer": "https://www.msn.com/",
|
||||
}
|
||||
}
|
||||
|
||||
// GetQuotes fetches real-time quotes for given stock IDs
|
||||
func (c *MSNClient) GetQuotes(ids []string) ([]QuoteData, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no stock IDs provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sFinance/Quotes?apikey=%s&ids=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
strings.Join(ids, ","),
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("quotes request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("quotes API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var quotes []QuoteData
|
||||
if err := json.Unmarshal([]byte(body), "es); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse quotes response: %w", err)
|
||||
}
|
||||
|
||||
return quotes, nil
|
||||
}
|
||||
|
||||
// GetQuoteSummary fetches detailed quote summary with multiple intents
|
||||
func (c *MSNClient) GetQuoteSummary(id string, intents []string) (map[string]json.RawMessage, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
intentStr := strings.Join(intents, ",")
|
||||
apiURL := fmt.Sprintf("%sFinance/QuoteSummary?apikey=%s&ids=%s&intents=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
id,
|
||||
intentStr,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("quote summary request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("quote summary API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var result []map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse quote summary response: %w", err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("empty quote summary response")
|
||||
}
|
||||
|
||||
return result[0], nil
|
||||
}
|
||||
|
||||
// GetCharts fetches historical chart data
|
||||
func (c *MSNClient) GetCharts(ids []string, chartType string) ([]ChartResponse, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no stock IDs provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sFinance/Charts?apikey=%s&cm=id-id&ids=%s&type=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
strings.Join(ids, ","),
|
||||
chartType,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("charts request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("charts API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var charts []ChartResponse
|
||||
if err := json.Unmarshal([]byte(body), &charts); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse charts response: %w", err)
|
||||
}
|
||||
|
||||
return charts, nil
|
||||
}
|
||||
|
||||
// GetEquities fetches company information
|
||||
func (c *MSNClient) GetEquities(ids []string) ([]EquityData, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no stock IDs provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sFinance/Equities?apikey=%s&ids=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
strings.Join(ids, ","),
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("equities request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("equities API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var equities []EquityData
|
||||
if err := json.Unmarshal([]byte(body), &equities); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse equities response: %w", err)
|
||||
}
|
||||
|
||||
return equities, nil
|
||||
}
|
||||
|
||||
// GetFinancialStatements fetches financial statements
|
||||
func (c *MSNClient) GetFinancialStatements(id string) (FinancialStatementsResponse, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
// URL encode the filter parameter
|
||||
filter := fmt.Sprintf("_p eq '%s'", id)
|
||||
apiURL := fmt.Sprintf("%sFinance/Equities/financialstatements?apikey=%s&$filter=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
url.QueryEscape(filter),
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("financial statements request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("financial statements API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
// Response is a direct array of FinancialStatement
|
||||
var result FinancialStatementsResponse
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse financial statements response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetEarnings fetches earnings events
|
||||
func (c *MSNClient) GetEarnings(ids []string) ([]EarningsEvent, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no stock IDs provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sFinance/Events/Earnings?apikey=%s&ids=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
strings.Join(ids, ","),
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("earnings request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("earnings API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
// Parse the actual API response format
|
||||
var apiResp EarningsAPIResponse
|
||||
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse earnings response: %w", err)
|
||||
}
|
||||
|
||||
// Convert quarterly earnings to EarningsEvent array
|
||||
var earnings []EarningsEvent
|
||||
for periodKey, data := range apiResp.History.Quarterly {
|
||||
// Parse fiscal year and quarter from CiqFiscalPeriodType (e.g., "Q42025")
|
||||
fiscalYear := 0
|
||||
fiscalQuarter := 0
|
||||
if len(data.CiqFiscalPeriodType) >= 6 {
|
||||
// Format: Q{quarter}{year} e.g., Q42025
|
||||
fmt.Sscanf(data.CiqFiscalPeriodType, "Q%d%d", &fiscalQuarter, &fiscalYear)
|
||||
}
|
||||
if fiscalYear == 0 && len(periodKey) >= 6 {
|
||||
// Fallback: parse from period key (e.g., "202512")
|
||||
fmt.Sscanf(periodKey[:4], "%d", &fiscalYear)
|
||||
month := 0
|
||||
fmt.Sscanf(periodKey[4:6], "%d", &month)
|
||||
fiscalQuarter = (month-1)/3 + 1
|
||||
}
|
||||
|
||||
// Parse event date
|
||||
eventDate := ""
|
||||
if data.EarningReleaseDate != "" {
|
||||
// Extract date portion from ISO timestamp
|
||||
if len(data.EarningReleaseDate) >= 10 {
|
||||
eventDate = data.EarningReleaseDate[:10]
|
||||
}
|
||||
}
|
||||
|
||||
earnings = append(earnings, EarningsEvent{
|
||||
ID: fmt.Sprintf("%s_%s", apiResp.InstrumentID, periodKey),
|
||||
EventDate: eventDate,
|
||||
FiscalYear: fiscalYear,
|
||||
FiscalQuarter: fiscalQuarter,
|
||||
EPSEstimate: data.EpsForecast,
|
||||
EPSActual: data.EpsActual,
|
||||
EPSSurprise: data.EpsSurprise,
|
||||
EPSSurprisePct: data.EpsSurprisePercent,
|
||||
RevenueEstimate: data.RevenueForecast,
|
||||
RevenueActual: data.RevenueActual,
|
||||
RevenueSurprise: data.RevenueSurprise,
|
||||
})
|
||||
}
|
||||
|
||||
return earnings, nil
|
||||
}
|
||||
|
||||
// GetSentiment fetches market sentiment
|
||||
func (c *MSNClient) GetSentiment(ids []string) ([]SentimentData, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no stock IDs provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sFinance/SentimentBrowser?apikey=%s&cm=id-id&it=web&scn=ANON&ids=%s&wrapodata=false&flightId=INeedDau",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
strings.Join(ids, ","),
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("sentiment request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("sentiment API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var sentiment []SentimentData
|
||||
if err := json.Unmarshal([]byte(body), &sentiment); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse sentiment response: %w", err)
|
||||
}
|
||||
|
||||
return sentiment, nil
|
||||
}
|
||||
|
||||
// GetKeyRatios fetches key financial ratios from api.msn.com
|
||||
func (c *MSNClient) GetKeyRatios(ids []string) ([]KeyRatios, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no stock IDs provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%skeyratios?apikey=%s&ids=%s&wrapodata=false",
|
||||
MSNAPIBaseURL,
|
||||
MSNAPIKey,
|
||||
strings.Join(ids, ","),
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("key ratios request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("key ratios API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var ratios []KeyRatios
|
||||
if err := json.Unmarshal([]byte(body), &ratios); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key ratios response: %w", err)
|
||||
}
|
||||
|
||||
return ratios, nil
|
||||
}
|
||||
|
||||
// GetInsights fetches AI-generated insights from api.msn.com
|
||||
func (c *MSNClient) GetInsights(id string) (*InsightData, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sinsights?apikey=%s&ids=%s&wrapodata=false",
|
||||
MSNAPIBaseURL,
|
||||
MSNAPIKey,
|
||||
id,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("insights request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("insights API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var insights []InsightData
|
||||
if err := json.Unmarshal([]byte(body), &insights); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse insights response: %w", err)
|
||||
}
|
||||
|
||||
if len(insights) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &insights[0], nil
|
||||
}
|
||||
|
||||
// GetNewsFeed fetches stock-related news
|
||||
func (c *MSNClient) GetNewsFeed(id string) ([]NewsItem, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("no stock ID provided")
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
// Use the stock-specific entity feed format from MSN website
|
||||
apiURL := fmt.Sprintf("%sMSN/Feed/me?$top=30&apikey=%s&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_%s&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
id,
|
||||
)
|
||||
|
||||
req := c.client.Get(g.String(apiURL))
|
||||
for k, v := range c.commonHeaders() {
|
||||
req = req.SetHeaders(k, v)
|
||||
}
|
||||
|
||||
resp := req.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("news feed request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("news feed API returned status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var newsFeed NewsFeedResponse
|
||||
if err := json.Unmarshal([]byte(body), &newsFeed); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse news feed response: %w", err)
|
||||
}
|
||||
|
||||
// Use SubCards if available (cardview response), otherwise use Value
|
||||
if len(newsFeed.SubCards) > 0 {
|
||||
return newsFeed.SubCards, nil
|
||||
}
|
||||
return newsFeed.Value, nil
|
||||
}
|
||||
|
||||
// GetAllCharts fetches all chart timeframes for a stock
|
||||
func (c *MSNClient) GetAllCharts(id string) (map[string][]ChartPoint, error) {
|
||||
chartTypes := []string{"1D1M", "1M", "3M", "1Y", "3Y"}
|
||||
result := make(map[string][]ChartPoint)
|
||||
|
||||
for _, chartType := range chartTypes {
|
||||
charts, err := c.GetCharts([]string{id}, chartType)
|
||||
if err != nil {
|
||||
continue // Skip failed chart types
|
||||
}
|
||||
if len(charts) > 0 {
|
||||
// Map chart type to friendlier names
|
||||
typeName := chartType
|
||||
switch chartType {
|
||||
case "1D1M":
|
||||
typeName = "1D"
|
||||
}
|
||||
result[typeName] = charts[0].Points
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FetchStockData fetches all data for a single stock
|
||||
func (c *MSNClient) FetchStockData(id string) (*StockData, error) {
|
||||
stock := &StockData{
|
||||
ID: id,
|
||||
FetchedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
FetchStatus: make(map[string]string),
|
||||
Charts: make(map[string][]ChartPoint),
|
||||
}
|
||||
|
||||
// Fetch quote
|
||||
quotes, err := c.GetQuotes([]string{id})
|
||||
if err != nil {
|
||||
stock.FetchStatus["quote"] = fmt.Sprintf("failed: %v", err)
|
||||
} else if len(quotes) > 0 {
|
||||
stock.Quote = "es[0]
|
||||
stock.Ticker = quotes[0].Symbol
|
||||
stock.Name = quotes[0].ShortName
|
||||
stock.Exchange = quotes[0].ExchangeID
|
||||
stock.FetchStatus["quote"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch company info
|
||||
equities, err := c.GetEquities([]string{id})
|
||||
if err != nil {
|
||||
stock.FetchStatus["company"] = fmt.Sprintf("failed: %v", err)
|
||||
} else if len(equities) > 0 {
|
||||
stock.Company = &equities[0]
|
||||
stock.Sector = equities[0].Sector
|
||||
stock.Industry = equities[0].Industry
|
||||
if stock.Name == "" {
|
||||
stock.Name = equities[0].ShortName
|
||||
}
|
||||
stock.FetchStatus["company"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch charts
|
||||
charts, err := c.GetAllCharts(id)
|
||||
if err != nil {
|
||||
stock.FetchStatus["charts"] = fmt.Sprintf("failed: %v", err)
|
||||
} else {
|
||||
stock.Charts = charts
|
||||
stock.FetchStatus["charts"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch key ratios
|
||||
ratios, err := c.GetKeyRatios([]string{id})
|
||||
if err != nil {
|
||||
stock.FetchStatus["key_ratios"] = fmt.Sprintf("failed: %v", err)
|
||||
} else if len(ratios) > 0 {
|
||||
stock.KeyRatios = &ratios[0]
|
||||
stock.FetchStatus["key_ratios"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch earnings
|
||||
earnings, err := c.GetEarnings([]string{id})
|
||||
if err != nil {
|
||||
stock.FetchStatus["earnings"] = fmt.Sprintf("failed: %v", err)
|
||||
} else {
|
||||
stock.Earnings = earnings
|
||||
stock.FetchStatus["earnings"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch sentiment
|
||||
sentiment, err := c.GetSentiment([]string{id})
|
||||
if err != nil {
|
||||
stock.FetchStatus["sentiment"] = fmt.Sprintf("failed: %v", err)
|
||||
} else if len(sentiment) > 0 {
|
||||
stock.Sentiment = &sentiment[0]
|
||||
stock.FetchStatus["sentiment"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch insights
|
||||
insights, err := c.GetInsights(id)
|
||||
if err != nil {
|
||||
stock.FetchStatus["insights"] = fmt.Sprintf("failed: %v", err)
|
||||
} else if insights != nil {
|
||||
stock.Insights = insights
|
||||
stock.FetchStatus["insights"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch financial statements
|
||||
financials, err := c.GetFinancialStatements(id)
|
||||
if err != nil {
|
||||
stock.FetchStatus["financials"] = fmt.Sprintf("failed: %v", err)
|
||||
} else if len(financials) > 0 {
|
||||
stock.Financials = &FinancialData{
|
||||
Statements: financials,
|
||||
}
|
||||
stock.FetchStatus["financials"] = "ok"
|
||||
}
|
||||
|
||||
// Fetch news
|
||||
news, err := c.GetNewsFeed(id)
|
||||
if err != nil {
|
||||
stock.FetchStatus["news"] = fmt.Sprintf("failed: %v", err)
|
||||
} else {
|
||||
stock.News = news
|
||||
stock.FetchStatus["news"] = "ok"
|
||||
}
|
||||
|
||||
return stock, nil
|
||||
}
|
||||
228
msn/msn_screener.go
Normal file
228
msn/msn_screener.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package msn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/enetx/g"
|
||||
)
|
||||
|
||||
// ScreenerFilter represents available screener filter presets
|
||||
type ScreenerFilter string
|
||||
|
||||
const (
|
||||
FilterTopPerformers ScreenerFilter = "top-performers"
|
||||
FilterWorstPerformers ScreenerFilter = "worst-performers"
|
||||
FilterHighDividend ScreenerFilter = "high-dividend"
|
||||
FilterLowPE ScreenerFilter = "low-pe"
|
||||
Filter52WeekHigh ScreenerFilter = "52w-high"
|
||||
Filter52WeekLow ScreenerFilter = "52w-low"
|
||||
FilterHighVolume ScreenerFilter = "high-volume"
|
||||
FilterLargeMarketCap ScreenerFilter = "large-cap"
|
||||
)
|
||||
|
||||
// Filter key mappings for MSN Screener API
|
||||
var screenerFilterKeys = map[ScreenerFilter]string{
|
||||
FilterTopPerformers: "st_list_topperfs",
|
||||
FilterWorstPerformers: "st_list_poorperfs",
|
||||
FilterHighDividend: "st_list_highdividend",
|
||||
FilterLowPE: "st_list_lowpe",
|
||||
Filter52WeekHigh: "st_list_52wkhi",
|
||||
Filter52WeekLow: "st_list_52wklow",
|
||||
FilterHighVolume: "st_list_highvol",
|
||||
FilterLargeMarketCap: "st_list_largecap",
|
||||
}
|
||||
|
||||
// Region key mappings for MSN Screener API
|
||||
var screenerRegionKeys = map[string]string{
|
||||
"id": "st_reg_id", // Indonesia
|
||||
"us": "st_reg_us", // United States
|
||||
"gb": "st_reg_gb", // United Kingdom
|
||||
"de": "st_reg_de", // Germany
|
||||
"jp": "st_reg_jp", // Japan
|
||||
"hk": "st_reg_hk", // Hong Kong
|
||||
"sg": "st_reg_sg", // Singapore
|
||||
"au": "st_reg_au", // Australia
|
||||
"in": "st_reg_in", // India
|
||||
"cn": "st_reg_cn", // China
|
||||
}
|
||||
|
||||
// ScreenerConfig holds screener configuration
|
||||
type ScreenerConfig struct {
|
||||
Region string // Country code (e.g., "id" for Indonesia)
|
||||
Filter ScreenerFilter // Filter preset
|
||||
Limit int // Max results
|
||||
PageIndex int // Page number (0-indexed)
|
||||
}
|
||||
|
||||
// ScreenerAPIResponse is the raw response from Finance/Screener
|
||||
type ScreenerAPIResponse struct {
|
||||
Count int `json:"count"`
|
||||
MatchIDs []string `json:"matchIds"`
|
||||
Quote []QuoteData `json:"quote"`
|
||||
Equity []EquityData `json:"equity"`
|
||||
Fund []interface{} `json:"fund"`
|
||||
}
|
||||
|
||||
// RunScreener executes the stock screener with given configuration
|
||||
func (c *MSNClient) RunScreener(config ScreenerConfig) (*ScreenerResponse, error) {
|
||||
if config.Region == "" {
|
||||
config.Region = "id" // Default to Indonesia
|
||||
}
|
||||
if config.Limit <= 0 {
|
||||
config.Limit = 50
|
||||
}
|
||||
|
||||
// Build filter array
|
||||
filters := buildScreenerFilters(config.Region, config.Filter)
|
||||
|
||||
req := ScreenerRequest{
|
||||
Filter: filters,
|
||||
Order: ScreenerOrder{Key: "st_1yr_asc_order", Dir: "desc"},
|
||||
ReturnValueType: []string{"quote", "equity"},
|
||||
ScreenerType: "stock",
|
||||
Limit: config.Limit,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal screener request: %w", err)
|
||||
}
|
||||
|
||||
c.waitForRateLimit()
|
||||
|
||||
apiURL := fmt.Sprintf("%sFinance/Screener?apikey=%s&wrapodata=false",
|
||||
MSNAssetsBaseURL,
|
||||
MSNAPIKey,
|
||||
)
|
||||
|
||||
httpReq := c.client.Post(g.String(apiURL)).
|
||||
SetHeaders("Content-Type", "text/plain;charset=UTF-8")
|
||||
for k, v := range c.commonHeaders() {
|
||||
httpReq = httpReq.SetHeaders(k, v)
|
||||
}
|
||||
httpReq = httpReq.Body(g.String(string(reqBody)))
|
||||
|
||||
resp := httpReq.Do()
|
||||
if resp.IsErr() {
|
||||
return nil, fmt.Errorf("screener request failed: %w", resp.Err())
|
||||
}
|
||||
|
||||
r := resp.Ok()
|
||||
if r.StatusCode != 200 {
|
||||
body := r.Body.String().Ok().Std()
|
||||
return nil, fmt.Errorf("screener API returned status %d: %s", r.StatusCode, body)
|
||||
}
|
||||
|
||||
body := r.Body.String().Ok().Std()
|
||||
|
||||
var apiResp ScreenerAPIResponse
|
||||
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse screener response: %w", err)
|
||||
}
|
||||
|
||||
// Merge quote and equity data into ScreenerStock
|
||||
stocks := mergeScreenerResults(apiResp)
|
||||
|
||||
return &ScreenerResponse{
|
||||
Value: stocks,
|
||||
Total: apiResp.Count,
|
||||
Count: apiResp.Count,
|
||||
MatchIDs: apiResp.MatchIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildScreenerFilters creates filter array based on region and preset
|
||||
func buildScreenerFilters(region string, filter ScreenerFilter) []ScreenerFilterItem {
|
||||
filters := make([]ScreenerFilterItem, 0, 2)
|
||||
|
||||
// Add filter preset
|
||||
if filterKey, ok := screenerFilterKeys[filter]; ok {
|
||||
filters = append(filters, ScreenerFilterItem{
|
||||
Key: filterKey,
|
||||
KeyGroup: "st_list_",
|
||||
IsRange: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Add region filter
|
||||
if regionKey, ok := screenerRegionKeys[region]; ok {
|
||||
filters = append(filters, ScreenerFilterItem{
|
||||
Key: regionKey,
|
||||
KeyGroup: "st_reg_",
|
||||
IsRange: false,
|
||||
})
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
// mergeScreenerResults combines quote and equity data into ScreenerStock slice
|
||||
func mergeScreenerResults(apiResp ScreenerAPIResponse) []ScreenerStock {
|
||||
// Build equity map by instrumentId
|
||||
equityMap := make(map[string]*EquityData)
|
||||
for i := range apiResp.Equity {
|
||||
eq := &apiResp.Equity[i]
|
||||
// Use instrumentId from the "_p" field if available
|
||||
if id := eq.ID; id != "" {
|
||||
equityMap[id] = eq
|
||||
}
|
||||
}
|
||||
|
||||
stocks := make([]ScreenerStock, 0, len(apiResp.Quote))
|
||||
for _, q := range apiResp.Quote {
|
||||
stock := ScreenerStock{
|
||||
ID: q.InstrumentID,
|
||||
InstrumentID: q.InstrumentID,
|
||||
Symbol: q.Symbol,
|
||||
ShortName: q.ShortName,
|
||||
DisplayName: q.DisplayName,
|
||||
ExchangeID: q.ExchangeID,
|
||||
ExchangeCode: q.ExchangeCode,
|
||||
Country: q.Country,
|
||||
Price: q.Price,
|
||||
PriceChange: q.PriceChange,
|
||||
PriceChangePct: q.PriceChangePct,
|
||||
MarketCap: q.MarketCap,
|
||||
Volume: q.AccumulatedVolume,
|
||||
Price52wHigh: q.Price52wHigh,
|
||||
Price52wLow: q.Price52wLow,
|
||||
Return1Year: q.Return1Year,
|
||||
ReturnYTD: q.ReturnYTD,
|
||||
}
|
||||
|
||||
// Merge equity data if available
|
||||
if eq, ok := equityMap[q.InstrumentID]; ok {
|
||||
stock.Sector = eq.Sector
|
||||
stock.Industry = eq.Industry
|
||||
}
|
||||
|
||||
stocks = append(stocks, stock)
|
||||
}
|
||||
|
||||
return stocks
|
||||
}
|
||||
|
||||
// ParseScreenerFilter converts string to ScreenerFilter
|
||||
func ParseScreenerFilter(s string) (ScreenerFilter, error) {
|
||||
switch s {
|
||||
case "top-performers", "top":
|
||||
return FilterTopPerformers, nil
|
||||
case "worst-performers", "worst":
|
||||
return FilterWorstPerformers, nil
|
||||
case "high-dividend", "dividend":
|
||||
return FilterHighDividend, nil
|
||||
case "low-pe", "pe":
|
||||
return FilterLowPE, nil
|
||||
case "52w-high", "52high":
|
||||
return Filter52WeekHigh, nil
|
||||
case "52w-low", "52low":
|
||||
return Filter52WeekLow, nil
|
||||
case "high-volume", "volume":
|
||||
return FilterHighVolume, nil
|
||||
case "large-cap", "largecap":
|
||||
return FilterLargeMarketCap, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown filter: %s (valid: top-performers, worst-performers, high-dividend, low-pe, 52w-high, 52w-low, high-volume, large-cap)", s)
|
||||
}
|
||||
}
|
||||
353
msn/msn_stock.go
Normal file
353
msn/msn_stock.go
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
package msn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StockFetcher handles parallel fetching of stock data
|
||||
type StockFetcher struct {
|
||||
msnClient *MSNClient
|
||||
bingClient *BingClient
|
||||
}
|
||||
|
||||
// NewStockFetcher creates a new stock fetcher
|
||||
func NewStockFetcher() *StockFetcher {
|
||||
return &StockFetcher{
|
||||
msnClient: NewMSNClient(),
|
||||
bingClient: NewBingClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes all clients
|
||||
func (f *StockFetcher) Close() {
|
||||
f.msnClient.Close()
|
||||
f.bingClient.Close()
|
||||
}
|
||||
|
||||
// FetchResult holds the result of fetching a single stock
|
||||
type StockFetchResult struct {
|
||||
Index int
|
||||
Stock *StockData
|
||||
Error error
|
||||
}
|
||||
|
||||
// FetchStocks fetches data for multiple stocks in parallel
|
||||
func (f *StockFetcher) FetchStocks(ctx context.Context, ids []string, concurrency int) []StockData {
|
||||
if concurrency <= 0 {
|
||||
concurrency = 5
|
||||
}
|
||||
|
||||
results := make([]StockData, len(ids))
|
||||
|
||||
// Create work channel
|
||||
work := make(chan int, len(ids))
|
||||
for i := range ids {
|
||||
work <- i
|
||||
}
|
||||
close(work)
|
||||
|
||||
// Worker pool
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for w := 0; w < concurrency; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case idx, ok := <-work:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id := ids[idx]
|
||||
stock := f.fetchSingleStock(ctx, id)
|
||||
|
||||
mu.Lock()
|
||||
results[idx] = *stock
|
||||
mu.Unlock()
|
||||
|
||||
// Count successful fetches
|
||||
successCount := 0
|
||||
for k, v := range stock.FetchStatus {
|
||||
if v == "ok" {
|
||||
successCount++
|
||||
}
|
||||
_ = k
|
||||
}
|
||||
|
||||
log.Printf("[%d/%d] %s (%s) - %d/%d APIs succeeded",
|
||||
idx+1, len(ids),
|
||||
stock.Ticker,
|
||||
stock.ID,
|
||||
successCount,
|
||||
len(stock.FetchStatus),
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// fetchSingleStock fetches all data for a single stock
|
||||
func (f *StockFetcher) fetchSingleStock(ctx context.Context, id string) *StockData {
|
||||
stock := &StockData{
|
||||
ID: id,
|
||||
FetchedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
FetchStatus: make(map[string]string),
|
||||
Charts: make(map[string][]ChartPoint),
|
||||
}
|
||||
|
||||
// Use channels for parallel fetching within a single stock
|
||||
type fetchResult struct {
|
||||
name string
|
||||
err error
|
||||
data interface{}
|
||||
}
|
||||
|
||||
resultChan := make(chan fetchResult, 10)
|
||||
var fetchWg sync.WaitGroup
|
||||
|
||||
// Fetch quote
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
quotes, err := f.msnClient.GetQuotes([]string{id})
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "quote", err: err}
|
||||
return
|
||||
}
|
||||
if len(quotes) > 0 {
|
||||
resultChan <- fetchResult{name: "quote", data: "es[0]}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch company info
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
equities, err := f.msnClient.GetEquities([]string{id})
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "company", err: err}
|
||||
return
|
||||
}
|
||||
if len(equities) > 0 {
|
||||
resultChan <- fetchResult{name: "company", data: &equities[0]}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch key ratios
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
ratios, err := f.msnClient.GetKeyRatios([]string{id})
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "key_ratios", err: err}
|
||||
return
|
||||
}
|
||||
if len(ratios) > 0 {
|
||||
resultChan <- fetchResult{name: "key_ratios", data: &ratios[0]}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch earnings
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
earnings, err := f.msnClient.GetEarnings([]string{id})
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "earnings", err: err}
|
||||
return
|
||||
}
|
||||
resultChan <- fetchResult{name: "earnings", data: earnings}
|
||||
}()
|
||||
|
||||
// Fetch sentiment
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
sentiment, err := f.msnClient.GetSentiment([]string{id})
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "sentiment", err: err}
|
||||
return
|
||||
}
|
||||
if len(sentiment) > 0 {
|
||||
resultChan <- fetchResult{name: "sentiment", data: &sentiment[0]}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch insights
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
insights, err := f.msnClient.GetInsights(id)
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "insights", err: err}
|
||||
return
|
||||
}
|
||||
resultChan <- fetchResult{name: "insights", data: insights}
|
||||
}()
|
||||
|
||||
// Fetch financial statements
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
financials, err := f.msnClient.GetFinancialStatements(id)
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "financials", err: err}
|
||||
return
|
||||
}
|
||||
resultChan <- fetchResult{name: "financials", data: financials}
|
||||
}()
|
||||
|
||||
// Fetch news
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
news, err := f.msnClient.GetNewsFeed(id)
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "news", err: err}
|
||||
return
|
||||
}
|
||||
resultChan <- fetchResult{name: "news", data: news}
|
||||
}()
|
||||
|
||||
// Fetch charts (all timeframes)
|
||||
chartTypes := []string{"1D1M", "1M", "3M", "1Y", "3Y"}
|
||||
for _, chartType := range chartTypes {
|
||||
ct := chartType
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
charts, err := f.msnClient.GetCharts([]string{id}, ct)
|
||||
if err != nil {
|
||||
return // Skip failed chart types silently
|
||||
}
|
||||
if len(charts) > 0 {
|
||||
points := charts[0].ToChartPoints()
|
||||
if len(points) > 0 {
|
||||
typeName := ct
|
||||
if ct == "1D1M" {
|
||||
typeName = "1D"
|
||||
}
|
||||
resultChan <- fetchResult{name: "chart_" + typeName, data: points}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Fetch ownership data from Bing
|
||||
fetchWg.Add(1)
|
||||
go func() {
|
||||
defer fetchWg.Done()
|
||||
ownership, err := f.bingClient.GetAllOwnership(id, 20)
|
||||
if err != nil {
|
||||
resultChan <- fetchResult{name: "ownership", err: err}
|
||||
return
|
||||
}
|
||||
resultChan <- fetchResult{name: "ownership", data: ownership}
|
||||
}()
|
||||
|
||||
// Close result channel when all fetches complete
|
||||
go func() {
|
||||
fetchWg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
for result := range resultChan {
|
||||
if result.err != nil {
|
||||
stock.FetchStatus[result.name] = fmt.Sprintf("failed: %v", result.err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch result.name {
|
||||
case "quote":
|
||||
if quote, ok := result.data.(*QuoteData); ok && quote != nil {
|
||||
stock.Quote = quote
|
||||
stock.Ticker = quote.Symbol
|
||||
stock.Name = quote.ShortName
|
||||
stock.Exchange = quote.ExchangeID
|
||||
stock.FetchStatus["quote"] = "ok"
|
||||
}
|
||||
case "company":
|
||||
if equity, ok := result.data.(*EquityData); ok && equity != nil {
|
||||
stock.Company = equity
|
||||
stock.Sector = equity.Sector
|
||||
stock.Industry = equity.Industry
|
||||
if stock.Name == "" {
|
||||
stock.Name = equity.ShortName
|
||||
}
|
||||
stock.FetchStatus["company"] = "ok"
|
||||
}
|
||||
case "key_ratios":
|
||||
if ratios, ok := result.data.(*KeyRatios); ok && ratios != nil {
|
||||
stock.KeyRatios = ratios
|
||||
stock.FetchStatus["key_ratios"] = "ok"
|
||||
}
|
||||
case "earnings":
|
||||
if earnings, ok := result.data.([]EarningsEvent); ok {
|
||||
stock.Earnings = earnings
|
||||
stock.FetchStatus["earnings"] = "ok"
|
||||
}
|
||||
case "sentiment":
|
||||
if sentiment, ok := result.data.(*SentimentData); ok && sentiment != nil {
|
||||
stock.Sentiment = sentiment
|
||||
stock.FetchStatus["sentiment"] = "ok"
|
||||
}
|
||||
case "insights":
|
||||
if insights, ok := result.data.(*InsightData); ok && insights != nil {
|
||||
stock.Insights = insights
|
||||
stock.FetchStatus["insights"] = "ok"
|
||||
}
|
||||
case "financials":
|
||||
if financials, ok := result.data.(FinancialStatementsResponse); ok && len(financials) > 0 {
|
||||
stock.Financials = &FinancialData{
|
||||
Statements: financials,
|
||||
}
|
||||
stock.FetchStatus["financials"] = "ok"
|
||||
}
|
||||
case "news":
|
||||
if news, ok := result.data.([]NewsItem); ok {
|
||||
stock.News = news
|
||||
stock.FetchStatus["news"] = "ok"
|
||||
}
|
||||
case "ownership":
|
||||
if ownership, ok := result.data.(*OwnershipData); ok && ownership != nil {
|
||||
stock.Ownership = ownership
|
||||
stock.FetchStatus["ownership"] = "ok"
|
||||
}
|
||||
default:
|
||||
// Handle chart results
|
||||
if len(result.name) > 6 && result.name[:6] == "chart_" {
|
||||
chartType := result.name[6:]
|
||||
if points, ok := result.data.([]ChartPoint); ok {
|
||||
stock.Charts[chartType] = points
|
||||
stock.FetchStatus["charts"] = "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stock
|
||||
}
|
||||
|
||||
// FetchStockByID fetches a single stock by ID
|
||||
func (f *StockFetcher) FetchStockByID(ctx context.Context, id string) (*StockData, error) {
|
||||
stocks := f.FetchStocks(ctx, []string{id}, 1)
|
||||
if len(stocks) == 0 {
|
||||
return nil, fmt.Errorf("failed to fetch stock %s", id)
|
||||
}
|
||||
return &stocks[0], nil
|
||||
}
|
||||
550
msn/msn_types.go
Normal file
550
msn/msn_types.go
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
package msn
|
||||
|
||||
// MSN API Constants
|
||||
const (
|
||||
MSNAssetsBaseURL = "https://assets.msn.com/service/"
|
||||
MSNAPIBaseURL = "https://api.msn.com/msn/v0/pages/finance/"
|
||||
BingAPIBaseURL = "https://services.bingapis.com/contentservices-finance.hedgefunddataprovider/api/v1/"
|
||||
|
||||
// Public API key from MSN Money website
|
||||
MSNAPIKey = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM"
|
||||
)
|
||||
|
||||
// ScreenerRequest is the POST body for Finance/Screener
|
||||
// Uses the actual MSN API format with predefined filter keys
|
||||
type ScreenerRequest struct {
|
||||
Filter []ScreenerFilterItem `json:"filter"`
|
||||
Order ScreenerOrder `json:"order"`
|
||||
ReturnValueType []string `json:"returnValueType"`
|
||||
ScreenerType string `json:"screenerType"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ScreenerFilterItem represents a filter condition in the screener
|
||||
type ScreenerFilterItem struct {
|
||||
Key string `json:"key"` // e.g., "st_list_topperfs", "st_reg_id"
|
||||
KeyGroup string `json:"keyGroup"` // e.g., "st_list_", "st_reg_"
|
||||
IsRange bool `json:"isRange"`
|
||||
}
|
||||
|
||||
// ScreenerOrder represents sort order for screener results
|
||||
type ScreenerOrder struct {
|
||||
Key string `json:"key"` // e.g., "st_1yr_asc_order"
|
||||
Dir string `json:"dir"` // "asc" or "desc"
|
||||
}
|
||||
|
||||
// ScreenerResponse from Finance/Screener
|
||||
type ScreenerResponse struct {
|
||||
Value []ScreenerStock `json:"value"`
|
||||
Total int `json:"total"`
|
||||
Count int `json:"count"`
|
||||
MatchIDs []string `json:"matchIds"`
|
||||
Equity []ScreenerStock `json:"equity"`
|
||||
Quote []QuoteData `json:"quote"`
|
||||
}
|
||||
|
||||
// ScreenerStock is a stock from screener results
|
||||
type ScreenerStock struct {
|
||||
ID string `json:"id"`
|
||||
InstrumentID string `json:"instrumentId,omitempty"`
|
||||
Symbol string `json:"symbol"`
|
||||
ShortName string `json:"shortName"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
ExchangeID string `json:"exchangeId"`
|
||||
ExchangeCode string `json:"exchangeCode,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Sector string `json:"sector,omitempty"`
|
||||
Industry string `json:"industry,omitempty"`
|
||||
Price float64 `json:"price"`
|
||||
PriceChange float64 `json:"priceChange"`
|
||||
PriceChangePct float64 `json:"priceChangePercent"`
|
||||
MarketCap float64 `json:"marketCap"`
|
||||
Volume float64 `json:"accumulatedVolume"`
|
||||
Price52wHigh float64 `json:"price52wHigh"`
|
||||
Price52wLow float64 `json:"price52wLow"`
|
||||
Return1Year float64 `json:"return1Year"`
|
||||
ReturnYTD float64 `json:"returnYTD"`
|
||||
}
|
||||
|
||||
// QuoteResponse from Finance/Quotes
|
||||
type QuoteResponse []QuoteData
|
||||
|
||||
// QuoteData represents real-time quote data
|
||||
type QuoteData struct {
|
||||
ID string `json:"id"`
|
||||
InstrumentID string `json:"instrumentId"`
|
||||
Symbol string `json:"symbol"`
|
||||
ShortName string `json:"shortName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Price float64 `json:"price"`
|
||||
PriceChange float64 `json:"priceChange"`
|
||||
PriceChangePct float64 `json:"priceChangePercent"`
|
||||
PriceDayOpen float64 `json:"priceDayOpen"`
|
||||
PriceDayHigh float64 `json:"priceDayHigh"`
|
||||
PriceDayLow float64 `json:"priceDayLow"`
|
||||
PricePreviousClose float64 `json:"pricePreviousClose"`
|
||||
PriceClose float64 `json:"priceClose"`
|
||||
Price52wHigh float64 `json:"price52wHigh"`
|
||||
Price52wLow float64 `json:"price52wLow"`
|
||||
AccumulatedVolume float64 `json:"accumulatedVolume"`
|
||||
AverageVolume float64 `json:"averageVolume"`
|
||||
MarketCap float64 `json:"marketCap"`
|
||||
MarketCapCurrency string `json:"marketCapCurrency"`
|
||||
ExchangeID string `json:"exchangeId"`
|
||||
ExchangeCode string `json:"exchangeCode"`
|
||||
ExchangeName string `json:"exchangeName"`
|
||||
Currency string `json:"currency"`
|
||||
Country string `json:"country"`
|
||||
Market string `json:"market"`
|
||||
TimeLastTraded string `json:"timeLastTraded"`
|
||||
TimeLastUpdated string `json:"timeLastUpdated"`
|
||||
// Historical price changes
|
||||
PriceChange1Week float64 `json:"priceChange1Week"`
|
||||
PriceChange1Month float64 `json:"priceChange1Month"`
|
||||
PriceChange3Month float64 `json:"priceChange3Month"`
|
||||
PriceChange6Month float64 `json:"priceChange6Month"`
|
||||
PriceChangeYTD float64 `json:"priceChangeYTD"`
|
||||
PriceChange1Year float64 `json:"priceChange1Year"`
|
||||
// Historical returns (percentage)
|
||||
Return1Week float64 `json:"return1Week"`
|
||||
Return1Month float64 `json:"return1Month"`
|
||||
Return3Month float64 `json:"return3Month"`
|
||||
Return6Month float64 `json:"return6Month"`
|
||||
ReturnYTD float64 `json:"returnYTD"`
|
||||
Return1Year float64 `json:"return1Year"`
|
||||
}
|
||||
|
||||
// QuoteSummaryResponse from Finance/QuoteSummary
|
||||
type QuoteSummaryResponse []struct {
|
||||
Quotes []QuoteData `json:"quotes"`
|
||||
Exchanges []ExchangeData `json:"exchanges"`
|
||||
Details []QuoteDetail `json:"quoteDetails"`
|
||||
ChartData []ChartResponse `json:"charts"`
|
||||
}
|
||||
|
||||
// ExchangeData from Finance/Exchanges
|
||||
type ExchangeData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Timezone string `json:"timeZone"`
|
||||
}
|
||||
|
||||
// QuoteDetail provides extended quote information
|
||||
type QuoteDetail struct {
|
||||
ID string `json:"id"`
|
||||
Beta float64 `json:"beta"`
|
||||
TrailingPE float64 `json:"trailingPE"`
|
||||
ForwardPE float64 `json:"forwardPE"`
|
||||
PriceToBook float64 `json:"priceToBook"`
|
||||
PriceToSales float64 `json:"priceToSales"`
|
||||
EnterpriseValue float64 `json:"enterpriseValue"`
|
||||
EBITDA float64 `json:"ebitda"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
GrossProfit float64 `json:"grossProfit"`
|
||||
FreeCashFlow float64 `json:"freeCashFlow"`
|
||||
DebtToEquity float64 `json:"debtToEquity"`
|
||||
QuickRatio float64 `json:"quickRatio"`
|
||||
CurrentRatio float64 `json:"currentRatio"`
|
||||
ReturnOnEquity float64 `json:"returnOnEquity"`
|
||||
ReturnOnAssets float64 `json:"returnOnAssets"`
|
||||
ProfitMargin float64 `json:"profitMargin"`
|
||||
OperatingMargin float64 `json:"operatingMargin"`
|
||||
GrossMargin float64 `json:"grossMargin"`
|
||||
}
|
||||
|
||||
// ChartResponse from Finance/Charts
|
||||
type ChartResponse struct {
|
||||
ID string `json:"_p"`
|
||||
ChartType string `json:"chartType"` // "1D1M", "1M", "3M", "1Y", "3Y"
|
||||
Symbol string `json:"symbol"`
|
||||
Series ChartSeriesData `json:"series"`
|
||||
Points []ChartPoint `json:"-"` // Computed from Series
|
||||
}
|
||||
|
||||
// ChartSeriesData is the raw series data from the API
|
||||
type ChartSeriesData struct {
|
||||
TimeStamps []string `json:"timeStamps"`
|
||||
Prices []float64 `json:"prices"`
|
||||
OpenPrices []float64 `json:"openPrices"`
|
||||
PricesHigh []float64 `json:"pricesHigh"`
|
||||
PricesLow []float64 `json:"pricesLow"`
|
||||
Volumes []float64 `json:"volumes"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
PriceHigh float64 `json:"priceHigh"`
|
||||
PriceLow float64 `json:"priceLow"`
|
||||
}
|
||||
|
||||
// ToChartPoints converts the series data into chart points
|
||||
func (c *ChartResponse) ToChartPoints() []ChartPoint {
|
||||
if len(c.Series.TimeStamps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
points := make([]ChartPoint, len(c.Series.TimeStamps))
|
||||
for i, ts := range c.Series.TimeStamps {
|
||||
point := ChartPoint{Time: ts}
|
||||
|
||||
if i < len(c.Series.Prices) {
|
||||
point.Price = c.Series.Prices[i]
|
||||
point.Close = c.Series.Prices[i]
|
||||
}
|
||||
if i < len(c.Series.OpenPrices) {
|
||||
point.Open = c.Series.OpenPrices[i]
|
||||
}
|
||||
if i < len(c.Series.PricesHigh) {
|
||||
point.High = c.Series.PricesHigh[i]
|
||||
}
|
||||
if i < len(c.Series.PricesLow) {
|
||||
point.Low = c.Series.PricesLow[i]
|
||||
}
|
||||
if i < len(c.Series.Volumes) {
|
||||
point.Volume = int64(c.Series.Volumes[i])
|
||||
}
|
||||
points[i] = point
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// ChartPoint is a single data point in a chart
|
||||
type ChartPoint struct {
|
||||
Time string `json:"time"`
|
||||
Price float64 `json:"price"`
|
||||
Open float64 `json:"open"`
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Close float64 `json:"close"`
|
||||
Volume int64 `json:"volume"`
|
||||
}
|
||||
|
||||
// EquityResponse from Finance/Equities
|
||||
type EquityResponse []EquityData
|
||||
|
||||
// EquityData represents company information
|
||||
type EquityData struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
ShortName string `json:"shortName"`
|
||||
LongName string `json:"longName"`
|
||||
Description string `json:"description"`
|
||||
Sector string `json:"sector"`
|
||||
Industry string `json:"industry"`
|
||||
Website string `json:"website"`
|
||||
Employees int `json:"fullTimeEmployees"`
|
||||
Address string `json:"address"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
Phone string `json:"phone"`
|
||||
Officers []Officer `json:"officers"`
|
||||
}
|
||||
|
||||
// Officer represents a company executive
|
||||
type Officer struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Age int `json:"age"`
|
||||
YearBorn int `json:"yearBorn"`
|
||||
TotalPay int64 `json:"totalPay"`
|
||||
}
|
||||
|
||||
// FinancialStatementsResponse from Finance/Equities/financialstatements
|
||||
// Response is an array of FinancialStatement objects
|
||||
type FinancialStatementsResponse []FinancialStatement
|
||||
|
||||
// FinancialStatement represents comprehensive financial data
|
||||
type FinancialStatement struct {
|
||||
UnderlyingInstrument InstrumentInfo `json:"underlyingInstrument"`
|
||||
BalanceSheets *BalanceSheet `json:"balanceSheets"`
|
||||
CashFlow *CashFlowData `json:"cashFlow"`
|
||||
IncomeStatements *IncomeStatement `json:"incomeStatements"`
|
||||
}
|
||||
|
||||
// InstrumentInfo contains basic stock information
|
||||
type InstrumentInfo struct {
|
||||
InstrumentID string `json:"instrumentId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ShortName string `json:"shortName"`
|
||||
ExchangeID string `json:"exchangeId"`
|
||||
ExchangeCode string `json:"exchangeCode"`
|
||||
SecurityType string `json:"securityType"`
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
|
||||
// BalanceSheet represents balance sheet data
|
||||
type BalanceSheet struct {
|
||||
CurrentAssets map[string]float64 `json:"currentAssets"`
|
||||
LongTermAssets map[string]float64 `json:"longTermAssets"`
|
||||
CurrentLiabilities map[string]float64 `json:"currentLiabilities"`
|
||||
Equity map[string]float64 `json:"equity"`
|
||||
Currency string `json:"currency"`
|
||||
Source string `json:"source"`
|
||||
SourceDate string `json:"sourceDate"`
|
||||
ReportDate string `json:"reportDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
}
|
||||
|
||||
// CashFlowData represents cash flow statement
|
||||
type CashFlowData struct {
|
||||
Financing map[string]float64 `json:"financing"`
|
||||
Investing map[string]float64 `json:"investing"`
|
||||
Operating map[string]float64 `json:"operating"`
|
||||
Currency string `json:"currency"`
|
||||
Source string `json:"source"`
|
||||
EndDate string `json:"endDate"`
|
||||
}
|
||||
|
||||
// IncomeStatement represents income statement data
|
||||
type IncomeStatement struct {
|
||||
Revenue map[string]float64 `json:"revenue"`
|
||||
Expenses map[string]float64 `json:"expenses"`
|
||||
Currency string `json:"currency"`
|
||||
Source string `json:"source"`
|
||||
EndDate string `json:"endDate"`
|
||||
}
|
||||
|
||||
// KeyRatiosResponse from api.msn.com keyratios
|
||||
type KeyRatiosResponse []KeyRatios
|
||||
|
||||
// KeyRatios represents financial ratios with historical data
|
||||
type KeyRatios struct {
|
||||
StockID string `json:"stockId"`
|
||||
ExchangeID string `json:"exchangeId"`
|
||||
Market string `json:"market"`
|
||||
Industry string `json:"industry"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ShortName string `json:"shortName"`
|
||||
Symbol string `json:"symbol"`
|
||||
IndustryMetrics []IndustryMetric `json:"industryMetrics"`
|
||||
}
|
||||
|
||||
// IndustryMetric represents financial metrics for a specific year
|
||||
type IndustryMetric struct {
|
||||
Year string `json:"year"`
|
||||
FiscalPeriodType string `json:"fiscalPeriodType"`
|
||||
RevenuePerShare float64 `json:"revenuePerShare"`
|
||||
EarningsPerShare float64 `json:"earningsPerShare"`
|
||||
FreeCashFlowPerShare float64 `json:"freeCashFlowPerShare"`
|
||||
DividendPerShare float64 `json:"dividendPerShare"`
|
||||
BookValuePerShare float64 `json:"bookValuePerShare"`
|
||||
RevenueGrowthRate float64 `json:"revenueGrowthRate"`
|
||||
EarningsGrowthRate float64 `json:"earningsGrowthRate"`
|
||||
GrossMargin float64 `json:"grossMargin"`
|
||||
OperatingMargin float64 `json:"operatingMargin"`
|
||||
NetMargin float64 `json:"netMargin"`
|
||||
ROE float64 `json:"roe"`
|
||||
ROIC float64 `json:"roic"`
|
||||
ROA float64 `json:"returnOnAssetCurrent"`
|
||||
DebtToEquityRatio float64 `json:"debtToEquityRatio"`
|
||||
DebtToEBITDA float64 `json:"debtToEbitda"`
|
||||
FinancialLeverage float64 `json:"financialLeverage"`
|
||||
QuickRatio float64 `json:"quickRatio"`
|
||||
CurrentRatio float64 `json:"currentRatio"`
|
||||
AssetTurnover float64 `json:"assetTurnover"`
|
||||
InventoryTurnover float64 `json:"inventoryTurnover"`
|
||||
ReceivableTurnover float64 `json:"receivableTurnover"`
|
||||
PayoutRatio float64 `json:"payoutRatio"`
|
||||
PriceToSalesRatio float64 `json:"priceToSalesRatio"`
|
||||
PriceToEarningsRatio float64 `json:"priceToEarningsRatio"`
|
||||
PriceToCashFlowRatio float64 `json:"priceToCashFlowRatio"`
|
||||
PriceToBookRatio float64 `json:"priceToBookRatio"`
|
||||
EVToEBITDA float64 `json:"evEbitda"`
|
||||
}
|
||||
|
||||
// EarningsAPIResponse represents the actual API response from Finance/Events/Earnings
|
||||
type EarningsAPIResponse struct {
|
||||
History struct {
|
||||
Quarterly map[string]EarningsData `json:"quarterly"`
|
||||
Annual map[string]EarningsData `json:"annual"`
|
||||
} `json:"History"`
|
||||
InstrumentID string `json:"InstrumentId"`
|
||||
Symbol string `json:"Symbol"`
|
||||
}
|
||||
|
||||
// EarningsData represents a single earnings report from the API
|
||||
type EarningsData struct {
|
||||
EpsActual float64 `json:"EpsActual"`
|
||||
EpsSurprise float64 `json:"EpsSurprise"`
|
||||
EpsSurprisePercent float64 `json:"EpsSurprisePercent"`
|
||||
EpsForecast float64 `json:"EpsForecast"`
|
||||
RevenueActual float64 `json:"RevenueActual"`
|
||||
RevenueSurprise float64 `json:"RevenueSurprise"`
|
||||
RevenueForecast float64 `json:"RevenueForecast"`
|
||||
EarningReleaseDate string `json:"EarningReleaseDate"`
|
||||
CiqFiscalPeriodType string `json:"CiqFiscalPeriodType"` // e.g., "Q42025", "Q12026"
|
||||
CalendarPeriodType string `json:"CalendarPeriodType"`
|
||||
}
|
||||
|
||||
// EarningsEvent represents a normalized earnings event for storage
|
||||
type EarningsEvent struct {
|
||||
ID string `json:"id"`
|
||||
EventDate string `json:"eventDate"`
|
||||
FiscalYear int `json:"fiscalYear"`
|
||||
FiscalQuarter int `json:"fiscalQuarter"`
|
||||
EPSEstimate float64 `json:"epsEstimate"`
|
||||
EPSActual float64 `json:"epsActual"`
|
||||
EPSSurprise float64 `json:"epsSurprise"`
|
||||
EPSSurprisePct float64 `json:"epsSurprisePercent"`
|
||||
RevenueEstimate float64 `json:"revenueEstimate"`
|
||||
RevenueActual float64 `json:"revenueActual"`
|
||||
RevenueSurprise float64 `json:"revenueSurprise"`
|
||||
}
|
||||
|
||||
// SentimentResponse from Finance/SentimentBrowser
|
||||
type SentimentResponse []SentimentData
|
||||
|
||||
// SentimentData represents market sentiment for a stock
|
||||
type SentimentData struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Market string `json:"market"`
|
||||
InstrumentID string `json:"instrumentId"`
|
||||
Symbol string `json:"symbol"`
|
||||
SentimentStatistics []SentimentStatistic `json:"sentimentStatistics"`
|
||||
}
|
||||
|
||||
// SentimentStatistic represents sentiment data for a time period
|
||||
type SentimentStatistic struct {
|
||||
StartTime int64 `json:"startTime"`
|
||||
EndTime int64 `json:"endTime"`
|
||||
TimeRangeName string `json:"timeRangeName"`
|
||||
TimeRangeEnum string `json:"timeRangeEnum"`
|
||||
Bullish int `json:"bullish"`
|
||||
Bearish int `json:"bearish"`
|
||||
Neutral int `json:"neutral"`
|
||||
BullishPercent float64 `json:"bullishPercent"`
|
||||
BearishPercent float64 `json:"bearishPercent"`
|
||||
NeutralPercent float64 `json:"neutralPercent"`
|
||||
Scenario string `json:"scenairo"` // Note: API has typo "scenairo"
|
||||
}
|
||||
|
||||
// InsightsResponse from api.msn.com insights
|
||||
type InsightsResponse []InsightData
|
||||
|
||||
// InsightData represents AI-generated stock insights
|
||||
type InsightData struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Highlights []string `json:"highlights"`
|
||||
Risks []string `json:"risks"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// NewsFeedResponse from MSN/Feed/me
|
||||
type NewsFeedResponse struct {
|
||||
Value []NewsItem `json:"value"`
|
||||
SubCards []NewsItem `json:"subCards"`
|
||||
}
|
||||
|
||||
// NewsItem represents a news article
|
||||
type NewsItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"abstract"`
|
||||
Provider *NewsProvider `json:"provider"`
|
||||
PublishTime string `json:"publishedDateTime"`
|
||||
Images []NewsImage `json:"images"`
|
||||
ReadTimeMin int `json:"readTimeMin"`
|
||||
}
|
||||
|
||||
// NewsProvider represents a news provider
|
||||
type NewsProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// NewsImage represents a news article image
|
||||
type NewsImage struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// Holder represents an institutional holder
|
||||
type Holder struct {
|
||||
Name string `json:"investorName"`
|
||||
Type string `json:"investorType"`
|
||||
SharesHeld int64 `json:"sharesHeld"`
|
||||
SharesChange int64 `json:"sharesChange"`
|
||||
SharesPct float64 `json:"sharesPercent"`
|
||||
Value float64 `json:"value"`
|
||||
ReportDate string `json:"reportDate"`
|
||||
}
|
||||
|
||||
// OwnershipResponse from Bing API
|
||||
type OwnershipResponse struct {
|
||||
Records []Holder `json:"records"`
|
||||
SecurityOwnerships []Holder `json:"securityOwnerships"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// OwnershipData aggregates all ownership information
|
||||
type OwnershipData struct {
|
||||
TopHolders []Holder `json:"top_holders"`
|
||||
TopBuyers []Holder `json:"top_buyers"`
|
||||
TopSellers []Holder `json:"top_sellers"`
|
||||
NewHolders []Holder `json:"new_holders"`
|
||||
ExitedHolders []Holder `json:"exited_holders"`
|
||||
}
|
||||
|
||||
// StockData is the complete stock information output
|
||||
type StockData struct {
|
||||
ID string `json:"id"`
|
||||
Ticker string `json:"ticker"`
|
||||
Name string `json:"name"`
|
||||
Exchange string `json:"exchange"`
|
||||
Sector string `json:"sector"`
|
||||
Industry string `json:"industry"`
|
||||
|
||||
// Real-time data
|
||||
Quote *QuoteData `json:"quote,omitempty"`
|
||||
|
||||
// Historical Charts
|
||||
Charts map[string][]ChartPoint `json:"charts,omitempty"`
|
||||
|
||||
// Fundamentals
|
||||
Financials *FinancialData `json:"financials,omitempty"`
|
||||
KeyRatios *KeyRatios `json:"key_ratios,omitempty"`
|
||||
|
||||
// Company Info
|
||||
Company *EquityData `json:"company,omitempty"`
|
||||
|
||||
// Events
|
||||
Earnings []EarningsEvent `json:"earnings,omitempty"`
|
||||
|
||||
// Analysis
|
||||
Sentiment *SentimentData `json:"sentiment,omitempty"`
|
||||
Insights *InsightData `json:"insights,omitempty"`
|
||||
|
||||
// Ownership (Bing API)
|
||||
Ownership *OwnershipData `json:"ownership,omitempty"`
|
||||
|
||||
// News
|
||||
News []NewsItem `json:"news,omitempty"`
|
||||
|
||||
// Metadata
|
||||
FetchedAt string `json:"fetched_at"`
|
||||
FetchStatus map[string]string `json:"fetch_status"`
|
||||
}
|
||||
|
||||
// FinancialData aggregates all financial statements
|
||||
type FinancialData struct {
|
||||
Statements []FinancialStatement `json:"statements,omitempty"`
|
||||
}
|
||||
|
||||
// ScreenerOutput is the JSON output for screener command
|
||||
type ScreenerOutput struct {
|
||||
Filter string `json:"filter"`
|
||||
Region string `json:"region"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Total int `json:"total"`
|
||||
Stocks []ScreenerStock `json:"stocks"`
|
||||
}
|
||||
|
||||
// FetchOutput is the JSON output for fetch command
|
||||
type FetchOutput struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Total int `json:"total"`
|
||||
Stocks []StockData `json:"stocks"`
|
||||
}
|
||||
194
msn/news_analysis.go
Normal file
194
msn/news_analysis.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package msn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// News categories
|
||||
const (
|
||||
CategoryEarnings = "earnings"
|
||||
CategoryDividend = "dividend"
|
||||
CategoryCorporateAction = "corporate_action"
|
||||
CategoryRegulation = "regulation"
|
||||
CategoryRating = "rating"
|
||||
CategoryExpansion = "expansion"
|
||||
CategoryLeadership = "leadership"
|
||||
CategoryMarket = "market"
|
||||
CategoryGeneral = "general"
|
||||
)
|
||||
|
||||
// Sentiment types
|
||||
const (
|
||||
SentimentPositive = "positive"
|
||||
SentimentNegative = "negative"
|
||||
SentimentNeutral = "neutral"
|
||||
)
|
||||
|
||||
// Category keywords (Indonesian + English)
|
||||
var categoryKeywords = map[string][]string{
|
||||
CategoryEarnings: {
|
||||
"laba", "rugi", "earnings", "profit", "net income", "pendapatan",
|
||||
"revenue", "keuntungan", "kerugian", "loss", "income", "untung",
|
||||
"quarterly", "kuartalan", "annual report", "laporan tahunan",
|
||||
"eps", "earning per share",
|
||||
},
|
||||
CategoryDividend: {
|
||||
"dividen", "dividend", "pembagian", "interim", "final dividend",
|
||||
"cum date", "ex date", "payment date", "tanggal pembayaran",
|
||||
"yield", "payout",
|
||||
},
|
||||
CategoryCorporateAction: {
|
||||
"akuisisi", "merger", "acquisition", "rights issue", "stock split",
|
||||
"reverse split", "buyback", "ipo", "penawaran umum", "private placement",
|
||||
"tender offer", "spin off", "spinoff", "demerger", "konsolidasi",
|
||||
"rights", "waran", "warrant", "obligasi", "bond", "sukuk",
|
||||
},
|
||||
CategoryRegulation: {
|
||||
"ojk", "regulasi", "peraturan", "kebijakan", "regulation", "policy",
|
||||
"compliance", "kepatuhan", "lisensi", "license", "izin", "permit",
|
||||
"pemerintah", "government", "bapepam", "bei", "idx", "bursa",
|
||||
},
|
||||
CategoryRating: {
|
||||
"rating", "peringkat", "upgrade", "downgrade", "outlook",
|
||||
"stable", "positive", "negative", "credit rating", "moody",
|
||||
"fitch", "s&p", "pefindo", "target price", "rekomendasi",
|
||||
"buy", "sell", "hold", "analyst",
|
||||
},
|
||||
CategoryExpansion: {
|
||||
"ekspansi", "expansion", "investasi", "investment", "proyek baru",
|
||||
"new project", "pabrik", "factory", "plant", "cabang", "branch",
|
||||
"pembangunan", "construction", "development", "joint venture", "jv",
|
||||
"kerjasama", "partnership", "kontrak", "contract",
|
||||
},
|
||||
CategoryLeadership: {
|
||||
"direktur", "director", "komisaris", "commissioner", "ceo", "cfo",
|
||||
"president director", "management", "manajemen", "direksi",
|
||||
"rups", "agm", "annual general meeting", "pengangkatan", "appointment",
|
||||
"pengunduran", "resignation", "pergantian", "change",
|
||||
},
|
||||
CategoryMarket: {
|
||||
"ihsg", "idx", "pasar modal", "bursa", "market", "saham",
|
||||
"stock", "trading", "perdagangan", "volume", "kapitalisasi",
|
||||
"market cap", "blue chip", "lq45", "idx80", "kompas100",
|
||||
},
|
||||
}
|
||||
|
||||
// Positive sentiment keywords
|
||||
var positiveKeywords = []string{
|
||||
// Indonesian
|
||||
"naik", "untung", "tumbuh", "positif", "optimis", "meningkat",
|
||||
"surplus", "berhasil", "sukses", "cemerlang", "bagus", "baik",
|
||||
"membaik", "melonjak", "meroket", "tertinggi", "rekor",
|
||||
"peningkatan", "pertumbuhan", "keuntungan", "laba bersih",
|
||||
"ekspansi", "pemulihan", "recovery",
|
||||
// English
|
||||
"rise", "gain", "growth", "positive", "optimistic", "increase",
|
||||
"surplus", "success", "excellent", "good", "improve", "surge",
|
||||
"soar", "highest", "record", "profit", "expansion", "recovery",
|
||||
"bullish", "upgrade", "beat", "exceed", "outperform",
|
||||
}
|
||||
|
||||
// Negative sentiment keywords
|
||||
var negativeKeywords = []string{
|
||||
// Indonesian
|
||||
"turun", "rugi", "anjlok", "negatif", "pesimis", "menurun",
|
||||
"defisit", "gagal", "buruk", "memburuk", "jatuh", "tertekan",
|
||||
"terendah", "penurunan", "kerugian", "merosot", "melemah",
|
||||
"default", "bangkrut", "pailit", "koreksi", "tekanan",
|
||||
// English
|
||||
"fall", "loss", "plunge", "negative", "pessimistic", "decrease",
|
||||
"deficit", "fail", "bad", "worsen", "drop", "pressure",
|
||||
"lowest", "decline", "weak", "default", "bankrupt", "correction",
|
||||
"bearish", "downgrade", "miss", "underperform", "concern", "risk",
|
||||
}
|
||||
|
||||
// Critical news keywords (alerts)
|
||||
var criticalKeywords = []string{
|
||||
// Indonesian
|
||||
"suspend", "suspensi", "fraud", "penipuan", "korupsi", "corruption",
|
||||
"default", "gagal bayar", "bangkrut", "pailit", "bankruptcy",
|
||||
"delisting", "pencabutan", "investigasi", "investigation",
|
||||
"skandal", "scandal", "illegal", "ilegal", "pelanggaran", "violation",
|
||||
"tuntutan", "lawsuit", "gugatan", "denda", "fine", "sanksi", "sanction",
|
||||
"pkpu", "penundaan", "moratorium", "restrukturisasi utang",
|
||||
// English
|
||||
"suspend", "fraud", "corruption", "default", "bankrupt", "bankruptcy",
|
||||
"delisting", "investigation", "scandal", "illegal", "violation",
|
||||
"lawsuit", "fine", "sanction", "debt restructuring", "warning",
|
||||
"material adverse", "going concern", "audit opinion", "disclaimer",
|
||||
}
|
||||
|
||||
// categorizeNews determines the category of a news article
|
||||
func categorizeNews(title, abstract string) string {
|
||||
text := strings.ToLower(title + " " + abstract)
|
||||
|
||||
// Check each category
|
||||
maxScore := 0
|
||||
bestCategory := CategoryGeneral
|
||||
|
||||
for category, keywords := range categoryKeywords {
|
||||
score := 0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > maxScore {
|
||||
maxScore = score
|
||||
bestCategory = category
|
||||
}
|
||||
}
|
||||
|
||||
return bestCategory
|
||||
}
|
||||
|
||||
// scoreNewsSentiment analyzes sentiment of a news article
|
||||
func scoreNewsSentiment(title, abstract string) (sentiment string, score float64) {
|
||||
text := strings.ToLower(title + " " + abstract)
|
||||
|
||||
positiveScore := 0
|
||||
negativeScore := 0
|
||||
|
||||
for _, keyword := range positiveKeywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
positiveScore++
|
||||
}
|
||||
}
|
||||
|
||||
for _, keyword := range negativeKeywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
negativeScore++
|
||||
}
|
||||
}
|
||||
|
||||
totalScore := positiveScore + negativeScore
|
||||
if totalScore == 0 {
|
||||
return SentimentNeutral, 0.0
|
||||
}
|
||||
|
||||
// Calculate score from -1 (very negative) to +1 (very positive)
|
||||
score = float64(positiveScore-negativeScore) / float64(totalScore)
|
||||
|
||||
if score > 0.2 {
|
||||
sentiment = SentimentPositive
|
||||
} else if score < -0.2 {
|
||||
sentiment = SentimentNegative
|
||||
} else {
|
||||
sentiment = SentimentNeutral
|
||||
}
|
||||
|
||||
return sentiment, score
|
||||
}
|
||||
|
||||
// isNewsCritical checks if news contains critical/alert-worthy content
|
||||
func isNewsCritical(title, abstract string) bool {
|
||||
text := strings.ToLower(title + " " + abstract)
|
||||
|
||||
for _, keyword := range criticalKeywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
97
msn/rate_limiter.go
Normal file
97
msn/rate_limiter.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package msn
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter implements a token bucket rate limiter with random delay
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
tokens float64
|
||||
maxTokens float64
|
||||
refillRate float64 // tokens per second
|
||||
lastRefill time.Time
|
||||
minDelayMs int // minimum delay in milliseconds
|
||||
maxDelayMs int // maximum delay in milliseconds
|
||||
requestCount int64
|
||||
}
|
||||
|
||||
// RateLimiterConfig holds rate limiter configuration
|
||||
type RateLimiterConfig struct {
|
||||
RequestsPerSecond float64 // target RPS
|
||||
MinDelayMs int // minimum random delay
|
||||
MaxDelayMs int // maximum random delay
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(config RateLimiterConfig) *RateLimiter {
|
||||
if config.RequestsPerSecond <= 0 {
|
||||
config.RequestsPerSecond = 10 // default 10 RPS
|
||||
}
|
||||
|
||||
return &RateLimiter{
|
||||
tokens: config.RequestsPerSecond, // start with full bucket
|
||||
maxTokens: config.RequestsPerSecond,
|
||||
refillRate: config.RequestsPerSecond,
|
||||
lastRefill: time.Now(),
|
||||
minDelayMs: config.MinDelayMs,
|
||||
maxDelayMs: config.MaxDelayMs,
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until a token is available and applies random delay
|
||||
func (r *RateLimiter) Wait() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Refill tokens based on elapsed time
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(r.lastRefill).Seconds()
|
||||
r.tokens += elapsed * r.refillRate
|
||||
if r.tokens > r.maxTokens {
|
||||
r.tokens = r.maxTokens
|
||||
}
|
||||
r.lastRefill = now
|
||||
|
||||
// Wait if no tokens available
|
||||
if r.tokens < 1 {
|
||||
waitTime := time.Duration((1-r.tokens)/r.refillRate*1000) * time.Millisecond
|
||||
r.mu.Unlock()
|
||||
time.Sleep(waitTime)
|
||||
r.mu.Lock()
|
||||
r.tokens = 0
|
||||
} else {
|
||||
r.tokens--
|
||||
}
|
||||
|
||||
r.requestCount++
|
||||
|
||||
// Apply random delay if configured
|
||||
if r.maxDelayMs > 0 {
|
||||
delayRange := r.maxDelayMs - r.minDelayMs
|
||||
if delayRange <= 0 {
|
||||
delayRange = 1
|
||||
}
|
||||
delay := r.minDelayMs + rand.Intn(delayRange)
|
||||
r.mu.Unlock()
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
r.mu.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
// RequestCount returns the total number of requests made
|
||||
func (r *RateLimiter) RequestCount() int64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.requestCount
|
||||
}
|
||||
|
||||
// SetRPS dynamically adjusts the rate limit
|
||||
func (r *RateLimiter) SetRPS(rps float64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.maxTokens = rps
|
||||
r.refillRate = rps
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue