idx-cli/msn/msn_screener.go

228 lines
6.4 KiB
Go

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)
}
}