5 / 12 · AUTO TRADING WIKI
수집한 데이터를 MySQL에 저장하기
수집 데이터의 저장 구조와 행 수·키·날짜 정합성을 확인합니다.
검증된 CSV를 MySQL에 저장하면 종목과 기간을 조건으로 필요한 행을 다시 읽을 수 있다. 이 장에서는 종목마다 테이블을 만들지 않고 daily_prices 하나에 모든 종목의 일봉을 모은다. 종목코드와 거래일을 기본 키로 지정해 같은 자료를 재실행해도 행이 계속 늘어나지 않게 한다.
MySQL 서버 설치와 사용자 관리는 독자의 실행 환경에 따라 다르다. 여기서는 별도의 학습용 MySQL 8.4 데이터베이스에 접속할 수 있다고 가정한다. 실제 서비스 DB에 예제를 그대로 실행하지 않고, 준비된 학습용 저장소에서 진행한다. MySQL 공식 설치 안내
1. 저장과 조회의 단위 정하기
한 종목의 1일 일봉을 한 행으로 저장한다. 이렇게 하면 종목이 늘어도 테이블 이름을 동적으로 만들 필요가 없고, 다음 장에서 같은 SQL로 모든 종목을 읽을 수 있다.
| 컬럼 | 자료형 | 역할 |
|---|---|---|
symbol |
CHAR(6) |
앞자리 0과 영문 대문자를 보존하는 6자리 종목코드 |
trade_date |
DATE |
거래일 |
open, high, low, close |
DECIMAL(18,4) |
시가·고가·저가·종가 |
volume |
BIGINT UNSIGNED |
거래량 |
updated_at |
TIMESTAMP |
DB에 마지막으로 저장·변경한 시각 |
가격에 DECIMAL을 사용하면 금액을 십진수 단위로 보관할 수 있다. 이번 CSV 예제는 원 단위 정수지만, SQL 구조는 소수 네 자리까지 허용한다. 이것이 지표 계산을 반드시 Decimal로 해야 한다는 뜻은 아니다. 금액 저장과 통계 계산의 숫자 정책은 목적에 맞게 구분한다. MySQL 공식 고정소수점 설명
현재 파일의 수정주가와 다른 가격 체계를 같은 키에 섞으면 출처를 구분하기 어렵다. 학습용 daily_prices는 4장에서 정한 KRX 수정주가 일봉만 담는다. 다른 시장·원주가 자료는 별도 데이터셋으로 관리한다.
2. 학습용 데이터베이스와 테이블 준비하기
DB를 관리하는 계정으로 auto_trading_lab을 만들고 아래 SQL을 실행한다. SQL은 config/schema.sql에 저장해 변경 이력을 남길 수 있다. CREATE TABLE IF NOT EXISTS는 기존 테이블의 구조를 자동으로 바꾸지 않으므로 이미 테이블이 있다면 SHOW CREATE TABLE로 확인한다.
CREATE DATABASE IF NOT EXISTS auto_trading_lab
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE auto_trading_lab;
CREATE TABLE IF NOT EXISTS universe_snapshots (
snapshot_date DATE NOT NULL,
symbol CHAR(6) NOT NULL,
name VARCHAR(100) NOT NULL,
source VARCHAR(500) NOT NULL,
PRIMARY KEY (snapshot_date, symbol)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS daily_prices (
symbol CHAR(6) NOT NULL,
trade_date DATE NOT NULL,
open DECIMAL(18,4) NOT NULL,
high DECIMAL(18,4) NOT NULL,
low DECIMAL(18,4) NOT NULL,
close DECIMAL(18,4) NOT NULL,
volume BIGINT UNSIGNED NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (symbol, trade_date),
CONSTRAINT chk_daily_prices_positive
CHECK (open > 0 AND high > 0 AND low > 0 AND close > 0),
CONSTRAINT chk_daily_prices_range
CHECK (high >= open AND high >= close AND high >= low
AND low <= open AND low <= close)
) ENGINE=InnoDB;universe_snapshots는 종목 목록의 기준일을 보존하는 구조다. 이번 실습의 가격 적재 코드는 daily_prices만 다룬다. 구성 종목 목록을 적재할 때도 (snapshot_date, symbol)을 기준으로 검증·매개변수 바인딩을 적용한다.
DDL은 데이터 적재 트랜잭션과 분리해서 실행한다. MySQL에서는 일부 DDL 문이 암묵적 커밋을 일으킨다. 프로그램에서 테이블 생성과 가격 입력을 한 트랜잭션처럼 취급하지 않는다. MySQL 공식 암묵적 커밋 문서
3. 접속 설정과 권한 구분하기
config/example.env와 개인용 config/.env에 다음 항목을 추가한다. 기존 KIS 설정을 지우거나 덮어쓰지 않는다.
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=lab_writer
MYSQL_PASSWORD=REPLACE_WITH_LOCAL_PASSWORD
MYSQL_DATABASE=auto_trading_lab
서버 설치 때 쓰는 관리자 계정과 일봉을 적재하는 계정을 구분한다. 적재 계정에는 해당 학습용 테이블의 SELECT, INSERT, UPDATE 등 필요한 권한만 부여하고, 지표 조회용 계정은 읽기 권한으로 좁힐 수 있다. 원격 서버에 연결한다면 인증서 검증을 포함한 TLS 설정도 환경에 맞게 준비한다.
명령줄 클라이언트에서는 비밀번호를 명령 인수에 직접 쓰지 않고 프롬프트로 입력한다.
mysql --host=127.0.0.1 --port=3306 --user=lab_writer --password auto_trading_lab접속한 뒤 SELECT DATABASE(), VERSION();와 SHOW TABLES;를 실행한다. 접속 성공과 테이블 쓰기 권한은 별도이므로 작은 CSV를 적재하는 단계에서 다시 확인한다.
4. CSV를 검사한 뒤 트랜잭션으로 저장하기
src/load_prices.py는 이미 수집 완료가 확인된 CSV 하나를 인수로 받는다. 폴더의 모든 CSV를 무조건 읽으면 이전 실패 실행의 파일이나 요약 파일까지 섞일 수 있다. 4장의 실행 요약에서 성공한 파일을 선택한다.
import argparse
from decimal import Decimal
from pathlib import Path
import mysql.connector
import pandas as pd
from src.kis_readonly import load_config
REQUIRED = ["symbol", "trade_date", "open", "high", "low", "close", "volume"]
SQL = """
INSERT INTO daily_prices
(symbol, trade_date, open, high, low, close, volume)
VALUES (%s, %s, %s, %s, %s, %s, %s) AS incoming
ON DUPLICATE KEY UPDATE
open = incoming.open, high = incoming.high, low = incoming.low,
close = incoming.close, volume = incoming.volume
"""
def read_rows(path):
frame = pd.read_csv(path, dtype="string", encoding="utf-8-sig")
if set(REQUIRED) - set(frame.columns) or frame.empty:
raise ValueError("The CSV is empty or missing required columns")
if frame[REQUIRED].isna().any().any():
raise ValueError("The CSV contains missing values")
if not frame.symbol.str.fullmatch(r"[A-Z0-9]{6}").all():
raise ValueError("Invalid stock symbol")
frame["trade_date"] = pd.to_datetime(
frame.trade_date, format="%Y-%m-%d", errors="raise"
).dt.date
if frame.duplicated(["symbol", "trade_date"]).any():
raise ValueError("Duplicate symbol/date in the input")
rows = []
for record in frame[REQUIRED].itertuples(index=False, name=None):
symbol, trade_date, *numbers = record
prices = [Decimal(value) for value in numbers[:4]]
volume_value = Decimal(numbers[4])
if not all(p.is_finite() and p > 0 for p in prices):
raise ValueError("Invalid price")
if any(p != p.quantize(Decimal("0.0001")) for p in prices):
raise ValueError("A price exceeds four decimal places")
if (not volume_value.is_finite() or volume_value < 0
or volume_value != volume_value.to_integral_value()):
raise ValueError("Invalid volume")
open_, high, low, close = prices
if high < max(open_, low, close) or low > min(open_, high, close):
raise ValueError("Invalid OHLC range")
rows.append((symbol, trade_date, *prices, int(volume_value)))
return rows
def main():
parser = argparse.ArgumentParser()
parser.add_argument("csv", type=Path)
args = parser.parse_args()
rows = read_rows(args.csv)
config = load_config()
required = ["MYSQL_HOST", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE"]
if any(not config.get(key) for key in required):
raise ValueError("MySQL settings are incomplete")
connection = mysql.connector.connect(
host=config["MYSQL_HOST"],
port=int(config.get("MYSQL_PORT", "3306")),
user=config["MYSQL_USER"],
password=config["MYSQL_PASSWORD"],
database=config["MYSQL_DATABASE"],
charset="utf8mb4",
connection_timeout=10,
autocommit=False,
)
cursor = None
try:
cursor = connection.cursor()
for row in rows:
cursor.execute(SQL, row)
connection.commit()
print(f"committed input_rows={len(rows)} file={args.csv.name}")
except Exception:
connection.rollback()
raise
finally:
if cursor is not None:
cursor.close()
connection.close()
if __name__ == "__main__":
main()SQL의 %s는 문자열 조합으로 채우는 자리가 아니라 Connector가 값을 바인딩하는 자리다. 종목코드나 날짜를 f-string으로 SQL에 붙이지 않는다. Connector/Python 공식 execute 문서
기본 키가 이미 있으면 새 가격으로 갱신한다. 이 예제의 행 별칭 문법은 MySQL 8.4를 기준으로 하며, 같은 파일의 재실행 시 행 수가 중복 증가하지 않는 것이 목적이다. 수정주가가 변경되면 값은 갱신될 수 있으므로 원본 CSV와 적재 일시를 남긴다. MySQL 공식 중복 키 갱신 문서
5. 적재 후 다시 조회하기
아래 파일명은 실제 수집 성공 파일명으로 바꾼다.
.\.venv\Scripts\python.exe -m py_compile src/load_prices.py
.\.venv\Scripts\python.exe -m src.load_prices outputs/daily/005930_2026-01-01_2026-10-16.csv출력의 input_rows는 커밋한 입력 건수다. 새로 추가된 DB 행 수와 같다고 해석하지 않는다. 중복 키 갱신이 포함되면 추가·변경·동일값 처리의 행 수 의미가 달라질 수 있다.
다음 SQL로 저장 상태를 다시 읽는다.
SELECT symbol, COUNT(*) AS rows_count,
MIN(trade_date) AS first_date, MAX(trade_date) AS last_date
FROM daily_prices
GROUP BY symbol
ORDER BY symbol;
SELECT symbol, trade_date, open, high, low, close, volume
FROM daily_prices
WHERE symbol = '005930'
ORDER BY trade_date DESC
LIMIT 5;같은 파일을 한 번 더 적재한 뒤 대상 종목의 행 수가 늘지 않는지 확인한다. 하나라도 실패하면 rollback()으로 해당 CSV의 트랜잭션을 되돌린다. 이미 성공한 다른 파일의 커밋까지 되돌리는 구조는 아니다. Connector/Python 공식 트랜잭션 예제
6. Codex에 저장 기능 요청하기
검증된 일봉 CSV를 auto_trading_lab.daily_prices에 적재해줘.
종목별 테이블을 만들지 말고 (symbol, trade_date)를 기본 키로 사용해줘.
테이블 생성 DDL과 데이터 적재 트랜잭션을 분리하고, 값은 매개변수로 바인딩해줘.
입력 검증을 먼저 끝낸 뒤 파일 단위로 commit/rollback해줘.
같은 CSV를 두 번 적재해 행 수가 중복 증가하지 않는지 검증해줘.
DB 접속이 준비되지 않았다면 CSV 검증·문법 검사까지만 실행했다고 명시해줘.
기존 테이블 삭제, 운영 DB 수정, 주문 기능 추가는 이 요청의 범위에 넣지 마.
7. 오류 대응과 완료 기준
| 오류 | 확인할 내용 |
|---|---|
| 연결 거절·시간 초과 | 서버 실행 상태, 호스트, 포트, 방화벽 |
Access denied |
사용자명·비밀번호와 접속 호스트별 계정 권한 |
| DB·테이블을 찾지 못함 | MYSQL_DATABASE, 스키마 생성 결과, 현재 접속 DB |
| CHECK 제약 위반 | CSV의 가격 범위와 수집 원본 |
| SQL 문법 오류 | 서버가 MySQL 8.4인지, 다른 DB 제품·버전인지 |
| 재실행 시 행 증가 | 실제 기본 키와 코드가 사용하는 테이블 이름 |
완료 기준은 입력 검증 → 커밋 → 다시 조회 → 동일 파일 재실행이 모두 설명되는 것이다. DB 연결을 실행하지 않았다면 저장 성공으로 기록하지 않는다. 다음 장에서는 daily_prices를 종목별 거래일 순서로 읽어 CCI를 계산한다.
공유 링크를 복사해 이어서 읽을 수 있습니다.