하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
9.5 KiB
Python
Executable File
223 lines
9.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""PostToolUse(Edit|Write|MultiEdit) — 편집된 backend/**/*.py 만 검사.
|
|
|
|
1) py_compile 실패 → exit 2
|
|
2) CLAUDE.md 정적 규칙: git diff(HEAD 대비) 추가 라인만 대상 → 기존 위반은 보고하지 않는다.
|
|
(추적되지 않는 새 파일은 전체 라인이 추가로 간주됨)
|
|
위반이 있으면 exit 2 + stderr 에 '파일:라인 규칙 — 근거(CLAUDE.md §)'. 경고성 항목은 stderr 로만 알리고 exit 0.
|
|
"""
|
|
import os
|
|
import py_compile
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _lib import git, project_dir, read_stdin_json # noqa: E402
|
|
|
|
REALTIME_MARKERS = (
|
|
"/app/realtime/",
|
|
"/services/daytrading/",
|
|
"/services/swing/scheduler.py",
|
|
"/kis_realtime_stream.py",
|
|
)
|
|
DB_READ_FUNCS = {"close_stale_positions", "populate_daytrading_watchlist", "restore_from_db"}
|
|
ALLOW_RE = re.compile(r"harness:\s*allow-(json-dumps|except-pass)\b")
|
|
EXCEPT_RE = re.compile(r"^\s*except\b[^:]*:\s*(.*)$")
|
|
|
|
|
|
def added_lines(root: str, rel: str, full_text: str) -> set[int]:
|
|
rc, _ = git(["ls-files", "--error-unmatch", rel], root)
|
|
if rc != 0: # 새 파일(미추적) → 전부 추가
|
|
return set(range(1, full_text.count("\n") + 2))
|
|
rc, out = git(["diff", "-U0", "HEAD", "--", rel], root)
|
|
if rc != 0:
|
|
return set()
|
|
res: set[int] = set()
|
|
for m in re.finditer(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", out, re.M):
|
|
start, cnt = int(m.group(1)), int(m.group(2) or 1)
|
|
res.update(range(start, start + cnt))
|
|
return res
|
|
|
|
|
|
def enclosing_def(lines: list[str], idx: int) -> tuple[str, int]:
|
|
indent = len(lines[idx]) - len(lines[idx].lstrip())
|
|
for j in range(idx, -1, -1):
|
|
m = re.match(r"^(\s*)(?:async\s+)?def\s+(\w+)", lines[j])
|
|
if m and len(m.group(1)) < indent:
|
|
return m.group(2), j
|
|
return "", -1
|
|
|
|
|
|
def next_stmt(lines: list[str], i: int) -> int | None:
|
|
for j in range(i + 1, min(i + 8, len(lines))):
|
|
s = lines[j].strip()
|
|
if s and not s.startswith("#"):
|
|
return j
|
|
return None
|
|
|
|
|
|
def allowed(lines: list[str], idx: int, kind: str, *extra: int) -> bool:
|
|
"""같은 줄 또는 직전 줄(extra 로 추가 줄 인덱스)에 `# harness: allow-<kind> <사유>` 마커가 있으면 True."""
|
|
for j in (idx, idx - 1, *extra):
|
|
if 0 <= j < len(lines):
|
|
m = ALLOW_RE.search(lines[j])
|
|
if m and m.group(1) == kind:
|
|
return True
|
|
return False
|
|
|
|
|
|
def check_static(path: str, rel: str, lines: list[str], added: set[int]) -> list[str]:
|
|
v: list[str] = []
|
|
is_json_helper = rel.endswith("app/core/json_helpers.py")
|
|
realtime = any(mk in "/" + rel for mk in REALTIME_MARKERS)
|
|
for idx, raw in enumerate(lines):
|
|
ln = idx + 1
|
|
s = raw.strip()
|
|
if s.startswith("#"):
|
|
continue
|
|
# json.dumps
|
|
if ln in added and not is_json_helper and re.search(r"\bjson\.dumps\(", raw) and not allowed(lines, idx, "json-dumps"):
|
|
v.append(f"{rel}:{ln} json.dumps 사용 — core.json_helpers._dumps() 를 쓸 것 (CLAUDE.md §4)")
|
|
# except 블록 규칙
|
|
m = EXCEPT_RE.match(raw)
|
|
if m:
|
|
nxt = next_stmt(lines, idx)
|
|
inline = m.group(1).strip()
|
|
first = inline if inline and not inline.startswith("#") else (lines[nxt].strip() if nxt is not None else "")
|
|
first_ln = ln if inline and not inline.startswith("#") else (nxt + 1 if nxt is not None else ln)
|
|
touched = ln in added or first_ln in added
|
|
if touched and re.search(r"\blog(?:ger|ging)?\.debug\(", first):
|
|
v.append(f"{rel}:{first_ln} except 블록의 첫 문장이 logger.debug — 최소 warning 이어야 한다 (CLAUDE.md §4, §8.1-10)")
|
|
if (
|
|
touched
|
|
and first == "pass"
|
|
and re.match(r"^\s*except(\s+(Exception|BaseException)(\s+as\s+\w+)?)?\s*:", raw)
|
|
and not allowed(lines, idx, "except-pass", first_ln - 1)
|
|
):
|
|
v.append(f"{rel}:{ln} `except Exception: pass` — 예외 삼킴 금지, 최소 warning 로그 (CLAUDE.md §4, §8.1-10)")
|
|
# 실시간 계층 DB I/O
|
|
if realtime and ln in added:
|
|
if ".commit(" in raw:
|
|
v.append(f"{rel}:{ln} 실시간 계층에서 commit() — DB 쓰기는 db_task_queue 경유 (CLAUDE.md §3.1·§3.2)")
|
|
if "AsyncSessionLocal(" in raw and not s.startswith(("def ", "class ", "from ", "import ")):
|
|
name, di = enclosing_def(lines, idx)
|
|
window = lines[max(0, idx - 3): idx + 1]
|
|
if name in DB_READ_FUNCS:
|
|
continue
|
|
if any("DB-READ-EXCEPTION" in w for w in window) or (di >= 0 and "DB-READ-EXCEPTION" in lines[di]):
|
|
continue
|
|
if rel.endswith("daytrading/scheduler.py") and any(
|
|
"restore_from_db" in w for w in lines[max(0, idx - 10): idx + 10]
|
|
):
|
|
continue
|
|
v.append(f"{rel}:{ln} 실시간 계층에서 AsyncSessionLocal( — SELECT 전용 예외면 `# DB-READ-EXCEPTION` 마커를 달고 commit 금지 (CLAUDE.md §3.1·§3.2 ③)")
|
|
return v
|
|
|
|
|
|
def _quote_of(tok_string: str) -> str:
|
|
"""'f"' / "rb'''" 같은 문자열 시작 토큰에서 따옴표 부분('"', "'''" 등)만 뽑는다."""
|
|
body = tok_string.lstrip("rRbBuUfF")
|
|
for q in ('"""', "'''", '"', "'"):
|
|
if body.startswith(q):
|
|
return q
|
|
return ""
|
|
|
|
|
|
def check_py311_fstring(rel: str, text: str) -> list[str]:
|
|
"""운영 이미지(python:3.11-slim)에서 SyntaxError 가 나는 PEP 701(3.12+) f-string 을 찾는다.
|
|
|
|
3.12 의 tokenize 는 f-string 을 FSTRING_START/END 로 쪼개므로, 열린 f-string 안에서
|
|
바깥과 같은 따옴표로 시작하는 문자열·f-string 이 나오면 3.11 에서는 바깥을 끝내 버린다
|
|
(2026-10-02 mdd_manager 4줄이 이 이유로 운영 기동을 막을 뻔했다). 3.12 미만에서 돌면 검사 생략.
|
|
"""
|
|
if sys.version_info < (3, 12):
|
|
return []
|
|
import io
|
|
import tokenize
|
|
|
|
out: list[str] = []
|
|
stack: list[str] = []
|
|
try:
|
|
for tok in tokenize.generate_tokens(io.StringIO(text).readline):
|
|
name = tokenize.tok_name.get(tok.type, "")
|
|
if name in ("FSTRING_START", "STRING"):
|
|
q = _quote_of(tok.string)
|
|
if stack and q and any(
|
|
(len(outer) == 1 and q[0] == outer) or (len(outer) == 3 and q == outer) for outer in stack
|
|
):
|
|
out.append(
|
|
f"{rel}:{tok.start[0]} f-string 안에서 바깥과 같은 따옴표 재사용 — Python 3.12 전용 문법(PEP 701), "
|
|
"운영 이미지 python:3.11 에서 SyntaxError 로 기동 실패 (Dockerfile)"
|
|
)
|
|
if name == "FSTRING_START":
|
|
stack.append(q)
|
|
elif name == "FSTRING_END" and stack:
|
|
stack.pop()
|
|
except (tokenize.TokenError, SyntaxError):
|
|
return []
|
|
return out
|
|
|
|
|
|
def check_router_auth(root: str, rel: str, text: str) -> list[str]:
|
|
if "/api/endpoints/" not in "/" + rel or not re.search(r"@router\.(post|put|delete|patch)\(", text):
|
|
return []
|
|
if "require_api_key" in text:
|
|
return []
|
|
init = os.path.join(root, "backend/app/api/__init__.py")
|
|
mod = os.path.splitext(os.path.basename(rel))[0]
|
|
try:
|
|
body = open(init, encoding="utf-8").read()
|
|
except OSError:
|
|
return []
|
|
for m in re.finditer(r"include_router\(\s*([\w.]+)\.router(.*?)\)\s*(?=\n\S|\Z|\napi_router)", body, re.S):
|
|
if m.group(1).split(".")[-1] == mod and "require_api_key" in m.group(2):
|
|
return []
|
|
return [f"{rel}:1 [경고] 쓰기 엔드포인트가 있는데 require_api_key 가 라우터/api/__init__.py 등록에서 확인되지 않음 (CLAUDE.md §4)"]
|
|
|
|
|
|
def main() -> int:
|
|
data = read_stdin_json()
|
|
fp = (data.get("tool_input") or {}).get("file_path")
|
|
if not isinstance(fp, str) or not fp.endswith(".py"):
|
|
return 0
|
|
root = os.path.realpath(project_dir(data))
|
|
fp = os.path.realpath(fp)
|
|
if not fp.startswith(root + "/backend/") or not os.path.isfile(fp):
|
|
return 0
|
|
rel = os.path.relpath(fp, root)
|
|
errors: list[str] = []
|
|
|
|
try:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
py_compile.compile(fp, cfile=os.path.join(td, "x.pyc"), doraise=True)
|
|
except py_compile.PyCompileError as e:
|
|
errors.append(f"{rel} py_compile 실패 — {str(e).strip().splitlines()[-1]}")
|
|
except Exception:
|
|
pass
|
|
|
|
in_tests = "/backend/tests/" in fp or "/backend/alembic/" in fp
|
|
warnings: list[str] = []
|
|
if not errors and not in_tests:
|
|
try:
|
|
text = open(fp, encoding="utf-8").read()
|
|
lines = text.split("\n")
|
|
errors += check_static(fp, rel, lines, added_lines(root, rel, text))
|
|
errors += check_py311_fstring(rel, text)
|
|
warnings += check_router_auth(root, rel, text)
|
|
except Exception as e: # noqa: BLE001
|
|
sys.stderr.write(f"[rules_check] internal {e!r}\n")
|
|
return 0
|
|
|
|
if warnings and not errors:
|
|
sys.stderr.write("\n".join(warnings) + "\n")
|
|
return 0
|
|
if errors:
|
|
sys.stderr.write("[harness] CLAUDE.md 규칙 위반(이번 편집에서 추가된 라인):\n" + "\n".join(errors + warnings) + "\n")
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|