first commit

This commit is contained in:
hayato5246
2026-03-31 19:32:59 +09:00
commit d10b794c9f
78 changed files with 1671595 additions and 0 deletions

377
services/account_service.go Normal file
View 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
}

View 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
}

View 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
View 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
View 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
View 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
View 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()
}

View 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
}

View 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
View 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, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", "\"")
s = strings.ReplaceAll(s, "&#39;", "'")
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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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)
}
}
}

View 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)
}