Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# 패키지는 pykis/ 에서 kis_agent/ 로 이름이 바뀌었다. 경로가 낡아 있던 동안
# lint/bandit/LOC/커버리지가 존재하지 않는 디렉터리를 검사하며 통과했다.
src-path: 'kis_agent/'
test-path: 'tests/unit/'
coverage-threshold: 0
test-path: 'tests/'
coverage-threshold: 100
max-line-count: 1500
secrets: inherit
18 changes: 10 additions & 8 deletions .github/workflows/coverage-boost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ on:
pull_request:
types: [opened, synchronize]
paths:
- "pykis/**/*.py"
- "kis_agent/**/*.py"
- "tests/**/*.py"
workflow_dispatch:
inputs:
target_coverage:
description: "Target coverage percentage"
required: false
default: "70"
default: "100"

env:
PYTHON_VERSION: "3.12"
TARGET_COVERAGE: ${{ github.event.inputs.target_coverage || '70' }}
TARGET_COVERAGE: ${{ github.event.inputs.target_coverage || '100' }}

jobs:
coverage-analysis:
Expand Down Expand Up @@ -46,11 +46,14 @@ jobs:
id: coverage
run: |
# Run tests with coverage
pytest tests/unit/ \
--cov=pykis \
pytest tests/ \
--cov=kis_agent \
--cov-fail-under="${TARGET_COVERAGE}" \
--cov-report=json:coverage.json \
--cov-report=term-missing \
-q --tb=no || true
--timeout=60 \
--disable-warnings \
-q --tb=no

