Skip to content

Commit cbc91ce

Browse files
authored
Merge pull request #410 from easygap/fix/basket-paper-account-attribution
fix: 바스켓 paper 계정·귀속 격리 — 적대적 자기검토 발견 결함 5건 해소
2 parents 213a60b + b68dabd commit cbc91ce

14 files changed

Lines changed: 450 additions & 136 deletions

core/basket_evaluation.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,37 +149,47 @@ def collect_basket_paper_evaluation(
149149
config=None,
150150
min_days: int = 60,
151151
include_benchmark: bool = True,
152+
basket_name: str | None = None,
152153
) -> tuple[dict[str, Any], str]:
153-
"""DB·설정에서 바스켓 paper 운영 데이터를 수집해 (평가결과, 바스켓 라벨) 반환.
154+
"""DB·설정에서 **특정 바스켓**의 paper 운영 데이터를 수집해 (평가결과, 바스켓 이름) 반환.
154155
155156
CLI(tools/basket_paper_evaluation.py)와 바스켓 live gate가 공유하는 단일 수집 경로.
156-
include_benchmark=False면 KS11 조회(네트워크)를 생략한다 — 게이트 경로에서는
157-
참고 지표 때문에 판정을 지연/실패시키지 않기 위함.
157+
거래·스냅샷·dead-letter 모두 바스켓 전용 키(basket_rebalance:<name>)로 필터한다 —
158+
바스켓별 귀속 없이 합산하면 A 바스켓의 트랙레코드로 B 바스켓이 승격되는 구멍이 생긴다.
159+
basket_name 미지정 시 enabled 바스켓이 정확히 1개면 그것을 쓰고, 0개·복수면
160+
ValueError(어느 기록을 평가하는지 모호 — fail-closed).
161+
include_benchmark=False면 KS11 조회(네트워크)를 생략한다(게이트 경로용).
158162
"""
159163
from datetime import date, datetime, timedelta
160164

161165
from config.config_loader import Config
162-
from core.basket_rebalancer import BasketRebalancer
166+
from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id
163167
from core.trading_hours import TradingHours
164168
from database.models import get_session, PortfolioSnapshot, TradeHistory, init_database
165169
from database.repositories import get_pending_failed_orders
166170

167171
init_database()
168172
config = config or Config.get()
169-
enabled = BasketRebalancer.get_enabled_baskets()
170-
basket_label = ",".join(enabled) if enabled else "(enabled 바스켓 없음)"
173+
if not basket_name:
174+
enabled = BasketRebalancer.get_enabled_baskets()
175+
if len(enabled) != 1:
176+
raise ValueError(
177+
f"평가 대상 바스켓이 모호합니다 (enabled={enabled}) — basket_name을 명시하세요."
178+
)
179+
basket_name = enabled[0]
180+
basket_key = rebalance_live_strategy_id(basket_name)
171181

172182
session = get_session()
173183
try:
174184
trades = (
175185
session.query(TradeHistory)
176-
.filter(TradeHistory.strategy.like("basket_rebalance%"))
186+
.filter(TradeHistory.strategy == basket_key)
177187
.filter(TradeHistory.mode == "paper")
178188
.all()
179189
)
180190
snaps = (
181191
session.query(PortfolioSnapshot)
182-
.filter(PortfolioSnapshot.account_key == "")
192+
.filter(PortfolioSnapshot.account_key == basket_key)
183193
.order_by(PortfolioSnapshot.date.asc())
184194
.all()
185195
)
@@ -201,6 +211,10 @@ def _d(v):
201211
d = operation_start
202212
while d <= today:
203213
if th.is_trading_day(datetime(d.year, d.month, d.day)):
214+
# 오늘은 스냅샷이 이미 찍힌 경우에만 분모에 포함한다 — 장전(스냅샷 저장 전)
215+
# 게이트 실행에서 분모만 +1 되어 커버리지가 경계에서 오판되는 것을 방지.
216+
if d == today and d not in snapshot_dates:
217+
break
204218
trading_days_total += 1
205219
if d in snapshot_dates:
206220
snapshot_days += 1
@@ -234,11 +248,13 @@ def _d(v):
234248
today=today,
235249
trading_days_total=trading_days_total,
236250
snapshot_days=snapshot_days,
237-
pending_failed_orders=len(get_pending_failed_orders() or []),
251+
# dead-letter도 이 바스켓 계정 것만 집계 — 다른 전략의 잔여 실패 주문이
252+
# 바스켓 승격을 막는 오판 방지(바스켓 자신의 실패는 여전히 fail-closed).
253+
pending_failed_orders=len(get_pending_failed_orders(account_key=basket_key) or []),
238254
total_costs=total_costs,
239255
initial_capital=initial_capital,
240256
nav_return_pct=nav_return_pct,
241257
benchmark_return_pct=benchmark_return_pct,
242258
min_trading_days=min_days,
243259
)
244-
return result, basket_label
260+
return result, basket_name

core/basket_rebalancer.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,20 @@ def check_basket_account_isolation(basket_names, config, mode: str) -> list[str]
3535
"""여러 enabled 바스켓이 같은 계좌(자본 풀)를 공유하는지 검사한다. fail-closed.
3636
3737
각 BasketRebalancer는 자기 목표 비중을 '총자산 × stock_fraction'에 독립적으로
38-
배분한다. 두 바스켓이 같은 자본을 공유하면 예컨대 80% + 80% = 160%를 배분하려
39-
들어 과배분·상호 간섭이 생기고, paper에서는 동일 default 계정의 NAV 시계열이
40-
섞여 60영업일 트랙레코드가 오염된다. 바스켓을 여러 개 운영하려면 계좌를
41-
분리해야 한다(live: kis_api.accounts에 basket_rebalance:<name>별 계좌 지정).
38+
배분한다. 두 바스켓이 같은 자본(계좌)을 공유하면 예컨대 80% + 80% = 160%를
39+
배분하려 들어 과배분·상호 간섭이 생긴다.
40+
41+
paper: 바스켓별 가상 계정 키(basket_rebalance:<name>)로 DB가 격리되고 각자
42+
initial_capital 기준으로 독립 집계되므로 자연 격리 — 통과.
43+
live: 같은 KIS 실계좌(잔고)를 공유하면 차단. 다중 바스켓 live 운영은
44+
kis_api.accounts에 basket_rebalance:<name>별 계좌를 분리 지정해야 한다.
4245
4346
반환: 이슈 문자열 리스트 (빈 리스트 = 통과).
4447
"""
4548
names = list(basket_names or [])
46-
if len(names) <= 1:
49+
if len(names) <= 1 or str(mode).lower() != "live":
4750
return []
4851

49-
if str(mode).lower() != "live":
50-
return [
51-
f"enabled 바스켓 {len(names)}개({', '.join(names)})가 paper 기본 계정(자본 풀)을 "
52-
"공유합니다 — 각 바스켓이 같은 자본에 목표 비중을 독립 배분해 과배분되고 "
53-
"트랙레코드(NAV 시계열)가 섞입니다. 하나만 enabled로 두거나 운영을 분리하세요."
54-
]
55-
5652
by_account: dict[str, list[str]] = {}
5753
for name in names:
5854
try:
@@ -98,11 +94,16 @@ def __init__(
9894
basket_name: str,
9995
config: Config = None,
10096
account_key: str = "",
101-
execution_strategy: str = "basket_rebalance",
97+
execution_strategy: str = "",
10298
):
10399
self.config = config or Config.get()
104-
self.account_key = account_key
105-
self.execution_strategy = execution_strategy or "basket_rebalance"
100+
# 계정·귀속 키 기본값: paper/live 공통으로 바스켓 전용 키(basket_rebalance:<name>).
101+
# 기본 계정("")은 전 계정 합산 뷰라서 다른 전략의 paper 거래 한 건에도 NAV·드리프트·
102+
# 평가가 오염되고, 이름 없는 strategy("basket_rebalance")로는 어느 바스켓의 트랙레코드
103+
# 인지 귀속이 불가능하다(다른 바스켓 기록으로 승격되는 구멍). 키로 격리·귀속한다.
104+
default_key = rebalance_live_strategy_id(basket_name)
105+
self.account_key = account_key or default_key
106+
self.execution_strategy = execution_strategy or default_key
106107
self.basket_name = basket_name
107108

108109
baskets_cfg = self._load_baskets_config()
@@ -146,6 +147,58 @@ def _is_live(self) -> bool:
146147
"""실전(live) 모드 여부."""
147148
return str(self.config.trading.get("mode", "paper")).lower() == "live"
148149

150+
def save_daily_nav_snapshot(self) -> bool:
151+
"""바스켓 계정의 일일 NAV 스냅샷 저장. 트랙레코드 시계열의 1행.
152+
153+
보유 종목 가격이 전부 확보됐을 때만 저장한다 — 가격 미확보 시
154+
avg_price 폴백으로 평가된 가짜 NAV가 '커버된 영업일'로 집계되는 것보다,
155+
스킵하고 health의 끊김 감지에 노출되는 편이 정직하다.
156+
157+
귀속 날짜는 NAV의 가격 기준일이다: 비거래일(주말·휴장일) 보충 실행에서
158+
조회되는 가격은 직전 거래일 종가이므로 그 거래일로 귀속한다 — PC가 꺼져
159+
있던 거래일을 다음날 보충 실행이 정당하게 커버한다(주말 날짜 스냅샷은
160+
커버리지에 영원히 안 잡히는 낭비였다). (account_key, date) upsert 멱등.
161+
"""
162+
try:
163+
snapshot = getattr(self, "_market_snapshot", None) or self._fetch_market_snapshot()
164+
prices = {s: v["price"] for s, v in snapshot.items()}
165+
positions = get_all_positions(account_key=self.account_key)
166+
missing = [p.symbol for p in positions if p.symbol not in prices]
167+
if missing:
168+
logger.warning(
169+
"바스켓 '{}' NAV 스냅샷 스킵 — 가격 미확보 종목: {} (가짜 NAV 방지)",
170+
self.basket_name, missing,
171+
)
172+
return False
173+
self.portfolio_mgr.save_daily_snapshot(
174+
current_prices=prices or None,
175+
snapshot_date=self._nav_attribution_date(),
176+
)
177+
return True
178+
except Exception as e:
179+
logger.warning("바스켓 '{}' NAV 스냅샷 저장 실패: {}", self.basket_name, e)
180+
return False
181+
182+
def _nav_attribution_date(self) -> datetime:
183+
"""NAV 스냅샷 귀속 날짜: 오늘이 거래일이면 오늘, 아니면 직전 거래일.
184+
185+
조회 가격이 직전 거래일 종가이므로 그 날짜가 정직한 귀속일이다.
186+
거래일 판정 실패 시 오늘로 폴백(보수적 — 기존 동작).
187+
"""
188+
now = datetime.now(_KST).replace(tzinfo=None)
189+
try:
190+
from core.trading_hours import TradingHours
191+
192+
th = TradingHours(self.config)
193+
d = now
194+
for _ in range(15): # 최장 연휴 커버
195+
if th.is_trading_day(d):
196+
return d
197+
d -= timedelta(days=1)
198+
except Exception as e:
199+
logger.debug("거래일 판정 실패 — 오늘로 귀속: {}", e)
200+
return now
201+
149202
# ------------------------------------------------------------------
150203
# Config
151204
# ------------------------------------------------------------------

core/live_readiness.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ def check_basket_live_readiness(config, strategy_name: str) -> list[str]:
5252
try:
5353
from core.basket_evaluation import collect_basket_paper_evaluation
5454

55+
# 반드시 '이 바스켓'의 기록으로 판정한다 — 이름 없이 합산 평가하면
56+
# 다른 바스켓의 60일 트랙레코드로 신규 바스켓이 승격되는 구멍이 생긴다.
5557
result, _label = collect_basket_paper_evaluation(
56-
config=config, include_benchmark=False,
58+
config=config, include_benchmark=False, basket_name=basket_name,
5759
)
5860
if result["verdict"] != "PASS_CANDIDATE":
5961
detail = "; ".join(result["issues"]) if result["issues"] else (

core/portfolio_manager.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,12 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict:
183183
"broker_balance_error": broker_balance_error,
184184
}
185185

186-
def save_daily_snapshot(self, current_prices: dict = None):
187-
"""일일 포트폴리오 스냅샷 저장"""
186+
def save_daily_snapshot(self, current_prices: dict = None, snapshot_date=None):
187+
"""일일 포트폴리오 스냅샷 저장.
188+
189+
snapshot_date: 귀속 날짜 지정(미지정 시 오늘). 비거래일 보충 실행에서
190+
NAV의 가격 기준일(직전 거래일)로 귀속할 때 사용.
191+
"""
188192
summary = self.get_portfolio_summary(current_prices)
189193

190194
save_portfolio_snapshot(
@@ -196,6 +200,7 @@ def save_daily_snapshot(self, current_prices: dict = None):
196200
position_count=summary["position_count"],
197201
account_key=self.account_key,
198202
peak_value=self._peak_value,
203+
snapshot_date=snapshot_date,
199204
)
200205

201206
logger.info(

core/scheduler.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,15 +1552,12 @@ def _run_basket_rebalance_check(self):
15521552
self.discord.send_message(msg, critical=True)
15531553
continue
15541554

1555+
# 계정·귀속 키는 paper/live·CLI/스케줄러 공통(basket_rebalance:<name>)
1556+
# — 트랙레코드가 바스켓별 한 곳에 쌓인다.
15551557
rebalancer = BasketRebalancer(
15561558
basket_name=name, config=self.config,
1557-
# paper는 CLI(--mode rebalance)와 동일한 기본 계정("")을 쓴다 —
1558-
# 스케줄러가 전략명 계정에 기록하면 일일 CLI로 쌓던 트랙레코드
1559-
# (NAV 시계열·평가·health 감시 모두 기본 계정 기준)와 찢어진다.
1560-
account_key=live_strategy_name if is_live else "",
1561-
execution_strategy=(
1562-
live_strategy_name if is_live else "basket_rebalance"
1563-
),
1559+
account_key=live_strategy_name,
1560+
execution_strategy=live_strategy_name,
15641561
)
15651562
if is_live:
15661563
sync_result = rebalancer.portfolio_mgr.sync_with_broker()
@@ -1574,26 +1571,27 @@ def _run_basket_rebalance_check(self):
15741571
continue
15751572

15761573
should, reason = rebalancer.should_rebalance()
1577-
if not should:
1574+
if should:
1575+
orders = rebalancer.plan_rebalance()
1576+
if orders:
1577+
result = rebalancer.execute(
1578+
orders,
1579+
live_confirmed=(
1580+
is_live and self._live_gate_validated
1581+
),
1582+
)
1583+
summary = (
1584+
f"🔄 바스켓 '{name}' 리밸런싱 완료: "
1585+
f"실행 {result['executed']}건, 실패 {result['failed']}건"
1586+
)
1587+
logger.info(summary)
1588+
self.discord.send_message(summary)
1589+
else:
15781590
logger.info("바스켓 '{}' 리밸런싱 불필요: {}", name, reason)
1579-
continue
15801591

1581-
orders = rebalancer.plan_rebalance()
1582-
if not orders:
1583-
continue
1584-
1585-
result = rebalancer.execute(
1586-
orders,
1587-
live_confirmed=(
1588-
is_live and self._live_gate_validated
1589-
),
1590-
)
1591-
summary = (
1592-
f"🔄 바스켓 '{name}' 리밸런싱 완료: "
1593-
f"실행 {result['executed']}건, 실패 {result['failed']}건"
1594-
)
1595-
logger.info(summary)
1596-
self.discord.send_message(summary)
1592+
# 트랙레코드: 스케줄러 단독 운영(상시 구동)에서도 바스켓 계정의
1593+
# 일일 NAV 스냅샷이 쌓이도록 거래 여부와 무관하게 저장(멱등 upsert).
1594+
rebalancer.save_daily_nav_snapshot()
15971595

15981596
except Exception as e:
15991597
logger.error("바스켓 '{}' 리밸런싱 오류: {}", name, e)

database/repositories.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -687,11 +687,19 @@ def save_portfolio_snapshot(
687687
position_count: int = 0,
688688
account_key: str = "",
689689
peak_value: float = None,
690+
snapshot_date: datetime = None,
690691
):
691-
"""일일 포트폴리오 스냅샷 저장 (account_key: 전략별 계좌 구분)."""
692+
"""일일 포트폴리오 스냅샷 저장 (account_key: 전략별 계좌 구분).
693+
694+
snapshot_date: 스냅샷 귀속 날짜(자정으로 정규화). 미지정 시 오늘.
695+
비거래일 보충 실행에서 NAV의 가격 기준일(직전 거래일)로 귀속할 때 사용.
696+
"""
692697
session = get_session()
693698
try:
694-
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
699+
base = snapshot_date or datetime.now()
700+
if not isinstance(base, datetime):
701+
base = datetime(base.year, base.month, base.day)
702+
today = base.replace(hour=0, minute=0, second=0, microsecond=0)
695703
ak = account_key or ""
696704
snapshot = PortfolioSnapshot(
697705
account_key=ak,

0 commit comments

Comments
 (0)