하루에 코스피+코스닥 합쳐서 2,500개가 넘는 종목이 거래된다. 일일이 확인할 수 없으니 “오늘 주목할 만한 종목”을 자동으로 걸러주는 시스템이 필요하다. 증권사에서는 이걸 종목 스크리너라고 부른다. 거래량 상위, 상승률 상위 같은 단순 필터링부터, 캔들 패턴을 분석해 점수를 매기는 복잡한 것까지 있다.
우리 대시보드에서는 두 가지를 구현했다: (1) 개별 종목에 0~100점 기술적 점수를 매기는 스코어링 알고리즘, (2) 박스권에서 거래량이 증가하는 종목을 잡아내는 박스권 탐지. 코드를 하나씩 뜯어본다.
점수화 알고리즘: 0~100점 — nalkkul_sa_score_stock()
기본 점수 50점에서 출발해서, 기술적 조건에 따라 가감한다. 최종 점수가 70점 이상이면 “강세 관심 종목”, 30점 이하면 “약세 주의 종목”으로 분류된다.
function nalkkul_sa_score_stock( $stock ) {
$score = 50; // 기본 점수
$fltRt = floatval( $stock['fltRt'] ?? 0 ); // 등락률
$clpr = intval( $stock['clpr'] ?? 0 ); // 종가
$mkp = intval( $stock['mkp'] ?? 0 ); // 시가
$hipr = intval( $stock['hipr'] ?? 0 ); // 고가
$lopr = intval( $stock['lopr'] ?? 0 ); // 저가
// 1. 등락률 점수 (-5~+5% → -10~+10점)
$score += min( 10, max( -10, $fltRt * 2 ) );
// 2. 양봉/음봉 (종가 > 시가 = 양봉 +5점)
if ( $clpr > $mkp ) {
$score += 5;
} elseif ( $clpr < $mkp ) {
$score -= 5;
}
// 3. 꼬리 분석
$body = abs( $clpr - $mkp );
$upper_shadow = $hipr - max( $clpr, $mkp );
$lower_shadow = min( $clpr, $mkp ) - $lopr;
if ( $body > 0 ) {
if ( $lower_shadow > $body * 2 ) {
$score += 5; // 긴 아래꼬리 = 매수세 유입
}
if ( $upper_shadow > $body * 2 ) {
$score -= 5; // 긴 윗꼬리 = 매도 압력
}
}
// 4. 종가 위치 (고가-저가 범위에서)
$range = $hipr - $lopr;
if ( $range > 0 ) {
$position = ( $clpr - $lopr ) / $range; // 0=저가, 1=고가
$score += ( $position - 0.5 ) * 10; // -5 ~ +5
}
return max( 0, min( 100, round( $score ) ) );
}
각 항목이 뜻하는 것
등락률 점수 (최대 ±10점): 5% 상승하면 +10점, 5% 이상 하락하면 -10점. 등락률에 2를 곱하되, ±10으로 클램핑한다. 상한가(+30%)나 하한가(-30%)가 나와도 ±10을 넘기지 않게 한 거다. 극단적인 종목 하나가 점수를 지배하면 안 되니까.
양봉/음봉 (±5점): 종가가 시가보다 높으면 양봉. 장중에 올랐다는 뜻이므로 +5점. 반대면 -5점.
꼬리 분석 (±5점): 캔들 차트에서 꼬리(shadow)의 길이가 몸통(body)의 2배를 넘으면 의미가 있다. 긴 아래꼬리는 “떨어졌다가 되살아남” → 매수세가 강하다 → +5점. 이걸 차트 분석에서 “망치형(hammer)”이라고 부른다. 긴 윗꼬리는 “올라갔다가 밀림” → 매도 압력 → -5점. “유성형(shooting star)”에 해당한다.
종가 위치 (±5점): 오늘 고가와 저가 사이에서 종가가 어디에 위치하는지 본다. 고가 근처에서 마감하면 상승 에너지가 강한 것이고(+5점 방향), 저가 근처에서 마감하면 하락 에너지가 강한 것(-5점 방향). $position이 0.8이면 고가 쪽 80% 지점이므로 (0.8 – 0.5) × 10 = +3점이 된다.
신호 태그 자동 생성 — nalkkul_sa_get_signals()
점수 외에 각 종목에 “급등”, “양봉”, “망치형” 같은 태그를 붙인다. 프론트엔드에서 컬러 배지로 표시된다.
function nalkkul_sa_get_signals( $stock ) {
$signals = array();
$fltRt = floatval( $stock['fltRt'] ?? 0 );
$clpr = intval( $stock['clpr'] ?? 0 );
$mkp = intval( $stock['mkp'] ?? 0 );
$hipr = intval( $stock['hipr'] ?? 0 );
$lopr = intval( $stock['lopr'] ?? 0 );
if ( $fltRt > 3 ) {
$signals[] = array( 'text' => '급등', 'color' => '#e74c3c' );
}
if ( $fltRt > 0 && $clpr > $mkp ) {
$signals[] = array( 'text' => '양봉', 'color' => '#27ae60' );
}
if ( $fltRt < -3 ) {
$signals[] = array( 'text' => '급락', 'color' => '#3498db' );
}
if ( $fltRt < 0 && $clpr < $mkp ) {
$signals[] = array( 'text' => '음봉', 'color' => '#e67e22' );
}
if ( $clpr == $hipr && $hipr > 0 ) {
$signals[] = array( 'text' => '고가마감', 'color' => '#e74c3c' );
}
if ( $clpr == $lopr && $lopr > 0 ) {
$signals[] = array( 'text' => '저가마감', 'color' => '#3498db' );
}
// 캔들 패턴
$body = abs( $clpr - $mkp );
$upper_shadow = $hipr - max( $clpr, $mkp );
$lower_shadow = min( $clpr, $mkp ) - $lopr;
if ( $body > 0 && $lower_shadow > $body * 2 ) {
$signals[] = array( 'text' => '망치형', 'color' => '#27ae60' );
}
if ( $body > 0 && $upper_shadow > $body * 2 ) {
$signals[] = array( 'text' => '유성형', 'color' => '#e67e22' );
}
// 종가 위치로 추세 판단
$range = $hipr - $lopr;
if ( $range > 0 ) {
$pos = ( $clpr - $lopr ) / $range;
if ( $pos > 0.8 ) {
$signals[] = array( 'text' => '상승세', 'color' => '#d32f2f' );
} elseif ( $pos < 0.2 ) {
$signals[] = array( 'text' => '하락세', 'color' => '#1565c0' );
}
}
return $signals;
}
조건은 겹칠 수 있다. “급등” + “양봉” + “고가마감”이 동시에 뜨는 종목은 확실히 강한 상승세다. 프론트엔드에서는 최대 4개 태그만 표시한다(너무 많으면 UI가 지저분해지니까).
색상 규칙을 간단히 정리하면: 빨간색(#e74c3c, #d32f2f) = 상승/강세 신호, 파란색(#3498db, #1565c0) = 하락/약세 신호, 초록(#27ae60) = 캔들 패턴 강세, 주황(#e67e22) = 캔들 패턴 약세. 한국 증시 관행(상승=빨강, 하락=파랑)을 따른다.
스크리닝 AJAX 핸들러의 전체 흐름
사용자가 “스크리닝” 탭을 클릭하면 AJAX로 nalkkul_sa_ajax_screening()이 호출된다. 이 함수의 동작을 순서대로 보자:
function nalkkul_sa_ajax_screening() {
// 1. 보안 검증 (nonce)
if ( ! check_ajax_referer( 'nalkkul_sa_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => '보안 검증에 실패했습니다.' ), 403 );
}
// 2. 캐시 체크 (날짜별 캐시)
$cache_key = 'nalkkul_sa_screening_' . date( 'Ymd' );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
wp_send_json_success( array( 'html' => $cached ) );
}
// 3. 최근 거래일의 100개 종목 데이터 가져오기
$items = array();
for ( $d = 0; $d <= 5; $d++ ) {
$try_date = date( 'Ymd', strtotime( "-{$d} days" ) );
$result = nalkkul_sa_fetch_stock( array(
'basDt' => $try_date,
'numOfRows' => 100,
) );
if ( ! is_wp_error( $result ) && ! empty( $result['items'] ) ) {
$items = $result['items'];
$target_date = $try_date;
break;
}
}
// 4. 외국 종목 필터링 (900xxx 코드 제외)
$items = array_filter( $items, function( $s ) {
$code = $s['srtnCd'] ?? '';
return ! preg_match( '/^9/', $code );
} );
// 5. 전 종목 점수 + 신호 태그 생성
$scored = array();
foreach ( $items as $stk ) {
$scored[] = array(
'stock' => $stk,
'score' => nalkkul_sa_score_stock( $stk ),
'signals' => nalkkul_sa_get_signals( $stk ),
);
}
// 6. 점수 내림차순 정렬
usort( $scored, function( $a, $b ) {
return $b['score'] - $a['score'];
} );
// 7. 박스권 탐지 (아래 별도 설명)
// ...
// 8. HTML 빌드 → 캐시 저장 → 응답
$html = nalkkul_sa_build_screening_html( $scored, $target_date, $box_stocks );
set_transient( $cache_key, $html, 1800 );
wp_send_json_success( array( 'html' => $html ) );
}
캐시 키에 date('Ymd')가 들어가서 날짜가 바뀌면 자동으로 새 데이터를 가져온다. 주말에는 금요일 데이터가 계속 캐시에서 나온다.
3번에서 최대 5일 전까지 거슬러 올라가는 이유는 주말과 공휴일 때문이다. 토요일에 접속하면 오늘 데이터가 없으므로 -1, -2 하면서 금요일 데이터를 찾는다.
4번에서 900xxx 코드를 필터링하는 이유: data.go.kr API가 간혹 외국주식 DR(Depositary Receipts)을 포함시키는 경우가 있다. 종목코드가 9로 시작하면 외국 종목이므로 빼는 것이 맞다.
강세 TOP 10 / 약세 TOP 10 분류
점수 내림차순으로 정렬한 뒤, 70점 이상과 30점 이하를 분리한다:
$bullish = array_filter( $scored, function( $s ) { return $s['score'] >= 70; } );
$bearish = array_filter( $scored, function( $s ) { return $s['score'] <= 30; } );
$top_bull = array_slice( $bullish, 0, 10 );
$top_bear = array_slice( array_values( $bearish ), 0, 10 );
array_values()로 재인덱싱하는 이유: array_filter()가 원래 키를 유지하기 때문에 array_slice()가 의도대로 안 잘릴 수 있다.
강세 종목 카드에는 점수에 따라 별(⭐)도 붙인다:
$stars = $score >= 90 ? 5 : ( $score >= 80 ? 4 : 3 );
$star_str = str_repeat( '⭐', $stars );
90점 이상 = 5성, 80점 이상 = 4성, 나머지 = 3성. 단순하지만 시각적 효과가 크다.
박스권 + 거래량 증가 종목 탐지
이게 이 대시보드에서 가장 독특한 기능이다. 박스권이란 가격이 좁은 범위에서 횡보하는 상태를 말한다. 이 상태에서 거래량이 갑자기 늘어나면, 누군가 대량으로 매집하고 있을 수 있다 — 곧 추세가 전환될 가능성이 있는 것이다.
탐지 조건은 두 가지다:
- 5거래일 전 대비 가격 변동이 ±3% 이내 (박스권)
- 5거래일 전 대비 거래량이 1.3배 이상 (거래량 증가)
구현하려면 5거래일 전 데이터가 따로 필요하다. API를 한 번 더 호출해서 과거 데이터를 가져온다:
// 3~7일 전 날짜 중 거래일 찾기
$dates_to_try = array();
$today_ts = strtotime( $target_date );
for ( $i = 3; $i <= 7; $i++ ) {
$d = date( 'Ymd', $today_ts - ( $i * 86400 ) );
$dates_to_try[] = $d;
}
$old_items = null;
foreach ( $dates_to_try as $old_date ) {
// 캐시 먼저 확인
$old_cache = 'nalkkul_sa_box_' . $old_date;
$old_cached = get_transient( $old_cache );
if ( false !== $old_cached ) {
$old_items = $old_cached;
break;
}
// API 호출
$old_result = nalkkul_sa_fetch_stock( array(
'basDt' => $old_date,
'numOfRows' => 100,
) );
if ( ! is_wp_error( $old_result ) && ! empty( $old_result['items'] ) ) {
$old_items = $old_result['items'];
set_transient( $old_cache, $old_items, 1800 );
break;
}
}
3일 전부터 시도하는 이유는 주말을 건너뛰기 위해서다. 월요일 기준이면 3일 전(금요일)이 마지막 거래일이니까. 7일 전까지 넓게 잡아서 공휴일도 커버한다.
과거 데이터를 가져왔으면 종목코드(srtnCd) 기준으로 매칭해서 비교한다:
// 과거 데이터를 종목코드로 인덱싱
$old_map = array();
foreach ( $old_items as $oi ) {
$old_map[ $oi['srtnCd'] ] = $oi;
}
$box_stocks = array();
foreach ( $items as $stock ) {
$code = $stock['srtnCd'];
if ( ! isset( $old_map[ $code ] ) ) continue;
$old = $old_map[ $code ];
$cur_price = (int) $stock['clpr'];
$old_price = (int) $old['clpr'];
$cur_vol = (int) $stock['trqu'];
$old_vol = (int) $old['trqu'];
if ( $old_price == 0 || $old_vol == 0 ) continue;
$price_change_pct = abs( ( $cur_price - $old_price ) / $old_price * 100 );
$vol_change_ratio = $cur_vol / $old_vol;
// 박스권(±3%) + 거래량 증가(1.3배+)
if ( $price_change_pct <= 3 && $vol_change_ratio >= 1.3 ) {
$box_stocks[] = array(
'stock' => $stock,
'old_price' => $old_price,
'price_change_pct' => round( $price_change_pct, 2 ),
'price_direction' => $cur_price >= $old_price ? '+' : '-',
'vol_ratio' => round( $vol_change_ratio, 1 ),
'old_vol' => $old_vol,
);
}
}
// 거래량 증가폭 내림차순으로 정렬
usort( $box_stocks, function( $a, $b ) {
return $b['vol_ratio'] <=> $a['vol_ratio'];
} );
// 상위 15개만
$box_stocks = array_slice( $box_stocks, 0, 15 );
거래량 증가 비율이 높은 순서대로 정렬해서 상위 15개를 뽑는다. 비율에 따라 배지도 다르게 붙인다:
if ( $vol_ratio >= 2.0 ) {
$vol_badge_text = '거래량 폭증'; // 🔥🔥
} elseif ( $vol_ratio >= 1.5 ) {
$vol_badge_text = '거래량 급증'; // 🔥
} else {
$vol_badge_text = '거래량 증가'; // 📈
}
카드마다 "차트 보기" 버튼도 붙어있다. 이 버튼을 누르면 모달 팝업이 뜨면서 Lightweight Charts 캔들스틱 차트가 로딩된다. 이건 4편에서 다룬다.
전체 스크리닝 테이블 — 정렬 가능한 인터랙티브 테이블
TOP 10 카드 아래에는 100개 전 종목을 보여주는 테이블이 있다. 헤더를 클릭하면 해당 컬럼 기준으로 정렬된다. 이 정렬 기능은 JavaScript로 구현했는데, 데이터를 data 속성에 넣어놓고 클라이언트에서 정렬한다:
<tr data-rank="<?php echo $rank + 1; ?>"
data-name="<?php echo esc_attr( $stk['itmsNm'] ); ?>"
data-price="<?php echo intval( $stk['clpr'] ); ?>"
data-change="<?php echo $fltRt; ?>"
data-volume="<?php echo intval( $stk['trqu'] ); ?>"
data-score="<?php echo $score; ?>">
<td><?php echo $rank + 1; ?></td>
<td class="tg-sa-td-name"><?php echo esc_html( $stk['itmsNm'] ); ?></td>
...
</tr>
HTML에 data 속성으로 raw 값을 넣어놓으면, JavaScript에서 정렬할 때 getAttribute('data-score')로 꺼내서 비교하면 된다. 서버에 다시 요청할 필요 없이 순수 클라이언트 정렬이라 빠르다.
면책 문구 — 법적으로 반드시 필요하다
스크리닝 결과 아래에 면책 문구를 넣었다:
<div class="tg-sa-disclaimer">
<span class="tg-sa-disclaimer-icon">⚠️</span>
<div>본 스크리닝은 당일 기술적 지표(가격, 거래량, 캔들 패턴)에 기반한 자동 분류이며,
종목 추천이 아닙니다. 투자 판단은 본인의 책임입니다.</div>
</div>
한국에서 주식 관련 콘텐츠를 제공할 때, "투자 추천"으로 오해받으면 자본시장법 위반 소지가 있다. 투자자문업 등록 없이 특정 종목을 매수/매도 권유하는 건 불법이다. "기술적 지표에 기반한 자동 분류"라는 성격을 명확히 하고, "종목 추천이 아니다"라고 밝히는 게 중요하다. 대시보드 상단과 하단, 두 군데에 넣었다.
스크리닝 HTML의 구조
전체 HTML 출력 순서는:
- 기준일 표시
- 박스권 + 거래량 증가 종목 (보라색 카드)
- 기술적 강세 TOP 10 (초록 그라데이션 카드)
- 기술적 약세 TOP 10 (주황 그라데이션 카드)
- 전체 종목 스크리닝 테이블 (정렬 가능)
- 면책 문구
카드 색상으로 성격을 구분했다. 강세 카드는 background: linear-gradient(135deg, #f0fdf4, #dcfce7) (연한 초록), 약세 카드는 linear-gradient(135deg, #fff7ed, #ffedd5) (연한 주황), 박스권 카드는 linear-gradient(135deg, #f5f0ff, #ede7ff) (연한 보라). 보라색을 선택한 이유는 박스권이 강세도 약세도 아닌 "관찰 대상"이라서, 빨강/파랑 어느 쪽에도 기울지 않은 중립적 색이 적합했다.
성능 고려사항
스크리닝은 API를 최소 1회(오늘 데이터), 박스권 탐지까지 하면 2회 호출한다. 그래서 캐싱이 특히 중요하다. 'nalkkul_sa_screening_' . date('Ymd')로 하루 단위 캐시를 걸어서, 첫 번째 사용자만 API를 호출하고 이후 접속자는 전부 캐시를 쓴다.
또 하나, HTML을 문자열로 캐시한다는 점도 중요하다. JSON 데이터가 아니라 완성된 HTML을 캐시하므로, 두 번째 이후 요청에서는 PHP가 HTML 렌더링조차 안 한다. 트랜지언트에서 HTML 꺼내서 바로 응답.
정리: 3편에서 구현한 것
nalkkul_sa_score_stock(): 등락률, 양봉/음봉, 꼬리 분석, 종가 위치로 0~100점 산출nalkkul_sa_get_signals(): 급등/급락, 양봉/음봉, 망치형/유성형, 고가마감/저가마감 태그nalkkul_sa_ajax_screening(): AJAX로 100종목 가져오기 + 점수 매기기 + 박스권 탐지- 박스권 탐지: 5거래일 전 대비 가격변동 ±3% + 거래량 1.3배 이상
- 강세/약세 TOP 10 카드 + 전체 종목 정렬 테이블
- 면책 문구
4편에서는 TradingView의 Lightweight Charts 라이브러리로 인터랙티브 캔들스틱 차트를 구현한다. 마우스 호버하면 가격이 뜨고, 이동평균선이 오버레이되는 그 차트다.
이 시리즈의 다른 글
- 1편 — 공공데이터 API 연동
- 2편 — 기술적 지표 계산 (MA, RSI, 볼린저 밴드)
- [현재 글] 3편 — 종목 스크리닝 시스템 구현
- 4편 — Lightweight Charts로 캔들스틱 차트 구현
- 5편 — AJAX 연동과 프론트엔드 완성