first commit
This commit is contained in:
377
services/account_service.go
Normal file
377
services/account_service.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AccountService 계좌 조회 서비스 (잔고/미체결/체결내역/주문가능)
|
||||
type AccountService struct {
|
||||
client *KiwoomClient
|
||||
}
|
||||
|
||||
var accountService *AccountService
|
||||
|
||||
// GetAccountService 계좌 서비스 싱글턴 반환
|
||||
func GetAccountService() *AccountService {
|
||||
if accountService == nil {
|
||||
accountService = &AccountService{client: GetKiwoomClient()}
|
||||
}
|
||||
return accountService
|
||||
}
|
||||
|
||||
// --- 미체결 ---
|
||||
|
||||
// PendingOrder 미체결 주문 항목
|
||||
type PendingOrder struct {
|
||||
OrdNo string `json:"ordNo"` // 주문번호
|
||||
StkCd string `json:"stkCd"` // 종목코드
|
||||
StkNm string `json:"stkNm"` // 종목명
|
||||
OrdQty string `json:"ordQty"` // 주문수량
|
||||
OrdPric string `json:"ordPric"` // 주문가격
|
||||
OsoQty string `json:"osoQty"` // 미체결수량
|
||||
IoTpNm string `json:"ioTpNm"` // 주문구분
|
||||
TrdeTp string `json:"trdeTp"` // 매매구분 (1:매도, 2:매수)
|
||||
Tm string `json:"tm"` // 시간
|
||||
CntrPric string `json:"cntrPric"` // 체결가
|
||||
CntrQty string `json:"cntrQty"` // 체결량
|
||||
}
|
||||
|
||||
// GetPendingOrders 미체결 주문 조회 (ka10075)
|
||||
func (s *AccountService) GetPendingOrders() ([]PendingOrder, error) {
|
||||
body := map[string]string{
|
||||
"all_stk_tp": "0", // 전체종목
|
||||
"trde_tp": "0", // 전체
|
||||
"stk_cd": "",
|
||||
"stex_tp": "0", // 통합
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("ka10075", "/api/dostk/acnt", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("미체결 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Oso []struct {
|
||||
OrdNo string `json:"ord_no"`
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
OrdQty string `json:"ord_qty"`
|
||||
OrdPric string `json:"ord_pric"`
|
||||
OsoQty string `json:"oso_qty"`
|
||||
IoTpNm string `json:"io_tp_nm"`
|
||||
TrdeTp string `json:"trde_tp"`
|
||||
Tm string `json:"tm"`
|
||||
CntrPric string `json:"cntr_pric"`
|
||||
CntrQty string `json:"cntr_qty"`
|
||||
} `json:"oso"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("미체결 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("미체결 조회 오류: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
orders := make([]PendingOrder, 0, len(result.Oso))
|
||||
for _, o := range result.Oso {
|
||||
orders = append(orders, PendingOrder{
|
||||
OrdNo: o.OrdNo,
|
||||
StkCd: o.StkCd,
|
||||
StkNm: o.StkNm,
|
||||
OrdQty: o.OrdQty,
|
||||
OrdPric: o.OrdPric,
|
||||
OsoQty: o.OsoQty,
|
||||
IoTpNm: o.IoTpNm,
|
||||
TrdeTp: o.TrdeTp,
|
||||
Tm: o.Tm,
|
||||
CntrPric: o.CntrPric,
|
||||
CntrQty: o.CntrQty,
|
||||
})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// --- 체결내역 ---
|
||||
|
||||
// OrderHistory 체결내역 항목
|
||||
type OrderHistory struct {
|
||||
OrdNo string `json:"ordNo"`
|
||||
StkNm string `json:"stkNm"`
|
||||
IoTpNm string `json:"ioTpNm"`
|
||||
OrdPric string `json:"ordPric"`
|
||||
OrdQty string `json:"ordQty"`
|
||||
CntrPric string `json:"cntrPric"`
|
||||
CntrQty string `json:"cntrQty"`
|
||||
OsoQty string `json:"osoQty"`
|
||||
TrdeCmsn string `json:"trdeCmsn"`
|
||||
TrdeTax string `json:"trdeTax"`
|
||||
OrdStt string `json:"ordStt"`
|
||||
TrdeTp string `json:"trdeTp"`
|
||||
OrdTm string `json:"ordTm"`
|
||||
StkCd string `json:"stkCd"`
|
||||
}
|
||||
|
||||
// GetOrderHistory 체결내역 조회 (ka10076)
|
||||
func (s *AccountService) GetOrderHistory() ([]OrderHistory, error) {
|
||||
body := map[string]string{
|
||||
"stk_cd": "",
|
||||
"qry_tp": "0", // 전체
|
||||
"sell_tp": "0", // 전체
|
||||
"ord_no": "",
|
||||
"stex_tp": "0", // 통합
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("ka10076", "/api/dostk/acnt", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("체결내역 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Cntr []struct {
|
||||
OrdNo string `json:"ord_no"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
IoTpNm string `json:"io_tp_nm"`
|
||||
OrdPric string `json:"ord_pric"`
|
||||
OrdQty string `json:"ord_qty"`
|
||||
CntrPric string `json:"cntr_pric"`
|
||||
CntrQty string `json:"cntr_qty"`
|
||||
OsoQty string `json:"oso_qty"`
|
||||
TdyTrdeCmsn string `json:"tdy_trde_cmsn"`
|
||||
TdyTrdeTax string `json:"tdy_trde_tax"`
|
||||
OrdStt string `json:"ord_stt"`
|
||||
TrdeTp string `json:"trde_tp"`
|
||||
OrdTm string `json:"ord_tm"`
|
||||
StkCd string `json:"stk_cd"`
|
||||
} `json:"cntr"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("체결내역 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("체결내역 조회 오류: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
history := make([]OrderHistory, 0, len(result.Cntr))
|
||||
for _, c := range result.Cntr {
|
||||
history = append(history, OrderHistory{
|
||||
OrdNo: c.OrdNo,
|
||||
StkNm: c.StkNm,
|
||||
IoTpNm: c.IoTpNm,
|
||||
OrdPric: c.OrdPric,
|
||||
OrdQty: c.OrdQty,
|
||||
CntrPric: c.CntrPric,
|
||||
CntrQty: c.CntrQty,
|
||||
OsoQty: c.OsoQty,
|
||||
TrdeCmsn: c.TdyTrdeCmsn,
|
||||
TrdeTax: c.TdyTrdeTax,
|
||||
OrdStt: c.OrdStt,
|
||||
TrdeTp: c.TrdeTp,
|
||||
OrdTm: c.OrdTm,
|
||||
StkCd: c.StkCd,
|
||||
})
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// --- 잔고 ---
|
||||
|
||||
// BalanceStock 잔고 개별 종목
|
||||
type BalanceStock struct {
|
||||
StkCd string `json:"stkCd"`
|
||||
StkNm string `json:"stkNm"`
|
||||
EvltvPrft string `json:"evltvPrft"` // 평가손익
|
||||
PrftRt string `json:"prftRt"` // 수익률
|
||||
PurPric string `json:"purPric"` // 매입가
|
||||
RmndQty string `json:"rmndQty"` // 보유수량
|
||||
TrdeAbleQty string `json:"trdeAbleQty"` // 매매가능수량
|
||||
CurPrc string `json:"curPrc"` // 현재가
|
||||
PurAmt string `json:"purAmt"` // 매입금액
|
||||
EvltAmt string `json:"evltAmt"` // 평가금액
|
||||
}
|
||||
|
||||
// BalanceResult 잔고 조회 결과
|
||||
type BalanceResult struct {
|
||||
TotPurAmt string `json:"totPurAmt"` // 총매입금액
|
||||
TotEvltAmt string `json:"totEvltAmt"` // 총평가금액
|
||||
TotEvltPl string `json:"totEvltPl"` // 총평가손익
|
||||
TotPrftRt string `json:"totPrftRt"` // 총수익률
|
||||
PrsmDpstAsetAmt string `json:"prsmDpstAsetAmt"` // 추정예탁자산
|
||||
Stocks []BalanceStock `json:"stocks"`
|
||||
}
|
||||
|
||||
// GetBalance 계좌 잔고 조회 (kt00018)
|
||||
func (s *AccountService) GetBalance() (*BalanceResult, error) {
|
||||
body := map[string]string{
|
||||
"qry_tp": "2", // 개별
|
||||
"dmst_stex_tp": "KRX",
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt00018", "/api/dostk/acnt", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("잔고 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TotPurAmt string `json:"tot_pur_amt"`
|
||||
TotEvltAmt string `json:"tot_evlt_amt"`
|
||||
TotEvltPl string `json:"tot_evlt_pl"`
|
||||
TotPrftRt string `json:"tot_prft_rt"`
|
||||
PrsmDpstAsetAmt string `json:"prsm_dpst_aset_amt"`
|
||||
AcntEvltRemnIndvTot []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
EvltvPrft string `json:"evltv_prft"`
|
||||
PrftRt string `json:"prft_rt"`
|
||||
PurPric string `json:"pur_pric"`
|
||||
RmndQty string `json:"rmnd_qty"`
|
||||
TrdeAbleQty string `json:"trde_able_qty"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PurAmt string `json:"pur_amt"`
|
||||
EvltAmt string `json:"evlt_amt"`
|
||||
} `json:"acnt_evlt_remn_indv_tot"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("잔고 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("잔고 조회 오류: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
stocks := make([]BalanceStock, 0, len(result.AcntEvltRemnIndvTot))
|
||||
for _, s := range result.AcntEvltRemnIndvTot {
|
||||
stocks = append(stocks, BalanceStock{
|
||||
StkCd: s.StkCd,
|
||||
StkNm: s.StkNm,
|
||||
EvltvPrft: s.EvltvPrft,
|
||||
PrftRt: s.PrftRt,
|
||||
PurPric: s.PurPric,
|
||||
RmndQty: s.RmndQty,
|
||||
TrdeAbleQty: s.TrdeAbleQty,
|
||||
CurPrc: s.CurPrc,
|
||||
PurAmt: s.PurAmt,
|
||||
EvltAmt: s.EvltAmt,
|
||||
})
|
||||
}
|
||||
|
||||
return &BalanceResult{
|
||||
TotPurAmt: result.TotPurAmt,
|
||||
TotEvltAmt: result.TotEvltAmt,
|
||||
TotEvltPl: result.TotEvltPl,
|
||||
TotPrftRt: result.TotPrftRt,
|
||||
PrsmDpstAsetAmt: result.PrsmDpstAsetAmt,
|
||||
Stocks: stocks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- 예수금상세 ---
|
||||
|
||||
// DepositResult 예수금 상세 결과
|
||||
type DepositResult struct {
|
||||
Entr string `json:"entr"` // 예수금
|
||||
D2Entra string `json:"d2Entra"` // D+2 추정예수금
|
||||
OrdAlowAmt string `json:"ordAlowAmt"` // 주문가능금액
|
||||
}
|
||||
|
||||
// GetDepositDetail 예수금 상세 조회 (kt00001)
|
||||
func (s *AccountService) GetDepositDetail() (*DepositResult, error) {
|
||||
body := map[string]string{
|
||||
"qry_tp": "3", // 추정조회
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt00001", "/api/dostk/acnt", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("예수금 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Entr string `json:"entr"`
|
||||
D2Entra string `json:"d2_entra"`
|
||||
OrdAlowAmt string `json:"ord_alow_amt"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("예수금 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("예수금 조회 오류: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
return &DepositResult{
|
||||
Entr: result.Entr,
|
||||
D2Entra: result.D2Entra,
|
||||
OrdAlowAmt: result.OrdAlowAmt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- 주문가능금액 ---
|
||||
|
||||
// OrderableResult 주문가능금액/수량
|
||||
type OrderableResult struct {
|
||||
OrdAlowa string `json:"ordAlowa"` // 주문가능현금
|
||||
Entr string `json:"entr"` // 예수금
|
||||
OrdAlowq string `json:"ordAlowq"` // 주문가능수량 (증거금100%)
|
||||
}
|
||||
|
||||
// GetOrderable 주문가능금액/수량 조회 (kt00010)
|
||||
// 모의투자 환경에서 RC7006 오류 시 kt00001(예수금상세) 폴백
|
||||
// side: "1"=매도, "2"=매수
|
||||
func (s *AccountService) GetOrderable(code, price, side string) (*OrderableResult, error) {
|
||||
body := map[string]string{
|
||||
"stk_cd": code,
|
||||
"trde_tp": side,
|
||||
"uv": price,
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt00010", "/api/dostk/acnt", body)
|
||||
if err != nil {
|
||||
// 네트워크 오류 시 예수금상세(kt00001)로 폴백
|
||||
return s.orderableFromDeposit()
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OrdAlowa string `json:"ord_alowa"`
|
||||
Entr string `json:"entr"`
|
||||
Profa100OrdAlowq string `json:"profa_100ord_alowq"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("주문가능금액 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
// 모의투자 환경(RC7006) 등 kt00010 미지원 시 kt00001 폴백
|
||||
return s.orderableFromDeposit()
|
||||
}
|
||||
|
||||
return &OrderableResult{
|
||||
OrdAlowa: result.OrdAlowa,
|
||||
Entr: result.Entr,
|
||||
OrdAlowq: result.Profa100OrdAlowq,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// orderableFromDeposit kt00001(예수금상세)로 주문가능금액 조회 (kt00010 폴백)
|
||||
func (s *AccountService) orderableFromDeposit() (*OrderableResult, error) {
|
||||
dep, err := s.GetDepositDetail()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("주문가능금액 조회 실패(폴백): %w", err)
|
||||
}
|
||||
return &OrderableResult{
|
||||
OrdAlowa: dep.OrdAlowAmt,
|
||||
Entr: dep.Entr,
|
||||
OrdAlowq: "0", // kt00001은 수량 미제공
|
||||
}, nil
|
||||
}
|
||||
349
services/analysis_service.go
Normal file
349
services/analysis_service.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
|
||||
|
||||
var (
|
||||
analysisSvcOnce sync.Once
|
||||
analysisSvc *AnalysisService
|
||||
)
|
||||
|
||||
// AnalysisService 공시·뉴스를 Groq LLM으로 분석하는 서비스
|
||||
type AnalysisService struct {
|
||||
dartSvc *DartService
|
||||
newsSvc *NewsService
|
||||
cache *CacheService
|
||||
groqAPIKey string // Groq API 키
|
||||
groqModel string // 사용할 모델명
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// GetAnalysisService 싱글턴 반환
|
||||
func GetAnalysisService(groqAPIKey, groqModel string) *AnalysisService {
|
||||
analysisSvcOnce.Do(func() {
|
||||
analysisSvc = &AnalysisService{
|
||||
dartSvc: GetDartService(),
|
||||
newsSvc: GetNewsService(),
|
||||
cache: GetCacheService(),
|
||||
groqAPIKey: groqAPIKey,
|
||||
groqModel: groqModel,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
})
|
||||
return analysisSvc
|
||||
}
|
||||
|
||||
// Analyze 공시·뉴스를 Groq LLM으로 분석하여 sentiment, reason 반환
|
||||
// sentiment: "호재" | "악재" | "중립" | "정보없음"
|
||||
func (s *AnalysisService) Analyze(code, name string) (string, string) {
|
||||
|
||||
kst, _ := time.LoadLocation("Asia/Seoul")
|
||||
now := time.Now().In(kst)
|
||||
today := now.Format("20060102")
|
||||
yesterday := now.AddDate(0, 0, -1).Format("20060102")
|
||||
|
||||
// 캐시 확인 (10분 TTL)
|
||||
cacheKey := "analysis:" + code + today
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if pair, ok := cached.([2]string); ok {
|
||||
return pair[0], pair[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 공시 조회 (오늘/어제 필터)
|
||||
var disclosureTitles []string
|
||||
disclosures, err := s.dartSvc.GetDisclosures(code)
|
||||
if err != nil {
|
||||
log.Printf("분석서비스 공시 조회 실패 [%s]: %v", code, err)
|
||||
} else {
|
||||
for _, d := range disclosures {
|
||||
if d.RceptDt == today || d.RceptDt == yesterday {
|
||||
disclosureTitles = append(disclosureTitles, d.ReportNm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 뉴스 조회 (오늘/어제 필터)
|
||||
var newsTitles []string
|
||||
newsItems, err := s.newsSvc.GetNews(name)
|
||||
if err != nil {
|
||||
log.Printf("분석서비스 뉴스 조회 실패 [%s]: %v", name, err)
|
||||
} else {
|
||||
for _, item := range newsItems {
|
||||
if isRecentDate(item.PublishedAt, today, yesterday) {
|
||||
newsTitles = append(newsTitles, item.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공시·뉴스 모두 없으면 API 호출 생략
|
||||
if len(disclosureTitles) == 0 && len(newsTitles) == 0 {
|
||||
s.cache.Set(cacheKey, [2]string{"정보없음", ""}, 10*time.Minute)
|
||||
return "정보없음", ""
|
||||
}
|
||||
|
||||
sentiment, reason := s.callGroqAPI(name, disclosureTitles, newsTitles)
|
||||
s.cache.Set(cacheKey, [2]string{sentiment, reason}, 10*time.Minute)
|
||||
return sentiment, reason
|
||||
}
|
||||
|
||||
// callGroqAPI Groq API를 호출하여 호재/악재/중립 분류
|
||||
func (s *AnalysisService) callGroqAPI(name string, disclosures, news []string) (string, string) {
|
||||
disclosureText := "없음"
|
||||
if len(disclosures) > 0 {
|
||||
disclosureText = strings.Join(disclosures, "\n")
|
||||
}
|
||||
newsText := "없음"
|
||||
if len(news) > 0 {
|
||||
newsText = strings.Join(news, "\n")
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
`당신은 주식 투자 전문가입니다. 아래 [%s] 종목의 공시와 뉴스를 보고`+
|
||||
` 투자자 관점에서 호재/악재/중립 중 하나로 분류하고, 이유를 15자 이내로 답하세요.`+
|
||||
` 반드시 JSON만 출력: {"sentiment":"호재","reason":"수주 계약 체결"}`+"\n\n"+
|
||||
"[공시]\n%s\n\n[뉴스]\n%s",
|
||||
name, disclosureText, newsText,
|
||||
)
|
||||
|
||||
return s.callGroq(prompt, func(text string) (string, string) {
|
||||
var result struct {
|
||||
Sentiment string `json:"sentiment"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
log.Printf("Groq 감성 분석 JSON 파싱 실패: %v (text=%s)", err, text)
|
||||
return "중립", ""
|
||||
}
|
||||
switch result.Sentiment {
|
||||
case "호재", "악재", "중립":
|
||||
return result.Sentiment, result.Reason
|
||||
default:
|
||||
return "중립", ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PredictTargetPriceFromSignal 체결강도 급등 종목 시그널로 단기 목표가 추론
|
||||
func (s *AnalysisService) PredictTargetPriceFromSignal(
|
||||
code, name string,
|
||||
currentPrice, high, low, open int64,
|
||||
changeRate, cntrStr, prevCntrStr float64,
|
||||
risingCount int,
|
||||
sentiment, sentimentReason string,
|
||||
) (int64, string) {
|
||||
|
||||
cacheKey := fmt.Sprintf("target:%s:%d", code, currentPrice)
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if pair, ok := cached.([2]interface{}); ok {
|
||||
return pair[0].(int64), pair[1].(string)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
`당신은 주식 단기 매매 전문가입니다. 체결강도가 연속 상승 중인 [%s](%s) 종목을 매수했을 때 단기(당일~2일) 수익 실현 매도 목표가를 추론하세요.
|
||||
목표가는 반드시 현재가(%d원)보다 높아야 합니다.
|
||||
현재가: %d원 / 시가: %d원 / 고가: %d원 / 저가: %d원
|
||||
등락률: %.2f%% / 체결강도: %.2f(직전 %.2f, %d회 연속 상승)
|
||||
공시·뉴스 분석: %s (%s)
|
||||
반드시 JSON만 출력: {"targetPrice":12500,"reason":"체결강도 급등+호재 공시"}`,
|
||||
name, code,
|
||||
currentPrice,
|
||||
currentPrice, open, high, low,
|
||||
changeRate, cntrStr, prevCntrStr, risingCount,
|
||||
sentiment, sentimentReason,
|
||||
)
|
||||
|
||||
price, reason := s.callGroq(prompt, func(text string) (string, string) {
|
||||
var result struct {
|
||||
TargetPrice int64 `json:"targetPrice"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
log.Printf("목표가 추론 JSON 파싱 실패 [%s]: %v (text=%s)", code, err, text)
|
||||
return "", ""
|
||||
}
|
||||
if result.TargetPrice <= currentPrice {
|
||||
log.Printf("목표가 무효 [%s]: AI 반환값 %d원 ≤ 현재가 %d원", code, result.TargetPrice, currentPrice)
|
||||
return "", ""
|
||||
}
|
||||
return fmt.Sprintf("%d", result.TargetPrice), result.Reason
|
||||
})
|
||||
|
||||
if price == "" {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
var targetPrice int64
|
||||
fmt.Sscanf(price, "%d", &targetPrice)
|
||||
s.cache.Set(cacheKey, [2]interface{}{targetPrice, reason}, 5*time.Minute)
|
||||
return targetPrice, reason
|
||||
}
|
||||
|
||||
// PredictNextDayTrend 체결강도·시세·감성 데이터를 바탕으로 익일 주가 추세를 예측
|
||||
// trend: "상승" | "하락" | "횡보", confidence: "높음" | "보통" | "낮음"
|
||||
func (s *AnalysisService) PredictNextDayTrend(
|
||||
code, name string,
|
||||
currentPrice, high, low, open int64,
|
||||
changeRate, cntrStr float64,
|
||||
risingCount int,
|
||||
sentiment, sentimentReason string,
|
||||
) (trend, confidence, reason string) {
|
||||
|
||||
kst, _ := time.LoadLocation("Asia/Seoul")
|
||||
today := time.Now().In(kst).Format("20060102")
|
||||
|
||||
cacheKey := fmt.Sprintf("nextday:%s:%s", code, today)
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if arr, ok := cached.([3]string); ok {
|
||||
return arr[0], arr[1], arr[2]
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
`당신은 한국 주식 단기 매매 전문가입니다. 아래 데이터를 종합하여 [%s](%s) 종목의 익일(다음 거래일) 주가 추세를 예측하세요.
|
||||
|
||||
현재가: %d원 / 시가: %d원 / 고가: %d원 / 저가: %d원
|
||||
당일 등락률: %.2f%% / 체결강도: %.2f (%d회 연속 상승)
|
||||
오늘 공시·뉴스 분석: %s (%s)
|
||||
|
||||
판단 기준:
|
||||
- 체결강도 연속 상승·호재 공시·양봉 마감이면 "상승" 가능성
|
||||
- 체결강도 100 미만·악재 공시·음봉 마감이면 "하락" 가능성
|
||||
- 신호 혼재 혹은 근거 부족이면 "횡보"
|
||||
- 신뢰도는 근거가 명확할수록 "높음", 애매하면 "낮음"
|
||||
|
||||
반드시 JSON만 출력: {"trend":"상승","confidence":"높음","reason":"체결강도 연속 급등+호재 공시"}`,
|
||||
name, code,
|
||||
currentPrice, open, high, low,
|
||||
changeRate, cntrStr, risingCount,
|
||||
sentiment, sentimentReason,
|
||||
)
|
||||
|
||||
raw, reason := s.callGroq(prompt, func(text string) (string, string) {
|
||||
var result struct {
|
||||
Trend string `json:"trend"`
|
||||
Confidence string `json:"confidence"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
log.Printf("익일 추세 JSON 파싱 실패 [%s]: %v (text=%s)", code, err, text)
|
||||
return "", ""
|
||||
}
|
||||
switch result.Trend {
|
||||
case "상승", "하락", "횡보":
|
||||
default:
|
||||
result.Trend = "횡보"
|
||||
}
|
||||
// trend|confidence 를 첫 번째 반환값에 묶어서 전달
|
||||
return result.Trend + "|" + result.Confidence, result.Reason
|
||||
})
|
||||
|
||||
if raw == "" {
|
||||
return "횡보", "", ""
|
||||
}
|
||||
|
||||
parts := strings.SplitN(raw, "|", 2)
|
||||
trend = parts[0]
|
||||
if len(parts) > 1 {
|
||||
confidence = parts[1]
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, [3]string{trend, confidence, reason}, 30*time.Minute)
|
||||
return trend, confidence, reason
|
||||
}
|
||||
|
||||
// callGroq Groq API 공통 호출 함수 (OpenAI 호환)
|
||||
// parseFunc: 응답 텍스트를 파싱하여 결과 반환
|
||||
func (s *AnalysisService) callGroq(prompt string, parseFunc func(string) (string, string)) (string, string) {
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"model": s.groqModel,
|
||||
"stream": false,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
},
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("POST", groqAPIURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
log.Printf("Groq API 요청 생성 실패: %v", err)
|
||||
return "중립", ""
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.groqAPIKey)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Groq API 호출 실패: %v", err)
|
||||
return "중립", ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("Groq API 오류 [%d]: %s", resp.StatusCode, string(body))
|
||||
return "중립", ""
|
||||
}
|
||||
|
||||
// OpenAI 호환 응답 파싱: choices[0].message.content
|
||||
var apiResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
log.Printf("Groq API 응답 파싱 실패: %v", err)
|
||||
return "중립", ""
|
||||
}
|
||||
if len(apiResp.Choices) == 0 {
|
||||
log.Printf("Groq API 응답 choices 비어있음")
|
||||
return "중립", ""
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(apiResp.Choices[0].Message.Content)
|
||||
|
||||
// JSON 블록 추출 (```json ... ``` 감싸진 경우 대비)
|
||||
if idx := strings.Index(text, "{"); idx >= 0 {
|
||||
if end := strings.LastIndex(text, "}"); end >= idx {
|
||||
text = text[idx : end+1]
|
||||
}
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
log.Printf("Groq API 응답 비어있음")
|
||||
return "중립", ""
|
||||
}
|
||||
|
||||
return parseFunc(text)
|
||||
}
|
||||
|
||||
// isRecentDate RFC1123Z 형식 날짜가 today/yesterday 포함 여부 확인
|
||||
func isRecentDate(pubDate, today, yesterday string) bool {
|
||||
// RFC1123Z: "Mon, 02 Jan 2006 15:04:05 -0700"
|
||||
formats := []string{
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
}
|
||||
for _, f := range formats {
|
||||
t, err := time.Parse(f, pubDate)
|
||||
if err == nil {
|
||||
kst, _ := time.LoadLocation("Asia/Seoul")
|
||||
d := t.In(kst).Format("20060102")
|
||||
return d == today || d == yesterday
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
772
services/autotrade_service.go
Normal file
772
services/autotrade_service.go
Normal file
@@ -0,0 +1,772 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"stocksearch/models"
|
||||
)
|
||||
|
||||
// newUUID UUID v4 생성
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
|
||||
const (
|
||||
maxLogEntries = 300 // 최대 로그 보관 건수 (debug 로그 빈도 대비)
|
||||
cooldownMinutes = 5 // 동일 종목 재진입 쿨다운(분)
|
||||
exitLoopSec = 5 // 청산 루프 주기(초)
|
||||
entryLoopSec = 10 // 진입 루프 주기(초)
|
||||
pendingCheckSec = 30 // pending 확인 주기(초)
|
||||
)
|
||||
|
||||
// AutoTradeService 자동매매 엔진 서비스
|
||||
type AutoTradeService struct {
|
||||
scanner *ScannerService
|
||||
orderSvc *OrderService
|
||||
accountSvc *AccountService
|
||||
stockSvc *StockService
|
||||
themeSvc *ThemeService
|
||||
|
||||
mu sync.RWMutex
|
||||
running int32 // atomic: 1=실행, 0=중지
|
||||
rules []models.AutoTradeRule
|
||||
positions map[string]*models.AutoTradePosition // code → 포지션
|
||||
logs []models.AutoTradeLog // 최근 maxLogEntries건
|
||||
cooldown map[string]time.Time // code → 마지막 진입 시각
|
||||
watchSource models.AutoTradeWatchSource // 감시 소스 설정
|
||||
logBroadcaster func(models.AutoTradeLog) // WS 브로드캐스트 콜백
|
||||
}
|
||||
|
||||
var autoTradeService *AutoTradeService
|
||||
|
||||
// GetAutoTradeService 자동매매 서비스 싱글턴 반환
|
||||
func GetAutoTradeService() *AutoTradeService {
|
||||
if autoTradeService == nil {
|
||||
autoTradeService = &AutoTradeService{
|
||||
scanner: GetScannerService(),
|
||||
orderSvc: GetOrderService(),
|
||||
accountSvc: GetAccountService(),
|
||||
stockSvc: GetStockService(),
|
||||
themeSvc: GetThemeService(),
|
||||
positions: make(map[string]*models.AutoTradePosition),
|
||||
cooldown: make(map[string]time.Time),
|
||||
watchSource: models.AutoTradeWatchSource{
|
||||
UseScanner: true,
|
||||
SelectedThemes: []models.ThemeRef{},
|
||||
},
|
||||
}
|
||||
}
|
||||
return autoTradeService
|
||||
}
|
||||
|
||||
// SetLogBroadcaster WS 브로드캐스트 콜백 등록 (main.go에서 Hub 주입 시 호출)
|
||||
func (s *AutoTradeService) SetLogBroadcaster(fn func(models.AutoTradeLog)) {
|
||||
s.mu.Lock()
|
||||
s.logBroadcaster = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetWatchSource 현재 감시 소스 설정 반환
|
||||
func (s *AutoTradeService) GetWatchSource() models.AutoTradeWatchSource {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ws := s.watchSource
|
||||
themes := make([]models.ThemeRef, len(ws.SelectedThemes))
|
||||
copy(themes, ws.SelectedThemes)
|
||||
ws.SelectedThemes = themes
|
||||
return ws
|
||||
}
|
||||
|
||||
// SetWatchSource 감시 소스 설정 업데이트
|
||||
func (s *AutoTradeService) SetWatchSource(ws models.AutoTradeWatchSource) {
|
||||
s.mu.Lock()
|
||||
s.watchSource = ws
|
||||
s.mu.Unlock()
|
||||
|
||||
sources := "없음"
|
||||
if ws.UseScanner && len(ws.SelectedThemes) > 0 {
|
||||
names := make([]string, len(ws.SelectedThemes))
|
||||
for i, t := range ws.SelectedThemes {
|
||||
names[i] = t.Name
|
||||
}
|
||||
sources = fmt.Sprintf("자동감지+테마(%s)", joinStrings(names, ","))
|
||||
} else if ws.UseScanner {
|
||||
sources = "체결강도 자동감지"
|
||||
} else if len(ws.SelectedThemes) > 0 {
|
||||
names := make([]string, len(ws.SelectedThemes))
|
||||
for i, t := range ws.SelectedThemes {
|
||||
names[i] = t.Name
|
||||
}
|
||||
sources = fmt.Sprintf("테마(%s)", joinStrings(names, ","))
|
||||
}
|
||||
s.addLog("info", "", fmt.Sprintf("감시 소스 변경: %s", sources))
|
||||
}
|
||||
|
||||
// joinStrings 문자열 슬라이스를 구분자로 결합
|
||||
func joinStrings(ss []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range ss {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getWatchSignals 설정된 감시 소스에서 신호 목록 수집
|
||||
func (s *AutoTradeService) getWatchSignals() []SignalStock {
|
||||
s.mu.RLock()
|
||||
ws := s.watchSource
|
||||
themes := make([]models.ThemeRef, len(ws.SelectedThemes))
|
||||
copy(themes, ws.SelectedThemes)
|
||||
s.mu.RUnlock()
|
||||
|
||||
var result []SignalStock
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// 체결강도 자동감지 신호 수집
|
||||
if ws.UseScanner {
|
||||
scannerSigs := s.scanner.GetSignals()
|
||||
s.addLog("debug", "", fmt.Sprintf("스캐너 신호 수신: %d개", len(scannerSigs)))
|
||||
for _, sig := range scannerSigs {
|
||||
s.addLog("debug", sig.Code, fmt.Sprintf("스캐너 [%s] 현재가=%s원 체결강도=%.1f RiseScore=%d 유형=%s 등락=%.2f%%",
|
||||
sig.Name, formatComma(sig.CurrentPrice), sig.CntrStr, sig.RiseScore, sig.SignalType, sig.ChangeRate))
|
||||
if !seen[sig.Code] {
|
||||
result = append(result, sig)
|
||||
seen[sig.Code] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 선택된 테마 종목 분석 (테마당 최대 15종목으로 제한해 API 호출량 억제)
|
||||
const maxThemeStocks = 15
|
||||
for _, theme := range themes {
|
||||
detail, err := s.themeSvc.GetThemeStocks(theme.Code, "D")
|
||||
if err != nil {
|
||||
s.addLog("warn", "", fmt.Sprintf("테마 종목 조회 실패 [%s]: %v", theme.Name, err))
|
||||
continue
|
||||
}
|
||||
codes := make([]string, 0, maxThemeStocks)
|
||||
for _, st := range detail.Stocks {
|
||||
if !seen[st.Code] {
|
||||
codes = append(codes, st.Code)
|
||||
if len(codes) >= maxThemeStocks {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
s.addLog("debug", "", fmt.Sprintf("테마[%s] 분석 대상 종목 없음 (중복 제외 후)", theme.Name))
|
||||
continue
|
||||
}
|
||||
s.addLog("debug", "", fmt.Sprintf("테마[%s] %d종목 분석 시작", theme.Name, len(codes)))
|
||||
sigs := s.scanner.AnalyzeWatchlist(codes)
|
||||
s.addLog("debug", "", fmt.Sprintf("테마[%s] 분석 완료: %d개 신호", theme.Name, len(sigs)))
|
||||
for _, sig := range sigs {
|
||||
s.addLog("debug", sig.Code, fmt.Sprintf("테마검증 [%s] 현재가=%s원 체결강도=%.1f RiseScore=%d 유형=%s 등락=%.2f%%",
|
||||
sig.Name, formatComma(sig.CurrentPrice), sig.CntrStr, sig.RiseScore, sig.SignalType, sig.ChangeRate))
|
||||
if !seen[sig.Code] {
|
||||
result = append(result, sig)
|
||||
seen[sig.Code] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Start 자동매매 엔진 시작
|
||||
func (s *AutoTradeService) Start() {
|
||||
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
|
||||
return // 이미 실행 중
|
||||
}
|
||||
s.addLog("info", "", "자동매매 엔진 시작")
|
||||
go s.entryLoop()
|
||||
go s.exitLoop()
|
||||
}
|
||||
|
||||
// Stop 자동매매 엔진 중지
|
||||
func (s *AutoTradeService) Stop() {
|
||||
if atomic.CompareAndSwapInt32(&s.running, 1, 0) {
|
||||
s.addLog("info", "", "자동매매 엔진 중지")
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning 엔진 실행 여부 확인
|
||||
func (s *AutoTradeService) IsRunning() bool {
|
||||
return atomic.LoadInt32(&s.running) == 1
|
||||
}
|
||||
|
||||
// EmergencyStop 긴급 청산: 엔진 중지 후 모든 포지션 시장가 매도
|
||||
func (s *AutoTradeService) EmergencyStop() {
|
||||
s.Stop()
|
||||
|
||||
s.mu.Lock()
|
||||
codes := make([]string, 0, len(s.positions))
|
||||
for code, p := range s.positions {
|
||||
if p.Status == "open" || p.Status == "pending" {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, code := range codes {
|
||||
s.mu.RLock()
|
||||
pos, ok := s.positions[code]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := s.executeSell(pos, "긴급"); err != nil {
|
||||
s.addLog("error", code, fmt.Sprintf("긴급청산 실패: %v", err))
|
||||
}
|
||||
}
|
||||
s.addLog("warn", "", "긴급 전량청산 완료")
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// AddRule 규칙 추가
|
||||
func (s *AutoTradeService) AddRule(rule models.AutoTradeRule) models.AutoTradeRule {
|
||||
rule.ID = newUUID()
|
||||
rule.CreatedAt = time.Now()
|
||||
s.mu.Lock()
|
||||
s.rules = append(s.rules, rule)
|
||||
s.mu.Unlock()
|
||||
s.addLog("info", "", fmt.Sprintf("규칙 추가: %s", rule.Name))
|
||||
return rule
|
||||
}
|
||||
|
||||
// UpdateRule 규칙 수정
|
||||
func (s *AutoTradeService) UpdateRule(id string, updated models.AutoTradeRule) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.rules {
|
||||
if r.ID == id {
|
||||
updated.ID = id
|
||||
updated.CreatedAt = r.CreatedAt
|
||||
s.rules[i] = updated
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DeleteRule 규칙 삭제
|
||||
func (s *AutoTradeService) DeleteRule(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.rules {
|
||||
if r.ID == id {
|
||||
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToggleRule 규칙 활성화/비활성화 토글
|
||||
func (s *AutoTradeService) ToggleRule(id string) (bool, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.rules {
|
||||
if r.ID == id {
|
||||
s.rules[i].Enabled = !r.Enabled
|
||||
return true, s.rules[i].Enabled
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// GetRules 규칙 목록 반환
|
||||
func (s *AutoTradeService) GetRules() []models.AutoTradeRule {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]models.AutoTradeRule, len(s.rules))
|
||||
copy(result, s.rules)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPositions 포지션 목록 반환
|
||||
func (s *AutoTradeService) GetPositions() []*models.AutoTradePosition {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]*models.AutoTradePosition, 0, len(s.positions))
|
||||
for _, p := range s.positions {
|
||||
cp := *p
|
||||
result = append(result, &cp)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetLogs 최근 로그 반환
|
||||
func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]models.AutoTradeLog, len(s.logs))
|
||||
copy(result, s.logs)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetStats 오늘 통계 반환 (매매 횟수, 손익)
|
||||
func (s *AutoTradeService) GetStats() (tradeCount int, totalPL int64) {
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, p := range s.positions {
|
||||
if p.Status == "closed" && !p.ExitTime.Before(today) {
|
||||
tradeCount++
|
||||
totalPL += (p.ExitPrice - p.BuyPrice) * p.Qty
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- 내부 고루틴 ---
|
||||
|
||||
// entryLoop 10초 주기 진입 루프
|
||||
func (s *AutoTradeService) entryLoop() {
|
||||
ticker := time.NewTicker(entryLoopSec * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if atomic.LoadInt32(&s.running) == 0 {
|
||||
return
|
||||
}
|
||||
<-ticker.C
|
||||
if atomic.LoadInt32(&s.running) == 0 {
|
||||
return
|
||||
}
|
||||
s.checkEntries()
|
||||
}
|
||||
}
|
||||
|
||||
// exitLoop 5초 주기 청산 루프
|
||||
func (s *AutoTradeService) exitLoop() {
|
||||
ticker := time.NewTicker(exitLoopSec * time.Second)
|
||||
defer ticker.Stop()
|
||||
pendingTicker := time.NewTicker(pendingCheckSec * time.Second)
|
||||
defer pendingTicker.Stop()
|
||||
|
||||
for {
|
||||
if atomic.LoadInt32(&s.running) == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if atomic.LoadInt32(&s.running) == 0 {
|
||||
return
|
||||
}
|
||||
s.checkExits()
|
||||
case <-pendingTicker.C:
|
||||
if atomic.LoadInt32(&s.running) == 0 {
|
||||
return
|
||||
}
|
||||
s.checkPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkEntries 진입 조건 체크 및 매수 주문
|
||||
func (s *AutoTradeService) checkEntries() {
|
||||
signals := s.getWatchSignals()
|
||||
|
||||
s.mu.RLock()
|
||||
rules := make([]models.AutoTradeRule, len(s.rules))
|
||||
copy(rules, s.rules)
|
||||
s.mu.RUnlock()
|
||||
|
||||
// 활성 규칙 수 계산
|
||||
activeRules := 0
|
||||
for _, r := range rules {
|
||||
if r.Enabled {
|
||||
activeRules++
|
||||
}
|
||||
}
|
||||
|
||||
s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules))
|
||||
|
||||
if len(signals) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sig := range signals {
|
||||
code := sig.Code
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
_, alreadyHeld := s.positions[code]
|
||||
lastEntry, hasCooldown := s.cooldown[code]
|
||||
posCount := s.countActivePositions()
|
||||
s.mu.RUnlock()
|
||||
|
||||
// 신호 검토 로그
|
||||
sentimentStr := sig.Sentiment
|
||||
if sentimentStr == "" {
|
||||
sentimentStr = "중립"
|
||||
}
|
||||
s.addLog("debug", code, fmt.Sprintf("검토 [%s] RiseScore=%d 체결강도=%.1f 감정=%s 유형=%s",
|
||||
sig.Name, sig.RiseScore, sig.CntrStr, sentimentStr, sig.SignalType))
|
||||
|
||||
// 이미 보유 중인 종목 스킵
|
||||
if alreadyHeld {
|
||||
s.addLog("debug", code, "스킵: 이미 보유 중")
|
||||
continue
|
||||
}
|
||||
|
||||
// 쿨다운 체크 (5분)
|
||||
if hasCooldown && time.Since(lastEntry) < cooldownMinutes*time.Minute {
|
||||
remaining := cooldownMinutes*time.Minute - time.Since(lastEntry)
|
||||
s.addLog("debug", code, fmt.Sprintf("스킵: 쿨다운 %.1f분 남음", remaining.Minutes()))
|
||||
continue
|
||||
}
|
||||
|
||||
// 최대 보유 종목 수 초과 스킵
|
||||
if posCount >= rule.MaxPositions {
|
||||
s.addLog("debug", code, fmt.Sprintf("스킵: 최대 포지션 초과 (%d/%d)", posCount, rule.MaxPositions))
|
||||
continue
|
||||
}
|
||||
|
||||
// 진입 조건 체크
|
||||
if sig.RiseScore < rule.MinRiseScore {
|
||||
s.addLog("debug", code, fmt.Sprintf("스킵: RiseScore 미달 (%d < %d)", sig.RiseScore, rule.MinRiseScore))
|
||||
continue
|
||||
}
|
||||
if sig.CntrStr < rule.MinCntrStr {
|
||||
s.addLog("debug", code, fmt.Sprintf("스킵: 체결강도 미달 (%.1f < %.1f)", sig.CntrStr, rule.MinCntrStr))
|
||||
continue
|
||||
}
|
||||
if rule.RequireBullish && sig.Sentiment != "호재" {
|
||||
s.addLog("debug", code, fmt.Sprintf("스킵: AI 호재 없음 (%s)", sentimentStr))
|
||||
continue
|
||||
}
|
||||
|
||||
// 주문가능금액 확인
|
||||
curPriceStr := strconv.FormatInt(sig.CurrentPrice, 10)
|
||||
orderable, err := s.accountSvc.GetOrderable(code, curPriceStr, "2")
|
||||
if err != nil {
|
||||
s.addLog("warn", code, fmt.Sprintf("주문가능금액 조회 실패: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
avail, _ := strconv.ParseInt(orderable.OrdAlowa, 10, 64)
|
||||
if avail < rule.OrderAmount {
|
||||
s.addLog("warn", code, fmt.Sprintf("주문가능금액 부족: 필요 %d원, 가용 %d원", rule.OrderAmount, avail))
|
||||
continue
|
||||
}
|
||||
|
||||
// 주문 수량 계산
|
||||
if sig.CurrentPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
qty := rule.OrderAmount / sig.CurrentPrice
|
||||
if qty <= 0 {
|
||||
s.addLog("warn", code, fmt.Sprintf("주문수량 계산 오류: 주문금액 %d원, 현재가 %d원", rule.OrderAmount, sig.CurrentPrice))
|
||||
continue
|
||||
}
|
||||
|
||||
// 매수 주문 실행 (시장가)
|
||||
result, err := s.orderSvc.Buy(OrderRequest{
|
||||
Exchange: "KRX",
|
||||
Code: code,
|
||||
Qty: strconv.FormatInt(qty, 10),
|
||||
Price: "",
|
||||
TradeTP: "3", // 시장가
|
||||
})
|
||||
if err != nil {
|
||||
s.addLog("error", code, fmt.Sprintf("매수주문 실패: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 포지션 등록
|
||||
pos := &models.AutoTradePosition{
|
||||
Code: code,
|
||||
Name: sig.Name,
|
||||
Qty: qty,
|
||||
OrderNo: result.OrderNo,
|
||||
EntryTime: time.Now(),
|
||||
RuleID: rule.ID,
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.positions[code] = pos
|
||||
s.cooldown[code] = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
s.addLog("info", code, fmt.Sprintf("매수 주문 접수: %s %d주 (주문번호: %s, RiseScore: %d)", sig.Name, qty, result.OrderNo, sig.RiseScore))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkExits 청산 조건 체크
|
||||
func (s *AutoTradeService) checkExits() {
|
||||
s.mu.RLock()
|
||||
codes := make([]string, 0, len(s.positions))
|
||||
for code, p := range s.positions {
|
||||
if p.Status == "open" {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
// 장 마감 전 청산 기준: KST 15:20
|
||||
loc, _ := time.LoadLocation("Asia/Seoul")
|
||||
kstNow := now.In(loc)
|
||||
closeTime := time.Date(kstNow.Year(), kstNow.Month(), kstNow.Day(), 15, 20, 0, 0, loc)
|
||||
|
||||
for _, code := range codes {
|
||||
s.mu.RLock()
|
||||
pos, ok := s.positions[code]
|
||||
if !ok || pos.Status != "open" {
|
||||
s.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
// 포지션 복사
|
||||
posCopy := *pos
|
||||
s.mu.RUnlock()
|
||||
|
||||
// 현재가 조회
|
||||
price, err := s.stockSvc.GetCurrentPrice(code)
|
||||
if err != nil {
|
||||
log.Printf("[자동매매] 현재가 조회 실패 [%s]: %v", code, err)
|
||||
continue
|
||||
}
|
||||
curPrice := price.CurrentPrice
|
||||
|
||||
// 포지션 모니터링 debug 로그
|
||||
if posCopy.BuyPrice > 0 {
|
||||
pl := (float64(curPrice) - float64(posCopy.BuyPrice)) / float64(posCopy.BuyPrice) * 100
|
||||
s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (손절=%s 익절=%s)",
|
||||
posCopy.Name, formatComma(curPrice), pl, formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit)))
|
||||
}
|
||||
|
||||
var reason string
|
||||
switch {
|
||||
case curPrice <= posCopy.StopLoss:
|
||||
reason = "손절"
|
||||
case curPrice >= posCopy.TakeProfit:
|
||||
reason = "익절"
|
||||
case exitBeforeCloseRule(s.rules, &posCopy) && kstNow.After(closeTime):
|
||||
reason = "장마감"
|
||||
case maxHoldExpired(s.rules, &posCopy, now):
|
||||
reason = "시간초과"
|
||||
}
|
||||
|
||||
if reason != "" {
|
||||
if err := s.executeSell(&posCopy, reason); err != nil {
|
||||
s.addLog("error", code, fmt.Sprintf("%s 매도 실패: %v", reason, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkPending pending 포지션 체결 확인
|
||||
func (s *AutoTradeService) checkPending() {
|
||||
s.mu.RLock()
|
||||
pending := make([]*models.AutoTradePosition, 0)
|
||||
for _, p := range s.positions {
|
||||
if p.Status == "pending" {
|
||||
cp := *p
|
||||
pending = append(pending, &cp)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := s.accountSvc.GetBalance()
|
||||
if err != nil {
|
||||
log.Printf("[자동매매] 잔고 조회 실패: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pos := range pending {
|
||||
for _, stock := range balance.Stocks {
|
||||
if stock.StkCd == pos.Code {
|
||||
buyPrice, _ := strconv.ParseInt(stock.PurPric, 10, 64)
|
||||
qty, _ := strconv.ParseInt(stock.RmndQty, 10, 64)
|
||||
if qty <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 청산가 계산
|
||||
rule := s.findRule(pos.RuleID)
|
||||
var stopLoss, takeProfit int64
|
||||
if rule != nil && buyPrice > 0 {
|
||||
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
|
||||
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" {
|
||||
p.BuyPrice = buyPrice
|
||||
p.Qty = qty
|
||||
p.StopLoss = stopLoss
|
||||
p.TakeProfit = takeProfit
|
||||
p.Status = "open"
|
||||
|
||||
// ExitBeforeClose 규칙 값 저장
|
||||
if rule != nil {
|
||||
// 포지션 자체에 규칙 정보가 없으므로 로그만 기록
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)", pos.Name, qty, buyPrice, stopLoss, takeProfit))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeSell 매도 주문 실행 및 포지션 업데이트
|
||||
func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason string) error {
|
||||
if pos.Qty <= 0 {
|
||||
return fmt.Errorf("매도 수량 없음")
|
||||
}
|
||||
|
||||
result, err := s.orderSvc.Sell(OrderRequest{
|
||||
Exchange: "KRX",
|
||||
Code: pos.Code,
|
||||
Qty: strconv.FormatInt(pos.Qty, 10),
|
||||
Price: "",
|
||||
TradeTP: "3", // 시장가
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 현재가 조회 (청산가 근사치)
|
||||
exitPrice := pos.BuyPrice
|
||||
if p, err := s.stockSvc.GetCurrentPrice(pos.Code); err == nil {
|
||||
exitPrice = p.CurrentPrice
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if p, ok := s.positions[pos.Code]; ok {
|
||||
p.Status = "closed"
|
||||
p.ExitTime = time.Now()
|
||||
p.ExitPrice = exitPrice
|
||||
p.ExitReason = reason
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
pl := (exitPrice - pos.BuyPrice) * pos.Qty
|
||||
s.addLog("info", pos.Code, fmt.Sprintf("%s 매도 완료: %s @ %d원 (손익: %+d원, 주문번호: %s)", reason, pos.Name, exitPrice, pl, result.OrderNo))
|
||||
return nil
|
||||
}
|
||||
|
||||
// countActivePositions pending+open 포지션 수
|
||||
func (s *AutoTradeService) countActivePositions() int {
|
||||
count := 0
|
||||
for _, p := range s.positions {
|
||||
if p.Status == "pending" || p.Status == "open" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// findRule ID로 규칙 조회
|
||||
func (s *AutoTradeService) findRule(id string) *models.AutoTradeRule {
|
||||
for _, r := range s.rules {
|
||||
if r.ID == id {
|
||||
cp := r
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLog 로그 추가 (최대 maxLogEntries 유지) + WS 브로드캐스트
|
||||
func (s *AutoTradeService) addLog(level, code, message string) {
|
||||
entry := models.AutoTradeLog{
|
||||
At: time.Now(),
|
||||
Level: level,
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
log.Printf("[자동매매][%s] %s %s", level, code, message)
|
||||
|
||||
s.mu.Lock()
|
||||
s.logs = append([]models.AutoTradeLog{entry}, s.logs...)
|
||||
if len(s.logs) > maxLogEntries {
|
||||
s.logs = s.logs[:maxLogEntries]
|
||||
}
|
||||
broadcaster := s.logBroadcaster
|
||||
s.mu.Unlock()
|
||||
|
||||
// WS 브로드캐스트 (락 밖에서 호출해 데드락 방지)
|
||||
if broadcaster != nil {
|
||||
broadcaster(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// formatComma 천 단위 콤마 포맷 (서비스 내부용)
|
||||
func formatComma(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
s := strconv.FormatInt(n, 10)
|
||||
result := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
result = append(result, ',')
|
||||
}
|
||||
result = append(result, byte(c))
|
||||
}
|
||||
if negative {
|
||||
return "-" + string(result)
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// --- 포지션 헬퍼 함수 (규칙 정보 조회용) ---
|
||||
|
||||
// exitBeforeCloseRule 포지션이 속한 규칙의 ExitBeforeClose 반환
|
||||
func exitBeforeCloseRule(rules []models.AutoTradeRule, p *models.AutoTradePosition) bool {
|
||||
for _, r := range rules {
|
||||
if r.ID == p.RuleID {
|
||||
return r.ExitBeforeClose
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maxHoldExpired 최대 보유 시간 초과 여부 반환
|
||||
func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, now time.Time) bool {
|
||||
for _, r := range rules {
|
||||
if r.ID == p.RuleID {
|
||||
if r.MaxHoldMinutes <= 0 {
|
||||
return false
|
||||
}
|
||||
return now.Sub(p.EntryTime) >= time.Duration(r.MaxHoldMinutes)*time.Minute
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
68
services/cache_service.go
Normal file
68
services/cache_service.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cacheItem 캐시 항목
|
||||
type cacheItem struct {
|
||||
value interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// CacheService 인메모리 TTL 캐시
|
||||
type CacheService struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*cacheItem
|
||||
}
|
||||
|
||||
var cacheSvc *CacheService
|
||||
var cacheOnce sync.Once
|
||||
|
||||
// GetCacheService 캐시 서비스 싱글턴 반환
|
||||
func GetCacheService() *CacheService {
|
||||
cacheOnce.Do(func() {
|
||||
c := &CacheService{items: make(map[string]*cacheItem)}
|
||||
go c.cleanup() // 만료 항목 주기적 정리
|
||||
cacheSvc = c
|
||||
})
|
||||
return cacheSvc
|
||||
}
|
||||
|
||||
// Set 캐시에 값 저장 (ttl: 유효기간)
|
||||
func (c *CacheService) Set(key string, value interface{}, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items[key] = &cacheItem{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// Get 캐시에서 값 조회. 없거나 만료됐으면 nil, false 반환
|
||||
func (c *CacheService) Get(key string) (interface{}, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
item, ok := c.items[key]
|
||||
if !ok || time.Now().After(item.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return item.value, true
|
||||
}
|
||||
|
||||
// cleanup 만료된 캐시 항목을 주기적으로 삭제 (메모리 누수 방지)
|
||||
func (c *CacheService) cleanup() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, item := range c.items {
|
||||
if now.After(item.expiresAt) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
223
services/dart_service.go
Normal file
223
services/dart_service.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"stocksearch/config"
|
||||
"stocksearch/models"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// normalizeStockCode 종목코드에서 6자리 숫자만 추출 (예: "018880_AL" → "018880")
|
||||
func normalizeStockCode(code string) string {
|
||||
if idx := strings.Index(code, "_"); idx >= 0 {
|
||||
return code[:idx]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
var (
|
||||
dartSvcOnce sync.Once
|
||||
dartSvc *DartService
|
||||
)
|
||||
|
||||
// DartService DART 공시 조회 서비스
|
||||
type DartService struct {
|
||||
httpClient *http.Client
|
||||
cache *CacheService
|
||||
apiKey string
|
||||
corpCodeMap map[string]string // stock_code → corp_code 인메모리 맵
|
||||
corpCodeMu sync.RWMutex
|
||||
}
|
||||
|
||||
// GetDartService 싱글턴 반환
|
||||
func GetDartService() *DartService {
|
||||
dartSvcOnce.Do(func() {
|
||||
// opendart.fss.or.kr는 구형 TLS 암호화 스위트를 사용하므로 최소 버전을 TLS 1.0으로 설정
|
||||
// Go 1.22+에서 RSA 키 교환 암호 스위트가 기본 제거됨
|
||||
// opendart.fss.or.kr은 TLS_RSA_WITH_AES_128_GCM_SHA256을 사용하므로 명시적으로 추가
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
},
|
||||
},
|
||||
}
|
||||
dartSvc = &DartService{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
cache: GetCacheService(),
|
||||
apiKey: config.App.DartAPIKey,
|
||||
corpCodeMap: make(map[string]string),
|
||||
}
|
||||
// 서버 시작 시 기업코드 맵 로딩
|
||||
if err := dartSvc.loadCorpCodeMap(); err != nil {
|
||||
log.Printf("DART 기업코드 맵 로딩 실패: %v", err)
|
||||
}
|
||||
})
|
||||
return dartSvc
|
||||
}
|
||||
|
||||
// loadCorpCodeMap 로컬 CORPCODE.xml 파일을 파싱하여 stock_code → corp_code 맵 구성
|
||||
func (s *DartService) loadCorpCodeMap() error {
|
||||
f, err := os.Open("CORPCODE.xml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("CORPCODE.xml 열기 실패: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
type corpItem struct {
|
||||
CorpCode string `xml:"corp_code"`
|
||||
StockCode string `xml:"stock_code"`
|
||||
}
|
||||
type corpResult struct {
|
||||
List []corpItem `xml:"list"`
|
||||
}
|
||||
|
||||
var result corpResult
|
||||
if err := xml.NewDecoder(f).Decode(&result); err != nil {
|
||||
return fmt.Errorf("CORPCODE.xml 디코딩 실패: %w", err)
|
||||
}
|
||||
|
||||
s.corpCodeMu.Lock()
|
||||
for _, item := range result.List {
|
||||
sc := strings.TrimSpace(item.StockCode)
|
||||
cc := strings.TrimSpace(item.CorpCode)
|
||||
if sc != "" && cc != "" {
|
||||
s.corpCodeMap[sc] = cc
|
||||
}
|
||||
}
|
||||
s.corpCodeMu.Unlock()
|
||||
|
||||
log.Printf("DART 기업코드 맵 로딩 완료: 총 %d건 (상장사 %d건)", len(result.List), len(s.corpCodeMap))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDisclosures 종목코드로 최근 공시 10건 반환 (5분 캐시)
|
||||
// DART에 등록되지 않은 종목(ETF, 신규상장 등)은 빈 목록 반환
|
||||
func (s *DartService) GetDisclosures(stockCode string) ([]models.Disclosure, error) {
|
||||
code := normalizeStockCode(stockCode)
|
||||
cacheKey := "disclosure:" + code
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
return cached.([]models.Disclosure), nil
|
||||
}
|
||||
|
||||
corpCode, err := s.getCorpCode(code)
|
||||
if err != nil {
|
||||
// corp_code 없음은 정상 케이스 (ETF, 신규상장 등) - 빈 목록 반환
|
||||
s.cache.Set(cacheKey, []models.Disclosure{}, 30*time.Minute)
|
||||
return []models.Disclosure{}, nil
|
||||
}
|
||||
|
||||
list, err := s.fetchList(corpCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, list, 5*time.Minute)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// getCorpCode 종목코드로 DART 고유번호 반환 (인메모리 맵 조회)
|
||||
func (s *DartService) getCorpCode(stockCode string) (string, error) {
|
||||
s.corpCodeMu.RLock()
|
||||
corpCode, ok := s.corpCodeMap[stockCode]
|
||||
s.corpCodeMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return corpCode, nil
|
||||
}
|
||||
return "", fmt.Errorf("corp_code 없음 (종목코드=%s, 맵 크기=%d)", stockCode, len(s.corpCodeMap))
|
||||
}
|
||||
|
||||
// fetchList DART 고유번호로 최근 공시 목록 조회
|
||||
func (s *DartService) fetchList(corpCode string) ([]models.Disclosure, error) {
|
||||
listURL := "https://opendart.fss.or.kr/api/list.json" +
|
||||
"?crtfc_key=" + s.apiKey +
|
||||
"&corp_code=" + corpCode +
|
||||
"&page_no=1&page_count=10&sort=date&sort_mth=desc"
|
||||
|
||||
resp, err := s.httpClient.Get(listURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DART list API 요청 실패: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
List []struct {
|
||||
RceptNo string `json:"rcept_no"`
|
||||
CorpName string `json:"corp_name"`
|
||||
ReportNm string `json:"report_nm"`
|
||||
RceptDt string `json:"rcept_dt"`
|
||||
FlrNm string `json:"flr_nm"`
|
||||
} `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("DART list API 파싱 실패: %w", err)
|
||||
}
|
||||
if result.Status != "000" && result.Status != "013" {
|
||||
// 013: 조회된 데이터가 없음 (정상 케이스)
|
||||
return nil, fmt.Errorf("DART list API 오류: %s", result.Message)
|
||||
}
|
||||
|
||||
list := make([]models.Disclosure, 0, len(result.List))
|
||||
for _, item := range result.List {
|
||||
list = append(list, models.Disclosure{
|
||||
RceptNo: item.RceptNo,
|
||||
CorpName: item.CorpName,
|
||||
ReportNm: item.ReportNm,
|
||||
RceptDt: item.RceptDt,
|
||||
FlrNm: item.FlrNm,
|
||||
URL: "https://dart.fss.or.kr/dsaf001/main.do?rcpNo=" + item.RceptNo,
|
||||
Tag: tagDisclosure(item.ReportNm),
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// tagDisclosure 보고서명 키워드로 이벤트 유형 태깅
|
||||
func tagDisclosure(reportNm string) string {
|
||||
type rule struct {
|
||||
keywords []string
|
||||
tag string
|
||||
}
|
||||
// 우선순위 순서로 검사 (위에서 먼저 매칭되면 반환)
|
||||
rules := []rule{
|
||||
{[]string{"사업보고서", "분기보고서", "반기보고서"}, "실적"},
|
||||
{[]string{"유상증자"}, "유증"},
|
||||
{[]string{"무상증자"}, "무증"},
|
||||
{[]string{"단일판매", "공급계약", "수주"}, "수주"},
|
||||
{[]string{"소송", "판결", "가처분"}, "소송"},
|
||||
{[]string{"합병", "인수", "양수도", "M&A"}, "M&A"},
|
||||
{[]string{"최대주주", "주요주주", "지분"}, "지분"},
|
||||
{[]string{"자기주식"}, "자사주"},
|
||||
{[]string{"임원", "이사회", "대표이사"}, "경영"},
|
||||
{[]string{"전환사채", "신주인수권"}, "CB/BW"},
|
||||
}
|
||||
for _, r := range rules {
|
||||
for _, kw := range r.keywords {
|
||||
if strings.Contains(reportNm, kw) {
|
||||
return r.tag
|
||||
}
|
||||
}
|
||||
}
|
||||
return "공시"
|
||||
}
|
||||
208
services/index_service.go
Normal file
208
services/index_service.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndexQuote 지수 현재가 정보
|
||||
type IndexQuote struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
Change float64 `json:"change"` // 전일대비
|
||||
ChangeRate float64 `json:"changeRate"` // 등락률(%)
|
||||
}
|
||||
|
||||
// indexCache 지수 캐시 (5초 TTL)
|
||||
type indexCache struct {
|
||||
data []IndexQuote
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
indexSvcOnce sync.Once
|
||||
indexSvc *IndexService
|
||||
)
|
||||
|
||||
// IndexService 국내/해외 주요 지수 조회 서비스
|
||||
type IndexService struct {
|
||||
kiwoom *KiwoomClient
|
||||
httpClient *http.Client
|
||||
mu sync.Mutex
|
||||
cache *indexCache
|
||||
}
|
||||
|
||||
// GetIndexService 싱글턴 반환
|
||||
func GetIndexService() *IndexService {
|
||||
indexSvcOnce.Do(func() {
|
||||
indexSvc = &IndexService{
|
||||
kiwoom: GetKiwoomClient(),
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
})
|
||||
return indexSvc
|
||||
}
|
||||
|
||||
// GetIndices 코스피·코스닥·다우·나스닥 현재가 반환 (5초 캐시)
|
||||
func (s *IndexService) GetIndices() ([]IndexQuote, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cache != nil && time.Now().Before(s.cache.expiresAt) {
|
||||
return s.cache.data, nil
|
||||
}
|
||||
|
||||
quotes := make([]IndexQuote, 0, 4)
|
||||
|
||||
// 국내 지수: 코스피(001), 코스닥(101)
|
||||
domestic := []struct {
|
||||
name string
|
||||
mrktTp string
|
||||
indsCd string
|
||||
}{
|
||||
{"KOSPI", "0", "001"},
|
||||
{"KOSDAQ", "1", "101"},
|
||||
}
|
||||
for _, d := range domestic {
|
||||
q, err := s.fetchKiwoomIndex(d.name, d.mrktTp, d.indsCd)
|
||||
if err != nil {
|
||||
log.Printf("지수 조회 실패 (%s): %v", d.name, err)
|
||||
quotes = append(quotes, IndexQuote{Name: d.name})
|
||||
continue
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
}
|
||||
|
||||
// 해외 지수: 다우(^DJI), 나스닥(^IXIC)
|
||||
overseas, err := s.fetchYahooIndices()
|
||||
if err != nil {
|
||||
log.Printf("해외 지수 조회 실패: %v", err)
|
||||
quotes = append(quotes, IndexQuote{Name: "DOW"}, IndexQuote{Name: "NASDAQ"})
|
||||
} else {
|
||||
quotes = append(quotes, overseas...)
|
||||
}
|
||||
|
||||
s.cache = &indexCache{data: quotes, expiresAt: time.Now().Add(5 * time.Second)}
|
||||
return quotes, nil
|
||||
}
|
||||
|
||||
// fetchKiwoomIndex 키움 ka20001로 업종 현재가 조회
|
||||
func (s *IndexService) fetchKiwoomIndex(name, mrktTp, indsCd string) (IndexQuote, error) {
|
||||
body := map[string]string{
|
||||
"mrkt_tp": mrktTp,
|
||||
"inds_cd": indsCd,
|
||||
}
|
||||
raw, err := s.kiwoom.post("ka20001", "/api/dostk/sect", body)
|
||||
if err != nil {
|
||||
return IndexQuote{Name: name}, err
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return IndexQuote{Name: name}, fmt.Errorf("파싱 실패: %w", err)
|
||||
}
|
||||
|
||||
return IndexQuote{
|
||||
Name: name,
|
||||
Price: parseIndexFloat(resp.CurPrc),
|
||||
Change: parseIndexFloat(resp.PredPre),
|
||||
ChangeRate: parseIndexFloat(resp.FluRt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchYahooIndices Yahoo Finance v8 chart API로 다우·나스닥 조회 (순서 보장)
|
||||
func (s *IndexService) fetchYahooIndices() ([]IndexQuote, error) {
|
||||
targets := []struct {
|
||||
name string
|
||||
symbol string
|
||||
}{
|
||||
{"DOW", "^DJI"},
|
||||
{"NASDAQ", "^IXIC"},
|
||||
}
|
||||
|
||||
quotes := make([]IndexQuote, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
q, err := s.fetchYahooChart(t.name, t.symbol)
|
||||
if err != nil {
|
||||
log.Printf("Yahoo Finance %s 조회 실패: %v", t.name, err)
|
||||
quotes = append(quotes, IndexQuote{Name: t.name}) // 실패 시 0값 플레이스홀더
|
||||
continue
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
}
|
||||
return quotes, nil
|
||||
}
|
||||
|
||||
// fetchYahooChart Yahoo Finance v8 chart API로 단일 지수 조회
|
||||
func (s *IndexService) fetchYahooChart(name, symbol string) (IndexQuote, error) {
|
||||
url := "https://query1.finance.yahoo.com/v8/finance/chart/" + symbol + "?interval=1d&range=1d"
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return IndexQuote{Name: name}, fmt.Errorf("%s 요청 실패: %w", name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
} `json:"meta"`
|
||||
} `json:"result"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return IndexQuote{Name: name}, fmt.Errorf("%s 파싱 실패: %w", name, err)
|
||||
}
|
||||
if len(result.Chart.Result) == 0 {
|
||||
return IndexQuote{Name: name}, fmt.Errorf("%s 데이터 없음", name)
|
||||
}
|
||||
|
||||
meta := result.Chart.Result[0].Meta
|
||||
price := meta.RegularMarketPrice
|
||||
prev := meta.ChartPreviousClose
|
||||
change := price - prev
|
||||
var changeRate float64
|
||||
if prev != 0 {
|
||||
changeRate = (change / prev) * 100
|
||||
}
|
||||
return IndexQuote{
|
||||
Name: name,
|
||||
Price: price,
|
||||
Change: change,
|
||||
ChangeRate: changeRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseIndexFloat 키움 지수 문자열 → float64 (부호 포함)
|
||||
func parseIndexFloat(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
neg := strings.HasPrefix(s, "-")
|
||||
s = strings.TrimLeft(s, "+-")
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
if neg {
|
||||
return -f
|
||||
}
|
||||
return f
|
||||
}
|
||||
575
services/kiwoom_service.go
Normal file
575
services/kiwoom_service.go
Normal file
@@ -0,0 +1,575 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"stocksearch/config"
|
||||
"stocksearch/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// cntrStrCacheEntry getCntrStr 캐시 항목
|
||||
type cntrStrCacheEntry struct {
|
||||
value float64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// KiwoomClient 키움증권 REST API HTTP 클라이언트
|
||||
type KiwoomClient struct {
|
||||
httpClient *http.Client
|
||||
tokenService *TokenService
|
||||
limiter *rate.Limiter
|
||||
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
|
||||
}
|
||||
|
||||
var kiwoomClient *KiwoomClient
|
||||
|
||||
// GetKiwoomClient 키움 클라이언트 싱글턴 반환
|
||||
func GetKiwoomClient() *KiwoomClient {
|
||||
if kiwoomClient == nil {
|
||||
kiwoomClient = &KiwoomClient{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
tokenService: GetTokenService(),
|
||||
// 초당 1건, 버스트 1 → 완전 직렬화 (키움 API 실질 한도 ~1req/s per API ID)
|
||||
limiter: rate.NewLimiter(rate.Limit(1), 1),
|
||||
}
|
||||
}
|
||||
return kiwoomClient
|
||||
}
|
||||
|
||||
// post 공통 POST 요청 (api-id 헤더, JSON body, Rate Limit 적용, 429 재시도)
|
||||
func (k *KiwoomClient) post(apiID string, path string, body map[string]string) ([]byte, error) {
|
||||
const maxRetries = 3
|
||||
backoff := 1 * time.Second
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if err := k.limiter.Wait(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("Rate Limit 대기 실패: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("요청 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
req.Header.Set("authorization", "Bearer "+k.tokenService.GetToken())
|
||||
req.Header.Set("api-id", apiID)
|
||||
req.Header.Set("cont-yn", "N")
|
||||
req.Header.Set("next-key", "")
|
||||
|
||||
resp, err := k.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API 요청 실패: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("응답 읽기 실패: %w", err)
|
||||
}
|
||||
|
||||
// 429: 잠시 대기 후 재시도
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
if attempt < maxRetries-1 {
|
||||
log.Printf("[키움API] 429 Too Many Requests (api-id=%s), %v 후 재시도 (%d/%d)", apiID, backoff, attempt+1, maxRetries)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("API 요청 한도 초과 (api-id=%s): %s", apiID, string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API 응답 오류: HTTP %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// HTML 응답 감지 (서버 점검/리다이렉트 시 HTML 반환)
|
||||
if len(respBody) > 0 && respBody[0] == '<' {
|
||||
return nil, fmt.Errorf("API 서버 점검 중 (HTML 응답 수신)")
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("API 요청 최대 재시도 초과 (api-id=%s)", apiID)
|
||||
}
|
||||
|
||||
// postPaged 연속조회 지원 POST 요청 - 응답 헤더(cont-yn, next-key) 함께 반환
|
||||
func (k *KiwoomClient) postPaged(apiID, path string, body map[string]string, contYn, nextKey string) ([]byte, string, string, error) {
|
||||
if err := k.limiter.Wait(context.Background()); err != nil {
|
||||
return nil, "", "", fmt.Errorf("Rate Limit 대기 실패: %w", err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("요청 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
req.Header.Set("authorization", "Bearer "+k.tokenService.GetToken())
|
||||
req.Header.Set("api-id", apiID)
|
||||
req.Header.Set("cont-yn", contYn)
|
||||
req.Header.Set("next-key", nextKey)
|
||||
|
||||
resp, err := k.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("API 요청 실패: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("응답 읽기 실패: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", "", fmt.Errorf("API 오류: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return respBody, resp.Header.Get("cont-yn"), resp.Header.Get("next-key"), nil
|
||||
}
|
||||
|
||||
// GetCurrentPrice 주식 기본정보 + 체결강도 조회 (ka10001 + ka10003)
|
||||
// NXT 거래소 데이터를 우선 조회하고, NXT에 없으면 KRX로 폴백
|
||||
func (k *KiwoomClient) GetCurrentPrice(stockCode string) (*models.StockPrice, error) {
|
||||
// 이미 거래소 접미사(_NX, _AL)가 붙어있으면 그대로 조회
|
||||
if strings.Contains(stockCode, "_") {
|
||||
return k.fetchPrice(stockCode)
|
||||
}
|
||||
|
||||
// NXT 우선 시도 → 실패하거나 종목명이 비어있으면 KRX 폴백
|
||||
if price, err := k.fetchPrice(stockCode + "_NX"); err == nil && price.Name != "" {
|
||||
log.Printf("NXT 가격 사용: %s → %d원", stockCode, price.CurrentPrice)
|
||||
return price, nil
|
||||
}
|
||||
return k.fetchPrice(stockCode)
|
||||
}
|
||||
|
||||
// fetchPrice ka10001로 특정 거래소 종목코드의 현재가 조회
|
||||
func (k *KiwoomClient) fetchPrice(stkCd string) (*models.StockPrice, error) {
|
||||
// 브라우저 표시용 종목코드는 거래소 접미사 제거 (005930_NX → 005930)
|
||||
displayCode := strings.SplitN(stkCd, "_", 2)[0]
|
||||
|
||||
body, err := k.post("ka10001", "/api/dostk/stkinfo", map[string]string{
|
||||
"stk_cd": stkCd,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("현재가 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("현재가 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
price := &models.StockPrice{
|
||||
Code: displayCode,
|
||||
Name: result.StkNm,
|
||||
CurrentPrice: absParseIntSafe(result.CurPrc),
|
||||
ChangePrice: parseIntSafe(result.PredPre),
|
||||
ChangeRate: parseFloatSafe(result.FluRt),
|
||||
Volume: absParseIntSafe(result.TrdeQty),
|
||||
High: absParseIntSafe(result.HighPric),
|
||||
Low: absParseIntSafe(result.LowPric),
|
||||
Open: absParseIntSafe(result.OpenPric),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// ka10003으로 체결강도 조회 (실패해도 나머지 데이터 반환)
|
||||
if cntrStr, err := k.getCntrStr(stkCd); err == nil {
|
||||
price.CntrStr = cntrStr
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// getCntrStr 체결정보에서 최신 체결강도 조회 (ka10003, 5초 캐시)
|
||||
func (k *KiwoomClient) getCntrStr(stockCode string) (float64, error) {
|
||||
// 캐시 확인
|
||||
if v, ok := k.cntrStrCache.Load(stockCode); ok {
|
||||
entry := v.(cntrStrCacheEntry)
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
return entry.value, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := k.post("ka10003", "/api/dostk/stkinfo", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
CntrInfr []struct {
|
||||
CntrStr string `json:"cntr_str"`
|
||||
} `json:"cntr_infr"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.ReturnCode != 0 || len(result.CntrInfr) == 0 {
|
||||
return 0, fmt.Errorf("체결강도 없음")
|
||||
}
|
||||
|
||||
val := parseFloatSafe(result.CntrInfr[0].CntrStr)
|
||||
// 결과 캐시 저장 (30초 — 스캔 3주기(30s) 동안 재호출 없음)
|
||||
k.cntrStrCache.Store(stockCode, cntrStrCacheEntry{value: val, expiresAt: time.Now().Add(30 * time.Second)})
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetDailyChart 일봉 차트 데이터 조회 (ka10005)
|
||||
func (k *KiwoomClient) GetDailyChart(stockCode string) ([]models.CandleData, error) {
|
||||
body, err := k.post("ka10005", "/api/dostk/mrkcond", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
StkDdwkmm []struct {
|
||||
Date string `json:"date"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
ClosePric string `json:"close_pric"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
} `json:"stk_ddwkmm"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("일봉 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("일봉 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
// 날짜 오름차순 정렬 (API는 내림차순 반환)
|
||||
candles := make([]models.CandleData, 0, len(result.StkDdwkmm))
|
||||
for i := len(result.StkDdwkmm) - 1; i >= 0; i-- {
|
||||
row := result.StkDdwkmm[i]
|
||||
candles = append(candles, models.CandleData{
|
||||
Time: parseDateToUnix(row.Date),
|
||||
Open: absParseIntSafe(row.OpenPric),
|
||||
High: absParseIntSafe(row.HighPric),
|
||||
Low: absParseIntSafe(row.LowPric),
|
||||
Close: absParseIntSafe(row.ClosePric),
|
||||
Volume: absParseIntSafe(row.TrdeQty),
|
||||
})
|
||||
}
|
||||
return candles, nil
|
||||
}
|
||||
|
||||
// GetMinuteChart 분봉 차트 데이터 조회 (ka10080)
|
||||
// minutes: 1, 5, 10, 15, 30, 60
|
||||
func (k *KiwoomClient) GetMinuteChart(stockCode string, minutes int) ([]models.CandleData, error) {
|
||||
body, err := k.post("ka10080", "/api/dostk/chart", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
"tic_scope": fmt.Sprintf("%d", minutes),
|
||||
"upd_stkpc_tp": "1",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
StkMinPoleChartQry []struct {
|
||||
CurPrc string `json:"cur_prc"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
CntrTm string `json:"cntr_tm"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
} `json:"stk_min_pole_chart_qry"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("분봉 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("분봉 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
// 시간 오름차순 정렬 (API는 내림차순 반환)
|
||||
rows := result.StkMinPoleChartQry
|
||||
candles := make([]models.CandleData, 0, len(rows))
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
row := rows[i]
|
||||
candles = append(candles, models.CandleData{
|
||||
Time: parseMinuteCandleTime(row.CntrTm),
|
||||
Open: absParseIntSafe(row.OpenPric),
|
||||
High: absParseIntSafe(row.HighPric),
|
||||
Low: absParseIntSafe(row.LowPric),
|
||||
Close: absParseIntSafe(row.CurPrc),
|
||||
Volume: absParseIntSafe(row.TrdeQty),
|
||||
})
|
||||
}
|
||||
return candles, nil
|
||||
}
|
||||
|
||||
// parseMinuteCandleTime 분봉 체결시간(YYYYMMDDHHmmss) → Unix 초 변환
|
||||
func parseMinuteCandleTime(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
// "YYYYMMDDHHmmss" (14자리) 또는 "HHmmss" (6자리) 처리
|
||||
var t time.Time
|
||||
var err error
|
||||
switch len(s) {
|
||||
case 14:
|
||||
t, err = time.ParseInLocation("20060102150405", s, time.Local)
|
||||
case 12:
|
||||
t, err = time.ParseInLocation("060102150405", s, time.Local)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
// GetTopVolumeStocks 거래량 상위 종목 조회 (ka10030)
|
||||
// market: "J"(KOSPI) → "001", "Q"(KOSDAQ) → "101"
|
||||
func (k *KiwoomClient) GetTopVolumeStocks(market string, count int) ([]models.StockPrice, error) {
|
||||
// market 코드 변환
|
||||
mrktTp := "000"
|
||||
mktName := "전체"
|
||||
switch market {
|
||||
case "J":
|
||||
mrktTp = "001"
|
||||
mktName = "KOSPI"
|
||||
case "Q":
|
||||
mrktTp = "101"
|
||||
mktName = "KOSDAQ"
|
||||
}
|
||||
|
||||
body, err := k.post("ka10030", "/api/dostk/rkinfo", map[string]string{
|
||||
"mrkt_tp": mrktTp,
|
||||
"sort_tp": "1", // 거래량 기준 정렬
|
||||
"mang_stk_incls": "1", // 관리종목 미포함
|
||||
"crd_tp": "0",
|
||||
"trde_qty_tp": "0",
|
||||
"pric_tp": "0",
|
||||
"trde_prica_tp": "0",
|
||||
"mrkt_open_tp": "0",
|
||||
"stex_tp": "3", // 통합
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TdyTrdeQtyUpper []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
} `json:"tdy_trde_qty_upper"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("거래량순위 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("거래량순위 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
stocks := make([]models.StockPrice, 0, count)
|
||||
for i, row := range result.TdyTrdeQtyUpper {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
stocks = append(stocks, models.StockPrice{
|
||||
Code: row.StkCd,
|
||||
Name: row.StkNm,
|
||||
CurrentPrice: absParseIntSafe(row.CurPrc),
|
||||
ChangePrice: parseIntSafe(row.PredPre),
|
||||
ChangeRate: parseFloatSafe(row.FluRt),
|
||||
Volume: absParseIntSafe(row.TrdeQty),
|
||||
Market: mktName,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return stocks, nil
|
||||
}
|
||||
|
||||
// getOrderBook 호가잔량 조회 (ka10004) - 총매도잔량, 총매수잔량, 총매도잔량직전대비 반환
|
||||
// WS 구독 중인 종목은 CacheService 캐시 우선 조회 → REST API 호출 최소화
|
||||
func (k *KiwoomClient) getOrderBook(stockCode string) (totalAsk, totalBid, askChange int64, err error) {
|
||||
if cached, ok := GetCacheService().Get("orderbook:" + stockCode); ok {
|
||||
if ob, ok2 := cached.(*models.OrderBook); ok2 {
|
||||
return ob.TotalAskVol, ob.TotalBidVol, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := k.post("ka10004", "/api/dostk/mrkcond", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
var result struct {
|
||||
TotSelReq string `json:"tot_sel_req"`
|
||||
TotBuyReq string `json:"tot_buy_req"`
|
||||
TotSelReqJub string `json:"tot_sel_req_jub_pre"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return 0, 0, 0, fmt.Errorf("호가 조회 실패 (return_code=%d)", result.ReturnCode)
|
||||
}
|
||||
return absParseIntSafe(result.TotSelReq),
|
||||
absParseIntSafe(result.TotBuyReq),
|
||||
parseIntSafe(result.TotSelReqJub),
|
||||
nil
|
||||
}
|
||||
|
||||
// GetTopFluctuation 전일대비 등락률 상위 종목 조회 (ka10027)
|
||||
// ascending=false: 상승률, ascending=true: 하락률
|
||||
func (k *KiwoomClient) GetTopFluctuation(market string, ascending bool, count int) ([]models.StockPrice, error) {
|
||||
sortTp := "1" // 상승률
|
||||
if ascending {
|
||||
sortTp = "3" // 하락률
|
||||
}
|
||||
|
||||
// market: "J"(KOSPI) → "001", "Q"(KOSDAQ) → "101", 그 외 "000"(전체)
|
||||
mrktTp := "000"
|
||||
mktName := "전체"
|
||||
switch market {
|
||||
case "J":
|
||||
mrktTp = "001"
|
||||
mktName = "KOSPI"
|
||||
case "Q":
|
||||
mrktTp = "101"
|
||||
mktName = "KOSDAQ"
|
||||
}
|
||||
|
||||
body, err := k.post("ka10027", "/api/dostk/rkinfo", map[string]string{
|
||||
"mrkt_tp": mrktTp,
|
||||
"sort_tp": sortTp,
|
||||
"trde_qty_cnd": "0000", // 거래량 전체
|
||||
"stk_cnd": "0", // 종목조건 전체
|
||||
"crd_cnd": "0", // 신용조건 전체
|
||||
"updown_incls": "1", // 상하한포함
|
||||
"pric_cnd": "0", // 가격조건 전체
|
||||
"trde_prica_cnd": "0", // 거래대금조건 전체
|
||||
"stex_tp": "1", // KRX
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
PredPreFluRtUpper []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
NowTrdeQty string `json:"now_trde_qty"`
|
||||
CntrStr string `json:"cntr_str"`
|
||||
} `json:"pred_pre_flu_rt_upper"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("등락률 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("등락률 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
stocks := make([]models.StockPrice, 0, count)
|
||||
for i, row := range result.PredPreFluRtUpper {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
stocks = append(stocks, models.StockPrice{
|
||||
Code: row.StkCd,
|
||||
Name: row.StkNm,
|
||||
CurrentPrice: absParseIntSafe(row.CurPrc),
|
||||
ChangePrice: parseIntSafe(row.PredPre),
|
||||
ChangeRate: parseFloatSafe(row.FluRt),
|
||||
Volume: absParseIntSafe(row.NowTrdeQty),
|
||||
CntrStr: parseFloatSafe(row.CntrStr),
|
||||
Market: mktName,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return stocks, nil
|
||||
}
|
||||
|
||||
// --- 유틸 함수 ---
|
||||
|
||||
func parseIntSafe(s string) int64 {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
s = strings.TrimPrefix(s, "+")
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// absParse 키움 API 가격 필드 파싱 (+/- 부호는 방향 표시용이므로 절댓값 반환)
|
||||
func absParseIntSafe(s string) int64 {
|
||||
n := parseIntSafe(s)
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseFloatSafe(s string) float64 {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
s = strings.TrimPrefix(s, "+")
|
||||
f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
return f
|
||||
}
|
||||
|
||||
// parseDateToUnix YYYYMMDD 형식을 Unix 타임스탬프(초)로 변환
|
||||
func parseDateToUnix(dateStr string) int64 {
|
||||
t, err := time.ParseInLocation("20060102", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
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
|
||||
}
|
||||
106
services/kospi200_service.go
Normal file
106
services/kospi200_service.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"stocksearch/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kospi200Service 코스피200 종목 조회 서비스
|
||||
type Kospi200Service struct {
|
||||
kiwoom *KiwoomClient
|
||||
cache *CacheService
|
||||
}
|
||||
|
||||
var kospi200Svc *Kospi200Service
|
||||
|
||||
// GetKospi200Service 코스피200 서비스 싱글턴 반환
|
||||
func GetKospi200Service() *Kospi200Service {
|
||||
if kospi200Svc == nil {
|
||||
kospi200Svc = &Kospi200Service{
|
||||
kiwoom: GetKiwoomClient(),
|
||||
cache: GetCacheService(),
|
||||
}
|
||||
}
|
||||
return kospi200Svc
|
||||
}
|
||||
|
||||
// GetStocks ka20002: 코스피200 구성종목 전체 조회 (연속조회, 캐시 1분)
|
||||
func (s *Kospi200Service) GetStocks() ([]models.Kospi200Stock, error) {
|
||||
const cacheKey = "kospi200_stocks"
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if stocks, ok := cached.([]models.Kospi200Stock); ok {
|
||||
return stocks, nil
|
||||
}
|
||||
}
|
||||
|
||||
var all []models.Kospi200Stock
|
||||
contYn, nextKey := "N", ""
|
||||
|
||||
for {
|
||||
body, nextContYn, nextNextKey, err := s.kiwoom.postPaged(
|
||||
"ka20002", "/api/dostk/sect",
|
||||
map[string]string{
|
||||
"mrkt_tp": "0", // 코스피
|
||||
"inds_cd": "201", // KOSPI200
|
||||
"stex_tp": "1", // KRX
|
||||
},
|
||||
contYn, nextKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("코스피200 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
IndsStkpc []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPreSig string `json:"pred_pre_sig"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
NowTrdeQty string `json:"now_trde_qty"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
} `json:"inds_stkpc"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("코스피200 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("코스피200 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
for _, s := range result.IndsStkpc {
|
||||
// 종목코드·종목명이 없는 행은 건너뜀
|
||||
if s.StkCd == "" || s.StkNm == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, models.Kospi200Stock{
|
||||
Code: s.StkCd,
|
||||
Name: s.StkNm,
|
||||
CurPrc: absParseIntSafe(s.CurPrc),
|
||||
PredPreSig: s.PredPreSig,
|
||||
PredPre: parseIntSafe(s.PredPre),
|
||||
FluRt: parseFloatSafe(s.FluRt),
|
||||
Volume: absParseIntSafe(s.NowTrdeQty),
|
||||
Open: absParseIntSafe(s.OpenPric),
|
||||
High: absParseIntSafe(s.HighPric),
|
||||
Low: absParseIntSafe(s.LowPric),
|
||||
})
|
||||
}
|
||||
|
||||
// 연속조회 종료 조건
|
||||
if nextContYn != "Y" || nextNextKey == "" {
|
||||
break
|
||||
}
|
||||
contYn, nextKey = nextContYn, nextNextKey
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, all, time.Minute)
|
||||
return all, nil
|
||||
}
|
||||
171
services/news_service.go
Normal file
171
services/news_service.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"stocksearch/config"
|
||||
"stocksearch/models"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
newsSvcOnce sync.Once
|
||||
newsSvc *NewsService
|
||||
)
|
||||
|
||||
// NewsService 네이버 뉴스 검색 서비스
|
||||
type NewsService struct {
|
||||
httpClient *http.Client
|
||||
cache *CacheService
|
||||
clientID string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
// GetNewsService 싱글턴 반환
|
||||
func GetNewsService() *NewsService {
|
||||
newsSvcOnce.Do(func() {
|
||||
newsSvc = &NewsService{
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
cache: GetCacheService(),
|
||||
clientID: config.App.NaverClientID,
|
||||
clientSecret: config.App.NaverClientSecret,
|
||||
}
|
||||
})
|
||||
return newsSvc
|
||||
}
|
||||
|
||||
// GetNews 종목명 기반 최근 뉴스 반환 (3분 캐시, 제목 중복 제거)
|
||||
func (s *NewsService) GetNews(stockName string) ([]models.NewsItem, error) {
|
||||
cacheKey := "news:" + stockName
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
return cached.([]models.NewsItem), nil
|
||||
}
|
||||
|
||||
items, err := s.fetchNaver(stockName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = dedupByTitle(items)
|
||||
s.cache.Set(cacheKey, items, 3*time.Minute)
|
||||
log.Printf("네이버 뉴스 조회: 종목=%s, 건수=%d", stockName, len(items))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// fetchNaver 네이버 뉴스 검색 API 호출 (최신순 10건)
|
||||
func (s *NewsService) fetchNaver(query string) ([]models.NewsItem, error) {
|
||||
if s.clientID == "" {
|
||||
return nil, fmt.Errorf("NAVER_CLIENT_ID가 설정되지 않았습니다")
|
||||
}
|
||||
|
||||
endpoint := "https://openapi.naver.com/v1/search/news.json"
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("display", "20") // 중복 제거 후 10건 확보를 위해 여유 있게 조회
|
||||
params.Set("sort", "date")
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("네이버 뉴스 요청 생성 실패: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Naver-Client-Id", s.clientID)
|
||||
req.Header.Set("X-Naver-Client-Secret", s.clientSecret)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("네이버 뉴스 API 요청 실패: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Items []struct {
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
PubDate string `json:"pubDate"`
|
||||
OriginalLink string `json:"originallink"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("네이버 뉴스 API 파싱 실패: %w", err)
|
||||
}
|
||||
|
||||
items := make([]models.NewsItem, 0, len(result.Items))
|
||||
for _, it := range result.Items {
|
||||
link := it.OriginalLink
|
||||
if link == "" {
|
||||
link = it.Link
|
||||
}
|
||||
items = append(items, models.NewsItem{
|
||||
Title: stripHTML(it.Title),
|
||||
URL: link,
|
||||
PublishedAt: it.PubDate,
|
||||
Source: extractDomain(link),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// htmlTagRe HTML 태그 제거용 정규식
|
||||
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// stripHTML HTML 태그 및 엔티티 제거
|
||||
func stripHTML(s string) string {
|
||||
s = htmlTagRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, "\"")
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// extractDomain URL에서 도메인명만 추출
|
||||
func extractDomain(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
// www. 제거
|
||||
host = strings.TrimPrefix(host, "www.")
|
||||
return host
|
||||
}
|
||||
|
||||
// normalizeTitle 제목을 소문자·공백 제거하여 중복 비교용 키 생성
|
||||
func normalizeTitle(title string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if !unicode.IsSpace(r) && !unicode.IsPunct(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// dedupByTitle 정규화된 제목 기준으로 중복 기사 제거
|
||||
func dedupByTitle(items []models.NewsItem) []models.NewsItem {
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
out := make([]models.NewsItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := normalizeTitle(item.Title)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
if len(out) == 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
132
services/order_service.go
Normal file
132
services/order_service.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// OrderService 주식 주문 서비스 (매수/매도/정정/취소)
|
||||
type OrderService struct {
|
||||
client *KiwoomClient
|
||||
}
|
||||
|
||||
var orderService *OrderService
|
||||
|
||||
// GetOrderService 주문 서비스 싱글턴 반환
|
||||
func GetOrderService() *OrderService {
|
||||
if orderService == nil {
|
||||
orderService = &OrderService{client: GetKiwoomClient()}
|
||||
}
|
||||
return orderService
|
||||
}
|
||||
|
||||
// OrderRequest 주문 요청 파라미터
|
||||
type OrderRequest struct {
|
||||
Exchange string // KRX, NXT, SOR
|
||||
Code string // 종목코드
|
||||
Qty string // 주문수량
|
||||
Price string // 주문단가 (시장가 시 빈 문자열)
|
||||
TradeTP string // 0:보통, 3:시장가, 5:조건부지정가, 6:최유리, 7:최우선
|
||||
}
|
||||
|
||||
// OrderResult 주문 응답
|
||||
type OrderResult struct {
|
||||
OrderNo string `json:"orderNo"`
|
||||
ReturnCode int `json:"returnCode"`
|
||||
ReturnMsg string `json:"returnMsg"`
|
||||
}
|
||||
|
||||
// Buy 매수주문 (kt10000)
|
||||
func (s *OrderService) Buy(req OrderRequest) (*OrderResult, error) {
|
||||
body := map[string]string{
|
||||
"dmst_stex_tp": req.Exchange,
|
||||
"stk_cd": req.Code,
|
||||
"ord_qty": req.Qty,
|
||||
"ord_uv": req.Price,
|
||||
"trde_tp": req.TradeTP,
|
||||
"cond_uv": "",
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt10000", "/api/dostk/ordr", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("매수주문 실패: %w", err)
|
||||
}
|
||||
|
||||
return parseOrderResult(respBody)
|
||||
}
|
||||
|
||||
// Sell 매도주문 (kt10001)
|
||||
func (s *OrderService) Sell(req OrderRequest) (*OrderResult, error) {
|
||||
body := map[string]string{
|
||||
"dmst_stex_tp": req.Exchange,
|
||||
"stk_cd": req.Code,
|
||||
"ord_qty": req.Qty,
|
||||
"ord_uv": req.Price,
|
||||
"trde_tp": req.TradeTP,
|
||||
"cond_uv": "",
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt10001", "/api/dostk/ordr", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("매도주문 실패: %w", err)
|
||||
}
|
||||
|
||||
return parseOrderResult(respBody)
|
||||
}
|
||||
|
||||
// Modify 정정주문 (kt10002)
|
||||
func (s *OrderService) Modify(exchange, origOrdNo, code, qty, price string) (*OrderResult, error) {
|
||||
body := map[string]string{
|
||||
"dmst_stex_tp": exchange,
|
||||
"orig_ord_no": origOrdNo,
|
||||
"stk_cd": code,
|
||||
"mdfy_qty": qty,
|
||||
"mdfy_uv": price,
|
||||
"mdfy_cond_uv": "",
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt10002", "/api/dostk/ordr", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("정정주문 실패: %w", err)
|
||||
}
|
||||
|
||||
return parseOrderResult(respBody)
|
||||
}
|
||||
|
||||
// Cancel 취소주문 (kt10003)
|
||||
// qty: "0" = 전량취소
|
||||
func (s *OrderService) Cancel(exchange, origOrdNo, code, qty string) (*OrderResult, error) {
|
||||
body := map[string]string{
|
||||
"dmst_stex_tp": exchange,
|
||||
"orig_ord_no": origOrdNo,
|
||||
"stk_cd": code,
|
||||
"cncl_qty": qty,
|
||||
}
|
||||
|
||||
respBody, err := s.client.post("kt10003", "/api/dostk/ordr", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("취소주문 실패: %w", err)
|
||||
}
|
||||
|
||||
return parseOrderResult(respBody)
|
||||
}
|
||||
|
||||
// parseOrderResult 주문 응답 JSON 파싱 공통 함수
|
||||
func parseOrderResult(body []byte) (*OrderResult, error) {
|
||||
var result struct {
|
||||
OrdNo string `json:"ord_no"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("주문 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("주문 오류: %s", result.ReturnMsg)
|
||||
}
|
||||
return &OrderResult{
|
||||
OrderNo: result.OrdNo,
|
||||
ReturnCode: result.ReturnCode,
|
||||
ReturnMsg: result.ReturnMsg,
|
||||
}, nil
|
||||
}
|
||||
767
services/scanner_service.go
Normal file
767
services/scanner_service.go
Normal file
@@ -0,0 +1,767 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"stocksearch/config"
|
||||
"stocksearch/models"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SignalStock 체결강도 상승 감지 시그널 종목
|
||||
type SignalStock struct {
|
||||
models.StockPrice
|
||||
PrevCntrStr float64 `json:"prevCntrStr"` // 직전 체결강도
|
||||
RisingCount int `json:"risingCount"` // 연속 상승 횟수 (10초 단위)
|
||||
DetectedAt time.Time `json:"detectedAt"` // 감지 시각
|
||||
Sentiment string `json:"sentiment"` // 호재/악재/중립/정보없음
|
||||
SentimentReason string `json:"sentimentReason"` // 한 줄 이유
|
||||
TargetPrice int64 `json:"targetPrice"` // AI 추론 목표가
|
||||
TargetReason string `json:"targetReason"` // 목표가 추론 근거 (한 줄)
|
||||
RiseScore int `json:"riseScore"` // 상승 확률 점수 (0~100)
|
||||
RiseLabel string `json:"riseLabel"` // "매우 높음" / "높음" / ""
|
||||
NextDayTrend string `json:"nextDayTrend"` // 익일 추세: "상승" | "하락" | "횡보"
|
||||
NextDayConf string `json:"nextDayConf"` // 신뢰도: "높음" | "보통" | "낮음"
|
||||
NextDayReason string `json:"nextDayReason"` // 익일 추세 근거 (한 줄)
|
||||
// 복합 분석 지표 (체결강도 + 매도잔량 + 거래량 + 가격위치)
|
||||
TotalAskVol int64 `json:"totalAskVol"` // 총매도잔량
|
||||
TotalBidVol int64 `json:"totalBidVol"` // 총매수잔량
|
||||
AskBidRatio float64 `json:"askBidRatio"` // 매도/매수 잔량비 (1 이상=매도우세)
|
||||
VolDelta int64 `json:"volDelta"` // 당 구간 거래량 증가분 (10초)
|
||||
VolRatio float64 `json:"volRatio"` // 거래량 증가율 (직전 평균 대비 배수)
|
||||
UpperWick float64 `json:"upperWick"` // 윗꼬리 비율 (0=없음, 1=전부 윗꼬리)
|
||||
PricePos float64 `json:"pricePos"` // 장중 가격 위치 % (0=저가, 100=고가)
|
||||
SignalType string `json:"signalType"` // "강한매수" | "매수우세" | "물량소화" | "추격위험" | "약한상승"
|
||||
}
|
||||
|
||||
// cntrHistory 종목별 체결강도 이력 (최근 N회)
|
||||
type cntrHistory struct {
|
||||
values []float64 // 오래된 것부터, 최신이 마지막
|
||||
}
|
||||
|
||||
func (h *cntrHistory) push(v float64) {
|
||||
h.values = append(h.values, v)
|
||||
if len(h.values) > 6 { // 최대 6회(1분) 유지
|
||||
h.values = h.values[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// risingCount 직전 N회 연속 상승 횟수 반환 (최소 1회 비교 필요)
|
||||
func (h *cntrHistory) risingCount() int {
|
||||
vals := h.values
|
||||
if len(vals) < 2 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for i := len(vals) - 1; i >= 1; i-- {
|
||||
if vals[i] > vals[i-1] {
|
||||
count++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// volumeHist 종목별 거래량 이력 (10초 구간 증가분 추적)
|
||||
type volumeHist struct {
|
||||
last int64 // 직전 스캔 누적 거래량
|
||||
deltas []int64 // 최근 6회 구간 증가분
|
||||
}
|
||||
|
||||
// push 현재 누적 거래량을 기록하고 (구간 증가분, 평균 대비 배수) 반환
|
||||
func (h *volumeHist) push(current int64) (delta int64, ratio float64) {
|
||||
if h.last > 0 && current > h.last {
|
||||
delta = current - h.last
|
||||
// 비율 계산: 기존 이력 기준 (현재 delta 추가 전)
|
||||
if len(h.deltas) > 0 {
|
||||
sum := int64(0)
|
||||
for _, d := range h.deltas {
|
||||
sum += d
|
||||
}
|
||||
avg := float64(sum) / float64(len(h.deltas))
|
||||
if avg > 0 {
|
||||
ratio = float64(delta) / avg
|
||||
}
|
||||
}
|
||||
h.deltas = append(h.deltas, delta)
|
||||
if len(h.deltas) > 6 {
|
||||
h.deltas = h.deltas[1:]
|
||||
}
|
||||
}
|
||||
h.last = current
|
||||
return delta, ratio
|
||||
}
|
||||
|
||||
// ScannerService 체결강도 상승 감지 스캐너 서비스
|
||||
type ScannerService struct {
|
||||
kiwoom *KiwoomClient
|
||||
stockSvc *StockService
|
||||
analysis *AnalysisService
|
||||
mu sync.RWMutex
|
||||
enabled int32 // atomic: 1=켜짐(기본), 0=꺼짐
|
||||
signals []SignalStock
|
||||
history map[string]*cntrHistory // 종목별 체결강도 이력
|
||||
volumeHistory map[string]*volumeHist // 종목별 거래량 이력
|
||||
signalCache map[string]SignalStock // 종목별 마지막 시그널 (LLM 결과 포함)
|
||||
signalExpiry map[string]time.Time // 종목별 시그널 만료 시각 (1분)
|
||||
// 관심종목 전용 이력/캐시
|
||||
watchlistHistory map[string]*cntrHistory
|
||||
watchlistVolHistory map[string]*volumeHist
|
||||
watchlistSignalCache map[string]SignalStock
|
||||
watchlistSignalExpiry map[string]time.Time
|
||||
// WS 구독 콜백 (Hub.SubscribeInternal 연결)
|
||||
subscribeCallback func([]string)
|
||||
}
|
||||
|
||||
var scannerSvc *ScannerService
|
||||
|
||||
// GetScannerService 스캐너 서비스 싱글턴 반환
|
||||
func GetScannerService() *ScannerService {
|
||||
if scannerSvc == nil {
|
||||
scannerSvc = &ScannerService{
|
||||
kiwoom: GetKiwoomClient(),
|
||||
stockSvc: GetStockService(),
|
||||
analysis: GetAnalysisService(config.App.GroqAPIKey, config.App.GroqModel),
|
||||
history: make(map[string]*cntrHistory),
|
||||
volumeHistory: make(map[string]*volumeHist),
|
||||
signalCache: make(map[string]SignalStock),
|
||||
signalExpiry: make(map[string]time.Time),
|
||||
watchlistHistory: make(map[string]*cntrHistory),
|
||||
watchlistVolHistory: make(map[string]*volumeHist),
|
||||
watchlistSignalCache: make(map[string]SignalStock),
|
||||
watchlistSignalExpiry: make(map[string]time.Time),
|
||||
}
|
||||
atomic.StoreInt32(&scannerSvc.enabled, 1) // 기본값: 켜짐
|
||||
}
|
||||
return scannerSvc
|
||||
}
|
||||
|
||||
// Start 스캐너 백그라운드 고루틴 시작
|
||||
func (s *ScannerService) Start() {
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// SetEnabled 스캐너 활성화 여부 설정
|
||||
func (s *ScannerService) SetEnabled(on bool) {
|
||||
if on {
|
||||
atomic.StoreInt32(&s.enabled, 1)
|
||||
} else {
|
||||
atomic.StoreInt32(&s.enabled, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled 스캐너 활성화 여부 반환
|
||||
func (s *ScannerService) IsEnabled() bool {
|
||||
return atomic.LoadInt32(&s.enabled) == 1
|
||||
}
|
||||
|
||||
// SetSubscribeCallback 종목 WS 구독 요청 콜백 설정 (Hub.SubscribeInternal 연결)
|
||||
func (s *ScannerService) SetSubscribeCallback(fn func([]string)) {
|
||||
s.mu.Lock()
|
||||
s.subscribeCallback = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetSignals 현재 감지된 시그널 종목 목록 반환
|
||||
func (s *ScannerService) GetSignals() []SignalStock {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]SignalStock, len(s.signals))
|
||||
copy(result, s.signals)
|
||||
return result
|
||||
}
|
||||
|
||||
// run 08:00 KST 이후 10초 주기로 스캔 반복 (enabled=0이면 스캔 건너뜀)
|
||||
func (s *ScannerService) run() {
|
||||
kst, _ := time.LoadLocation("Asia/Seoul")
|
||||
for {
|
||||
now := time.Now().In(kst)
|
||||
if now.Hour() < 8 {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, kst)
|
||||
log.Printf("스캐너: 08:00 KST 대기 중 (%v)", next.Format("2006-01-02 15:04:05"))
|
||||
time.Sleep(time.Until(next))
|
||||
}
|
||||
if s.IsEnabled() {
|
||||
s.scan()
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// calcRiseScore 4가지 복합 요소 기반 상승 확률 점수 계산 (0~100점)
|
||||
// A.체결강도(30) + B.연속상승(25) + C.가격위치/캔들(20) + D.거래량건전성(15) + E.매도잔량소화(10)
|
||||
func calcRiseScore(cntrStr float64, risingCount int, changeRate float64,
|
||||
high, low, currentPrice int64, volRatio float64,
|
||||
totalAskVol, totalBidVol int64) int {
|
||||
|
||||
score := 0
|
||||
|
||||
// A. 체결강도 레벨 (0~30점)
|
||||
switch {
|
||||
case cntrStr >= 150:
|
||||
score += 30
|
||||
case cntrStr >= 130:
|
||||
score += 22
|
||||
case cntrStr >= 110:
|
||||
score += 14
|
||||
case cntrStr >= 100:
|
||||
score += 7
|
||||
}
|
||||
|
||||
// B. 연속 상승 횟수 (0~25점)
|
||||
switch {
|
||||
case risingCount >= 5:
|
||||
score += 25
|
||||
case risingCount >= 4:
|
||||
score += 20
|
||||
case risingCount >= 3:
|
||||
score += 14
|
||||
case risingCount >= 2:
|
||||
score += 8
|
||||
case risingCount == 1:
|
||||
score += 3
|
||||
}
|
||||
|
||||
// C. 가격 위치 및 캔들 형태 (0~20점)
|
||||
// C1. 등락률
|
||||
switch {
|
||||
case changeRate >= 3.0:
|
||||
score += 6
|
||||
case changeRate >= 1.0:
|
||||
score += 4
|
||||
case changeRate >= 0.0:
|
||||
score += 1
|
||||
}
|
||||
// C2. 윗꼬리 비율 (0=없음 → 강한 양봉, 클수록 매도 압력)
|
||||
if high > low {
|
||||
upperWick := float64(high-currentPrice) / float64(high-low)
|
||||
switch {
|
||||
case upperWick <= 0.10:
|
||||
score += 10 // 윗꼬리 거의 없음 = 강한 양봉
|
||||
case upperWick <= 0.25:
|
||||
score += 6
|
||||
case upperWick <= 0.40:
|
||||
score += 2
|
||||
case upperWick > 0.60:
|
||||
score -= 8 // 긴 윗꼬리 = 강한 매도 압력
|
||||
}
|
||||
// C3. 가격이 고가 80% 이상 위치 → 매수 우세
|
||||
pricePos := float64(currentPrice-low) / float64(high-low) * 100
|
||||
if pricePos >= 80 {
|
||||
score += 4
|
||||
}
|
||||
}
|
||||
|
||||
// D. 거래량 건전성 (0~15점)
|
||||
// 2~5배 증가가 최적, 10배+ 과열은 고점 물량털기 가능성으로 감점
|
||||
switch {
|
||||
case volRatio >= 2.0 && volRatio < 5.0:
|
||||
score += 15 // 건강한 거래량 증가
|
||||
case volRatio >= 1.5 && volRatio < 2.0:
|
||||
score += 10
|
||||
case volRatio >= 1.0 && volRatio < 1.5:
|
||||
score += 6
|
||||
case volRatio >= 5.0 && volRatio < 10.0:
|
||||
score += 4 // 과열 초입
|
||||
case volRatio >= 10.0:
|
||||
score -= 5 // 폭발적 과열: 고점 물량털기 경계
|
||||
case volRatio > 0:
|
||||
score += 2
|
||||
}
|
||||
|
||||
// E. 매도잔량 소화 여부 (0~10점)
|
||||
if totalAskVol > 0 && totalBidVol > 0 {
|
||||
bidAskRatio := float64(totalBidVol) / float64(totalAskVol)
|
||||
switch {
|
||||
case bidAskRatio >= 1.5:
|
||||
score += 10 // 매수잔량 압도적: 위 물량 소화 중
|
||||
case bidAskRatio >= 1.0:
|
||||
score += 6 // 매수 ≥ 매도
|
||||
case bidAskRatio >= 0.7:
|
||||
score += 2
|
||||
default:
|
||||
score -= 3 // 매도잔량 크게 우세: 상단 물량 부담
|
||||
}
|
||||
}
|
||||
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// classifySignalType 4가지 요소 조합으로 신호 유형 분류
|
||||
func classifySignalType(sig *SignalStock) string {
|
||||
upperWick := sig.UpperWick
|
||||
askBidRatio := sig.AskBidRatio // 1 이상=매도우세, 1 미만=매수우세
|
||||
if askBidRatio == 0 {
|
||||
askBidRatio = 1.0 // 데이터 없으면 중립으로 취급
|
||||
}
|
||||
|
||||
// 추격위험: 체결강도 과열 + 거래량 폭발 + 긴 윗꼬리
|
||||
// → 단타 추격매수 몰림 후 고점 물량털기 패턴
|
||||
if sig.CntrStr >= 170 && sig.VolRatio >= 7.0 && upperWick >= 0.4 {
|
||||
return "추격위험"
|
||||
}
|
||||
|
||||
// 강한매수: 체결강도 강함 + 연속상승 + 가격 우상향 + 윗꼬리 없음 + 매도잔량 소화
|
||||
// → "사는 쪽이 실제로 강하고, 던지는 물량도 받아내는" 패턴
|
||||
if sig.CntrStr >= 130 && sig.RisingCount >= 3 &&
|
||||
sig.ChangeRate >= 1.0 && upperWick <= 0.25 && askBidRatio <= 1.0 {
|
||||
return "강한매수"
|
||||
}
|
||||
|
||||
// 물량소화: 체결강도 높은데 가격이 제자리 + 긴 윗꼬리
|
||||
// → 위에서 던지는 물량을 받아내기만 하는 중
|
||||
if sig.CntrStr >= 120 && sig.ChangeRate <= 0.5 && upperWick >= 0.35 {
|
||||
return "물량소화"
|
||||
}
|
||||
|
||||
// 약한상승: 거래량 적고 체결강도도 약함
|
||||
// → 얇은 호가에서 뜬 상승, 쉽게 꺾일 수 있음
|
||||
if sig.VolRatio < 1.0 && sig.CntrStr < 120 {
|
||||
return "약한상승"
|
||||
}
|
||||
|
||||
return "매수우세"
|
||||
}
|
||||
|
||||
// scan 거래량 상위 20종목을 조회해 복합 분석으로 시그널 종목 필터링
|
||||
func (s *ScannerService) scan() {
|
||||
stocks, err := s.kiwoom.GetTopVolumeStocks("J", 20)
|
||||
if err != nil {
|
||||
log.Printf("스캐너 거래량순위 조회 실패: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 거래량 상위 종목 WS 구독 요청 (캐시 활용을 위해 미리 등록)
|
||||
s.mu.RLock()
|
||||
cb := s.subscribeCallback
|
||||
s.mu.RUnlock()
|
||||
if cb != nil {
|
||||
codes := make([]string, len(stocks))
|
||||
for i, st := range stocks {
|
||||
codes[i] = st.Code
|
||||
}
|
||||
cb(codes)
|
||||
}
|
||||
|
||||
var signals []SignalStock
|
||||
|
||||
s.mu.Lock()
|
||||
for _, stock := range stocks {
|
||||
// ka10003으로 최신 체결강도 조회; 실패 시 순위 응답값 사용
|
||||
cntrStr, err := s.kiwoom.getCntrStr(stock.Code)
|
||||
if err != nil || cntrStr == 0 {
|
||||
cntrStr = stock.CntrStr
|
||||
}
|
||||
|
||||
// 체결강도 이력 업데이트
|
||||
h, ok := s.history[stock.Code]
|
||||
if !ok {
|
||||
h = &cntrHistory{}
|
||||
s.history[stock.Code] = h
|
||||
}
|
||||
h.push(cntrStr)
|
||||
|
||||
rising := h.risingCount()
|
||||
if rising == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
prev := float64(0)
|
||||
if len(h.values) >= 2 {
|
||||
prev = h.values[len(h.values)-2]
|
||||
}
|
||||
|
||||
// 거래량 이력 업데이트 → 구간 증가분 및 증가율 계산
|
||||
vh, ok := s.volumeHistory[stock.Code]
|
||||
if !ok {
|
||||
vh = &volumeHist{}
|
||||
s.volumeHistory[stock.Code] = vh
|
||||
}
|
||||
volDelta, volRatio := vh.push(stock.Volume)
|
||||
|
||||
// 윗꼬리 비율 및 가격 위치 계산
|
||||
upperWick, pricePos := 0.5, 50.0 // 데이터 없으면 중간값
|
||||
if stock.High > stock.Low {
|
||||
upperWick = float64(stock.High-stock.CurrentPrice) / float64(stock.High-stock.Low)
|
||||
pricePos = float64(stock.CurrentPrice-stock.Low) / float64(stock.High-stock.Low) * 100
|
||||
}
|
||||
|
||||
stock.CntrStr = cntrStr
|
||||
signals = append(signals, SignalStock{
|
||||
StockPrice: stock,
|
||||
PrevCntrStr: prev,
|
||||
RisingCount: rising,
|
||||
DetectedAt: time.Now(),
|
||||
VolDelta: volDelta,
|
||||
VolRatio: volRatio,
|
||||
UpperWick: upperWick,
|
||||
PricePos: pricePos,
|
||||
})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// ── 호가잔량 병렬 조회 (체결강도 상승 종목에 한해) ────────────────
|
||||
if len(signals) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
for i := range signals {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
signals[idx].TotalAskVol = ask
|
||||
signals[idx].TotalBidVol = bid
|
||||
if bid > 0 {
|
||||
signals[idx].AskBidRatio = float64(ask) / float64(bid)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ── 최종 스코어 및 신호 유형 계산 (호가잔량 포함) ────────────────
|
||||
for i := range signals {
|
||||
sig := &signals[i]
|
||||
sig.RiseScore = calcRiseScore(
|
||||
sig.CntrStr, sig.RisingCount, sig.ChangeRate,
|
||||
sig.High, sig.Low, sig.CurrentPrice,
|
||||
sig.VolRatio, sig.TotalAskVol, sig.TotalBidVol,
|
||||
)
|
||||
sig.SignalType = classifySignalType(sig)
|
||||
switch {
|
||||
case sig.RiseScore >= 70:
|
||||
sig.RiseLabel = "매우 높음"
|
||||
case sig.RiseScore >= 50:
|
||||
sig.RiseLabel = "높음"
|
||||
default:
|
||||
sig.RiseLabel = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1분 유지 캐시 병합 ────────────────────────────────────────────
|
||||
const signalTTL = time.Minute
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
activeCodes := make(map[string]bool, len(signals))
|
||||
for i := range signals {
|
||||
code := signals[i].Code
|
||||
activeCodes[code] = true
|
||||
s.signalExpiry[code] = now.Add(signalTTL)
|
||||
// 기존 LLM 결과 재사용 (Groq 재호출 방지)
|
||||
if cached, ok := s.signalCache[code]; ok && cached.Sentiment != "" {
|
||||
signals[i].Sentiment = cached.Sentiment
|
||||
signals[i].SentimentReason = cached.SentimentReason
|
||||
signals[i].TargetPrice = cached.TargetPrice
|
||||
signals[i].TargetReason = cached.TargetReason
|
||||
signals[i].NextDayTrend = cached.NextDayTrend
|
||||
signals[i].NextDayConf = cached.NextDayConf
|
||||
signals[i].NextDayReason = cached.NextDayReason
|
||||
}
|
||||
}
|
||||
// 만료 안 된 이전 시그널 병합
|
||||
for code, expiry := range s.signalExpiry {
|
||||
if expiry.Before(now) {
|
||||
delete(s.signalExpiry, code)
|
||||
delete(s.signalCache, code)
|
||||
continue
|
||||
}
|
||||
if !activeCodes[code] {
|
||||
signals = append(signals, s.signalCache[code])
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// ── RiseScore 내림차순 정렬 (동점 시 체결강도 기준) ──────────────
|
||||
sort.Slice(signals, func(i, j int) bool {
|
||||
if signals[i].RiseScore != signals[j].RiseScore {
|
||||
return signals[i].RiseScore > signals[j].RiseScore
|
||||
}
|
||||
return signals[i].CntrStr > signals[j].CntrStr
|
||||
})
|
||||
|
||||
// ── LLM 병렬 분석 (shouldAnalyze 통과 종목만, 5초 타임아웃) ──────
|
||||
if len(signals) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
for i := range signals {
|
||||
if !shouldAnalyze(&signals[i]) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sig := &signals[idx]
|
||||
sentiment, reason := s.analysis.Analyze(sig.Code, sig.Name)
|
||||
sig.Sentiment = sentiment
|
||||
sig.SentimentReason = reason
|
||||
|
||||
targetPrice, targetReason := s.analysis.PredictTargetPriceFromSignal(
|
||||
sig.Code, sig.Name,
|
||||
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
|
||||
sig.ChangeRate, sig.CntrStr, sig.PrevCntrStr, sig.RisingCount,
|
||||
sentiment, reason,
|
||||
)
|
||||
sig.TargetPrice = targetPrice
|
||||
sig.TargetReason = targetReason
|
||||
|
||||
trend, conf, trendReason := s.analysis.PredictNextDayTrend(
|
||||
sig.Code, sig.Name,
|
||||
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
|
||||
sig.ChangeRate, sig.CntrStr, sig.RisingCount,
|
||||
sentiment, reason,
|
||||
)
|
||||
sig.NextDayTrend = trend
|
||||
sig.NextDayConf = conf
|
||||
sig.NextDayReason = trendReason
|
||||
}(i)
|
||||
}
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Printf("스캐너: 감성 분석 5초 타임아웃 (일부 미완료 가능)")
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for _, sig := range signals {
|
||||
s.signalCache[sig.Code] = sig
|
||||
}
|
||||
s.signals = signals
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("스캐너: 거래량상위20 체결강도 체크 완료 → 시그널 %d개", len(signals))
|
||||
}
|
||||
|
||||
// shouldAnalyze LLM 분석 호출 여부 판단 (Groq API 호출량 절감)
|
||||
// 상승 확률이 높은 종목만 통과: RiseScore 50+, 2회 이상 연속 상승, 체결강도 100+, 등락률 0% 이상
|
||||
func shouldAnalyze(sig *SignalStock) bool {
|
||||
return sig.RiseScore >= 50 &&
|
||||
sig.RisingCount >= 2 &&
|
||||
sig.CntrStr >= 100 &&
|
||||
sig.ChangeRate >= 0
|
||||
}
|
||||
|
||||
// AnalyzeWatchlist 관심종목 코드 목록에 대해 복합 분석 수행 후 SignalStock 반환
|
||||
func (s *ScannerService) AnalyzeWatchlist(codes []string) []SignalStock {
|
||||
// 분석 전 WS 구독 요청 (다음 사이클부터 캐시 활용 가능)
|
||||
s.mu.RLock()
|
||||
cb := s.subscribeCallback
|
||||
s.mu.RUnlock()
|
||||
if cb != nil && len(codes) > 0 {
|
||||
cb(codes)
|
||||
}
|
||||
|
||||
type interim struct {
|
||||
code string
|
||||
cntrStr float64
|
||||
prev float64
|
||||
rising int
|
||||
volDelta int64
|
||||
volRatio float64
|
||||
price *models.StockPrice
|
||||
}
|
||||
|
||||
// Phase 1: 현재가/체결강도/거래량 이력 순차 수집
|
||||
// GetCurrentPrice 내부에서 getCntrStr(ka10003)을 이미 호출하므로 중복 호출 없음
|
||||
items := make([]interim, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
// 현재가 조회 (캐시 활용, 내부적으로 체결강도도 포함)
|
||||
sp, err := s.stockSvc.GetCurrentPrice(code)
|
||||
if err != nil {
|
||||
log.Printf("관심종목 현재가 조회 실패 [%s]: %v", code, err)
|
||||
continue
|
||||
}
|
||||
|
||||
cntrStr := sp.CntrStr
|
||||
|
||||
// 체결강도 이력 업데이트
|
||||
s.mu.Lock()
|
||||
h, ok := s.watchlistHistory[code]
|
||||
if !ok {
|
||||
h = &cntrHistory{}
|
||||
s.watchlistHistory[code] = h
|
||||
}
|
||||
h.push(cntrStr)
|
||||
rising := h.risingCount()
|
||||
prev := float64(0)
|
||||
if len(h.values) >= 2 {
|
||||
prev = h.values[len(h.values)-2]
|
||||
}
|
||||
|
||||
// 거래량 이력 업데이트
|
||||
vh, ok := s.watchlistVolHistory[code]
|
||||
if !ok {
|
||||
vh = &volumeHist{}
|
||||
s.watchlistVolHistory[code] = vh
|
||||
}
|
||||
volDelta, volRatio := vh.push(sp.Volume)
|
||||
s.mu.Unlock()
|
||||
|
||||
items = append(items, interim{
|
||||
code: code,
|
||||
cntrStr: cntrStr,
|
||||
prev: prev,
|
||||
rising: rising,
|
||||
volDelta: volDelta,
|
||||
volRatio: volRatio,
|
||||
price: sp,
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 2: SignalStock 슬라이스 구성
|
||||
signals := make([]SignalStock, 0, len(items))
|
||||
for _, it := range items {
|
||||
sp := it.price
|
||||
upperWick, pricePos := 0.5, 50.0
|
||||
if sp.High > sp.Low {
|
||||
upperWick = float64(sp.High-sp.CurrentPrice) / float64(sp.High-sp.Low)
|
||||
pricePos = float64(sp.CurrentPrice-sp.Low) / float64(sp.High-sp.Low) * 100
|
||||
}
|
||||
signals = append(signals, SignalStock{
|
||||
StockPrice: *sp,
|
||||
PrevCntrStr: it.prev,
|
||||
RisingCount: it.rising,
|
||||
DetectedAt: time.Now(),
|
||||
VolDelta: it.volDelta,
|
||||
VolRatio: it.volRatio,
|
||||
UpperWick: upperWick,
|
||||
PricePos: pricePos,
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 3: 호가잔량 병렬 조회
|
||||
if len(signals) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
for i := range signals {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
signals[idx].TotalAskVol = ask
|
||||
signals[idx].TotalBidVol = bid
|
||||
if bid > 0 {
|
||||
signals[idx].AskBidRatio = float64(ask) / float64(bid)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Phase 4: 스코어 및 신호 유형 계산
|
||||
for i := range signals {
|
||||
sig := &signals[i]
|
||||
sig.RiseScore = calcRiseScore(
|
||||
sig.CntrStr, sig.RisingCount, sig.ChangeRate,
|
||||
sig.High, sig.Low, sig.CurrentPrice,
|
||||
sig.VolRatio, sig.TotalAskVol, sig.TotalBidVol,
|
||||
)
|
||||
sig.SignalType = classifySignalType(sig)
|
||||
switch {
|
||||
case sig.RiseScore >= 70:
|
||||
sig.RiseLabel = "매우 높음"
|
||||
case sig.RiseScore >= 50:
|
||||
sig.RiseLabel = "높음"
|
||||
default:
|
||||
sig.RiseLabel = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: 1분 TTL 캐시 병합 (LLM 결과 재사용)
|
||||
const signalTTL = time.Minute
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
for i := range signals {
|
||||
code := signals[i].Code
|
||||
s.watchlistSignalExpiry[code] = now.Add(signalTTL)
|
||||
if cached, ok := s.watchlistSignalCache[code]; ok && cached.Sentiment != "" {
|
||||
signals[i].Sentiment = cached.Sentiment
|
||||
signals[i].SentimentReason = cached.SentimentReason
|
||||
signals[i].TargetPrice = cached.TargetPrice
|
||||
signals[i].TargetReason = cached.TargetReason
|
||||
signals[i].NextDayTrend = cached.NextDayTrend
|
||||
signals[i].NextDayConf = cached.NextDayConf
|
||||
signals[i].NextDayReason = cached.NextDayReason
|
||||
}
|
||||
}
|
||||
// 만료된 캐시 항목 정리
|
||||
for code, expiry := range s.watchlistSignalExpiry {
|
||||
if expiry.Before(now) {
|
||||
delete(s.watchlistSignalExpiry, code)
|
||||
delete(s.watchlistSignalCache, code)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Phase 6: LLM 병렬 분석 (shouldAnalyze 통과 종목만, 5초 타임아웃)
|
||||
if len(signals) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
for i := range signals {
|
||||
if !shouldAnalyze(&signals[i]) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sig := &signals[idx]
|
||||
sentiment, reason := s.analysis.Analyze(sig.Code, sig.Name)
|
||||
sig.Sentiment = sentiment
|
||||
sig.SentimentReason = reason
|
||||
|
||||
targetPrice, targetReason := s.analysis.PredictTargetPriceFromSignal(
|
||||
sig.Code, sig.Name,
|
||||
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
|
||||
sig.ChangeRate, sig.CntrStr, sig.PrevCntrStr, sig.RisingCount,
|
||||
sentiment, reason,
|
||||
)
|
||||
sig.TargetPrice = targetPrice
|
||||
sig.TargetReason = targetReason
|
||||
|
||||
trend, conf, trendReason := s.analysis.PredictNextDayTrend(
|
||||
sig.Code, sig.Name,
|
||||
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
|
||||
sig.ChangeRate, sig.CntrStr, sig.RisingCount,
|
||||
sentiment, reason,
|
||||
)
|
||||
sig.NextDayTrend = trend
|
||||
sig.NextDayConf = conf
|
||||
sig.NextDayReason = trendReason
|
||||
}(i)
|
||||
}
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Printf("관심종목 분석: 감성 분석 5초 타임아웃")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7: 결과 캐시 저장
|
||||
s.mu.Lock()
|
||||
for _, sig := range signals {
|
||||
s.watchlistSignalCache[sig.Code] = sig
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return signals
|
||||
}
|
||||
155
services/search_service.go
Normal file
155
services/search_service.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// StockItem 검색용 종목 정보
|
||||
type StockItem struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Market string `json:"market"`
|
||||
}
|
||||
|
||||
// SearchService 종목 검색 서비스 (메모리 캐시)
|
||||
type SearchService struct {
|
||||
kiwoom *KiwoomClient
|
||||
mu sync.RWMutex
|
||||
stocks []StockItem // 전체 종목 리스트 캐시
|
||||
}
|
||||
|
||||
var (
|
||||
searchSvcOnce sync.Once
|
||||
searchSvc *SearchService
|
||||
)
|
||||
|
||||
// GetSearchService 싱글턴 반환
|
||||
func GetSearchService() *SearchService {
|
||||
searchSvcOnce.Do(func() {
|
||||
searchSvc = &SearchService{kiwoom: GetKiwoomClient()}
|
||||
})
|
||||
return searchSvc
|
||||
}
|
||||
|
||||
// Load 코스피·코스닥 전체 종목 목록을 키움 ka10099로 로딩
|
||||
// 서버 시작 시 1회 호출 (고루틴)
|
||||
func (s *SearchService) Load() {
|
||||
go func() {
|
||||
stocks, err := s.fetchAll()
|
||||
if err != nil {
|
||||
log.Printf("종목 리스트 로딩 실패: %v", err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.stocks = stocks
|
||||
s.mu.Unlock()
|
||||
log.Printf("종목 리스트 로딩 완료: %d개", len(stocks))
|
||||
}()
|
||||
}
|
||||
|
||||
// Search 종목명 또는 코드로 검색 (최대 10건)
|
||||
func (s *SearchService) Search(q string) []StockItem {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" || len(s.stocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
qLower := strings.ToLower(q)
|
||||
isDigit := isAllDigit(q)
|
||||
|
||||
var results []StockItem
|
||||
for _, st := range s.stocks {
|
||||
var match bool
|
||||
if isDigit {
|
||||
// 숫자면 코드 전방일치
|
||||
match = strings.HasPrefix(st.Code, q)
|
||||
} else {
|
||||
// 문자면 종목명 포함검색
|
||||
match = strings.Contains(strings.ToLower(st.Name), qLower)
|
||||
}
|
||||
if match {
|
||||
results = append(results, st)
|
||||
if len(results) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// fetchAll ka10099 연속조회로 코스피+코스닥 전체 종목 수집
|
||||
func (s *SearchService) fetchAll() ([]StockItem, error) {
|
||||
var all []StockItem
|
||||
for _, mrkt := range []struct{ tp, name string }{
|
||||
{"0", "KOSPI"},
|
||||
{"10", "KOSDAQ"},
|
||||
} {
|
||||
items, err := s.fetchMarket(mrkt.tp, mrkt.name)
|
||||
if err != nil {
|
||||
log.Printf("종목 리스트 조회 실패 (%s): %v", mrkt.name, err)
|
||||
continue
|
||||
}
|
||||
all = append(all, items...)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// fetchMarket 특정 시장의 전체 종목을 연속조회로 수집
|
||||
func (s *SearchService) fetchMarket(mrktTp, marketName string) ([]StockItem, error) {
|
||||
body := map[string]string{"mrkt_tp": mrktTp}
|
||||
|
||||
var items []StockItem
|
||||
contYn := "N"
|
||||
nextKey := ""
|
||||
|
||||
for {
|
||||
raw, respContYn, respNextKey, err := s.kiwoom.postPaged("ka10099", "/api/dostk/stkinfo", body, contYn, nextKey)
|
||||
if err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
List []struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
} `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
for _, it := range resp.List {
|
||||
items = append(items, StockItem{
|
||||
Code: it.Code,
|
||||
Name: it.Name,
|
||||
Market: marketName,
|
||||
})
|
||||
}
|
||||
|
||||
// 연속조회 종료 조건
|
||||
if respContYn != "Y" || respNextKey == "" {
|
||||
break
|
||||
}
|
||||
contYn = "Y"
|
||||
nextKey = respNextKey
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func isAllDigit(s string) bool {
|
||||
for _, r := range s {
|
||||
if !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
75
services/session_service.go
Normal file
75
services/session_service.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionService 인메모리 세션 스토어
|
||||
type SessionService struct {
|
||||
sessions sync.Map // sessionID → time.Time(만료시각)
|
||||
ttl time.Duration // 세션 유효 기간
|
||||
}
|
||||
|
||||
var sessionServiceInstance *SessionService
|
||||
var sessionServiceOnce sync.Once
|
||||
|
||||
// GetSessionService 세션 서비스 싱글톤 반환
|
||||
func GetSessionService() *SessionService {
|
||||
sessionServiceOnce.Do(func() {
|
||||
sessionServiceInstance = NewSessionService()
|
||||
})
|
||||
return sessionServiceInstance
|
||||
}
|
||||
|
||||
// NewSessionService 세션 서비스 초기화 (TTL 24시간)
|
||||
func NewSessionService() *SessionService {
|
||||
svc := &SessionService{ttl: 24 * time.Hour}
|
||||
go svc.cleanup()
|
||||
return svc
|
||||
}
|
||||
|
||||
// Create 새 세션 생성 → sessionID(UUID) 반환
|
||||
func (s *SessionService) Create() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
id := hex.EncodeToString(b)
|
||||
s.sessions.Store(id, time.Now().Add(s.ttl))
|
||||
return id
|
||||
}
|
||||
|
||||
// Validate 세션 유효성 검사 (만료 시 자동 삭제)
|
||||
func (s *SessionService) Validate(id string) bool {
|
||||
val, ok := s.sessions.Load(id)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
expiry, ok := val.(time.Time)
|
||||
if !ok || time.Now().After(expiry) {
|
||||
s.sessions.Delete(id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete 세션 삭제 (로그아웃)
|
||||
func (s *SessionService) Delete(id string) {
|
||||
s.sessions.Delete(id)
|
||||
}
|
||||
|
||||
// cleanup 만료된 세션을 주기적으로 정리 (1시간 주기)
|
||||
func (s *SessionService) cleanup() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
s.sessions.Range(func(key, val any) bool {
|
||||
if expiry, ok := val.(time.Time); ok && now.After(expiry) {
|
||||
s.sessions.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
103
services/stock_service.go
Normal file
103
services/stock_service.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"stocksearch/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StockService 주식 비즈니스 로직 (캐시 + 키움 API 조합)
|
||||
type StockService struct {
|
||||
kiwoom *KiwoomClient
|
||||
cache *CacheService
|
||||
}
|
||||
|
||||
var stockSvc *StockService
|
||||
|
||||
// GetStockService 주식 서비스 싱글턴 반환
|
||||
func GetStockService() *StockService {
|
||||
if stockSvc == nil {
|
||||
stockSvc = &StockService{
|
||||
kiwoom: GetKiwoomClient(),
|
||||
cache: GetCacheService(),
|
||||
}
|
||||
}
|
||||
return stockSvc
|
||||
}
|
||||
|
||||
// GetCurrentPrice 현재가 조회 (1초 캐시 적용)
|
||||
func (s *StockService) GetCurrentPrice(stockCode string) (*models.StockPrice, error) {
|
||||
cacheKey := "price:" + stockCode
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if price, ok := cached.(*models.StockPrice); ok {
|
||||
return price, nil
|
||||
}
|
||||
}
|
||||
|
||||
price, err := s.kiwoom.GetCurrentPrice(stockCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("현재가 조회 실패 [%s]: %w", stockCode, err)
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, price, 30*time.Second)
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// GetDailyChart 일봉 데이터 조회 (5분 캐시 적용)
|
||||
func (s *StockService) GetDailyChart(stockCode string) ([]models.CandleData, error) {
|
||||
cacheKey := "chart:daily:" + stockCode
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if candles, ok := cached.([]models.CandleData); ok {
|
||||
return candles, nil
|
||||
}
|
||||
}
|
||||
|
||||
candles, err := s.kiwoom.GetDailyChart(stockCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("일봉 조회 실패 [%s]: %w", stockCode, err)
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, candles, 5*time.Minute)
|
||||
return candles, nil
|
||||
}
|
||||
|
||||
// GetMinuteChart 분봉 데이터 조회 (30초 캐시 적용)
|
||||
func (s *StockService) GetMinuteChart(stockCode string, minutes int) ([]models.CandleData, error) {
|
||||
cacheKey := fmt.Sprintf("chart:minute%d:%s", minutes, stockCode)
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if candles, ok := cached.([]models.CandleData); ok {
|
||||
return candles, nil
|
||||
}
|
||||
}
|
||||
|
||||
candles, err := s.kiwoom.GetMinuteChart(stockCode, minutes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("분봉 조회 실패 [%s]: %w", stockCode, err)
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, candles, 30*time.Second)
|
||||
return candles, nil
|
||||
}
|
||||
|
||||
// GetTopFluctuation 상위 등락률 종목 조회 (1분 캐시 적용)
|
||||
func (s *StockService) GetTopFluctuation(market string, ascending bool, count int) ([]models.StockPrice, error) {
|
||||
dir := "up"
|
||||
if ascending {
|
||||
dir = "down"
|
||||
}
|
||||
cacheKey := fmt.Sprintf("fluctuation:%s:%s:%d", market, dir, count)
|
||||
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if stocks, ok := cached.([]models.StockPrice); ok {
|
||||
return stocks, nil
|
||||
}
|
||||
}
|
||||
|
||||
stocks, err := s.kiwoom.GetTopFluctuation(market, ascending, count)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("등락률 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, stocks, 1*time.Minute)
|
||||
return stocks, nil
|
||||
}
|
||||
152
services/theme_service.go
Normal file
152
services/theme_service.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"stocksearch/models"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ThemeService 테마 분석 서비스
|
||||
type ThemeService struct {
|
||||
kiwoom *KiwoomClient
|
||||
cache *CacheService
|
||||
}
|
||||
|
||||
var themeSvc *ThemeService
|
||||
|
||||
// GetThemeService 테마 서비스 싱글턴 반환
|
||||
func GetThemeService() *ThemeService {
|
||||
if themeSvc == nil {
|
||||
themeSvc = &ThemeService{
|
||||
kiwoom: GetKiwoomClient(),
|
||||
cache: GetCacheService(),
|
||||
}
|
||||
}
|
||||
return themeSvc
|
||||
}
|
||||
|
||||
// GetThemes ka90001: 테마그룹 목록 조회 (캐시 1분)
|
||||
// sortTp: "3"=상위등락률, "1"=상위기간수익률
|
||||
func (s *ThemeService) GetThemes(dateTp, sortTp string) ([]models.ThemeGroup, error) {
|
||||
cacheKey := fmt.Sprintf("themes:%s:%s", dateTp, sortTp)
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if groups, ok := cached.([]models.ThemeGroup); ok {
|
||||
return groups, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := s.kiwoom.post("ka90001", "/api/dostk/thme", map[string]string{
|
||||
"qry_tp": "0", // 전체검색
|
||||
"stk_cd": "",
|
||||
"date_tp": dateTp,
|
||||
"thema_nm": "",
|
||||
"flu_pl_amt_tp": sortTp,
|
||||
"stex_tp": "1", // KRX
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("테마 목록 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ThemaGrp []struct {
|
||||
ThemaGrpCd string `json:"thema_grp_cd"`
|
||||
ThemaNm string `json:"thema_nm"`
|
||||
StkNum string `json:"stk_num"`
|
||||
FluSig string `json:"flu_sig"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
RisingStkNum string `json:"rising_stk_num"`
|
||||
FallStkNum string `json:"fall_stk_num"`
|
||||
DtPrftRt string `json:"dt_prft_rt"`
|
||||
MainStk string `json:"main_stk"`
|
||||
} `json:"thema_grp"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("테마 목록 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("테마 목록 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
groups := make([]models.ThemeGroup, 0, len(result.ThemaGrp))
|
||||
for _, g := range result.ThemaGrp {
|
||||
groups = append(groups, models.ThemeGroup{
|
||||
Code: g.ThemaGrpCd,
|
||||
Name: g.ThemaNm,
|
||||
StockCount: int(parseIntSafe(g.StkNum)),
|
||||
FluSig: g.FluSig,
|
||||
FluRt: parseFloatSafe(strings.TrimPrefix(g.FluRt, "+")),
|
||||
RisingCount: int(parseIntSafe(g.RisingStkNum)),
|
||||
FallCount: int(parseIntSafe(g.FallStkNum)),
|
||||
PeriodRt: parseFloatSafe(strings.TrimPrefix(g.DtPrftRt, "+")),
|
||||
MainStock: g.MainStk,
|
||||
})
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, groups, time.Minute)
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetThemeStocks ka90002: 테마구성종목 조회 (캐시 2분)
|
||||
func (s *ThemeService) GetThemeStocks(themeCode, dateTp string) (*models.ThemeDetail, error) {
|
||||
cacheKey := fmt.Sprintf("theme_stocks:%s:%s", themeCode, dateTp)
|
||||
if cached, ok := s.cache.Get(cacheKey); ok {
|
||||
if detail, ok := cached.(*models.ThemeDetail); ok {
|
||||
return detail, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := s.kiwoom.post("ka90002", "/api/dostk/thme", map[string]string{
|
||||
"thema_grp_cd": themeCode,
|
||||
"date_tp": dateTp,
|
||||
"stex_tp": "1", // KRX
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("테마 구성종목 조회 실패: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
FluRt string `json:"flu_rt"`
|
||||
DtPrftRt string `json:"dt_prft_rt"`
|
||||
ThemaCompStk []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
FluSig string `json:"flu_sig"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
} `json:"thema_comp_stk"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("테마 구성종목 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("테마 구성종목 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
stocks := make([]models.ThemeStock, 0, len(result.ThemaCompStk))
|
||||
for _, s := range result.ThemaCompStk {
|
||||
stocks = append(stocks, models.ThemeStock{
|
||||
Code: s.StkCd,
|
||||
Name: s.StkNm,
|
||||
CurPrc: absParseIntSafe(s.CurPrc),
|
||||
FluSig: s.FluSig,
|
||||
PredPre: parseIntSafe(s.PredPre),
|
||||
FluRt: parseFloatSafe(strings.TrimPrefix(s.FluRt, "+")),
|
||||
})
|
||||
}
|
||||
|
||||
detail := &models.ThemeDetail{
|
||||
FluRt: parseFloatSafe(strings.TrimPrefix(result.FluRt, "+")),
|
||||
PeriodRt: parseFloatSafe(strings.TrimPrefix(result.DtPrftRt, "+")),
|
||||
Stocks: stocks,
|
||||
}
|
||||
|
||||
s.cache.Set(cacheKey, detail, 2*time.Minute)
|
||||
return detail, nil
|
||||
}
|
||||
129
services/token_service.go
Normal file
129
services/token_service.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"stocksearch/config"
|
||||
"stocksearch/models"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenService 키움증권 액세스 토큰 관리 (싱글턴)
|
||||
type TokenService struct {
|
||||
mu sync.RWMutex
|
||||
token *models.TokenResponse
|
||||
}
|
||||
|
||||
var tokenSvc *TokenService
|
||||
var tokenOnce sync.Once
|
||||
|
||||
// GetTokenService 토큰 서비스 싱글턴 반환
|
||||
func GetTokenService() *TokenService {
|
||||
tokenOnce.Do(func() {
|
||||
tokenSvc = &TokenService{}
|
||||
})
|
||||
return tokenSvc
|
||||
}
|
||||
|
||||
// Start 서버 시작 시 토큰 발급 후 자동 갱신 고루틴 실행
|
||||
func (s *TokenService) Start() error {
|
||||
if err := s.refresh(); err != nil {
|
||||
return fmt.Errorf("초기 토큰 발급 실패: %w", err)
|
||||
}
|
||||
go s.autoRefresh()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetToken 현재 유효한 액세스 토큰 반환
|
||||
func (s *TokenService) GetToken() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.token == nil {
|
||||
return ""
|
||||
}
|
||||
return s.token.Token
|
||||
}
|
||||
|
||||
// refresh 키움증권 API로 토큰 발급
|
||||
func (s *TokenService) refresh() error {
|
||||
cfg := config.App
|
||||
body := map[string]string{
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": cfg.AppKey,
|
||||
"secretkey": cfg.AppSecret,
|
||||
}
|
||||
data, _ := json.Marshal(body)
|
||||
|
||||
resp, err := http.Post(
|
||||
cfg.BaseURL+"/oauth2/token",
|
||||
"application/json;charset=UTF-8",
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("토큰 발급 요청 실패: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("토큰 발급 응답 오류: HTTP %d, 응답: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// HTML 응답 시 서버 점검 중으로 판단
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if len(respBody) > 0 && respBody[0] == '<' || (contentType != "" && !bytes.Contains([]byte(contentType), []byte("json"))) {
|
||||
return fmt.Errorf("키움증권 서버 점검 중 (HTML 응답): %s", contentType)
|
||||
}
|
||||
|
||||
var tokenResp models.TokenResponse
|
||||
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||
return fmt.Errorf("토큰 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if tokenResp.ReturnCode != 0 {
|
||||
return fmt.Errorf("토큰 발급 실패: %s", tokenResp.ReturnMsg)
|
||||
}
|
||||
|
||||
// expires_dt: YYYYMMDDHHmmss 형식 파싱
|
||||
expiresTime, err := time.ParseInLocation("20060102150405", tokenResp.ExpiresAt, time.Local)
|
||||
if err != nil {
|
||||
// 파싱 실패 시 12시간 후를 기본값으로 설정
|
||||
expiresTime = time.Now().Add(12 * time.Hour)
|
||||
}
|
||||
tokenResp.ExpiresTime = expiresTime
|
||||
|
||||
s.mu.Lock()
|
||||
s.token = &tokenResp
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("토큰 발급 완료 (만료: %s)", expiresTime.Format("2006-01-02 15:04:05"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoRefresh 만료 1시간 전에 자동으로 토큰 갱신
|
||||
func (s *TokenService) autoRefresh() {
|
||||
for {
|
||||
s.mu.RLock()
|
||||
expiresTime := s.token.ExpiresTime
|
||||
s.mu.RUnlock()
|
||||
|
||||
// 만료 1시간 전에 갱신
|
||||
refreshAt := expiresTime.Add(-1 * time.Hour)
|
||||
waitDuration := time.Until(refreshAt)
|
||||
if waitDuration < 0 {
|
||||
waitDuration = 10 * time.Second // 이미 만료됐으면 즉시 재시도
|
||||
}
|
||||
|
||||
time.Sleep(waitDuration)
|
||||
|
||||
if err := s.refresh(); err != nil {
|
||||
log.Printf("토큰 자동 갱신 실패: %v (10초 후 재시도)", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
101
services/watchlist_service.go
Normal file
101
services/watchlist_service.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// WatchlistItem 관심종목 항목
|
||||
type WatchlistItem struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WatchlistService 관심종목 JSON 파일 CRUD
|
||||
type WatchlistService struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
list []WatchlistItem
|
||||
}
|
||||
|
||||
var watchlistServiceInstance *WatchlistService
|
||||
var watchlistServiceOnce sync.Once
|
||||
|
||||
// GetWatchlistService 관심종목 서비스 싱글톤 반환
|
||||
func GetWatchlistService() *WatchlistService {
|
||||
watchlistServiceOnce.Do(func() {
|
||||
watchlistServiceInstance = NewWatchlistService("data/watchlist.json")
|
||||
})
|
||||
return watchlistServiceInstance
|
||||
}
|
||||
|
||||
// NewWatchlistService 관심종목 서비스 초기화 (파일 로드)
|
||||
func NewWatchlistService(path string) *WatchlistService {
|
||||
svc := &WatchlistService{path: path}
|
||||
svc.load()
|
||||
return svc
|
||||
}
|
||||
|
||||
// GetAll 전체 관심종목 반환
|
||||
func (s *WatchlistService) GetAll() []WatchlistItem {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]WatchlistItem, len(s.list))
|
||||
copy(result, s.list)
|
||||
return result
|
||||
}
|
||||
|
||||
// Add 관심종목 추가 (중복 방지)
|
||||
func (s *WatchlistService) Add(code, name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, item := range s.list {
|
||||
if item.Code == code {
|
||||
return fmt.Errorf("이미 추가된 종목입니다: %s", code)
|
||||
}
|
||||
}
|
||||
s.list = append(s.list, WatchlistItem{Code: code, Name: name})
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Remove 관심종목 삭제
|
||||
func (s *WatchlistService) Remove(code string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
newList := make([]WatchlistItem, 0, len(s.list))
|
||||
for _, item := range s.list {
|
||||
if item.Code != code {
|
||||
newList = append(newList, item)
|
||||
}
|
||||
}
|
||||
s.list = newList
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// load 파일에서 관심종목 로드 (파일 없으면 빈 배열 초기화)
|
||||
func (s *WatchlistService) load() {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
s.list = []WatchlistItem{}
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(data, &s.list); err != nil {
|
||||
s.list = []WatchlistItem{}
|
||||
}
|
||||
}
|
||||
|
||||
// save 관심종목을 JSON 파일에 저장 (내부용)
|
||||
func (s *WatchlistService) save() error {
|
||||
// data/ 디렉토리 자동 생성
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s.list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user