fix: 매매일지 표시·중복·스케줄러 중단 + 전략 파라미터 자동적용 차단
매매일지는 정상 생성되고 있었다(205건, 커버리지 100%, 16:00 자동 생성도 동작).
"작성이 안 된다"의 실체는 아래 표시 계층 문제였다.
## 표시
- Dashboard journals 쿼리가 staleTime:Infinity + refetchInterval 없음이라
페이지 로드 순간의 목록에서 영구 고정됐다 → 30s/60s 폴링
- WS journal_created 는 205건 중 13건(6%)만 발화하고(16:00 스케줄러와
/analysis/generate-journals 는 브로드캐스트 없음) 그마저 커밋 전에 나간다
→ 이벤트 의존 제거, 수신 시 즉시+3초 재invalidate 로 레이스 보강
- 수동 버튼이 목록을 갱신하지 않고 안내가 "매매 로그"(없는 위치)를 가리켰다
→ invalidateQueries + 실제 위치·비동기 지연 명시
## 시각 +9h
- journals 가 KST naive 값에 UTC 라벨을 붙여 화면이 +9h 밀렸다
(실측 KST 15:48 → 화면 09-30 00:48, 즉 당일 16:00 일지가 "다음날 01:00")
- _utc_iso → _kst_iso, +09:00 명시. 세 응답 지점 통일(기존엔 목록/상세가 달랐다)
- /trades 는 raw naive + 프론트 보정으로 짝이 맞아 있어 건드리지 않았다
(회귀 테스트가 trades.py 에 +09:00 유입을 차단)
- JournalPanel 에 조건부 +09:00 정규화 추가(매매내역 경로와 동일 패턴)
## now_kst 섀도잉 4곳
- from timeutil import now_kst 해놓고 함수 내 now_kst = _dt.now(_KST) 로 대입해
같은 함수의 now_kst() 가 TypeError. post_market 에서 실장애였고 나머지 3곳은
무증상 잠재 → 전부 _now 로 리네임 (AST 전수, backend+mcp-server 잔존 0)
- TypeError 를 은폐하던 logger.debug → warning(exc_info=True), except: pass 1곳도
## trade_id=0 중복
- db_worker 에 _handle_insert_trade_journal 신설 — order_id 로 TradeHistory.id
해소, 해소 실패 시 None(0 아님). 정상경로·종료 drain 공용
- 커버 판정 trade_id > 0 (id 는 PK라 0 은 애초에 아무 행도 커버하지 않음)
- order_id 추출이 raw KIS 응답에 없는 top-level "odno" 였다 → output.ODNO 우선
## 전략 파라미터 자동 적용 차단 (STRATEGY_AUTO_APPLY_ENABLED=False)
섀도잉 수정이 post_market 의 자동 최적화를 부활시킨다 — run_optimization 이
strategy_cfg.upsert() 로 실매매 파라미터(RSI 진입·손절·포지션 크기)를 덮어쓴다.
게이트가 "최근 7일 SELL>=5건"이라 5거래 표본 그리드 서치가 alembic 0012 로
정합시킨 값을 조용히 바꿀 수 있다 — §8.1-5·§8.4·§8.5-1 과 정면 충돌.
- 계산·분석·로그는 유지하고 upsert 만 차단. 가드 2단, 둘 다 try 밖
- 차단 시 키별 현재값→제안값 + IS/OOS Sharpe 를 warning + apply_reason 에 기록
- 켜기 전 §8.4 백테스트 요건 충족 필요(config 주석에 명시)
## 부수 발견
strategy_config_service.upsert 가 __import__("datetime").now_kst() 로 항상
AttributeError → 전략 파라미터 저장이 UI·일괄·optimizer 전 경로에서 500이었다.
역설적으로 이것이 자동적용의 2차 방어선이었다 — 고쳤으므로 플래그가 필수다.
⚠️ 배포 후 UI 파라미터 저장이 처음으로 실제 동작한다(§0 정합 주의).
테스트 677 → 715 passed / 0 failed. 전략 임계값 정적 변경 0건.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1494,6 +1494,12 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
# 해외 주문 응답은 ord_prc 대신 ovrs_ord_unpr 사용
|
||||
_out = order_result.get("output", {})
|
||||
_raw_price = _out.get("ord_prc") or _out.get("ovrs_ord_unpr") or order_price
|
||||
# [2026-09-29 C1] `place_order()` 는 raw KIS 응답을 그대로 준다 —
|
||||
# 주문번호는 **`output.ODNO`** 이고 top-level `"odno"` 는 없다.
|
||||
# 이전 코드가 `order_result.get("odno")` 를 써서 TradeHistory.order_id
|
||||
# 가 항상 None 이었다(이 함수는 deprecated·미배선이라 드러나지 않았다).
|
||||
# 다른 6개 주문 경로·us_market_scheduler.py:359 와 같은 형태로 통일.
|
||||
_order_id = _out.get("ODNO") or order_result.get("odno")
|
||||
executed_price = Decimal(str(_raw_price))
|
||||
total_amount = Decimal(str(executed_price)) * sell_quantity
|
||||
profit_loss = (float(executed_price) - avg_price) * sell_quantity
|
||||
@@ -1511,7 +1517,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
"executed_quantity": sell_quantity,
|
||||
"total_amount": total_amount,
|
||||
"status": _trade_status,
|
||||
"order_id": order_result.get("odno"),
|
||||
"order_id": _order_id,
|
||||
"message": f"자동 청산: {profit_loss_rate:.2f}%",
|
||||
"exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
|
||||
"created_at": _get_kst_now(),
|
||||
@@ -1587,9 +1593,10 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
})
|
||||
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
|
||||
|
||||
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달
|
||||
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
|
||||
asyncio.create_task(_create_journal_background(
|
||||
trade_id=0,
|
||||
trade_id=None,
|
||||
order_id=_order_id,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type="SELL",
|
||||
@@ -1614,7 +1621,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
|
||||
|
||||
async def _create_journal_background(
|
||||
trade_id: int,
|
||||
trade_id: Optional[int],
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
order_type: str,
|
||||
@@ -1623,8 +1630,16 @@ async def _create_journal_background(
|
||||
profit_loss: float,
|
||||
profit_loss_rate: float,
|
||||
exit_reason: str,
|
||||
order_id: Optional[str] = None,
|
||||
):
|
||||
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크)"""
|
||||
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크).
|
||||
|
||||
[2026-09-29 B3] `trade_id` 는 주문 직후엔 알 수 없다(TradeHistory INSERT 가
|
||||
db_worker 큐에 있어 PK 미확정). 이전에는 `0` 을 넣었는데, 그 값은 존재하지 않는
|
||||
PK 라 `get_uncovered_trades()` 의 커버 판정을 통과하지 못하고 같은 매도에 16:00
|
||||
배치가 두 번째 일지를 만들었다. 이제 `order_id` 를 함께 넘겨
|
||||
`db_worker._handle_insert_trade_journal()` 이 INSERT 시점에 해소한다.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
@@ -1659,7 +1674,8 @@ async def _create_journal_background(
|
||||
mood = result.get("mood")
|
||||
tags = result.get("tags", [])
|
||||
except Exception as mcp_err:
|
||||
logger.debug(f"MCP 일지 생성 실패 ({stock_code}): {mcp_err}")
|
||||
# CLAUDE.md §4: 예외 로그는 최소 warning (폴백이 있어도 원인은 남겨야 한다)
|
||||
logger.warning(f"MCP 일지 생성 실패 → 규칙 기반 폴백 ({stock_code}): {mcp_err}")
|
||||
|
||||
# 폴백: 규칙 기반 간단 일지
|
||||
if not content:
|
||||
@@ -1677,7 +1693,9 @@ async def _create_journal_background(
|
||||
# DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유
|
||||
from app.services.db_worker import enqueue_trade_journal_raw
|
||||
await enqueue_trade_journal_raw({
|
||||
"trade_id": trade_id,
|
||||
"trade_id": trade_id or None,
|
||||
# order_id 는 TradeJournal 컬럼이 아니다 — 워커가 trade_id 해소 후 제거한다.
|
||||
"order_id": order_id,
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"journal_date": now_kst(),
|
||||
@@ -1872,9 +1890,10 @@ async def sell_stock(
|
||||
|
||||
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
|
||||
|
||||
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달
|
||||
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
|
||||
asyncio.create_task(_create_journal_background(
|
||||
trade_id=0,
|
||||
trade_id=None,
|
||||
order_id=order_result.get('output', {}).get('ODNO'),
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type="SELL",
|
||||
|
||||
Reference in New Issue
Block a user