커서가 망쳐놓은 듯

This commit is contained in:
2026-03-17 12:33:30 +09:00
parent 6fc179f598
commit c2b2b711e0
91 changed files with 45391 additions and 2244 deletions

View File

@@ -23,4 +23,141 @@
- 1. 손절(Stop-loss) 및 예외 처리 로직이 포함되었는가?
- 2. API 호출 제한(429 Error) 및 슬리피지 고려가 되었는가?
- 3. 사용자가 요청한 기존 로직과 100% 동일한 기능을 수행하는가?
- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요.
- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요.
# [CRITICAL SYSTEM DIRECTIVES: 절대 엄수 사항 - 위반 시 작동 중지]
## 1. 🚨 하드코딩 절대 금지 (NO HARDCODING)
- 어떠한 경우에도 코드 내부에 임계값, 비율, 점수, 시간 등의 수치를 직접 하드코딩하지 마라.
- 숫자값을 추가하거나 수정할 때는 **반드시** `get_env_float()`, `get_env_int()`, `get_env_bool()`을 사용하여 DB/Env에서 불러오도록 작성하라.
- 예시: `if rsi > 78:` (X, 절대 금지) / `rsi_limit = get_env_float("RSI_LIMIT", 78.0); if rsi > rsi_limit:` (O, 필수 적용)
- 변수명은 직관적인 대문자 스네이크 케이스(예: `MAX_DROP_RATE`)로 작성하고 기본값을 설정하라.
## 2. 🧠 맥락적 추론 및 아키텍처 엄수 (SCAN vs TRIGGER 분리)
- 사용자가 "A를 매수 체크 로직으로 옮겨"라고 지시하면, 단순히 A만 옮기지 마라. 사용자의 의도는 **"스캔(Scan) 단계에서는 조건 필터링을 최소화하여 후보를 DB에 최대한 많이 올리고, 실제 매수 직전(Trigger)에 모든 엄격한 필터(보조지표, 호가, 수급 등)를 한 번에 검사하라"**는 뜻이다.
- 무거운 연산(API 추가 호출, 분봉 분석 등)은 절대 5분 주기 스캔 함수에 넣지 말고, 매수 타점 체크 함수에 넣어라.
## 한툭투자증권 api 사용 규칙
API Reference
한국투자증권 오픈API는 REST 방식과 Websocket 방식으로 구성됩니다. 각 방식별 호출 도메인은 아래와 같습니다.
실전투자
REST: https://openapi.koreainvestment.com:9443
Websocket: ws://ops.koreainvestment.com:21000
모의투자
REST: https://openapivts.koreainvestment.com:29443
Websocket: ws://ops.koreainvestment.com:31000
REST API 호출 시 지원 가능 프로토콜 : TLS 1.2, TLS 1.3
※ TLS 1.0과 TLS 1.1 프로토콜은 보안 문제로 2025.12.12(금) 이후 지원하지 않습니다. 해당 프로토콜로 호출 시, 서비스 이용이 불가하오니 반드시 변경 부탁드립니다.
OAuth 인증
한국투자 오픈API는 보안코드(appkey, appsecret)를 사용하여 인증합니다.
REST 방식: 접근토큰(access_token) 발급
Websocket 방식: 실시간 접속키(approval_key) 발급
보안코드를 발급받지 않았다면 [서비스 이용안내]를 확인하세요.
종목정보 파일
주문 및 시세 조회가 가능한 종목정보 마스터파일을 제공합니다.
해당 파일은 당사에서 공통 관리하며 매일 업데이트됩니다.
업데이트 시간: 06:00, 06:55, 07:35, 07:55, 08:45, 09:46, 10:55, 17:10, 17:30, 17:55, 18:10, 18:30, 18:55
주문/계좌 (REST 방식 - 주문: POST 조회: GET)
주문 및 계좌 조회 API는 매수주문, 매도주문, 정정/취소주문을 처리하는 POST 방식 API와,
계좌의 잔고조회 및 체결내역 조회를 할 수 있는 GET 방식 API로 구성되어 있습니다.
주문 접수 시 장시간 확인하시기 바랍니다. 거래 가능 시간은 한국투자증권 홈페이지에서 확인하실 수 있습니다.
거래 가능 시간:
(국내주식/선물옵션) https://securities.koreainvestment.com/main/customer/guide/_static/TF04ad010000.jsp
(해외주식) https://securities.koreainvestment.com/main/bond/research/_static/TF03ca050001.jsp
(해외선물옵션) https://securities.koreainvestment.com/main/bond/foreign/_static/TF03df010300.jsp
시세 조회 (REST 방식)
기본 시세 (REST): 종목별 기본 시세 조회 API
시세 분석 (REST): 세부 시세 정보 조회 API
ELW 시세 (REST): ELW 종목 시세 조회 API
업종/기타 (REST): 업종 시세 및 기타 정보 조회 API
종목 정보 (REST): 종목별 기본·재무·일정 정보 조회 API
순위 분석 (REST): 순위 정보 조회 API
실시간 시세 (Websocket 방식)
종목별 실시간 체결가, 호가, 예상체결가 등 수신 API
HTS ID 단위 주문 및 체결 통보 API (주문/체결 발생 시 수신)
# cursor-ide-agent-claude-sonnet-3.7_20250309
source: <https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools/blob/main/cursor%20agent.txt>
## Prompt
You are a powerful agentic AI coding assistant, powered by Claude 3.7 Sonnet. You operate exclusively in Cursor, the world's best IDE.
You are pair programming with a USER to solve their coding task.
The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.
Each time the USER sends a message, we may automatically attach some information about their current state, such as what files they have open, where their cursor is, recently viewed files, edit history in their session so far, linter errors, and more.
This information may or may not be relevant to the coding task, it is up for you to decide.
Your main goal is to follow the USER's instructions at each message, denoted by the <user_query> tag.
\<tool_calling>
You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. The conversation may reference tools that are no longer available. NEVER call tools that are not explicitly provided.
3. **NEVER refer to tool names when speaking to the USER.** For example, instead of saying 'I need to use the edit_file tool to edit your file', just say 'I will edit your file'.
4. Only calls tools when they are necessary. If the USER's task is general or you already know the answer, just respond without calling tools.
5. Before calling each tool, first explain to the USER why you are calling it.
</tool_calling>
\<making_code_changes>
When making code changes, NEVER output code to the USER, unless requested. Instead use one of the code edit tools to implement the change.
Use the code edit tools at most once per turn.
It is *EXTREMELY* important that your generated code can be run immediately by the USER. To ensure this, follow these instructions carefully:
1. Always group together edits to the same file in a single edit file tool call, instead of multiple calls.
2. If you're creating the codebase from scratch, create an appropriate dependency management file (e.g. requirements.txt) with package versions and a helpful README.
3. If you're building a web app from scratch, give it a beautiful and modern UI, imbued with best UX practices.
4. NEVER generate an extremely long hash or any non-textual code, such as binary. These are not helpful to the USER and are very expensive.
5. Unless you are appending some small easy to apply edit to a file, or creating a new file, you MUST read the the contents or section of what you're editing before editing it.
6. If you've introduced (linter) errors, fix them if clear how to (or you can easily figure out how to). Do not make uneducated guesses. And DO NOT loop more than 3 times on fixing linter errors on the same file. On the third time, you should stop and ask the user what to do next.
7. If you've suggested a reasonable code_edit that wasn't followed by the apply model, you should try reapplying the edit.
</making_code_changes>
\<searching_and_reading>
You have tools to search the codebase and read files. Follow these rules regarding tool calls:
1. If available, heavily prefer the semantic search tool to grep search, file search, and list dir tools.
2. If you need to read a file, prefer to read larger sections of the file at once over multiple smaller calls.
3. If you have found a reasonable place to edit or answer, do not continue calling tools. Edit or answer from the information you have found.
</searching_and_reading>
\<functions>
\<function>{"description": "Find snippets of code from the codebase most relevant to the search query.\nThis is a semantic search tool, so the query should ask for something semantically matching what is needed.\nIf it makes sense to only search in particular directories, please specify them in the target_directories field.\nUnless there is a clear reason to use your own search query, please just reuse the user's exact query with their wording.\nTheir exact wording/phrasing can often be helpful for the semantic search query. Keeping the same exact question format can also be helpful.", "name": "codebase_search", "parameters": {"properties": {"explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "query": {"description": "The search query to find relevant code. You should reuse the user's exact query/most recent message with their wording unless there is a clear reason not to.", "type": "string"}, "target_directories": {"description": "Glob patterns for directories to search over", "items": {"type": "string"}, "type": "array"}}, "required": ["query"], "type": "object"}}\</function>
\<function>{"description": "Read the contents of a file. the output of this tool call will be the 1-indexed file contents from start_line_one_indexed to end_line_one_indexed_inclusive, together with a summary of the lines outside start_line_one_indexed and end_line_one_indexed_inclusive.\nNote that this call can view at most 250 lines at a time.\n\nWhen using this tool to gather information, it's your responsibility to ensure you have the COMPLETE context. Specifically, each time you call this command you should:\n1) Assess if the contents you viewed are sufficient to proceed with your task.\n2) Take note of where there are lines not shown.\n3) If the file contents you have viewed are insufficient, and you suspect they may be in lines not shown, proactively call the tool again to view those lines.\n4) When in doubt, call this tool again to gather more information. Remember that partial file views may miss critical dependencies, imports, or functionality.\n\nIn some cases, if reading a range of lines is not enough, you may choose to read the entire file.\nReading entire files is often wasteful and slow, especially for large files (i.e. more than a few hundred lines). So you should use this option sparingly.\nReading the entire file is not allowed in most cases. You are only allowed to read the entire file if it has been edited or manually attached to the conversation by the user.", "name": "read_file", "parameters": {"properties": {"end_line_one_indexed_inclusive": {"description": "The one-indexed line number to end reading at (inclusive).", "type": "integer"}, "explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "should_read_entire_file": {"description": "Whether to read the entire file. Defaults to false.", "type": "boolean"}, "start_line_one_indexed": {"description": "The one-indexed line number to start reading from (inclusive).", "type": "integer"}, "target_file": {"description": "The path of the file to read. You can use either a relative path in the workspace or an absolute path. If an absolute path is provided, it will be preserved as is.", "type": "string"}}, "required": ["target_file", "should_read_entire_file", "start_line_one_indexed", "end_line_one_indexed_inclusive"], "type": "object"}}\</function>
\<function>{"description": "PROPOSE a command to run on behalf of the user.\nIf you have this tool, note that you DO have the ability to run commands directly on the USER's system.\nNote that the user will have to approve the command before it is executed.\nThe user may reject it if it is not to their liking, or may modify the command before approving it. If they do change it, take those changes into account.\nThe actual command will NOT execute until the user approves it. The user may not approve it immediately. Do NOT assume the command has started running.\nIf the step is WAITING for user approval, it has NOT started running.\nIn using these tools, adhere to the following guidelines:\n1. Based on the contents of the conversation, you will be told if you are in the same shell as a previous step or a different shell.\n2. If in a new shell, you should `cd` to the appropriate directory and do necessary setup in addition to running the command.\n3. If in the same shell, the state will persist (eg. if you cd in one step, that cwd is persisted next time you invoke this tool).\n4. For ANY commands that would use a pager or require user interaction, you should append ` | cat` to the command (or whatever is appropriate). Otherwise, the command will break. You MUST do this for: git, less, head, tail, more, etc.\n5. For commands that are long running/expected to run indefinitely until interruption, please run them in the background. To run jobs in the background, set `is_background` to true rather than changing the details of the command.\n6. Dont include any newlines in the command.", "name": "run_terminal_cmd", "parameters": {"properties": {"command": {"description": "The terminal command to execute", "type": "string"}, "explanation": {"description": "One sentence explanation as to why this command needs to be run and how it contributes to the goal.", "type": "string"}, "is_background": {"description": "Whether the command should be run in the background", "type": "boolean"}, "require_user_approval": {"description": "Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.", "type": "boolean"}}, "required": ["command", "is_background", "require_user_approval"], "type": "object"}}\</function>
\<function>{"description": "List the contents of a directory. The quick tool to use for discovery, before using more targeted tools like semantic search or file reading. Useful to try to understand the file structure before diving deeper into specific files. Can be used to explore the codebase.", "name": "list_dir", "parameters": {"properties": {"explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "relative_workspace_path": {"description": "Path to list contents of, relative to the workspace root.", "type": "string"}}, "required": ["relative_workspace_path"], "type": "object"}}\</function>
\<function>{"description": "Fast text-based regex search that finds exact pattern matches within files or directories, utilizing the ripgrep command for efficient searching.\nResults will be formatted in the style of ripgrep and can be configured to include line numbers and content.\nTo avoid overwhelming output, the results are capped at 50 matches.\nUse the include or exclude patterns to filter the search scope by file type or specific paths.\n\nThis is best for finding exact text matches or regex patterns.\nMore precise than semantic search for finding specific strings or patterns.\nThis is preferred over semantic search when we know the exact symbol/function name/etc. to search in some set of directories/file types.", "name": "grep_search", "parameters": {"properties": {"case_sensitive": {"description": "Whether the search should be case sensitive", "type": "boolean"}, "exclude_pattern": {"description": "Glob pattern for files to exclude", "type": "string"}, "explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "include_pattern": {"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", "type": "string"}, "query": {"description": "The regex pattern to search for", "type": "string"}}, "required": ["query"], "type": "object"}}\</function>
\<function>{"description": "Use this tool to propose an edit to an existing file.\n\nThis will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\nWhen writing the edit, you should specify each edit in sequence, with the special comment `// ... existing code ...` to represent unchanged code in between edited lines.\n\nFor example:\n\n```\n// ... existing code ...\nFIRST_EDIT\n// ... existing code ...\nSECOND_EDIT\n// ... existing code ...\nTHIRD_EDIT\n// ... existing code ...\n```\n\nYou should still bias towards repeating as few lines of the original file as possible to convey the change.\nBut, each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity.\nDO NOT omit spans of pre-existing code (or comments) without using the `// ... existing code ...` comment to indicate its absence. If you omit the existing code comment, the model may inadvertently delete these lines.\nMake sure it is clear what the edit should be, and where it should be applied.\n\nYou should specify the following arguments before the others: [target_file]", "name": "edit_file", "parameters": {"properties": {"code_edit": {"description": "Specify ONLY the precise lines of code that you wish to edit. **NEVER specify or write out unchanged code**. Instead, represent all unchanged code using the comment of the language you're editing in - example: `// ... existing code ...`", "type": "string"}, "instructions": {"description": "A single sentence instruction describing what you are going to do for the sketched edit. This is used to assist the less intelligent model in applying the edit. Please use the first person to describe what you are going to do. Dont repeat what you have said previously in normal messages. And use it to disambiguate uncertainty in the edit.", "type": "string"}, "target_file": {"description": "The target file to modify. Always specify the target file as the first argument. You can use either a relative path in the workspace or an absolute path. If an absolute path is provided, it will be preserved as is.", "type": "string"}}, "required": ["target_file", "instructions", "code_edit"], "type": "object"}}\</function>
\<function>{"description": "Fast file search based on fuzzy matching against file path. Use if you know part of the file path but don't know where it's located exactly. Response will be capped to 10 results. Make your query more specific if need to filter results further.", "name": "file_search", "parameters": {"properties": {"explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "query": {"description": "Fuzzy filename to search for", "type": "string"}}, "required": ["query", "explanation"], "type": "object"}}\</function>
\<function>{"description": "Deletes a file at the specified path. The operation will fail gracefully if:\n - The file doesn't exist\n - The operation is rejected for security reasons\n - The file cannot be deleted", "name": "delete_file", "parameters": {"properties": {"explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "target_file": {"description": "The path of the file to delete, relative to the workspace root.", "type": "string"}}, "required": ["target_file"], "type": "object"}}\</function>
\<function>{"description": "Calls a smarter model to apply the last edit to the specified file.\nUse this tool immediately after the result of an edit_file tool call ONLY IF the diff is not what you expected, indicating the model applying the changes was not smart enough to follow your instructions.", "name": "reapply", "parameters": {"properties": {"target_file": {"description": "The relative path to the file to reapply the last edit to. You can use either a relative path in the workspace or an absolute path. If an absolute path is provided, it will be preserved as is.", "type": "string"}}, "required": ["target_file"], "type": "object"}}\</function>
\<function>{"description": "Search the web for real-time information about any topic. Use this tool when you need up-to-date information that might not be available in your training data, or when you need to verify current facts. The search results will include relevant snippets and URLs from web pages. This is particularly useful for questions about current events, technology updates, or any topic that requires recent information.", "name": "web_search", "parameters": {"properties": {"explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}, "search_term": {"description": "The search term to look up on the web. Be specific and include relevant keywords for better results. For technical queries, include version numbers or dates if relevant.", "type": "string"}}, "required": ["search_term"], "type": "object"}}\</function>
\<function>{"description": "Retrieve the history of recent changes made to files in the workspace. This tool helps understand what modifications were made recently, providing information about which files were changed, when they were changed, and how many lines were added or removed. Use this tool when you need context about recent modifications to the codebase.", "name": "diff_history", "parameters": {"properties": {"explanation": {"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", "type": "string"}}, "required": [], "type": "object"}}\</function>
</functions>
You MUST use the following format when citing code regions or blocks:
```startLine:endLine:filepath
// ... existing code ...
```
This is the ONLY acceptable format for code citations. The format is ```startLine:endLine:filepath where startLine and endLine are line numbers.
<user_info>
The user's OS version is win32 10.0.26100. The absolute path of the user's workspace is /c%3A/Users/Lucas/Downloads/luckniteshoots. The user's shell is C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe.
</user_info>
Answer the user's request using the relevant tool(s), if they are available. Check that all the required parameters for each tool call are provided or can reasonably be inferred from context. IF there are no relevant tools or there are missing values for required parameters, ask the user to supply these values; otherwise proceed with the tool calls. If the user provides a specific value for a parameter (for example provided in quotes), make sure to use that value EXACTLY. DO NOT make up values for or ask about optional parameters. Carefully analyze descriptive terms in the request as they may indicate required parameter values that should be included even if not explicitly quoted.
기존 로직 절대 존중: 현재 잘 작동하는 코드 구조(함수형, 절차적 등)를 최대한 유지한다.
오버엔지니어링 금지: '더 나은 구조'를 명목으로 코드를 임의로 클래스화하거나 불필요하게 복잡하게 꼬지 않는다.
선 보고, 후 수정: 핵심 매매 로직이나 구조를 변경해야만 하는 치명적인 이유가 있다면, 코드를 바로 수정하지 말고 반드시 먼저 이유를 설명하고 승인을 대기한다.
현상 유지: 기존에 작성된 주석(Comments)과 로거(Logger) 등은 절대 지우지 않고 그대로 유지한다.

33
.gitignore vendored
View File

@@ -1,16 +1,17 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.env
# Environment variables
.env
*.log
*.json
*.db
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.env
# Environment variables
.env
*.log
*.json
*.db
.aider*

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

14
.idea/kis_bot.iml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="Flask">
<option name="enabled" value="true" />
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kis_bot.iml" filepath="$PROJECT_DIR$/.idea/kis_bot.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

9
CONVENTIONS.md Normal file
View File

@@ -0,0 +1,9 @@
전체 소스 유지: 모든 코드는 //...생략 없이 **전체 소스(Full Source)**로 제공하며, 기존의 함수형/절차적 구조와 주석, 로거를 절대 수정하거나 삭제하지 않는다.
하드코딩 절대 금지: 모든 수치(임계값, 비율 등)는 하드코딩하지 말고 반드시 get_env_float/int/bool()을 사용하여 환경변수나 DB에서 불러오도록 작성한다.
안정성 우선: 모든 매매 로직에는 필수적으로 **손절(Stop-loss)**과 예외 처리를 포함하며, 데이터(JSON)는 이벤트 발생 즉시 저장(Atomic Save)한다.
효율적 아키텍처: 모든 API 요청은 SafeRequest를 상속받아 429 에러 재시도 로직을 포함하며, 무거운 연산은 스캔(Scan) 단계가 아닌 매수 타점(Trigger) 단계에서 실행한다.
검증 후 출력: 코드 출력 전 1)손절 로직 유무 2)API 호출 제한 준수 3)기존 로직과의 기능 동일성을 스스로 검토한 후 최종 결과를 한국어로 출력한다.

60
KIS_TOKEN_SMS.md Normal file
View File

@@ -0,0 +1,60 @@
# KIS 오픈API 접근 토큰 발급 SMS가 뒤죽박죽인 이유
## 현상
- 한투에서 "접근 토큰이 발급되었습니다" SMS가 **날마다 시각이 다름** (예: 04:37, 19:09, 11:50, 03:57).
- 가끔 **24시간이 지난 뒤**에 발급되기도 함 (예: 주말 다음 월요일 새벽).
## 원인 요약
### 1. 발급 시점 = "만료를 처음 감지한 프로세스가 API를 호출한 시각"
- 한투 측: 토큰은 **발급 시점부터 24시간** 유효. SMS는 **실제로 발급이 일어난 순간**에만 옴.
- 우리 쪽: **어떤 프로세스가** "만료됐다"고 **언제** 판단하느냐에 따라, 그때 `/oauth2/tokenP`를 호출하고 → 그 시각에 발급 → SMS.
### 2. 여러 봇을 동시에 돌리면 시각이 제각각이 됨
- `kis_short_ver3`, `kis_scalping_ver2`, `holding_bot`, `backtest_web`(KIS 호출 시) 등이 **각자** 토큰을 사용.
- 봇 A는 새벽 4시에 시작 → 그때 캐시가 이미 만료(또는 1시간 전 이내) → **4시대에** 발급 요청 → SMS 04:37.
- 봇 B는 저녁 7시에만 API 호출 → 그날은 7시에 만료 감지 → **7시대에** 발급 → SMS 19:09.
- 따라서 **동시에 돌리는 것**이 직접 원인이라기보다, **“만료를 처음 감지하는 시점”이 프로세스/실행 시각에 따라 달라져서** 발급 시각이 들쭉날쭉해지는 것.
### 3. 갱신 기준이 스크립트마다 다름 (불일치)
| 위치 | 만료로 보는 기준 | 비고 |
|------|------------------|------|
| `kis_token_manager.ensure_token()` | 만료 **1시간 전**까지 유효하면 재사용 | 파일 잠금 사용, 중복 발급 방지 |
| `KisTokenManager.get_token()` | 만료 **10분 전**부터 갱신 시도 | ensure_token() 호출 |
| `kis_short_ver3``KISClient._auth()` | 만료 **1분 전**까지 캐시 사용, 그 외 **직접 tokenP 호출** | **잠금 없음** → 동시 발급 가능 |
**같은 토큰인데** “만료”로 보는 시각이 1시간 전 / 10분 전 / 1분 전으로 나뉘고,
**`_auth()`는 잠금 없이 직접 발급**하므로, “누가 먼저 API를 쓰느냐”에 따라 발급 시각이 매일 달라짐.
### 4. 24시간이 지나서 발급되는 것처럼 보이는 경우
- 토큰이 **금요일 저녁**에 만료되면, 주말에 장이 없어 봇이 API를 안 쓸 수 있음.
- **월요일 장 시작 전후**에 첫 API 호출에서 만료 감지 → 그때 새 토큰 발급 → SMS는 **월요일 새벽/아침**에 옴.
- 즉 “24시간이 지나서”가 아니라, **“다음에 API를 호출한 시각”**에 발급되는 것.
## 한투 정책 (유의사항 문구)
- "접근 토큰은 **1일 1회 발급 원칙**이며, 유효기간 내 **잦은 토큰 발급** 발생 시 이용이 제한될 수 있습니다."
- 따라서 **갱신은 만료 임박 시 한 번만** 하도록 하고, **여러 프로세스가 동시에 발급하지 않도록** 하는 것이 좋음.
## 권장 대응 (코드 반영 완료)
1. **갱신 경로 통일**
모든 새 토큰 발급을 `kis_token_manager.ensure_token()` **한 경로**로만 하도록 수정함.
- `kis_short_ver3`, `kis_short_ver2`: `KISClient._auth()`에서 캐시 없/만료 시 **직접 tokenP 호출 제거**`ensure_token(mock)` 후 캐시 재로드.
- `kis_scalping_ver2`, `kis_scalping_ver1`: `_init_token()`에서 캐시 없을 때 `ensure_token()` 사용.
- `kis_long_ver1`: 폴백 발급을 `ensure_token()`으로 대체.
→ 발급 시 **파일 잠금** 사용·중복 발급 방지, SMS 시각이 상대적으로 안정됨.
2. **발급 시각을 고정하고 싶다면**
- 매일 **정해진 시간**(예: 오전 6시)에 **한 번만**
`python3 kis_token_manager.py --refresh`
를 크론으로 실행해 두면, 그 시각에만 발급 요청이 나가서 SMS 시각이 고정됨.
- 단, 그날 그 시간 이전에 토큰이 만료되면 그 전에 다른 봇이 먼저 호출해 발급할 수는 있음.
3. **캐시 파일**
- 실전: `.kis_token_cache_real.json`
- 모의: `.kis_token_cache_mock.json`
- 두 파일의 `access_token_token_expired`**만료 시각** (한투 서버 기준 24시간 후).
- SMS 시각은 “그 토큰이 **발급된** 시각”이므로, 보통 만료 시각 - 24시간 정도로 생각하면 됨.
## 참고: tail_engine / scalping_engine
- `tail_engine.py`, `scalping_engine.py`는 **백테스트 전용**이라 KIS API/토큰을 사용하지 않음.
- SMS 발급과 무관.

245
README_ETF.md Normal file
View File

@@ -0,0 +1,245 @@
# ETF 액티브 매매 봇 (RSI 기반 분할 매수 & 슈팅 익절)
## 📊 전략 개요
테마성 ETF (원자력, 전력망, 이차전지 등) 의 **눌림목 분할 매수**와 **슈팅 익절** 전략
### 매매 로직
| 구분 | 조건 | 실행 |
|------|------|------|
| **1 차 매수** | RSI < 35 | 자본금의 30% 매수 |
| **2 차 매수** | RSI < 30 | 자본금의 30% 추가 매수 (물타기) |
| **3 차 매수** | RSI < 25 | 자본금의 40% 풀매수 |
| **익절** | 수익률 ≥ +4% 또는 RSI ≥ 70 | 전량 매도 |
| **손절** | 수익률 ≤ -10% | 전량 매도 (리스크 관리) |
### 왜 이 전략이 효과적인가?
- **눌림목 매수**: 테마 ETF 가 며칠간 -2~-3% 눌릴 때 RSI 가 하락 → 3 분할로 평단가 낮춤
- **슈팅 익절**: 호재로 +3~5% 슈팅 시 RSI 과열 (70 이상) → 전량 익절
- **리스크 관리**: -10% 손절로 테마 소멸 시 즉시 손절
---
## 🚀 빠른 시작
### 1. 환경 변수 설정
```bash
# DB 에 ETF 유니버스 등록
python update_env_etf.py
```
**설정 예시:**
- `069500`: KODEX 200
- `114800`: KODEX 은행
- `280670`: KODEX 원자력
- `395030`: KODEX 이차전지산업
- `405840`: KODEX AI 반도체
### 2. 백테스트 (선택)
```bash
# 과거 데이터로 전략 검증
python etf_backtest.py
```
**결과 확인:**
- `URA_etf_trade_history.json`: 매매 내역
- 최종 수익률, 매매 횟수 등 통계
### 3. 실전 매매
```bash
# 한투 API 연동 실전 매매
python etf_ver1.py
```
---
## 📁 파일 구성
| 파일 | 역할 |
|------|------|
| `etf_backtest.py` | 백테스트 프로그램 (야후 파이낸스 데이터) |
| `etf_ver1.py` | 실전 매매 봇 (한투 API 연동) |
| `update_env_etf.py` | ETF 유니버스 설정 스크립트 |
| `kis_ws.py` | WebSocket 실시간 가격 수신 (기존) |
| `database.py` | DB 관리 (기존) |
---
## 🔧 설정 (DB/env)
### ETF 유니버스 변경
`update_env_etf.py` 파일의 `ETF_UNIVERSE` 값 수정:
```python
"ETF_UNIVERSE": "069500,114800,280670,395030,405840",
```
### 매매 비율 조정
`etf_ver1.py` 내부 상수 수정:
```python
# 3 분할 비율 (현재: 30% / 30% / 40%)
target_amount = cash * 0.3 # 1 차
# 익절/손절 기준
if profit_rate >= 0.04: # +4% 익절
if profit_rate <= -0.10: # -10% 손절
```
---
## 📊 백테스트 결과 분석
### 결과 파일
- `{TICKER}_etf_trade_history.json`: 전체 매매 내역
- 예: `URA_etf_trade_history.json`
### 주요 지표
```json
{
"ticker": "URA",
"date": "2024-03-15",
"type": "SELL (슈팅 익절)",
"price": 25.43,
"qty": 1000,
"profit_loss": 125000,
"rsi": 72.5
}
```
---
## 🏦 한투 API 설정
### 모의투자 vs 실전투자
`database.py``env_config` 테이블에서 설정:
| 키 | 모의투자 | 실전투자 |
|----|---------|---------|
| `KIS_APP_KEY` | `KIS_APP_KEY_MOCK` | `KIS_APP_KEY_REAL` |
| `KIS_SECRET` | `KIS_APP_SECRET_MOCK` | `KIS_APP_SECRET_REAL` |
| `KIS_ACCOUNT` | `KIS_ACCOUNT_NO_MOCK` | `KIS_ACCOUNT_NO_REAL` |
| `KIS_MOCK` | `true` | `false` |
### WebSocket 설정
- `KIS_WS_URL_REAL`: `ws://ops.koreainvestment.com:21000`
- `KIS_WS_URL_MOCK`: `ws://ops.koreainvestment.com:31000`
- `KIS_WS_MOCK_ENABLED`: `true` (모의투자 WebSocket 활성화)
---
## 📱 메신저 알림
### 텔레그램
```python
# env 설정
MM_BOT_TOKEN_ = "YOUR_BOT_TOKEN"
KIS_SHORT_MM_CHANNEL = "YOUR_CHAT_ID"
```
### 매터모스트
```python
# env 설정
MM_SERVER_URL = "https://mattermost.hoonfam.org"
MATTERMOST_CHANNEL = "stock"
```
---
## ⚠️ 주의사항
### 1. API 호출 제한
- 한투 API 는 초당 호출 제한이 있습니다
- `etf_ver1.py``random.sleep(60~180)`으로 부하 방지
- 백테스트도 `random.sleep(0.5~1.5)` 적용
### 2. 슬리피지 & 수수료
- 수수료: 0.015% (ETF 우대)
- 슬리피지: 0.05% (호가 차이)
- 백테스트와 실전의 차이는 슬리피지에서 발생
### 3. RSI 계산
- 일봉 14 일 기준
- 장중 RSI 가 아닌 **전일 종가 기준 RSI**
- 실시간 RSI 는 WebSocket 체결가로 계산 가능 (확장 필요)
---
## 💡 전략 개선 아이디어
### 1. 분봉 RSI 활용
```python
# 5 분봉 RSI 추가
prices_5m = get_5min_prices(code, days=5)
rsi_5m = calculate_rsi(prices_5m, period=14)
```
### 2. 거래량 필터
```python
# 평균 거래량 대비 200% 이상 시 매수
if volume > volume_ma20 * 2.0:
# 수급 유입으로 판단
```
### 3. 테마 분석
```python
# 원자력 관련 뉴스 발생 시
if "원자력" in news and "SMR" in news:
# 해당 ETF 우선 매수
```
---
## 📚 참고 문서
- [한투 API 문서](./한투 GIT)
- [한투 WebSocket 문서](./한투 API)
- [기존 단타 봇](./kis_short_ver2.py)
- [WebSocket 실시간 가격](./kis_ws.py)
---
## ❓ FAQ
### Q1. ETF 가 아닌 종목에도 사용 가능한가요?
A: 네, RSI 전략은 개별 종목에도 적용 가능합니다. 다만 레버리지/인버스 ETF 는 변동성이 너무 커서 손절 라인을 넓혀야 합니다.
### Q2. 3 분할이 아니라 5 분할로 하고 싶어요.
A: `etf_ver1.py``buy_step` 변수를 0~4 로 확장하고 비율을 20% 씩으로 조정하세요.
### Q3. 백테스트 결과가 실전과 달라요.
A: 슬리피지와 호가 공백을 고려하지 않았을 수 있습니다. `slippage_rate` 를 0.1% 로 높여서 테스트해보세요.
---
## 🎯 다음 단계
1. **백테스트 돌리기**: `python etf_backtest.py`
2. **모의투자 테스트**: `KIS_MOCK=true` 로 설정 후 `python etf_ver1.py`
3. **실전 투자**: `KIS_MOCK=false` 로 설정 후 소액으로 시작
---
**📈 행복한 ETF 투자 되세요!**

136
TAIL_BACKTEST_VS_LIVE.md Normal file
View File

@@ -0,0 +1,136 @@
# 꼬리잡기 백테스트 vs 실매매 마이너스 원인
## 요약
- **백테스트**: 플러스 (예: tail2.log 기준 순손익 +19,223,972원, 승률 55.2%)
- **실매매**: 마이너스 (누적손익 -29,571,921원 등)
- **원인**: 계산식·진입/청산 규칙이 백테스트와 실매매에서 **다르게** 적용되거나, **진입가·타이밍·추가 매도 규칙** 차이로 인한 것.
---
## 1. 진입가(Entry Price) 차이 ⭐ 가장 유력
| 구분 | 백테스트 | 실매매 |
|------|----------|--------|
| 진입가 | **신호 봉 다음 봉 시가** (next bar open) | **신호
감지 시점 현재가** (시장가 매수) |
- 백테스트: "이 봉에서 조건 충족" → **다음 봉 시가**에 매수로 가정 (look-ahead 없음).
- 실매매: 조건 충족 시 **그 순간 시장가**로 매수 → 실제 체결가는 다음 봉 시가보다 **불리할 가능성**이 큼 (이미 올라온 뒤 매수, 슬리피지).
- **영향**: 실매매에서 같은 신호라도 **진입가가 더 높아져** 익절 도달이 어렵고, 손절에 더 자주 걸릴 수 있음.
---
## 2. 봉 데이터·타이밍 차이
- **백테스트**: `ws_candles` DB의 **확정된 3분봉**만 사용 (`is_confirmed=1`). 봉이 닫힌 뒤에만 신호 판단.
- **실매매**: WS 메모리 또는 REST 3분봉 사용. **봉이 아직 확정 전**일 때 현재가로 조건을 체크할 수 있음.
- 봉 미확정 시점의 "현재가"는 3분봉 종가와 다를 수 있어, **같은 조건이라도 백테와 다른 시점에 신호가 나오거나** 회복률/낙폭 계산이 달라질 수 있음.
### 매수 허용 시간대 (TIME_START / TIME_END)
- **영향**: 봉 시간이 `time_start_hm`(예: 930) ~ `time_end_hm`(예: 1500) 밖이면 **진입 자체를 하지 않음** → 결과에 영향 있음.
- **백테스트**: `tail_engine.run_tail_backtest`에서 `time_start_hm`, `time_end_hm` 사용 (파라미터로 적용됨).
- **실매매**: `tail_engine.check_buy_signal_live`에서도 동일하게 사용. ver3는 `get_tail_defaults_from_db()`로 params를 채우므로, DB에 `TIME_START`/`TIME_END` 컬럼과 값이 있으면 **실매에도 동일 시간대가 적용**됨. (컬럼이 없으면 기본 930/1500.)
---
## 3. 매도(청산) 규칙 복잡도 차이 ⭐
| 구분 | 백테스트 (tail_engine) | 실매매 (kis_short_ver3) |
|------|------------------------|--------------------------|
| 청산 종류 | 4가지만: **손절 / 익절 / 어깨컷 / 장마감** | 그 위에 **금액손실컷(MAX_LOSS_PER_TRADE_KRW), MIN_HOLD_AFTER_BUY_SEC**, 매수 시 **ATR 기반 손절가/목표가** 등 추가 |
- 백테스트는 **% 손절(sl_pct), % 익절(tp_pct), 어깨컷(shoulder_min_high + shoulder_cut_pct), 장마감** 만 사용.
- 실매매는 **어깨컷 + 금액손실컷(MAX_LOSS_PER_TRADE_KRW) + MIN_HOLD_AFTER_BUY_SEC** 적용. 매수 시 **STOP_ATR_MULTIPLIER_TAIL / TARGET_ATR_MULTIPLIER_TAIL** 로 ATR 기반 손절/목표가를 쓸 수 있어, 백테와 청산 시점이 달라질 수 있음.
---
## 3-1. 실매 env vs 백테스트·웹·파라서치 대응표
**요약**: 웹 백테·파라서치에는 **금액손실컷(MAX_LOSS_PER_TRADE_KRW), MIN_HOLD_AFTER_BUY_SEC, ATR 기반 손절/목표가****없습니다**. 실매에만 있고, 백테 엔진(tail_engine)에는 구현되어 있지 않음. 그 외 꼬리잡기 진입/청산용 env는 아래처럼 백테·웹·파라서치에 반영됨.
| 실매(ver3) env | tail_engine 백테 | 백테스트웹 입력란 | tail_param_search 그리드 |
|----------------|------------------|-------------------|---------------------------|
| **매수** | | | |
| MIN_DROP_RATE, MIN_RECOVERY_RATIO_SHORT | ✅ | ✅ tl_drop, tl_rec | ✅ min_drop_rate, min_recovery_ratio |
| TAIL_RATIO_MIN, TAIL_PCT_MIN | ✅ | ✅ tl_tail, tl_tail_pct | ✅ tail_ratio_min, tail_pct_min (fine/full) |
| RSI_PERIOD, RSI_OVERHEAT_THRESHOLD | ✅ | ✅ tl_rsi_period, tl_rsi | ✅ rsi_threshold (full), rsi_period (full) |
| MAX_RECOVERY_RATIO_3M, HIGH_PRICE_CHASE_THRESHOLD | ✅ | ✅ tl_max_rec_3m, tl_high_chase | ✅ max_rec_3m, high_chase_thr (fine/full) |
| SHOULDER_MIN_HIGH_PCT, SHOULDER_CUT_PCT | ✅ | ✅ tl_smin, tl_scut | ✅ shoulder_cut_pct, shoulder_min_high (fine/full) |
| REENTRY_COOLDOWN_SEC, TIME_START, TIME_END, MAX_STOCKS | ✅ | ✅ tl_cool, tl_ts, tl_te, tl_maxd | DB 고정 (주석 해제 시 그리드 추가 가능) |
| **매도** | | | |
| STOP_LOSS_PCT, TAKE_PROFIT_PCT | ✅ | ✅ tl_sl, tl_tp | ✅ sl_pct, tp_pct |
| MAX_LOSS_PER_TRADE_KRW (금액손실컷) | ❌ | ❌ | ❌ (실매 전용) |
| MIN_HOLD_AFTER_BUY_SEC | ❌ | ❌ | ❌ (실매 전용) |
| STOP_ATR_MULTIPLIER_TAIL, TARGET_ATR_MULTIPLIER_TAIL | ❌ | ❌ | ❌ (실매 전용, ATR 기반 손절/목표가) |
- **백테스트 엔진**에는 위 표에서 ✅ 인 항목만 반영됨. **금액손실컷·MIN_HOLD·ATR 기반은 실매에만 있고**, 웹 백테·파라서치에는 없어서, 백테 결과와 실매 결과 차이의 원인이 될 수 있음.
- **파라미터 서치**는 fine/full 모드에서 **어깨발동(shoulder_min_high), 꼬리최소(tail_pct_min), 3분최대회복(max_rec_3m), 고점추격방지(high_chase_thr)** 를 그리드에 넣어 경우의 수에 포함함.
- **다른 env**: 꼬리잡기(tail) 전략 기준으로 실매에서 쓰는 env 중 위 표에 없는 것은 없음. (리스크/슬롯/키움 등 공통 env는 별도.)
---
## 4. 백테스트웹 vs 파라미터서치(tail_param_search) 결과가 다를 때
**같은** 낙폭/회복/손절/익절/어깨 수치를 넣어도 **거래 건수·손익이 다르게** 나올 수 있음.
| 원인 | 백테스트웹 | 파라미터서치 |
|------|------------|--------------|
| **매수시간대** | UI 매수시작/매수종료 (예: 8301530) | **DB 고정값** (`TIME_START`/`TIME_END` 또는 기본 9301500) — 시간대가 다르면 거래 수·손익이 달라짐 |
| **일일최대매수** | UI에서 직접 입력 (예: 3) | **DB 고정값** (`get_tail_defaults_from_db()` → MAX_STOCKS) — 예: 7 |
| **RSI 기간** | 요청에 없으면 DB 기본 또는 14 | **DB 고정값** (RSI_PERIOD) — 예: 5 |
| **기간** | UI 시작/종료일 | `--start` / `--end` (예: 다른 구간이면 당연히 결과 다름) |
- 파라미터서치는 **그리드에 없는 값**은 전부 **DB(env_config)에서 한 번만 읽어** 모든 조합에 동일 적용함.
→ 당시 DB에 `MAX_STOCKS=7`, `RSI_PERIOD=5` 등이면 **39건, +167,142원** 같은 결과가 나옴.
- 백테스트웹에서 **일일최대매수 3**, RSI 기간은 요청에 없어 14로 두면 **32건, -992원**처럼 달라짐.
**동일 비교하려면**:
- 백테스트웹에서 **시작/종료일**을 파라서치와 똑같이 두고, **매수시작/매수종료**도 맞출 것 (파라서치는 DB 기준 09301500 사용. 웹에서 08301530 등 다르게 두었으면 비교 시 0930·1500으로 맞추면 됨).
- **일일최대매수·RSI 기간**도 파라서치 1위와 맞춤 (예: 7, 5).
- 또는 파라서치 실행 전에 DB를 원하는 값(일일최대 3, RSI_PERIOD 14)으로 맞춘 뒤 돌리면, 백테스트웹과 비슷한 조건으로 비교 가능.
**거래 수 차이(예: 파라서치 39건 vs 웹 19건)**:
- 같은 기간·같은 로직으로 재현하면 **18~19건**이 나오는 경우가 있음. 파라서치 결과 JSON의 39건은 **당시 DB에 더 많은 거래일 데이터가 있었거나**, **매수시간대(9301500 vs 8301530)** 등 설정 차이로 인한 것일 수 있음. 비교 시 **시작/종료일 + 매수시작/매수종료**를 동일하게 두면 차이가 줄어듦.
**실매매는 지금 코드상**:
- **kis_short_ver3**는 `get_tail_defaults_from_db()`로 params를 채워 `tail_engine.check_buy_signal_live` / `check_sell_signal_live`에 넘김.
- 즉 **실매 = 현재 DB(env_config)에 저장된 값**으로 동작.
- 파라미터서치에서 `--apply` 하면 1위 조합이 DB에 들어가므로, 그 다음부터 **실매는 파라서치 1위와 같은 파라미터**(일일최대 7, RSI 5, 손절 1% 익절 6% 등)로 돌아감.
- 백테스트웹에서만 “일일최대 3”으로 두고 실매는 그대로 두면, **웹 백테(32건, -992)와 실매(DB 기준 7/5 등)는 서로 다른 설정**이 됨.
---
## 5. 파라미터 불일치 가능성
- 백테스트 UI/API에서 넣는 값(낙폭 3%, 회복률 30%, 꼬리/몸통 2, 손절 5%, 익절 3% 등)과 **봇 DB(env_config)** 값이 다를 수 있음.
- 봇은 `MIN_RECOVERY_RATIO_SHORT`, `MIN_DROP_RATE`, `TAIL_RATIO_MIN`, `STOP_LOSS_PCT`, `TAKE_PROFIT_PCT` 등을 DB에서 읽음.
- **권장**: 백테스트에서 사용한 파라미터를 그대로 `api/backtest/tail/save_config`로 DB에 저장한 뒤 실매매를 돌려, **동일 수치**로 맞출 것.
---
## 6. 쿨다운(재진입 제한)
- 백테스트: **쿨다운 15분** 등으로 동일 종목 재진입 제한.
- 실매매: `REENTRY_COOLDOWN_SEC` 등이 DB에 있으면 적용되지만, **없거나 다르면** 백테보다 더 자주/덜 진입할 수 있음.
---
## 7. 대응 방향
1. **진입가 통일 (가능한 범위에서)**
- 실매매에서 "다음 봉 시가"에 가깝게 하려면, **3분봉 확정 시점(봉 마감 후)**에만 매수 체크를 하거나, 매수 호가를 다음 봉 시가에 가깝게 넣는 방식 검토 (구현 복잡도는 있음).
2. **매도 규칙을 백테와 동일하게**
- **tail_engine**의 청산 규칙(손절/익절/어깨컷/장마감만)을 실매매에서도 쓰고, **kis_short_ver3**에서는 금액손실컷·ATR·퀵프로핏 등을 빼거나, 백테스트에 없는 규칙은 선택 옵션으로 두어 비교 테스트.
3. **공통 엔진 사용**
- `tail_engine.check_buy_signal_live` / `check_sell_signal_live`를 실매매에서 사용하고, 백테스트는 `tail_engine.run_tail_backtest` 또는 동일 식을 쓰면 **계산식 자체**는 일치.
4. **파라미터 동기화**
- 백테스트 결과 좋은 파라미터를 `save_config`로 DB에 저장 후, 실매매는 **그 DB 값만** 쓰도록 해서 불일치 제거.
---
## 8. tail_engine / kis_short_ver3
- **tail_engine.py**: 꼬리잡기 **진입·청산 공통 계산식** (백테스트·실매매 동일).
- **backtest_web** 꼬리잡기 API: `use_engine=1`(기본)이면 `tail_engine.run_tail_backtest` 사용.
- **kis_short_ver3**: tail_engine을 사용하는 실매매 봇. ver2와 동일 구조이되, **매수/매도 판단만 엔진 호출**로 통일하면 백테와 결과를 맞추기 쉬움.

Binary file not shown.

337
ai_recommend_notice.py Normal file
View File

@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""
[1회성] 13시 발송용 AI 추천 수치 스크립트
- DB env_config 최신 수치 + trade_history 최근 10건 + journalctl 최근 200줄을 참고해
Gemini로 문제점·수치 추천 분석 후 Mattermost 발송 및 last_ai_recommendations 저장.
- 실행: python3 ai_recommend_notice.py (cron 0 13 * * * 등으로 13:00 실행 가능)
"""
import os
import re
import sys
import json
import subprocess
import warnings
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
# DB/설정은 프로젝트 DB 사용
from database import TradeDB, ENV_CONFIG_KEYS
warnings.filterwarnings("ignore", message=".*google.*")
# Gemini
GEMINI_MODEL_ID = "gemini-2.5-flash"
gemini_client = None
try:
import google.generativeai as genai
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
latest = db.get_latest_env()
snap = (latest or {}).get("snapshot") or {}
GEMINI_API_KEY = (snap.get("GEMINI_API_KEY") or "").strip()
if GEMINI_API_KEY:
genai.configure(api_key=GEMINI_API_KEY)
gemini_client = genai.GenerativeModel(GEMINI_MODEL_ID)
else:
db.close()
print("❌ GEMINI_API_KEY 없음 (DB env_config)")
sys.exit(1)
except Exception as e:
print(f"❌ Gemini/DB 초기화 실패: {e}")
sys.exit(1)
# 단타 봇(kis_short_ver1.py) 코드 기준: env 키가 쓰이는 위치 (AI 추천 시 반드시 참고)
KIS_SHORT_CODE_REFERENCE = """
**[단타 봇 kis_short_ver1.py env 수치 사용 위치 (추천 시 이 봇에만 쓰이는 키만 제안할 것)]**
■ reload_config() DB에서 읽어 봇에 반영
- STOP_LOSS_PCT: 퍼센트 손절 (profit_pct <= 이 값이면 칼손절, 기본 -0.04)
- TAKE_PROFIT_PCT: 퍼센트 익절 (profit_pct >= 이 값이면 익절(퍼센트), 기본 0.05)
- MAX_STOCKS: 최대 보유 종목 수
- MIN_DROP_RATE: 매수 조건 당일 낙폭 하한 (시가 대비 저가 하락률, drop_rate < 이 값이면 탈락-낙폭, 기본 0.03)
- MIN_RECOVERY_RATIO_SHORT: 매수 조건 일봉 회복률 하한 (저가~현재가/당일범위, recovery_pos_day < 이 값이면 탈락-회복률, 기본 0.5). 동일 값이 3분봉 회복률 구간 하한으로도 사용됨(min_recovery_ratio ~ MAX_RECOVERY_RATIO_3M).
- STOP_ATR_MULTIPLIER_TAIL, TARGET_ATR_MULTIPLIER_TAIL: ATR 손절가/목표가 배율 (stop_price = 매수가 - ATR*배율, target = 매수가 + ATR*배율)
- MIN_HOLD_HOURS: 24시간 보유 전략의 기준 시간(시간). 이 시간 미만이면 “24시간내” 규칙, 이상이면 “24시간 경과” 규칙 적용.
- RISK_PCT_PER_TRADE, KELLY_MULTIPLIER, MAX_POSITION_PCT, MIN_POSITION_AMOUNT, USE_KELLY_FORMULA: RiskManager(매수 금액·종목당 상한)
- SLOT_BASE_AMOUNT_CAP: RiskManager 종목당 원화 상한(0이면 미적용). 매수 금액이 이 값을 넘으면 이 값으로 잘림.
- ML: USE_ML_SIGNAL, ML_MIN_PROBABILITY
- TOTAL_DEPOSIT: 리포트용
■ 매수 필터 (check_buy_signal_tail_catch) 순서대로 적용, 하나라도 실패하면 탈락
- 낙폭: drop_rate = (시가-저가)/시가. drop_rate < MIN_DROP_RATE → 탈락-낙폭
- 회복률(일봉): recovery_pos_day = (현재가-저가)/(고가-저가). < MIN_RECOVERY_RATIO_SHORT → 탈락-회복률
- 꼬리: tail_ratio = 꼬리길이/몸통길이, tail_pct = 꼬리길이/저가. TAIL_RATIO_MIN(기본 1.5) 또는 TAIL_PCT_MIN(기본 0.003) 미만 → 탈락-꼬리
- 3분봉 회복: recovery_pos = (현재가-저가)/(고가-저가) (해당 3분봉 내). MIN_RECOVERY_RATIO_SHORT ~ MAX_RECOVERY_RATIO_3M(기본 0.8) 구간이 아니면 → 탈락-회복3분
- 피뢰침: HIGH_PRICE_CHASE_THRESHOLD(0.96) 현재가 >= 당일고가*이 값 → 탈락-피뢰침 고점추격. MAX_DAILY_CHANGE_PCT(20) (고가-저가)/저가*100 > 이 값 → 탈락-피뢰침 급등주
- RSI_OVERHEAT_THRESHOLD(78): RSI >= 이 값 → 탈락-RSI
- MA20: 현재가 < MA20 → 탈락-MA20. 현재가 > MA20*(1+MA20_MAX_ABOVE_PCT/100) → 탈락-MA20초과
■ 매도 (check_sell_signals) 아래 순서대로 적용, 먼저 걸린 사유로 매도
- [1] SHOULDER_CUT_PCT(0.03): 고점 대비 하락률 >= 이 값 → 어깨매도 (최우선)
- [2] MAX_LOSS_PER_TRADE_KRW(200000): 원화 손실(profit_val) <= -이 값 → 금액손실컷. 0이면 이 조건 비활성화.
- [3] SCALP_ATR_UP_MULT, SCALP_ATR_DOWN_MULT, SCALP_ATR_DROP_MULT: ATR 스캘핑 → 스캘핑_본절사수 / 스캘핑_익절보존
- [4] USE_QUICK_PROFIT_PROTECTION(True): **매수 후 0.5시간(QUICK_PROFIT_PROTECT_HOURS) 이내에만** 적용. 이 시간이 지나면 이 조건은 더 이상 보지 않음. 조건: 고점이 매수가*QUICK_PROFIT_MAX_RATIO(1.005) 이상 올랐는데, 현재가가 매수가*QUICK_PROFIT_CURRENT_MIN(1.0015) 이하로 다시 내려오면 → "💨 작은수익보호" 매도. (24시간 제한과 별개: 0.5시간만 적용, 그 이후는 [5] 24시간 보유 전략이 적용됨)
- [5] 24시간 보유 전략 (MIN_HOLD_HOURS 기준): **보유 시간이 MIN_HOLD_HOURS(24) 미만**이면: 수익률 > MIN_HOLD_EARLY_TAKE_PCT(0.05) → 익절, 또는 고점이 매수가*MIN_HOLD_HIGH_PCT(1.07) 이상 올랐다가 현재가 <= 고점*MIN_HOLD_DROP_FROM_HIGH(0.97) → 고점→하락 매도. **MIN_HOLD_HOURS 이상**이면: 수익률 > POST_HOLD_TAKE_PCT(0.02) → 익절, 또는 수익 중인데 현재가 < 고점*POST_HOLD_DROP_FROM_HIGH(0.97) → 익절보호.
- [6] (위에서 모두 걸리지 않았을 때만) 목표가/손절: 현재가 >= target_price → 목표달성. 현재가 <= stop_price(ATR 손절가) → 전략손절. profit_pct <= stop_loss_pct → 칼손절. profit_pct >= take_profit_pct → 익절(퍼센트).
※ CONSECUTIVE_LOSS_LIMIT, MAX_PEG, MIN_RECOVERY_RATIO(단타는 MIN_RECOVERY_RATIO_SHORT만 사용)는 이 단타 봇에서 사용하지 않음. MAX_PEG는 kis_long_ver1.py(늘림목 봇) 전용. 추천 시 위에 나온 키만 사용할 것.
"""
# DB 최신 env에서 수치만 (계좌/키/토큰 제외)
EXCLUDE = {
"MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY",
"KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK",
"KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK",
"KIS_SHORT_MM_CHANNEL", "KIS_LONG_MM_CHANNEL",
}
def get_env_numeric_snapshot(db_instance):
"""DB 최신 env에서 계좌/키 제외한 수치만 키=값 줄 단위로 반환."""
latest = db_instance.get_latest_env()
if not latest or not latest.get("snapshot"):
return ""
snap = latest["snapshot"]
lines = []
for k, v in sorted(snap.items()):
if k in EXCLUDE or v is None:
continue
v = (str(v) or "").strip()
if "#" in v:
v = v.split("#")[0].strip()
if not v:
continue
lines.append(f"{k}={v}")
return "\n".join(lines)
def get_recent_trades(db_instance, limit=10):
"""trade_history 최근 N건."""
out = []
try:
cursor = db_instance.conn.execute("""
SELECT code, name, buy_price, sell_price, qty, profit_rate,
realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes
FROM trade_history
ORDER BY id DESC
LIMIT ?
""", (limit,))
for row in cursor.fetchall():
out.append({
"code": row[0], "name": row[1], "buy_price": row[2], "sell_price": row[3],
"qty": row[4], "profit_rate": row[5], "realized_pnl": row[6], "strategy": row[7],
"sell_reason": row[8], "buy_date": row[9], "sell_date": row[10], "hold_minutes": row[11] or 0,
})
except Exception as e:
print(f"⚠️ 거래 내역 조회 실패: {e}")
return out
def get_journalctl_recent(lines=200, unit=None):
"""journalctl 최근 N줄. unit 있으면 -u unit 적용 (예: kis_short)."""
cmd = ["journalctl", "-n", str(lines), "-o", "short-iso"]
if unit:
cmd = ["journalctl", "-u", unit, "-n", str(lines), "-o", "short-iso"]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if r.returncode == 0 and r.stdout:
return r.stdout.strip()
except Exception as e:
print(f"⚠️ journalctl 조회 실패: {e}")
return ""
def save_ai_recommendations_from_text(db_instance, analysis_text):
"""AI 분석문에서 KEY=값 추천만 추출해 DB last_ai_recommendations에 저장."""
if not analysis_text or not db_instance:
return
valid_keys = set(ENV_CONFIG_KEYS)
result = []
for line in analysis_text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^([A-Z][A-Z0-9_]*)=(.+)$", line)
if m and m.group(1) in valid_keys:
result.append(f"{m.group(1)}={m.group(2).strip()}")
if result:
db_instance.set_last_ai_recommendations("\n".join(result))
def send_mm(message, db_instance):
"""Mattermost 발송 (DB env에서 URL/토큰/채널 조회, mm_config.json 채널 ID)."""
latest = db_instance.get_latest_env()
snap = (latest or {}).get("snapshot") or {}
url = (snap.get("MM_SERVER_URL") or "https://mattermost.hoonfam.org").strip().rstrip("/")
token = (snap.get("MM_BOT_TOKEN_") or "").strip()
channel_alias = (snap.get("KIS_SHORT_MM_CHANNEL") or snap.get("MATTERMOST_CHANNEL") or "stock").strip()
if not token:
print("❌ MM_BOT_TOKEN_ 없음")
return False
config_path = SCRIPT_DIR / "mm_config.json"
channels = {}
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
channels = json.load(f).get("channels", {})
except Exception:
pass
channel_id = channels.get(channel_alias)
if not channel_id:
print(f"❌ mm_config.json에 채널 alias '{channel_alias}' 없음")
return False
import requests
api_url = f"{url}/api/v4/posts"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
payload = {"channel_id": channel_id, "message": message}
try:
r = requests.post(api_url, headers=headers, json=payload, timeout=10)
r.raise_for_status()
return True
except Exception as e:
print(f"❌ MM 전송 에러: {e}")
return False
def main():
# DB 참고: 수치 스냅샷, 최근 거래, 후보 수
env_lines = get_env_numeric_snapshot(db)
recent_trades = get_recent_trades(db, 10)
db_candidates = db.get_target_candidates()
candidate_count = len(db_candidates)
# journalctl 최근 200줄 (단위는 환경변수 JOURNALCTL_UNIT 있으면 사용, 없으면 전체)
journal_unit = os.environ.get("JOURNALCTL_UNIT", "kis_short_ver1.service").strip() or None
journal_log = get_journalctl_recent(200, unit=journal_unit)
if not journal_log:
journal_log = "(journalctl 로그 없음)"
# 프롬프트: DB + 거래 + journal 로그 반영
if not recent_trades:
summary = f"- 유니버스 후보: {candidate_count}\n- 최근 거래: 없음"
trades_text = "없음"
prompt = f"""당신은 퀀트 트레이딩 전문가입니다.
**현재 상태**
{summary}
**현재 DB 설정 수치 (일부만, 계좌/키 제외)**
```
{env_lines}
```
**봇 최근 로그 (journalctl 최근 200줄) 탈락 사유·API 과부하·매수 시도 등 참고**
```
{journal_log[:15000]}
```
{KIS_SHORT_CODE_REFERENCE}
**당신의 임무**
1. 위 설정·후보 수·로그·코드 기준을 보고 문제점 분석 (필터 과도, API 제한, 매수 미발생 원인 등). 추천은 반드시 위 코드 기준에 나온 키만 제안할 것.
2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. DB에 그대로 복붙 적용 가능하게.
예: MIN_DROP_RATE=0.005
예: MIN_RECOVERY_RATIO_SHORT=0.25
예: TAIL_RATIO_MIN=0.5
3. 예상 효과 한두 줄.
**출력 형식 (반드시 준수)**
## 🔍 문제점
1. [구체적 문제 1]
2. [구체적 문제 2]
## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용 가능)
KEY=value
...
## 📈 예상 효과
- [효과]
"""
else:
total = len(recent_trades)
wins = sum(1 for t in recent_trades if (t.get("profit_rate") or 0) > 0)
losses = total - wins
win_rate = (wins / total * 100) if total > 0 else 0
avg_profit = sum(t.get("profit_rate") or 0 for t in recent_trades) / total
total_pnl = sum(t.get("realized_pnl") or 0 for t in recent_trades)
avg_hold = sum(t.get("hold_minutes") or 0 for t in recent_trades) / total
trades_text = ""
for i, t in enumerate(recent_trades, 1):
trades_text += f"""
[거래 {i}] {t['name']} ({t.get('strategy','')})
- 매수: {t.get('buy_price',0):,.0f}× {t.get('qty',0)}주 | 매도: {t.get('sell_price',0):,.0f}
- 손익: {t.get('profit_rate',0):+.2f}% ({t.get('realized_pnl',0):,.0f}원) | 보유: {t.get('hold_minutes',0)}
- 사유: {t.get('sell_reason','')}
"""
summary = f"- 유니버스 후보: {candidate_count}\n- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}{losses}패)\n- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:,.0f}원 | 평균 보유: {avg_hold:.0f}"
prompt = f"""당신은 퀀트 트레이딩 전문가입니다.
**현재 상태**
{summary}
**최근 거래 내역**
{trades_text}
**현재 DB 설정 수치 (계좌/키 제외)**
```
{env_lines}
```
**봇 최근 로그 (journalctl 최근 200줄) 탈락 사유·매수 실패·과부하 등 참고**
```
{journal_log[:15000]}
```
{KIS_SHORT_CODE_REFERENCE}
**당신의 임무**
1. 거래 내역·설정·로그·코드 기준을 보고 승률/매수 미발생 원인 등 구체적으로 3가지 진단. 추천은 반드시 위 코드 기준에 나온 키만 제안할 것.
2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. DB에 그대로 복붙 적용 가능하게.
3. 예상 효과 한두 줄.
**출력 형식 (반드시 준수)**
## 🔍 문제점 (승률/매수 원인)
1. [구체적 문제 1]
2. [구체적 문제 2]
3. [구체적 문제 3]
## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용)
KEY=value
...
## 📈 예상 효과
- [효과]
"""
# Gemini 호출
try:
response = gemini_client.generate_content(prompt)
analysis = (response.text if hasattr(response, "text") else
(response.candidates[0].content.parts[0].text if response.candidates else ""))
except Exception as e:
print(f"❌ Gemini 생성 실패: {e}")
db.close()
sys.exit(1)
save_ai_recommendations_from_text(db, analysis)
message = f"""🤖 **[13시 AI 추천 수치]** (DB + journalctl 로그 반영)
{summary}
{analysis}
---
💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다.
"""
if send_mm(message, db):
print("✅ AI 추천 안내 MM 발송 완료")
else:
print("⚠️ MM 발송 실패 (내용은 생성됨, last_ai_recommendations 저장됨)")
db.close()
if __name__ == "__main__":
main()

164
back_test.py Normal file
View File

@@ -0,0 +1,164 @@
"""
어깨매도(Shoulder Cut) 백테스트 및 원인 분석
[문제] 어깨매도가 마이너스 수익률로 체결되는 이유
============================================================
현재 로직 (kis_short_ver2.py):
- 어깨매도 = "보유 중 고점(max_price) 대비 3%(SHOULDER_CUT_PCT) 이상 하락 시"
수익/손실 불문하고 즉시 매도.
- max_price 초기값 = 매수가(buy_price). 루프마다 현재가로 갱신.
왜 마이너스로 나가는가?
1) 매수 후 한 번도 안 올랐을 때
→ max_price = buy_price 유지 → 고점 대비 3% 하락 = 매수가 대비 -3% 하락
→ 이미 손실 구간에서 어깨매도 조건만 만족해 매도됨.
2) 조금 올랐다가 3% 빠졌을 때
→ 예: 매수가 10,000원 → 10,100원 갱신(고점) → 9,800원으로 하락(고점 대비 ~3%)
→ 어깨매도 체결 시 매수가 대비 -2% (손실).
결론: "그 전에 팔아야 한다" = 수익이 났을 때 먼저 팔아야 하는데,
현재는 "고점 대비 N% 하락"만 보고 있어서, 고점이 매수가 근처면
손실로 어깨매도되는 것이 현재 설계 그대로 동작한 결과임.
개선 방향 (백테스트에서 검증 대상):
- (A) 어깨매도를 "수익일 때만" 적용: profit_pct > 0 일 때만 고점 대비 N% 하락 시 매도.
- (B) 고점이 매수가 + X% 이상일 때만 어깨매도 적용 (일단 수익 구간 진입한 경우만).
"""
import sqlite3
from pathlib import Path
from collections import defaultdict
SCRIPT_DIR = Path(__file__).resolve().parent
DB_PATH = SCRIPT_DIR / "quant_bot.db"
def get_conn():
return sqlite3.connect(DB_PATH)
def report_shoulder_sell_analysis():
"""DB trade_history에서 어깨매도 건만 추출해 통계 및 원인 보고."""
conn = get_conn()
conn.row_factory = sqlite3.Row
# 어깨매도만 필터
rows = conn.execute("""
SELECT code, name, buy_price, sell_price, qty, profit_rate, realized_pnl,
buy_date, sell_date, sell_reason
FROM trade_history
WHERE sell_reason = '어깨매도'
ORDER BY sell_date DESC
""").fetchall()
conn.close()
if not rows:
print("[어깨매도] trade_history에 '어깨매도' 건이 없습니다.")
return []
total = len(rows)
wins = [r for r in rows if r["profit_rate"] > 0]
losses = [r for r in rows if r["profit_rate"] <= 0]
win_count = len(wins)
loss_count = len(losses)
avg_profit = sum(r["profit_rate"] for r in rows) / total
avg_loss_when_loss = sum(r["profit_rate"] for r in losses) / loss_count if losses else 0
total_pnl = sum(r["realized_pnl"] for r in rows)
print("=" * 60)
print("📊 어깨매도(Shoulder Cut) 실거래 통계 (trade_history)")
print("=" * 60)
print(f" 총 건수: {total}")
print(f" 익절 건수: {win_count} ({100*win_count/total:.1f}%)")
print(f" 손실 건수: {loss_count} ({100*loss_count/total:.1f}%)")
print(f" 평균 수익률: {avg_profit:.2f}%")
if losses:
print(f" 손실 건 평균 수익률: {avg_loss_when_loss:.2f}%")
print(f" 실현 손익 합계: {total_pnl:+,.0f}")
print()
print("📌 왜 어깨매도가 마이너스로 나가는가?")
print(" - 조건: 보유 고점(max_price) 대비 3% 하락 시 무조건 매도.")
print(" - max_price 초기값 = 매수가 → 올라본 적 없으면 '매수가=고점'에서 3% 하락 = 이미 -3% 근처.")
print(" - 조금 올랐다가 3% 빠져도, 매수가 대비하면 손실일 수 있음.")
print(" → 개선: 수익일 때만 어깨매도 적용(profit_pct>0) 또는 고점이 매수가+X% 이상일 때만 적용.")
print()
return rows
def backtest_shoulder_rule_current(rows):
"""현재 규칙 그대로: 이미 DB에 기록된 결과 요약 (재현 확인)."""
if not rows:
return
print("=" * 60)
print("🔬 백테스트 1: 현재 규칙 (고점 대비 3% 하락 시 무조건 매도)")
print("=" * 60)
print(" → 실거래와 동일. 위 실거래 통계가 곧 현재 규칙 결과.")
print()
def backtest_shoulder_rule_profit_only(rows):
"""
개선안 A: 수익일 때만 어깨매도 적용.
(실제로는 매도 시점의 profit_rate만 있으므로, '그때 수익이었으면 어깨매도 적용했을 것'으로 가정.)
여기서는 "손실로 어깨매도된 건"을 제외하면 몇 건이었을지, 그때 손익이 얼마였을지 보여줌.
"""
if not rows:
return
# 손실로 어깨매도된 건 = 개선안 적용 시 어깨매도로 나가지 않았을 건
would_skip = [r for r in rows if r["profit_rate"] <= 0]
would_apply = [r for r in rows if r["profit_rate"] > 0]
print("=" * 60)
print("🔬 백테스트 2: 개선안 A — 수익일 때만 어깨매도 적용")
print("=" * 60)
print(f" 현재 규칙으로 손실로 어깨매도된 건: {len(would_skip)}")
print(f" → 개선안 적용 시 이 건들은 어깨매도 조건에서 제외됨 (다른 손절/익절로 나갔을 가능성).")
print(f" 수익으로 어깨매도된 건(유지): {len(would_apply)}")
if would_skip:
skip_pnl = sum(r["realized_pnl"] for r in would_skip)
print(f" 제외되는 건들의 실현손익 합계: {skip_pnl:+,.0f}원 (이 손실들은 다른 로직으로 나갈 때까지 보유됨)")
print()
def backtest_shoulder_rule_high_above_entry(rows, min_high_pct=0.01):
"""
개선안 B: 고점이 매수가 대비 min_high_pct 이상일 때만 어깨매도 적용.
DB에는 고점이 없으므로, "매도가가 매수가보다 높았던 건" = 수익 매도만 해당.
(실제 고점은 매도가보다 높았을 수 있음. 보수적으로 수익 건만 적용된 것처럼 집계.)
"""
if not rows:
return
# 수익 매도 = 매도가 > 매수가 → 고점은 매수가 이상이었을 것
above_entry = [r for r in rows if r["sell_price"] > r["buy_price"]]
below_entry = [r for r in rows if r["sell_price"] <= r["buy_price"]]
print("=" * 60)
print(f"🔬 백테스트 3: 개선안 B — 고점이 매수가+{min_high_pct*100:.0f}% 이상일 때만 어깨매도")
print("=" * 60)
print(" (DB에 고점 미저장으로, 매도가>매수가인 수익 건만 '고점이 매수가 위'로 간주)")
print(f" 적용될 건(수익 매도): {len(above_entry)}")
print(f" 제외될 건(손실 매도): {len(below_entry)}")
if below_entry:
pnl_excluded = sum(r["realized_pnl"] for r in below_entry)
print(f" 제외 건 실현손익 합계: {pnl_excluded:+,.0f}")
print()
def main():
print("\n")
rows = report_shoulder_sell_analysis()
backtest_shoulder_rule_current(rows)
backtest_shoulder_rule_profit_only(rows)
backtest_shoulder_rule_high_above_entry(rows, min_high_pct=0.01)
print("=" * 60)
print("✅ 백테스트 완료. 개선안 반영 시 kis_short_ver2.py 매도 로직에서")
print(" 어깨매도 조건에 profit_pct > 0 또는 max_price >= buy_price * (1 + X) 추가 검토.")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
holding_param_search.py — 홀딩 전략 파라미터 자동 탐색 (종목별 Grid Search)
=============================================================================
실행:
cd /home/hoon/kis_bot
python3 backtest_scalping/holding_param_search.py
옵션:
--code 종목코드 (미지정 시 관심종목 전체)
--start 시작일 (기본: 2년 전)
--end 종료일 (기본: 오늘)
--mode coarse(기본) / fine / full
--top 상위 N개 출력 (기본: 10)
--apply 1위 파라미터를 DB에 자동 적용
예시:
python3 backtest_scalping/holding_param_search.py --code 005930 --apply
python3 backtest_scalping/holding_param_search.py --mode fine --apply
"""
import sys, os, json, time, argparse
from datetime import datetime, timedelta
from itertools import product as iproduct
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from database import TradeDB
import holding_bot as hb
# ──────────────────────────────────────────────────────────────────────────────
# 그리드 정의
# ──────────────────────────────────────────────────────────────────────────────
GRIDS = {
# 빠른 1차 탐색 (~270 조합)
"coarse": {
"rsi_buy1": [40, 35, 30],
"rsi_buy2": [35, 30, 25],
"rsi_buy3": [30, 25, 20],
"take_profit_pct": [3.0, 4.0, 5.0, 6.0, 8.0],
"stop_loss_pct": [7.0, 10.0, 15.0],
},
# 세밀 2차 탐색 (~2,400 조합)
"fine": {
"rsi_buy1": [45, 40, 35, 30],
"rsi_buy2": [40, 35, 30, 25],
"rsi_buy3": [35, 30, 25, 20],
"take_profit_pct": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0],
"stop_loss_pct": [5.0, 7.0, 10.0, 15.0, 20.0],
"rsi_sell": [65, 70, 75],
},
# 전체 탐색 (~10,000+ 조합)
"full": {
"rsi_buy1": [50, 45, 40, 35, 30],
"rsi_buy2": [45, 40, 35, 30, 25],
"rsi_buy3": [40, 35, 30, 25, 20, 15],
"take_profit_pct": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0],
"stop_loss_pct": [5.0, 7.0, 10.0, 15.0, 20.0],
"rsi_sell": [60, 65, 70, 75, 80],
"rsi_period": [7, 14, 21],
},
}
FIXED_DEFAULTS = {
"rsi_sell": 70,
"rsi_period": 14,
"buy1_ratio": 30,
"buy2_ratio": 30,
"buy3_ratio": 40,
"slot_money": 3_000_000,
}
def run_search(code: str, name: str, candles, mode: str,
top_n: int, min_trades: int, apply_best: bool, db):
grid = GRIDS[mode]
keys = list(grid.keys())
combos = list(iproduct(*[grid[k] for k in keys]))
valid = [(zip(keys, v)) for v in combos]
results = []
base_cfg = dict(hb.DEFAULT_STOCK_CONFIG)
base_cfg.update(FIXED_DEFAULTS)
t0 = time.time()
total = len(combos)
print(f"\n[{code}] {name} | {mode.upper()} | 조합: {total:,}")
print("=" * 60)
for i, vals in enumerate(combos):
cfg = dict(base_cfg)
cfg.update(dict(zip(keys, vals)))
# rsi_buy 순서 보정
if cfg["rsi_buy1"] <= cfg["rsi_buy2"] or cfg["rsi_buy2"] <= cfg["rsi_buy3"]:
continue
res = hb.run_backtest(candles, cfg)
s = res.get("summary", {})
if s.get("total_trades", 0) < min_trades:
continue
results.append({
"params": {k: cfg[k] for k in keys},
"total_pnl": s["total_pnl"],
"win_rate": s["win_rate"],
"total_trades": s["total_trades"],
"pf": s["profit_factor"],
"avg_hold": s["avg_hold_days"],
"mdd": s["max_drawdown"],
})
elapsed = time.time() - t0
print(f"완료: {elapsed:.1f}초 | 유효: {len(results)}")
if not results:
print("⚠️ 유효 결과 없음. 기간을 늘리거나 min_trades를 낮추세요.")
return
results.sort(key=lambda x: x["total_pnl"], reverse=True)
col_w = max(len(k) for k in keys) + 2
hdr = " ".join(f"{k:>{col_w}}" for k in keys)
print(f"\n{'='*60}")
print(f" TOP {min(top_n, len(results))}")
print(f"{'='*60}")
print(f"{hdr} | {'손익':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유(일)':>8}")
print("-" * (len(hdr) + 50))
for r in results[:top_n]:
p = r["params"]
row = " ".join(f"{p[k]:>{col_w}.4g}" for k in keys)
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>7.1f}")
best = results[0]
bp = best["params"]
print(f"""
╔═══════════════════════════════════════════╗
║ 🏆 [{code}] {name[:10]} 최적 파라미터
╠═══════════════════════════════════════════╣""")
for k, v in bp.items():
print(f"{k:<22s} : {v!s:>8}")
print(f"""╠═══════════════════════════════════════════╣
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
║ 승률 : {best['win_rate']:>6.1f}% ║
║ 거래 : {best['total_trades']:>5} 건 ║
║ PF : {best['pf']:>5.2f}
║ 보유(일) : {best['avg_hold']:>5.1f} 일 ║
║ MDD : {best['mdd']:>12,.0f} 원 ║
╚═══════════════════════════════════════════╝""")
# 결과 저장
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"holding_{code}_{mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({"code": code, "name": name, "mode": mode,
"top": results[:top_n]}, f, ensure_ascii=False, indent=2)
print(f"\n💾 결과 저장: {out_path}")
if apply_best:
hb.set_stock_config(db, code, name, bp)
print(f"✅ DB 파라미터 적용 완료 ({code})")
def main():
today = datetime.now().strftime("%Y-%m-%d")
two_ago = (datetime.now() - timedelta(days=730)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="홀딩 전략 파라미터 Grid Search")
parser.add_argument("--code", default="", help="종목코드 (공백=관심종목 전체)")
parser.add_argument("--start", default=two_ago, help="시작일")
parser.add_argument("--end", default=today, help="종료일")
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"])
parser.add_argument("--top", default=10, type=int)
parser.add_argument("--min_trades", default=2, type=int)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
db = TradeDB()
hb.ensure_holding_tables(db)
items = hb.load_watchlist()
if args.code:
items = [i for i in items if i["code"] == args.code]
if not items:
print(f"종목을 찾을 수 없습니다: {args.code}")
db.close()
return
for item in items:
code = item["code"]
name = item["name"]
candles = hb.get_stored_candles(db, code, args.start, args.end)
if len(candles) < 20:
print(f"⚠️ [{code}] {name}: 봉 부족 ({len(candles)}개) → 스킵. 먼저 캔들을 수집하세요.")
print(f" 실행: python3 holding_bot.py fetch --start {args.start} --end {args.end}")
continue
run_search(code, name, candles, args.mode,
args.top, args.min_trades, args.apply, db)
db.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""
param_search.py — 스캘핑 백테스트 파라미터 자동 탐색 (Grid Search)
=====================================================================
실행:
cd /home/hoon/kis_bot
python3 backtest_scalping/param_search.py
옵션:
--start 시작일 (기본: 오늘-7일)
--end 종료일 (기본: 오늘)
--mode 탐색 모드: coarse(기본) / fine / full
--top 상위 N개 출력 (기본: 20)
--min_trades 최소 거래 건수 필터 (기본: 5)
--apply 결과 1위를 DB에 자동 적용 (기본: False)
예시:
python3 backtest_scalping/param_search.py --start 2026-03-01 --end 2026-03-06 --mode fine --apply
"""
"""
# 오늘 하루 기준, 빠른 탐색 (약 720 조합, 2분 소요)
python3 backtest_scalping/param_search.py --start 2026-03-06 --end 2026-03-06
# 지난 1주일, 세밀 탐색 (약 5,400 조합, 15분 소요)
python3 backtest_scalping/param_search.py --start 2026-03-01 --end 2026-03-06 --mode fine
# 탐색 후 1위 파라미터를 DB에 자동 적용
python3 backtest_scalping/param_search.py --start 2026-11-01 --end 2026-03-06 --mode fine --apply
# 전체 탐색 (51,840 조합, 2시간 이상, 주말에 돌려두기)
python3 backtest_scalping/param_search.py --start 2026-02-01 --end 2026-03-06 --mode full --apply
결과는 backtest_scalping/results/search_coarse_YYYYMMDD_HHMMSS.json에 자동 저장됩니다.
핵심 팁: 데이터가 많을수록 신뢰도가 높으니, ws_candles 데이터가 2주 이상 쌓인 후에 --mode fine으로 돌리는 게 좋습니다.
"""
import sys, os, json, time, argparse
from datetime import datetime, timedelta
from itertools import product
from typing import Optional
MIN_WIN_RATE_DEFAULT = 52.0
# kis_bot 루트를 경로에 추가
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제
from backtest_web import app
from database import TradeDB
import scalping_engine as se
# ──────────────────────────────────────────────────────────────────────────────
# 스캘핑 기본값 = 엔진에서 DB 로드 (백테스트 API와 동일 단일 소스, 실매매와 동기화)
# ──────────────────────────────────────────────────────────────────────────────
def _fixed_defaults():
"""엔진 get_scalping_defaults_from_db() 사용. API 쿼리용으로 percent 변환."""
_d = se.get_scalping_defaults_from_db()
return {
"rsi_period": _d["rsi_period"],
"slot_money": _d["slot_money"],
"vol_mult": _d["vol_mult"],
"trail_trigger": _d["trail_trigger"] * 100,
"trail_stop": _d["trail_stop"] * 100,
"cooldown_min": _d["cooldown_min"],
"time_start": _d["time_start"],
"time_end": _d["time_end"],
"max_daily": _d["max_daily"],
"fee_rate": _d["fee_rate"] * 100,
"sell_tax": _d["sell_tax"] * 100,
}
# ──────────────────────────────────────────────────────────────────────────────
# 파라미터 그리드 정의
# ──────────────────────────────────────────────────────────────────────────────
GRIDS = {
# 빠른 1차 탐색 (약 720 조합)
"coarse": {
"rsi_oversold": [10, 12, 15, 18, 20, 25],
"sl_pct": [0.8, 1.0, 1.5, 2.0],
"tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0],
"drop_rate": [0.5, 1.0, 1.5, 2.0, 2.5, 3.0],
# 아래는 고정 (coarse 모드)
},
# 세밀 2차 탐색 (약 5,400 조합, 시간 오래 걸림)
"fine": {
"rsi_oversold": [15, 16, 17, 18, 19, 20],
"sl_pct": [0.7, 0.8, 1.0, 1.2, 1.5],
"tp_pct": [2.0, 2.5, 3.0, 3.5, 4.0],
"drop_rate": [1.5, 2.0, 2.5, 3.0],
"trail_trigger": [0, 0.5, 0.8],
"trail_stop": [0.3, 0.5],
"cooldown_min": [5, 10, 15],
},
# 전체 탐색 (약 51,840 조합, 매우 오래 걸림)
"full": {
"rsi_oversold": [10, 12, 15, 18, 20, 25],
"sl_pct": [0.8, 1.0, 1.5, 2.0],
"tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0],
"drop_rate": [0.5, 1.0, 1.5, 2.0, 2.5, 3.0],
"trail_trigger": [0, 0.5, 0.8, 1.0],
"trail_stop": [0.3, 0.5, 0.8],
"cooldown_min": [5, 10, 20],
"max_daily": [2, 3],
},
}
def _get_scalp_field_map():
"""스캘핑 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용)."""
return {
"rsi_oversold": ("SCALP_RSI_OVERSOLD", lambda v: str(int(v))),
"sl_pct": ("SCALP_STOP_LOSS_PCT", lambda v: str(float(v) / 100)),
"tp_pct": ("SCALP_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)),
"drop_rate": ("SCALP_MIN_DROP_RATE", lambda v: str(float(v) / 100)),
}
def _params_to_db_snapshot(params: dict) -> dict:
"""그리드 params(표시 단위) → env_config 컬럼명:값 문자열 dict."""
field_map = _get_scalp_field_map()
return {
db_col: fmt(params[param_k])
for param_k, (db_col, fmt) in field_map.items()
if param_k in params
}
def _apply_from_latest_json(rank: int):
"""최근 search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용."""
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
if not os.path.isdir(out_dir):
print("⚠️ results 디렉터리가 없습니다.")
return
jsons = [f for f in os.listdir(out_dir) if f.startswith("search_") and f.endswith(".json")]
if not jsons:
print("⚠️ search_*.json 파일이 없습니다.")
return
jsons.sort(key=lambda f: os.path.getmtime(os.path.join(out_dir, f)), reverse=True)
latest_path = os.path.join(out_dir, jsons[0])
with open(latest_path, "r", encoding="utf-8") as f:
data = json.load(f)
top = data.get("top") or []
if rank < 1 or rank > len(top):
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
return
item = top[rank - 1]
merged = item.get("merged_params")
if not merged:
merged = item.get("params")
if not merged:
print("⚠️ 해당 항목에 merged_params/params가 없습니다.")
return
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
_apply_to_db(merged)
def run_search(start: str, end: str, mode: str, top_n: int,
min_trades: int, min_win_rate: float, apply_rank: Optional[int], from_file_only: bool):
if from_file_only and apply_rank is not None and apply_rank >= 1:
_apply_from_latest_json(apply_rank)
return
grid = GRIDS[mode]
keys = list(grid.keys())
combos = list(product(*[grid[k] for k in keys]))
total = len(combos)
print(f"\n[{mode.upper()} 모드] 탐색 조합: {total:,}개 | 기간: {start} ~ {end}")
print("=" * 70)
results = []
t0 = time.time()
FIXED_DEFAULTS = _fixed_defaults()
with app.test_client() as c:
for i, vals in enumerate(combos):
params = dict(FIXED_DEFAULTS) # 고정값 먼저 (DB 쿨다운/장시간 반영)
params.update(dict(zip(keys, vals))) # 그리드값으로 덮어쓰기
params["start"] = start
params["end"] = end
qs = "&".join(f"{k}={v}" for k, v in params.items())
r = c.get(f"/api/backtest/scalping?{qs}")
if r.status_code != 200:
continue
d = json.loads(r.data)
s = d.get("summary", {})
if s.get("total_trades", 0) < min_trades:
continue
results.append({
"params": {k: params[k] for k in keys}, # 탐색 파라미터만 저장
"total_pnl": s["total_pnl"],
"win_rate": s["win_rate"],
"total_trades": s["total_trades"],
"pf": s["profit_factor"],
"avg_hold": s["avg_hold_min"],
"mdd": s["max_drawdown"],
})
# 진행률 출력 (200건마다)
if (i + 1) % 200 == 0:
elapsed = time.time() - t0
eta = elapsed / (i + 1) * (total - i - 1)
print(f" 진행: {i+1:>5}/{total} 경과: {elapsed:>5.0f}s 남은: {eta:>5.0f}s", flush=True)
elapsed = time.time() - t0
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}")
if not results:
print("⚠️ 수익 조합을 찾지 못했습니다. 기간을 늘리거나 min_trades를 낮춰보세요.")
return
# 총 손익 기준 정렬 후, 승률 min_win_rate 이상만 우선; 없으면 차악(손익 1위) 유지
results.sort(key=lambda x: x["total_pnl"], reverse=True)
results_52 = [r for r in results if r["win_rate"] >= min_win_rate]
if results_52:
results = results_52
results.sort(key=lambda x: x["total_pnl"], reverse=True)
print(f"✅ 승률 {min_win_rate}% 이상 {len(results)}건 중 손익순으로 1위 선정")
else:
print(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(손익 1위) 적용")
# ── 결과 출력 ──
hdr_keys = [k for k in keys]
col_w = max(len(k) for k in hdr_keys) + 2
print(f"\n{'='*70}")
print(f" 🏆 수익 TOP {min(top_n, len(results))} (투자금 1,000,000원 기준)")
print(f"{'='*70}")
# 헤더
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
print("-" * (len(hdr) + 55))
for r in results[:top_n]:
p = r["params"]
row = " ".join(f"{p[k]:>{col_w}.4g}" for k in hdr_keys)
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}")
best = results[0]
bp = best["params"]
print(f"""
╔══════════════════════════════════════════╗
║ 🏆 1위 최적 파라미터 ║
╠══════════════════════════════════════════╣""")
# 결과 파라미터명 → DB 컬럼명 표시
_disp_map = {
"rsi_oversold": "SCALP_RSI_OVERSOLD",
"sl_pct": "SCALP_STOP_LOSS_PCT (÷100)",
"tp_pct": "SCALP_TAKE_PROFIT_PCT(÷100)",
"drop_rate": "SCALP_MIN_DROP_RATE (÷100)",
"trail_trigger": "(백테스트전용-미저장)",
"trail_stop": "(백테스트전용-미저장)",
"cooldown_min": "SCALP_COOLDOWN_SEC(÷60=분)",
}
for k, v in bp.items():
label = _disp_map.get(k, k)
print(f"{label:<30s} : {v!s:>6}")
print(f"""╠══════════════════════════════════════════╣
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
║ 승률 : {best['win_rate']:>6.1f}% ║
║ 총 거래 : {best['total_trades']:>5} 건 ║
║ Profit Factor : {best['pf']:>5.2f}
║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║
║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║
╚══════════════════════════════════════════╝""")
# ── 각 결과에 순번·merged_params·db_snapshot 부여 (JSON 및 apply N 지원) ──
top_list = []
for idx, r in enumerate(results[:top_n]):
p = r["params"]
top_list.append({
"rank": idx + 1,
"params": p,
"merged_params": p,
"db_snapshot": _params_to_db_snapshot(p),
"total_pnl": r["total_pnl"],
"win_rate": r["win_rate"],
"total_trades": r["total_trades"],
"pf": r["pf"],
"avg_hold": r["avg_hold"],
"mdd": r["mdd"],
})
# ── JSON 결과 저장 ──
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"search_{mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({
"mode": mode,
"start": start,
"end": end,
"min_win_rate": min_win_rate,
"top": top_list,
}, f, ensure_ascii=False, indent=2)
print(f"\n💾 결과 저장: {out_path}")
# ── DB 자동 적용 ──
if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list):
_apply_to_db(top_list[apply_rank - 1]["merged_params"])
print(f"{apply_rank}번째 결과 적용 완료")
def _apply_to_db(best_params: dict):
"""1위 파라미터를 env_config DB에 자동 반영.
trail_trigger / trail_stop / cooldown_min 은 env_config 컬럼이 없으므로 저장 제외.
"""
field_map = _get_scalp_field_map()
sets = []
vals = []
for param_k, val in best_params.items():
if param_k not in field_map:
continue
db_col, fmt = field_map[param_k]
sets.append(f"{db_col} = %s")
vals.append(fmt(val))
if not sets:
print("DB 적용할 파라미터가 없습니다.")
return
db = TradeDB()
db.conn.execute(f"UPDATE env_config SET {', '.join(sets)}", vals)
db.conn.commit()
db.close()
print("\n✅ DB env_config 자동 적용 완료:")
for param_k, val in best_params.items():
if param_k in field_map:
db_col, fmt = field_map[param_k]
print(f" {db_col:<30s} = {fmt(val)}")
# ──────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ──────────────────────────────────────────────────────────────────────────────
def main():
today = datetime.now().strftime("%Y-%m-%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="스캘핑 백테스트 파라미터 Grid Search")
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)")
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)")
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"],
help="탐색 모드: coarse(빠름) / fine(보통) / full(느림)")
parser.add_argument("--top", default=20, type=int, help="상위 N개 출력")
parser.add_argument("--min_trades", default=5, type=int, help="최소 거래 건수")
parser.add_argument("--min_win_rate", default=MIN_WIN_RATE_DEFAULT, type=float, help="승률 하한 (%%). 이 이상만 손익순 1위")
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과를 DB에 적용 (기본 1). --from-file 시 최근 JSON에서 적용")
parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 JSON에서만 적용 (탐색 생략)")
args = parser.parse_args()
run_search(
start = args.start,
end = args.end,
mode = args.mode,
top_n = args.top,
min_trades = args.min_trades,
min_win_rate = args.min_win_rate,
apply_rank = args.apply,
from_file_only = args.from_file,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,531 @@
#!/usr/bin/env python3
"""
tail_param_search.py — 꼬리잡기 백테스트 파라미터 자동 탐색 (Grid Search)
==========================================================================
tail_engine을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(env_config) 단일 소스.
실매매·백테스트·파라서치가 동일한 env 값을 사용해 결과 예측 가능.
실행:
cd /home/hoon/kis_bot
python3 backtest_scalping/tail_param_search.py
옵션:
--start 시작일 (기본: 오늘-7일)
--end 종료일 (기본: 오늘)
--mode 탐색 모드: coarse(기본) / fine / full
--top 상위 N개 출력 (기본: 20)
--min_trades 최소 거래 건수 필터 (기본: 3)
--min_win_rate 승률 하한 (기본: 52). 이 이상인 것만 손익순 정렬 후 1위. 없으면 차악(손익 1위) 적용.
--apply [N] 1위(또는 N위) 결과를 DB에 적용. N 생략 시 1.
--from-file --apply N 과 함께 사용 시, 최근 결과 JSON에서 N번째 적용 (탐색 생략).
예시:
python3 backtest_scalping/tail_param_search.py --start 2026-03-01 --end 2026-03-06
python3 backtest_scalping/tail_param_search.py --mode fine --apply
python3 backtest_scalping/tail_param_search.py --apply 2 --from-file
참고:
- 백테스트 엔진(tail_engine)에는 실매 전용 로직이 없음: 금액손실컷(MAX_LOSS_PER_TRADE_KRW),
MIN_HOLD_AFTER_BUY_SEC, ATR 기반 손절/목표가(STOP_ATR_MULTIPLIER_TAIL 등) → 웹 백테·파라서치에도 없음.
- full 모드 조합 수가 너무 크면 OOM으로 Killed 되므로, 그리드 값 개수를 제한함.
"""
MIN_WIN_RATE_DEFAULT = 52.0
import sys, os, json, time, argparse
from datetime import datetime, timedelta
from itertools import product
from typing import Optional
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제
from database import TradeDB
import tail_engine as te
# ──────────────────────────────────────────────────────────────────────────────
# 파라미터 그리드 (그리드 값은 % 단위 → 엔진 호출 시 비율로 변환)
# 실매(kis_short_ver3)와 동일한 env를 경우의수에 포함해 진짜 백테스트에 가깝게 함.
# time_start_hm / time_end_hm: DB 고정. 탐색 시 아래 주석 해제.
# ──────────────────────────────────────────────────────────────────────────────
GRIDS = {
"coarse": {
"min_drop_rate": [1.5, 2.0, 2.5, 3.0, 4.0],
"min_recovery_ratio": [30, 40, 50, 60],
"sl_pct": [1.5, 2.0, 3.0],
"tp_pct": [3.0, 4.0, 5.0, 6.0],
"tail_ratio_min": [1.0, 1.5, 2.0],
"max_loss_per_trade_krw": [150000, 200000],
"risk_pct_per_trade": [1.0, 2.0],
# "time_start_hm": [900, 930],
# "time_end_hm": [1430, 1500],
},
"fine": {
"min_drop_rate": [1.5, 2.0, 2.5, 3.0, 4.0],
"min_recovery_ratio": [30, 40, 50, 60],
"sl_pct": [1.0, 1.5, 2.0, 2.5, 3.0],
"tp_pct": [3.0, 4.0, 5.0, 6.0, 7.0],
"tail_ratio_min": [0.8, 1.0, 1.5, 2.0],
"shoulder_cut_pct": [2.0, 3.0, 4.0],
"shoulder_min_high": [1.0, 1.2, 1.5],
"tail_pct_min": [0.1, 0.15, 0.2],
"max_rec_3m": [70, 80, 90],
"high_chase_thr": [94, 96, 98],
"max_loss_per_trade_krw": [150000, 200000],
"risk_pct_per_trade": [1.0, 2.0],
# "time_start_hm": [900, 930],
# "time_end_hm": [1430, 1500],
},
# full: 조합 수 ~52만 (26만×2×2). MAX_LOSS 하드캡, RISK_PCT 엑셀레이터 테스트. rsi_period는 꼬리잡기용 DB 고정(그리드 제외). 1억 개에서 터진 거라 20~30만이면 10GB·10~20분 내 가능.
"full": {
"min_drop_rate": [1.5, 2.0, 2.5, 3.0, 4.0],
"min_recovery_ratio": [30, 40, 50, 60, 70],
"sl_pct": [1.0, 1.5, 2.0, 3.0],
"tp_pct": [4.0, 5.0, 6.0, 8.0],
"tail_ratio_min": [1.0, 1.5, 2.0],
"shoulder_cut_pct": [2.0, 3.0, 4.0],
"shoulder_min_high": [1.0, 1.2, 1.5],
"tail_pct_min": [0.1, 0.15, 0.2],
"max_rec_3m": [70, 80],
"high_chase_thr": [94, 96],
"rsi_threshold": [72, 78],
"max_loss_per_trade_krw": [150000, 200000],
"risk_pct_per_trade": [1.0, 2.0],
},
}
# % 단위 그리드 키 → 엔진은 비율(0~1) 사용
_PCT_KEYS = frozenset({
"min_drop_rate", "min_recovery_ratio", "max_rec_3m", "tail_pct_min",
"sl_pct", "tp_pct", "shoulder_min_high", "shoulder_cut_pct", "high_chase_thr",
"risk_pct_per_trade",
})
# 포지션 사이징: 실매와 동일한 effective_slot 계산용 (백테 가정 자본·켈리)
BACKTEST_CAPITAL = 100_000_000 # 1억 원 가정
KELLY_MULTIPLIER_TAIL = 0.25
def _get_fixed_defaults():
"""고정값 = DB(env_config) 단일 소스. 엔진과 동일."""
d = te.get_tail_defaults_from_db()
d["slot_money"] = 1_000_000
# 수수료/세금은 백테스트와 동일하게 (backtest_web _get_fee_defaults 기준)
d["fee_rate"] = 0.00015 # 0.015%
d["sell_tax"] = 0.0018 # 0.18%
return d
def _load_candles(start: str, end: str, rsi_period: int):
"""ws_candles 3분봉 확정봉만 로드 (backtest_web과 동일)."""
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
db = TradeDB()
try:
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=3 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
candles_by_code = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles_by_code[code] = [dict(r) for r in rows]
return candles_by_code
finally:
db.close()
def _params_for_engine(fixed: dict, grid_keys: list, grid_vals: list) -> dict:
"""고정값 + 그리드 조합 → 엔진용 params (비율 단위)."""
params = dict(fixed)
for k, v in zip(grid_keys, grid_vals):
if k in _PCT_KEYS:
params[k] = float(v) / 100.0
else:
params[k] = v
# 엔진에 불필요한 키 제거 (slot_money, fee_rate, sell_tax는 백테 후처리용)
for skip in ("slot_money", "fee_rate", "sell_tax"):
params.pop(skip, None)
return params
def _summary_from_trades(all_trades: list, params: dict, fixed: dict, fee_rate: float, sell_tax: float) -> dict:
"""all_trades에 pnl/hold_min 보강 후 요약 통계 반환 (backtest_web과 동일).
params에 max_loss_per_trade_krw, risk_pct_per_trade, sl_pct가 있으면 실매와 동일한 effective_slot로 손익 계산."""
def _t2dt(s):
from datetime import datetime
if isinstance(s, str) and len(s) >= 12:
return datetime(int(s[:4]), int(s[4:6]), int(s[6:8]), int(s[8:10]), int(s[10:12]))
return None
# 포지션 크기: 실매와 동일 공식 적용 시 effective_slot, 아니면 고정 slot_money
if ("max_loss_per_trade_krw" in params and "risk_pct_per_trade" in params and "sl_pct" in params
and params.get("sl_pct", 0) > 0):
sl_pct = float(params["sl_pct"])
max_loss_krw = int(params["max_loss_per_trade_krw"])
risk_pct = float(params["risk_pct_per_trade"])
from_risk = BACKTEST_CAPITAL * risk_pct * KELLY_MULTIPLIER_TAIL / sl_pct
from_cap = max_loss_krw / sl_pct
effective_slot = min(from_risk, from_cap)
else:
effective_slot = fixed.get("slot_money", 1_000_000)
for t in all_trades:
qty = max(1, int(effective_slot / t["entry"]))
fee = (t["entry"] + t["exit"]) * qty * fee_rate
tax = t["exit"] * qty * sell_tax
t["pnl"] = round((t["exit"] - t["entry"]) * qty - fee - tax)
t["hold_min"] = round((_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1) if _t2dt(t["exit_time"]) and _t2dt(t["entry_time"]) else 0
total = len(all_trades)
if total == 0:
return {"total_pnl": 0, "win_rate": 0, "total_trades": 0, "profit_factor": 0, "avg_hold_min": 0, "max_drawdown": 0}
total_pnl = sum(t["pnl"] for t in all_trades)
wins = [t for t in all_trades if t["pnl"] > 0]
win_pnl = sum(t["pnl"] for t in wins)
loss_pnl = sum(t["pnl"] for t in all_trades if t["pnl"] < 0)
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
cum = 0
mdd = 0
for t in all_trades:
cum += t["pnl"]
if cum > mdd:
mdd = cum
mdd = min(mdd, cum)
mdd = abs(min(0, mdd))
avg_hold = sum(t["hold_min"] for t in all_trades) / total
return {
"total_pnl": round(total_pnl),
"win_rate": round(len(wins) / total * 100, 1),
"total_trades": total,
"profit_factor": pf,
"avg_hold_min": round(avg_hold, 1),
"max_drawdown": round(mdd),
}
def _apply_from_latest_json(rank: int):
"""최근 tail_search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용."""
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
if not os.path.isdir(out_dir):
print("⚠️ results 디렉터리가 없습니다.")
return
jsons = [f for f in os.listdir(out_dir) if f.startswith("tail_search_") and f.endswith(".json")]
if not jsons:
print("⚠️ tail_search_*.json 파일이 없습니다.")
return
jsons.sort(key=lambda f: os.path.getmtime(os.path.join(out_dir, f)), reverse=True)
latest_path = os.path.join(out_dir, jsons[0])
with open(latest_path, "r", encoding="utf-8") as f:
data = json.load(f)
top = data.get("top") or []
if rank < 1 or rank > len(top):
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
return
item = top[rank - 1]
merged = item.get("merged_params")
if not merged:
print("⚠️ 해당 항목에 merged_params가 없습니다. (구 버전 JSON)")
return
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
_apply_to_db(merged)
def run_search(start: str, end: str, mode: str, top_n: int,
min_trades: int, min_win_rate: float, apply_rank: Optional[int], from_file_only: bool):
if from_file_only and apply_rank is not None and apply_rank >= 1:
_apply_from_latest_json(apply_rank)
return
grid = GRIDS[mode]
keys = list(grid.keys())
combos = list(product(*[grid[k] for k in keys]))
total = len(combos)
fixed = _get_fixed_defaults()
fee_rate = fixed["fee_rate"]
sell_tax = fixed["sell_tax"]
rsi_period = fixed.get("rsi_period", 14)
print(f"\n[{mode.upper()} 모드 — 꼬리잡기] 탐색 조합: {total:,}개 | 기간: {start} ~ {end}")
print(" 기본값: DB(env_config) tail_engine.get_tail_defaults_from_db()")
print("=" * 70)
candles_by_code = _load_candles(start, end, rsi_period)
if not candles_by_code:
print("⚠️ 해당 기간 ws_candles 3분봉 데이터가 없습니다.")
return
results = []
t0 = time.time()
for i, vals in enumerate(combos):
params = _params_for_engine(fixed, keys, vals)
all_trades = te.run_tail_backtest(candles_by_code, params)
if len(all_trades) < min_trades:
continue
s = _summary_from_trades(all_trades, params, fixed, fee_rate, sell_tax)
results.append({
"params": dict(zip(keys, vals)),
"total_pnl": s["total_pnl"],
"win_rate": s["win_rate"],
"total_trades": s["total_trades"],
"pf": s["profit_factor"],
"avg_hold": s["avg_hold_min"],
"mdd": s["max_drawdown"],
})
if (i + 1) % 100 == 0:
elapsed = time.time() - t0
eta = elapsed / (i + 1) * (total - i - 1)
print(f" 진행: {i+1:>5}/{total} 경과: {elapsed:>5.0f}s 남은: {eta:>5.0f}s", flush=True)
elapsed = time.time() - t0
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}")
if not results:
print("⚠️ 유효 조합을 찾지 못했습니다. 기간을 늘리거나 min_trades를 낮춰보세요.")
return
results.sort(key=lambda x: x["total_pnl"], reverse=True)
# 승률 min_win_rate 이상만 우선; 없으면 차악(손익 1위) 유지
results_52 = [r for r in results if r["win_rate"] >= min_win_rate]
if results_52:
results = results_52
results.sort(key=lambda x: x["total_pnl"], reverse=True)
print(f"✅ 승률 {min_win_rate}% 이상 {len(results)}건 중 손익순으로 1위 선정")
else:
print(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(손익 1위) 적용")
# ── 결과 출력 ──
hdr_keys = list(keys)
col_w = max(len(k) for k in hdr_keys) + 2
print(f"\n{'='*70}")
print(f" 🏆 수익 TOP {min(top_n, len(results))} (손익: 조합별 effective_slot·MAX_LOSS·RISK_PCT 적용)")
print(f"{'='*70}")
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
print("-" * (len(hdr) + 55))
for r in results[:top_n]:
p = r["params"]
row = " ".join(f"{p[k]:>{col_w}.4g}" for k in hdr_keys)
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}")
best = results[0]
bp = best["params"]
# 적용 시 사용할 전체 파라미터 = 고정값(DB) + 그리드 1위
merged = dict(fixed)
for k, v in bp.items():
merged[k] = v
# 표시용: 그리드 키 + 결과에 영향 주는 고정 파라미터
_tail_disp_map = {
"min_drop_rate": "MIN_DROP_RATE (÷100)",
"min_recovery_ratio": "MIN_RECOVERY_RATIO_SHORT(÷100)",
"sl_pct": "STOP_LOSS_PCT (음수÷100)",
"tp_pct": "TAKE_PROFIT_PCT (÷100)",
"tail_ratio_min": "TAIL_RATIO_MIN",
"tail_pct_min": "TAIL_PCT_MIN (÷100)",
"shoulder_min_high": "SHOULDER_MIN_HIGH_PCT (÷100)",
"shoulder_cut_pct": "SHOULDER_CUT_PCT (÷100)",
"rsi_period": "RSI_PERIOD",
"rsi_threshold": "RSI_OVERHEAT_THRESHOLD",
"max_rec_3m": "MAX_RECOVERY_RATIO_3M (÷100)",
"high_chase_thr": "HIGH_PRICE_CHASE_THRESHOLD(÷100)",
"cooldown_min": "REENTRY_COOLDOWN_SEC (×60초)",
"time_start_hm": "TIME_START / TAIL_TIME_START",
"time_end_hm": "TIME_END / TAIL_TIME_END",
"max_daily": "MAX_DAILY_TAIL / MAX_STOCKS",
"max_loss_per_trade_krw": "MAX_LOSS_PER_TRADE_KRW (원)",
"risk_pct_per_trade": "RISK_PCT_PER_TRADE (÷100)",
}
print(f"""
╔══════════════════════════════════════════╗
║ 🏆 꼬리잡기 최적 파라미터 ║
╠══════════════════════════════════════════╣""")
for k in keys:
v = bp.get(k, merged.get(k))
label = _tail_disp_map.get(k, k)
print(f"{label:<34s} : {v!s:>6}")
for k in ("rsi_period", "rsi_threshold", "max_rec_3m", "tail_pct_min", "shoulder_min_high",
"high_chase_thr", "cooldown_min", "time_start_hm", "time_end_hm", "max_daily",
"max_loss_per_trade_krw", "risk_pct_per_trade"):
if k in merged and k not in keys:
label = _tail_disp_map.get(k, k)
print(f"{label:<34s} : {merged[k]!s:>6}")
print(f"""╠══════════════════════════════════════════╣
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
║ 승률 : {best['win_rate']:>6.1f}% ║
║ 총 거래 : {best['total_trades']:>5} 건 ║
║ Profit Factor : {best['pf']:>5.2f}
║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║
║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║
╚══════════════════════════════════════════╝""")
# ── 각 결과에 순번·merged_params·db_snapshot 부여 (JSON 및 apply N 지원) ──
top_list = []
for idx, r in enumerate(results[:top_n]):
merged_for_db = dict(fixed)
for k, v in r["params"].items():
merged_for_db[k] = (float(v) / 100.0) if k in _PCT_KEYS else v
for skip in ("slot_money", "fee_rate", "sell_tax"):
merged_for_db.pop(skip, None)
top_list.append({
"rank": idx + 1,
"params": r["params"],
"total_pnl": r["total_pnl"],
"win_rate": r["win_rate"],
"total_trades": r["total_trades"],
"pf": r["pf"],
"avg_hold": r["avg_hold"],
"mdd": r["mdd"],
"merged_params": merged_for_db,
"db_snapshot": _merged_to_db_snapshot(merged_for_db),
})
# ── JSON 저장 ──
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"tail_search_{mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({
"mode": mode,
"start": start,
"end": end,
"min_win_rate": min_win_rate,
"top": top_list,
}, f, ensure_ascii=False, indent=2)
print(f"\n💾 결과 저장: {out_path}")
if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list):
_apply_to_db(top_list[apply_rank - 1]["merged_params"])
print(f"{apply_rank}번째 결과 적용 완료")
def _get_tail_field_map():
"""꼬리잡기 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용)."""
return {
"min_drop_rate": ("MIN_DROP_RATE", lambda v: str(float(v))),
"min_recovery_ratio": ("MIN_RECOVERY_RATIO_SHORT", lambda v: str(float(v))),
"sl_pct": ("STOP_LOSS_PCT", lambda v: str(-abs(float(v)))),
"tp_pct": ("TAKE_PROFIT_PCT", lambda v: str(float(v))),
"tail_ratio_min": ("TAIL_RATIO_MIN", lambda v: str(float(v))),
"tail_pct_min": ("TAIL_PCT_MIN", lambda v: str(float(v))),
"shoulder_min_high": ("SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v))),
"shoulder_cut_pct": ("SHOULDER_CUT_PCT", lambda v: str(float(v))),
"rsi_period": ("RSI_PERIOD", lambda v: str(int(v))),
"rsi_threshold": ("RSI_OVERHEAT_THRESHOLD", lambda v: str(float(v))),
"max_rec_3m": ("MAX_RECOVERY_RATIO_3M", lambda v: str(float(v))),
"high_chase_thr": ("HIGH_PRICE_CHASE_THRESHOLD",lambda v: str(float(v))),
"cooldown_min": ("REENTRY_COOLDOWN_SEC", lambda v: str(int(v) * 60)),
"max_daily": ("MAX_STOCKS", lambda v: str(int(v))),
"max_loss_per_trade_krw": ("MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(v))),
"risk_pct_per_trade": ("RISK_PCT_PER_TRADE", lambda v: str(float(v))),
}
def _merged_to_db_snapshot(merged_params: dict) -> dict:
"""merged_params(비율 단위) → env_config 컬럼명:값 문자열 dict (JSON 저장용)."""
field_map = _get_tail_field_map()
if "time_start_hm" in merged_params:
field_map = {**field_map, "time_start_hm": ("TIME_START", lambda v: str(int(v)))}
if "time_end_hm" in merged_params:
field_map = {**field_map, "time_end_hm": ("TIME_END", lambda v: str(int(v)))}
return {
db_col: fmt(merged_params[param_k])
for param_k, (db_col, fmt) in field_map.items()
if param_k in merged_params
}
def _apply_to_db(merged_params: dict):
"""1위 파라미터(비율 단위)를 env_config DB에 자동 반영. 결과에 영향 주는 env 키 전부 반영."""
field_map = _get_tail_field_map()
if "time_start_hm" in merged_params:
field_map["time_start_hm"] = ("TIME_START", lambda v: str(int(v)))
if "time_end_hm" in merged_params:
field_map["time_end_hm"] = ("TIME_END", lambda v: str(int(v)))
sets, vals = [], []
for param_k, val in merged_params.items():
if param_k not in field_map:
continue
db_col, fmt = field_map[param_k]
sets.append(f"{db_col} = %s")
vals.append(fmt(val))
if not sets:
print("DB 적용할 파라미터가 없습니다.")
return
db = TradeDB()
try:
db.conn.execute(f"UPDATE env_config SET {', '.join(sets)} WHERE id = (SELECT MAX(id) FROM env_config)", vals)
db.conn.commit()
except Exception as e:
# 컬럼 없음 등으로 실패 시 일부만 적용
print(f"⚠️ 전체 적용 실패: {e}. 적용 가능한 컬럼만 시도합니다.")
for param_k, val in merged_params.items():
if param_k not in field_map:
continue
db_col, fmt = field_map[param_k]
try:
db.conn.execute(f"UPDATE env_config SET {db_col} = %s WHERE id = (SELECT MAX(id) FROM env_config)", [fmt(val)])
db.conn.commit()
print(f" {db_col:<30s} = {fmt(val)}")
except Exception:
pass
db.close()
print("\n✅ DB env_config 자동 적용 완료:")
for param_k, val in merged_params.items():
if param_k in field_map:
db_col, fmt = field_map[param_k]
print(f" {db_col:<30s} = {fmt(val)}")
# ──────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ──────────────────────────────────────────────────────────────────────────────
def main():
today = datetime.now().strftime("%Y-%m-%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="꼬리잡기 백테스트 파라미터 Grid Search")
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)")
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)")
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"],
help="탐색 모드")
parser.add_argument("--top", default=20, type=int)
parser.add_argument("--min_trades", default=3, type=int, help="최소 거래 건수")
parser.add_argument("--min_win_rate", default=MIN_WIN_RATE_DEFAULT, type=float, help="승률 하한 (%%). 이 이상만 손익순 1위")
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과를 DB에 적용 (기본 1). --from-file 과 함께 시 최근 JSON에서 적용")
parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 JSON에서만 적용 (탐색 생략)")
args = parser.parse_args()
run_search(
start = args.start,
end = args.end,
mode = args.mode,
top_n = args.top,
min_trades = args.min_trades,
min_win_rate = args.min_win_rate,
apply_rank = args.apply,
from_file_only = args.from_file,
)
if __name__ == "__main__":
main()

View File

@@ -196,26 +196,36 @@ def api_actual():
def _t2dt(t: str) -> datetime:
return datetime.strptime(t, "%Y%m%d%H%M")
import scalping_engine as se
@app.route("/api/backtest/scalping", methods=["GET"])
def api_backtest_scalping():
# 기본값 = DB(엔진 단일 소스) → 백테스트/param_search/실매매 동일 값
_def = se.get_scalping_defaults_from_db()
start = request.args.get("start", "")
end = request.args.get("end", "")
rsi_period = int(request.args.get("rsi_period", 3))
rsi_period = int(request.args.get("rsi_period", _def["rsi_period"]))
rsi_oversold = float(request.args.get("rsi_oversold", 25))
sl_pct = float(request.args.get("sl_pct", 1.5)) / 100 # 손절 %
tp_pct = float(request.args.get("tp_pct", 1.5)) / 100 # 익절 %
drop_rate = float(request.args.get("drop_rate", 1.5)) / 100 # 최소 낙폭(당일 시가→저가)
slot_money = float(request.args.get("slot_money", 1_000_000)) # 1회 투자금
_fee_d = _get_fee_defaults()
fee_rate = float(request.args.get("fee_rate", _fee_d["fee_rate"])) / 100
sell_tax = float(request.args.get("sell_tax", _fee_d["sell_tax"])) / 100
cooldown_min = int(request.args.get("cooldown_min", 10)) # 손익 후 재진입 금지(분)
vol_mult = float(request.args.get("vol_mult", 0)) # 거래량 필터(0=비활성)
trail_trigger = float(request.args.get("trail_trigger", 0.7)) / 100 # 트레일링 발동 수익률
trail_stop = float(request.args.get("trail_stop", 0.4)) / 100 # 트레일링 추적폭
time_start_hm = int(request.args.get("time_start", 900)) # 매수 가능 시작(HHMM)
time_end_hm = int(request.args.get("time_end", 1400)) # 매수 가능 종료(HHMM)
max_daily = int(request.args.get("max_daily", 3)) # 종목당 일일 최대 거래횟수
slot_money = float(request.args.get("slot_money", _def["slot_money"])) # 1회 투자금
_fee_rate = request.args.get("fee_rate")
fee_rate = float(_fee_rate) / 100 if _fee_rate not in (None, "") else _def["fee_rate"]
_sell_tax = request.args.get("sell_tax")
sell_tax = float(_sell_tax) / 100 if _sell_tax not in (None, "") else _def["sell_tax"]
_cooldown = request.args.get("cooldown_min")
cooldown_min = float(_cooldown) if _cooldown not in (None, "") else _def["cooldown_min"]
vol_mult = float(request.args.get("vol_mult", _def["vol_mult"])) # 거래량 필터(0=비활성)
_tr_trigger = request.args.get("trail_trigger")
trail_trigger = float(_tr_trigger) / 100 if _tr_trigger not in (None, "") else _def["trail_trigger"]
_tr_stop = request.args.get("trail_stop")
trail_stop = float(_tr_stop) / 100 if _tr_stop not in (None, "") else _def["trail_stop"]
_time_start = request.args.get("time_start")
time_start_hm = int(_time_start) if _time_start not in (None, "") else _def["time_start_hm"]
_time_end = request.args.get("time_end")
time_end_hm = int(_time_end) if _time_end not in (None, "") else _def["time_end_hm"]
max_daily = int(request.args.get("max_daily", _def["max_daily"])) # 종목당 일일 최대 거래횟수
db = _db()
try:
@@ -228,10 +238,9 @@ def api_backtest_scalping():
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r['code'] for r in codes_raw]
all_virtual_trades: List[Dict] = []
codes = [r["code"] for r in codes_raw]
codes_candles: Dict[str, List[Dict]] = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
@@ -244,170 +253,30 @@ def api_backtest_scalping():
).fetchall()
if len(rows) < rsi_period + 5:
continue
codes_candles[code] = [dict(r) for r in rows]
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
volumes = [float(c["volume"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {} # day → 마지막 청산 시각 (쿨다운)
daily_cnt: Dict[str, int] = {} # day → 당일 거래횟수
# 선행 편향 없는 당일 OHLC 누적값
cur_day = None
running_open = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12]) # HHMM 정수
cl = float(c["close"])
lo = float(c["low"])
hi = float(c["high"])
vol = volumes[i]
# ── 당일 누적값 갱신 (look-ahead 없이 실시간 계산) ────────
if day != cur_day:
cur_day = day
running_open = float(c["open"])
running_low = lo
else:
running_low = min(running_low, lo)
# ── 당일 마지막 봉 여부 ────────────────────────────────────
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_price = max(position["max_price"], hi)
position["max_price"] = max_price
reason = None
exit_price = cl
# 손절: 캔들 저가가 손절선 이하
if lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"] # limit-stop 가정
# 익절: 캔들 고가가 익절선 이상
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 트레일링스탑: 고점 대비 하락
elif trail_trigger > 0 and max_price >= position["entry_price"] * (1 + trail_trigger):
ts = max_price * (1 - trail_stop)
if cl <= ts:
reason = "트레일링스탑"
exit_price = cl
# 장마감 강제청산 (당일 마지막 봉)
if reason is None and is_eod:
reason = "장마감청산"
exit_price = cl
if reason:
qty = position["qty"]
buy_amt = position["entry_price"] * qty
sell_amt = exit_price * qty
pnl = (sell_amt - buy_amt
- buy_amt * fee_rate
- sell_amt * fee_rate
- sell_amt * sell_tax)
hold_min = int((_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])).total_seconds() / 60)
all_virtual_trades.append({
"code": code,
"buy_time": position["entry_time"],
"sell_time": c["candle_time"],
"buy_price": position["entry_price"],
"sell_price": round(exit_price, 2),
"qty": qty,
"pnl": round(pnl),
"profit_rate": round((exit_price - position["entry_price"])
/ position["entry_price"] * 100, 2),
"hold_min": hold_min,
"sell_reason": reason,
"rsi_entry": round(position["rsi"], 1),
})
last_exit_dt[day] = _t2dt(c["candle_time"])
position = None
continue # 포지션 보유 중이면 신규 진입 건너뜀
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 신호 체크
# ─────────────────────────────────────────────────────────
# 거래 가능 시간 필터
if hm < time_start_hm or hm >= time_end_hm:
continue
# 쿨다운 체크 (당일 마지막 청산 기준)
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
# 일일 최대 거래횟수 초과
if daily_cnt.get(day, 0) >= max_daily:
continue
# RSI 과매도 조건
rsi = rsis[i]
if rsi is None or rsi > rsi_oversold:
continue
# 반전 캔들 패턴: 이전봉 음봉 + 현재봉 양봉
prev_c = candles[i - 1]
prev_bear = float(prev_c["close"]) < float(prev_c["open"])
curr_bull = cl > float(c["open"])
if not (prev_bear and curr_bull):
continue
# 당일 낙폭 필터 (look-ahead 없는 running_low 사용)
if running_open <= 0:
continue
dr = (running_open - running_low) / running_open
if dr < drop_rate:
continue
# 거래량 급증 필터
if vol_mult > 0:
win = max(1, min(20, i))
vol_avg = sum(volumes[i - win:i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
continue
# ─── 진입 가격: 신호봉 다음 봉 시가 (현실적 체결 가정) ───
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day: # 다음봉이 익일이면 스킵
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
continue
qty = max(1, int(slot_money / entry_price))
stop = entry_price * (1 - sl_pct)
target = entry_price * (1 + tp_pct)
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"qty": qty,
"stop": stop,
"target": target,
"max_price": entry_price,
"rsi": rsi,
}
daily_cnt[day] = daily_cnt.get(day, 0) + 1
params = {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"drop_rate": drop_rate,
"slot_money": slot_money,
"fee_rate": fee_rate,
"sell_tax": sell_tax,
"cooldown_min": cooldown_min,
"trail_trigger": trail_trigger,
"trail_stop": trail_stop,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"max_daily": max_daily,
"vol_mult": vol_mult,
}
all_virtual_trades = se.run_scalping_backtest(codes_candles, params)
# ─────────────────────────────────────────────────────────────────
# 결과 집계
# ─────────────────────────────────────────────────────────────────
all_virtual_trades.sort(key=lambda x: x["sell_time"])
equity = []
cum = 0.0
@@ -478,38 +347,57 @@ def api_backtest_scalping():
# ────────────────────────────────────────────────────────────────────────────
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반)
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반, tail_engine 공통 로직 사용)
# ────────────────────────────────────────────────────────────────────────────
try:
import tail_engine as te
_TAIL_ENGINE_AVAILABLE = True
except ImportError:
_TAIL_ENGINE_AVAILABLE = False
def _get_tail_defaults_for_backtest():
"""꼬리잡기 백테스트 기본값: DB 단일 소스. 엔진 없으면 빈 dict."""
if not _TAIL_ENGINE_AVAILABLE:
return {}
return te.get_tail_defaults_from_db()
@app.route("/api/backtest/tail", methods=["GET"])
def api_backtest_tail():
"""
꼬리잡기 전략 가격 재현 백테스트.
entry 조건: 당일 낙폭(drop_rate) + 회복률(recovery_ratio) + 망치봉 꼬리 + RSI
exit 조건: 손절 / 익절 / 어깨 컷(trailing) / 장 마감 강제 청산
기본값 = DB(env_config) → tail_engine.get_tail_defaults_from_db(), 요청으로 덮어쓰기.
"""
_def = _get_tail_defaults_for_backtest()
start = request.args.get("start", "")
end = request.args.get("end", "")
rsi_period = int( request.args.get("rsi_period", 14))
rsi_threshold = float(request.args.get("rsi_threshold", 78)) # RSI 과열 기준
min_drop_rate = float(request.args.get("min_drop_rate", 3.0)) / 100 # 최소 당일 낙폭
min_recovery_ratio = float(request.args.get("min_recovery_ratio",50)) / 100 # 최소 당일 회복률
max_rec_3m = float(request.args.get("max_rec_3m", 80)) / 100 # 3분봉 최대 회복위치
tail_ratio_min = float(request.args.get("tail_ratio_min", 1.5)) # 꼬리/몸통 비율
tail_pct_min = float(request.args.get("tail_pct_min", 0.3)) / 100 # 꼬리 최소 %
sl_pct = float(request.args.get("sl_pct", 3.0)) / 100 # 손절 %
tp_pct = float(request.args.get("tp_pct", 5.0)) / 100 # 익절 %
shoulder_min_high = float(request.args.get("shoulder_min_high", 1.5)) / 100 # 어깨 발동 최소 이익
shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", 3.0)) / 100 # 고점 대비 하락 시 매도
high_chase_thr = float(request.args.get("high_chase_thr", 96.0)) / 100 # 고점 추격 방지
rsi_period = int( request.args.get("rsi_period", _def.get("rsi_period", 14)))
rsi_threshold = float(request.args.get("rsi_threshold", _def.get("rsi_threshold", 78)))
min_drop_rate = float(request.args.get("min_drop_rate", _def.get("min_drop_rate", 0.03) * 100)) / 100
min_recovery_ratio = float(request.args.get("min_recovery_ratio", _def.get("min_recovery_ratio", 0.5) * 100)) / 100
# max_rec_3m / high_chase_thr: 폼에서 80·96(퍼센트) 또는 0.8·0.96(비율) 전달 가능 → 엔진은 항상 비율(0~1)
_max_rec_raw = float(request.args.get("max_rec_3m", _def.get("max_rec_3m", 0.8)))
max_rec_3m = _max_rec_raw if 0 < _max_rec_raw <= 1 else _max_rec_raw / 100
tail_ratio_min = float(request.args.get("tail_ratio_min", _def.get("tail_ratio_min", 1.5)))
tail_pct_min = float(request.args.get("tail_pct_min", _def.get("tail_pct_min", 0.003) * 100)) / 100
sl_pct = float(request.args.get("sl_pct", _def.get("sl_pct", 0.03) * 100)) / 100
tp_pct = float(request.args.get("tp_pct", _def.get("tp_pct", 0.05) * 100)) / 100
shoulder_min_high = float(request.args.get("shoulder_min_high", _def.get("shoulder_min_high", 0.015) * 100)) / 100
shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.03) * 100)) / 100
_high_chase_raw = float(request.args.get("high_chase_thr", _def.get("high_chase_thr", 0.96)))
high_chase_thr = _high_chase_raw if 0 < _high_chase_raw <= 1 else _high_chase_raw / 100
slot_money = float(request.args.get("slot_money", 1_000_000))
_fee_d = _get_fee_defaults()
fee_rate = float(request.args.get("fee_rate", _fee_d["fee_rate"])) / 100
sell_tax = float(request.args.get("sell_tax", _fee_d["sell_tax"])) / 100
cooldown_min = int( request.args.get("cooldown_min", 30))
time_start_hm = int( request.args.get("time_start", 930))
time_end_hm = int( request.args.get("time_end", 1500))
max_daily = int( request.args.get("max_daily", 3))
cooldown_min = int( request.args.get("cooldown_min", _def.get("cooldown_min", 15)))
time_start_hm = int( request.args.get("time_start", _def.get("time_start_hm", 930)))
time_end_hm = int( request.args.get("time_end", _def.get("time_end_hm", 1500)))
max_daily = int( request.args.get("max_daily", _def.get("max_daily", 3)))
db = _db()
try:
@@ -523,201 +411,233 @@ def api_backtest_tail():
).fetchall()
codes = [r["code"] for r in codes_raw]
use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
all_trades: List[Dict] = []
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
if use_engine:
# tail_engine 공통 로직 사용 (실매매 ver3과 동일 계산식)
candles_by_code = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles_by_code[code] = [dict(r) for r in rows]
params = {
"min_drop_rate": min_drop_rate, "min_recovery_ratio": min_recovery_ratio,
"max_rec_3m": max_rec_3m, "tail_ratio_min": tail_ratio_min, "tail_pct_min": tail_pct_min,
"sl_pct": sl_pct, "tp_pct": tp_pct,
"shoulder_min_high": shoulder_min_high, "shoulder_cut_pct": shoulder_cut_pct,
"rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase_thr,
"time_start_hm": time_start_hm, "time_end_hm": time_end_hm,
"cooldown_min": cooldown_min, "max_daily": max_daily,
}
all_trades = te.run_tail_backtest(candles_by_code, params)
for t in all_trades:
qty = max(1, int(slot_money / t["entry"]))
fee = (t["entry"] + t["exit"]) * qty * fee_rate
tax = t["exit"] * qty * sell_tax
t["pnl"] = round((t["exit"] - t["entry"]) * qty - fee - tax)
t["hold_min"] = round((_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1)
else:
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
# look-ahead 없는 당일 누적 OHLC
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
# look-ahead 없는 당일 누적 OHLC
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
# ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
# ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
# ── 마지막 봉 여부 ────────────────────────────────────────
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ── 마지막 봉 여부 ────────────────────────────────────────
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
reason = None
exit_price = cl
# 손절: 저가가 손절선 이하
if lo > 0 and lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
# 익절: 고가가 익절선 이상
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 어깨 컷(trailing): 충분히 오른 뒤 고점에서 일정 이상 하락
elif (max_p >= position["entry_price"] * (1 + shoulder_min_high)
and cl <= max_p * (1 - shoulder_cut_pct)):
reason = "어깨컷"
exit_price = cl
# 장 마감 강제 청산
elif is_eod:
reason = "장마감"
reason = None
exit_price = cl
if reason:
ep = position["entry_price"]
qty = max(1, int(slot_money / ep))
fee = (ep + exit_price) * qty * fee_rate
tax = exit_price * qty * sell_tax
pnl = (exit_price - ep) * qty - fee - tax
hold = round((
_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])
).total_seconds() / 60, 1)
# 손절: 저가가 손절선 이하
if lo > 0 and lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
# 익절: 고가가 익절선 이상
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 어깨 컷(trailing): 충분히 오른 뒤 고점에서 일정 이상 하락
elif (max_p >= position["entry_price"] * (1 + shoulder_min_high)
and cl <= max_p * (1 - shoulder_cut_pct)):
reason = "어깨컷"
exit_price = cl
# 장 마감 강제 청산
elif is_eod:
reason = "장마감"
exit_price = cl
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(ep),
"exit": round(exit_price),
"pnl": round(pnl),
"reason": reason,
"hold_min": hold,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
continue # 다음 봉으로
if reason:
ep = position["entry_price"]
qty = max(1, int(slot_money / ep))
fee = (ep + exit_price) * qty * fee_rate
tax = exit_price * qty * sell_tax
pnl = (exit_price - ep) * qty - fee - tax
hold = round((
_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])
).total_seconds() / 60, 1)
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 조건 체크
# ─────────────────────────────────────────────────────────
if cl <= 0 or running_open <= 0:
continue
if hm < time_start_hm or hm > time_end_hm:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(ep),
"exit": round(exit_price),
"pnl": round(pnl),
"reason": reason,
"hold_min": hold,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
continue # 다음 봉으로
# 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 조건 체크
# ─────────────────────────────────────────────────────────
if cl <= 0 or running_open <= 0:
continue
if hm < time_start_hm or hm > time_end_hm:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
# ── 1. 당일 낙폭 ─────────────────────────────────────────
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
continue
# ── 2. 당일 회복률 ─────────────────────────────────────
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
continue
# ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
# 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
# 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len = tl2
body_len = bl2
lo = l2
break
tail_ratio = tail_len / body_len
tail_pct = tail_len / lo if lo > 0 and tail_len > 0 else 0.0
# ── 1. 당일 낙폭 ─────────────────────────────────────────
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
continue
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
# ── 2. 당일 회복률 ─────────────────────────────────────
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
continue
# ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
c_range = float(c["high"]) - float(c["low"])
rec_3m = (cl - float(c["low"])) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
continue
# ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
# 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len = tl2
body_len = bl2
lo = l2
break
# ── 5. RSI 과열 방지 ────────────────────────────────────
rsi_val = rsis[i]
if rsi_val is None or rsi_val >= rsi_threshold:
continue
tail_ratio = tail_len / body_len
tail_pct = tail_len / lo if lo > 0 and tail_len > 0 else 0.0
# ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
if cl >= running_high * high_chase_thr:
continue
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
# ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip
# ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
c_range = float(c["high"]) - float(c["low"])
rec_3m = (cl - float(c["low"])) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
# ── 5. RSI 과열 방지 ────────────────────────────────────
rsi_val = rsis[i]
if rsi_val is None or rsi_val >= rsi_threshold:
continue
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
# 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
i += 1
# ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
if cl >= running_high * high_chase_thr:
continue
# ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
# 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
i += 1
# ── 통계 집계 ──────────────────────────────────────────────────
total = len(all_trades)
@@ -766,6 +686,10 @@ def api_backtest_tail():
"shoulder_cut_pct": shoulder_cut_pct * 100,
"slot_money": slot_money,
"cooldown_min": cooldown_min,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
"max_daily": max_daily,
"codes_analyzed": len(codes),
},
"summary": {
@@ -1424,10 +1348,28 @@ def api_tail_save_config():
snap["SHOULDER_MIN_HIGH_PCT"] = str(float(body["shoulder_min_high"]) / 100)
if "cooldown_min" in body:
snap["REENTRY_COOLDOWN_SEC"] = str(int(float(body["cooldown_min"])) * 60)
if "rsi_threshold" in body:
snap["RSI_OVERHEAT_THRESHOLD"] = str(float(body["rsi_threshold"]))
if "rsi_period" in body:
snap["RSI_PERIOD"] = str(int(float(body["rsi_period"])))
if "time_start" in body:
snap["TIME_START"] = str(int(float(body["time_start"])))
if "time_end" in body:
snap["TIME_END"] = str(int(float(body["time_end"])))
if "max_daily" in body:
snap["MAX_STOCKS"] = str(int(float(body["max_daily"])))
if "tail_pct_min" in body:
snap["TAIL_PCT_MIN"] = str(float(body["tail_pct_min"]) / 100)
if "max_rec_3m" in body:
snap["MAX_RECOVERY_RATIO_3M"] = str(float(body["max_rec_3m"]) / 100)
if "high_chase_thr" in body:
snap["HIGH_PRICE_CHASE_THRESHOLD"] = str(float(body["high_chase_thr"]) / 100)
env_id = db.insert_env_snapshot(snap)
return jsonify({"ok": True, "env_id": env_id, "saved_keys": [
"MIN_DROP_RATE","MIN_RECOVERY_RATIO_SHORT","STOP_LOSS_PCT",
"TAKE_PROFIT_PCT","TAIL_RATIO_MIN","SHOULDER_CUT_PCT"
"TAKE_PROFIT_PCT","TAIL_RATIO_MIN","TAIL_PCT_MIN","SHOULDER_CUT_PCT",
"REENTRY_COOLDOWN_SEC","RSI_OVERHEAT_THRESHOLD","RSI_PERIOD",
"TIME_START","TIME_END","MAX_STOCKS","MAX_RECOVERY_RATIO_3M","HIGH_PRICE_CHASE_THRESHOLD"
]})
except Exception as e:
logger.error(f"꼬리잡기 설정저장 오류: {e}")
@@ -1480,6 +1422,20 @@ def api_env_params():
v = fv(key)
return round(v / 60) if v is not None else None
def _ratio_to_pct(val, default):
"""비율(0~1)을 폼 퍼센트 표시용(80, 96 등)으로. DB 0.8 → 80 반환."""
if val is None:
return default
try:
v = float(val)
if 0 < v <= 1:
return round(v * 100, 2)
if v > 1:
return round(v, 2)
except (ValueError, TypeError):
pass
return default
return jsonify({
"scalp": {
"rsi_oversold": fv("SCALP_RSI_OVERSOLD"), # 과매도 임계값 (숫자 그대로)
@@ -1491,15 +1447,23 @@ def api_env_params():
"slot_money": fv("SLOT_MONEY_DEFAULT"),
},
"tail": {
"drop": smart_pct("MIN_DROP_RATE"),
"rec": smart_pct("MIN_RECOVERY_RATIO_SHORT"),
"tail_ratio": fv("TAIL_RATIO_MIN"),
"sl_pct": smart_pct("STOP_LOSS_PCT"), # 음수 → 양수도 자동처리
"tp_pct": smart_pct("TAKE_PROFIT_PCT"),
"smin": smart_pct("SHOULDER_MIN_HIGH_PCT"),
"scut": smart_pct("SHOULDER_CUT_PCT"),
"cool": sec_to_min("REENTRY_COOLDOWN_SEC"),
"rsi": fv("RSI_OVERHEAT_THRESHOLD"),
"drop": smart_pct("MIN_DROP_RATE"),
"rec": smart_pct("MIN_RECOVERY_RATIO_SHORT"),
"tail_ratio": fv("TAIL_RATIO_MIN"),
"tail_pct_min": smart_pct("TAIL_PCT_MIN"),
"sl_pct": smart_pct("STOP_LOSS_PCT"), # 음수 → 양수도 자동처리
"tp_pct": smart_pct("TAKE_PROFIT_PCT"),
"smin": smart_pct("SHOULDER_MIN_HIGH_PCT"),
"scut": smart_pct("SHOULDER_CUT_PCT"),
"cool": sec_to_min("REENTRY_COOLDOWN_SEC"),
"rsi": fv("RSI_OVERHEAT_THRESHOLD"),
"rsi_period": int(fv("RSI_PERIOD") or 14), # RSI 기간 (실매·파라서치와 동일)
"time_start": int(fv("TIME_START") or 930), # 매수시작 HHMM
"time_end": int(fv("TIME_END") or 1500), # 매수종료 HHMM
"max_daily": int(fv("MAX_STOCKS") or 3), # 일일최대매수 (실매 MAX_STOCKS)
# 비율(0~1) 저장값을 폼 퍼센트 표시용으로 80·96 형태로 반환 (백테 API는 80→0.8로 변환)
"max_rec_3m": _ratio_to_pct(fv("MAX_RECOVERY_RATIO_3M"), 80),
"high_chase": _ratio_to_pct(fv("HIGH_PRICE_CHASE_THRESHOLD"), 96),
},
})
finally:
@@ -1965,9 +1929,25 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<label class="form-label param-row" title="고점 대비 이 % 하락 시 어깨 컷 매도">어깨컷(%)</label>
<input type="number" class="form-control" id="tl_scut" value="3.0" min="0.5" max="8" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="꼬리 길이 최소 비율(%) → TAIL_PCT_MIN">꼬리최소(%)</label>
<input type="number" class="form-control" id="tl_tail_pct" value="0.3" min="0.01" max="2" step="0.05">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="3분봉 회복위치 상한(%) → MAX_RECOVERY_RATIO_3M">3분최대회복(%)</label>
<input type="number" class="form-control" id="tl_max_rec_3m" value="80" min="50" max="100" step="5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="고점 대비 이 비율 이상이면 진입 거부(%) → HIGH_PRICE_CHASE_THRESHOLD">고점추격방지(%)</label>
<input type="number" class="form-control" id="tl_high_chase" value="96" min="90" max="100" step="1">
</div>
</div>
<!-- 고급 파라미터 (2행) -->
<div class="row g-2 align-items-end mt-1">
<div class="col-4 col-md-1">
<label class="form-label param-row" title="RSI 계산 캔들 수 (실매·파라서치와 동일)">RSI기간</label>
<input type="number" class="form-control" id="tl_rsi_period" value="14" min="3" max="21" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="RSI 이 값 이상이면 과열로 진입 거부">RSI과열기준</label>
<input type="number" class="form-control" id="tl_rsi" value="78" min="50" max="95" step="1">
@@ -1985,7 +1965,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<input type="number" class="form-control" id="tl_te" value="1500" min="900" max="1530" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 하루 최대 거래횟수">일일최대매수</label>
<label class="form-label param-row" title="종목당 하루 최대 거래횟수 (실매 MAX_STOCKS)">일일최대매수</label>
<input type="number" class="form-control" id="tl_maxd" value="3" min="1" max="10">
</div>
<div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
@@ -2007,7 +1987,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
</div>
<!-- 파라미터 설명 -->
<div class="mt-3 p-2 rounded" style="background:#0d1117;border:1px solid #21262d;font-size:11px;color:var(--muted)">
<b style="color:var(--accent)">📖 파라미터 설명 (실제 봇 <code>kis_short_ver2.py</code> 기준)</b>
<b style="color:var(--accent)">📖 파라미터 설명 (실매·파라서치와 동일 DB env_config — 화면 값 = 백테에 사용되는 값)</b>
<div class="row g-1 mt-1">
<div class="col-12 col-md-6">
<table style="width:100%;border-collapse:collapse">
@@ -2022,12 +2002,16 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨발동(%)</td><td>수익이 이 값 이상일 때 어깨 컷 활성 → 봇 DB: <b>SHOULDER_MIN_HIGH_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨컷(%)</td><td>고점 대비 이 값 하락 시 매도 → 봇 DB: <b>SHOULDER_CUT_PCT=3%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">꼬리최소(%)</td><td>꼬리 길이 최소 비율 → 봇 DB: <b>TAIL_PCT_MIN</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">3분최대회복(%)</td><td>3분봉 회복위치 상한 → 봇 DB: <b>MAX_RECOVERY_RATIO_3M</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">고점추격방지(%)</td><td>고점 대비 이 비율 이상이면 진입 거부 → 봇 DB: <b>HIGH_PRICE_CHASE_THRESHOLD</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI과열기준</td><td>RSI 이상이면 진입 거부 → 봇 DB: <b>RSI_OVERHEAT_THRESHOLD=78</b></td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 재진입 금지 → <span style="opacity:.7">봇에는 없음(백테스트 전용)</span></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 재진입 금지 → 봇 DB: <b>REENTRY_COOLDOWN_SEC</b> (초→분)</td></tr>
</table>
</div>
</div>
<div class="mt-1">⚡ 진입가 = <b>신호봉(3분봉) 다음 봉 시가</b> | 수수료 0.015%×2 + 거래세 0.18% 자동 차감 | 데이터: ws_candles 3분봉</div>
<div class="mt-1" style="color:var(--muted)">📌 웹 백테와 파라미터서치 결과를 비교하려면 <b>시작일/종료일</b>과 <b>매수시작/매수종료</b>를 동일하게 두세요. 파라서치는 DB 기준(예: 9301500)을 쓰므로, 비교 시 웹에서도 0930·1500으로 맞추면 거래 수·손익이 비슷해집니다.</div>
</div>
</div>
@@ -2360,7 +2344,7 @@ document.querySelectorAll('[data-tab]').forEach(el => {
set('bt_cooldown', s.cooldown_min);
if (s.slot_money) set('bt_slot', s.slot_money);
// 꼬리잡기 탭
// 꼬리잡기 탭 — DB(env_config)와 동일 값으로 채움 (실매·파라서치와 동일)
const t = d.tail || {};
set('tl_drop', t.drop);
set('tl_rec', t.rec);
@@ -2371,6 +2355,13 @@ document.querySelectorAll('[data-tab]').forEach(el => {
set('tl_scut', t.scut);
set('tl_cool', t.cool);
set('tl_rsi', t.rsi);
set('tl_ts', t.time_start);
set('tl_te', t.time_end);
set('tl_maxd', t.max_daily);
set('tl_rsi_period', t.rsi_period);
set('tl_tail_pct', t.tail_pct_min);
set('tl_max_rec_3m', t.max_rec_3m);
set('tl_high_chase', t.high_chase);
})
.catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });
})();
@@ -2598,16 +2589,25 @@ function saveTailConfig() {
min_drop_rate: parseFloat($('tl_drop').value),
min_recovery_ratio: parseFloat($('tl_rec').value),
tail_ratio_min: parseFloat($('tl_tail').value),
tail_pct_min: parseFloat($('tl_tail_pct').value),
max_rec_3m: parseFloat($('tl_max_rec_3m').value),
sl_pct: parseFloat($('tl_sl').value),
tp_pct: parseFloat($('tl_tp').value),
shoulder_cut_pct: parseFloat($('tl_scut').value),
shoulder_min_high: parseFloat($('tl_smin').value),
high_chase_thr: parseFloat($('tl_high_chase').value),
cooldown_min: parseFloat($('tl_cool').value),
rsi_threshold: parseFloat($('tl_rsi').value),
rsi_period: parseInt($('tl_rsi_period').value, 10),
time_start: parseInt($('tl_ts').value, 10),
time_end: parseInt($('tl_te').value, 10),
max_daily: parseInt($('tl_maxd').value, 10),
};
if (!confirm(`💾 꼬리잡기 봇(kis_short_ver2.py)에 아래 파라미터를 저장합니까?\n\n` +
`낙폭: ${body.min_drop_rate}%\n회복: ${body.min_recovery_ratio}%\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n` +
`꼬리/몸통: ${body.tail_ratio_min}\n어깨컷: ${body.shoulder_cut_pct}%\n` +
`\n⚠ STOP_LOSS_PCT는 음수로 저장됩니다 (봇 규칙). 봇이 실행 중이면 다음 루프부터 반영됩니다.`)) return;
if (!confirm(`💾 꼬리잡기 봇(실매·백테와 동일 DB)에 아래 파라미터를 저장합니까?\n\n` +
`낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` +
`손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` +
`RSI기간: ${body.rsi_period} | RSI과열: ${body.rsi_threshold} | 쿨다운: ${body.cooldown_min}분 | 매수시간: ${body.time_start}-${body.time_end} | 일일최대: ${body.max_daily}\n\n` +
`⚠️ STOP_LOSS_PCT는 음수로 저장됩니다. 봇 실행 중이면 다음 루프부터 반영됩니다.`)) return;
fetch('/api/backtest/tail/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
@@ -2726,10 +2726,14 @@ function runTailBacktest() {
min_drop_rate: $('tl_drop').value,
min_recovery_ratio: $('tl_rec').value,
tail_ratio_min: $('tl_tail').value,
tail_pct_min: $('tl_tail_pct').value,
max_rec_3m: $('tl_max_rec_3m').value,
sl_pct: $('tl_sl').value,
tp_pct: $('tl_tp').value,
shoulder_min_high: $('tl_smin').value,
shoulder_cut_pct: $('tl_scut').value,
high_chase_thr: $('tl_high_chase').value,
rsi_period: $('tl_rsi_period').value,
rsi_threshold: $('tl_rsi').value,
cooldown_min: $('tl_cool').value,
time_start: $('tl_ts').value,
@@ -2753,7 +2757,7 @@ function renderTailBacktest(d) {
$('tl_params_bar').innerHTML =
`낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` +
`RSI과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | 종목수 ${p.codes_analyzed}개`;
`RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회 | 종목수 ${p.codes_analyzed}개`;
$('tl_total').textContent = (s.total_trades||0) + '';
$('tl_winrate').textContent = (s.win_rate||0) + '%';

View File

@@ -237,6 +237,8 @@ ENV_CONFIG_KEYS = (
"KIS_WS_URL_REAL", "KIS_WS_URL_MOCK", "KIS_WS_MOCK_ENABLED",
# 재진입 쿨다운: 매도 후 같은 종목 재매수를 N초 동안 차단 (반복매매 루프 방지)
"REENTRY_COOLDOWN_SEC",
# 꼬리잡기/단타 매수 허용 시간대 (HHMM 정수, 930=09:30, 1500=15:00) — 백테스트·실매 공통
"TIME_START", "TIME_END",
# 매도 실패 백오프: 영업일 아님·장외 시간 오류 시 N초 동안 재시도 금지 (API 낭비·차단 방지)
"SELL_FAILURE_BACKOFF_SEC",
# ── 스캘핑봇(kis_scalping_ver1) 전용 키 ──────────────────────────────
@@ -267,6 +269,9 @@ ENV_CONFIG_KEYS = (
"SCALP_MIN_DROP_RATE",
# 봉부족 감지 시 재갭보정 최소 간격(초): 같은 종목 중복 REST 호출 방지 (기본 30초)
"SCALP_GAP_RETRY_SEC",
# 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (기본 600=10분)
# 백테스트와 동일 파라미터로 맞추려면 DB에 값 저장 후 봇/백테스트 모두 이 값 사용
"SCALP_COOLDOWN_SEC",
# 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이 이상 올라야 트레일링 활성화
# 0.5%면 수수료(~0.21%) 뺀 나머지만 이익 → 1.5 이상 권장
"SCALP_TRAIL_TRIGGER_PCT",

62
docs/CANDLE_FLOW.md Normal file
View File

@@ -0,0 +1,62 @@
# 캔들 수집·저장·매매 흐름 (스캘핑 vs 꼬리잡기)
## 1. 어떤 봉이 쌓이나? (전략별)
| 전략 | CandleAggregator timeframes | 주전략 봉 | 비고 |
|------|-----------------------------|-----------|------|
| **스캘핑** (kis_scalping_ver1/ver2) | [1, 3, 15, 60] | 1분봉 (SCALP_CANDLE_TIMEFRAME=1) | 4분봉 없음 |
| **꼬리잡기** (kis_short_ver2/ver3) | [3, 15, 60] | 3분봉 | 1분봉·4분봉 없음 |
- **4분봉**은 어디에도 사용하지 않음. 1분 / 3분 / 15분 / 60분만 집계·저장.
- 스캘핑은 1분봉 중심, 꼬리잡기는 3분봉만 매수 신호에 사용.
---
## 2. 메모리 vs DB — 전략별 PK 여부
- **ws_candles** 테이블은 **전략별 PK가 없음**.
`UNIQUE KEY (code, timeframe, candle_time)` 하나로
스캘핑이 넣은 1·3·15·60분봉과 꼬리잡기가 넣은 3·15·60분봉이 **같은 테이블**에 쌓임.
- **꼬리잡기**도 3분봉을 **메모리(CandleAggregator)** 에 먼저 쌓고,
봉이 **확정될 때마다** Queue → 백그라운드 스레드가 **ws_candles에 배치 INSERT**.
흐름 요약:
1. **트랙 1 (매매 두뇌)**
WebSocket 틱 → `on_tick()` → **RAM**에서만 OHLCV 갱신 →
매수/매도 판단은 `get_candles()` / `get_latest_confirmed()`**메모리만** 사용 (DB 대기 없음).
2. **트랙 2 (기록)**
**확정** 시점에만 dict를 Queue에 `put_nowait()`
`_db_writer` 스레드가 BATCH_SIZE(50)개 또는 FLUSH_INTERVAL(2초)마다 **ws_candles**에 배치 INSERT.
**봉 모을 때까지 기다리지 않고**, 확정되는 대로 메모리에 반영되고, DB는 그 뒤에 비동기로 저장.
---
## 3. 매수 시 캔들 조회 — 세 가지 분기 (DB / 메모리 / 키움·KIS REST)
꼬리잡기 매수 신호(`check_buy_signal_tail_catch`)에서 3분봉을 가져오는 순서:
| 순서 | 경로 | 설명 |
|------|------|------|
| 1 | **메모리** | `candle_agg.get_candles(code, 3, 50)` — 확정봉만, DB/네트워크 없음 |
| 2 | **DB** | 메모리에 부족하면 `db.get_ws_candles(code, 3, limit=50, confirmed_only=True)` |
| 3 | **REST** | 그래도 없으면 `_get_candles_df()` → 실패 시 `client.get_minute_chart(code, period="3", limit=20)` (KIS). 갭보정은 키움 우선, 없으면 KIS. |
- **매수가 확정봉일 때만 가능**하도록 되어 있음:
- 1·2번은 애초에 확정봉만 반환.
- 3번(REST)에서 가져온 DataFrame은 **마지막 행(진행봉) 제거** `df.iloc[:-1]` 후 신호 판단에 사용.
위 분기는 **kis_short_ver3.py** `check_buy_signal_tail_catch()` 안에 그대로 구현되어 있음 (메모리 → DB → REST 순).
---
## 4. “캔들 넣고 바로 매매” — 현재 동작
- 봉이 **확정되는 순간** 이미 **RAM(_confirmed)** 에 들어가므로,
매수 루프는 **DB 쓰기 완료를 기다리지 않고** 바로 그 데이터로 신호 판단.
- DB는 **그 뒤에** 배치로 저장되므로, “캔들 DB에 넣고 나서 매매”를 **기다릴 필요 없음**.
다만 **봇 기동 직후**에는 메모리에 봉이 없을 수 있음 →
`_fill_all_gaps()`에서 키움(우선) 또는 KIS REST로 과거 봉을 가져와 `fill_gap_from_rest()`
**RAM + Queue(→ DB)** 에 채움. 이 갭보정이 끝나면 해당 종목은 바로 매수 체크 가능.
정리: **봉 모을 때까지 기다리지 않고**, 확정봉이 메모리에 들어오는 즉시 매매 로직에 쓰이고, DB는 비동기로 쌓인다.

404
etf_backtest.py Normal file
View File

@@ -0,0 +1,404 @@
"""
etf_backtest.py — ETF 액티브 매매 백테스트 (RSI 기반 분할 매수 & 슈팅 익절)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
역할: 테마성 ETF (원자력, 전력망 등) 의 눌림목 분할 매수와 슈팅 익절 전략 검증
일봉 데이터로 백테스트 수행, 거래 내역은 즉시 저장 (Atomic Save)
전략 개요:
- 매수: RSI 35/30/25 이하에서 3 분할 매수 (30%/30%/40%)
- 매도: 수익률 +4% 이상 또는 RSI 70 이상에서 전량 익절
- 손절: 평단가 대비 -10% 에서 전량 손절 (리스크 관리)
- 유니버스: 사용자가 지정한 ETF 종목 (예: URA, ICLN, QCLN 등)
KIS API 준수:
- SafeRequest: API 429 에러 대비 재시도 로직
- 메신저 알림: 텔레그램 (HTML), 매터모스트 (Markdown) 분리
- Atomic Save: 매매 발생 시 즉시 JSON 저장 (재시작 안전성)
"""
import yfinance as yf
import pandas as pd
import numpy as np
import json
import os
import time
import random
import requests
from datetime import datetime
from typing import Dict, List, Optional
# 로깅 설정
import logging
logging.basicConfig(
format='[%(asctime)s] %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO,
)
logger = logging.getLogger("ETFBacktest")
# ==============================================================================
# [메신저 알림 기능]
# 텔레그램 (HTML) 및 매터모스트 (Markdown) 분리 구현
# ==============================================================================
def msg_tg(token: str, chat_id: str, trade_info: Dict):
"""
텔레그램 메시지 전송 (HTML 방식)
이미지 태그 (URL 링크) 방식을 우선하여 속도 저하를 방지합니다.
"""
url = f"https://api.telegram.org/bot{token}/sendMessage"
# 텔레그램 메시지 상세 구성
text = (
f"<b>🔔 [ETF 액티브 봇] {trade_info['type']}</b>\n\n"
f"▪️ <b>종목명:</b> {trade_info['ticker']}\n"
f"▪️ <b>체결일자:</b> {trade_info['date']}\n"
f"▪️ <b>체결단가:</b> {trade_info['price']:,.2f}\n"
f"▪️ <b>체결수량:</b> {trade_info['qty']:,}\n"
f"▪️ <b>현재 RSI:</b> {trade_info['rsi']:.1f}\n"
)
if "profit_loss" in trade_info:
text += f"▪️ <b>실현손익:</b> {trade_info['profit_loss']:,.0f}\n"
text += f"▪️ <b>현재자산 (현금):</b> {trade_info['capital']:,.0f}\n"
text += f"\n📊 <a href='https://finance.yahoo.com/quote/{trade_info['ticker']}/chart'>[야후 파이낸스 차트 확인]</a>"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": False
}
try:
requests.post(url, data=payload, timeout=5)
except Exception as e:
logger.debug(f"[텔레그램 전송 에러] {e}")
def msg_mm(webhook_url: str, trade_info: Dict):
"""
매터모스트 메시지 전송 (Markdown 방식)
"""
text = (
f"### 🔔 [ETF 액티브 봇] {trade_info['type']}\n"
f"- **종목명:** {trade_info['ticker']}\n"
f"- **체결일자:** {trade_info['date']}\n"
f"- **체결단가:** {trade_info['price']:,.2f}\n"
f"- **체결수량:** {trade_info['qty']:,}\n"
f"- **현재 RSI:** {trade_info['rsi']:.1f}\n"
)
if "profit_loss" in trade_info:
text += f"- **실현손익:** {trade_info['profit_loss']:,.0f}\n"
text += f"- **현재자산 (현금):** {trade_info['capital']:,.0f}\n"
text += f"\n📊 [야후 파이낸스 차트 확인](https://finance.yahoo.com/quote/{trade_info['ticker']}/chart)"
payload = {
"text": text
}
try:
requests.post(webhook_url, json=payload, timeout=5)
except Exception as e:
logger.debug(f"[매터모스트 전송 에러] {e}")
# ==============================================================================
# [네트워크/API 안전성 관리]
# 모든 API 요청은 SafeRequest 클래스를 거쳐 429 에러 발생 시 재시도합니다.
# ==============================================================================
class SafeRequest:
def __init__(self, max_retries: int = 3, delay: float = 2.0):
self.max_retries = max_retries
self.delay = delay
def fetch_data(self, ticker_symbol: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""yfinance 데이터 다운로드 (재시도 로직 포함)"""
for attempt in range(self.max_retries):
try:
logger.info(f"[{datetime.now().strftime('%H:%M:%S')}] {ticker_symbol} 데이터 요청 중... (시도: {attempt + 1}/{self.max_retries})")
data = yf.download(ticker_symbol, start=start_date, end=end_date, progress=False)
if not data.empty:
return data
except Exception as e:
logger.warning(f"[에러 발생] 데이터 요청 실패: {e}")
time.sleep(self.delay)
raise Exception("API 요청 한도 초과 또는 네트워크 오류가 지속됩니다.")
# ==============================================================================
# [핵심 매매 로직] RSI 기반 3 분할 매수 및 슈팅 익절 알고리즘
# ==============================================================================
class ETFActiveBacktester:
"""
ETF 액티브 매매 백테스터
사용법:
tester = ETFActiveBacktester(
ticker="URA",
start_date="2023-01-01",
end_date="2024-01-01",
initial_capital=10000000
)
tester.run()
"""
def __init__(self, ticker: str, start_date: str, end_date: str, initial_capital: float = 10000000):
self.ticker = ticker
self.start_date = start_date
self.end_date = end_date
# 기본 자본금 및 수수료/슬리피지 설정
self.capital = initial_capital
self.initial_capital = initial_capital
self.fee_rate = 0.00015 # 수수료 0.015%
self.slippage_rate = 0.0005 # 슬리피지 0.05%
# 포트폴리오 상태
self.position = 0 # 보유 수량
self.total_invested = 0.0 # 총 투자 금액 (평단가 계산용)
self.buy_step = 0 # 현재 몇 차 매수까지 진행되었는지 (0~3)
# 로깅 설정 (Atomic Save)
self.trade_log_file = f"{self.ticker}_etf_trade_history.json"
if os.path.exists(self.trade_log_file):
with open(self.trade_log_file, "r", encoding="utf-8") as f:
try:
self.trade_history = json.load(f)
except json.JSONDecodeError:
self.trade_history = []
else:
self.trade_history = []
def save_trade_immediately(self, trade_record: Dict):
"""
[Atomic Save] 데이터 파일은 프로그램 종료 시점이 아니라 이벤트 즉시 저장합니다.
기존 데이터를 보존하며 추가합니다. (재시작 안전성)
"""
self.trade_history.append(trade_record)
with open(self.trade_log_file, "w", encoding="utf-8") as f:
json.dump(self.trade_history, f, indent=4, ensure_ascii=False)
logger.info(f"✅ 거래 로그 저장: {trade_record['date']} | {trade_record['type']}")
def calculate_rsi(self, df: pd.DataFrame, period: int = 14) -> pd.DataFrame:
"""
RSI(상대강도지수) 계산 함수. 초보자도 이해하기 쉬운 단순 이동평균 방식 사용.
Args:
df: 일봉 데이터 (Open, High, Low, Close 컬럼 필요)
period: RSI 계산 기간 (기본 14 일)
Returns:
RSI 컬럼이 추가된 DataFrame
"""
delta = df['Close'].diff()
gain = (delta.where(delta > 0, 0)).ewm(alpha=1/period, adjust=False).mean()
loss = (-delta.where(delta < 0, 0)).ewm(alpha=1/period, adjust=False).mean()
rs = gain / loss
df['RSI'] = 100 - (100 / (1 + rs))
return df
def run(self, enable_notifications: bool = False, tg_token: str = "", tg_chat_id: str = "", mm_webhook: str = ""):
"""
백테스트 실행
Args:
enable_notifications: 메신저 알림 활성화 여부
tg_token: 텔레그램 봇 토큰
tg_chat_id: 텔레그램 채팅 ID
mm_webhook: 매터모스트 웹훅 URL
"""
api = SafeRequest()
df = api.fetch_data(self.ticker, self.start_date, self.end_date)
if df.empty:
logger.error(f"{self.ticker} 데이터를 가져올 수 없습니다.")
return
# DataFrame MultiIndex 구조 평탄화 (yfinance 최신버전 호환)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
df = self.calculate_rsi(df)
logger.info(f"\n=== [{self.ticker}] ETF 액티브 백테스트 시작 ===")
logger.info(f"초기 자본금: {self.capital:,.0f}")
logger.info(f"테스트 기간: {self.start_date} ~ {self.end_date}\n")
for index, row in df.iterrows():
# 서버 부하 방지를 위해 일반 루프에 1~3 초 랜덤 딜레이 적용
time.sleep(random.uniform(0.5, 1.5))
if pd.isna(row.get('RSI')):
continue # RSI 계산을 위한 초기 기간 (14 일) 패스
current_date = index.strftime('%Y-%m-%d')
close_price = float(row['Close'])
rsi = float(row['RSI'])
# 평균 단가 계산
avg_price = (self.total_invested / self.position) if self.position > 0 else 0.0
# [1. 매도 로직] : 보유 수량이 있을 때 (익절 또는 손절)
if self.position > 0:
profit_rate = (close_price - avg_price) / avg_price if avg_price > 0 else 0
# 익절 조건: 4% 이상 수익이 났거나, RSI 가 70(과열) 을 넘을 때 전량 매도
if profit_rate >= 0.04 or rsi >= 70:
sell_price = close_price * (1 - self.fee_rate - self.slippage_rate)
revenue = self.position * sell_price
profit_loss = revenue - self.total_invested
self.capital += revenue
record = {
"ticker": self.ticker,
"date": current_date,
"type": "SELL (슈팅 익절)",
"price": round(sell_price, 2),
"qty": self.position,
"rsi": round(rsi, 2),
"capital": round(self.capital, 2),
"profit_loss": round(profit_loss, 2)
}
self.save_trade_immediately(record)
# 메신저 알림
if enable_notifications:
msg_tg(tg_token, tg_chat_id, record)
msg_mm(mm_webhook, record)
# 상태 초기화
self.position = 0
self.total_invested = 0.0
self.buy_step = 0
continue
# 손절 조건: 평단가 대비 -10% 이하로 떨어지면 리스크 관리 차원에서 손절
elif profit_rate <= -0.10:
sell_price = close_price * (1 - self.fee_rate - self.slippage_rate)
revenue = self.position * sell_price
profit_loss = revenue - self.total_invested
self.capital += revenue
record = {
"ticker": self.ticker,
"date": current_date,
"type": "SELL (안전 손절)",
"price": round(sell_price, 2),
"qty": self.position,
"rsi": round(rsi, 2),
"capital": round(self.capital, 2),
"profit_loss": round(profit_loss, 2)
}
self.save_trade_immediately(record)
# 메신저 알림
if enable_notifications:
msg_tg(tg_token, tg_chat_id, record)
msg_mm(mm_webhook, record)
# 상태 초기화
self.position = 0
self.total_invested = 0.0
self.buy_step = 0
continue
# [2. 매수 로직] : RSI 에 따른 3 분할 매수
# 자금 배분: 1 차 30%, 2 차 30%, 3 차 40%
actual_buy_price = close_price * (1 + self.fee_rate + self.slippage_rate)
buy_amount = 0
buy_type = ""
if self.buy_step == 0 and rsi < 35:
# 1 차 매수 (RSI 35 미만)
buy_amount = self.initial_capital * 0.3
buy_type = "BUY (1 차 진입)"
self.buy_step = 1
elif self.buy_step == 1 and rsi < 30:
# 2 차 매수 (RSI 30 미만)
buy_amount = self.initial_capital * 0.3
buy_type = "BUY (2 차 물타기)"
self.buy_step = 2
elif self.buy_step == 2 and rsi < 25:
# 3 차 매수 (RSI 25 미만)
buy_amount = self.initial_capital * 0.4 # 남은 현금 전부
buy_type = "BUY (3 차 풀매수)"
self.buy_step = 3
# 매수 실행
if buy_amount > 0 and self.capital >= buy_amount:
quantity = int(buy_amount // actual_buy_price)
if quantity > 0:
invest_cost = quantity * actual_buy_price
self.position += quantity
self.total_invested += invest_cost
self.capital -= invest_cost
record = {
"ticker": self.ticker,
"date": current_date,
"type": buy_type,
"price": round(actual_buy_price, 2),
"qty": quantity,
"rsi": round(rsi, 2),
"capital": round(self.capital, 2)
}
self.save_trade_immediately(record)
# 메신저 알림
if enable_notifications:
msg_tg(tg_token, tg_chat_id, record)
msg_mm(mm_webhook, record)
# 백테스트 종료
logger.info(f"\n=== 백테스트 종료 ===")
avg_price = (self.total_invested / self.position) if self.position > 0 else 0.0
final_assets = self.capital + (self.position * avg_price)
# 성과 요약
total_return = ((final_assets - self.initial_capital) / self.initial_capital) * 100
logger.info(f"📊 [{self.ticker}] 백테스트 결과")
logger.info(f" 초기 자본금: {self.initial_capital:,.0f}")
logger.info(f" 최종 자산: {final_assets:,.0f}")
logger.info(f" 총 수익률: {total_return:.2f}%")
logger.info(f" 총 매매 횟수: {len([t for t in self.trade_history if 'SELL' in t['type']])}")
# 메신저 알림 (최종 결과)
if enable_notifications:
final_record = {
"ticker": self.ticker,
"date": datetime.now().strftime('%Y-%m-%d'),
"type": "백테스트 완료",
"price": 0,
"qty": 0,
"rsi": 0,
"capital": round(final_assets, 2),
"profit_loss": round(final_assets - self.initial_capital, 2)
}
msg_tg(tg_token, tg_chat_id, final_record)
msg_mm(mm_webhook, final_record)
# ==============================================================================
# [실행부]
# ==============================================================================
if __name__ == "__main__":
# 예시: URA (글로벌 X 우라늄 ETF) 로 테스트
# 한국 ETF 는 '069500.KS' (KODEX 200) 형태
tester = ETFActiveBacktester(
ticker="URA",
start_date="2023-01-01",
end_date="2024-12-31",
initial_capital=10000000 # 1 천만원
)
# 백테스트 실행 (메신저 알림은 주석 처리)
tester.run(
enable_notifications=False, # True 로 설정 후 토큰 입력하면 알림 발송
# tg_token="YOUR_BOT_TOKEN",
# tg_chat_id="YOUR_CHAT_ID",
# mm_webhook="YOUR_WEBHOOK_URL"
)

887
etf_ver1.py Normal file
View File

@@ -0,0 +1,887 @@
"""
etf_ver1.py — ETF 액티브 매매 봇 (RSI 기반 분할 매수 & 슈팅 익절)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
역할: 테마성 ETF (원자력, 전력망 등) 의 눌림목 분할 매수와 슈팅 익절 전략
한투 API 연동, WebSocket 실시간 가격 수신, DB 기반 상태 관리
전략 개요:
- 매수: RSI 35/30/25 이하에서 3 분할 매수 (30%/30%/40%)
- 매도: 수익률 +4% 이상 또는 RSI 70 이상에서 전량 익절
- 손절: 평단가 대비 -10% 에서 전량 손절 (리스크 관리)
- 유니버스: DB(env) 에서 지정한 ETF 종목 목록
KIS API 준수:
- SafeRequest: HTTP 429 에러 대비 재시도 로직
- WebSocket: H0STCNT0 실시간 체결가 수신 (kis_ws.py)
- 메신저 알림: 텔레그램 (HTML), 매터모스트 (Markdown) 분리
- Atomic Save: 매매 발생 시 DB 즉시 저장 (재시작 안전성)
"""
import os
import json
import time
import random
import logging
import datetime
from pathlib import Path
from typing import List, Dict, Optional
import pandas as pd
import requests
from database import TradeDB, ENV_CONFIG_KEYS
# WebSocket 실시간 체결가 캐시
try:
from kis_ws import KISWebSocketPriceCache
_KIS_WS_AVAILABLE = True
except ImportError:
_KIS_WS_AVAILABLE = False
# 로깅 설정
logging.basicConfig(
format='[%(asctime)s] %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO,
)
logger = logging.getLogger("ETFActiveBot")
# DB 초기화
SCRIPT_DIR = Path(__file__).resolve().parent
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
# ==============================================================================
# [환경 변수 로드] DB 우선 (하드코딩 금지)
# ==============================================================================
def get_env_from_db(key, default=""):
"""DB 에서 환경변수 읽기"""
env_data = db.get_latest_env()
if env_data and env_data.get("snapshot"):
return env_data["snapshot"].get(key, default)
return default
def get_env_float(key, default):
"""환경변수를 float 로 변환 (DB 우선)"""
value = get_env_from_db(key, str(default))
if isinstance(value, str) and "#" in value:
value = value.split("#")[0].strip()
try:
return float(value) if value else default
except (ValueError, TypeError):
return default
def get_env_int(key, default):
"""환경변수를 int 로 변환 (DB 우선)"""
value = get_env_from_db(key, str(default))
if isinstance(value, str) and "#" in value:
value = value.split("#")[0].strip()
try:
return int(value) if value else default
except (ValueError, TypeError):
return default
def get_env_bool(key, default=False):
"""환경변수를 bool 로 변환 (DB 우선)"""
value = get_env_from_db(key, str(default)).lower()
return value in ("true", "1", "yes")
# ==============================================================================
# [메신저 알림] 텔레그램 & 매터모스트
# ==============================================================================
def msg_tg(token: str, chat_id: str, message: str):
"""텔레그램 메시지 전송 (HTML 방식)"""
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML"
}
try:
requests.post(url, data=payload, timeout=5)
except Exception as e:
logger.debug(f"[텔레그램 전송 에러] {e}")
def msg_mm(webhook_url: str, message: str):
"""매터모스트 메시지 전송 (Markdown 방식)"""
payload = {"text": message}
try:
requests.post(webhook_url, json=payload, timeout=5)
except Exception as e:
logger.debug(f"[매터모스트 전송 에러] {e}")
# ==============================================================================
# [한투 API 클라이언트] REST API 안전성 관리
# ==============================================================================
class KISAPI:
"""
한국투자증권 REST API 클라이언트
- 429 에러 대비 재시도 로직
- 모의투자/실전투자 분리
"""
def __init__(self):
self.mock = get_env_bool("KIS_MOCK", True)
self.app_key = get_env_from_db("KIS_APP_KEY_MOCK" if self.mock else "KIS_APP_KEY_REAL", "")
self.app_secret = get_env_from_db("KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_SECRET_REAL", "")
self.base_url = (
"https://openapivts.koreainvestment.com:29443"
if self.mock
else "https://openapi.koreainvestment.com:9443"
)
# 계좌 정보
self.account_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK" if self.mock else "KIS_ACCOUNT_NO_REAL", "")
self.account_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK" if self.mock else "KIS_ACCOUNT_CODE_REAL", "")
# 액세스 토큰
self.access_token = None
self.token_expiry = 0
# WebSocket 캐시 (ETF 는 REST 만 사용, 단타용과 공유 안함)
# ETF 는 일봉 RSI 기반, 1~3 분 주기 체크로 REST 로 충분
self.ws_cache = None
# if _KIS_WS_AVAILABLE:
# try:
# self.ws_cache = KISWebSocketPriceCache(
# app_key=self.app_key,
# app_secret=self.app_secret,
# is_mock=self.mock
# )
# if self.ws_cache.start(force_cleanup=True):
# logger.info("✅ WebSocket 활성 (force_cleanup=True, 비정상 종료 대비)")
# else:
# logger.info(" WebSocket 비활성 (모의 or 키 미설정) → REST fallback")
# except Exception as e:
# logger.warning(f"⚠️ WebSocket 초기화 예외: {e}")
# self.ws_cache = None
def get_access_token(self) -> str:
"""액세스 토큰 발급 (유효기간 23 시간)"""
now = time.time()
if self.access_token and now < self.token_expiry:
return self.access_token
try:
url = f"{self.base_url}/oauth2/token"
data = {
"grant_type": "client_credentials",
"appkey": self.app_key,
"secretkey": self.app_secret,
}
response = requests.post(url, json=data, timeout=10)
data = response.json()
self.access_token = data.get("access_token")
self.token_expiry = now + 82800 # 23 시간
logger.info(f"✅ 액세스 토큰 발급 완료 (앞 8 자: {self.access_token[:8]}...)")
return self.access_token
except Exception as e:
logger.error(f"❌ 토큰 발급 실패: {e}")
raise
def request(self, method: str, url: str, headers: dict = None, data: dict = None, params: dict = None, max_retries: int = 3):
"""
HTTP 요청 (429 에러 재시도 로직 포함)
Args:
method: HTTP 메서드 (GET, POST 등)
url: 요청 URL
headers: HTTP 헤더
data: 요청 바디
params: 쿼리 파라미터
max_retries: 최대 재시도 횟수
"""
for attempt in range(max_retries):
try:
response = requests.request(
method=method,
url=url,
headers=headers,
json=data,
params=params,
timeout=10
)
# HTTP 429 (Too Many Requests)
if response.status_code == 429:
wait_time = (attempt + 1) * 2
logger.warning(f"⚠️ API 제한 (429) → {wait_time}초 대기 후 재시도...")
time.sleep(wait_time)
continue
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
logger.error(f"❌ HTTP 에러: {e}")
if attempt >= max_retries - 1:
raise
time.sleep(2)
except Exception as e:
logger.error(f"❌ 요청 실패: {e}")
if attempt >= max_retries - 1:
raise
time.sleep(2)
raise Exception("API 요청 최대 재시도 횟수 초과")
def get_stock_price(self, code: str) -> Optional[float]:
"""
주식/ETF 현재가 조회 (WebSocket 우선, fallback REST)
Args:
code: 종목코드 (6 자리)
Returns:
현재가 (float) 또는 None
"""
# WebSocket 캐시 확인
if self.ws_cache and self.ws_cache.is_active:
ws_data = self.ws_cache.get_price(code, max_age_sec=5.0)
if ws_data:
price = float(ws_data.get("stck_prpr", 0))
if price > 0:
logger.debug(f"📡 WebSocket 가격 수신: {code}{price:,.0f}")
return price
# REST fallback
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "FHKST01010100",
}
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
}
result = self.request("GET", url, headers=headers, params=params)
output = result.get("output", {})
price = float(output.get("stck_prpr", 0))
if price > 0:
logger.debug(f"📡 REST 가격 수신: {code}{price:,.0f}")
return price
except Exception as e:
logger.error(f"❌ 현재가 조회 실패 ({code}): {e}")
return None
def get_account_balance(self) -> Dict:
"""
계좌 잔고 조회
Returns:
{'cash': float, 'total_asset': float, 'holdings': {code: {'qty': int, 'avg_price': float}}}
"""
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "TTDO84013", # 모의투자 잔고조회
}
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "01",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNAG_AMT_AUTO_INPT_STPL_YN": "N",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
result = self.request("GET", url, headers=headers, params=params)
output = result.get("output", {})
cash = float(output.get("prvs_rcdl_excc_amt", 0))
total_asset = float(output.get("tot_asst_amt", 0))
holdings = {}
for item in output.get("fdtl_invest_item_lst", []):
code = item.get("pdno", "")
qty = int(item.get("hldg_qty", 0))
avg_price = float(item.get("pchs_amt", 0)) / qty if qty > 0 else 0
if code and qty > 0:
holdings[code] = {'qty': qty, 'avg_price': avg_price}
return {'cash': cash, 'total_asset': total_asset, 'holdings': holdings}
except Exception as e:
logger.error(f"❌ 잔고 조회 실패: {e}")
return {'cash': 0, 'total_asset': 0, 'holdings': {}}
def buy(self, code: str, qty: int, price: Optional[int] = None) -> bool:
"""
주식/ETF 매수 주문
Args:
code: 종목코드
qty: 매수 수량
price: 매수가 (None 이면 시장가)
Returns:
성공 시 True
"""
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "VTTC0802U", # 모의투자 현금매수
}
# 시장가 주문 (ETF 는 호가단위 없음)
if price is None:
ord_pric = "0"
ord_dvsn = "01" # 시장가
else:
ord_pric = str(price)
ord_dvsn = "00" # 지정가
data = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"PDNO": code,
"ORD_DVSN": ord_dvsn,
"ORD_QTY": str(qty),
"ORD_UNPR": ord_pric,
}
result = self.request("POST", url, headers=headers, data=data)
if result.get("rt_cd") == "0":
logger.info(f"✅ 매수 주문 성공: {code} {qty}")
return True
else:
logger.error(f"❌ 매수 주문 실패: {result.get('msg1', '')}")
return False
except Exception as e:
logger.error(f"❌ 매수 주문 예외: {e}")
return False
def sell(self, code: str, qty: int, price: Optional[int] = None) -> bool:
"""
주식/ETF 매도 주문
Args:
code: 종목코드
qty: 매도 수량
price: 매도가 (None 이면 시장가)
Returns:
성공 시 True
"""
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "VTTC0801U", # 모의투자 현금매도
}
if price is None:
ord_pric = "0"
ord_dvsn = "01" # 시장가
else:
ord_pric = str(price)
ord_dvsn = "00" # 지정가
data = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"PDNO": code,
"ORD_DVSN": ord_dvsn,
"ORD_QTY": str(qty),
"ORD_UNPR": ord_pric,
}
result = self.request("POST", url, headers=headers, data=data)
if result.get("rt_cd") == "0":
logger.info(f"✅ 매도 주문 성공: {code} {qty}")
return True
else:
logger.error(f"❌ 매도 주문 실패: {result.get('msg1', '')}")
return False
except Exception as e:
logger.error(f"❌ 매도 주문 예외: {e}")
return False
# ==============================================================================
# [핵심 매매 로직] ETF 액티브 (RSI 기반 분할 매수)
# ==============================================================================
class ETFActiveTrader:
"""
ETF 액티브 매매 트레이더
사용법:
trader = ETFActiveTrader()
trader.run()
"""
def __init__(self):
self.api = KISAPI()
self.db = db
# ETF 유니버스 (DB 에서 로드)
self.etf_universe = self._load_etf_universe()
# 활성 트레이딩 상태 (DB 에서 복원)
self.active_trades = self.db.get_active_trades(strategy_prefix="ETF")
# 메신저 설정
self.mm_token = get_env_from_db("MM_BOT_TOKEN_", "")
self.mm_channel = get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock")
self.mm_server = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org")
logger.info(f"✅ ETF 액티브 트레이더 초기화 완료")
logger.info(f" 유니버스: {len(self.etf_universe)}개 종목")
logger.info(f" 활성 트레이딩: {len(self.active_trades)}")
def _load_etf_universe(self) -> List[str]:
"""
DB 에서 ETF 유니버스 로드
Returns:
ETF 종목코드 목록
"""
# 예시: env 에서 "ETF_UNIVERSE" 키로 콤마 구분된 종목코드 로드
# 예: "069500,114800,280670" (KODEX 200, KODEX 은행, KODEX 원자력)
etf_list_str = get_env_from_db("ETF_UNIVERSE", "069500,114800,280670")
etf_codes = [code.strip() for code in etf_list_str.split(",") if code.strip()]
if not etf_codes:
logger.warning("⚠️ ETF 유니버스가 비어있습니다. 기본값을 사용합니다.")
etf_codes = ["069500", "114800", "280670"] # KODEX 200, 은행, 원자력
logger.info(f"📊 ETF 유니버스 로드: {etf_codes}")
return etf_codes
def calculate_rsi(self, prices: List[float], period: int = 14) -> Optional[float]:
"""
RSI 계산 (단순 이동평균 방식)
Args:
prices: 종가 목록 (최신순)
period: RSI 기간
Returns:
RSI 값 또는 None
"""
if len(prices) < period + 1:
return None
# 최근 N 일 사용
prices = prices[:period+1][::-1] # 오름차순 정렬
gains = []
losses = []
for i in range(1, len(prices)):
delta = prices[i] - prices[i-1]
if delta > 0:
gains.append(delta)
losses.append(0)
else:
gains.append(0)
losses.append(abs(delta))
avg_gain = sum(gains) / period
avg_loss = sum(losses) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
def get_daily_prices(self, code: str, days: int = 30) -> List[float]:
"""
일봉 종가 조회 (최근 N 일)
Args:
code: 종목코드
days: 일수
Returns:
종가 목록 (최신순)
"""
try:
# 한투 API 일봉 조회
token = self.api.get_access_token()
url = f"{self.api.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.api.app_key,
"secretkey": self.api.app_secret,
"tr_id": "FHKST03010100",
}
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
"FID_INPUT_DATE_1": "",
"FID_INPUT_DATE_2": "",
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "0",
}
result = self.api.request("GET", url, headers=headers, params=params)
output = result.get("output", {})
data_list = output.get("output2", [])
prices = []
for item in data_list[:days]:
close = float(item.get("stck_clpr", 0))
if close > 0:
prices.append(close)
return prices
except Exception as e:
logger.error(f"❌ 일봉 조회 실패 ({code}): {e}")
return []
def send_notification(self, title: str, content: str):
"""메신저 알림 발송"""
# 텔레그램
if self.mm_token:
tg_msg = f"<b>[ETF 액티브]</b> {title}\n\n{content}"
msg_tg(self.mm_token, self.mm_channel, tg_msg)
# 매터모스트
mm_msg = f"**[ETF 액티브]** {title}\n\n{content}"
msg_mm(f"{self.mm_server}/hooks/{self.mm_token}", mm_msg)
def run(self):
"""메인 루프 (최적화 + 매수/매도 동기화)"""
logger.info("\n=== [ETF 액티브 매매] 메인 루프 시작 ===\n")
# [최적화] 일봉 데이터는 10 분마다 갱신 (캐시)
daily_price_cache = {}
last_price_cache_update = 0
while True:
try:
now = time.time()
# [최적화 1] 일봉 데이터 10 분마다 갱신
if now - last_price_cache_update > 600: # 600 초 = 10 분
for code in self.etf_universe + list(self.active_trades.keys()):
prices = self.get_daily_prices(code, days=30)
if prices:
daily_price_cache[code] = prices
last_price_cache_update = now
logger.debug(f"📊 일봉 데이터 갱신: {len(daily_price_cache)}종목")
# 1. 활성 트레이딩 상태 업데이트 (매도 판단)
for code in list(self.active_trades.keys()):
self._update_active_trade(code, daily_price_cache)
# 2. 매수 기회 탐색 (RSI 35 이하)
for code in self.etf_universe:
if code not in self.active_trades:
self._check_buy_opportunity(code, daily_price_cache)
# 3. 서버 부하 방지 (1~3 분 대기)
sleep_time = random.uniform(60, 180)
logger.info(f"💤 {sleep_time/60:.1f}분 대기...")
time.sleep(sleep_time)
except KeyboardInterrupt:
logger.info("🛑 사용자 요청으로 매매 중단")
break
except Exception as e:
logger.error(f"❌ 메인 루프 예외: {e}")
time.sleep(60)
def _update_active_trade(self, code: str, daily_price_cache: Dict = None):
"""
활성 트레이딩 상태 업데이트 (매도 판단)
Args:
code: 종목코드
daily_price_cache: 일봉 데이터 캐시 (최적화용)
"""
trade = self.active_trades.get(code)
if not trade:
return
# 현재가 조회 (WebSocket 또는 REST)
current_price = self.api.get_stock_price(code)
if not current_price:
return
# 평균 단가
avg_price = trade.get('avg_buy_price', 0)
if avg_price <= 0:
return
# 수익률
profit_rate = (current_price - avg_price) / avg_price
# 일봉으로 RSI 계산 (캐시 사용)
prices = daily_price_cache.get(code) if daily_price_cache else None
if not prices:
prices = self.get_daily_prices(code, days=30)
rsi = self.calculate_rsi(prices) if prices else None
logger.info(f"📊 {code} 현재: {current_price:,.0f}원 | 수익률: {profit_rate*100:.2f}% | RSI: {rsi}")
# [매도 판단]
sell_reason = None
# 1. 익절: +4% 이상 또는 RSI 70 이상
if profit_rate >= 0.04:
sell_reason = "슈팅 익절 (+4%)"
elif rsi and rsi >= 70:
sell_reason = f"RSI 과열 ({rsi:.1f})"
# 2. 손절: -10% 이하
elif profit_rate <= -0.10:
sell_reason = "안전 손절 (-10%)"
# 매도 실행
if sell_reason:
self._execute_sell(code, trade, current_price, sell_reason)
def _execute_sell(self, code: str, trade: Dict, current_price: float, reason: str):
"""
매도 실행
Args:
code: 종목코드
trade: 트레이드 정보
current_price: 현재가
reason: 매도 사유
"""
qty = trade.get('qty', 0)
avg_price = trade.get('avg_buy_price', 0)
if qty <= 0:
logger.warning(f"⚠️ 매도 수량 없음: {code}")
return
# 시장가 매도
success = self.api.sell(code, qty)
if success:
# 손익 계산
profit_loss = (current_price - avg_price) * qty
# DB 에서 제거
self.db.close_trade(
code=code,
sell_price=current_price,
sell_reason=reason,
size_class=trade.get('size_class')
)
# 활성 트레이딩에서 제거
del self.active_trades[code]
logger.info(f"✅ [{code}] 매도 완료: {qty}주 @ {current_price:,.0f}원 | {reason} | 손익: {profit_loss:,.0f}")
# 메신저 알림
self.send_notification(
f"매도 완료: {trade.get('name', code)}",
f"▪️ 종목: {code}\n"
f"▪️ 수량: {qty}\n"
f"▪️ 가격: {current_price:,.0f}\n"
f"▪️ 사유: {reason}\n"
f"▪️ 손익: {profit_loss:,.0f}"
)
def _check_buy_opportunity(self, code: str, daily_price_cache: Dict = None):
"""
매수 기회 탐색 (RSI 기반 3 분할 매수)
Args:
code: 종목코드
daily_price_cache: 일봉 데이터 캐시 (최적화용)
"""
# 이미 보유 중인 종목은 추가 매수만 고려
is_additional_buy = code in self.active_trades
# 일봉으로 RSI 계산 (캐시 사용)
prices = daily_price_cache.get(code) if daily_price_cache else None
if not prices:
prices = self.get_daily_prices(code, days=30)
if not prices or len(prices) < 15:
return
rsi = self.calculate_rsi(prices)
if rsi is None:
return
current_price = self.api.get_stock_price(code)
if not current_price:
return
logger.debug(f"🔍 {code} 확인: RSI={rsi:.1f} | 가격={current_price:,.0f}원 | 보유여부: {is_additional_buy}")
# [중요] 매수 직전 계좌 잔고 확인 (즉시 동기화)
# 매수/매도 시마다 계좌와 동기화되므로 여기서 반드시 확인
balance = self.api.get_account_balance()
cash = balance.get('cash', 0)
if cash < current_price * 10: # 최소 10 주 매수 가능 현금
logger.warning(f"⚠️ 현금 부족: {cash:,.0f}")
return
# [1 차 매수] RSI 35 이하 && 보유 없음
if not is_additional_buy and rsi < 35:
target_amount = cash * 0.3 # 1 차 매수 금액 (자본금의 30%)
target_qty = int(target_amount / current_price)
if target_qty < 10:
logger.warning(f"⚠️ 매수 수량 부족: {target_qty}")
return
# 시장가 매수
success = self.api.buy(code, target_qty)
if success:
# DB 에 등록 (평단가 = 현재가)
trade_data = {
'code': code,
'name': f"ETF-{code}",
'strategy': 'ETF_ACTIVE',
'avg_buy_price': current_price, # 1 차는 현재가가 평단가
'current_price': current_price,
'target_qty': target_qty * 3, # 3 분할 목표
'current_qty': target_qty,
'total_invested': current_price * target_qty,
'status': 'BUYING',
'size_class': 'MID',
'rsi': rsi,
}
self.db.upsert_trade(trade_data)
self.active_trades[code] = trade_data
logger.info(f"✅ [{code}] 1 차 매수: {target_qty}주 @ {current_price:,.0f}원 | RSI={rsi:.1f}")
# 메신저 알림
self.send_notification(
f"1 차 매수: {trade_data['name']}",
f"▪️ 종목: {code}\n"
f"▪️ 수량: {target_qty}\n"
f"▪️ 가격: {current_price:,.0f}\n"
f"▪️ RSI: {rsi:.1f}\n"
f"▪️ 현금: {cash:,.0f}"
)
# [2 차, 3 차 매수] 보유 중인 종목 && RSI 추가 하락
elif is_additional_buy:
trade = self.active_trades[code]
current_qty = trade.get('current_qty', 0)
target_qty = trade.get('target_qty', 0)
avg_price = trade.get('avg_buy_price', 0)
# 2 차 매수: RSI 30 이하 && 1 차 수량보다 적을 때
if rsi < 30 and current_qty < target_qty * 0.6:
add_amount = cash * 0.3 # 2 차 매수 금액 (자본금의 30%)
add_qty = int(add_amount / current_price)
if add_qty < 10:
return
success = self.api.buy(code, add_qty)
if success:
# 평단가 재계산: (기존 투자금 + 추가 투자금) / (기존 수량 + 추가 수량)
new_total_invested = (avg_price * current_qty) + (current_price * add_qty)
new_total_qty = current_qty + add_qty
new_avg_price = new_total_invested / new_total_qty if new_total_qty > 0 else 0
# DB 업데이트
trade['avg_buy_price'] = new_avg_price
trade['current_qty'] = new_total_qty
trade['total_invested'] = new_total_invested
trade['rsi'] = rsi
self.db.upsert_trade(trade)
self.active_trades[code] = trade
logger.info(f"✅ [{code}] 2 차 매수: {add_qty}주 @ {current_price:,.0f}원 | RSI={rsi:.1f} | 평단가: {new_avg_price:,.0f}")
# 메신저 알림
self.send_notification(
f"2 차 매수 (물타기): {trade['name']}",
f"▪️ 종목: {code}\n"
f"▪️ 추가수량: {add_qty}\n"
f"▪️ 총수량: {new_total_qty}\n"
f"▪️ 현재가: {current_price:,.0f}\n"
f"▪️ 평단가: {new_avg_price:,.0f}원 (기존: {avg_price:,.0f}원)\n"
f"▪️ RSI: {rsi:.1f}"
)
# 3 차 매수: RSI 25 이하 && 2 차 수량보다 적을 때
elif rsi < 25 and current_qty < target_qty * 0.9:
add_amount = cash * 0.4 # 3 차 매수 금액 (자본금의 40%)
add_qty = int(add_amount / current_price)
if add_qty < 10:
return
success = self.api.buy(code, add_qty)
if success:
# 평단가 재계산
new_total_invested = (avg_price * current_qty) + (current_price * add_qty)
new_total_qty = current_qty + add_qty
new_avg_price = new_total_invested / new_total_qty if new_total_qty > 0 else 0
# DB 업데이트
trade['avg_buy_price'] = new_avg_price
trade['current_qty'] = new_total_qty
trade['total_invested'] = new_total_invested
trade['status'] = 'HOLDING' # 3 차까지 완료
trade['rsi'] = rsi
self.db.upsert_trade(trade)
self.active_trades[code] = trade
logger.info(f"✅ [{code}] 3 차 매수: {add_qty}주 @ {current_price:,.0f}원 | RSI={rsi:.1f} | 평단가: {new_avg_price:,.0f}")
# 메신저 알림
self.send_notification(
f"3 차 매수 (풀매수): {trade['name']}",
f"▪️ 종목: {code}\n"
f"▪️ 추가수량: {add_qty}\n"
f"▪️ 총수량: {new_total_qty}\n"
f"▪️ 현재가: {current_price:,.0f}\n"
f"▪️ 평단가: {new_avg_price:,.0f}\n"
f"▪️ RSI: {rsi:.1f}\n"
f"▪️ 3 분할 완료!"
)
# ==============================================================================
# [실행부]
# ==============================================================================
if __name__ == "__main__":
trader = ETFActiveTrader()
trader.run()

132
export_sniper.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
관세청 수출 호재 감지 스나이퍼
- 네이버 금융 속보에서 '수출/반도체' 관련 뉴스 실시간 감시
- 호재 종목을 즉시 후보군에 추가
"""
import requests
from bs4 import BeautifulSoup
import time
import datetime
import logging
import re
from typing import List, Dict, Optional
logger = logging.getLogger("ExportSniper")
class ExportSniper:
"""관세청 수출 호재 감지기"""
def __init__(self):
# 네이버 금융 실시간 속보 URL
self.target_url = "https://finance.naver.com/news/news_list.naver?mode=LSS2D&section_id=101&section_id2=258"
self.headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
self.keywords = ['관세청', '수출', '반도체', '잠정', '무역수지', '수출입']
self.seen_news = set() # 이미 본 뉴스는 중복 출력 방지
# 종목명 매칭용 (반도체 관련)
self.semiconductor_stocks = {
'삼성전자': '005930',
'SK하이닉스': '000660',
'삼성SDI': '006400',
'LG화학': '051910',
'LG에너지솔루션': '373220',
'포스코홀딩스': '005490',
'포스코케미칼': '003670',
}
def fetch_news(self) -> List[Dict]:
"""네이버 금융 속보에서 뉴스 가져오기"""
try:
res = requests.get(self.target_url, headers=self.headers, timeout=5)
soup = BeautifulSoup(res.text, 'html.parser')
# 뉴스 리스트 파싱
news_items = soup.select('ul.realtimeNewsList li dl')
found_news = []
for item in news_items:
title_tag = item.select_one('a')
if not title_tag:
continue
title = title_tag.text.strip()
link = "https://finance.naver.com" + title_tag.get('href', '')
# 중복 체크
if title in self.seen_news:
continue
self.seen_news.add(title)
# 키워드 감지
if any(keyword in title for keyword in self.keywords):
# 긍정적 키워드 체크 (증가, 급증, 상승 등)
positive_keywords = ['증가', '급증', '상승', '호조', '기록', '최대', '신고']
is_positive = any(pk in title for pk in positive_keywords)
if is_positive:
found_news.append({
'title': title,
'link': link,
'timestamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
logger.info(f"🔥 [호재 포착] {title}")
return found_news
except Exception as e:
logger.error(f"❌ 뉴스 크롤링 실패: {e}")
return []
def extract_stocks_from_news(self, news_title: str) -> List[str]:
"""
뉴스 제목에서 관련 종목 코드 추출
:param news_title: 뉴스 제목
:return: 종목 코드 리스트
"""
stocks = []
# 반도체 관련 종목 매칭
for name, code in self.semiconductor_stocks.items():
if name in news_title:
stocks.append(code)
# "반도체" 키워드가 있으면 주요 반도체 종목 모두 추가
if '반도체' in news_title:
stocks.extend(['005930', '000660']) # 삼성전자, SK하이닉스
return list(set(stocks)) # 중복 제거
def get_hot_stocks(self) -> List[Dict]:
"""
호재 뉴스에서 관련 종목 추출
:return: [{'code': '005930', 'name': '삼성전자', 'reason': '뉴스 제목'}, ...]
"""
news_list = self.fetch_news()
hot_stocks = []
for news in news_list:
codes = self.extract_stocks_from_news(news['title'])
for code in codes:
# 종목명 찾기
name = None
for stock_name, stock_code in self.semiconductor_stocks.items():
if stock_code == code:
name = stock_name
break
if name:
hot_stocks.append({
'code': code,
'name': name,
'reason': news['title'],
'timestamp': news['timestamp']
})
return hot_stocks

327
fetch_stock_meta.py Normal file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
fetch_stock_meta.py — 키움 테마 API로 stock_meta 자동 채우기
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[역할]
- 키움 REST API (ka90001 / ka90002) 로 전체 테마 목록 + 구성 종목을 조회
- stock_meta 테이블에 종목별 테마명·테마순위 저장
- 이후 스캘핑/꼬리잡기 매수 시 "이 종목의 테마 60분봉 RSI" 확인 가능
[사용방법]
python3 fetch_stock_meta.py # 전체 테마 스캔 (기본)
python3 fetch_stock_meta.py --top 30 # 상위 30개 테마만 (빠름)
python3 fetch_stock_meta.py --theme AI # 특정 테마명 검색
[systemd timer 등록 예시 — 장 마감 후 1일 1회]
systemctl --user enable kis_fetch_meta.timer
[키움 API 엔드포인트]
ka90001: 테마그룹별 조회 (테마 목록)
ka90002: 테마구성종목 조회 (각 테마 내 종목 리스트)
"""
import argparse
import logging
import sys
import time
import random
from pathlib import Path
from typing import Optional
import requests
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from database import TradeDB
# _get_kiwoom_creds: DB에서 키움 키 꺼내기
# _get_kiwoom_token_cached: 23시간 캐시 토큰 (au10001 rate limit 방지)
from kis_ws import _get_kiwoom_creds, _get_kiwoom_token_cached
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("FetchStockMeta")
# ──────────────────────────────────────────────────────────────
# 키움 토큰: kis_ws._get_kiwoom_token_cached() 재사용
# (23시간 모듈 레벨 캐시 → au10001 rate limit 방지)
# ──────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────
# ka90001: 테마 목록 조회
# ──────────────────────────────────────────────────────────────
def fetch_theme_list(
token: str,
app_key: str,
app_secret: str,
is_mock: bool,
theme_name_filter: str = "",
) -> list:
"""
키움 ka90001 API 로 전체 테마 그룹 목록 조회.
Returns:
[{'thema_grp_cd': '100', 'thema_nm': 'AI반도체', 'stk_num': '15', ...}, ...]
"""
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
url = f"https://{domain}/api/dostk/thme"
headers = {
"content-type": "application/json;charset=UTF-8",
"appkey": app_key,
"appsecret": app_secret,
"authorization": f"Bearer {token}",
"api-id": "ka90001",
"cont-yn": "N",
"next-key": "",
}
# qry_tp=0: 전체검색 / date_tp=1: 1일 / flu_pl_amt_tp=1: 상위기간수익률 / stex_tp=1: KRX
body = {
"qry_tp": "1" if theme_name_filter else "0",
"thema_nm": theme_name_filter,
"stk_cd": "",
"date_tp": "1",
"flu_pl_amt_tp":"1",
"stex_tp": "1",
}
themes = []
while True:
try:
resp = requests.post(url, json=body, headers=headers, timeout=15)
data = resp.json()
except Exception as e:
logger.warning("ka90001 조회 실패: %s", e)
break
groups = data.get("thema_grp") or []
themes.extend(groups)
cont_yn = str(resp.headers.get("cont-yn", "N")).upper()
next_key = str(resp.headers.get("next-key", "")).strip()
if cont_yn != "Y" or not next_key:
break
headers["cont-yn"] = cont_yn
headers["next-key"] = next_key
time.sleep(0.4)
logger.info("✅ 테마 목록 조회 완료: %d", len(themes))
return themes
# ──────────────────────────────────────────────────────────────
# ka90002: 테마 구성 종목 조회
# ──────────────────────────────────────────────────────────────
def fetch_theme_stocks(
token: str,
app_key: str,
app_secret: str,
is_mock: bool,
thema_grp_cd: str,
) -> list:
"""
키움 ka90002 API 로 특정 테마의 구성 종목 조회.
Returns:
[{'stk_cd': '005930', 'stk_nm': '삼성전자', ...}, ...]
"""
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
url = f"https://{domain}/api/dostk/thme"
headers = {
"content-type": "application/json;charset=UTF-8",
"appkey": app_key,
"appsecret": app_secret,
"authorization": f"Bearer {token}",
"api-id": "ka90002",
"cont-yn": "N",
"next-key": "",
}
body = {
"thema_grp_cd": thema_grp_cd,
"stex_tp": "1",
"date_tp": "1",
}
stocks = []
while True:
try:
resp = requests.post(url, json=body, headers=headers, timeout=15)
data = resp.json()
except Exception as e:
logger.warning("ka90002 조회 실패 (테마%s): %s", thema_grp_cd, e)
break
items = data.get("thema_comp_stk") or []
stocks.extend(items)
cont_yn = str(resp.headers.get("cont-yn", "N")).upper()
next_key = str(resp.headers.get("next-key", "")).strip()
if cont_yn != "Y" or not next_key:
break
headers["cont-yn"] = cont_yn
headers["next-key"] = next_key
time.sleep(0.35)
return stocks
# ──────────────────────────────────────────────────────────────
# 종목코드로 시장구분 추정 (KOSPI / KOSDAQ / ETF)
# 정확한 구분은 별도 API 필요, 여기선 휴리스틱
# ──────────────────────────────────────────────────────────────
def _guess_market(code: str) -> str:
"""
KOSPI ETF 코드 범위 (069XXX, 102XXX 등) / 기타 판별은 어려워
가장 보수적으로: 6자리 숫자 기준 추정.
- 0XXXXX, 1XXXXX: 주로 KOSPI (삼성 005930, 현대 005380)
- 2XXXXX, 3XXXXX: 주로 KOSDAQ (카카오 035720)
- ETF는 069XXX, 102XXX, 114XXX, 122XXX, 133XXX, 229XXX 등
완벽하지 않음 — 향후 KIS FHKST01010100 API로 정확히 수정 가능
"""
if not code or len(code) != 6 or not code.isdigit():
return "Q"
n = int(code)
# 대표적 ETF 코드 대역
etf_ranges = [
(69000, 70000), (102000, 103000), (114000, 115000),
(122000, 123000), (133000, 134000), (229000, 230000),
(252000, 253000), (261000, 262000), (278000, 279000),
(364000, 365000),
]
for lo, hi in etf_ranges:
if lo <= n < hi:
return "E"
# KOSPI 범위 (0~200000 사이, 대략)
if n < 200000:
return "K"
return "Q"
# ──────────────────────────────────────────────────────────────
# 메인: 테마 → 종목 → stock_meta 저장
# ──────────────────────────────────────────────────────────────
def run(top_n: int = 0, theme_filter: str = "", dry_run: bool = False):
"""
Args:
top_n : 상위 N개 테마만 처리 (0=전체)
theme_filter : 특정 테마명 검색 (예: "AI", "반도체")
dry_run : DB 저장 없이 출력만
"""
db = TradeDB()
# 1. DB에서 키움 키 꺼내기
kw_key, kw_secret, kw_mock = _get_kiwoom_creds(db)
if not kw_key or not kw_secret:
logger.error("❌ 키움 앱키/시크릿 없음 → update_env_simple.py 로 KIWOOM_APP_KEY_REAL 설정 필요")
sys.exit(1)
mode_str = "모의" if kw_mock else "실전"
logger.info("🔑 키움 키 확인 완료 [%s] (앞8자: %s…)", mode_str, kw_key[:8])
# 2. 키움 OAuth 토큰 (캐시 사용)
token = _get_kiwoom_token_cached(kw_key, kw_secret, kw_mock)
if not token:
sys.exit(1)
logger.info("✅ 키움 토큰 확인 완료")
# 3. 테마 목록 조회
themes = fetch_theme_list(token, kw_key, kw_secret, kw_mock, theme_filter)
if not themes:
logger.error("❌ 테마 목록 없음 (API 응답 확인 필요)")
sys.exit(1)
if top_n > 0:
themes = themes[:top_n]
logger.info("📋 상위 %d개 테마만 처리", top_n)
# 4. 테마별 구성 종목 조회 + stock_meta 저장
total_saved = 0
total_skipped = 0
for idx, tg in enumerate(themes, 1):
grp_cd = str(tg.get("thema_grp_cd") or "").strip()
theme_nm = str(tg.get("thema_nm") or "").strip()
stk_num = str(tg.get("stk_num") or "0").strip()
if not grp_cd or not theme_nm:
continue
logger.info("[%d/%d] 테마: %s (%s종목)", idx, len(themes), theme_nm, stk_num)
stocks = fetch_theme_stocks(token, kw_key, kw_secret, kw_mock, grp_cd)
if not stocks:
logger.debug(" → 구성 종목 없음 (스킵)")
total_skipped += 1
continue
for rank, stk in enumerate(stocks, 1):
code = str(stk.get("stk_cd") or "").strip()
name = str(stk.get("stk_nm") or "").strip()
if not code or len(code) != 6:
continue
# 테마 내 순위: 상위 3위까지 핵심주(1), 4~10위 연관주(2), 나머지 주변주(3)
theme_rank = 1 if rank <= 3 else (2 if rank <= 10 else 3)
market = _guess_market(code)
if dry_run:
print(f" {code} {name:12s} market={market} theme={theme_nm} rank={theme_rank}")
else:
ok = db.upsert_stock_meta(
code = code,
name = name,
market = market,
sector = "", # 섹터는 별도 API 필요 (KIS FHKST01010100)
theme = theme_nm,
theme_rank = theme_rank,
)
if ok:
total_saved += 1
if dry_run:
total_saved += 1
time.sleep(random.uniform(0.3, 0.6)) # API 레이트리밋
# 5. 결과 요약
if not dry_run:
total_stocks = db.conn.execute("SELECT COUNT(*) AS n FROM stock_meta").fetchone()['n']
logger.info("=" * 50)
logger.info("✅ 저장 완료: %d건 | stock_meta 전체: %d종목", total_saved, total_stocks)
# 테마별 종목 수 TOP 10
rows = db.conn.execute("""
SELECT theme, COUNT(*) AS cnt
FROM stock_meta
WHERE theme IS NOT NULL AND theme != ''
GROUP BY theme
ORDER BY cnt DESC
LIMIT 10
""").fetchall()
logger.info("📊 테마별 종목 수 TOP 10:")
for r in rows:
logger.info(" %-25s: %d종목", r['theme'], r['cnt'])
else:
logger.info("(dry-run 모드: DB 저장 안 함, 조회만 %d건)", total_saved)
# ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="키움 테마 API → stock_meta DB 자동 채우기")
ap.add_argument("--top", type=int, default=0, help="상위 N개 테마만 처리 (0=전체)")
ap.add_argument("--theme", type=str, default="", help="특정 테마명 검색 (예: AI, 반도체)")
ap.add_argument("--dry-run",action="store_true", help="DB 저장 없이 출력만")
args = ap.parse_args()
run(
top_n = args.top,
theme_filter= args.theme,
dry_run = args.dry_run,
)

2186
holding_bot.py Normal file

File diff suppressed because it is too large Load Diff

2678
kis_api.htm Normal file

File diff suppressed because it is too large Load Diff

18
kis_backtest_web.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=KIS Quant Backtest Web Dashboard
After=network.target
[Service]
Type=simple
User=hoon
WorkingDirectory=/home/hoon/kis_bot
# 시작 전 5050 포트 강제 해제 (재시작 시 포트 잔존 방지)
ExecStartPre=/bin/sh -c 'fuser -k 5050/tcp 2>/dev/null; sleep 1; true'
ExecStart=/home/hoon/kis_bot/.venv/bin/python /home/hoon/kis_bot/backtest_web.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

483
kis_holding_ver1.py Normal file
View File

@@ -0,0 +1,483 @@
#!/usr/bin/env python3
"""
kis_holding_ver1.py — 홀딩 전략 V1 (RSI 3단계 분할매수 · 횡보장 특화)
=======================================================================
holding_bot.py(추세추종)의 대응 버전. 추세 판단 없이 RSI만으로 진입,
가격이 더 빠질수록 비중을 늘려가는 전통적 '물타기(분할매수)' 전략.
전략 개요
---------
진입 (분할매수):
· RSI ≤ rsi_buy1 → 1단계 매수 (slot_money × buy1_ratio)
· 보유 중 RSI ≤ rsi_buy2 → 2단계 추가매수 (× buy2_ratio)
· 보유 중 RSI ≤ rsi_buy3 → 3단계 추가매수 (× buy3_ratio)
· 낙폭 필터(ath/year/w52)로 "충분히 싼 구간"에만 진입 가능
청산:
· 평단가 대비 +take_profit_pct% → 익절
· RSI ≥ rsi_sell → 과열 청산
· 평단가 대비 stop_loss_pct% → 손절
holding_bot.py와 동일한 DB 테이블(holding_candles, holding_stock_config) 사용.
실행 예시:
python3 kis_holding_ver1.py --code 005930 \\
--start 2024-01-01 --end 2026-03-06
"""
import sys, os, json, argparse
from datetime import date as _date, timedelta as _td, datetime
from typing import List, Dict, Optional
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
import logging
logger = logging.getLogger(__name__)
# holding_bot.py와 DB/캔들 공통 함수 공유
from holding_bot import (
ensure_holding_tables, get_stored_candles,
_rsi_series,
)
from database import TradeDB
# ─────────────────────────────────────────────────────────────────────────────
# 기본 파라미터 (V1 전용 — MA/추세 관련 파라미터 없음)
# ─────────────────────────────────────────────────────────────────────────────
DEFAULT_V1_CONFIG: Dict = {
"rsi_period": 14.0,
# ── 진입 RSI 임계값 (rsi_buy1 > rsi_buy2 > rsi_buy3 이어야 함) ──────────
"rsi_buy1": 50.0, # 1단계: RSI ≤ 이 값 → 처음 매수
"rsi_buy2": 40.0, # 2단계: RSI ≤ 이 값 → 추가매수
"rsi_buy3": 30.0, # 3단계: RSI ≤ 이 값 → 최종 추가매수
# ── 청산 기준 ────────────────────────────────────────────────────────────
"rsi_sell": 75.0, # RSI 과열 청산
"take_profit_pct": 15.0, # 평단가 대비 익절 %
"stop_loss_pct": 10.0, # 평단가 대비 손절 %
# ── 투자금 / 분할 비율 ────────────────────────────────────────────────────
"slot_money": 3_000_000.0,
"buy1_ratio": 0.4, # 1단계 투자금 비율 (40%)
"buy2_ratio": 0.35, # 2단계 투자금 비율 (35%)
"buy3_ratio": 0.25, # 3단계 투자금 비율 (25%)
# ── 비용 ────────────────────────────────────────────────────────────────
"fee_rate": 0.0015, # 수수료율 (편도)
"sell_tax": 0.0018, # 증권거래세
# ── 낙폭 필터 (0=비활성) ─────────────────────────────────────────────────
"ath_drop_min_pct": 0.0, # 역대 최고가 대비 최소 낙폭 %
"year_drop_min_pct": 0.0, # 당해연도 고점 대비 최소 낙폭 %
"w52_drop_min_pct": 0.0, # 52주 고점 대비 최소 낙폭 %
}
# ─────────────────────────────────────────────────────────────────────────────
# 백테스트 엔진
# ─────────────────────────────────────────────────────────────────────────────
def run_backtest_v1(candles: List[Dict], cfg: Dict) -> Dict:
"""
V1 RSI 분할매수 전략 백테스트.
실행가: 신호봉 다음 봉 시가 (1봉 지연, 실매매와 동일)
수수료: 편도 fee_rate × 2 + 매도 시 sell_tax
"""
rsi_period = int(cfg.get("rsi_period", DEFAULT_V1_CONFIG["rsi_period"]))
rsi_buy1 = float(cfg.get("rsi_buy1", DEFAULT_V1_CONFIG["rsi_buy1"]))
rsi_buy2 = float(cfg.get("rsi_buy2", DEFAULT_V1_CONFIG["rsi_buy2"]))
rsi_buy3 = float(cfg.get("rsi_buy3", DEFAULT_V1_CONFIG["rsi_buy3"]))
rsi_sell = float(cfg.get("rsi_sell", DEFAULT_V1_CONFIG["rsi_sell"]))
tp_pct = float(cfg.get("take_profit_pct",DEFAULT_V1_CONFIG["take_profit_pct"]))
sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_V1_CONFIG["stop_loss_pct"]))
slot_money = float(cfg.get("slot_money", DEFAULT_V1_CONFIG["slot_money"]))
buy1_r = float(cfg.get("buy1_ratio", DEFAULT_V1_CONFIG["buy1_ratio"]))
buy2_r = float(cfg.get("buy2_ratio", DEFAULT_V1_CONFIG["buy2_ratio"]))
buy3_r = float(cfg.get("buy3_ratio", DEFAULT_V1_CONFIG["buy3_ratio"]))
fee_rate = float(cfg.get("fee_rate", DEFAULT_V1_CONFIG["fee_rate"]))
sell_tax = float(cfg.get("sell_tax", DEFAULT_V1_CONFIG["sell_tax"]))
ath_drop_min = float(cfg.get("ath_drop_min_pct", DEFAULT_V1_CONFIG["ath_drop_min_pct"]))
year_drop_min = float(cfg.get("year_drop_min_pct", DEFAULT_V1_CONFIG["year_drop_min_pct"]))
w52_drop_min = float(cfg.get("w52_drop_min_pct", DEFAULT_V1_CONFIG["w52_drop_min_pct"]))
if len(candles) < rsi_period + 5:
return {"error": f"봉 부족: {len(candles)}개 (최소 {rsi_period + 5}개 필요)"}
closes = [float(c["close"]) for c in candles]
highs = [float(c["high"]) for c in candles]
lows = [float(c["low"]) for c in candles]
opens = [float(c["open"]) for c in candles]
dates = [str(c["candle_date"])[:10] for c in candles]
rsis = _rsi_series(closes, rsi_period)
# ── 컨텍스트 배열 사전 계산 (ATH / 연도 고점 / 52주 고점) ─────────────────
ath_arr: List[float] = []
year_arr: List[float] = []
w52_arr: List[float] = []
_year_max: Dict[str, float] = {}
_ath_run = 0.0
for i in range(len(candles)):
h = highs[i]; y = dates[i][:4]
_ath_run = max(_ath_run, h)
_year_max[y] = max(_year_max.get(y, 0.0), h)
# 52주(약 252거래일) 슬라이딩 윈도우
w52_s = max(0, i - 251)
try:
cutoff = str(_date.fromisoformat(dates[i]) - _td(days=365))
tmp = i
while tmp > 0 and dates[tmp - 1] >= cutoff:
tmp -= 1
w52_s = tmp
except Exception:
pass
ath_arr.append(_ath_run)
year_arr.append(_year_max[y])
w52_arr.append(max(highs[w52_s:i + 1]))
# ── 시뮬레이션 ────────────────────────────────────────────────────────────
position = None # None 또는 Dict
trades: List[Dict] = []
equity: List[Dict] = []
cum_pnl = 0.0
start_i = rsi_period + 1
for i in range(start_i, len(candles) - 1):
rsi = rsis[i]
if rsi is None:
continue
close = closes[i]
next_open = float(opens[i + 1]) if opens[i + 1] > 0 else close
if next_open <= 0:
continue
date_str = dates[i]
def _drop(ref: float) -> float:
"""현재가 기준 고점 대비 낙폭 %"""
return (ref - close) / ref * 100 if ref > 0 else 0.0
# ─── 보유 중: 청산 먼저 체크, 그 다음 추가매수 ───────────────────────
if position is not None:
avg = position["avg"]
qty = position["qty"]
stage = position["stage"]
profit_pct_now = (close - avg) / avg * 100 if avg > 0 else 0.0
# 청산 조건
sell_reason = None
if profit_pct_now >= tp_pct:
sell_reason = f"익절(+{profit_pct_now:.1f}%)"
elif rsi >= rsi_sell:
sell_reason = f"RSI과열({rsi:.1f})"
elif profit_pct_now <= -sl_pct:
sell_reason = f"손절({profit_pct_now:.1f}%)"
if sell_reason:
exit_price = next_open
fee = exit_price * qty * (fee_rate + sell_tax)
pnl = (exit_price - avg) * qty - fee
cum_pnl += pnl
trades.append({
"buy_date": dates[position["entry_i"]],
"sell_date": dates[i + 1],
"avg_price": round(avg),
"exit_price": round(exit_price),
"qty": qty,
"pnl": round(pnl),
"hold_days": i + 1 - position["entry_i"],
"reason": sell_reason,
"stage": stage,
"ath_drop": position.get("ath_drop", 0),
})
equity.append({"date": dates[i + 1], "cum_pnl": round(cum_pnl)})
position = None
else:
# ─ 추가매수 (분할매수 핵심) ─────────────────────────────────
# 2단계: 더 빠져서 rsi_buy2 이하가 됐을 때 추가
if stage == 1 and rsi <= rsi_buy2:
add_inv = slot_money * buy2_r
add_qty = max(1, int(add_inv / next_open))
add_cost = next_open * add_qty * (1 + fee_rate)
new_qty = qty + add_qty
position["avg"] = (avg * qty + next_open * add_qty) / new_qty
position["qty"] = new_qty
position["cost"] += add_cost
position["stage"] = 2
# 3단계: 더 빠져서 rsi_buy3 이하가 됐을 때 추가
elif stage == 2 and rsi <= rsi_buy3:
add_inv = slot_money * buy3_r
add_qty = max(1, int(add_inv / next_open))
add_cost = next_open * add_qty * (1 + fee_rate)
new_qty = qty + add_qty
position["avg"] = (avg * qty + next_open * add_qty) / new_qty
position["qty"] = new_qty
position["cost"] += add_cost
position["stage"] = 3
continue # 보유 중엔 신규 진입 스킵
# ─── 미보유: 낙폭 필터 → 1단계 진입 ──────────────────────────────────
if ath_drop_min > 0 and _drop(ath_arr[i]) < ath_drop_min: continue
if year_drop_min > 0 and _drop(year_arr[i]) < year_drop_min: continue
if w52_drop_min > 0 and _drop(w52_arr[i]) < w52_drop_min: continue
if rsi > rsi_buy1:
continue
invest = slot_money * buy1_r
qty = max(1, int(invest / next_open))
cost = next_open * qty * (1 + fee_rate)
position = {
"avg": next_open,
"qty": qty,
"cost": cost,
"stage": 1,
"entry_i": i + 1,
"ath_drop": round(_drop(ath_arr[i]), 1),
}
# ── 기간 종료 강제 청산 ───────────────────────────────────────────────────
if position is not None and candles:
exit_price = closes[-1]
avg = position["avg"]; qty = position["qty"]
fee = exit_price * qty * (fee_rate + sell_tax)
pnl = (exit_price - avg) * qty - fee
cum_pnl += pnl
trades.append({
"buy_date": dates[position["entry_i"]],
"sell_date": dates[-1],
"avg_price": round(avg),
"exit_price": round(exit_price),
"qty": qty,
"pnl": round(pnl),
"hold_days": len(candles) - 1 - position["entry_i"],
"reason": "기간종료",
"stage": position["stage"],
"ath_drop": position.get("ath_drop", 0),
})
equity.append({"date": dates[-1], "cum_pnl": round(cum_pnl)})
# ── 요약 통계 ──────────────────────────────────────────────────────────────
total = len(trades)
wins = [t for t in trades if t["pnl"] > 0]
losses = [t for t in trades if t["pnl"] < 0]
gross_profit = sum(t["pnl"] for t in wins)
gross_loss = abs(sum(t["pnl"] for t in losses))
pf = round(gross_profit / gross_loss, 2) if gross_loss > 0 else 9999.0
peak_eq = 0.0; mdd = 0.0; run_pnl = 0.0
for t in trades:
run_pnl += t["pnl"]
peak_eq = max(peak_eq, run_pnl)
mdd = max(mdd, peak_eq - run_pnl)
# Buy & Hold 비교 (첫 진입 시점 기준)
bnh_pct = 0.0; bnh_pnl = 0.0; bot_pct = 0.0
if candles and slot_money > 0:
first_p = float(candles[start_i]["open"])
last_p = float(candles[-1]["close"])
if first_p > 0:
bnh_qty = max(1, int(slot_money / first_p))
bnh_pnl = round((last_p - first_p) * bnh_qty)
bnh_pct = round((last_p - first_p) / first_p * 100, 2)
total_pnl = sum(t["pnl"] for t in trades)
bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0
reason_dist: Dict[str, int] = {}
for t in trades:
r = t["reason"].split("(")[0]
reason_dist[r] = reason_dist.get(r, 0) + 1
avg_hold = round(sum(t["hold_days"] for t in trades) / total, 1) if total else 0
return {
"candle_count": len(candles),
"summary": {
"total_trades": total,
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"profit_factor": pf,
"max_drawdown": round(mdd),
"avg_hold_days": avg_hold,
"reason_dist": reason_dist,
"bnh_pct": bnh_pct,
"bnh_pnl": bnh_pnl,
"bot_pct": bot_pct,
},
"equity": equity[-200:],
"trades": trades[-200:],
}
# ─────────────────────────────────────────────────────────────────────────────
# 파라미터 Grid Search
# ─────────────────────────────────────────────────────────────────────────────
def run_param_search_v1(
candles: List[Dict],
grid: Optional[Dict] = None,
min_trades: int = 2,
base_cfg: Optional[Dict] = None,
) -> List[Dict]:
"""
V1 전략 파라미터 Grid Search.
base_cfg: 탐색 그리드에 없는 파라미터 기준값.
웹 카드에서 전달받은 사용자 설정값 사용.
"""
from itertools import product as iproduct
if grid is None:
grid = {
# ── 진입 RSI 임계값 ──────────────────────────────────────────────
"rsi_buy1": [60, 55, 50, 45],
"rsi_buy2": [50, 45, 40, 35],
"rsi_buy3": [40, 35, 30, 25],
# ── 매도 RSI ────────────────────────────────────────────────────
"rsi_sell": [70, 75, 80],
# ── 익절/손절 ────────────────────────────────────────────────────
"take_profit_pct": [10.0, 15.0, 20.0, 30.0],
"stop_loss_pct": [7.0, 10.0, 15.0],
# ── ATH 낙폭 필터 ─────────────────────────────────────────────
"ath_drop_min_pct": [0.0, 20.0],
}
keys = list(grid.keys())
combos = list(iproduct(*[grid[k] for k in keys]))
_base = dict(DEFAULT_V1_CONFIG)
if base_cfg:
_base.update(base_cfg)
results = []
for vals in combos:
cfg = dict(_base)
cfg.update(dict(zip(keys, vals)))
# rsi_buy1 > rsi_buy2 > rsi_buy3 조건 보정
if not (cfg["rsi_buy1"] > cfg["rsi_buy2"] > cfg["rsi_buy3"]):
continue
res = run_backtest_v1(candles, cfg)
if "error" in res:
continue
s = res.get("summary", {})
if s.get("total_trades", 0) < min_trades:
continue
results.append({
"params": {k: cfg[k] for k in keys},
"total_pnl": s["total_pnl"],
"win_rate": s["win_rate"],
"total_trades": s["total_trades"],
"pf": s["profit_factor"],
"avg_hold": s["avg_hold_days"],
"mdd": s["max_drawdown"],
})
results.sort(key=lambda x: x["total_pnl"], reverse=True)
return results
# ─────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ─────────────────────────────────────────────────────────────────────────────
def main():
today = datetime.now().strftime("%Y-%m-%d")
year_ago = (datetime.now() - _td(days=365)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(
description="홀딩 V1 (RSI 분할매수) 백테스트 · 파라미터탐색"
)
parser.add_argument("--code", required=True, help="종목 코드 (예: 005930)")
parser.add_argument("--start", default=year_ago, help="시작일 YYYY-MM-DD")
parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD")
parser.add_argument("--search", action="store_true", help="파라미터 탐색 모드")
parser.add_argument("--min_trades", default=2, type=int, help="탐색 최소 거래 수")
parser.add_argument("--top", default=20, type=int, help="탐색 결과 상위 N개")
parser.add_argument("--rsi_buy1", default=None, type=float)
parser.add_argument("--rsi_buy2", default=None, type=float)
parser.add_argument("--rsi_buy3", default=None, type=float)
parser.add_argument("--rsi_sell", default=None, type=float)
parser.add_argument("--tp", default=None, type=float, dest="take_profit_pct")
parser.add_argument("--sl", default=None, type=float, dest="stop_loss_pct")
parser.add_argument("--ath_drop", default=None, type=float, dest="ath_drop_min_pct")
args = parser.parse_args()
import logging as _lg
_lg.getLogger("TradeDB").setLevel(_lg.WARNING)
db = TradeDB()
ensure_holding_tables(db)
candles = get_stored_candles(db, args.code, args.start, args.end)
db.close()
if not candles:
print(f"{args.code} 캔들 없음 (holding_candles 테이블 확인)")
return
print(f"{args.code} | {len(candles)}봉 ({candles[0]['candle_date']} ~ {candles[-1]['candle_date']})")
# CLI 파라미터 오버라이드
override = {}
for k in ["rsi_buy1", "rsi_buy2", "rsi_buy3", "rsi_sell",
"take_profit_pct", "stop_loss_pct", "ath_drop_min_pct"]:
v = getattr(args, k, None)
if v is not None:
override[k] = v
if args.search:
print(f"\n🔍 V1 파라미터 탐색 중…")
results = run_param_search_v1(candles, min_trades=args.min_trades,
base_cfg=override or None)
if not results:
print("⚠️ 유효 결과 없음")
return
print(f"\n{'='*70}")
print(f" 🏆 RSI 분할매수 V1 — TOP {min(args.top, len(results))}")
print(f"{'='*70}")
keys = list(results[0]["params"].keys())
hdr = " ".join(f"{k:>14}" for k in keys)
print(f"{hdr} | {'손익':>10} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
print("-" * (len(hdr) + 50))
for r in results[:args.top]:
p = r["params"]
row = " ".join(f"{p[k]:>14.4g}" for k in keys)
print(f"{row} | {r['total_pnl']:>+10,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}")
else:
cfg = dict(DEFAULT_V1_CONFIG)
cfg.update(override)
res = run_backtest_v1(candles, cfg)
if "error" in res:
print(f"{res['error']}")
return
s = res["summary"]
print(f"""
╔══════════════════════════════════════════════╗
║ 📊 V1 RSI 분할매수 백테스트 결과 ║
╠══════════════════════════════════════════════╣
║ 총 거래 : {s['total_trades']:>5}건 ║
║ 승률 : {s['win_rate']:>5.1f}% ║
║ 순손익 : {s['total_pnl']:>+12,.0f} 원 ║
║ PF : {s['profit_factor']:>5.2f}
║ MDD : {s['max_drawdown']:>12,.0f} 원 ║
║ 평균보유 : {s['avg_hold_days']:>5.1f}일 ║
╠══════════════════════════════════════════════╣
║ 봇 수익률 : {s['bot_pct']:>+8.2f}% ║
║ B&H 수익률 : {s['bnh_pct']:>+8.2f}% ║
║ 알파 : {round(s['bot_pct']-s['bnh_pct'],2):>+8.2f}%p ║
╚══════════════════════════════════════════════╝""")
for t in res["trades"][-10:]:
print(f" {t['buy_date']}{t['sell_date']} "
f"평단:{t['avg_price']:,} 매도:{t['exit_price']:,} "
f"수량:{t['qty']} 손익:{t['pnl']:+,} 단계:{t['stage']} {t['reason']}")
if __name__ == "__main__":
main()

View File

@@ -8,7 +8,7 @@ KIS Long Trading Bot Ver1 - 늘림목 전략용 한투 API 트레이딩 시스
import os
import json
import ㅁㅁ
import time
import random
import logging
import datetime
@@ -52,9 +52,9 @@ except ImportError:
ML_AVAILABLE = False
logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가")
# DB 초기화
# DB 초기화 — MariaDB 192.168.0.141 (database.py 모듈 상수 사용)
SCRIPT_DIR = Path(__file__).resolve().parent
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
db = TradeDB() # db_path 인수 무시됨, MariaDB 직접 연결
# DB에서 환경변수 로드 (초기 버전: Mattermost/Gemini 설정용)
def get_env_from_db(key, default=""):
@@ -153,6 +153,43 @@ def _save_kis_token_cache(access_token, access_token_token_expired, mock):
pass
def _invalidate_kis_token_cache(mock: bool = False):
"""
토큰 만료(EGW00123 등) 감지 시 캐시 무효화 → 다음 _headers()에서 자동 재발급.
1. KisTokenManager 메모리 캐시 초기화
2. 레거시 파일 캐시 삭제 (폴백용)
"""
try:
from kis_token_manager import KisTokenManager, _km_instances, _km_instances_lock
import threading
with _km_instances_lock:
if mock in _km_instances:
inst = _km_instances[mock]
inst._token = None
inst._expiry = None
logger.info("한투 토큰 메모리 캐시 초기화 (만료 감지) [%s]",
"모의" if mock else "실전")
except Exception as e:
logger.debug("KisTokenManager 초기화 실패: %s", e)
# 레거시 파일도 삭제
try:
if KIS_TOKEN_CACHE_PATH.exists():
KIS_TOKEN_CACHE_PATH.unlink()
logger.info("한투 토큰 레거시 캐시 삭제: %s", KIS_TOKEN_CACHE_PATH)
except Exception as e:
logger.warning("한투 토큰 캐시 삭제 실패(%s): %s", KIS_TOKEN_CACHE_PATH, e)
def _is_token_expired_response(j: dict) -> bool:
"""응답이 '기간이 만료된 token' 오류(EGW00123 등)인지 여부."""
if not j or not isinstance(j, dict):
return False
msg_cd = j.get("msg_cd") or ""
msg1 = str(j.get("msg1", "") or "")
# EGW00123 외에도 msg1 문자열에 '만료된 token' / '만료' 가 포함되어 있으면 토큰 만료로 간주
return msg_cd == "EGW00123" or "만료된 token" in msg1 or "만료" in msg1
class KISClientWithOrder:
"""주문 기능이 추가된 KIS 클라이언트 (env 키는 단타 봇과 동일: KIS_APP_KEY_MOCK/REAL, KIS_ACCOUNT_NO_MOCK/REAL)"""
def __init__(self, mock=None):
@@ -213,32 +250,46 @@ class KISClientWithOrder:
self._auth()
def _auth(self):
"""접근 토큰 발급"""
"""
접근 토큰 준비 — KisTokenManager 싱글톤 우선 사용.
- 메모리 캐시 → 파일 캐시 → 신규 발급 순서로 처리
- 만료 10분 전 선제 갱신 (KIS 23시간 정책 자동 준수)
"""
if not self.app_key or not self.app_secret:
hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)"
hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL"
raise ValueError(f"한투 앱키 미설정: {hint}")
mode_str = "모의" if self.mock else "실전"
try:
from kis_token_manager import KisTokenManager
token = KisTokenManager.instance(is_mock=self.mock).get_token()
if token:
self.access_token = token
logger.info("✅ 한투 토큰 준비 완료 [%s] (KisTokenManager, 앞8자: %s…)",
mode_str, token[:8])
return
except Exception as e:
logger.debug("KisTokenManager 실패, 파일 캐시 폴백: %s", e)
# 폴백: 기존 파일 캐시
cached = _load_kis_token_cache(self.mock)
if cached:
self.access_token = cached
logger.info("한투 토큰 캐시 사용 (%s)", "모의" if self.mock else "실전")
logger.info("한투 토큰 파일 캐시 사용 [%s]", mode_str)
return
url = f"{self.base_url}/oauth2/tokenP"
body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret}
# 폴백: kis_token_manager 경로로만 발급 (잠금·1일1회 준수, SMS 시각 안정화)
try:
r = requests.post(url, json=body, timeout=10)
data = r.json()
if "access_token" in data:
self.access_token = data["access_token"]
expired = data.get("access_token_token_expired") or ""
_save_kis_token_cache(self.access_token, expired, self.mock)
logger.info("한투 토큰 발급 완료 (%s)", "모의" if self.mock else "실전")
else:
raise RuntimeError("한투 토큰 발급 실패")
from kis_token_manager import ensure_token
if ensure_token(self.mock):
cached = _load_kis_token_cache(self.mock)
if cached:
self.access_token = cached
logger.info("✅ 한투 토큰 발급 완료 [%s]", mode_str)
return
except Exception as e:
logger.error("한투 인증 예외: %s", e)
raise
logger.warning("kis_token_manager 발급 실패: %s", e)
raise RuntimeError("한투 토큰 발급 실패")
def _get_hashkey(self, body):
"""해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용"""
@@ -260,7 +311,19 @@ class KISClientWithOrder:
return None
def _headers(self, tr_id, hashkey=None):
"""API 호출용 헤더"""
"""
API 호출용 헤더.
KisTokenManager.get_token() 으로 만료 10분 전 선제 갱신:
- 유효하면 메모리에서 즉시 반환 (오버헤드 없음)
- 만료 임박 시 자동 갱신 후 새 토큰 사용 → EGW00123 방지
"""
try:
from kis_token_manager import KisTokenManager
fresh = KisTokenManager.instance(is_mock=self.mock).get_token()
if fresh:
self.access_token = fresh
except Exception:
pass # 실패 시 기존 access_token 유지
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {self.access_token}",
@@ -272,24 +335,39 @@ class KISClientWithOrder:
headers["hashkey"] = hashkey
return headers
def _get(self, path, tr_id, params, max_retries=3):
"""GET 요청. 429 시 지수 백오프 재시도"""
def _get(self, path, tr_id, params, max_retries=5):
"""GET 요청. 429/500+EGW00201(초당 거래건수 초과) 시 대기 후 재시도, 토큰 만료 시 캐시 삭제 후 1회 재인증."""
url = f"{self.base_url}{path}"
token_refreshed = False
for attempt in range(max_retries):
try:
r = requests.get(url, headers=self._headers(tr_id), params=params, timeout=15)
if r.status_code == 429:
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})")
wait_time = 1.5 + (attempt * 1.0)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) path={path}")
time.sleep(wait_time)
continue
if r.status_code == 200:
j = r.json()
if j.get("rt_cd") == "0":
# 200 또는 500(한투가 EGW00201 시 500 반환) 응답에서 body 파싱
if r.status_code in (200, 500):
try:
j = r.json()
except Exception:
j = {}
if r.status_code == 200 and j.get("rt_cd") == "0":
return r
elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도")
if _is_token_expired_response(j) and not token_refreshed:
logger.warning("🔑 한투 토큰 만료 감지(EGW00123 등) → 캐시 무효화 후 재인증, GET 재시도")
_invalidate_kis_token_cache(mock=self.mock)
self._auth()
token_refreshed = True
time.sleep(0.5)
continue
# EGW00201 또는 msg1에 '초과'/'과부하' → 초당 거래건수 초과, 대기 후 재시도
if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = 1.5 + (attempt * 1.0)
logger.warning(
f"⏳ API 초당거래 초과 (EGW00201) GET {path} -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}"
)
time.sleep(wait_time)
continue
return r
@@ -456,33 +534,48 @@ class KISClientWithOrder:
except Exception:
return None
def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3):
"""POST 요청. 해시키 사용 및 429 시 지수 백오프 재시도"""
def _post(self, path, tr_id, body, use_hashkey=True, max_retries=5):
"""POST 요청. 429/500+EGW00201 시 대기 후 재시도, 토큰 만료 시 캐시 삭제 후 1회 재인증."""
url = f"{self.base_url}{path}"
hashkey = None
if use_hashkey:
hashkey = self._get_hashkey(body)
if not hashkey:
logger.debug("해시키 발급 실패, 해시키 없이 진행")
token_refreshed = False
for attempt in range(max_retries):
try:
r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15)
if r.status_code == 429:
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})")
wait_time = 1.5 + (attempt * 1.0)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) path={path}")
time.sleep(wait_time)
if use_hashkey:
hashkey = self._get_hashkey(body)
continue
if r.status_code == 200:
j = r.json()
if j.get("rt_cd") == "0":
if r.status_code in (200, 500):
try:
j = r.json()
except Exception:
j = {}
if r.status_code == 200 and j.get("rt_cd") == "0":
return r
elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도")
if _is_token_expired_response(j) and not token_refreshed:
logger.warning("🔑 한투 토큰 만료 감지(EGW00123 등) → 캐시 무효화 후 재인증, POST 재시도")
_invalidate_kis_token_cache(mock=self.mock)
self._auth()
token_refreshed = True
if use_hashkey:
hashkey = self._get_hashkey(body)
time.sleep(0.5)
continue
if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = 1.5 + (attempt * 1.0)
logger.warning(
f"⏳ API 초당거래 초과 (EGW00201) POST {path} -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}"
)
time.sleep(wait_time)
if use_hashkey:
hashkey = self._get_hashkey(body)

1008
kis_long_ver2.py Normal file

File diff suppressed because it is too large Load Diff

1658
kis_scalping_ver1.py Normal file

File diff suppressed because it is too large Load Diff

1589
kis_scalping_ver2.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -332,34 +332,19 @@ class KISClient:
logger.info("한투 토큰 캐시 사용 (%s) | 파일=%s | 토큰앞8=%s", mode_str, path, token_head)
return
# 캐시 없음/만료 → API로 새 토큰 발급 (캐시 파일 없어도 자동 발급)
appkey_tail = (self.app_key[-4:] if len(self.app_key) >= 4 else self.app_key) or "????"
logger.info(
"한투 토큰 발급 요청 (%s) | 앱키 끝4자리=%s | 저장할 캐시=%s",
mode_str, appkey_tail, path,
)
url = f"{self.base_url}/oauth2/tokenP"
body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret}
# 캐시 없음/만료 → kis_token_manager 경로로만 발급 (잠금·1일1회 준수, SMS 시각 안정화)
try:
r = requests.post(url, json=body, timeout=10)
data = r.json()
if "access_token" in data:
self.access_token = data["access_token"]
expired = data.get("access_token_token_expired") or ""
_save_kis_token_cache(self.access_token, expired, self.mock)
token_head = (self.access_token[:8] + "") if self.access_token and len(self.access_token) > 8 else "(없음)"
logger.info(
"한투 토큰 발급 완료 (%s) | 캐시=%s | 앱키끝4=%s | 토큰앞8=%s",
mode_str, path, appkey_tail, token_head,
)
else:
logger.error("한투 토큰 발급 실패: %s", data)
if isinstance(data, dict) and data.get("error_code") == "EGW00133":
logger.warning("한투 1분당 1회 제한. 1분 후 재시도하거나 캐시 사용: %s", path)
raise RuntimeError("한투 토큰 발급 실패")
from kis_token_manager import ensure_token
if ensure_token(self.mock):
cached = _load_kis_token_cache(self.mock)
if cached:
self.access_token = cached
token_head = (cached[:8] + "") if len(cached) > 8 else "(없음)"
logger.info("한투 토큰 발급 완료 (%s) | 캐시=%s | 토큰앞8=%s", mode_str, path, token_head)
return
except Exception as e:
logger.error("한투 인증 예외: %s", e)
raise
logger.warning("kis_token_manager 발급 실패, 재시도: %s", e)
raise RuntimeError("한투 토큰 발급 실패 (캐시 없음·만료 시 kis_token_manager.ensure_token 사용)")
def _get_hashkey(self, body):
"""해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용"""

3658
kis_short_ver3.py Normal file

File diff suppressed because it is too large Load Diff

397
kis_token_manager.py Normal file
View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""
kis_token_manager.py — KIS 실전/모의 토큰 통합 자동 관리
==========================================================
문제: 모의 모드로만 봇을 돌리면 실전 토큰이 갱신되지 않음.
→ 실전 토큰 만료 → WebSocket/홀딩봇 REST 호출 실패 → 거래 없음
해결: 어느 모드로 봇을 실행해도 실전+모의 토큰 모두 유효하게 유지.
파일 잠금(lockfile) 으로 동시에 여러 봇이 실행해도 중복 발급 방지.
KIS 제한:
- 하루 1회 원칙 (23시간 내 중복 발급 시 제한 가능)
- 이 모듈은 만료 1시간 전까지 기존 토큰 재사용 → 하루 1회만 발급
사용 예시 (각 봇 시작부):
from kis_token_manager import ensure_both_tokens
ensure_both_tokens() # 실전+모의 토큰 자동 갱신 (만료된 것만)
CLI:
python3 kis_token_manager.py # 상태 확인
python3 kis_token_manager.py --refresh # 강제 갱신 시도
"""
import json
import logging
import os
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
import requests
logger = logging.getLogger(__name__)
ROOT = Path(__file__).parent
CACHE_MOCK = ROOT / ".kis_token_cache_mock.json"
CACHE_REAL = ROOT / ".kis_token_cache_real.json"
LOCK_FILE = ROOT / ".kis_token_manager.lock"
EXPIRE_MARGIN_H = 1 # 파일 캐시 상태 조회용 (get_token_status, ensure_token) 버퍼
EXPIRE_MARGIN_M = 10 # KisTokenManager.get_token() 선제 갱신 버퍼 (10분)
LOCK_TIMEOUT_S = 60 # 잠금 최대 대기 시간(초)
# ─────────────────────────────────────────────────────────────────────────────
# 내부 유틸
# ─────────────────────────────────────────────────────────────────────────────
def _load_env() -> dict:
"""DB env_config 테이블에서 KIS 키 로드"""
try:
from database import TradeDB
db = TradeDB()
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
db.close()
return dict(row) if row else {}
except Exception as e:
logger.warning(f"DB env 로드 실패: {e}")
return {}
def _parse_expired(s: str):
"""만료 시간 문자열 → datetime. 실패 시 None."""
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y%m%d%H%M%S"):
try:
return datetime.strptime(str(s).strip(), fmt)
except Exception:
pass
return None
def get_token_status(is_mock: bool) -> dict:
"""
캐시 파일 상태 반환.
반환: {"valid": bool, "token": str, "expires": str, "expires_in_h": float}
"""
cache_path = CACHE_MOCK if is_mock else CACHE_REAL
mode = "모의" if is_mock else "실전"
if not cache_path.exists():
return {"valid": False, "token": "", "expires": "파일없음", "expires_in_h": -999}
try:
cache = json.loads(cache_path.read_text(encoding="utf-8"))
token = cache.get("access_token", "")
expired_s = cache.get("access_token_token_expired", "")
exp_dt = _parse_expired(expired_s)
if not token or exp_dt is None:
return {"valid": False, "token": "", "expires": expired_s, "expires_in_h": -999}
now = datetime.now()
expires_in = (exp_dt - now).total_seconds() / 3600
valid = now < exp_dt - timedelta(hours=EXPIRE_MARGIN_H)
return {
"valid": valid,
"token": token[:12] + "",
"expires": expired_s,
"expires_in_h": round(expires_in, 1),
}
except Exception as e:
return {"valid": False, "token": "", "expires": f"읽기오류: {e}", "expires_in_h": -999}
def _acquire_lock() -> bool:
"""파일 잠금 획득 (중복 발급 방지). 성공 시 True."""
deadline = time.time() + LOCK_TIMEOUT_S
while time.time() < deadline:
try:
# O_CREAT | O_EXCL: 파일이 없을 때만 생성 (원자적)
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
# 기존 잠금이 5분 이상 됐으면 강제 해제 (좀비 잠금)
try:
mtime = LOCK_FILE.stat().st_mtime
if time.time() - mtime > 300:
LOCK_FILE.unlink(missing_ok=True)
continue
except Exception:
pass
time.sleep(2)
except Exception as e:
logger.warning(f"잠금 획득 실패: {e}")
return False
logger.warning("잠금 획득 타임아웃 → 갱신 건너뜀")
return False
def _release_lock():
"""파일 잠금 해제"""
try:
LOCK_FILE.unlink(missing_ok=True)
except Exception:
pass
def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
"""
KIS /oauth2/tokenP 엔드포인트로 새 토큰 발급 후 캐시 저장.
성공 시 True, 실패 시 False.
※ KIS 정책: 23시간 내 중복 발급 시 서비스 제한 가능.
이 함수는 만료 1시간 전까지 기존 토큰을 재사용하므로 하루 1회만 호출됨.
"""
base_url = (
"https://openapivts.koreainvestment.com:29443"
if is_mock else
"https://openapi.koreainvestment.com:9443"
)
cache_path = CACHE_MOCK if is_mock else CACHE_REAL
mode = "모의" if is_mock else "실전"
try:
resp = requests.post(
f"{base_url}/oauth2/tokenP",
json={"grant_type": "client_credentials",
"appkey": app_key, "appsecret": app_secret},
timeout=15,
)
data = resp.json()
token = data.get("access_token", "")
exp = data.get("access_token_token_expired", "")
if not token:
err = data.get("message") or data.get("error_description") or str(data)
logger.error(f"{mode} 토큰 발급 실패: {err}")
# EGW00133: 1분당 1회 제한
if "EGW00133" in str(data):
logger.warning("⚠️ KIS 분당 발급 제한. 1분 후 재시도하세요.")
return False
cache_path.write_text(
json.dumps({
"access_token": token,
"access_token_token_expired": exp,
"mock": is_mock,
}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(f"{mode} 토큰 발급 완료 | 만료: {exp} | 앞12자: {token[:12]}")
return True
except Exception as e:
logger.error(f"{mode} 토큰 발급 예외: {e}")
return False
# ─────────────────────────────────────────────────────────────────────────────
# 공개 API
# ─────────────────────────────────────────────────────────────────────────────
def ensure_token(is_mock: bool, env: dict = None) -> bool:
"""
단일 모드(실전/모의) 토큰 유효성 확인 + 필요 시 자동 갱신.
이미 유효한 경우 → 발급 없이 True 반환 (23시간 정책 자동 준수).
"""
status = get_token_status(is_mock)
mode = "모의" if is_mock else "실전"
if status["valid"]:
logger.info(
f"🔑 {mode} 토큰 유효 ({status['expires_in_h']:.1f}h 남음) → 재사용"
)
return True
logger.info(f"{mode} 토큰 만료/없음 → 갱신 시도 (남은시간: {status['expires_in_h']:.1f}h)")
if env is None:
env = _load_env()
key_suffix = "MOCK" if is_mock else "REAL"
app_key = str(env.get(f"KIS_APP_KEY_{key_suffix}", "") or "").strip()
app_secret = str(env.get(f"KIS_APP_SECRET_{key_suffix}", "") or "").strip()
if not app_key or not app_secret:
logger.warning(f"⚠️ {mode} 키 없음 (KIS_APP_KEY_{key_suffix}) → 발급 불가")
return False
if not _acquire_lock():
return False
try:
# 잠금 획득 후 다시 확인 (다른 프로세스가 갱신했을 수 있음)
status = get_token_status(is_mock)
if status["valid"]:
logger.info(f"🔑 {mode} 토큰 이미 갱신됨 (다른 프로세스) → 재사용")
return True
return _issue_token(app_key, app_secret, is_mock)
finally:
_release_lock()
def ensure_both_tokens(env: dict = None) -> dict:
"""
실전 + 모의 토큰 모두 유효하게 유지.
어느 모드로 봇을 실행하든 두 토큰을 동시에 관리.
만료된 것만 갱신 → KIS 23시간 정책 자동 준수.
반환: {"real": True/False, "mock": True/False}
"""
if env is None:
env = _load_env()
results = {}
# 실전 먼저 (더 중요, 잠금 경쟁 최소화)
for is_mock in [False, True]:
mode = "mock" if is_mock else "real"
results[mode] = ensure_token(is_mock, env)
ok_str = " | ".join(
f"{'모의' if k == 'mock' else '실전'}={'' if v else ''}"
for k, v in results.items()
)
logger.info(f"🔄 토큰 상태: {ok_str}")
return results
# ─────────────────────────────────────────────────────────────────────────────
# KisTokenManager — KiwoomTokenManager 와 동일한 get_token() 인터페이스
#
# 동작 원리:
# 1. 메모리 캐시 (_token, _expiry) — 파일 읽기 최소화
# 2. JSON 파일 캐시 — 크로스 프로세스 공유 + 재시작 후 토큰 유지
# 3. 만료 10분 전 선제 갱신 — API 에러(EGW00123) 없이 자동 교체
#
# KIS 에는 토큰 상태 조회 API 가 없음 → 발급 시 받은 expires_dt 를
# 서버 현재시간과 비교해서 판단 (JSON 파일에 저장된 날짜 사용)
#
# 봇에서 사용:
# from kis_token_manager import KisTokenManager
# token = KisTokenManager.instance(is_mock).get_token() # 매 API 호출 전
# ─────────────────────────────────────────────────────────────────────────────
_km_instances: dict = {} # is_mock(bool) → KisTokenManager 싱글톤
_km_instances_lock = threading.Lock()
class KisTokenManager:
"""
KIS 토큰 싱글톤 매니저.
- get_token(): 유효하면 바로 반환, 만료 10분 전부터 선제 갱신
- kiwoom_rest_api TokenManager / KiwoomTokenManager 와 동일한 인터페이스
"""
@classmethod
def instance(cls, is_mock: bool) -> "KisTokenManager":
"""프로세스당 실전/모의 각 1개 싱글톤 반환"""
with _km_instances_lock:
if is_mock not in _km_instances:
_km_instances[is_mock] = cls(is_mock)
return _km_instances[is_mock]
def __init__(self, is_mock: bool):
self._is_mock = is_mock
self._mode_str = "모의" if is_mock else "실전"
self._cache_path = CACHE_MOCK if is_mock else CACHE_REAL
self._lock = threading.Lock()
self._token: Optional[str] = None
self._expiry: Optional[datetime] = None
self._load_from_file() # 재시작 후에도 기존 토큰 재사용
# ── 내부 ──────────────────────────────────────────────────────
def _load_from_file(self) -> None:
"""JSON 파일 → 메모리 로드"""
if not self._cache_path.exists():
return
try:
data = json.loads(self._cache_path.read_text(encoding="utf-8"))
token = data.get("access_token", "")
exp_dt = _parse_expired(data.get("access_token_token_expired", ""))
if token and exp_dt:
self._token = token
self._expiry = exp_dt
except Exception as e:
logger.debug("KIS 파일 캐시 로드 실패 [%s]: %s", self._mode_str, e)
def _is_valid(self) -> bool:
"""
만료 10분 전부터 유효하지 않다고 판단 → 선제 갱신 트리거.
KIS에는 토큰 상태 조회 API 없음 → JSON 파일의 expires_dt 와 서버시간 비교.
"""
if not self._token or not self._expiry:
return False
return datetime.now() < self._expiry - timedelta(minutes=EXPIRE_MARGIN_M)
# ── 공개 ──────────────────────────────────────────────────────
def get_token(self) -> Optional[str]:
"""
유효한 토큰 반환. 만료 10분 전 자동 갱신.
매수/매도 API 호출 직전마다 호출해도 안전 (갱신은 만료 임박 시만).
흐름:
1. 메모리 캐시 유효 → 즉시 반환 (파일 읽기 없음)
2. 메모리 만료 → 파일 재로드 (다른 프로세스가 갱신했을 수 있음)
3. 파일도 만료 → ensure_token() 으로 신규 발급 + 파일/메모리 갱신
"""
with self._lock:
if self._is_valid():
return self._token
# 다른 프로세스가 갱신했을 수 있으니 파일 재로드
self._load_from_file()
if self._is_valid():
logger.info("🔑 %s 토큰 파일 갱신 감지 → 메모리 갱신", self._mode_str)
return self._token
# 파일도 만료 → 잠금 기반 신규 발급
ok = ensure_token(self._is_mock)
if ok:
with self._lock:
self._load_from_file()
logger.info(
"🔄 %s 토큰 선제 갱신 완료 (만료 10분 전) → 앞8자: %s",
self._mode_str,
(self._token[:8] if self._token else "N/A"),
)
return self._token if ok else None
def status(self) -> dict:
"""현재 상태 반환 (get_token_status 래퍼, 남은 시간 포함)"""
return get_token_status(self._is_mock)
# ─────────────────────────────────────────────────────────────────────────────
# CLI: 상태 확인 / 강제 갱신
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
import logging as _lg
_lg.basicConfig(
level=_lg.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
parser = argparse.ArgumentParser(description="KIS 토큰 관리자")
parser.add_argument("--refresh", action="store_true", help="만료 여부와 관계없이 강제 갱신 시도")
args = parser.parse_args()
print("\n" + "=" * 55)
print(" KIS 토큰 상태")
print("=" * 55)
for is_mock, label in [(False, "실전"), (True, "모의")]:
s = get_token_status(is_mock)
flag = "✅ 유효" if s["valid"] else "❌ 만료/없음"
print(f" {label:4s} | {flag} | 만료: {s['expires']} ({s['expires_in_h']:+.1f}h)")
print("=" * 55)
if args.refresh:
print("\n강제 갱신 시도 중...")
# 캐시 무효화 후 재발급
for path in [CACHE_REAL, CACHE_MOCK]:
if path.exists():
path.unlink()
print(f" 캐시 삭제: {path.name}")
result = ensure_both_tokens()
print("\n갱신 결과:", result, "\n")

1545
kis_ws.py Normal file

File diff suppressed because it is too large Load Diff

View File

0
kiwoom_rest_api/api.py Normal file
View File

View File

View File

View File

@@ -0,0 +1,109 @@
from datetime import datetime, timedelta
from typing import Dict, Optional, Any
import time
from kiwoom_rest_api.config import get_api_key, get_api_secret, TOKEN_URL
from kiwoom_rest_api.core.sync_client import make_request
class TokenManager:
"""Manages OAuth tokens for Kiwoom API"""
def __init__(self):
self._access_token = None
self._token_expiry = None
self._refresh_token = None
self._refresh_expiry = None
@property
def access_token(self) -> Optional[str]:
"""Get the current access token, refreshing if necessary"""
if self._is_access_token_valid():
return self._access_token
# Try to refresh the token
if self._can_refresh_token():
self._refresh_access_token()
return self._access_token
# Get a new token
self._request_new_token()
return self._access_token
def get_token(self) -> str:
"""Get the current access token (alias for access_token property)"""
return self.access_token
def _is_access_token_valid(self) -> bool:
"""Check if the current access token is valid"""
if not self._access_token or not self._token_expiry:
return False
# Add a small buffer (30 seconds) to avoid edge cases
return datetime.now() < self._token_expiry - timedelta(seconds=30)
def _can_refresh_token(self) -> bool:
"""Check if we can refresh the current token"""
if not self._refresh_token or not self._refresh_expiry:
return False
return datetime.now() < self._refresh_expiry - timedelta(seconds=30)
def _request_new_token(self) -> None:
"""Request a new access token"""
response = make_request(
endpoint=TOKEN_URL,
method="POST",
data={
"grant_type": "client_credentials",
"appkey": get_api_key(),
"secretkey": get_api_secret(),
},
)
self._update_token_info(response)
def _refresh_access_token(self) -> None:
"""Refresh the access token using the refresh token"""
response = make_request(
endpoint=TOKEN_URL,
method="POST",
data={
"grant_type": "refresh_token",
"refresh_token": self._refresh_token,
"appkey": get_api_key(),
"appsecret": get_api_secret(),
},
)
self._update_token_info(response)
def _update_token_info(self, token_response: Dict[str, Any]) -> None:
"""Update token information from the API response"""
self._access_token = token_response.get("token")
# Calculate expiry time
if "expires_in" in token_response:
self._token_expiry = datetime.now() + timedelta(seconds=token_response["expires_in"])
if "expires_dt" in token_response:
self._token_expiry = datetime.strptime(token_response["expires_dt"], "%Y%m%d%H%M%S")
# Update refresh token if provided
refresh_token = token_response.get("refresh_token")
if refresh_token:
self._refresh_token = refresh_token
if "refresh_token_expires_in" in token_response:
self._refresh_expiry = datetime.now() + timedelta(seconds=token_response["refresh_token_expires_in"])
# Convenience functions
def get_access_token() -> str:
"""Get a valid access token"""
manager = TokenManager()
return manager.get_token()
if __name__ == "__main__":
print(get_access_token())

View File

View File

@@ -0,0 +1,88 @@
import os
import typer
import json
from rich import print as rprint # print_json 대신 사용할 수 있음
from rich.pretty import pprint # 객체 예쁘게 출력
# 필요한 클래스 임포트
from kiwoom_rest_api.koreanstock.stockinfo import StockInfo
from kiwoom_rest_api.auth.token import TokenManager
from kiwoom_rest_api.core.base import APIError
# Typer 앱 인스턴스 생성
# no_args_is_help=True: 인자 없이 실행 시 도움말 표시
app = typer.Typer(no_args_is_help=True, add_completion=False)
# --- 앱의 기본 동작 (선택 사항, 도움말 개선 등) ---
@app.callback()
def main_callback(ctx: typer.Context):
"""
키움증권 Open API CLI 도구
"""
# 서브커맨드가 없으면 도움말 표시 (Typer가 기본 처리)
# 여기에 앱 전역 설정을 추가할 수도 있음
pass
# --- ka10001 서브커맨드 정의 ---
@app.command()
def ka10001(
stock_code: str = typer.Argument(..., help="조회할 주식 종목 코드 (예: 005930)"),
api_key: str = typer.Option(
None, "--api-key", "-k",
help="키움증권 API Key (환경 변수 KIWOOM_API_KEY)",
envvar="KIWOOM_API_KEY",
show_envvar=True,
),
api_secret: str = typer.Option(
None, "--api-secret", "-s",
help="키움증권 API Secret (환경 변수 KIWOOM_API_SECRET)",
envvar="KIWOOM_API_SECRET",
show_envvar=True,
),
base_url: str = typer.Option(
"https://api.kiwoom.com",
"--base-url", "-u",
help="API 기본 URL"
),
):
"""
주식 기본 정보 요청 (KA10001) API를 호출합니다.
"""
if not api_key:
typer.secho("오류: API Key가 제공되지 않았습니다.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
if not api_secret:
typer.secho("오류: API Secret이 제공되지 않았습니다.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
typer.echo(f"종목 코드 {stock_code} 요청 시작 (URL: {base_url})")
try:
token_manager = TokenManager()
stock_info = StockInfo(base_url=base_url, token_manager=token_manager, use_async=False)
result = stock_info.basic_stock_information_request_ka10001(stock_code)
typer.echo("\n--- API 응답 ---")
# rich의 pprint 사용 (print_json 대신)
pprint(result, expand_all=True)
typer.echo("----------------")
except APIError as e:
typer.secho(f"\nAPI 오류 (HTTP {e.status_code}): {e.message}", fg=typer.colors.RED, err=True)
if e.error_data:
typer.echo("오류 데이터:", err=True)
pprint(e.error_data, expand_all=True)
raise typer.Exit(code=1)
except Exception as e:
typer.secho(f"\n예상치 못한 오류: {type(e).__name__}", fg=typer.colors.RED, err=True)
typer.secho(f"메시지: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
# --- 다른 서브커맨드 추가 가능 ---
# @app.command()
# def another_command(...):
# ...
# --- 메인 실행 블록 ---
if __name__ == "__main__":
app()

60
kiwoom_rest_api/config.py Normal file
View File

@@ -0,0 +1,60 @@
import os
from typing import Optional
# Base URLs
DEFAULT_BASE_URL = os.environ.get("KIWOOM_DEFAULT_BASE_URL", "https://api.kiwoom.com")
SANDBOX_BASE_URL = os.environ.get("KIWOOM_SANDBOX_BASE_URL", "https://mockapi.kiwoom.com")
# WebSocket URLs
DEFAULT_WS_URL = os.environ.get("KIWOOM_DEFAULT_WS_URL", "wss://api.kiwoom.com:10000")
SANDBOX_WS_URL = os.environ.get("KIWOOM_SANDBOX_WS_URL", "wss://mockapi.kiwoom.com:10000")
WS_ENDPOINT = "/api/dostk/websocket"
# API Credentials
API_KEY = os.environ.get("KIWOOM_API_KEY", "")
API_SECRET = os.environ.get("KIWOOM_API_SECRET", "")
# Authentication
TOKEN_URL = "/oauth2/token"
AUTH_URL = "/oauth2/authorize"
# Timeouts
DEFAULT_TIMEOUT = 30.0 # seconds
WS_TIMEOUT = 10.0 # seconds
# Environment setting
USE_SANDBOX = os.environ.get("KIWOOM_USE_SANDBOX", "false").lower() == "true"
def get_base_url() -> str:
"""Return the base URL based on environment settings"""
if USE_SANDBOX:
return SANDBOX_BASE_URL
return DEFAULT_BASE_URL
def get_ws_url() -> str:
"""Return the WebSocket URL based on environment settings"""
base_ws_url = SANDBOX_WS_URL if USE_SANDBOX else DEFAULT_WS_URL
return f"{base_ws_url}{WS_ENDPOINT}"
def get_api_key() -> str:
"""Return the API key"""
return API_KEY
def get_api_secret() -> str:
"""Return the API secret"""
return API_SECRET
def get_headers(access_token: Optional[str] = None) -> dict:
"""Return common headers for API requests"""
headers = {
"Content-Type": "application/json;charset=UTF-8",
}
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
else:
headers["appkey"] = get_api_key()
headers["appsecret"] = get_api_secret()
return headers

View File

View File

@@ -0,0 +1,49 @@
from typing import Any, Dict, Optional
import httpx
from kiwoom_rest_api.core.base import prepare_request_params, process_response_async
async def make_request_async(
endpoint: str,
method: str = "GET",
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
access_token: Optional[str] = None,
timeout: Optional[float] = None,
**kwargs # Add **kwargs
) -> Dict[str, Any]:
"""Make an asynchronous HTTP request to the Kiwoom API"""
request_params = prepare_request_params(
endpoint=endpoint,
method=method,
params=params,
data=data,
headers=headers,
access_token=access_token,
timeout=timeout,
)
# Handle 'json' data from kwargs
json_data = kwargs.get('json')
if json_data and method in ["POST", "PUT", "PATCH"]:
# Prioritize explicitly passed 'json' data
request_params["json"] = json_data
# If 'data' was also prepared, 'json' takes precedence here.
# Remove 'data' if 'json' is being used to avoid conflicts in httpx
request_params.pop("data", None)
async with httpx.AsyncClient() as client:
# This should return an httpx.Response object
response: httpx.Response = await client.request(
method=request_params["method"],
url=request_params["url"],
params=request_params.get("params"),
json=request_params.get("json"),
data=request_params.get("data"),
headers=request_params["headers"],
timeout=request_params["timeout"],
)
return await process_response_async(response)

View File

@@ -0,0 +1,224 @@
from typing import Any, Dict, Optional, Union
import json
from urllib.parse import urljoin
import httpx
import inspect # Import inspect
from kiwoom_rest_api.config import get_base_url, get_headers, DEFAULT_TIMEOUT
class APIError(Exception):
"""Custom exception for API errors"""
def __init__(self, status_code: int, message: str, error_data: dict = None):
self.status_code = status_code
self.message = message
self.error_data = error_data or {}
super().__init__(f"API Error (HTTP {status_code}): {message}")
def __str__(self):
return f"API Error (HTTP {self.status_code}): {self.message}"
def make_url(endpoint: str) -> str:
"""Create a full URL from an endpoint"""
if endpoint.startswith(('http://', 'https://')):
return endpoint
# Ensure endpoint starts with a forward slash
if not endpoint.startswith('/'):
endpoint = f"/{endpoint}"
print("\n\n## full url ##\n\n", urljoin(get_base_url(), endpoint))
return urljoin(get_base_url(), endpoint)
def process_response(response: Any) -> Dict[str, Any]:
"""Process API response and handle errors"""
if not hasattr(response, 'status_code'):
raise ValueError(f"Invalid response object: {response}")
if 200 <= response.status_code < 300:
if not response.text:
return {}
try:
response_json = response.json()
access_control_expose_headers = response.headers.get("access-control-expose-headers")
if access_control_expose_headers:
access_control_expose_headers = access_control_expose_headers.split(",")
for header in access_control_expose_headers:
response_json[header] = response.headers.get(header)
return response_json
return response_json
except json.JSONDecodeError:
return {"content": response.text}
# Handle error responses
error_message = "Unknown error"
error_data = None
try:
error_data = response.json()
error_message = error_data.get("message", "Unknown error")
except (json.JSONDecodeError, AttributeError):
if response.text:
error_message = response.text
raise APIError(response.status_code, error_message, error_data)
def prepare_request_params(
endpoint: str,
method: str = "GET",
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
access_token: Optional[str] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""Prepare request parameters for HTTP request"""
# 헤더 정규화
normalized_headers = {}
if headers:
for key, value in headers.items():
# 모든 헤더 키를 소문자로 변환하여 중복 방지
normalized_headers[key.lower()] = value
# 기본 헤더 설정
default_headers = {
"content-type": "application/json;charset=UTF-8",
}
# API 키 추가
from kiwoom_rest_api.config import get_api_key, get_api_secret
default_headers["appkey"] = get_api_key()
default_headers["appsecret"] = get_api_secret()
# 헤더 병합 (사용자 정의 헤더가 기본 헤더보다 우선)
merged_headers = {**default_headers, **normalized_headers}
# 액세스 토큰 추가
if access_token:
merged_headers["authorization"] = f"Bearer {access_token}"
# URL 구성
from kiwoom_rest_api.config import get_base_url
url = endpoint if endpoint.startswith(("http://", "https://")) else f"{get_base_url()}{endpoint}"
# 요청 파라미터 구성
request_params = {
"url": url,
"method": method,
"headers": merged_headers,
"timeout": timeout or DEFAULT_TIMEOUT,
}
# 쿼리 파라미터 추가
if params:
request_params["params"] = params
# POST/PUT/PATCH 요청용 데이터 추가
if method in ["POST", "PUT", "PATCH"] and data:
if merged_headers.get("content-type", "").startswith("application/json"):
request_params["json"] = data
else:
request_params["data"] = data
return request_params
async def process_response_async(response: httpx.Response) -> Dict[str, Any]:
if not isinstance(response, httpx.Response):
print("ERROR: process_response_async did not receive an httpx.Response object!")
raise TypeError(f"Expected httpx.Response, but got {type(response)}")
# --- 추가 디버깅 ---
json_method = getattr(response, 'json', None)
is_json_coro = inspect.iscoroutinefunction(json_method)
print(f"DEBUG: inspect.iscoroutinefunction(response.json) = {is_json_coro}")
# --- 추가 디버깅 끝 ---
try:
# 성공(200) 응답 처리
if response.status_code == 200:
try:
# 여기서 여전히 TypeError 발생 가능성 있음
json_data = await response.json()
access_control_expose_headers = response.headers.get("access-control-expose-headers")
if access_control_expose_headers:
access_control_expose_headers = access_control_expose_headers.split(",")
for header in access_control_expose_headers:
json_data[header] = response.headers.get(header)
if isinstance(json_data, dict) and str(json_data.get("return_code")) != "0":
error_message = json_data.get("return_msg", "Unknown API error message")
raise APIError(response.status_code, error_message, json_data)
return json_data
except json.JSONDecodeError:
# 여기서도 TypeError 발생 가능성 있음
raw_text_content = await response.text()
error_message = f"Failed to decode JSON response. Content: {raw_text_content[:200]}"
raise APIError(response.status_code, error_message, {"raw_content": raw_text_content})
except TypeError as te: # await 실패 시
print(f"ERROR: TypeError on SUCCESS path await: {te}")
# await 없이 직접 접근 시도 (진단용)
try:
json_data = response.json() # await 없이 호출
access_control_expose_headers = response.headers.get("access-control-expose-headers")
if access_control_expose_headers:
access_control_expose_headers = access_control_expose_headers.split(",")
for header in access_control_expose_headers:
json_data[header] = response.headers.get(header)
if isinstance(json_data, dict) and str(json_data.get("return_code")) != "0":
error_message = json_data.get("return_msg", "Unknown API error message")
raise APIError(response.status_code, error_message, json_data)
return json_data # 성공하면 반환
except Exception as direct_err:
print(f"ERROR: Direct access failed after TypeError: {direct_err}")
raw_text_content = getattr(response, 'text', 'N/A') # text 속성 접근 시도
raise APIError(response.status_code, f"TypeError processing SUCCESS response: {te}. Raw content: {raw_text_content[:200]}", {"raw_content": raw_text_content})
# HTTP 에러(400 등) 처리
else:
error_message = f"HTTP Error {response.status_code}"
error_data = {"status_code": response.status_code}
raw_text_content = "Could not retrieve error content"
# --- 진단: await 없이 text 속성 직접 접근 시도 ---
try:
if hasattr(response, 'text') and isinstance(response.text, str):
print("DEBUG: Accessing response.text directly as attribute.")
raw_text_content = response.text
error_data["raw_content"] = raw_text_content
error_message += f". Content: {raw_text_content[:500]}" # 내용 조금 더 보기
# 텍스트 내용으로 JSON 파싱 시도
try:
error_json = json.loads(raw_text_content)
error_msg1 = error_json.get("msg1", "No msg1 found in error JSON")
error_message = f"HTTP Error {response.status_code}: {error_msg1}" # 에러 메시지 개선
error_data.update(error_json)
except json.JSONDecodeError:
print("DEBUG: Error response body is not JSON.")
else:
print("DEBUG: response.text is not a direct string attribute.")
# 여기서 await response.text()를 시도하면 TypeError 발생 가능성 높음
except Exception as e_diag:
print(f"ERROR: Exception during diagnostic access of response text: {e_diag}")
# --- 진단 끝 ---
# 최종 에러 발생
raise APIError(response.status_code, error_message, error_data)
except httpx.RequestError as e:
# 네트워크 관련 에러
raise APIError(500, f"Request failed: {str(e)}", {"exception": str(e)})

View File

@@ -0,0 +1,56 @@
from typing import Optional
from kiwoom_rest_api.core.sync_client import make_request
from kiwoom_rest_api.core.async_client import make_request_async
class KiwoomBaseAPI:
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = ""
):
self.base_url = base_url
self.token_manager = token_manager
self.use_async = use_async
self.resource_url = resource_url
self._request_func = make_request_async if use_async else make_request
def _get_access_token(self) -> Optional[str]:
if self.token_manager:
return self.token_manager.get_token()
return None
async def _get_access_token_async(self) -> Optional[str]:
if self.token_manager and hasattr(self.token_manager, 'get_token_async'):
return await self.token_manager.get_token_async()
return self._get_access_token()
def _make_request(self, method: str, url: str, **kwargs):
headers = kwargs.pop("headers", {})
headers["content-type"] = "application/json;charset=UTF-8"
if self.token_manager:
access_token = self._get_access_token()
headers["Authorization"] = f"Bearer {access_token}"
return make_request(endpoint=url, method=method, headers=headers, **kwargs)
async def _make_request_async(self, method: str, url: str, **kwargs):
headers = kwargs.pop("headers", {})
headers["content-type"] = "application/json;charset=UTF-8"
if self.token_manager:
access_token = await self._get_access_token_async()
headers["Authorization"] = f"Bearer {access_token}"
return await make_request_async(endpoint=url, method=method, headers=headers, **kwargs)
def _execute_request(self, method: str, resource_url: str = None, **kwargs):
# resource_url이 제공되면 임시로 사용, 아니면 기본값 사용
url_resource = resource_url if resource_url is not None else self.resource_url
#url = f"{self.base_url}{url_resource}" if self.base_url else f"/{url_resource}"
if self.base_url:
# base_url 끝의 /와 url_resource 앞의 /를 모두 떼고 중간에 / 하나만 넣음
url = f"{self.base_url.rstrip('/')}/{url_resource.lstrip('/')}"
else:
url = f"/{url_resource.lstrip('/')}"
if self.use_async:
return self._make_request_async(method, url, **kwargs)
return self._make_request(method, url, **kwargs)

View File

@@ -0,0 +1,43 @@
from typing import Any, Dict, Optional
import httpx
from kiwoom_rest_api.core.base import prepare_request_params, process_response
def make_request(
endpoint: str,
method: str = "GET",
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
access_token: Optional[str] = None,
timeout: Optional[float] = None,
**kwargs: Any
) -> Dict[str, Any]:
"""Make a synchronous HTTP request to the Kiwoom API"""
request_params = prepare_request_params(
endpoint=endpoint,
method=method,
params=params,
data=data,
headers=headers,
access_token=access_token,
timeout=timeout,
)
# 추가: kwargs에서 json 데이터 처리
if 'json' in kwargs and method in ["POST", "PUT", "PATCH"]:
request_params["json"] = kwargs['json']
with httpx.Client() as client:
response = client.request(
method=request_params["method"],
url=request_params["url"],
params=request_params.get("params"),
json=request_params.get("json"),
headers=request_params["headers"],
timeout=request_params["timeout"],
)
return process_response(response)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
from typing import Dict, Optional, Any
from kiwoom_rest_api.core.sync_client import make_request
def get_per_analysis(
market_code: str = "0",
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
PER/PBR/배당수익률 (KA-STOCK-010)
Args:
market_code: 시장분류코드 (0:전체, 1:코스피, 2:코스닥)
access_token: OAuth 액세스 토큰
Returns:
PER/PBR/배당수익률 데이터
"""
endpoint = "/stock/per"
params = {
"FID_COND_MRKT_DIV_CODE": market_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_rapid_price_change(
market_code: str = "0",
sort_code: str = "1",
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
급등락 종목 (KA-STOCK-009)
Args:
market_code: 시장분류코드 (0:전체, 1:코스피, 2:코스닥)
sort_code: 정렬구분 (1:급등, 2:급락)
access_token: OAuth 액세스 토큰
Returns:
급등락 종목 데이터
"""
endpoint = "/stock/rapid"
params = {
"FID_COND_MRKT_DIV_CODE": market_code,
"FID_INPUT_ISCD": sort_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_price_ranges(
stock_code: str,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
가격 매물대 조회 (KA-STOCK-012)
Args:
stock_code: 종목코드 (6자리)
access_token: OAuth 액세스 토큰
Returns:
가격 매물대 데이터
"""
endpoint = "/stock/price-ranges"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_stock_trend(
stock_code: str,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
주가 이격도 추이 (KA-STOCK-011)
Args:
stock_code: 종목코드 (6자리)
access_token: OAuth 액세스 토큰
Returns:
주가 이격도 데이터
"""
endpoint = "/stock/trend"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)

View File

@@ -0,0 +1,739 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class Chart(KiwoomBaseAPI):
"""한국 주식 섹터 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/chart"
):
"""
Chart 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def stockwise_investor_institution_chart_request_ka10060(
self,
dt: str,
stk_cd: str,
amt_qty_tp: str,
trde_tp: str,
unit_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
종목별투자자기관별차트요청 (ka10060)
Args:
dt (str): 일자 (YYYYMMDD)
stk_cd (str): 종목코드
amt_qty_tp (str): 금액수량구분 (1:금액, 2:수량)
trde_tp (str): 매매구분 (0:순매수, 1:매수, 2:매도)
unit_tp (str): 단위구분 (1000:천주, 1:단주)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 종목별투자자기관별차트 데이터
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10060",
}
data = {
"dt": dt,
"stk_cd": stk_cd,
"amt_qty_tp": amt_qty_tp,
"trde_tp": trde_tp,
"unit_tp": unit_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def intraday_investor_trading_chart_request_ka10064(
self,
mrkt_tp: str,
amt_qty_tp: str,
trde_tp: str,
stk_cd: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
장중투자자별매매차트요청 (ka10064)
Args:
mrkt_tp (str): 시장구분 (000:전체, 001:코스피, 101:코스닥)
amt_qty_tp (str): 금액수량구분 (1:금액, 2:수량)
trde_tp (str): 매매구분 (0:순매수, 1:매수, 2:매도)
stk_cd (str): 종목코드
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 장중투자자별매매차트 데이터
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10064",
}
data = {
"mrkt_tp": mrkt_tp,
"amt_qty_tp": amt_qty_tp,
"trde_tp": trde_tp,
"stk_cd": stk_cd,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_tick_chart_request_ka10079(
self,
stk_cd: str,
tic_scope: str,
upd_stkpc_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
주식틱차트조회요청 (ka10079)
Args:
stk_cd (str): 종목코드 (거래소별 종목코드 KRX:039490,NXT:039490_NX,SOR:039490_AL)
tic_scope (str): 틱범위 (1:1틱, 3:3틱, 5:5틱, 10:10틱, 30:30틱)
upd_stkpc_tp (str): 수정주가구분 (0 or 1)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식틱차트 데이터
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10079",
}
data = {
"stk_cd": stk_cd,
"tic_scope": tic_scope,
"upd_stkpc_tp": upd_stkpc_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_minute_chart_request_ka10080(
self,
stk_cd: str,
tic_scope: str,
upd_stkpc_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
주식분봉차트조회요청 (ka10080)
Args:
stk_cd (str): 종목코드 (거래소별 종목코드 KRX:039490,NXT:039490_NX,SOR:039490_AL)
tic_scope (str): 틱범위 (1:1분, 3:3분, 5:5분, 10:10분, 15:15분, 30:30분, 45:45분, 60:60분)
upd_stkpc_tp (str): 수정주가구분 (0 or 1)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식분봉차트 데이터
- stk_cd (str): 종목코드
- stk_min_pole_chart_qry (list): 주식분봉차트조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- cntr_tm (str): 체결시간
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- upd_stkpc_tp (str): 수정주가구분
- upd_rt (str): 수정비율
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- upd_stkpc_event (str): 수정주가이벤트
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10080",
}
data = {
"stk_cd": stk_cd,
"tic_scope": tic_scope,
"upd_stkpc_tp": upd_stkpc_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_daily_chart_request_ka10081(
self,
stk_cd: str,
base_dt: str,
upd_stkpc_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
주식일봉차트조회요청 (ka10081)
Args:
stk_cd (str): 종목코드 (거래소별 종목코드 KRX:039490,NXT:039490_NX,SOR:039490_AL)
base_dt (str): 기준일자 (YYYYMMDD)
upd_stkpc_tp (str): 수정주가구분 (0 or 1)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식일봉차트 데이터
- stk_cd (str): 종목코드
- stk_dt_pole_chart_qry (list): 주식일봉차트조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- trde_prica (str): 거래대금
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- upd_stkpc_tp (str): 수정주가구분
- upd_rt (str): 수정비율
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- upd_stkpc_event (str): 수정주가이벤트
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10081",
}
data = {
"stk_cd": stk_cd,
"base_dt": base_dt,
"upd_stkpc_tp": upd_stkpc_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_weekly_chart_request_ka10082(
self,
stk_cd: str,
base_dt: str,
upd_stkpc_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
주식주봉차트조회요청 (ka10082)
Args:
stk_cd (str): 종목코드 (거래소별 종목코드 KRX:039490,NXT:039490_NX,SOR:039490_AL)
base_dt (str): 기준일자 (YYYYMMDD)
upd_stkpc_tp (str): 수정주가구분 (0 or 1)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식주봉차트 데이터
- stk_cd (str): 종목코드
- stk_stk_pole_chart_qry (list): 주식주봉차트조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- trde_prica (str): 거래대금
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- upd_stkpc_tp (str): 수정주가구분 (1:유상증자, 2:무상증자, 4:배당락, 8:액면분할, 16:액면병합, 32:기업합병, 64:감자, 256:권리락)
- upd_rt (str): 수정비율
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- upd_stkpc_event (str): 수정주가이벤트
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10082",
}
data = {
"stk_cd": stk_cd,
"base_dt": base_dt,
"upd_stkpc_tp": upd_stkpc_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_monthly_chart_request_ka10083(
self,
stk_cd: str,
base_dt: str,
upd_stkpc_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
주식월봉차트조회요청 (ka10083)
Args:
stk_cd (str): 종목코드 (거래소별 종목코드 KRX:039490,NXT:039490_NX,SOR:039490_AL)
base_dt (str): 기준일자 (YYYYMMDD)
upd_stkpc_tp (str): 수정주가구분 (0 or 1)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식월봉차트 데이터
- stk_cd (str): 종목코드
- stk_mth_pole_chart_qry (list): 주식월봉차트조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- trde_prica (str): 거래대금
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- upd_stkpc_tp (str): 수정주가구분 (1:유상증자, 2:무상증자, 4:배당락, 8:액면분할, 16:액면병합, 32:기업합병, 64:감자, 256:권리락)
- upd_rt (str): 수정비율
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- upd_stkpc_event (str): 수정주가이벤트
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10083",
}
data = {
"stk_cd": stk_cd,
"base_dt": base_dt,
"upd_stkpc_tp": upd_stkpc_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_yearly_chart_request_ka10094(
self,
stk_cd: str,
base_dt: str,
upd_stkpc_tp: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
주식년봉차트조회요청 (ka10094)
Args:
stk_cd (str): 종목코드 (거래소별 종목코드 KRX:039490,NXT:039490_NX,SOR:039490_AL)
base_dt (str): 기준일자 (YYYYMMDD)
upd_stkpc_tp (str): 수정주가구분 (0 or 1)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식년봉차트 데이터
- stk_cd (str): 종목코드
- stk_yr_pole_chart_qry (list): 주식년봉차트조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- trde_prica (str): 거래대금
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- upd_stkpc_tp (str): 수정주가구분 (1:유상증자, 2:무상증자, 4:배당락, 8:액면분할, 16:액면병합, 32:기업합병, 64:감자, 256:권리락)
- upd_rt (str): 수정비율
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- upd_stkpc_event (str): 수정주가이벤트
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10094",
}
data = {
"stk_cd": stk_cd,
"base_dt": base_dt,
"upd_stkpc_tp": upd_stkpc_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_tick_chart_request_ka20004(
self,
inds_cd: str,
tic_scope: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
업종틱차트조회요청 (ka20004)
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
tic_scope (str): 틱범위 (1:1틱, 3:3틱, 5:5틱, 10:10틱, 30:30틱)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종틱차트 데이터
- inds_cd (str): 업종코드
- inds_tic_chart_qry (list): 업종틱차트조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- cntr_tm (str): 체결시간
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20004",
}
data = {
"inds_cd": inds_cd,
"tic_scope": tic_scope,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_minute_chart_request_ka20005(
self,
inds_cd: str,
tic_scope: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
업종분봉조회요청 (ka20005)
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
tic_scope (str): 틱범위 (1:1분, 3:3분, 5:5분, 10:10분, 15:15분, 30:30분, 45:45분, 60:60분)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종분봉차트 데이터
- inds_cd (str): 업종코드
- inds_min_pole_qry (list): 업종분봉조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- cntr_tm (str): 체결시간
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20005",
}
data = {
"inds_cd": inds_cd,
"tic_scope": tic_scope,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_daily_chart_request_ka20006(
self,
inds_cd: str,
base_dt: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
업종일봉조회요청 (ka20006)
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
base_dt (str): 기준일자 (YYYYMMDD)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종일봉차트 데이터
- inds_cd (str): 업종코드
- inds_dt_pole_qry (list): 업종일봉조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- trde_prica (str): 거래대금
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20006",
}
data = {
"inds_cd": inds_cd,
"base_dt": base_dt,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_weekly_chart_request_ka20007(
self,
inds_cd: str,
base_dt: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
업종주봉조회요청 (ka20007)
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
base_dt (str): 기준일자 (YYYYMMDD)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종주봉차트 데이터
- inds_cd (str): 업종코드
- inds_stk_pole_qry (list): 업종주봉조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- trde_prica (str): 거래대금
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20007",
}
data = {
"inds_cd": inds_cd,
"base_dt": base_dt,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_monthly_chart_request_ka20008(
self,
inds_cd: str,
base_dt: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
업종월봉조회요청 (ka20008)
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
base_dt (str): 기준일자 (YYYYMMDD)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종월봉차트 데이터
- inds_cd (str): 업종코드
- inds_mth_pole_qry (list): 업종월봉조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- trde_prica (str): 거래대금
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20008",
}
data = {
"inds_cd": inds_cd,
"base_dt": base_dt,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_yearly_chart_request_ka20019(
self,
inds_cd: str,
base_dt: str,
cont_yn: str = "N",
next_key: str = ""
) -> dict:
"""
업종년봉조회요청 (ka20019)
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
base_dt (str): 기준일자 (YYYYMMDD)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종년봉차트 데이터
- inds_cd (str): 업종코드
- inds_yr_pole_qry (list): 업종년봉조회 데이터 리스트
- cur_prc (str): 현재가
- trde_qty (str): 거래량
- dt (str): 일자
- open_pric (str): 시가
- high_pric (str): 고가
- low_pric (str): 저가
- trde_prica (str): 거래대금
- bic_inds_tp (str): 대업종구분
- sm_inds_tp (str): 소업종구분
- stk_infr (str): 종목정보
- pred_close_pric (str): 전일종가
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20019",
}
data = {
"inds_cd": inds_cd,
"base_dt": base_dt,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

View File

@@ -0,0 +1,291 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class CreditOrder(KiwoomBaseAPI):
"""한국 주식 신용매매 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/crdordr"
):
"""
CreditOrder 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def margin_buy_order_request_kt10006(
self,
dmst_stex_tp: str,
stk_cd: str,
ord_qty: str,
trde_tp: str,
ord_uv: str = "",
cond_uv: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""신용 매수주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
stk_cd (str): 종목코드
ord_qty (str): 주문수량
trde_tp (str): 매매구분 (0:보통, 3:시장가, 5:조건부지정가, 81:장마감후시간외, 61:장시작전시간외, 62:시간외단일가, 6:최유리지정가, 7:최우선지정가, 10:보통(IOC), 13:시장가(IOC), 16:최유리(IOC), 20:보통(FOK), 23:시장가(FOK), 26:최유리(FOK), 28:스톱지정가, 29:중간가, 30:중간가(IOC), 31:중간가(FOK))
ord_uv (str, optional): 주문단가. Defaults to "".
cond_uv (str, optional): 조건단가. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 신용 매수주문 결과
{
"ord_no": str, # 주문번호
"dmst_stex_tp": str, # 국내거래소구분
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.credit_order.margin_buy_order_request_kt10006(
... dmst_stex_tp="KRX",
... stk_cd="005930",
... ord_qty="1",
... ord_uv="2580",
... trde_tp="0"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10006",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"stk_cd": stk_cd,
"ord_qty": ord_qty,
"ord_uv": ord_uv,
"trde_tp": trde_tp,
"cond_uv": cond_uv,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def margin_sell_order_request_kt10007(
self,
dmst_stex_tp: str,
stk_cd: str,
ord_qty: str,
trde_tp: str,
crd_deal_tp: str,
ord_uv: str = "",
crd_loan_dt: str = "",
cond_uv: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""신용 매도주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
stk_cd (str): 종목코드
ord_qty (str): 주문수량
trde_tp (str): 매매구분 (0:보통, 3:시장가, 5:조건부지정가, 81:장마감후시간외, 61:장시작전시간외, 62:시간외단일가, 6:최유리지정가, 7:최우선지정가, 10:보통(IOC), 13:시장가(IOC), 16:최유리(IOC), 20:보통(FOK), 23:시장가(FOK), 26:최유리(FOK), 28:스톱지정가, 29:중간가, 30:중간가(IOC), 31:중간가(FOK))
crd_deal_tp (str): 신용거래구분 (33:융자, 99:융자합)
ord_uv (str, optional): 주문단가. Defaults to "".
crd_loan_dt (str, optional): 대출일 YYYYMMDD(융자일경우필수). Defaults to "".
cond_uv (str, optional): 조건단가. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 신용 매도주문 결과
{
"ord_no": str, # 주문번호
"dmst_stex_tp": str, # 국내거래소구분
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.credit_order.margin_sell_order_request_kt10007(
... dmst_stex_tp="KRX",
... stk_cd="005930",
... ord_qty="3",
... ord_uv="6450",
... trde_tp="0",
... crd_deal_tp="99"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10007",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"stk_cd": stk_cd,
"ord_qty": ord_qty,
"ord_uv": ord_uv,
"trde_tp": trde_tp,
"crd_deal_tp": crd_deal_tp,
"crd_loan_dt": crd_loan_dt,
"cond_uv": cond_uv,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def margin_modify_order_request_kt10008(
self,
dmst_stex_tp: str,
orig_ord_no: str,
stk_cd: str,
mdfy_qty: str,
mdfy_uv: str,
mdfy_cond_uv: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""신용 정정주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
orig_ord_no (str): 원주문번호
stk_cd (str): 종목코드
mdfy_qty (str): 정정수량
mdfy_uv (str): 정정단가
mdfy_cond_uv (str, optional): 정정조건단가. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 신용 정정주문 결과
{
"ord_no": str, # 주문번호
"base_orig_ord_no": str, # 모주문번호
"mdfy_qty": str, # 정정수량
"dmst_stex_tp": str, # 국내거래소구분
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.credit_order.margin_modify_order_request_kt10008(
... dmst_stex_tp="KRX",
... orig_ord_no="0000455",
... stk_cd="005930",
... mdfy_qty="1",
... mdfy_uv="2590"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10008",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"orig_ord_no": orig_ord_no,
"stk_cd": stk_cd,
"mdfy_qty": mdfy_qty,
"mdfy_uv": mdfy_uv,
"mdfy_cond_uv": mdfy_cond_uv,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def margin_cancel_order_request_kt10009(
self,
dmst_stex_tp: str,
orig_ord_no: str,
stk_cd: str,
cncl_qty: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""신용 취소주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
orig_ord_no (str): 원주문번호
stk_cd (str): 종목코드
cncl_qty (str): 취소수량 ('0' 입력시 잔량 전부 취소)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 신용 취소주문 결과
{
"ord_no": str, # 주문번호
"base_orig_ord_no": str, # 모주문번호
"cncl_qty": str, # 취소수량
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.credit_order.margin_cancel_order_request_kt10009(
... dmst_stex_tp="KRX",
... orig_ord_no="0001615",
... stk_cd="005930",
... cncl_qty="1"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10009",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"orig_ord_no": orig_ord_no,
"stk_cd": stk_cd,
"cncl_qty": cncl_qty,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,621 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class ETF(KiwoomBaseAPI):
"""한국 주식 ETF 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/etf"
):
"""
ETF 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def etf_return_rate_request_ka40001(
self,
stock_code: str,
etf_object_index_code: str,
period: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 수익률을 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
etf_object_index_code (str): ETF대상지수코드 (3자리)
period (str): 기간
- "0": 1주
- "1": 1달
- "2": 6개월
- "3": 1년
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 수익률 데이터
{
"etfprft_rt_lst": list, # ETF수익율 리스트
[
{
"etfprft_rt": str, # ETF수익률
"cntr_prft_rt": str, # 체결수익률
"for_netprps_qty": str, # 외인순매수수량
"orgn_netprps_qty": str, # 기관순매수수량
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_return_rate_request_ka40001(
... stock_code="069500",
... etf_object_index_code="207",
... period="3"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40001",
}
data = {
"stk_cd": stock_code,
"etfobjt_idex_cd": etf_object_index_code,
"dt": period,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_stock_info_request_ka40002(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 종목정보를 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 종목정보 데이터
{
"stk_nm": str, # 종목명
"etfobjt_idex_nm": str, # ETF대상지수명
"wonju_pric": str, # 원주가격
"etftxon_type": str, # ETF과세유형
"etntxon_type": str, # ETN과세유형
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_stock_info_request_ka40002(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40002",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_daily_trend_request_ka40003(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 일별추이를 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 일별추이 데이터
{
"etfdaly_trnsn": list, # ETF일별추이 리스트
[
{
"cntr_dt": str, # 체결일자
"cur_prc": str, # 현재가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"pre_rt": str, # 대비율
"trde_qty": str, # 거래량
"nav": str, # NAV
"acc_trde_prica": str, # 누적거래대금
"navidex_dispty_rt": str, # NAV/지수괴리율
"navetfdispty_rt": str, # NAV/ETF괴리율
"trace_eor_rt": str, # 추적오차율
"trace_cur_prc": str, # 추적현재가
"trace_pred_pre": str, # 추적전일대비
"trace_pre_sig": str, # 추적대비기호
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_daily_trend_request_ka40003(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40003",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_overall_market_price_request_ka40004(
self,
tax_type: str = "0",
nav_pre: str = "0",
management_company: str = "0000",
tax_yn: str = "0",
trace_index: str = "0",
exchange_type: str = "1",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 전체시세를 조회합니다.
Args:
tax_type (str, optional): 과세유형. Defaults to "0".
- "0": 전체
- "1": 비과세
- "2": 보유기간과세
- "3": 회사형
- "4": 외국
- "5": 비과세해외(보유기간관세)
nav_pre (str, optional): NAV대비. Defaults to "0".
- "0": 전체
- "1": NAV > 전일종가
- "2": NAV < 전일종가
management_company (str, optional): 운용사. Defaults to "0000".
- "0000": 전체
- "3020": KODEX(삼성)
- "3027": KOSEF(키움)
- "3191": TIGER(미래에셋)
- "3228": KINDEX(한국투자)
- "3023": KStar(KB)
- "3022": 아리랑(한화)
- "9999": 기타운용사
tax_yn (str, optional): 과세여부. Defaults to "0".
- "0": 전체
- "1": 과세
- "2": 비과세
trace_index (str, optional): 추적지수. Defaults to "0".
- "0": 전체
exchange_type (str, optional): 거래소구분. Defaults to "1".
- "1": KRX
- "2": NXT
- "3": 통합
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 전체시세 데이터
{
"etfall_mrpr": list, # ETF전체시세 리스트
[
{
"stk_cd": str, # 종목코드
"stk_cls": str, # 종목분류
"stk_nm": str, # 종목명
"close_pric": str, # 종가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"pre_rt": str, # 대비율
"trde_qty": str, # 거래량
"nav": str, # NAV
"trace_eor_rt": str, # 추적오차율
"txbs": str, # 과표기준
"dvid_bf_base": str, # 배당전기준
"pred_dvida": str, # 전일배당금
"trace_idex_nm": str, # 추적지수명
"drng": str, # 배수
"trace_idex_cd": str, # 추적지수코드
"trace_idex": str, # 추적지수
"trace_flu_rt": str, # 추적등락율
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_overall_market_price_request_ka40004(
... tax_type="0",
... nav_pre="0",
... management_company="0000",
... tax_yn="0",
... trace_index="0",
... exchange_type="1"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40004",
}
data = {
"txon_type": tax_type,
"navpre": nav_pre,
"mngmcomp": management_company,
"txon_yn": tax_yn,
"trace_idex": trace_index,
"stex_tp": exchange_type,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_time_segment_trend_request_ka40006(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 시간대별추이를 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 시간대별추이 데이터
{
"stk_nm": str, # 종목명
"etfobjt_idex_nm": str, # ETF대상지수명
"wonju_pric": str, # 원주가격
"etftxon_type": str, # ETF과세유형
"etntxon_type": str, # ETN과세유형
"etftisl_trnsn": list, # ETF시간대별추이 리스트
[
{
"tm": str, # 시간
"close_pric": str, # 종가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락율
"trde_qty": str, # 거래량
"nav": str, # NAV
"trde_prica": str, # 거래대금
"navidex": str, # NAV지수
"navetf": str, # NAVETF
"trace": str, # 추적
"trace_idex": str, # 추적지수
"trace_idex_pred_pre": str, # 추적지수전일대비
"trace_idex_pred_pre_sig": str, # 추적지수전일대비기호
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_time_segment_trend_request_ka40006(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40006",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_time_segment_execution_request_ka40007(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 시간대별체결을 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 시간대별체결 데이터
{
"stk_cls": str, # 종목분류
"stk_nm": str, # 종목명
"etfobjt_idex_nm": str, # ETF대상지수명
"etfobjt_idex_cd": str, # ETF대상지수코드
"objt_idex_pre_rt": str, # 대상지수대비율
"wonju_pric": str, # 원주가격
"etftisl_cntr_array": list, # ETF시간대별체결배열
[
{
"cntr_tm": str, # 체결시간
"cur_prc": str, # 현재가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"trde_qty": str, # 거래량
"stex_tp": str, # 거래소구분 (KRX, NXT, 통합)
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_time_segment_execution_request_ka40007(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40007",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_datewise_execution_request_ka40008(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 일자별체결을 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 일자별체결 데이터
{
"cntr_tm": str, # 체결시간
"cur_prc": str, # 현재가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"trde_qty": str, # 거래량
"etfnetprps_qty_array": list, # ETF순매수수량배열
[
{
"dt": str, # 일자
"cur_prc_n": str, # 현재가n
"pre_sig_n": str, # 대비기호n
"pred_pre_n": str, # 전일대비n
"acc_trde_qty": str, # 누적거래량
"for_netprps_qty": str, # 외인순매수수량
"orgn_netprps_qty": str, # 기관순매수수량
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_datewise_execution_request_ka40008(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40008",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_timewise_execution_request_ka40009(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 시간대별NAV를 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 시간대별NAV 데이터
{
"etfnavarray": list, # ETFNAV배열
[
{
"nav": str, # NAV
"navpred_pre": str, # NAV전일대비
"navflu_rt": str, # NAV등락율
"trace_eor_rt": str, # 추적오차율
"dispty_rt": str, # 괴리율
"stkcnt": str, # 주식수
"base_pric": str, # 기준가
"for_rmnd_qty": str, # 외인보유수량
"repl_pric": str, # 대용가
"conv_pric": str, # 환산가격
"drstk": str, # DR/주
"wonju_pric": str, # 원주가격
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_timewise_execution_request_ka40009(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40009",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def etf_timewise_trend_request_ka40010(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""ETF 시간대별추이를 조회합니다.
Args:
stock_code (str): 종목코드 (6자리)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: ETF 시간대별추이 데이터
{
"etftisl_trnsn": list, # ETF시간대별추이 리스트
[
{
"cur_prc": str, # 현재가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"trde_qty": str, # 거래량
"for_netprps": str, # 외인순매수
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.etf_timewise_trend_request_ka40010(
... stock_code="069500"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka40010",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

View File

@@ -0,0 +1,234 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class ForeignInstitution(KiwoomBaseAPI):
"""한국 주식 외인 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/frgnistt"
):
"""
ForeignInstitution 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def foreign_investor_stockwise_trading_trend_request_ka10008(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""주식 외국인 종목별 매매 동향을 조회합니다.
Args:
stock_code (str): 종목코드 (거래소별 종목코드)
- KRX: 039490
- NXT: 039490_NX
- SOR: 039490_AL
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식 외국인 종목별 매매 동향 데이터
{
"stk_frgnr": [
{
"dt": str, # 일자
"close_pric": str, # 종가
"pred_pre": str, # 전일대비
"trde_qty": str, # 거래량
"chg_qty": str, # 변동수량
"poss_stkcnt": str, # 보유주식수
"wght": str, # 비중
"gain_pos_stkcnt": str, # 취득가능주식수
"frgnr_limit": str, # 외국인한도
"frgnr_limit_irds": str, # 외국인한도증감
"limit_exh_rt": str, # 한도소진률
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.foreign_institution.foreign_investor_stockwise_trading_trend_request_ka10008(
... stock_code="005930"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10008",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def institutional_stock_request_ka10009(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""주식 기관 요청을 조회합니다.
Args:
stock_code (str): 종목코드 (거래소별 종목코드)
- KRX: 039490
- NXT: 039490_NX
- SOR: 039490_AL
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주식 기관 요청 데이터
{
"date": str, # 날짜
"close_pric": str, # 종가
"pre": str, # 대비
"orgn_dt_acc": str, # 기관기간누적
"orgn_daly_nettrde": str, # 기관일별순매매
"frgnr_daly_nettrde": str, # 외국인일별순매매
"frgnr_qota_rt": str, # 외국인지분율
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.foreign_institution.institutional_stock_request_ka10009(
... stock_code="005930"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10009",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def institution_foreign_consecutive_trading_status_request_ka10131(
self,
dt: str,
mrkt_tp: str,
netslmt_tp: str = "2",
stk_inds_tp: str = "0",
amt_qty_tp: str = "0",
stex_tp: str = "1",
strt_dt: str = "",
end_dt: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""기관외국인연속매매현황을 조회합니다.
Args:
dt (str): 기간 (1:최근일, 3:3일, 5:5일, 10:10일, 20:20일, 120:120일, 0:시작일자/종료일자로 조회)
mrkt_tp (str): 장구분 (001:코스피, 101:코스닥)
netslmt_tp (str, optional): 순매도수구분 (2:순매수(고정값)). Defaults to "2".
stk_inds_tp (str, optional): 종목업종구분 (0:종목(주식),1:업종). Defaults to "0".
amt_qty_tp (str, optional): 금액수량구분 (0:금액, 1:수량). Defaults to "0".
stex_tp (str, optional): 거래소구분 (1:KRX, 2:NXT, 3:통합). Defaults to "1".
strt_dt (str, optional): 시작일자 (YYYYMMDD). Defaults to "".
end_dt (str, optional): 종료일자 (YYYYMMDD). Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 기관외국인연속매매현황 데이터
{
"orgn_frgnr_cont_trde_prst": [
{
"rank": str, # 순위
"stk_cd": str, # 종목코드
"stk_nm": str, # 종목명
"prid_stkpc_flu_rt": str, # 기간중주가등락률
"orgn_nettrde_amt": str, # 기관순매매금액
"orgn_nettrde_qty": str, # 기관순매매량
"orgn_cont_netprps_dys": str, # 기관계연속순매수일수
"orgn_cont_netprps_qty": str, # 기관계연속순매수량
"orgn_cont_netprps_amt": str, # 기관계연속순매수금액
"frgnr_nettrde_qty": str, # 외국인순매매량
"frgnr_nettrde_amt": str, # 외국인순매매액
"frgnr_cont_netprps_dys": str, # 외국인연속순매수일수
"frgnr_cont_netprps_qty": str, # 외국인연속순매수량
"frgnr_cont_netprps_amt": str, # 외국인연속순매수금액
"nettrde_qty": str, # 순매매량
"nettrde_amt": str, # 순매매액
"tot_cont_netprps_dys": str, # 합계연속순매수일수
"tot_cont_nettrde_qty": str, # 합계연속순매매수량
"tot_cont_netprps_amt": str, # 합계연속순매수금액
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.foreign_institution.institution_foreign_consecutive_trading_status_request_ka10131(
... dt="1",
... mrkt_tp="001"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10131",
}
data = {
"dt": dt,
"strt_dt": strt_dt,
"end_dt": end_dt,
"mrkt_tp": mrkt_tp,
"netslmt_tp": netslmt_tp,
"stk_inds_tp": stk_inds_tp,
"amt_qty_tp": amt_qty_tp,
"stex_tp": stex_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

View File

@@ -0,0 +1,115 @@
from typing import Dict, Optional, Any
from kiwoom_rest_api.core.sync_client import make_request
def get_investor_trend(
stock_code: str,
period: str = "D",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
투자자별 매매동향 (KA-STOCK-013)
Args:
stock_code: 종목코드 (6자리)
period: 기간분류코드 (D:일봉, W:주봉, M:월봉)
start_date: 조회 시작 날짜 (YYYYMMDD)
end_date: 조회 끝 날짜 (YYYYMMDD)
access_token: OAuth 액세스 토큰
Returns:
투자자별 매매동향 데이터
"""
endpoint = "/stock/investor"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
"FID_PERIOD_DIV_CODE": period,
}
if start_date:
params["FID_INPUT_DATE_1"] = start_date
if end_date:
params["FID_INPUT_DATE_2"] = end_date
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_market_investor_trend(
market_code: str = "0",
period: str = "D",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
전체 시장별 투자자 매매동향 (KA-STOCK-014)
Args:
market_code: 시장분류코드 (0:전체, 1:코스피, 2:코스닥)
period: 기간분류코드 (D:일봉, W:주봉, M:월봉)
start_date: 조회 시작 날짜 (YYYYMMDD)
end_date: 조회 끝 날짜 (YYYYMMDD)
access_token: OAuth 액세스 토큰
Returns:
시장별 투자자 매매동향 데이터
"""
endpoint = "/stock/investor/market"
params = {
"FID_COND_MRKT_DIV_CODE": market_code,
"FID_PERIOD_DIV_CODE": period,
}
if start_date:
params["FID_INPUT_DATE_1"] = start_date
if end_date:
params["FID_INPUT_DATE_2"] = end_date
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_program_trading(
market_code: str = "0",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
프로그램 매매현황 (KA-STOCK-015)
Args:
market_code: 시장분류코드 (0:전체, 1:코스피, 2:코스닥)
start_date: 조회 시작 날짜 (YYYYMMDD)
end_date: 조회 끝 날짜 (YYYYMMDD)
access_token: OAuth 액세스 토큰
Returns:
프로그램 매매현황 데이터
"""
endpoint = "/stock/program"
params = {
"FID_COND_MRKT_DIV_CODE": market_code,
}
if start_date:
params["FID_INPUT_DATE_1"] = start_date
if end_date:
params["FID_INPUT_DATE_2"] = end_date
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class Order(KiwoomBaseAPI):
"""한국 주식 주문 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/ordr"
):
"""
Order 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def stock_buy_order_request_kt10000(
self,
dmst_stex_tp: str,
stk_cd: str,
ord_qty: str,
trde_tp: str,
ord_uv: str = "",
cond_uv: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""주식 매수주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
stk_cd (str): 종목코드
ord_qty (str): 주문수량
trde_tp (str): 매매구분
- 0: 보통
- 3: 시장가
- 5: 조건부지정가
- 81: 장마감후시간외
- 61: 장시작전시간외
- 62: 시간외단일가
- 6: 최유리지정가
- 7: 최우선지정가
- 10: 보통(IOC)
- 13: 시장가(IOC)
- 16: 최유리(IOC)
- 20: 보통(FOK)
- 23: 시장가(FOK)
- 26: 최유리(FOK)
- 28: 스톱지정가
- 29: 중간가
- 30: 중간가(IOC)
- 31: 중간가(FOK)
ord_uv (str, optional): 주문단가. Defaults to "".
cond_uv (str, optional): 조건단가. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주문 응답 데이터
{
"ord_no": str, # 주문번호
"dmst_stex_tp": str, # 국내거래소구분
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.order.stock_buy_order_request_kt10000(
... dmst_stex_tp="KRX",
... stk_cd="005930",
... ord_qty="1",
... trde_tp="3"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10000",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"stk_cd": stk_cd,
"ord_qty": ord_qty,
"ord_uv": ord_uv,
"trde_tp": trde_tp,
"cond_uv": cond_uv,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_sell_order_request_kt10001(
self,
dmst_stex_tp: str,
stk_cd: str,
ord_qty: str,
trde_tp: str,
ord_uv: str = "",
cond_uv: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""주식 매도주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
stk_cd (str): 종목코드
ord_qty (str): 주문수량
trde_tp (str): 매매구분
- 0: 보통
- 3: 시장가
- 5: 조건부지정가
- 81: 장마감후시간외
- 61: 장시작전시간외
- 62: 시간외단일가
- 6: 최유리지정가
- 7: 최우선지정가
- 10: 보통(IOC)
- 13: 시장가(IOC)
- 16: 최유리(IOC)
- 20: 보통(FOK)
- 23: 시장가(FOK)
- 26: 최유리(FOK)
- 28: 스톱지정가
- 29: 중간가
- 30: 중간가(IOC)
- 31: 중간가(FOK)
ord_uv (str, optional): 주문단가. Defaults to "".
cond_uv (str, optional): 조건단가. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 주문 응답 데이터
{
"ord_no": str, # 주문번호
"dmst_stex_tp": str, # 국내거래소구분
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.order.stock_sell_order_request_kt10001(
... dmst_stex_tp="KRX",
... stk_cd="005930",
... ord_qty="1",
... trde_tp="3"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10001",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"stk_cd": stk_cd,
"ord_qty": ord_qty,
"ord_uv": ord_uv,
"trde_tp": trde_tp,
"cond_uv": cond_uv,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_modify_order_request_kt10002(
self,
dmst_stex_tp: str,
orig_ord_no: str,
stk_cd: str,
mdfy_qty: str,
mdfy_uv: str,
mdfy_cond_uv: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""주식 정정주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
orig_ord_no (str): 원주문번호
stk_cd (str): 종목코드
mdfy_qty (str): 정정수량
mdfy_uv (str): 정정단가
mdfy_cond_uv (str, optional): 정정조건단가. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 정정주문 응답 데이터
{
"ord_no": str, # 주문번호
"base_orig_ord_no": str, # 모주문번호
"mdfy_qty": str, # 정정수량
"dmst_stex_tp": str, # 국내거래소구분
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.order.stock_modify_order_request_kt10002(
... dmst_stex_tp="KRX",
... orig_ord_no="0000139",
... stk_cd="005930",
... mdfy_qty="1",
... mdfy_uv="199700"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10002",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"orig_ord_no": orig_ord_no,
"stk_cd": stk_cd,
"mdfy_qty": mdfy_qty,
"mdfy_uv": mdfy_uv,
"mdfy_cond_uv": mdfy_cond_uv,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_cancel_order_request_kt10003(
self,
dmst_stex_tp: str,
orig_ord_no: str,
stk_cd: str,
cncl_qty: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""주식 취소주문을 요청합니다.
Args:
dmst_stex_tp (str): 국내거래소구분 (KRX, NXT, SOR)
orig_ord_no (str): 원주문번호
stk_cd (str): 종목코드
cncl_qty (str): 취소수량 ('0' 입력시 잔량 전부 취소)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 취소주문 응답 데이터
{
"ord_no": str, # 주문번호
"base_orig_ord_no": str, # 모주문번호
"cncl_qty": str, # 취소수량
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.order.stock_cancel_order_request_kt10003(
... dmst_stex_tp="KRX",
... orig_ord_no="0000140",
... stk_cd="005930",
... cncl_qty="1"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "kt10003",
}
data = {
"dmst_stex_tp": dmst_stex_tp,
"orig_ord_no": orig_ord_no,
"stk_cd": stk_cd,
"cncl_qty": cncl_qty,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,507 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class Sector(KiwoomBaseAPI):
"""한국 주식 섹터 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/sect"
):
"""
Sector 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def industry_program_trading_request_ka10010(
self,
stock_code: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""업종프로그램매매를 조회합니다.
Args:
stock_code (str): 종목코드 (거래소별 종목코드)
- KRX: 039490
- NXT: 039490_NX
- SOR: 039490_AL
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종프로그램매매 데이터
{
"dfrt_trst_sell_qty": str, # 차익위탁매도수량
"dfrt_trst_sell_amt": str, # 차익위탁매도금액
"dfrt_trst_buy_qty": str, # 차익위탁매수수량
"dfrt_trst_buy_amt": str, # 차익위탁매수금액
"dfrt_trst_netprps_qty": str, # 차익위탁순매수수량
"dfrt_trst_netprps_amt": str, # 차익위탁순매수금액
"ndiffpro_trst_sell_qty": str, # 비차익위탁매도수량
"ndiffpro_trst_sell_amt": str, # 비차익위탁매도금액
"ndiffpro_trst_buy_qty": str, # 비차익위탁매수수량
"ndiffpro_trst_buy_amt": str, # 비차익위탁매수금액
"ndiffpro_trst_netprps_qty": str, # 비차익위탁순매수수량
"ndiffpro_trst_netprps_amt": str, # 비차익위탁순매수금액
"all_dfrt_trst_sell_qty": str, # 전체차익위탁매도수량
"all_dfrt_trst_sell_amt": str, # 전체차익위탁매도금액
"all_dfrt_trst_buy_qty": str, # 전체차익위탁매수수량
"all_dfrt_trst_buy_amt": str, # 전체차익위탁매수금액
"all_dfrt_trst_netprps_qty": str, # 전체차익위탁순매수수량
"all_dfrt_trst_netprps_amt": str, # 전체차익위탁순매수금액
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.industry_program_trading_request_ka10010(
... stock_code="005930"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10010",
}
data = {
"stk_cd": stock_code,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industrywise_investor_net_buy_request_ka10051(
self,
mrkt_tp: str,
amt_qty_tp: str,
stex_tp: str,
base_dt: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""업종별투자자순매수를 조회합니다.
Args:
mrkt_tp (str): 시장구분 (코스피:0, 코스닥:1)
amt_qty_tp (str): 금액수량구분 (금액:0, 수량:1)
stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합)
base_dt (str, optional): 기준일자 (YYYYMMDD). Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종별투자자순매수 데이터
{
"inds_netprps": [
{
"inds_cd": str, # 업종코드
"inds_nm": str, # 업종명
"cur_prc": str, # 현재가
"pre_smbol": str, # 대비부호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락율
"trde_qty": str, # 거래량
"sc_netprps": str, # 증권순매수
"insrnc_netprps": str, # 보험순매수
"invtrt_netprps": str, # 투신순매수
"bank_netprps": str, # 은행순매수
"jnsinkm_netprps": str, # 종신금순매수
"endw_netprps": str, # 기금순매수
"etc_corp_netprps": str, # 기타법인순매수
"ind_netprps": str, # 개인순매수
"frgnr_netprps": str, # 외국인순매수
"native_trmt_frgnr_netprps": str, # 내국인대우외국인순매수
"natn_netprps": str, # 국가순매수
"samo_fund_netprps": str, # 사모펀드순매수
"orgn_netprps": str, # 기관계순매수
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.industrywise_investor_net_buy_request_ka10051(
... mrkt_tp="0",
... amt_qty_tp="0",
... stex_tp="3",
... base_dt="20241107"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10051",
}
data = {
"mrkt_tp": mrkt_tp,
"amt_qty_tp": amt_qty_tp,
"base_dt": base_dt,
"stex_tp": stex_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_current_price_request_ka20001(
self,
mrkt_tp: str,
inds_cd: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""업종현재가를 조회합니다.
Args:
mrkt_tp (str): 시장구분 (0:코스피, 1:코스닥, 2:코스피200)
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
- 나머지 업종코드 참고
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종현재가 데이터
{
"cur_prc": str, # 현재가
"pred_pre_sig": str, # 전일대비기호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락률
"trde_qty": str, # 거래량
"trde_prica": str, # 거래대금
"trde_frmatn_stk_num": str, # 거래형성종목수
"trde_frmatn_rt": str, # 거래형성비율
"open_pric": str, # 시가
"high_pric": str, # 고가
"low_pric": str, # 저가
"upl": str, # 상한
"rising": str, # 상승
"stdns": str, # 보합
"fall": str, # 하락
"lst": str, # 하한
"52wk_hgst_pric": str, # 52주최고가
"52wk_hgst_pric_dt": str, # 52주최고가일
"52wk_hgst_pric_pre_rt": str, # 52주최고가대비율
"52wk_lwst_pric": str, # 52주최저가
"52wk_lwst_pric_dt": str, # 52주최저가일
"52wk_lwst_pric_pre_rt": str, # 52주최저가대비율
"inds_cur_prc_tm": [ # 업종현재가_시간별
{
"tm_n": str, # 시간n
"cur_prc_n": str, # 현재가n
"pred_pre_sig_n": str, # 전일대비기호n
"pred_pre_n": str, # 전일대비n
"flu_rt_n": str, # 등락률n
"trde_qty_n": str, # 거래량n
"acc_trde_qty_n": str, # 누적거래량n
"stex_tp": str, # 거래소구분
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.industry_current_price_request_ka20001(
... mrkt_tp="0",
... inds_cd="001"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20001",
}
data = {
"mrkt_tp": mrkt_tp,
"inds_cd": inds_cd,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industrywise_stock_price_request_ka20002(
self,
mrkt_tp: str,
inds_cd: str,
stex_tp: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""업종별주가를 조회합니다.
Args:
mrkt_tp (str): 시장구분 (0:코스피, 1:코스닥, 2:코스피200)
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
- 나머지 업종코드 참고
stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종별주가 데이터
{
"inds_stkpc": [ # 업종별주가
{
"stk_cd": str, # 종목코드
"stk_nm": str, # 종목명
"cur_prc": str, # 현재가
"pred_pre_sig": str, # 전일대비기호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락률
"now_trde_qty": str, # 현재거래량
"sel_bid": str, # 매도호가
"buy_bid": str, # 매수호가
"open_pric": str, # 시가
"high_pric": str, # 고가
"low_pric": str, # 저가
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.industrywise_stock_price_request_ka20002(
... mrkt_tp="0",
... inds_cd="001",
... stex_tp="1"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20002",
}
data = {
"mrkt_tp": mrkt_tp,
"inds_cd": inds_cd,
"stex_tp": stex_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def all_industries_index_request_ka20003(
self,
inds_cd: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""전업종지수를 조회합니다.
Args:
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
- 나머지 업종코드 참고
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 전업종지수 데이터
{
"all_inds_idex": [ # 전업종지수
{
"stk_cd": str, # 종목코드
"stk_nm": str, # 종목명
"cur_prc": str, # 현재가
"pre_sig": str, # 대비기호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락률
"trde_qty": str, # 거래량
"wght": str, # 비중
"trde_prica": str, # 거래대금
"upl": str, # 상한
"rising": str, # 상승
"stdns": str, # 보합
"fall": str, # 하락
"lst": str, # 하한
"flo_stk_num": str, # 상장종목수
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.all_industries_index_request_ka20003(
... inds_cd="001"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20003",
}
data = {
"inds_cd": inds_cd,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def industry_daily_current_price_request_ka20009(
self,
mrkt_tp: str,
inds_cd: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""업종현재가일별을 조회합니다.
Args:
mrkt_tp (str): 시장구분 (0:코스피, 1:코스닥, 2:코스피200)
inds_cd (str): 업종코드
- 001: 종합(KOSPI)
- 002: 대형주
- 003: 중형주
- 004: 소형주
- 101: 종합(KOSDAQ)
- 201: KOSPI200
- 302: KOSTAR
- 701: KRX100
- 나머지 업종코드 참고
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 업종현재가일별 데이터
{
"cur_prc": str, # 현재가
"pred_pre_sig": str, # 전일대비기호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락률
"trde_qty": str, # 거래량
"trde_prica": str, # 거래대금
"trde_frmatn_stk_num": str, # 거래형성종목수
"trde_frmatn_rt": str, # 거래형성비율
"open_pric": str, # 시가
"high_pric": str, # 고가
"low_pric": str, # 저가
"upl": str, # 상한
"rising": str, # 상승
"stdns": str, # 보합
"fall": str, # 하락
"lst": str, # 하한
"52wk_hgst_pric": str, # 52주최고가
"52wk_hgst_pric_dt": str, # 52주최고가일
"52wk_hgst_pric_pre_rt": str, # 52주최고가대비율
"52wk_lwst_pric": str, # 52주최저가
"52wk_lwst_pric_dt": str, # 52주최저가일
"52wk_lwst_pric_pre_rt": str, # 52주최저가대비율
"inds_cur_prc_daly_rept": [ # 업종현재가_일별반복
{
"dt_n": str, # 일자n
"cur_prc_n": str, # 현재가n
"pred_pre_sig_n": str, # 전일대비기호n
"pred_pre_n": str, # 전일대비n
"flu_rt_n": str, # 등락률n
"acc_trde_qty_n": str, # 누적거래량n
},
...
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.industry_daily_current_price_request_ka20009(
... mrkt_tp="0",
... inds_cd="001"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20009",
}
data = {
"mrkt_tp": mrkt_tp,
"inds_cd": inds_cd,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

View File

@@ -0,0 +1,287 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class SecuritiesLendingAndBorrowing(KiwoomBaseAPI):
"""한국 주식 대차거래 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/slb"
):
"""
SecuritiesLendingAndBorrowing 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def stock_lending_trend_request_ka10068(
self,
strt_dt: str = "",
end_dt: str = "",
all_tp: str = "1",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""대차거래추이를 요청합니다.
Args:
strt_dt (str, optional): 시작일자 (YYYYMMDD). Defaults to "".
end_dt (str, optional): 종료일자 (YYYYMMDD). Defaults to "".
all_tp (str, optional): 전체구분 (1: 전체표시). Defaults to "1".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 대차거래추이 데이터
{
"dbrt_trde_trnsn": list, # 대차거래추이 리스트
[
{
"dt": str, # 일자
"dbrt_trde_cntrcnt": str, # 대차거래체결주수
"dbrt_trde_rpy": str, # 대차거래상환주수
"dbrt_trde_irds": str, # 대차거래증감
"rmnd": str, # 잔고주수
"remn_amt": str, # 잔고금액
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.slb.stock_lending_trend_request_ka10068(
... strt_dt="20250401",
... end_dt="20250430",
... all_tp="1"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10068",
}
data = {
"strt_dt": strt_dt,
"end_dt": end_dt,
"all_tp": all_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def top10_stock_lending_request_ka10069(
self,
strt_dt: str,
end_dt: str = "",
mrkt_tp: str = "001",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""대차거래상위10종목을 요청합니다.
Args:
strt_dt (str): 시작일자 (YYYYMMDD 형식)
end_dt (str, optional): 종료일자 (YYYYMMDD 형식). Defaults to "".
mrkt_tp (str, optional): 시장구분 (001:코스피, 101:코스닥). Defaults to "001".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 대차거래상위10종목 데이터
{
"dbrt_trde_cntrcnt_sum": str, # 대차거래체결주수합
"dbrt_trde_rpy_sum": str, # 대차거래상환주수합
"rmnd_sum": str, # 잔고주수합
"remn_amt_sum": str, # 잔고금액합
"dbrt_trde_cntrcnt_rt": str, # 대차거래체결주수비율
"dbrt_trde_rpy_rt": str, # 대차거래상환주수비율
"rmnd_rt": str, # 잔고주수비율
"remn_amt_rt": str, # 잔고금액비율
"dbrt_trde_upper_10stk": list, # 대차거래상위10종목 리스트
[
{
"stk_nm": str, # 종목명
"stk_cd": str, # 종목코드
"dbrt_trde_cntrcnt": str, # 대차거래체결주수
"dbrt_trde_rpy": str, # 대차거래상환주수
"rmnd": str, # 잔고주수
"remn_amt": str, # 잔고금액
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.slb.top10_stock_lending_request_ka10069(
... strt_dt="20241110",
... end_dt="20241125",
... mrkt_tp="001"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka10069",
}
data = {
"strt_dt": strt_dt,
"end_dt": end_dt,
"mrkt_tp": mrkt_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stockwise_lending_trend_request_ka20068(
self,
stk_cd: str,
strt_dt: str = "",
end_dt: str = "",
all_tp: str = "0",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""종목별 대차거래추이를 요청합니다.
Args:
stk_cd (str): 종목코드
strt_dt (str, optional): 시작일자 (YYYYMMDD). Defaults to "".
end_dt (str, optional): 종료일자 (YYYYMMDD). Defaults to "".
all_tp (str, optional): 전체구분 (0:종목코드 입력종목만 표시). Defaults to "0".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 종목별 대차거래추이 데이터
{
"dbrt_trde_trnsn": list, # 대차거래추이 리스트
[
{
"dt": str, # 일자
"dbrt_trde_cntrcnt": str, # 대차거래체결주수
"dbrt_trde_rpy": str, # 대차거래상환주수
"dbrt_trde_irds": str, # 대차거래증감
"rmnd": str, # 잔고주수
"remn_amt": str, # 잔고금액
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.slb.stockwise_lending_trend_request_ka20068(
... stk_cd="005930",
... strt_dt="20250401",
... end_dt="20250430",
... all_tp="0"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka20068",
}
data = {
"stk_cd": stk_cd,
"strt_dt": strt_dt,
"end_dt": end_dt,
"all_tp": all_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def stock_lending_details_request_ka90012(
self,
dt: str,
mrkt_tp: str,
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""대차거래내역을 요청합니다.
Args:
dt (str): 일자 (YYYYMMDD 형식)
mrkt_tp (str): 시장구분 (001:코스피, 101:코스닥)
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 대차거래내역 데이터
{
"dbrt_trde_prps": list, # 대차거래내역 리스트
[
{
"stk_nm": str, # 종목명
"stk_cd": str, # 종목코드
"dbrt_trde_cntrcnt": str, # 대차거래체결주수
"dbrt_trde_rpy": str, # 대차거래상환주수
"rmnd": str, # 잔고주수
"remn_amt": str, # 잔고금액
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.slb.stock_lending_details_request_ka90012(
... dt="20241101",
... mrkt_tp="101"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka90012",
}
data = {
"dt": dt,
"mrkt_tp": mrkt_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
from kiwoom_rest_api.core.base_api import KiwoomBaseAPI
from typing import Union, Dict, Any, Awaitable
class Theme(KiwoomBaseAPI):
"""한국 주식 테마 관련 API를 제공하는 클래스"""
def __init__(
self,
base_url: str = None,
token_manager=None,
use_async: bool = False,
resource_url: str = "/api/dostk/thme"
):
"""
Theme 클래스 초기화
Args:
base_url (str, optional): API 기본 URL
token_manager: 토큰 관리자 객체
use_async (bool): 비동기 클라이언트 사용 여부 (기본값: False)
"""
super().__init__(
base_url=base_url,
token_manager=token_manager,
use_async=use_async,
resource_url=resource_url
)
def theme_group_list_request_ka90001(
self,
qry_tp: str,
date_tp: str,
flu_pl_amt_tp: str,
stex_tp: str,
stk_cd: str = "",
thema_nm: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""테마그룹별 조회를 요청합니다.
Args:
qry_tp (str): 검색구분 (0:전체검색, 1:테마검색, 2:종목검색)
date_tp (str): 날짜구분 n일전 (1일 ~ 99일 날짜입력)
flu_pl_amt_tp (str): 등락수익구분 (1:상위기간수익률, 2:하위기간수익률, 3:상위등락률, 4:하위등락률)
stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합)
stk_cd (str, optional): 검색하려는 종목코드. Defaults to "".
thema_nm (str, optional): 검색하려는 테마명. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 테마그룹별 데이터
{
"thema_grp": list, # 테마그룹별 리스트
[
{
"thema_grp_cd": str, # 테마그룹코드
"thema_nm": str, # 테마명
"stk_num": str, # 종목수
"flu_sig": str, # 등락기호
"flu_rt": str, # 등락율
"rising_stk_num": str, # 상승종목수
"fall_stk_num": str, # 하락종목수
"dt_prft_rt": str, # 기간수익률
"main_stk": str, # 주요종목
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.theme_group_list_request_ka90001(
... qry_tp="0",
... date_tp="10",
... flu_pl_amt_tp="1",
... stex_tp="1"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka90001",
}
data = {
"qry_tp": qry_tp,
"stk_cd": stk_cd,
"date_tp": date_tp,
"thema_nm": thema_nm,
"flu_pl_amt_tp": flu_pl_amt_tp,
"stex_tp": stex_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)
def theme_component_stocks_request_ka90002(
self,
thema_grp_cd: str,
stex_tp: str,
date_tp: str = "",
cont_yn: str = "N",
next_key: str = "",
) -> dict:
"""테마구성종목 조회를 요청합니다.
Args:
thema_grp_cd (str): 테마그룹코드 번호
stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합)
date_tp (str, optional): 날짜구분 1일 ~ 99일 날짜입력. Defaults to "".
cont_yn (str, optional): 연속조회여부. Defaults to "N".
next_key (str, optional): 연속조회키. Defaults to "".
Returns:
dict: 테마구성종목 데이터
{
"flu_rt": str, # 등락률
"dt_prft_rt": str, # 기간수익률
"thema_comp_stk": list, # 테마구성종목 리스트
[
{
"stk_cd": str, # 종목코드
"stk_nm": str, # 종목명
"cur_prc": str, # 현재가
"flu_sig": str, # 등락기호
"pred_pre": str, # 전일대비
"flu_rt": str, # 등락율
"acc_trde_qty": str, # 누적거래량
"sel_bid": str, # 매도호가
"sel_req": str, # 매도잔량
"buy_bid": str, # 매수호가
"buy_req": str, # 매수잔량
"dt_prft_rt_n": str, # 기간수익률n
}
],
"return_code": int, # 응답코드
"return_msg": str, # 응답메시지
}
Example:
>>> from kiwoom_rest_api import KiwoomRestAPI
>>> api = KiwoomRestAPI()
>>> result = api.sector.theme_component_stocks_request_ka90002(
... thema_grp_cd="100",
... stex_tp="1",
... date_tp="2"
... )
>>> print(result)
"""
headers = {
"cont-yn": cont_yn,
"next-key": next_key,
"api-id": "ka90002",
}
data = {
"date_tp": date_tp,
"thema_grp_cd": thema_grp_cd,
"stex_tp": stex_tp,
}
return self._execute_request(
"POST",
json=data,
headers=headers,
)

View File

@@ -0,0 +1,107 @@
from typing import Dict, Optional, Any
from kiwoom_rest_api.core.sync_client import make_request
def get_trading_volume(
stock_code: str,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
거래량 급증 종목 (KA-STOCK-008)
Args:
stock_code: 종목코드 (6자리)
access_token: OAuth 액세스 토큰
Returns:
거래량 급증 데이터
"""
endpoint = "/stock/volume"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_execution_price(
stock_code: str,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
체결가 추이 (KA-STOCK-005)
Args:
stock_code: 종목코드 (6자리)
access_token: OAuth 액세스 토큰
Returns:
체결가 추이 데이터
"""
endpoint = "/stock/execution"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_orderbook(
stock_code: str,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
호가 정보 조회 (KA-STOCK-006)
Args:
stock_code: 종목코드 (6자리)
access_token: OAuth 액세스 토큰
Returns:
호가 정보 데이터
"""
endpoint = "/stock/orderbook"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)
def get_trading_brokers(
stock_code: str,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
거래원 정보 조회 (KA-STOCK-007)
Args:
stock_code: 종목코드 (6자리)
access_token: OAuth 액세스 토큰
Returns:
거래원 정보 데이터
"""
endpoint = "/stock/broker"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
return make_request(
endpoint=endpoint,
params=params,
access_token=access_token,
)

325
kiwoom_rest_api/trader.py Normal file
View File

@@ -0,0 +1,325 @@
import time
import json
import datetime
import pandas as pd
import numpy as np
import os
# =========================================================
# [Part 1] 데이터 표준화 (Data Class)
# 증권사마다 주는 데이터 이름이 다르므로, 우리 봇이 쓸 공통 이름으로 정의
# =========================================================
class StockData:
def __init__(self, code, name, price, open_p, high, low, close, volume):
self.code = code
self.name = name
self.current_price = price # 현재가
self.open = open_p # 시가
self.high = high # 고가
self.low = low # 저가
self.close = close # 종가
self.volume = volume # 거래량
class OrderbookData:
def __init__(self, total_bid, total_ask):
self.total_bid = total_bid # 총 매수 잔량 (살 사람)
self.total_ask = total_ask # 총 매도 잔량 (팔 사람)
# =========================================================
# [Part 2] 증권사 연결 인터페이스 (이 부분만 채우세요!)
# =========================================================
class BrokerAPI:
"""
[안내] 여기에 한국투자증권(KIS)이나 다른 API 코드를 연결합니다.
봇 로직은 이 함수들을 호출해서 데이터를 받아옵니다.
"""
def get_rank_list(self):
# [To-Do] 거래대금 상위 종목 리스트 반환 (API 호출)
# return [{"code": "005930", "price": 60000}, ...]
return [] # 테스트용 빈 리스트
def get_ohlcv_limit(self, code, timeframe='3m', limit=100):
# [To-Do] 특정 종목의 캔들(OHLCV) 데이터 반환 (RSI, MA 계산용)
# DataFrame 형태로 반환: columns=['open', 'high', 'low', 'close', 'volume']
return pd.DataFrame()
def get_daily_ohlc_yesterday(self, code):
# [To-Do] 어제 일봉 데이터 반환 (Dynamic K 계산용)
# return StockData(...)
pass
def get_current_data(self, code):
# [To-Do] 현재가 데이터 반환
pass
def get_orderbook(self, code):
# [To-Do] 호가창 데이터 반환
# return OrderbookData(bid, ask)
pass
def buy_market_order(self, code, qty):
# [To-Do] 시장가 매수 주문
print(f"✅ [API] {code} {qty}주 매수 주문 전송 완료")
def sell_market_order(self, code, qty):
# [To-Do] 시장가 매도 주문
print(f"✅ [API] {code} {qty}주 매도 주문 전송 완료")
# =========================================================
# [Part 3] 정훈님의 정글 서바이버 봇 (핵심 로직)
# =========================================================
class JungleSurvivorBot:
def __init__(self, broker_api, budget=100000):
self.api = broker_api # 증권사 API 객체 연결
self.budget = budget # 운용 자금 (10만원)
self.target_list = [] # 감시 종목 리스트
self.portfolio = {} # 보유 종목: {'code': {'buy_price': 1000, 'max_price': 1100, 'qty': 10}}
# --- [전략 파라미터] ---
self.rsi_period = 11 # RSI 기간 (남들 14보다 빠르게)
self.rsi_sell_std = 68 # RSI 매도 기준 (70보다 조금 낮게 선취매도)
self.trailing_gap = 0.01 # 트레일링 스탑 (고점 대비 1% 하락 시 매도)
self.target_profit = 0.035 # 목표 수익률 (3.5%면 무조건 익절)
self.stop_loss = -0.02 # 손절매 (-2%)
# -----------------------------------------------------
# [3-1] 계산기 (Indicators)
# -----------------------------------------------------
def calc_rsi(self, df):
"""RSI 지표 계산"""
delta = df['close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=self.rsi_period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=self.rsi_period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
def calc_ma(self, df, period=20):
"""이동평균선 계산"""
return df['close'].rolling(window=period).mean()
def calc_dynamic_k(self, yesterday_data):
"""
변동성 돌파 K값 실시간 변경 (노이즈 비율 활용)
노이즈가 심하면 K값을 높여서 진입 장벽을 높임
"""
if yesterday_data is None: return 0.5 # 데이터 없으면 기본값
total_range = yesterday_data.high - yesterday_data.low
body_range = abs(yesterday_data.open - yesterday_data.close)
if total_range == 0: return 0.5
noise_ratio = 1 - (body_range / total_range)
# K값은 최소 0.3 ~ 최대 0.9 사이로 제한
return max(0.3, min(noise_ratio, 0.9))
# -----------------------------------------------------
# [3-2] 타겟 선정 (Dynamic Universe)
# -----------------------------------------------------
def update_universe(self):
print("\n🔄 [리스트 갱신] 실시간 주도주 스캔 중...")
raw_list = self.api.get_rank_list()
new_targets = []
for item in raw_list:
code = item['code']
price = item['price']
# [필터] 1. 가격 (2천원 ~ 5만원) / 10만원으로 살 수 있어야 함
if not (2000 <= price <= 50000) or (price > self.budget):
continue
# [필터] 2. 프로그램 매매 추이 (API 제공 시 추가)
# if item['prog_buy'] < 0: continue
new_targets.append(code)
# JSON 파일 저장 (로그용)
with open('target_universe.json', 'w') as f:
json.dump(new_targets, f)
self.target_list = new_targets
print(f"✅ 타겟 갱신 완료: {len(self.target_list)}개 종목 감시 시작")
# -----------------------------------------------------
# [3-3] 매수 판단 로직 (살까 말까?)
# -----------------------------------------------------
def check_buy(self, code):
# 1. 데이터 수집
df_candle = self.api.get_ohlcv_limit(code) # 3분봉
curr_data = self.api.get_current_data(code)
yesterday = self.api.get_daily_ohlc_yesterday(code)
orderbook = self.api.get_orderbook(code)
prog_net_buy = self.api.get_program_net_buy(code) # 프로그램 수급 확인
if prog_net_buy < 0:
print(f"🚫 {code} 패스: 프로그램 매도세 ({prog_net_buy})")
return False
if len(df_candle) < 20: return False # 데이터 부족
current_price = curr_data.current_price
# 2. 지표 계산
ma20 = self.calc_ma(df_candle, 20).iloc[-1]
k_val = self.calc_dynamic_k(yesterday)
# 3. 변동성 돌파 목표가 계산
# 목표가 = 오늘 시가 + (어제 변동폭 * K)
prev_range = yesterday.high - yesterday.low
breakout_price = curr_data.open + (prev_range * k_val)
print(f"🧐 {code} 분석: 현재가 {current_price} | 20선 {ma20:.0f} | 목표가 {breakout_price:.0f} (K:{k_val:.2f})")
# --- [매수 조건 (AND)] ---
# A. 추세: 현재가가 20일선 위에 있는가?
cond_trend = current_price >= ma20
# B. 변동성 돌파: 의미 있는 가격(목표가)을 넘었는가?
cond_breakout = current_price >= breakout_price
# C. 수급 안전판: 매수 잔량이 매도 잔량보다 2배 많은가?
cond_safe = orderbook.total_bid >= (orderbook.total_ask * 2)
if cond_trend and cond_breakout and cond_safe:
print(f"🚀 [매수 신호] {code} 발견! (Trend+Breakout+Safe)")
return True
return False
# -----------------------------------------------------
# [3-4] 매도 판단 로직 (팔까 말까?)
# -----------------------------------------------------
def check_sell(self, code):
if code not in self.portfolio: return False
info = self.portfolio[code]
buy_price = info['buy_price']
max_price = info['max_price'] # 트레일링 스탑용 고점
qty = info['qty']
curr_data = self.api.get_current_data(code)
curr_price = curr_data.current_price
# 고점 갱신 (트레일링 스탑용)
if curr_price > max_price:
self.portfolio[code]['max_price'] = curr_price
max_price = curr_price
# 지표 계산
df_candle = self.api.get_ohlcv_limit(code)
rsi = self.calc_rsi(df_candle).iloc[-1]
# 수익률 계산
profit_rate = (curr_price - buy_price) / buy_price
drop_from_high = (max_price - curr_price) / max_price
print(f"👀 {code} 보유중: 수익률 {profit_rate * 100:.2f}% | 고점대비하락 {drop_from_high * 100:.2f}% | RSI {rsi:.1f}")
# --- [매도 조건 (OR)] ---
# 1. [목표 달성] 3.5% 먹으면 묻지도 따지지도 말고 익절
if profit_rate >= self.target_profit:
print("💰 목표 수익 달성! 매도!")
return True
# 2. [트레일링 스탑] 고점 대비 1% 빠지면 익절/청산
if drop_from_high >= self.trailing_gap and profit_rate > 0:
print("📉 트레일링 스탑 발동! 매도!")
return True
# 3. [조기 퇴근] RSI가 68 넘으면 과열이므로 선취 매도
if rsi >= self.rsi_sell_std:
print("🔥 RSI 과열! 조기 매도!")
return True
# 4. [손절] -2% 살려주세요
if profit_rate <= self.stop_loss:
print("😭 손절매 실행...")
return True
return False
# -----------------------------------------------------
# [3-5] 메인 루프 (실행기)
# -----------------------------------------------------
def run(self):
print("🤖 정글 서바이버 봇 가동 시작!")
# [수정 1] 봇 켜자마자 일단 유니버스(감시 종목)부터 만들고 시작!
# 파일이 없거나 비어있을 수 있으니 강제 갱신 1회 실행
if not os.path.exists('target_universe.json'):
print("📂 타겟 파일이 없어서 새로 만듭니다...")
self.update_universe()
else:
# 파일이 있어도 비어있는지 확인
try:
with open('target_universe.json', 'r') as f:
json.load(f)
except json.JSONDecodeError:
print("📂 타겟 파일이 비어있어서 새로 채웁니다...")
self.update_universe()
while True:
# 1. 현재 시간 체크
now = datetime.datetime.now()
# 2. 유니버스 갱신 (30분마다)
if now.minute % 30 == 0 and now.second < 5:
self.update_universe()
time.sleep(5)
# 3. 매도 감시 (보유 종목 먼저 체크)
sell_list = []
for code in list(self.portfolio.keys()):
if self.check_sell(code):
# 매도 실행
qty = self.portfolio[code]['qty']
self.api.sell_market_order(code, qty)
sell_list.append(code)
# 포트폴리오에서 삭제
for code in sell_list:
del self.portfolio[code]
# 4. 매수 감시 (보유 종목 없을 때 or 자금 남을 때)
if len(self.portfolio) < 3:
# [수정 2] 읽을 때도 안전장치 추가
try:
with open('target_universe.json', 'r') as f:
targets = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
targets = [] # 에러 나면 그냥 빈 리스트로 처리
for code in targets:
if code in self.portfolio: continue # 이미 가진 건 패스
if self.check_buy(code):
# 매수 실행 (예: 10만원어치 계산)
curr_price = self.api.get_current_data(code).current_price
qty = int(self.budget / 3 / curr_price) # 자금의 1/3 투입
if qty > 0:
self.api.buy_market_order(code, qty)
# 포트폴리오 등록
self.portfolio[code] = {
'buy_price': curr_price,
'max_price': curr_price,
'qty': qty
}
break # 한 턴에 하나만 산다 (서버 부하 방지)
time.sleep(1) # 1초 대기
# =========================================================
# 실행부
# =========================================================
if __name__ == "__main__":
# 1. API 객체 생성 (나중에 키움/한투 코드로 교체될 부분)
my_broker = BrokerAPI()
# 2. 봇 생성 및 실행
bot = JungleSurvivorBot(my_broker, budget=100000)
bot.run()

View File

@@ -0,0 +1,321 @@
import asyncio
import json
import logging
from typing import Any, Callable, Dict, List, Optional, Union
import websockets
from websockets.exceptions import ConnectionClosed, WebSocketException
from .config import get_ws_url, WS_TIMEOUT
logger = logging.getLogger(__name__)
class WebSocketError(Exception):
"""Custom exception for WebSocket errors"""
def __init__(self, message: str, error_data: dict = None):
self.message = message
self.error_data = error_data or {}
super().__init__(message)
class RealTimeData:
"""실시간 데이터를 담는 클래스"""
def __init__(self, data: Dict[str, Any]):
self.raw_data = data
self.trnm = data.get('trnm')
self.return_code = data.get('return_code')
self.return_msg = data.get('return_msg')
self.data = data.get('data', [])
class WebSocketClient:
"""키움증권 실시간 웹소켓 클라이언트"""
def __init__(
self,
access_token: str,
ws_url: Optional[str] = None,
auto_reconnect: bool = True,
reconnect_interval: int = 5,
ping_interval: int = 30
):
"""
웹소켓 클라이언트 초기화
Args:
access_token: 액세스 토큰
ws_url: 웹소켓 URL (None이면 설정에서 자동 선택)
auto_reconnect: 자동 재연결 여부
reconnect_interval: 재연결 간격 (초)
ping_interval: PING 간격 (초)
"""
self.access_token = access_token
self.ws_url = ws_url or get_ws_url()
self.auto_reconnect = auto_reconnect
self.reconnect_interval = reconnect_interval
self.ping_interval = ping_interval
self.websocket: Optional[websockets.WebSocketServerProtocol] = None
self.connected = False
self.keep_running = True
self.is_logged_in = False
# 콜백 함수들
self.on_connect: Optional[Callable] = None
self.on_disconnect: Optional[Callable] = None
self.on_login: Optional[Callable] = None
self.on_data: Optional[Callable[[RealTimeData], None]] = None
self.on_error: Optional[Callable[[Exception], None]] = None
# 태스크들
self._receive_task: Optional[asyncio.Task] = None
self._ping_task: Optional[asyncio.Task] = None
async def connect(self) -> None:
"""웹소켓 서버에 연결"""
try:
logger.info(f"웹소켓 서버에 연결 중: {self.ws_url}")
self.websocket = await websockets.connect(
self.ws_url,
ping_interval=self.ping_interval,
ping_timeout=WS_TIMEOUT
)
self.connected = True
logger.info("웹소켓 서버 연결 성공")
if self.on_connect:
await self.on_connect()
except Exception as e:
logger.error(f"웹소켓 연결 실패: {e}")
self.connected = False
if self.on_error:
await self.on_error(e)
raise WebSocketError(f"연결 실패: {e}")
async def login(self) -> None:
"""웹소켓 서버에 로그인"""
if not self.connected:
await self.connect()
login_data = {
'trnm': 'LOGIN',
'token': self.access_token
}
await self.send(login_data)
logger.info("로그인 요청 전송")
async def send(self, message: Union[Dict[str, Any], str]) -> None:
"""메시지 전송"""
if not self.connected:
if self.auto_reconnect:
await self.connect()
else:
raise WebSocketError("웹소켓이 연결되지 않았습니다")
try:
if isinstance(message, dict):
message_str = json.dumps(message)
else:
message_str = message
await self.websocket.send(message_str)
logger.debug(f"메시지 전송: {message_str}")
except Exception as e:
logger.error(f"메시지 전송 실패: {e}")
if self.on_error:
await self.on_error(e)
raise WebSocketError(f"메시지 전송 실패: {e}")
async def register_realtime(
self,
group_no: str = "1",
type_list: List[str] = None,
item_list: List[str] = None,
refresh: str = "1"
) -> None:
"""
실시간 데이터 등록
Args:
group_no: 그룹 번호
type_list: 실시간 항목 리스트 (예: ['04', '0A', '0B'])
item_list: 종목코드 리스트 (빈 리스트면 전체)
refresh: 기존등록유지여부 (0: 기존유지안함, 1: 기존유지)
"""
if type_list is None:
type_list = ['04'] # 기본값: 잔고
if item_list is None:
item_list = [''] # 빈 문자열은 전체 종목
register_data = {
'trnm': 'REG',
'grp_no': group_no,
'refresh': refresh,
'data': [{
'item': item_list,
'type': type_list
}]
}
await self.send(register_data)
logger.info(f"실시간 데이터 등록: {type_list}")
async def unregister_realtime(self, group_no: str = "1") -> None:
"""실시간 데이터 해지"""
unregister_data = {
'trnm': 'REMOVE',
'grp_no': group_no
}
await self.send(unregister_data)
logger.info("실시간 데이터 해지")
async def _handle_message(self, message: str) -> None:
"""메시지 처리"""
try:
data = json.loads(message)
realtime_data = RealTimeData(data)
trnm = realtime_data.trnm
if trnm == 'LOGIN':
if realtime_data.return_code == 0:
self.is_logged_in = True
logger.info("로그인 성공")
if self.on_login:
await self.on_login()
else:
error_msg = realtime_data.return_msg or "로그인 실패"
logger.error(f"로그인 실패: {error_msg}")
if self.on_error:
await self.on_error(WebSocketError(error_msg))
elif trnm == 'PING':
# PING에 PONG으로 응답
await self.send(data)
logger.debug("PING-PONG 응답")
elif trnm == 'REAL':
# 실시간 데이터 수신
logger.debug(f"실시간 데이터 수신: {data}")
if self.on_data:
await self.on_data(realtime_data)
else:
# 기타 응답
logger.debug(f"기타 응답 수신: {data}")
if self.on_data:
await self.on_data(realtime_data)
except json.JSONDecodeError as e:
logger.error(f"JSON 파싱 오류: {e}")
if self.on_error:
await self.on_error(e)
except Exception as e:
logger.error(f"메시지 처리 오류: {e}")
if self.on_error:
await self.on_error(e)
async def _receive_messages(self) -> None:
"""메시지 수신 루프"""
while self.keep_running:
try:
if not self.connected or not self.websocket:
break
message = await self.websocket.recv()
await self._handle_message(message)
except ConnectionClosed:
logger.warning("웹소켓 연결이 종료되었습니다")
self.connected = False
self.is_logged_in = False
if self.on_disconnect:
await self.on_disconnect()
if self.auto_reconnect and self.keep_running:
logger.info(f"{self.reconnect_interval}초 후 재연결을 시도합니다")
await asyncio.sleep(self.reconnect_interval)
try:
await self.connect()
await self.login()
except Exception as e:
logger.error(f"재연결 실패: {e}")
else:
break
except Exception as e:
logger.error(f"메시지 수신 오류: {e}")
if self.on_error:
await self.on_error(e)
async def _ping_loop(self) -> None:
"""PING 루프 (연결 유지)"""
while self.keep_running and self.connected:
try:
await asyncio.sleep(self.ping_interval)
if self.connected and self.websocket:
await self.websocket.ping()
logger.debug("PING 전송")
except Exception as e:
logger.error(f"PING 오류: {e}")
async def start(self) -> None:
"""웹소켓 클라이언트 시작"""
try:
await self.connect()
await self.login()
# 수신 및 PING 태스크 시작
self._receive_task = asyncio.create_task(self._receive_messages())
self._ping_task = asyncio.create_task(self._ping_loop())
logger.info("웹소켓 클라이언트 시작됨")
except Exception as e:
logger.error(f"웹소켓 클라이언트 시작 실패: {e}")
raise
async def stop(self) -> None:
"""웹소켓 클라이언트 중지"""
logger.info("웹소켓 클라이언트 중지 중...")
self.keep_running = False
# 태스크들 취소
if self._receive_task:
self._receive_task.cancel()
if self._ping_task:
self._ping_task.cancel()
# 웹소켓 연결 종료
if self.websocket:
await self.websocket.close()
self.connected = False
self.is_logged_in = False
logger.info("웹소켓 클라이언트 중지됨")
async def run_forever(self) -> None:
"""무한 루프로 실행"""
try:
await self.start()
# 태스크들이 완료될 때까지 대기
if self._receive_task:
await self._receive_task
except asyncio.CancelledError:
logger.info("웹소켓 클라이언트가 취소되었습니다")
except Exception as e:
logger.error(f"웹소켓 클라이언트 실행 오류: {e}")
finally:
await self.stop()
def run_sync(self) -> None:
"""동기적으로 실행 (새로운 이벤트 루프에서)"""
try:
asyncio.run(self.run_forever())
except KeyboardInterrupt:
logger.info("사용자에 의해 중단되었습니다")
except Exception as e:
logger.error(f"동기 실행 오류: {e}")
raise

View File

@@ -0,0 +1,186 @@
"""
키움증권 웹소켓 실시간 데이터 타입 상수
"""
# 실시간 데이터 타입
REALTIME_TYPES = {
'04': '잔고',
'00': '주문체결',
'0A': '주식기세',
'0B': '주식체결',
'0C': '주식우선호가',
'0D': '주식호가잔량',
'0E': '주식시간외호가',
'0F': '주식당일거래원',
'0G': 'ETF NAV',
'0H': '주식예상체결',
'0J': '업종지수',
'0U': '업종등락',
'0g': '주식종목정보',
'0m': 'ELW 이론가',
'0s': '장시작시간',
'0u': 'ELW 지표',
'0w': '종목프로그램매매',
'1h': 'VI발동/해제'
}
# 실시간 데이터 필드 매핑 (잔고 04 기준)
BALANCE_FIELDS = {
'9201': '계좌번호',
'9001': '종목코드',
'917': '신용구분',
'916': '대출일',
'302': '종목명',
'10': '현재가',
'930': '보유수량',
'931': '매입단가',
'932': '총매입가',
'933': '주문가능수량',
'945': '당일순매수량',
'946': '매도/매수구분',
'950': '당일총매도손익',
'951': 'Extra Item',
'27': '매도호가',
'28': '매수호가',
'307': '기준가',
'8019': '손익률',
'957': '신용금액',
'958': '신용이자',
'918': '만기일',
'990': '당일실현손익(유가)',
'991': '당일실현손익율(유가)',
'992': '당일실현손익(신용)',
'993': '당일실현손익율(신용)',
'959': '담보대출수량',
'924': 'Extra Item'
}
# 주식체결 필드 (0B)
STOCK_TRADE_FIELDS = {
'9001': '종목코드',
'900': '종목명',
'10': '현재가',
'11': '전일대비',
'12': '등락율',
'27': '매도호가',
'28': '매수호가',
'15': '거래량',
'13': '누적거래량',
'14': '누적거래대금',
'16': '시가',
'17': '고가',
'18': '저가',
'25': '전일대비구분',
'26': '전일거래량대비',
'29': '거래대금증감',
'30': '전일거래량대비',
'31': '거래회전율',
'32': '거래비용',
'311': '종목코드명',
'567': '체결강도',
'568': '체결구분',
'569': '체결시간',
'570': '체결수량',
'571': '체결가격',
'572': '체결구분',
'573': '체결시간',
'574': '체결수량',
'575': '체결가격'
}
# 주식호가 필드 (0C)
STOCK_QUOTE_FIELDS = {
'9001': '종목코드',
'900': '종목명',
'27': '매도호가1',
'28': '매수호가1',
'29': '매도호가2',
'30': '매수호가2',
'31': '매도호가3',
'32': '매수호가3',
'33': '매도호가4',
'34': '매수호가4',
'35': '매도호가5',
'36': '매수호가5',
'37': '매도호가6',
'38': '매수호가6',
'39': '매도호가7',
'40': '매수호가7',
'41': '매도호가8',
'42': '매수호가8',
'43': '매도호가9',
'44': '매수호가9',
'45': '매도호가10',
'46': '매수호가10',
'47': '매도호가수량1',
'48': '매수호가수량1',
'49': '매도호가수량2',
'50': '매수호가수량2',
'51': '매도호가수량3',
'52': '매수호가수량3',
'53': '매도호가수량4',
'54': '매수호가수량4',
'55': '매도호가수량5',
'56': '매수호가수량5',
'57': '매도호가수량6',
'58': '매수호가수량6',
'59': '매도호가수량7',
'60': '매수호가수량7',
'61': '매도호가수량8',
'62': '매수호가수량8',
'63': '매도호가수량9',
'64': '매수호가수량9',
'65': '매도호가수량10',
'66': '매수호가수량10'
}
# 주식기세 필드 (0A)
STOCK_TREND_FIELDS = {
'9001': '종목코드',
'900': '종목명',
'10': '현재가',
'11': '전일대비',
'12': '등락율',
'27': '매도호가',
'28': '매수호가',
'15': '거래량',
'13': '누적거래량',
'14': '누적거래대금',
'16': '시가',
'17': '고가',
'18': '저가',
'25': '전일대비구분',
'26': '전일거래량대비',
'29': '거래대금증감',
'30': '전일거래량대비',
'31': '거래회전율',
'32': '거래비용',
'311': '종목코드명',
'567': '체결강도',
'568': '체결구분',
'569': '체결시간',
'570': '체결수량',
'571': '체결가격',
'572': '체결구분',
'573': '체결시간',
'574': '체결수량',
'575': '체결가격'
}
# 필드 매핑 딕셔너리
FIELD_MAPPINGS = {
'04': BALANCE_FIELDS,
'0B': STOCK_TRADE_FIELDS,
'0C': STOCK_QUOTE_FIELDS,
'0A': STOCK_TREND_FIELDS
}
def get_field_name(type_code: str, field_code: str) -> str:
"""실시간 데이터 타입과 필드 코드로 필드명을 반환"""
if type_code in FIELD_MAPPINGS:
return FIELD_MAPPINGS[type_code].get(field_code, field_code)
return field_code
def get_type_name(type_code: str) -> str:
"""실시간 데이터 타입 코드로 타입명을 반환"""
return REALTIME_TYPES.get(type_code, type_code)

View File

@@ -0,0 +1,283 @@
import asyncio
import logging
from typing import Any, Callable, Dict, List, Optional, Union
from datetime import datetime
from .websocket import WebSocketClient, RealTimeData, WebSocketError
from .websocket_constants import get_field_name, get_type_name, REALTIME_TYPES
logger = logging.getLogger(__name__)
class RealTimeDataProcessor:
"""실시간 데이터 처리기"""
def __init__(self):
self.data_handlers: Dict[str, Callable] = {}
self.balance_data: Dict[str, Dict] = {} # 계좌별 잔고 데이터
self.stock_data: Dict[str, Dict] = {} # 종목별 시세 데이터
def register_handler(self, type_code: str, handler: Callable):
"""특정 타입의 데이터 핸들러 등록"""
self.data_handlers[type_code] = handler
def process_data(self, realtime_data: RealTimeData) -> Dict[str, Any]:
"""실시간 데이터 처리"""
if realtime_data.trnm != 'REAL':
return {}
processed_data = {}
for item_data in realtime_data.data:
type_code = item_data.get('type', '')
item_code = item_data.get('item', '')
values = item_data.get('values', {})
# 데이터 타입별 처리
if type_code == '04': # 잔고
processed = self._process_balance_data(item_code, values)
self.balance_data[item_code] = processed
processed_data[item_code] = processed
elif type_code in ['0A', '0B', '0C']: # 주식 관련
processed = self._process_stock_data(type_code, item_code, values)
self.stock_data[item_code] = processed
processed_data[item_code] = processed
# 등록된 핸들러 호출
if type_code in self.data_handlers:
try:
self.data_handlers[type_code](processed_data)
except Exception as e:
logger.error(f"데이터 핸들러 오류 ({type_code}): {e}")
return processed_data
def _process_balance_data(self, item_code: str, values: Dict) -> Dict[str, Any]:
"""잔고 데이터 처리"""
processed = {
'종목코드': item_code,
'처리시간': datetime.now().isoformat(),
'데이터타입': '잔고'
}
# 필드 매핑 적용
for field_code, value in values.items():
field_name = get_field_name('04', field_code)
processed[field_name] = value
return processed
def _process_stock_data(self, type_code: str, item_code: str, values: Dict) -> Dict[str, Any]:
"""주식 데이터 처리"""
processed = {
'종목코드': item_code,
'처리시간': datetime.now().isoformat(),
'데이터타입': get_type_name(type_code)
}
# 필드 매핑 적용
for field_code, value in values.items():
field_name = get_field_name(type_code, field_code)
processed[field_name] = value
return processed
def get_balance_data(self, item_code: str = None) -> Dict:
"""잔고 데이터 조회"""
if item_code:
return self.balance_data.get(item_code, {})
return self.balance_data
def get_stock_data(self, item_code: str = None) -> Dict:
"""주식 데이터 조회"""
if item_code:
return self.stock_data.get(item_code, {})
return self.stock_data
class SimpleWebSocketClient:
"""간단한 웹소켓 클라이언트 (사용하기 쉬운 인터페이스)"""
def __init__(
self,
access_token: str,
ws_url: Optional[str] = None,
auto_reconnect: bool = True
):
self.client = WebSocketClient(
access_token=access_token,
ws_url=ws_url,
auto_reconnect=auto_reconnect
)
self.processor = RealTimeDataProcessor()
self._setup_default_handlers()
def _setup_default_handlers(self):
"""기본 핸들러 설정"""
self.client.on_data = self._on_data_received
self.client.on_connect = self._on_connected
self.client.on_login = self._on_logged_in
self.client.on_error = self._on_error
async def _on_data_received(self, realtime_data: RealTimeData):
"""데이터 수신 시 호출"""
processed_data = self.processor.process_data(realtime_data)
if processed_data:
logger.info(f"실시간 데이터 처리 완료: {len(processed_data)}개 항목")
async def _on_connected(self):
"""연결 성공 시 호출"""
logger.info("웹소켓 서버에 연결되었습니다")
async def _on_logged_in(self):
"""로그인 성공 시 호출"""
logger.info("웹소켓 서버 로그인 성공")
async def _on_error(self, error: Exception):
"""오류 발생 시 호출"""
logger.error(f"웹소켓 오류: {error}")
def register_data_handler(self, type_code: str, handler: Callable):
"""데이터 핸들러 등록"""
self.processor.register_handler(type_code, handler)
async def start(self, type_list: List[str] = None, item_list: List[str] = None):
"""웹소켓 클라이언트 시작"""
if type_list is None:
type_list = ['04'] # 기본값: 잔고
await self.client.start()
await self.client.register_realtime(type_list=type_list, item_list=item_list)
async def stop(self):
"""웹소켓 클라이언트 중지"""
await self.client.stop()
def run_sync(self, type_list: List[str] = None, item_list: List[str] = None):
"""동기적으로 실행"""
async def run():
await self.start(type_list, item_list)
await self.client.run_forever()
try:
asyncio.run(run())
except KeyboardInterrupt:
logger.info("사용자에 의해 중단되었습니다")
asyncio.run(self.stop())
def get_balance_data(self, item_code: str = None) -> Dict:
"""잔고 데이터 조회"""
return self.processor.get_balance_data(item_code)
def get_stock_data(self, item_code: str = None) -> Dict:
"""주식 데이터 조회"""
return self.processor.get_stock_data(item_code)
class WebSocketManager:
"""웹소켓 클라이언트 관리자 (여러 클라이언트 관리)"""
def __init__(self):
self.clients: Dict[str, WebSocketClient] = {}
self.tasks: Dict[str, asyncio.Task] = {}
async def add_client(
self,
client_id: str,
access_token: str,
ws_url: Optional[str] = None,
type_list: List[str] = None,
item_list: List[str] = None
) -> WebSocketClient:
"""클라이언트 추가"""
if client_id in self.clients:
raise ValueError(f"클라이언트 ID '{client_id}'가 이미 존재합니다")
client = WebSocketClient(access_token=access_token, ws_url=ws_url)
self.clients[client_id] = client
# 클라이언트 시작
await client.start()
if type_list:
await client.register_realtime(type_list=type_list, item_list=item_list)
# 태스크 생성
task = asyncio.create_task(client.run_forever())
self.tasks[client_id] = task
return client
async def remove_client(self, client_id: str):
"""클라이언트 제거"""
if client_id not in self.clients:
return
# 태스크 취소
if client_id in self.tasks:
self.tasks[client_id].cancel()
del self.tasks[client_id]
# 클라이언트 중지
await self.clients[client_id].stop()
del self.clients[client_id]
async def stop_all(self):
"""모든 클라이언트 중지"""
for client_id in list(self.clients.keys()):
await self.remove_client(client_id)
def get_client(self, client_id: str) -> Optional[WebSocketClient]:
"""클라이언트 조회"""
return self.clients.get(client_id)
def list_clients(self) -> List[str]:
"""클라이언트 목록 조회"""
return list(self.clients.keys())
# 유틸리티 함수들
def create_simple_client(
access_token: str,
ws_url: Optional[str] = None,
type_list: List[str] = None,
item_list: List[str] = None
) -> SimpleWebSocketClient:
"""간단한 웹소켓 클라이언트 생성"""
client = SimpleWebSocketClient(access_token=access_token, ws_url=ws_url)
if type_list:
for type_code in type_list:
if type_code not in REALTIME_TYPES:
logger.warning(f"알 수 없는 실시간 타입: {type_code}")
return client
def format_balance_data(balance_data: Dict) -> str:
"""잔고 데이터를 보기 좋게 포맷팅"""
if not balance_data:
return "잔고 데이터가 없습니다"
lines = []
for item_code, data in balance_data.items():
lines.append(f"종목: {data.get('종목명', item_code)}")
lines.append(f" 현재가: {data.get('현재가', 'N/A')}")
lines.append(f" 보유수량: {data.get('보유수량', 'N/A')}")
lines.append(f" 매입단가: {data.get('매입단가', 'N/A')}")
lines.append(f" 손익률: {data.get('손익률', 'N/A')}%")
lines.append("")
return "\n".join(lines)
def format_stock_data(stock_data: Dict) -> str:
"""주식 데이터를 보기 좋게 포맷팅"""
if not stock_data:
return "주식 데이터가 없습니다"
lines = []
for item_code, data in stock_data.items():
lines.append(f"종목: {data.get('종목명', item_code)}")
lines.append(f" 현재가: {data.get('현재가', 'N/A')}")
lines.append(f" 등락율: {data.get('등락율', 'N/A')}%")
lines.append(f" 거래량: {data.get('거래량', 'N/A')}")
lines.append(f" 매도호가: {data.get('매도호가', 'N/A')}")
lines.append(f" 매수호가: {data.get('매수호가', 'N/A')}")
lines.append("")
return "\n".join(lines)

1131
kiwoom_trader_dual.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

679
kiwoom_universe_scanner.py Normal file
View File

@@ -0,0 +1,679 @@
# kiwoom_universe_scanner.py
# 키움 REST API로 개미털기 유니버스 스캔 → MariaDB kis_quant_db 저장
# 매매 없음, 스캔 전용 단독 실행 파일
import time
import json
import datetime
import os
import logging
import requests
import random
from dotenv import load_dotenv
import sys
current_dir = os.path.dirname(os.path.abspath(__file__))
# ── 키움 REST API 인증 .env 로드 ─────────────────────────────────────
# kiwoom_rest_api/.env 에 KIWOOM_API_KEY / KIWOOM_API_SECRET / KIWOOM_USE_SANDBOX 저장
# (API 키는 보안상 .env 파일에만 보관, DB 저장 안 함)
_env_path = os.path.join(current_dir, "kiwoom_rest_api", ".env")
if not os.path.exists(_env_path):
_env_path = os.path.join(current_dir, ".env")
load_dotenv(_env_path)
# ── 로거 ──────────────────────────────────────────────
logging.basicConfig(
format='%(asctime)s %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO
)
logger = logging.getLogger('UniverseScanner')
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('requests').setLevel(logging.WARNING)
# ── MariaDB 연동: 봇들과 동일한 DB에서 설정값 읽기 ─────────────────────
# get_env_int/float("KEY", default) → DB env_config 최신 행 우선, 없으면 default
try:
from database import TradeDB as _TradeDB
_db = _TradeDB()
_env_snap = (_db.get_latest_env() or {}).get("snapshot", {})
logger.info("✅ MariaDB 설정 연동 완료 (env_config 최신 %d키 로드)",
sum(1 for v in _env_snap.values() if v))
def _get_str(key: str, default: str = "") -> str:
v = _env_snap.get(key, "")
return str(v).split("#")[0].strip() if v else default
def _get_int(key: str, default: int) -> int:
try:
return int(_get_str(key, str(default))) or default
except (ValueError, TypeError):
return default
def _get_float(key: str, default: float) -> float:
try:
return float(_get_str(key, str(default))) or default
except (ValueError, TypeError):
return default
except Exception as _db_e:
logger.warning("⚠️ MariaDB 연동 실패 → os.environ fallback: %s", _db_e)
_env_snap = {}
def _get_str(key: str, default: str = "") -> str:
return os.environ.get(key, default)
def _get_int(key: str, default: int) -> int:
try:
return int(os.environ.get(key, str(default)))
except (ValueError, TypeError):
return default
def _get_float(key: str, default: float) -> float:
try:
return float(os.environ.get(key, str(default)))
except (ValueError, TypeError):
return default
# ── 설정값 (DB 우선, fallback: 기본값) ───────────────────────────────
MM_SERVER_URL = _get_str("MM_SERVER_URL", "https://mattermost.hoonfam.org")
MM_BOT_TOKEN = _get_str("MM_BOT_TOKEN_", "").strip() # DB 키는 MM_BOT_TOKEN_ (언더스코어)
MM_CONFIG_FILE = os.path.join(current_dir, 'mm_config.json')
SCAN_INTERVAL = _get_int("SCAN_INTERVAL_SEC", 300) # 스캔 주기 (초), 기본 5분
# 유니버스 저장 필터
TOP_N = _get_int("UPDATE_UNIVERSE_TOP_N", 20) # 저장 상위 N개
MIN_SCORE = _get_float("UPDATE_UNIVERSE_MIN_SCORE", 4.0) # 최소 강도 점수
# ── 종목 단가 상한 (리스크 파라미터 자동 파생) ───────────────────────────
# 원리: 투자금 = MAX_LOSS ÷ |STOP_PCT| → qty = floor(투자금/price)
# qty ≥ 1 이 되려면: price ≤ 투자금 = MAX_LOSS ÷ STOP_PCT
# 봇과 동일한 DB값 사용 → 봇이 실제로 살 수 없는 종목은 유니버스에 애초에 넣지 않음
_max_loss_krw = _get_int("MAX_LOSS_PER_TRADE_KRW", 200000)
_stop_loss_pct = abs(_get_float("STOP_LOSS_PCT", 0.03)) # 음수 저장 방지
_stop_loss_pct = _stop_loss_pct if _stop_loss_pct > 0 else 0.03
MAX_STOCK_PRICE_KRW = int(_max_loss_krw / _stop_loss_pct)
# ── Kiwoom API import ──────────────────────────────────
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
# 퀀트 트레이딩 필수 API 추가
from kiwoom_rest_api.koreanstock.sector import Sector
from kiwoom_rest_api.koreanstock.foreign_institution import ForeignInstitution
from kiwoom_rest_api.koreanstock.theme import Theme
from kiwoom_rest_api.koreanstock.etf import ETF
except ImportError as e:
logger.critical(f"❌ 키움 REST API 모듈 임포트 실패: {e}")
raise e
# ── Mattermost ─────────────────────────────────────────
class MattermostBot:
def __init__(self):
self.api_url = f'{MM_SERVER_URL.rstrip("/")}/api/v4/posts'
self.headers = {
'Authorization': f'Bearer {MM_BOT_TOKEN}',
'Content-Type': 'application/json'
}
self.channels = self._load_channels()
def _load_channels(self):
try:
if os.path.exists(MM_CONFIG_FILE):
with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f).get('channels', {})
except Exception as e:
logger.error(f'MM 채널 로드 오류: {e}')
return {}
def send(self, channel_alias, message):
channel_id = self.channels.get(channel_alias)
if not channel_id:
logger.warning(f'MM 채널 {channel_alias} ID 없음')
return False
try:
res = requests.post(
self.api_url,
headers=self.headers,
json={'channel_id': channel_id, 'message': message},
timeout=3
)
res.raise_for_status()
return True
except Exception as e:
logger.error(f'MM 전송 오류: {e}')
return False
# ── Broker API (스캔 전용) ─────────────────────────────
class BrokerAPI:
def __init__(self):
logger.info('키움 REST API 초기화 중...')
try:
self.token_manager = TokenManager()
# access_token 프로퍼티 호출로 최초 토큰 발급
_tok = self.token_manager.access_token
logger.info('키움 토큰 발급 완료 (len=%d)', len(_tok or ''))
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.sector = Sector(token_manager=self.token_manager)
self.foreign_inst = ForeignInstitution(token_manager=self.token_manager)
self.theme = Theme(token_manager=self.token_manager)
self.etf = ETF(token_manager=self.token_manager)
self.acc_no = os.environ.get('KIWOOM_ACCOUNT_NO', '')
logger.info(f'키움 API 초기화 완료 | 계좌: {self.acc_no}')
except Exception as e:
logger.critical(f'키움 API 초기화 실패: {e}')
raise
def _safe_request(self, func, *args, **kwargs):
"""
API 호출 안전장치
- return_code != '0' 이면 return_msg 로깅 후 재시도 여부 판단
- 429 / 초과 / 과부하 → 지수 백오프 재시도
- 인증 오류(8005) → 토큰 재발급 후 1회 재시도
"""
full_name = func.__name__
api_id = full_name.split('_')[-1]
max_retries = 3
for i in range(max_retries):
try:
time.sleep(0.5)
result = func(*args, **kwargs)
if isinstance(result, dict):
return_code = str(result.get('return_code', '0'))
return_msg = str(result.get('return_msg', ''))
if return_code == '0':
return result # ✅ 정상
# 토큰 만료(8005) → 재발급 후 1회 재시도
if '8005' in return_msg or 'Token' in return_msg:
logger.warning(f"⚠️ [{api_id}] 토큰 만료 감지 → 재발급 후 재시도")
self.token_manager._request_new_token()
time.sleep(1)
continue
# API 호출 초과 / 과부하
if '초과' in return_msg or '과부하' in return_msg or '429' in return_msg:
wait = (2 ** i) + random.uniform(0.5, 1.5)
logger.warning(f"⚠️ [{api_id}] 호출 제한 → {wait:.1f}초 대기 ({i+1}/{max_retries})")
time.sleep(wait)
continue
# 그 외 오류 → 로깅 후 빈 dict 반환 (재시도 불필요)
logger.warning(f"⚠️ [{api_id}] API 오류 (code={return_code}): {return_msg[:80]}")
return {}
return result
except Exception as e:
msg = str(e)
if ("429" in msg) or ("초과" in msg) or ("과부하" in msg):
wait = (2 ** i) + random.uniform(0.5, 1.5)
logger.warning(f"⚠️ [{api_id}] 예외 호출제한 → {wait:.1f}초 대기 ({i+1}/{max_retries})")
time.sleep(wait)
else:
logger.error(f"❌ [{api_id}] 예외: {e}")
return {}
logger.error(f"💀 [{api_id}] {max_retries}회 재시도 모두 실패")
return {}
def _is_valid_stock(self, name, code):
"""종목 필터링 (스팩, ETN, 우선주 등 제외)"""
if len(code) != 6 or not code.isdigit():
return False
exclude = ['스팩', 'ETN', 'W', 'ELW', '채권', '레버리지', '인버스',
'곱버스', '선물', '', '', '2X', '3X', '합성', 'H', 'B']
if any(k in name for k in exclude):
return False
if name.endswith('') or name.endswith('우B'):
return False
return True
def scan_ant_shaking_candidates(self):
"""
[조건검색 대체 로직] 개미털기(눌림목) 후보 종목 스캔
스캔 순서:
1. 거래대금/회전율 상위 종목 수집
2. [1차 컷] 가격 상한 필터 (MAX_STOCK_PRICE_KRW = MAX_LOSS ÷ STOP_PCT)
→ 이 이상 가격 종목은 1주라도 사면 손절 시 MAX_LOSS 초과 → 제외
3. [2차 계산] 상세 OHLCV → 낙폭·회복률·강도 점수 산정
→ 가격컷을 강도계산 이전에 수행해 불필요한 API 호출 최소화
"""
logger.info(f"🐜 [개미털기] 스캔 시작 (단가상한 {MAX_STOCK_PRICE_KRW:,}원 = 손실한도{_max_loss_krw:,}÷손절{_stop_loss_pct*100:.1f}%)")
logger.info(f" 📡 수집 방식: 거래대금 + 회전율 (2가지 소스)")
raw_codes_set = set()
scan_strategies = [("3", "거래대금"), ("2", "회전율")]
for sort_tp, desc in scan_strategies:
logger.info(f" 🔍 [{desc}] 상위 종목 조회 중...")
try:
res = self._safe_request(
self.rank.top_trading_volume_today_request_ka10030,
mrkt_tp="000",
sort_tp=sort_tp,
mang_stk_incls="3",
pric_tp="0",
crd_tp="0",
trde_qty_tp="0",
trde_prica_tp="0",
mrkt_open_tp="0",
stex_tp="3",
cont_yn="N"
)
if not res or 'tdy_trde_qty_upper' not in res:
logger.warning(f" ⚠️ [{desc}] 응답 없음")
continue
logger.info(f" ✅ [{desc}] {len(res['tdy_trde_qty_upper'])}개 수신")
for stock in res['tdy_trde_qty_upper']:
code = stock['stk_cd'].split('_')[0]
try:
price = abs(float(stock['cur_prc']))
except:
continue
# open_pric 없는 경우 현재가로 대체 (모의 API 일부 누락)
raw_open = stock.get('open_pric') or stock.get('opmr_pred_rt') or 0
try:
open_price = abs(float(raw_open)) if raw_open else price
except (ValueError, TypeError):
open_price = price
change_rate = ((price - open_price) / open_price * 100) if open_price > 0 else 0
if price < 1000: # 동전주 제외
continue
if change_rate > 20: # 상한가 근처 제외
continue
# [1차 컷] 단가 상한: price > MAX_LOSS ÷ STOP_PCT 이면 qty=0이 되어 매수 불가
# → 강도 계산(2차) 이전에 먼저 제거해 불필요한 API 호출 방지
if price > MAX_STOCK_PRICE_KRW:
continue
name = stock['stk_nm']
if self._is_valid_stock(name, code):
raw_codes_set.add(code)
except Exception as e:
logger.error(f"스캔({desc}) 중 에러: {e}")
raw_codes = list(raw_codes_set)
logger.info(f" 📥 후보 수집: {len(raw_codes)}개 -> 정밀 분석")
final_list = []
chunk_size = 50
try:
for i in range(0, len(raw_codes), chunk_size):
chunk = raw_codes[i:i + chunk_size]
code_str = '|'.join(chunk)
res = self._safe_request(
self.stock_info.watchlist_stock_information_request_ka10095,
stock_code=code_str
)
if res and 'atn_stk_infr' in res:
for item in res['atn_stk_infr']:
code = item['stk_cd'].strip()
if code.startswith('A'):
code = code[1:]
try:
op = abs(float(item.get('open_pric', 0)))
hi = abs(float(item.get('high_pric', 0)))
lo = abs(float(item.get('low_pric', 0)))
cl = abs(float(item.get('cur_prc', 0)))
if op == 0:
continue
# 가격 상한은 1차 수집 단계에서 이미 적용됨 → 중복 필터 불필요
drop_rate = (op - lo) / op
total_range = hi - lo
recovery_pos = (cl - lo) / total_range if total_range > 0 else 0
if total_range > 0 and drop_rate > 0.03:
if recovery_pos > 0.5:
score = drop_rate * 100
logger.info(
f" 💎 {item['stk_nm'].strip()} {code}: "
f"낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 점수 {score:.2f}"
)
final_list.append({
'code': code,
'name': item['stk_nm'].strip(),
'price': cl,
'score': score
})
except Exception as e:
logger.debug(f"종목 분석 오류({code}): {e}")
continue
time.sleep(0.5)
final_list.sort(key=lambda x: x['score'], reverse=True)
return final_list
except Exception as e:
logger.error(f"분석 중 치명적 에러: {e}")
return []
def get_foreign_consecutive_buy(self, consecutive_days=3, market="001", limit=20):
try:
res = self._safe_request(
self.rank.top_foreign_consecutive_net_buy_request_ka10035,
mrkt_tp=market,
trde_tp="2",
base_dt_tp="1",
stex_tp="1"
)
stocks = res.get('for_cont_nettrde_upper', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"외국인 연속 순매수 조회 실패: {e}")
return []
def get_institutional_buy_stocks(self, market="001", limit=20):
try:
from datetime import datetime as dt
today = dt.now().strftime("%Y%m%d")
res = self._safe_request(
self.rank.same_day_net_buying_ranking_request_ka10062,
strt_dt=today,
mrkt_tp=market,
trde_tp="1",
sort_cnd="1",
unit_tp="1",
stex_tp="1"
)
stocks = res.get('eql_nettrde_rank', []) if res else []
return [s for s in stocks if float(s.get('orgn_nettrde_qty', 0)) > 0][:limit]
except Exception as e:
logger.error(f"기관 순매수 조회 실패: {e}")
return []
def get_volume_surge_stocks(self, market="001", min_volume="50", limit=20):
try:
res = self._safe_request(
self.rank.sudden_increase_trading_volume_request_ka10023,
mrkt_tp=market,
sort_tp="1",
tm_tp="2",
trde_qty_tp=min_volume,
stk_cnd="1",
pric_tp="0",
stex_tp="1"
)
stocks = res.get('trde_qty_sdnin', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"거래량 급증 조회 실패: {e}")
return []
def get_top_price_movers(self, market="001", sort_type="1", limit=20):
try:
res = self._safe_request(
self.rank.top_day_over_day_change_rate_request_ka10027,
mrkt_tp=market,
sort_tp=sort_type,
trde_qty_cnd="0000",
stk_cnd="1",
crd_cnd="0",
updown_incls="1",
pric_cnd="0",
trde_prica_cnd="0",
stex_tp="1"
)
stocks = res.get('pred_pre_flu_rt_upper', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"등락률 조회 실패: {e}")
return []
def check_market_status(self):
now = datetime.datetime.now()
if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)):
return False
if now.weekday() >= 5:
return False
return True
# ── MariaDB 저장 ────────────────────────────────────────
def save_to_kis_db(candidates: list, db_path: str = None):
"""
target_candidates 테이블에 저장 (MariaDB kis_quant_db).
db_path 인수는 하위 호환용으로 유지하되 무시됩니다.
TradeDB.update_target_candidates() 를 사용합니다.
"""
try:
from database import TradeDB
db = TradeDB()
ok = db.update_target_candidates(candidates)
if ok:
logger.info(f'💾 MariaDB 저장 완료: {len(candidates)}종목 → kis_quant_db.target_candidates')
return ok
except Exception as e:
logger.error(f'MariaDB 저장 실패: {e}')
return False
# ── 유니버스 업데이트 ──────────────────────────────────
def update_universe(api: BrokerAPI, mm: MattermostBot):
logger.info('=' * 50)
logger.info(f'🔄 유니버스 업데이트 시작 | {datetime.datetime.now().strftime("%H:%M:%S")}')
all_candidates = {} # code → {code, name, price, basescore, bonusscore, fromant}
# 1. 개미털기 스캔 (핵심)
# 가격 상한(MAX_STOCK_PRICE_KRW)은 scan_ant_shaking_candidates 내부에서 1차로 적용됨
try:
antshaking = api.scan_ant_shaking_candidates()
for item in antshaking:
code = item['code']
all_candidates[code] = {
'code': code,
'name': item['name'],
'price': item['price'],
'basescore': item['score'],
'bonusscore': 0.0,
'fromant': True
}
logger.info(f'🐜 개미털기 후보: {len(antshaking)}종목')
except Exception as e:
logger.warning(f'개미털기 스캔 오류: {e}')
# 2. 외국인 연속 매수 보너스 (0.5점)
try:
foreign_buy = api.get_foreign_consecutive_buy(consecutive_days=2, limit=30)
logger.info(f'🌍 외국인 연속매수: {len(foreign_buy)}')
for idx, item in enumerate(foreign_buy):
code = item.get('stk_cd', '').strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.5
if code in all_candidates:
all_candidates[code]['bonusscore'] += bonus
else:
all_candidates[code] = {
'code': code, 'name': item.get('stk_nm', code),
'price': 0, 'basescore': 3.0, 'bonusscore': bonus, 'fromant': False
}
except Exception as e:
logger.warning(f'외국인 연속매수 오류: {e}')
# 3. 거래량 급증 보너스 (0.3점)
try:
vol_surge = api.get_volume_surge_stocks(min_volume="50", limit=30)
logger.info(f'📈 거래량 급증: {len(vol_surge)}')
for idx, item in enumerate(vol_surge):
code = item.get('stk_cd', '').split('_')[0].strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.3
price = abs(float(item.get('cur_prc') or item.get('prpr') or 0))
if code in all_candidates:
all_candidates[code]['bonusscore'] += bonus
else:
all_candidates[code] = {
'code': code, 'name': item.get('stk_nm', code),
'price': price,
'basescore': 2.5, 'bonusscore': bonus, 'fromant': False
}
except Exception as e:
logger.warning(f'거래량 급증 오류: {e}')
# 4. 기관 매수 보너스 (0.3점)
try:
inst_buy = api.get_institutional_buy_stocks(limit=30)
logger.info(f'🏦 기관 매수: {len(inst_buy)}')
for idx, item in enumerate(inst_buy):
code = item.get('stk_cd', '').strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.3
price = abs(float(item.get('cur_prc') or item.get('prpr') or 0))
if code in all_candidates:
all_candidates[code]['bonusscore'] += bonus
else:
all_candidates[code] = {
'code': code, 'name': item.get('stk_nm', code),
'price': price,
'basescore': 2.5, 'bonusscore': bonus, 'fromant': False
}
except Exception as e:
logger.warning(f'기관 매수 오류: {e}')
# 5. 등락률 상위 보너스 (0.2점)
try:
price_movers = api.get_top_price_movers(sort_type='1', limit=30)
logger.info(f'🚀 등락률 상위: {len(price_movers)}')
for idx, item in enumerate(price_movers):
code = item.get('stk_cd', '').strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.2
price = abs(float(item.get('cur_prc') or item.get('prpr') or 0))
if code in all_candidates:
all_candidates[code]['bonusscore'] += bonus
else:
all_candidates[code] = {
'code': code, 'name': item.get('stk_nm', code),
'price': price,
'basescore': 2.0, 'bonusscore': bonus, 'fromant': False
}
except Exception as e:
logger.warning(f'등락률 순위 오류: {e}')
# 6. 최종 후보 필터 (MIN_SCORE 이상만)
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
final_candidates = []
ant_count = 0
bonus_count = 0
for code, data in all_candidates.items():
total_score = data['basescore'] + data['bonusscore']
if total_score >= MIN_SCORE:
final_candidates.append({
'code': code,
'name': data['name'],
'score': round(total_score, 2),
'price': data['price'],
'scan_time': now_str
})
if data['fromant']:
ant_count += 1
else:
bonus_count += 1
final_candidates.sort(key=lambda x: x['score'], reverse=True)
final_candidates = final_candidates[:TOP_N]
top5 = [f"{x['name']}({x['score']:.1f})" for x in final_candidates[:5]]
logger.info(f'✅ 최종 후보: {len(final_candidates)}종목 (개미털기:{ant_count} 보너스:{bonus_count})')
logger.info(f'🔝 Top5: {", ".join(top5)}')
# 7. MariaDB 저장
if final_candidates:
save_to_kis_db(final_candidates)
else:
logger.warning('⚠️ 최종 후보 0개 → DB 저장 생략')
# 8. Mattermost 알림
try:
msg = (
f'🔄 유니버스 업데이트 완료\n'
f'- 후보: {len(final_candidates)}종목 (개미털기:{ant_count} 보너스:{bonus_count})\n'
f'- Top5: {", ".join(top5)}'
)
mm.send('stock', msg)
except Exception as e:
logger.debug(f'MM 알림 오류: {e}')
logger.info('=' * 50)
# ── 메인 루프 ──────────────────────────────────────────
def main():
logger.info('🚀 키움 유니버스 스캐너 시작')
logger.info(f' DB : MariaDB kis_quant_db (192.168.0.141)')
logger.info(f' 스캔 주기 : {SCAN_INTERVAL}')
logger.info(f' 최소 점수 : {MIN_SCORE}')
logger.info(f' 저장 Top N : {TOP_N}')
api = BrokerAPI()
mm = MattermostBot()
is_first_run = True
while True:
try:
is_open = api.check_market_status()
now = datetime.datetime.now()
# 장 중이거나 첫 실행이면 스캔
if is_open or is_first_run:
update_universe(api, mm)
is_first_run = False
else:
logger.info(f'⏸ 장 외 시간 ({now.strftime("%H:%M:%S")}) → 스캔 대기')
# 다음 5분 경계까지 대기
next_min = (now.minute // 5 + 1) * 5
if next_min >= 60:
next_time = now.replace(hour=now.hour + 1, minute=0, second=5, microsecond=0)
else:
next_time = now.replace(minute=next_min, second=5, microsecond=0)
wait_sec = max(5, (next_time - now).total_seconds())
logger.info(f'⏳ 다음 스캔: {next_time.strftime("%H:%M:%S")} ({wait_sec:.0f}초 후)')
time.sleep(wait_sec)
except KeyboardInterrupt:
logger.info('⛔ 종료 요청')
break
except Exception as e:
logger.error(f'메인 루프 오류: {e}')
time.sleep(60)
if __name__ == '__main__':
main()

BIN
ml_model.pkl Normal file

Binary file not shown.

View File

@@ -1,13 +1,12 @@
#!/usr/bin/env python3
"""
KIS Bot용 ML 승률 예측 모델
- kis_bot/quant_bot.db의 trade_history 데이터로 학습
- MariaDB kis_quant_db의 trade_history 데이터로 학습
- 매수 신호의 승률 예측 (0.0 ~ 1.0)
- 주간 단위 자동 재학습
"""
import os
import pickle
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timedelta
@@ -20,7 +19,6 @@ logger = logging.getLogger("KIS_MLPredictor")
try:
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, precision_score, recall_score
ML_AVAILABLE = True
except ImportError:
@@ -37,11 +35,10 @@ class MLPredictor:
def __init__(
self,
db_path: str = None,
db_path: str = None, # 하위 호환용 (무시됨) — MariaDB 사용
model_path: str = None,
):
# 기본값: kis_bot/quant_bot.db, kis_bot/ml_model.pkl
self.db_path = db_path or str(SCRIPT_DIR / "quant_bot.db")
# db_path: 하위 호환을 위해 파라미터 유지하나 내부적으로 MariaDB 사용
self.model_path = model_path or str(SCRIPT_DIR / "ml_model.pkl")
self.model = None
self.feature_names = [
@@ -63,40 +60,53 @@ class MLPredictor:
self.load_model()
def extract_features_from_db(self, days: int = 90) -> pd.DataFrame:
"""DB에서 학습용 피처 추출
"""MariaDB trade_history 에서 학습용 피처 추출.
현재는 trade_history의 profit_rate 기반으로 승/패 라벨 생성하고,
피처는 프로토타입 단계로 랜덤 사용한다.
(실전에서는 active_trades에 진입 시점 피처를 저장해서 사용해야 함)
- profit_rate 로 승/패 라벨 생성
- 진입 시점 피처(rsi, volume_ratio 등)는 매수 시 DB에 저장된 값 사용
- 시간순 정렬 후 반환 (시계열 분리 시 미래 참조 방지)
"""
try:
conn = sqlite3.connect(self.db_path)
from database import TradeDB
db = TradeDB()
cutoff_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
query = f"""
SELECT profit_rate, buy_date, sell_date, strategy
FROM trade_history
WHERE sell_date >= '{cutoff_date}'
ORDER BY sell_date DESC
"""
df = pd.read_sql_query(query, conn)
conn.close()
feat_cols = ", ".join(f"`{c}`" for c in self.feature_names)
rows = db.conn.execute(
f"SELECT profit_rate, buy_date, sell_date, strategy, {feat_cols} "
f"FROM trade_history WHERE sell_date >= %s ORDER BY sell_date ASC",
(cutoff_date,)
).fetchall()
if not rows:
logger.warning("⚠️ 학습 데이터 없음 (trade_history 조회 결과 0건)")
return None
# DictCursor 반환 → DataFrame 직접 생성
df = pd.DataFrame(rows)
if len(df) < self.min_train_samples:
logger.warning(
f"⚠️ 학습 데이터 부족: {len(df)}건 (최소 {self.min_train_samples}건 필요)"
)
return None
# 진입 피처가 전부 NULL인 과거 데이터 제외
feature_ok = df[self.feature_names].notna().any(axis=1)
df = df.loc[feature_ok].copy()
if len(df) < self.min_train_samples:
logger.warning(
f"⚠️ 진입 피처가 있는 데이터 부족: {len(df)}건 (최소 {self.min_train_samples}건 필요). "
"매수 시 entry_features 저장 후 누적되면 학습 가능합니다."
)
return None
df["is_win"] = (df["profit_rate"] > 0).astype(int)
logger.info(
f"📊 학습 데이터 로드: {len(df)}"
f"(익절: {df['is_win'].sum()}건, 손절: {(1 - df['is_win']).sum()}건)"
f"📊 학습 데이터 로드: {len(df)} (시간순) "
f"(익절: {df['is_win'].sum()}건, 손절: {(~df['is_win']).sum()}건)"
)
return df
except Exception as e:
logger.error(f"❌ 피처 추출 실패: {e}")
return None
@@ -117,29 +127,19 @@ class MLPredictor:
logger.warning("⚠️ 학습 데이터 부족 - ML 모델 사용 불가")
return False
logger.warning("⚠️ [프로토타입] 랜덤 피처로 학습 중")
logger.warning(" → 실제 운영 시: active_trades 테이블에 진입 피처 저장 후 사용")
# TODO: 실제 피처 데이터로 교체 필요
# 현재는 데모용 랜덤 피처 사용
np.random.seed(42)
X = pd.DataFrame(
{
"rsi": np.random.uniform(20, 80, len(df)),
"volume_ratio": np.random.uniform(0.5, 5.0, len(df)),
"tail_length_pct": np.random.uniform(0, 5, len(df)),
"ma5_gap_pct": np.random.uniform(-5, 5, len(df)),
"ma20_gap_pct": np.random.uniform(-10, 10, len(df)),
"foreign_net_buy": np.random.uniform(-1000, 1000, len(df)),
"institution_net_buy": np.random.uniform(-500, 500, len(df)),
"market_hour": np.random.randint(9, 15, len(df)),
}
)
# 실제 DB 진입 피처 사용 (누락값은 0으로 보정)
X = df[self.feature_names].fillna(0).astype(float)
y = df["is_win"].values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# 시계열 분리: 무작위 셔플 금지. 과거 80% = 학습, 최근 20% = 테스트 (미래 참조 방지)
n = len(X)
train_size = int(n * 0.8)
if train_size < 10 or (n - train_size) < 5:
logger.warning("⚠️ 데이터 적어 시계열 분리 불가 (학습/테스트 각 10건·5건 이상 권장)")
train_size = max(10, n - 5)
X_train, X_test = X.iloc[:train_size], X.iloc[train_size:]
y_train, y_test = y[:train_size], y[train_size:]
logger.info(f"📅 시계열 분리: 학습 {len(y_train)}건 (과거) / 테스트 {len(y_test)}건 (최근)")
logger.info("🤖 RandomForest 학습 시작...")
self.model = RandomForestClassifier(
@@ -173,7 +173,8 @@ class MLPredictor:
return True
def predict_win_probability(self, features: dict) -> float:
"""매수 신호의 승률 예측 (0.0 ~ 1.0)"""
"""매수 신호의 승률 예측 (0.0 ~ 1.0).
매매 로직에서는 ML_MIN_PROBABILITY(권장 0.65 이상) 미만일 때 진입 보류 권장."""
if not ML_AVAILABLE or self.model is None:
return 0.5

1083
mm_butler.py Normal file

File diff suppressed because it is too large Load Diff

290
mm_remote.py Normal file
View File

@@ -0,0 +1,290 @@
"""
매터모스트 원격 조종 (양방향 챗봇)
- 채널 메시지를 폴링하여 !적용 / !설정 명령을 처리
- 수치만 DB(env_config)에 반영. 메인 루프가 매 사이클마다 reload_config() 하므로 별도 콜백 없음.
"""
import json
import re
import logging
import threading
import time
from pathlib import Path
from typing import Optional, Tuple
import requests
from database import TradeDB, ENV_CONFIG_KEYS
logger = logging.getLogger("MMRemote")
class MattermostRemoteController:
"""
매터모스트 채널에서 !적용 / !설정 명령을 감지해 DB(env_config)에만 반영.
메인 매매 루프가 매 사이클마다 reload_config() 하므로 여기서는 수치만 저장.
별도 스레드에서 폴링하며 동작합니다.
"""
KV_LAST_SEEN_TS = "mm_remote_last_seen_ts"
def __init__(
self,
server_url: str,
bot_token: str,
channel_alias: str,
mm_config_path: Path,
db: TradeDB,
poll_interval_sec: int = 18,
):
self.server_url = server_url.rstrip("/")
self.bot_token = bot_token
self.channel_alias = channel_alias
self.mm_config_path = Path(mm_config_path)
self.db = db
self.poll_interval_sec = poll_interval_sec
self._channel_id: Optional[str] = None
self._bot_user_id: Optional[str] = None
self._headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json",
}
self._running = False
self._thread: Optional[threading.Thread] = None
def _load_channel_id(self) -> Optional[str]:
if self._channel_id:
return self._channel_id
try:
if self.mm_config_path.exists():
with open(self.mm_config_path, "r", encoding="utf-8") as f:
data = json.load(f)
channels = data.get("channels", {})
self._channel_id = channels.get(self.channel_alias)
return self._channel_id
except Exception as e:
logger.warning("채널 ID 로드 실패: %s", e)
return None
def _get_bot_user_id(self) -> Optional[str]:
if self._bot_user_id:
return self._bot_user_id
try:
r = requests.get(
f"{self.server_url}/api/v4/users/me",
headers=self._headers,
timeout=5,
)
r.raise_for_status()
self._bot_user_id = r.json().get("id")
return self._bot_user_id
except Exception as e:
logger.warning("봇 사용자 ID 조회 실패: %s", e)
return None
def _fetch_posts(self) -> list:
"""채널 최근 게시물 목록 (최신순)."""
cid = self._load_channel_id()
if not cid:
return []
try:
r = requests.get(
f"{self.server_url}/api/v4/channels/{cid}/posts",
params={"per_page": 30},
headers=self._headers,
timeout=5,
)
r.raise_for_status()
data = r.json()
order = data.get("order", [])
posts = data.get("posts", {})
bot_id = self._get_bot_user_id()
out = []
for pid in order:
p = posts.get(pid)
if not p:
continue
if bot_id and p.get("user_id") == bot_id:
continue
out.append(p)
return out
except Exception as e:
logger.debug("게시물 조회 실패: %s", e)
return []
def _post_reply(self, message: str, root_id: Optional[str] = None) -> bool:
cid = self._load_channel_id()
if not cid:
return False
payload = {"channel_id": cid, "message": message}
if root_id:
payload["root_id"] = root_id
try:
r = requests.post(
f"{self.server_url}/api/v4/posts",
headers=self._headers,
json=payload,
timeout=5,
)
r.raise_for_status()
return True
except Exception as e:
logger.error("MM 전송 실패: %s", e)
return False
def _apply_all(self) -> Tuple[bool, str]:
"""마지막 AI 추천문을 파싱해 전부 env에 반영. (성공 여부, 요약 메시지)"""
text = self.db.get_last_ai_recommendations()
if not text or not text.strip():
return False, "저장된 AI 추천이 없습니다. 먼저 13시 AI 리포트를 받아주세요."
valid_keys = set(ENV_CONFIG_KEYS)
updates = {}
for line in text.splitlines():
line = line.strip()
if not line:
continue
m = re.match(r"^([A-Z][A-Z0-9_]*)=(.+)$", line)
if m and m.group(1) in valid_keys:
updates[m.group(1)] = m.group(2).strip()
if not updates:
return False, "추천문에서 유효한 KEY=값을 찾지 못했습니다."
latest = self.db.get_latest_env()
if not latest or not latest.get("snapshot"):
return False, "현재 env가 없습니다."
snap = dict(latest["snapshot"])
snap.update(updates)
rid = self.db.insert_env_snapshot(snap)
if rid is None:
return False, "DB 반영 실패."
summary = ", ".join(f"{k}={v}" for k, v in sorted(updates.items())[:10])
if len(updates) > 10:
summary += f"{len(updates) - 10}"
return True, f"✅ 적용 완료 ({len(updates)}건): {summary}"
def _apply_one(self, key: str, value: str) -> Tuple[bool, str]:
"""단일 키만 env에 반영. (성공 여부, 요약 메시지)"""
key = key.strip().upper()
if key not in set(ENV_CONFIG_KEYS):
return False, f"알 수 없는 설정 키: {key}"
latest = self.db.get_latest_env()
if not latest or not latest.get("snapshot"):
return False, "현재 env가 없습니다."
snap = dict(latest["snapshot"])
snap[key] = value.strip()
rid = self.db.insert_env_snapshot(snap)
if rid is None:
return False, "DB 반영 실패."
return True, f"✅ 설정 반영: {key}={value}"
def _process_message(self, message: str, post_id: str) -> Optional[str]:
"""
메시지에서 !적용 / !설정 처리. 처리 시 DB 반영 후 응답 문구 반환.
"""
msg = (message or "").strip()
if not msg.startswith("!"):
return None
if msg == "!적용" or msg.startswith("!적용 "):
ok, reply = self._apply_all()
return reply
if msg == "!설정":
return "사용법: !설정 KEY 값 또는 !설정 KEY=값"
if msg.startswith("!설정 "):
rest = msg[3:].strip()
# !설정 MAX_STOCKS 4 또는 !설정 MAX_STOCKS=4
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$", rest)
if m:
key, val = m.group(1), m.group(2).strip()
else:
parts = rest.split(None, 1)
if len(parts) < 2:
return "사용법: !설정 KEY 값 또는 !설정 KEY=값"
key, val = parts[0], parts[1]
ok, reply = self._apply_one(key, val)
return reply
return None
def _poll_loop(self):
"""폴링 루프: 마지막 처리 시각 이후 메시지만 처리 (재시작 시 DB에서 복원)."""
ts_str = self.db.get_kv(self.KV_LAST_SEEN_TS)
last_seen_ts = int(ts_str) if ts_str else int(time.time() * 1000)
self.db.set_kv(self.KV_LAST_SEEN_TS, str(last_seen_ts))
while self._running:
try:
time.sleep(self.poll_interval_sec)
if not self._running:
break
posts = self._fetch_posts()
bot_id = self._get_bot_user_id()
for p in posts:
create_at = int(p.get("create_at", 0))
if create_at <= last_seen_ts:
continue
last_seen_ts = max(last_seen_ts, create_at)
if bot_id and p.get("user_id") == bot_id:
continue
msg = (p.get("message") or "").strip()
reply = self._process_message(msg, p.get("id", ""))
if reply:
self._post_reply(reply, root_id=p.get("id"))
logger.info("MM 원격 명령 처리: %s -> %s", msg[:50], reply[:50])
self.db.set_kv(self.KV_LAST_SEEN_TS, str(last_seen_ts))
except Exception as e:
logger.warning("MM 폴링 예외: %s", e)
def start(self):
"""백그라운드 스레드로 폴링 시작.
mm_butler.py 가 동일 채널을 이미 처리 중이면 중복 응답을 막기 위해
mm_remote 리스너를 시작하지 않습니다.
mm_butler 가 없거나 다른 채널을 담당할 때만 활성화됩니다.
"""
if self._running:
return
if not self.bot_token or not self._load_channel_id():
logger.warning("MM 원격 조종: 토큰/채널 없음 - 리스너 미시작")
return
# mm_butler.py 와 동일 채널이면 중복 처리 방지 (mm_butler 가 우선)
try:
from kis_long_ver1 import get_env_from_db, MM_CONFIG_FILE
import json as _json
butler_alias = get_env_from_db("MM_BUTLER_CHANNEL", "default").strip() or "default"
if MM_CONFIG_FILE.exists():
with open(MM_CONFIG_FILE, "r", encoding="utf-8") as _f:
_cfg = _json.load(_f)
butler_cid = _cfg.get("channels", {}).get(butler_alias)
my_cid = self._load_channel_id()
if butler_cid and my_cid and butler_cid == my_cid:
logger.info(
" mm_remote: mm_butler.py 가 동일 채널(%s) 담당 중 → mm_remote 리스너 비활성 (중복 응답 방지)",
butler_alias,
)
return
except Exception:
pass # 확인 실패 시 기존대로 시작
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
logger.info("MM 원격 조종 리스너 시작 (채널=%s, !적용 / !설정)", self.channel_alias)
def stop(self):
"""폴링 중지."""
self._running = False
if self._thread:
self._thread.join(timeout=self.poll_interval_sec * 2)
self._thread = None

215
news_analyzer.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
뉴스 AI 분석 모듈 (참고용 알림)
- 네이버 금융 뉴스 크롤링
- Claude/GPT API로 요약 및 관련 업종 추출
- Mattermost로 참고용 알림만 전송 (실제 매매 판단 안 함!)
"""
import os
import requests
from bs4 import BeautifulSoup
from datetime import datetime
import logging
logger = logging.getLogger("NewsAnalyzer")
try:
import anthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
logger.warning("⚠️ anthropic 미설치! pip install anthropic")
class NewsAnalyzer:
"""뉴스 AI 분석기"""
def __init__(self, api_key: str = None):
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
if not self.api_key:
logger.warning("⚠️ ANTHROPIC_API_KEY 없음 - 뉴스 분석 불가")
self.client = None
elif not ANTHROPIC_AVAILABLE:
logger.error("❌ anthropic 라이브_러리 미설치!")
self.client = None
else:
self.client = anthropic.Anthropic(api_key=self.api_key)
logger.info("✅ Claude API 초기화 완료")
def crawl_naver_finance_news(self, max_news: int = 5):
"""
네이버 금융 주요 뉴스 크롤링
Returns:
[{'title': '...', 'link': '...', 'date': '...'}, ...]
"""
try:
url = "https://finance.naver.com/news/news_list.naver?mode=LSS2D&section_id=101&section_id2=258"
headers = {'User-Agent': 'Mozilla/5.0'}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
news_items = soup.select('.newsList .articleSubject a')
news_list = []
for item in news_items[:max_news]:
title = item.get('title', '').strip()
link = "https://finance.naver.com" + item.get('href', '')
if title:
news_list.append({
'title': title,
'link': link,
'date': datetime.now().strftime('%Y-%m-%d %H:%M')
})
logger.info(f"📰 네이버 금융 뉴스 {len(news_list)}건 크롤링 완료")
return news_list
except Exception as e:
logger.error(f"❌ 뉴스 크롤링 실패: {e}")
return []
def analyze_news_with_claude(self, news_list: list) -> dict:
"""
Claude API로 뉴스 분석
Args:
news_list: [{'title': '...', 'link': '...', 'date': '...'}, ...]
Returns:
{
'summary': '오늘의 주요 이슈 요약',
'sectors': ['반도체', 'AI', '자동차'],
'sentiment': 'positive/neutral/negative',
'recommended_stocks': [{'code': '005930', 'name': '삼성전자', 'reason': '...'}]
}
"""
if not self.client or not news_list:
return None
try:
# 뉴스 제목들을 하나로 합치기
news_titles = "\n".join([f"- {item['title']}" for item in news_list])
prompt = f"""다음은 오늘의 주요 금융 뉴스 제목들입니다:
{news_titles}
이 뉴스들을 분석하여 다음 정보를 JSON 형식으로 제공해주세요:
1. summary: 오늘의 주요 이슈를 2-3문장으로 요약
2. sectors: 관련 업종 리스트 (최대 3개, 예: ["반도체", "AI", "자동차"])
3. sentiment: 전반적 시장 분위기 (positive/neutral/negative)
4. recommended_stocks: 관련 주요 종목 (최대 3개)
- code: 종목코드 (6자리)
- name: 종목명
- reason: 추천 이유 (한 줄)
반드시 유효한 JSON 형식으로만 응답하세요. 설명 없이 JSON만 출력하세요.
"""
message = self.client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}]
)
# JSON 파싱
import json
result_text = message.content[0].text.strip()
# JSON 코드 블록 제거 (```json ... ``` 형태)
if result_text.startswith('```'):
result_text = result_text.split('```')[1]
if result_text.startswith('json'):
result_text = result_text[4:]
result_text = result_text.strip()
result = json.loads(result_text)
logger.info(f"✅ Claude 분석 완료")
logger.info(f" 요약: {result.get('summary', '')[:50]}...")
logger.info(f" 업종: {', '.join(result.get('sectors', []))}")
logger.info(f" 분위기: {result.get('sentiment', 'unknown')}")
return result
except Exception as e:
logger.error(f"❌ Claude 분석 실패: {e}")
return None
def format_analysis_for_mattermost(self, analysis: dict, news_list: list) -> str:
"""
Mattermost 알림 메시지 포맷
Returns:
마크다운 포맷 메시지
"""
if not analysis:
return None
msg = "## 📰 AI 뉴스 분석 (참고용)\n\n"
# 요약
msg += f"**📌 오늘의 이슈**\n{analysis.get('summary', '요약 없음')}\n\n"
# 관련 업종
sectors = analysis.get('sectors', [])
if sectors:
msg += f"**🏢 관련 업종**\n"
msg += ", ".join([f"`{s}`" for s in sectors]) + "\n\n"
# 시장 분위기
sentiment = analysis.get('sentiment', 'neutral')
sentiment_emoji = {
'positive': '😊 긍정적',
'neutral': '😐 중립',
'negative': '😰 부정적'
}
msg += f"**💭 시장 분위기**: {sentiment_emoji.get(sentiment, '알 수 없음')}\n\n"
# 추천 종목
stocks = analysis.get('recommended_stocks', [])
if stocks:
msg += f"**📊 관련 종목**\n"
for stock in stocks:
msg += f"- `{stock.get('code', '')}` {stock.get('name', '')}: {stock.get('reason', '')}\n"
msg += "\n"
# 뉴스 링크
if news_list:
msg += f"**🔗 주요 뉴스**\n"
for news in news_list[:3]:
msg += f"- [{news['title']}]({news['link']})\n"
msg += "\n"
# 경고 문구
msg += "---\n"
msg += "⚠️ **주의**: 이 분석은 참고용입니다. 최종 매수 판단은 ML 모델 + 기술적 지표로 이루어집니다.\n"
return msg
# 사용 예시
if __name__ == "__main__":
analyzer = NewsAnalyzer()
# 뉴스 크롤링
news = analyzer.crawl_naver_finance_news(max_news=5)
if news:
print("\n📰 크롤링된 뉴스:")
for item in news:
print(f" - {item['title']}")
# Claude 분석
if analyzer.client:
analysis = analyzer.analyze_news_with_claude(news)
if analysis:
# Mattermost 메시지 생성
message = analyzer.format_analysis_for_mattermost(analysis, news)
print("\n" + message)

Binary file not shown.

View File

@@ -20,12 +20,21 @@ class RiskManager:
"""
def __init__(
self,
risk_pct_per_trade: float = 0.02, # 1회 거래 시 허용 손실 비율 (2%)
max_position_pct: float = 0.20, # 종목당 최대 비중 (20%)
min_position_amount: int = 50000, # 최소 매수 금액 (5만원)
use_kelly: bool = False, # 켈리 공식 사용 여부
kelly_multiplier: float = 0.5 # 켈리 배율 (0.5 = 하프 켈리)
self,
risk_pct_per_trade: float = 0.02, # 1회 거래 시 허용 손실 비율 (2%)
max_position_pct: float = 0.20, # 종목당 최대 비중 (20%)
min_position_amount: int = 50000, # 최소 매수 금액 (5만원)
use_kelly: bool = False, # 켈리 공식 사용 여부
kelly_multiplier: float = 0.5, # 켈리 배율 (0.5 = 하프 켈리)
slot_base_amount_cap: int = 0, # 종목당 원화 상한 (0=미적용)
# ── 반드시 적용되는 MAX_LOSS 기반 절대 상한 ─────────────────────
# max_loss_per_trade_krw / |stop_loss_pct| = 투자 상한 금액
# 0이면 미적용 (RiskManager 계산 결과를 그대로 사용)
max_loss_per_trade_krw: int = 0, # 1회 최대 손실 허용액(원), MAX_LOSS_PER_TRADE_KRW
stop_loss_pct: float = 0.0, # 손절 비율(음수 가능), STOP_LOSS_PCT
# ── 사이즈 클래스별 매수 비율 (env/DB에서 주입, 하드코딩 금지) ──
size_small_ratio: float = 0.70, # 소형주 투자 비율 (SIZE_CLASS_SMALL_RATIO)
size_mid_ratio: float = 0.85, # 중형주 투자 비율 (SIZE_CLASS_MID_RATIO)
):
"""
Args:
@@ -34,16 +43,31 @@ class RiskManager:
min_position_amount: 최소 매수 금액 (너무 작은 주문 방지)
use_kelly: 켈리 공식 활성화 여부
kelly_multiplier: 켈리 배율 (0.5 = 하프켈리, 0.25 = 쿼터켈리)
slot_base_amount_cap: 종목당 매수 금액 원화 상한 (0이면 미적용, SLOT_BASE_AMOUNT_CAP)
"""
self.risk_pct = risk_pct_per_trade
self.max_pos_pct = max_position_pct
self.min_amount = min_position_amount
self.use_kelly = use_kelly
self.kelly_mult = kelly_multiplier
self.slot_base_amount_cap = slot_base_amount_cap if slot_base_amount_cap and slot_base_amount_cap > 0 else 0
# ── MAX_LOSS 기반 절대 투자 상한 ─────────────────────────────────
# = MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT|
# 0이면 미적용 (stop_loss_pct 가 0이면 계산 불가이므로 미적용)
self._max_loss_krw = max_loss_per_trade_krw
_stop_abs = abs(stop_loss_pct)
self._max_loss_cap = int(max_loss_per_trade_krw / _stop_abs) if (max_loss_per_trade_krw > 0 and _stop_abs > 0) else 0
# ── 사이즈 클래스 비율 (하드코딩 금지) ────────────────────────────
self.size_small_ratio = size_small_ratio # 소형주 (SIZE_CLASS_SMALL_RATIO)
self.size_mid_ratio = size_mid_ratio # 중형주 (SIZE_CLASS_MID_RATIO)
logger.info(
f"💰 RiskManager 초기화: 리스크{self.risk_pct*100}% | "
f"최대비중{self.max_pos_pct*100}% | 켈리{'ON' if use_kelly else 'OFF'}"
+ (f" | 슬롯상한{self.slot_base_amount_cap:,}" if self.slot_base_amount_cap else "")
+ (f" | MAX_LOSS상한{self._max_loss_cap:,}" if self._max_loss_cap else "")
)
def calculate_volatility_atr(self, df: pd.DataFrame, period: int = 14) -> float:
@@ -197,15 +221,28 @@ class RiskManager:
note = "(변동성기반)"
# 5-2. 대형/소형 구간별 조정 (소형주는 포지션 축소)
# → 비율은 하드코딩 금지: __init__ 시 SIZE_CLASS_SMALL/MID_RATIO 주입
if size_class == "":
final_amount = int(final_amount * 0.7)
note = "(소형주 70%)"
final_amount = int(final_amount * self.size_small_ratio)
note = f"(소형주 {self.size_small_ratio*100:.0f}%)"
elif size_class == "":
final_amount = int(final_amount * 0.85)
note = "(중형주 85%)"
final_amount = int(final_amount * self.size_mid_ratio)
note = f"(중형주 {self.size_mid_ratio*100:.0f}%)"
elif size_class == "":
pass # 대형주는 기존과 동일
# 5-3. 슬롯 원화 상한 (SLOT_BASE_AMOUNT_CAP, 0이면 미적용)
if self.slot_base_amount_cap > 0 and final_amount > self.slot_base_amount_cap:
final_amount = self.slot_base_amount_cap
note = f"(슬롯상한{self.slot_base_amount_cap:,}원)"
# ── 5-4. MAX_LOSS 기반 절대 투자 상한 ────────────────────────
# 어떤 전략이든 MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT| 를 초과 불가
# 이 값을 넘으면 손절 시 의도한 금액 손실(원)을 초과하게 됨
if self._max_loss_cap > 0 and final_amount > self._max_loss_cap:
final_amount = self._max_loss_cap
note = f"(MAX_LOSS상한 {self._max_loss_cap:,}원)"
# 6. 하한선 체크 (너무 작은 금액은 거래 안함)
if final_amount < self.min_amount:
logger.info(

392
scalping_engine.py Normal file
View File

@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""
scalping_engine.py — 스캘핑 백테스트·파라미터서치·실매매 공통 엔진
====================================================================
계산식만 같으면 백테스트와 실매매 결과가 같아지도록, 공통 로직만 이 모듈에 두고
backtest_web / param_search / kis_scalping_ver2 가 모두 이 엔진만 호출합니다.
■ 엔진에 들어간 공통 로직 (백테스트·파라미터서치·실매매 동일)
- 매수: 시간대(time_start_hm~time_end_hm), 쿨다운(cooldown_min), 일일 진입 횟수(max_daily),
RSI(3) 과매도(rsi_oversold), 이전봉 음봉+현재봉 양봉, 당일 낙폭(drop_rate), 거래량 배수(vol_mult).
- 매도: 손절가(sl_pct), 익절가(tp_pct), 트레일링스탑(trail_trigger/trail_stop), 장마감청산(is_eod).
■ 엔진 밖(실매매 전용) 로직 — 백테스트에는 없음
- 매수 추가 필터: 고점추격 방지(high_chase_thr), 급등주(max_daily_chg), 시장/테마 필터, ML, min_price 등.
- 매도 추가: 본절사수(breakeven), 금액손실컷(MAX_LOSS_PER_TRADE_KRW). (백테스트는 % 손절/익절/트레일만 사용)
캔들 형식: list of dict with keys candle_time(YYYYMMDDHHMI), open, high, low, close, volume
"""
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
# DB 기본값 로드 (백테스트/param_search가 동일한 값 사용하도록 단일 소스)
def get_scalping_defaults_from_db() -> Dict[str, Any]:
"""
env_config 최신 행에서 스캘핑 관련 기본값 로드.
백테스트 API·param_search가 이 함수만 쓰면 실매매(DB)와 동일한 값으로 동작.
반환 키: cooldown_min, time_start_hm, time_end_hm, fee_rate, sell_tax,
slot_money, rsi_period, vol_mult, trail_trigger, trail_stop, max_daily
"""
try:
from database import TradeDB
db = TradeDB()
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
db.close()
if row:
r = dict(row)
# SCALP_COOLDOWN_SEC(초) → cooldown_min(분). 실매매와 동일 키 사용
sec = r.get("SCALP_COOLDOWN_SEC") or r.get("REENTRY_COOLDOWN_SEC") or "600"
cooldown_min = max(0, int(float(sec)) // 60)
fee_pct = float(r.get("FEE_RATE_PCT") or 0.015)
tax_pct = float(r.get("SELL_TAX_RATE_PCT") or 0.18)
slot = float(r.get("SLOT_MONEY_DEFAULT") or 300_000)
else:
cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0
except Exception:
cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0
return {
"cooldown_min": cooldown_min,
"time_start_hm": 900,
"time_end_hm": 1530,
"time_start": 900,
"time_end": 1530,
"fee_rate": fee_pct / 100,
"sell_tax": tax_pct / 100,
"slot_money": slot,
"rsi_period": 3,
"vol_mult": 0,
"trail_trigger": 0.007,
"trail_stop": 0.004,
"max_daily": 3,
}
def compute_rsi_series(closes: list, period: int = 3) -> list:
"""RSI 시리즈 계산 (Wilder 스무딩). backtest_web과 동일."""
rsi_list = [None] * len(closes)
if len(closes) < period + 1:
return rsi_list
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(closes)):
idx = i - 1
if i > period:
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
rsi_list[i] = rsi_val
return rsi_list
def _t2dt(t: str) -> datetime:
"""candle_time 문자열 → datetime."""
return datetime.strptime(t, "%Y%m%d%H%M")
def run_scalping_backtest(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
) -> List[Dict]:
"""
종목별 캔들에 대해 스캘핑 백테스트 실행. 실매매와 동일한 규칙 적용.
params:
rsi_period, rsi_oversold, sl_pct, tp_pct, drop_rate,
slot_money, fee_rate, sell_tax,
cooldown_min, trail_trigger, trail_stop,
time_start_hm, time_end_hm, max_daily, vol_mult
sl_pct/tp_pct/drop_rate/trail_trigger/trail_stop: 비율 (0.015 = 1.5%)
fee_rate, sell_tax: 비율 (0.00015, 0.0018)
time_start_hm, time_end_hm: HHMM 정수 (900, 1530)
"""
rsi_period = int(params.get("rsi_period", 3))
rsi_oversold = float(params.get("rsi_oversold", 25))
sl_pct = float(params.get("sl_pct", 0.015))
tp_pct = float(params.get("tp_pct", 0.015))
drop_rate = float(params.get("drop_rate", 0.015))
slot_money = float(params.get("slot_money", 300_000))
fee_rate = float(params.get("fee_rate", 0.00015))
sell_tax = float(params.get("sell_tax", 0.0018))
cooldown_min = float(params.get("cooldown_min", 10))
trail_trigger = float(params.get("trail_trigger", 0))
trail_stop = float(params.get("trail_stop", 0.004))
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("time_end_hm", 1530))
max_daily = int(params.get("max_daily", 3))
vol_mult = float(params.get("vol_mult", 0))
all_trades: List[Dict] = []
for code, rows in codes_candles.items():
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
volumes = [float(c.get("volume", 0)) for c in candles]
rsis = compute_rsi_series(closes, rsi_period)
position: Optional[Dict] = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
cur_day = None
running_open = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
cl = float(c["close"])
lo = float(c["low"])
hi = float(c["high"])
vol = volumes[i] if i < len(volumes) else 0
if day != cur_day:
cur_day = day
running_open = float(c["open"])
running_low = lo
else:
running_low = min(running_low, lo)
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ── 포지션 보유 중: 청산 체크 ──
if position is not None:
max_price = max(position["max_price"], hi)
position["max_price"] = max_price
reason = None
exit_price = cl
if lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
elif trail_trigger > 0 and max_price >= position["entry_price"] * (1 + trail_trigger):
ts = max_price * (1 - trail_stop)
if cl <= ts:
reason = "트레일링스탑"
exit_price = cl
if reason is None and is_eod:
reason = "장마감청산"
exit_price = cl
if reason:
qty = position["qty"]
buy_amt = position["entry_price"] * qty
sell_amt = exit_price * qty
pnl = (
sell_amt
- buy_amt
- buy_amt * fee_rate
- sell_amt * fee_rate
- sell_amt * sell_tax
)
hold_min = int(
(_t2dt(c["candle_time"]) - _t2dt(position["entry_time"])).total_seconds() / 60
)
all_trades.append({
"code": code,
"buy_time": position["entry_time"],
"sell_time": c["candle_time"],
"buy_price": position["entry_price"],
"sell_price": round(exit_price, 2),
"qty": qty,
"pnl": round(pnl),
"profit_rate": round(
(exit_price - position["entry_price"]) / position["entry_price"] * 100, 2
),
"hold_min": hold_min,
"sell_reason": reason,
"rsi_entry": round(position["rsi"], 1),
})
last_exit_dt[day] = _t2dt(c["candle_time"])
position = None
continue
# ── 포지션 없음: 매수 신호 ──
if hm < time_start_hm or hm >= time_end_hm:
continue
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
rsi = rsis[i]
if rsi is None or rsi > rsi_oversold:
continue
prev_c = candles[i - 1]
prev_bear = float(prev_c["close"]) < float(prev_c["open"])
curr_bull = cl > float(c["open"])
if not (prev_bear and curr_bull):
continue
if running_open <= 0:
continue
dr = (running_open - running_low) / running_open
if dr < drop_rate:
continue
if vol_mult > 0:
win = max(1, min(20, i))
vol_avg = sum(volumes[i - win : i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
continue
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
continue
qty = max(1, int(slot_money / entry_price))
stop = entry_price * (1 - sl_pct)
target = entry_price * (1 + tp_pct)
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"qty": qty,
"stop": stop,
"target": target,
"max_price": entry_price,
"rsi": rsi,
}
daily_cnt[day] = daily_cnt.get(day, 0) + 1
all_trades.sort(key=lambda x: x["sell_time"])
return all_trades
# ── 실시간 봇용: 단일 시점 매수/매도 판단 (백테스트와 동일 규칙) ─────────────────
def check_buy_signal_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""
실시간 봇: 현재 캔들 리스트의 마지막 봉이 백테스트와 동일한 매수 신호인지 판단.
candles: 확정 봉 리스트 (candle_time, open, high, low, close, volume)
state: { "last_exit_dt": datetime or None (당일 마지막 청산 시각), "daily_cnt": int (당일 이미 진입한 횟수) }
반환: (reject_reason, reject_msg, sig) — 통과 시 (None, None, {"signal": True, "rsi": float}),
탈락 시 ("탈락-XXX", "메시지", None). 실매 ver2에서 ver1과 동일한 로그 출력용.
"""
if len(candles) < 4:
return ("탈락-봉부족", "확정봉 4개 미만", None)
rsi_period = int(params.get("rsi_period", 3))
rsi_oversold = float(params.get("rsi_oversold", 25))
rsi_overbought = float(params.get("rsi_overbought", 75.0))
sl_pct = float(params.get("sl_pct", 0.015))
tp_pct = float(params.get("tp_pct", 0.015))
drop_rate = float(params.get("drop_rate", 0.015))
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("time_end_hm", 1530))
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 3))
vol_mult = float(params.get("vol_mult", 0))
i = len(candles) - 1
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
cl = float(c["close"])
lo = float(c["low"])
prev_c = candles[i - 1]
if hm < time_start_hm or hm >= time_end_hm:
return (None, None, None)
last_exit_dt = state.get("last_exit_dt")
if last_exit_dt is not None:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60
if elapsed < cooldown_min:
return (None, None, None)
if state.get("daily_cnt", 0) >= max_daily:
return (None, None, None)
closes = [float(x["close"]) for x in candles]
rsis = compute_rsi_series(closes, rsi_period)
rsi = rsis[i] if i < len(rsis) else None
if rsi is None:
return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None)
if rsi <= 0.0:
return ("탈락-RSI무효", "RSI3=0.0 (봉 부족, 계산 불가)", None)
if rsi > rsi_overbought:
return ("탈락-RSI과열", "RSI3=%.1f > %.0f" % (rsi, rsi_overbought), None)
if rsi > rsi_oversold:
return ("탈락-RSI조건", "RSI3=%.1f (과매도<%.0f 아님)" % (rsi, rsi_oversold), None)
prev_bear = float(prev_c["close"]) < float(prev_c["open"])
curr_bull = cl > float(c["open"])
if not (prev_bear and curr_bull):
return ("탈락-되돌림없음", "prev_bear=%s curr_bull=%s" % (prev_bear, curr_bull), None)
running_open = float(c["open"])
running_low = lo
for j in range(i - 1, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_open = float(candles[j]["open"])
running_low = min(running_low, float(candles[j]["low"]))
if running_open <= 0:
return (None, None, None)
dr = (running_open - running_low) / running_open
if dr < drop_rate:
return ("탈락-낙폭", "%.2f%% < %.1f%%(SCALP_MIN_DROP_RATE)" % (dr * 100, drop_rate * 100), None)
if vol_mult > 0:
volumes = [float(x.get("volume", 0)) for x in candles]
vol = volumes[i] if i < len(volumes) else 0
win = max(1, min(20, i))
vol_avg = sum(volumes[i - win : i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
return ("탈락-거래량", "%.0f < 평균%.0f × %.1f" % (vol, vol_avg, vol_mult), None)
return (None, None, {"signal": True, "rsi": rsi})
def check_sell_signal_live(
position: Dict[str, Any],
current_candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
) -> Optional[tuple]:
"""
실시간 봇: 보유 포지션에 대해 백테스트와 동일한 청산 조건 판단.
position: { "entry_price", "entry_time", "qty", "stop", "target", "max_price", "rsi" }
current_candle: { "high", "low", "close" } (현재가 기준이면 high=low=close 또는 고점 갱신값)
is_eod: 장 마감 구간이면 True 시 장마감청산 반환
반환: (reason_str, exit_price) 또는 None
"""
sl_pct = float(params.get("sl_pct", 0.015))
tp_pct = float(params.get("tp_pct", 0.015))
trail_trigger = float(params.get("trail_trigger", 0))
trail_stop = float(params.get("trail_stop", 0.004))
hi = float(current_candle.get("high", current_candle["close"]))
lo = float(current_candle.get("low", current_candle["close"]))
cl = float(current_candle["close"])
max_price = max(position["max_price"], hi)
reason = None
exit_price = cl
if lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
elif trail_trigger > 0 and max_price >= position["entry_price"] * (1 + trail_trigger):
ts = max_price * (1 - trail_stop)
if cl <= ts:
reason = "트레일링스탑"
exit_price = cl
if reason is None and is_eod:
reason = "장마감청산"
exit_price = cl
if reason:
return (reason, exit_price)
return None

259
smart_executor.py Normal file
View File

@@ -0,0 +1,259 @@
"""
스마트 주문 집행 모듈 (TWAP - Time Weighted Average Price)
- 1초 간격 기계적 매수 방지
- 랜덤 딜레이로 시장 충격 최소화
- 분할 매수 진행 상태 저장 (봇 재시작 시에도 안전)
"""
import time
import random
import json
import os
import logging
from datetime import datetime, timedelta
from typing import Dict, Callable, Optional
logger = logging.getLogger("SmartExecutor")
class SmartOrderExecutor:
"""
[스텔스 주문 집행기]
- TWAP(시간 가중 평균) 방식으로 긴 시간 동안 랜덤하게 물량 분할 집행
- 호가 잡아먹힘 방지 및 슬리피지 최소화
"""
def __init__(
self,
state_file: str = "smart_orders.json",
min_split_amount: int = 500000, # 1회 최소 주문 금액 (50만원)
max_split_amount: int = 2000000, # 1회 최대 주문 금액 (200만원)
min_delay_seconds: int = 30, # 최소 대기 시간 (초)
max_delay_seconds: int = 180, # 최대 대기 시간 (초)
default_duration_minutes: int = 30 # 기본 분할 기간 (분)
):
"""
Args:
state_file: 진행 상태 저장 파일
min_split_amount: 1회 최소 주문 금액
max_split_amount: 1회 최대 주문 금액
min_delay_seconds: 분할 매수 간 최소 딜레이
max_delay_seconds: 분할 매수 간 최대 딜레이
default_duration_minutes: 기본 분할 기간
"""
self.state_file = state_file
self.min_split = min_split_amount
self.max_split = max_split_amount
self.min_delay = min_delay_seconds
self.max_delay = max_delay_seconds
self.duration = default_duration_minutes
self.active_orders: Dict = self._load_state()
logger.info(
f"🎯 SmartExecutor 초기화: "
f"분할{self.min_split//10000}~{self.max_split//10000}만원 | "
f"딜레이{min_delay_seconds}~{max_delay_seconds}"
)
def _load_state(self) -> Dict:
"""저장된 진행 상태 로드"""
if os.path.exists(self.state_file):
try:
with open(self.state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
logger.info(f"📂 진행 중인 스마트 주문 로드: {len(data)}")
return data
except Exception as e:
logger.error(f"❌ 상태 파일 로드 실패: {e}")
return {}
return {}
def _save_state(self):
"""현재 상태 저장 (Atomic Write)"""
try:
# 임시 파일에 먼저 쓰고 rename (원자성 보장)
temp_file = f"{self.state_file}.tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(self.active_orders, f, indent=2, ensure_ascii=False)
# Windows 호환성을 위해 기존 파일 삭제 후 rename
if os.path.exists(self.state_file):
os.remove(self.state_file)
os.rename(temp_file, self.state_file)
except Exception as e:
logger.error(f"❌ 상태 저장 실패: {e}")
def add_order(
self,
stock_code: str,
stock_name: str,
total_amount: int,
duration_minutes: Optional[int] = None
):
"""
스마트 주문 등록 (분할 매수 시작)
Args:
stock_code: 종목코드
stock_name: 종목명
total_amount: 총 매수 금액
duration_minutes: 분할 기간 (분), None이면 기본값 사용
"""
if stock_code in self.active_orders:
logger.warning(f"⚠️ [{stock_name}] 이미 진행 중인 스마트 주문 존재")
return False
duration = duration_minutes or self.duration
# 분할 횟수 계산 (총 금액을 min~max 사이로 나눔)
avg_split = (self.min_split + self.max_split) / 2
split_count = max(2, int(total_amount / avg_split))
amount_per_trade = int(total_amount / split_count)
# 첫 실행 시간 설정 (즉시 ~ 1분 후)
next_run = datetime.now() + timedelta(seconds=random.randint(1, 60))
end_time = datetime.now() + timedelta(minutes=duration)
self.active_orders[stock_code] = {
'name': stock_name,
'total_amount': total_amount,
'remaining_amount': total_amount,
'split_count': split_count,
'amount_per_trade': amount_per_trade,
'executed_count': 0,
'start_time': datetime.now().isoformat(),
'end_time': end_time.isoformat(),
'next_run_time': next_run.isoformat()
}
self._save_state()
logger.info(
f"📝 [{stock_name}] 스마트 주문 등록: "
f"{total_amount:,}원 -> {split_count}회 분할 ({duration}분)"
)
return True
def process_orders(
self,
current_prices: Dict[str, float],
buy_callback: Callable[[str, str, int, float], bool]
) -> int:
"""
스마트 주문 처리 (메인 루프에서 주기적으로 호출)
Args:
current_prices: {종목코드: 현재가} 딕셔너리
buy_callback: 실제 매수 실행 함수 (code, name, amount, price) -> success
Returns:
이번 사이클에서 실행한 주문 수
"""
executed_count = 0
completed_codes = []
now = datetime.now()
for code, order in list(self.active_orders.items()):
try:
# 1. 실행 시간 체크
next_run = datetime.fromisoformat(order['next_run_time'])
if now < next_run:
continue # 아직 실행 시간 안됨
# 2. 종료 시간 체크
end_time = datetime.fromisoformat(order['end_time'])
if now > end_time:
logger.warning(
f"⏰ [{order['name']}] 시간 초과 (잔액: {order['remaining_amount']:,}원) "
f"-> 남은 금액은 다음 기회에..."
)
completed_codes.append(code)
continue
# 3. 현재가 확인
current_price = current_prices.get(code)
if not current_price or current_price <= 0:
logger.debug(f"⚠️ [{order['name']}] 현재가 없음 -> 스킵")
continue
# 4. 이번 실행 금액 결정
remaining = order['remaining_amount']
buy_amount = min(order['amount_per_trade'], remaining)
# 5. 콜백으로 실제 매수 시도
success = buy_callback(code, order['name'], buy_amount, current_price)
if success:
# 6. 성공 시 상태 업데이트
order['remaining_amount'] -= buy_amount
order['executed_count'] += 1
executed_count += 1
logger.info(
f"✅ [{order['name']}] 분할 매수 {order['executed_count']}회차: "
f"{buy_amount:,}원 @ {current_price:,} "
f"(잔액: {order['remaining_amount']:,}원)"
)
# 7. 완료 체크
if order['remaining_amount'] < 10000: # 1만원 미만 잔돈은 무시
logger.info(f"🎉 [{order['name']}] 스마트 주문 완료!")
completed_codes.append(code)
else:
# 8. 다음 실행 시간 랜덤 설정
delay = random.randint(self.min_delay, self.max_delay)
order['next_run_time'] = (now + timedelta(seconds=delay)).isoformat()
logger.info(
f"⏳ [{order['name']}] 다음 매수는 {delay}초 후 "
f"({order['split_count'] - order['executed_count']}회 남음)"
)
else:
# 매수 실패 시 1분 뒤 재시도
order['next_run_time'] = (now + timedelta(seconds=60)).isoformat()
logger.warning(f"⚠️ [{order['name']}] 매수 실패 -> 1분 후 재시도")
except Exception as e:
logger.error(f"❌ [{code}] 스마트 주문 처리 에러: {e}")
continue
# 완료된 주문 삭제
for code in completed_codes:
del self.active_orders[code]
# 상태 저장 (변경사항 있을 때만)
if executed_count > 0 or completed_codes:
self._save_state()
return executed_count
def cancel_order(self, stock_code: str) -> bool:
"""스마트 주문 취소"""
if stock_code in self.active_orders:
order = self.active_orders.pop(stock_code)
self._save_state()
logger.info(f"🚫 [{order['name']}] 스마트 주문 취소")
return True
return False
def get_status(self, stock_code: str = None) -> Dict:
"""
진행 상태 조회
Args:
stock_code: 특정 종목 (None이면 전체)
Returns:
주문 상태 딕셔너리
"""
if stock_code:
return self.active_orders.get(stock_code, {})
return self.active_orders.copy()
def has_active_order(self, stock_code: str) -> bool:
"""해당 종목의 진행 중인 스마트 주문이 있는지 확인"""
return stock_code in self.active_orders
def get_active_count(self) -> int:
"""진행 중인 스마트 주문 개수"""
return len(self.active_orders)

483
tail_engine.py Normal file
View File

@@ -0,0 +1,483 @@
#!/usr/bin/env python3
"""
tail_engine.py — 꼬리잡기 백테스트·실매매 공통 엔진
====================================================
백테스트(backtest_web)와 실매매(kis_short_ver3)가 동일한 진입/청산 계산식을 쓰도록 공통 로직만 둠.
■ 엔진 공통 로직 (백테·실매 동일)
매수: 당일 낙폭(drop) ≥ min_drop_rate, 당일 회복률(rec_day) ≥ min_recovery_ratio,
망치봉 꼬리(tail_ratio/tail_pct), 3분봉 회복(rec_3m) 구간, RSI < rsi_threshold,
고점추격 방지(high_chase_thr), 시간대/쿨다운/max_daily.
매도: 손절가(sl_pct), 익절가(tp_pct), 어깨컷(shoulder_min_high + shoulder_cut_pct), 장마감.
■ 엔진 밖(실매매 전용): 금액손실컷, ATR 기반 손절/목표가, 퀵프로핏, MA20/ML 필터 등.
"""
from datetime import datetime
from typing import List, Dict, Any, Optional
def get_tail_defaults_from_db(db=None) -> Dict[str, Any]:
"""
env_config 최신 행에서 꼬리잡기 관련 값 전부 로드.
백테스트·파라미터서치·실매매가 동일 DB 값을 쓰도록 단일 소스.
db: 기존 TradeDB 인스턴스. 주면 연결 생성/종료 없이 재사용 (봇 메인 루프에서 반복 호출 시 로그 스팸·연결 낭비 방지).
결과에 영향을 주는 env 키: MIN_DROP_RATE, MIN_RECOVERY_RATIO_SHORT, TAIL_RATIO_MIN, TAIL_PCT_MIN,
STOP_LOSS_PCT, TAKE_PROFIT_PCT, SHOULDER_MIN_HIGH_PCT, SHOULDER_CUT_PCT, RSI_PERIOD, RSI_OVERHEAT_THRESHOLD,
REENTRY_COOLDOWN_SEC, HIGH_PRICE_CHASE_THRESHOLD, MAX_RECOVERY_RATIO_3M, (시간/일일한도).
"""
own_db = None
try:
if db is None:
from database import TradeDB
own_db = TradeDB()
db = own_db
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
if row:
r = dict(row)
min_drop = float(r.get("MIN_DROP_RATE") or 0.03)
min_rec = float(r.get("MIN_RECOVERY_RATIO_SHORT") or 0.5)
tail_ratio = float(r.get("TAIL_RATIO_MIN") or 1.5)
tail_pct = float(r.get("TAIL_PCT_MIN") or 0.003)
sl_pct = abs(float(r.get("STOP_LOSS_PCT") or -0.03))
tp_pct = float(r.get("TAKE_PROFIT_PCT") or 0.05)
shoulder_high = float(r.get("SHOULDER_MIN_HIGH_PCT") or 0.015)
shoulder_cut = float(r.get("SHOULDER_CUT_PCT") or 0.03)
cooldown_sec = int(float(r.get("REENTRY_COOLDOWN_SEC") or 900))
rsi_period = int(r.get("RSI_PERIOD") or 14)
rsi_threshold = float(r.get("RSI_OVERHEAT_THRESHOLD") or 78)
max_rec_3m = float(r.get("MAX_RECOVERY_RATIO_3M") or 0.8)
high_chase = float(r.get("HIGH_PRICE_CHASE_THRESHOLD") or 0.96)
time_start = int(r.get("TAIL_TIME_START") or r.get("TIME_START") or 930)
time_end = int(r.get("TAIL_TIME_END") or r.get("TIME_END") or 1500)
max_daily = int(r.get("MAX_DAILY_TAIL") or r.get("MAX_STOCKS") or 3)
else:
min_drop, min_rec = 0.03, 0.5
tail_ratio, tail_pct = 1.5, 0.003
sl_pct, tp_pct = 0.03, 0.05
shoulder_high, shoulder_cut = 0.015, 0.03
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
max_rec_3m, high_chase = 0.8, 0.96
time_start, time_end, max_daily = 930, 1500, 3
except Exception:
min_drop, min_rec = 0.03, 0.5
tail_ratio, tail_pct = 1.5, 0.003
sl_pct, tp_pct = 0.03, 0.05
shoulder_high, shoulder_cut = 0.015, 0.03
cooldown_sec, rsi_period, rsi_threshold = 900, 14, 78.0
max_rec_3m, high_chase = 0.8, 0.96
time_start, time_end, max_daily = 930, 1500, 3
finally:
if own_db is not None:
try:
own_db.close()
except Exception:
pass
return {
"min_drop_rate": min_drop,
"min_recovery_ratio": min_rec,
"max_rec_3m": max_rec_3m,
"tail_ratio_min": tail_ratio,
"tail_pct_min": tail_pct,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"shoulder_min_high": shoulder_high,
"shoulder_cut_pct": shoulder_cut,
"rsi_period": rsi_period,
"rsi_threshold": rsi_threshold,
"high_chase_thr": high_chase,
"cooldown_min": cooldown_sec // 60,
"time_start_hm": time_start,
"time_end_hm": time_end,
"max_daily": max_daily,
}
def compute_rsi_series(closes: List[float], period: int = 14) -> List[Optional[float]]:
"""RSI 시리즈 (Wilder 스무딩). backtest_web과 동일."""
rsi_list: List[Optional[float]] = [None] * len(closes)
if len(closes) < period + 1:
return rsi_list
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(closes)):
idx = i - 1
if i > period:
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
rsi_list[i] = rsi_val
return rsi_list
def _t2dt(t: str) -> datetime:
"""candle_time 문자열 → datetime."""
return datetime.strptime(t, "%Y%m%d%H%M")
def check_buy_signal_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> tuple:
"""
실시간: 마지막 봉이 백테스트(run_tail_backtest)와 동일한 꼬리잡기 매수 조건을 만족하는지 판단.
로직 단일 소스: run_tail_backtest와 완전 동일한 조건·계산식.
candles: 3분봉 리스트 (candle_time, open, high, low, close, volume)
state: { "last_exit_dt": datetime|None, "daily_cnt": int }
반환: (reject_reason, reject_msg, signal_dict)
- 통과 시: (None, None, {"signal": True, "tail_ratio", "recovery_pos", "rsi_val"})
- 탈락 시: ("탈락-XXX", "상세메시지(숫자포함)", None) → 호출측에서 "🔍 [탈락-XXX] name code: 상세메시지" 로그
"""
if len(candles) < 10:
return ("탈락-데이터", "봉 수 부족 (len<10)", None)
i = len(candles) - 1
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
min_drop_rate = float(params.get("min_drop_rate", 0.03))
min_recovery_ratio = float(params.get("min_recovery_ratio", 0.5))
max_rec_3m = float(params.get("max_rec_3m", 0.8))
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
tail_pct_min = float(params.get("tail_pct_min", 0.003))
rsi_period = int(params.get("rsi_period", 14))
rsi_threshold = float(params.get("rsi_threshold", 78))
high_chase_thr = float(params.get("high_chase_thr", 0.96))
time_start_hm = int(params.get("time_start_hm", 930))
time_end_hm = int(params.get("time_end_hm", 1500))
cooldown_min = float(params.get("cooldown_min", 15))
max_daily = int(params.get("max_daily", 3))
if hm < time_start_hm or hm > time_end_hm:
return (None, None, None) # 시간대 탈락 (로그 생략 가능)
if state.get("daily_cnt", 0) >= max_daily:
return (None, None, None)
last_exit_dt = state.get("last_exit_dt")
if last_exit_dt is not None:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60
if elapsed < cooldown_min:
return (None, None, None)
# 당일 누적 OHLC (run_tail_backtest와 동일)
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
for j in range(i - 1, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_open = float(candles[j]["open"])
running_high = max(running_high, float(candles[j]["high"]))
lj = float(candles[j]["low"])
if lj > 0:
running_low = min(running_low, lj)
if cl <= 0 or running_open <= 0:
return (None, None, None)
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
return (
"탈락-낙폭",
f"낙폭 {drop*100:.2f}% < {min_drop_rate*100:.1f}% (당일 시가 {running_open:,.0f}원 → 저점 {running_low:,.0f}원)",
None,
)
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
return (
"탈락-회복률",
f"회복률 {rec_day*100:.1f}% < {min_recovery_ratio*100:.0f}% (저점 {running_low:,.0f}원 → 현재 {cl:,.0f}원 / 범위 {day_range:,.0f}원)",
None,
)
# 망치봉 꼬리 (run_tail_backtest와 동일: 최대 3봉 전까지 탐색)
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
lo_use = lo
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2 = float(prev["open"])
l2 = float(prev["low"])
c2 = float(prev["close"])
if l2 <= 0:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len, body_len, lo_use = tl2, bl2, l2
break
tail_ratio = tail_len / body_len if body_len > 0 else 0
tail_pct = tail_len / lo_use if lo_use > 0 and tail_len > 0 else 0.0
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
return (
"탈락-꼬리",
f"꼬리비율 {tail_ratio:.2f} (기준 {tail_ratio_min}) 또는 꼬리% {tail_pct*100:.2f}% (기준 {tail_pct_min*100:.2f}%)",
None,
)
c_range = hi - lo if hi > lo else 0
rec_3m = (cl - lo) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
return (
"탈락-회복3분",
f"3분봉 회복률 {rec_3m*100:.1f}% (기준 {min_recovery_ratio*100:.0f}~{max_rec_3m*100:.0f}%)",
None,
)
closes = [float(x["close"]) for x in candles]
rsis = compute_rsi_series(closes, rsi_period)
rsi_val = rsis[i] if i < len(rsis) else None
if rsi_val is None or rsi_val >= rsi_threshold:
return (
"탈락-RSI",
(f"RSI {rsi_val:.1f}" if rsi_val is not None else "RSI None") + f"{rsi_threshold:.0f}",
None,
)
if cl >= running_high * high_chase_thr:
return (
"탈락-피뢰침 고점추격",
f"현재가 {cl:,.0f} ≥ 고점대비 {high_chase_thr*100:.0f}%",
None,
)
return (
None,
None,
{"signal": True, "tail_ratio": tail_ratio, "tail_pct": tail_pct, "recovery_pos": rec_3m, "rsi_val": rsi_val},
)
def check_sell_signal_live(
position: Dict[str, Any],
current_candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
) -> Optional[tuple]:
"""
실시간: 백테스트와 동일한 청산 조건 (손절/익절/어깨컷/장마감).
position: entry_price, entry_time, stop, target, max_price
current_candle: high, low, close
반환: (reason_str, exit_price) 또는 None
"""
sl_pct = float(params.get("sl_pct", 0.03))
tp_pct = float(params.get("tp_pct", 0.05))
shoulder_min_high = float(params.get("shoulder_min_high", 0.015))
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.03))
hi = float(current_candle.get("high", current_candle["close"]))
lo = float(current_candle.get("low", current_candle["close"]))
cl = float(current_candle["close"])
max_p = max(position["max_price"], hi)
ep = position["entry_price"]
stop = position.get("stop", ep * (1 - sl_pct))
target = position.get("target", ep * (1 + tp_pct))
reason = None
exit_price = cl
if lo > 0 and lo <= stop:
reason = "손절"
exit_price = stop
elif hi >= target:
reason = "익절"
exit_price = target
elif max_p >= ep * (1 + shoulder_min_high) and cl <= max_p * (1 - shoulder_cut_pct):
reason = "어깨컷"
exit_price = cl
if reason is None and is_eod:
reason = "장마감"
exit_price = cl
if reason:
return (reason, exit_price)
return None
def run_tail_backtest(
candles_by_code: Dict[str, List[Dict]],
params: Dict[str, Any],
) -> List[Dict]:
"""
백테스트 1회 실행 (backtest_web과 동일 로직). API에서 호출해 단일 소스 유지.
candles_by_code: code -> list of {candle_time, open, high, low, close, volume}
params: get_tail_defaults_from_db() + sl_pct, tp_pct 등
반환: all_trades 리스트
"""
min_drop_rate = float(params.get("min_drop_rate", 0.03))
min_recovery_ratio = float(params.get("min_recovery_ratio", 0.5))
max_rec_3m = float(params.get("max_rec_3m", 0.8))
tail_ratio_min = float(params.get("tail_ratio_min", 1.5))
tail_pct_min = float(params.get("tail_pct_min", 0.003))
sl_pct = float(params.get("sl_pct", 0.03))
tp_pct = float(params.get("tp_pct", 0.05))
shoulder_min_high = float(params.get("shoulder_min_high", 0.015))
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.03))
rsi_period = int(params.get("rsi_period", 14))
rsi_threshold = float(params.get("rsi_threshold", 78))
high_chase_thr = float(params.get("high_chase_thr", 0.96))
time_start_hm = int(params.get("time_start_hm", 930))
time_end_hm = int(params.get("time_end_hm", 1500))
cooldown_min = float(params.get("cooldown_min", 15))
max_daily = int(params.get("max_daily", 3))
all_trades: List[Dict] = []
for code, candles in candles_by_code.items():
if len(candles) < rsi_period + 5:
continue
closes = [float(c["close"]) for c in candles]
rsis = compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
cur_day = None
running_open, running_high, running_low = 0.0, 0.0, 0.0
i = rsi_period + 1
while i < len(candles):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
reason = None
exit_price = cl
if lo > 0 and lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
elif max_p >= position["entry_price"] * (1 + shoulder_min_high) and cl <= max_p * (1 - shoulder_cut_pct):
reason = "어깨컷"
exit_price = cl
elif is_eod:
reason = "장마감"
exit_price = cl
if reason:
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(position["entry_price"]),
"exit": round(exit_price),
"pnl": 0,
"reason": reason,
"hold_min": 0,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
i += 1
continue
if cl <= 0 or running_open <= 0:
i += 1
continue
if hm < time_start_hm or hm > time_end_hm:
i += 1
continue
if daily_cnt.get(day, 0) >= max_daily:
i += 1
continue
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
i += 1
continue
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
i += 1
continue
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
i += 1
continue
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
lo_use = lo
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, l2, c2 = float(prev["open"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len, body_len, lo_use = tl2, bl2, l2
break
tail_ratio = tail_len / body_len if body_len > 0 else 0
tail_pct = tail_len / lo_use if lo_use > 0 and tail_len > 0 else 0.0
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
i += 1
continue
c_range = hi - lo if hi > lo else 0
rec_3m = (cl - lo) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
i += 1
continue
rsi_val = rsis[i] if i < len(rsis) else None
if rsi_val is None or rsi_val >= rsi_threshold:
i += 1
continue
if cl >= running_high * high_chase_thr:
i += 1
continue
if i + 1 >= len(candles):
i += 1
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
i += 1
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
i += 1 # 진입 봉(next) 건너뜀 — 다음 봉부터 포지션 보유로 청산 체크
continue
return all_trades

248
trend_divergence.py Normal file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
구글 트렌드 괴리율 분석 모듈
- 검색량 급증 vs 주가 미반영 = 단타 기회 포착
- 단타 전략에 활용: 검색량이 폭발하는데 주가는 아직 안 오른 종목
"""
import logging
import time
import random # 랜덤 딜레이를 위한 모듈 추가
from typing import Dict, Optional, List
import math
logger = logging.getLogger("TrendDivergence")
# pandas 임포트 (NaN 체크용)
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
pd = None
# yfinance 임포트 (의존성 충돌 대비)
try:
import yfinance as yf
YFINANCE_AVAILABLE = True
except ImportError:
YFINANCE_AVAILABLE = False
logger.warning("⚠️ yfinance 미설치! pip install 'yfinance<0.2.40'")
except Exception as e:
YFINANCE_AVAILABLE = False
logger.warning(f"⚠️ yfinance 임포트 실패 (의존성 충돌 가능): {e}")
# pytrends 임포트
try:
from pytrends.request import TrendReq
PTRENDS_AVAILABLE = True
except ImportError:
PTRENDS_AVAILABLE = False
logger.warning("⚠️ pytrends 미설치! pip install pytrends")
class TrendDivergenceAnalyzer:
"""구글 트렌드 괴리율 분석기"""
def __init__(self):
self.pytrends = None
self.rate_limit_hit = False # 429 에러 발생 시 플래그
self.last_request_time = 0 # 마지막 요청 시간 추적
if PTRENDS_AVAILABLE:
try:
# 구글의 봇 차단을 우회하기 위해 최신 크롬 브라우저처럼 User-Agent(신분증) 위장
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
# requests_args 매개변수를 통해 헤더 정보 함께 전송
self.pytrends = TrendReq(hl='ko-KR', tz=540, requests_args={'headers': headers})
logger.info("✅ 구글 트렌드 API 초기화 완료 (User-Agent 우회 적용)")
except Exception as e:
logger.error(f"❌ 구글 트렌드 초기화 실패: {e}")
self.pytrends = None
def get_trend_score(self, keyword: str, stock_code: str = None) -> Optional[float]:
"""
검색량 급증 점수 계산 (0.0 ~ 1.0)
:param keyword: 검색 키워드 (예: "삼성전자", "갤럭시")
:param stock_code: 종목 코드 (예: "005930.KS") - 주가 비교용
:return: 괴리율 점수 (None이면 데이터 없음)
"""
if not self.pytrends:
return None
if not keyword or not keyword.strip():
return None
try:
# ✅ Rate Limit 방지: 429 에러 발생 시 더 긴 대기
if self.rate_limit_hit:
wait_time = random.uniform(60.0, 120.0) # 1~2분 대기
logger.warning(f"⏳ Rate Limit 복구 대기 중... ({wait_time:.0f}초)")
time.sleep(wait_time)
self.rate_limit_hit = False # 재시도
# ✅ 요청 간 최소 간격 보장 (5초 이상)
current_time = time.time()
if self.last_request_time > 0:
elapsed = current_time - self.last_request_time
if elapsed < 5.0:
sleep_time = 5.0 - elapsed + random.uniform(1.0, 3.0)
time.sleep(sleep_time)
# 1. 구글 트렌드 데이터 가져오기 (최근 7일)
# ✅ 개선: timeframe을 'now 7-d'로 변경 (최신 데이터), geo='KR' 추가
self.pytrends.build_payload([keyword], cat=0, timeframe='now 7-d', geo='KR')
# ✅ 개선: 네트워크 타임아웃 방지 (5~8초 랜덤 대기로 증가)
time.sleep(random.uniform(5.0, 8.0))
self.last_request_time = time.time() # 요청 시간 기록
trend_data = self.pytrends.interest_over_time()
if trend_data.empty:
logger.debug(f"트렌드 데이터 없음: {keyword}")
return None
if keyword not in trend_data.columns:
logger.debug(f"키워드 컬럼 없음: {keyword}")
return None
# 데이터가 충분한지 확인 (최소 3일 필요)
if len(trend_data) < 3:
logger.debug(f"데이터 부족 ({len(trend_data)}일): {keyword}")
return None
# 최근 3일 평균 vs 지난 7일 평균 비교
recent_avg = trend_data[keyword].tail(3).mean()
long_term_avg = trend_data[keyword].mean()
# NaN 체크 (pandas 있으면 pd.isna, 없으면 수동 체크)
if PANDAS_AVAILABLE and pd is not None:
if pd.isna(recent_avg) or pd.isna(long_term_avg):
logger.debug(f"NaN 값 발견: {keyword}")
return None
else:
# pandas 없으면 간단한 체크 (NaN은 float('nan')이므로 자기 자신과 비교)
if recent_avg is None or long_term_avg is None:
logger.debug(f"None 값 발견: {keyword}")
return None
if isinstance(recent_avg, float) and math.isnan(recent_avg):
logger.debug(f"NaN 값 발견 (recent_avg): {keyword}")
return None
if isinstance(long_term_avg, float) and math.isnan(long_term_avg):
logger.debug(f"NaN 값 발견 (long_term_avg): {keyword}")
return None
if long_term_avg == 0:
logger.debug(f"장기 평균 0: {keyword}")
return None
# 검색량 급증 비율
surge_ratio = recent_avg / long_term_avg if long_term_avg > 0 else 1.0
# 2. 주가 데이터와 비교 (괴리율 계산)
price_change_pct = 0.0
if stock_code and YFINANCE_AVAILABLE:
try:
stock = yf.Ticker(stock_code)
hist = stock.history(period="7d")
if not hist.empty and len(hist) >= 2:
# 최근 3일 평균 vs 지난 7일 평균
recent_price = hist['Close'].tail(3).mean()
long_price = hist['Close'].mean()
price_change_pct = ((recent_price - long_price) / long_price) * 100
except Exception as e:
logger.debug(f"주가 조회 실패 ({stock_code}): {e}")
elif stock_code and not YFINANCE_AVAILABLE:
logger.debug(f"yfinance 미사용 - 주가 비교 스킵 ({stock_code})")
# 3. 괴리율 점수 계산
# 검색량이 2배 이상 급증했는데 주가는 5% 미만 오름 = 괴리율 발생
if surge_ratio >= 2.0 and price_change_pct < 5.0:
# 괴리율이 클수록 점수 높음 (최대 1.0)
divergence_score = min(1.0, (surge_ratio - 1.0) * 0.3)
logger.info(f"🔍 [{keyword}] 검색량 {surge_ratio:.1f}배 급증, 주가 {price_change_pct:.1f}% → 괴리율 점수 {divergence_score:.2f}")
return divergence_score
else:
return 0.0
except Exception as e:
error_str = str(e)
# ✅ 개선: 400 에러는 데이터 없음으로 간주 (로그 레벨 낮춤)
if "400" in error_str or "Bad Request" in error_str:
logger.debug(f"검색량 부족 (400): {keyword}")
return None
# ✅ 개선: 429 에러는 Rate Limit으로 처리 (더 긴 대기 필요)
elif "429" in error_str or "Too Many Requests" in error_str:
self.rate_limit_hit = True
logger.warning(f"⚠️ Rate Limit 도달 (429): {keyword} - 구글 트렌드 분석 일시 중단")
# 429 에러 발생 시 나머지 분석 중단
return None
else:
logger.error(f"❌ 트렌드 분석 실패 ({keyword}): {e}")
return None
def analyze_stocks(self, stock_list: List[Dict]) -> Dict[str, float]:
"""
종목 리스트에 대한 트렌드 괴리율 점수 계산
:param stock_list: [{'code': '005930', 'name': '삼성전자'}, ...]
:return: {code: score} 딕셔너리
"""
results = {}
if not self.pytrends:
logger.warning("⚠️ 구글 트렌드 API 사용 불가")
return results
if not stock_list or not isinstance(stock_list, list):
logger.debug("종목 리스트가 비어있거나 잘못된 형식")
return results
try:
# ✅ 개선: 최대 5개로 제한 (429 에러 방지를 위해 더 줄임)
max_stocks = 5 if not self.rate_limit_hit else 0
if max_stocks == 0:
logger.warning("⚠️ Rate Limit 상태 - 구글 트렌드 분석 스킵")
return results
for idx, stock in enumerate(stock_list[:max_stocks], 1):
# Rate Limit 발생 시 즉시 중단
if self.rate_limit_hit:
logger.warning(f"⚠️ Rate Limit 발생 - {idx-1}개 분석 후 중단")
break
if not isinstance(stock, dict):
continue
code = stock.get('code', '')
name = stock.get('name', '')
if not name or not code:
continue
# 한국 주식 티커 형식
ticker = f"{code}.KS" if len(str(code)) == 6 else None
# 종목명으로 검색량 분석
try:
logger.debug(f"🔍 [{idx}/{max_stocks}] {name} 분석 중...")
score = self.get_trend_score(keyword=name, stock_code=ticker)
if score is not None and score > 0:
results[code] = score
except Exception as e:
logger.debug(f"종목 분석 실패 ({name} {code}): {e}")
continue
# get_trend_score 내부에서 이미 딜레이 처리하므로 여기서는 추가 딜레이 불필요
except Exception as e:
logger.error(f"❌ 종목 리스트 분석 중 에러: {e}")
# ✅ 개선: 분석 완료 후 충분한 쿨다운 (다음 배치까지 여유)
if results:
logger.info(f"✅ 트렌드 괴리율 발견: {len(results)}개 종목")
return results

61
update_env_etf.py Normal file
View File

@@ -0,0 +1,61 @@
"""
update_env_etf.py — ETF 액티브 매매 봇 환경 변수 설정 스크립트
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
역할: DB 에 ETF 유니버스 및 매매 설정을 등록
사용법:
python update_env_etf.py
실행 후 DB(env_config) 에 다음 설정이 저장됩니다:
- ETF_UNIVERSE: 매매 대상 ETF 종목코드 목록
- 기타 ETF 매매 관련 설정
"""
from database import TradeDB
from pathlib import Path
# DB 초기화
SCRIPT_DIR = Path(__file__).resolve().parent
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
# ==============================================================================
# ETF 액티브 매매 설정
# ==============================================================================
etf_settings = {
# ETF 유니버스 (콤마로 구분된 종목코드)
# 원자력/전력망 테마 ETF 예시:
# - 069500: KODEX 200 (대표 ETF)
# - 114800: KODEX 은행
# - 280670: KODEX 원자력
# - 395030: KODEX 이차전지산업
# - 405840: KODEX AI반도체
"ETF_UNIVERSE": "069500,114800,280670,395030,405840",
# 매매 관련 설정 (필요시 추가)
# "ETF_RSI_BUY_1": "35", # 1 차 매수 RSI
# "ETF_RSI_BUY_2": "30", # 2 차 매수 RSI
# "ETF_RSI_BUY_3": "25", # 3 차 매수 RSI
# "ETF_PROFIT_TAKE": "0.04", # 익절 비율 (+4%)
# "ETF_STOP_LOSS": "-0.10", # 손절 비율 (-10%)
}
# DB 에 저장
snapshot_id = db.insert_env_snapshot(etf_settings)
if snapshot_id:
print(f"✅ ETF 환경 변수 저장 완료 (ID: {snapshot_id})")
print("\n📊 저장된 설정:")
for key, value in etf_settings.items():
print(f" {key}: {value}")
print("\n📝 사용법:")
print(" 1. 백테스트: python etf_backtest.py")
print(" 2. 실전 매매: python etf_ver1.py")
print("\n💡 팁:")
print(" - ETF_UNIVERSE 에 원하는 ETF 종목코드를 추가하세요")
print(" - 예: '069500,114800,280670,395030,405840,XXXXXX'")
print(" - 한국 ETF 종목코드 뒤에 '.KS' 는 붙이지 마세요 (6 자리 숫자만)")
else:
print("❌ 환경 변수 저장 실패")
db.close()

View File

@@ -80,7 +80,10 @@ def update_short_bot_env():
# ========== 매수 전략 파라미터 ==========
"RSI_OVERHEAT_THRESHOLD": "78", # RSI 과열 기준 (진입 보류 기준)
"SHOULDER_CUT_PCT": "0.03", # 어깨 매도 기준 (고점 대비 하락률)
"SHOULDER_CUT_PCT": "0.03", # 어깨 매도: 고점 대비 이 비율만큼 하락 시 매도
"SHOULDER_MIN_HIGH_PCT": "0.01", # 어깨 매도 적용: 고점이 매수가 대비 이 비율 이상일 때만 (env 하한은 수수료 반영으로 자동 상향)
"SHOULDER_MIN_NET_PCT": "0.001", # 수수료 차감 후 남기려는 최소 순수익률 (0.001=0.1%). 이만큼 남기려면 고점 하한이 자동 계산됨
"ROUND_TRIP_COST_PCT": "0.0026", # 매매 수수료+세금 왕복 비율 (어깨매도 고점 하한 계산에 사용)
# ========== 🚨 피뢰침 방지 필터 (강화!) ==========
"HIGH_PRICE_CHASE_THRESHOLD": "0.96", # 일일 최고가 추격 매수 방지 기준

View File

@@ -54,12 +54,16 @@ if __name__ == "__main__":
# 여기에 업데이트할 값들을 딕셔너리로 넣으세요
my_updates = {
# 한투 API 설정
"KIS_MOCK": "true",
#"KIS_MOCK": "true",
#"KIS_APP_KEY_REAL": "PSUT2l4CO94DjrwDa2EAnEl639YXnWHdbbkN", # 여기에 앱키 입력
#"KIS_APP_SECRET_REAL": "2SkPBKrztpBomcR+pYNEBuVa5/iSqYLQxsDn/YqJuQ0dULp/GqTAePhe4czJHuf/1XBUd18KDV6ZTrmxfI8eTiCfIEaO6jMKSq0u+CoUkHTrO9TfliYtxsNbl43jL+rokLB54V2VmHrlqM4WCF+54bMWhzzSE7z3OOl67V9yWKCWIoTrcYg=",
# "KIS_APP_KEY_MOCK": "PSdfKtsMihgC9tLiUr2XISscuR3fHxl6kvmV", # 여기에 앱키 입력`
# "KIS_APP_SECRET_MOCK": "Ip+XZrZcoz11thgDD40XS8i6R1AalYkKFZwg2w8+ZMulVKN8rJVXiqGONxc4EYxw1S3TgOcx7fSldDc6EGq63bprfbgHwKWxstu29ZmLAtRNU0oFqV7e9vCOfgiWxrfnCqwcihoS7ovmza9+Ylqd8/EtjFGNmhQHWocyTAm8kdp5IG6tFtc=", # 여기에 앱시크릿 입력
#"KIS_APP_KEY_MOCK": "PSdfKtsMihgC9tLiUr2XISscuR3fHxl6kvmV", # 여기에 앱키 입력`
# 리스크매니저: 종목당 원화 상한(50만) + 종목당 최대 비중(1.5%)
# "SLOT_BASE_AMOUNT_CAP": "500000",
# "MAX_POSITION_PCT": "0.05",
#"KIS_APP_SECRET_MOCK": "Ip+XZrZcoz11thgDD40XS8i6R1AalYkKFZwg2w8+ZMulVKN8rJVXiqGONxc4EYxw1S3TgOcx7fSldDc6EGq63bprfbgHwKfalse29Zm "USE_MARKET_IOC": "false",ovmza9+Ylqd8/EtjFGNmhQHWocyTAm8kdp5IG6tFtc=", # 여기에 앱시크릿 입력
# "FORCE_BUY_TEST": "false",
# "USE_MARKET_IOC": "false",
# "KIS_ACCOUNT_NO_REAL": "44030801",
# "KIS_ACCOUNT_NO_MOCK": "50166974",
# "KIS_ACCOUNT_NO_MOCK": "44030801",
@@ -67,15 +71,139 @@ if __name__ == "__main__":
# "KIS_ACCOUNT_CODE_REAL": "01",
# "KIS_ACCOUNT_CODE_MOCK": "01",
#"MM_BOT_TOKEN_": "5o4bfsqo97dedyq7599wz6joie",
"TAKE_PROFIT_PCT_LONG": "0.010",
#"TAKE_PROFIT_PCT_LONG": "0.010",
#"MIN_DROP_RATE": "0.015",
#"MIN_RECOVERY_RATIO": 0.35,
#"MIN_DROP_RATE": 0.02,
#"MIN_DROP_RATE": 0.02
# "TAIL_RATIO_MIN": "0.85",
#"TAIL_PCT_MIN": "0.002"
# "TAKE_PROFIT_PCT":"0.012",
# "SHOULDER_CUT_PCT":"0.009",
# "MIN_RECOVERY_RATIO":"0.5",
# "MIN_RECOVERY_RATIO_SHORT" : "0.10",
# "MIN_DROP_RATE":"0.003",
# "SHOULDER_CUT_PCT":"0.012",
# "TAKE_PROFIT_PCT":"0.018",
# "SHOULDER_MIN_HIGH_PCT":"0.2",
# "MIN DROP_RATE":"0.015"
# "STOP_LOSS_PCT":"0.007",
# "MIN_DROP_RATE":"0.008",
# "MIN_RECOVERY_RATIO_SHORT":"0.30",
# "SHOULDER_MIN_HIGH_PCT":"0.2",
# "MAX_LOSS_PER_TRADE_KRW":"150000",
# # STOP_LOSS_PCT를 음수로 사용하면 '손실일 때만' 칼손절이 걸립니다.
# # 예: -0.007 → 수익 구간(+0.x%)에서는 칼손절이 아니라 다른 익절/보호 로직이 우선 적용되고,
# # 손실이 -0.7% 이하로 커졌을 때만 profit_pct <= STOP_LOSS_PCT 조건이 발동합니다.
# "STOP_LOSS_PCT":"-0.007",
# "TAIL_RATIO_MIN":"0.8",
# "SHOULDER_CUT_PCT":"0.018",
# # 작은수익보호 완화: 고점이 매수가 대비 1.2% 이상 올랐을 때만,
# # 현재가가 매수가 대비 +0.05% 이하로 되밀리면 보호 매도.
# # (이전 기본: 고점 +0.5% / 현재가 +0.15% → 너무 빨리 잘려 나가던 구간을 완화)
# "QUICK_PROFIT_PROTECT_HOURS":"0.5",
# "QUICK_PROFIT_MAX_RATIO":"1.012",
# "QUICK_PROFIT_CURRENT_MIN":"1.0005"
# "ANTHROPIC_API_KEY":"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"
# "KIS_WS_URL_REAL":"ws://ops.koreainvestment.com:21000",
# "KIS_WS_URL_MOCK":"ws://ops.koreainvestment.com:31000",
# "KIS_WS_MOCK_ENABLED":"true",
# "REENTRY_COOLDOWN_SEC":"300",
# "SELL_FAILURE_BACKOFF_SEC":"1800",
# "LONG_NEWS_ENABLED":"true",
# "SCALP_ATR_UP_MULT": "2.0",
# "SCALP_ATR_DOWN_MULT": "0.8",
# "SCALP_ATR_DROP_MULT": "1.5",
# ── 스캘핑봇 전용 ──────────────────────────────────────────────
# "SCALP_RSI_OVERSOLD": "25.0", # 과매도 임계값 (기본 25)
# "SCALP_RSI_OVERBOUGHT": "75.0", # 과매수 진입 금지 임계값
# "SCALP_CANDLE_TIMEFRAME": "1", # 봉 단위(분): 1 또는 3 권장
# "SCALP_MARKET_OPEN_WAIT_MIN": "5", # 장 시작 후 대기(분)
# "SCALP_GAP_FILL_LIMIT": "100", # WS 재접속 후 갭보정 캔들 수
# "SCALP_MIN_VOLUME": "1000", # 봉당 최소 거래량
# "KIS_SCALP_MM_CHANNEL": "kis-scalping", # 스캘핑봇 MM 채널
# "SCALP_CANDLE_KEEP_DAYS": "3", # ws_candles 보존 일수
# ── 스캘핑 전용 손익절/낙폭 (꼬리잡기와 완전 분리) ──────────────
# 1분봉 초단타: 손절-4%/익절+5%는 도달 불가 → 박리다매로 빠르게 끊어냄
# SCALP_STOP_LOSS_PCT : 손절 % (양수 입력, 기본 1.5%)
# SCALP_TAKE_PROFIT_PCT: 익절 % (양수 입력, 기본 1.5%)
# SCALP_MIN_DROP_RATE : 당일 낙폭 필터 % (기본 1.5%, 꼬리잡기 3%보다 완화)
# "SCALP_STOP_LOSS_PCT": "0.015", # 1.5% 손절
# "SCALP_TAKE_PROFIT_PCT": "0.015", # 1.5% 익절
# "SCALP_MIN_DROP_RATE": "0.015", # 1.5% 낙폭 이상만 진입
# ── 단타봇(kis_short_ver2) 전용 ────────────────────────────────
# "USE_KELLY_FORMULA": "true", # 켈리 공식 사용 여부
# "KELLY_MULTIPLIER": "0.25", # 켈리 25% (보수적)
# "USE_MARKET_IOC": "true", # 시장가 IOC 주문
# "KIS_PRICE_CACHE_TTL_SEC": "1.0", # 실시간 가격 캐시 유효기간(초)
# "SHORT_GAP_FILL_LIMIT": "100", # 갭보정 분봉 캔들 수
# "MIN_HOLD_AFTER_BUY_SEC": "30.0", # 매수 직후 최소 보유(초)
# "MIN_HOLD_HOURS": "24.0", # 최소 보유(시간)
# "MAX_RECOVERY_RATIO_3M": "0.8", # 3개월 회복 80% 이하만 편입
# "CANDIDATE_LIST_TOP_N_LIGHT": "20", # 1차 경량 체크 상위 N개
# "SCAN_UNIVERSE_MAX_CODES": "150", # 유니버스 최대 종목 수
# "TAIL_CANDLE_LOOKBACK": "5", # 꼬리 패턴 인식 lookback 봉수
# ── 공통 자산/슬롯 ───────────────────────────────────────────────
# "TOTAL_DEPOSIT": "500000000", # 총 입금액 (원) → 누적손익% 계산에 사용
# "SLOT_MONEY_DEFAULT": "300000", # 슬롯당 기본 투자금 (원)
# "ROUND_TRIP_COST_PCT": "0.003", # 왕복 수수료 0.3%
# "POP_NET_PCT": "0.005", # 익절 보호 0.5%
# "LOCK_NET_PCT": "0.010", # 잠금 수익 1.0%
# ── mm_butler / AI ──────────────────────────────────────────────
# "GEMINI_MODEL_ID": "gemini-2.5-flash", # Gemini 모델 ID
# "MM_BUTLER_POLL_SEC": "15", # MM 폴링 주기(초)
# "AI_SOURCE_MAX_CHARS": "120000", # AI 입력 최대 문자 수
# ── 유니버스 스캐너 ─────────────────────────────────────────────
# "UPDATE_UNIVERSE_TOP_N": "20", # 저장할 상위 N개
# "UPDATE_UNIVERSE_MIN_SCORE": "4.0", # 최소 강도 점수
# "SCAN_INTERVAL_SEC": "300", # 스캔 주기(초)
# ── 수수료·거래세 (전략 공통) ────────────────────────────────────
# 위탁수수료: 매수·매도 각각 0.015% (KIS 온라인 기본)
# 증권거래세: 2025년 기준 코스피·코스닥 공통 0.18% (매도 시만 부과)
# 왕복 총비용 = 0.015%(매수) + 0.015%(매도) + 0.18%(거래세) = 약 0.21%
"FEE_RATE_PCT": "0.015",
"SELL_TAX_RATE_PCT": "0.18",
# ── 스캘핑 본절 방어 ─────────────────────────────────────────────
# 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이만큼 올라야 트레일링 활성화
# 수수료(~0.21%) + 마진(0.2%) + 트레일링폭(0.8%) = 최소 1.2% 이상 권장
"SCALP_TRAIL_TRIGGER_PCT": "1.5",
# 본절사수 시 최소 순이익(%): 수수료+세금 위에 추가 확보 (0이면 본절, 0.2면 +0.2%)
"SCALP_MIN_PROFIT_PCT": "0.2",
# 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (600=10분, 백테스트 cooldown_min과 동일하게)
"SCALP_COOLDOWN_SEC": "600",
# 키움증권 REST API 키 — 실전/모의 분리 (KIS_MOCK 설정에 따라 자동 선택)
"KIWOOM_APP_KEY_REAL": "2HX8cA7AkrZsF7zBrMQXbFASvkKOkHtxh4-eia56hjo",
"KIWOOM_APP_SECRET_REAL": "XaNENlYjQrwhT18h-EwKGonq-R4PpW-sT7s_-F9q3Nk",
# 모의 키가 있으면 아래 주석 해제 후 입력
# "KIWOOM_APP_KEY_MOCK": "모의앱키입력",
# "KIWOOM_APP_SECRET_MOCK": "모의시크릿입력",
# 레거시 키 (이전 버전 호환, REAL/MOCK 키 없을 때 폴백)
"KIWOOM_APP_KEY": "2HX8cA7AkrZsF7zBrMQXbFASvkKOkHtxh4-eia56hjo",
"KIWOOM_APP_SECRET": "XaNENlYjQrwhT18h-EwKGonq-R4PpW-sT7s_-F9q3Nk",
# ── 영구 구독 ETF (시장 방향 필터) ────────────────────────────────
# 스캘핑/꼬리잡기 봇이 이 ETF를 항상 구독 → 60분봉 RSI로 상승장/하락장 판단
# KODEX200(069500) = 코스피 대표, KODEX KOSDAQ150(229200) = 코스닥 대표
# 콤마 구분, 추가하려면 섹터 ETF 코드도 넣으면 됨 (예: 반도체 ETF 091160)
"PERMANENT_WS_CODES": "069500,229200",
}
# 빈 값 제거 (업데이트하지 않음)
my_updates = {k: v for k, v in my_updates.items() if v}
if not my_updates:
print("⚠️ 업데이트할 값이 없습니다.")
print(" update_env_simple.py 파일을 열어서 my_updates 딕셔너리에 값을 입력하세요.")

File diff suppressed because it is too large Load Diff