alt/services/worker/internal/providers/kis/daily_itemchartprice_test.go
toki 6524afaa20 feat: korea provider implementation and data foundation
- Add types_test.go for market domain contracts
- Add worker provider package for Korea data
- Update market types and go.work.sum
- Remove deprecated 01_provider_foundation task files
2026-05-29 19:54:28 +09:00

273 lines
9.5 KiB
Go

package kis
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
const fixtureDir = "../../../testdata/providers/kis"
func readFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join(fixtureDir, name))
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return data
}
func TestLoadDailyItemChartPriceFixture(t *testing.T) {
req, err := DecodeDailyItemChartPriceRequest(readFixture(t, "daily_itemchartprice_request.sample.json"))
if err != nil {
t.Fatalf("decode request: %v", err)
}
if req.Endpoint != "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" {
t.Errorf("endpoint mismatch: got %q", req.Endpoint)
}
if req.TrID != "FHKST03010100" {
t.Errorf("tr_id mismatch: got %q", req.TrID)
}
resp, err := DecodeDailyItemChartPriceResponse(readFixture(t, "daily_itemchartprice_response.sample.json"))
if err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Output1.ShortCode != "005930" {
t.Errorf("output1 short code mismatch: got %q", resp.Output1.ShortCode)
}
if len(resp.Output2) != 2 {
t.Fatalf("output2 row count: got %d, want 2", len(resp.Output2))
}
}
// expectedDailyBarsFixture is a test-only view of
// daily_bars_normalized.expected.json. It is the contract that
// NormalizeDailyBars must reproduce; keeping it here (not in the production
// package) avoids widening the provider API for test concerns.
type expectedDailyBarsFixture struct {
Instrument struct {
ID string `json:"id"`
Market string `json:"market"`
Venue string `json:"venue"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Currency string `json:"currency"`
ProviderSymbols map[string]string `json:"provider_symbols"`
} `json:"instrument"`
Bars []struct {
InstrumentID string `json:"instrument_id"`
Timeframe string `json:"timeframe"`
Timestamp string `json:"timestamp"`
Open string `json:"open"`
High string `json:"high"`
Low string `json:"low"`
Close string `json:"close"`
Volume string `json:"volume"`
Currency string `json:"currency"`
} `json:"bars"`
}
func decodeExpectedDailyBarsFixture(t *testing.T) expectedDailyBarsFixture {
t.Helper()
var fx expectedDailyBarsFixture
if err := json.Unmarshal(readFixture(t, "daily_bars_normalized.expected.json"), &fx); err != nil {
t.Fatalf("decode expected bars fixture: %v", err)
}
return fx
}
func (fx expectedDailyBarsFixture) instrument() market.Instrument {
return market.Instrument{
ID: market.InstrumentID(fx.Instrument.ID),
Market: market.Market(fx.Instrument.Market),
Venue: market.Venue(fx.Instrument.Venue),
Symbol: fx.Instrument.Symbol,
Name: fx.Instrument.Name,
Currency: market.Currency(fx.Instrument.Currency),
ProviderSymbols: fx.Instrument.ProviderSymbols,
}
}
func (fx expectedDailyBarsFixture) bars(t *testing.T) []market.Bar {
t.Helper()
bars := make([]market.Bar, 0, len(fx.Bars))
for i, b := range fx.Bars {
ts, err := time.Parse(time.RFC3339, b.Timestamp)
if err != nil {
t.Fatalf("parse expected timestamp[%d] %q: %v", i, b.Timestamp, err)
}
cur := market.Currency(b.Currency)
bars = append(bars, market.Bar{
InstrumentID: market.InstrumentID(b.InstrumentID),
Timeframe: market.Timeframe(b.Timeframe),
Timestamp: ts,
Open: market.Price{Currency: cur, Amount: market.Decimal{Value: b.Open}},
High: market.Price{Currency: cur, Amount: market.Decimal{Value: b.High}},
Low: market.Price{Currency: cur, Amount: market.Decimal{Value: b.Low}},
Close: market.Price{Currency: cur, Amount: market.Decimal{Value: b.Close}},
Volume: market.Quantity{Amount: market.Decimal{Value: b.Volume}},
})
}
return bars
}
func TestNormalizeDailyItemChartPriceBars(t *testing.T) {
resp, err := DecodeDailyItemChartPriceResponse(readFixture(t, "daily_itemchartprice_response.sample.json"))
if err != nil {
t.Fatalf("decode response: %v", err)
}
expected := decodeExpectedDailyBarsFixture(t)
inst := expected.instrument()
// Lock the KRX daily defaults the fixture encodes before normalizing.
if inst.ID != market.InstrumentID("KRX:005930") {
t.Errorf("fixture instrument id: got %q, want %q", inst.ID, "KRX:005930")
}
if inst.Currency != market.CurrencyKRW {
t.Errorf("fixture instrument currency: got %q, want %q", inst.Currency, market.CurrencyKRW)
}
if inst.Venue != market.VenueKRX {
t.Errorf("fixture instrument venue: got %q, want %q", inst.Venue, market.VenueKRX)
}
if got := inst.ProviderSymbols[string(market.ProviderKIS)]; got != "005930" {
t.Errorf("fixture kis provider symbol: got %q, want %q", got, "005930")
}
bars, err := NormalizeDailyBars(resp, inst)
if err != nil {
t.Fatalf("normalize bars: %v", err)
}
want := expected.bars(t)
if len(bars) != len(want) {
t.Fatalf("bar count: got %d, want %d", len(bars), len(want))
}
for i := range want {
assertBarEqual(t, i, bars[i], want[i])
}
}
func assertBarEqual(t *testing.T, idx int, got, want market.Bar) {
t.Helper()
if got.InstrumentID != want.InstrumentID {
t.Errorf("bar[%d] instrument id: got %q, want %q", idx, got.InstrumentID, want.InstrumentID)
}
if got.Timeframe != want.Timeframe {
t.Errorf("bar[%d] timeframe: got %q, want %q", idx, got.Timeframe, want.Timeframe)
}
if !got.Timestamp.Equal(want.Timestamp) {
t.Errorf("bar[%d] timestamp: got %s, want %s", idx, got.Timestamp.Format(time.RFC3339), want.Timestamp.Format(time.RFC3339))
}
// Lock the +09:00 wall-clock representation, not just the instant.
if got.Timestamp.Format(time.RFC3339) != want.Timestamp.Format(time.RFC3339) {
t.Errorf("bar[%d] timestamp offset: got %s, want %s", idx, got.Timestamp.Format(time.RFC3339), want.Timestamp.Format(time.RFC3339))
}
assertPrice(t, idx, "open", got.Open, want.Open)
assertPrice(t, idx, "high", got.High, want.High)
assertPrice(t, idx, "low", got.Low, want.Low)
assertPrice(t, idx, "close", got.Close, want.Close)
if got.Volume.Amount.Value != want.Volume.Amount.Value {
t.Errorf("bar[%d] volume: got %q, want %q", idx, got.Volume.Amount.Value, want.Volume.Amount.Value)
}
}
func assertPrice(t *testing.T, idx int, field string, got, want market.Price) {
t.Helper()
if got.Currency != want.Currency {
t.Errorf("bar[%d] %s currency: got %q, want %q", idx, field, got.Currency, want.Currency)
}
if got.Amount.Value != want.Amount.Value {
t.Errorf("bar[%d] %s amount: got %q, want %q", idx, field, got.Amount.Value, want.Amount.Value)
}
}
// providerSymbolsFixture is a test-only view of provider_symbols.sample.json. It
// asserts that the selector vocabulary added to packages/domain/market stays in
// sync with the fixture that downstream universe/import work will consume.
type providerSymbolsFixture struct {
Provider string `json:"provider"`
Selectors []struct {
Kind string `json:"kind"`
Venue string `json:"venue"`
Name string `json:"name"`
Symbols []string `json:"symbols"`
} `json:"selectors"`
Instruments []struct {
ID string `json:"id"`
Market string `json:"market"`
Venue string `json:"venue"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Currency string `json:"currency"`
ProviderSymbols map[string]string `json:"provider_symbols"`
} `json:"instruments"`
}
func TestProviderSymbolsFixtureVocabulary(t *testing.T) {
var fx providerSymbolsFixture
if err := json.Unmarshal(readFixture(t, "provider_symbols.sample.json"), &fx); err != nil {
t.Fatalf("decode provider symbols fixture: %v", err)
}
if market.Provider(fx.Provider) != market.ProviderKIS {
t.Errorf("provider: got %q, want %q", fx.Provider, market.ProviderKIS)
}
wantKinds := map[market.UniverseSelectorKind]bool{
market.UniverseSelectorWatchlist: false,
market.UniverseSelectorExchange: false,
market.UniverseSelectorSector: false,
}
for i, sel := range fx.Selectors {
kind := market.UniverseSelectorKind(sel.Kind)
if _, ok := wantKinds[kind]; !ok {
t.Errorf("selector[%d] unexpected kind %q", i, sel.Kind)
continue
}
wantKinds[kind] = true
if len(sel.Symbols) == 0 {
t.Errorf("selector[%d] %q has no symbols", i, sel.Kind)
}
switch kind {
case market.UniverseSelectorExchange:
if market.Venue(sel.Venue) != market.VenueKRX {
t.Errorf("exchange selector venue: got %q, want %q", sel.Venue, market.VenueKRX)
}
case market.UniverseSelectorSector:
if sel.Name == "" {
t.Errorf("sector selector missing name")
}
}
}
for kind, seen := range wantKinds {
if !seen {
t.Errorf("selector kind %q missing from fixture", kind)
}
}
if len(fx.Instruments) == 0 {
t.Fatal("provider symbols fixture has no instruments")
}
for i, inst := range fx.Instruments {
if market.Venue(inst.Venue) != market.VenueKRX {
t.Errorf("instrument[%d] venue: got %q, want %q", i, inst.Venue, market.VenueKRX)
}
if market.Currency(inst.Currency) != market.CurrencyKRW {
t.Errorf("instrument[%d] currency: got %q, want %q", i, inst.Currency, market.CurrencyKRW)
}
sym, ok := inst.ProviderSymbols[string(market.ProviderKIS)]
if !ok || sym == "" {
t.Errorf("instrument[%d] missing kis provider symbol", i)
continue
}
if sym != inst.Symbol {
t.Errorf("instrument[%d] kis provider symbol %q != symbol %q", i, sym, inst.Symbol)
}
}
}