- 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
273 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|