커서가 망쳐놓은 듯
This commit is contained in:
139
.cursorrules
139
.cursorrules
@@ -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
33
.gitignore
vendored
@@ -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
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 디폴트 무시된 파일
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 에디터 기반 HTTP 클라이언트 요청
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
14
.idea/kis_bot.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
9
CONVENTIONS.md
Normal 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
60
KIS_TOKEN_SMS.md
Normal 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
245
README_ETF.md
Normal 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
136
TAIL_BACKTEST_VS_LIVE.md
Normal 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 매수시작/매수종료 (예: 830–1530) | **DB 고정값** (`TIME_START`/`TIME_END` 또는 기본 930–1500) — 시간대가 다르면 거래 수·손익이 달라짐 |
|
||||
| **일일최대매수** | 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 기준 0930–1500 사용. 웹에서 0830–1530 등 다르게 두었으면 비교 시 0930·1500으로 맞추면 됨).
|
||||
- **일일최대매수·RSI 기간**도 파라서치 1위와 맞춤 (예: 7, 5).
|
||||
- 또는 파라서치 실행 전에 DB를 원하는 값(일일최대 3, RSI_PERIOD 14)으로 맞춘 뒤 돌리면, 백테스트웹과 비슷한 조건으로 비교 가능.
|
||||
|
||||
**거래 수 차이(예: 파라서치 39건 vs 웹 19건)**:
|
||||
- 같은 기간·같은 로직으로 재현하면 **18~19건**이 나오는 경우가 있음. 파라서치 결과 JSON의 39건은 **당시 DB에 더 많은 거래일 데이터가 있었거나**, **매수시간대(930–1500 vs 830–1530)** 등 설정 차이로 인한 것일 수 있음. 비교 시 **시작/종료일 + 매수시작/매수종료**를 동일하게 두면 차이가 줄어듦.
|
||||
|
||||
**실매매는 지금 코드상**:
|
||||
- **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.
Binary file not shown.
Binary file not shown.
337
ai_recommend_notice.py
Normal file
337
ai_recommend_notice.py
Normal 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
164
back_test.py
Normal 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()
|
||||
210
backtest_scalping/holding_param_search.py
Normal file
210
backtest_scalping/holding_param_search.py
Normal 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()
|
||||
378
backtest_scalping/param_search.py
Normal file
378
backtest_scalping/param_search.py
Normal 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()
|
||||
531
backtest_scalping/tail_param_search.py
Normal file
531
backtest_scalping/tail_param_search.py
Normal 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()
|
||||
762
backtest_web.py
762
backtest_web.py
@@ -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 기준(예: 930–1500)을 쓰므로, 비교 시 웹에서도 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) + '%';
|
||||
|
||||
@@ -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
62
docs/CANDLE_FLOW.md
Normal 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
404
etf_backtest.py
Normal 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
887
etf_ver1.py
Normal 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
132
export_sniper.py
Normal 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§ion_id=101§ion_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
327
fetch_stock_meta.py
Normal 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
2186
holding_bot.py
Normal file
File diff suppressed because it is too large
Load Diff
2678
kis_api.htm
Normal file
2678
kis_api.htm
Normal file
File diff suppressed because it is too large
Load Diff
18
kis_backtest_web.service
Normal file
18
kis_backtest_web.service
Normal 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
483
kis_holding_ver1.py
Normal 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()
|
||||
181
kis_long_ver1.py
181
kis_long_ver1.py
@@ -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
1008
kis_long_ver2.py
Normal file
File diff suppressed because it is too large
Load Diff
1658
kis_scalping_ver1.py
Normal file
1658
kis_scalping_ver1.py
Normal file
File diff suppressed because it is too large
Load Diff
1589
kis_scalping_ver2.py
Normal file
1589
kis_scalping_ver2.py
Normal file
File diff suppressed because it is too large
Load Diff
1844
kis_short_ver1.py
1844
kis_short_ver1.py
File diff suppressed because it is too large
Load Diff
@@ -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
3658
kis_short_ver3.py
Normal file
File diff suppressed because it is too large
Load Diff
397
kis_token_manager.py
Normal file
397
kis_token_manager.py
Normal 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")
|
||||
0
kiwoom_rest_api/__init__.py
Normal file
0
kiwoom_rest_api/__init__.py
Normal file
0
kiwoom_rest_api/api.py
Normal file
0
kiwoom_rest_api/api.py
Normal file
0
kiwoom_rest_api/api_async.py
Normal file
0
kiwoom_rest_api/api_async.py
Normal file
0
kiwoom_rest_api/auth/__init__.py
Normal file
0
kiwoom_rest_api/auth/__init__.py
Normal file
109
kiwoom_rest_api/auth/token.py
Normal file
109
kiwoom_rest_api/auth/token.py
Normal 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())
|
||||
|
||||
0
kiwoom_rest_api/cli/__init__.py
Normal file
0
kiwoom_rest_api/cli/__init__.py
Normal file
88
kiwoom_rest_api/cli/main.py
Normal file
88
kiwoom_rest_api/cli/main.py
Normal 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
60
kiwoom_rest_api/config.py
Normal 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
|
||||
0
kiwoom_rest_api/core/__init__.py
Normal file
0
kiwoom_rest_api/core/__init__.py
Normal file
49
kiwoom_rest_api/core/async_client.py
Normal file
49
kiwoom_rest_api/core/async_client.py
Normal 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)
|
||||
224
kiwoom_rest_api/core/base.py
Normal file
224
kiwoom_rest_api/core/base.py
Normal 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)})
|
||||
56
kiwoom_rest_api/core/base_api.py
Normal file
56
kiwoom_rest_api/core/base_api.py
Normal 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)
|
||||
43
kiwoom_rest_api/core/sync_client.py
Normal file
43
kiwoom_rest_api/core/sync_client.py
Normal 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)
|
||||
0
kiwoom_rest_api/koreanstock/__init__.py
Normal file
0
kiwoom_rest_api/koreanstock/__init__.py
Normal file
2050
kiwoom_rest_api/koreanstock/account.py
Normal file
2050
kiwoom_rest_api/koreanstock/account.py
Normal file
File diff suppressed because it is too large
Load Diff
108
kiwoom_rest_api/koreanstock/analysis.py
Normal file
108
kiwoom_rest_api/koreanstock/analysis.py
Normal 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,
|
||||
)
|
||||
739
kiwoom_rest_api/koreanstock/chart.py
Normal file
739
kiwoom_rest_api/koreanstock/chart.py
Normal 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,
|
||||
)
|
||||
291
kiwoom_rest_api/koreanstock/credit_order.py
Normal file
291
kiwoom_rest_api/koreanstock/credit_order.py
Normal 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,
|
||||
)
|
||||
1113
kiwoom_rest_api/koreanstock/elw.py
Normal file
1113
kiwoom_rest_api/koreanstock/elw.py
Normal file
File diff suppressed because it is too large
Load Diff
621
kiwoom_rest_api/koreanstock/etf.py
Normal file
621
kiwoom_rest_api/koreanstock/etf.py
Normal 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,
|
||||
)
|
||||
234
kiwoom_rest_api/koreanstock/foreign_institution.py
Normal file
234
kiwoom_rest_api/koreanstock/foreign_institution.py
Normal 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,
|
||||
)
|
||||
115
kiwoom_rest_api/koreanstock/investor.py
Normal file
115
kiwoom_rest_api/koreanstock/investor.py
Normal 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,
|
||||
)
|
||||
1111
kiwoom_rest_api/koreanstock/market_condition.py
Normal file
1111
kiwoom_rest_api/koreanstock/market_condition.py
Normal file
File diff suppressed because it is too large
Load Diff
317
kiwoom_rest_api/koreanstock/order.py
Normal file
317
kiwoom_rest_api/koreanstock/order.py
Normal 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,
|
||||
)
|
||||
2311
kiwoom_rest_api/koreanstock/rank_info.py
Normal file
2311
kiwoom_rest_api/koreanstock/rank_info.py
Normal file
File diff suppressed because it is too large
Load Diff
507
kiwoom_rest_api/koreanstock/sector.py
Normal file
507
kiwoom_rest_api/koreanstock/sector.py
Normal 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,
|
||||
)
|
||||
287
kiwoom_rest_api/koreanstock/slb.py
Normal file
287
kiwoom_rest_api/koreanstock/slb.py
Normal 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,
|
||||
)
|
||||
1608
kiwoom_rest_api/koreanstock/stockinfo.py
Normal file
1608
kiwoom_rest_api/koreanstock/stockinfo.py
Normal file
File diff suppressed because it is too large
Load Diff
175
kiwoom_rest_api/koreanstock/theme.py
Normal file
175
kiwoom_rest_api/koreanstock/theme.py
Normal 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,
|
||||
)
|
||||
107
kiwoom_rest_api/koreanstock/trading.py
Normal file
107
kiwoom_rest_api/koreanstock/trading.py
Normal 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
325
kiwoom_rest_api/trader.py
Normal 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()
|
||||
321
kiwoom_rest_api/websocket.py
Normal file
321
kiwoom_rest_api/websocket.py
Normal 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
|
||||
186
kiwoom_rest_api/websocket_constants.py
Normal file
186
kiwoom_rest_api/websocket_constants.py
Normal 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)
|
||||
283
kiwoom_rest_api/websocket_helper.py
Normal file
283
kiwoom_rest_api/websocket_helper.py
Normal 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
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
679
kiwoom_universe_scanner.py
Normal 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
BIN
ml_model.pkl
Normal file
Binary file not shown.
@@ -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
1083
mm_butler.py
Normal file
File diff suppressed because it is too large
Load Diff
290
mm_remote.py
Normal file
290
mm_remote.py
Normal 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
215
news_analyzer.py
Normal 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§ion_id=101§ion_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)
|
||||
BIN
quant_bot.db
BIN
quant_bot.db
Binary file not shown.
@@ -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
392
scalping_engine.py
Normal 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
259
smart_executor.py
Normal 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
483
tail_engine.py
Normal 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
248
trend_divergence.py
Normal 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
61
update_env_etf.py
Normal 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()
|
||||
@@ -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", # 일일 최고가 추격 매수 방지 기준
|
||||
|
||||
@@ -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 딕셔너리에 값을 입력하세요.")
|
||||
|
||||
2678
한국투자증권_오픈API_전체문서_20260228_030000.htm
Normal file
2678
한국투자증권_오픈API_전체문서_20260228_030000.htm
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user