diff --git a/tests/application/test_operator_report.py b/tests/application/test_operator_report.py index d361b93..7cddc4c 100644 --- a/tests/application/test_operator_report.py +++ b/tests/application/test_operator_report.py @@ -8,7 +8,6 @@ from trex_core.application.operator_report import ( TradeRow, apply_wac_to_report, build_report, - buy_fee_by_asset, kst_day_bounds_utc, realized_pnl, render_json, @@ -103,7 +102,6 @@ class TestApplyWacToReport: rep, wac_total=49675.0, # (75000-70000)*10 - 100 - 225 wac_by_asset={"005930": 49675.0}, - buy_fee_only_assets={}, data_completeness_warning=False, ) assert rep.realized_pnl_wac == 49675.0 @@ -132,16 +130,18 @@ class TestApplyWacToReport: rep, wac_total=49675.0, wac_by_asset={"005930": 49675.0}, - buy_fee_only_assets={}, data_completeness_warning=False, ) assert rep.realized_pnl_wac == 49675.0 assert rep.realized_pnl_wac != rep.realized_pnl # WAC != 당일 net def test_wac_equals_net_when_same_day_buy_sell(self): - """REQ-110 AC-2: 동일날 매수+매도(WAC=당일매수가) → WAC ≈ 당일 net. + """REQ-110 AC-2(엄격): 동일날 매수+매도 → WAC 실현손익 == 당일 net (완전 동치). - 일중 진입·청산 시 WAC 매수가 = 당일 net 매수 대금이므로 결과가 일치한다. + portfolio-hub WAC가 매수 fee를 cost basis에 편입하므로(CHG-20260626-02): + WAC = (1000*10 + 5) / 10 = 1000.5 + realized = (1100 - 1000.5) * 10 - 5 - 33 = 957 + 당일 net도 957이므로 닫힌 거래에서 엄격 동치가 성립한다. """ rep = build_report( holdings=[], @@ -154,53 +154,38 @@ class TestApplyWacToReport: # 당일 net = (11000-5-33) - (10000+5) = 10962 - 10005 = 957 assert rep.realized_pnl == 957.0 - # WAC = (1100-1000)*10 - 5 - 33 = 962 (fee for sell subtracted here) - # 단, 당일 매수 fee(5)는 buy_fee_only_assets 아닌 실현PnL에 반영됨 - # 사실 WAC 계산: realized = (sell_price - wac) * qty - fee - tax - # wac = 1000 (동일날 매수가), qty=10, sell=1100, fee=5, tax=33 - # realized = (1100-1000)*10 - 5 - 33 = 962 - # 당일 net = sell대금 - buy대금 = (11000-5-33)-(10000+5) = 10962-10005 = 957 - # 미세한 차이는 buy_fee 처리 방식 차이 — 여기서는 WAC 동일날 시나리오 확인 + # WAC(편입 후) = (1000*10+5)/10 = 1000.5 + # realized_pnl_wac = (1100-1000.5)*10 - 5 - 33 = 9950 - 38 - 5 - 33 = 957 apply_wac_to_report( rep, - wac_total=962.0, - wac_by_asset={"A": 962.0}, - buy_fee_only_assets={}, + wac_total=957.0, + wac_by_asset={"A": 957.0}, data_completeness_warning=False, ) - # NFR-2: 당일 net(기존) 필드는 유지되고 독립적이다 - assert rep.realized_pnl == 957.0 - assert rep.realized_pnl_wac == 962.0 + # NFR-2 엄격화 (AC-2): 일중 닫힌 거래에서 WAC 실현손익 == 당일 net + assert rep.realized_pnl_wac == rep.realized_pnl # 957 == 957 - def test_buy_only_assets_has_fee(self): - """REQ-110 AC-3: 매수만 있는 자산 → realized 0 + buy_fee_only_assets 표시.""" + def test_buy_only_asset_no_fee_line(self): + """REQ-110 AC-3(변경): 매수만 있는 자산 → 실현 0, 별도 수수료 라인 없음. + + CHG-20260626-02: 매수 fee가 cost basis에 편입 → 매도 시 실현되므로 + 매수 전용일에 별도 "수수료 비용" 라인을 표시하지 않는다(이중계상 방지). + """ rep = build_report( holdings=[Holding("000660", "pf-2", 10, 50000)], pending=[], trades=[TradeRow("pf-2", "buy", 10, 50000, fee=250)], ) - # 매도 없음 → sell_assets = {} - fee_map = buy_fee_by_asset(rep.holdings.__class__([]), # type: ignore - exclude_symbols=set()) - fee_map2 = buy_fee_by_asset( - [TradeRow("pf-2", "buy", 10, 50000, fee=250)], - exclude_symbols=set(), - ) - assert fee_map2 == {"pf-2": 250.0} - apply_wac_to_report( rep, wac_total=0.0, wac_by_asset={}, - buy_fee_only_assets={"pf-2": 250.0}, data_completeness_warning=False, ) assert rep.realized_pnl_wac == 0.0 - assert rep.buy_fee_only_assets == {"pf-2": 250.0} - # render_text에 buy_fee_only_assets 섹션 포함 + # 별도 수수료 라인 없음 text = render_text(rep) - assert "매수 전용 자산 수수료" in text - assert "pf-2" in text + assert "매수 전용 자산 수수료" not in text def test_completeness_warning_in_text_and_json(self): """REQ-110 AC-4: completeness:true → text·json 모두 경고 표면화.""" @@ -209,7 +194,6 @@ class TestApplyWacToReport: rep, wac_total=0.0, wac_by_asset={}, - buy_fee_only_assets={}, data_completeness_warning=True, ) text = render_text(rep) @@ -225,27 +209,28 @@ class TestApplyWacToReport: rep, wac_total=100.0, wac_by_asset={"005930": 80.0, "000660": 20.0}, - buy_fee_only_assets={}, data_completeness_warning=False, ) text = render_text(rep) assert "005930" in text and "000660" in text - def test_render_json_has_all_four_wac_fields(self): - """REQ-110 AC-6: --json에 신규 4필드 포함.""" + def test_render_json_has_three_wac_fields(self): + """REQ-110 AC-6: --json에 신규 3 WAC 필드 포함 (CHG-20260626-02: buy_fee_only_assets 제거). + + 필드: realized_pnl_wac, realized_pnl_wac_by_asset, data_completeness_warning. + """ rep = build_report([], [], []) apply_wac_to_report( rep, wac_total=5000.0, wac_by_asset={"X": 5000.0}, - buy_fee_only_assets={}, data_completeness_warning=False, ) j = render_json(rep) assert "realized_pnl_wac" in j assert "realized_pnl_wac_by_asset" in j - assert "buy_fee_only_assets" in j assert "data_completeness_warning" in j + assert "buy_fee_only_assets" not in j assert j["realized_pnl_wac"] == 5000.0 @@ -289,8 +274,8 @@ class TestWacDegrade: # WAC 필드는 null assert j["realized_pnl_wac"] is None assert j["realized_pnl_wac_by_asset"] is None - assert j["buy_fee_only_assets"] is None assert j["data_completeness_warning"] is None + assert "buy_fee_only_assets" not in j # CHG-20260626-02: 필드 자체 제거됨 # 에러 필드 존재 assert "realized_pnl_wac_error" in j assert "unavailable" in j["realized_pnl_wac_error"] diff --git a/tests/runtime/test_cli_report_wac.py b/tests/runtime/test_cli_report_wac.py index 3794abc..9a3a266 100644 --- a/tests/runtime/test_cli_report_wac.py +++ b/tests/runtime/test_cli_report_wac.py @@ -143,8 +143,11 @@ class TestReportWacPath: # 에러 없음 assert "unavailable" not in out - def test_wac_json_has_all_four_fields(self, capsys): - """REQ-110 AC-6: --json에 4 WAC 필드 포함.""" + def test_wac_json_has_three_wac_fields(self, capsys): + """REQ-110 AC-6: --json에 3 WAC 필드 포함 (CHG-20260626-02: buy_fee_only_assets 제거). + + 필드: realized_pnl_wac, realized_pnl_wac_by_asset, data_completeness_warning. + """ db_rows = [ [("005930", "pf-1", 10, 70000.0)], [], @@ -168,8 +171,9 @@ class TestReportWacPath: assert rc == 0 j = json.loads(capsys.readouterr().out) for field in ("realized_pnl_wac", "realized_pnl_wac_by_asset", - "buy_fee_only_assets", "data_completeness_warning"): + "data_completeness_warning"): assert field in j, f"missing field: {field}" + assert "buy_fee_only_assets" not in j def test_completeness_warning_in_text_and_json(self, capsys): """REQ-110 AC-4: completeness:true → text·json 경고 표면화.""" @@ -266,8 +270,11 @@ class TestReportWacPath: assert j["fills_count"] == 1 assert j["holdings"][0]["symbol"] == "005930" - def test_buy_only_asset_fee_displayed(self, capsys): - """REQ-110 AC-3: 매수만 → 실현 0 + buy_fee_only_assets 표시.""" + def test_buy_only_asset_no_fee_line(self, capsys): + """REQ-110 AC-3(변경): 매수만 → 실현 0, 별도 수수료 라인 없음. + + CHG-20260626-02: 매수 fee 편입 → 매수 전용 자산 수수료 섹션 제거. + """ db_rows = [ [("000660", "pf-2", 5, 50000.0)], # holdings (보유 중) [], @@ -291,8 +298,8 @@ class TestReportWacPath: rc = cli.main(["report"]) assert rc == 0 out = capsys.readouterr().out - assert "매수 전용 자산 수수료" in out - assert "pf-2" in out + # 매수 전용 수수료 섹션은 더 이상 표시하지 않음 + assert "매수 전용 자산 수수료" not in out def test_no_fallback_to_daily_net(self, capsys): """REQ-111 AC-3: degrade 시 당일 net 폴백 안 함. diff --git a/trex_core/application/operator_report.py b/trex_core/application/operator_report.py index 406230d..3295ed9 100644 --- a/trex_core/application/operator_report.py +++ b/trex_core/application/operator_report.py @@ -53,7 +53,6 @@ class OperatorReport: # REQ-110 WAC 실현손익 (portfolio-hub 통합) realized_pnl_wac: float = 0.0 realized_pnl_wac_by_asset: dict[str, float] = field(default_factory=dict) - buy_fee_only_assets: dict[str, float] = field(default_factory=dict) data_completeness_warning: bool = False # REQ-111 degrade flag wac_unavailable: bool = False @@ -104,34 +103,19 @@ def apply_wac_to_report( *, wac_total: float, wac_by_asset: dict[str, float], - buy_fee_only_assets: dict[str, float], data_completeness_warning: bool, ) -> None: """REQ-110: WAC 실현손익을 OperatorReport에 in-place로 적용한다. 순수 도메인 계층에서는 이 헬퍼가 아닌 build_report를 사용하고, CLI(_cmd_report)에서 portfolio-hub 응답을 받아 이 함수로 주입한다. + CHG-20260626-02: 매수 fee가 cost basis에 편입되어 buy_fee_only_assets 제거됨. """ rep.realized_pnl_wac = wac_total rep.realized_pnl_wac_by_asset = dict(wac_by_asset) - rep.buy_fee_only_assets = dict(buy_fee_only_assets) rep.data_completeness_warning = data_completeness_warning -def buy_fee_by_asset(trades: list[TradeRow], *, exclude_symbols: set[str] | None = None) -> dict[str, float]: - """OQ-3: 당일 매수 거래 fee 합산, 자산별 (매도 없는 자산 대상). - - exclude_symbols: 매도 실현손익이 이미 WAC에 반영된 자산 집합. - """ - fee_map: dict[str, float] = {} - for t in trades: - if t.side == "buy": - fee_map[t.asset_id] = fee_map.get(t.asset_id, 0.0) + t.fee - if exclude_symbols: - return {k: v for k, v in fee_map.items() if k not in exclude_symbols} - return fee_map - - def render_text(rep: OperatorReport) -> str: """사람용 표 요약.""" lines: list[str] = [] @@ -155,10 +139,6 @@ def render_text(rep: OperatorReport) -> str: lines.append(f" {asset}: {pnl:,.0f}") if rep.data_completeness_warning: lines.append(" [!] 이력 불완전 경고: cost basis 산출이 부정확할 수 있습니다.") - if rep.buy_fee_only_assets: - lines.append("=== 매수 전용 자산 수수료 ===") - for asset, fee in sorted(rep.buy_fee_only_assets.items()): - lines.append(f" {asset}: -{fee:,.0f}") return "\n".join(lines) @@ -177,10 +157,9 @@ def render_json(rep: OperatorReport) -> dict: "fills_count": rep.fills_count, "realized_pnl": rep.realized_pnl, "realized_pnl_by_asset": rep.realized_pnl_by_asset, - # REQ-110 신규 WAC 필드 + # REQ-110 신규 WAC 필드 (CHG-20260626-02: buy_fee_only_assets 제거) "realized_pnl_wac": None if rep.wac_unavailable else rep.realized_pnl_wac, "realized_pnl_wac_by_asset": None if rep.wac_unavailable else rep.realized_pnl_wac_by_asset, - "buy_fee_only_assets": None if rep.wac_unavailable else rep.buy_fee_only_assets, "data_completeness_warning": None if rep.wac_unavailable else rep.data_completeness_warning, } # REQ-111: degrade 표기 diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index fbbb687..003e480 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -668,7 +668,6 @@ def _cmd_verify(args, cfg) -> int: def _cmd_report(args, cfg) -> int: """REQ-088/110/111: holdings / today's fills / WAC realized PnL / pending — direct-DB.""" import psycopg - from datetime import date, timedelta from trex_core.application.operator_report import ( Holding, @@ -676,7 +675,6 @@ def _cmd_report(args, cfg) -> int: TradeRow, apply_wac_to_report, build_report, - buy_fee_by_asset, kst_day_bounds_utc, render_json, render_text, @@ -749,15 +747,10 @@ def _cmd_report(args, cfg) -> int: key = item.symbol or pid wac_by_asset_accum[key] = wac_by_asset_accum.get(key, 0.0) + item.realized_pnl - # OQ-3: buy_fee_only_assets = 당일 매수 거래 fee 합산 (매도 없는 자산 대상) - sell_assets = {t.asset_id for t in trades if t.side == "sell"} - buy_fee_only = buy_fee_by_asset(trades, exclude_symbols=sell_assets) - apply_wac_to_report( rep, wac_total=wac_total, wac_by_asset=wac_by_asset_accum, - buy_fee_only_assets=buy_fee_only, data_completeness_warning=any_warning, )