수정사항 반영

This commit is contained in:
2026-03-03 10:09:41 +09:00
parent bb89dc3f42
commit 1e22c9a4af
3 changed files with 297 additions and 73 deletions

View File

@@ -33,6 +33,18 @@ logger = logging.getLogger(__name__)
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
# 해외 거래소 코드 목록
OVERSEAS_MARKETS = {"NASD", "NYSE", "AMEX", "TSEX", "SEHK", "SHAA", "SZAA", "HASE", "VNSE"}
def _is_domestic(stock_code: str, market: str = None) -> bool:
"""국내 종목 여부 판별.
market 컬럼이 있으면 우선 사용, 없으면 종목코드 패턴으로 폴백.
"""
if market:
return market not in OVERSEAS_MARKETS
# 폴백: 6자리 숫자이면 국내(KRX)
return stock_code.isdigit() and len(stock_code) == 6
# Request/Response Models
class SellRequest(BaseModel):
stock_code: str
@@ -144,15 +156,45 @@ class PortfolioCache:
self.next_retry_at = None
self.last_sync_success = True
def cache_summary(self, balance_data: dict = None):
def cache_summary(self, balance_data: dict = None, overseas_balance_data: dict = None, exchange_rate: float = 0.0):
"""포트폴리오 요약 캐시 업데이트.
Args:
balance_data: 국내 잔고 KIS API 응답
overseas_balance_data: 해외 잔고 KIS API 응답
exchange_rate: USD/KRW 환율 (해외 종목 원화 환산용, 0이면 미제공)
"""
holdings = self.holdings
def pick_float(d: dict, keys: list) -> float:
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d[k])
except Exception:
continue
return 0.0
total_stocks = len(holdings)
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
# 국내/해외 분리하여 평가금액 계산
domestic_value = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_value_usd = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 해외 원화 환산 (환율 미제공 시 overseas_balance_data output2의 tot_evlu_amt 사용)
overseas_value_krw = overseas_value_usd * exchange_rate if exchange_rate > 0 else 0.0
total_value = domestic_value + overseas_value_krw
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
# 평가손익은 현재가 기준으로 직접 계산 (KIS evlu_pfls_amt는 지연될 수 있음)
total_profit_loss = total_value - total_investment
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
# KIS API output2에서 예수금 및 총자산 정보 추출
available_cash = 0.0
total_assets = 0.0
@@ -164,19 +206,10 @@ class PortfolioCache:
logger.info(f"💰 output2 data: {output2}")
else:
logger.warning("⚠️ No 'output2' in balance_data!")
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
def pick_float(d: dict, keys: list[str]) -> float:
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d.get(k))
except Exception:
continue
return 0.0
available_cash = pick_float(output2, [
"dnca_tot_amt",
"d2_depo_amt",
@@ -189,7 +222,6 @@ class PortfolioCache:
if kis_total_assets > 0:
total_assets = kis_total_assets
calculated_assets = total_value + available_cash
if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug(
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
@@ -210,16 +242,27 @@ class PortfolioCache:
)
except Exception:
pass
# D+2 정산액 및 기타 상세 정보 추출
d2_settlement = 0.0
stock_value_from_api = 0.0
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
# 해외 잔고 합산 (원화 환산 총자산에 포함)
overseas_total_krw = 0.0
if overseas_balance_data and "output2" in overseas_balance_data:
ovrs_out2 = overseas_balance_data["output2"]
if isinstance(ovrs_out2, list):
ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {}
# tot_evlu_amt: 해외주식 평가금액 합계 (원화)
overseas_total_krw = pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"])
if overseas_total_krw > 0:
total_assets += overseas_total_krw
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
self.summary = {
"total_stocks": total_stocks,
"total_value": total_value,
@@ -229,7 +272,11 @@ class PortfolioCache:
"available_cash": available_cash,
"total_assets": total_assets,
"d2_settlement": d2_settlement,
"stock_value_from_api": stock_value_from_api
"stock_value_from_api": stock_value_from_api,
# 해외 관련 추가 필드
"overseas_value_usd": overseas_value_usd,
"overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw,
"exchange_rate": exchange_rate,
}
portfolio_cache = PortfolioCache()
@@ -247,7 +294,8 @@ class PortfolioResponse(BaseModel):
current_price: Decimal = None
profit_loss: Decimal = None
profit_loss_rate: Decimal = None
market: str = "KRX"
class Config:
from_attributes = True
@@ -305,7 +353,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
try:
portfolio_cache.last_attempt = datetime.now()
try:
balance_data = await korea_investment_api.get_balance()
# 국내·해외 잔고 병렬 조회
balance_data, overseas_balance_data = await asyncio.gather(
korea_investment_api.get_balance(),
korea_investment_api.get_overseas_balance(),
return_exceptions=True,
)
if isinstance(balance_data, Exception):
logger.warning(f"⚠️ 국내 잔고 조회 실패: {balance_data}")
balance_data = {}
if isinstance(overseas_balance_data, Exception):
logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}")
overseas_balance_data = {}
# 디버깅: KIS API 전체 응답 로깅
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
@@ -430,6 +489,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
h.current_price = current_price
h.profit_loss = profit_loss
h.profit_loss_rate = profit_loss_rate
h.market = "KRX"
new_holdings.append(h)
else:
new_holding = Portfolio(
@@ -439,10 +499,87 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
average_price=average_price,
current_price=current_price,
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate
profit_loss_rate=profit_loss_rate,
market="KRX",
)
db.add(new_holding)
new_holdings.append(new_holding)
# ── 해외 잔고 처리 ─────────────────────────────────────────────────
if overseas_balance_data and overseas_balance_data.get("rt_cd") == "0" and "output1" in overseas_balance_data:
ovrs_items = overseas_balance_data["output1"] or []
# 해외 현재가 병렬 조회
_ovrs_codes = [
item.get("ovrs_pdno") for item in ovrs_items
if item.get("ovrs_pdno") and int(item.get("ord_psbl_qty", 0)) > 0
]
if _ovrs_codes:
_ovrs_price_results = await asyncio.gather(
*[korea_investment_api.get_overseas_price(c, item.get("ovrs_excg_cd", "NASD"))
for c, item in zip(_ovrs_codes, ovrs_items)],
return_exceptions=True,
)
_ovrs_price_map = dict(zip(_ovrs_codes, _ovrs_price_results))
else:
_ovrs_price_map = {}
for item in ovrs_items:
stock_code = item.get("ovrs_pdno")
if not stock_code:
continue
quantity = int(item.get("ord_psbl_qty", 0))
if quantity <= 0:
continue
stock_name = item.get("ovrs_item_name", stock_code)
exchange_code = item.get("ovrs_excg_cd", "NASD")
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# 실시간 현재가
price_resp = _ovrs_price_map.get(stock_code)
try:
if price_resp and not isinstance(price_resp, Exception) and "output" in price_resp:
realtime_price = float(price_resp["output"].get("last", 0))
current_price = Decimal(str(realtime_price)) if realtime_price > 0 else Decimal(str(item.get("now_pric2", 0)))
else:
current_price = Decimal(str(item.get("now_pric2", 0)))
except Exception:
current_price = Decimal(str(item.get("now_pric2", 0)))
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt2", 0)))
logger.info(
f"[해외] Portfolio sync - {stock_name}({stock_code}/{exchange_code}): "
f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}"
)
processed_codes.add(stock_code)
if stock_code in existing_all:
h = existing_all[stock_code]
h.stock_name = stock_name
h.quantity = quantity
h.average_price = average_price
h.current_price = current_price
h.profit_loss = profit_loss
h.profit_loss_rate = profit_loss_rate
h.market = exchange_code
new_holdings.append(h)
else:
new_holding = Portfolio(
stock_code=stock_code,
stock_name=stock_name,
quantity=quantity,
average_price=average_price,
current_price=current_price,
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
market=exchange_code,
)
db.add(new_holding)
new_holdings.append(new_holding)
# ──────────────────────────────────────────────────────────────────
# 제거된 종목 반영 (DB에서 완전 삭제)
for code, h in existing_all.items():
if code not in processed_codes:
@@ -456,7 +593,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
await db.delete(h)
await db.commit()
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
portfolio_cache.cache_summary(balance_data) # balance_data 전달
portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
portfolio_cache.last_synced = datetime.now()
portfolio_cache.change_flag = False
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
@@ -638,12 +775,19 @@ async def check_and_execute_exit_trades(db: AsyncSession):
stock_name = holding.stock_name
quantity = holding.quantity
avg_price = float(holding.average_price or 0)
market = getattr(holding, "market", None) or ("KRX" if _is_domestic(stock_code) else "NASD")
domestic = _is_domestic(stock_code, market)
# 실시간 가격 확인
price_resp = await korea_investment_api.get_current_price(stock_code)
# ── 실시간 가격 확인 (국내/해외 분기) ───────────────────────
latest_price = float(holding.current_price or 0)
if price_resp and price_resp.get("output"):
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
if domestic:
price_resp = await korea_investment_api.get_current_price(stock_code)
if price_resp and price_resp.get("output"):
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
else:
price_resp = await korea_investment_api.get_overseas_price(stock_code, market)
if price_resp and price_resp.get("output"):
latest_price = float(price_resp["output"].get("last", latest_price))
if latest_price <= 0 or avg_price <= 0:
continue
@@ -660,43 +804,77 @@ async def check_and_execute_exit_trades(db: AsyncSession):
active_trade = at_result.scalar_one_or_none()
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0
# ATR 기반 청산 시도 (차트 데이터 필요)
# ── ATR 기반 청산 시도 (국내만, 해외는 고정 비율 폴백) ──────
exit_signal = None
try:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
if domestic:
try:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
atr = calculate_atr(highs, lows, closes)
rsi = calculate_rsi(closes)
bb = calculate_bollinger(closes)
atr = calculate_atr(highs, lows, closes)
rsi = calculate_rsi(closes)
bb = calculate_bollinger(closes)
if atr and rsi is not None and bb:
bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price
if atr and rsi is not None and bb:
bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price
trailing_high = max(highs[-10:]) if highs else None
regime = MarketRegime.BULL
trailing_high = max(highs[-10:]) if highs else None
exit_signal = check_exit_signal(
regime=regime,
rsi=rsi,
close=latest_price,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=avg_price,
atr=atr,
lowest_since_entry=lowest_since,
remaining_pct=remaining_pct,
trailing_high=trailing_high,
)
except Exception as chart_err:
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
else:
# 해외: 해외 차트로 ATR 계산 시도
try:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, market)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(d.get("clos", 0)) for d in reversed(chart_data)]
highs = [float(d.get("high", 0)) for d in reversed(chart_data)]
lows = [float(d.get("low", 0)) for d in reversed(chart_data)]
regime = MarketRegime.BULL
atr = calculate_atr(highs, lows, closes)
rsi = calculate_rsi(closes)
bb = calculate_bollinger(closes)
exit_signal = check_exit_signal(
regime=regime,
rsi=rsi,
close=latest_price,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=avg_price,
atr=atr,
lowest_since_entry=lowest_since,
remaining_pct=remaining_pct,
trailing_high=trailing_high,
)
except Exception as chart_err:
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
if atr and rsi is not None and bb:
bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price
trailing_high = max(highs[-10:]) if highs else None
regime = MarketRegime.BULL
exit_signal = check_exit_signal(
regime=regime,
rsi=rsi,
close=latest_price,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=avg_price,
atr=atr,
lowest_since_entry=lowest_since,
remaining_pct=remaining_pct,
trailing_high=trailing_high,
)
except Exception as chart_err:
logger.debug(f"[해외] ATR 청산 계산 실패 ({stock_code}): {chart_err}")
should_exit = False
exit_reason = ""
@@ -717,24 +895,37 @@ async def check_and_execute_exit_trades(db: AsyncSession):
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
if should_exit:
logger.info(f"{exit_reason} - {stock_name}({stock_code})")
try:
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
logger.info(f"{exit_reason} - {stock_name}({stock_code}) [{'국내' if domestic else '해외/' + market}]")
# ── 주문 실행 (국내/해외 분기) ───────────────────────────
if domestic:
try:
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_quantity,
price=order_price
)
except Exception as e:
logger.warning(f"지정가 실패, 시장가 대체: {e}")
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_quantity,
price=None
)
else:
order_result = await korea_investment_api.place_overseas_order(
symbol=stock_code,
order_type="SELL",
quantity=sell_quantity,
price=order_price
)
except Exception as e:
logger.warning(f"지정가 실패, 시장가 대체: {e}")
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_quantity,
price=None
price=order_price,
exchange_code=market,
)
executed_price = Decimal(str(order_result.get("output", {}).get("ord_prc", order_price)))
# 해외 주문 응답은 ord_prc 대신 ovrs_ord_unpr 사용
_out = order_result.get("output", {})
_raw_price = _out.get("ord_prc") or _out.get("ovrs_ord_unpr") or order_price
executed_price = Decimal(str(_raw_price))
total_amount = Decimal(str(executed_price)) * sell_quantity
profit_loss = (float(executed_price) - avg_price) * sell_quantity

