Files
auto-stock/.claude/scripts/deploy.sh
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 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>
2026-10-02 12:01:31 +09:00

140 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# auto-stock 배포: 작업트리 확인 → 깨끗한 worktree 유닛 스위트 → push dev → 임시 worktree 에서 main 병합·푸시
# → 재기동 감시(다운 → 기동) → 공개/인증 API 스모크.
# 사용:
# deploy.sh [-m "병합 메시지"] [--no-test] [--dry-run]
# deploy.sh --watch-only # 이미 푸시된 배포의 재기동 감시 + 스모크만
# 사용자의 작업 브랜치(dev)는 바꾸지 않는다. 운영 서버/KIS 직접 호출 없음(공개 GET 만).
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
SCRIPT_DIR="$ROOT/.claude/scripts"
BASE="${PROD_BASE_URL:-https://stock.shium.net}"
# DB 만 읽는 가벼운 엔드포인트. 2026-10-02(H-1)부터 키 없이 401 — 200(구버전)·401(신버전) 모두 "기동됨".
WATCH_URL="$BASE/api/v1/journals/stats"
WATCH_MAX_SEC="${WATCH_MAX_SEC:-900}"
WATCH_INTERVAL="${WATCH_INTERVAL:-15}"
DEFAULT_MSG="Merge dev into main (deploy)"
TRAILER="Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
MSG="$DEFAULT_MSG"; NO_TEST=0; WATCH_ONLY=0; DRY=0
while [ $# -gt 0 ]; do
case "$1" in
-m|--message) MSG="${2:?-m 에 메시지 필요}"; shift 2 ;;
--no-test) NO_TEST=1; shift ;;
--watch-only) WATCH_ONLY=1; shift ;;
--dry-run) DRY=1; shift ;;
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
*) echo "알 수 없는 옵션: $1" >&2; exit 64 ;;
esac
done
TMPDIRS=()
cleanup() { for d in "${TMPDIRS[@]:-}"; do [ -n "$d" ] && git -C "$ROOT" worktree remove --force "$d" >/dev/null 2>&1 || true; done; git -C "$ROOT" worktree prune >/dev/null 2>&1 || true; }
trap cleanup EXIT
kst() { TZ=Asia/Seoul date "$@"; }
market_warning() {
local dow hm
dow="$(kst +%u)"; hm="$(kst +%H%M)"; hm=$((10#$hm))
if [ "$dow" -le 5 ] && [ "$hm" -ge 830 ] && [ "$hm" -le 1540 ]; then
echo "!! 장중 배포 경고 — KST $(kst '+%H:%M') (평일 08:30~15:40). 재기동이 매매에 영향(DT 포지션·스케줄러 상태·KIS 토큰 재발급)."
else
echo "배포 시각 KST $(kst '+%Y-%m-%d %H:%M') — 장외."
fi
}
http_code() { local c; c="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$1" 2>/dev/null || true)"; echo "${c:-000}"; }
# 앱이 요청을 처리하고 있는가: 200(공개/구버전) 또는 401(인증 필수 — 정상 거부)
is_up() { [ "$1" = "200" ] || [ "$1" = "401" ]; }
watch_restart() {
echo "== (e) 배포 감시: $WATCH_URL (다운 → 200/401 재기동 감지, 최대 ${WATCH_MAX_SEC}s, ${WATCH_INTERVAL}s 간격) =="
local start now code seen_down=0 first=1 elapsed
start="$(date +%s)"
while :; do
now="$(date +%s)"; elapsed=$((now - start))
code="$(http_code "$WATCH_URL")"
printf '[%4ss] http=%s\n' "$elapsed" "$code"
if ! is_up "$code"; then
[ "$seen_down" = 0 ] && echo " -> 다운 감지(재기동 중)"
seen_down=1
elif [ "$seen_down" = 1 ]; then
echo " -> 재기동 완료: http=$code 복귀 (${elapsed}s)"; return 0
elif [ "$first" = 1 ]; then
echo " (현재 http=$code — 아직 옛 컨테이너일 수 있음. 빌드/재기동 대기)"
fi
first=0
if [ "$elapsed" -ge "$WATCH_MAX_SEC" ]; then
if [ "$seen_down" = 1 ]; then echo "!! ${WATCH_MAX_SEC}s 경과 — 여전히 다운(200/401 아님). Jenkins 로그/컨테이너 확인 필요."; return 2; fi
echo "!! ${WATCH_MAX_SEC}s 동안 재기동 미감지(계속 기동 상태) — 빌드 지연·무중단 교체·변경 없음 가능. Jenkins 로그 확인."; return 1
fi
sleep "$WATCH_INTERVAL"
done
}
if [ "$WATCH_ONLY" = 1 ]; then
market_warning
rc=0; watch_restart || rc=$?
echo "== (f) 스모크 =="; "$SCRIPT_DIR/prod_smoke.sh" || rc=$?
exit "$rc"
fi
market_warning
# (a) 작업트리 확인
BR="$(git rev-parse --abbrev-ref HEAD)"
[ "$BR" = "dev" ] || { echo "중단: 현재 브랜치가 dev 가 아님($BR). 작업은 dev 에서만(branch-workflow-dev-only)." >&2; exit 1; }
DIRTY="$(git status --porcelain | grep -v -E '^.. \.claude/settings\.local\.json$' || true)"
if [ -n "$DIRTY" ]; then echo "중단: 미커밋 변경이 있다(.claude/settings.local.json 제외):" >&2; echo "$DIRTY" >&2; exit 1; fi
HEAD_SHA="$(git rev-parse HEAD)"
echo "== (a) 작업트리 깨끗 — dev @ ${HEAD_SHA:0:9} =="
git fetch origin main --quiet || { echo "중단: origin fetch 실패" >&2; exit 1; }
if git diff --name-only origin/main..HEAD -- backend/alembic/versions | grep -q .; then
echo "!! alembic 마이그레이션 포함: $(git diff --name-only origin/main..HEAD -- backend/alembic/versions | tr '\n' ' ')"
echo " 배포 후 Jenkins 로그에서 'alembic upgrade head' 성공 여부를 반드시 확인(앱 기동보다 먼저 실행돼야 함)."
fi
# (b) 깨끗한 worktree 에서 유닛 스위트
if [ "$NO_TEST" = 1 ]; then
echo "!! 경고: --no-test — 유닛 스위트 검증 없이 진행한다."
else
PY="$ROOT/.venv/bin/python"
[ -x "$PY" ] || { echo "중단: $PY 없음(테스트 불가). 정말 건너뛰려면 --no-test." >&2; exit 1; }
WT1="$(mktemp -d "${TMPDIR:-/tmp}/autostock-deploy-test.XXXXXX")"; rmdir "$WT1"
git worktree add --detach "$WT1" "$HEAD_SHA" >/dev/null; TMPDIRS+=("$WT1")
echo "== (b) HEAD 기준 유닛 스위트 (깨끗한 worktree) =="
( cd "$WT1/backend" && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" \
"$PY" -m pytest tests/ --ignore=tests/integration -q -x -p no:cacheprovider ) \
|| { echo "중단: 유닛 스위트 실패 — 배포하지 않음." >&2; exit 1; }
fi
if [ "$DRY" = 1 ]; then echo "== --dry-run: 푸시/병합 생략. 여기까지 통과 =="; exit 0; fi
# (c) dev 푸시
echo "== (c) git push origin dev =="
git push origin dev
# (d) 임시 worktree 에서 origin/main 에 --no-ff 병합 후 푸시 (작업 브랜치 불변)
git fetch origin main --quiet
WT2="$(mktemp -d "${TMPDIR:-/tmp}/autostock-deploy-merge.XXXXXX")"; rmdir "$WT2"
git worktree add --detach "$WT2" origin/main >/dev/null; TMPDIRS+=("$WT2")
echo "== (d) origin/main 에 dev(${HEAD_SHA:0:9}) --no-ff 병합 =="
if git -C "$WT2" merge-base --is-ancestor "$HEAD_SHA" origin/main; then
echo "main 이 이미 dev 를 포함 — 병합/푸시할 것 없음. 스모크만 수행."
"$SCRIPT_DIR/prod_smoke.sh"; exit $?
fi
if ! git -C "$WT2" merge --no-ff "$HEAD_SHA" -m "$MSG" -m "$TRAILER"; then
git -C "$WT2" merge --abort >/dev/null 2>&1 || true
echo "중단: 병합 충돌. main 은 건드리지 않았다. dev 에 origin/main 을 먼저 병합해 해결하라." >&2; exit 1
fi
git -C "$WT2" push origin HEAD:main
echo " main -> $(git -C "$WT2" rev-parse --short HEAD) 푸시 완료 (Jenkins 배포 트리거)"
# (e)(f)
rc=0; watch_restart || rc=$?
echo "== (f) 스모크 =="; "$SCRIPT_DIR/prod_smoke.sh" || rc=$?
exit "$rc"