first commit
This commit is contained in:
600
services/kiwoom_ws_service.go
Normal file
600
services/kiwoom_ws_service.go
Normal file
@@ -0,0 +1,600 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"stocksearch/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
kiwoomWSURL = "wss://api.kiwoom.com:10000/api/dostk/websocket"
|
||||
writeTimeout = 10 * time.Second // 쓰기 타임아웃
|
||||
)
|
||||
|
||||
// KiwoomWSClient 키움증권 실시간 WebSocket 클라이언트
|
||||
type KiwoomWSClient struct {
|
||||
tokenService *TokenService
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex // conn 보호 + 직렬화된 쓰기 보장
|
||||
|
||||
// 현재 구독 중인 종목 코드 집합 (재연결 시 복구용)
|
||||
subscribed map[string]bool
|
||||
|
||||
// REG 배치 전송용: 짧은 시간 내 요청을 모아 단일 REG로 발송
|
||||
pendingReg []string
|
||||
regTimer *time.Timer
|
||||
|
||||
// 실시간 데이터 수신 콜백
|
||||
onPrice func(price *models.StockPrice)
|
||||
onOrderBook func(ob *models.OrderBook)
|
||||
onProgram func(pg *models.ProgramTrading)
|
||||
onMarketStatus func(ms *models.MarketStatus)
|
||||
onMeta func(meta *models.StockMeta)
|
||||
}
|
||||
|
||||
var kiwoomWSOnce sync.Once
|
||||
var kiwoomWSSvc *KiwoomWSClient
|
||||
|
||||
// GetKiwoomWSClient KiwoomWS 클라이언트 싱글턴 반환
|
||||
func GetKiwoomWSClient(onPrice func(*models.StockPrice)) *KiwoomWSClient {
|
||||
kiwoomWSOnce.Do(func() {
|
||||
kiwoomWSSvc = &KiwoomWSClient{
|
||||
tokenService: GetTokenService(),
|
||||
subscribed: make(map[string]bool),
|
||||
onPrice: onPrice,
|
||||
}
|
||||
})
|
||||
return kiwoomWSSvc
|
||||
}
|
||||
|
||||
// SetCallbacks 추가 실시간 데이터 콜백 등록
|
||||
func (k *KiwoomWSClient) SetCallbacks(
|
||||
onOrderBook func(*models.OrderBook),
|
||||
onProgram func(*models.ProgramTrading),
|
||||
onMarketStatus func(*models.MarketStatus),
|
||||
onMeta func(*models.StockMeta),
|
||||
) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.onOrderBook = onOrderBook
|
||||
k.onProgram = onProgram
|
||||
k.onMarketStatus = onMarketStatus
|
||||
k.onMeta = onMeta
|
||||
}
|
||||
|
||||
// Connect 키움 WS 서버에 연결 후 읽기 루프 시작
|
||||
func (k *KiwoomWSClient) Connect() error {
|
||||
conn, err := k.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
k.mu.Lock()
|
||||
k.conn = conn
|
||||
k.mu.Unlock()
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
go k.readLoop(conn, stopCh)
|
||||
|
||||
log.Println("키움 WS 연결 완료")
|
||||
return nil
|
||||
}
|
||||
|
||||
// dial WSS 연결 수립 후 로그인 패킷 전송
|
||||
func (k *KiwoomWSClient) dial() (*websocket.Conn, error) {
|
||||
// HTTP 헤더 없이 연결 (키움 WS는 헤더 인증 불필요)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(kiwoomWSURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 연결 직후 로그인 패킷 전송 (Bearer 없이 token만)
|
||||
loginMsg := map[string]string{
|
||||
"trnm": "LOGIN",
|
||||
"token": k.tokenService.GetToken(),
|
||||
}
|
||||
data, _ := json.Marshal(loginMsg)
|
||||
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 로그인 응답 대기
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
_, resp, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var loginResp struct {
|
||||
Trnm string `json:"trnm"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &loginResp); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("로그인 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if loginResp.Trnm != "LOGIN" || loginResp.ReturnCode != 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("키움 WS 로그인 실패 [%d]: %s", loginResp.ReturnCode, loginResp.ReturnMsg)
|
||||
}
|
||||
|
||||
// 읽기 데드라인 초기화 (readLoop에서 관리)
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
// 장운영 상태(0s) 글로벌 구독 (item 빈 문자열)
|
||||
k.sendMarketStatusReg(conn)
|
||||
|
||||
log.Println("키움 WS 로그인 성공")
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// sendMarketStatusReg 장운영 상태(0s) 구독 전송 (item="", 전역)
|
||||
func (k *KiwoomWSClient) sendMarketStatusReg(conn *websocket.Conn) {
|
||||
msg := map[string]interface{}{
|
||||
"trnm": "REG",
|
||||
"grp_no": "1",
|
||||
"refresh": "1",
|
||||
"data": []map[string]interface{}{
|
||||
{"item": []string{""}, "type": []string{"0s"}},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("키움 WS 장운영상태 구독 실패: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribePair KRX + NXT 종목을 debounce 배치 REG로 구독
|
||||
// 200ms 내 여러 호출이 들어오면 하나의 REG 메시지로 묶어 전송
|
||||
func (k *KiwoomWSClient) SubscribePair(code string) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
nxt := code + "_NX"
|
||||
if !k.subscribed[code] {
|
||||
k.subscribed[code] = true
|
||||
k.pendingReg = append(k.pendingReg, code)
|
||||
}
|
||||
if !k.subscribed[nxt] {
|
||||
k.subscribed[nxt] = true
|
||||
k.pendingReg = append(k.pendingReg, nxt)
|
||||
}
|
||||
k.scheduleFlush()
|
||||
}
|
||||
|
||||
// scheduleFlush REG 배치 전송 타이머 설정 (mu 보유 상태에서 호출)
|
||||
// 연속 호출 시 타이머를 초기화해 마지막 호출로부터 200ms 후 한 번만 전송
|
||||
func (k *KiwoomWSClient) scheduleFlush() {
|
||||
if k.regTimer != nil {
|
||||
k.regTimer.Stop()
|
||||
}
|
||||
k.regTimer = time.AfterFunc(200*time.Millisecond, k.flushPendingReg)
|
||||
}
|
||||
|
||||
// flushPendingReg 누적된 구독 코드를 단일 REG 메시지로 전송
|
||||
func (k *KiwoomWSClient) flushPendingReg() {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
if len(k.pendingReg) == 0 {
|
||||
return
|
||||
}
|
||||
codes := k.pendingReg
|
||||
k.pendingReg = nil
|
||||
k.sendRegBatch(codes, "1")
|
||||
log.Printf("키움 WS 배치 구독 전송 (%d개): %v", len(codes), codes)
|
||||
}
|
||||
|
||||
// UnsubscribePair KRX + NXT 종목을 단일 REMOVE 메시지로 동시 해제
|
||||
func (k *KiwoomWSClient) UnsubscribePair(code string) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
nxt := code + "_NX"
|
||||
var toRemove []string
|
||||
if k.subscribed[code] {
|
||||
delete(k.subscribed, code)
|
||||
toRemove = append(toRemove, code)
|
||||
}
|
||||
if k.subscribed[nxt] {
|
||||
delete(k.subscribed, nxt)
|
||||
toRemove = append(toRemove, nxt)
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
k.sendRemoveBatch(toRemove)
|
||||
log.Printf("키움 WS 구독 해제: %v", toRemove)
|
||||
}
|
||||
}
|
||||
|
||||
// sendRegBatch 여러 종목을 단일 REG 메시지로 배치 전송 (mu 보유 상태에서 호출)
|
||||
// 0B: KRX + NXT 전체, 0D/0H/0w/0g: KRX 코드만
|
||||
func (k *KiwoomWSClient) sendRegBatch(codes []string, refresh string) {
|
||||
// KRX 전용 코드 추출 (_NX 등 접미사 없는 코드)
|
||||
krxCodes := filterKRXCodes(codes)
|
||||
|
||||
dataItems := []map[string]interface{}{
|
||||
{"item": codes, "type": []string{"0B"}},
|
||||
}
|
||||
if len(krxCodes) > 0 {
|
||||
dataItems = append(dataItems, map[string]interface{}{
|
||||
"item": krxCodes,
|
||||
"type": []string{"0D", "0H", "0w", "0g"},
|
||||
})
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"trnm": "REG",
|
||||
"grp_no": "1",
|
||||
"refresh": refresh,
|
||||
"data": dataItems,
|
||||
}
|
||||
if err := k.write(msg); err != nil {
|
||||
log.Printf("키움 WS 구독 전송 실패: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendRemoveBatch 여러 종목을 단일 REMOVE 메시지로 배치 전송 (mu 보유 상태에서 호출)
|
||||
func (k *KiwoomWSClient) sendRemoveBatch(codes []string) {
|
||||
krxCodes := filterKRXCodes(codes)
|
||||
|
||||
dataItems := []map[string]interface{}{
|
||||
{"item": codes, "type": []string{"0B"}},
|
||||
}
|
||||
if len(krxCodes) > 0 {
|
||||
dataItems = append(dataItems, map[string]interface{}{
|
||||
"item": krxCodes,
|
||||
"type": []string{"0D", "0H", "0w", "0g"},
|
||||
})
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"trnm": "REMOVE",
|
||||
"grp_no": "1",
|
||||
"data": dataItems,
|
||||
}
|
||||
if err := k.write(msg); err != nil {
|
||||
log.Printf("키움 WS 구독해제 전송 실패: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// filterKRXCodes 접미사 없는 KRX 코드만 반환
|
||||
func filterKRXCodes(codes []string) []string {
|
||||
var krx []string
|
||||
for _, c := range codes {
|
||||
if !strings.Contains(c, "_") {
|
||||
krx = append(krx, c)
|
||||
}
|
||||
}
|
||||
return krx
|
||||
}
|
||||
|
||||
// write JSON 메시지 전송 (mu 보유 상태에서 호출)
|
||||
func (k *KiwoomWSClient) write(v interface{}) error {
|
||||
if k.conn == nil {
|
||||
return nil
|
||||
}
|
||||
data, _ := json.Marshal(v)
|
||||
k.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
return k.conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
// readLoop 키움 WS 메시지 수신 루프
|
||||
func (k *KiwoomWSClient) readLoop(conn *websocket.Conn, stopCh chan struct{}) {
|
||||
defer func() {
|
||||
close(stopCh)
|
||||
|
||||
k.mu.Lock()
|
||||
if k.conn == conn {
|
||||
k.conn = nil
|
||||
}
|
||||
k.mu.Unlock()
|
||||
|
||||
conn.Close()
|
||||
log.Println("키움 WS 연결 끊김, 재연결 시도...")
|
||||
go k.reconnect()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
log.Printf("키움 WS 읽기 오류: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
k.handleMessage(data)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage 수신 메시지 파싱 및 콜백 호출
|
||||
func (k *KiwoomWSClient) handleMessage(data []byte) {
|
||||
var msg struct {
|
||||
Trnm string `json:"trnm"`
|
||||
Data []struct {
|
||||
Type string `json:"type"`
|
||||
Item string `json:"item"`
|
||||
Values map[string]string `json:"values"`
|
||||
} `json:"data"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Trnm {
|
||||
case "PING":
|
||||
// 키움 서버 PING 수신 → PONG 응답 전송
|
||||
k.mu.Lock()
|
||||
_ = k.write(map[string]string{"trnm": "PONG"})
|
||||
k.mu.Unlock()
|
||||
case "REG", "REMOVE":
|
||||
if msg.ReturnCode != 0 {
|
||||
log.Printf("키움 WS %s 오류: %s", msg.Trnm, msg.ReturnMsg)
|
||||
}
|
||||
case "REAL":
|
||||
for _, d := range msg.Data {
|
||||
switch d.Type {
|
||||
case "0B":
|
||||
if k.onPrice != nil {
|
||||
k.onPrice(parseRealPrice(d.Item, d.Values))
|
||||
}
|
||||
case "0D":
|
||||
if k.onOrderBook != nil {
|
||||
k.onOrderBook(parseOrderBook(d.Item, d.Values))
|
||||
}
|
||||
case "0H":
|
||||
// 예상체결 데이터 → StockPrice 형식으로 통합
|
||||
if k.onPrice != nil {
|
||||
k.onPrice(parseExpectedPrice(d.Item, d.Values))
|
||||
}
|
||||
case "0w":
|
||||
if k.onProgram != nil {
|
||||
k.onProgram(parseProgramTrading(d.Item, d.Values))
|
||||
}
|
||||
case "0s":
|
||||
if k.onMarketStatus != nil {
|
||||
k.onMarketStatus(parseMarketStatus(d.Values))
|
||||
}
|
||||
case "0g":
|
||||
if k.onMeta != nil {
|
||||
k.onMeta(parseStockMeta(d.Item, d.Values))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect 재연결 및 기존 구독 복구 (지수 백오프)
|
||||
func (k *KiwoomWSClient) reconnect() {
|
||||
k.mu.Lock()
|
||||
codes := make([]string, 0, len(k.subscribed))
|
||||
for code := range k.subscribed {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
k.mu.Unlock()
|
||||
|
||||
delay := 5 * time.Second
|
||||
for {
|
||||
time.Sleep(delay)
|
||||
log.Printf("키움 WS 재연결 시도... (%v 후 다음 시도)", delay*2)
|
||||
|
||||
conn, err := k.dial()
|
||||
if err != nil {
|
||||
log.Printf("키움 WS 재연결 실패: %v", err)
|
||||
if delay < 60*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
k.mu.Lock()
|
||||
k.conn = conn
|
||||
// 기존 구독 복구: 모든 코드를 단일 REG 메시지로 배치 전송
|
||||
if len(codes) > 0 {
|
||||
k.sendRegBatch(codes, "1")
|
||||
}
|
||||
k.mu.Unlock()
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
go k.readLoop(conn, stopCh)
|
||||
|
||||
log.Printf("키움 WS 재연결 성공, %d개 종목 구독 복구", len(codes))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// parseRealPrice 0B 실시간 주식체결 값 → StockPrice 변환
|
||||
// 20=체결시간, 10=현재가, 11=전일대비, 12=등락률, 13=누적거래량, 14=누적거래대금
|
||||
// 15=거래량(체결량), 16=시가, 17=고가, 18=저가, 27=최우선매도호가, 28=최우선매수호가
|
||||
// 228=체결강도, 290=장구분
|
||||
// NXT 종목코드(005930_NX)는 _NX 접미사 제거 후 KRX 코드(005930)로 통일
|
||||
func parseRealPrice(code string, v map[string]string) *models.StockPrice {
|
||||
normalized := strings.TrimPrefix(code, "A")
|
||||
normalized = strings.SplitN(normalized, "_", 2)[0] // _NX, _AL 등 접미사 제거
|
||||
return &models.StockPrice{
|
||||
Code: normalized,
|
||||
CurrentPrice: absInt(parseWSInt(v["10"])),
|
||||
ChangePrice: parseWSInt(v["11"]),
|
||||
ChangeRate: parseWSFloat(v["12"]),
|
||||
Volume: absInt(parseWSInt(v["13"])),
|
||||
TradeMoney: absInt(parseWSInt(v["14"])),
|
||||
TradeVolume: absInt(parseWSInt(v["15"])),
|
||||
Open: absInt(parseWSInt(v["16"])),
|
||||
High: absInt(parseWSInt(v["17"])),
|
||||
Low: absInt(parseWSInt(v["18"])),
|
||||
TradeTime: v["20"],
|
||||
AskPrice1: absInt(parseWSInt(v["27"])),
|
||||
BidPrice1: absInt(parseWSInt(v["28"])),
|
||||
CntrStr: parseWSFloat(v["228"]),
|
||||
MarketStatus: v["290"],
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// parseExpectedPrice 0H 주식예상체결 → StockPrice 변환 (장 전/후 예상체결 시 사용)
|
||||
func parseExpectedPrice(code string, v map[string]string) *models.StockPrice {
|
||||
normalized := strings.TrimPrefix(code, "A")
|
||||
normalized = strings.SplitN(normalized, "_", 2)[0]
|
||||
return &models.StockPrice{
|
||||
Code: normalized,
|
||||
CurrentPrice: absInt(parseWSInt(v["10"])),
|
||||
ChangePrice: parseWSInt(v["11"]),
|
||||
ChangeRate: parseWSFloat(v["12"]),
|
||||
TradeVolume: absInt(parseWSInt(v["15"])),
|
||||
Volume: absInt(parseWSInt(v["13"])),
|
||||
TradeTime: v["20"],
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// parseOrderBook 0D 주식호가잔량 → OrderBook 변환
|
||||
// 41~50=매도호가1~10, 61~70=매도호가수량1~10
|
||||
// 51~60=매수호가1~10, 71~80=매수호가수량1~10
|
||||
// 121=매도총잔량, 125=매수총잔량, 23=예상체결가, 24=예상체결수량
|
||||
func parseOrderBook(code string, v map[string]string) *models.OrderBook {
|
||||
normalized := strings.TrimPrefix(code, "A")
|
||||
normalized = strings.SplitN(normalized, "_", 2)[0]
|
||||
|
||||
ob := &models.OrderBook{
|
||||
Code: normalized,
|
||||
AskTime: v["21"],
|
||||
}
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
askKey := strconv.Itoa(40 + i) // 41..50
|
||||
askVolKey := strconv.Itoa(60 + i) // 61..70
|
||||
bidKey := strconv.Itoa(50 + i) // 51..60
|
||||
bidVolKey := strconv.Itoa(70 + i) // 71..80
|
||||
|
||||
ob.Asks = append(ob.Asks, models.OrderBookEntry{
|
||||
Price: absInt(parseWSInt(v[askKey])),
|
||||
Volume: absInt(parseWSInt(v[askVolKey])),
|
||||
})
|
||||
ob.Bids = append(ob.Bids, models.OrderBookEntry{
|
||||
Price: absInt(parseWSInt(v[bidKey])),
|
||||
Volume: absInt(parseWSInt(v[bidVolKey])),
|
||||
})
|
||||
}
|
||||
|
||||
ob.TotalAskVol = absInt(parseWSInt(v["121"]))
|
||||
ob.TotalBidVol = absInt(parseWSInt(v["125"]))
|
||||
ob.ExpectedPrc = absInt(parseWSInt(v["23"]))
|
||||
ob.ExpectedVol = absInt(parseWSInt(v["24"]))
|
||||
|
||||
return ob
|
||||
}
|
||||
|
||||
// parseProgramTrading 0w 종목프로그램매매 → ProgramTrading 변환
|
||||
func parseProgramTrading(code string, v map[string]string) *models.ProgramTrading {
|
||||
normalized := strings.TrimPrefix(code, "A")
|
||||
normalized = strings.SplitN(normalized, "_", 2)[0]
|
||||
return &models.ProgramTrading{
|
||||
Code: normalized,
|
||||
SellVolume: absInt(parseWSInt(v["202"])),
|
||||
SellAmount: absInt(parseWSInt(v["204"])),
|
||||
BuyVolume: absInt(parseWSInt(v["206"])),
|
||||
BuyAmount: absInt(parseWSInt(v["208"])),
|
||||
NetBuyVolume: parseWSInt(v["210"]),
|
||||
NetBuyAmount: parseWSInt(v["212"]),
|
||||
}
|
||||
}
|
||||
|
||||
// parseMarketStatus 0s 장시작시간 → MarketStatus 변환
|
||||
func parseMarketStatus(v map[string]string) *models.MarketStatus {
|
||||
code := v["215"]
|
||||
return &models.MarketStatus{
|
||||
StatusCode: code,
|
||||
StatusName: marketStatusName(code),
|
||||
Time: v["20"],
|
||||
}
|
||||
}
|
||||
|
||||
// marketStatusName 장운영구분 코드 → 한글 이름
|
||||
func marketStatusName(code string) string {
|
||||
switch code {
|
||||
case "0":
|
||||
return "장시작전"
|
||||
case "2":
|
||||
return "장마감알림"
|
||||
case "3":
|
||||
return "장 중"
|
||||
case "4":
|
||||
return "장마감"
|
||||
case "8":
|
||||
return "정규장마감"
|
||||
case "9":
|
||||
return "전체장마감"
|
||||
case "a":
|
||||
return "시간외종가시작"
|
||||
case "b":
|
||||
return "시간외종가종료"
|
||||
case "c":
|
||||
return "시간외단일가시작"
|
||||
case "d":
|
||||
return "시간외단일가종료"
|
||||
default:
|
||||
return "장외"
|
||||
}
|
||||
}
|
||||
|
||||
// parseStockMeta 0g 주식종목정보 → StockMeta 변환
|
||||
func parseStockMeta(code string, v map[string]string) *models.StockMeta {
|
||||
normalized := strings.TrimPrefix(code, "A")
|
||||
normalized = strings.SplitN(normalized, "_", 2)[0]
|
||||
return &models.StockMeta{
|
||||
Code: normalized,
|
||||
UpperLimit: absInt(parseWSInt(v["305"])),
|
||||
LowerLimit: absInt(parseWSInt(v["306"])),
|
||||
BasePrice: absInt(parseWSInt(v["307"])),
|
||||
}
|
||||
}
|
||||
|
||||
func parseWSInt(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
neg := strings.HasPrefix(s, "-")
|
||||
s = strings.TrimLeft(s, "+-")
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
if neg {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseWSFloat(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
neg := strings.HasPrefix(s, "-")
|
||||
s = strings.TrimLeft(s, "+-")
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
if neg {
|
||||
return -f
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func absInt(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user