feat(Core/UI): 백테스트 전역 엔진 선택 버튼 추가 및 스캘핑 Rust 방어로직 포팅 1차 완료

This commit is contained in:
Your Name
2026-09-03 02:49:14 +09:00
parent ac1190f1fc
commit 3b7c21cde4
316 changed files with 56952 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ use pyo3::prelude::*;
pub mod tail;
pub mod db_loader;
pub mod scalp;
/// 파이썬에서 호출할 백테스트 벤치마크 함수
#[pyfunction]
@@ -18,7 +19,10 @@ fn kis_rust_core(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(run_dummy_backtest, m)?)?;
m.add_function(wrap_pyfunction!(tail::run_tail_backtest_fast, m)?)?;
m.add_function(wrap_pyfunction!(db_loader::load_candles_from_db, m)?)?;
m.add_function(wrap_pyfunction!(scalp::run_scalp_backtest_fast, m)?)?;
m.add_class::<tail::TailParams>()?;
m.add_class::<tail::CandleData>()?;
m.add_class::<scalp::ScalpParams>()?;
m.add_class::<scalp::ScalpTrade>()?;
Ok(())
}

313
kis_rust_core/src/scalp.rs Normal file
View File

@@ -0,0 +1,313 @@
use pyo3::prelude::*;
use serde::{Deserialize, Serialize};
use crate::tail::CandleData;
#[pyclass]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScalpParams {
#[pyo3(get, set)]
pub rsi_period: usize,
#[pyo3(get, set)]
pub rsi_oversold: f64,
#[pyo3(get, set)]
pub rsi_overbought: f64,
#[pyo3(get, set)]
pub sl_pct: f64,
#[pyo3(get, set)]
pub tp_pct: f64,
#[pyo3(get, set)]
pub drop_rate: f64,
#[pyo3(get, set)]
pub cooldown_min: f64,
#[pyo3(get, set)]
pub max_daily: usize,
#[pyo3(get, set)]
pub high_chase_thr: f64,
#[pyo3(get, set)]
pub max_daily_chg: f64,
#[pyo3(get, set)]
pub min_price: f64,
#[pyo3(get, set)]
pub vol_mult: f64,
#[pyo3(get, set)]
pub use_defense_filters: bool,
#[pyo3(get, set)]
pub skip_hts: bool,
#[pyo3(get, set)]
pub time_start_hm: usize,
#[pyo3(get, set)]
pub time_end_hm: usize,
}
#[pymethods]
impl ScalpParams {
#[new]
#[allow(clippy::too_many_arguments)]
pub fn new(
rsi_period: usize, rsi_oversold: f64, rsi_overbought: f64,
sl_pct: f64, tp_pct: f64, drop_rate: f64, cooldown_min: f64, max_daily: usize,
high_chase_thr: f64, max_daily_chg: f64, min_price: f64, vol_mult: f64,
use_defense_filters: bool, skip_hts: bool, time_start_hm: usize, time_end_hm: usize
) -> Self {
Self {
rsi_period, rsi_oversold, rsi_overbought, sl_pct, tp_pct, drop_rate, cooldown_min, max_daily,
high_chase_thr, max_daily_chg, min_price, vol_mult, use_defense_filters, skip_hts,
time_start_hm, time_end_hm
}
}
}
#[pyclass]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScalpTrade {
#[pyo3(get, set)]
pub code: String,
#[pyo3(get, set)]
pub buy_time: String,
#[pyo3(get, set)]
pub sell_time: String,
#[pyo3(get, set)]
pub buy_price: f64,
#[pyo3(get, set)]
pub sell_price: f64,
#[pyo3(get, set)]
pub pnl_pct: f64,
#[pyo3(get, set)]
pub reason: String,
#[pyo3(get, set)]
pub rsi_entry: f64,
}
#[pymethods]
impl ScalpTrade {
#[new]
#[allow(clippy::too_many_arguments)]
pub fn new(code: String, buy_time: String, sell_time: String, buy_price: f64, sell_price: f64, pnl_pct: f64, reason: String, rsi_entry: f64) -> Self {
Self { code, buy_time, sell_time, buy_price, sell_price, pnl_pct, reason, rsi_entry }
}
}
// 간단한 Wilder's Smoothing RSI 연산
fn compute_rsi_series(closes: &[f64], period: usize) -> Vec<f64> {
let mut rsis = vec![0.0; closes.len()];
if closes.len() <= period || period == 0 {
return rsis;
}
let mut sum_gain = 0.0;
let mut sum_loss = 0.0;
for i in 1..=period {
let diff = closes[i] - closes[i - 1];
if diff >= 0.0 {
sum_gain += diff;
} else {
sum_loss -= diff;
}
}
let mut avg_gain = sum_gain / period as f64;
let mut avg_loss = sum_loss / period as f64;
if avg_loss == 0.0 {
rsis[period] = 100.0;
} else {
let rs = avg_gain / avg_loss;
rsis[period] = 100.0 - (100.0 / (1.0 + rs));
}
for i in (period + 1)..closes.len() {
let diff = closes[i] - closes[i - 1];
let mut gain = 0.0;
let mut loss = 0.0;
if diff > 0.0 {
gain = diff;
} else {
loss = -diff;
}
avg_gain = (avg_gain * (period - 1) as f64 + gain) / period as f64;
avg_loss = (avg_loss * (period - 1) as f64 + loss) / period as f64;
if avg_loss == 0.0 {
rsis[i] = 100.0;
} else {
let rs = avg_gain / avg_loss;
rsis[i] = 100.0 - (100.0 / (1.0 + rs));
}
}
rsis
}
fn parse_hm(time_str: &str) -> usize {
if time_str.len() >= 12 {
time_str[8..12].parse::<usize>().unwrap_or(0)
} else {
0
}
}
fn parse_mins(time_str: &str) -> i64 {
if time_str.len() >= 12 {
let h = time_str[8..10].parse::<i64>().unwrap_or(0);
let m = time_str[10..12].parse::<i64>().unwrap_or(0);
h * 60 + m
} else {
0
}
}
/// 스캘핑 백테스트 엔진
#[pyfunction]
pub fn run_scalp_backtest_fast(
target_code: &str,
candles: Vec<CandleData>,
params: &ScalpParams,
) -> PyResult<Vec<ScalpTrade>> {
let mut trades = Vec::new();
let mut in_position = false;
let mut buy_price = 0.0;
let mut buy_time = String::new();
let mut rsi_entry = 0.0;
let mut daily_cnt = 0;
let mut cur_day = String::new();
let mut last_exit_mins = -999;
let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
let rsis = compute_rsi_series(&closes, params.rsi_period);
let mut running_open = 0.0;
let mut running_high = 0.0;
let mut running_low = 0.0;
for i in params.rsi_period..candles.len() {
let c = &candles[i];
let day = if c.time_str.len() >= 8 { &c.time_str[0..8] } else { "" };
let hm = parse_hm(&c.time_str);
// 일별 변수 갱신
if cur_day != day {
cur_day = day.to_string();
daily_cnt = 0;
last_exit_mins = -999;
running_open = c.open;
running_high = c.high;
running_low = c.low;
} else {
if c.high > running_high { running_high = c.high; }
if c.low < running_low { running_low = c.low; }
}
let current_mins = parse_mins(&c.time_str);
if in_position {
let pnl = (c.close - buy_price) / buy_price;
// 청산 1: EOD (장마감 청산)
let is_eod = hm >= 1520 && hm <= 1530 && c.time_str.ends_with("1520"); // 간이 eod
if is_eod || hm >= 1530 {
trades.push(ScalpTrade {
code: target_code.to_string(), buy_time: buy_time.clone(), sell_time: c.time_str.clone(),
buy_price, sell_price: c.close, pnl_pct: pnl * 100.0, reason: "EOD_SELL".to_string(), rsi_entry
});
in_position = false;
last_exit_mins = current_mins;
}
// 청산 2: TP
else if pnl >= params.tp_pct {
trades.push(ScalpTrade {
code: target_code.to_string(), buy_time: buy_time.clone(), sell_time: c.time_str.clone(),
buy_price, sell_price: c.close, pnl_pct: pnl * 100.0, reason: "TP_REACHED".to_string(), rsi_entry
});
in_position = false;
last_exit_mins = current_mins;
}
// 청산 3: SL
else if pnl <= -params.sl_pct {
trades.push(ScalpTrade {
code: target_code.to_string(), buy_time: buy_time.clone(), sell_time: c.time_str.clone(),
buy_price, sell_price: c.close, pnl_pct: pnl * 100.0, reason: "SL_REACHED".to_string(), rsi_entry
});
in_position = false;
last_exit_mins = current_mins;
}
} else {
// 시간 필터
if hm < params.time_start_hm || hm >= params.time_end_hm {
continue;
}
// 쿨다운
if current_mins - last_exit_mins < params.cooldown_min as i64 {
continue;
}
// 일일 횟수
if daily_cnt >= params.max_daily {
continue;
}
// 방어 로직 (use_defense_filters)
if params.use_defense_filters {
if c.close < params.min_price { continue; }
if running_open > 0.0 && !params.skip_hts {
let dr = (running_open - running_low) / running_open;
if dr < params.drop_rate { continue; } // 낙폭 부족
if c.close >= running_high * params.high_chase_thr { continue; } // 고점 추격
let daily_chg_pct = (running_high - running_low) / running_low * 100.0;
if daily_chg_pct > params.max_daily_chg { continue; } // 급등주
}
}
// 거래량 필터
if !params.skip_hts && params.vol_mult > 0.0 {
let win = if i > 20 { 20 } else { i };
if win > 0 {
let mut sum_vol = 0.0;
for j in (i - win)..i {
sum_vol += candles[j].volume;
}
let avg_vol = sum_vol / win as f64;
if avg_vol > 0.0 && c.volume < avg_vol * params.vol_mult {
continue;
}
}
}
let rsi = rsis[i];
if params.skip_hts {
// skip_hts가 켜져 있으면, HTS가 통과한 유니버스(포트폴리오 모드)라고 가정. RSI 조건은 통과했다고 봄.
// (파이썬 엔진에서도 skip_hts=True면 바로 매수)
in_position = true;
buy_price = c.close;
buy_time = c.time_str.clone();
rsi_entry = rsi;
daily_cnt += 1;
} else {
// V자 반등(RSI) 체크
if i < 2 { continue; }
let prev_rsi = rsis[i-1];
let prev2_rsi = rsis[i-2];
if rsi <= 0.0 || rsi > params.rsi_overbought { continue; }
let is_v_rebound = (prev2_rsi <= params.rsi_oversold || prev_rsi <= params.rsi_oversold)
&& rsi > prev_rsi;
let prev_c = &candles[i-1];
let prev_bear = prev_c.close < prev_c.open;
let curr_bull = c.close > c.open; // require_reversal_candle
if is_v_rebound && (prev_bear && curr_bull) {
in_position = true;
buy_price = c.close;
buy_time = c.time_str.clone();
rsi_entry = rsi;
daily_cnt += 1;
}
}
}
}
Ok(trades)
}