commit db81f0e4a445e9f3c732f5db7206abc4c6957594 Author: jongjae Date: Sun May 31 21:05:59 2026 +0900 Initial profile site commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..657b23d --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# 포트폴리오 사이트 - 설치 가이드 + +JSON + PHP 기반의 가벼운 포트폴리오 사이트입니다. +Synology NAS Web Station에서 동작하도록 설계되었습니다. + +## 📁 파일 구조 + +``` +portfolio/ +├── index.html # 메인 페이지 (프로젝트 목록) +├── profile.html # 프로필 페이지 +├── generate_password.php # 비밀번호 해시 생성기 (사용 후 삭제!) +├── api/ +│ ├── config.php # 설정 (비밀번호 해시 위치) +│ ├── auth.php # 로그인/로그아웃 API +│ ├── projects.php # 프로젝트 CRUD API +│ ├── profile.php # 프로필 수정 API +│ └── upload.php # 이미지 업로드 API +├── data/ +│ ├── projects.json # 프로젝트 데이터 +│ ├── profile.json # 프로필 데이터 +│ └── .htaccess # 직접 접근 차단 +└── uploads/ + └── .htaccess # PHP 실행 차단 +``` + +## 🚀 설치 (Synology NAS 기준) + +### 1. Web Station 설치 및 PHP 활성화 +- Package Center에서 **Web Station** 설치 +- **Web Station → Web Service Portal**에서 PHP 8.x 활성화 +- **Web Service**에서 사이트의 PHP 버전을 8.x로 지정 + +### 2. 파일 업로드 +- File Station에서 `/web/portfolio/` 폴더 생성 +- 위 모든 파일을 해당 폴더에 업로드 + +### 3. 권한 설정 +다음 폴더는 PHP가 쓸 수 있어야 합니다: +- `data/` (JSON 쓰기) +- `uploads/` (이미지 업로드) + +File Station에서 두 폴더의 권한을 `http` 그룹에 쓰기 가능하게 설정하세요. + +### 4. 비밀번호 설정 (⚠️ 매우 중요) + +**(A) 해시 생성** +브라우저에서 접속: +``` +http://your-nas/portfolio/generate_password.php +``` +원하는 비밀번호를 입력하면 해시가 출력됩니다. + +**(B) config.php 수정** +`api/config.php` 파일을 열어 아래 줄을 찾고: +```php +define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...'); +``` +방금 생성된 해시로 교체합니다. + +**(C) generate_password.php 삭제** +보안을 위해 반드시 삭제하세요! + +### 5. 접속 +- 메인: `http://your-nas/portfolio/` +- 관리자 로그인: 페이지 하단 우측 방패 아이콘(🛡️) 클릭 + +## 🎯 기능 + +### 일반 방문자 +- 프로젝트 목록 보기 +- 프로필 페이지에서 자기소개/스킬/타임라인/연락처 확인 + +### 관리자 (로그인 후) +- ✅ 새 프로젝트 등록 +- ✅ 기존 프로젝트 수정/삭제 +- ✅ 프로필 정보 편집 (이름, 사진, 자기소개) +- ✅ 기술 스택 추가/수정/삭제 (스킬 바) +- ✅ 타임라인 추가/수정/삭제 +- ✅ SNS 링크 관리 + +## 🔒 보안 체크리스트 + +- [ ] `generate_password.php` 삭제했는가? +- [ ] `config.php`의 ADMIN_PASSWORD_HASH를 실제 해시로 교체했는가? +- [ ] HTTPS 설정 (NAS의 Reverse Proxy 또는 Let's Encrypt) +- [ ] 비밀번호는 8자 이상, 영문/숫자/기호 조합 +- [ ] `data/`, `uploads/` 폴더의 .htaccess 파일이 동작하는지 확인 + +## 💾 백업 + +데이터는 모두 텍스트 파일이므로 Git으로 관리할 수 있습니다: +```bash +cd /web/portfolio +git init +git remote add origin http://your-nas:3000/your-name/portfolio.git +git add . +git commit -m "initial" +git push -u origin main +``` + +`data/*.json` 파일이 변경 이력으로 남아 언제든 복구 가능합니다. + +## 🐛 문제 해결 + +**"데이터를 읽을 수 없습니다" 오류** +→ `data/` 폴더의 쓰기 권한을 확인하세요. + +**로그인이 안 됨** +→ `config.php`의 ADMIN_PASSWORD_HASH가 올바르게 교체되었는지 확인하세요. + +**.htaccess가 동작하지 않음** +→ Web Station에서 Apache 사용 + `mod_rewrite`, `AllowOverride All` 설정 필요. + nginx를 쓴다면 nginx 설정으로 동일한 차단 규칙을 추가해야 합니다. + +## 🔄 향후 확장 아이디어 + +- 프로젝트별 상세 페이지 (markdown 지원) +- 태그/카테고리 필터링 +- 방문자 통계 +- 다국어 지원 (i18n) +- Gitea API 연동으로 저장소 정보 자동 가져오기 diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..5e0634a --- /dev/null +++ b/api/auth.php @@ -0,0 +1,60 @@ + '비밀번호를 입력하세요'], 400); + } + + // 무차별 대입 방지 - 간단한 딜레이 + usleep(500000); // 0.5초 + + if (password_verify($password, ADMIN_PASSWORD_HASH)) { + // 세션 고정 공격 방지 + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['login_time'] = time(); + json_response(['success' => true, 'message' => '로그인 성공']); + } else { + json_response(['error' => '비밀번호가 일치하지 않습니다'], 401); + } +} + +// ===================================================== +// 로그아웃 +// ===================================================== +if ($method === 'POST' && $action === 'logout') { + session_destroy(); + json_response(['success' => true]); +} + +// ===================================================== +// 인증 상태 확인 +// ===================================================== +if ($method === 'GET' && $action === 'check') { + $authenticated = isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true; + + // 타임아웃 체크 + if ($authenticated && isset($_SESSION['login_time'])) { + if ((time() - $_SESSION['login_time']) > SESSION_LIFETIME) { + session_destroy(); + $authenticated = false; + } + } + + json_response(['authenticated' => $authenticated]); +} + +json_response(['error' => 'Invalid action'], 400); diff --git a/api/categories.php b/api/categories.php new file mode 100644 index 0000000..a6cf8e4 --- /dev/null +++ b/api/categories.php @@ -0,0 +1,210 @@ + '카테고리 이름은 필수입니다'], 400); + } + + $categories = read_json_safe(CATEGORIES_FILE) ?? []; + + // parent_id 유효성 검증 + if ($parentId !== null) { + $parentFound = false; + foreach ($categories as $c) { + if (($c['id'] ?? 0) === $parentId) { + // 부모가 또 다른 부모를 가지면 안 됨 (2단계로 제한) + if (!empty($c['parent_id'])) { + json_response(['error' => '서브 카테고리 아래에는 카테고리를 만들 수 없습니다'], 400); + } + $parentFound = true; + break; + } + } + if (!$parentFound) { + json_response(['error' => '부모 카테고리를 찾을 수 없습니다'], 400); + } + } + + $maxId = 0; + $maxOrder = 0; + foreach ($categories as $c) { + if (($c['id'] ?? 0) > $maxId) $maxId = $c['id']; + // 같은 부모 내에서 max order + if (($c['parent_id'] ?? null) === $parentId && ($c['order'] ?? 0) > $maxOrder) { + $maxOrder = $c['order']; + } + } + + $newCategory = [ + 'id' => $maxId + 1, + 'name' => $name, + 'color' => $color, + 'parent_id' => $parentId, + 'order' => $maxOrder + 1 + ]; + + $categories[] = $newCategory; + + if (write_json_safe(CATEGORIES_FILE, $categories)) { + json_response(['success' => true, 'category' => $newCategory]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +// ===================================================== +// PUT: 카테고리 수정 +// ===================================================== +if ($method === 'PUT') { + $input = get_json_input(); + $id = intval($input['id'] ?? 0); + + if ($id <= 0) { + json_response(['error' => '유효하지 않은 ID'], 400); + } + + $categories = read_json_safe(CATEGORIES_FILE) ?? []; + $found = false; + + foreach ($categories as $key => $cat) { + if ($cat['id'] === $id) { + if (isset($input['name'])) $categories[$key]['name'] = trim($input['name']); + if (isset($input['color'])) $categories[$key]['color'] = trim($input['color']); + if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']); + // parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게 + if (array_key_exists('parent_id', $input)) { + $newParent = $input['parent_id']; + $newParent = ($newParent === '' || $newParent === null) ? null : intval($newParent); + + // 자기 자신을 부모로 설정 불가 + if ($newParent === $id) { + json_response(['error' => '자기 자신을 부모로 설정할 수 없습니다'], 400); + } + + // 자식이 있는 카테고리는 다른 카테고리의 자식이 될 수 없음 + $hasChildren = false; + foreach ($categories as $c) { + if (($c['parent_id'] ?? null) === $id) { + $hasChildren = true; + break; + } + } + if ($newParent !== null && $hasChildren) { + json_response(['error' => '하위 카테고리가 있는 카테고리는 다른 카테고리의 하위로 이동할 수 없습니다'], 400); + } + + $categories[$key]['parent_id'] = $newParent; + } + $found = true; + break; + } + } + + if (!$found) { + json_response(['error' => '카테고리를 찾을 수 없습니다'], 404); + } + + if (write_json_safe(CATEGORIES_FILE, $categories)) { + json_response(['success' => true]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +// ===================================================== +// DELETE: 카테고리 삭제 +// ===================================================== +if ($method === 'DELETE') { + $id = intval($_GET['id'] ?? 0); + + if ($id <= 0) { + json_response(['error' => '유효하지 않은 ID'], 400); + } + + $categories = read_json_safe(CATEGORIES_FILE) ?? []; + + // 자식 카테고리 ID 수집 + $allIdsToDelete = [$id]; + foreach ($categories as $c) { + if (($c['parent_id'] ?? null) === $id) { + $allIdsToDelete[] = $c['id']; + } + } + + // 해당 카테고리(들)을 사용하는 글이 있는지 확인 + $learnings = read_json_safe(DATA_DIR . '/learning.json') ?? []; + $usedCount = 0; + foreach ($learnings as $l) { + if (in_array(($l['category_id'] ?? 0), $allIdsToDelete)) $usedCount++; + } + + if ($usedCount > 0) { + $isParent = count($allIdsToDelete) > 1; + $msg = $isParent + ? "이 카테고리(또는 하위 카테고리)를 사용하는 글이 {$usedCount}개 있습니다. 먼저 해당 글들을 다른 카테고리로 옮기거나 삭제해주세요." + : "이 카테고리를 사용하는 글이 {$usedCount}개 있습니다. 먼저 해당 글들을 다른 카테고리로 옮기거나 삭제해주세요."; + json_response(['error' => $msg], 400); + } + + $filtered = array_values(array_filter($categories, function($c) use ($allIdsToDelete) { + return !in_array(($c['id'] ?? 0), $allIdsToDelete); + })); + + if (count($filtered) === count($categories)) { + json_response(['error' => '카테고리를 찾을 수 없습니다'], 404); + } + + if (write_json_safe(CATEGORIES_FILE, $filtered)) { + json_response(['success' => true, 'deleted_count' => count($allIdsToDelete)]); + } else { + json_response(['error' => '삭제에 실패했습니다'], 500); + } +} + +json_response(['error' => 'Method not allowed'], 405); diff --git a/api/config.php b/api/config.php new file mode 100644 index 0000000..2d20ffb --- /dev/null +++ b/api/config.php @@ -0,0 +1,101 @@ + 'Unauthorized']); + exit; + } + + // 세션 타임아웃 체크 + if (isset($_SESSION['login_time']) && + (time() - $_SESSION['login_time']) > SESSION_LIFETIME) { + session_destroy(); + http_response_code(401); + echo json_encode(['error' => 'Session expired']); + exit; + } +} + +// JSON 입력 받기 +function get_json_input() { + $input = file_get_contents('php://input'); + return json_decode($input, true); +} + +// 응답 헬퍼 +function json_response($data, $status = 200) { + http_response_code($status); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; +} diff --git a/api/delete_files.php b/api/delete_files.php new file mode 100644 index 0000000..5f99488 --- /dev/null +++ b/api/delete_files.php @@ -0,0 +1,100 @@ + 'POST만 허용됩니다'], 405); +} + +$input = get_json_input(); + +// 안전 가드: uploads 디렉토리 밖으로 못 나가게 +function is_safe_uploads_path($path) { + $real_uploads = realpath(UPLOADS_DIR); + $real_target = realpath($path); + if ($real_uploads === false) return false; + if ($real_target === false) return false; + return strpos($real_target, $real_uploads) === 0; +} + +$deleted = []; +$failed = []; + +// 1) URL 리스트로 개별 파일 삭제 +if (isset($input['urls']) && is_array($input['urls'])) { + foreach ($input['urls'] as $url) { + $url = trim($url); + if ($url === '') continue; + + // uploads/ 로 시작하는 상대 경로만 허용 + if (!preg_match('#^uploads/#', $url)) { + $failed[] = ['url' => $url, 'reason' => 'invalid path']; + continue; + } + + // 외부 URL은 건너뛰기 (http://, https:// 등) + if (preg_match('#^https?://#i', $url)) { + continue; + } + + // 절대 경로 만들기 + $relative = preg_replace('#^uploads/#', '', $url); + $full_path = UPLOADS_DIR . '/' . $relative; + + if (!file_exists($full_path)) { + $failed[] = ['url' => $url, 'reason' => 'not found']; + continue; + } + + if (!is_safe_uploads_path($full_path)) { + $failed[] = ['url' => $url, 'reason' => 'unsafe path']; + continue; + } + + if (@unlink($full_path)) { + $deleted[] = $url; + } else { + $failed[] = ['url' => $url, 'reason' => 'unlink failed']; + } + } +} + +// 2) 폴더 삭제 (프로젝트 폴더 통째로) +if (isset($input['folder'])) { + $folder = trim($input['folder']); + if ($folder !== '' && !preg_match('#[/\\\\]#', $folder)) { + $folder_path = UPLOADS_DIR . '/' . $folder; + if (is_dir($folder_path) && is_safe_uploads_path($folder_path)) { + // 폴더 안의 파일들 삭제 + $files = glob($folder_path . '/*'); + foreach ($files as $f) { + if (is_file($f)) { + if (@unlink($f)) { + $deleted[] = $folder . '/' . basename($f); + } else { + $failed[] = ['url' => $folder . '/' . basename($f), 'reason' => 'unlink failed']; + } + } + } + // 빈 폴더는 삭제 + @rmdir($folder_path); + } + } +} + +json_response([ + 'success' => true, + 'deleted_count' => count($deleted), + 'failed_count' => count($failed), + 'deleted' => $deleted, + 'failed' => $failed +]); diff --git a/api/error_config.php b/api/error_config.php new file mode 100644 index 0000000..68dae6c --- /dev/null +++ b/api/error_config.php @@ -0,0 +1,58 @@ + 10 * 1024 * 1024) { + rename(LOG_FILE, LOG_FILE . '.' . date('Ymd')); +} + +// ── 미처리 예외 핸들러 ──────────────────────────────────── +set_exception_handler(function (Throwable $e) { + app_log('ERROR', $e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => '서버 오류가 발생했습니다.']); + exit; +}); diff --git a/api/learning.php b/api/learning.php new file mode 100644 index 0000000..16c52f3 --- /dev/null +++ b/api/learning.php @@ -0,0 +1,249 @@ + '글을 찾을 수 없습니다'], 404); + } + + // is_learning 필드 마이그레이션 (제거) + foreach ($learnings as &$l) { + unset($l['is_learning']); + } + unset($l); + + // 카테고리 필터: ?category_id=N (단일 또는 카테고리 + 모든 자식) + if (isset($_GET['category_id'])) { + $catId = intval($_GET['category_id']); + $categories = read_json_safe(DATA_DIR . '/categories.json') ?? []; + + // 자식 카테고리 ID도 포함 + $catIds = [$catId]; + foreach ($categories as $c) { + if (($c['parent_id'] ?? null) === $catId) { + $catIds[] = $c['id']; + } + } + + $learnings = array_values(array_filter($learnings, function($l) use ($catIds) { + return in_array(($l['category_id'] ?? 0), $catIds); + })); + } + + // 최신순 정렬 + usort($learnings, function($a, $b) { + $aDate = $a['created_at'] ?? ''; + $bDate = $b['created_at'] ?? ''; + if ($aDate !== $bDate) return strcmp($bDate, $aDate); + return ($b['id'] ?? 0) - ($a['id'] ?? 0); + }); + + json_response($learnings); +} + +require_auth(); + +// ===================================================== +// POST: 새 학습 일지 작성 +// ===================================================== +if ($method === 'POST') { + $input = get_json_input(); + + $title = trim($input['title'] ?? ''); + $content = trim($input['content'] ?? ''); + $categoryId = intval($input['category_id'] ?? 0); + + if (empty($title) || empty($content)) { + json_response(['error' => '제목과 내용은 필수입니다'], 400); + } + + if ($categoryId <= 0) { + json_response(['error' => '카테고리를 선택해주세요'], 400); + } + + // 카테고리가 서브 카테고리(parent_id 있음)인지 확인 - 글은 서브 카테고리에만 속해야 함 + $categories = read_json_safe(DATA_DIR . '/categories.json') ?? []; + $foundCat = null; + foreach ($categories as $c) { + if (($c['id'] ?? 0) === $categoryId) { + $foundCat = $c; + break; + } + } + if (!$foundCat) { + json_response(['error' => '카테고리를 찾을 수 없습니다'], 400); + } + if (empty($foundCat['parent_id'])) { + json_response(['error' => '글은 서브 카테고리에만 작성할 수 있습니다 (예: Unity > 학습)'], 400); + } + + // 태그 처리 + $tags = []; + if (isset($input['tags'])) { + if (is_array($input['tags'])) { + $tags = array_values(array_filter( + array_map('trim', $input['tags']), + fn($v) => $v !== '' + )); + } elseif (is_string($input['tags'])) { + $tags = array_values(array_filter( + array_map('trim', explode(',', $input['tags'])), + fn($v) => $v !== '' + )); + } + } + + $learnings = read_json_safe(LEARNING_FILE) ?? []; + + $maxId = 0; + foreach ($learnings as $l) { + if (($l['id'] ?? 0) > $maxId) $maxId = $l['id']; + } + + $createdAt = trim($input['created_at'] ?? ''); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdAt)) { + $createdAt = date('Y-m-d'); + } + + $newLearning = [ + 'id' => $maxId + 1, + 'title' => $title, + 'category_id' => $categoryId, + 'tags' => $tags, + 'content' => $content, + 'created_at' => $createdAt, + 'updated_at' => date('Y-m-d') + ]; + + $learnings[] = $newLearning; + + if (write_json_safe(LEARNING_FILE, $learnings)) { + json_response(['success' => true, 'learning' => $newLearning]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +// ===================================================== +// PUT: 학습 일지 수정 +// ===================================================== +if ($method === 'PUT') { + $input = get_json_input(); + $id = intval($input['id'] ?? 0); + + if ($id <= 0) { + json_response(['error' => '유효하지 않은 ID'], 400); + } + + $learnings = read_json_safe(LEARNING_FILE) ?? []; + $found = false; + + foreach ($learnings as $key => $l) { + if ($l['id'] === $id) { + // is_learning 필드는 제거 + unset($learnings[$key]['is_learning']); + + if (isset($input['title'])) { + $title = trim($input['title']); + if ($title === '') json_response(['error' => '제목은 비울 수 없습니다'], 400); + $learnings[$key]['title'] = $title; + } + if (isset($input['content'])) { + $content = trim($input['content']); + if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400); + $learnings[$key]['content'] = $content; + } + if (isset($input['category_id'])) { + $catId = intval($input['category_id']); + if ($catId > 0) { + // 서브 카테고리 검증 + $categories = read_json_safe(DATA_DIR . '/categories.json') ?? []; + $foundCat = null; + foreach ($categories as $c) { + if (($c['id'] ?? 0) === $catId) { $foundCat = $c; break; } + } + if ($foundCat && empty($foundCat['parent_id'])) { + json_response(['error' => '글은 서브 카테고리에만 속할 수 있습니다'], 400); + } + $learnings[$key]['category_id'] = $catId; + } + } + if (isset($input['tags'])) { + $tags = is_array($input['tags']) + ? array_values(array_filter(array_map('trim', $input['tags']), fn($v) => $v !== '')) + : (is_string($input['tags']) + ? array_values(array_filter(array_map('trim', explode(',', $input['tags'])), fn($v) => $v !== '')) + : []); + $learnings[$key]['tags'] = $tags; + } + if (isset($input['created_at'])) { + $createdAt = trim($input['created_at']); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdAt)) { + $learnings[$key]['created_at'] = $createdAt; + } + } + $learnings[$key]['updated_at'] = date('Y-m-d'); + $found = true; + break; + } + } + + if (!$found) { + json_response(['error' => '글을 찾을 수 없습니다'], 404); + } + + if (write_json_safe(LEARNING_FILE, $learnings)) { + json_response(['success' => true]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +// ===================================================== +// DELETE: 학습 일지 삭제 +// ===================================================== +if ($method === 'DELETE') { + $id = intval($_GET['id'] ?? 0); + + if ($id <= 0) { + json_response(['error' => '유효하지 않은 ID'], 400); + } + + $learnings = read_json_safe(LEARNING_FILE) ?? []; + $filtered = array_values(array_filter($learnings, function($l) use ($id) { + return ($l['id'] ?? 0) !== $id; + })); + + if (count($filtered) === count($learnings)) { + json_response(['error' => '글을 찾을 수 없습니다'], 404); + } + + if (write_json_safe(LEARNING_FILE, $filtered)) { + json_response(['success' => true]); + } else { + json_response(['error' => '삭제에 실패했습니다'], 500); + } +} + +json_response(['error' => 'Method not allowed'], 405); diff --git a/api/logs/app.log b/api/logs/app.log new file mode 100644 index 0000000..e69de29 diff --git a/api/profile.php b/api/profile.php new file mode 100644 index 0000000..beb0ee5 --- /dev/null +++ b/api/profile.php @@ -0,0 +1,114 @@ + '기술', + 'items' => array_map(fn($s) => $s['name'] ?? '', $profile['skills']) + ] + ]; + $profile['skills'] = $migrated; + } + + return $profile; +} + +// ===================================================== +// GET: 프로필 조회 (인증 불필요) +// ===================================================== +if ($method === 'GET') { + $profile = read_json_safe(PROFILE_FILE); + if ($profile === null) { + json_response(['error' => '프로필을 읽을 수 없습니다'], 500); + } + $profile = migrate_skills($profile); + json_response($profile); +} + +// 이하 수정은 인증 필요 +require_auth(); + +// ===================================================== +// PUT: 프로필 전체 업데이트 +// ===================================================== +if ($method === 'PUT') { + $input = get_json_input(); + + if (!is_array($input)) { + json_response(['error' => '잘못된 데이터 형식'], 400); + } + + $current = read_json_safe(PROFILE_FILE) ?? []; + + $updated = array_merge($current, [ + 'name' => trim($input['name'] ?? $current['name'] ?? ''), + 'title' => trim($input['title'] ?? $current['title'] ?? ''), + 'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''), + 'avatar' => trim($input['avatar'] ?? $current['avatar'] ?? ''), + 'bio' => trim($input['bio'] ?? $current['bio'] ?? ''), + 'location' => trim($input['location'] ?? $current['location'] ?? ''), + 'email' => trim($input['email'] ?? $current['email'] ?? ''), + ]); + + // 카테고리화된 스킬 처리 + if (isset($input['skills']) && is_array($input['skills'])) { + $cleanSkills = []; + foreach ($input['skills'] as $cat) { + if (!isset($cat['category']) || !isset($cat['items'])) continue; + $catName = trim($cat['category']); + if ($catName === '') continue; + + $items = is_array($cat['items']) + ? array_values(array_filter(array_map('trim', $cat['items']), fn($v) => $v !== '')) + : []; + + if (count($items) > 0) { + $cleanSkills[] = [ + 'category' => $catName, + 'items' => $items + ]; + } + } + $updated['skills'] = $cleanSkills; + } + + if (isset($input['timeline']) && is_array($input['timeline'])) { + $updated['timeline'] = array_values(array_filter($input['timeline'], function($t) { + return !empty($t['title']); + })); + } + + if (isset($input['social']) && is_array($input['social'])) { + $updated['social'] = array_merge($current['social'] ?? [], $input['social']); + } + + if (write_json_safe(PROFILE_FILE, $updated)) { + json_response(['success' => true, 'profile' => $updated]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +json_response(['error' => 'Method not allowed'], 405); diff --git a/api/projects.php b/api/projects.php new file mode 100644 index 0000000..0e457e3 --- /dev/null +++ b/api/projects.php @@ -0,0 +1,221 @@ + $v !== '' + )); + return $images; + } + if (isset($input['image']) && trim($input['image']) !== '') { + return [trim($input['image'])]; + } + return []; +} + +function normalize_stack($input) { + if (isset($input['stack']) && is_array($input['stack'])) { + return array_values(array_filter( + array_map('trim', $input['stack']), + fn($v) => $v !== '' + )); + } + if (isset($input['stack']) && is_string($input['stack'])) { + return array_values(array_filter( + array_map('trim', explode(',', $input['stack'])), + fn($v) => $v !== '' + )); + } + return []; +} + +// 기존 프로젝트 데이터를 새 형식으로 마이그레이션 +function migrate_project_data($project) { + if (!isset($project['images']) || !is_array($project['images'])) { + $project['images'] = []; + if (!empty($project['image'])) { + $project['images'][] = $project['image']; + } + } + $project['image'] = $project['images'][0] ?? ''; + + // 새 필드들 기본값 + if (!isset($project['stack']) || !is_array($project['stack'])) $project['stack'] = []; + if (!isset($project['period_start'])) $project['period_start'] = ''; + if (!isset($project['period_end'])) $project['period_end'] = ''; + if (!isset($project['video_url'])) $project['video_url'] = ''; + + // 기존 demo_url 필드는 제거 (마이그레이션) + unset($project['demo_url']); + + return $project; +} + +// ===================================================== +// GET: 프로젝트 목록 조회 +// ===================================================== +if ($method === 'GET') { + $projects = read_json_safe(PROJECTS_FILE); + if ($projects === null) { + json_response(['error' => '데이터를 읽을 수 없습니다'], 500); + } + + $projects = array_map('migrate_project_data', $projects); + + usort($projects, function($a, $b) { + return ($b['id'] ?? 0) - ($a['id'] ?? 0); + }); + + json_response($projects); +} + +require_auth(); + +// ===================================================== +// POST: 새 프로젝트 추가 +// ===================================================== +if ($method === 'POST') { + $input = get_json_input(); + + $title = trim($input['title'] ?? ''); + $label = trim($input['label'] ?? ''); + $description = trim($input['description'] ?? ''); + + if (empty($title) || empty($label) || empty($description)) { + json_response(['error' => '제목, 라벨, 설명은 필수입니다'], 400); + } + + $projects = read_json_safe(PROJECTS_FILE) ?? []; + + $maxId = 0; + foreach ($projects as $p) { + if (($p['id'] ?? 0) > $maxId) $maxId = $p['id']; + } + + $images = normalize_images($input); + $stack = normalize_stack($input); + + $newProject = [ + 'id' => $maxId + 1, + 'title' => $title, + 'label' => $label, + 'description' => $description, + 'icon' => trim($input['icon'] ?? 'fa-solid fa-code'), + 'images' => $images, + 'image' => $images[0] ?? '', + 'link' => trim($input['link'] ?? ''), + 'stack' => $stack, + 'period_start' => trim($input['period_start'] ?? ''), + 'period_end' => trim($input['period_end'] ?? ''), + 'video_url' => trim($input['video_url'] ?? ''), + 'created_at' => date('Y-m-d') + ]; + + $projects[] = $newProject; + + if (write_json_safe(PROJECTS_FILE, $projects)) { + json_response(['success' => true, 'project' => $newProject]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +// ===================================================== +// PUT: 프로젝트 수정 +// ===================================================== +if ($method === 'PUT') { + $input = get_json_input(); + $id = intval($input['id'] ?? 0); + + if ($id <= 0) { + json_response(['error' => '유효하지 않은 ID'], 400); + } + + $projects = read_json_safe(PROJECTS_FILE) ?? []; + $found = false; + + foreach ($projects as $key => $project) { + if ($project['id'] === $id) { + $projects[$key]['title'] = trim($input['title'] ?? $project['title']); + $projects[$key]['label'] = trim($input['label'] ?? $project['label']); + $projects[$key]['description'] = trim($input['description'] ?? $project['description']); + $projects[$key]['icon'] = trim($input['icon'] ?? $project['icon']); + $projects[$key]['link'] = trim($input['link'] ?? $project['link']); + + if (isset($input['images']) || isset($input['image'])) { + $images = normalize_images($input); + $projects[$key]['images'] = $images; + $projects[$key]['image'] = $images[0] ?? ''; + } + + if (isset($input['stack'])) { + $projects[$key]['stack'] = normalize_stack($input); + } + + if (isset($input['period_start'])) { + $projects[$key]['period_start'] = trim($input['period_start']); + } + if (isset($input['period_end'])) { + $projects[$key]['period_end'] = trim($input['period_end']); + } + if (isset($input['video_url'])) { + $projects[$key]['video_url'] = trim($input['video_url']); + } + + // 기존 demo_url 필드 제거 + unset($projects[$key]['demo_url']); + + $projects[$key]['updated_at'] = date('Y-m-d'); + $found = true; + break; + } + } + + if (!$found) { + json_response(['error' => '프로젝트를 찾을 수 없습니다'], 404); + } + + if (write_json_safe(PROJECTS_FILE, $projects)) { + json_response(['success' => true]); + } else { + json_response(['error' => '저장에 실패했습니다'], 500); + } +} + +// ===================================================== +// DELETE: 프로젝트 삭제 +// ===================================================== +if ($method === 'DELETE') { + $id = intval($_GET['id'] ?? 0); + + if ($id <= 0) { + json_response(['error' => '유효하지 않은 ID'], 400); + } + + $projects = read_json_safe(PROJECTS_FILE) ?? []; + $filtered = array_values(array_filter($projects, function($p) use ($id) { + return ($p['id'] ?? 0) !== $id; + })); + + if (count($filtered) === count($projects)) { + json_response(['error' => '프로젝트를 찾을 수 없습니다'], 404); + } + + if (write_json_safe(PROJECTS_FILE, $filtered)) { + json_response(['success' => true]); + } else { + json_response(['error' => '삭제에 실패했습니다'], 500); + } +} + +json_response(['error' => 'Method not allowed'], 405); diff --git a/api/upload.php b/api/upload.php new file mode 100644 index 0000000..2bd777b --- /dev/null +++ b/api/upload.php @@ -0,0 +1,229 @@ + 'POST만 허용됩니다'], 405); +} + +if (!isset($_FILES['file']) && !isset($_FILES['image'])) { + json_response(['error' => '파일 업로드 실패'], 400); +} + +// 'file' 또는 'image' 둘 다 받기 (호환성) +$file = $_FILES['file'] ?? $_FILES['image']; +if ($file['error'] !== UPLOAD_ERR_OK) { + json_response(['error' => '파일 업로드 실패 (코드: ' . $file['error'] . ')'], 400); +} + +// ===================================================== +// 파일 사이즈 조정 +// 이미지 5MB / 동영상 100MB / 일반 파일 50MB +// 단위: byte (1MB = 1024 * 1024) +// 제한 없음으로 하려면 PHP_INT_MAX로 설정 +// 단, Synology PHP의 upload_max_filesize / post_max_size 설정도 함께 조정 필요 +// ===================================================== +$MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 이미지: 5 MB +$MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 동영상: 100 MB +$MAX_FILE_SIZE = 50 * 1024 * 1024; // 일반 파일: 50 MB +// ===================================================== + +// 허용 MIME 타입과 확장자 +$image_types = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp' +]; +$video_types = [ + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + 'video/ogg' => 'ogv', + 'video/quicktime' => 'mov' +]; +// 일반 파일 허용 목록 (MIME → 확장자) +$file_types = [ + 'application/pdf' => 'pdf', + 'application/zip' => 'zip', + 'application/x-zip-compressed' => 'zip', + 'application/x-7z-compressed' => '7z', + 'application/x-rar-compressed' => 'rar', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'text/plain' => 'txt', + 'text/markdown' => 'md', + 'text/csv' => 'csv', + 'application/json' => 'json', +]; + +$finfo = new finfo(FILEINFO_MIME_TYPE); +$mime = $finfo->file($file['tmp_name']); + +$is_image = isset($image_types[$mime]); +$is_video = isset($video_types[$mime]); +$is_file = !$is_image && !$is_video && isset($file_types[$mime]); + +if (!$is_image && !$is_video && !$is_file) { + json_response(['error' => '허용되지 않는 파일 형식입니다. (이미지/동영상/PDF/ZIP/문서 등)'], 400); +} + +// 파일 크기 검증 +if ($is_image && $file['size'] > $MAX_IMAGE_SIZE) { + $mb = round($MAX_IMAGE_SIZE / 1024 / 1024, 1); + json_response(['error' => "이미지 파일은 {$mb}MB 이하여야 합니다"], 400); +} +if ($is_video && $file['size'] > $MAX_VIDEO_SIZE) { + $mb = round($MAX_VIDEO_SIZE / 1024 / 1024, 1); + json_response(['error' => "동영상 파일은 {$mb}MB 이하여야 합니다"], 400); +} +if ($is_file && $file['size'] > $MAX_FILE_SIZE) { + $mb = round($MAX_FILE_SIZE / 1024 / 1024, 1); + json_response(['error' => "파일은 {$mb}MB 이하여야 합니다"], 400); +} + +if ($is_image) { + $ext = $image_types[$mime]; + $kind = 'image'; + $prefix = 'img_'; +} elseif ($is_video) { + $ext = $video_types[$mime]; + $kind = 'video'; + $prefix = 'vid_'; +} else { + $ext = $file_types[$mime]; + $kind = 'file'; + $prefix = 'file_'; + // 일반 파일은 원본 파일명 일부를 살림 (정리 후) + $origName = pathinfo($file['name'], PATHINFO_FILENAME); + $origName = preg_replace('/[^\p{L}\p{N}\-_]/u', '_', $origName); + $origName = mb_substr($origName, 0, 30, 'UTF-8'); + $prefix = $origName . '_'; +} + +// ===================================================== +// 폴더명 생성 +// ===================================================== +function sanitize_folder_name($title) { + $title = trim($title); + if ($title === '') return ''; + + // 위험한 문자 제거 + $title = preg_replace('/[\/\\\\\:\*\?"<>\|]/u', '', $title); + $title = preg_replace('/\.\.+/u', '', $title); + + // 공백을 하이픈으로 + $title = preg_replace('/\s+/u', '-', $title); + + // 영문/숫자/한글/하이픈/언더스코어 외 모두 제거 + $title = preg_replace('/[^\p{L}\p{N}\-_]/u', '', $title); + + $title = trim($title, '-_'); + + if (mb_strlen($title, 'UTF-8') > 50) { + $title = mb_substr($title, 0, 50, 'UTF-8'); + } + + return $title; +} + +$projectTitle = isset($_POST['project_title']) ? trim($_POST['project_title']) : ''; +$folderName = sanitize_folder_name($projectTitle); + +if ($folderName === '') { + $folderName = '_untitled'; +} + +$uploadDir = UPLOADS_DIR . '/' . $folderName; + +if (!is_dir(UPLOADS_DIR)) { + @mkdir(UPLOADS_DIR, 0775, true); +} +if (!is_dir($uploadDir)) { + if (!@mkdir($uploadDir, 0775, true)) { + json_response(['error' => '폴더 생성 실패. uploads 폴더에 쓰기 권한을 확인해주세요.'], 500); + } +} + +// 안전한 파일명 생성 +$filename = uniqid($prefix, true) . '.' . $ext; +$target_path = $uploadDir . '/' . $filename; + +// ===================================================== +// 이미지: WebP 변환 시도 (GD 지원 시) +// 동영상/일반 파일은 원본 그대로 저장 +// ===================================================== +function try_save_as_webp(string $tmp, string $dest_dir, string $base_name, string $mime, int $quality = 82): string|false { + if (!function_exists('imagewebp')) return false; + + $src = match ($mime) { + 'image/jpeg' => @imagecreatefromjpeg($tmp), + 'image/png' => @imagecreatefrompng($tmp), + 'image/gif' => @imagecreatefromgif($tmp), + 'image/webp' => @imagecreatefromwebp($tmp), + default => false, + }; + if (!$src) return false; + + // PNG 투명도 보존 + if ($mime === 'image/png') { + imagepalettetotruecolor($src); + imagealphablending($src, true); + imagesavealpha($src, true); + } + + // 가로 1200px 초과 시 리사이즈 + $ow = imagesx($src); + $oh = imagesy($src); + if ($ow > 1200) { + $nw = 1200; + $nh = (int)round($oh * (1200 / $ow)); + $dst = imagecreatetruecolor($nw, $nh); + imagealphablending($dst, false); + imagesavealpha($dst, true); + $t = imagecolorallocatealpha($dst, 0, 0, 0, 127); + imagefilledrectangle($dst, 0, 0, $nw, $nh, $t); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh); + imagedestroy($src); + $src = $dst; + } + + $dest_path = $dest_dir . '/' . $base_name . '.webp'; + $ok = imagewebp($src, $dest_path, $quality); + imagedestroy($src); + return $ok ? $dest_path : false; +} + +if ($is_image) { + $base_name = pathinfo($filename, PATHINFO_FILENAME); + $webp_path = try_save_as_webp($file['tmp_name'], $uploadDir, $base_name, $mime); + if ($webp_path !== false) { + $filename = $base_name . '.webp'; + $target_path = $webp_path; + $saved = true; + } else { + $saved = move_uploaded_file($file['tmp_name'], $target_path); + } +} else { + $saved = move_uploaded_file($file['tmp_name'], $target_path); +} + +if ($saved) { + json_response([ + 'success' => true, + 'url' => 'uploads/' . $folderName . '/' . $filename, + 'filename' => $filename, + 'original_name' => $file['name'], + 'folder' => $folderName, + 'kind' => $kind + ]); +} else { + $err = error_get_last(); + json_response(['error' => '파일 저장 실패', 'detail' => $err, 'target' => $target_path, 'tmp' => $file['tmp_name'], 'tmp_exists' => file_exists($file['tmp_name'])], 500); +} diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..4f014b7 --- /dev/null +++ b/backup.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# ============================================================ +# 포트폴리오 NAS 백업 스크립트 +# 위치: /web/my_profile/backup.sh +# 실행: bash backup.sh 또는 crontab으로 자동화 +# Synology Task Scheduler에 등록 권장 (매일 새벽 3시) +# ============================================================ + +SITE_DIR="/web/my_profile" +BACKUP_BASE="/volume1/backup/portfolio" # 백업 저장 경로 (필요 시 수정) +DATE=$(date +"%Y%m%d_%H%M%S") +BACKUP_DIR="$BACKUP_BASE/$DATE" +KEEP_DAYS=30 # 보관 기간 (일) + +# ── 백업 디렉토리 생성 ────────────────────────────────────── +mkdir -p "$BACKUP_DIR" + +# ── data/*.json 백업 ─────────────────────────────────────── +if [ -d "$SITE_DIR/data" ]; then + cp -r "$SITE_DIR/data" "$BACKUP_DIR/data" + echo "[$(date)] data/ 백업 완료" +else + echo "[$(date)] WARNING: data/ 디렉토리 없음" +fi + +# ── uploads/ 백업 (심볼릭 링크 포함) ────────────────────── +if [ -d "$SITE_DIR/uploads" ]; then + cp -rL "$SITE_DIR/uploads" "$BACKUP_DIR/uploads" + echo "[$(date)] uploads/ 백업 완료" +else + echo "[$(date)] WARNING: uploads/ 디렉토리 없음" +fi + +# ── HTML/PHP 소스 백업 ──────────────────────────────────── +cp "$SITE_DIR"/*.html "$BACKUP_DIR/" 2>/dev/null +cp "$SITE_DIR"/*.css "$BACKUP_DIR/" 2>/dev/null +cp -r "$SITE_DIR/api" "$BACKUP_DIR/api" 2>/dev/null +echo "[$(date)] 소스 파일 백업 완료" + +# ── 압축 ────────────────────────────────────────────────── +tar -czf "$BACKUP_BASE/portfolio_$DATE.tar.gz" -C "$BACKUP_BASE" "$DATE" +rm -rf "$BACKUP_DIR" +echo "[$(date)] 압축 완료: portfolio_$DATE.tar.gz" + +# ── 오래된 백업 삭제 ────────────────────────────────────── +find "$BACKUP_BASE" -name "portfolio_*.tar.gz" -mtime +$KEEP_DAYS -delete +echo "[$(date)] ${KEEP_DAYS}일 이상 된 백업 삭제 완료" + +# ── 용량 확인 ───────────────────────────────────────────── +TOTAL=$(du -sh "$BACKUP_BASE" 2>/dev/null | cut -f1) +COUNT=$(ls "$BACKUP_BASE"/portfolio_*.tar.gz 2>/dev/null | wc -l) +echo "[$(date)] 백업 완료 | 총 ${COUNT}개 | 용량: ${TOTAL}" diff --git a/backup_20260517/index.html b/backup_20260517/index.html new file mode 100644 index 0000000..3e0216b --- /dev/null +++ b/backup_20260517/index.html @@ -0,0 +1,2126 @@ + + + + + +이종재 | Game & XR Developer + + + + + + + + +
+