View File

@@ -109,7 +109,7 @@ class TradeHistory(Base):
class Portfolio(Base):
__tablename__ = "portfolio"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, unique=True)
stock_name = Column(String(100))
@@ -118,6 +118,7 @@ class Portfolio(Base):
current_price = Column(Numeric(15, 2))
profit_loss = Column(Numeric(15, 2))
profit_loss_rate = Column(Numeric(10, 4))
market = Column(String(10), nullable=True, default="KRX") # KRX, NASD, NYSE, AMEX
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -773,7 +773,39 @@ class KoreaInvestmentAPI:
except Exception as e:
logger.error(f"Error fetching balance: {e}")
raise
async def get_overseas_balance(self) -> Dict[str, Any]:
"""해외주식 잔고 조회 (미국 기준)
실전: TTTS3012R / 모의: VTTS3012R
응답 output1: 보유 종목 목록 (ovrs_pdno, ovrs_item_name, ovrs_excg_cd, ord_psbl_qty, pchs_avg_pric, now_pric2, evlu_pfls_amt, evlu_pfls_rt2)
응답 output2: 계좌 요약 (tot_evlu_amt, frcr_evlu_tota, usd_amt, ovrs_tot_pfls)
"""
try:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
is_virtual = "vts" in self.base_url.lower()
tr_id = "VTTS3012R" if is_virtual else "TTTS3012R"
result, _ = await self._make_request(
"GET",
"/uapi/overseas-stock/v1/trading/inquire-balance",
tr_id,
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"OVRS_EXCG_CD": "NASD", # 미국 전체 (NASD 조회 시 NYSE·AMEX 포함)
"TR_CRCY_CD": "USD",
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
)
return result
except Exception as e:
logger.error(f"Error fetching overseas balance: {e}")
return {}
async def place_order(
self,
stock_code: str,