커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병. 넣을 파일 신규: kis_trader/engine/live_sell_price.py kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price) 전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0) database.py (키 주석 + legacy/ sys.path) kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고) EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후) 문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구) 메시지 초안 fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM) 횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다. 호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백. 영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님) 커밋 2 — 루트 정리 (remove/ vs legacy/) 왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐. 넣을 파일 이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등) 이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2) 신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py docs/like_mcp.md/code_architecture.md 수동 노트 메시지 초안 chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/ remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은 ensure_legacy_root로 legacy/만 본다. 빼기: scratch/set_ws_price_max_age_zero.py (일회성)
This commit is contained in:
59
remove/legacy_root/kiwwom_trader/README.md
Normal file
59
remove/legacy_root/kiwwom_trader/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# kiwwom_trader
|
||||
|
||||
키움 REST API + 웹소켓 기반 스캘핑 봇. KIS `kis_scalping_ver2`와 **동일한 로직·구조**를 유지하며, 키움 증권 API로 전환한 버전입니다.
|
||||
|
||||
## 폴더 이전 후에도 동작하도록
|
||||
|
||||
- **프로젝트 루트**: `KIWOOM_TRADER_ROOT` 환경변수로 지정 가능. 미설정 시 이 폴더(`kiwwom_trader`) 기준.
|
||||
- **kiwoom_rest_api**: 이 폴더의 **상위 디렉터리** 또는 **형제 디렉터리**에 두면 자동으로 import됩니다.
|
||||
- 예: `/home/hoon/kiwwom_trader` + `/home/hoon/kiwoom_rest_api`
|
||||
- **database, scalping_engine** (kis_bot): `KIS_BOT_PATH` 환경변수로 kis_bot 루트 지정.
|
||||
- 예: `export KIS_BOT_PATH=/home/hoon/kis_bot`
|
||||
- 미설정 시 상위의 `kis_bot` 폴더를 참조합니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# kiwwom_trader 폴더에서
|
||||
python run.py
|
||||
```
|
||||
|
||||
이전 후 예시:
|
||||
|
||||
```bash
|
||||
export KIWOOM_TRADER_ROOT=/home/hoon/kiwwom_trader
|
||||
export KIS_BOT_PATH=/home/hoon/kis_bot
|
||||
cd /home/hoon/kiwwom_trader
|
||||
python run.py
|
||||
```
|
||||
|
||||
## 구성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `config.py` | ROOT·경로 독립, sys.path 설정, .env 로드 |
|
||||
| `run.py` | 진입점 |
|
||||
| `scalping_bot.py` | 키움 REST(주문/조회) + 매수신호(scalping_engine) 메인 루프 |
|
||||
| `ws_candle_agg.py` | 웹소켓 실시간 체결(0B) → 1분봉 집계 (필요 시 봇에서 연결) |
|
||||
|
||||
## 웹소켓
|
||||
|
||||
키움 웹소켓 실시간 타입 **0B(주식체결)** 를 구독하면 1분봉을 메모리/DB에 쌓을 수 있습니다.
|
||||
`ws_candle_agg.py`의 `KiwoomCandleAggregator`와 `build_ws_client_with_candle_callback`를 사용해,
|
||||
봇 초기화 시 웹소켓 클라이언트를 띄우고 후보 종목 리스트로 등록하면 됩니다.
|
||||
(현재 `run()` 루프는 REST 조회 + DB `ws_candles` 기준으로 동작합니다.)
|
||||
|
||||
## 의존성
|
||||
|
||||
- `pip install -r requirements.txt` (httpx 등 kiwoom_rest_api 의존성 포함)
|
||||
- `kiwoom_rest_api` 패키지는 이 저장소의 `kiwoom_rest_api` 폴더를 상위/형제 경로에 두거나, `PYTHONPATH`에 추가해야 합니다.
|
||||
|
||||
## 인증
|
||||
|
||||
`kiwoom_rest_api/.env` 또는 이 폴더의 `.env`에 다음을 설정합니다.
|
||||
|
||||
- `KIWOOM_API_KEY`
|
||||
- `KIWOOM_API_SECRET`
|
||||
- `KIWOOM_USE_SANDBOX` (true/false)
|
||||
|
||||
DB(MariaDB) 접속은 kis_bot과 동일하게 `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME` 등 환경변수를 사용합니다.
|
||||
43
remove/legacy_root/kiwwom_trader/config.py
Normal file
43
remove/legacy_root/kiwwom_trader/config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# kiwwom_trader/config.py
|
||||
# 폴더를 /home/hoon 밑으로 옮겨도 경로 변경 없이 동작하도록 ROOT·경로 독립 처리
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 프로젝트 루트: 환경변수 우선, 없으면 이 파일 기준 상위 디렉터리 (kiwwom_trader)
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.environ.get("KIWOOM_TRADER_ROOT", _THIS_DIR)
|
||||
|
||||
# kiwoom_rest_api 패키지 경로 후보 (폴더 이전 후에도 import 가능하도록)
|
||||
def _ensure_paths():
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
# 부모 디렉터리: /home/hoon/kiwwom_trader → /home/hoon (kiwoom_rest_api, kis_bot 형제)
|
||||
_parent = os.path.dirname(ROOT)
|
||||
if _parent not in sys.path:
|
||||
sys.path.insert(0, _parent)
|
||||
# kis_bot (database, scalping_engine): 환경변수로 명시 가능
|
||||
_kis_bot = os.environ.get("KIS_BOT_PATH", os.path.join(_parent, "kis_bot"))
|
||||
if os.path.isdir(_kis_bot) and _kis_bot not in sys.path:
|
||||
sys.path.insert(0, _kis_bot)
|
||||
|
||||
_ensure_paths()
|
||||
|
||||
# .env 로드: kiwoom_rest_api/.env 또는 ROOT/.env
|
||||
def load_dotenv():
|
||||
try:
|
||||
from dotenv import load_dotenv as _load
|
||||
for p in [
|
||||
os.path.join(ROOT, "kiwoom_rest_api", ".env"),
|
||||
os.path.join(ROOT, ".env"),
|
||||
os.path.join(os.path.dirname(ROOT), "kiwoom_rest_api", ".env"),
|
||||
]:
|
||||
if os.path.isfile(p):
|
||||
_load(p)
|
||||
return p
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
LOG_DIR = os.environ.get("KIWOOM_TRADER_LOG_DIR", os.path.join(ROOT, "logs"))
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
5
remove/legacy_root/kiwwom_trader/requirements.txt
Normal file
5
remove/legacy_root/kiwwom_trader/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# kiwwom_trader 전용 (키움 REST API는 상위 또는 형제 경로 kiwoom_rest_api 사용)
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.28.0
|
||||
websockets>=11.0
|
||||
httpx>=0.24.0
|
||||
23
remove/legacy_root/kiwwom_trader/run.py
Normal file
23
remove/legacy_root/kiwwom_trader/run.py
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
# kiwwom_trader/run.py
|
||||
# 진입점. 폴더를 /home/hoon 밑으로 옮겨도 KIWOOM_TRADER_ROOT / KIS_BOT_PATH 로 동작.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 이 파일이 있는 디렉터리를 먼저 path에 넣어 같은 폴더의 config 로드
|
||||
_run_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _run_dir not in sys.path:
|
||||
sys.path.insert(0, _run_dir)
|
||||
|
||||
import config # noqa: F401
|
||||
|
||||
config.load_dotenv()
|
||||
|
||||
def main():
|
||||
from scalping_bot import ScalpingBotKiwoom
|
||||
bot = ScalpingBotKiwoom()
|
||||
bot.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
261
remove/legacy_root/kiwwom_trader/scalping_bot.py
Normal file
261
remove/legacy_root/kiwwom_trader/scalping_bot.py
Normal file
@@ -0,0 +1,261 @@
|
||||
# kiwwom_trader/scalping_bot.py
|
||||
# 키움 REST API + 웹소켓 기반 스캘핑 봇. KIS kis_scalping_ver2와 동일한 로직·구조.
|
||||
# 폴더를 /home/hoon 밑으로 옮겨도 config 경로 설정으로 동작.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime as dt
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# config는 run.py에서 먼저 import됨. 여기서는 ROOT만 참조
|
||||
import config
|
||||
|
||||
logging.basicConfig(
|
||||
format="[%(asctime)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
level=logging.INFO,
|
||||
)
|
||||
logger = logging.getLogger("KiwoomScalp")
|
||||
|
||||
# ── 의존성: kiwoom_rest_api (ROOT/.. 에 있거나 sys.path에 추가된 상태) ──
|
||||
try:
|
||||
from kiwoom_rest_api.auth.token import TokenManager
|
||||
from kiwoom_rest_api.koreanstock.stockinfo import StockInfo
|
||||
from kiwoom_rest_api.koreanstock.chart import Chart
|
||||
from kiwoom_rest_api.koreanstock.order import Order
|
||||
from kiwoom_rest_api.koreanstock.rank_info import RankInfo
|
||||
from kiwoom_rest_api.koreanstock.account import Account
|
||||
from kiwoom_rest_api.koreanstock.market_condition import MarketCondition
|
||||
_KIWOOM_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
logger.warning("kiwoom_rest_api 미설치 또는 경로 미등록: %s", e)
|
||||
_KIWOOM_AVAILABLE = False
|
||||
|
||||
# ── kis_bot (database, scalping_engine 래핑 로직) ──
|
||||
try:
|
||||
from database import TradeDB
|
||||
from scalping_logic import (
|
||||
load_defaults_from_db,
|
||||
check_buy_signal as logic_check_buy_signal,
|
||||
)
|
||||
_KIS_BOT_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
logger.warning(
|
||||
"kis_bot 경로 미등록(database, scalping_logic): %s → KIS_BOT_PATH 또는 동일 레포 내 실행 필요",
|
||||
e,
|
||||
)
|
||||
_KIS_BOT_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_env_from_db(db, key: str, default: str = ""):
|
||||
if not db:
|
||||
return os.environ.get(key, default)
|
||||
env_data = db.get_latest_env()
|
||||
if env_data and env_data.get("snapshot"):
|
||||
return env_data["snapshot"].get(key, default)
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def _get_env_float(db, key: str, default: float):
|
||||
v = _get_env_from_db(db, key, str(default))
|
||||
if isinstance(v, str) and "#" in v:
|
||||
v = v.split("#")[0].strip()
|
||||
try:
|
||||
return float(v) if v else default
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_env_int(db, key: str, default: int):
|
||||
v = _get_env_from_db(db, key, str(default))
|
||||
if isinstance(v, str) and "#" in v:
|
||||
v = v.split("#")[0].strip()
|
||||
try:
|
||||
return int(v) if v else default
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
class ScalpingBotKiwoom:
|
||||
"""
|
||||
키움 REST API로 주문·조회, 웹소켓으로 실시간 체결 수신 후 1분봉 집계.
|
||||
매수/매도 판단은 scalping_engine (kis_bot)과 동일.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if not _KIWOOM_AVAILABLE:
|
||||
raise RuntimeError("kiwoom_rest_api를 사용할 수 없습니다. 경로 또는 설치를 확인하세요.")
|
||||
if not _KIS_BOT_AVAILABLE:
|
||||
raise RuntimeError("database, scalping_engine을 사용할 수 없습니다. KIS_BOT_PATH 또는 kis_bot 경로를 설정하세요.")
|
||||
|
||||
self.db = TradeDB()
|
||||
self.token_manager = TokenManager()
|
||||
self.stock_info = StockInfo(token_manager=self.token_manager)
|
||||
self.chart = Chart(token_manager=self.token_manager)
|
||||
self.order = Order(token_manager=self.token_manager)
|
||||
self.rank = RankInfo(token_manager=self.token_manager)
|
||||
self.account = Account(token_manager=self.token_manager)
|
||||
self.market = MarketCondition(token_manager=self.token_manager)
|
||||
|
||||
self.candle_tf = 1
|
||||
self.rsi_oversold = _get_env_float(self.db, "SCALP_RSI_OVERSOLD", 25.0)
|
||||
self.scalp_stop_loss_pct = _get_env_float(self.db, "SCALP_STOP_LOSS_PCT", 0.015)
|
||||
self.scalp_take_profit_pct = _get_env_float(self.db, "SCALP_TAKE_PROFIT_PCT", 0.015)
|
||||
self.scalp_min_drop_rate = _get_env_float(self.db, "SCALP_MIN_DROP_RATE", 0.015)
|
||||
self.max_stocks = _get_env_int(self.db, "SCALP_MAX_POSITIONS", 3)
|
||||
self._scan_engine_params = None
|
||||
self._candle_agg = None
|
||||
self._ws_client = None
|
||||
self._ws_thread = None
|
||||
|
||||
def start_websocket_candles(self, codes: List[str]) -> None:
|
||||
"""웹소켓 실시간 체결(0B) 구독 → 1분봉 집계. codes는 종목 코드 리스트 (최대 100개 권장)."""
|
||||
from ws_candle_agg import KiwoomCandleAggregator, build_ws_client_with_candle_callback
|
||||
token = self.token_manager.access_token
|
||||
if not token:
|
||||
logger.warning("웹소켓: 토큰 없음 → 스킵")
|
||||
return
|
||||
self._candle_agg = KiwoomCandleAggregator()
|
||||
client = build_ws_client_with_candle_callback(token, self._candle_agg)
|
||||
if client is None:
|
||||
return
|
||||
self._ws_client = client
|
||||
import asyncio
|
||||
async def run_ws():
|
||||
await client.start()
|
||||
await client.register_realtime(type_list=["0B"], item_list=codes if codes else [""])
|
||||
await client.run_forever()
|
||||
def thread_target():
|
||||
asyncio.run(run_ws())
|
||||
import threading
|
||||
self._ws_thread = threading.Thread(target=thread_target, daemon=True)
|
||||
self._ws_thread.start()
|
||||
logger.info("웹소켓 캔들 구독 시작: %d종목", len(codes))
|
||||
|
||||
def _ensure_engine_params(self):
|
||||
if self._scan_engine_params is None:
|
||||
d = load_defaults_from_db()
|
||||
self._scan_engine_params = {
|
||||
**d,
|
||||
"rsi_oversold": self.rsi_oversold,
|
||||
"sl_pct": abs(self.scalp_stop_loss_pct),
|
||||
"tp_pct": self.scalp_take_profit_pct,
|
||||
"drop_rate": self.scalp_min_drop_rate,
|
||||
}
|
||||
|
||||
def _norm_candle(self, c: dict) -> dict:
|
||||
"""scalping_engine이 기대하는 키: candle_time, open, high, low, close, volume."""
|
||||
return {
|
||||
"candle_time": c.get("candle_time", ""),
|
||||
"open": float(c.get("open", 0)),
|
||||
"high": float(c.get("high", 0)),
|
||||
"low": float(c.get("low", 0)),
|
||||
"close": float(c.get("close", 0)),
|
||||
"volume": int(c.get("volume", 0)),
|
||||
}
|
||||
|
||||
def get_candles(self, code: str, n: int = 50) -> List[dict]:
|
||||
"""캔들 소스: 웹소켓 집계 → 없으면 DB ws_candles. scalping_engine 형식으로 반환."""
|
||||
if self._candle_agg:
|
||||
candles = self._candle_agg.get_candles(code, self.candle_tf, n=n)
|
||||
if candles:
|
||||
return [self._norm_candle(c) for c in candles]
|
||||
raw = self.db.get_ws_candles(code, self.candle_tf, limit=n, confirmed_only=True)
|
||||
return [self._norm_candle(c) for c in raw]
|
||||
|
||||
def _get_ws_codes_from_universe_history(self, max_codes: int = 100) -> List[str]:
|
||||
"""
|
||||
오늘자 유니버스 히스토리(target_candidates_history)에서
|
||||
슬롯 순서대로 종목 코드를 모아서 웹소켓 구독 리스트 생성.
|
||||
- slot_key(YYYYMMDDHHMM) 오름차순으로 flatten
|
||||
- 중복 제거, 최대 max_codes개까지
|
||||
- 히스토리가 비어 있으면 현재 target_candidates로 폴백
|
||||
"""
|
||||
today_ymd = dt.now().strftime("%Y%m%d")
|
||||
codes: List[str] = []
|
||||
seen = set()
|
||||
try:
|
||||
universe_by_slot = self.db.get_universe_history_for_backtest(today_ymd, today_ymd)
|
||||
for slot_key in sorted(universe_by_slot.keys()):
|
||||
for code in universe_by_slot[slot_key]:
|
||||
if not code or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
codes.append(code)
|
||||
if len(codes) >= max_codes:
|
||||
break
|
||||
if len(codes) >= max_codes:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("유니버스 히스토리 조회 실패 → 폴백 사용: %s", e)
|
||||
|
||||
# 히스토리가 아직 없으면 현재 target_candidates 상위 N개로 폴백
|
||||
if not codes:
|
||||
try:
|
||||
current = self.db.get_target_candidates()
|
||||
for c in current:
|
||||
code = c.get("code")
|
||||
if not code or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
codes.append(code)
|
||||
if len(codes) >= max_codes:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("폴백 후보 조회 실패: %s", e)
|
||||
|
||||
return codes
|
||||
|
||||
def check_buy_signal(self, code: str, name: str) -> Optional[dict]:
|
||||
"""scalping_engine.check_buy_signal_live 사용 (KIS 봇과 동일)."""
|
||||
self._ensure_engine_params()
|
||||
candles = self.get_candles(code, n=50)
|
||||
if len(candles) < 5:
|
||||
return None
|
||||
today = dt.now().strftime("%Y%m%d")
|
||||
try:
|
||||
today_trades = self.db.get_trades_by_date(today)
|
||||
daily_cnt = len([t for t in today_trades if t.get("code") == code and str(t.get("strategy", "")).startswith("SCALP")])
|
||||
except Exception:
|
||||
daily_cnt = 0
|
||||
state = {"last_exit_dt": None, "daily_cnt": daily_cnt}
|
||||
reject_reason, reject_msg, sig = logic_check_buy_signal(candles, self._scan_engine_params, state)
|
||||
if reject_reason or sig is None:
|
||||
return None
|
||||
return sig
|
||||
|
||||
def run(self):
|
||||
"""메인 루프: 후보 조회 → 매수 신호 검사 → 주문 (구조만 연결, 실제 주문은 안전을 위해 로그만)."""
|
||||
logger.info("키움 스캘핑 봇 시작 (ROOT=%s)", config.ROOT)
|
||||
# 웹소켓 캔들: 후보가 있으면 0B 구독 (선택)
|
||||
if _get_env_from_db(self.db, "KIWOOM_WS_CANDLES", "0").lower() in ("1", "true", "yes"):
|
||||
try:
|
||||
codes = self._get_ws_codes_from_universe_history(max_codes=100)
|
||||
if codes:
|
||||
self.start_websocket_candles(codes)
|
||||
except Exception as e:
|
||||
logger.warning("웹소켓 캔들 시작 스킵: %s", e)
|
||||
while True:
|
||||
try:
|
||||
candidates = self.db.get_target_candidates()
|
||||
if not candidates:
|
||||
logger.info("후보 0건 → 대기")
|
||||
time.sleep(60)
|
||||
continue
|
||||
for c in candidates[: self.max_stocks * 2]:
|
||||
code = c.get("code")
|
||||
name = c.get("name", code)
|
||||
if not code:
|
||||
continue
|
||||
sig = self.check_buy_signal(code, name)
|
||||
if sig:
|
||||
logger.info("[매수신호] %s %s RSI=%.1f (실주문은 주석 해제 후 사용)", name, code, sig.get("rsi"))
|
||||
# self._place_buy(code, name, sig) # 실제 주문 시 구현
|
||||
time.sleep(30)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("종료")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception("루프 오류: %s", e)
|
||||
time.sleep(60)
|
||||
45
remove/legacy_root/kiwwom_trader/scalping_logic.py
Normal file
45
remove/legacy_root/kiwwom_trader/scalping_logic.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
kiwwom_trader/scalping_logic.py
|
||||
키움 스캘핑 봇이 사용할 공통 매매 로직 래퍼.
|
||||
|
||||
핵심 계산/판단은 모두 kis_bot/scalping_engine.py 를 그대로 호출해서 사용하고,
|
||||
봇 쪽에서는 이 모듈만 임포트해서 매수/매도 시그널을 판단한다.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from kis_trader.engine import scalping_engine as _se
|
||||
|
||||
|
||||
def load_defaults_from_db() -> Dict[str, Any]:
|
||||
"""
|
||||
env_config 최신 행에서 스캘핑 관련 기본값 로드.
|
||||
kis_bot/scalping_engine.get_scalping_defaults_from_db 를 그대로 래핑.
|
||||
"""
|
||||
return _se.get_scalping_defaults_from_db()
|
||||
|
||||
|
||||
def check_buy_signal(
|
||||
candles: List[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
state: Dict[str, Any],
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
실시간 매수 시그널 판단.
|
||||
kis_bot/scalping_engine.check_buy_signal_live 를 그대로 사용.
|
||||
"""
|
||||
return _se.check_buy_signal_live(candles, params, state)
|
||||
|
||||
|
||||
def check_sell_signal(
|
||||
position: Dict[str, Any],
|
||||
current_candle: Dict[str, Any],
|
||||
params: Dict[str, Any],
|
||||
is_eod: bool = False,
|
||||
) -> Optional[Tuple[str, float]]:
|
||||
"""
|
||||
실시간 매도 시그널 판단.
|
||||
kis_bot/scalping_engine.check_sell_signal_live 를 그대로 사용.
|
||||
"""
|
||||
return _se.check_sell_signal_live(position, current_candle, params, is_eod)
|
||||
|
||||
122
remove/legacy_root/kiwwom_trader/ws_candle_agg.py
Normal file
122
remove/legacy_root/kiwwom_trader/ws_candle_agg.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# kiwwom_trader/ws_candle_agg.py
|
||||
# 키움 웹소켓 실시간 체결(0B) 수신 → 1분봉 집계 → DB 또는 메모리 버퍼
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _slot_1m(now: datetime) -> str:
|
||||
"""현재 시각 기준 1분 봉 키 (YYYYMMDDHHMI)."""
|
||||
return now.strftime("%Y%m%d%H%M")
|
||||
|
||||
|
||||
class KiwoomCandleAggregator:
|
||||
"""
|
||||
키움 실시간 타입 0B(주식체결) 수신 시 호출되는 콜백에서
|
||||
종목별·1분봉별 OHLCV를 집계. 확정된 봉은 on_candle 콜백 또는 DB에 전달.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
on_candle: Optional[Callable[[str, int, str, dict], None]] = None,
|
||||
timeframe_min: int = 1,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
on_candle: (code, timeframe_min, candle_time, candle_dict) 호출. DB 저장 시 사용.
|
||||
timeframe_min: 봉 간격(분). 1 = 1분봉.
|
||||
"""
|
||||
self.on_candle = on_candle
|
||||
self.timeframe_min = timeframe_min
|
||||
# code -> slot_1m -> { open, high, low, close, volume }
|
||||
self._buf: Dict[str, Dict[str, dict]] = defaultdict(dict)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def push_tick(self, code: str, price: float, volume: int, tick_time: Optional[datetime] = None):
|
||||
"""
|
||||
체결 1건 누적. 스레드 세이프하지 않음; 이벤트 루프 한 스레드에서 호출 권장.
|
||||
"""
|
||||
t = tick_time or datetime.now()
|
||||
slot = _slot_1m(t)
|
||||
if slot not in self._buf[code]:
|
||||
self._buf[code][slot] = {
|
||||
"open": price,
|
||||
"high": price,
|
||||
"low": price,
|
||||
"close": price,
|
||||
"volume": volume,
|
||||
}
|
||||
else:
|
||||
c = self._buf[code][slot]
|
||||
c["high"] = max(c["high"], price)
|
||||
c["low"] = min(c["low"], price)
|
||||
c["close"] = price
|
||||
c["volume"] = c.get("volume", 0) + volume
|
||||
|
||||
def flush_slot(self, code: str, slot: str) -> Optional[dict]:
|
||||
"""해당 종목·슬롯 봉을 확정해 반환하고 버퍼에서 제거."""
|
||||
if code not in self._buf or slot not in self._buf[code]:
|
||||
return None
|
||||
c = self._buf[code].pop(slot)
|
||||
if not self._buf[code]:
|
||||
del self._buf[code]
|
||||
return c
|
||||
|
||||
def get_candles(self, code: str, timeframe_min: int, n: int = 50) -> List[dict]:
|
||||
"""
|
||||
메모리 버퍼만 사용하는 경우: 현재 버퍼에 쌓인 봉 반환. 없으면 빈 리스트.
|
||||
DB와 연동 시에는 이 메서드에서 DB를 조회하도록 확장.
|
||||
"""
|
||||
if code not in self._buf:
|
||||
return []
|
||||
slots = sorted(self._buf[code].keys())
|
||||
out = []
|
||||
for slot in slots[-n:]:
|
||||
c = self._buf[code][slot].copy()
|
||||
c["candle_time"] = slot
|
||||
c["volume"] = c.get("volume", 0)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def build_ws_client_with_candle_callback(
|
||||
access_token: str,
|
||||
aggregator: KiwoomCandleAggregator,
|
||||
ws_url: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
키움 WebSocketClient에 실시간 0B 수신 시 aggregator.push_tick 호출하도록 연결.
|
||||
kiwoom_rest_api.websocket / websocket_helper 사용.
|
||||
"""
|
||||
try:
|
||||
from kiwoom_rest_api.websocket import WebSocketClient, RealTimeData
|
||||
from kiwoom_rest_api.websocket_constants import STOCK_TRADE_FIELDS, get_field_name
|
||||
except ImportError:
|
||||
logger.warning("kiwoom_rest_api 미설치 또는 경로 미등록 → 웹소켓 캔들 비활성")
|
||||
return None
|
||||
|
||||
client = WebSocketClient(access_token=access_token, ws_url=ws_url)
|
||||
|
||||
async def on_data(realtime_data: RealTimeData):
|
||||
if realtime_data.trnm != "REAL":
|
||||
return
|
||||
for item in realtime_data.data or []:
|
||||
type_code = item.get("type", "")
|
||||
if type_code != "0B":
|
||||
continue
|
||||
item_code = (item.get("item") or [""])[0] if isinstance(item.get("item"), list) else item.get("item", "")
|
||||
if not item_code:
|
||||
continue
|
||||
values = item.get("values") or {}
|
||||
# 10:현재가, 15:거래량 등 (STOCK_TRADE_FIELDS)
|
||||
price = float(values.get("10", 0) or 0)
|
||||
vol = int(values.get("15", 0) or 0)
|
||||
if price > 0:
|
||||
aggregator.push_tick(item_code, price, vol)
|
||||
|
||||
client.on_data = on_data
|
||||
return client
|
||||
Reference in New Issue
Block a user