이종재 Portfolio

+

Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.
Gitea 서버를 통해 실제 소스 코드를 확인하실 수 있습니다.

+
+ +
+
+

DEVELOPMENT LOG

+
+ +
+
+ +
+ + + + + + + + + + + + diff --git a/backup_20260517/learning.html b/backup_20260517/learning.html new file mode 100644 index 0000000..1acaa3b --- /dev/null +++ b/backup_20260517/learning.html @@ -0,0 +1,4184 @@ + + + + + +Learning Log | 이종재 + + + + + + + + + + + + + + + +
+ + + + +
+
+
+

전체

+ 0 posts +
+
+ + +
+
+ +
+ + + +
+ +
+ +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backup_20260517/profile.html b/backup_20260517/profile.html new file mode 100644 index 0000000..7c40a7f --- /dev/null +++ b/backup_20260517/profile.html @@ -0,0 +1,1266 @@ + + + + + +Profile | 이종재 + + + + + + + + +
+
+
+
+ +
+
+
+ // PROFILE +

Loading...

+
+

+
+ + +
+
+
+ +
+
+

ABOUT

+
+ +
+

+
+ +
+
+

TECH STACK

+
+
+
+
+ +
+
+

JOURNEY

+
+
+
+
+ +
+
+

CONTACT

+
+
+
+
+ + + + + + + +
+ 복사되었습니다 +
+ + + + + + + diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..184d866 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,12 @@ +# data/ 폴더 직접 접근 차단 +# 브라우저에서 /data/projects.json 같은 직접 접근을 막습니다. +# 데이터는 반드시 PHP API를 통해서만 조회되어야 합니다. + + + Require all denied + + + + Order deny,allow + Deny from all + diff --git a/data/categories.json b/data/categories.json new file mode 100644 index 0000000..e625006 --- /dev/null +++ b/data/categories.json @@ -0,0 +1,163 @@ +[ + { + "id": 1, + "name": "Unity", + "color": "#00f2ff", + "parent_id": null, + "order": 1 + }, + { + "id": 2, + "name": "학습", + "color": "#ffd166", + "parent_id": 1, + "order": 1 + }, + { + "id": 3, + "name": "개발일지", + "color": "#06ffa5", + "parent_id": 1, + "order": 2 + }, + { + "id": 4, + "name": "Shader", + "color": "#7000ff", + "parent_id": null, + "order": 2 + }, + { + "id": 5, + "name": "학습", + "color": "#ffd166", + "parent_id": 4, + "order": 1 + }, + { + "id": 6, + "name": "개발일지", + "color": "#06ffa5", + "parent_id": 4, + "order": 2 + }, + { + "id": 7, + "name": "C++", + "color": "#ff6b9d", + "parent_id": null, + "order": 3 + }, + { + "id": 8, + "name": "학습", + "color": "#ffd166", + "parent_id": 7, + "order": 1 + }, + { + "id": 9, + "name": "개발일지", + "color": "#06ffa5", + "parent_id": 7, + "order": 2 + }, + { + "id": 10, + "name": "XR / VR", + "color": "#ffd166", + "parent_id": null, + "order": 4 + }, + { + "id": 11, + "name": "학습", + "color": "#ffd166", + "parent_id": 10, + "order": 1 + }, + { + "id": 12, + "name": "개발일지", + "color": "#06ffa5", + "parent_id": 10, + "order": 2 + }, + { + "id": 13, + "name": "Server / DevOps", + "color": "#06ffa5", + "parent_id": null, + "order": 5 + }, + { + "id": 14, + "name": "학습", + "color": "#ffd166", + "parent_id": 13, + "order": 1 + }, + { + "id": 15, + "name": "개발일지", + "color": "#06ffa5", + "parent_id": 13, + "order": 2 + }, + { + "id": 16, + "name": "3dsMAx", + "color": "#a6ff00", + "parent_id": null, + "order": 6 + }, + { + "id": 17, + "name": "학습", + "color": "#ffd166", + "parent_id": 16, + "order": 1 + }, + { + "id": 18, + "name": "메모", + "color": "#ff007b", + "parent_id": null, + "order": 7 + }, + { + "id": 19, + "name": "메모", + "color": "#ffd166", + "parent_id": 18, + "order": 1 + }, + { + "id": 20, + "name": "미래내일일경험", + "color": "#fff700", + "parent_id": null, + "order": 8 + }, + { + "id": 21, + "name": "일지", + "color": "#f50000", + "parent_id": 20, + "order": 1 + }, + { + "id": 22, + "name": "AI", + "color": "#ffbb00", + "parent_id": null, + "order": 9 + }, + { + "id": 23, + "name": "바이브코딩", + "color": "#ffd166", + "parent_id": 22, + "order": 1 + } +] \ No newline at end of file diff --git a/data/learning.json b/data/learning.json new file mode 100644 index 0000000..131943b --- /dev/null +++ b/data/learning.json @@ -0,0 +1,170 @@ +[ + { + "id": 3, + "title": "Synology NAS에 Gitea + CI/CD 환경 구축", + "category_id": 15, + "tags": [ + "Gitea", + "Docker", + "NAS", + "CI/CD" + ], + "content": "## 목표\n\n외부 서비스 의존 없이 개인 개발 인프라 구축.\n\n## 진행 과정\n\n1. Synology Container Manager로 Gitea 컨테이너 띄우기\n2. Reverse Proxy로 외부 도메인 연결\n3. SSH 키 등록 및 첫 푸시 테스트\n\n## 배운 것\n\n- Docker volume 관리의 중요성 (데이터 유실 방지)\n- HTTPS 인증서 자동 갱신 설정\n- 백업 전략 수립", + "created_at": "2026-03-10", + "updated_at": "2026-03-12" + }, + { + "id": 4, + "title": "BeatSaber 모작하기 -1", + "category_id": 12, + "tags": [ + "Unity", + "VR", + "Game" + ], + "content": "## 1. 프로젝트 개선 목표 설정\n\n수업 시간에 배운 비트세이버 프로젝트를 기반으로, 더 높은 완성도를 위해 다음과 같은 개선 목표를 설정한다.\n\n[ 현 재 ]\n\n- 단순 파괴: 스포너에서 생성된 큐브의 {color:#ff4757}색상과 방향이 맞으면 그 자리에서 바로 Destroy 처리{/color}됨.\n\n- 랜덤 스폰: 큐브가 나오는 {color:#ff4757}시점과 위치가 단순히 시간별 랜덤으로 설정{/color}되어 있음.\n\n- 기본 리소스: 검의 모델링이 단순한 막대기 형태로 되어 있어 시각적 몰입감이 떨어짐.\n\n[ 목 표 ]\n\n- 슬라이싱 시스템: 큐브가 사라지는 대신, {color:#54a0ff}검의 궤적에 따라 실제로 잘리는 효과{/color}로 수정.\n\n- 패턴 커스터마이징: 랜덤 스폰이 아닌, {color:#54a0ff}개발자가 직접 의도한 패턴대로 큐브가 나오도록{/color} 수정.\n\n- 에셋 고도화: 무료 에셋을 활용하여 검의 외형을 화려하게 변경.\n---\n\n## 2. 세부 실행 방안\n### 검 에셋 변경 및 적용\n구글링 및 유니티 에셋 스토어를 통해 무료 검 에셋을 확보하여 기존 막대 모델링을 대체 적용.\n\n### 슬라이싱 시스템 구현 (EzySlice 도입)\n기존의 단순 파괴 로직에서 벗어나 실제 메시(Mesh)를 절단하기 위해 외부 라이브러리를 활용한다.\n\n라이브러리 준비\n\nDavidArayan의 EzySlice GitHub 저장소에서 라이브러리 다운로드.\n\n> 인용문을 작성하세요\nhttps://github.com/DavidArayan/ezy-slice\n\n프로젝트 내 Assets/Plugins 폴더를 생성하여 관련 파일 임포트.\n\n코드 분석 및 변경\n\n- 기존 방식: {color:#ff6b9d}Raycast를 쏘아 충돌한 물체의 각도만 체크{/color}하고 Destroy 호출.\n\n- 변경 방식: {color:#00d2d3}Linecast를 사용하여 검의 날 전체 범위를 체크{/color}하고, EzySlice 함수를 호출하여 {color:#00d2d3}잘린 단면 생성 및 물리 효과 부여{/color}.\n---\n\n## 3. 기존 코드 분석 (Before)\n현재 적용되어 있는 Raycast 기반의 단순 파괴 로직이다.\n```\nusing System.ComponentModel.Design.Serialization;\nusing UnityEngine;\n\npublic class Saber : MonoBehaviour\n{\n public LayerMask layer;\n Vector3 prevPos;\n\n void Update()\n {\n RaycastHit hit;\n \n // Raycast를 사용하여 충돌 감지 (위치, 방향, 저장변수, 거리, 레이어)\n if(Physics.Raycast(transform.position, transform.forward, out hit, 1, layer))\n {\n // 현재위치 - 이전 위치 = 이동방향 벡터 계산\n Vector3 v1 = transform.position - prevPos; \n \n // 이동방향(v1)과 큐브의 위쪽 방향(up) 사이의 각도가 130도 이상이면 파괴\n if(Vector3.Angle(v1, hit.transform.up) > 130)\n {\n Destroy(hit.transform.gameObject);\n }\n }\n\n // 다음 프레임 계산을 위해 현재 위치 저장\n prevPos = transform.position;\n }\n}\n```\n\n---\n\n## 4. 주요 변경 및 개선 사항 (Key Changes)\n기존의 단순한 로직을 물리 기반의 정밀한 시스템으로 리팩토링하며 다음과 같은 큰 변화를 주었습니다.\n\n### 1) 충돌 감지 방식의 정밀도 향상 (Raycast → Linecast)\n- 기존: 검의 한 지점에서 정해진 방향으로 광선을 쏘는 Raycast 방식을 사용했습니다. 이는 검이 빠를 경우 물체를 지나쳐버리는 '터널링' 현상이 발생할 수 있었습니다.\n\n- 변경: 검의 손잡이(Start)와 끝(End) 지점을 잇는 Linecast 방식을 도입하여, 검의 전체 면적에 대한 충돌을 실시간으로 체크하도록 개선했습니다.\n\n### 2) 물리 엔진 기반의 속도 측정 (VelocityEstimator)\n- 기존: Update 문에서 프레임 간의 위치 차이를 직접 계산하여 속도를 구했습니다. 이는 프레임 드랍 발생 시 속도 값이 부정확해지는 단점이 있었습니다.\n\n- 변경: 별도의 VelocityEstimator 스크립트를 통해 물리 연산 주기(FixedUpdate)에 맞춘 정확한 속도 벡터를 추출합니다. 이를 통해 일정 속도(swingSpeedThreshold) 이상으로 휘두를 때만 잘리도록 '손맛'을 조절했습니다.\n\n### 3) 외적(Cross Product)을 이용한 동적 절단면 생성\n- 기존: 단순히 충돌 여부와 각도만 체크하여 오브젝트를 통째로 삭제(Destroy)했습니다.\n \n- 변경: 검의 방향 벡터와 검의 휘두름(속도) 벡터를 외적(Cross Product) 연산하여 절단면의 법선(Normal)을 구합니다. 이를 통해 사용자가 휘두르는 궤적 그대로 물체가 잘리는 물리적 사실감을 구현했습니다.\n\n### 4) 절단 후 파편 처리 및 물리 부여 (SetupHull)\n- 기존: 별도의 파편 처리가 없었습니다.\n\n- 변경: EzySlice를 통해 생성된 두 개의 파편(UpperHull, LowerHull)에 실시간으로 MeshCollider와 Rigidbody를 추가합니다. 잘린 단면에 폭발적인 힘(AddExplosionForce)을 가해 조각들이 사방으로 튕겨 나가는 효과를 추가했습니다.\n---\n\n## 5. 향후 작업 계획\n- 음악의 제작 씬을 별도로 만들어서 음악에 맞는 비트를 찍을수 있도록 구성.", + "created_at": "2026-04-30", + "updated_at": "2026-04-30" + }, + { + "id": 6, + "title": "[AR] 동물캐릭터를 그림위로 뽑아보기", + "category_id": 11, + "tags": [ + "VR", + "Unity" + ], + "content": "##스마트폰을 사용하여 그림 위에 캐릭터를 불러와보기!!\n### 1. 준비과정\n- 유니티의 새 프로젝트 중 \"{color:#54a0ff}AR Mobile{/color}\"을 사용.\n- 에셋스토어에서 {color:#54a0ff}\"Teddy Head Kids 2\"{/color} 다운 및 임포트.\n- 동물이 소환될 이미지 다운.\n\n\n---\n\n### 2. 만들기\n1. 준비된 에셋 중 {color:#ff6b9d}Prefab에서 Bear랑 Hippo를 씬에 올려{/color}준다.\n2. 동물들의 크기를 조정하고 거기에 {color:#ff6b9d}Add Componet를 눌러 Sphere Collider를 추가{/color}한후 사이즈를 조정해준다.\n3. 완료된 오브젝트를 프로젝트로 내려 {color:#ff6b9d}하나의 Prefab으로 만들어{/color}준다.\n4. 프로젝트창에서 Create -> XR -> Reference Image Libary 생성 후 'add Image'를 눌러 2개 생성 => 이미지 추가.\n5. Layer에 Animal을 추가하고 각 동물의 {color:#ff6b9d}Prefab에 Layer 적용{/color}.\n6. 스크립트 2개 생성\n- Animal\n 터치 혹은 생성되었을 때 동물에게 입력된 울음 소리가 나올수 있도록 구성.\n```javascript\nusing UnityEngine;\nusing UnityEngine.InputSystem;\n\n\npublic class Animal : MonoBehaviour\n{\n\n public LayerMask animalLayer;\n\n public AudioSource audioSource;\n\n public AudioClip crySound;\n\n void Update()\n {\n if (Touchscreen.current != null)\n {\n var touch = Touchscreen.current.primaryTouch;\n if (touch.press.wasPressedThisFrame)\n\n {\n Vector2 touchPos = touch.position.ReadValue();\n Ray ray = Camera.main.ScreenPointToRay(touchPos);\n if (Physics.Raycast(ray, out RaycastHit hit, Mathf.Infinity, animalLayer))\n\n {\n if (hit.transform.IsChildOf(transform))\n {\n if (crySound != null && !audioSource.isPlaying)\n {\n audioSource.PlayOneShot(crySound);\n }\n }\n }\n }\n }\n }\n}\n\n```\n\n- imageTracker\n 기기(스마트폰 등)에서 이미지를 보여주면 생성, 숨기는 기능을 구성.\n```javascript\nusing System.Collections.Generic;\nusing UnityEngine;\nusing UnityEngine.XR.ARFoundation;\nusing UnityEngine.XR.ARSubsystems;\nusing UnityEngine.InputSystem;\n\n\npublic class ImageTracker : MonoBehaviour\n{\n ARTrackedImageManager manager;\n [SerializeField] List listName;\n [SerializeField] List listAnimal;\n\n Dictionary dictPrefab = new();\n Dictionary dictSpawn = new();\n\n void Awake()\n {\n manager = FindFirstObjectByType();\n for (int i = 0; i < listName.Count; i++)\n { dictPrefab[listName[i]] = listAnimal[i]; }\n }\n\n void SpawnCharacter(ARTrackedImage img)\n\n {\n string name = img.referenceImage.name;\n\n if (!dictPrefab.ContainsKey(name)) return;\n\n var go = Instantiate(dictPrefab[name], img.transform);\n go.transform.localPosition = Vector3.zero;\n\n dictSpawn[name] = go;\n }\n\n void UpdateCharacter(ARTrackedImage img)\n {\n string name = img.referenceImage.name;\n\n if (!dictSpawn.ContainsKey(name)) return;\n\n bool active = img.trackingState == TrackingState.Tracking;\n\n dictSpawn[name].SetActive(active);\n }\n\n void HideCharacter(ARTrackedImage img)\n {\n string name = img.referenceImage.name;\n\n if (dictSpawn.ContainsKey(name))\n\n { dictSpawn[name].SetActive(false); }\n }\n\n void OnChanged(ARTrackablesChangedEventArgs args)\n {\n foreach (var img in args.added)\n\n SpawnCharacter(img);\n\n foreach (var img in args.updated)\n\n UpdateCharacter(img);\n\n foreach (var img in args.removed)\n\n HideCharacter(img.Value);\n }\n\n void OnEnable()\n { manager.trackablesChanged.AddListener(OnChanged); }\n\n void OnDisable()\n { manager.trackablesChanged.RemoveListener(OnChanged); }\n}\n```\n\n6. ImageTracker의 경우 씬에 빈 오브젝트를 만들어(ArBookManager) 거기안에 추가하기 그리고 난후 {color:#ff6b9d}List Name과 List Animal에 Bear, Hippo를 입력 및 Prefab을 넣어{/color}준다.\n\n7. 각 동물 프리펩에 Audio Source를 추가하고 'Audio Generator'에 준비된 울음소리를 넣어준다. 그다음 {color:#ff6b9d}Animal 스크립트를 붙여주고 거기에 울음소리와 Audio Source를 넣어{/color}준다.\n\n---\n\n> 실제 작동영상\n@video[w=200px,center](uploads/learning/vid_69faa7131137b6.34828927.mp4)", + "created_at": "2026-05-02", + "updated_at": "2026-05-06" + }, + { + "id": 7, + "title": "5/6", + "category_id": 19, + "tags": [], + "content": "1. mcp for Unity 링크\nhttps://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main\n2. 항목 2\n3. 항목 3", + "created_at": "2026-05-06", + "updated_at": "2026-05-06" + }, + { + "id": 8, + "title": "[계획서] 모니터링", + "category_id": 21, + "tags": [ + "미래내일일경험" + ], + "content": "## 모니터링 프로그램\n1. 서버의 cpu, ram의 온도 등을 받아와 api 혹은 soket으로 json 전송.\n2-1. make를 활용하여 해방하는 파트 혹은 구상하여 온도를 텍스트를 실시간으로 전송.\n2-2. 유니티 혹은 code에서 json을 csv 혹은 그래프에 대한 이미지 생성.\n3. make에서 해당파트 클릭스 그래프로 보이게.", + "created_at": "2026-05-06", + "updated_at": "2026-05-06" + }, + { + "id": 9, + "title": "동물사전 만들어보기 -1", + "category_id": 12, + "tags": [ + "Unity", + "AR" + ], + "content": "## 공부한 것을 통해 간단한 동물사전 만들기\n\n### 변경할점\n1. 기존에 배경 이미지에서 불러오던것을 이번에는 qr코드로 대체\n\n### 세팅\n\n1. Unity에서 새로운 프로젝트를 만든다.(AR Mobile Core) -> 이름은 \"Animals Book\"\n2. 세팅하기 필요없는 씬위에 오브젝트들을 날려주자.\n - {color:#ff4757}UI{/color} 삭제\n - XR Origin 내 Camera Offset -> {color:#ff4757}Object Spawner, Screen Space Ray Interactor{/color} 삭제\n - {color:#ff4757}EventSystem{/color} 삭제\n> [!NOTE]\n> ![](uploads/learning/img_69fbebaa899a91.08494186.png)\n---\n\n### 설치 파일\n1. qr코드를 사용하기 위해서 ZXing이 필요하여 검색하여 유니티에 설치.\n2. Animal 스크립트 작성.\n```csharp\nusing UnityEngine;\nusing UnityEngine.InputSystem;\n\n\npublic class Animal : MonoBehaviour\n{\n public LayerMask animalLayer;\n \n public AudioSource audioSource;\n \n public AudioClip crySound;\n\n void Update()\n {\n if(Touchscreen.current != null)\n {\n var touch = Touchscreen.current.primaryTouch;\n\n if(touch.press.wasPressedThisFrame)\n {\n Vector2 touchPos = touch.position.ReadValue();\n\n Ray ray = Camera.main.ScreenPointToRay(touchPos);\n\n if(Physics.Raycast(ray, out RaycastHit hit, Mathf.Infinity, animalLayer))\n {\n if (hit.transform.IsChildOf(transform))\n {\n if(crySound != null && !audioSource.isPlaying)\n {\n audioSource.PlayOneShot(crySound);\n }\n }\n }\n }\n }\n }\n}\n```\n\n3. ImageTracker 스크립트 작성\n```csharp\nusing System.Collections;\nusing System.Collections.Generic;\nusing UnityEngine;\nusing UnityEngine.XR.ARFoundation;\nusing ZXing;\nusing Unity.XR.CoreUtils;\nusing UnityEngine.XR.ARSubsystems;\n\n\npublic class ImageTracker : MonoBehaviour\n{\n\n ARCameraManager cameraManager;\n\n [SerializeField] List listName;\n\n [SerializeField] List listAnimal;\n\n\n Dictionary dictPrefab = new();\n\n Dictionary dictSpawn = new();\n\n string lastDetected = \"\";\n\n IBarcodeReader reader = new BarcodeReader();\n\n void Awake()\n {\n cameraManager = FindFirstObjectByType();\n\n for (int i = 0; i < listName.Count; i++)\n {\n dictPrefab[listName[i]] = listAnimal[i];\n }\n }\n\n void OnEnable()\n {\n StartCoroutine(ScanQR());\n }\n\n void OnDisable()\n {\n StopAllCoroutines();\n }\n\n IEnumerator ScanQR()\n {\n while (true)\n {\n yield return new WaitForSeconds(0.5f);\n\n if(!cameraManager.TryAcquireLatestCpuImage(out var cpuImage)) continue;\n\n using (cpuImage)\n {\n var tex = new Texture2D(cpuImage.width, cpuImage.height, TextureFormat.RGBA32, false);\n var conversionParams = new XRCpuImage.ConversionParams(cpuImage, TextureFormat.RGBA32);\n var buffer = tex.GetRawTextureData();\n cpuImage.Convert(conversionParams, buffer);\n tex.Apply();\n\n var result = reader.Decode(tex.GetPixels32(), tex.width, tex.height);\n Destroy(tex);\n\n if (result == null) continue;\n\n string detected = result.Text;\n\n if (detected == lastDetected) continue;\n lastDetected = detected;\n\n SpawnCharacter(detected);\n }\n }\n }\n\n void SpawnCharacter(string name)\n {\n if (!dictPrefab.ContainsKey(name)) return;\n\n if (dictSpawn.ContainsKey(name))\n {\n dictSpawn[name].SetActive(true);\n return;\n }\n\n var spawnPos = Camera.main.transform.position + Camera.main.transform.forward * 1.5f;\n var go = Instantiate(dictPrefab[name], spawnPos, Quaternion.identity);\n dictSpawn[name] = go;\n }\n\n void HideAll()\n {\n foreach (var go in dictSpawn.Values)\n go.SetActive(false);\n }\n}\n```\n\n4. qr 생성기 만들기\n - Assets 폴더 안에 Editor 폴더를 만들어서 넣고 유니티를 재시작하면 상단 메뉴에 Tool이 생긴다.\n```csharp\nusing UnityEngine;\nusing UnityEditor;\nusing ZXing;\nusing ZXing.QrCode;\nusing System.IO;\n\npublic class QRCodeGenerator : EditorWindow\n{\n string animalName = \"\";\n int qrSize = 256;\n string savePath = \"Assets/QRCodes\";\n\n Texture2D previewTex;\n\n [MenuItem(\"Tools/QR Code Generator\")]\n public static void ShowWindow()\n {\n GetWindow(\"QR Code Generator\");\n }\n\n void OnGUI()\n {\n GUILayout.Label(\"QR Code Generator\", EditorStyles.boldLabel);\n GUILayout.Space(10);\n\n animalName = EditorGUILayout.TextField(\"Animal Name\", animalName);\n qrSize = EditorGUILayout.IntField(\"QR Size\", qrSize);\n savePath = EditorGUILayout.TextField(\"Save Path\", savePath);\n\n GUILayout.Space(10);\n\n if (GUILayout.Button(\"Preview\"))\n {\n if (!string.IsNullOrEmpty(animalName))\n previewTex = GenerateQR(animalName, qrSize);\n }\n\n if (GUILayout.Button(\"Generate & Save\"))\n {\n if (!string.IsNullOrEmpty(animalName))\n SaveQR(animalName);\n }\n\n GUILayout.Space(10);\n\n if (previewTex != null)\n {\n GUILayout.Label(\"Preview:\");\n GUILayout.Label(previewTex, GUILayout.Width(256), GUILayout.Height(256));\n }\n }\n\n Texture2D GenerateQR(string text, int size)\n {\n var writer = new BarcodeWriter\n {\n Format = BarcodeFormat.QR_CODE,\n Options = new QrCodeEncodingOptions\n {\n Width = size,\n Height = size,\n Margin = 1\n }\n };\n\n var pixels = writer.Write(text);\n var tex = new Texture2D(size, size);\n tex.SetPixels32(pixels);\n tex.Apply();\n return tex;\n }\n\n void SaveQR(string text)\n {\n if (!Directory.Exists(savePath))\n Directory.CreateDirectory(savePath);\n\n var tex = GenerateQR(text, qrSize);\n var bytes = tex.EncodeToPNG();\n var path = $\"{savePath}/{text}_QR.png\";\n\n File.WriteAllBytes(path, bytes);\n AssetDatabase.Refresh();\n\n Debug.Log($\"QR 저장 완료: {path}\");\n previewTex = tex;\n }\n}\n```\n\n5. 세팅\n 1. 각 {color:#54a0ff}동물 프리팹에 'Audio Source'와 'Animal' 스크립트를 추가{/color}해준다.\n 2. Animal 스크립트 내부에 적절하게 배치해준다\n 3. 각 동물 프리팹에 {color:#ff4757}layer를 넣어{/color}준다.\n![|w=250px,center](uploads/learning/img_69fc1ee3125881.56665647.png)\n\n6. 씬에 {color:#54a0ff}빈오브텍트(ARBookManager)를 만들고 그안에 \"Image Tracker\" 스크립트를 넣어{/color}준다.\n - Name, Animal 리스트에 추가하여 이름과 프리팹 넣어주기\n\n7. QR 코드 만들기 {color:#54a0ff}상단 메뉴 Tool에서 생성기를 누르고 이름을 넣고 만든다{/color}.\n -> 앞으로 이것을 통해 소환된다.\n\n---\n\n### {color:#ee5a6f}문제점{/color}\n1. qr인식을 통해 소환되지만 크기가 제각각이다.\n2. 소환된 동물이 중력의 영향을 받아 떨어져서 없어진다\n3. 방향이 카메라 보는 방향을 보아서 다양한 각도에서 보기힘들다.", + "created_at": "2026-05-07", + "updated_at": "2026-05-07" + }, + { + "id": 10, + "title": "동물사전 만들어보기 -2", + "category_id": 12, + "tags": [ + "Unity", + "AR" + ], + "content": "## 문제점 고치기\n\n### 1. QR 인식을 통해 소환되지만 크기가 제각각이다\n\n소환 위치가 AR 평면 감지 여부에 따라 달라지다 보니\n카메라와의 거리가 매번 달라져 크기가 제각각으로 보이는 문제가 있었다.\n\n`ImageTracker.cs`에 `spawnScale` 값을 추가해 소환 시 크기를 강제로 고정시켜 해결했다.\n\n```javascript\n// 소환 시 크기 고정\ngo.transform.localScale = Vector3.one * spawnScale;\n```\nInspector에서 `Spawn Scale` 값을 조절해 원하는 크기로 맞출 수 있다.\n\n---\n\n### 2. 소환된 동물이 중력의 영향을 받아 떨어져서 없어진다\n\n아주 간단하면서도 어이없는 이유였다.\n\n프리팹을 열어보니 원래 조종이 가능하게 설정되어 스크립트들과\n**Character Controller**가 컴포넌트로 들어가 있었다.\n\nCharacter Controller는 Rigidbody처럼 중력에 영향을 주기 때문에\n해당 컴포넌트를 제거하고 **Sphere Collider**만 추가해주니 해결되었다.\n\n![|w=250px,center](uploads/learning/img_69fc0fc99e4878.90583225.png)\n\n---\n\n### 3. 방향이 카메라 보는 방향을 보아서 다양한 각도에서 보기 힘들다\n\n소환된 동물이 항상 카메라를 정면으로 바라보도록 고정되어 있어\n측면이나 후면을 볼 수 없는 문제가 있었다.\n\n`Animal.cs`에 터치 드래그로 Y축 회전하는 로직을 추가해 해결했다.\n\n```javascript\n// 드래그로 Y축 360도 회전\nfloat deltaX = currentPos.x - lastTouchPos.x;\ntransform.Rotate(Vector3.up, -deltaX * rotateSpeed, Space.World);\n```\n\n터치한 채로 좌우로 드래그하면 동물이 Y축 기준으로 360도 회전해\n다양한 각도에서 확인할 수 있다.", + "created_at": "2026-05-08", + "updated_at": "2026-05-07" + }, + { + "id": 11, + "title": "기획서 및 예상 방향", + "category_id": 21, + "tags": [ + "미래내일일경험" + ], + "content": "![](uploads/learning/img_69fc4a449dbd03.59040666.png)\n\n1. 간단한 테스트 결과 소켓으로 정보 전송이 가능하다 판별하여 진행하기로 합의.\n2. 모니터링의 구성방향에 대하여 소통결과 일단 2가지 방향으로 잡고 실행할 예정.\n3. 추후 담당자와 연결되면 어디까지 기술적으로 가능하고 안되는지 판별 후 추가 및 소거 예정.", + "created_at": "2026-05-07", + "updated_at": "2026-05-07" + }, + { + "id": 12, + "title": "바이브코딩으로 주식 자동매매 프로그램 만들어보기 -1", + "category_id": 23, + "tags": [ + "Claude", + "자동매매", + "바이브 코딩" + ], + "content": "🚀 {color:#00f2ff}Claude Pro를 활용한 AI 주식 자동매매 프로그램 제작기{/color}\n최근 핫한 Claude Pro와 개인용 서버인 Synology NAS를 활용하여, AI 기반의 주식 자동매매 프로그램을 구축하는 과정을 기록합니다. 코드 자체보다는 기획, 피드백, 그리고 시스템의 작동 방식에 집중하여 프로젝트를 설계했습니다.\n\n---\n\n🛠️ {color:#00f2ff}개발 환경 구성{/color}\nAI 모델: Claude Pro (Anthropic)\n\n실행 환경: Synology NAS (Docker 또는 직접 실행)\n\n언어: Python\n\n---\n\n📋 1단계: 프로젝트 생성 및 지침(Custom Instructions) 설정\nClaude 내에 전용 프로젝트를 생성하고, 효율적인 협업을 위해 아래와 같은 엄격한 지침을 설정했습니다. 불필요한 설명을 줄이고 코드와 핵심 로직에만 집중하기 위함입니다.\n\n[Claude 프로젝트 지침]\n\n1. 코드만 출력, 설명은 주석으로 처리할 것\n\n2. 수정 시에는 변경된 함수나 diff 단위로만 제공할 것\n\n3. 모든 답변 마지막은 반드시 1줄 요약으로 끝낼 것\n\n4. 상세 설명은 \"설명해줘\"라고 별도 요청 시에만 작성할 것\n---\n\n📝 2단계: 프로젝트 기획서 초안 작성\n첫 번째 메시지로 \"주식 자동 매매 프로그램을 만들어볼까 하는데 너는 어떻게 하면 좋을지 기획서를 만들어봐\"라고 요청했습니다. Claude는 즉시 시스템 아키텍처와 주요 기능을 포함한 종합기획서_단타자동매매.md 파일을 생성했습니다.\n\n[📎 종합기획서_단타자동매매.md (18.4 KB)](uploads/learning/종합기획서_단타자동매매_6a041d60084271.93225085.txt)\n---\n\n🤖 3단계: AI 전략 고도화 (v2 업데이트)\n단순히 조건에 맞춰 매매하는 기능을 넘어, AI의 판단 능력을 어떻게 활용할지 고민했습니다. 토큰 비용과 효율성을 고려하여 다음과 같은 AI 시장 분석 로직을 추가했습니다.\n\nAI의 역할 정의\n - 장 시작 전 분석: 하루 한 번, 시장 데이터를 분석하여 \"오늘 거래를 진행할지\" 여부를 결정.\n\n - 리스크 관리: 당일 피해야 할 섹터나 종목을 미리 선별하여 필터링.\n\n - 실행: 결정된 가이드라인에 따라 프로그램이 실시간 단타 매매 수행.\n\n이러한 피드백을 반영하여 더욱 정교해진 종합기획서 v2가 완성되었습니다.\n\n[📎 종합기획서_단타자동매매_v2.md (25.8 KB)](uploads/learning/종합기획서_단타자동매매_v2_6a041d600dc6a8.01129668.txt)\n\n💡 주요 시스템 작동 방식 (Summary)\n 1. 시장 분석 (Pre-market): AI가 뉴스 및 지표를 분석해 당일 매매 전략 수립.\n\n 2. 데이터 수집 (Real-time): API를 통해 실시간 주가 및 체결 데이터 수신.\n\n 3. 전략 실행 (Execution): AI의 가이드라인 내에서 기술적 지표에 따라 자동 매수/매도.\n\n 4. 로깅 및 저장: 모든 거래 내역은 Synology NAS에 저장되어 사후 분석에 활용.\n\n![](uploads/learning/img_6a03e141ed08a3.31548852.png)\n\n---\n\n🔨다음에 해볼것!\n 1. 백테스트를 돌려보기 완료하기!(가상의 데이터로도 토큰을 많이 잡아먹기에 투자증권 api를 미리 발급받기)\n 2. 코드적으로 문제없는지 확인하고 만들기", + "created_at": "2026-05-13", + "updated_at": "2026-05-13" + }, + { + "id": 13, + "title": "바이브코딩으로 주식 자동매매 프로그램 만들어보기 -2", + "category_id": 23, + "tags": [ + "Claude", + "자동매매", + "바이브 코딩" + ], + "content": "# 🚀 Claude Pro를 활용한 AI 주식 자동매매 프로그램 제작기\n\n## 2일차: 방향성 확정 및 시스템 구조 완성\n\n백테스트를 여러 차례 시도했지만 실질적인 결과를 얻기 어렵다는 판단을 내렸습니다. 합성 데이터는 현실의 갭하락, VI 발동, 거래정지 같은 변수를 반영하지 못하고, KRX 실제 데이터는 회원제로 전환되어 접근이 번거로워졌습니다.\n\n결론적으로 **백테스트보다 모의투자 직접 검증**이 더 현실적이라는 방향을 확정했습니다.\n\n---\n\n### 📋 시스템 방향성 확정\n\nClaude에게 원하는 구조를 명확하게 전달했습니다.\n\n1. 장 시작 30분 전, Claude Code가 뉴스·수급·지수를 분석하여 오늘 전략 판단\n2. 09:00 장 시작 시 프로그램이 자동 매매 시작\n3. 장 마감 후 오늘 결과를 Claude Code에 전송하여 피드백 및 코드 자동 수정\n4. 모든 과정을 Discord로 실시간 전송\n\n---\n\n### 🤖 핵심 기술 선택\n\n단순히 Claude API를 호출하는 방식이 아니라, **Claude Code headless** 모드를 Docker 컨테이너로 패키징하여 NAS Container Manager에서 자동 스케줄 실행하는 구조를 채택했습니다.\n\n이렇게 하면 별도의 API 비용 없이 **Claude Code 구독 하나**로 장 전 분석과 장 후 피드백을 모두 처리할 수 있습니다.\n\n---\n\n### ⚙️ 최종 시스템 구성\n\n```\n08:30 claude_morning 컨테이너 → 뉴스/수급 분석 → daily_context.json 생성\n → Discord 분석 결과 전송\n09:00 stockbot-main 컨테이너 → 변동성 돌파 전략 자동 매매 시작\n14:50 강제 전량 청산 (하드코딩, 예외 없음)\n15:10 일일 결산 저장 → Discord 결산 전송\n15:30 claude_evening 컨테이너 → 결과 분석 + 코드 자동 수정\n → reports/daily/ 리포트 저장\n → Discord 수정 내용 전송\n → 실전 전환 조건 충족 시 🚀 알림\n```\n\n---\n\n### ✅ 개발 완료 항목\n\nClaude와 함께 전체 폴더 구조와 코드를 완성하고 Gitea에 Push까지 마쳤습니다.\n\n- KIS Open API 연결 테스트 통과 (토큰 발급, 현재가, 잔고, 거래량 순위)\n- Discord Webhook 연결 테스트 통과\n- 모의투자 모드 (`KIS_MOCK=true`) + `DRY_RUN=true` 정상 구동 확인\n- 모의투자 예수금 10,000,000원 확인\n\n---\n\n### 🔭 다음 단계\n\n내일 장 시작(09:00)부터 `DRY_RUN=true` 상태로 실제 신호가 얼마나 발생하는지 Discord로 모니터링합니다. 며칠간 신호 패턴이 정상이면 `DRY_RUN=false`로 전환하여 모의투자 실주문을 시작합니다.\n\n30거래일 검증 후 아래 5가지 실전 전환 조건을 모두 충족하면 Claude Code가 자동으로 실거래 전환을 권고합니다.\n\n| 조건 | 기준 |\n|------|------|\n| 누적 운영 | 30거래일 이상 |\n| 승률 | 최근 30일 > 48% |\n| MDD | 최근 30일 < -10% |\n| 샤프지수 | 최근 30일 > 1.0 |\n| L3 발동 | 월 2회 이하 |", + "created_at": "2026-05-14", + "updated_at": "2026-05-18" + }, + { + "id": 14, + "title": "문자", + "category_id": 8, + "tags": [ + "C언어" + ], + "content": "### **1. ==fgets==: 안전한 문자열 입력의 시작**\n\n==scanf==는 공백을 구분자로 인식하여 데이터가 누락될 위험이 있고, 무엇보다 **버퍼 오버플로우(Buffer Overflow)** 방어 메커니즘이 없습니다. 반면, ==fgets==는 안전한 입력의 표준입니다.\n\n- 필요 헤더: {color:#ff6b9d}{/color}\n\n- 문법: {color:#ff6b9d}fgets(char *str, int n, FILE *stream);{/color}\n\n- 핵심 원리:\n\n - ==n==에 지정된 크기만큼만 데이터를 읽어 들여, 할당된 {color:#54a0ff}배열의 크기를 넘어서는 데이터가 입력되는 것을 원천 차단{/color}합니다.\n\n - 사용자가 입력한 {color:#ff4757}개행 문자(\\n)까지 버퍼에 포함시키는 특성{/color}이 있습니다.\n\n```c\nchar st[100];\nfgets(st, sizeof(st), stdin);\n```\n---\n### **2. ==strcspn==: 문자열의 '불순물' 제거**\n==fgets==로 입력을 받을 때 마지막에 포함되는 ==\\n==은 문자열 연산이나 비교 시 의도치 않은 결과를 낳을 수 있습니다. 이를 제거하는 것은 정제된 데이터를 다루기 위한 필수 과정입니다.\n\n- 필요 헤더: {color:#ff6b9d}{/color}\n\n- 사용 문법: {color:#ff6b9d}st[strcspn(st, \"\\n\")] = '\\0';{/color}\n\n- 동작 원리:\n\n - ==strcspn==은 문자열 내에서 특정 문자(여기서는 ==\\n==)가 처음 등장하는 인덱스를 반환합니다.\n\n - 이 위치에 널 문자(==\\0==)를 강제로 삽입함으로써, 문자열을 ==\\n== 바로 앞에서 종료시킵니다.\n\n```c\n// 예시: 입력받은 문자열 끝의 \\n 제거\nchar st[100];\nfgets(st, sizeof(st), stdin);\nst[strcspn(st, \"\\n\")] = '\\0';\n```\n---\n\n### **3. ==isalnum==과 ==unsigned char== 형변환: 견고한 데이터 필터링**\n데이터를 검증할 때 단순히 문자를 체크하는 것을 넘어, '안전한 형변환'을 고려해야 합니다.\n\n- 필요 헤더: {color:#ff6b9d}{/color}\n\n- 핵심 원리:\n\n - ==isalnum==은 {color:#54a0ff}매개변수로 정수를 전달{/color}받습니다.\n\n - C언어의 {color:#06ffa5}char는 시스템에 따라 음수를 가질 수 있습니다{/color}. 만약 한글이나 특수 문자가 입력되어 음수 값이 전달되면, 함수 내부적으로 배열의 음수 인덱스(Negative Index)를 참조하게 되어 프로그램이 즉시 종료(Crash)될 수 있습니다.\n\n - 이를 방지하기 위해 반드시 ==(unsigned char)==로 형변환을 하여 {color:#54a0ff}0~255 사이의 양수 인덱스만 전달{/color}되도록 해야 합니다.\n\n```c\n#include \n\n// 필터링 예제\nchar st[100];\nfgets(st, sizeof(st), stdin);\nif (isalnum((unsigned char)st[i])) {\n // 영어이거나 숫자일 때만 수행할 작업\n}\n```\n> [!TIP]\n> ### 왜 unsigned char를 쓰나요?\n>char 타입은 시스템에 따라 음수를 가질 수 있습니다. 특수 기호나 한글 등이 입력되어 음수 값이 isalnum에 들어가면, 함수 내부 배열에서 잘못된 인덱스(음수 인덱스)를 참조하여 프로그램이 멈추거나 오류가 발생합니다. unsigned char로 변환하면 항상 양수(0~255)로 전달되므로 훨씬 안전합니다.\n\n---\n### **🚀 통합 예제: \"영어+숫자만 남기기\"**\n위에서 배운 함수들을 모두 조합하여, 입력받은 문자열에서 영어와 숫자만 남기고 소문자로 변환하는 코드입니다.\n```c\n#include \n#include \n#include \n\nint main() {\n char st[101];\n char result[101];\n int j = 0;\n\n printf(\"문자열 입력: \");\n if (fgets(st, sizeof(st), stdin) != NULL) {\n \n // 1. 순회하며 알파벳/숫자만 골라내기\n for (int i = 0; st[i] != '\\0'; i++) {\n if (isalnum((unsigned char)st[i])) {\n result[j++] = tolower((unsigned char)st[i]);\n }\n }\n result[j] = '\\0'; // 문자열 끝 마무리\n \n printf(\"결과: %s\\n\", result);\n }\n return 0;\n}\n```", + "created_at": "2026-05-14", + "updated_at": "2026-05-14" + }, + { + "id": 15, + "title": "프로젝트 수행 및 운영 계획", + "category_id": 21, + "tags": [ + "미래내일일경험" + ], + "content": "### 📂 프로젝트 수행 및 예산 운용 계획\n\n프로젝트의 본격적인 시작에 앞서, 팀원들과 함께 향후 수행 계획 및 지원금 활용 방안에 대해 심도 있는 논의를 진행했습니다.\n\n---\n\n### 💰 지원금 운용 계획: AI 도구 활용 극대화\n\n단순히 소모품을 구매하기보다, {color:#ffd166}팀 전체의 기술적 역량 강화와 결과물의 퀄리티 향상을 최우선 목표로 설정{/color}했습니다. 이에 따라 지원금의 대부분을 {color:#ffd166}최신 AI 솔루션 구독에 투자{/color}하기로 결정했습니다.\n\n - 선택 도구: ==Claude MAX==\n\n - 선정 이유: 최근 개발 및 코딩 분야에서 가장 뛰어난 퍼포먼스를 보여주는 모델로 판단했으며, 이를 통해 개발 효율성을 극대화하고자 합니다.\n\n---\n\n### 👥 팀 내부 역할 분담 (R&R)\n\n효율적인 {color:#3d6b50}프로젝트 관리를 위해 팀원들의 의사를 존중하여 역할을 분배{/color}했습니다. 팀장으로서의 업무 과부하를 방지하고 각 파트의 전문성을 높이는 데 집중했습니다.\n\n| 역할 | 담당 업무 | 비고 |\n| --- | --- | --- |\n| 팀장 | 프로젝트 총괄 및 방향성 설정, 최종 의사결정 | 전체적인 리딩 및 파트별 소통 |\n| 총무 | 지원금 집행 관리, 예산 정산 및 증빙 | 투명한 예산 운영 |\n| 서기 | 회의록 작성, 정기 보고서 및 문서화 | 프로젝트 히스토리 관리 |\n| 운영 보 | 전체적인 일정 관리 및 파트별 업무 서포트 | 유동적인 리소스 지원 |", + "created_at": "2026-05-14", + "updated_at": "2026-05-15" + }, + { + "id": 16, + "title": "바이브코딩으로 주식 자동매매 프로그램 만들어보기 -3", + "category_id": 23, + "tags": [ + "Claude", + "자동매매", + "바이브 코딩" + ], + "content": "# 🚀 Claude Pro를 활용한 AI 주식 자동매매 프로그램 제작기\n\n## 3일차: 첫 실전 가동 — 버그와의 전쟁\n\n드디어 장 중에 처음으로 프로그램을 돌려봤습니다. 결론부터 말하면, 생각보다 많은 버그가 숨어 있었습니다.\n\n---\n\n### 🔴 첫 번째 문제 — KIS Rate Limit\n\n08:30 유니버스 갱신 시 30종목 전일 데이터를 한꺼번에 요청하면서 KIS API 초당 1건 제한을 초과했습니다. 에러 로그가 쏟아졌고, 실패한 종목을 계속 재시도하면서 무한 루프에 빠졌습니다.\n\n원인은 두 가지였습니다.\n\n- 전일 날짜가 아닌 **당일 날짜**로 OHLCV를 요청해서 데이터가 없었음\n- 이미 받은 종목도 재요청하는 로직\n\n`has_prev_data()` 메서드를 추가해서 캐시된 종목은 skip하고, sleep을 1.1초로 늘려서 해결했습니다.\n\n---\n\n### 🔴 두 번째 문제 — 타이밍 미스\n\n08:50에 재시작하는 바람에 08:30 유니버스 갱신 타이밍을 놓쳤습니다. 전일 데이터 없이 목표가 계산이 안 된 상태로 매매 루프가 시작됐습니다.\n\n오늘은 장 중 재시작을 감지해서 즉시 유니버스 + 목표가를 계산하는 임시 코드를 추가해서 진행했습니다. 다음 가동부터는 삭제할 코드입니다.\n\n---\n\n### 🔴 세 번째 문제 — 매도 실패\n\n매수는 되는데 매도가 계속 실패했습니다. 에러 메시지는 `near \"ORDER\": syntax error`.\n\nSQLite는 `UPDATE ... ORDER BY LIMIT` 문법을 지원하지 않습니다. `order_executor.py` 안에 직접 작성된 SQL이 문제였고, 서브쿼리 방식으로 수정해서 해결했습니다.\n\n```sql\n-- 수정 전 (SQLite 미지원)\nUPDATE trades SET ...\nWHERE ticker=? AND exit_time IS NULL\nORDER BY id DESC LIMIT 1\n\n-- 수정 후 (서브쿼리)\nUPDATE trades SET ...\nWHERE id = (\n SELECT id FROM trades\n WHERE ticker=? AND exit_time IS NULL\n ORDER BY id DESC LIMIT 1\n)\n```\n\n---\n\n### ✅ 오늘의 성과\n\n버그투성이였지만 결국 DRY_RUN 상태에서 아래 흐름이 전부 정상 동작하는 것을 확인했습니다.\n\n- 매수 신호 감지 ✅\n- 매도 실행 ✅\n- L3 3연속 손절 발동 → 당일 매매 중단 ✅\n\n오늘 데이터는 `price=0`으로 매수된 종목도 있어서 전략 판단 자료로는 쓸 수 없지만, 시스템이 설계대로 움직인다는 건 확인했습니다.\n\n---\n\n### 🤖 Claude Code 연동 완료\n\n매번 채팅창에서 코드를 주고받는 방식의 한계를 느꼈습니다. 토큰 소모도 많고, 수정된 코드를 파일에 적용하려면 복붙을 반복해야 했습니다.\n\n그래서 **Claude Code**를 로컬에 설치하고 Gitea와 연동했습니다. 이제 터미널에서 명령하면 Claude Code가 직접 파일을 읽고 수정하고 git push까지 합니다.\n\n```\n터미널 1: python app/main.py ← 매매 프로그램 실행\n터미널 2: claude ← 코드 수정/디버깅\n```\n\n`CLAUDE.md`와 `.claude/settings.json`도 세팅해서 매번 컨텍스트 설명 없이도 프로젝트 구조와 규칙을 인식하도록 했습니다.\n\n```json\n{\n \"dangerouslySkipPermissions\": true,\n \"instructions\": \"코드만 출력, 설명은 주석으로. 수정은 변경된 함수/diff 단위만. 수정 후 반드시 git commit/push.\"\n}\n```\n\n---\n\n### 📋 다음 단계\n\n- [ ] `check_entries()` / `check_exits()` sleep 1.1초 적용 (rate limit 근본 해결)\n- [ ] 월요일 08:30 전 정상 가동 확인\n- [ ] 로컬 계정 전환 + 작업 스케줄러 등록 (자동 시작)\n\n---\n\n## 🔭 앞으로 할 것\n\n### 1. 모의투자 정상 가동\n\n현재까지는 타이밍 미스와 버그 수정으로 정상적인 하루 흐름을 한 번도 완주하지 못했습니다. 08:30 전 시작 → 유니버스 갱신 → 목표가 계산 → 09:00 매매 루프 → 14:50 강제 청산 → 15:10 결산까지 전체 흐름이 한 번도 끊기지 않고 돌아가는 것을 먼저 확인합니다. 이후 며칠간 신호 패턴이 정상이면 `DRY_RUN=false`로 전환해서 모의투자 실주문을 시작합니다.\n\n---\n\n### 2. AI 장 전 분석 구현 (claude_morning)\n\n지금까지 `daily_context.json`이 없어서 AI 필터가 fallback 기본값으로만 동작했습니다. 원래 기획대로 장 시작 30분 전에 뉴스와 수급 데이터를 수집하고, 오늘 시장 분위기와 주목할 섹터를 판단해서 매매 프로그램에 전달하는 구조를 완성합니다. 이게 완성되면 기획서에 설계한 전체 흐름이 처음으로 완성됩니다.\n\n```\n08:30 claude_morning → 뉴스/수급 분석 → daily_context.json → Discord 전송\n09:00 매매 프로그램 → AI 필터 적용 → 자동 매매\n15:30 claude_evening → 결과 분석 + config.py 조정 → Discord 전송\n```\n\n---\n\n### 3. NAS 이전 및 완전 자동화\n\n로컬 PC에서 검증이 끝나면 Synology NAS Docker로 이전합니다. 작업 스케줄러 대신 Container Manager가 스케줄을 관리하고, PC 없이 NAS만으로 24시간 자동 운영되는 구조를 완성합니다. 30거래일 검증 후 5가지 실전 전환 조건을 모두 충족하면 Claude Code가 자동으로 실거래 전환을 권고합니다.", + "created_at": "2026-05-18", + "updated_at": "2026-05-18" + }, + { + "id": 17, + "title": "바이브코딩으로 주식 자동매매 프로그램 만들어보기 -4", + "category_id": 23, + "tags": [ + "Claude", + "자동매매", + "바이브 코딩" + ], + "content": "# 4일차: 두 번째 실전 가동 — 구조적 버그 전면 수정\n\n- 첫날(05-18) 봇을 실제로 돌려보며 발견한 구조적 문제들을 오늘 전면 개선했습니다. \n- 버그 수정 4건 + 새 기능 1건.\n\n---\n\n## 🌅 오늘 아침 — 봇이 또 여러 번 켜졌다\n\n![center](uploads/learning/img_6a0c199a30c9b5.01614885.webp)\n\n07:55 스케줄러 태스크와 `run_morning.ps1` 마지막의 `/start-bot` 호출이 둘 다 살아있었습니다. \n봇이 두 번 시작되고 충돌하면서 재시작을 반복하는 상황.\n\n해결: `StockBot_Bot(07:55)` 태스크 비활성화. 모닝 스크립트가 분석 완료 후 봇을 직접 시작하는 구조로 정리.\n\n정리된 스케줄:\n\n| 시간 | 태스크 | 역할 |\n|-------|------------------|-----------------------|\n| 08:15 | StockBot_Morning | 장 전 분석 → 봇 시작 |\n| 11:20 | StockBot_Midday | 장중 분석 → 점심 세션 |\n| 15:30 | StockBot_Evening | 장 후 분석 → 리포트 |\n\n---\n\n## 📊 오늘 디스코드 알림 흐름\n\n![center](uploads/learning/img_6a0c1a0f63adc3.95162510.webp)\n\n08:16에 장 전 분석이 정상 완료됐습니다.\n\n```text\n[장전분석] 2026-05-19 08:15:10\n시장: 중립(52점) | 리스크: 보통 | ✅ 거래허용\n주목 섹터: 반도체, 지주사\n회피 섹터: 원유/에너지, 기술주(해외)\n관심 종목: 000660, 034730, 005930\n📝 美금리 쇼크+마이크론 급락 부담, 지주사 외국인 수급·반도체 모멘텀 혼재\n```\n\n09:00부터 매매가 시작되면서 알림이 쏟아졌습니다. \n전체 거래가 09:00~10:31에 집중됐는데, 변동성 돌파 전략이 장 초반에 가장 활발하게 작동하는 특성이 다시 한번 확인됐습니다.\n\n---\n\n## 🔴 버그 1 — KIS API TR_ID 오류\n\n장 전 분석에서 수급 데이터 수집 시 KIS API가 \"없는 서비스 코드\" 오류를 반환하는 문제.\n\n| 함수 | 기존 (오류) | 수정 |\n|------|------------|------|\n| `get_foreign_institution_rank()` | `FHKST04430000` | `FHPTJ04400000` + 파라미터 추가 |\n| `get_sector_trend()` | `FHKST03010100` | `FHPUP02100000` × 15개 섹터 개별 호출 |\n\nKIS Open API 공식 문서와 실제 동작하는 TR_ID가 달라서 직접 테스트로 하나씩 확인해야 했습니다.\n\n---\n\n## 🔴 버그 2 — L3 발동 시 SL 모니터링 중단\n\n==[red]:가장 치명적인 버그였습니다.== 10:31에 실제로 발생했습니다.\n\n```text\n10:31 [경고-L3] L3: 3연속 손절 발생\n → can_trade() = False\n → check_exits()도 스킵됨 ← 버그\n → 선도전기(007610) SL 감시 없이 방치\n```\n\n기존 L3는 3연속 손절 시 `can_trade() = False`로 전체 매매를 중단하는 방식이었는데, 청산 로직까지 함께 멈춰버렸습니다.\n\nB안으로 전환 — 전면 중단 대신 포지션 크기를 단계적으로 축소:\n\n| 연속 손절 | 포지션 크기 |\n|-----------|--------------|\n| 0회 | 1.0× (정상) |\n| 1회 | 0.7× |\n| 2회 | 0.5× |\n| 3회+ | 0.3× (최소) |\n| 익절 1회 | 한 단계 회복 |\n\n전면 중단이 없어지니 SL 모니터링도 항상 유지됩니다.\n\n---\n\n## 🔴 버그 3 — 14:30 이후 재시작 시 강제청산 미실행\n\n```python\n# 수정 전\nif \"09:00\" <= now <= \"14:30\": # 14:42 재시작 → 조건 밖 → trading_loop 미진입\n\n# 수정 후\nif \"09:00\" <= now < \"15:00\": # 강제청산(14:50) 전까지 포함\n```\n\n14:42에 봇을 재시작했는데 14:50 강제청산이 실행되지 않았고, 선도전기 포지션이 장 마감 후까지 열려있었습니다. 종가 기준 수동 처리했습니다.\n\n---\n\n## 🔴 버그 4 — 14:00~14:50 SL 모니터링 중단\n\n```python\n# 수정 전\nif now_str > \"14:00\":\n await asyncio.sleep(1)\n continue # check_exits() 스킵 → SL 감시 없음\n\n# 수정 후\nif now_str > \"14:00\":\n await self.check_exits() # 청산은 계속\n await asyncio.sleep(1)\n continue\n```\n\n신규 진입만 막고 청산은 계속 모니터링하도록 수정했습니다.\n\n---\n\n## ✨ 새 기능 — 점심 세션 이벤트 기반으로 전환\n\n![center](uploads/learning/img_6a0c1a0f93c817.82156909.webp)\n\n기존 방식은 12:00에 점심 세션이 고정으로 시작됐습니다. \n분석 결과와 무관하게 시간만 되면 시작하는 구조라 비효율적이었습니다.\n\n변경된 흐름:\n\n```\n11:20 /midday 실행\n → 오전 거래 결과 + 현재 시장 스냅샷 분석\n → midday_context.json 저장\n → 봇이 파일 감지 즉시 점심 세션 시작 (이벤트 기반)\n```\n\n봇이 파일 수정 시각(`mtime`)을 비교해서 새로운 분석이 도착했을 때만 반응합니다:\n\n```python\ndef _check_midday_context(self):\n path = Path(\"data/midday_context.json\")\n mtime = path.stat().st_mtime\n if mtime <= self._midday_ctx_mtime:\n return # 변경 없으면 무시\n ctx = json.loads(path.read_text(encoding=\"utf-8\"))\n # 점심 진입 허용 여부, 포지션 배율, 섹터 업데이트 적용\n self._midday_ctx_mtime = mtime\n```\n\n분석이 끝나는 시점에 바로 세션이 시작되므로, 12:00까지 기다리며 시간을 낭비하지 않습니다.\n\n---\n\n## 📈 오늘 매매 결과\n\n| 항목 | 수치 |\n|----------|----------------------------------|\n| 총 거래 | 9건 |\n| 승/패 | 6승 3패 (66.7%) |\n| 순손익 | ==[green]:+90,429원== |\n| TP1 합계 | +199,110원 |\n| SL 합계 | -108,681원 |\n| 강제청산 | +103,343원 (선도전기, 수동 처리) |\n\n버그가 있었음에도 수익이 났습니다. 시스템이 설계대로 작동하고 있다는 건 확인됐습니다.\n\n---\n\n## 📋 누적 현황\n\n- 운영 2거래일\n- 실전 전환 조건까지 28거래일 남음\n- 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true)\n\n---\n\n## 🔭 다음 단계\n\n- [ ] `DRY_RUN=false` 전환 → 모의투자 실주문 시작\n- [ ] `midday_context.json` 이벤트 감지 안정성 검증\n- [ ] NAS 이전 준비 (로컬 PC 검증 완료 후)", + "created_at": "2026-05-19", + "updated_at": "2026-05-19" + } +] \ No newline at end of file diff --git a/data/profile.json b/data/profile.json new file mode 100644 index 0000000..1d38f49 --- /dev/null +++ b/data/profile.json @@ -0,0 +1,74 @@ +{ + "name": "이종재", + "title": "Game & XR Developer", + "tagline": "가상과 현실의 경계를 허무는 인터랙티브 경험을 만듭니다", + "avatar": "uploads/profile.jpg", + "bio": "Unity와 Unreal Engine을 기반으로 게임과 XR 콘텐츠를 개발하고 있습니다. 단순히 작동하는 코드를 넘어, 사용자가 몰입할 수 있는 경험을 설계하는 것에 집중합니다. 자체 NAS 환경에서 Gitea와 CI/CD 파이프라인을 운영하며 개발 인프라부터 클라이언트까지 전 과정을 다룹니다.", + "location": "Gwangmyeong-si, Gyeonggi-do, South Korea", + "email": "jongjae0305@gmail.com", + "skills": [ + { + "category": "언어", + "items": [ + "C#", + "C++", + "Python", + "JavaScript", + "Java" + ] + }, + { + "category": "엔진 / 프레임워크", + "items": [ + "Unity", + "Unreal Engine" + ] + }, + { + "category": "인프라 / DevOps", + "items": [ + "Docker", + "Gitea", + "Synology NAS" + ] + }, + { + "category": "도구", + "items": [ + "Git", + "Visual Studio", + "3dsMAx" + ] + }, + { + "category": "AI", + "items": [ + "Claude", + "Gemini", + "ChatGpt" + ] + } + ], + "timeline": [ + { + "year": "2026", + "title": "Unity, Unreal, 3dMax 공부", + "description": "Unity, Unreal, 3dMax 공부 및 게임 개발" + }, + { + "year": "2025", + "title": "AI 공부", + "description": "Python 공부 및 전반적인 AI의 시스템 공부" + }, + { + "year": "2024", + "title": "풀스택 교육을 통한 개발공부시작", + "description": "국비지원을 통한 학원에서 Java, Javascript, react, sql 등 풀스택" + } + ], + "social": { + "github": "https://whdwo798.synology.me", + "linkedin": "", + "blog": "" + } +} \ No newline at end of file diff --git a/data/projects.json b/data/projects.json new file mode 100644 index 0000000..08238f1 --- /dev/null +++ b/data/projects.json @@ -0,0 +1,66 @@ +[ + { + "id": 1, + "title": "WildRoot", + "label": "UNITY", + "description": "Unity로 만든 안드로이드 게임.\n조이스틱과 실드, 총 버튼을 이용하여 생성되는 무기를 파괴하여 타임을 기록하는게임", + "icon": "fa-solid fa-code", + "images": [ + "uploads/WildRoot/img_69f356c0354dc0.69476663.jpg", + "uploads/WildRoot/img_69f356c02ec8e4.60887672.jpg", + "uploads/WildRoot/img_69f356c037cfb4.05035323.jpg" + ], + "image": "uploads/WildRoot/img_69f356c0354dc0.69476663.jpg", + "link": "https://whdwo798.synology.me/Game/WildRoot.git", + "stack": [ + "Unity" + ], + "period_start": "2026-04-20", + "period_end": "2026-04-24", + "video_url": "uploads/WildRoot/WIldRoot.mp4", + "created_at": "2026-04-30" + }, + { + "id": 2, + "title": "동물 사전", + "label": "UNITY, AR", + "description": "QR코드를 통하여 이미지를 동물을 소환하여 현실감 있게 느끼는 AR사전", + "icon": "fa-solid fa-code", + "images": [ + "uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg", + "uploads/동물-사전/img_69fc432b0552a7.38602241.jpg" + ], + "image": "uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg", + "link": "https://whdwo798.synology.me/whdwo798/AnimalDictionary.git", + "stack": [ + "Unity" + ], + "period_start": "2026-05-01", + "period_end": "2026-05-07", + "video_url": "/uploads/동물-사전/AnimalsDictionary.mp4", + "created_at": "2026-05-07", + "updated_at": "2026-05-07" + }, + { + "id": 3, + "title": "BeatSaber", + "label": "UNITY, VR", + "description": "비트세이버 게임 모작", + "icon": "fa-solid fa-code", + "images": [ + "uploads/BeatSaber/img_6a193f37360c14.56970202.webp", + "uploads/BeatSaber/img_6a193f37c56dc7.05489597.webp", + "uploads/BeatSaber/img_6a193f386123d1.60613240.webp" + ], + "image": "uploads/BeatSaber/img_6a193f37360c14.56970202.webp", + "link": "https://whdwo798.synology.me/whdwo798/BeatSaber.git", + "stack": [ + "Unity" + ], + "period_start": "2026-05-01", + "period_end": "2026-05-29", + "video_url": "uploads/BeatSaber/beatsaber.mp4", + "created_at": "2026-05-29", + "updated_at": "2026-05-29" + } +] \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..13c57b6 --- /dev/null +++ b/index.html @@ -0,0 +1,1911 @@ + + + + + + + + + + + + + +이종재 | Game & XR Developer + + + + + + + + + +
+

