수정사항 반영
This commit is contained in:
@@ -33,6 +33,18 @@ logger = logging.getLogger(__name__)
|
|||||||
# 한국 시간대
|
# 한국 시간대
|
||||||
KST = pytz.timezone('Asia/Seoul')
|
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
|
# Request/Response Models
|
||||||
class SellRequest(BaseModel):
|
class SellRequest(BaseModel):
|
||||||
stock_code: str
|
stock_code: str
|
||||||
@@ -144,15 +156,45 @@ class PortfolioCache:
|
|||||||
self.next_retry_at = None
|
self.next_retry_at = None
|
||||||
self.last_sync_success = True
|
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
|
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_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)
|
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 = total_value - total_investment
|
||||||
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
|
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
|
||||||
|
|
||||||
# KIS API output2에서 예수금 및 총자산 정보 추출
|
# KIS API output2에서 예수금 및 총자산 정보 추출
|
||||||
available_cash = 0.0
|
available_cash = 0.0
|
||||||
total_assets = 0.0
|
total_assets = 0.0
|
||||||
@@ -164,19 +206,10 @@ class PortfolioCache:
|
|||||||
logger.info(f"💰 output2 data: {output2}")
|
logger.info(f"💰 output2 data: {output2}")
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠️ No 'output2' in balance_data!")
|
logger.warning("⚠️ No 'output2' in balance_data!")
|
||||||
|
|
||||||
if balance_data and "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"]
|
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, [
|
available_cash = pick_float(output2, [
|
||||||
"dnca_tot_amt",
|
"dnca_tot_amt",
|
||||||
"d2_depo_amt",
|
"d2_depo_amt",
|
||||||
@@ -189,7 +222,6 @@ class PortfolioCache:
|
|||||||
if kis_total_assets > 0:
|
if kis_total_assets > 0:
|
||||||
total_assets = kis_total_assets
|
total_assets = kis_total_assets
|
||||||
calculated_assets = total_value + available_cash
|
calculated_assets = total_value + available_cash
|
||||||
|
|
||||||
if abs(kis_total_assets - calculated_assets) > 1000:
|
if abs(kis_total_assets - calculated_assets) > 1000:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
|
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
|
||||||
@@ -210,16 +242,27 @@ class PortfolioCache:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# D+2 정산액 및 기타 상세 정보 추출
|
# D+2 정산액 및 기타 상세 정보 추출
|
||||||
d2_settlement = 0.0
|
d2_settlement = 0.0
|
||||||
stock_value_from_api = 0.0
|
stock_value_from_api = 0.0
|
||||||
|
|
||||||
if balance_data and "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"]
|
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"])
|
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"])
|
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 = {
|
self.summary = {
|
||||||
"total_stocks": total_stocks,
|
"total_stocks": total_stocks,
|
||||||
"total_value": total_value,
|
"total_value": total_value,
|
||||||
@@ -229,7 +272,11 @@ class PortfolioCache:
|
|||||||
"available_cash": available_cash,
|
"available_cash": available_cash,
|
||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
"d2_settlement": d2_settlement,
|
"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()
|
portfolio_cache = PortfolioCache()
|
||||||
@@ -247,7 +294,8 @@ class PortfolioResponse(BaseModel):
|
|||||||
current_price: Decimal = None
|
current_price: Decimal = None
|
||||||
profit_loss: Decimal = None
|
profit_loss: Decimal = None
|
||||||
profit_loss_rate: Decimal = None
|
profit_loss_rate: Decimal = None
|
||||||
|
market: str = "KRX"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -305,7 +353,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
|||||||
try:
|
try:
|
||||||
portfolio_cache.last_attempt = datetime.now()
|
portfolio_cache.last_attempt = datetime.now()
|
||||||
try:
|
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 전체 응답 로깅
|
# 디버깅: KIS API 전체 응답 로깅
|
||||||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
|
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.current_price = current_price
|
||||||
h.profit_loss = profit_loss
|
h.profit_loss = profit_loss
|
||||||
h.profit_loss_rate = profit_loss_rate
|
h.profit_loss_rate = profit_loss_rate
|
||||||
|
h.market = "KRX"
|
||||||
new_holdings.append(h)
|
new_holdings.append(h)
|
||||||
else:
|
else:
|
||||||
new_holding = Portfolio(
|
new_holding = Portfolio(
|
||||||
@@ -439,10 +499,87 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
|||||||
average_price=average_price,
|
average_price=average_price,
|
||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
profit_loss=profit_loss,
|
profit_loss=profit_loss,
|
||||||
profit_loss_rate=profit_loss_rate
|
profit_loss_rate=profit_loss_rate,
|
||||||
|
market="KRX",
|
||||||
)
|
)
|
||||||
db.add(new_holding)
|
db.add(new_holding)
|
||||||
new_holdings.append(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에서 완전 삭제)
|
# 제거된 종목 반영 (DB에서 완전 삭제)
|
||||||
for code, h in existing_all.items():
|
for code, h in existing_all.items():
|
||||||
if code not in processed_codes:
|
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.delete(h)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
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.last_synced = datetime.now()
|
||||||
portfolio_cache.change_flag = False
|
portfolio_cache.change_flag = False
|
||||||
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
|
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
|
stock_name = holding.stock_name
|
||||||
quantity = holding.quantity
|
quantity = holding.quantity
|
||||||
avg_price = float(holding.average_price or 0)
|
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)
|
latest_price = float(holding.current_price or 0)
|
||||||
if price_resp and price_resp.get("output"):
|
if domestic:
|
||||||
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
|
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:
|
if latest_price <= 0 or avg_price <= 0:
|
||||||
continue
|
continue
|
||||||
@@ -660,43 +804,77 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
|||||||
active_trade = at_result.scalar_one_or_none()
|
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
|
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0
|
||||||
|
|
||||||
# ATR 기반 청산 시도 (차트 데이터 필요)
|
# ── ATR 기반 청산 시도 (국내만, 해외는 고정 비율 폴백) ──────
|
||||||
exit_signal = None
|
exit_signal = None
|
||||||
try:
|
if domestic:
|
||||||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
|
try:
|
||||||
chart_data = chart_resp.get("output2", [])
|
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
|
||||||
if len(chart_data) >= 20:
|
chart_data = chart_resp.get("output2", [])
|
||||||
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
|
if len(chart_data) >= 20:
|
||||||
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
|
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
|
||||||
lows = [float(d.get("stck_lwpr", 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)
|
atr = calculate_atr(highs, lows, closes)
|
||||||
rsi = calculate_rsi(closes)
|
rsi = calculate_rsi(closes)
|
||||||
bb = calculate_bollinger(closes)
|
bb = calculate_bollinger(closes)
|
||||||
|
|
||||||
if atr and rsi is not None and bb:
|
if atr and rsi is not None and bb:
|
||||||
bb_upper, bb_middle, bb_lower = bb
|
bb_upper, bb_middle, bb_lower = bb
|
||||||
lowest_since = min(lows[-5:]) if lows else avg_price
|
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(
|
if atr and rsi is not None and bb:
|
||||||
regime=regime,
|
bb_upper, bb_middle, bb_lower = bb
|
||||||
rsi=rsi,
|
lowest_since = min(lows[-5:]) if lows else avg_price
|
||||||
close=latest_price,
|
trailing_high = max(highs[-10:]) if highs else None
|
||||||
bb_upper=bb_upper,
|
regime = MarketRegime.BULL
|
||||||
bb_middle=bb_middle,
|
|
||||||
bb_lower=bb_lower,
|
exit_signal = check_exit_signal(
|
||||||
entry_price=avg_price,
|
regime=regime,
|
||||||
atr=atr,
|
rsi=rsi,
|
||||||
lowest_since_entry=lowest_since,
|
close=latest_price,
|
||||||
remaining_pct=remaining_pct,
|
bb_upper=bb_upper,
|
||||||
trailing_high=trailing_high,
|
bb_middle=bb_middle,
|
||||||
)
|
bb_lower=bb_lower,
|
||||||
except Exception as chart_err:
|
entry_price=avg_price,
|
||||||
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
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
|
should_exit = False
|
||||||
exit_reason = ""
|
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}%)"
|
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
|
||||||
|
|
||||||
if should_exit:
|
if should_exit:
|
||||||
logger.info(f"{exit_reason} - {stock_name}({stock_code})")
|
logger.info(f"{exit_reason} - {stock_name}({stock_code}) [{'국내' if domestic else '해외/' + market}]")
|
||||||
try:
|
# ── 주문 실행 (국내/해외 분기) ───────────────────────────
|
||||||
order_result = await korea_investment_api.place_order(
|
if domestic:
|
||||||
stock_code=stock_code,
|
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",
|
order_type="SELL",
|
||||||
quantity=sell_quantity,
|
quantity=sell_quantity,
|
||||||
price=order_price
|
price=order_price,
|
||||||
)
|
exchange_code=market,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
total_amount = Decimal(str(executed_price)) * sell_quantity
|
||||||
profit_loss = (float(executed_price) - avg_price) * sell_quantity
|
profit_loss = (float(executed_price) - avg_price) * sell_quantity
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class TradeHistory(Base):
|
|||||||
|
|
||||||
class Portfolio(Base):
|
class Portfolio(Base):
|
||||||
__tablename__ = "portfolio"
|
__tablename__ = "portfolio"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
stock_code = Column(String(20), nullable=False, unique=True)
|
stock_code = Column(String(20), nullable=False, unique=True)
|
||||||
stock_name = Column(String(100))
|
stock_name = Column(String(100))
|
||||||
@@ -118,6 +118,7 @@ class Portfolio(Base):
|
|||||||
current_price = Column(Numeric(15, 2))
|
current_price = Column(Numeric(15, 2))
|
||||||
profit_loss = Column(Numeric(15, 2))
|
profit_loss = Column(Numeric(15, 2))
|
||||||
profit_loss_rate = Column(Numeric(10, 4))
|
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)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -773,7 +773,39 @@ class KoreaInvestmentAPI:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching balance: {e}")
|
logger.error(f"Error fetching balance: {e}")
|
||||||
raise
|
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(
|
async def place_order(
|
||||||
self,
|
self,
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user