Skip to content

Commit 0962250

Browse files
committed
Introduce symbol conversion utilities and integrate into services
- Add `to_kis_symbol`, `to_yahoo_symbol`, and `to_db_symbol` in `symbol.py` for consistent handling of symbol formats across external APIs. - Update KIS services to use `to_kis_symbol` for API requests, ensuring accurate symbol formatting. - Integrate `to_db_symbol` into KIS trading and holdings services to standardize symbol comparisons. - Adapt Yahoo Finance fetch functions to utilize `to_yahoo_symbol` for ticker formatting. - Ensure robust symbol normalization in tasks and automate conversions where necessary.
1 parent f70ad7b commit 0962250

File tree

6 files changed

+71
-23
lines changed

6 files changed

+71
-23
lines changed

app/core/symbol.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
해외주식 심볼 변환 유틸리티
3+
4+
DB 기준 형식: `.` (예: BRK.B)
5+
- Yahoo Finance: `-` (예: BRK-B)
6+
- KIS API: `/` (예: BRK/B)
7+
"""
8+
9+
10+
def to_kis_symbol(symbol: str) -> str:
11+
"""DB 심볼(.)을 KIS API 형식(/)으로 변환
12+
13+
예: BRK.B -> BRK/B
14+
"""
15+
return symbol.replace(".", "/")
16+
17+
18+
def to_yahoo_symbol(symbol: str) -> str:
19+
"""DB 심볼(.)을 Yahoo Finance 형식(-)으로 변환
20+
21+
예: BRK.B -> BRK-B
22+
"""
23+
return symbol.replace(".", "-")
24+
25+
26+
def to_db_symbol(symbol: str) -> str:
27+
"""외부 심볼을 DB 형식(.)으로 정규화
28+
29+
예: BRK-B -> BRK.B, BRK/B -> BRK.B
30+
"""
31+
return symbol.replace("-", ".").replace("/", ".")

app/services/kis.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pandas import DataFrame
1010

1111
from app.core.config import settings
12+
from app.core.symbol import to_kis_symbol
1213
from app.services.redis_token_manager import redis_token_manager
1314

1415
BASE = "https://openapi.koreainvestment.com:9443"
@@ -856,7 +857,7 @@ async def inquire_overseas_daily_price(
856857
params = {
857858
"AUTH": "",
858859
"EXCD": excd, # 거래소코드 (3자리)
859-
"SYMB": symbol, # 심볼
860+
"SYMB": to_kis_symbol(symbol), # 심볼 (DB형식 . -> KIS형식 /)
860861
"GUBN": "0", # 0:일, 1:주, 2:월
861862
"BYMD": bymd, # 조회기준일자
862863
"MODP": "1", # 0:수정주가 미반영, 1:수정주가 반영
@@ -960,7 +961,7 @@ async def inquire_overseas_price(
960961
params = {
961962
"AUTH": "",
962963
"EXCD": excd, # 거래소코드 (3자리)
963-
"SYMB": symbol,
964+
"SYMB": to_kis_symbol(symbol), # 심볼 (DB형식 . -> KIS형식 /)
964965
}
965966

966967
async with httpx.AsyncClient(timeout=5) as cli:
@@ -1069,7 +1070,7 @@ async def fetch_overseas_fundamental_info(
10691070
params = {
10701071
"AUTH": "",
10711072
"EXCD": excd, # 거래소코드 (3자리)
1072-
"SYMB": symbol,
1073+
"SYMB": to_kis_symbol(symbol), # 심볼 (DB형식 . -> KIS형식 /)
10731074
}
10741075

10751076
async with httpx.AsyncClient(timeout=5) as cli:
@@ -1134,7 +1135,7 @@ async def inquire_overseas_minute_chart(
11341135
params = {
11351136
"AUTH": "",
11361137
"EXCD": excd, # 거래소코드 (3자리)
1137-
"SYMB": symbol,
1138+
"SYMB": to_kis_symbol(symbol), # 심볼 (DB형식 . -> KIS형식 /)
11381139
"NMIN": "1", # 1분봉
11391140
"PINC": "1", # 1:주가, 2:대비
11401141
"NEXT": "", # 연속조회
@@ -1525,7 +1526,7 @@ async def inquire_overseas_buyable_amount(
15251526
"ACNT_PRDT_CD": acnt_prdt_cd,
15261527
"OVRS_EXCG_CD": excd, # 해외거래소코드 (3자리)
15271528
"OVRS_ORD_UNPR": str(price), # 해외주문단가 (0: 시장가)
1528-
"ITEM_CD": symbol, # 종목코드
1529+
"ITEM_CD": to_kis_symbol(symbol), # 종목코드 (DB형식 . -> KIS형식 /)
15291530
}
15301531

15311532
logging.info(f"해외주식 매수가능금액 조회 - symbol: {symbol}, exchange: {excd}, price: {price}")
@@ -1623,7 +1624,7 @@ async def order_overseas_stock(
16231624
"CANO": cano,
16241625
"ACNT_PRDT_CD": acnt_prdt_cd,
16251626
"OVRS_EXCG_CD": excd, # 해외거래소코드 (3자리)
1626-
"PDNO": symbol, # 상품번호(종목코드)
1627+
"PDNO": to_kis_symbol(symbol), # 상품번호(종목코드) (DB형식 . -> KIS형식 /)
16271628
"ORD_DVSN": ord_dvsn, # 주문구분 (00:지정가, 01:시장가)
16281629
"ORD_QTY": str(quantity), # 주문수량
16291630
"OVRS_ORD_UNPR": str(price), # 해외주문단가 (시장가일 경우 0)
@@ -1888,7 +1889,7 @@ async def cancel_overseas_order(
18881889
"CANO": cano,
18891890
"ACNT_PRDT_CD": acnt_prdt_cd,
18901891
"OVRS_EXCG_CD": exchange_code, # 해외거래소코드
1891-
"PDNO": symbol, # 상품번호(종목코드)
1892+
"PDNO": to_kis_symbol(symbol), # 상품번호(종목코드) (DB형식 . -> KIS형식 /)
18921893
"ORGN_ODNO": order_number, # 원주문번호
18931894
"RVSE_CNCL_DVSN_CD": "02", # 정정취소구분코드 (01:정정, 02:취소)
18941895
"ORD_QTY": str(quantity), # 주문수량

app/services/kis_holdings_service.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
import logging
55

6+
from app.core.symbol import to_db_symbol
67
from app.models.manual_holdings import MarketType
78
from app.services.kis import KISClient
89

@@ -13,7 +14,7 @@ async def get_kis_holding_for_ticker(
1314
kis_client: KISClient, ticker: str, market_type: MarketType
1415
) -> dict[str, float]:
1516
"""Fetch KIS holding info for a single ticker."""
16-
normalized_ticker = ticker.upper()
17+
normalized_ticker = to_db_symbol(ticker.upper())
1718
default = {"quantity": 0, "avg_price": 0.0, "current_price": 0.0}
1819

1920
try:
@@ -29,7 +30,8 @@ async def get_kis_holding_for_ticker(
2930
else:
3031
stocks = await kis_client.fetch_overseas_stocks()
3132
for stock in stocks:
32-
if stock.get("ovrs_pdno") == normalized_ticker:
33+
# KIS API 응답의 심볼도 정규화하여 비교
34+
if to_db_symbol(stock.get("ovrs_pdno", "")) == normalized_ticker:
3335
return {
3436
"quantity": int(float(stock.get("ovrs_cblc_qty", 0))),
3537
"avg_price": float(stock.get("pchs_avg_pric", 0)),

app/services/kis_trading_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import logging
33
from typing import Dict, Any, List, Optional, Tuple
4+
from app.core.symbol import to_db_symbol
45
from app.services.kis import KISClient
56
from app.models.analysis import StockAnalysisResult
67
from app.core.config import settings
@@ -452,7 +453,9 @@ async def process_kis_overseas_sell_orders_with_analysis(
452453

453454
# KIS 계좌의 실제 주문가능수량 조회 (토스 등 수동 잔고 제외)
454455
my_stocks = await kis_client.fetch_my_overseas_stocks()
455-
target_stock = next((s for s in my_stocks if s.get('ovrs_pdno') == symbol), None)
456+
# 심볼 형식 정규화하여 비교
457+
normalized_symbol = to_db_symbol(symbol)
458+
target_stock = next((s for s in my_stocks if to_db_symbol(s.get('ovrs_pdno', '')) == normalized_symbol), None)
456459
if target_stock:
457460
# ord_psbl_qty가 있으면 사용, 없으면 ovrs_cblc_qty 사용
458461
actual_qty = int(float(target_stock.get('ord_psbl_qty', target_stock.get('ovrs_cblc_qty', 0))))

app/services/yahoo.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import pandas as pd
55
import yfinance as yf
66

7+
from app.core.symbol import to_yahoo_symbol
8+
79

810
def _flatten_cols(df: pd.DataFrame) -> pd.DataFrame:
911
"""('open','NVDA') → open 처럼 1단 컬럼으로 변환"""
@@ -16,10 +18,11 @@ def _flatten_cols(df: pd.DataFrame) -> pd.DataFrame:
1618

1719
async def fetch_ohlcv(ticker: str, days: int = 100) -> pd.DataFrame:
1820
"""최근 days개(최대 100) 일봉 OHLCV DataFrame 반환"""
21+
yahoo_ticker = to_yahoo_symbol(ticker) # DB형식 . -> Yahoo형식 -
1922
end = datetime.now(timezone.utc).date()
2023
start = end - timedelta(days=days * 2) # 휴일 감안 넉넉히
2124
df = yf.download(
22-
ticker, start=start, end=end, interval="1d", progress=False, auto_adjust=False
25+
yahoo_ticker, start=start, end=end, interval="1d", progress=False, auto_adjust=False
2326
)
2427
df = _flatten_cols(df).reset_index(names="date") # ← 여기만 변경
2528
df = (
@@ -35,9 +38,10 @@ async def fetch_ohlcv(ticker: str, days: int = 100) -> pd.DataFrame:
3538

3639
async def fetch_price(ticker: str) -> pd.DataFrame:
3740
"""미국 장중 현재가(15분 지연) 1행 DF – yfinance fast_info"""
38-
info = yf.Ticker(ticker).fast_info
41+
yahoo_ticker = to_yahoo_symbol(ticker) # DB형식 . -> Yahoo형식 -
42+
info = yf.Ticker(yahoo_ticker).fast_info
3943
row = {
40-
"code": ticker,
44+
"code": ticker, # DB 형식 유지
4145
"date": datetime.now(timezone.utc).date(),
4246
"time": datetime.now(timezone.utc).time(),
4347
"open": getattr(info, "open", 0.0),
@@ -55,7 +59,8 @@ async def fetch_fundamental_info(ticker: str) -> dict:
5559
yf.Ticker(ticker).info에서 PER, PBR, EPS, BPS, 배당수익률 등
5660
주요 펀더멘털 지표를 가져와 딕셔너리로 반환합니다.
5761
"""
58-
info = yf.Ticker(ticker).info
62+
yahoo_ticker = to_yahoo_symbol(ticker) # DB형식 . -> Yahoo형식 -
63+
info = yf.Ticker(yahoo_ticker).info
5964