이종재 Portfolio

+

Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.
Gitea 서버를 통해 실제 소스 코드를 확인하실 수 있습니다.

+
+ +
+
+

DEVELOPMENT LOG

+
+ +
+
+ +
+ +
+

© 2026 Lee Jong-jae. Hosted on Private Synology NAS.

+ + + +
+ + + + + + + + + + diff --git a/index_backup.html b/index_backup.html new file mode 100644 index 0000000..ff59247 --- /dev/null +++ b/index_backup.html @@ -0,0 +1,639 @@ + + + + + + 이종재 | Game & XR Developer + + + + + + + + +
+

이종재 Portfolio

+

Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.
Gitea 서버를 통해 실제 소스 코드를 확인하실 수 있습니다.

+
+ +
+
+

DEVELOPMENT LOG

+
+ +
+ +
+ +
+
+ +
+

© 2026 Lee Jong-jae. Hosted on Private Synology NAS.

+ + + +
+ + + + + + + + + + diff --git a/learning.html b/learning.html new file mode 100644 index 0000000..ee37eb5 --- /dev/null +++ b/learning.html @@ -0,0 +1,4065 @@ + + + + + + + + + + + + + +Learning Log | 이종재 + + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+

전체

+ 0 posts +
+
+ + +
+
+ +
+ + + +
+ +
+ +
+
+
+ +
+

