alt/services/worker/internal/socket/market.go
toki 620ca92e2e refactor: market status parsing과 backtest runtime 흐름 통합
- parser_map.go 업데이트하여 market status 파싱 로직 통일
- protobuf market.proto 변경사항 적용 (market.pb.go, market.pb.dart)
- socket handlers, market, backtest 관련 테스트 및 런타임 코드 개선
- workerclient와 alt-worker main.go 변경사항 반영
- agent-task archive 이동 (01_import_contract_worker_api → archive/2026/06/)
2026-06-03 20:26:56 +09:00

248 lines
8.8 KiB
Go

package socket
import (
"context"
"errors"
"time"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
)
// importDateLayout matches the YYYYMMDD date fields the import command carries,
// mirroring the job payload date contract so the same dates reach the provider.
const importDateLayout = "20060102"
const (
marketErrorInvalidRequest = "invalid_request"
marketErrorUnavailable = "unavailable"
marketErrorNotFound = "not_found"
marketErrorTimeout = "timeout"
marketErrorInternal = "internal"
)
func marketHandlers(deps Deps) []sessionHandler {
return []sessionHandler{
{
requestType: protoSocket.TypeNameOf(&altv1.ListInstrumentsRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.ListInstrumentsRequest, *altv1.ListInstrumentsResponse](
&client.Communicator,
func(req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
return handleListInstruments(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.ListBarsRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.ListBarsRequest, *altv1.ListBarsResponse](
&client.Communicator,
func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
return handleListBars(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.ImportDailyBarsRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](
&client.Communicator,
func(req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) {
return handleImportDailyBars(deps, req)
},
)
},
},
}
}
// handleImportDailyBars validates the import command, converts it into the
// importer request, and runs the import. The worker owns provider fetch and
// persistence; counts come back so the operator surface can confirm what landed.
func handleImportDailyBars(deps Deps, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) {
if req.GetProvider() == "" {
return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("provider is required")}, nil
}
if req.GetSelectorKind() == "" {
return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("selector_kind is required")}, nil
}
if len(req.GetSymbols()) == 0 {
return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("symbols is required")}, nil
}
mkt, _, err := marketFilterFromProto(req.GetMarket())
if err != nil {
return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil
}
venue, err := venueFromProto(req.GetVenue())
if err != nil {
return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil
}
from, to, err := parseImportDateRange(req.GetFromYyyymmdd(), req.GetToYyyymmdd())
if err != nil {
return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil
}
if deps.DailyBarImporter == nil {
return &altv1.ImportDailyBarsResponse{Error: marketUnavailableError("daily bar import is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
result, err := deps.DailyBarImporter.ImportDailyBars(ctx, importer.DailyBarRequest{
Provider: market.Provider(req.GetProvider()),
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorKind(req.GetSelectorKind()),
Market: mkt,
Venue: venue,
Symbols: req.GetSymbols(),
Name: req.GetName(),
},
From: from,
To: to,
})
if err != nil {
return &altv1.ImportDailyBarsResponse{Error: marketBackendErrorInfo(err)}, nil
}
return &altv1.ImportDailyBarsResponse{
Provider: req.GetProvider(),
InstrumentCount: int32(result.Instruments),
BarCount: int32(result.Bars),
}, nil
}
// parseImportDateRange requires both endpoints of the import date range and
// returns them parsed for the importer request. The live KIS importer requires
// a bounded from/to range and rejects an inverted range, so the worker closes
// those operator-input errors here as invalid_request before reaching the
// importer instead of letting them surface as an internal backend failure.
func parseImportDateRange(fromValue, toValue string) (time.Time, time.Time, error) {
if fromValue == "" {
return time.Time{}, time.Time{}, errors.New("from_yyyymmdd is required")
}
if toValue == "" {
return time.Time{}, time.Time{}, errors.New("to_yyyymmdd is required")
}
from, err := time.Parse(importDateLayout, fromValue)
if err != nil {
return time.Time{}, time.Time{}, errors.New("from_yyyymmdd must be YYYYMMDD")
}
to, err := time.Parse(importDateLayout, toValue)
if err != nil {
return time.Time{}, time.Time{}, errors.New("to_yyyymmdd must be YYYYMMDD")
}
if from.After(to) {
return time.Time{}, time.Time{}, errors.New("from_yyyymmdd cannot be after to_yyyymmdd")
}
return from.UTC(), to.UTC(), nil
}
func handleListInstruments(deps Deps, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
marketFilter, hasMarketFilter, err := marketFilterFromProto(req.GetMarket())
if err != nil {
return &altv1.ListInstrumentsResponse{Error: marketInvalidRequest(err.Error())}, nil
}
if deps.Instruments == nil {
return &altv1.ListInstrumentsResponse{Error: marketUnavailableError("instrument store is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
insts, err := deps.Instruments.ListInstruments(ctx)
if err != nil {
return &altv1.ListInstrumentsResponse{Error: marketBackendErrorInfo(err)}, nil
}
filtered := filterInstruments(insts, marketFilter, hasMarketFilter, req.GetProvider())
return &altv1.ListInstrumentsResponse{Instruments: instrumentsToProto(filtered)}, nil
}
func handleListBars(deps Deps, req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
if req.GetInstrumentId() == "" {
return &altv1.ListBarsResponse{Error: marketInvalidRequest("instrument_id is required")}, nil
}
timeframe, err := timeframeFromProto(req.GetTimeframe())
if err != nil {
return &altv1.ListBarsResponse{Error: marketInvalidRequest(err.Error())}, nil
}
if req.GetFromUnixMs() == 0 {
return &altv1.ListBarsResponse{Error: marketInvalidRequest("from_unix_ms is required")}, nil
}
if req.GetToUnixMs() == 0 {
return &altv1.ListBarsResponse{Error: marketInvalidRequest("to_unix_ms is required")}, nil
}
if req.GetFromUnixMs() > req.GetToUnixMs() {
return &altv1.ListBarsResponse{Error: marketInvalidRequest("from_unix_ms cannot be after to_unix_ms")}, nil
}
if deps.Bars == nil {
return &altv1.ListBarsResponse{Error: marketUnavailableError("bar store is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
from := time.UnixMilli(req.GetFromUnixMs()).UTC()
to := time.UnixMilli(req.GetToUnixMs()).UTC()
bars, err := deps.Bars.GetBars(ctx, market.InstrumentID(req.GetInstrumentId()), timeframe, from, to)
if err != nil {
return &altv1.ListBarsResponse{Error: marketBackendErrorInfo(err)}, nil
}
return &altv1.ListBarsResponse{Bars: barsToProto(bars)}, nil
}
func marketFilterFromProto(m altv1.Market) (market.Market, bool, error) {
switch m {
case altv1.Market_MARKET_UNSPECIFIED:
return "", false, nil
case altv1.Market_MARKET_KR, altv1.Market_MARKET_US:
converted, err := marketFromProto(m)
return converted, true, err
default:
return "", false, errors.New("market filter is unsupported")
}
}
func filterInstruments(insts []market.Instrument, marketFilter market.Market, hasMarketFilter bool, provider string) []market.Instrument {
if !hasMarketFilter && provider == "" {
return insts
}
out := make([]market.Instrument, 0, len(insts))
for _, inst := range insts {
if hasMarketFilter && inst.Market != marketFilter {
continue
}
if provider != "" && inst.ProviderSymbols[provider] == "" {
continue
}
out = append(out, inst)
}
return out
}
func marketInvalidRequest(reason string) *altv1.ErrorInfo {
return errorInfo(marketErrorInvalidRequest, "invalid market request: "+reason)
}
func marketUnavailableError(message string) *altv1.ErrorInfo {
return errorInfo(marketErrorUnavailable, message)
}
func marketBackendErrorInfo(err error) *altv1.ErrorInfo {
if err == nil {
return nil
}
switch {
case errors.Is(err, storage.ErrInstrumentNotFound):
return errorInfo(marketErrorNotFound, err.Error())
case errors.Is(err, context.DeadlineExceeded):
return errorInfo(marketErrorTimeout, err.Error())
default:
return errorInfo(marketErrorInternal, err.Error())
}
}