WBS-8.1 & WBS-9.7 자동화 구현 완료
## WBS-8.1: T+20 거래 레저 자동 수집 신규 도구: tools/auto_collect_t20_ledger_v1.py 기능: - T+20 경과 거래 자동 감지 - 성과 데이터 자동 수집 - performance 탭 자동 기록 - 진행률 모니터링 (목표: 30건) 목표 달성 시기: 2026-07-15 진행률 추적: 일일 1회 실행 ## WBS-9.7: 자동 백업 정책 구현 신규 워크플로우: .gitea/workflows/auto_backup_schedule.yml 백업 정책: - 일일 증분 백업 (매일 자정) - 주간 전체 백업 (매주 월요일) - 상태 점검 (매일 정오) - 월간 복구 테스트 (매월 1일) 목표: - 복구 시간 < 1시간 - 성공률 99% - 30일 자동 보관 ## 병렬 진행 상태 WBS-8: 12.5% (1/8 완료) - 8.1: T+20 자동 수집 체계 완성 - 8.5: 섹터 플로우 누적 중 (10%) - 8.4: 실거래 대기 (80%) WBS-9: 71.4% (5/7 준비 완료) - 9.1: F14 완료 - 9.4: 장애 대응 준비 완료 - 9.7: 백업 정책 완성 다음 마일스톤: - 2026-07-01: WBS-9.4 장애 대응 훈련 - 2026-07-15: WBS-8.1 활성화 (T+20 30건) - 2026-08-01: WBS-9 공식 시작 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
name: Auto Backup - WBS-9.7
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 매일 자정 (UTC)
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
daily-backup:
|
||||||
|
runs-on: act-runner
|
||||||
|
name: Daily Backup
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
run: |
|
||||||
|
python --version
|
||||||
|
|
||||||
|
- name: Run Daily Backup
|
||||||
|
run: |
|
||||||
|
python tools/backup_recovery_manager_v1.py
|
||||||
|
|
||||||
|
- name: Cleanup Old Backups
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
from tools.backup_recovery_manager_v1 import BackupRecoveryManager
|
||||||
|
manager = BackupRecoveryManager(retention_days=30)
|
||||||
|
result = manager.cleanup_old_backups()
|
||||||
|
print(f'Cleanup: {result}')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Log Backup Result
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Backup completed at $(date)"
|
||||||
|
ls -lh backups/ | tail -5
|
||||||
|
|
||||||
|
weekly-full-backup:
|
||||||
|
runs-on: act-runner
|
||||||
|
name: Weekly Full Backup
|
||||||
|
|
||||||
|
# 매주 월요일 1:00 UTC
|
||||||
|
schedule:
|
||||||
|
- cron: '0 1 * * 1'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
run: python --version
|
||||||
|
|
||||||
|
- name: Create Weekly Full Backup
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
from tools.backup_recovery_manager_v1 import BackupRecoveryManager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
manager = BackupRecoveryManager()
|
||||||
|
result = manager.create_weekly_full_backup()
|
||||||
|
print(f'Weekly backup: {result}')
|
||||||
|
|
||||||
|
# 신뢰성 테스트
|
||||||
|
if 'backup_name' in result:
|
||||||
|
integrity = manager.test_backup_integrity(result['backup_name'])
|
||||||
|
print(f'Integrity: {integrity}')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Backup to Cloud (Optional)
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
# Synology NAS로 동기화 (설정 필요)
|
||||||
|
# rsync -av backups/ admin@SYNOLOGY_IP:/backup/data_feed/
|
||||||
|
echo "Cloud sync would run here if configured"
|
||||||
|
|
||||||
|
- name: Notify Completion
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "Weekly backup completed successfully"
|
||||||
|
df -h | grep -E "Filesystem|data"
|
||||||
|
|
||||||
|
backup-health-check:
|
||||||
|
runs-on: act-runner
|
||||||
|
name: Backup Health Check
|
||||||
|
|
||||||
|
# 매일 12:00 UTC
|
||||||
|
schedule:
|
||||||
|
- cron: '0 12 * * *'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Check Backup Integrity
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
from tools.backup_recovery_manager_v1 import BackupRecoveryManager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
manager = BackupRecoveryManager()
|
||||||
|
|
||||||
|
# 가장 최근 백업 확인
|
||||||
|
backups = sorted(Path('backups/').glob('*'), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
if backups:
|
||||||
|
latest = backups[0].name
|
||||||
|
print(f'Latest backup: {latest}')
|
||||||
|
|
||||||
|
integrity = manager.test_backup_integrity(latest)
|
||||||
|
print(f'Status: {integrity.get(\"status\")}')
|
||||||
|
|
||||||
|
if integrity.get('database_integrity') != 'ok':
|
||||||
|
print('WARNING: Database integrity issue detected')
|
||||||
|
else:
|
||||||
|
print('ERROR: No backups found')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Log Backup Statistics
|
||||||
|
run: |
|
||||||
|
echo "=== Backup Statistics ==="
|
||||||
|
find backups/ -type f -name "metadata.json" | wc -l
|
||||||
|
du -sh backups/ | awk '{print "Total size: " $1}'
|
||||||
|
|
||||||
|
test-recovery:
|
||||||
|
runs-on: act-runner
|
||||||
|
name: Monthly Recovery Test
|
||||||
|
|
||||||
|
# 매월 1일 2:00 UTC
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 1 * *'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Test Recovery Procedure
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
from tools.backup_recovery_manager_v1 import BackupRecoveryManager
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
manager = BackupRecoveryManager()
|
||||||
|
|
||||||
|
# 가장 최근 백업에서 복구 테스트
|
||||||
|
backups = sorted(Path('backups/').glob('*'), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
if backups:
|
||||||
|
test_backup = backups[0].name
|
||||||
|
|
||||||
|
# 임시 디렉토리에 복구
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = manager.restore_from_backup(test_backup, tmpdir)
|
||||||
|
print(f'Recovery test: {result.get(\"status\")}')
|
||||||
|
print(f'Recovery time: {result.get(\"recovery_time_seconds\")}s')
|
||||||
|
|
||||||
|
if result.get('status') == 'SUCCESS':
|
||||||
|
print('Recovery procedure validated')
|
||||||
|
else:
|
||||||
|
print('ERROR: Recovery test failed')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Document Recovery Capability
|
||||||
|
run: |
|
||||||
|
echo "Monthly recovery test completed"
|
||||||
|
echo "Recovery time target: < 1 hour"
|
||||||
|
echo "Success rate target: 99%"
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WBS-8.1: T+20 레저 30건 자동 수집 체계
|
||||||
|
|
||||||
|
목표: 거래 후 T+20일 경과시 자동으로 성과 데이터 수집 및 정리
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class T20LedgerCollector:
|
||||||
|
"""T+20 거래 성과 자동 수집"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = None):
|
||||||
|
self.db_path = db_path or "src/quant_engine/data_feed.db"
|
||||||
|
self.results = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"collections": [],
|
||||||
|
"summary": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _query_pending_trades(self) -> List[Dict]:
|
||||||
|
"""T+20이 경과했지만 성과가 미기록된 거래 조회"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# T+20 경과 기준: entry_date + 20 <= today
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
ticker,
|
||||||
|
name,
|
||||||
|
entry_date,
|
||||||
|
entry_price,
|
||||||
|
quantity,
|
||||||
|
stop_price,
|
||||||
|
target_price,
|
||||||
|
entry_stage,
|
||||||
|
account,
|
||||||
|
CAST((julianday('now') - julianday(entry_date)) AS INTEGER) as days_elapsed
|
||||||
|
FROM data_feed
|
||||||
|
WHERE ticker NOT NULL
|
||||||
|
AND entry_date NOT NULL
|
||||||
|
AND entry_price NOT NULL
|
||||||
|
AND quantity NOT NULL
|
||||||
|
AND days_elapsed >= 20
|
||||||
|
AND ticker NOT IN (
|
||||||
|
SELECT DISTINCT ticker FROM performance
|
||||||
|
WHERE entry_date = data_feed.entry_date
|
||||||
|
)
|
||||||
|
ORDER BY entry_date ASC
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
|
||||||
|
cursor.execute(query)
|
||||||
|
trades = [dict(row) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return trades
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error querying trades: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_current_price(self, ticker: str) -> Optional[float]:
|
||||||
|
"""현재 가격 조회 (data_feed에서 최신값)"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT close_price FROM data_feed
|
||||||
|
WHERE ticker = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
cursor.execute(query, (ticker,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return result[0] if result else None
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def collect_t20_performance(self) -> Dict:
|
||||||
|
"""T+20 성과 데이터 수집"""
|
||||||
|
pending_trades = self._query_pending_trades()
|
||||||
|
|
||||||
|
if not pending_trades:
|
||||||
|
return {
|
||||||
|
"status": "NO_PENDING_TRADES",
|
||||||
|
"message": "T+20 경과 미기록 거래 없음",
|
||||||
|
"collected_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
collected = []
|
||||||
|
for trade in pending_trades:
|
||||||
|
ticker = trade["ticker"]
|
||||||
|
current_price = self._get_current_price(ticker)
|
||||||
|
|
||||||
|
if current_price is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 수익률 계산
|
||||||
|
entry_price = trade["entry_price"]
|
||||||
|
pnl_pct = ((current_price - entry_price) / entry_price) * 100
|
||||||
|
|
||||||
|
collection = {
|
||||||
|
"ticker": ticker,
|
||||||
|
"name": trade["name"],
|
||||||
|
"entry_date": trade["entry_date"],
|
||||||
|
"t20_date": (
|
||||||
|
datetime.strptime(trade["entry_date"], "%Y-%m-%d") +
|
||||||
|
timedelta(days=20)
|
||||||
|
).strftime("%Y-%m-%d"),
|
||||||
|
"days_elapsed": trade["days_elapsed"],
|
||||||
|
"entry_price": entry_price,
|
||||||
|
"current_price": current_price,
|
||||||
|
"pnl_pct": round(pnl_pct, 2),
|
||||||
|
"quantity": trade["quantity"],
|
||||||
|
"stop_price": trade["stop_price"],
|
||||||
|
"target_price": trade["target_price"],
|
||||||
|
"entry_stage": trade["entry_stage"],
|
||||||
|
"account": trade["account"],
|
||||||
|
"status": "T20_MILESTONE_REACHED"
|
||||||
|
}
|
||||||
|
|
||||||
|
collected.append(collection)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"collected_count": len(collected),
|
||||||
|
"trades": collected,
|
||||||
|
"next_action": "Record in performance sheet"
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_t20_in_performance(self, collections: List[Dict]) -> Dict:
|
||||||
|
"""T+20 성과를 performance 탭에 기록"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
recorded = 0
|
||||||
|
for trade in collections:
|
||||||
|
# 이미 기록되어 있는지 확인
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) FROM performance WHERE ticker = ? AND entry_date = ?",
|
||||||
|
(trade["ticker"], trade["entry_date"])
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.fetchone()[0] > 0:
|
||||||
|
continue # 이미 기록됨
|
||||||
|
|
||||||
|
# 성과 기록
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO performance (
|
||||||
|
ticker, name, entry_date, entry_price,
|
||||||
|
quantity, stop_price, target_price,
|
||||||
|
entry_stage, account, current_price,
|
||||||
|
pnl_pct, status, t20_milestone
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
trade["ticker"],
|
||||||
|
trade["name"],
|
||||||
|
trade["entry_date"],
|
||||||
|
trade["entry_price"],
|
||||||
|
trade["quantity"],
|
||||||
|
trade["stop_price"],
|
||||||
|
trade["target_price"],
|
||||||
|
trade["entry_stage"],
|
||||||
|
trade["account"],
|
||||||
|
trade["current_price"],
|
||||||
|
trade["pnl_pct"],
|
||||||
|
"T20_RECORDED",
|
||||||
|
trade["t20_date"]
|
||||||
|
))
|
||||||
|
|
||||||
|
recorded += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"recorded_count": recorded,
|
||||||
|
"message": f"{recorded}개 거래 T+20 성과 기록 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "ERROR",
|
||||||
|
"error": str(e),
|
||||||
|
"recorded_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_t20_ledger_status(self) -> Dict:
|
||||||
|
"""T+20 레저 진행 상태 확인"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 전체 완료 거래
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) FROM performance WHERE exit_date IS NOT NULL"
|
||||||
|
)
|
||||||
|
total_completed = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# T+20 이상 경과한 거래
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM data_feed
|
||||||
|
WHERE ticker NOT NULL
|
||||||
|
AND entry_date NOT NULL
|
||||||
|
AND CAST((julianday('now') - julianday(entry_date)) AS INTEGER) >= 20
|
||||||
|
""")
|
||||||
|
t20_eligible = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# T+20 기록된 거래
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) FROM performance WHERE t20_milestone IS NOT NULL"
|
||||||
|
)
|
||||||
|
t20_recorded = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
progress = (t20_recorded / 30) * 100 if 30 > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "MONITORING",
|
||||||
|
"target": 30,
|
||||||
|
"recorded": t20_recorded,
|
||||||
|
"eligible": t20_eligible,
|
||||||
|
"progress_pct": round(progress, 1),
|
||||||
|
"days_to_target": self._estimate_days_to_target(t20_recorded)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "ERROR",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _estimate_days_to_target(self, current_count: int, target: int = 30) -> int:
|
||||||
|
"""목표 도달까지 예상 일수"""
|
||||||
|
if current_count >= target:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 평균 수집 속도 추정 (일일 ~0.5건)
|
||||||
|
remaining = target - current_count
|
||||||
|
avg_daily_rate = 0.5
|
||||||
|
|
||||||
|
estimated_days = int(remaining / avg_daily_rate)
|
||||||
|
return max(1, estimated_days)
|
||||||
|
|
||||||
|
def generate_report(self) -> Dict:
|
||||||
|
"""전체 리포트 생성"""
|
||||||
|
# T+20 경과 거래 수집
|
||||||
|
collection_result = self.collect_t20_performance()
|
||||||
|
|
||||||
|
# 성과 기록
|
||||||
|
if collection_result.get("trades"):
|
||||||
|
record_result = self.record_t20_in_performance(
|
||||||
|
collection_result["trades"]
|
||||||
|
)
|
||||||
|
collection_result["recorded"] = record_result
|
||||||
|
|
||||||
|
# 현황 확인
|
||||||
|
status = self.check_t20_ledger_status()
|
||||||
|
|
||||||
|
self.results["collections"] = collection_result
|
||||||
|
self.results["status"] = status
|
||||||
|
self.results["summary"] = {
|
||||||
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"wbs_8_1_target": 30,
|
||||||
|
"wbs_8_1_progress": status.get("recorded", 0),
|
||||||
|
"wbs_8_1_progress_pct": status.get("progress_pct", 0),
|
||||||
|
"next_milestone": "2026-07-15 (30건 목표)",
|
||||||
|
"data_quality": "MONITORING"
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
def print_report(self):
|
||||||
|
"""리포트 출력"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("[WBS-8.1] T+20 거래 레저 자동 수집 실행")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"시간: {self.results['timestamp']}\n")
|
||||||
|
|
||||||
|
# 수집 현황
|
||||||
|
collection = self.results.get("collections", {})
|
||||||
|
if collection.get("status") == "SUCCESS":
|
||||||
|
print(f"[수집] {collection['collected_count']}개 거래 T+20 도달")
|
||||||
|
for trade in collection.get("trades", [])[:5]:
|
||||||
|
print(f" - {trade['ticker']}: {trade['pnl_pct']:+.2f}% (T+{trade['days_elapsed']})")
|
||||||
|
else:
|
||||||
|
print(f"[수집] {collection.get('message', 'N/A')}")
|
||||||
|
|
||||||
|
# 기록 현황
|
||||||
|
if "recorded" in collection:
|
||||||
|
recorded = collection["recorded"]
|
||||||
|
print(f"\n[기록] {recorded.get('recorded_count', 0)}개 성과 기록")
|
||||||
|
|
||||||
|
# 진행률
|
||||||
|
status = self.results.get("status", {})
|
||||||
|
print(f"\n[진행률]")
|
||||||
|
print(f" 목표: {status.get('target', 30)}건")
|
||||||
|
print(f" 달성: {status.get('recorded', 0)}건 ({status.get('progress_pct', 0):.1f}%)")
|
||||||
|
print(f" 남은 기간: 약 {status.get('days_to_target', 'N/A')}일")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80 + "\n")
|
||||||
|
|
||||||
|
def save_report(self, output_file: str = None):
|
||||||
|
"""리포트 저장"""
|
||||||
|
if not output_file:
|
||||||
|
output_file = f"Temp/t20_ledger_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
|
|
||||||
|
Path(output_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.results, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"리포트 저장: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
collector = T20LedgerCollector()
|
||||||
|
collector.generate_report()
|
||||||
|
collector.print_report()
|
||||||
|
collector.save_report()
|
||||||
Reference in New Issue
Block a user