© 2026 Lee Jong-jae. Hosted on Private Synology NAS. + + + +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/profile.html b/profile.html new file mode 100644 index 0000000..f6941a0 --- /dev/null +++ b/profile.html @@ -0,0 +1,1086 @@ + + + + + + + + + + + + + +Profile | 이종재 + + + + + + + + + +
+
+
+
+ +
+
+
+ // PROFILE +

+
+

+
+ + +
+
+
+ +
+
+

ABOUT

+
+ +
+

+
+ +
+
+

TECH STACK

+
+
+
+
+ +
+
+

JOURNEY

+
+
+
+
+ +
+
+

CONTACT

+
+
+
+
+ +
+

© 2026 Lee Jong-jae. Hosted on Private Synology NAS.

+
+ + + + + +
+ 복사되었습니다 +
+ + + + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..e8c143f --- /dev/null +++ b/style.css @@ -0,0 +1,245 @@ +/* ===== CSS 변수 (라이트/다크 모드) ===== */ +:root { + --bg: #F0EEE9; + --bg-card: #E8E4DE; + --bg-deep: #DDD8D2; + --bg-nav: rgba(240,238,233,0.95); + --primary: #5C8A6A; + --primary-dim: rgba(92,138,106,0.15); + --secondary: #3d6b50; + --text: #1a1a18; + --text-dim: #6a7a6e; + --text-sub: #8a9a8e; + --border: #d0ccc6; + --border-card: rgba(92,138,106,0.2); + --danger: #c0392b; + --shadow: rgba(0,0,0,0.08); + --nav-border: rgba(92,138,106,0.2); + /* 하위 호환 별칭 */ + --bg-dark: var(--bg); + --card-bg: var(--bg-card); + --text-white: var(--text); + --learning-yellow: #b07a20; + --grass-l1: #a8c8b4; + --grass-l2: #6fa882; + --grass-l3: #3d7a56; +} + +/* ===== 다크 모드 ===== */ +[data-theme="dark"] { + --bg: #2a2420; + --bg-card: #322c28; + --bg-deep: #1e1a17; + --bg-nav: rgba(42,36,32,0.95); + --primary: #B2E2D2; + --primary-dim: rgba(178,226,210,0.12); + --secondary: #7ab8a8; + --text: #F5F5DC; + --text-dim: #a0b4ac; + --text-sub: #7a8a86; + --border: rgba(178,226,210,0.15); + --border-card: rgba(178,226,210,0.15); + --danger: #e07060; + --shadow: rgba(0,0,0,0.3); + --nav-border: rgba(178,226,210,0.15); + --bg-dark: var(--bg); + --card-bg: var(--bg-card); + --text-white: var(--text); + --learning-yellow: #c9a050; + --grass-l1: #4a8a72; + --grass-l2: #6fb89a; + --grass-l3: #B2E2D2; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: var(--bg); + color: var(--text); + line-height: 1.7; + overflow-x: hidden; + transition: background-color 0.3s, color 0.3s; +} +h1, h2, h3, .logo, .card-label { font-family: 'Orbitron', sans-serif; } + +nav { + background: var(--bg-nav); + backdrop-filter: blur(10px); + padding: 1.2rem 8%; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 1000; + border-bottom: 1px solid var(--nav-border); +} +nav .logo { + font-weight: 700; font-size: 1.4rem; color: var(--primary); letter-spacing: 2px; + text-decoration: none; + transition: 0.2s; +} +nav .logo:hover { + text-shadow: 0 0 20px var(--primary-dim); +} +nav .links { display: flex; align-items: center; gap: 2rem; } +nav .links a { + text-decoration: none; color: var(--text); font-size: 0.9rem; + transition: 0.3s; display: flex; align-items: center; gap: 6px; + position: relative; + padding: 4px 0; +} +nav .links a:hover { color: var(--primary); } +nav .links a.nav-active { + color: var(--primary); +} +nav .links a.nav-active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 1px; + background: var(--primary); + box-shadow: 0 0 8px var(--primary); +} + +/* ===== 버튼 (공통) ===== */ +.btn { + padding: 0.6rem 1.2rem; border-radius: 6px; + font-family: 'Orbitron', sans-serif; font-size: 0.75rem; letter-spacing: 1.5px; + cursor: pointer; border: none; transition: 0.3s; + display: inline-flex; align-items: center; gap: 8px; +} +.btn-primary { background: var(--primary); color: var(--bg); font-weight: 700; } +.btn-primary:hover { background: var(--text); color: var(--bg); transform: translateY(-2px); } +.btn-outline { background: transparent; border: 1px solid var(--primary); color: var(--primary); } +.btn-outline:hover { background: var(--primary); color: var(--bg); } +.btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); } +.btn-danger:hover { background: var(--danger); color: #fff; } + + +/* ===== 모달 (공통) ===== */ +.modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + z-index: 2000; + justify-content: center; align-items: center; + padding: 20px; +} +.modal-overlay.active { display: flex; } +.modal { + background: var(--bg-card); color: var(--text); + background: var(--bg-card); + border: 1px solid rgba(var(--primary-rgb, 92,138,106), 0.3); + border-radius: 1rem; + padding: 2rem; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} +.modal.modal-wide { + max-width: min(66vw, 900px); + width: 100%; +} +.modal h2 { + color: var(--primary); margin-bottom: 1.5rem; + font-size: 1.4rem; letter-spacing: 1px; padding-right: 40px; +} +.modal-close-x { + position: absolute; + top: 1.2rem; right: 1.2rem; + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-dim); cursor: pointer; + font-size: 0.9rem; transition: 0.2s; z-index: 5; +} +.modal-close-x:hover { + color: var(--danger); border-color: var(--danger); + transform: rotate(90deg); +} + +.form-group { margin-bottom: 1.2rem; } +.form-group label { + display: block; margin-bottom: 6px; font-size: 0.85rem; + color: var(--text-dim); font-family: 'Orbitron', sans-serif; letter-spacing: 1px; +} +.form-group input, .form-group textarea, .form-group select { + width: 100%; padding: 12px; + background: var(--bg-deep); + border: 1px solid var(--border); border-radius: 6px; + color: var(--text); font-family: 'Noto Sans KR', sans-serif; + font-size: 0.95rem; transition: 0.3s; +} +.form-group input:focus, .form-group textarea:focus, .form-group select:focus { + outline: none; border-color: var(--primary); + box-shadow: 0 0 0 3px var(--border); +} +.form-group textarea { min-height: 100px; resize: vertical; } +.modal-actions { + display: flex; gap: 10px; justify-content: flex-end; margin-top: 1.5rem; +} +.alert { + padding: 12px; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; +} +.alert-error { background: rgba(255, 71, 87, 0.1); border: 1px solid var(--danger); color: var(--danger); } +.alert-success { background: var(--border); border: 1px solid var(--primary); color: var(--primary); } + + +/* ===== 테마 토글 버튼 ===== */ +.theme-toggle { + background: var(--primary-dim); + border: 1px solid var(--border-card); + color: var(--primary); + width: 34px; height: 34px; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + transition: 0.2s; + flex-shrink: 0; +} +.theme-toggle:hover { + background: var(--primary); + color: var(--bg); +} + +/* ===== 푸터 관리자 토글 ===== */ + +footer { + padding: 60px 8% 30px; + text-align: center; + border-top: 1px solid var(--border); + color: var(--text-dim); + font-size: 0.9rem; +} +footer .admin-toggle { + display: inline-block; margin-top: 10px; + opacity: 0.3; cursor: pointer; transition: 0.3s; +} +footer .admin-toggle:hover { opacity: 1; color: var(--primary); } + +/* ===== 반응형 공통 (nav + modal) ===== */ +@media (max-width: 768px) { + nav { padding: 0.9rem 4%; flex-wrap: wrap; gap: 8px; } + nav .logo { font-size: 1.1rem; letter-spacing: 1px; } + nav .links { gap: 1rem; } + nav .links a { font-size: 0.8rem; } + .modal-overlay { padding: 12px; } + .modal.modal-wide { max-width: 92vw; } + .modal-actions .btn { width: 100%; justify-content: center; padding: 0.8rem; font-size: 0.8rem; } +} +@media (max-width: 380px) { + nav .logo { font-size: 0.95rem; } + nav .links { gap: 0.6rem; } + nav .links a { font-size: 0.75rem; } +} diff --git a/uploads/.htaccess b/uploads/.htaccess new file mode 100644 index 0000000..c3ed167 --- /dev/null +++ b/uploads/.htaccess @@ -0,0 +1,27 @@ +# uploads/ 폴더에서 PHP 실행 방지 +# 업로드된 파일이 코드로 실행되는 것을 차단합니다 (보안 핵심) + + + + Require all denied + + + Order deny,allow + Deny from all + + + +# PHP 핸들러 비활성화 + + php_flag engine off + + + php_flag engine off + + +# .htaccess 자체 접근 차단 + + + Require all denied + + diff --git a/uploads/BeatSaber/beatsaber.mp4 b/uploads/BeatSaber/beatsaber.mp4 new file mode 100644 index 0000000..a8f016b Binary files /dev/null and b/uploads/BeatSaber/beatsaber.mp4 differ diff --git a/uploads/BeatSaber/img_6a193f37360c14.56970202.webp b/uploads/BeatSaber/img_6a193f37360c14.56970202.webp new file mode 100644 index 0000000..b1e9a99 Binary files /dev/null and b/uploads/BeatSaber/img_6a193f37360c14.56970202.webp differ diff --git a/uploads/BeatSaber/img_6a193f37c56dc7.05489597.webp b/uploads/BeatSaber/img_6a193f37c56dc7.05489597.webp new file mode 100644 index 0000000..1fa3aa7 Binary files /dev/null and b/uploads/BeatSaber/img_6a193f37c56dc7.05489597.webp differ diff --git a/uploads/BeatSaber/img_6a193f386123d1.60613240.webp b/uploads/BeatSaber/img_6a193f386123d1.60613240.webp new file mode 100644 index 0000000..7bb5965 Binary files /dev/null and b/uploads/BeatSaber/img_6a193f386123d1.60613240.webp differ diff --git a/uploads/WildRoot/WIldRoot.mp4 b/uploads/WildRoot/WIldRoot.mp4 new file mode 100644 index 0000000..1ace4a2 Binary files /dev/null and b/uploads/WildRoot/WIldRoot.mp4 differ diff --git a/uploads/WildRoot/img_69f356c02ec8e4.60887672.jpg b/uploads/WildRoot/img_69f356c02ec8e4.60887672.jpg new file mode 100644 index 0000000..5d3a752 Binary files /dev/null and b/uploads/WildRoot/img_69f356c02ec8e4.60887672.jpg differ diff --git a/uploads/WildRoot/img_69f356c0354dc0.69476663.jpg b/uploads/WildRoot/img_69f356c0354dc0.69476663.jpg new file mode 100644 index 0000000..b4883d6 Binary files /dev/null and b/uploads/WildRoot/img_69f356c0354dc0.69476663.jpg differ diff --git a/uploads/WildRoot/img_69f356c037cfb4.05035323.jpg b/uploads/WildRoot/img_69f356c037cfb4.05035323.jpg new file mode 100644 index 0000000..cf0d928 Binary files /dev/null and b/uploads/WildRoot/img_69f356c037cfb4.05035323.jpg differ diff --git a/uploads/learning/img_69fbebaa899a91.08494186.png b/uploads/learning/img_69fbebaa899a91.08494186.png new file mode 100644 index 0000000..a3ca553 Binary files /dev/null and b/uploads/learning/img_69fbebaa899a91.08494186.png differ diff --git a/uploads/learning/img_69fc0fc99e4878.90583225.png b/uploads/learning/img_69fc0fc99e4878.90583225.png new file mode 100644 index 0000000..2698ef8 Binary files /dev/null and b/uploads/learning/img_69fc0fc99e4878.90583225.png differ diff --git a/uploads/learning/img_69fc1ee3125881.56665647.png b/uploads/learning/img_69fc1ee3125881.56665647.png new file mode 100644 index 0000000..f17a170 Binary files /dev/null and b/uploads/learning/img_69fc1ee3125881.56665647.png differ diff --git a/uploads/learning/img_69fc4a449dbd03.59040666.png b/uploads/learning/img_69fc4a449dbd03.59040666.png new file mode 100644 index 0000000..d212d1b Binary files /dev/null and b/uploads/learning/img_69fc4a449dbd03.59040666.png differ diff --git a/uploads/learning/img_6a03e141ed08a3.31548852.png b/uploads/learning/img_6a03e141ed08a3.31548852.png new file mode 100644 index 0000000..b546a6b Binary files /dev/null and b/uploads/learning/img_6a03e141ed08a3.31548852.png differ diff --git a/uploads/learning/img_6a0c199a30c9b5.01614885.webp b/uploads/learning/img_6a0c199a30c9b5.01614885.webp new file mode 100644 index 0000000..4f19e23 Binary files /dev/null and b/uploads/learning/img_6a0c199a30c9b5.01614885.webp differ diff --git a/uploads/learning/img_6a0c1a0f63adc3.95162510.webp b/uploads/learning/img_6a0c1a0f63adc3.95162510.webp new file mode 100644 index 0000000..eec8670 Binary files /dev/null and b/uploads/learning/img_6a0c1a0f63adc3.95162510.webp differ diff --git a/uploads/learning/img_6a0c1a0f93c817.82156909.webp b/uploads/learning/img_6a0c1a0f93c817.82156909.webp new file mode 100644 index 0000000..0ab2eea Binary files /dev/null and b/uploads/learning/img_6a0c1a0f93c817.82156909.webp differ diff --git a/uploads/learning/vid_69faa7131137b6.34828927.mp4 b/uploads/learning/vid_69faa7131137b6.34828927.mp4 new file mode 100644 index 0000000..e555cb2 Binary files /dev/null and b/uploads/learning/vid_69faa7131137b6.34828927.mp4 differ diff --git a/uploads/learning/종합기획서_단타자동매매_6a041d60084271.93225085.txt b/uploads/learning/종합기획서_단타자동매매_6a041d60084271.93225085.txt new file mode 100644 index 0000000..f36cbf3 --- /dev/null +++ b/uploads/learning/종합기획서_단타자동매매_6a041d60084271.93225085.txt @@ -0,0 +1,536 @@ +# 단타 자동매매 시스템 종합 기획서 + +> 버전: v1.0 +> 기준: 한국 주식 (코스피/코스닥) / KIS Open API / Synology NAS Docker +> 원칙: 규칙 기반 완전 자동화, 인간 판단 개입 0 + +--- + +## 0. 설계 원칙 (절대 불변) + +1. **감정 0** — 모든 진입/청산은 코드가 결정. 예외 없음 +2. **손절 우선** — 수익 극대화보다 손실 제한이 먼저 +3. **단순함 우선** — 복잡한 전략보다 단순하고 견고한 전략 +4. **검증 후 실거래** — 백테스트 → 모의투자 3개월 → 소액 실거래 순서 필수 +5. **14:50 전량 청산** — 오버나이트는 절대 없음. 하드코딩 + +--- + +## 1. 시스템 전체 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Synology NAS (Docker) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ stockbot │ │ kill-switch │ ← 별도 컨테이너 │ +│ │ (메인) │ │ (긴급중단) │ │ +│ └──────┬───────┘ └──────────────┘ │ +│ │ │ +│ ┌──────▼───────────────────────────────────┐ │ +│ │ asyncio Event Loop │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │ +│ │ │Universe │ │Strategy │ │ Risk │ │ │ +│ │ │Scanner │ │Engine │ │ Manager │ │ │ +│ │ └─────────┘ └─────────┘ └───────────┘ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │ +│ │ │ Data │ │ Order │ │ Notifier │ │ │ +│ │ │Collector│ │Executor │ │(Telegram) │ │ │ +│ │ └─────────┘ └─────────┘ └───────────┘ │ │ +│ └───────────────────────┬──────────────────┘ │ +│ │ │ +│ ┌───────────────────────▼──────────────────┐ │ +│ │ SQLite (체결/포지션/로그) │ │ +│ │ Redis (실시간 시세 캐시) │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Streamlit Dashboard (포트 8501) │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ │ + KIS WebSocket KIS REST API + (실시간 시세) (주문/잔고) +``` + +--- + +## 2. 기술 스택 (확정) + +| 항목 | 선택 | 이유 | +|------|------|------| +| 언어 | Python 3.11 | 생태계, KIS 예제 코드 모두 Python | +| 비동기 | asyncio + aiohttp | 초당 20건 rate limit 정밀 제어 | +| DB | SQLite | NAS 메모리 절약, 단일 파일 백업 용이 | +| 캐시 | Redis 7 (Docker) | 실시간 시세 캐시, TTL 관리 | +| 스케줄러 | APScheduler 3.x (AsyncIOScheduler) | 장 시작/마감 이벤트 트리거 | +| 알림 | python-telegram-bot | 단일 채널, 신뢰성 높음 | +| 대시보드 | Streamlit | 설치 단순, NAS 내부망 접근 | +| 컨테이너 | Docker Compose | Synology Container Manager 호환 | +| 백테스트 | vectorbt | pandas 기반, 분봉 데이터 처리 빠름 | +| 데이터 수집 | pykrx + KIS REST | 과거 분봉 백필 | + +--- + +## 3. 디렉토리 구조 + +``` +/volume1/docker/stockbot/ +│ +├── docker-compose.yml +├── .env ← API 키 전용 (Git 제외) +│ +├── app/ +│ ├── main.py ← 진입점, asyncio 루프 +│ ├── config.py ← 전략 파라미터 전용 +│ │ +│ ├── data/ +│ │ ├── collector.py ← KIS WebSocket 시세 수신 +│ │ ├── universe.py ← 종목 풀 갱신 (08:30) +│ │ └── backfill.py ← 과거 분봉 백필 스크립트 +│ │ +│ ├── strategy/ +│ │ ├── base.py ← 전략 추상 클래스 +│ │ └── volatility_breakout.py ← 전략 A (유일한 실전 전략) +│ │ +│ ├── risk/ +│ │ └── manager.py ← 손절/일일한도/강제청산 +│ │ +│ ├── execution/ +│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼 +│ │ └── order_executor.py ← 주문 전송, 재시도, 슬리피지 +│ │ +│ ├── monitor/ +│ │ ├── notifier.py ← 텔레그램 알림 +│ │ └── dashboard.py ← Streamlit 대시보드 +│ │ +│ └── db/ +│ ├── models.py ← SQLite 스키마 +│ └── repository.py ← DB 접근 레이어 +│ +├── kill_switch/ +│ └── kill.py ← 긴급 전량 청산 단독 실행 +│ +├── backtest/ +│ ├── run_backtest.py ← vectorbt 백테스트 실행 +│ └── results/ ← 백테스트 결과 저장 +│ +├── data/ +│ ├── stockbot.db ← SQLite DB +│ └── universe_cache.json ← 당일 종목 풀 캐시 +│ +└── logs/ + └── trades.log ← 영구 보관 (세금 신고용) +``` + +--- + +## 4. 전략 (확정: 전략 A 단독) + +### 변동성 돌파 전략 (Volatility Breakout) + +**선정 이유:** +- 래리 윌리엄스 실전 검증, 30년 이상 데이터 기반 +- 룰이 단순 → 과적합 위험 최소 +- 분봉 적용 시 한국 장 오전 변동성과 궁합 최적 +- 진입 조건이 명확 → 자동화 구현 난이도 낮음 + +**핵심 파라미터 (config.py에서 관리)** +```python +STRATEGY_K = 0.5 # 변동성 계수 (0.4~0.6 안정 구간) +ENTRY_START = "09:00" # 진입 허용 시작 +ENTRY_END = "14:30" # 진입 허용 마감 +FORCE_EXIT = "14:50" # 강제 청산 시각 (하드코딩) +TP1_PCT = 0.02 # 1차 익절 +2% (50% 매도) +TP2_PCT = 0.03 # 2차 익절 +3% (전량) +SL_PCT = 0.015 # 손절 -1.5% (즉시 시장가) +MAX_HOLD_MIN = 120 # 최대 보유시간 120분 (무수익 시 청산) +``` + +**진입 로직** +``` +목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K + +진입 조건 (전부 충족 시): + 1. 현재가 >= 목표가 + 2. 현재 시각 ENTRY_START ~ ENTRY_END + 3. 당일 KOSPI 등락률 > -1.0% ← 시장 약세 차단 + 4. 전일 거래대금 >= 100억 + 5. 시가총액 1,000억 ~ 3조 + 6. VI 미발동 종목 + 7. 당일 보유 종목 수 < 2 ← 동시 최대 2종목 + 8. 일일 누적 손실 < -3% ← 손실 한도 미도달 +``` + +**청산 로직 (우선순위 순)** +``` +1순위: 강제 청산 → 14:50 시장가 전량 +2순위: 손절 → 현재가 <= 매수가 × (1 - SL_PCT) → 시장가 +3순위: 1차 익절 → 현재가 >= 매수가 × (1 + TP1_PCT) → 50% 지정가 +4순위: 2차 익절 → 현재가 >= 매수가 × (1 + TP2_PCT) → 전량 지정가 +5순위: 시간 청산 → 보유 후 MAX_HOLD_MIN 경과, 수익 없을 시 시장가 +``` + +--- + +## 5. 종목 유니버스 선정 (Universe) + +매일 08:30 자동 갱신. + +**1차 필터 (정적)** +| 조건 | 기준 | +|------|------| +| 시장 | 코스피 + 코스닥 | +| 시가총액 | 1,000억 ~ 3조 | +| 상장 기간 | 6개월 이상 | +| 제외 | 관리종목, 거래정지, 우선주, 스팩, ETF, ETN | + +**2차 필터 (동적, 전일 기준)** +| 조건 | 기준 | +|------|------| +| 전일 거래대금 | 100억 이상 | +| 5일 평균 거래대금 | 50억 이상 | +| 전일 등락률 | -3% ~ +15% | +| 일봉 60일 이평선 | 현재가 위 | + +**결과: 최대 30종목** (KIS WebSocket 안정성 기준으로 50 → 30 축소) + +--- + +## 6. 리스크 관리 + +### 포지션 규칙 +| 항목 | 값 | +|------|-----| +| 1종목 최대 비중 | 총자산 × 20% | +| 동시 보유 최대 | 2종목 | +| 분할매수 | 1차 10%, 신호 유지 시 2차 10% | + +### 손실 한도 (계층별 자동 중단) +| 레벨 | 조건 | 동작 | +|------|------|------| +| L1 | 1회 매매 -1.5% | 즉시 손절 | +| L2 | 일일 누적 -3% | 당일 신규 진입 중단 | +| L3 | 3연속 손절 | 당일 매매 중단 | +| L4 | 주간 누적 -7% | 주말까지 중단 + 텔레그램 경고 | +| L5 | 월간 누적 -15% | 자동 전략 폐기 + 백테스트 재요청 | + +### 안전장치 +- VI(변동성완화장치) 발동 → 즉시 해당 종목 청산 +- 호가 스프레드 > 0.5% → 진입 금지 +- 매수 후 5분 미체결 → 주문 취소 +- WebSocket 끊김 → 보유 포지션 즉시 시장가 청산 +- API 오류 10건/분 → kill-switch 컨테이너 자동 실행 + +--- + +## 7. KIS API 운용 + +### 초당 20건 제한 대응 전략 +``` +WebSocket (실시간 시세) : 30종목 구독 → REST 호출 없음 +REST (주문/잔고) : 주문 시에만 호출 +REST (분봉 조회) : 신호 확인용, 1초 간격 제한 + +asyncio Semaphore(20) 로 초당 20건 보장 +배치 처리: 30종목 ÷ 20건 = 1.5초/1회전 +``` + +### WebSocket 구독 항목 +- 실시간 체결가 (H0STCNT0) +- 실시간 호가 (H0STASP0) +- VI 발동/해제 (H0STVI0) + +### 주문 타입 결정 +| 상황 | 주문 타입 | +|------|----------| +| 진입 | 시장가 (빠른 체결 우선) | +| 1차 익절 | 지정가 (목표가 미리 걸기) | +| 2차 익절 | 지정가 | +| 손절 | 시장가 (슬리피지 감수) | +| 강제청산 | 시장가 | + +--- + +## 8. 스케줄 타임라인 + +``` +08:00 │ 시스템 기동, DB 연결 확인 +08:30 │ Universe 갱신 (pykrx 전일 데이터) +08:50 │ WebSocket 연결, 30종목 구독 시작 + │ 당일 시가 기반 목표가 계산 +09:00 │ ─────────── 진입 허용 시작 ─────────── + │ 1분봉 루프 시작 (asyncio, 1초 단위) +11:30 │ 신규 진입 일시 중단 (점심) +13:00 │ 신규 진입 재개 +14:30 │ 신규 진입 마감 +14:50 │ ─────────── 강제 청산 ─────────────── + │ 보유 포지션 전량 시장가 매도 +15:00 │ WebSocket 연결 종료 +15:10 │ 당일 결산 로그 생성 +15:20 │ 텔레그램 일일 결산 알림 발송 +15:30 │ 시스템 대기 (다음 날 08:00까지) +``` + +--- + +## 9. DB 스키마 (SQLite) + +```sql +-- 체결 내역 +CREATE TABLE trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, -- YYYY-MM-DD + ticker TEXT NOT NULL, + entry_time TEXT NOT NULL, -- HH:MM:SS + exit_time TEXT, + entry_price REAL NOT NULL, + exit_price REAL, + quantity INTEGER NOT NULL, + side TEXT NOT NULL, -- BUY / SELL + exit_reason TEXT, -- TP1/TP2/SL/FORCE/TIME + pnl REAL, + fee REAL, + slippage REAL, + strategy TEXT DEFAULT 'VB' +); + +-- 일일 요약 +CREATE TABLE daily_summary ( + date TEXT PRIMARY KEY, + total_trades INTEGER, + win_trades INTEGER, + lose_trades INTEGER, + gross_pnl REAL, + total_fee REAL, + net_pnl REAL, + max_drawdown REAL, + trading_stopped INTEGER DEFAULT 0 -- L2 발동 여부 +); + +-- 포지션 (장중 현황) +CREATE TABLE positions ( + ticker TEXT PRIMARY KEY, + entry_time TEXT, + entry_price REAL, + quantity INTEGER, + tp1_done INTEGER DEFAULT 0, + target_price REAL, + stop_price REAL +); +``` + +--- + +## 10. 알림 설계 (텔레그램) + +| 이벤트 | 메시지 형식 | +|--------|-----------| +| 진입 | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` | +| 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` | +| 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` | +| 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` | +| L2 발동 | `[경고] 일일 손실 -3% 도달. 오늘 매매 중단.` | +| 일일 결산 | `[결산] 매매 5회 / 승 3 패 2 / 순손익 +1.2%` | +| 장애 | `[긴급] WebSocket 끊김. kill-switch 실행.` | + +--- + +## 11. 백테스트 계획 + +### 데이터 준비 +``` +수집 대상: 코스피200 + 코스닥150 편입 종목 +기간: 2021-01-01 ~ 2024-12-31 (4년치) +봉: 1분봉 +도구: pykrx (무료, 과거 분봉 제공) +생존편향: 상장폐지 종목 별도 수집 포함 +``` + +### 수수료 시뮬레이션 +``` +매수 수수료: 0.015% +매도 수수료: 0.015% +거래세: 0.18% (매도 시) +슬리피지: 시장가 0.10%, 지정가 0.03% +총 1회전 비용 추정: 약 0.21% +``` + +### 합격 기준 (out-of-sample 1년 기준) +| 지표 | 통과 기준 | 목표 | +|------|----------|------| +| 샤프지수 | > 1.0 | > 1.5 | +| MDD | < 15% | < 10% | +| 승률 | > 45% | > 55% | +| 손익비 | > 1.3 | > 1.8 | +| 일평균 매매 | 1~5회 | 2~3회 | + +### K값 최적화 방법 +``` +1. K = 0.3 ~ 0.8 구간 0.05 단위 그리드 서치 +2. 인샘플(2021~2023) 에서 상위 3개 K값 선별 +3. 아웃샘플(2024) 에서 재검증 +4. 피크 K값 배제 → 샤프지수 안정 구간 채택 +5. 최종 채택: K = 0.5 (사전 기본값, 백테스트로 재확인) +``` + +--- + +## 12. 인프라 (Synology Docker) + +### docker-compose.yml +```yaml +version: "3.9" + +services: + redis: + image: redis:7-alpine + container_name: stockbot-redis + restart: unless-stopped + volumes: + - ./data/redis:/data + + stockbot: + build: ./app + container_name: stockbot-main + restart: unless-stopped + depends_on: + - redis + env_file: .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + - TZ=Asia/Seoul + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + + kill-switch: + build: ./kill_switch + container_name: stockbot-killswitch + restart: "no" # 수동 또는 자동 트리거 시에만 실행 + env_file: .env + profiles: ["emergency"] # docker compose --profile emergency up + + dashboard: + build: ./monitor + container_name: stockbot-dashboard + restart: unless-stopped + ports: + - "8501:8501" + volumes: + - ./data:/app/data + environment: + - TZ=Asia/Seoul +``` + +### .env 구조 +``` +# KIS API +KIS_APP_KEY=... +KIS_APP_SECRET=... +KIS_ACCOUNT_NO=... +KIS_MOCK=true # 모의투자: true / 실거래: false + +# 텔레그램 +TELEGRAM_TOKEN=... +TELEGRAM_CHAT_ID=... + +# Redis +REDIS_HOST=stockbot-redis +REDIS_PORT=6379 + +# 운영 모드 +DRY_RUN=true # 주문 실제 전송 여부 +LOG_LEVEL=INFO +``` + +--- + +## 13. 개발 로드맵 + +### Phase 1 — 데이터 기반 구축 (2주) +- [ ] KIS Open API 신청 + 모의투자 계좌 개설 +- [ ] pykrx로 2021~2024 분봉 데이터 백필 +- [ ] SQLite 스키마 생성 +- [ ] Universe 스캐너 구현 (08:30 갱신) +- [ ] Docker Compose 기동 확인 (NAS) + +### Phase 2 — 백테스트 (3주) +- [ ] vectorbt 환경 구성 +- [ ] 변동성 돌파 전략 백테스트 코드 작성 +- [ ] 수수료/슬리피지 반영 +- [ ] K값 그리드 서치 +- [ ] out-of-sample 검증 → 합격 기준 통과 확인 + +### Phase 3 — KIS 연동 + dry-run (2주) +- [ ] KIS REST 클라이언트 구현 (인증/토큰 갱신) +- [ ] KIS WebSocket 클라이언트 구현 (시세 수신) +- [ ] OrderExecutor 구현 (시장가/지정가/IOC) +- [ ] DRY_RUN=true 상태에서 신호 발생 확인 + +### Phase 4 — 리스크 + 알림 + 대시보드 (2주) +- [ ] RiskManager 전 계층 구현 (L1~L5) +- [ ] kill-switch 컨테이너 구현 +- [ ] 텔레그램 Notifier 구현 +- [ ] Streamlit 대시보드 구현 (실시간 PnL) + +### Phase 5 — 모의투자 실운영 (최소 3개월) +- [ ] KIS_MOCK=true, DRY_RUN=false +- [ ] 매일 결산 로그 검토 +- [ ] 주간 샤프지수/MDD 추적 +- [ ] 이상 거동 발생 시 전략 파라미터 재검토 (코드 수정, K값 조정) +- [ ] 3개월 연속 샤프 > 1.0, MDD < 15% 달성 시 Phase 6 진입 + +### Phase 6 — 소액 실거래 (무기한) +- [ ] KIS_MOCK=false +- [ ] 총자산의 5% 한도로 시작 +- [ ] 1개월 단위 성과 검토, 한도 점진 확대 + +--- + +## 14. 보안 체크리스트 + +- [ ] .env → .gitignore 등록 필수 +- [ ] KIS API 키 → GitHub 절대 커밋 금지 +- [ ] Synology 방화벽: 8501 포트 내부망만 허용 +- [ ] 텔레그램 봇: 특정 chat_id만 허용 (화이트리스트) +- [ ] 모든 주문 로그 SQLite + logs/ 이중 보관 +- [ ] 월 1회 .env 파일 암호화 백업 + +--- + +## 15. 절대 금지 항목 (하드코딩) + +```python +# 이 리스트는 코드에서 상수로 관리, 절대 런타임 수정 불가 +BLACKLIST_REASONS = [ + "신규상장 6개월 미만", + "관리종목", + "투자경고", + "거래정지", + "우선주", + "스팩", + "ETF/ETN", +] + +TRADING_BLACKOUT = [ + ("11:30", "13:00"), # 점심 휴식 + ("14:50", "15:30"), # 장 마감 전 + ("08:00", "09:00"), # 동시호가 +] + +HARD_EXIT_TIME = "14:50" # 절대 변경 불가 +``` + +--- + +## 16. 면책 조항 + +> 본 기획서는 시스템 설계 문서이며, 투자 수익을 보장하지 않는다. +> 단타는 통계적으로 개인투자자의 90% 이상이 손실을 보는 영역이다. +> 백테스트 결과는 미래 수익을 보장하지 않으며, 반드시 모의투자 3개월 이상 검증 후 실거래로 전환할 것. +> 시스템 장애, API 오류, 네트워크 단절로 인한 손실에 대해 어떠한 책임도 지지 않는다. diff --git a/uploads/learning/종합기획서_단타자동매매_v2_6a041d600dc6a8.01129668.txt b/uploads/learning/종합기획서_단타자동매매_v2_6a041d600dc6a8.01129668.txt new file mode 100644 index 0000000..66a3374 --- /dev/null +++ b/uploads/learning/종합기획서_단타자동매매_v2_6a041d600dc6a8.01129668.txt @@ -0,0 +1,743 @@ +# 단타 자동매매 시스템 종합 기획서 v2.0 + +> 버전: v2.0 (AI 판단 레이어 통합) +> 기준: 한국 주식 (코스피/코스닥) / KIS Open API / Synology NAS Docker +> 핵심 변경: 규칙 기반 실행 + Claude AI 일일 시장 판단 레이어 추가 + +--- + +## 0. 설계 원칙 (절대 불변) + +1. **감정 0** — 실행은 코드가 결정. 단, 판단은 AI가 보조 +2. **속도/판단 역할 분리** — AI는 느리지만 깊게(매일 1회), 실행은 빠르게(수식) +3. **손절 우선** — AI가 긍정 판단해도 손절 룰은 무조건 우선 +4. **검증 후 실거래** — 백테스트 → 모의투자 3개월 → 소액 실거래 +5. **14:50 전량 청산** — 하드코딩, 어떤 상황에서도 예외 없음 + +--- + +## 1. 시스템 전체 구조 (v2.0) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Synology NAS (Docker) │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [레이어 1] AI 판단 엔진 (08:00) │ │ +│ │ │ │ +│ │ 데이터 수집 │ │ +│ │ ├─ 전일 뉴스 (네이버 금융 크롤링) │ │ +│ │ ├─ KOSPI/KOSDAQ 지수 흐름 (KIS REST) │ │ +│ │ ├─ 외국인/기관 순매수 상위 (KIS 순위분석 API) │ │ +│ │ ├─ 거래량 급증 종목 (KIS 순위분석 API) │ │ +│ │ └─ 섹터별 등락률 (KIS 업종/기타 API) │ │ +│ │ ↓ │ │ +│ │ Claude API 호출 (하루 1회, 약 3,000 토큰) │ │ +│ │ ↓ │ │ +│ │ daily_context.json 생성 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ↓ 읽기만 함 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [레이어 2] 규칙 기반 실행 엔진 (09:00~) │ │ +│ │ │ │ +│ │ asyncio Event Loop │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │ +│ │ │Universe │ │Strategy │ │ Risk │ │ │ +│ │ │Scanner │ │Engine │ │ Manager │ │ │ +│ │ └──────────┘ └──────────┘ └────────────┘ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │ +│ │ │ Data │ │ Order │ │ Notifier │ │ │ +│ │ │Collector │ │Executor │ │(Telegram) │ │ │ +│ │ └──────────┘ └──────────┘ └────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SQLite (체결/포지션/로그) │ Redis (실시간 시세 캐시) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Streamlit Dashboard (포트 8501) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ kill-switch (별도 컨테이너 / 긴급 청산) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ │ + KIS WebSocket KIS REST API + (실시간 시세/VI) (주문/잔고/순위/수급) +``` + +--- + +## 2. 핵심 변경: AI 판단 레이어 상세 + +### 2-1. 수집 데이터 소스 + +| 데이터 | 소스 | 방법 | 수집 시각 | +|--------|------|------|---------| +| 전일 종목 뉴스 헤드라인 | 네이버 금융 | HTTP 크롤링 | 07:30 | +| KOSPI/KOSDAQ 전일 종가·등락률 | KIS REST | API | 07:40 | +| 외국인 순매수 상위 30종목 | KIS 순위분석 API | API | 07:40 | +| 기관 순매수 상위 30종목 | KIS 순위분석 API | API | 07:40 | +| 거래량 급증 상위 30종목 | KIS 순위분석 API | API | 07:40 | +| 업종별 등락률 | KIS 업종/기타 API | API | 07:40 | +| 당일 경제 일정 (FOMC 등) | 네이버 금융 크롤링 | HTTP 크롤링 | 07:30 | + +> KIS API만으로 수급·수치 데이터 대부분 커버 가능. 뉴스는 네이버 금융 크롤링으로 보완. + +### 2-2. Claude API 프롬프트 구조 + +```python +SYSTEM_PROMPT = """ +당신은 한국 주식 단타 전문 AI 분석가입니다. +매일 장 시작 전, 제공된 데이터를 분석해 오늘 단타 매매 전략을 판단합니다. +반드시 JSON 형식으로만 응답하며, 다른 텍스트는 포함하지 않습니다. +""" + +USER_PROMPT = f""" +[전일 시장 요약] +- KOSPI: {kospi_change}% ({kospi_close}pt) +- KOSDAQ: {kosdaq_change}% ({kosdaq_close}pt) + +[외국인 순매수 상위] +{foreign_buy_top10} + +[기관 순매수 상위] +{institution_buy_top10} + +[거래량 급증 상위] +{volume_surge_top10} + +[업종별 등락률] +{sector_changes} + +[주요 뉴스 헤드라인 (상위 20건)] +{news_headlines} + +[오늘 경제 일정] +{economic_calendar} + +위 데이터를 분석해 다음 JSON을 반환하세요: +{{ + "trade_allowed": true/false, + "market_sentiment": "강세/중립/약세", + "sentiment_score": 0~100, + "risk_level": "낮음/보통/높음", + "hot_sectors": ["섹터1", "섹터2"], + "avoid_sectors": ["섹터3"], + "boosted_tickers": ["005930", "000660"], + "blacklist_tickers": ["종목코드"], + "position_size_multiplier": 0.5~1.5, + "reason": "한 줄 판단 이유" +}} +""" +``` + +### 2-3. daily_context.json 구조 + +```json +{ + "date": "2026-05-13", + "generated_at": "08:05:22", + "trade_allowed": true, + "market_sentiment": "중립", + "sentiment_score": 62, + "risk_level": "보통", + "hot_sectors": ["반도체", "2차전지"], + "avoid_sectors": ["금융", "건설"], + "boosted_tickers": ["005930", "000660", "373220"], + "blacklist_tickers": [], + "position_size_multiplier": 1.0, + "reason": "외국인 반도체 순매수 지속, KOSPI 박스권 상단 접근 중립 판단" +} +``` + +### 2-4. 실행 엔진에서의 활용 + +```python +# 진입 조건에 AI 판단 필터 추가 (기존 조건 1~8 + 신규 9~11) +진입 조건 (전부 충족 시): + 1. 현재가 >= 목표가 (변동성 돌파) + 2. 현재 시각 09:00 ~ 14:30 + 3. KOSPI 등락률 > -1.0% + 4. 전일 거래대금 >= 100억 + 5. 시가총액 1,000억 ~ 3조 + 6. VI 미발동 + 7. 보유 종목 수 < 2 + 8. 일일 누적 손실 < -3% + ── AI 판단 필터 (신규) ── + 9. daily_context["trade_allowed"] == true + 10. 해당 종목 섹터가 avoid_sectors에 없음 + 11. 해당 종목이 blacklist_tickers에 없음 + +# 포지션 사이즈 조정 +실제_투자비중 = 기본비중(20%) × position_size_multiplier +# sentiment_score 높을수록 multiplier 증가 (0.5~1.5) + +# boosted_tickers 우선 진입 +boosted 종목은 동일 신호 시 다른 종목보다 먼저 진입 처리 +``` + +--- + +## 3. 기술 스택 (v2.0 확정) + +| 항목 | 선택 | 이유 | +|------|------|------| +| 언어 | Python 3.11 | KIS 예제 코드 모두 Python | +| 비동기 | asyncio + aiohttp | rate limit 정밀 제어 | +| DB | SQLite | NAS 메모리 절약, 백업 단순 | +| 캐시 | Redis 7 (Docker) | 실시간 시세 캐시 | +| 스케줄러 | APScheduler (AsyncIOScheduler) | 장 이벤트 트리거 | +| AI 판단 | Claude claude-sonnet-4-20250514 | 뉴스/수급 분석 | +| 뉴스 수집 | aiohttp + BeautifulSoup4 | 네이버 금융 크롤링 | +| 알림 | python-telegram-bot | 텔레그램 단일 채널 | +| 대시보드 | Streamlit | NAS 내부망 접근 | +| 컨테이너 | Docker Compose | Synology Container Manager | +| 백테스트 | vectorbt | 분봉 데이터 고속 처리 | +| 데이터 수집 | pykrx + KIS REST | 과거 분봉 백필 | + +--- + +## 4. 디렉토리 구조 (v2.0) + +``` +/volume1/docker/stockbot/ +│ +├── docker-compose.yml +├── .env +│ +├── app/ +│ ├── main.py ← 진입점, asyncio 루프 +│ ├── config.py ← 전략 파라미터 +│ │ +│ ├── ai/ ← [신규] AI 판단 레이어 +│ │ ├── context_builder.py ← 데이터 수집 + Claude API 호출 +│ │ ├── news_crawler.py ← 네이버 금융 뉴스 크롤링 +│ │ └── prompts.py ← 프롬프트 템플릿 관리 +│ │ +│ ├── data/ +│ │ ├── collector.py ← KIS WebSocket 시세 수신 +│ │ ├── universe.py ← 종목 풀 갱신 (08:30) +│ │ └── backfill.py ← 과거 분봉 백필 +│ │ +│ ├── strategy/ +│ │ ├── base.py ← 전략 추상 클래스 +│ │ └── volatility_breakout.py ← 변동성 돌파 전략 (AI 필터 포함) +│ │ +│ ├── risk/ +│ │ └── manager.py ← 손절/일일한도/강제청산 +│ │ +│ ├── execution/ +│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼 +│ │ └── order_executor.py ← 주문 전송, 재시도 +│ │ +│ ├── monitor/ +│ │ ├── notifier.py ← 텔레그램 알림 +│ │ └── dashboard.py ← Streamlit 대시보드 +│ │ +│ └── db/ +│ ├── models.py ← SQLite 스키마 +│ └── repository.py ← DB 접근 레이어 +│ +├── kill_switch/ +│ └── kill.py +│ +├── backtest/ +│ ├── run_backtest.py +│ └── results/ +│ +├── data/ +│ ├── stockbot.db +│ ├── universe_cache.json +│ └── daily_context.json ← [신규] AI 판단 결과 파일 +│ +└── logs/ + ├── trades.log + └── ai_context.log ← [신규] AI 판단 이력 보관 +``` + +--- + +## 5. 전략 (확정: 변동성 돌파 + AI 필터) + +### 핵심 파라미터 (config.py) + +```python +# 변동성 돌파 파라미터 +STRATEGY_K = 0.5 +ENTRY_START = "09:00" +ENTRY_END = "14:30" +FORCE_EXIT = "14:50" +TP1_PCT = 0.02 +TP2_PCT = 0.03 +SL_PCT = 0.015 +MAX_HOLD_MIN = 120 + +# AI 판단 파라미터 (신규) +AI_CONTEXT_PATH = "/app/data/daily_context.json" +AI_MIN_SCORE = 40 # sentiment_score 40 미만 시 trade_allowed=false 강제 +AI_BOOST_MULTI = 1.5 # boosted_tickers 진입 비중 배율 +AI_RISK_SL_MAP = { # risk_level별 손절 강화 + "낮음": 0.015, + "보통": 0.015, + "높음": 0.010 # 고위험 장세엔 손절 타이트하게 +} +``` + +### 진입 로직 (AI 필터 통합) + +``` +목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K + +진입 조건 (전부 충족): + [기술적 조건] + 1. 현재가 >= 목표가 + 2. 09:00 ~ 14:30 + 3. KOSPI 등락률 > -1.0% + 4. 전일 거래대금 >= 100억 + 5. 시가총액 1,000억 ~ 3조 + 6. VI 미발동 + 7. 보유 종목 수 < 2 + 8. 일일 누적 손실 < -3% + + [AI 판단 조건] + 9. trade_allowed == true + 10. 종목 섹터 not in avoid_sectors + 11. 종목 not in blacklist_tickers +``` + +### 청산 로직 (우선순위) + +``` +1순위: 14:50 강제 청산 (시장가) +2순위: 손절 → 현재가 <= 매수가 × (1 - SL_PCT) [risk_level에 따라 조정] +3순위: 1차 익절 → +TP1_PCT 도달 → 50% 지정가 +4순위: 2차 익절 → +TP2_PCT 도달 → 전량 지정가 +5순위: 시간 청산 → MAX_HOLD_MIN 경과 + 무수익 +``` + +--- + +## 6. 종목 유니버스 (Universe) + +매일 08:30 자동 갱신. + +**1차 필터 (정적)** +| 조건 | 기준 | +|------|------| +| 시장 | 코스피 + 코스닥 | +| 시가총액 | 1,000억 ~ 3조 | +| 상장 기간 | 6개월 이상 | +| 제외 | 관리종목, 거래정지, 우선주, 스팩, ETF, ETN | + +**2차 필터 (동적)** +| 조건 | 기준 | +|------|------| +| 전일 거래대금 | 100억 이상 | +| 5일 평균 거래대금 | 50억 이상 | +| 전일 등락률 | -3% ~ +15% | +| 60일 이평선 | 현재가 위 | + +**AI 보정** +- boosted_tickers → 우선 감시 목록 상단 배치 +- blacklist_tickers → 당일 유니버스에서 즉시 제거 +- avoid_sectors → 해당 섹터 전체 진입 금지 + +**최대 감시 종목: 30개** (WebSocket 안정성 기준) + +--- + +## 7. 리스크 관리 + +### 포지션 규칙 +| 항목 | 기본값 | AI 조정 | +|------|--------|--------| +| 1종목 최대 비중 | 총자산 × 20% | × position_size_multiplier | +| 동시 보유 최대 | 2종목 | risk_level=높음 시 1종목 | +| 손절 기준 | -1.5% | risk_level=높음 시 -1.0% | + +### 손실 한도 (계층별) +| 레벨 | 조건 | 동작 | +|------|------|------| +| L1 | 1회 매매 -1.5% | 즉시 손절 | +| L2 | 일일 누적 -3% | 당일 신규 진입 중단 | +| L3 | 3연속 손절 | 당일 매매 중단 | +| L4 | 주간 누적 -7% | 주말까지 중단 + 텔레그램 경고 | +| L5 | 월간 누적 -15% | 전략 폐기 + 백테스트 재실행 | + +### 안전장치 +- VI 발동 → 해당 종목 즉시 청산 +- 호가 스프레드 > 0.5% → 진입 금지 +- 매수 후 5분 미체결 → 주문 취소 +- WebSocket 끊김 → 보유 포지션 즉시 시장가 청산 +- API 오류 10건/분 → kill-switch 자동 실행 +- Claude API 오류 → daily_context 없을 시 보수적 기본값으로 fallback + +```python +# Claude API 장애 시 fallback +DEFAULT_CONTEXT = { + "trade_allowed": True, + "market_sentiment": "중립", + "sentiment_score": 50, + "risk_level": "보통", + "hot_sectors": [], + "avoid_sectors": [], + "boosted_tickers": [], + "blacklist_tickers": [], + "position_size_multiplier": 0.8, # 보수적으로 축소 + "reason": "AI 판단 실패 - 기본값 적용" +} +``` + +--- + +## 8. KIS API 활용 전체 목록 (v2.0) + +### 기존 (실행 레이어) +| API | 용도 | 방식 | +|-----|------|------| +| H0STCNT0 | 실시간 체결가 | WebSocket | +| H0STASP0 | 실시간 호가 | WebSocket | +| H0STVI0 | VI 발동/해제 | WebSocket | +| TTTC0802U | 주식 매수 주문 | REST POST | +| TTTC0801U | 주식 매도 주문 | REST POST | +| TTTC8001R | 잔고 조회 | REST GET | + +### 신규 (AI 판단 레이어) +| API | 용도 | 수집 시각 | +|-----|------|---------| +| FHKST01010100 | 종목 현재가 (KOSPI/KOSDAQ 지수) | 07:40 | +| FHKST03010100 | 업종별 등락률 | 07:40 | +| FHPST01710000 | 거래량 순위 상위 30 | 07:40 | +| FHPST01700000 | 등락률 순위 상위 30 | 07:40 | +| FHKST04430000 | 외국인/기관 순매수 가집계 | 07:40 | + +> 위 API 호출은 모두 장 시작 전 단 1회 → rate limit 부담 없음 + +--- + +## 9. 스케줄 타임라인 (v2.0) + +``` +07:30 │ [AI] 네이버 금융 뉴스 크롤링 (전일 헤드라인 수집) +07:40 │ [AI] KIS REST API → 수급/순위/업종 데이터 수집 +08:00 │ [AI] Claude API 호출 → daily_context.json 생성 + │ 텔레그램 알림: "[AI분석] 오늘 시장: 중립 / 반도체 주목" +08:30 │ Universe 갱신 + AI 블랙리스트 적용 +08:50 │ WebSocket 연결, 30종목 구독 + │ 목표가 계산 (변동성 돌파) +09:00 │ ─────────── 진입 허용 시작 ─────────── + │ 1초 단위 asyncio 루프 시작 +11:30 │ 신규 진입 중단 (점심) +13:00 │ 신규 진입 재개 +14:30 │ 신규 진입 마감 +14:50 │ ─────────── 강제 청산 ─────────────── +15:00 │ WebSocket 종료 +15:10 │ 당일 결산 로그 +15:20 │ 텔레그램 일일 결산 알림 +15:30 │ 대기 +``` + +--- + +## 10. DB 스키마 (SQLite, v2.0) + +```sql +-- 기존 체결 내역 +CREATE TABLE trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + ticker TEXT NOT NULL, + entry_time TEXT NOT NULL, + exit_time TEXT, + entry_price REAL NOT NULL, + exit_price REAL, + quantity INTEGER NOT NULL, + side TEXT NOT NULL, + exit_reason TEXT, + pnl REAL, + fee REAL, + slippage REAL, + strategy TEXT DEFAULT 'VB', + ai_boosted INTEGER DEFAULT 0 -- [신규] AI boosted 여부 +); + +-- 기존 일일 요약 +CREATE TABLE daily_summary ( + date TEXT PRIMARY KEY, + total_trades INTEGER, + win_trades INTEGER, + lose_trades INTEGER, + gross_pnl REAL, + total_fee REAL, + net_pnl REAL, + max_drawdown REAL, + trading_stopped INTEGER DEFAULT 0 +); + +-- 포지션 (장중 현황) +CREATE TABLE positions ( + ticker TEXT PRIMARY KEY, + entry_time TEXT, + entry_price REAL, + quantity INTEGER, + tp1_done INTEGER DEFAULT 0, + target_price REAL, + stop_price REAL +); + +-- [신규] AI 판단 이력 +CREATE TABLE ai_context_log ( + date TEXT PRIMARY KEY, + generated_at TEXT, + trade_allowed INTEGER, + market_sentiment TEXT, + sentiment_score INTEGER, + risk_level TEXT, + hot_sectors TEXT, -- JSON 배열 + avoid_sectors TEXT, -- JSON 배열 + boosted_tickers TEXT, -- JSON 배열 + blacklist_tickers TEXT, -- JSON 배열 + position_size_mult REAL, + reason TEXT, + claude_tokens_used INTEGER, + api_call_success INTEGER DEFAULT 1 +); +``` + +--- + +## 11. 알림 설계 (텔레그램, v2.0) + +| 이벤트 | 메시지 형식 | +|--------|-----------| +| AI 분석 완료 | `[AI분석] 시장: 중립(62점) / 주목: 반도체,2차전지 / 회피: 금융` | +| AI 진입 차단 | `[AI차단] 삼성전자 진입 차단 - 금융 섹터 회피` | +| 매수 (일반) | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` | +| 매수 (AI부스트) | `[매수★] 하이닉스 185,000원 / AI 추천 종목` | +| 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` | +| 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` | +| 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` | +| L2 발동 | `[경고] 일일 손실 -3% 도달. 오늘 매매 중단.` | +| 일일 결산 | `[결산] 매매 5회 / 승 3 패 2 / 순손익 +1.2% / AI 정확도: 3/5` | +| 장애 | `[긴급] WebSocket 끊김. kill-switch 실행.` | +| AI 실패 | `[경고] AI 판단 실패. 기본값 적용 (비중 80%).` | + +--- + +## 12. 비용 분석 (월간) + +### Claude API 토큰 사용량 추정 +| 작업 | 빈도 | 토큰/회 | 월간 토큰 | +|------|------|---------|---------| +| 일일 시장 판단 | 1회/일 × 22거래일 | ~3,000 | ~66,000 | +| fallback 재시도 | 0~2회/월 | ~3,000 | ~6,000 | +| **합계** | | | **~72,000 토큰/월** | + +**월 비용 (Claude Sonnet 4): 약 $2~3 (한화 약 3,000~4,000원)** + +### 전체 운영 비용 +| 항목 | 월 비용 | +|------|--------| +| KIS API | 무료 | +| Claude API | ~$3 | +| 네이버 크롤링 | 무료 | +| Synology NAS 전기세 | 기존 운영 중이면 추가 없음 | +| **합계** | **~$3/월** | + +--- + +## 13. 백테스트 계획 (v2.0) + +### AI 판단 레이어 검증 방법 +AI 판단은 백테스트에 직접 포함 불가 (과거 뉴스 재현 어려움). +따라서 2단계로 분리 검증: + +``` +1단계: 규칙 기반 백테스트 (기존) + - 변동성 돌파 전략 단독 성과 측정 + - K값 최적화, 수수료/슬리피지 반영 + +2단계: AI 필터 사후 분석 (모의투자 후) + - 3개월 모의투자 후 daily_context.json 이력 vs 실제 결과 대조 + - AI가 "avoid" 판단한 날 손실률 vs "trade_allowed" 날 수익률 비교 + - AI 판단 정확도 계산 → 임계값 재조정 +``` + +### 합격 기준 (out-of-sample) +| 지표 | 통과 기준 | 목표 | +|------|----------|------| +| 샤프지수 | > 1.0 | > 1.5 | +| MDD | < 15% | < 10% | +| 승률 | > 45% | > 55% | +| 손익비 | > 1.3 | > 1.8 | +| AI 차단 정확도 | > 55% | > 65% | + +--- + +## 14. 인프라 (docker-compose.yml) + +```yaml +version: "3.9" + +services: + redis: + image: redis:7-alpine + container_name: stockbot-redis + restart: unless-stopped + volumes: + - ./data/redis:/data + + stockbot: + build: ./app + container_name: stockbot-main + restart: unless-stopped + depends_on: + - redis + env_file: .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + - TZ=Asia/Seoul + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + + kill-switch: + build: ./kill_switch + container_name: stockbot-killswitch + restart: "no" + env_file: .env + profiles: ["emergency"] + + dashboard: + build: ./monitor + container_name: stockbot-dashboard + restart: unless-stopped + ports: + - "8501:8501" + volumes: + - ./data:/app/data + environment: + - TZ=Asia/Seoul +``` + +### .env 구조 (v2.0) + +``` +# KIS API +KIS_APP_KEY=... +KIS_APP_SECRET=... +KIS_ACCOUNT_NO=... +KIS_MOCK=true + +# Claude API (신규) +ANTHROPIC_API_KEY=... +CLAUDE_MODEL=claude-sonnet-4-20250514 + +# 텔레그램 +TELEGRAM_TOKEN=... +TELEGRAM_CHAT_ID=... + +# Redis +REDIS_HOST=stockbot-redis +REDIS_PORT=6379 + +# 운영 모드 +DRY_RUN=true +LOG_LEVEL=INFO +AI_ENABLED=true # false 시 AI 레이어 비활성화, 기본값 사용 +``` + +--- + +## 15. 개발 로드맵 (v2.0) + +### Phase 1 — 데이터 기반 구축 (2주) +- [ ] KIS Open API 신청 + 모의투자 계좌 +- [ ] pykrx 분봉 데이터 백필 (2021~2024) +- [ ] SQLite 스키마 (v2.0 포함) +- [ ] Universe 스캐너 구현 +- [ ] Docker Compose NAS 기동 확인 + +### Phase 2 — 백테스트 (3주) +- [ ] vectorbt 변동성 돌파 백테스트 +- [ ] 수수료/슬리피지 반영 +- [ ] K값 그리드 서치 + out-of-sample 검증 + +### Phase 3 — KIS 연동 + dry-run (2주) +- [ ] KIS REST/WebSocket 클라이언트 +- [ ] OrderExecutor (시장가/지정가/IOC) +- [ ] DRY_RUN=true 신호 발생 확인 + +### Phase 4 — AI 레이어 구현 (2주) ← 신규 +- [ ] 네이버 금융 뉴스 크롤러 구현 +- [ ] KIS 순위/수급 API 수집 모듈 +- [ ] Claude API 연동 + 프롬프트 튜닝 +- [ ] daily_context.json 생성 파이프라인 +- [ ] fallback 로직 구현 +- [ ] ai_context_log DB 저장 + +### Phase 5 — 리스크 + 알림 + 대시보드 (2주) +- [ ] RiskManager L1~L5 구현 +- [ ] kill-switch 컨테이너 +- [ ] 텔레그램 Notifier (AI 메시지 포함) +- [ ] Streamlit 대시보드 (AI 판단 현황 패널 추가) + +### Phase 6 — 모의투자 실운영 (최소 3개월) +- [ ] KIS_MOCK=true, DRY_RUN=false +- [ ] 매일 결산 로그 + AI 정확도 추적 +- [ ] 3개월 후 AI 필터 임계값 재조정 +- [ ] 샤프 > 1.0, MDD < 15% 달성 시 Phase 7 + +### Phase 7 — 소액 실거래 (무기한) +- [ ] KIS_MOCK=false +- [ ] 총자산 5% 한도로 시작 +- [ ] 1개월 단위 성과 검토 + +--- + +## 16. 보안 체크리스트 + +- [ ] .env → .gitignore 등록 +- [ ] KIS/Anthropic API 키 → GitHub 절대 금지 +- [ ] ANTHROPIC_API_KEY 월 1회 암호화 백업 +- [ ] Synology 방화벽: 8501 내부망만 허용 +- [ ] 텔레그램 봇: chat_id 화이트리스트 +- [ ] 주문 로그 SQLite + logs/ 이중 보관 +- [ ] AI 판단 이력 ai_context_log 영구 보관 + +--- + +## 17. 절대 금지 (하드코딩) + +```python +BLACKLIST_REASONS = [ + "신규상장 6개월 미만", "관리종목", "투자경고", + "거래정지", "우선주", "스팩", "ETF/ETN", +] + +TRADING_BLACKOUT = [ + ("11:30", "13:00"), # 점심 + ("14:50", "15:30"), # 마감 + ("08:00", "09:00"), # 동시호가 +] + +HARD_EXIT_TIME = "14:50" # 절대 변경 불가 + +# AI가 trade_allowed=false 반환해도 이미 보유 중인 포지션 청산은 진행 +# AI는 신규 진입만 차단, 청산 로직에는 관여하지 않음 +AI_SCOPE = "ENTRY_ONLY" # 절대 변경 불가 +``` + +--- + +## 18. 면책 조항 + +> 본 기획서는 시스템 설계 문서이며, 투자 수익을 보장하지 않는다. +> AI 판단 레이어는 보조 필터일 뿐, 수익을 보장하지 않는다. +> 단타는 개인투자자의 90% 이상이 손실을 보는 영역이다. +> 반드시 모의투자 3개월 이상 검증 후 실거래 전환할 것. diff --git a/uploads/profile.jpg b/uploads/profile.jpg new file mode 100644 index 0000000..aa3c1e0 Binary files /dev/null and b/uploads/profile.jpg differ diff --git a/uploads/동물-사전/AnimalsDictionary.mp4 b/uploads/동물-사전/AnimalsDictionary.mp4 new file mode 100644 index 0000000..5db073e Binary files /dev/null and b/uploads/동물-사전/AnimalsDictionary.mp4 differ diff --git a/uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg b/uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg new file mode 100644 index 0000000..77efb42 Binary files /dev/null and b/uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg differ diff --git a/uploads/동물-사전/img_69fc432b0552a7.38602241.jpg b/uploads/동물-사전/img_69fc432b0552a7.38602241.jpg new file mode 100644 index 0000000..f2ed23d Binary files /dev/null and b/uploads/동물-사전/img_69fc432b0552a7.38602241.jpg differ