Skip to content
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

일일 운영 뒤 공시·종목별 수급을 자동 수집하고, 새 주문의 계획가격 대비 체결비용과 등록 이후 모의운용 성과를 누적합니다. 화면에서 수집 실패·측정 근거·상승/하락일 성과·결측을 확인할 수 있습니다. [자동 수집·체결비용·장기 검증 안내](docs/RESEARCH_OPERATIONS_20261013.md)

2026년 10월 급락 뒤 투자 방식과 시장 상황을 다시 조사했습니다. 그 결과를 바탕으로 대형주 계좌의 손절·낙폭 한도와 ETF 적립 계좌의 1주 단위 주문 계획을 고쳤고, 넣은 돈 기준 수익률도 함께 보여 줍니다. [투자 방식 조사와 운용 개선](docs/MARKET_RESEARCH_20261020.md)

## 투자 내역 저장

![날짜별 평가금액·투자원금·수익률과 고점 대비 하락률](docs/images/history-table-20260922.png)
Expand Down
23 changes: 20 additions & 3 deletions api/kis_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ def buy_order(
quantity: int,
price: int = 0,
order_type: str = "00",
exchange_id: str = "KRX",
) -> Optional[Dict[str, Any]]:
"""
매수 주문
Expand All @@ -1018,6 +1019,7 @@ def buy_order(
quantity: 주문 수량
price: 주문 가격 (0: 시장가)
order_type: "00"(지정가), "01"(시장가)
exchange_id: 거래소ID구분코드 KRX | NXT | SOR (모의투자는 KRX만 지원)

Returns:
주문 결과 딕셔너리
Expand All @@ -1030,8 +1032,10 @@ def buy_order(
raise PermissionError(
"실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다."
)
# 모의투자 vs 실전 거래 ID
tr_id = "VTTC0802U" if self.use_mock else "TTTC0802U"
# 모의투자 vs 실전 거래 ID — 대체거래소(NXT) 도입 뒤의 현행 주문 규격(TTTC0012U,
# 거래소ID구분코드 필수). 예전 TTTC0802U는 KRX 전용으로 동작했고, 취소(TTTC0013U)는
# 이미 새 규격이라 같은 주문의 접수·취소가 서로 다른 규격을 쓰고 있었다.
tr_id = "VTTC0012U" if self.use_mock else "TTTC0012U"

body = {
"CANO": self.cano,
Expand All @@ -1040,6 +1044,7 @@ def buy_order(
"ORD_DVSN": order_type,
"ORD_QTY": str(quantity),
"ORD_UNPR": str(price),
"EXCG_ID_DVSN_CD": self._order_exchange(exchange_id),
}

data = self._request(
Expand All @@ -1064,6 +1069,7 @@ def sell_order(
quantity: int,
price: int = 0,
order_type: str = "00",
exchange_id: str = "KRX",
) -> Optional[Dict[str, Any]]:
"""
매도 주문
Expand All @@ -1073,6 +1079,7 @@ def sell_order(
quantity: 주문 수량
price: 주문 가격 (0: 시장가)
order_type: "00"(지정가), "01"(시장가)
exchange_id: 거래소ID구분코드 KRX | NXT | SOR (모의투자는 KRX만 지원)

Returns:
주문 결과 딕셔너리
Expand All @@ -1085,7 +1092,7 @@ def sell_order(
raise PermissionError(
"실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다."
)
tr_id = "VTTC0801U" if self.use_mock else "TTTC0801U"
tr_id = "VTTC0011U" if self.use_mock else "TTTC0011U"

body = {
"CANO": self.cano,
Expand All @@ -1094,6 +1101,7 @@ def sell_order(
"ORD_DVSN": order_type,
"ORD_QTY": str(quantity),
"ORD_UNPR": str(price),
"EXCG_ID_DVSN_CD": self._order_exchange(exchange_id),
}

data = self._request(
Expand Down Expand Up @@ -1194,6 +1202,15 @@ def cancel_order(
logger.error("미체결 주문 취소 실패: {} - {}", normalized_order_no, msg)
return None

def _order_exchange(self, exchange_id: str) -> str:
"""주문 거래소 코드. 모의투자는 KRX 주문만 받으므로 다른 값은 미리 막는다."""
code = str(exchange_id or "KRX").strip().upper()
if code not in {"KRX", "NXT", "SOR"}:
raise ValueError("거래소ID구분코드는 KRX, NXT, SOR 중 하나여야 합니다.")
if self.use_mock and code != "KRX":
raise ValueError("모의투자 주문은 KRX만 지원합니다.")
return code

@staticmethod
def _odno_from_order_output(order_output: Optional[Dict[str, Any]]) -> str:
if not order_output or not isinstance(order_output, dict):
Expand Down
16 changes: 16 additions & 0 deletions config/baskets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,24 @@ baskets:
# 단일 종목 대형 손실만 잘라내도록 넓게 잡는다. 실측 참고: 이 구간 최악은
# 현대차 -34.8%였고, 정상 조정권은 -10~-17%대에 몰려 있었다.
stop_loss_pct: 0.25
# 2026-10-20: 손절을 같은 기간 KODEX 200 대비로 잰다. 평균 매입가 대비 -25%는
# 시장 전체가 빠질 때 연달아 걸렸다(9/21 현대모비스, 10/15 삼성전자·현대차 — 그
# 사이 KODEX 200도 -30% 가까이 하락). 두 구간(2014-07~, 2021-12~) 비교에서 절대
# 손절로 판 종목은 그 뒤 60일 동안 시장보다 +1~+2%p 더 올랐고(바닥에서 판 셈), 시장
# 대비 손절로 판 종목은 -1~-3%p 더 빠졌다. 수익률은 손절 없음과 비슷해서, 수익을
# 올리는 장치가 아니라 한 종목 사고만 막는 장치로 둔다. docs/HOLD_RISK_REVIEW_20261020.md
stop_basis: relative
stop_benchmark: "069500"
take_profit_pct: 0 # 익절 없음 — 장기 보유가 이 트랙의 전제
trailing_stop_pct: 0 # 트레일링 없음 — 저회전 유지
# 손절한 종목의 재매수를 막는 기간. 0이면 손절이 무의미해진다 — 비워진 슬롯을
# 같은 사이클의 비중 교정이 곧바로 되사기 때문이다(2026-08-07 10:07에 실제로
# 현대차를 손절 4초 뒤 되사는 왕복매매가 발생). 분기 리밸런싱 성격에 맞춰 60일.
reentry_cooldown_days: 60
# 계좌 낙폭이 15%를 넘어도 선언한 주식 비중(60%)까지는 되산다. 전역 낙폭 가드에
# 맡겨 두면 손절·비중 초과 매도로 생긴 현금이 돌아가지 못해 비중이 한쪽으로만
# 줄어든다(10/15 이후 45%). 주문 단계의 노출 상한(60% + 밴드 3%p)은 그대로 검사한다.
account_drawdown_guard: delegate
monitoring:
# 2026-08-26: 1.0(감시 사실상 해제)에서 복원. 1.0으로 둔 이유는 '하이닉스 슬롯을
# 못 채워 생기는 -21%p 미달은 조치 불가'였는데, 그 슬롯을 제거하고 목표 배치율을
Expand Down Expand Up @@ -222,6 +234,10 @@ baskets:
min_trade_amount: 50000
max_turnover_ratio: 0.6
deployment_band: 0.03
# 종목별 내림 대신 1주 단위 수량 조합을 함께 고른다(core/integer_allocation.py).
# 10/12 주식 ETF 26.7%·CD 61.2%(목표 47.5%·47.5%)에서 종목별 부족·초과가 모두 1주
# 미만이라 주문이 0건이었다. CD 2주를 팔아 주식 ETF 1주를 사면 주식 53.5%가 된다.
planner: integer
holdings:
"069500": 0.5 # KODEX 200 — 위험자산 슬리브(총자산의 47.5%)
"357870": 0.5 # TIGER CD금리투자KIS(합성) — 파킹 슬리브(총자산의 47.5%)
Expand Down
40 changes: 39 additions & 1 deletion core/basket_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from __future__ import annotations

import math
from typing import Any

from loguru import logger
Expand Down Expand Up @@ -329,6 +330,34 @@ def _d(v):
return max(weighted, 1.0)


def money_weighted_metrics(
starting_capital: float,
flows: list,
operation_start: Any,
end_day: Any,
end_value: float | None,
) -> dict[str, float | None]:
"""넣은 돈 기준 수익률(Modified Dietz) — 같은 기간의 시간가중 수익률과 나란히 본다.

(기말 평가액 − 원금 − 입금 합) ÷ (원금 + 각 입금 × 남은 기간 비율). 입금이 없으면
단순 수익률과 같다. 연환산하지 않는다(몇 달짜리 기록을 연율로 키우면 과장된다).
"""
try:
start = float(starting_capital)
end = None if end_value is None else float(end_value)
except (TypeError, ValueError):
return {"money_weighted_return_pct": None, "contributed_capital": None}
deposits = sum(float(amount) for _, amount in (flows or []))
contributed = start + deposits
if end is None or not math.isfinite(end) or contributed <= 0:
return {"money_weighted_return_pct": None, "contributed_capital": contributed}
denominator = time_weighted_capital(start, flows, operation_start, end_day)
return {
"money_weighted_return_pct": (end - contributed) / denominator * 100,
"contributed_capital": contributed,
}


def decompose_return_gap(
nav_return_pct: float | None,
design_return_pct: float | None,
Expand Down Expand Up @@ -526,10 +555,12 @@ def _config_date(value):
# 흐름 0건이면 아래 두 값은 기존과 완전히 동일하다(하위 호환).
from database.repositories import get_cash_flows, has_cash_flows
account_has_flows = has_cash_flows(account_key=basket_key, mode="paper")
starting_capital = initial_capital
account_flows = get_cash_flows(account_key=basket_key, mode="paper") if account_has_flows else []
if account_has_flows:
initial_capital = time_weighted_capital(
initial_capital,
get_cash_flows(account_key=basket_key, mode="paper"),
account_flows,
operation_start,
today,
)
Expand Down Expand Up @@ -586,6 +617,13 @@ def _config_date(value):

promotion = basket_cfg.get("promotion") or {}
result.setdefault("metrics", {})["close_comparison"] = comparison
# 시간가중(TWR)은 운용 규칙의 성과, 금액가중은 '넣은 돈 기준' 성과다. 적립 계좌는 돈이
# 들어온 시점에 따라 둘이 갈린다(자본시장연구원 26-04: 개인 계좌는 금액가중 기준 수익 계좌
# 비율이 시간가중보다 낮았다). 둘을 함께 보여 주되 판정에는 쓰지 않는다.
result["metrics"].update(money_weighted_metrics(
starting_capital, account_flows, operation_start, nav_end,
float(snaps[-1].total_value) if snaps else None,
))
result["metrics"]["comparison_basis_note"] = (
"별도 종가 평가 기준" if comparison else "동일 시점 종가 평가 기록 부족 — 벤치마크·귀속 비교 보류"
)
Expand Down
21 changes: 19 additions & 2 deletions core/basket_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ def snapshot_entry_status(snapshot, basket, risk_params):
return result
result["measured_at"] = str(snapshot.get("created_at") or snapshot.get("date") or "")
# An explicit basket drawdown rule owns both MDD and daily-loss policy.
delegated = bool(((basket.get("overlays") or {}).get("drawdown_guard") or {}).get("enabled"))
from core.basket_risk import delegates_account_drawdown_guard

delegated = delegates_account_drawdown_guard(basket or {})
limits = risk_params.get("drawdown") or {}
if not delegated:
for field, limit_field, label in (
Expand Down Expand Up @@ -62,8 +64,23 @@ def allocation_diagnostics(total, cash, positions, targets, defensive_symbol=Non
assets.append({"asset": key, "label": label, "actual": actual, "target": target, "gap": actual - target})
target_cash = max(0.0, 1 - sum(targets.values()))
assets.append({"asset": "cash", "label": "현금", "actual": cash / total, "target": target_cash, "gap": cash / total - target_cash})
# CD 방어 자산과 현금은 위험이 거의 같다. 둘을 합친 안전 자산 비중이 목표 근처이고
# 남는 현금으로 방어 자산 1주를 더 살 수도 없다면, 둘 사이의 차이는 1주 단위 때문에
# 생긴 구조적 차이다 — 경보로 세지 않고 표시만 한다(조치할 수 없는 경보는 신호를 죽인다).
structural = set()
if defensive_symbol:
safe = [a for a in assets if a["asset"] in ("defensive", "cash")]
safe_gap = sum(a["gap"] for a in safe)
unit = next((float(p["price"]) for p in positions if p["symbol"] == defensive_symbol), None)
idle_cash = cash - target_cash * total
can_buy_one = unit is not None and unit > 0 and idle_cash >= unit
if abs(safe_gap) <= tolerance and not can_buy_one:
structural = {"defensive", "cash"}
for a in assets:
if a["asset"] in structural and abs(a["gap"]) > tolerance:
a["structural"] = True
issues = [f"{a['label']} 비중 {a['actual']:.1%}, 목표 {a['target']:.1%} ({a['gap'] * 100:+.1f}%p)"
for a in assets if abs(a["gap"]) > tolerance]
for a in assets if abs(a["gap"]) > tolerance and a["asset"] not in structural]
return {"status": "ATTENTION" if issues else "OK", "assets": assets, "issues": issues}


Expand Down
Loading
Loading