주식 차트 아래에 달리는 보조 지표 — 이동평균선, RSI, 볼린저 밴드. 증권사 앱에서는 버튼 하나로 켜지지만, 직접 만들려면 원시 데이터에서 계산해야 한다. 놀랍게도 수학은 중학교 수준이고, PHP 코드는 함수 하나에 10줄 남짓이다. 이번 편에서는 1편에서 받아온 60거래일 데이터로 기술적 지표를 계산하는 전체 과정을 풀어놓는다.
기술적 분석이 뭔가 — 30초 요약
주식의 미래 가격을 예측하는 방법은 크게 두 가지다. 기본적 분석(재무제표, 실적)과 기술적 분석(가격과 거래량의 패턴). 기술적 분석은 “과거 가격 움직임이 미래에 반복된다”는 전제를 갖고 있다. 맞든 틀리든, 많은 트레이더가 이 지표를 보고 매매하기 때문에 자기실현적 예언 효과가 있다.
우리 대시보드에서 계산하는 지표는 4가지다:
- 이동평균선 (MA): 5일, 20일, 60일
- RSI: 14일 기준 상대강도지수
- 볼린저 밴드: 20일 MA ± 2표준편차
- 거래량 분석: 5일 평균 대비 현재
전부 nalkkul_sa_calculate_indicators() 함수에서 한 번에 계산한다. 입력은 날짜 기준 최신순 정렬된 가격 배열이다.
데이터 구조 이해 — 코드를 읽기 전에
API에서 받은 $items 배열은 최신 날짜가 인덱스 0이다. 즉 $items[0]이 오늘(또는 가장 최근 거래일), $items[59]가 60거래일 전이다. 종가 배열도 같은 순서다:
$closes = array_map( function( $i ) { return (float) $i['clpr']; }, $items );
$volumes = array_map( function( $i ) { return (float) $i['trqu']; }, $items );
$count = count( $closes );
// $closes[0] = 오늘 종가, $closes[1] = 어제 종가, ...
이 순서를 기억해야 한다. MA 계산에서 “최근 5일”은 $closes[0]부터 $closes[4]까지다.
이동평균선(MA) 계산 — 5일, 20일, 60일
이동평균선은 가장 직관적인 지표다. 최근 N일 종가의 산술 평균이 전부다.
공식: MA(N) = (C₁ + C₂ + … + Cₙ) / N
function nalkkul_sa_calc_ma( $closes, $period ) {
if ( count( $closes ) < $period ) {
return null;
}
$sum = 0;
for ( $i = 0; $i < $period; $i++ ) {
$sum += $closes[ $i ];
}
return round( $sum / $period, 0 );
}
$closes가 최신순이므로 인덱스 0부터 N개를 더하면 "최근 N일 평균"이 된다. 데이터가 N일치 미만이면 null을 반환한다 — 60일 MA를 계산하려면 최소 60개 데이터가 필요한데, 신규 상장 종목은 그만큼 없을 수 있다.
계산 호출 부분:
$ma5 = nalkkul_sa_calc_ma( $closes, 5 ); // 단기
$ma20 = nalkkul_sa_calc_ma( $closes, 20 ); // 중기
$ma60 = nalkkul_sa_calc_ma( $closes, 60 ); // 장기
$indicators['ma'] = array(
'ma5' => $ma5,
'ma20' => $ma20,
'ma60' => $ma60,
'signal' => nalkkul_sa_detect_cross( $closes, 5, 20 ),
);
골든크로스 / 데드크로스 감지
5일선이 20일선을 아래에서 위로 뚫으면 골든크로스(매수 신호), 위에서 아래로 뚫으면 데드크로스(매도 신호)다. 이걸 감지하려면 오늘과 어제의 MA를 비교해야 한다:
function nalkkul_sa_detect_cross( $closes, $short_p, $long_p ) {
$n = count( $closes );
if ( $n < $long_p + 1 ) return 'none';
// 오늘의 단기/장기 이동평균
$short_now = array_sum( array_slice( $closes, 0, $short_p ) ) / $short_p;
$long_now = array_sum( array_slice( $closes, 0, $long_p ) ) / $long_p;
// 어제의 단기/장기 이동평균
$short_prev = array_sum( array_slice( $closes, 1, $short_p ) ) / $short_p;
$long_prev = array_sum( array_slice( $closes, 1, $long_p ) ) / $long_p;
// 어제: 단기 ≤ 장기, 오늘: 단기 > 장기 → 골든크로스
if ( $short_prev <= $long_prev && $short_now > $long_now ) {
return 'golden';
}
// 어제: 단기 ≥ 장기, 오늘: 단기 < 장기 → 데드크로스
if ( $short_prev >= $long_prev && $short_now < $long_now ) {
return 'dead';
}
// 크로스는 아니지만 위치 관계
if ( $short_now > $long_now ) {
return 'above'; // 정배열 (단기 > 장기, 상승 추세)
}
return 'below'; // 역배열 (단기 < 장기, 하락 추세)
}
핵심 아이디어는 단순하다. 어제는 5일선이 20일선 아래에 있었는데($short_prev <= $long_prev) 오늘 위로 올라왔으면($short_now > $long_now) 골든크로스다. array_slice( $closes, 1, $short_p )는 인덱스 1부터 시작하니까 "어제 기준"이 된다.
크로스가 없더라도 단기선과 장기선의 위치 관계를 above/below로 반환한다. 프론트엔드에서 "정배열(상승 추세)" / "역배열(하락 추세)" 같은 텍스트를 보여줄 때 쓴다.
RSI(14일) 계산 — 과매수/과매도 판정
RSI(Relative Strength Index)는 최근 N일 동안 상승한 날의 평균 상승폭과 하락한 날의 평균 하락폭을 비교하는 지표다.
공식:
- RS = 평균 상승폭 / 평균 하락폭
- RSI = 100 - (100 / (1 + RS))
- RSI > 70 → 과매수 구간 (매도 관심)
- RSI < 30 → 과매도 구간 (매수 관심)
function nalkkul_sa_calc_rsi( $closes, $period = 14 ) {
$n = count( $closes );
if ( $n < $period + 1 ) return null;
// 가격 변동 계산 (closes는 최신순)
$gains = array();
$losses = array();
for ( $i = 0; $i < $n - 1; $i++ ) {
$change = $closes[ $i ] - $closes[ $i + 1 ]; // 최신 - 이전 = 상승이면 양수
if ( $change > 0 ) {
$gains[] = $change;
$losses[] = 0;
} else {
$gains[] = 0;
$losses[] = abs( $change );
}
}
// 최근 14일의 평균 상승/하락
$avg_gain = array_sum( array_slice( $gains, 0, $period ) ) / $period;
$avg_loss = array_sum( array_slice( $losses, 0, $period ) ) / $period;
if ( $avg_loss == 0 ) return 100; // 14일 연속 상승 → RSI 100
$rs = $avg_gain / $avg_loss;
$rsi = 100 - ( 100 / ( 1 + $rs ) );
return round( $rsi, 1 );
}
코드가 짧은데 주의할 부분이 있다. $closes가 최신순이라서 $closes[$i] - $closes[$i+1]은 "오늘 - 어제"다. 즉 양수면 상승, 음수면 하락. $avg_loss가 0이면 (14일 연속 상승) 분모가 0이 되므로 100을 직접 반환한다.
실무에서는 Wilder의 지수이동평균(EMA) 방식을 쓰기도 하는데, 여기서는 단순평균(SMA) 방식을 썼다. 60거래일 데이터면 SMA 방식도 충분히 신뢰할 만하고, 구현이 훨씬 직관적이다.
볼린저 밴드 — 표준편차로 변동성 측정
볼린저 밴드는 세 개의 선으로 구성된다:
- 중간밴드: 20일 이동평균 (= MA20)
- 상단밴드: 중간밴드 + 2 × 표준편차
- 하단밴드: 중간밴드 - 2 × 표준편차
현재가가 상단밴드 근처면 "과열", 하단밴드 근처면 "과매도"로 해석한다. 밴드 폭이 좁으면 곧 큰 변동이 올 수 있다는 신호다.
function nalkkul_sa_calc_bollinger( $closes, $period = 20, $mult = 2 ) {
if ( count( $closes ) < $period ) return null;
$slice = array_slice( $closes, 0, $period );
$mean = array_sum( $slice ) / $period;
// 분산 계산
$variance = 0;
foreach ( $slice as $v ) {
$variance += pow( $v - $mean, 2 );
}
$std = sqrt( $variance / $period ); // 표준편차
$upper = round( $mean + $mult * $std, 0 );
$middle = round( $mean, 0 );
$lower = round( $mean - $mult * $std, 0 );
$current = $closes[0]; // 현재가
// 현재가가 밴드 내 어디에 위치하는지
$position = 'middle';
if ( $current >= $upper ) $position = 'above_upper';
elseif ( $current <= $lower ) $position = 'below_lower';
elseif ( $current > $middle ) $position = 'upper_half';
elseif ( $current < $middle ) $position = 'lower_half';
// 밴드 폭 (%)
$width = $upper > 0 ? round( ( $upper - $lower ) / $middle * 100, 2 ) : 0;
// 현재가의 밴드 내 퍼센트 위치 (0=하단, 100=상단)
$pct = ( $upper - $lower ) > 0
? round( ( $current - $lower ) / ( $upper - $lower ) * 100, 1 )
: 50;
return array(
'upper' => $upper,
'middle' => $middle,
'lower' => $lower,
'position' => $position,
'width' => $width,
'pct' => $pct,
);
}
표준편차 계산이 유일하게 수학적으로 복잡한 부분인데, 코드로 보면 별 것 아니다: 각 종가에서 평균을 빼고 제곱한 뒤, 평균을 내고, 제곱근을 씌우면 끝이다. PHP의 pow()과 sqrt()가 다 해준다.
$position과 $pct는 프론트엔드에서 시각적 표현(마커 위치, 색상)에 쓸 값이다. $width는 밴드 폭인데, 5% 미만이면 "수렴 — 큰 변동 예상", 15% 이상이면 "확대 — 변동성 높음"으로 안내한다.
거래량 분석 — 5일 평균 대비
거래량은 가격 다음으로 중요한 데이터다. 갑자기 거래량이 평소의 2배, 3배로 뛰면 뭔가 변화가 생겼다는 뜻이다 — 호재건 악재건.
// 오늘 거래량
$vol_today = $volumes[0];
// 어제~5일 전 평균 (오늘 제외)
$vol_5avg = $count >= 6 ? array_sum( array_slice( $volumes, 1, 5 ) ) / 5 : 0;
$indicators['volume'] = array(
'today' => $vol_today,
'avg5' => $vol_5avg,
'ratio' => $vol_5avg > 0 ? round( $vol_today / $vol_5avg, 2 ) : 0,
'surge' => $vol_5avg > 0 && $vol_today >= $vol_5avg * 2,
);
array_slice( $volumes, 1, 5 )가 핵심이다. 인덱스 1(어제)부터 5개를 잘라서 평균을 낸다. 인덱스 0(오늘)을 빼는 이유는 오늘 거래량과 비교 대상(과거 평균)을 겹치지 않게 하기 위해서다.
surge는 2배 이상이면 true. 프론트엔드에서 빨간색 경고 배너를 띄우는 조건으로 쓴다.
전체를 하나로 묶는 calculate_indicators()
위의 개별 함수들을 모아서 한 번에 호출하는 메인 함수:
function nalkkul_sa_calculate_indicators( $items ) {
if ( empty( $items ) ) return null;
$closes = array_map( function( $i ) { return (float) $i['clpr']; }, $items );
$volumes = array_map( function( $i ) { return (float) $i['trqu']; }, $items );
$count = count( $closes );
$latest = $items[0];
$indicators = array();
// 이동평균선
$indicators['ma'] = array(
'ma5' => nalkkul_sa_calc_ma( $closes, 5 ),
'ma20' => nalkkul_sa_calc_ma( $closes, 20 ),
'ma60' => nalkkul_sa_calc_ma( $closes, 60 ),
'signal' => nalkkul_sa_detect_cross( $closes, 5, 20 ),
);
// RSI
$indicators['rsi'] = nalkkul_sa_calc_rsi( $closes, 14 );
// 거래량
$vol_5avg = $count >= 6 ? array_sum( array_slice( $volumes, 1, 5 ) ) / 5 : 0;
$indicators['volume'] = array(
'today' => $volumes[0],
'avg5' => $vol_5avg,
'ratio' => $vol_5avg > 0 ? round( $volumes[0] / $vol_5avg, 2 ) : 0,
'surge' => $vol_5avg > 0 && $volumes[0] >= $vol_5avg * 2,
);
// 볼린저 밴드
$indicators['bollinger'] = nalkkul_sa_calc_bollinger( $closes, 20, 2 );
// 최신 가격 데이터 (AI 분석용)
$indicators['latest'] = array(
'clpr' => (int) $latest['clpr'],
'vs' => (int) $latest['vs'],
'fltRt' => $latest['fltRt'],
'trqu' => (int) $latest['trqu'],
'basDt' => $latest['basDt'],
);
return $indicators;
}
반환하는 $indicators 배열에 latest도 포함시킨 이유가 있다. AJAX 응답에 이 지표 데이터를 같이 보내서, 프론트엔드(JavaScript)에서도 지표 값을 참조할 수 있게 하기 위해서다. 나중에 5편에서 AI 분석 기능이 이 데이터를 사용한다.
서버사이드 HTML 빌드 — MA 분석 카드
계산된 지표를 HTML로 변환하는 함수도 보자. MA 카드를 예로 들면:
function nalkkul_sa_build_ma_html( $ma, $current_price ) {
$ma_items = array(
array( 'label' => '5일 (단기)', 'value' => $ma['ma5'], 'color' => '#ef4444' ),
array( 'label' => '20일 (중기)', 'value' => $ma['ma20'], 'color' => '#f59e0b' ),
array( 'label' => '60일 (장기)', 'value' => $ma['ma60'], 'color' => '#3b82f6' ),
);
foreach ( $ma_items as $m ) {
$val = $m['value'];
if ( $val !== null ) {
$diff = $current_price - $val;
$diff_pct = $val > 0 ? round( $diff / $val * 100, 2 ) : 0;
// "현재가 대비 +2.35%" 같은 텍스트 생성
}
}
// 골든크로스/데드크로스 신호
$signal_map = array(
'golden' => array( '🔥 골든크로스 발생!', '매수 신호', 'tg-sa-signal-bull' ),
'dead' => array( '💀 데드크로스 발생!', '매도 신호', 'tg-sa-signal-bear' ),
'above' => array( '📈 정배열 (단기 > 중기)', '상승 추세', 'tg-sa-signal-bull' ),
'below' => array( '📉 역배열 (단기 < 중기)', '하락 추세', 'tg-sa-signal-bear' ),
);
// ...
}
HTML을 서버에서 완성해서 AJAX로 내려보내는 방식이다. 클라이언트에서 JSON 데이터를 받아 DOM을 조립하는 것보다 서버 렌더링이 훨씬 빠르고, 코드도 깔끔하다. PHP의 ob_start()/ob_get_clean() 패턴으로 템플릿 코드를 버퍼에 담는다.
RSI 시각화 — 바 차트로 위치 표시
RSI 값을 숫자로만 보여주면 직관적이지 않다. 0~100 범위의 바 위에 마커를 찍어서 현재 위치를 보여주는 게 훨씬 낫다:
function nalkkul_sa_build_rsi_html( $rsi ) {
if ( $rsi === null ) {
return '<div class="tg-sa-empty-indicator">RSI 계산에 충분한 데이터가 없습니다 (최소 15일 필요)</div>';
}
$zone = 'neutral';
$zone_text = '중립 구간';
$zone_color = '#6b7280';
if ( $rsi <= 30 ) {
$zone = 'oversold';
$zone_text = '과매도 (매수 관심 구간)';
$zone_color = '#2563eb';
} elseif ( $rsi >= 70 ) {
$zone = 'overbought';
$zone_text = '과매수 (매도 관심 구간)';
$zone_color = '#dc2626';
}
// 바 위에 마커 위치: left:%
// 과매도 구간(0~30) = 파란색, 중립(30~70) = 회색, 과매수(70~100) = 빨간색
$html .= '<div class="tg-sa-rsi-marker" style="left:' . min( max( $rsi, 0 ), 100 ) . '%;"></div>';
// ...
}
CSS에서 바를 3등분해서 색을 칠한다: 왼쪽 30%는 파란색(과매도), 가운데 40%는 회색(중립), 오른쪽 30%는 빨간색(과매수). 마커의 left 퍼센트에 RSI 값을 그대로 넣으면 시각적으로 정확한 위치가 된다.
볼린저 밴드 시각화
볼린저 밴드도 같은 패턴이다. 하단밴드=0%, 상단밴드=100%로 놓고, 현재가가 어디쯤 있는지 마커로 표시한다:
// 밴드 내 위치 결정
$pos_map = array(
'above_upper' => array( '상단밴드 돌파 (과매수 가능)', '#dc2626' ),
'below_lower' => array( '하단밴드 이탈 (과매도 가능)', '#2563eb' ),
'upper_half' => array( '중심선 상단 (상승 우위)', '#f59e0b' ),
'lower_half' => array( '중심선 하단 (하락 우위)', '#6b7280' ),
'middle' => array( '중심선 부근 (중립)', '#6b7280' ),
);
// 밴드 폭 해석
$bb['width'] < 5 ? '수렴 — 큰 변동 예상'
: $bb['width'] > 15 ? '확대 — 변동성 높음'
: '보통';
밴드 폭($width)이 좁아질수록 "스퀴즈" 상태고, 이 상태에서 밴드가 확 벌어지면 큰 추세 전환이 올 수 있다. 5% 미만일 때 "큰 변동 예상"이라는 경고를 표시한다.
거래량 바 차트
거래량 표시에서 재밌는 부분은 비율 바다. 5일 평균을 기준선(50%)으로 두고, 현재 거래량을 비율로 환산해서 채운다:
$ratio = $vol['ratio'];
$bar_w = min( $ratio * 50, 100 ); // 비율 × 50 = 바 너비%
$ref_w = 50; // 50% 지점 = 1배 (평균)
// 2배 이상이면 빨간색 "급증" 스타일
$surge_cls = $vol['surge'] ? 'tg-sa-vol-surge' : '';
비율이 1.0이면 바가 50%(기준선과 같은 위치), 2.0이면 100%(끝까지 차는 빨간 바)가 된다. 2배 이상일 때는 "거래량 급증! 평소 대비 X.X배 거래되고 있습니다" 경고 문구가 뜬다.
정리: 2편에서 구현한 계산 로직
nalkkul_sa_calc_ma(): N일 단순이동평균 계산nalkkul_sa_detect_cross(): 골든크로스/데드크로스 감지 (오늘 vs 어제 비교)nalkkul_sa_calc_rsi(): 14일 RSI (과매수 70+, 과매도 30-)nalkkul_sa_calc_bollinger(): 20일 볼린저 밴드 (중간/상단/하단 + 위치 + 밴드폭)- 거래량 분석: 5일 평균 대비 비율 + 급증 감지
nalkkul_sa_calculate_indicators(): 위 함수들을 통합 호출- 각 지표별 HTML 빌더 함수 (서버 렌더링)
다음 3편에서는 이 지표들을 활용한 종목 스크리닝 시스템을 만든다. 100개 종목을 한꺼번에 가져와서 점수를 매기고, "강세 TOP 10" / "약세 TOP 10"을 자동 분류하는 알고리즘이다.
이 시리즈의 다른 글
- 1편 — 공공데이터 API 연동
- [현재 글] 2편 — 기술적 지표 계산 (MA, RSI, 볼린저 밴드)
- 3편 — 종목 스크리닝 시스템 구현
- 4편 — Lightweight Charts로 캔들스틱 차트 구현
- 5편 — AJAX 연동과 프론트엔드 완성