6065
fundamental_data = {
6166
"PER": info.get("trailingPE"),

app/tasks/kis.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from celery import shared_task
77

88
from app.analysis.service_analyzers import KISAnalyzer
9+
from app.core.symbol import to_db_symbol
910
from app.monitoring.trade_notifier import get_trade_notifier
1011
from app.monitoring.error_reporter import get_error_reporter
1112
from app.services.kis import KISClient
@@ -824,8 +825,10 @@ async def _run() -> dict:
824825
kis = KISClient()
825826
try:
826827
my_stocks = await kis.fetch_my_overseas_stocks()
827-
target_stock = next((s for s in my_stocks if s['ovrs_pdno'] == symbol), None)
828-
828+
# 심볼 형식 정규화하여 비교
829+
normalized_symbol = to_db_symbol(symbol)
830+
target_stock = next((s for s in my_stocks if to_db_symbol(s.get('ovrs_pdno', '')) == normalized_symbol), None)
831+
829832
if target_stock:
830833
avg_price = float(target_stock['pchs_avg_pric'])
831834
current_price = float(target_stock['now_pric2'])
@@ -835,7 +838,7 @@ async def _run() -> dict:
835838
avg_price = 0.0 # 신규 매수이므로 평단 없음
836839
except Exception as price_error:
837840
return {'success': False, 'message': f'현재가 조회 실패: {price_error}'}
838-
841+
839842
res = await process_kis_overseas_buy_orders_with_analysis(kis, symbol, current_price, avg_price)
840843
return res
841844
except Exception as e:
@@ -851,8 +854,10 @@ async def _run() -> dict:
851854
kis = KISClient()
852855
try:
853856
my_stocks = await kis.fetch_my_overseas_stocks()
854-
target_stock = next((s for s in my_stocks if s['ovrs_pdno'] == symbol), None)
855-
857+
# 심볼 형식 정규화하여 비교
858+
normalized_symbol = to_db_symbol(symbol)
859+
target_stock = next((s for s in my_stocks if to_db_symbol(s.get('ovrs_pdno', '')) == normalized_symbol), None)
860+
856861
if not target_stock:
857862
return {'success': False, 'message': '보유 중인 주식이 아닙니다.'}
858863

@@ -1230,10 +1235,11 @@ async def _cancel_overseas_pending_orders(
12301235
# sll_buy_dvsn_cd: 01=매도, 02=매수
12311236
target_code = "02" if order_type == "buy" else "01"
12321237

1233-
# 해당 종목의 주문만 필터링 (필드명 대소문자 모두 확인)
1238+
# 해당 종목의 주문만 필터링 (필드명 대소문자 모두 확인, 심볼 형식 정규화)
1239+
normalized_symbol = to_db_symbol(symbol)
12341240
target_orders = [
12351241
order for order in all_open_orders
1236-
if (order.get('pdno') or order.get('PDNO')) == symbol
1242+
if to_db_symbol(order.get('pdno') or order.get('PDNO') or '') == normalized_symbol
12371243
and (order.get('sll_buy_dvsn_cd') or order.get('SLL_BUY_DVSN_CD')) == target_code
12381244
]
12391245

@@ -1297,8 +1303,8 @@ async def _run() -> dict:
12971303
# 3. 수동 잔고 종목을 KIS 형식으로 변환하여 병합
12981304
for holding in manual_holdings:
12991305
ticker = holding.ticker
1300-
# KIS에 이미 있는 종목은 건너뛰기
1301-
if any(s.get('ovrs_pdno') == ticker for s in my_stocks):
1306+
# KIS에 이미 있는 종목은 건너뛰기 (심볼 형식 정규화하여 비교)
1307+
if any(to_db_symbol(s.get('ovrs_pdno', '')) == ticker for s in my_stocks):
13021308
continue
13031309

13041310
# 수동 잔고 종목을 my_stocks에 추가 (KIS 형식으로 변환)

0 commit comments

Comments
 (0)