# Extract overall coverage
TOTAL_COV=$(python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered'])" 2>/dev/null || echo "0")
Expand All @@ -69,7 +72,7 @@ jobs:

low_coverage = []
for filepath, info in data.get('files', {}).items():
if filepath.startswith('pykis/'):
if filepath.startswith('kis_agent/'):
pct = info['summary']['percent_covered']
missing = info['missing_lines']
if pct < TARGET and missing:
Expand Down Expand Up @@ -175,4 +178,3 @@ jobs:
coverage.json
coverage_report.md
low_coverage_files.txt

25 changes: 10 additions & 15 deletions kis_agent/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,9 @@ def make_request(
data = None
try:
if self.verbose:
logger.info(f"[API] ({method}) {tr_id} 시도 {attempt+1}/{retries}")
logger.info(
f"[API] ({method}) {tr_id} 시도 {attempt + 1}/{retries}"
)

response = httpx.request(
method.upper(),
Expand All @@ -396,7 +398,7 @@ def make_request(
data = response.json()
except json.JSONDecodeError:
logger.error(
f"[{tr_id}] JSON 디코드 실패 (시도 {attempt+1}/{retries})"
f"[{tr_id}] JSON 디코드 실패 (시도 {attempt + 1}/{retries})"
)
logger.error(
f"[{tr_id}] 원시 응답 텍스트: {response.text[:500]}..."
Expand Down Expand Up @@ -430,11 +432,11 @@ def make_request(
self.rate_limiter.report_success()
return data
else:
if response.status_code == 200 and rt_cd != "0":
if response.status_code == 200:
api_msg = data.get("msg1", "")
api_code = data.get("rt_cd")
logger.warning(
f"[{tr_id}] API 오류 응답 (시도 {attempt+1}/{retries}): {api_msg} (code: {api_code})"
f"[{tr_id}] API 오류 응답 (시도 {attempt + 1}/{retries}): {api_msg} (code: {api_code})"
)

# 유량 제한 에러 체크
Expand Down Expand Up @@ -477,7 +479,7 @@ def make_request(

if attempt < retries - 1:
logger.warning(
f"[{tr_id}] API 유량 제한 감지 (code: {api_code}). 0.5초 대기 후 재시도... ({attempt+1}/{retries})"
f"[{tr_id}] API 유량 제한 감지 (code: {api_code}). 0.5초 대기 후 재시도... ({attempt + 1}/{retries})"
)
time.sleep(
0.5
Expand All @@ -495,7 +497,7 @@ def make_request(
"status_code": response.status_code,
"error_type": "ApiError",
}
elif response.status_code != 200:
else:
http_error_msg = (
data.get("msg1", response.text)
if data and isinstance(data, dict)
Expand All @@ -506,7 +508,7 @@ def make_request(
if data and isinstance(data, dict)
else None
)
log_entry = f"[{tr_id}] HTTP 오류 응답 (시도 {attempt+1}/{retries}): Status {response.status_code}, Message: {http_error_msg}"
log_entry = f"[{tr_id}] HTTP 오류 응답 (시도 {attempt + 1}/{retries}): Status {response.status_code}, Message: {http_error_msg}"
if http_error_code_from_json:
log_entry += (
f" (API Code in JSON: {http_error_code_from_json})"
Expand All @@ -530,13 +532,8 @@ def make_request(
"error_type": "HTTPErrorFinal",
}
)
else:
logger.error(
f"[{tr_id}] 로직 오류: 예상치 못한 HTTP/API 상태 (시도 {attempt+1}/{retries}). 응답: {data}. HTTP Status: {response.status_code if response else 'N/A'}"
)
return data
except (httpx.RequestError, requests.exceptions.RequestException) as e:
logger.error(f"[{tr_id}] 요청 실패 (시도 {attempt+1}/{retries}): {e}")
logger.error(f"[{tr_id}] 요청 실패 (시도 {attempt + 1}/{retries}): {e}")
last_exception = e
if attempt < retries - 1:
time.sleep(
Expand All @@ -552,8 +549,6 @@ def make_request(
logger.error(
f"[{tr_id}] 최종 실패 후 루프 외부 도달: {last_exception if last_exception else '알 수 없는 오류'}"
)
if last_exception:
raise last_exception
raise Exception("Unknown error after retries")

def refresh_token(self) -> None:
Expand Down
10 changes: 6 additions & 4 deletions kis_agent/core/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,9 @@ def __init__(
# 1초 sliding window 내 N개 제한을 확실히 지키려면 슬롯 간격이 1/RPS보다
# 조금 커야 함. 그렇지 않으면 윈도우 경계와 슬롯이 우연히 정확히 겹치며
# N+1개가 1초 안에 들어올 수 있다 (예: 5 RPS에 200ms 간격이면 윈도우
# [t-ε, t+1-ε)에 6개). 1ms safety로 이 corner case를 방지.
rps_floor = (1.0 / requests_per_second) + 0.001
# [t-ε, t+1-ε)에 6개). 실제 sleep의 undershoot와 스레드 재개 편차도
# 고려해 20ms safety를 둔다.
rps_floor = (1.0 / requests_per_second) + 0.020
self.min_interval = max(min_interval_ms / 1000.0, rps_floor)
self.burst_size = burst_size
self.enable_adaptive = enable_adaptive
Expand Down Expand Up @@ -215,8 +216,9 @@ def acquire(self, priority: int = 0) -> float:
# 만큼 추가 대기. monotonic 시계와 time.sleep()의 oversleep/undersleep
# 누적으로 윈도우 경계 직전에 요청이 몰리는 것을 방지.
# macOS/Linux의 time.sleep은 최대 ~10ms undersleep 발생할 수 있어
# 보수적으로 20ms padding (1초 한도의 2% 비용으로 한도 위반 확실히 차단).
_SAFETY_PADDING_S = 0.020
# CI/macOS의 스레드 스케줄링 편차까지 고려해 50ms를 둔다. 경계에서
# 실제 완료 시각이 앞당겨져 sliding-window 한도를 넘는 것을 막는다.
_SAFETY_PADDING_S = 0.050

# 2) 초당 한도: 1초 이내 요청이 RPS 이상이면 가장 오래된 요청
# + 1초 + padding이 다음 가용 시점. burst는 priority>=1에서만 허용하되,
Expand Down
2 changes: 1 addition & 1 deletion kis_agent/core/response_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ResponseProcessor(ABC):
@abstractmethod
def process(self, response: Dict, field_type: Optional[str] = None) -> Any:
"""응답 처리 추상 메서드"""
pass
raise NotImplementedError


class DictResponseProcessor(ResponseProcessor):
Expand Down
35 changes: 15 additions & 20 deletions kis_agent/futures/historical.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,26 +332,21 @@ def _fetch_page(
}
bars.append(bar)

# 다음 페이지 정보 (가장 오래된 데이터 기준)
if bars:
oldest = bars[-1]
next_date = oldest.get("date")
next_time = oldest.get("time")
# 1분 이전으로 설정
if next_time:
try:
t = datetime.strptime(f"{next_date}{next_time}", "%Y%m%d%H%M%S")
t -= timedelta(minutes=1)
next_date = t.strftime("%Y%m%d")
next_time = t.strftime("%H%M%S")
except ValueError:
# 날짜 파싱 실패 시 기존 값 유지하고 로깅
logger.warning(
f"날짜 파싱 실패: {next_date}{next_time}, 기존 값 유지"
)
return bars, next_date, next_time

return bars, None, None
# output2가 비어 있는 경우는 위에서 반환했으므로 bars에는 항상 항목이 있다.
oldest = bars[-1]
next_date = oldest.get("date")
next_time = oldest.get("time")
# 1분 이전으로 설정
if next_time:
try:
t = datetime.strptime(f"{next_date}{next_time}", "%Y%m%d%H%M%S")
t -= timedelta(minutes=1)
next_date = t.strftime("%Y%m%d")
next_time = t.strftime("%H%M%S")
except ValueError:
# 날짜 파싱 실패 시 기존 값 유지하고 로깅
logger.warning(f"날짜 파싱 실패: {next_date}{next_time}, 기존 값 유지")
return bars, next_date, next_time

def get_contract_history(
self,
Expand Down
2 changes: 0 additions & 2 deletions kis_agent/futures/vkospi.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,6 @@ def _calc_vega_weighted_iv(rows: list[dict]) -> float | None:
return None

total_vega = sum(v for _, v in valid)
if total_vega == 0:
return None
return sum(iv * v for iv, v in valid) / total_vega


Expand Down
32 changes: 8 additions & 24 deletions kis_agent/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,25 +54,13 @@ def get_second_thursday(year: int, month: int) -> datetime:
if current_month in expiry_months:
expiry_date = get_second_thursday(current_year, current_month)
if today.date() > expiry_date.date():
found = False
for month in expiry_months:
if month > current_month:
expiry_month = month
found = True
break
if not found:
expiry_month = 3
expiry_month = next(
(month for month in expiry_months if month > current_month), 3
)
else:
expiry_month = current_month
else:
found = False
for month in expiry_months:
if month > current_month:
expiry_month = month
found = True
break
if not found:
expiry_month = 3
expiry_month = next(month for month in expiry_months if month > current_month)

return f"A016{expiry_month:02d}"

Expand Down Expand Up @@ -114,8 +102,7 @@ def __init__(
)

logger.warning(
"DEPRECATION: pykis.stock.api.StockAPI는 레거시입니다. "
"from kis_agent.stock import StockAPI를 사용하세요."
"DEPRECATION: pykis.stock.api.StockAPI는 레거시입니다. from kis_agent.stock import StockAPI를 사용하세요."
)

def __getattr__(self, name: str) -> Any:
Expand All @@ -137,8 +124,7 @@ def __getattr__(self, name: str) -> Any:
return getattr(api, name)

raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'. "
f"This is a legacy class. Use 'from kis_agent.stock import StockAPI' instead."
f"'{type(self).__name__}' object has no attribute '{name}'. This is a legacy class. Use 'from kis_agent.stock import StockAPI' instead."
)

# ===== 주문 관련 메서드 - AccountAPI로 이동됨 =====
Expand All @@ -147,15 +133,13 @@ def __getattr__(self, name: str) -> Any:
def order_cash(self, *args, **kwargs):
"""DEPRECATED: AccountAPI.order_cash를 사용하세요"""
raise DeprecationWarning(
"order_cash는 StockAPI에서 제거되었습니다. "
"AccountAPI.order_cash() 또는 agent.order_stock_cash()를 사용하세요."
"order_cash는 StockAPI에서 제거되었습니다. AccountAPI.order_cash() 또는 agent.order_stock_cash()를 사용하세요."
)

def order_credit(self, *args, **kwargs):
"""DEPRECATED: AccountAPI.order_credit를 사용하세요"""
raise DeprecationWarning(
"order_credit는 StockAPI에서 제거되었습니다. "
"AccountAPI.order_credit() 또는 agent.order_stock_credit()를 사용하세요."
"order_credit는 StockAPI에서 제거되었습니다. AccountAPI.order_credit() 또는 agent.order_stock_credit()를 사용하세요."
)


Expand Down
4 changes: 2 additions & 2 deletions kis_agent/stock/api_improved.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ def get_foreign_net_buy(self, code: str, date: str = None) -> tuple:

# API 호출
response = self._make_request_dict(
endpoint=API_ENDPOINTS["INQUIRE_DAILY_TRADE"],
endpoint=API_ENDPOINTS["INQUIRE_INVESTOR"],
tr_id="FHKST01010900",
params={
"fid_cond_mrkt_div_code": "J",
Expand Down Expand Up @@ -332,7 +332,7 @@ def get_holidays(self, year: str = None) -> pd.DataFrame:

# DataFrame으로 반환
return self._make_request_dataframe(
endpoint=API_ENDPOINTS["INQUIRE_HOLIDAY"], tr_id="CTCA0903R", params=params
endpoint=API_ENDPOINTS["CHK_HOLIDAY"], tr_id="CTCA0903R", params=params
)


Expand Down
7 changes: 2 additions & 5 deletions kis_agent/stock/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,10 @@ def get_condition_stocks_dict(agent) -> Dict[str, List[Dict]]:
logging.warning("조건검색식 종목 조회 실패")
return {}

# condition API에서 직접 리스트를 반환하므로 바로 사용
# condition API에서 직접 리스트를 반환하므로 바로 사용한다.
# 빈 결과는 위의 `if not stocks`에서 이미 처리됐다.
stock_list = stocks

if not stock_list:
logging.warning("조건검색식 종목이 없습니다.")
return {}

# StockMonitor.py에서 기대하는 형태로 변환
# {조건검색식명: [종목정보리스트]} 형태
condition_stocks = {"기본조건검색식": []} # 기본 조건검색식으로 설정
Expand Down
Loading
Loading