- 장중 10분 주기 journal_intraday_scheduler 신설 (SELECT 전용, 쓰기는 db_worker 큐) - 매도 후 3분 유예(JOURNAL_INTRADAY_GRACE_MIN), trade_id in-flight TTL 30분 - order_id 로 해소된 trade_id 에 일지가 있으면 INSERT skip (중복 방지) - 16:00 배치 enqueue_trade_journal 이 strategy_type 을 넘기지 않아 전 일지가 'unknown' 이던 결함 수정 - analyze_performance profit_factor 를 손익률(%) 합이 아닌 금액 기준으로 (1.22 → 0.62) - 일지 대상 SELL 상태에 PARTIAL 포함 (성과 집계 필터는 불변) - journals 단건 생성: 중복 행 시 500 대신 409, JOURNAL_SELL_STATUSES 사용 - 16:00 배치 조회일수 config 화, 손익 0 기간 PF 오경고 제거 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
9.5 KiB
Python
269 lines
9.5 KiB
Python
"""매매일지 회귀 (2026-10-01): 장중 생성 부재 · strategy_name='unknown' · 프로핏팩터.
|
|
|
|
- 16:00 배치 enqueue_trade_journal 이 strategy_type 컬럼을 넘기지 않아 전부 'unknown'.
|
|
- 장중 주기 일지 스케줄러 신설(멱등).
|
|
- profit_factor 는 손익률(%) 합이 아니라 금액(gross_profit/|gross_loss|) 기준.
|
|
"""
|
|
import asyncio
|
|
import pathlib
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
APP_ROOT = pathlib.Path(__file__).resolve().parents[1] / "app"
|
|
|
|
|
|
def _trade(**kw):
|
|
base = dict(
|
|
id=1, stock_code="005930", stock_name="삼성전자", order_type="SELL",
|
|
profit_loss_rate=-2.0, net_profit_loss=-1000, entry_reason="",
|
|
exit_reason="stop_loss", strategy_type="swing", executed_at=None, created_at=None,
|
|
executed_price=1, executed_quantity=1, order_quantity=1,
|
|
)
|
|
base.update(kw)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
@pytest.mark.parametrize("st,expected", [
|
|
("swing", "mean_reversion"), ("daytrading", "day_trade"),
|
|
("day_trade", "day_trade"), ("manual", "manual"),
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue_trade_journal_uses_strategy_type(monkeypatch, st, expected):
|
|
from app.services import db_worker
|
|
|
|
sent = []
|
|
|
|
async def _put(item):
|
|
sent.append(item)
|
|
|
|
monkeypatch.setattr(db_worker.db_task_queue, "put", _put)
|
|
await db_worker.enqueue_trade_journal(_trade(strategy_type=st), "c")
|
|
assert sent[0]["data"]["strategy_name"] == expected
|
|
assert sent[0]["data"]["tags"][0] == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_and_manual_paths_agree_on_strategy(monkeypatch):
|
|
from app.services import db_worker
|
|
from app.services.trade_analyzer import _parse_strategy
|
|
|
|
sent = []
|
|
|
|
async def _put(item):
|
|
sent.append(item)
|
|
|
|
monkeypatch.setattr(db_worker.db_task_queue, "put", _put)
|
|
t = _trade(strategy_type="daytrading")
|
|
await db_worker.enqueue_trade_journal(t, "c")
|
|
assert sent[0]["data"]["strategy_name"] == _parse_strategy(t.entry_reason, t.strategy_type)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue_journals_v2_is_idempotent(monkeypatch):
|
|
from app.services import schedulers, db_worker
|
|
|
|
calls = []
|
|
|
|
async def _enq(trade, content):
|
|
calls.append(trade.id)
|
|
|
|
monkeypatch.setattr(db_worker, "enqueue_trade_journal", _enq)
|
|
schedulers._JOURNAL_ENQUEUED_IDS.clear()
|
|
trades = [_trade(id=11), _trade(id=12)]
|
|
assert await schedulers._enqueue_journals_v2(trades) == 2
|
|
assert await schedulers._enqueue_journals_v2(trades) == 0 # 큐 적체 중 재조회
|
|
assert calls == [11, 12]
|
|
schedulers._JOURNAL_ENQUEUED_IDS.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue_failure_is_not_marked_and_retried(monkeypatch):
|
|
from app.services import schedulers, db_worker
|
|
|
|
async def _boom(trade, content):
|
|
raise RuntimeError("queue down")
|
|
|
|
monkeypatch.setattr(db_worker, "enqueue_trade_journal", _boom)
|
|
schedulers._JOURNAL_ENQUEUED_IDS.clear()
|
|
assert await schedulers._enqueue_journals_v2([_trade(id=21)]) == 0
|
|
assert 21 not in schedulers._JOURNAL_ENQUEUED_IDS
|
|
|
|
|
|
def test_intraday_scheduler_wired_and_config_driven():
|
|
main_src = (APP_ROOT / "main.py").read_text()
|
|
assert "journal_intraday_scheduler()" in main_src
|
|
assert '"journal_intraday_task"' in main_src
|
|
src = (APP_ROOT / "services" / "schedulers.py").read_text()
|
|
body = src[src.index("async def journal_intraday_scheduler"):src.index("async def post_market_analyzer_scheduler")]
|
|
assert "settings.JOURNAL_INTRADAY_INTERVAL_MIN" in body
|
|
assert "commit(" not in body # 실시간 계층 DB 쓰기 금지
|
|
assert "logger.debug" not in body
|
|
|
|
|
|
def test_uncovered_includes_partial_but_not_pending():
|
|
from app.services.trade_analyzer import JOURNAL_SELL_STATUSES
|
|
|
|
assert "PARTIAL" in JOURNAL_SELL_STATUSES
|
|
assert "PENDING" not in JOURNAL_SELL_STATUSES and "FAILED" not in JOURNAL_SELL_STATUSES
|
|
|
|
|
|
def test_performance_stats_keep_completed_filter():
|
|
src = (APP_ROOT / "services" / "trade_analyzer.py").read_text()
|
|
seg = src[src.index("async def analyze_performance"):src.index("async def analyze_performance") + 2500]
|
|
assert '["COMPLETED", "FILLED"]' in seg and "PARTIAL" not in seg
|
|
|
|
|
|
class _R:
|
|
def __init__(self, items):
|
|
self._i = items
|
|
|
|
def scalars(self):
|
|
return SimpleNamespace(all=lambda: list(self._i))
|
|
|
|
|
|
class _DB:
|
|
def __init__(self, trades):
|
|
self.t = trades
|
|
|
|
async def execute(self, stmt, *a, **k):
|
|
return _R(self.t)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_profit_factor_uses_amounts_not_percent_sums():
|
|
"""실측 재현: gross_profit 266,370 / gross_loss -430,619 인데 PF>1 이던 버그."""
|
|
from app.services.trade_analyzer import analyze_performance
|
|
|
|
trades = [
|
|
# 소액 대박(+30%, +1,000원) 과 대금 큰 소폭손실(-1%, -430,619원)
|
|
_trade(id=1, profit_loss_rate=30.0, net_profit_loss=266370),
|
|
_trade(id=2, profit_loss_rate=-1.0, net_profit_loss=-430619),
|
|
]
|
|
perf = await analyze_performance(_DB(trades), days=30)
|
|
s = perf["summary"]
|
|
assert s["gross_profit"] == 266370 and s["gross_loss"] == -430619
|
|
assert s["profit_factor"] == pytest.approx(266370 / 430619, rel=1e-3)
|
|
assert s["profit_factor"] < 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_profit_factor_no_loss_capped():
|
|
from app.services.trade_analyzer import analyze_performance
|
|
|
|
perf = await analyze_performance(_DB([_trade(profit_loss_rate=3, net_profit_loss=500)]), days=30)
|
|
assert perf["summary"]["profit_factor"] == 99.0
|
|
|
|
|
|
# ── 2026-10-01 보완 ──────────────────────────────────────────────────────────
|
|
class _FR:
|
|
def __init__(self, items):
|
|
self._i = list(items)
|
|
|
|
def scalar_one_or_none(self):
|
|
return self._i[0] if self._i else None
|
|
|
|
|
|
class _FSess:
|
|
def __init__(self, results):
|
|
self.r = list(results)
|
|
self.added = []
|
|
self.commits = 0
|
|
|
|
async def execute(self, stmt, *a, **k):
|
|
return _FR(self.r.pop(0) if self.r else [])
|
|
|
|
def add(self, o):
|
|
self.added.append(o)
|
|
|
|
async def commit(self):
|
|
self.commits += 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handler_skips_when_resolved_trade_already_has_journal():
|
|
from app.services.db_worker import _handle_insert_trade_journal
|
|
|
|
db = _FSess([[334], [77]]) # order_id→334 해소, 기존 일지 id=77 존재
|
|
await _handle_insert_trade_journal(db, {
|
|
"trade_id": None, "order_id": "1", "stock_code": "009830", "content": "x",
|
|
})
|
|
assert db.added == [] and db.commits == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handler_explicit_trade_id_still_skips_lookup():
|
|
from app.services.db_worker import _handle_insert_trade_journal
|
|
|
|
db = _FSess([])
|
|
await _handle_insert_trade_journal(db, {"trade_id": 5, "stock_code": "x", "content": "c"})
|
|
assert len(db.added) == 1
|
|
|
|
|
|
def test_uncovered_v2_has_grace_cutoff_and_intraday_uses_it():
|
|
import inspect
|
|
from app.services import trade_analyzer, schedulers
|
|
|
|
sig = inspect.signature(trade_analyzer.get_uncovered_trades)
|
|
assert list(sig.parameters) == ["db", "days"] # 기존 시그니처 불변
|
|
assert "min_age_minutes" in inspect.signature(trade_analyzer.get_uncovered_trades_v2).parameters
|
|
body = inspect.getsource(schedulers.journal_intraday_scheduler)
|
|
assert "get_uncovered_trades_v2" in body and "JOURNAL_INTRADAY_GRACE_MIN" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uncovered_v2_sql_has_created_at_cutoff():
|
|
from app.services.trade_analyzer import get_uncovered_trades_v2
|
|
|
|
class _D:
|
|
stmts = []
|
|
|
|
async def execute(self, stmt, *a, **k):
|
|
self.stmts.append(stmt)
|
|
return SimpleNamespace(fetchall=lambda: [], scalars=lambda: SimpleNamespace(all=lambda: []))
|
|
|
|
d = _D()
|
|
await get_uncovered_trades_v2(d, days=7, min_age_minutes=3)
|
|
sql = str(d.stmts[1].compile()).replace("\n", " ")
|
|
assert "trade_history.created_at <=" in sql
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_entries_expire_after_ttl(monkeypatch):
|
|
from app.core.config import settings
|
|
from app.services import schedulers, db_worker
|
|
|
|
calls = []
|
|
|
|
async def _enq(trade, content):
|
|
calls.append(trade.id)
|
|
|
|
monkeypatch.setattr(db_worker, "enqueue_trade_journal", _enq)
|
|
schedulers._JOURNAL_ENQUEUED_IDS.clear()
|
|
t = [_trade(id=31)]
|
|
assert await schedulers._enqueue_journals_v2(t) == 1
|
|
assert await schedulers._enqueue_journals_v2(t) == 0
|
|
# 등록 시각을 TTL 이전으로 되돌리면 재시도된다 (DLQ 로 간 INSERT 복구)
|
|
schedulers._JOURNAL_ENQUEUED_IDS[31] -= settings.JOURNAL_ENQUEUE_TTL_MIN * 60 + 1
|
|
assert await schedulers._enqueue_journals_v2(t) == 1
|
|
assert calls == [31, 31]
|
|
schedulers._JOURNAL_ENQUEUED_IDS.clear()
|
|
|
|
|
|
def test_journals_endpoint_defenses_and_lookback_config():
|
|
src = (APP_ROOT / "api" / "endpoints" / "journals.py").read_text()
|
|
assert "existing.scalars().first()" in src
|
|
assert "JOURNAL_SELL_STATUSES" in src and '("COMPLETED", "FILLED")' not in src
|
|
sch = (APP_ROOT / "services" / "schedulers.py").read_text()
|
|
assert "get_uncovered_trades(db, days=7)" not in sch
|
|
assert "days=settings.JOURNAL_LOOKBACK_DAYS" in sch
|
|
|
|
|
|
def test_insights_skip_pf_when_no_pnl():
|
|
from app.services.trade_analyzer import build_insights, _empty_summary
|
|
|
|
out = build_insights({"summary": _empty_summary()})
|
|
assert not any("Profit Factor" in i["message"] for i in out)
|
|
out2 = build_insights({"summary": {**_empty_summary(), "gross_loss": -100.0, "profit_factor": 0.5}})
|
|
assert any("Profit Factor" in i["message"] for i in out2)
|