Compare commits

..

4 Commits

43 changed files with 421 additions and 9630 deletions
+36
View File
@@ -0,0 +1,36 @@
# Local agent/task workspace
.strideterm/
# Local secrets and environment overrides
api/config.local.php
api/config.local.php*
.env
.env.*
!.env.example
# Runtime logs
*.log
api/logs/*
!api/logs/.htaccess
# User-uploaded/generated media
uploads/*
!uploads/.htaccess
!uploads/**/.htaccess
# Local backups and temporary files
backup_*/
*_backup.*
*.bak
*.backup
*.tmp
*.temp
*.orig
*.rej
# OS/editor noise
.DS_Store
Thumbs.db
Desktop.ini
.vscode/
.idea/
+26 -13
View File
@@ -42,24 +42,36 @@ portfolio/
File Station에서 두 폴더의 권한을 `http` 그룹에 쓰기 가능하게 설정하세요.
### 4. 비밀번호 설정 (⚠️ 매우 중요)
### 4. 관리자 비밀번호와 보안 설정
**(A) 해시 생성**
브라우저에서 접속:
실제 관리자 비밀번호 해시는 저장소에 커밋하지 않습니다. 운영 서버에서는 다음 둘 중 하나로 설정하세요.
**(A) api/config.local.php 사용 권장**
1. `api/config.local.example.php``api/config.local.php`로 복사합니다.
2. 브라우저에서 다음 주소에 접속해 원하는 비밀번호의 해시를 생성합니다.
```
http://your-nas/portfolio/generate_password.php
```
원하는 비밀번호를 입력하면 해시가 출력됩니다.
3. 생성된 해시를 `api/config.local.php``ADMIN_PASSWORD_HASH` 값으로 넣습니다.
4. `api/config.local.php``.gitignore`에 포함되어 있으므로 저장소에 올리지 않습니다.
**(B) config.php 수정**
`api/config.php` 파일을 열어 아래 줄을 찾고:
```php
define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...');
**(B) 환경변수 사용**
서버 환경변수 `ADMIN_PASSWORD_HASH``password_hash()`로 만든 해시를 설정해도 됩니다.
```bash
ADMIN_PASSWORD_HASH='$2y$10$replace_with_your_generated_hash'
```
방금 생성된 해시로 교체합니다.
**(C) generate_password.php 삭제**
보안을 위해 반드시 삭제하세요!
`api/config.php`에는 기본 관리자 비밀번호나 실제 해시가 들어 있지 않아야 합니다. 설정이 없으면 관리자 로그인은 실패합니다.
**추가 운영 보안**
- 로그인 후 상태 변경 API는 CSRF 토큰을 요구합니다. 프론트엔드의 `fetch` 흐름은 로그인/세션 확인 응답에서 받은 토큰을 자동으로 보냅니다.
- 업로드 실패 응답에는 서버 내부 경로, 임시 경로, 저장 대상 경로가 노출되지 않습니다. 상세 오류는 서버 로그에서만 확인합니다.
- URL 입력은 `http`, `https`, `uploads/` 상대 경로만 허용합니다. CSS 색상 값은 hex 색상 allowlist로 제한합니다.
- 보안을 위해 `generate_password.php`는 설정 완료 후 운영 서버에서 삭제하거나 접근을 차단하세요.
### 5. 접속
- 메인: `http://your-nas/portfolio/`
@@ -82,7 +94,8 @@ define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...');
## 🔒 보안 체크리스트
- [ ] `generate_password.php` 삭제했는가?
- [ ] `config.php`ADMIN_PASSWORD_HASH를 실제 해시로 교체했는가?
- [ ] `api/config.local.php` 또는 `ADMIN_PASSWORD_HASH` 환경변수에 실제 관리자 해시가 설정되어 있는가?
- [ ] `api/config.local.php`가 저장소에 커밋되지 않는가?
- [ ] HTTPS 설정 (NAS의 Reverse Proxy 또는 Let's Encrypt)
- [ ] 비밀번호는 8자 이상, 영문/숫자/기호 조합
- [ ] `data/`, `uploads/` 폴더의 .htaccess 파일이 동작하는지 확인
@@ -107,7 +120,7 @@ git push -u origin main
`data/` 폴더의 쓰기 권한을 확인하세요.
**로그인이 안 됨**
`config.php`ADMIN_PASSWORD_HASH가 올바르게 교체되었는지 확인하세요.
`api/config.local.php` 또는 `ADMIN_PASSWORD_HASH` 환경변수에 올바른 해시가 설정되어 있는지 확인하세요.
**.htaccess가 동작하지 않음**
→ Web Station에서 Apache 사용 + `mod_rewrite`, `AllowOverride All` 설정 필요.
+13 -3
View File
@@ -1,7 +1,7 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
$method = $_SERVER['REQUEST_METHOD'];
@@ -21,12 +21,16 @@ if ($method === 'POST' && $action === 'login') {
// 무차별 대입 방지 - 간단한 딜레이
usleep(500000); // 0.5초
if (ADMIN_PASSWORD_HASH === '') {
json_response(['error' => 'Admin password is not configured'], 500);
}
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
// 세션 고정 공격 방지
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['login_time'] = time();
json_response(['success' => true, 'message' => '로그인 성공']);
json_response(['success' => true, 'message' => '로그인 성공', 'csrf_token' => ensure_csrf_token()]);
} else {
json_response(['error' => '비밀번호가 일치하지 않습니다'], 401);
}
@@ -36,6 +40,8 @@ if ($method === 'POST' && $action === 'login') {
// 로그아웃
// =====================================================
if ($method === 'POST' && $action === 'logout') {
require_auth();
require_csrf();
session_destroy();
json_response(['success' => true]);
}
@@ -54,7 +60,11 @@ if ($method === 'GET' && $action === 'check') {
}
}
json_response(['authenticated' => $authenticated]);
$response = ['authenticated' => $authenticated];
if ($authenticated) {
$response['csrf_token'] = ensure_csrf_token();
}
json_response($response);
}
json_response(['error' => 'Invalid action'], 400);
+5 -4
View File
@@ -1,7 +1,7 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
define('CATEGORIES_FILE', DATA_DIR . '/categories.json');
@@ -12,7 +12,7 @@ $method = $_SERVER['REQUEST_METHOD'];
function normalize_category($cat) {
if (!isset($cat['parent_id'])) $cat['parent_id'] = null;
if (!isset($cat['order'])) $cat['order'] = 0;
if (!isset($cat['color'])) $cat['color'] = '#00f2ff';
$cat['color'] = clean_css_color($cat['color'] ?? '#00f2ff');
return $cat;
}
@@ -35,6 +35,7 @@ if ($method === 'GET') {
}
require_auth();
require_csrf();
// =====================================================
// POST: 카테고리 추가
@@ -42,7 +43,7 @@ require_auth();
if ($method === 'POST') {
$input = get_json_input();
$name = trim($input['name'] ?? '');
$color = trim($input['color'] ?? '#00f2ff');
$color = clean_css_color($input['color'] ?? '#00f2ff');
$parentId = isset($input['parent_id']) && $input['parent_id'] !== '' && $input['parent_id'] !== null
? intval($input['parent_id'])
: null;
@@ -115,7 +116,7 @@ if ($method === 'PUT') {
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['color'])) $categories[$key]['color'] = clean_css_color($input['color']);
if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']);
// parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게
if (array_key_exists('parent_id', $input)) {
+5
View File
@@ -0,0 +1,5 @@
<?php
// Copy this file to api/config.local.php on the server and replace the value.
// Generate a hash with generate_password.php or:
// php -r "echo password_hash('your-strong-password', PASSWORD_DEFAULT);"
define('ADMIN_PASSWORD_HASH', '$2y$10$replace_with_your_generated_hash');
+102 -38
View File
@@ -1,39 +1,57 @@
<?php
// =====================================================
// 설정 파일
// Runtime configuration
// =====================================================
// ⚠️ 중요: 처음 사용 시 아래 ADMIN_PASSWORD_HASH를 변경하세요
// 비밀번호 해시 생성 방법:
// 브라우저에서 generate_password.php 접속 후 원하는 비번 입력
// 또는 터미널에서: php -r "echo password_hash('your_password', PASSWORD_DEFAULT);"
// ADMIN_PASSWORD_HASH is loaded from api/config.local.php or the environment.
// Do not commit real password hashes or other secrets to the repository.
// =====================================================
// 기본 비밀번호: "admin1234" (반드시 변경하세요!)
define('ADMIN_PASSWORD_HASH', '$2y$10$Wj/5fxQX90AlvyVPBfE0te2aUbysSBlE/Umm7EluG880rqcRUlHGm');
$localConfig = __DIR__ . '/config.local.php';
if (is_file($localConfig)) {
require_once $localConfig;
}
// 데이터 파일 경로
if (!defined('ADMIN_PASSWORD_HASH')) {
$adminPasswordHash = getenv('ADMIN_PASSWORD_HASH');
define('ADMIN_PASSWORD_HASH', is_string($adminPasswordHash) ? trim($adminPasswordHash) : '');
}
// Data paths
define('DATA_DIR', __DIR__ . '/../data');
define('UPLOADS_DIR', __DIR__ . '/../uploads');
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
define('PROFILE_FILE', DATA_DIR . '/profile.json');
// 세션 설정
define('SESSION_LIFETIME', 3600 * 4); // 4시간
// Session settings
define('SESSION_LIFETIME', 3600 * 4);
function start_secure_session() {
if (session_status() !== PHP_SESSION_NONE) {
return;
}
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
// CORS 및 JSON 헤더
function set_json_headers() {
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
}
// JSON 파일을 락 걸고 읽기
function read_json_safe($filepath) {
if (!file_exists($filepath)) {
return null;
}
$fp = fopen($filepath, 'r');
if (!$fp) return null;
if (flock($fp, LOCK_SH)) {
$content = stream_get_contents($fp);
flock($fp, LOCK_UN);
@@ -44,41 +62,33 @@ function read_json_safe($filepath) {
return null;
}
// JSON 파일을 락 걸고 쓰기
// Write JSON atomically so a crash during write does not leave a 0-byte file.
function write_json_safe($filepath, $data) {
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) return false;
$fp = fopen($filepath, 'c+');
if (!$fp) return false;
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $json);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return true;
$tmp = $filepath . '.tmp.' . uniqid('', true);
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
@unlink($tmp);
return false;
}
fclose($fp);
return false;
if (!rename($tmp, $filepath)) {
@unlink($tmp);
return false;
}
return true;
}
// 인증 체크
function require_auth() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
start_secure_session();
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// 세션 타임아웃 체크
if (isset($_SESSION['login_time']) &&
if (isset($_SESSION['login_time']) &&
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
session_destroy();
http_response_code(401);
@@ -87,13 +97,67 @@ function require_auth() {
}
}
// JSON 입력 받기
function ensure_csrf_token() {
start_secure_session();
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function require_csrf() {
start_secure_session();
$sessionToken = $_SESSION['csrf_token'] ?? '';
$requestToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!is_string($sessionToken) || $sessionToken === '' ||
!is_string($requestToken) || !hash_equals($sessionToken, $requestToken)) {
json_response(['error' => 'Invalid CSRF token'], 403);
}
}
function get_json_input() {
$input = file_get_contents('php://input');
return json_decode($input, true);
}
// 응답 헬퍼
function is_safe_public_url($url) {
if (!is_string($url)) return false;
$url = trim($url);
if ($url === '') return true;
if (preg_match('#^uploads/[A-Za-z0-9가-힣._~!$&\'()*+,;=:@%/-]+$#u', $url)) {
return !str_contains($url, '..') && !preg_match('#(^|/)\.#', $url);
}
$parts = parse_url($url);
if ($parts === false || empty($parts['scheme'])) return false;
return in_array(strtolower($parts['scheme']), ['http', 'https'], true);
}
function clean_public_url($url) {
$url = is_string($url) ? trim($url) : '';
return is_safe_public_url($url) ? $url : '';
}
function clean_public_urls($urls) {
if (!is_array($urls)) return [];
$clean = [];
foreach ($urls as $url) {
$url = clean_public_url($url);
if ($url !== '') $clean[] = $url;
}
return array_values(array_unique($clean));
}
function clean_css_color($value, $fallback = '#00f2ff') {
$value = is_string($value) ? trim($value) : '';
if (preg_match('/^#[0-9a-fA-F]{6}$/', $value) || preg_match('/^#[0-9a-fA-F]{3}$/', $value)) {
return $value;
}
return $fallback;
}
function json_response($data, $status = 200) {
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
+2 -1
View File
@@ -1,9 +1,10 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
require_auth();
require_csrf();
$method = $_SERVER['REQUEST_METHOD'];
+22 -3
View File
@@ -1,13 +1,31 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
define('LEARNING_FILE', DATA_DIR . '/learning.json');
$method = $_SERVER['REQUEST_METHOD'];
function clean_learning_content($content) {
$content = is_string($content) ? trim($content) : '';
$content = preg_replace_callback('/\]\(([^)]+)\)/', function($m) {
$url = trim($m[1]);
return is_safe_public_url($url) ? '](' . $url . ')' : '](#)';
}, $content);
$content = preg_replace_callback('/@video(\[[^\]]*\])?\(([^)]+)\)/', function($m) {
$attrs = $m[1] ?? '';
$url = trim($m[2]);
return is_safe_public_url($url) ? '@video' . $attrs . '(' . $url . ')' : '';
}, $content);
$content = preg_replace_callback('/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/', function($m) {
$color = clean_css_color($m[1], '');
return $color === '' ? $m[2] : '{color:' . $color . '}' . $m[2] . '{/color}';
}, $content);
return $content;
}
// =====================================================
// GET: 학습 일지 목록 또는 단일 글 (인증 불필요)
// =====================================================
@@ -63,6 +81,7 @@ if ($method === 'GET') {
}
require_auth();
require_csrf();
// =====================================================
// POST: 새 학습 일지 작성
@@ -71,7 +90,7 @@ if ($method === 'POST') {
$input = get_json_input();
$title = trim($input['title'] ?? '');
$content = trim($input['content'] ?? '');
$content = clean_learning_content($input['content'] ?? '');
$categoryId = intval($input['category_id'] ?? 0);
if (empty($title) || empty($content)) {
@@ -170,7 +189,7 @@ if ($method === 'PUT') {
$learnings[$key]['title'] = $title;
}
if (isset($input['content'])) {
$content = trim($input['content']);
$content = clean_learning_content($input['content']);
if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400);
$learnings[$key]['content'] = $content;
}
+8
View File
@@ -0,0 +1,8 @@
# 로그 디렉토리 웹 직접 접근 차단
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
View File
+10 -3
View File
@@ -1,7 +1,7 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
$method = $_SERVER['REQUEST_METHOD'];
@@ -49,6 +49,7 @@ if ($method === 'GET') {
// 이하 수정은 인증 필요
require_auth();
require_csrf();
// =====================================================
// PUT: 프로필 전체 업데이트
@@ -66,7 +67,7 @@ if ($method === 'PUT') {
'name' => trim($input['name'] ?? $current['name'] ?? ''),
'title' => trim($input['title'] ?? $current['title'] ?? ''),
'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''),
'avatar' => trim($input['avatar'] ?? $current['avatar'] ?? ''),
'avatar' => clean_public_url($input['avatar'] ?? $current['avatar'] ?? ''),
'bio' => trim($input['bio'] ?? $current['bio'] ?? ''),
'location' => trim($input['location'] ?? $current['location'] ?? ''),
'email' => trim($input['email'] ?? $current['email'] ?? ''),
@@ -101,7 +102,13 @@ if ($method === 'PUT') {
}
if (isset($input['social']) && is_array($input['social'])) {
$updated['social'] = array_merge($current['social'] ?? [], $input['social']);
$social = array_merge($current['social'] ?? [], $input['social']);
foreach (['github', 'linkedin', 'blog'] as $key) {
if (isset($social[$key])) {
$social[$key] = clean_public_url($social[$key]);
}
}
$updated['social'] = $social;
}
if (write_json_safe(PROFILE_FILE, $updated)) {
+9 -11
View File
@@ -1,7 +1,7 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
$method = $_SERVER['REQUEST_METHOD'];
@@ -11,14 +11,11 @@ $method = $_SERVER['REQUEST_METHOD'];
// =====================================================
function normalize_images($input) {
if (isset($input['images']) && is_array($input['images'])) {
$images = array_values(array_filter(
array_map('trim', $input['images']),
fn($v) => $v !== ''
));
return $images;
return clean_public_urls($input['images']);
}
if (isset($input['image']) && trim($input['image']) !== '') {
return [trim($input['image'])];
$image = clean_public_url($input['image']);
return $image === '' ? [] : [$image];
}
return [];
}
@@ -80,6 +77,7 @@ if ($method === 'GET') {
}
require_auth();
require_csrf();
// =====================================================
// POST: 새 프로젝트 추가
@@ -113,11 +111,11 @@ if ($method === 'POST') {
'icon' => trim($input['icon'] ?? 'fa-solid fa-code'),
'images' => $images,
'image' => $images[0] ?? '',
'link' => trim($input['link'] ?? ''),
'link' => clean_public_url($input['link'] ?? ''),
'stack' => $stack,
'period_start' => trim($input['period_start'] ?? ''),
'period_end' => trim($input['period_end'] ?? ''),
'video_url' => trim($input['video_url'] ?? ''),
'video_url' => clean_public_url($input['video_url'] ?? ''),
'created_at' => date('Y-m-d')
];
@@ -150,7 +148,7 @@ if ($method === 'PUT') {
$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']);
$projects[$key]['link'] = clean_public_url($input['link'] ?? $project['link']);
if (isset($input['images']) || isset($input['image'])) {
$images = normalize_images($input);
@@ -169,7 +167,7 @@ if ($method === 'PUT') {
$projects[$key]['period_end'] = trim($input['period_end']);
}
if (isset($input['video_url'])) {
$projects[$key]['video_url'] = trim($input['video_url']);
$projects[$key]['video_url'] = clean_public_url($input['video_url']);
}
// 기존 demo_url 필드 제거
+8 -2
View File
@@ -1,9 +1,10 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
start_secure_session();
set_json_headers();
require_auth();
require_csrf();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['error' => 'POST만 허용됩니다'], 405);
@@ -225,5 +226,10 @@ if ($saved) {
]);
} 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);
error_log('Upload save failed: ' . json_encode([
'detail' => $err,
'target' => $target_path,
'tmp_exists' => file_exists($file['tmp_name'])
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
json_response(['error' => '파일 저장 실패'], 500);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+38 -14
View File
@@ -883,6 +883,7 @@ main { padding: 80px 8%; }
<script>
// ===== 상태 =====
let isAdmin = false;
let csrfToken = '';
// ===== 테마 =====
function applyTheme(theme) {
@@ -977,10 +978,19 @@ async function checkAuth() {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
csrfToken = data.csrf_token || '';
document.getElementById('adminControls').classList.toggle('hidden', !isAdmin);
} catch (e) { isAdmin = false; }
}
function csrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
function csrfFetch(url, options = {}) {
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
}
function openLoginModal() {
if (isAdmin) { logout(); return; }
document.getElementById('loginModal').classList.add('active');
@@ -1004,6 +1014,7 @@ async function doLogin() {
});
const data = await res.json();
if (data.success) {
csrfToken = data.csrf_token || csrfToken;
isAdmin = true;
document.getElementById('adminControls').classList.remove('hidden');
closeLoginModal();
@@ -1017,7 +1028,8 @@ async function doLogin() {
}
async function logout() {
await fetch('api/auth.php?action=logout', { method: 'POST' });
await csrfFetch('api/auth.php?action=logout', { method: 'POST' });
csrfToken = '';
isAdmin = false;
document.getElementById('adminControls').classList.add('hidden');
await loadProjects();
@@ -1069,6 +1081,8 @@ function renderProjects() {
grid.innerHTML = pageProjects.map(p => {
const images = (p.images && p.images.length > 0) ? p.images : (p.image ? [p.image] : []);
const hasMultiple = images.length > 1;
const safeLink = sanitizeUrl(p.link);
const safeVideoUrl = sanitizeUrl(p.video_url);
const cardId = `card-${p.id}`;
return `
@@ -1087,7 +1101,7 @@ function renderProjects() {
onmouseleave="resumeSlideshow('${cardId}')">
${images.map((img, i) => `
<div class="slideshow-slide ${i === 0 ? 'active' : ''}"
style="background-image:url('${escapeHtml(img)}')"></div>
style="background-image:url('${escapeHtml(sanitizeUrl(img))}')"></div>
`).join('')}
${hasMultiple ? `
<button class="slideshow-arrow prev" onclick="slideshowPrev('${cardId}')" aria-label="이전">
@@ -1125,10 +1139,10 @@ function renderProjects() {
</div>
` : ''}
<div class="card-actions">
${p.link ? `<a href="${escapeHtml(p.link)}" class="btn-link git" target="_blank">
${safeLink ? `<a href="${escapeHtml(safeLink)}" class="btn-link git" target="_blank" rel="noopener noreferrer">
<i class="fa-brands fa-git-alt"></i><span class="btn-text"> 소스 코드</span>
</a>` : ''}
${p.video_url ? `<a href="${escapeHtml(p.video_url)}" class="btn-link video" target="_blank">
${safeVideoUrl ? `<a href="${escapeHtml(safeVideoUrl)}" class="btn-link video" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-circle-play"></i><span class="btn-text"> 영상 보기</span>
</a>` : ''}
</div>
@@ -1734,7 +1748,7 @@ async function saveProject() {
const formData = new FormData();
formData.append('file', item.file);
formData.append('project_title', title);
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (!result.success) throw new Error(result.error || '이미지 업로드 실패');
URL.revokeObjectURL(item.url); // 메모리 해제
@@ -1753,7 +1767,7 @@ async function saveProject() {
const formData = new FormData();
formData.append('file', pendingVideoFile);
formData.append('project_title', title);
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (!result.success) throw new Error(result.error || '영상 업로드 실패');
videoUrl = result.url;
@@ -1784,13 +1798,13 @@ async function saveProject() {
let res;
if (id) {
data.id = parseInt(id);
res = await fetch('api/projects.php', {
res = await csrfFetch('api/projects.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await fetch('api/projects.php', {
res = await csrfFetch('api/projects.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -1849,7 +1863,7 @@ async function deleteProject(id) {
}
try {
const res = await fetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const res = await csrfFetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (!result.success) {
alert(result.error || '삭제 실패');
@@ -1858,16 +1872,14 @@ async function deleteProject(id) {
if (deleteFolder && folderName) {
try {
const fileRes = await fetch('api/delete_files.php', {
const fileRes = await csrfFetch('api/delete_files.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: folderName })
});
const fileResult = await fileRes.json();
if (fileResult.success) {
if (fileResult.deleted_count > 0) {
console.log(`이미지 폴더 정리: ${fileResult.deleted_count}개 파일 삭제됨`);
}
if (!fileResult.success || fileResult.failed_count > 0) {
alert('프로젝트는 삭제되었지만, 일부 파일 삭제에 실패했습니다.');
}
} catch (e) {
alert('프로젝트는 삭제되었지만, 폴더 삭제 중 오류: ' + e.message);
@@ -1896,6 +1908,18 @@ function escapeHtml(str) {
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
// ESC 키로 모달 닫기 (바깥 클릭은 무시 - 실수 방지)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
-639
View File
@@ -1,639 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이종재 | Game & XR Developer</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+KR:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--primary: #00f2ff;
--secondary: #7000ff;
--bg-dark: #0a0a0f;
--card-bg: #161625;
--text-white: #e2e8f0;
--text-dim: #94a3b8;
--danger: #ff4757;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: var(--bg-dark);
color: var(--text-white);
line-height: 1.7;
overflow-x: hidden;
}
h1, h2, h3, .logo, .card-label { font-family: 'Orbitron', sans-serif; }
nav {
background: rgba(10, 10, 15, 0.95);
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 rgba(0, 242, 255, 0.1);
}
nav .logo { font-weight: 700; font-size: 1.4rem; color: var(--primary); letter-spacing: 2px; }
nav .links { display: flex; align-items: center; gap: 2rem; }
nav .links a {
text-decoration: none;
color: var(--text-white);
font-size: 0.9rem;
transition: 0.3s;
display: flex;
align-items: center;
gap: 6px;
}
nav .links a:hover { color: var(--primary); }
nav .links a.profile-link {
border: 1px solid var(--primary);
padding: 0.5rem 1rem;
border-radius: 6px;
}
nav .links a.profile-link:hover {
background: var(--primary);
color: #000;
}
header {
min-height: 60vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 80px 20px;
background:
radial-gradient(circle at 30% 50%, rgba(112, 0, 255, 0.15) 0%, transparent 50%),
radial-gradient(circle at 70% 50%, rgba(0, 242, 255, 0.1) 0%, transparent 50%);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
header h1 {
font-size: 3.5rem;
margin-bottom: 1rem;
text-shadow: 0 0 30px rgba(0, 242, 255, 0.5);
letter-spacing: 2px;
}
header p { font-size: 1.2rem; color: var(--text-dim); max-width: 800px; padding: 0 20px; }
.highlight { color: var(--primary); }
main { padding: 80px 8%; }
.section-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4rem; }
.section-header h2 { font-size: 2rem; letter-spacing: 1px; }
.line { flex-grow: 1; height: 1px; background: rgba(0, 242, 255, 0.2); }
.admin-controls {
display: flex;
gap: 10px;
}
.admin-controls.hidden { display: none; }
.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: #000; font-weight: 700; }
.btn-primary:hover { background: #fff; transform: translateY(-2px); }
.btn-outline { background: transparent; border: 1px solid var(--primary); color: var(--primary); }
.btn-outline:hover { background: var(--primary); color: #000; }
.btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2.5rem;
}
.card {
background: var(--card-bg);
border-radius: 1.2rem;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.05);
transition: 0.3s ease;
position: relative;
}
.card:hover { transform: translateY(-10px); border-color: var(--primary); box-shadow: 0 10px 40px rgba(0, 242, 255, 0.1); }
.card-img {
width: 100%;
height: 200px;
background: linear-gradient(135deg, #1e1e30 0%, #2a1a3e 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 3.5rem;
color: rgba(255,255,255,0.15);
background-size: cover;
background-position: center;
}
.card-content { padding: 1.8rem; }
.card-label {
color: var(--primary);
font-size: 0.7rem;
letter-spacing: 2px;
margin-bottom: 0.8rem;
display: block;
font-weight: 700;
}
.card h3 { font-size: 1.4rem; margin-bottom: 0.8rem; }
.card p { color: var(--text-dim); margin-bottom: 1.5rem; font-size: 0.95rem; }
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.card-actions.admin-mode {
border-top: 1px dashed rgba(255,255,255,0.1);
padding-top: 1rem;
margin-top: 1rem;
}
.btn-git {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #000;
background: var(--primary);
padding: 0.7rem 1.2rem;
border-radius: 0.6rem;
font-weight: 700;
font-size: 0.85rem;
transition: 0.3s;
}
.btn-git:hover { background: white; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; }
/* 모달 */
.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(--card-bg);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 1rem;
padding: 2rem;
max-width: 550px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal h2 {
color: var(--primary);
margin-bottom: 1.5rem;
font-size: 1.4rem;
letter-spacing: 1px;
}
.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: #0a0a0f;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: var(--text-white);
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 rgba(0, 242, 255, 0.1);
}
.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: rgba(0, 242, 255, 0.1); border: 1px solid var(--primary); color: var(--primary); }
.icon-hint {
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 4px;
}
.icon-hint a { color: var(--primary); }
footer {
padding: 60px 8% 30px;
text-align: center;
border-top: 1px solid rgba(255,255,255,0.05);
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); }
@media (max-width: 768px) {
header h1 { font-size: 2.2rem; }
.grid { grid-template-columns: 1fr; }
nav { padding: 1rem 5%; }
nav .links { gap: 1rem; }
main { padding: 60px 5%; }
}
</style>
</head>
<body>
<nav>
<div class="logo">JONGJAE.XR</div>
<div class="links">
<a href="#work">PROJECTS</a>
<a href="profile.html" class="profile-link">
<i class="fa-solid fa-user"></i> PROFILE
</a>
</div>
</nav>
<header>
<h1>이종재 <span class="highlight">Portfolio</span></h1>
<p>Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.<br>Gitea 서버를 통해 실제 <span class="highlight">소스 코드</span>를 확인하실 수 있습니다.</p>
</header>
<main id="work">
<div class="section-header">
<h2>DEVELOPMENT LOG</h2>
<div class="line"></div>
<div class="admin-controls hidden" id="adminControls">
<button class="btn btn-primary" onclick="openProjectModal()">
<i class="fa-solid fa-plus"></i> 새 프로젝트
</button>
<button class="btn btn-outline" onclick="logout()">
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
</button>
</div>
</div>
<div class="grid" id="projectsGrid">
<!-- 프로젝트가 동적으로 로드됩니다 -->
</div>
</main>
<footer>
<p>&copy; 2026 Lee Jong-jae. Hosted on Private Synology NAS.</p>
<span class="admin-toggle" onclick="openLoginModal()" title="Admin">
<i class="fa-solid fa-shield-halved"></i>
</span>
</footer>
<!-- 로그인 모달 -->
<div class="modal-overlay" id="loginModal">
<div class="modal">
<h2><i class="fa-solid fa-lock"></i> ADMIN LOGIN</h2>
<div id="loginAlert"></div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="loginPassword" placeholder="비밀번호를 입력하세요"
onkeypress="if(event.key==='Enter') doLogin()">
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeLoginModal()">취소</button>
<button class="btn btn-primary" onclick="doLogin()">로그인</button>
</div>
</div>
</div>
<!-- 프로젝트 등록/수정 모달 -->
<div class="modal-overlay" id="projectModal">
<div class="modal">
<h2 id="projectModalTitle">새 프로젝트</h2>
<div id="projectAlert"></div>
<input type="hidden" id="projectId">
<div class="form-group">
<label>제목 *</label>
<input type="text" id="projectTitle" placeholder="예: XR 인터랙티브 시뮬레이션">
</div>
<div class="form-group">
<label>라벨 *</label>
<input type="text" id="projectLabel" placeholder="예: UNITY / XR">
</div>
<div class="form-group">
<label>설명 *</label>
<textarea id="projectDescription" placeholder="프로젝트 소개"></textarea>
</div>
<div class="form-group">
<label>아이콘 (Font Awesome 클래스)</label>
<input type="text" id="projectIcon" placeholder="예: fa-solid fa-cube">
<p class="icon-hint">예시: <code>fa-solid fa-cube</code> · <code>fa-solid fa-gamepad</code> · <code>fa-solid fa-microchip</code> · <a href="https://fontawesome.com/search?o=r&m=free" target="_blank">아이콘 검색</a></p>
</div>
<div class="form-group">
<label>썸네일 이미지 URL (선택)</label>
<input type="text" id="projectImage" placeholder="비우면 아이콘 표시">
</div>
<div class="form-group">
<label>링크 URL (Gitea 저장소 등)</label>
<input type="text" id="projectLink" placeholder="https://...">
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeProjectModal()">취소</button>
<button class="btn btn-primary" onclick="saveProject()">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
</div>
</div>
</div>
<script>
// ===== 상태 =====
let isAdmin = false;
let projects = [];
// ===== 초기화 =====
async function init() {
await checkAuth();
await loadProjects();
}
// ===== 인증 =====
async function checkAuth() {
try {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
document.getElementById('adminControls').classList.toggle('hidden', !isAdmin);
} catch (e) {
isAdmin = false;
}
}
function openLoginModal() {
if (isAdmin) {
logout();
return;
}
document.getElementById('loginModal').classList.add('active');
document.getElementById('loginPassword').focus();
}
function closeLoginModal() {
document.getElementById('loginModal').classList.remove('active');
document.getElementById('loginPassword').value = '';
document.getElementById('loginAlert').innerHTML = '';
}
async function doLogin() {
const password = document.getElementById('loginPassword').value;
if (!password) return;
try {
const res = await fetch('api/auth.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (data.success) {
isAdmin = true;
document.getElementById('adminControls').classList.remove('hidden');
closeLoginModal();
await loadProjects();
} else {
showAlert('loginAlert', data.error || '로그인 실패', 'error');
}
} catch (e) {
showAlert('loginAlert', '서버 오류', 'error');
}
}
async function logout() {
await fetch('api/auth.php?action=logout', { method: 'POST' });
isAdmin = false;
document.getElementById('adminControls').classList.add('hidden');
await loadProjects();
}
// ===== 프로젝트 로드 =====
async function loadProjects() {
try {
const res = await fetch('api/projects.php');
projects = await res.json();
renderProjects();
} catch (e) {
console.error('Failed to load projects', e);
}
}
function renderProjects() {
const grid = document.getElementById('projectsGrid');
if (!projects || projects.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<i class="fa-solid fa-folder-open"></i>
<p>아직 등록된 프로젝트가 없습니다.</p>
</div>
`;
return;
}
grid.innerHTML = projects.map(p => `
<div class="card">
<div class="card-img" ${p.image ? `style="background-image:url('${escapeHtml(p.image)}'); font-size:0;"` : ''}>
${!p.image ? `<i class="${escapeHtml(p.icon || 'fa-solid fa-code')}"></i>` : ''}
</div>
<div class="card-content">
<span class="card-label">${escapeHtml(p.label)}</span>
<h3>${escapeHtml(p.title)}</h3>
<p>${escapeHtml(p.description)}</p>
<div class="card-actions">
${p.link ? `<a href="${escapeHtml(p.link)}" class="btn-git" target="_blank">
<i class="fa-brands fa-git-alt"></i> 소스 코드 보기
</a>` : ''}
</div>
${isAdmin ? `
<div class="card-actions admin-mode">
<button class="btn btn-outline" onclick="editProject(${p.id})">
<i class="fa-solid fa-pen"></i> 수정
</button>
<button class="btn btn-danger" onclick="deleteProject(${p.id})">
<i class="fa-solid fa-trash"></i> 삭제
</button>
</div>` : ''}
</div>
</div>
`).join('');
}
// ===== 프로젝트 모달 =====
function openProjectModal(project = null) {
document.getElementById('projectAlert').innerHTML = '';
if (project) {
document.getElementById('projectModalTitle').textContent = '프로젝트 수정';
document.getElementById('projectId').value = project.id;
document.getElementById('projectTitle').value = project.title;
document.getElementById('projectLabel').value = project.label;
document.getElementById('projectDescription').value = project.description;
document.getElementById('projectIcon').value = project.icon || '';
document.getElementById('projectImage').value = project.image || '';
document.getElementById('projectLink').value = project.link || '';
} else {
document.getElementById('projectModalTitle').textContent = '새 프로젝트';
document.getElementById('projectId').value = '';
['projectTitle', 'projectLabel', 'projectDescription',
'projectIcon', 'projectImage', 'projectLink'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('projectIcon').value = 'fa-solid fa-code';
}
document.getElementById('projectModal').classList.add('active');
}
function closeProjectModal() {
document.getElementById('projectModal').classList.remove('active');
}
function editProject(id) {
const project = projects.find(p => p.id === id);
if (project) openProjectModal(project);
}
async function saveProject() {
const id = document.getElementById('projectId').value;
const data = {
title: document.getElementById('projectTitle').value.trim(),
label: document.getElementById('projectLabel').value.trim(),
description: document.getElementById('projectDescription').value.trim(),
icon: document.getElementById('projectIcon').value.trim(),
image: document.getElementById('projectImage').value.trim(),
link: document.getElementById('projectLink').value.trim()
};
if (!data.title || !data.label || !data.description) {
showAlert('projectAlert', '제목, 라벨, 설명은 필수입니다', 'error');
return;
}
try {
let res;
if (id) {
data.id = parseInt(id);
res = await fetch('api/projects.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await fetch('api/projects.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await res.json();
if (result.success) {
closeProjectModal();
await loadProjects();
} else {
showAlert('projectAlert', result.error || '저장 실패', 'error');
}
} catch (e) {
showAlert('projectAlert', '서버 오류', 'error');
}
}
async function deleteProject(id) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await fetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
await loadProjects();
} else {
alert(result.error || '삭제 실패');
}
} catch (e) {
alert('서버 오류');
}
}
// ===== 유틸 =====
function showAlert(elemId, message, type) {
const elem = document.getElementById(elemId);
elem.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
if (type === 'success') {
setTimeout(() => elem.innerHTML = '', 3000);
}
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 모달 외부 클릭 시 닫기
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
init();
</script>
</body>
</html>
+110 -41
View File
@@ -1585,7 +1585,7 @@ footer {
</div>
<div class="form-group">
<label>색상</label>
<input type="color" id="categoryColor" value="var(--primary)" style="height: 44px;">
<input type="color" id="categoryColor" value="#00f2ff" style="height: 44px;">
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="saveCategory()">
@@ -1981,6 +1981,7 @@ function toggleTheme() {
applyTheme(saved || prefer);
})();
let isAdmin = false;
let csrfToken = '';
let categories = []; // 모든 카테고리 (parent + child)
let posts = [];
let currentFilter = { type: 'all', value: null };
@@ -2013,10 +2014,7 @@ function renderMarkdown(src) {
parts.forEach(p => {
const m = p.match(/^w(?:idth)?\s*=\s*(.+)$/i);
if (m) {
let w = m[1].trim();
// 숫자만 있으면 px 추가
if (/^\d+$/.test(w)) w = w + 'px';
result.width = w;
result.width = sanitizeMediaWidth(m[1].trim());
return;
}
if (/^(left|center|right)$/i.test(p)) {
@@ -2040,6 +2038,8 @@ function renderMarkdown(src) {
src = src.replace(
/@video(?:\[([^\]]*)\])?\(([^)]+)\)/g,
function(match, attrStr, url) {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return '';
const attrs = parseAttrs(attrStr || '');
// width와 align은 wrap에 적용 (video 자체가 아님)
const styles = [];
@@ -2048,7 +2048,7 @@ function renderMarkdown(src) {
if (alignS) styles.push(alignS.replace(/;$/, ''));
styles.push('max-width:100%');
const wrapStyle = `style="${styles.join(';')}"`;
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${url.trim()}"></video></div>`;
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${escapeHtml(safeUrl)}"></video></div>`;
}
);
@@ -2057,13 +2057,15 @@ function renderMarkdown(src) {
src = src.replace(
/!\[([^\]]*?)\|([^\]]+)\]\(([^)]+)\)/g,
function(match, alt, attrStr, url) {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return '';
const attrs = parseAttrs(attrStr);
const styles = [];
if (attrs.width) styles.push(`width:${attrs.width}`);
styles.push('max-width:100%');
const alignS = alignStyle(attrs.align);
if (alignS) styles.push(alignS.replace(/;$/, ''));
return `<img src="${url.trim()}" alt="${alt}" style="${styles.join(';')};">`;
return `<img src="${escapeHtml(safeUrl)}" alt="${escapeHtml(alt)}" style="${styles.join(';')};">`;
}
);
@@ -2091,10 +2093,12 @@ function renderMarkdown(src) {
src = src.replace(/==([^=\n\[]+)==/g, '<mark>$1</mark>');
// ===== 5) 색상: {color:#ff0000}text{/color} =====
src = src.replace(/\{color:(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\}([\s\S]+?)\{\/color\}/g,
'<span class="md-color" style="color:$1">$2</span>');
src = src.replace(/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/g, function(match, color, text) {
const safeColor = sanitizeCssColor(color);
return safeColor ? `<span class="md-color" style="color:${safeColor}">${text}</span>` : text;
});
return DOMPurify.sanitize(marked.parse(src));
return sanitizeAllowedStyles(DOMPurify.sanitize(marked.parse(src)));
}
// 렌더 후 비디오에 커스텀 컨트롤 부착
@@ -2406,8 +2410,7 @@ function addMediaToolbar(wrap, kind, curWidth, curAlign) {
}
function applyWidth(wrap, widthVal, kind) {
// 숫자만 입력되면 px 추가
if (/^\d+$/.test(widthVal)) widthVal = widthVal + 'px';
widthVal = sanitizeMediaWidth(widthVal);
wrap.style.width = widthVal || '';
// 툴바 input 동기화
const input = wrap.querySelector('.md-toolbar-size-input');
@@ -2486,10 +2489,19 @@ async function checkAuth() {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
csrfToken = data.csrf_token || '';
document.body.classList.toggle('admin-on', isAdmin);
} catch (e) { isAdmin = false; }
}
function csrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
function csrfFetch(url, options = {}) {
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
}
function openLoginModal() {
if (isAdmin) { logout(); return; }
document.getElementById('loginModal').classList.add('active');
@@ -2511,6 +2523,7 @@ async function doLogin() {
});
const data = await res.json();
if (data.success) {
csrfToken = data.csrf_token || csrfToken;
isAdmin = true;
document.body.classList.add('admin-on');
closeLoginModal();
@@ -2522,7 +2535,8 @@ async function doLogin() {
}
}
async function logout() {
await fetch('api/auth.php?action=logout', { method: 'POST' });
await csrfFetch('api/auth.php?action=logout', { method: 'POST' });
csrfToken = '';
isAdmin = false;
document.body.classList.remove('admin-on');
}
@@ -2746,7 +2760,7 @@ function renderCategoryList() {
onclick="onParentClick(${parent.id})">
<div class="cat-name">
<i class="fa-solid fa-chevron-right cat-toggle"></i>
<span class="cat-dot" style="background: ${escapeHtml(parent.color)};"></span>
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(parent.color, '#00f2ff'))};"></span>
<span class="cat-label">${escapeHtml(parent.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
@@ -2781,7 +2795,7 @@ function renderCategoryList() {
<div class="cat-item ${isActive ? 'active' : ''}"
onclick="setFilter('category', ${child.id})">
<div class="cat-name">
<span class="cat-dot" style="background: ${escapeHtml(child.color)};"></span>
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(child.color, '#00f2ff'))};"></span>
<span class="cat-label">${escapeHtml(child.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
@@ -2909,10 +2923,6 @@ function renderPosts() {
const postDate = (p.created_at || '').trim();
return postDate === dateFilter;
});
// 디버그: 일치하는 글 없으면 콘솔에 출력
if (filtered.length === 0) {
console.log('[날짜 필터]', dateFilter, '- 전체 글의 날짜:', posts.map(p => p.created_at));
}
}
// 검색 (scope에 따라 분기)
@@ -2959,7 +2969,7 @@ function renderPosts() {
list.innerHTML = filtered.map(p => {
const cat = getCategoryById(p.category_id);
const parent = cat ? getCategoryById(cat.parent_id) : null;
const catColor = cat ? cat.color : 'var(--primary)';
const catColor = cat ? sanitizeCssColor(cat.color, '#00f2ff') : '#00f2ff';
const preview = stripMarkdown(p.content || '').slice(0, 200);
return `
@@ -2972,7 +2982,7 @@ function renderPosts() {
</div>
${cat ? `
<div class="post-cat-pill">
<span class="cat-dot" style="background: ${escapeHtml(cat.color)};"></span>
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} </span>` : ''}
${escapeHtml(cat.name)}
</div>
@@ -3012,7 +3022,7 @@ function openPostDetail(id) {
if (cat) {
metaParts.push(`
<div class="post-cat-pill">
<span class="cat-dot" style="background: ${escapeHtml(cat.color)};"></span>
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} </span>` : ''}
${escapeHtml(cat.name)}
</div>
@@ -3109,7 +3119,7 @@ async function deleteCurrentPost() {
try {
// 글 삭제
const res = await fetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
const res = await csrfFetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
const result = await res.json();
if (!result.success) {
alert(result.error || '글 삭제 실패');
@@ -3119,16 +3129,13 @@ async function deleteCurrentPost() {
// 첨부 파일 삭제 (선택했을 때)
if (deleteFiles && attachedUrls.length > 0) {
try {
const fileRes = await fetch('api/delete_files.php', {
const fileRes = await csrfFetch('api/delete_files.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: attachedUrls })
});
const fileResult = await fileRes.json();
if (fileResult.success) {
if (fileResult.deleted_count > 0) {
console.log(`첨부 파일 ${fileResult.deleted_count}개 삭제됨`);
}
if (fileResult.failed_count > 0) {
alert(`글은 삭제되었지만, ${fileResult.failed_count}개 파일 삭제에 실패했습니다.`);
}
@@ -3523,9 +3530,9 @@ function buildMediaAttrs() {
const parts = [];
let width = '';
if (widthSel === 'custom') {
width = widthCust;
width = sanitizeMediaWidth(widthCust);
} else if (widthSel) {
width = widthSel;
width = sanitizeMediaWidth(widthSel);
}
if (width) parts.push(`w=${width}`);
if (align) parts.push(align);
@@ -3568,11 +3575,11 @@ function insertAttachFile() {
}
function insertMediaFromUrl() {
const url = document.getElementById('imageInsertUrl').value.trim();
const url = sanitizeUrl(document.getElementById('imageInsertUrl').value.trim());
const alt = document.getElementById('imageInsertAlt').value.trim();
const kind = document.getElementById('imageInsertKind').value;
if (!url) {
showAlert('imageInsertAlert', 'URL을 입력해주세요', 'error');
showAlert('imageInsertAlert', 'http, https, uploads/ 경로만 사용할 수 있습니다', 'error');
return;
}
insertAtCursor(buildMediaMarkdown(kind, url, alt));
@@ -3667,7 +3674,7 @@ async function uploadPendingMedia(content) {
const formData = new FormData();
formData.append('file', file);
formData.append('project_title', 'learning');
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (result.success && result.url) {
@@ -3699,7 +3706,7 @@ async function uploadPendingMedia(content) {
// ===== 색상 선택 =====
const COLOR_PALETTE = [
// 세이지/민트 계열
'var(--primary)', '#3d6b50', '#8ab89a', '#B2E2D2',
'#00f2ff', '#3d6b50', '#8ab89a', '#B2E2D2',
// 웜 뉴트럴
'#b07a20', '#c9a050', '#c8856a', '#9e6b52',
// 레드/핑크 (핀포인트)
@@ -3751,7 +3758,8 @@ function closeColorPicker() {
document.getElementById('colorPickerModal').classList.remove('active');
}
function applyColor(color) {
wrapSelection(`{color:${color}}`, '{/color}', '');
const safeColor = sanitizeCssColor(color);
if (safeColor) wrapSelection(`{color:${safeColor}}`, '{/color}', '');
closeColorPicker();
}
@@ -3847,13 +3855,13 @@ async function savePost() {
let res;
if (id) {
data.id = parseInt(id);
res = await fetch('api/learning.php', {
res = await csrfFetch('api/learning.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await fetch('api/learning.php', {
res = await csrfFetch('api/learning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -3900,7 +3908,7 @@ function openCategoryModal(cat = null) {
document.getElementById('categoryModalTitle').textContent = '카테고리 수정';
document.getElementById('categoryId').value = cat.id;
document.getElementById('categoryName').value = cat.name;
document.getElementById('categoryColor').value = cat.color || 'var(--primary)';
document.getElementById('categoryColor').value = sanitizeCssColor(cat.color, '#00f2ff');
parentSel.value = cat.parent_id || '';
// 자식이 있으면 부모 변경 비활성화
const hasChildren = categories.some(c => c.parent_id === cat.id);
@@ -3912,7 +3920,7 @@ function openCategoryModal(cat = null) {
document.getElementById('categoryModalTitle').textContent = '카테고리 추가';
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryColor').value = 'var(--primary)';
document.getElementById('categoryColor').value = '#00f2ff';
parentSel.value = '';
parentSel.disabled = false;
}
@@ -3966,7 +3974,7 @@ async function deleteCategory(id) {
if (!confirm(msg)) return;
try {
const res = await fetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
const res = await csrfFetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
// 현재 필터가 영향받으면 전체로
@@ -4002,13 +4010,13 @@ async function saveCategory() {
let res;
if (id) {
payload.id = parseInt(id);
res = await fetch('api/categories.php', {
res = await csrfFetch('api/categories.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
res = await fetch('api/categories.php', {
res = await csrfFetch('api/categories.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
@@ -4049,6 +4057,67 @@ function escapeHtml(str) {
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
function sanitizeCssColor(color, fallback = '') {
const trimmed = String(color || '').trim();
return /^#[0-9a-fA-F]{3}$/.test(trimmed) || /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : fallback;
}
function sanitizeMediaWidth(width) {
let value = String(width || '').trim();
if (/^\d+$/.test(value)) value += 'px';
const px = value.match(/^(\d{1,4})px$/);
if (px) return Math.min(Math.max(parseInt(px[1], 10), 40), 1200) + 'px';
const pct = value.match(/^(\d{1,3})%$/);
if (pct) return Math.min(Math.max(parseInt(pct[1], 10), 1), 100) + '%';
return '';
}
function sanitizeAllowedStyles(html) {
const template = document.createElement('template');
template.innerHTML = html;
template.content.querySelectorAll('[style]').forEach(el => {
const allowed = [];
const declarations = String(el.getAttribute('style') || '').split(';');
declarations.forEach(decl => {
const [rawProp, ...rawValueParts] = decl.split(':');
if (!rawProp || rawValueParts.length === 0) return;
const prop = rawProp.trim().toLowerCase();
const value = rawValueParts.join(':').trim();
if (prop === 'width') {
const width = sanitizeMediaWidth(value);
if (width) allowed.push(`width:${width}`);
} else if (prop === 'max-width' && value === '100%') {
allowed.push('max-width:100%');
} else if (prop === 'display' && value === 'block') {
allowed.push('display:block');
} else if ((prop === 'margin-left' || prop === 'margin-right') && /^(auto|0)$/.test(value)) {
allowed.push(`${prop}:${value}`);
} else if (prop === 'color') {
const color = sanitizeCssColor(value);
if (color) allowed.push(`color:${color}`);
}
});
if (allowed.length > 0) {
el.setAttribute('style', allowed.join(';'));
} else {
el.removeAttribute('style');
}
});
return template.innerHTML;
}
// ESC / 백스페이스로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
+27 -3
View File
@@ -695,6 +695,7 @@ function toggleTheme() {
applyTheme(saved || prefer);
})();
let isAdmin = false;
let csrfToken = '';
let profile = null;
async function init() {
@@ -707,12 +708,21 @@ async function checkAuth() {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
csrfToken = data.csrf_token || '';
document.querySelectorAll('.admin-controls').forEach(el => {
el.classList.toggle('hidden', !isAdmin);
});
} catch (e) { isAdmin = false; }
}
function csrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
function csrfFetch(url, options = {}) {
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
}
async function loadProfile() {
try {
const res = await fetch('api/profile.php');
@@ -734,7 +744,7 @@ function renderProfile() {
const avatarEl = document.getElementById('avatarEl');
if (profile.avatar) {
avatarEl.style.backgroundImage = `url('${escapeAttr(profile.avatar)}')`;
avatarEl.style.backgroundImage = `url('${escapeAttr(sanitizeUrl(profile.avatar))}')`;
avatarEl.style.backgroundSize = 'cover';
avatarEl.style.backgroundPosition = 'center';
avatarEl.innerHTML = '';
@@ -818,8 +828,10 @@ function renderProfile() {
</a>
`;
}
const safeHref = sanitizeUrl(c.href);
if (!safeHref) return '';
return `
<a href="${escapeAttr(c.href)}" class="contact-card" target="_blank">
<a href="${escapeAttr(safeHref)}" class="contact-card" target="_blank" rel="noopener noreferrer">
<i class="${c.icon}"></i>
<span class="label">${c.label}</span>
<span class="value">${escapeHtml(c.value)}</span>
@@ -1040,7 +1052,7 @@ async function saveProfile() {
};
try {
const res = await fetch('api/profile.php', {
const res = await csrfFetch('api/profile.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -1071,6 +1083,18 @@ function escapeHtml(str) {
}
function escapeAttr(str) { return escapeHtml(str); }
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
// ESC 키로 모달 닫기 (바깥 클릭 닫기 제거)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
@@ -1,536 +0,0 @@
# 단타 자동매매 시스템 종합 기획서
> 버전: 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 오류, 네트워크 단절로 인한 손실에 대해 어떠한 책임도 지지 않는다.
@@ -1,743 +0,0 @@
# 단타 자동매매 시스템 종합 기획서 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개월 이상 검증 후 실거래 전환할 것.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB