fix(journal): 장중 일지 자동 생성 + 전략명 unknown + profit_factor 단위 오류
- 장중 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>
This commit is contained in:
@@ -455,13 +455,19 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
||||
win_rate = len(wins) / len(pl_rates) if pl_rates else 0.0
|
||||
avg_win = sum(wins) / len(wins) if wins else 0.0
|
||||
avg_loss = sum(losses) / len(losses) if losses else 0.0
|
||||
profit_factor = (
|
||||
(sum(wins) / abs(sum(losses))) if losses and sum(losses) != 0 else float("inf")
|
||||
)
|
||||
|
||||
gross_profit = sum(a for a in pl_amounts if a > 0)
|
||||
gross_loss = sum(a for a in pl_amounts if a < 0)
|
||||
net_pnl = gross_profit + gross_loss
|
||||
# [2026-10-01] PF 는 **금액** 기준이다(gross_profit / |gross_loss|). 이전에는 손익률(%)
|
||||
# 합계로 계산해 gross_profit 266,370 / gross_loss -430,619 인데 PF 1.2165 가 나왔다
|
||||
# (소액 거래의 큰 %가 대금 큰 거래의 작은 %를 압도). 손실 없음 → inf(아래서 99 로 캡),
|
||||
# 수익도 손실도 없음 → 0.
|
||||
if gross_loss < 0:
|
||||
profit_factor = gross_profit / abs(gross_loss)
|
||||
elif gross_profit > 0:
|
||||
profit_factor = float("inf")
|
||||
else:
|
||||
profit_factor = 0.0
|
||||
|
||||
# 최대 연속 손실
|
||||
max_consecutive_loss = _calc_max_consecutive_loss(pl_rates)
|
||||
@@ -636,7 +642,11 @@ def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
else:
|
||||
insights.append({"level": "info", "message": f"승률 {win_rate:.1f}% — 양호합니다."})
|
||||
|
||||
if pf < 1.0:
|
||||
# 거래 없음/수익·손실 모두 0 이면 PF=0 은 "손실>수익"이 아니라 판단 불가 — 인사이트 생략
|
||||
_has_pnl = bool(summary.get("gross_profit") or summary.get("gross_loss"))
|
||||
if not _has_pnl:
|
||||
pass
|
||||
elif pf < 1.0:
|
||||
insights.append({"level": "danger", "message": f"Profit Factor {pf:.2f} — 손실이 수익보다 큽니다. 손절 기준 강화 필요."})
|
||||
elif pf < 1.5:
|
||||
insights.append({"level": "warning", "message": f"Profit Factor {pf:.2f} — 개선 여지 있습니다."})
|
||||
@@ -688,9 +698,29 @@ def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
# 매매일지 생성
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 일지 대상 SELL 상태. 성과 집계(analyze_performance)의 COMPLETED/FILLED 와 **의도적으로 다르다**:
|
||||
# PARTIAL 은 실제 체결된 매도(부분 체결)라 기록할 가치가 있고, 이후 COMPLETED 로 갱신돼도
|
||||
# 같은 TradeHistory.id 라 trade_id 커버 판정으로 중복되지 않는다. PENDING(체결 미확인)·
|
||||
# FAILED(거부)는 손익이 확정되지 않았거나 거래가 없으므로 제외한다.
|
||||
# 성과 집계에는 PARTIAL 을 넣지 않는다(손익 이중 계상 방지) — 이 상수는 일지 전용.
|
||||
JOURNAL_SELL_STATUSES = ("COMPLETED", "FILLED", "PARTIAL")
|
||||
|
||||
|
||||
async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHistory]:
|
||||
"""일지가 아직 없는 완료 SELL 거래 목록을 반환."""
|
||||
"""일지가 아직 없는 체결 SELL(COMPLETED/FILLED/PARTIAL) 거래 목록을 반환."""
|
||||
return await get_uncovered_trades_v2(db, days=days, min_age_minutes=0)
|
||||
|
||||
|
||||
async def get_uncovered_trades_v2(
|
||||
db: AsyncSession, days: int = 7, min_age_minutes: int = 0
|
||||
) -> List[TradeHistory]:
|
||||
"""get_uncovered_trades + 유예시간: created_at 이 min_age_minutes 이내인 SELL 은 제외.
|
||||
|
||||
매도 직후 `_create_journal_background`(MCP 분석 최대 30초 후 큐 등록)가 같은 매도의
|
||||
일지를 만들 시간을 준다 — 장중 주기 태스크가 그 사이에 중복 생성하지 않도록.
|
||||
"""
|
||||
since = now_kst() - timedelta(days=days)
|
||||
cutoff = now_kst() - timedelta(minutes=max(0, int(min_age_minutes)))
|
||||
|
||||
# 이미 일지가 있는 trade_id 목록
|
||||
# [2026-09-29 B3] `> 0` 조건이 필수다. 과거 호출부가 trade_id 미확보 시 `0` 을
|
||||
@@ -703,8 +733,9 @@ async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHis
|
||||
stmt = select(TradeHistory).where(
|
||||
and_(
|
||||
TradeHistory.order_type == "SELL",
|
||||
TradeHistory.status.in_(["COMPLETED", "FILLED"]),
|
||||
TradeHistory.status.in_(JOURNAL_SELL_STATUSES),
|
||||
TradeHistory.created_at >= since,
|
||||
TradeHistory.created_at <= cutoff,
|
||||
TradeHistory.id.not_in(covered_ids) if covered_ids else True,
|
||||
)
|
||||
).order_by(TradeHistory.created_at.desc())
|
||||
|
||||
Reference in New Issue
Block a user